Compare commits
110
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6811a12330 | ||
|
|
a1712337a8 | ||
|
|
c2ee691db7 | ||
|
|
1920552ab0 | ||
|
|
f1b5330a4e | ||
|
|
f9ccff963f | ||
|
|
5cbc11d58f | ||
|
|
a0a995cfee | ||
|
|
354c4546d8 | ||
|
|
3ab22071fb | ||
|
|
a34f46794e | ||
|
|
f0b0bdbe48 | ||
|
|
1fe019994d | ||
|
|
85cb41423b | ||
|
|
02ccd9d134 | ||
|
|
c280b19568 | ||
|
|
bb5065410e | ||
|
|
0353215b1d | ||
|
|
3c7ed03bd6 | ||
|
|
a1f8ecb7f0 | ||
|
|
a34ca5a137 | ||
|
|
7887b9473f | ||
|
|
fd3c82aa91 | ||
|
|
9266632694 | ||
|
|
b0cbcd7a04 | ||
|
|
6251117bd0 | ||
|
|
566803c431 | ||
|
|
d6848da60b | ||
|
|
5f5d7ee21e | ||
|
|
53169a41af | ||
|
|
e90d5bac68 | ||
|
|
d725bdbaa3 | ||
|
|
9aea8c0b5c | ||
|
|
f54b964d62 | ||
|
|
11fbeb4851 | ||
|
|
2b1f38edae | ||
|
|
2f3797c1f6 | ||
|
|
a0b0d98180 | ||
|
|
990c85d36b | ||
|
|
b742343937 | ||
|
|
657df05d40 | ||
|
|
86f2b14449 | ||
|
|
86014c97cf | ||
|
|
94f3bba504 | ||
|
|
339eaa4b1b | ||
|
|
96774e6e23 | ||
|
|
1f77ba31f3 | ||
|
|
eb0bd69b82 | ||
|
|
c1d90672a8 | ||
|
|
3d2d287723 | ||
|
|
969292a8d7 | ||
|
|
4bcf71fb9e | ||
|
|
ac610f2d24 | ||
|
|
98f45cfe23 | ||
|
|
fbf97ce402 | ||
|
|
f5b029b1a5 | ||
|
|
75f92cf546 | ||
|
|
6d485f47eb | ||
|
|
6651a162c5 | ||
|
|
a9aa39c3e2 | ||
|
|
545ee17316 | ||
|
|
0c62938f74 | ||
|
|
b0cd419f28 | ||
|
|
aa487002b7 | ||
|
|
941bf01e30 | ||
|
|
b439c5fa09 | ||
|
|
67250a9da5 | ||
|
|
70243acb95 | ||
|
|
6cce7fdbd8 | ||
|
|
45b43e52bb | ||
|
|
95a5cc42ce | ||
|
|
fec750b09e | ||
|
|
ddd5f15b91 | ||
|
|
db50be868b | ||
|
|
a6220d7d8b | ||
|
|
81bf0d2a6b | ||
|
|
f3a95d34a3 | ||
|
|
2ea5acc2a3 | ||
|
|
5e1bac51e5 | ||
|
|
a37e6e6847 | ||
|
|
0ceb96c745 | ||
|
|
114503d52f | ||
|
|
9de1c9c304 | ||
|
|
840e1d665f | ||
|
|
f362c8c2a4 | ||
|
|
e73a1556ce | ||
|
|
186f2fa474 | ||
|
|
d91a93c43b | ||
|
|
08254e2a36 | ||
|
|
fdd9e189d6 | ||
|
|
65ce71b8d4 | ||
|
|
6bfcac4d54 | ||
|
|
f819566c63 | ||
|
|
10d9aa3058 | ||
|
|
eebdf52da9 | ||
|
|
a0ca1fdb28 | ||
|
|
9eaee76a51 | ||
|
|
5a93f80daa | ||
|
|
8260cf7f53 | ||
|
|
9fd38d9b9d | ||
|
|
58dfbc5b6e | ||
|
|
d7c28bcfef | ||
|
|
287b079c18 | ||
|
|
50f8ade1d7 | ||
|
|
b87cad1eb5 | ||
|
|
0f2eec145e | ||
|
|
64c2e8544c | ||
|
|
fc31fddd73 | ||
|
|
4298d0833b | ||
|
|
7e0957d9e8 |
@@ -152,7 +152,6 @@ jobs:
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
filename.startsWith('internal/app/upgrade') ||
|
||||
filename.startsWith('internal/transport/') ||
|
||||
filename.startsWith('internal/recovery/') ||
|
||||
filename.startsWith('internal/syncdata/') ||
|
||||
filename.includes('/testdata/') ||
|
||||
filename.startsWith('testdata/') ||
|
||||
@@ -1176,7 +1175,7 @@ jobs:
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
COVERAGE_TARGET: "100"
|
||||
COVERAGE_ENFORCE_OVERALL: "false"
|
||||
COVERAGE_OVERALL_TOLERANCE: "0"
|
||||
COVERAGE_OVERALL_TOLERANCE: "0.1"
|
||||
run: |
|
||||
policy_profile=coverage-policy.txt
|
||||
if [ "$FULL_SUITE" != true ]; then
|
||||
|
||||
@@ -302,9 +302,8 @@ on the leaf:
|
||||
|
||||
```bash
|
||||
dws auth status # token_valid should be true
|
||||
dws cache refresh # deprecated no-op: prints a retirement notice (discovery cache is gone; refreshes nothing)
|
||||
dws schema <mcp-canonical> -f json
|
||||
# or CLI path: dws schema --cli-path "drive copy" -f json
|
||||
dws schema <mcp-canonical> --jq '{canonical_path,interface_ref,parameters}' -f json
|
||||
# or CLI path: dws schema --cli-path "drive copy" --jq '{canonical_path,interface_ref,parameters}' -f json
|
||||
```
|
||||
|
||||
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
|
||||
@@ -321,8 +320,10 @@ Skill (evidence only)**.
|
||||
|
||||
Split work by product groups. Each agent must:
|
||||
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
|
||||
for its tools.
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live
|
||||
`dws schema <leaf> --compact` for its tools. Mapping/interface/provenance
|
||||
audits may query the full leaf only through a narrow `--jq` / `--fields`
|
||||
projection; do not load an entire full leaf into Agent context.
|
||||
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
|
||||
forbid wholesale JSON merges from review dumps.
|
||||
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
|
||||
@@ -471,13 +472,15 @@ path; a generator unit test or JSON count alone is insufficient.
|
||||
`parameters` object for commands without flags. Keep it suitable for the #602
|
||||
compatibility baseline and fail rather than silently emitting a partial
|
||||
export.
|
||||
- `schema --all` is not normal command discovery. Use overview -> product/group
|
||||
-> leaf for routine Agent work. `--compact` is supported for context-saving
|
||||
projections, but a compact full export is not a complete compatibility
|
||||
baseline.
|
||||
- `schema --all` is not normal command discovery. Use overview -> compact
|
||||
product/group -> compact leaf for routine Agent work. `--compact` is the
|
||||
reviewed positive-field allowlist for Agent context: new full/audit fields
|
||||
must not appear there until explicitly reviewed. A compact full export is not
|
||||
a complete compatibility baseline.
|
||||
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
|
||||
executable accepts. A leaf Schema defines Agent selection, parameter mapping
|
||||
and constraints, and safety/confirmation semantics. A conflict is contract
|
||||
drift, not permission to guess.
|
||||
executable accepts. A compact leaf defines Agent selection, CLI parameters,
|
||||
constraints, and safety/confirmation semantics. Full leaf fields such as
|
||||
`property`, `interface_ref`, and provenance are audit facts. A conflict is
|
||||
contract drift, not permission to guess.
|
||||
- Schema and Help describe commands; neither returns DingTalk business data.
|
||||
After discovery, execute the real read/search/list command to obtain data.
|
||||
|
||||
+122
@@ -6,8 +6,130 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.57-beta.4] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Expanded open CLI workflows** (#887) — adds calendar event-instance
|
||||
queries, Drive latest-file selection, Markdown diff, Mail calendar/export/
|
||||
share-to-chat workflows, and Minutes hot-word, permission, and audio-memo
|
||||
operations, with matching Schema and cross-platform coverage.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Multi-skill framework alignment** (#887) — folds long-tail skills into
|
||||
`dingtalk-misc`, renames the shared package to `dingtalk-shared`, removes
|
||||
stale Preview guidance, and reorganizes shared recipes and routing for more
|
||||
predictable Agent selection.
|
||||
- **Bounded Agent Schema delivery** (#887) — keeps compact and wire projections
|
||||
focused on executable contract facts, retires stale MCP metadata candidates,
|
||||
and teaches Agents to prefer `dws schema --compact` for bounded context.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- **Recovery and discovery-cache compatibility surfaces** (#887) — keeps
|
||||
visible Deprecated `dws recovery` and `dws cache` compatibility stubs while
|
||||
retiring their former recovery engine and dynamic discovery-cache behavior.
|
||||
Recovery plan/execute/finalize now return an explicit “不再支持” notice, and
|
||||
Skills no longer teach either retired workflow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mail share-to-chat confirmation** (#887) — requires explicit confirmation
|
||||
before the first remote write, while preserving the confirmed sign-retry
|
||||
flow and covering both direct-success and retry responses.
|
||||
|
||||
## [1.0.57] - 2026-08-06
|
||||
|
||||
This stable release promotes the fully delivered `v1.0.57-beta.4` baseline.
|
||||
It includes the v1.0.57 beta-line command-contract, document, chat, OA, Wiki,
|
||||
and compatibility improvements, plus the multi-skill framework alignment and
|
||||
expanded calendar, Drive, Markdown, Mail, and Minutes workflows validated in
|
||||
the final prerelease.
|
||||
|
||||
- **Promote v1.0.57-beta.4** — publishes the final validated prerelease
|
||||
baseline as stable `v1.0.57` without adding post-beta product changes.
|
||||
|
||||
## [1.0.57-beta.3] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Reviewed document shortcuts** (#880) — adds public document shortcuts for
|
||||
safe local downloads, content and history, review, media and style, and
|
||||
document access/sharing workflows, while retaining reviewed compatibility
|
||||
identities and confirmation safeguards for writes.
|
||||
- **Mentions in chat replies** (#881) — `chat message reply` now supports
|
||||
`--at-open-dingtalk-ids` and `--at-all`, forwarding reply mention fields and
|
||||
adding any required mention placeholders without changing existing send
|
||||
behavior.
|
||||
|
||||
## [1.0.57-beta.2] - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stable Chat command compatibility** (#876) — restores the hidden migration
|
||||
entries for `chat send`, `chat history`, and their `im` aliases, preserving
|
||||
the v1.0.56 command surface while directing callers to the supported
|
||||
`chat message send/list` commands. Legacy flags now reach the same migration
|
||||
hints instead of failing during flag parsing.
|
||||
- **Drive download cancellation-test stability** (#876) — replaces a
|
||||
timing-sensitive worker-cancellation coverage test with a deterministic seam,
|
||||
reducing flaky CI without changing download behavior.
|
||||
|
||||
## [1.0.57-beta.1] - 2026-08-05
|
||||
|
||||
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
|
||||
command-contract and runtime Schema architecture, complete Multi IM Chat
|
||||
coverage, document whiteboard and OA approval workflows, Wiki activity feeds,
|
||||
and compatibility and CI reliability fixes.
|
||||
|
||||
### Added
|
||||
|
||||
- **Contact personal-status updates** (#872) — adds `contact user update-ownness`
|
||||
(alias `set-ownness`) for updating a user's personal status text. The write
|
||||
operation maps reviewed `userId` and `ownnessText` parameters to the service
|
||||
contract and requires confirmation unless `--yes` is explicitly supplied.
|
||||
- **Document whiteboard workflows** (#861) — adds `doc whiteboard insert`,
|
||||
`whiteboard query/update`, and `doc media upload`. These commands support
|
||||
confirmed document-embedded whiteboard creation and updates, structured
|
||||
OpenNodes reads, and preparation of node-bound Vector/SVG resources.
|
||||
- **Complete Multi IM Chat coverage** (#860) — hardens deterministic group and
|
||||
stable-ID resolution, sending, querying, downloading, pagination, and JSON
|
||||
export. The remaining reviewed Chat Shortcuts enter Schema coverage, with
|
||||
destructive delete and clear operations aligned to confirmation gates.
|
||||
- **OA approval form workflows** (#853) — adds OA form-schema lookup,
|
||||
process forecast, and confirmed approval-instance creation, supporting both
|
||||
simple flags and complete `--request` payloads.
|
||||
- **Wiki activity-feed queries** (#862) — adds `wiki feed list` to retrieve
|
||||
workspace document activity, with cursor paging and optional file exclusion.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Unified command and Schema contract framework** (#830) — Leaf commands and
|
||||
Shortcuts now use the shared typed `corecmd` base for flags, constraints,
|
||||
confirmation, Help, and runtime Schema projection. Schema delivery assembles
|
||||
from leaf Contract declarations at runtime; the retired hint overlays,
|
||||
pinned MCP metadata, and committed Catalog artifacts are no longer delivery
|
||||
authorities.
|
||||
- **Faster macOS CI without reducing native coverage** (#857) — narrows the
|
||||
macOS race suite to Keychain, codesign, and Darwin-only tests while adding a
|
||||
reachability contract that prevents native-only tests from being silently
|
||||
excluded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Chat media-download JSON compatibility** (#854) — restores parseable
|
||||
`success`, `downloadUrl`, and `output` fields for
|
||||
`chat message download-media --format json` after a successful download,
|
||||
without progress output corrupting JSON stdout.
|
||||
|
||||
### Added
|
||||
|
||||
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat reply mentions** — `dws chat message reply` can @ specified group members with `--at-open-dingtalk-ids` or @ everyone with `--at-all`, forwarding the existing `send_personal_message` mention fields and automatically adding missing current-user `<@id>` / `<@all>` placeholders.
|
||||
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
|
||||
- **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger.
|
||||
- **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly.
|
||||
|
||||
@@ -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.56-beta.4"
|
||||
version "1.0.57-beta.4"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "ff363e258d463732e4dc02aa71ac1b5b1f05c25107c32ddcc5ae11d14931a782"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "3eafcc4c27931b8457f3611a12ce4ae727f7bd65356fbef80958699defa09acf"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "49eabb4d2c419d8d1fb0fcf71d9c318ef49cf3d198431df1bcbaa71589e11383"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "fb3903485fe494e1fadb67ec87b2fef19cf575c72b7df25f0b93f80f8b36273f"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
|
||||
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-skills.zip"
|
||||
sha256 "6bd11363dbd2ce79627f49bc80b8a92f9c79fca083a6bd3ff9bc5c4833594fb9"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -131,6 +131,9 @@ skill-context-budget:
|
||||
multi-im-skill-chain-integrity:
|
||||
@./scripts/policy/check-multi-im-skill-chain.sh
|
||||
|
||||
skill-mono-multi-content:
|
||||
@./scripts/policy/check-mono-multi-skill-content.sh
|
||||
|
||||
cli-smoke:
|
||||
@./scripts/policy/check-cli-smoke.sh
|
||||
|
||||
|
||||
@@ -71,9 +71,7 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
How to pick:
|
||||
|
||||
@@ -371,7 +369,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
Use Cobra help and Schema for different parts of the command contract:
|
||||
|
||||
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
|
||||
- `dws schema "<path>"` is the Agent contract for command selection, parameter mappings and constraints, risk, and confirmation semantics.
|
||||
- `dws schema "<path>" --compact` is the normative Agent view for command selection, CLI parameters and constraints, risk, and confirmation; use a full leaf with a narrow `--jq` projection for mapping or provenance audits.
|
||||
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
|
||||
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
|
||||
|
||||
@@ -380,21 +378,21 @@ Use Cobra help and Schema for different parts of the command contract:
|
||||
dws aitable record query --help
|
||||
|
||||
# Discover within a product, then inspect the selected leaf contract
|
||||
dws schema aitable
|
||||
dws schema "aitable record query"
|
||||
dws schema aitable --compact
|
||||
dws schema "aitable record query" --compact
|
||||
|
||||
# Execute the real business query
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should prefer product/group discovery followed by a leaf query to avoid loading the full Catalog into context.
|
||||
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should query progressively with `--compact`; its positive field allowlist prevents new full/audit fields from silently expanding Agent context.
|
||||
|
||||
### Agent Skills
|
||||
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
|
||||
|
||||
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
|
||||
|
||||
@@ -443,7 +441,6 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
|
||||
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
|
||||
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
|
||||
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
|
||||
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
|
||||
|
||||
<details>
|
||||
@@ -539,7 +536,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
|
||||
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
|
||||
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
|
||||
|
||||
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
|
||||
See `skills/multi/dingtalk-misc/references/event.md` for the Agent workflow and supported event parameters.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -624,7 +621,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# Built-in jq expressions
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema "dev app create" --jq '.tool.required'
|
||||
dws schema "dev app create" --jq '.parameters'
|
||||
|
||||
# Return only specific fields
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -636,9 +633,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
|
||||
|
||||
```bash
|
||||
dws schema aitable # discover product commands
|
||||
dws schema "aitable record query" # view the selected leaf contract
|
||||
dws schema "aitable record query" --jq '.tool.required' # view required fields
|
||||
dws schema aitable --compact # discover product commands
|
||||
dws schema "aitable record query" --compact # view the selected Agent leaf contract
|
||||
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # view required fields
|
||||
dws schema --all # full export for CI/audit/baselines
|
||||
```
|
||||
|
||||
|
||||
+11
-14
@@ -71,9 +71,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -365,7 +363,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
命令帮助和 Schema 分别负责命令契约的不同部分:
|
||||
|
||||
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
|
||||
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
|
||||
- `dws schema "<path>" --compact` 是 Agent 选命令、CLI 参数与约束、风险和确认语义的规范视图;映射或 provenance 审计使用 full leaf 配合 `--jq` 精确投影。
|
||||
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
|
||||
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
|
||||
|
||||
@@ -374,21 +372,21 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
dws aitable record query --help
|
||||
|
||||
# 先在产品内发现命令,再查看选中 leaf 的契约
|
||||
dws schema aitable
|
||||
dws schema "aitable record query"
|
||||
dws schema aitable --compact
|
||||
dws schema "aitable record query" --compact
|
||||
|
||||
# 执行真实业务查询
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应优先按产品/分组发现后查询 leaf,避免把整个 Catalog 加载进上下文。
|
||||
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应使用 `--compact` 渐进查询;该视图采用正向字段白名单,full 新增的审计字段不会自动进入 Agent 上下文。
|
||||
|
||||
### Agent Skills
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
|
||||
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
@@ -437,7 +435,6 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
|
||||
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
|
||||
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
|
||||
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
|
||||
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
|
||||
|
||||
<details>
|
||||
@@ -533,7 +530,7 @@ dws event stop <subscribe_id>
|
||||
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
|
||||
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
|
||||
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-misc/references/event.md`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -618,7 +615,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# 内置 jq 表达式
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema "dev app create" --jq '.tool.required'
|
||||
dws schema "dev app create" --jq '.parameters'
|
||||
|
||||
# 只返回指定字段
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -630,9 +627,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
|
||||
|
||||
```bash
|
||||
dws schema aitable # 发现产品命令
|
||||
dws schema "aitable record query" # 查看选中 leaf 契约
|
||||
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
|
||||
dws schema aitable --compact # 发现产品命令
|
||||
dws schema "aitable record query" --compact # 查看 Agent leaf 契约
|
||||
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # 定向查看必填字段
|
||||
dws schema --all # CI/审计/基线的全量导出
|
||||
```
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
- `internal/output`: response formatting (json, table, raw, pretty)
|
||||
- `internal/logging`: structured logging and argument sanitization
|
||||
- `internal/tui`: terminal UI helpers
|
||||
- `internal/recovery`: panic recovery and graceful degradation
|
||||
- `pkg/configmeta`: environment variable registry and documentation
|
||||
- `pkg/config`: configuration constants and paths
|
||||
- `pkg/edition`: edition detection (oss vs enterprise)
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ repository root while preserving repo-local guidance for automation.
|
||||
- Error message or category issues: inspect `internal/errors`
|
||||
- Audit log issues: inspect `internal/audit`
|
||||
- Plugin loading or command surface: inspect `internal/plugin`
|
||||
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
|
||||
- Failure or degraded mode: inspect `internal/errors`
|
||||
|
||||
## Policy Checks
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
|
||||
|
||||
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
|
||||
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
|
||||
> 用途:评测 agent(加载 `dingtalk-misc` 的 `references/devapp.md` 后)能否正确处理开放平台 dev 任务。
|
||||
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
|
||||
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 一键安装
|
||||
|
||||
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
|
||||
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),**只需要 curl + tar,不需要 git / go / make**。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
@@ -24,7 +24,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
|
||||
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
|
||||
2. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
|
||||
3. 从 Release 的 skills 包里安装 `dingtalk-misc` skill 到本机已检测到的 Agent 目录。
|
||||
|
||||
支持这些环境变量(全部可选):
|
||||
|
||||
@@ -33,7 +33,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
|
||||
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
|
||||
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
|
||||
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
|
||||
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-misc` skill 安装 |
|
||||
|
||||
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
|
||||
|
||||
|
||||
+11
-10
@@ -94,7 +94,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
|
||||
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
|
||||
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
|
||||
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
|
||||
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
|
||||
dws schema -f pretty "calendar event create" --compact # Pretty Agent schema view / Agent Schema 彩色查看
|
||||
```
|
||||
|
||||
## Dry Run / 试运行
|
||||
@@ -119,27 +119,28 @@ Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树
|
||||
|
||||
```bash
|
||||
dws schema # 当前公开产品面的紧凑概览
|
||||
dws schema calendar # 展开一个产品
|
||||
dws schema "calendar event" # 展开一个命令分组
|
||||
dws schema "calendar event create" # 按 CLI 空格路径查询工具
|
||||
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
|
||||
dws schema --cli-path "calendar event create" # 显式 CLI path
|
||||
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
|
||||
dws schema calendar --compact # Agent 产品视图
|
||||
dws schema "calendar event" --compact # Agent 分组视图
|
||||
dws schema "calendar event create" --compact # Agent leaf(CLI 空格路径)
|
||||
dws schema calendar.create_calendar_event --compact # Agent leaf(canonical path)
|
||||
dws schema --cli-path "calendar event create" --compact # Agent leaf(显式 CLI path)
|
||||
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
|
||||
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
|
||||
```
|
||||
|
||||
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
|
||||
|
||||
Leaf 查询、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;概览、产品/分组和 Catalog summary 也由该 `ToolSpec` 的统一 summary 投影生成。通过 alias 查询时,只允许 `cli_path` 和 `is_alias` 发生视图变化,参数、安全和接口契约不得变化。
|
||||
省略 `--compact` 的 full leaf、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;compact leaf 仅做字段白名单投影,不重新解析语义。概览、产品/分组和 Catalog summary 也来自同一 `ToolSpec`。通过 alias 查询时,只允许路径视图发生变化,参数、安全和接口契约不得变化。
|
||||
|
||||
`--compact` 是 Schema 的展示选项。当前版本支持该 flag;若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试。这只降低输出裁剪能力,不表示 leaf 不存在,也不能改用 Schema 查询业务数据。
|
||||
`--compact` 是 Schema 的稳定 Agent 字段白名单,也是普通 Agent 查询的规范选项。它保留 CLI 参数、组合约束、选择和安全语义,但有意省略 `interface_ref`、参数 `property/interface_type` 与 provenance。检查这些映射/审计字段时,使用 full leaf 并通过 `--jq` / `--fields` 精确投影。若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试;这只降低输出裁剪能力,不表示 leaf 缺失。
|
||||
|
||||
### Schema、Help 与业务数据的边界
|
||||
|
||||
| 问题 | 事实源 |
|
||||
|------|--------|
|
||||
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
|
||||
| Agent 选哪个命令、参数映射与组合约束、risk/confirmation | 对应的 leaf `dws schema "<path>"` |
|
||||
| Agent 选哪个命令、CLI 参数与组合约束、risk/confirmation | 对应的 Agent leaf `dws schema "<path>" --compact` |
|
||||
| CLI↔RPC 参数映射、接口绑定与 provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
|
||||
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||
|
||||
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 第一步:安装 dws
|
||||
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
|
||||
@@ -231,7 +231,8 @@ Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/prov
|
||||
| 问题 | 事实源 |
|
||||
|---|---|
|
||||
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
|
||||
| Agent 选哪个命令、参数映射/required/约束、risk/confirmation | 对应 leaf `dws schema "<path>"` |
|
||||
| Agent 选哪个命令、CLI 参数/required/约束、risk/confirmation | Agent leaf `dws schema "<path>" --compact` |
|
||||
| CLI↔RPC 参数映射、接口绑定、provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
|
||||
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||
|
||||
Schema 和 Help 冲突是契约漂移,不能静默猜测:
|
||||
@@ -246,10 +247,10 @@ Schema 和 Help 冲突是契约漂移,不能静默猜测:
|
||||
|
||||
```bash
|
||||
dws schema # 产品紧凑概览
|
||||
dws schema calendar # 产品摘要
|
||||
dws schema "calendar event" # 分组摘要
|
||||
dws schema "calendar event create" # 完整 leaf
|
||||
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
|
||||
dws schema calendar --compact # Agent 产品摘要
|
||||
dws schema "calendar event" --compact # Agent 分组摘要
|
||||
dws schema "calendar event create" --compact # Agent leaf
|
||||
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
|
||||
dws schema --all # 所有工具的完整 leaf 导出
|
||||
```
|
||||
|
||||
@@ -257,7 +258,7 @@ dws schema --all # 所有工具的完整 leaf 导
|
||||
|
||||
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
|
||||
|
||||
`--compact` 当前受支持,适合减少常规 leaf 查询上下文。`schema --all --compact` 也可执行,但会移除 provenance/debug 和接口映射字段,不能作为完整兼容性 baseline。
|
||||
`--compact` 是普通 Agent 查询的规范视图:通过正向字段白名单保留选参、约束与安全语义,full 新增字段不会自动进入 Agent 上下文。省略它的 leaf 包含参数 property、接口绑定和 provenance,只用于定向审计;`schema --all --compact` 也可执行,但不能作为完整兼容性 baseline。
|
||||
|
||||
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-07-29T00:06:19.285348",
|
||||
"count": 265,
|
||||
"generated_at": "2026-08-05T22:43:52.497190",
|
||||
"count": 294,
|
||||
"results": [
|
||||
{
|
||||
"suite": "read",
|
||||
@@ -485,7 +485,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。",
|
||||
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -658,6 +658,16 @@
|
||||
"semantic_delta": "群邀请链接是一对一读取;Shortcut 未增加生命周期或分享编排。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "chat",
|
||||
"command": "+chat-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "对齐 lark-cli +chat-list:默认仅群聊,支持 --types group/p2p、--exclude-muted、page-size/page-token 别名,并投影 openConversationId/name/conversationType;不宣称 sort 或 bot 身份 p2p 剥离。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "chat",
|
||||
@@ -715,7 +725,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。",
|
||||
"semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1215,7 +1225,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。",
|
||||
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 统一下载 mediaId 与 fileId,复用受信任下载域、相对路径、无覆盖和原子落盘防护,对重复资源去重并逐资源返回下载失败 ledger;安全本地下载沿用 read/not_required 契约,不产生非交互确认盲区。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1295,7 +1305,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。",
|
||||
"semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1315,7 +1325,7 @@
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。",
|
||||
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 发送;current-user 支持文本、Markdown、mediaId 图片、安全相对路径本地文件上传、userId 姓名解析与幂等键,bot/webhook 仍只暴露下层真实支持的文本/Markdown 能力。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1344,8 +1354,8 @@
|
||||
"command": "+messages-send-card",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1415,7 +1425,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。",
|
||||
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1435,7 +1445,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1708,123 +1718,454 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+access-change",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "读取当前权限后再变更角色,避免把不存在的协作者当作成功更新。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+access-grant",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在第一次写入前解析全部接收人,再批量授予文档权限并输出逐项 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+access-revoke",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "预检目标协作者权限后移除并输出逐项结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+background-delete",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以 clear 语义移除文档背景色。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+background-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "校验并设置 #RRGGBB 文档背景纯色。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+checkpoint-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "写入前保存版本快照,更新后读回验证并输出逐步 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-create",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "永久删除指定评论,并由静态安全契约强制确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一评论类型、解决状态与分页过滤。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-reply",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一评论回复、表情回复和 mention 参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "更新指定评论正文与 mention。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+copy",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 Markdown/JSONML 内容输入、目标位置与创建后保真写入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+create-from-template",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "支持 templateId 直达或按名称搜索消歧后创建文档。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+doc-append",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+export",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+export-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+export-submit",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "导出中断恢复所需的专家入口;常规场景使用一体化 +export。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+fetch",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 simple/with-ids/full 细节层级与 full/outline/range/section/keyword/tags 局部读取。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+find-doc",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+grant-and-share",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先确保目标角色,再发送链接;消息失败保留逐人 ledger,并以非零退出报告 failed/partial_success。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+history-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一历史版本分页参数并返回可用于回滚的版本列表。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+history-revert",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先验证目标版本存在,再执行回滚并读回当前文档状态。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+history-save",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+import",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+inspect",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "聚合文档元信息,并按需读取样式、权限、历史、媒体和评论。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+media-download",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "解析附件临时链接并通过受控相对路径、no-clobber、原子发布安全下载。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+media-insert",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "组合本地文件校验、上传凭证、OSS PUT、插块和验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+media-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "从文档块中提取图片、附件及其 block/resource 标识。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+media-preview",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "将正文媒体下载到受控临时目录并返回本地预览 artifact。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+move",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+resource-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以幂等 clear 语义移除当前文档封面。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+resource-download",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "读取当前文档封面配置并安全下载资源到本地。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+resource-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "支持本地图片或 HTTPS 图片转存后设置文档封面。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+review",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "聚合未解决评论、划词引用和确定性上下文,不调用模型生成总结。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+share",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析唯一用户后发送文档链接,不改变文档权限。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+share-doc",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+template-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 MY/PUBLIC 模板浏览和分页参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+template-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+version-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+version-revert",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+version-save",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# DWS Skill 内容框架合同
|
||||
|
||||
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
|
||||
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
|
||||
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
|
||||
|
||||
## 1. 两棵内容树
|
||||
|
||||
| 树 | 路径 | 角色 |
|
||||
|---|---|---|
|
||||
| **mono**(单 skill) | `skills/mono/` | 单一 `SKILL.md` 入口 + `references/products/*` 产品面 + 全局协议 |
|
||||
| **multi**(多 skill) | `skills/multi/` | 平铺 `dingtalk-*` 产品 skill + 必选 `dingtalk-shared` |
|
||||
|
||||
Agent / 安装面选哪棵树由**行为分支**决定;本文件只规定树内合同。
|
||||
|
||||
## 2. Multi 目录合同(如何新增一个产品 skill)
|
||||
|
||||
新建 `skills/multi/<name>/` 时必须满足:
|
||||
|
||||
1. **命名**
|
||||
- 产品 skill:`dingtalk-<product>`(小写、连字符)
|
||||
- 共享 skill:仅允许 `dingtalk-shared`
|
||||
2. **根文件**
|
||||
- 必有 `SKILL.md`(YAML frontmatter + 正文)
|
||||
- `references/` 推荐;无 reference 的 skill(如极简 profile)须在质检 omit 表登记
|
||||
- `scripts/` 可选;脚本须被本 skill 树内某 `.md` 引用,或进入 orphan allowlist
|
||||
3. **Frontmatter 最小集**(产品 / shared)
|
||||
- `name`:与目录名一致
|
||||
- `description`:非空,含触发意图与边界
|
||||
- `metadata.category`:`product` 或 `shared`(允许历史写法把 `cli_version` 放在 frontmatter 顶层)
|
||||
- `metadata.requires.bins`:含 `dws`
|
||||
4. **契约块**
|
||||
- 产品 skill 推荐内嵌 `<!-- DWS_RUNTIME_CONTRACT_START -->…END -->` **或** 明确 PREREQUISITE 指向 `dingtalk-shared`
|
||||
- `dingtalk-shared` 承载跨产品路由与全局协议落点
|
||||
5. **与 mono 映射**
|
||||
- 每个 mono `references/products/<stem>`(文件或目录)必须在
|
||||
`skills/content-qa/mono-multi-coverage.yaml` 有 `coverage` 或 `omit_coverage` 行
|
||||
|
||||
### 2.1 推荐骨架
|
||||
|
||||
```text
|
||||
skills/multi/dingtalk-example/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── example.md # 主产品面
|
||||
│ └── … # 子章节 / 意图表
|
||||
└── scripts/ # 可选;须被 md 引用
|
||||
└── example_helper.py
|
||||
```
|
||||
|
||||
## 3. Mono 目录合同(质检对照基准)
|
||||
|
||||
```text
|
||||
skills/mono/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── products/ # 覆盖质检主源
|
||||
│ ├── error-codes.md # 全局协议示例
|
||||
│ ├── error-codes.md
|
||||
│ └── …
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
- `references/products/` 下每个顶层 stem(`.md` 去后缀或子目录名)计入覆盖索引。
|
||||
- 同 stem 的 `.md` + 子目录视为同一产品面(如 `doc.md` + `doc/`)。
|
||||
|
||||
## 4. 共享内容(`dingtalk-shared`)
|
||||
|
||||
| 职责 | 落点 |
|
||||
|---|---|
|
||||
| 跨产品路由 / 工作流 | `references/routing.md`、`workflow-routing.md`、`intent-guide.md` |
|
||||
| 运行时最小契约长文 | `references/runtime-contract.md`(受 context-budget 约束) |
|
||||
| 全局协议(确认门禁 / Schema 教学等) | `references/`;见质检基线 |
|
||||
| 与 mono 全局文同名迁移 | `error-codes`、`url-patterns`、`capability-limits`、`channel-login`、`global-reference`、`recipes/`(`conventions.md`、`meta.md`、`lite-catalog.md`) |
|
||||
|
||||
产品专属规则(如 AI 表格 `field-rules`)允许下沉到对应 `dingtalk-*`,须在覆盖表注明。
|
||||
|
||||
## 5. Zip 内容布局合同(形状,非安装默认)
|
||||
|
||||
发布物 `dws-skills.zip`(及 embed 同源)内容树形状:
|
||||
|
||||
| Zip 路径 | 含义 |
|
||||
|---|---|
|
||||
| `<root>/` | mono 内容副本(兼容旧面) |
|
||||
| `<root>/mono/` | 与 `skills/mono/` 同构 |
|
||||
| `<root>/multi/` | 与 `skills/multi/` 同构 |
|
||||
|
||||
质检可断言源树形状;**不**断言安装器默认解压哪棵。
|
||||
|
||||
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
|
||||
|
||||
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|
||||
|---|---|---|
|
||||
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
|
||||
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
|
||||
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
|
||||
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
|
||||
|
||||
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
|
||||
|
||||
## 7. 变更流程
|
||||
|
||||
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
|
||||
2. 跑 `make skill-mono-multi-content`(或 `make policy`)
|
||||
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
|
||||
@@ -0,0 +1,65 @@
|
||||
# Mono↔Multi Skill 内容质检规格
|
||||
|
||||
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
|
||||
> 执行:`make skill-mono-multi-content`(已挂入 `make policy`)。
|
||||
|
||||
## 1. 质检矩阵
|
||||
|
||||
| ID | 类型 | 输入 | 通过准则 |
|
||||
|---|---|---|---|
|
||||
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
|
||||
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
|
||||
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
|
||||
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 一致;全局协议存在或 ∈ `omit_global` |
|
||||
|
||||
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
|
||||
|
||||
## 2. 有意省略 / 延期登记格式
|
||||
|
||||
YAML(见 coverage 文件):
|
||||
|
||||
```yaml
|
||||
omit_coverage:
|
||||
- mono: simple
|
||||
disposition: covered_by # covered_by | defer | wontfix
|
||||
via: dingtalk-misc # optional
|
||||
reason: "拆入 oa/devdoc…"
|
||||
|
||||
omit_global:
|
||||
- id: field-rules-global
|
||||
mono_path: references/field-rules.md
|
||||
expected_multi: dingtalk-aitable/references/field-rules.md
|
||||
disposition: covered_by
|
||||
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
|
||||
|
||||
orphan_scripts_allowlist:
|
||||
- path: dingtalk-misc/scripts/report_received_today.py
|
||||
disposition: defer
|
||||
reason: "pending report.md reference"
|
||||
```
|
||||
|
||||
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
|
||||
|
||||
## 3. 缺口基线(相对 mono)
|
||||
|
||||
| ID | 项 | disposition | 说明 |
|
||||
|---|---|---|---|
|
||||
| M1 | recovery-guide / RECOVERY_EVENT_ID 闭环 | **removed** | 已从 mono/multi skill 文档删除;不做移植 |
|
||||
| M2 | confirmation_required 全局协议 | **done** | `dingtalk-shared/references/confirmation.md` + SKILL 导航 |
|
||||
| M3 | Schema 渐进查询教学 | **done** | `dingtalk-shared/references/schema-usage.md` |
|
||||
| M4 | `report_inbox_today.py` | `defer` / orphan 侧 | 验证后迁 misc 或删 |
|
||||
| M5 | multi LICENSE/NOTICE | `defer` | 内容或打包注入 |
|
||||
| M6 | aiapp 路由 vs orphan 脚本 | **done(标明未产品化)** | mono 死链移除;`unsupported-scripts.md` |
|
||||
| X1 | yida/finance/aiapp orphan scripts | **done(登记)** | 由 unsupported-scripts 具名引用 |
|
||||
| X2 | chat 死链 `extract_media_id.py` | n/a | 现仅为反模式提及 |
|
||||
| X3 | routing → markdown 错路径 | **done** | 已指 `dingtalk-misc/references/markdown.md`;drive 尾链已修 |
|
||||
| X4 | event 缺 metadata | **done** | |
|
||||
| X5 | multi skill 横幅 /「优先 mono」文案 | **done** | 横幅已全部移除 |
|
||||
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
|
||||
|
||||
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
|
||||
|
||||
## 4. 与悟空
|
||||
|
||||
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
|
||||
@@ -0,0 +1,272 @@
|
||||
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
|
||||
|
||||
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
|
||||
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
|
||||
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
|
||||
> 门禁:`make skill-mono-multi-content`(已入 `make policy`)
|
||||
>
|
||||
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
|
||||
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
|
||||
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
|
||||
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
|
||||
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
|
||||
>
|
||||
> 对照仓:
|
||||
>
|
||||
> | 仓 | 路径 | 基线 |
|
||||
> |---|---|---|
|
||||
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
|
||||
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
|
||||
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
|
||||
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
|
||||
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
|
||||
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
|
||||
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
|
||||
|
||||
### 0.1 IN SCOPE
|
||||
|
||||
| 类别 | 包含 |
|
||||
|---|---|
|
||||
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
|
||||
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
|
||||
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
|
||||
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
|
||||
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
|
||||
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
|
||||
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
|
||||
|
||||
### 0.2 OUT OF SCOPE
|
||||
|
||||
| 类别 | 去向 |
|
||||
|---|---|
|
||||
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
|
||||
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
|
||||
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
|
||||
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
|
||||
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
|
||||
| 把质检做成「改安装默认值」的后门 | 拒绝 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 内容现状盘点
|
||||
|
||||
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
|
||||
|
||||
```text
|
||||
skills/mono/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
|
||||
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
|
||||
│ └── best_practices/…
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
### 1.2 DWS `skills/multi`(内容主体)
|
||||
|
||||
```text
|
||||
skills/multi/
|
||||
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
|
||||
└── dingtalk-*/ # 19 产品 + 各 references、scripts
|
||||
```
|
||||
|
||||
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
|
||||
|
||||
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
|
||||
|
||||
### 1.4 Zip 内容布局合同
|
||||
|
||||
| Zip 路径 | 内容含义 |
|
||||
|---|---|
|
||||
| `<root>/` | mono 副本(兼容) |
|
||||
| `<root>/mono/` | 显式 mono 内容源 |
|
||||
| `<root>/multi/` | 与 `skills/multi/` 同构 |
|
||||
|
||||
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
|
||||
|
||||
### 1.5 现有 DWS skill 内容质检资产(复用清单)
|
||||
|
||||
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|
||||
|---|---|---|
|
||||
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
|
||||
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
|
||||
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
|
||||
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
|
||||
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
|
||||
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
|
||||
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
|
||||
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
|
||||
|
||||
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
|
||||
|
||||
### 1.6 悟空侧类比质检
|
||||
|
||||
| 悟空 | 说明 | 本分支 |
|
||||
|---|---|---|
|
||||
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
|
||||
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
|
||||
|
||||
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
|
||||
|
||||
---
|
||||
|
||||
## 2. Diff(内容组织 + 质检视角)
|
||||
|
||||
### 2.1 已同构
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
|
||||
|
||||
### 2.2 分叉与已知内容风险(质检要盯的)
|
||||
|
||||
| 风险 ID | 现象(线索) | 质检类型 |
|
||||
|---|---|---|
|
||||
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
|
||||
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
|
||||
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
|
||||
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
|
||||
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
|
||||
|
||||
### 2.3 Reject
|
||||
|
||||
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals / Non-goals
|
||||
|
||||
### 3.1 Goals
|
||||
|
||||
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
|
||||
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
|
||||
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
|
||||
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
|
||||
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
|
||||
|
||||
### 3.2 Non-goals
|
||||
|
||||
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
|
||||
|
||||
---
|
||||
|
||||
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
|
||||
|
||||
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
|
||||
|
||||
### Phase 0 — 方案冻结(本文)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 本文件;§7(含质检轨)勾选 |
|
||||
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
|
||||
|
||||
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
|
||||
| **触达** | `docs/skill-content-framework.md` |
|
||||
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
|
||||
|
||||
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
|
||||
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
|
||||
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
|
||||
|
||||
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | G1–G4 自动门禁 |
|
||||
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
|
||||
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
|
||||
|
||||
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
|
||||
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
|
||||
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
|
||||
|
||||
### 延期登记(非本分支)
|
||||
|
||||
| 主题 | 载体 |
|
||||
|---|---|
|
||||
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
|
||||
| skillhome / 安装面 bootstrap | 行为分支 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Port / Adapt / Reject
|
||||
|
||||
| 项 | 决策 | 说明 |
|
||||
|---|---|---|
|
||||
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
|
||||
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
|
||||
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
|
||||
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
|
||||
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
|
||||
| 行为 cherry-pick | **Defer** | 另分支 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 与 `402429ac` / `d5c8982c`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 本分支 cherry-pick? | **否** |
|
||||
| 质检是否替代行为翻转? | **否** |
|
||||
| 行为分支 | 另开;可与内容/质检并行 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 批准清单(请重新勾选)
|
||||
|
||||
**范围**
|
||||
|
||||
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
|
||||
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
|
||||
- [x] 取消产品与悟空客户端链路仍拒绝
|
||||
|
||||
**内容框架 Phase**
|
||||
|
||||
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
|
||||
|
||||
**质检轨 Phase**
|
||||
|
||||
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
|
||||
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
|
||||
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
|
||||
|
||||
**可选**
|
||||
|
||||
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
|
||||
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
|
||||
|
||||
**Follow-up 知悉**
|
||||
|
||||
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
|
||||
|
||||
---
|
||||
|
||||
## 8. 下一步
|
||||
|
||||
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
|
||||
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
|
||||
安装默认 multi 等行为仍走 **另一分支**。
|
||||
|
||||
---
|
||||
|
||||
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
|
||||
@@ -335,12 +335,6 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
|
||||
edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }})
|
||||
captureRuntimeFailure(executor.Invocation{}, nil, nil)
|
||||
captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), nil)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc", "download", "--node", "n"}
|
||||
if got := runtimeCommandPath(executor.Invocation{}); len(got) != 2 {
|
||||
t.Fatalf("runtime command path = %#v", got)
|
||||
}
|
||||
os.Args = oldArgs
|
||||
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
if hostControlProviderFromEnv() != "" {
|
||||
@@ -360,6 +354,10 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
|
||||
|
||||
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
|
||||
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
|
||||
command.SetOut(io.Discard)
|
||||
rootWrap := &cobra.Command{Use: "dws"}
|
||||
rootWrap.PersistentFlags().String("format", "json", "")
|
||||
rootWrap.AddCommand(command)
|
||||
command.SetOut(io.Discard)
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -389,18 +387,18 @@ func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.
|
||||
for _, format := range []string{"json", "pretty", "table"} {
|
||||
_ = cacheRoot.PersistentFlags().Set("format", format)
|
||||
cacheCmd.SetOut(io.Discard)
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
|
||||
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
fail := errors.New("write")
|
||||
cacheCmd.SetOut(appFailWriter{err: fail})
|
||||
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
|
||||
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
|
||||
t.Fatalf("pretty write error = %v", err)
|
||||
}
|
||||
_ = cacheRoot.PersistentFlags().Set("format", "table")
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
|
||||
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
|
||||
t.Fatalf("table write error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const cacheUnsupportedMessage = "dws cache 不再支持:服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。"
|
||||
|
||||
const cacheReplacementHint = "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。"
|
||||
|
||||
type cacheCompatNotice struct {
|
||||
Status string `json:"status"`
|
||||
Command string `json:"command"`
|
||||
@@ -28,24 +32,32 @@ type cacheCompatNotice struct {
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
}
|
||||
|
||||
// newCacheCommand keeps a visible Deprecated compatibility surface for
|
||||
// historical argv (refresh/status/clean). Behavior is a successful no-op notice.
|
||||
// Skills must not teach this path. Deprecated leaves are excluded from Schema
|
||||
// via cobra.IsAvailableCommand() — do not add schema_command_exclusions entries.
|
||||
func newCacheCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cache",
|
||||
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
|
||||
Hidden: true,
|
||||
Short: "不再支持:服务发现缓存兼容入口",
|
||||
Long: "此命令组仅为历史 argv 兼容保留。静态端点模式下无需服务发现缓存;Skill / Agent 请勿引导此路径。",
|
||||
Deprecated: "不再支持;" + cacheUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
return printCacheCompatNotice(cmd, "dws cache")
|
||||
},
|
||||
}
|
||||
for _, name := range []string{"refresh", "status", "clean"} {
|
||||
subName := name
|
||||
sub := &cobra.Command{
|
||||
Use: name,
|
||||
Short: "已弃用:静态端点模式无需服务发现缓存",
|
||||
Use: subName,
|
||||
Short: "不再支持:静态端点模式无需服务发现缓存",
|
||||
Deprecated: "不再支持;" + cacheUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return printCacheCompatNotice(cmd, name)
|
||||
return printCacheCompatNotice(cmd, "dws cache "+subName)
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(sub)
|
||||
@@ -56,9 +68,9 @@ func newCacheCommand() *cobra.Command {
|
||||
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
||||
notice := cacheCompatNotice{
|
||||
Status: "deprecated",
|
||||
Command: "dws cache " + command,
|
||||
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
|
||||
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
|
||||
Command: command,
|
||||
Message: cacheUnsupportedMessage,
|
||||
Replacement: cacheReplacementHint,
|
||||
}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
@@ -66,8 +78,7 @@ func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
|
||||
case "pretty":
|
||||
data, _ := json.MarshalIndent(notice, "", " ")
|
||||
var err error
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
return err
|
||||
default:
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCacheDeprecatedCompatShim(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
group := mustFindCommand(t, root, "cache")
|
||||
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
|
||||
t.Fatalf("cache group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
|
||||
}
|
||||
if group.IsAvailableCommand() {
|
||||
t.Fatal("deprecated cache group must not be IsAvailableCommand")
|
||||
}
|
||||
|
||||
for _, leaf := range []string{"refresh", "status", "clean"} {
|
||||
cmd := mustFindCommand(t, root, "cache", leaf)
|
||||
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
|
||||
t.Fatalf("cache %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
|
||||
}
|
||||
if cmd.IsAvailableCommand() {
|
||||
t.Fatalf("deprecated cache %s must not be IsAvailableCommand", leaf)
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "不再支持", "服务发现已下线"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"", "json", "pretty", "table"} {
|
||||
var buf bytes.Buffer
|
||||
parent := &cobra.Command{Use: "dws"}
|
||||
parent.PersistentFlags().String("format", format, "")
|
||||
parent.SetOut(&buf)
|
||||
sub := &cobra.Command{Use: "cache"}
|
||||
parent.AddCommand(sub)
|
||||
if err := printCacheCompatNotice(sub, "dws cache status"); err != nil {
|
||||
t.Fatalf("format=%q: %v", format, err)
|
||||
}
|
||||
text := buf.String()
|
||||
if !strings.Contains(text, "不再支持") && !strings.Contains(text, "服务发现已下线") {
|
||||
t.Fatalf("format=%q missing notice:\n%s", format, text)
|
||||
}
|
||||
if format == "" || format == "json" || format == "pretty" {
|
||||
if !strings.Contains(text, `"status":"deprecated"`) && !strings.Contains(text, `"status": "deprecated"`) {
|
||||
t.Fatalf("format=%q missing deprecated JSON status:\n%s", format, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"pretty", "table"} {
|
||||
parent := &cobra.Command{Use: "dws"}
|
||||
parent.PersistentFlags().String("format", format, "")
|
||||
parent.SetOut(failWriter{})
|
||||
sub := &cobra.Command{Use: "cache"}
|
||||
parent.AddCommand(sub)
|
||||
if err := printCacheCompatNotice(sub, "dws cache clean"); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
|
||||
}
|
||||
}
|
||||
|
||||
parent := newCacheCommand()
|
||||
var parentOut bytes.Buffer
|
||||
rootWrap := &cobra.Command{Use: "dws"}
|
||||
rootWrap.PersistentFlags().String("format", "json", "")
|
||||
rootWrap.SetOut(&parentOut)
|
||||
rootWrap.AddCommand(parent)
|
||||
parent.SetOut(&parentOut)
|
||||
if err := parent.RunE(parent, nil); err != nil {
|
||||
t.Fatalf("cache parent RunE = %v, want nil success", err)
|
||||
}
|
||||
if !strings.Contains(parentOut.String(), `"command":"dws cache"`) {
|
||||
t.Fatalf("cache parent notice missing command:\n%s", parentOut.String())
|
||||
}
|
||||
|
||||
cache := newCacheCommand()
|
||||
cache.SetOut(&bytes.Buffer{})
|
||||
cache.SetArgs([]string{"status"})
|
||||
if err := cache.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
@@ -227,115 +226,6 @@ func TestCrossPlatformCoverageDocDownloadPureCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryPureCoverage(t *testing.T) {
|
||||
if _, err := decodeRecoveryAttempts(nil, nil, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts(json.RawMessage("null"), nil, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := decodeRecoveryAttempts(json.RawMessage(`[{"command_summary":"one"}]`), nil, "", ""); err != nil || len(got) != 1 {
|
||||
t.Fatalf("array attempts = %#v %v", got, err)
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts(json.RawMessage("{"), nil, "", ""); err == nil {
|
||||
t.Fatal("malformed attempts succeeded")
|
||||
}
|
||||
if got, err := decodeRecoveryAttempts(json.RawMessage("2"), []string{"a"}, "ok", ""); err != nil || len(got) != 2 {
|
||||
t.Fatalf("legacy attempts = %#v %v", got, err)
|
||||
}
|
||||
if legacyRecoveryAttempts(0, nil, "", "") != nil || len(legacyRecoveryAttempts(1, nil, "", "")) != 1 {
|
||||
t.Fatal("legacy attempts edge mismatch")
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"dws", "--debug", "doc", "get", "--node", "n"},
|
||||
{"dws", "--format=json", "doc", "--", "ignored"},
|
||||
{"dws", "--unknown", "value", "doc"},
|
||||
} {
|
||||
old := os.Args
|
||||
os.Args = args
|
||||
_ = currentCommandPath()
|
||||
os.Args = old
|
||||
}
|
||||
for _, inv := range []executor.Invocation{
|
||||
{LegacyPath: "legacy path"},
|
||||
{CanonicalProduct: "doc", Tool: "get"},
|
||||
{CanonicalProduct: "doc"},
|
||||
{},
|
||||
} {
|
||||
old := os.Args
|
||||
os.Args = []string{"dws"}
|
||||
_ = runtimeCommandPath(inv)
|
||||
os.Args = old
|
||||
}
|
||||
if cloneRecoveryArgs(nil) != nil {
|
||||
t.Fatal("empty recovery args should clone to nil")
|
||||
}
|
||||
original := map[string]any{"x": 1}
|
||||
clone := cloneRecoveryArgs(original)
|
||||
clone["x"] = 2
|
||||
if original["x"] != 1 {
|
||||
t.Fatal("recovery args were not cloned")
|
||||
}
|
||||
|
||||
if got, _ := (*recoveryRuntime)(nil).Search(context.Background(), "query", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
|
||||
t.Fatalf("nil recovery search = %#v", got)
|
||||
}
|
||||
if got, _ := (&recoveryRuntime{}).Search(context.Background(), " ", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
|
||||
t.Fatalf("blank recovery search = %#v", got)
|
||||
}
|
||||
if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil {
|
||||
t.Fatal("nil recovery runtime call succeeded")
|
||||
}
|
||||
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
|
||||
t.Fatalf("missing recovery endpoint error = %v", err)
|
||||
} else {
|
||||
var apiErr *apperrors.Error
|
||||
if !errors.As(err, &apiErr) || apiErr.Category != apperrors.CategoryAPI || apiErr.Operation != "discovery.resolve" || apiErr.Reason != "endpoint_not_resolved" {
|
||||
t.Fatalf("missing recovery endpoint classification = %#v", err)
|
||||
}
|
||||
}
|
||||
t.Setenv("DINGTALK_OK_MCP_URL", " https://catalog.test ")
|
||||
runtime := &recoveryRuntime{}
|
||||
if got, err := runtime.resolveEndpoint(context.Background(), "ok", "tool"); err != nil || got != "https://catalog.test" {
|
||||
t.Fatalf("recovery endpoint override = %q %v", got, err)
|
||||
}
|
||||
if recoveryRuntimeToken(nil) != "" || recoveryRuntimeToken(&GlobalFlags{Token: " token "}) != "token" {
|
||||
t.Fatal("recovery token mismatch")
|
||||
}
|
||||
if toRecoveryToolResponse(nil) != nil {
|
||||
t.Fatal("nil recovery response should stay nil")
|
||||
}
|
||||
response := toRecoveryToolResponse(&transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Type: "text", Text: "body"}}})
|
||||
if response == nil || !response.IsError || len(response.Content) != 1 {
|
||||
t.Fatalf("recovery response = %#v", response)
|
||||
}
|
||||
|
||||
items := []any{map[string]any{"title": "A", "url": "u", "desc": "d"}, "skip", map[string]any{}}
|
||||
for _, payload := range []map[string]any{
|
||||
nil,
|
||||
{"items": items},
|
||||
{"data": map[string]any{"items": items}},
|
||||
{"result": map[string]any{"items": items}},
|
||||
} {
|
||||
_ = parseDocSearchItemsFromMap(payload)
|
||||
}
|
||||
if toDocSearchItems("bad") != nil {
|
||||
t.Fatal("non-list doc items accepted")
|
||||
}
|
||||
result := &transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "{"}, {Text: `{"items":[{"title":"B"}]}`}}}
|
||||
if got := parseDocSearchItems(result); len(got) != 1 {
|
||||
t.Fatalf("block doc items = %#v", got)
|
||||
}
|
||||
if parseDocSearchItems(nil) != nil {
|
||||
t.Fatal("nil doc result should be nil")
|
||||
}
|
||||
searchItems := []recovery.DocSearchItem{{Title: "query", URL: "u"}, {Title: "other"}, {Title: "third"}, {Title: "fourth"}}
|
||||
if len(rerankDocSearchHits("query", recovery.RecoveryContext{ToolName: "tool", CommandPath: []string{"doc"}}, searchItems)) != 3 || rerankDocSearchHits("", recovery.RecoveryContext{}, nil) != nil {
|
||||
t.Fatal("doc search reranking mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
|
||||
RegisterPluginAuth("coverage-registry", &PluginAuth{Token: "token"})
|
||||
t.Cleanup(func() {
|
||||
@@ -511,59 +401,6 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
_ = defaultPATMCPEndpoint()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryLoadExecutionCoverage(t *testing.T) {
|
||||
if _, err := loadRecoveryExecution(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing recovery execution succeeded")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRecoveryExecution(path); err == nil {
|
||||
t.Fatal("malformed recovery execution succeeded")
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"legacy": `{"action":" one ","attempt":2,"result":" ok ","error":" bad "}`,
|
||||
"modern": `{"actions":["one"],"attempts":[{"command_summary":"one"}],"error_summary":"bad"}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := loadRecoveryExecution(path); err != nil || len(got.Actions) != 1 || len(got.Attempts) == 0 {
|
||||
t.Fatalf("loaded execution = %#v %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
|
||||
var result map[string]any = map[string]any{"content": []map[string]any{{"type": "text", "text": `{"items":[{"title":"query result","url":"u"}]}`}}}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
|
||||
}))
|
||||
defer server.Close()
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
|
||||
t.Cleanup(func() { SetDynamicServers(nil) })
|
||||
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
|
||||
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
|
||||
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
|
||||
t.Fatalf("recovery search = %#v %v", got, err)
|
||||
}
|
||||
result = map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "failed"}}}
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
|
||||
t.Fatal("recovery MCP error succeeded")
|
||||
}
|
||||
server.Close()
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
|
||||
t.Fatal("recovery network error succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventCommandPureCoverage(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
@@ -760,7 +597,7 @@ func TestCrossPlatformCoverageVersionCacheCompletionCoverage(t *testing.T) {
|
||||
child := &cobra.Command{Use: "child"}
|
||||
root.AddCommand(child)
|
||||
child.SetOut(io.Discard)
|
||||
if err := printCacheCompatNotice(child, "status"); err != nil {
|
||||
if err := printCacheCompatNotice(child, "dws cache status"); err != nil {
|
||||
t.Fatalf("cache %s: %v", format, err)
|
||||
}
|
||||
root.RemoveCommand(child)
|
||||
@@ -1747,9 +1584,6 @@ func TestCrossPlatformCoverageDoctorCommandCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, jsonOut := range []bool{false, true} {
|
||||
if got := doctorCheckCache(io.Discard, jsonOut); got.Status != statusPass {
|
||||
t.Fatal("cache check failed")
|
||||
}
|
||||
if got := doctorCheckPerf(io.Discard, jsonOut); got.Status != statusPass {
|
||||
t.Fatalf("perf check = %#v", got)
|
||||
}
|
||||
@@ -2168,7 +2002,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
}
|
||||
_ = os.Symlink(filepath.Join(mono, "SKILL.md"), filepath.Join(mono, "linked.md"))
|
||||
multi := filepath.Join(t.TempDir(), "multi")
|
||||
for _, name := range []string{"dws-shared", "dingtalk-a", "dingtalk-b"} {
|
||||
for _, name := range []string{"dingtalk-shared", "dingtalk-a", "dingtalk-b"} {
|
||||
dir := filepath.Join(multi, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2193,10 +2027,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output, warnings, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") || warnings == "" {
|
||||
t.Fatalf("multi setup = %q / %q, %v", output, warnings, err)
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") {
|
||||
t.Fatalf("multi setup = %q, %v", output, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
|
||||
@@ -2220,7 +2054,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
all := []string{"dingtalk-a", "dingtalk-b", "dws-shared"}
|
||||
all := []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}
|
||||
for _, tc := range []struct {
|
||||
include []string
|
||||
exclude []string
|
||||
@@ -2238,7 +2072,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
t.Errorf("filter %#v/%#v = %v", tc.include, tc.exclude, err)
|
||||
}
|
||||
}
|
||||
for _, selected := range [][]string{nil, {"dws-shared"}, {"dingtalk-a"}} {
|
||||
for _, selected := range [][]string{nil, {"dingtalk-shared"}, {"dingtalk-a"}} {
|
||||
_ = ensureMandatorySharedSkill(selected, all)
|
||||
}
|
||||
_ = ensureMandatorySharedSkill([]string{"dingtalk-a"}, []string{"dingtalk-a"})
|
||||
|
||||
@@ -302,7 +302,6 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
|
||||
_ = newConfigCommand()
|
||||
_ = newCacheCommand()
|
||||
_ = newVersionCommand()
|
||||
_ = newRecoveryCommand(&GlobalFlags{})
|
||||
_ = newAPICommand(&GlobalFlags{})
|
||||
_ = NewRootCommand(context.Background())
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func newDoctorCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "环境健康检查",
|
||||
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
|
||||
Long: "一键检查登录态、网络连通性和版本更新,快速定位常见问题。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runDoctor,
|
||||
@@ -92,9 +92,6 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
|
||||
checks = append(checks, networkResult)
|
||||
|
||||
cacheResult := doctorCheckCache(w, jsonOut)
|
||||
checks = append(checks, cacheResult)
|
||||
|
||||
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
|
||||
checks = append(checks, versionResult)
|
||||
|
||||
@@ -297,24 +294,6 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Cache check ─────────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
|
||||
}
|
||||
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusPass,
|
||||
Message: "静态端点模式, 无需缓存",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Version check ───────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
|
||||
@@ -109,31 +109,6 @@ func TestPrintCheckResultNoHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckCacheEmpty(t *testing.T) {
|
||||
t.Setenv("DWS_CACHE_DIR", t.TempDir())
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, false)
|
||||
|
||||
if r.Status != statusPass {
|
||||
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
|
||||
t.Setenv("DWS_CACHE_DIR", t.TempDir())
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, true)
|
||||
|
||||
if r.Status != statusPass {
|
||||
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected no output in JSON mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
|
||||
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
|
||||
// contract asserts the exact phrase below inside the installed
|
||||
// dws-shared/SKILL.md, so removing it from the embedded skill source must
|
||||
// dingtalk-shared/SKILL.md, so removing it from the embedded skill source must
|
||||
// fail at PR time instead of at release time.
|
||||
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||
@@ -19,12 +19,12 @@ func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "dws-shared", "SKILL.md"))
|
||||
data, err := os.ReadFile(filepath.Join(dir, "dingtalk-shared", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded dws-shared/SKILL.md: %v", err)
|
||||
t.Fatalf("read embedded dingtalk-shared/SKILL.md: %v", err)
|
||||
}
|
||||
const rule = "禁止选择第一项、最近登录或最近使用账号"
|
||||
if !strings.Contains(string(data), rule) {
|
||||
t.Fatalf("embedded dws-shared/SKILL.md lost the mandatory account safety rule %q", rule)
|
||||
t.Fatalf("embedded dingtalk-shared/SKILL.md lost the mandatory account safety rule %q", rule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,43 +33,43 @@ func contains(ss []string, want string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// dws-shared must ship even when --skill narrows the set to a single product.
|
||||
// dingtalk-shared must ship even when --skill narrows the set to a single product.
|
||||
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
|
||||
src := writeMultiSkillSrc(t, "dingtalk-shared", "dingtalk-aitable", "dingtalk-calendar")
|
||||
all, err := listMultiSkillNames(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(all, "dws-shared") {
|
||||
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
|
||||
if !contains(all, "dingtalk-shared") {
|
||||
t.Fatalf("listMultiSkillNames did not enumerate dingtalk-shared: %v", all)
|
||||
}
|
||||
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contains(filtered, "dws-shared") {
|
||||
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
|
||||
if contains(filtered, "dingtalk-shared") {
|
||||
t.Fatalf("precondition: filter should drop dingtalk-shared for -s aitable: %v", filtered)
|
||||
}
|
||||
final := ensureMandatorySharedSkill(filtered, all)
|
||||
if !contains(final, "dws-shared") {
|
||||
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
|
||||
if !contains(final, "dingtalk-shared") {
|
||||
t.Fatalf("ensureMandatorySharedSkill must re-add dingtalk-shared: %v", final)
|
||||
}
|
||||
|
||||
// Actually install with the filtered+mandatory set and assert dws-shared landed.
|
||||
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dingtalk-shared not installed with -s aitable: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dingtalk-aitable not installed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// When the source has no dws-shared (older layout), nothing is forced.
|
||||
// When the source has no dingtalk-shared (older layout), nothing is forced.
|
||||
func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dingtalk-aitable")
|
||||
all, err := listMultiSkillNames(src)
|
||||
@@ -77,7 +77,7 @@ func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
|
||||
if contains(final, "dws-shared") {
|
||||
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
|
||||
if contains(final, "dingtalk-shared") {
|
||||
t.Fatalf("must not invent dingtalk-shared when source lacks it: %v", final)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,496 +1,118 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
recoverySavePlan = (*recovery.Store).SavePlan
|
||||
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
|
||||
)
|
||||
const recoveryUnsupportedMessage = "dws recovery 不再支持:失败快照恢复计划/执行/闭环已下线,请改用 doctor / schema / 对应业务命令排查。"
|
||||
|
||||
func newRecoveryCommand(flags *GlobalFlags) *cobra.Command {
|
||||
var (
|
||||
planUseLast bool
|
||||
planEventID string
|
||||
executeUseLast bool
|
||||
executeEventID string
|
||||
finalEventID string
|
||||
finalOutcome string
|
||||
executionFile string
|
||||
)
|
||||
|
||||
runtime := newRecoveryRuntime(flags)
|
||||
type recoveryCompatNotice struct {
|
||||
Status string `json:"status"`
|
||||
Command string `json:"command"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// newRecoveryCommand keeps a visible Deprecated compatibility surface for
|
||||
// historical argv and Interface Integrity. Behavior is unchanged: every leaf
|
||||
// returns an explicit unsupported notice. Skills must not teach this path.
|
||||
func newRecoveryCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "recovery",
|
||||
Short: "错误恢复辅助命令",
|
||||
Long: "读取失败快照,生成恢复分析,并回写恢复结果。",
|
||||
Short: "不再支持:错误恢复辅助命令(兼容入口)",
|
||||
Long: "此命令组仅为历史 argv 兼容保留,不再读取失败快照或生成恢复计划。Skill / Agent 请勿引导此路径。",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
return printRecoveryUnsupported(cmd, "dws recovery")
|
||||
},
|
||||
}
|
||||
|
||||
planCmd := &cobra.Command{
|
||||
Use: "plan",
|
||||
Short: "基于失败快照生成恢复计划",
|
||||
Short: "不再支持:基于失败快照生成恢复计划",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
last, err := loadRecoverySnapshot(store, planUseLast, planEventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
planner := recovery.NewPlanner(runtime)
|
||||
plan := planner.PlanWithOptions(cmd.Context(), last.Context, recovery.PlanOptions{
|
||||
EventID: last.EventID,
|
||||
EnableDocSearch: true,
|
||||
})
|
||||
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
|
||||
if err := recoverySavePlan(store, last.EventID, plan); err != nil {
|
||||
return fmt.Errorf("保存恢复计划失败: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"event_id": last.EventID,
|
||||
"context": last.Context,
|
||||
"plan": plan,
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
|
||||
return printRecoveryUnsupported(cmd, "dws recovery plan")
|
||||
},
|
||||
}
|
||||
planCmd.Flags().BoolVar(&planUseLast, "last", false, "读取最近一次失败快照")
|
||||
planCmd.Flags().StringVar(&planEventID, "event-id", "", "按 event_id 读取失败快照")
|
||||
planCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
|
||||
planCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||
|
||||
executeCmd := &cobra.Command{
|
||||
Use: "execute",
|
||||
Short: "生成面向 Agent 的恢复分析包",
|
||||
Short: "不再支持:生成面向 Agent 的恢复分析包",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
last, err := loadRecoverySnapshot(store, executeUseLast, executeEventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
planner := recovery.NewPlanner(runtime)
|
||||
executor := recovery.NewExecutor(planner, runtime)
|
||||
bundle := executor.Execute(cmd.Context(), *last)
|
||||
if err := recoverySaveAnalysis(store, last.EventID, bundle.Plan, bundle); err != nil {
|
||||
return fmt.Errorf("保存恢复分析失败: %w", err)
|
||||
}
|
||||
|
||||
return output.WriteCommandPayload(cmd, bundle, output.FormatJSON)
|
||||
return printRecoveryUnsupported(cmd, "dws recovery execute")
|
||||
},
|
||||
}
|
||||
executeCmd.Flags().BoolVar(&executeUseLast, "last", false, "读取最近一次失败快照")
|
||||
executeCmd.Flags().StringVar(&executeEventID, "event-id", "", "按 event_id 读取失败快照")
|
||||
executeCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
|
||||
executeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||
|
||||
finalizeCmd := &cobra.Command{
|
||||
Use: "finalize",
|
||||
Short: "回写恢复闭环结果",
|
||||
Short: "不再支持:回写恢复闭环结果",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(finalEventID) == "" {
|
||||
return fmt.Errorf("必须提供 --event-id")
|
||||
}
|
||||
if strings.TrimSpace(finalOutcome) == "" {
|
||||
return fmt.Errorf("必须提供 --outcome")
|
||||
}
|
||||
switch finalOutcome {
|
||||
case "recovered", "failed", "handoff":
|
||||
default:
|
||||
return fmt.Errorf("--outcome 仅支持 recovered|failed|handoff")
|
||||
}
|
||||
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
var execution *recovery.RecoveryExecution
|
||||
if strings.TrimSpace(executionFile) != "" {
|
||||
loaded, err := loadRecoveryExecution(executionFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
execution = &loaded
|
||||
}
|
||||
if err := store.Finalize(finalEventID, finalOutcome, execution); err != nil {
|
||||
return fmt.Errorf("回写恢复结果失败: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"event_id": finalEventID,
|
||||
"outcome": finalOutcome,
|
||||
"success": true,
|
||||
}
|
||||
if execution != nil {
|
||||
payload["execution_recorded"] = true
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
|
||||
return printRecoveryUnsupported(cmd, "dws recovery finalize")
|
||||
},
|
||||
}
|
||||
finalizeCmd.Flags().StringVar(&finalEventID, "event-id", "", "恢复事件 ID")
|
||||
finalizeCmd.Flags().StringVar(&finalOutcome, "outcome", "", "恢复结果: recovered|failed|handoff")
|
||||
finalizeCmd.Flags().StringVar(&executionFile, "execution-file", "", "Agent 执行详情 JSON 文件")
|
||||
finalizeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||
finalizeCmd.Flags().String("outcome", "", "旧版兼容参数;recovery 不再支持")
|
||||
finalizeCmd.Flags().String("execution-file", "", "旧版兼容参数;recovery 不再支持")
|
||||
|
||||
cmd.AddCommand(planCmd, executeCmd, finalizeCmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func loadRecoverySnapshot(store *recovery.Store, useLast bool, eventID string) (*recovery.LastError, error) {
|
||||
if useLast && strings.TrimSpace(eventID) != "" {
|
||||
return nil, fmt.Errorf("--last 和 --event-id 不能同时使用")
|
||||
func printRecoveryUnsupported(cmd *cobra.Command, command string) error {
|
||||
notice := recoveryCompatNotice{
|
||||
Status: "unsupported",
|
||||
Command: command,
|
||||
Message: recoveryUnsupportedMessage,
|
||||
}
|
||||
switch {
|
||||
case useLast:
|
||||
last, err := store.LoadLastError()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取失败快照失败: %w", err)
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "", "json":
|
||||
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(notice); err != nil {
|
||||
return err
|
||||
}
|
||||
return last, nil
|
||||
case strings.TrimSpace(eventID) != "":
|
||||
last, err := store.LoadErrorByEvent(strings.TrimSpace(eventID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取失败快照失败: %w", err)
|
||||
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||
case "pretty":
|
||||
data, _ := json.MarshalIndent(notice, "", " ")
|
||||
if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
|
||||
return err
|
||||
}
|
||||
return last, nil
|
||||
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||
default:
|
||||
return nil, fmt.Errorf("必须通过 --last 或 --event-id 指定失败快照")
|
||||
}
|
||||
}
|
||||
|
||||
func loadRecoveryExecution(path string) (recovery.RecoveryExecution, error) {
|
||||
var execution recovery.RecoveryExecution
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return execution, fmt.Errorf("读取恢复执行详情失败: %w", err)
|
||||
}
|
||||
var payload recoveryExecutionPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
|
||||
}
|
||||
execution.Actions = append([]string(nil), payload.Actions...)
|
||||
if len(execution.Actions) == 0 && strings.TrimSpace(payload.Action) != "" {
|
||||
execution.Actions = []string{strings.TrimSpace(payload.Action)}
|
||||
}
|
||||
execution.Result = strings.TrimSpace(payload.Result)
|
||||
execution.ErrorSummary = strings.TrimSpace(payload.ErrorSummary)
|
||||
if execution.ErrorSummary == "" {
|
||||
execution.ErrorSummary = strings.TrimSpace(payload.Error)
|
||||
}
|
||||
|
||||
attempts, err := decodeRecoveryAttempts(payload.Attempts, execution.Actions, execution.Result, execution.ErrorSummary)
|
||||
if err != nil {
|
||||
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
|
||||
}
|
||||
if len(attempts) == 0 && payload.Attempt > 0 {
|
||||
attempts = legacyRecoveryAttempts(payload.Attempt, execution.Actions, execution.Result, execution.ErrorSummary)
|
||||
}
|
||||
execution.Attempts = attempts
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
type recoveryExecutionPayload struct {
|
||||
Action string `json:"action,omitempty"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
Attempts json.RawMessage `json:"attempts,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorSummary string `json:"error_summary,omitempty"`
|
||||
}
|
||||
|
||||
func decodeRecoveryAttempts(raw json.RawMessage, actions []string, result, errorSummary string) ([]recovery.RecoveryAttempt, error) {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
var attempts []recovery.RecoveryAttempt
|
||||
if err := json.Unmarshal(raw, &attempts); err != nil {
|
||||
return nil, err
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", notice.Command, notice.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
return attempts, nil
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := json.Unmarshal(raw, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return legacyRecoveryAttempts(count, actions, result, errorSummary), nil
|
||||
}
|
||||
|
||||
func legacyRecoveryAttempts(count int, actions []string, result, errorSummary string) []recovery.RecoveryAttempt {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
summary := strings.TrimSpace(strings.Join(actions, ", "))
|
||||
if summary == "" {
|
||||
summary = "legacy execution attempt"
|
||||
}
|
||||
attempts := make([]recovery.RecoveryAttempt, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
attempts = append(attempts, recovery.RecoveryAttempt{
|
||||
CommandSummary: summary,
|
||||
Result: result,
|
||||
ErrorSummary: errorSummary,
|
||||
Source: "legacy_execution_file",
|
||||
})
|
||||
}
|
||||
return attempts
|
||||
}
|
||||
|
||||
type recoveryRuntime struct {
|
||||
transport *transport.Client
|
||||
flags *GlobalFlags
|
||||
}
|
||||
|
||||
func newRecoveryRuntime(flags *GlobalFlags) *recoveryRuntime {
|
||||
var httpClient *http.Client
|
||||
if flags != nil && flags.Timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
|
||||
}
|
||||
client := transport.NewClient(httpClient)
|
||||
client.ExtraHeaders = resolveIdentityHeaders()
|
||||
return &recoveryRuntime{
|
||||
transport: client,
|
||||
flags: flags,
|
||||
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.RecoveryContext) (recovery.KnowledgeRetrieval, error) {
|
||||
const (
|
||||
searchPage = 1
|
||||
searchSize = 5
|
||||
)
|
||||
requestArgs := map[string]any{
|
||||
"keyword": query,
|
||||
"page": searchPage,
|
||||
"size": searchSize,
|
||||
}
|
||||
|
||||
retrieval := recovery.KnowledgeRetrieval{
|
||||
DocSearch: recovery.DocSearch{
|
||||
Provider: "open_platform_docs",
|
||||
Query: query,
|
||||
Page: searchPage,
|
||||
Size: searchSize,
|
||||
Status: "empty",
|
||||
Request: &recovery.ToolCallRecord{
|
||||
ServerID: "devdoc",
|
||||
ToolName: "search_open_platform_docs_rag",
|
||||
Arguments: cloneRecoveryArgs(requestArgs),
|
||||
},
|
||||
},
|
||||
}
|
||||
if r == nil || strings.TrimSpace(query) == "" {
|
||||
retrieval.DocSearch.Status = "skipped"
|
||||
return retrieval, nil
|
||||
}
|
||||
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
|
||||
if result != nil {
|
||||
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
|
||||
}
|
||||
if err != nil {
|
||||
retrieval.DocSearch.Status = "error"
|
||||
retrieval.DocSearch.Error = err.Error()
|
||||
return retrieval, err
|
||||
}
|
||||
|
||||
retrieval.DocSearch.Items = parseDocSearchItems(result)
|
||||
if len(retrieval.DocSearch.Items) > 0 {
|
||||
retrieval.DocSearch.Status = "success"
|
||||
retrieval.KBHits = rerankDocSearchHits(query, rc, retrieval.DocSearch.Items)
|
||||
}
|
||||
return retrieval, nil
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error) {
|
||||
if r == nil || r.transport == nil {
|
||||
return nil, fmt.Errorf("recovery runtime not initialized")
|
||||
}
|
||||
endpoint, err := r.resolveEndpoint(ctx, serverID, toolName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags))
|
||||
if err != nil {
|
||||
return nil, tokenResolutionError(err)
|
||||
}
|
||||
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
|
||||
result, err := tc.CallTool(ctx, endpoint, toolName, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.IsError {
|
||||
return &result, apperrors.NewAPI(
|
||||
extractMCPErrorMessage(result),
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("mcp_tool_error"),
|
||||
apperrors.WithServerKey(serverID),
|
||||
)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) resolveEndpoint(_ context.Context, productID, toolName string) (string, error) {
|
||||
if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok {
|
||||
return endpoint, nil
|
||||
}
|
||||
return "", endpointNotResolvedError(productID, toolName, "no dynamic endpoint registered for product or tool")
|
||||
}
|
||||
|
||||
func recoveryRuntimeToken(flags *GlobalFlags) string {
|
||||
if flags == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(flags.Token)
|
||||
}
|
||||
|
||||
func toRecoveryToolResponse(result *transport.ToolCallResult) *recovery.ToolResponse {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
response := &recovery.ToolResponse{IsError: result.IsError}
|
||||
if len(result.Blocks) > 0 {
|
||||
response.Content = make([]recovery.ToolResponseBlock, 0, len(result.Blocks))
|
||||
for _, block := range result.Blocks {
|
||||
response.Content = append(response.Content, recovery.ToolResponseBlock{
|
||||
Type: block.Type,
|
||||
Text: block.Text,
|
||||
})
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func parseDocSearchItems(result *transport.ToolCallResult) []recovery.DocSearchItem {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if items := parseDocSearchItemsFromMap(result.Content); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
for _, block := range result.Blocks {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(block.Text), &payload); err == nil {
|
||||
if items := parseDocSearchItemsFromMap(payload); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDocSearchItemsFromMap(payload map[string]any) []recovery.DocSearchItem {
|
||||
if len(payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
if items := toDocSearchItems(payload["items"]); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
if data, ok := payload["data"].(map[string]any); ok {
|
||||
if items := toDocSearchItems(data["items"]); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
if result, ok := payload["result"].(map[string]any); ok {
|
||||
if items := toDocSearchItems(result["items"]); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toDocSearchItems(raw any) []recovery.DocSearchItem {
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
items := make([]recovery.DocSearchItem, 0, len(list))
|
||||
for _, entry := range list {
|
||||
object, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := recovery.DocSearchItem{}
|
||||
if title, ok := object["title"].(string); ok {
|
||||
item.Title = title
|
||||
}
|
||||
if url, ok := object["url"].(string); ok {
|
||||
item.URL = url
|
||||
}
|
||||
if desc, ok := object["desc"].(string); ok {
|
||||
item.Desc = desc
|
||||
}
|
||||
if item.Title != "" || item.URL != "" || item.Desc != "" {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func rerankDocSearchHits(query string, rc recovery.RecoveryContext, items []recovery.DocSearchItem) []recovery.KBHit {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
keywords := strings.Fields(strings.ToLower(strings.TrimSpace(query)))
|
||||
type scoredHit struct {
|
||||
hit recovery.KBHit
|
||||
score float64
|
||||
}
|
||||
scored := make([]scoredHit, 0, len(items))
|
||||
for _, item := range items {
|
||||
text := strings.ToLower(strings.Join(append([]string{
|
||||
item.Title,
|
||||
item.URL,
|
||||
item.Desc,
|
||||
rc.ToolName,
|
||||
}, rc.CommandPath...), " "))
|
||||
score := 0.0
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
score += 1
|
||||
}
|
||||
}
|
||||
scored = append(scored, scoredHit{
|
||||
hit: recovery.KBHit{
|
||||
Source: "open_platform_docs",
|
||||
Title: item.Title,
|
||||
URL: item.URL,
|
||||
Snippet: item.Desc,
|
||||
Score: score,
|
||||
},
|
||||
score: score,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(scored, func(i, j int) bool {
|
||||
return scored[i].score > scored[j].score
|
||||
})
|
||||
limit := len(scored)
|
||||
if limit > 3 {
|
||||
limit = 3
|
||||
}
|
||||
hits := make([]recovery.KBHit, 0, limit)
|
||||
for _, item := range scored[:limit] {
|
||||
hits = append(hits, item.hit)
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryDeprecatedUnsupportedShim(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
group := mustFindCommand(t, root, "recovery")
|
||||
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
|
||||
t.Fatalf("recovery group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
|
||||
}
|
||||
|
||||
for _, leaf := range []string{"plan", "execute", "finalize"} {
|
||||
cmd := mustFindCommand(t, root, "recovery", leaf)
|
||||
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
|
||||
t.Fatalf("recovery %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
|
||||
}
|
||||
wantFlags := []string{"event-id"}
|
||||
switch leaf {
|
||||
case "plan", "execute":
|
||||
wantFlags = append(wantFlags, "last")
|
||||
case "finalize":
|
||||
wantFlags = append(wantFlags, "outcome", "execution-file")
|
||||
}
|
||||
for _, flag := range wantFlags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("recovery %s missing --%s", leaf, flag)
|
||||
}
|
||||
}
|
||||
for _, child := range newRecoveryCommand().Commands() {
|
||||
if child.Name() != leaf {
|
||||
continue
|
||||
}
|
||||
if err := child.RunE(child, nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
|
||||
t.Fatalf("recovery %s RunE = %v, want 不再支持", leaf, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"", "json", "pretty", "table"} {
|
||||
var out bytes.Buffer
|
||||
cmd := &cobra.Command{Use: "dws"}
|
||||
cmd.PersistentFlags().String("format", format, "")
|
||||
cmd.SetOut(&out)
|
||||
sub := &cobra.Command{Use: "recovery"}
|
||||
cmd.AddCommand(sub)
|
||||
err := printRecoveryUnsupported(sub, "dws recovery plan")
|
||||
if err == nil {
|
||||
t.Fatalf("format=%q returned nil error", format)
|
||||
}
|
||||
typed, ok := err.(*apperrors.Error)
|
||||
if !ok || typed.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("format=%q error = %T/%v, want validation Error", format, err, err)
|
||||
}
|
||||
got := out.String() + err.Error()
|
||||
if !strings.Contains(got, "不再支持") {
|
||||
t.Fatalf("format=%q missing 不再支持:\n%s", format, got)
|
||||
}
|
||||
if format == "" || format == "json" || format == "pretty" {
|
||||
if !strings.Contains(got, `"status":"unsupported"`) && !strings.Contains(got, `"status": "unsupported"`) {
|
||||
t.Fatalf("format=%q missing unsupported JSON status:\n%s", format, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"json", "pretty", "table"} {
|
||||
cmd := &cobra.Command{Use: "dws"}
|
||||
cmd.PersistentFlags().String("format", format, "")
|
||||
cmd.SetOut(failWriter{})
|
||||
sub := &cobra.Command{Use: "recovery"}
|
||||
cmd.AddCommand(sub)
|
||||
if err := printRecoveryUnsupported(sub, "dws recovery plan"); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := newRecoveryCommand().RunE(newRecoveryCommand(), nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
|
||||
t.Fatalf("recovery parent RunE = %v, want 不再支持", err)
|
||||
}
|
||||
captureRuntimeFailure(executor.Invocation{}, nil, nil)
|
||||
}
|
||||
|
||||
type failWriter struct{}
|
||||
|
||||
func (failWriter) Write([]byte) (int, error) {
|
||||
return 0, errWriteFailed
|
||||
}
|
||||
|
||||
var errWriteFailed = errors.New("write failed")
|
||||
@@ -1,151 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
|
||||
cmd := newRecoveryCommand(&GlobalFlags{})
|
||||
out := &strings.Builder{}
|
||||
cmd.SetOut(out)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(cmdArgs)
|
||||
err := cmd.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryCommandRemainingCoverage(t *testing.T) {
|
||||
oldSavePlan, oldSaveAnalysis := recoverySavePlan, recoverySaveAnalysis
|
||||
t.Cleanup(func() {
|
||||
recoverySavePlan, recoverySaveAnalysis = oldSavePlan, oldSaveAnalysis
|
||||
})
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
store := recovery.NewStore(configDir)
|
||||
last, err := store.Capture(recovery.RecoveryContext{ServerID: "doc", ToolName: "get"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recoverySavePlan = func(*recovery.Store, string, recovery.RecoveryPlan) error { return errors.New("save plan") }
|
||||
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
|
||||
t.Fatal("injected plan save failure succeeded")
|
||||
}
|
||||
recoverySavePlan = oldSavePlan
|
||||
recoverySaveAnalysis = func(*recovery.Store, string, recovery.RecoveryPlan, recovery.RecoveryBundle) error {
|
||||
return errors.New("save analysis")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
|
||||
t.Fatal("injected analysis save failure succeeded")
|
||||
}
|
||||
recoverySaveAnalysis = oldSaveAnalysis
|
||||
|
||||
parent := newRecoveryCommand(nil)
|
||||
parent.SetOut(io.Discard)
|
||||
if err := parent.RunE(parent, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("plan", "--last"); err != nil || !strings.Contains(out, last.EventID) {
|
||||
t.Fatalf("recovery plan = %q, %v", out, err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("execute", "--event-id", last.EventID); err != nil || out == "" {
|
||||
t.Fatalf("recovery execute = %q, %v", out, err)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"finalize"},
|
||||
{"finalize", "--event-id", last.EventID},
|
||||
{"finalize", "--event-id", last.EventID, "--outcome", "unknown"},
|
||||
{"finalize", "--event-id", last.EventID, "--outcome", "recovered", "--execution-file", "missing"},
|
||||
} {
|
||||
if _, err := recoveryCoverageRun(args...); err == nil {
|
||||
t.Fatalf("recovery finalize %#v should fail", args)
|
||||
}
|
||||
}
|
||||
executionPath := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(executionPath, []byte(`{"action":"retry","attempt":1,"result":"ok"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "handoff", "--execution-file", executionPath); err != nil || !strings.Contains(out, "execution_recorded") {
|
||||
t.Fatalf("recovery finalize = %q, %v", out, err)
|
||||
}
|
||||
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "failed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := loadRecoverySnapshot(store, true, last.EventID); err == nil {
|
||||
t.Fatal("conflicting snapshot selectors should fail")
|
||||
}
|
||||
if _, err := loadRecoverySnapshot(store, false, "missing"); err == nil {
|
||||
t.Fatal("missing event snapshot should fail")
|
||||
}
|
||||
if _, err := loadRecoverySnapshot(store, false, ""); err == nil {
|
||||
t.Fatal("empty snapshot selector should fail")
|
||||
}
|
||||
missingStore := recovery.NewStore(t.TempDir())
|
||||
if _, err := loadRecoverySnapshot(missingStore, true, ""); err == nil {
|
||||
t.Fatal("missing latest snapshot should fail")
|
||||
}
|
||||
|
||||
eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl")
|
||||
if err := os.Remove(eventsPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(eventsPath, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
|
||||
t.Fatal("recovery plan save should fail")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
|
||||
t.Fatal("recovery analysis save should fail")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "recovered"); err == nil {
|
||||
t.Fatal("recovery finalization save should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *testing.T) {
|
||||
t.Setenv("DINGTALK_DEVDOC_MCP_URL", "http://127.0.0.1:1")
|
||||
path := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(path, []byte(`{"attempts":{}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRecoveryExecution(path); err == nil {
|
||||
t.Fatal("invalid attempts should fail")
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts([]byte(`[{}`), nil, "", ""); err == nil {
|
||||
t.Fatal("invalid attempt array should fail")
|
||||
}
|
||||
|
||||
SetDynamicServers(nil)
|
||||
runtime := &recoveryRuntime{
|
||||
transport: transport.NewClient(nil),
|
||||
flags: &GlobalFlags{Token: "token"},
|
||||
}
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
|
||||
t.Fatalf("direct resolution error = %v", err)
|
||||
}
|
||||
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
|
||||
t.Fatalf("search error = %#v, %v", got, err)
|
||||
}
|
||||
if got := parseDocSearchItems(&transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "not-json"}}}); got != nil {
|
||||
t.Fatalf("empty doc search items = %#v", got)
|
||||
}
|
||||
for _, payload := range []map[string]any{
|
||||
{"data": map[string]any{}},
|
||||
{"result": map[string]any{}},
|
||||
} {
|
||||
if got := parseDocSearchItemsFromMap(payload); got != nil {
|
||||
t.Fatalf("empty nested doc search items = %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
)
|
||||
|
||||
func captureRuntimeFailure(invocation executor.Invocation, rawErr, wrappedErr error) {
|
||||
if rawErr == nil && wrappedErr == nil {
|
||||
return
|
||||
}
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
if store == nil || !store.Enabled() {
|
||||
return
|
||||
}
|
||||
input := recovery.CaptureInput{
|
||||
CommandPath: runtimeCommandPath(invocation),
|
||||
ServerID: strings.TrimSpace(invocation.CanonicalProduct),
|
||||
ToolName: strings.TrimSpace(invocation.Tool),
|
||||
Args: cloneRecoveryArgs(invocation.Params),
|
||||
Argv: append([]string(nil), os.Args[1:]...),
|
||||
RawErr: rawErr,
|
||||
WrappedErr: wrappedErr,
|
||||
}
|
||||
_, _ = store.Capture(recovery.BuildContext(input), recovery.BuildReplay(input))
|
||||
}
|
||||
|
||||
func runtimeCommandPath(invocation executor.Invocation) []string {
|
||||
if path := currentCommandPath(); len(path) > 0 {
|
||||
return path
|
||||
}
|
||||
if legacy := strings.Fields(strings.TrimSpace(invocation.LegacyPath)); len(legacy) > 0 {
|
||||
return legacy
|
||||
}
|
||||
if product := strings.TrimSpace(invocation.CanonicalProduct); product != "" {
|
||||
if tool := strings.TrimSpace(invocation.Tool); tool != "" {
|
||||
return []string{product, tool}
|
||||
}
|
||||
return []string{product}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentCommandPath() []string {
|
||||
boolFlags := map[string]struct{}{
|
||||
"--verbose": {},
|
||||
"-v": {},
|
||||
"--debug": {},
|
||||
"--mock": {},
|
||||
"--dry-run": {},
|
||||
"--yes": {},
|
||||
"-y": {},
|
||||
"--help": {},
|
||||
"-h": {},
|
||||
"--json": {},
|
||||
}
|
||||
path := make([]string, 0, len(os.Args))
|
||||
skipNext := false
|
||||
for _, arg := range os.Args[1:] {
|
||||
if skipNext {
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
if arg == "--" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
if strings.Contains(arg, "=") {
|
||||
continue
|
||||
}
|
||||
if _, ok := boolFlags[arg]; ok {
|
||||
continue
|
||||
}
|
||||
skipNext = true
|
||||
continue
|
||||
}
|
||||
path = append(path, arg)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func cloneRecoveryArgs(args map[string]any) map[string]any {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
// captureRuntimeFailure previously persisted a recovery snapshot for
|
||||
// `dws recovery`. The recovery package is gone; keep a no-op seam so runner
|
||||
// failure paths stay stable while the visible Deprecated shim remains.
|
||||
func captureRuntimeFailure(_ executor.Invocation, _, _ error) {}
|
||||
|
||||
+3
-12
@@ -39,7 +39,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
@@ -51,15 +50,11 @@ import (
|
||||
|
||||
type outputFileContextKey struct{}
|
||||
|
||||
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
|
||||
|
||||
var (
|
||||
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
|
||||
rootExecuteCommand = (*cobra.Command).ExecuteC
|
||||
rootNewRootCommandWithEngine = NewRootCommandWithEngine
|
||||
rootRunPreParse = pipeline.RunPreParse
|
||||
rootLatestRecoveryCapture = recovery.LatestCapture
|
||||
rootResetRecoveryState = recovery.ResetRuntimeState
|
||||
rootStopAllStdioClients = StopAllStdioClients
|
||||
rootLoadPlugins = loadPlugins
|
||||
rootMkdirAll = os.MkdirAll
|
||||
@@ -107,7 +102,6 @@ func Execute() (exitCode int) {
|
||||
ctx = WithTimingCollector(ctx, timing)
|
||||
|
||||
initStart := time.Now()
|
||||
rootResetRecoveryState()
|
||||
engine := newPipelineEngine()
|
||||
root := rootNewRootCommandWithEngine(ctx, engine)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
@@ -133,9 +127,6 @@ func Execute() (exitCode int) {
|
||||
_, _ = fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||
if last := rootLatestRecoveryCapture(); last != nil && last.EventID != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
|
||||
}
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
return 0
|
||||
@@ -477,10 +468,10 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
newCatalogCommand(),
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newRecoveryCommand(),
|
||||
newEventCommand(),
|
||||
newAuditCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(flags),
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
newPluginCommand(),
|
||||
@@ -724,8 +715,8 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
var builtinCommandNames = map[string]bool{
|
||||
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
|
||||
"doctor": true, "event": true, "completion": true, "skill": true,
|
||||
"plugin": true, "profile": true, "version": true, "help": true,
|
||||
"recovery": true, "schema": true, "mcp": true, "upgrade": true,
|
||||
"plugin": true, "profile": true, "recovery": true, "version": true, "help": true,
|
||||
"schema": true, "mcp": true, "upgrade": true,
|
||||
}
|
||||
|
||||
// commandNameSet returns a new set containing every name in base plus extras.
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -24,8 +23,6 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldLatest := rootLatestRecoveryCapture
|
||||
oldReset := rootResetRecoveryState
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
@@ -33,20 +30,16 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootLatestRecoveryCapture = oldLatest
|
||||
rootResetRecoveryState = oldReset
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootResetRecoveryState = func() {}
|
||||
rootStopAllStdioClients = func() {}
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
}
|
||||
rootLatestRecoveryCapture = func() *recovery.LastError { return nil }
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("successful Execute code = %d", code)
|
||||
@@ -59,7 +52,6 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
|
||||
wantErr := errors.New("unknown command missing")
|
||||
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("failed Execute returned zero")
|
||||
|
||||
@@ -72,6 +72,39 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
|
||||
|
||||
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
for _, path := range []string{
|
||||
"chat send",
|
||||
"chat history",
|
||||
"im send",
|
||||
"im history",
|
||||
} {
|
||||
command, remaining, err := root.Find(strings.Fields(path))
|
||||
if err != nil {
|
||||
t.Fatalf("find %s: %v", path, err)
|
||||
}
|
||||
if len(remaining) != 0 || !command.Hidden || !command.Runnable() {
|
||||
t.Fatalf("%s compatibility contract: remaining=%v hidden=%v runnable=%v", path, remaining, command.Hidden, command.Runnable())
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
hint string
|
||||
}{
|
||||
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command := NewRootCommand()
|
||||
command.SilenceErrors = true
|
||||
command.SilenceUsage = true
|
||||
command.SetArgs(tc.args)
|
||||
err := command.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
|
||||
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
|
||||
}
|
||||
}
|
||||
|
||||
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
|
||||
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
|
||||
if listDirect.Flags().Lookup(flag) == nil {
|
||||
@@ -386,20 +419,14 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRefreshCompatibilityStub(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
|
||||
func TestCacheCommandDeprecatedCompatStub(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
cmd, _, err := root.Find([]string{"cache", "refresh"})
|
||||
if err != nil || cmd == nil || cmd == root {
|
||||
t.Fatalf("dws cache refresh compatibility stub missing: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "服务发现已下线"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
|
||||
}
|
||||
if cmd.Hidden || cmd.Deprecated == "" {
|
||||
t.Fatalf("cache refresh must be visible Deprecated: hidden=%v deprecated=%q", cmd.Hidden, cmd.Deprecated)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 266
|
||||
publicShortcutCount = 294
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 267
|
||||
schemaPublishedShortcutCount = 295
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 266
|
||||
publiclyDeliveredShortcutCount = 294
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -194,17 +194,9 @@ func assertDeliveryShortcutSafetyAndInterface(
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
risk := declared.Risk
|
||||
if risk == "" {
|
||||
risk = shortcut.RiskRead
|
||||
}
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent"
|
||||
switch risk {
|
||||
case shortcut.RiskWrite:
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown"
|
||||
case shortcut.RiskHighWrite:
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown"
|
||||
}
|
||||
safety := shortcut.EffectiveSafety(declared)
|
||||
wantEffect, wantRisk := safety.Effect, safety.Risk
|
||||
wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency
|
||||
for field, want := range map[string]string{
|
||||
"effect": wantEffect,
|
||||
"risk": wantRisk,
|
||||
@@ -234,6 +226,15 @@ func assertDeliveryShortcutParameters(
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Hidden {
|
||||
publicFlags = append(publicFlags, flag)
|
||||
if flag.AliasesVisible {
|
||||
for _, alias := range flag.Aliases {
|
||||
aliasFlag := flag
|
||||
aliasFlag.Name = alias
|
||||
aliasFlag.Default = ""
|
||||
aliasFlag.Aliases = nil
|
||||
publicFlags = append(publicFlags, aliasFlag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if got, want := len(parameters), len(publicFlags); got != want {
|
||||
@@ -299,6 +300,13 @@ func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
|
||||
if flag.Name == flagName && flag.Required {
|
||||
return true
|
||||
}
|
||||
if flag.Required && flag.AliasesVisible {
|
||||
for _, alias := range flag.Aliases {
|
||||
if alias == flagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public := make(map[string]bool, len(declared.Flags))
|
||||
for _, flag := range declared.Flags {
|
||||
|
||||
@@ -75,10 +75,8 @@ func newSkillSetupCommand() *cobra.Command {
|
||||
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
|
||||
|
||||
支持两种模式:
|
||||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||||
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
|
||||
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
|
||||
生产前请评估,问题请提 issue 反馈
|
||||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||||
multi 多 skill—— 按产品拆 N 个独立 skill
|
||||
|
||||
multi 模式支持按产品挑选:
|
||||
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
|
||||
@@ -150,7 +148,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
if filterErr != nil {
|
||||
return filterErr
|
||||
}
|
||||
// dws-shared carries the global rules every product skill declares as a
|
||||
// dingtalk-shared carries the global rules every product skill declares as a
|
||||
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
|
||||
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
||||
}
|
||||
@@ -177,8 +175,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
fmt.Fprintln(out, "已取消。")
|
||||
return nil
|
||||
}
|
||||
} else if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
|
||||
}
|
||||
|
||||
var installed, skipped int
|
||||
@@ -205,8 +201,8 @@ const multiSkillPrefix = "dingtalk-"
|
||||
// multiSharedSkill is the shared, non-product skill that every per-product
|
||||
// skill declares as a PREREQUISITE. It must always be installed in multi mode
|
||||
// regardless of --skill / --exclude, otherwise the product skills reference a
|
||||
// dws-shared that was never installed.
|
||||
const multiSharedSkill = "dws-shared"
|
||||
// dingtalk-shared that was never installed.
|
||||
const multiSharedSkill = "dingtalk-shared"
|
||||
|
||||
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
|
||||
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
|
||||
@@ -365,10 +361,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("选择 dws skill 安装模式").
|
||||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
|
||||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分的独立 skill").
|
||||
Options(
|
||||
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
|
||||
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
|
||||
huh.NewOption("multi — 多 skill(按产品拆分)", skillSetupModeMulti),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
@@ -526,15 +522,6 @@ func detectExistingAgentHomes(home, mode string) []string {
|
||||
}
|
||||
|
||||
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
|
||||
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
|
||||
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
|
||||
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
|
||||
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
|
||||
fmt.Fprintln(out, " 稳定版请用 --mode mono")
|
||||
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
|
||||
}
|
||||
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
|
||||
// contains both the shared routing skill and the PAT product skill. Structured
|
||||
// contains both the shared routing skill and misc (including folded PAT docs). Structured
|
||||
// Schema hints are build inputs and must not become a third installable mode.
|
||||
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||
@@ -71,9 +71,9 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
t.Fatalf("extracted dir %s is not a valid multi skill source root", dir)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
filepath.Join("dws-shared", "SKILL.md"),
|
||||
filepath.Join("dingtalk-pat", "SKILL.md"),
|
||||
filepath.Join("dingtalk-pat", "references", "pat.md"),
|
||||
filepath.Join("dingtalk-shared", "SKILL.md"),
|
||||
filepath.Join("dingtalk-misc", "SKILL.md"),
|
||||
filepath.Join("dingtalk-misc", "references", "pat.md"),
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
|
||||
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("empty multi source should fail")
|
||||
}
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dws-shared", "dingtalk-doc"}, nil }
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-shared", "dingtalk-doc"}, nil }
|
||||
skillSetupFilterMulti = func([]string, []string, []string) ([]string, error) { return nil, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
|
||||
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
|
||||
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
|
||||
Long: `查看当前可运行命令的 Schema 元数据。
|
||||
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。--all 输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)。--compact 去除 provenance / debug 字段,仅保留 Agent 选参所需信息(适合 Agent 上下文)。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束和安全语义。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -113,7 +113,7 @@ func NewSchemaCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
|
||||
cmd.Flags().Bool("compact", false, "去除 provenance/debug 字段,仅保留 Agent 选参所需信息")
|
||||
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束和安全语义")
|
||||
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -24,39 +24,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalMCPMetadataLookup(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "reply"}
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
cmd.Flags().String("text", "", "text")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "text", "text", "string", false)
|
||||
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "reply_personal_message", CanonicalPath: "chat.reply_personal_message",
|
||||
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeMCP,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
|
||||
},
|
||||
})
|
||||
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "chat", ToolName: "reply_personal_message", Command: cmd,
|
||||
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
|
||||
}
|
||||
metadata := runtimeSchemaMetadataSources{
|
||||
MCP: embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{
|
||||
"text": {Type: "string"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
if _, err := runtimeToolSpecFromContractFinal(entry, mustFinal(t, cmd), metadata); err != nil {
|
||||
t.Fatalf("runtimeToolSpecFromContractFinal with MCP metadata = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
@@ -318,7 +285,7 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalSafetyAnnotationFa
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalParameterResolutionError(t *testing.T) {
|
||||
oldParameters := resolveRuntimeParameters
|
||||
t.Cleanup(func() { resolveRuntimeParameters = oldParameters })
|
||||
resolveRuntimeParameters = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
resolveRuntimeParameters = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return nil, errors.New("parameters failed")
|
||||
}
|
||||
entry := runtimeSchemaEntry{
|
||||
|
||||
+85
-184
@@ -89,9 +89,9 @@ func RegisterRuntimeSchemaConstraints(canonicalPath string, constraints RuntimeS
|
||||
}
|
||||
|
||||
// emptyPinnedMCPMetadata returns the retired pin shape with no tools.
|
||||
// schema_mcp_metadata.json is deleted; production assembly does not embed or
|
||||
// load a pinned MCP snapshot. Test fixtures may still inject non-empty maps
|
||||
// through schemaRegistryForTestWithMetadata.
|
||||
// schema_mcp_metadata.json is deleted; Schema parameter assembly never loads
|
||||
// or ranks MCP pin candidates. Optional Interface-registry validators may
|
||||
// still pass this empty shape when they only need ContractFinal self-checks.
|
||||
func emptyPinnedMCPMetadata() embeddedMCPMetadata {
|
||||
return embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
|
||||
}
|
||||
@@ -217,62 +217,6 @@ func collectRuntimeSchemaEntriesFromBound(bound BoundCommandRegistry) ([]runtime
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func pinnedMCPMetadataForEntryFrom(entry runtimeSchemaEntry, agentMetadata agentMetadata, mcpMetadata embeddedMCPMetadata) (embeddedMCPToolMetadata, bool) {
|
||||
// Optional test/diagnostic lookup only. Production mcpMetadata is empty;
|
||||
// Contract/ParamDecl own interface facts. When a non-empty fixture is
|
||||
// injected, ContractFinal Interface.Ref remaps CLI canonical names onto
|
||||
// fixture keys (e.g. reply_personal_message → chat.send_personal_message).
|
||||
if len(mcpMetadata.Tools) == 0 {
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
if entry.Command != nil {
|
||||
if final, ok := RuntimeContractFinal(entry.Command); ok && final.Interface != nil && final.Interface.Ref != nil {
|
||||
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, final.Interface.Ref.ProductID, final.Interface.Ref.RPCName); found {
|
||||
return metadata, true
|
||||
}
|
||||
}
|
||||
}
|
||||
paths := []string{
|
||||
entry.PrimaryCLIPath,
|
||||
entry.CLIPath,
|
||||
entry.ProductID + "." + entry.ToolName,
|
||||
}
|
||||
paths = append(paths, entry.Aliases...)
|
||||
if toolMetadata, ok := lookupAgentToolMetadataFrom(agentMetadata, paths...); ok && toolMetadata.InterfaceRef != nil {
|
||||
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, toolMetadata.InterfaceRef.ProductID, toolMetadata.InterfaceRef.RPCName); found {
|
||||
return metadata, true
|
||||
}
|
||||
}
|
||||
for _, key := range []string{
|
||||
entry.SourceProductID + "." + entry.ToolName,
|
||||
entry.ProductID + "." + entry.ToolName,
|
||||
} {
|
||||
key = strings.Trim(key, ".")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if meta, ok := mcpMetadata.Tools[key]; ok {
|
||||
return meta, true
|
||||
}
|
||||
}
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
|
||||
func mcpMetadataForInterfaceRef(mcpMetadata embeddedMCPMetadata, productID, rpcName string) (embeddedMCPToolMetadata, bool) {
|
||||
productID = strings.TrimSpace(productID)
|
||||
rpcName = strings.TrimSpace(rpcName)
|
||||
key := strings.Trim(productID+"."+rpcName, ".")
|
||||
if key == "" {
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
metadata, exists := mcpMetadata.Tools[key]
|
||||
if !exists {
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
metadata.InterfaceRef = &embeddedMCPInterfaceRef{ProductID: productID, RPCName: rpcName}
|
||||
return metadata, true
|
||||
}
|
||||
|
||||
func runtimeSchemaAnnotations(cmd *cobra.Command) (productID, toolName, source string) {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return "", "", ""
|
||||
@@ -322,7 +266,6 @@ const (
|
||||
runtimeSchemaRankDefault = 0
|
||||
runtimeSchemaRankDerived = 50
|
||||
runtimeSchemaRankInference = 100
|
||||
runtimeSchemaRankMCP = 400
|
||||
runtimeSchemaRankCobraHelp = 450
|
||||
runtimeSchemaRankCobraDefault = 600
|
||||
runtimeSchemaRankCobraContract = 610
|
||||
@@ -332,7 +275,7 @@ const (
|
||||
runtimeSchemaRankVersionedBinding = 650
|
||||
// ParamDecl.Property (dws.schema.property) outranks residual versioned
|
||||
// binding candidates (active bindings JSON is empty after Phase 2).
|
||||
// Mapping exclusions stay highest so an explicit "no MCP property" review
|
||||
// Mapping exclusions stay highest so an explicit "no RPC property" review
|
||||
// cannot be overridden by a leaf ParamDecl that still carries a Property.
|
||||
runtimeSchemaRankParamDeclProperty = 655
|
||||
runtimeSchemaRankMappingExclusion = 660
|
||||
@@ -340,7 +283,6 @@ const (
|
||||
runtimeSchemaPrecedenceDefault = "default"
|
||||
runtimeSchemaPrecedenceDerived = "derived_resolution"
|
||||
runtimeSchemaPrecedenceInference = "inference"
|
||||
runtimeSchemaPrecedenceMCP = "mcp_metadata"
|
||||
runtimeSchemaPrecedenceCobraHelp = "cobra_help"
|
||||
runtimeSchemaPrecedenceCobra = "cobra_contract"
|
||||
runtimeSchemaPrecedenceNativeAnnotation = "native_annotation"
|
||||
@@ -533,8 +475,6 @@ func runtimeSchemaSourcePriority(source string) (int, string) {
|
||||
return runtimeSchemaRankCobraDefault, runtimeSchemaPrecedenceCobra
|
||||
}
|
||||
return runtimeSchemaRankCobraContract, runtimeSchemaPrecedenceCobra
|
||||
case "mcp_metadata", "pinned_mcp_metadata":
|
||||
return runtimeSchemaRankMCP, runtimeSchemaPrecedenceMCP
|
||||
case "cobra_help":
|
||||
return runtimeSchemaRankCobraHelp, runtimeSchemaPrecedenceCobraHelp
|
||||
case "flag_name_inference", "usage_required_inference", "usage_format_inference":
|
||||
@@ -575,9 +515,9 @@ func runtimeSchemaParameterMappingKey(canonicalPath, flagName string) string {
|
||||
|
||||
// runtimeSchemaParameterMappingCandidates resolves the two reviewed,
|
||||
// versioned property-mapping inputs. An exclusion is an explicit statement
|
||||
// that the CLI parameter is not a direct MCP property: it therefore supplies
|
||||
// a present empty candidate (rather than allowing name inference to survive)
|
||||
// and keeps the review reason in provenance.
|
||||
// that the CLI parameter is not a direct RPC/interface property: it therefore
|
||||
// supplies a present empty candidate (rather than allowing name inference to
|
||||
// survive) and keeps the review reason in provenance.
|
||||
func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnapshot, canonicalPath, flagName string) (runtimeSchemaFieldCandidate, runtimeSchemaFieldCandidate, error) {
|
||||
binding := strings.TrimSpace(snapshot.Bindings[strings.TrimSpace(canonicalPath)][strings.TrimSpace(flagName)])
|
||||
bindingCandidate := runtimeSchemaStringCandidate(binding, "versioned_parameter_binding")
|
||||
@@ -605,34 +545,24 @@ func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnap
|
||||
type runtimeParameterFieldContext struct {
|
||||
flag *pflag.Flag
|
||||
metadata RuntimeSchemaParameterMetadata
|
||||
pinnedParam embeddedMCPParamMeta
|
||||
hasPinned bool
|
||||
paramType string
|
||||
constraints RuntimeSchemaConstraints
|
||||
property string
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) interfaceTypeCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagTypeAnnotation), "native_annotation"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Type, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates,
|
||||
runtimeSchemaStringCandidateAtRank(c.paramType, "cobra_flag_type", runtimeSchemaRankInference, "fallback"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) descriptionCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagDescriptionAnnotation), "native_annotation"),
|
||||
runtimeSchemaStringCandidate(c.flag.Usage, "cobra_usage"),
|
||||
runtimeSchemaCandidate("", true, "default"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Description, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate("", true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldCandidate {
|
||||
@@ -649,7 +579,7 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
|
||||
break
|
||||
}
|
||||
}
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
constraintRequired,
|
||||
runtimeSchemaCandidate(true, typedRequired, "typed_parameter_metadata"),
|
||||
runtimeSchemaAnnotatedBoolCandidate(c.flag, runtimeSchemaFlagMetadataRequiredAnnotation, "typed_parameter_metadata"),
|
||||
@@ -657,50 +587,36 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
|
||||
runtimeSchemaCandidate(true, runtimeFlagCobraHardRequired(c.flag), "cobra_hard_required"),
|
||||
runtimeSchemaCandidate(false, cobraDefaultOptional, "cobra_nonzero_default"),
|
||||
runtimeSchemaCandidate(usageRequired, usageRequired, "usage_required_inference"),
|
||||
runtimeSchemaCandidate(false, true, "default"),
|
||||
}
|
||||
if c.hasPinned && c.pinnedParam.Required != nil {
|
||||
candidates = append(candidates, runtimeSchemaCandidate(*c.pinnedParam.Required, true, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate(false, true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) requiredWhenCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(c.metadata.RequiredWhen[c.flag.Name], "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataRequiredWhenAnnotation), "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagRequiredWhenAnnotation), "native_annotation"),
|
||||
runtimeSchemaCandidate("", true, "default"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.RequiredWhen, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate("", true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) formatCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(c.metadata.Formats[c.flag.Name], "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataFormatAnnotation), "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, "x-cli-format"), "native_annotation"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Format, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates,
|
||||
runtimeSchemaStringCandidate(inferredRuntimeFlagFormat(c.flag), "usage_format_inference"),
|
||||
runtimeSchemaCandidate("", true, "default"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) enumCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaEnumCandidate(c.metadata.Enums[c.flag.Name], "typed_parameter_metadata"),
|
||||
runtimeSchemaEnumCandidate(runtimeFlagEnumAnnotation(c.flag, runtimeSchemaFlagMetadataEnumAnnotation), "typed_parameter_metadata"),
|
||||
runtimeSchemaEnumCandidate(runtimeFlagEnum(c.flag), "native_annotation"),
|
||||
runtimeSchemaCandidate([]string{}, true, "default"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaEnumCandidate(c.pinnedParam.Enum, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate([]string{}, true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCandidate {
|
||||
@@ -717,7 +633,8 @@ func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCa
|
||||
// source may intentionally raise or lower type/mapping/description semantics.
|
||||
// required is different: Cobra MarkFlagRequired is a hard floor that no
|
||||
// lower-priority source may demote (see resolveRequiredProjection).
|
||||
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
// MCP pin / mcp_metadata is not a candidate source.
|
||||
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
if cmd == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -758,18 +675,10 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
return
|
||||
}
|
||||
property, _ := propertyWinner.Value.(string)
|
||||
// pinnedParams remains for test fixtures that inject MCP-shaped maps;
|
||||
// production assembly always passes an empty map (pin retired).
|
||||
pinnedParam, hasPinnedParam := embeddedMCPParamMeta{}, false
|
||||
if len(pinnedParams) > 0 && strings.TrimSpace(property) != "" {
|
||||
pinnedParam, hasPinnedParam = lookupPinnedMCPParam(pinnedParams, property, flag.Name)
|
||||
}
|
||||
paramType := runtimeFlagCLIType(flag)
|
||||
fieldCtx := runtimeParameterFieldContext{
|
||||
flag: flag,
|
||||
metadata: metadata,
|
||||
pinnedParam: pinnedParam,
|
||||
hasPinned: hasPinnedParam,
|
||||
paramType: paramType,
|
||||
constraints: constraints,
|
||||
property: property,
|
||||
@@ -794,10 +703,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
return
|
||||
}
|
||||
description, _ := descriptionWinner.Value.(string)
|
||||
interfaceDescription := ""
|
||||
if hasPinnedParam {
|
||||
interfaceDescription = strings.TrimSpace(pinnedParam.Description)
|
||||
}
|
||||
|
||||
// Required uses field-level safe merge: higher sources may raise required, but
|
||||
// Cobra MarkFlagRequired cannot be projected away as optional.
|
||||
@@ -838,9 +743,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
runtimeSchemaCandidate(true, true, "cobra_hard_required"),
|
||||
)
|
||||
}
|
||||
if interfaceDescription != "" && interfaceDescription != description {
|
||||
parameter.InterfaceDescription = interfaceDescription
|
||||
}
|
||||
if interfaceType != "" && interfaceType != paramType {
|
||||
parameter.InterfaceType = interfaceType
|
||||
fieldProvenance["interface_type"] = runtimeSchemaFieldProvenance(interfaceTypeWinner)
|
||||
@@ -855,12 +757,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
if def := runtimeFlagDefault(flag); def != "" {
|
||||
parameter.Default = runtimeSchemaJSONString(def)
|
||||
}
|
||||
if hasPinnedParam {
|
||||
interfaceDefault := strings.TrimSpace(pinnedParam.Default)
|
||||
if interfaceDefault != "" && interfaceDefault != runtimeFlagDefault(flag) {
|
||||
parameter.InterfaceDefault = runtimeSchemaJSONString(interfaceDefault)
|
||||
}
|
||||
}
|
||||
formatWinner, ok := resolveField("format", fieldCtx.formatCandidates())
|
||||
if !ok {
|
||||
return
|
||||
@@ -991,19 +887,6 @@ func runtimeSchemaConstraintsEmpty(constraints RuntimeSchemaConstraints) bool {
|
||||
return runtimeannotate.ConstraintsEmpty(constraints)
|
||||
}
|
||||
|
||||
func lookupPinnedMCPParam(params map[string]embeddedMCPParamMeta, property, flagName string) (embeddedMCPParamMeta, bool) {
|
||||
if len(params) == 0 {
|
||||
return embeddedMCPParamMeta{}, false
|
||||
}
|
||||
if meta, ok := params[property]; ok {
|
||||
return meta, true
|
||||
}
|
||||
if meta, ok := params[flagName]; ok {
|
||||
return meta, true
|
||||
}
|
||||
return embeddedMCPParamMeta{}, false
|
||||
}
|
||||
|
||||
func isGenericPayloadFlag(flag *pflag.Flag) bool {
|
||||
if flag == nil {
|
||||
return false
|
||||
@@ -1162,69 +1045,87 @@ func strconvQuote(value string) string {
|
||||
|
||||
// ─── --compact mode ──────────────────────────────────────────────────────────
|
||||
|
||||
// schemaCompactStripKeys are top-level tool/product keys removed in --compact mode.
|
||||
var schemaCompactStripKeys = map[string]bool{
|
||||
// provenance / debug
|
||||
"agent_metadata_source": true,
|
||||
"agent_source_refs": true,
|
||||
"agent_summary_source": true,
|
||||
"effect_source": true,
|
||||
"metadata_source": true,
|
||||
"source": true,
|
||||
"agent_metadata": true,
|
||||
"field_provenance": true,
|
||||
"reviewed": true,
|
||||
// redundant with canonical_path / cli_path
|
||||
"name": true,
|
||||
"path": true,
|
||||
"cli_name": true,
|
||||
"primary_cli_path": true,
|
||||
"is_alias": true,
|
||||
"has_parameters": true,
|
||||
"parameter_count": true,
|
||||
"product_id": true,
|
||||
"display": true,
|
||||
"title": true,
|
||||
"group": true,
|
||||
"source_product_id": true,
|
||||
"aliases": true,
|
||||
"catalog_hash": true,
|
||||
"surface_hash": true,
|
||||
"workflow_refs": true,
|
||||
"prerequisites": true,
|
||||
"tips": true,
|
||||
"interface_ref": true,
|
||||
// schemaCompactPayloadKeys is the reviewed Agent-view allowlist. Keep this a
|
||||
// positive list: a new full/audit field must not silently expand routine Agent
|
||||
// context just because it was added to ToolSpec.ToPayload.
|
||||
var schemaCompactPayloadKeys = map[string]bool{
|
||||
// Navigation envelopes.
|
||||
"kind": true, "level": true, "count": true, "tool_count": true,
|
||||
"products": true, "product": true, "tools": true,
|
||||
"id": true, "schema_path": true, "runtime": true,
|
||||
// Leaf identity and execution semantics.
|
||||
"canonical_path": true, "cli_path": true,
|
||||
"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,
|
||||
"examples": true, "use_when": true, "avoid_when": true,
|
||||
}
|
||||
|
||||
// schemaCompactParamStripKeys are per-parameter keys removed in --compact mode.
|
||||
var schemaCompactParamStripKeys = map[string]bool{
|
||||
"interface_description": true,
|
||||
"interface_type": true,
|
||||
"property": true,
|
||||
"field_provenance": true,
|
||||
// schemaCompactParamKeys is the reviewed parameter allowlist for Agent command
|
||||
// construction. RPC mapping and provenance fields intentionally remain in the
|
||||
// full/audit view.
|
||||
var schemaCompactParamKeys = map[string]bool{
|
||||
"type": true, "description": true, "required": true,
|
||||
"cli_required": true, "required_when": true,
|
||||
"default": true, "interface_default": true, "example": true,
|
||||
"format": true, "enum": true,
|
||||
}
|
||||
|
||||
// stripSchemaPayloadCompact walks a schema payload map and removes provenance,
|
||||
// debug and redundant keys so that only agent-essential fields remain.
|
||||
// It operates recursively on nested maps, slices, and parameter objects.
|
||||
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
|
||||
// Agent-view allowlist. Structural product/tool children are projected
|
||||
// recursively; constraint, positional and dry-run values are already typed
|
||||
// contract data and are retained verbatim.
|
||||
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]any, len(payload))
|
||||
for k, v := range payload {
|
||||
if schemaCompactStripKeys[k] {
|
||||
if !schemaCompactPayloadKeys[k] {
|
||||
continue
|
||||
}
|
||||
if k == "parameters" {
|
||||
switch k {
|
||||
case "parameters":
|
||||
result[k] = stripSchemaParametersCompact(v)
|
||||
continue
|
||||
case "product":
|
||||
if product, ok := v.(map[string]any); ok {
|
||||
result[k] = stripSchemaPayloadCompact(product)
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
case "products", "tools":
|
||||
result[k] = stripSchemaPayloadCollectionCompact(v)
|
||||
default:
|
||||
result[k] = v
|
||||
}
|
||||
result[k] = stripSchemaValueCompact(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func stripSchemaPayloadCollectionCompact(value any) any {
|
||||
switch values := value.(type) {
|
||||
case []map[string]any:
|
||||
result := make([]map[string]any, len(values))
|
||||
for i, item := range values {
|
||||
result[i] = stripSchemaPayloadCompact(item)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]any, len(values))
|
||||
for i, item := range values {
|
||||
if payload, ok := item.(map[string]any); ok {
|
||||
result[i] = stripSchemaPayloadCompact(payload)
|
||||
} else {
|
||||
result[i] = item
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func stripSchemaParametersCompact(value any) any {
|
||||
parameters, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
@@ -1278,7 +1179,7 @@ func stripSchemaValueCompact(v any) any {
|
||||
func stripSchemaParamCompact(param map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(param))
|
||||
for k, v := range param {
|
||||
if schemaCompactParamStripKeys[k] {
|
||||
if !schemaCompactParamKeys[k] {
|
||||
continue
|
||||
}
|
||||
result[k] = v
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"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/corecmd/runtimeannotate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -63,39 +62,6 @@ func TestCrossPlatformCoverageCollectRuntimeSchemaEntriesErrorsAndOrdering(t *te
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeSchemaMetadataLookupEdges(t *testing.T) {
|
||||
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}); ok {
|
||||
t.Fatal("empty lookup unexpectedly matched")
|
||||
}
|
||||
|
||||
leaf := &cobra.Command{Use: "reply"}
|
||||
contractfinal.RegisterRuntimeContractFinal(leaf, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "sample", Name: "run", CanonicalPath: "sample.run",
|
||||
CLIPath: "sample run", PrimaryCLIPath: "sample run",
|
||||
},
|
||||
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeMCP,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(leaf) })
|
||||
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"clawType": {Type: "string"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{Command: leaf, ProductID: "chat", ToolName: "reply_personal_message"}, agentMetadata{}, mcp)
|
||||
if !ok || got.Parameters["clawType"].Type != "string" {
|
||||
t.Fatalf("ContractFinal Interface.Ref MCP remap = %#v ok=%v", got, ok)
|
||||
}
|
||||
if got.InterfaceRef == nil || got.InterfaceRef.RPCName != "send_personal_message" {
|
||||
t.Fatalf("InterfaceRef = %#v", got.InterfaceRef)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
value any
|
||||
want int
|
||||
@@ -155,13 +121,13 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
cmd.Flags().String("value", "", "value")
|
||||
flag := cmd.Flags().Lookup("value")
|
||||
|
||||
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
t.Fatalf("nil command specs = %#v, err = %v", specs, err)
|
||||
}
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{}, errors.New("load failed")
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
|
||||
t.Fatalf("binding load error = %v", err)
|
||||
}
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
@@ -179,7 +145,7 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
MappingExclusions: map[string]string{"sample.run --value": " "},
|
||||
}, nil
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
|
||||
t.Fatalf("mapping exclusion error = %v", err)
|
||||
}
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
@@ -194,22 +160,22 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
}
|
||||
return resolveRuntimeSchemaCandidate(field, candidates...)
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
|
||||
t.Fatalf("%s resolution error = %v", target, err)
|
||||
}
|
||||
}
|
||||
resolveRuntimeSchemaField = realResolver
|
||||
|
||||
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
t.Fatalf("empty specs = %#v, err = %v", specs, err)
|
||||
}
|
||||
if payload, err := runtimeCommandParameters(nil, "", nil, RuntimeSchemaConstraints{}); err != nil || payload != nil {
|
||||
if payload, err := runtimeCommandParameters(nil, "", RuntimeSchemaConstraints{}); err != nil || payload != nil {
|
||||
t.Fatalf("empty payload = %#v, err = %v", payload, err)
|
||||
}
|
||||
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return []ParameterSpec{{Name: "bad", Example: json.RawMessage("{")}}, nil
|
||||
})
|
||||
if _, err := runtimeCommandParameters(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
|
||||
if _, err := runtimeCommandParameters(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
|
||||
t.Fatalf("payload serialization error = %v", err)
|
||||
}
|
||||
|
||||
@@ -218,32 +184,21 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
t.Fatalf("required annotation = %v/%v", required, present)
|
||||
}
|
||||
|
||||
// Fixture MCP-shaped maps still participate when explicitly injected.
|
||||
// Binding snapshot still supplies reviewed property mappings without MCP pin.
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{
|
||||
Bindings: map[string]map[string]string{"sample.run": {"value": "clawType"}},
|
||||
}, nil
|
||||
})
|
||||
requiredTrue := true
|
||||
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", map[string]embeddedMCPParamMeta{
|
||||
"clawType": {
|
||||
Type: "string",
|
||||
Description: "fixture description",
|
||||
Required: &requiredTrue,
|
||||
Default: "fixture-default",
|
||||
},
|
||||
}, RuntimeSchemaConstraints{})
|
||||
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{})
|
||||
if err != nil {
|
||||
t.Fatalf("fixture pinned parameter specs error = %v", err)
|
||||
t.Fatalf("parameter specs error = %v", err)
|
||||
}
|
||||
if len(specs) != 1 || specs[0].Property != "clawType" || specs[0].InterfaceDescription != "fixture description" {
|
||||
t.Fatalf("fixture pinned parameter specs = %#v", specs)
|
||||
if len(specs) != 1 || specs[0].Property != "clawType" {
|
||||
t.Fatalf("parameter specs = %#v", specs)
|
||||
}
|
||||
if len(specs[0].InterfaceDefault) == 0 {
|
||||
t.Fatalf("fixture interface_default missing: %#v", specs[0])
|
||||
}
|
||||
if prov := specs[0].FieldProvenance["required"]; prov.Source == "" {
|
||||
t.Fatalf("fixture required provenance missing: %#v", specs[0].FieldProvenance)
|
||||
if prov := specs[0].FieldProvenance["property"]; prov.Source == "" {
|
||||
t.Fatalf("property provenance missing: %#v", specs[0].FieldProvenance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,9 +228,6 @@ func TestCrossPlatformCoverageRuntimeSchemaPureHelperEdges(t *testing.T) {
|
||||
if !reflect.DeepEqual(groups, [][]string{{"one"}}) {
|
||||
t.Fatalf("normalized groups = %#v", groups)
|
||||
}
|
||||
if meta, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"flag": {Type: "string"}}, "property", "flag"); !ok || meta.Type != "string" {
|
||||
t.Fatalf("flag fallback metadata = %#v/%v", meta, ok)
|
||||
}
|
||||
if isGenericPayloadFlag(nil) {
|
||||
t.Fatal("nil flag cannot be a generic payload")
|
||||
}
|
||||
@@ -322,4 +274,75 @@ func TestCrossPlatformCoverageSchemaCompactProjectionEdges(t *testing.T) {
|
||||
if _, exists := value["property"]; exists {
|
||||
t.Fatalf("compact parameter value = %#v", value)
|
||||
}
|
||||
// Non-parameter nested maps fall through to payload compacting.
|
||||
nested := stripSchemaValueCompact(map[string]any{"description": "keep", "provenance": "drop"}).(map[string]any)
|
||||
if nested["description"] != "keep" {
|
||||
t.Fatalf("nested non-param map = %#v", nested)
|
||||
}
|
||||
if _, exists := nested["provenance"]; exists {
|
||||
t.Fatalf("nested non-param provenance should drop: %#v", nested)
|
||||
}
|
||||
// Type-only maps still count as parameter objects.
|
||||
typedOnly := stripSchemaValueCompact(map[string]any{"type": "string", "property": "remote"}).(map[string]any)
|
||||
if _, exists := typedOnly["property"]; exists {
|
||||
t.Fatalf("type-only param value = %#v", typedOnly)
|
||||
}
|
||||
mapSlice := stripSchemaValueCompact([]map[string]any{{"description": "leaf", "provenance": "drop"}}).([]map[string]any)
|
||||
if len(mapSlice) != 1 || mapSlice[0]["description"] != "leaf" {
|
||||
t.Fatalf("value compact []map = %#v", mapSlice)
|
||||
}
|
||||
if _, exists := mapSlice[0]["provenance"]; exists {
|
||||
t.Fatalf("value compact []map provenance should drop: %#v", mapSlice)
|
||||
}
|
||||
anySlice := stripSchemaValueCompact([]any{map[string]any{"description": "leaf", "provenance": "drop"}, "raw"}).([]any)
|
||||
if len(anySlice) != 2 || anySlice[1] != "raw" {
|
||||
t.Fatalf("value compact []any = %#v", anySlice)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"description": "calendar",
|
||||
"provenance": map[string]any{"source": "drop"},
|
||||
"parameters": parameters,
|
||||
"product": map[string]any{"description": "calendar", "provenance": "drop"},
|
||||
"products": []map[string]any{
|
||||
{"description": "calendar", "provenance": "drop"},
|
||||
},
|
||||
"tools": []any{
|
||||
map[string]any{"description": "leaf", "provenance": "drop"},
|
||||
"skip-me",
|
||||
},
|
||||
"constraints": map[string]any{"require_one_of": []any{}},
|
||||
}
|
||||
stripped := stripSchemaPayloadCompact(payload)
|
||||
if stripped["description"] != "calendar" {
|
||||
t.Fatalf("compact description = %#v", stripped["description"])
|
||||
}
|
||||
if _, exists := stripped["provenance"]; exists {
|
||||
t.Fatalf("compact should drop provenance: %#v", stripped)
|
||||
}
|
||||
if product, ok := stripped["product"].(map[string]any); !ok || product["description"] != "calendar" {
|
||||
t.Fatalf("compact product = %#v", stripped["product"])
|
||||
}
|
||||
if _, exists := stripped["product"].(map[string]any)["provenance"]; exists {
|
||||
t.Fatalf("nested product provenance should drop: %#v", stripped["product"])
|
||||
}
|
||||
if products, ok := stripped["products"].([]map[string]any); !ok || len(products) != 1 || products[0]["description"] != "calendar" {
|
||||
t.Fatalf("compact products = %#v", stripped["products"])
|
||||
}
|
||||
if tools, ok := stripped["tools"].([]any); !ok || len(tools) != 2 {
|
||||
t.Fatalf("compact tools = %#v", stripped["tools"])
|
||||
}
|
||||
if tool, ok := stripped["tools"].([]any)[0].(map[string]any); !ok || tool["description"] != "leaf" {
|
||||
t.Fatalf("compact tools[0] = %#v", stripped["tools"].([]any)[0])
|
||||
}
|
||||
if stripped["tools"].([]any)[1] != "skip-me" {
|
||||
t.Fatalf("compact tools[1] = %#v", stripped["tools"].([]any)[1])
|
||||
}
|
||||
// Non-map product values are retained verbatim.
|
||||
if got := stripSchemaPayloadCompact(map[string]any{"product": "raw"}); got["product"] != "raw" {
|
||||
t.Fatalf("non-map product = %#v", got["product"])
|
||||
}
|
||||
if got := stripSchemaPayloadCollectionCompact("raw"); got != "raw" {
|
||||
t.Fatalf("non-collection compact = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,38 +30,29 @@ import (
|
||||
// remains a precise reviewed exception for such a capability whose runtime
|
||||
// preconditions cannot be exercised safely and deterministically in the
|
||||
// isolated test process.
|
||||
type AgentExampleMode string
|
||||
type AgentExampleMode = contract.ExampleDispositionMode
|
||||
|
||||
const (
|
||||
AgentExampleModeContract AgentExampleMode = "contract"
|
||||
AgentExampleModeDryRun AgentExampleMode = "dry_run"
|
||||
AgentExampleModeContractOnly AgentExampleMode = "contract_only"
|
||||
AgentExampleModeContract = contract.ExampleDispositionModeContract
|
||||
AgentExampleModeDryRun = contract.ExampleDispositionModeDryRun
|
||||
AgentExampleModeContractOnly = contract.ExampleDispositionModeContractOnly
|
||||
)
|
||||
|
||||
// AgentExampleReasonCode is a closed taxonomy for reviewed contract-only
|
||||
// exceptions to an explicit dry-run capability.
|
||||
type AgentExampleReasonCode string
|
||||
type AgentExampleReasonCode = contract.ExampleDispositionReasonCode
|
||||
|
||||
const (
|
||||
AgentExampleReasonLocalState AgentExampleReasonCode = "local_state"
|
||||
AgentExampleReasonStatefulPreflight AgentExampleReasonCode = "stateful_preflight"
|
||||
AgentExampleReasonLocalState = contract.ExampleDispositionReasonLocalState
|
||||
AgentExampleReasonStatefulPreflight = contract.ExampleDispositionReasonStatefulPreflight
|
||||
)
|
||||
|
||||
// AgentExampleDisposition narrows one exact example with an explicit
|
||||
// typed dry-run capability to contract-only. Index is a pointer so a missing
|
||||
// field cannot silently select example zero.
|
||||
//
|
||||
// Dispositions are authored as an in-test / future ContractFinal extension
|
||||
// surface; production ContractFinal Selection currently does not declare them,
|
||||
// so the delivery plan treats every example as default-typed (contract or
|
||||
// dry_run from ToolSpec.DryRun).
|
||||
type AgentExampleDisposition struct {
|
||||
Index *int `json:"index"`
|
||||
Mode AgentExampleMode `json:"mode"`
|
||||
ReasonCode AgentExampleReasonCode `json:"reason_code"`
|
||||
Reason string `json:"reason"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
}
|
||||
// Dispositions are authored on the owning ContractFinal Selection.
|
||||
type AgentExampleDisposition = contract.ExampleDisposition
|
||||
|
||||
// AgentExampleExecution is one resolved example and its effective test mode.
|
||||
type AgentExampleExecution struct {
|
||||
|
||||
@@ -189,25 +189,6 @@ func cloneFieldCandidates(source []contract.FieldCandidateProvenance) []contract
|
||||
return out
|
||||
}
|
||||
|
||||
func lookupAgentToolMetadataFrom(source agentMetadata, paths ...string) (agentToolMetadata, bool) {
|
||||
seen := map[string]bool{}
|
||||
for _, path := range paths {
|
||||
for _, candidate := range []string{
|
||||
strings.TrimSpace(path),
|
||||
strings.Join(splitSchemaPathTokens(path), " "),
|
||||
} {
|
||||
if candidate == "" || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
if metadata, ok := source.Tools[candidate]; ok {
|
||||
return metadata, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return agentToolMetadata{}, false
|
||||
}
|
||||
|
||||
// agentMetadataSummaryFromProducts publishes Catalog-level Agent coverage from
|
||||
// the assembled Schema surface (ContractFinal / ProductDecl). This keeps
|
||||
// runtime delivery and CI dumps hash-aligned without requiring build-time
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -94,9 +93,8 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
|
||||
// longer participates in assembly.
|
||||
root := buildRuntimeSchemaTestRoot()
|
||||
declareRuntimeSchemaTestRootDoc(t, root, nil)
|
||||
mcpFixture := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
|
||||
|
||||
leaf, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
|
||||
leaf, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeSchemaPayloadForTest(leaf): %v", err)
|
||||
}
|
||||
@@ -110,7 +108,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
|
||||
t.Fatalf("leaf examples = %#v", leaf["examples"])
|
||||
}
|
||||
|
||||
catalog, err := runtimeSchemaPayloadForTestWithMetadata(root, nil, emptyAgentMetadata(), mcpFixture)
|
||||
catalog, err := runtimeSchemaPayloadForTest(root, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeSchemaPayloadForTest(catalog): %v", err)
|
||||
}
|
||||
@@ -136,7 +134,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
|
||||
t.Fatalf("product summary must not include examples: %#v", tools[0])
|
||||
}
|
||||
|
||||
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), mcpFixture)
|
||||
registry, err := schemaRegistryForTest(root)
|
||||
if err != nil {
|
||||
t.Fatalf("schemaRegistryForTest(): %v", err)
|
||||
}
|
||||
@@ -162,7 +160,7 @@ func TestRuntimeSchemaAllPayloadContainsFullLeafParameters(t *testing.T) {
|
||||
// exercises the production assembly path.
|
||||
root := buildRuntimeSchemaTestRoot()
|
||||
declareRuntimeSchemaTestRootDoc(t, root, nil)
|
||||
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), embeddedMCPMetadata{})
|
||||
registry, err := schemaRegistryForTest(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -203,9 +201,8 @@ func schemaTestInt(value any) int {
|
||||
}
|
||||
|
||||
func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
|
||||
// interface_ref declares on the leaf ContractFinal; the injected MCP
|
||||
// fixture participates through the gated fixture lookup (remapped via the
|
||||
// declared Interface.Ref).
|
||||
// interface_ref declares on the leaf ContractFinal; MCP pin is not a
|
||||
// parameter candidate source.
|
||||
root := buildRuntimeSchemaTestRoot()
|
||||
declareRuntimeSchemaTestRootDoc(t, root, func(payload *contract.ContractFinalPayload) {
|
||||
payload.Interface = &contract.InterfaceSpec{
|
||||
@@ -215,17 +212,8 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "documents", RPCName: "create_doc_v2"},
|
||||
}
|
||||
})
|
||||
mcpFixture := embeddedMCPMetadata{
|
||||
Tools: map[string]embeddedMCPToolMetadata{
|
||||
"documents.create_doc_v2": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"title": {Description: "MCP document title"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
|
||||
payload, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -233,112 +221,11 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
|
||||
if ref["product_id"] != "documents" || ref["rpc_name"] != "create_doc_v2" {
|
||||
t.Fatalf("interface_ref = %#v", payload["interface_ref"])
|
||||
}
|
||||
parameters, _ := payload["parameters"].(map[string]any)
|
||||
title, _ := parameters["title"].(map[string]any)
|
||||
if title["interface_description"] != "MCP document title" {
|
||||
t.Fatalf("title metadata = %#v", title)
|
||||
if payload["interface_mode"] != contract.InterfaceModeMCP {
|
||||
t.Fatalf("interface_mode = %#v", payload["interface_mode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPRequiredParticipatesInSourcePrecedence(t *testing.T) {
|
||||
required := true
|
||||
mcpFixture := embeddedMCPMetadata{
|
||||
Tools: map[string]embeddedMCPToolMetadata{
|
||||
"sample.list_items": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"limit": {Required: &required},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
list.Flags().Int("limit", 0, "optional page size")
|
||||
AttachRuntimeSchema(list, "sample", "list_items", "test")
|
||||
sample := &cobra.Command{Use: "sample"}
|
||||
sample.AddCommand(list)
|
||||
root.AddCommand(sample)
|
||||
declareSampleListItemsLeaf(t, list)
|
||||
|
||||
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parameters, _ := payload["parameters"].(map[string]any)
|
||||
limit, _ := parameters["limit"].(map[string]any)
|
||||
if limit["required"] != true {
|
||||
t.Fatalf("MCP required candidate did not win over the default: %#v", limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPDefaultDoesNotOverrideCLIDefault(t *testing.T) {
|
||||
mcpFixture := embeddedMCPMetadata{
|
||||
Tools: map[string]embeddedMCPToolMetadata{
|
||||
"sample.list_items": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"limit": {Default: "50"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
list.Flags().Int("limit", 10, "optional page size")
|
||||
AttachRuntimeSchema(list, "sample", "list_items", "test")
|
||||
sample := &cobra.Command{Use: "sample"}
|
||||
sample.AddCommand(list)
|
||||
root.AddCommand(sample)
|
||||
declareSampleListItemsLeaf(t, list)
|
||||
|
||||
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parameters, _ := payload["parameters"].(map[string]any)
|
||||
limit, _ := parameters["limit"].(map[string]any)
|
||||
if limit["default"] != "10" || limit["interface_default"] != "50" {
|
||||
t.Fatalf("CLI and interface defaults were not separated: %#v", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// declareSampleListItemsLeaf registers the ContractFinal / ProductDecl
|
||||
// declarations for the synthetic sample.list_items leaf so MCP fixture tests
|
||||
// assemble through the production path.
|
||||
func declareSampleListItemsLeaf(t *testing.T, list *cobra.Command) {
|
||||
t.Helper()
|
||||
contractfinal.RegisterRuntimeContractFinal(list, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "sample", Name: "list_items", CanonicalPath: "sample.list_items",
|
||||
CLIPath: "sample list", PrimaryCLIPath: "sample list",
|
||||
},
|
||||
Title: "List items",
|
||||
Description: "List sample items",
|
||||
Safety: &contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local", Availability: "available", Reason: "test local leaf",
|
||||
},
|
||||
Selection: &contract.SelectionSpec{
|
||||
AgentSummary: "List sample items",
|
||||
UseWhen: []string{"list sample items"},
|
||||
AvoidWhen: []string{"not listing"},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(list) })
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "sample",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "Sample product",
|
||||
UseWhen: []string{"sample routing"},
|
||||
AvoidWhen: []string{"not sample"},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { contract.ClearProductDeclForTest("sample") })
|
||||
}
|
||||
|
||||
func findSchemaProduct(products []map[string]any, id string) map[string]any {
|
||||
for _, product := range products {
|
||||
if product["id"] == id {
|
||||
|
||||
@@ -178,6 +178,7 @@ func contractFinalToolSelection(command *cobra.Command) AgentToolSelection {
|
||||
out.UseWhen = selection.UseWhen
|
||||
out.AvoidWhen = selection.AvoidWhen
|
||||
out.Examples = selection.Examples
|
||||
out.ExampleDispositions = selection.ExampleDispositions
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -508,6 +508,10 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf["future_audit_field"] = "must not leak into Agent view"
|
||||
for _, raw := range schemaMap(leaf["parameters"]) {
|
||||
raw["future_mapping_field"] = "must not leak into Agent view"
|
||||
}
|
||||
stripped := stripSchemaPayloadCompact(leaf)
|
||||
|
||||
// Must keep agent-essential fields.
|
||||
@@ -518,7 +522,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
|
||||
}
|
||||
|
||||
// Must strip provenance / redundant fields.
|
||||
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display"} {
|
||||
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display", "future_audit_field"} {
|
||||
if _, ok := stripped[key]; ok {
|
||||
t.Fatalf("compact leaf still contains stripped key %q", key)
|
||||
}
|
||||
@@ -528,7 +532,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
|
||||
if params, ok := stripped["parameters"].(map[string]any); ok {
|
||||
for name, p := range params {
|
||||
if pm, ok := p.(map[string]any); ok {
|
||||
for _, stripped := range []string{"interface_description", "interface_type", "property"} {
|
||||
for _, stripped := range []string{"interface_description", "interface_type", "property", "future_mapping_field"} {
|
||||
if _, present := pm[stripped]; present {
|
||||
t.Fatalf("compact param %q still contains %q", name, stripped)
|
||||
}
|
||||
@@ -1304,7 +1308,7 @@ func TestDeliveryCatalogContactParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
}
|
||||
if want.interfaceType != "" {
|
||||
prov := schemaMap(param["field_provenance"])["interface_type"]
|
||||
if src, _ := prov["source"].(string); src != "native_annotation" && src != "mcp_metadata" {
|
||||
if src, _ := prov["source"].(string); src != "native_annotation" {
|
||||
t.Fatalf("%s --%s interface_type source = %#v", tc.path, flagName, prov)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ package cli
|
||||
var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
{
|
||||
ID: "cli-management",
|
||||
Reason: "Local CLI lifecycle, authentication, configuration, recovery, and plugin-management commands are user-operated controls rather than stable Agent tools.",
|
||||
Reason: "Local CLI lifecycle, authentication, configuration, and plugin-management commands are user-operated controls rather than stable Agent tools.",
|
||||
Reviewed: true,
|
||||
Commands: []string{
|
||||
"api",
|
||||
@@ -53,9 +53,6 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"profile list",
|
||||
"profile switch",
|
||||
"profile use",
|
||||
"recovery execute",
|
||||
"recovery finalize",
|
||||
"recovery plan",
|
||||
"schema",
|
||||
"skill get",
|
||||
"skill install",
|
||||
|
||||
@@ -123,16 +123,11 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
|
||||
t.Run("disposition narrows dry_run capability", func(t *testing.T) {
|
||||
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
|
||||
payload.DryRun = &contract.DryRunSpec{PreviewKind: "plan"}
|
||||
})
|
||||
t.Cleanup(restoreSelection)
|
||||
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
|
||||
selection := contractFinalToolSelection(cmd)
|
||||
selection.ExampleDispositions = []AgentExampleDisposition{{
|
||||
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
|
||||
Reason: "cannot dry-run safely", ReasonCode: AgentExampleReasonStatefulPreflight,
|
||||
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
|
||||
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
|
||||
Reason: "cannot dry-run safely", ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
|
||||
}}
|
||||
return selection
|
||||
}
|
||||
})
|
||||
plan, err := BuildAgentExampleExecutionPlan(bound, registry)
|
||||
if err != nil {
|
||||
t.Fatalf("plan error = %v", err)
|
||||
@@ -146,16 +141,12 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("disposition without dry_run capability fails", func(t *testing.T) {
|
||||
bound, registry := crossPlatformAgentExampleFixture(t, nil)
|
||||
t.Cleanup(restoreSelection)
|
||||
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
|
||||
selection := contractFinalToolSelection(cmd)
|
||||
selection.ExampleDispositions = []AgentExampleDisposition{{
|
||||
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
|
||||
Reason: "no dry run", ReasonCode: AgentExampleReasonLocalState,
|
||||
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
|
||||
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
|
||||
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
|
||||
Reason: "no dry run", ReasonCode: contract.ExampleDispositionReasonLocalState,
|
||||
}}
|
||||
return selection
|
||||
}
|
||||
})
|
||||
_, err := BuildAgentExampleExecutionPlan(bound, registry)
|
||||
if err == nil || !strings.Contains(err.Error(), "narrows no explicit dry_run") {
|
||||
t.Fatalf("error = %v", err)
|
||||
|
||||
@@ -137,8 +137,8 @@ func schemaToolSpecFromPayload(payload map[string]any) (ToolSpec, error) {
|
||||
|
||||
// runtimeCommandParameters is the compatibility wire adapter used only by
|
||||
// tests; resolution happens in runtimeCommandParameterSpecs.
|
||||
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) (map[string]any, error) {
|
||||
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, pinnedParams, constraints)
|
||||
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) (map[string]any, error) {
|
||||
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, constraints)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -969,16 +969,16 @@ func TestOverallCoverageGapDeliveryCompletenessAndDryRun(t *testing.T) {
|
||||
func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
|
||||
prevSpecs := runtimeCommandParameterSpecsForPayload
|
||||
t.Cleanup(func() { runtimeCommandParameterSpecsForPayload = prevSpecs })
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return nil, fmt.Errorf("specs boom")
|
||||
}
|
||||
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil {
|
||||
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{}); err == nil {
|
||||
t.Fatal("parameter specs error must surface")
|
||||
}
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return []ParameterSpec{{Name: "ok", Type: "string"}}, nil
|
||||
}
|
||||
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{})
|
||||
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{})
|
||||
if err != nil || payload["ok"] == nil {
|
||||
t.Fatalf("parameter payload = %#v err=%v", payload, err)
|
||||
}
|
||||
@@ -1070,24 +1070,6 @@ func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOverallRegressionRecovery(t *testing.T) {
|
||||
if _, ok := lookupPinnedMCPParam(nil, "property", "flag"); ok {
|
||||
t.Fatal("nil pinned params must miss")
|
||||
}
|
||||
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{}, "property", "flag"); ok {
|
||||
t.Fatal("empty pinned params must miss")
|
||||
}
|
||||
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"other": {Type: "string"}}, "property", "flag"); ok {
|
||||
t.Fatal("unmatched pinned params must miss")
|
||||
}
|
||||
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{}); ok {
|
||||
t.Fatal("empty MCP metadata must not match")
|
||||
}
|
||||
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{
|
||||
Tools: map[string]embeddedMCPToolMetadata{"other.key": {}},
|
||||
}); ok {
|
||||
t.Fatal("missing MCP metadata keys must not match")
|
||||
}
|
||||
|
||||
left := runtimeSchemaStringCandidateAtPriority("same", true, "z-source", 5, "p")
|
||||
right := runtimeSchemaStringCandidateAtPriority("same", true, "a-source", 5, "p")
|
||||
winner, err := resolveRuntimeSchemaCandidate("source-order", left, right)
|
||||
|
||||
@@ -410,7 +410,7 @@ func TestRuntimeCommandParameterSpecsPreserveReviewedEmptyPropertyProvenance(t *
|
||||
cmd := &cobra.Command{Use: "query"}
|
||||
cmd.Flags().Bool("all", false, "fetch every page")
|
||||
|
||||
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", nil, RuntimeSchemaConstraints{})
|
||||
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", RuntimeSchemaConstraints{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -448,6 +448,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"drive.download_file --output": "local output path",
|
||||
"drive.download_file --parallel": "local multipart download control; never sent to download_file",
|
||||
"drive.download_file --part-size": "local multipart download control; never sent to download_file",
|
||||
"drive.download_file --version": "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; not a download_file interface property",
|
||||
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI-local multipart download control and does not publish a direct interface property.",
|
||||
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -655,7 +656,6 @@ var reviewedSchemaParameterBindingRemovals = map[string]schemaParameterBindingRe
|
||||
"contact.get_dept_info_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"contact.get_dept_members_by_deptId --ids": {Reason: "The public flag was renamed from --ids to the unambiguous --depts spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"contact.get_sub_depts_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"drive.download_file --version": {Reason: "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; the version property belongs to download_file_version metadata, not download_file.", Reviewed: true},
|
||||
"minutes.query_user_tag_list --limit": {Reason: "The current helper and pinned interface have no pagination input.", Reviewed: true},
|
||||
"oa.list_pending_approvals --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"oa.list_user_visible_process --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
|
||||
@@ -70,18 +70,6 @@ func schemaRegistryForTest(root *cobra.Command) (SchemaRegistry, error) {
|
||||
return AssembleSchemaRegistryFromBound(bound)
|
||||
}
|
||||
|
||||
func schemaRegistryForTestWithMetadata(root *cobra.Command, agent agentMetadata, mcp embeddedMCPMetadata) (SchemaRegistry, error) {
|
||||
bound, err := boundTestCommandRegistry(root)
|
||||
if err != nil {
|
||||
return SchemaRegistry{}, err
|
||||
}
|
||||
// Production-shaped assembly: leaves must carry ContractFinal and products
|
||||
// a ProductDecl (see declareRuntimeSchemaTestRootDoc). Injected MCP/agent
|
||||
// fixtures participate only through the gated fixture lookup in
|
||||
// runtimeToolSpecFromContractFinal; production passes an empty pin.
|
||||
return assembleSchemaRegistryFromBound(bound, runtimeSchemaMetadataSources{Agent: agent, MCP: mcp})
|
||||
}
|
||||
|
||||
// declareRuntimeSchemaTestRootDoc registers the ContractFinal / ProductDecl
|
||||
// declarations for the synthetic doc.create_document tree built by
|
||||
// buildRuntimeSchemaTestRoot, so production-shaped assembly can resolve it.
|
||||
@@ -156,18 +144,6 @@ func runtimeSchemaPayloadForTest(root *cobra.Command, args []string) (map[string
|
||||
return schemaPayloadFromLoadedCatalog(loaded, args)
|
||||
}
|
||||
|
||||
func runtimeSchemaPayloadForTestWithMetadata(root *cobra.Command, args []string, agent agentMetadata, mcp embeddedMCPMetadata) (map[string]any, error) {
|
||||
registry, err := schemaRegistryForTestWithMetadata(root, agent, mcp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loaded, err := loadedSchemaCatalogForTestRegistry(registry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return schemaPayloadFromLoadedCatalog(loaded, args)
|
||||
}
|
||||
|
||||
func runtimeSchemaAllPayloadForTest(root *cobra.Command) (map[string]any, error) {
|
||||
registry, err := schemaRegistryForTest(root)
|
||||
if err != nil {
|
||||
|
||||
@@ -15,8 +15,10 @@ import (
|
||||
)
|
||||
|
||||
type runtimeSchemaMetadataSources struct {
|
||||
// Agent remains only for historical test seams that still construct this
|
||||
// struct; production assembly does not overlay Agent or MCP pin onto
|
||||
// parameters or tool text.
|
||||
Agent agentMetadata
|
||||
MCP embeddedMCPMetadata
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -62,10 +64,9 @@ func (resolved ResolvedSchemaBuild) CommandCount() int {
|
||||
}
|
||||
|
||||
func pinnedRuntimeSchemaMetadataSources() runtimeSchemaMetadataSources {
|
||||
return runtimeSchemaMetadataSources{
|
||||
Agent: runtimeAgentMetadata(),
|
||||
MCP: emptyPinnedMCPMetadata(),
|
||||
}
|
||||
// Production pin and Agent inject are both retired; assembly is Contract /
|
||||
// ParamDecl / Cobra only.
|
||||
return runtimeSchemaMetadataSources{}
|
||||
}
|
||||
|
||||
// ResolveSchemaBuild is the only assembly path from executable Cobra commands
|
||||
@@ -123,7 +124,7 @@ func AssembleSchemaRegistryFromBound(bound BoundCommandRegistry) (SchemaRegistry
|
||||
|
||||
// assembleSchemaRegistryFromBound resolves every entry through the
|
||||
// ContractFinal / ProductDecl production path. Missing declarations fail
|
||||
// closed; retired skill/MCP/agent-inject overlays are never reopened.
|
||||
// closed; retired skill/MCP-pin/agent-inject overlays are never reopened.
|
||||
func assembleSchemaRegistryFromBound(bound BoundCommandRegistry, metadata runtimeSchemaMetadataSources) (SchemaRegistry, error) {
|
||||
entries, err := assembleCollectEntries(bound)
|
||||
if err != nil {
|
||||
@@ -197,18 +198,12 @@ func assembleProductSelection(entry runtimeSchemaEntry) (contract.SelectionSpec,
|
||||
|
||||
// runtimeToolSpecFromContractFinal pass-throughs Contract-authored Schema fields.
|
||||
// Declared values are the final data source; hints/registry text does not merge.
|
||||
// Production MCP pin is empty, so assembly skips MCP-metadata lookups entirely;
|
||||
// interface_type / interface_* facts come from ParamDecl / native annotations.
|
||||
// Tests may still inject a non-empty MCP fixture map, which participates through
|
||||
// pinnedMCPMetadataForEntryFrom.
|
||||
// MCP pin is retired: interface_type / interface_* facts come from ParamDecl /
|
||||
// native annotations only.
|
||||
func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.ContractFinalPayload, metadata runtimeSchemaMetadataSources) (ToolSpec, error) {
|
||||
_ = metadata // reserved for historical assemble seams; no overlay sources remain
|
||||
canonicalPath := entry.ProductID + "." + entry.ToolName
|
||||
constraints := runtimeCommandConstraints(entry.Command)
|
||||
var pinnedParams map[string]embeddedMCPParamMeta
|
||||
if len(metadata.MCP.Tools) > 0 {
|
||||
pinnedMeta, _ := pinnedMCPMetadataForEntryFrom(entry, metadata.Agent, metadata.MCP)
|
||||
pinnedParams = pinnedMeta.Parameters
|
||||
}
|
||||
// Apply parameter declarations from the contract.ContractFinalPayload before the
|
||||
// resolver reads them. The decls were put there by AttachContract at
|
||||
// DeclareLeafMetadata time; now that all flags exist on the fully-built
|
||||
@@ -216,7 +211,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
||||
if err := ApplyParamDecls(entry.Command, final.Parameters); err != nil {
|
||||
return ToolSpec{}, fmt.Errorf("apply Contract Schema ParamDecls for %s: %w", canonicalPath, err)
|
||||
}
|
||||
parameters, err := resolveRuntimeParameters(entry.Command, canonicalPath, pinnedParams, constraints)
|
||||
parameters, err := resolveRuntimeParameters(entry.Command, canonicalPath, constraints)
|
||||
if err != nil {
|
||||
return ToolSpec{}, fmt.Errorf("resolve Contract Schema parameters for %s: %w", canonicalPath, err)
|
||||
}
|
||||
@@ -332,6 +327,10 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
||||
reviewed := true
|
||||
selection.Reviewed = &reviewed
|
||||
}
|
||||
// Example dispositions control only the policy gate's execution eligibility.
|
||||
// They remain on ContractFinal for BuildAgentExampleExecutionPlan and are not
|
||||
// part of the public ToolSpec / Schema wire contract.
|
||||
selection.ExampleDispositions = nil
|
||||
|
||||
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
|
||||
|
||||
|
||||
@@ -278,23 +278,10 @@ func TestCrossPlatformCoverageRenderSafetyAnnotationSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMCPMetadataInterfaceRefEdges(t *testing.T) {
|
||||
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{}, " ", " "); ok {
|
||||
t.Fatal("blank interface ref must miss")
|
||||
}
|
||||
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}, "chat", "missing"); ok {
|
||||
t.Fatal("missing MCP tool must miss")
|
||||
}
|
||||
agent := agentMetadata{Tools: map[string]agentToolMetadata{
|
||||
"chat reply": {InterfaceRef: &embeddedMCPInterfaceRef{ProductID: "chat", RPCName: "send_personal_message"}},
|
||||
}}
|
||||
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{"clawType": {Type: "string"}}},
|
||||
}}
|
||||
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{
|
||||
PrimaryCLIPath: "chat reply", ProductID: "chat", ToolName: "reply_personal_message",
|
||||
}, agent, mcp)
|
||||
if !ok || got.Parameters["clawType"].Type != "string" {
|
||||
t.Fatalf("agent InterfaceRef remap = %#v ok=%v", got, ok)
|
||||
// MCP pin lookup helpers are retired; keep this named coverage slot as a
|
||||
// no-op marker so CrossPlatformCoverage* selection stays stable.
|
||||
if got := emptyPinnedMCPMetadata(); got.Tools == nil || len(got.Tools) != 0 {
|
||||
t.Fatalf("empty pinned metadata = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -200,6 +200,10 @@ type SelectionSpec struct {
|
||||
Tips []string
|
||||
WorkflowRefs []string
|
||||
Examples []string
|
||||
// ExampleDispositions narrows an exact example with a reviewed local or
|
||||
// stateful precondition from dry-run execution to contract validation.
|
||||
// It does not change the command's declared DryRun capability.
|
||||
ExampleDispositions []ExampleDisposition
|
||||
// Reviewed is a legacy-path (hints/registry) marker only. The Contract
|
||||
// declaration path must not set it: declared selection is final by
|
||||
// construction, and assembly rejects a declared payload carrying it.
|
||||
@@ -219,10 +223,54 @@ func (s SelectionSpec) Normalized() SelectionSpec {
|
||||
out.Tips = stableUniqueStrings(s.Tips)
|
||||
out.WorkflowRefs = stableUniqueStrings(s.WorkflowRefs)
|
||||
out.Examples = stableUniqueStrings(s.Examples)
|
||||
out.ExampleDispositions = cloneExampleDispositions(s.ExampleDispositions)
|
||||
out.SourceRefs = sortedUniqueStrings(s.SourceRefs)
|
||||
return out
|
||||
}
|
||||
|
||||
// ExampleDispositionMode controls how an already contract-validated example
|
||||
// is exercised by the Agent example gate.
|
||||
type ExampleDispositionMode string
|
||||
|
||||
const (
|
||||
ExampleDispositionModeContract ExampleDispositionMode = "contract"
|
||||
ExampleDispositionModeDryRun ExampleDispositionMode = "dry_run"
|
||||
ExampleDispositionModeContractOnly ExampleDispositionMode = "contract_only"
|
||||
)
|
||||
|
||||
// ExampleDispositionReasonCode is the closed taxonomy for reviewed
|
||||
// contract-only exceptions to an explicit dry-run capability.
|
||||
type ExampleDispositionReasonCode string
|
||||
|
||||
const (
|
||||
ExampleDispositionReasonLocalState ExampleDispositionReasonCode = "local_state"
|
||||
ExampleDispositionReasonStatefulPreflight ExampleDispositionReasonCode = "stateful_preflight"
|
||||
)
|
||||
|
||||
// ExampleDisposition narrows one exact example to contract-only validation.
|
||||
// Index is a pointer so a missing index cannot silently select example zero.
|
||||
type ExampleDisposition struct {
|
||||
Index *int `json:"index"`
|
||||
Mode ExampleDispositionMode `json:"mode"`
|
||||
ReasonCode ExampleDispositionReasonCode `json:"reason_code"`
|
||||
Reason string `json:"reason"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
}
|
||||
|
||||
func cloneExampleDispositions(in []ExampleDisposition) []ExampleDisposition {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := append([]ExampleDisposition(nil), in...)
|
||||
for i := range out {
|
||||
if out[i].Index != nil {
|
||||
index := *out[i].Index
|
||||
out[i].Index = &index
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParamDecl is one parameter-level Schema fact declared on a command. It is
|
||||
// stored at DeclareLeafMetadata time and applied as annotations at assembly
|
||||
// time, when all flags are guaranteed to exist on the fully-built command tree.
|
||||
|
||||
@@ -75,9 +75,14 @@ func TestCrossPlatformCoverageInterfaceSpecAgentExecutableAndValidate(t *testing
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *testing.T) {
|
||||
exampleIndex := 0
|
||||
normalized := (SelectionSpec{
|
||||
UseWhen: []string{" one ", "one", ""},
|
||||
AvoidWhen: []string{"avoid"},
|
||||
UseWhen: []string{" one ", "one", ""},
|
||||
AvoidWhen: []string{"avoid"},
|
||||
ExampleDispositions: []ExampleDisposition{{
|
||||
Index: &exampleIndex, Mode: ExampleDispositionModeContractOnly,
|
||||
ReasonCode: ExampleDispositionReasonLocalState, Reason: "local file", Reviewed: true,
|
||||
}},
|
||||
SourceRefs: []string{"b", "a", "b"},
|
||||
}).Normalized()
|
||||
if len(normalized.UseWhen) != 1 || normalized.UseWhen[0] != "one" {
|
||||
@@ -86,6 +91,16 @@ func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *tes
|
||||
if normalized.SourceRefs[0] != "a" || normalized.SourceRefs[1] != "b" {
|
||||
t.Fatalf("SourceRefs = %#v", normalized.SourceRefs)
|
||||
}
|
||||
if len(normalized.ExampleDispositions) != 1 || normalized.ExampleDispositions[0].Index == nil || *normalized.ExampleDispositions[0].Index != 0 {
|
||||
t.Fatalf("ExampleDispositions = %#v", normalized.ExampleDispositions)
|
||||
}
|
||||
exampleIndex = 1
|
||||
if *normalized.ExampleDispositions[0].Index != 0 {
|
||||
t.Fatal("ExampleDispositions index was not cloned")
|
||||
}
|
||||
if got := cloneExampleDispositions(nil); got != nil {
|
||||
t.Fatalf("cloneExampleDispositions(nil) = %#v", got)
|
||||
}
|
||||
if got := stableUniqueStrings(nil); got != nil {
|
||||
t.Fatalf("stableUniqueStrings(nil) = %#v", got)
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
|
||||
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
|
||||
|
||||
命令结构:
|
||||
dws calendar event [list|get|create|update|delete|suggest|respond] 日程管理
|
||||
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
|
||||
dws calendar attendee [list|add|delete] 参会人管理
|
||||
dws calendar room [search|add|delete|list-groups] 会议室管理
|
||||
dws calendar busy search 闲忙查询 (可查人、查会议室)
|
||||
@@ -2082,7 +2082,184 @@ func newCalendarCommand() *cobra.Command {
|
||||
eventSuggestCmd.Flags().String("members", "", "")
|
||||
_ = eventSuggestCmd.Flags().MarkHidden("members")
|
||||
eventSuggestCmd.Flags().String("duration", "", "日程持续时间 (分钟,默认30)")
|
||||
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd)
|
||||
|
||||
eventInstancesCmd := &cobra.Command{
|
||||
Use: "instances",
|
||||
Short: "查询循环日程的实例列表",
|
||||
Long: `查询指定重复性日程(SeriesMaster)在指定时间范围内的所有实例。
|
||||
**注意**:此接口只能查询重复性日程的实例;若传入的是普通非循环日程,将查不到任何实例信息。
|
||||
必须传入 --id 指定重复性日程的 eventId(即 SeriesMaster 的 eventId,可通过 event list 获取)。
|
||||
不传 --start/--end 时,默认查询今天(00:00:00 ~ 23:59:59)的实例。`,
|
||||
Example: ` dws calendar event instances --id EVENT_ID
|
||||
dws calendar event instances --id EVENT_ID --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00"
|
||||
dws calendar event instances --id EVENT_ID --limit 50
|
||||
dws calendar event instances --id EVENT_ID --cursor "<nextCursor>"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"eventId": eventID}
|
||||
var startTime, endTime int64
|
||||
var now time.Time
|
||||
if v := flagOrFallback(cmd, "start", "time-min", "min-time", "start-time", "startTime", "start_time", "start-date", "startDate"); v != "" {
|
||||
startTime, err = parseISOTimeToMillis("start", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["startTime"] = startTime
|
||||
} else {
|
||||
now = time.Now()
|
||||
startTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
|
||||
toolArgs["startTime"] = startTime
|
||||
}
|
||||
if v := flagOrFallback(cmd, "end", "time-max", "max-time", "end-time", "endTime", "end_time", "end-date", "endDate"); v != "" {
|
||||
endTime, err = parseISOTimeToMillis("end", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["endTime"] = endTime
|
||||
} else {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
endTime = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
|
||||
toolArgs["endTime"] = endTime
|
||||
}
|
||||
if err := validateTimeRange(startTime, endTime); err != nil {
|
||||
return err
|
||||
}
|
||||
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
|
||||
toolArgs["calendarId"] = v
|
||||
}
|
||||
if v := flagOrFallback(cmd, "cursor", "next-cursor", "nextCursor", "page-token", "pageToken", "next-token"); v != "" {
|
||||
toolArgs["cursor"] = v
|
||||
}
|
||||
if lim, _ := cmd.Flags().GetInt("limit"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("max-results"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("maxResults"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("page-size"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("size"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("count"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
}
|
||||
return callSortedCalendarEvents(cmd, "list_event_instances", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(eventInstancesCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "calendar",
|
||||
Name: "list_event_instances",
|
||||
CanonicalPath: "calendar.list_event_instances",
|
||||
CLIPath: "calendar event instances",
|
||||
PrimaryCLIPath: "calendar event instances",
|
||||
},
|
||||
Description: "查询循环日程的实例列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "list_event_instances"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询循环日程在时间范围内展开的实例",
|
||||
UseWhen: []string{"已知循环日程 eventId(SeriesMaster),需要列出某时间窗内的实例时"},
|
||||
AvoidWhen: []string{
|
||||
"普通非循环日程请用 dws calendar event get / list",
|
||||
"未知 eventId 时先 dws calendar event list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws calendar event instances --id <EVENT_ID>",
|
||||
"dws calendar event instances --id <EVENT_ID> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-31T23:59:59+08:00\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "eventId", Required: boolPtr(true)},
|
||||
{Name: "start", Property: "startTime"},
|
||||
{Name: "end", Property: "endTime"},
|
||||
{Name: "calendar-id", Property: "calendarId"},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
{Name: "limit", Property: "limit", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// InstancesEvent flags (aligned with event list aliases)
|
||||
eventInstancesCmd.Flags().String("id", "", "日程 ID (必填)")
|
||||
eventInstancesCmd.Flags().String("event", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("event")
|
||||
eventInstancesCmd.Flags().String("event-id", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("event-id")
|
||||
eventInstancesCmd.Flags().String("eventId", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("eventId")
|
||||
eventInstancesCmd.Flags().String("start", "", "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00)")
|
||||
eventInstancesCmd.Flags().String("time-min", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("time-min")
|
||||
eventInstancesCmd.Flags().String("min-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("min-time")
|
||||
eventInstancesCmd.Flags().String("start-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start-time")
|
||||
eventInstancesCmd.Flags().String("startTime", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("startTime")
|
||||
eventInstancesCmd.Flags().String("start_time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start_time")
|
||||
eventInstancesCmd.Flags().String("start-date", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start-date")
|
||||
eventInstancesCmd.Flags().String("startDate", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("startDate")
|
||||
eventInstancesCmd.Flags().String("end", "", "结束时间 ISO-8601 (例如 2026-03-31T23:59:59+08:00)")
|
||||
eventInstancesCmd.Flags().String("time-max", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("time-max")
|
||||
eventInstancesCmd.Flags().String("max-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("max-time")
|
||||
eventInstancesCmd.Flags().String("end-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end-time")
|
||||
eventInstancesCmd.Flags().String("endTime", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("endTime")
|
||||
eventInstancesCmd.Flags().String("end_time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end_time")
|
||||
eventInstancesCmd.Flags().String("end-date", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end-date")
|
||||
eventInstancesCmd.Flags().String("endDate", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("endDate")
|
||||
eventInstancesCmd.Flags().String("calendar-id", "", "日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 book list 获取)")
|
||||
eventInstancesCmd.Flags().String("calendarId", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("calendarId")
|
||||
eventInstancesCmd.Flags().String("calendar", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("calendar")
|
||||
eventInstancesCmd.Flags().String("cursor", "", "分页游标 (首次查询无需传入,仅翻页时传入上一次返回的 nextCursor)")
|
||||
eventInstancesCmd.Flags().String("next-cursor", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("next-cursor")
|
||||
eventInstancesCmd.Flags().String("nextCursor", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("nextCursor")
|
||||
eventInstancesCmd.Flags().String("page-token", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("page-token")
|
||||
eventInstancesCmd.Flags().String("pageToken", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("pageToken")
|
||||
eventInstancesCmd.Flags().String("next-token", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("next-token")
|
||||
eventInstancesCmd.Flags().Int("limit", 0, "每页返回条数 (默认 100,最大 100)")
|
||||
eventInstancesCmd.Flags().Int("max-results", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("max-results")
|
||||
eventInstancesCmd.Flags().Int("maxResults", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("maxResults")
|
||||
eventInstancesCmd.Flags().Int("page-size", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("page-size")
|
||||
eventInstancesCmd.Flags().Int("size", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("size")
|
||||
eventInstancesCmd.Flags().Int("count", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("count")
|
||||
|
||||
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
|
||||
|
||||
// participant
|
||||
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
|
||||
|
||||
+85
-27
@@ -54,6 +54,14 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error
|
||||
}
|
||||
}
|
||||
|
||||
func chatCompatibilityHintSubCmd(use, hint string) *cobra.Command {
|
||||
command := hintSubCmd(use, hint)
|
||||
// Legacy callers may still pass the old command's flags. Let the migration
|
||||
// command consume them so Cobra reaches RunE and returns the replacement path.
|
||||
command.DisableFlagParsing = true
|
||||
return command
|
||||
}
|
||||
|
||||
type nativeChatTargetReader struct{}
|
||||
|
||||
func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
@@ -396,6 +404,50 @@ func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool)
|
||||
return text
|
||||
}
|
||||
|
||||
// applyCurrentUserGroupMentions keeps the body placeholders and
|
||||
// send_personal_message mention arguments aligned for send and reply.
|
||||
func applyCurrentUserGroupMentions(params map[string]any, text, rawOpenIDs string, atAll bool) string {
|
||||
var atOpenIDs []string
|
||||
if rawOpenIDs != "" {
|
||||
atOpenIDs = strings.Split(rawOpenIDs, ",")
|
||||
}
|
||||
if atAll && !strings.Contains(text, "<@all>") {
|
||||
text = "<@all> " + text
|
||||
}
|
||||
text = normalizeAtPlaceholders(text, atOpenIDs, true)
|
||||
if atAll {
|
||||
params["atAll"] = true
|
||||
}
|
||||
if len(atOpenIDs) > 0 {
|
||||
params["atOpenDingTalkIds"] = atOpenIDs
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func addMissingCurrentUserMentionPlaceholders(text, rawOpenIDs string) string {
|
||||
if rawOpenIDs == "" {
|
||||
return text
|
||||
}
|
||||
missing := make([]string, 0)
|
||||
probeText := text
|
||||
for _, id := range parseCSVValues(rawOpenIDs) {
|
||||
placeholder := "<@" + id + ">"
|
||||
if strings.Contains(probeText, placeholder) {
|
||||
continue
|
||||
}
|
||||
missing = append(missing, placeholder)
|
||||
probeText += placeholder
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return text
|
||||
}
|
||||
prefix := strings.Join(missing, " ")
|
||||
if strings.HasPrefix(text, "<@all> ") {
|
||||
return "<@all> " + prefix + " " + strings.TrimPrefix(text, "<@all> ")
|
||||
}
|
||||
return prefix + " " + text
|
||||
}
|
||||
|
||||
func containsMessageMention(text, placeholder string) bool {
|
||||
if strings.HasPrefix(placeholder, "<") {
|
||||
return strings.Contains(text, placeholder)
|
||||
@@ -2029,29 +2081,15 @@ func newChatCommand() *cobra.Command {
|
||||
if groupID != "" {
|
||||
atAll, _ := cmd.Flags().GetBool("at-all")
|
||||
atOpenIdsStr, _ := cmd.Flags().GetString("at-open-dingtalk-ids")
|
||||
var atOpenIds []string
|
||||
if atOpenIdsStr != "" {
|
||||
atOpenIds = strings.Split(atOpenIdsStr, ",")
|
||||
}
|
||||
if atAll && !strings.Contains(text, "<@all>") {
|
||||
text = "<@all> " + text
|
||||
}
|
||||
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 不变
|
||||
text = normalizeAtPlaceholders(text, atOpenIds, true)
|
||||
// 群聊统一走 openDingTalkId @ 人接口。
|
||||
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
|
||||
newParams := map[string]any{
|
||||
"openConversationId": groupID,
|
||||
"msgType": "markdown",
|
||||
"content": string(contentJSON),
|
||||
"clawType": clawType,
|
||||
}
|
||||
if atAll {
|
||||
newParams["atAll"] = true
|
||||
}
|
||||
if len(atOpenIds) > 0 {
|
||||
newParams["atOpenDingTalkIds"] = atOpenIds
|
||||
}
|
||||
text = applyCurrentUserGroupMentions(newParams, text, atOpenIdsStr, atAll)
|
||||
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
|
||||
newParams["content"] = string(contentJSON)
|
||||
if msgUuid != "" {
|
||||
newParams["uuid"] = msgUuid
|
||||
}
|
||||
@@ -5368,13 +5406,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
chatMessageReplyCmd := &cobra.Command{
|
||||
Use: "reply",
|
||||
Short: "引用回复消息(支持单聊/群聊)",
|
||||
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。
|
||||
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。群聊回复可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人;正文中的裸 @openDingTalkId 会自动规范化为 <@openDingTalkId>,缺少对应成员或 <@all> 占位符时会自动补齐。
|
||||
|
||||
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
|
||||
- 群聊:dws chat search --query "群名"
|
||||
- 单聊:dws chat conversation-info --open-dingtalk-id <openDingTalkId>
|
||||
(人员信息可通过 dws contact user search --keyword "姓名" --format json 获取)`,
|
||||
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
|
||||
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"
|
||||
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
|
||||
return err
|
||||
@@ -5387,13 +5426,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
}
|
||||
refSender = resolved
|
||||
}
|
||||
replyContent := map[string]string{
|
||||
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
|
||||
"srcMsgSendOpenDingTalkId": refSender,
|
||||
"replyMsgType": "text",
|
||||
"content": mustGetFlag(cmd, "text"),
|
||||
}
|
||||
contentJSON, _ := marshalJSONRaw(replyContent)
|
||||
clawType := ""
|
||||
aiTag, _ := cmd.Flags().GetBool("ai-tag")
|
||||
if aiTag {
|
||||
@@ -5402,9 +5434,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "conversation-id"),
|
||||
"msgType": "reply",
|
||||
"content": string(contentJSON),
|
||||
"clawType": clawType,
|
||||
}
|
||||
atAll, _ := cmd.Flags().GetBool("at-all")
|
||||
atOpenIDs := mustGetFlag(cmd, "at-open-dingtalk-ids")
|
||||
replyText := applyCurrentUserGroupMentions(
|
||||
toolArgs,
|
||||
mustGetFlag(cmd, "text"),
|
||||
atOpenIDs,
|
||||
atAll,
|
||||
)
|
||||
replyText = addMissingCurrentUserMentionPlaceholders(replyText, atOpenIDs)
|
||||
replyContent := map[string]string{
|
||||
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
|
||||
"srcMsgSendOpenDingTalkId": refSender,
|
||||
"replyMsgType": "text",
|
||||
"content": replyText,
|
||||
}
|
||||
contentJSON, _ := marshalJSONRaw(replyContent)
|
||||
toolArgs["content"] = string(contentJSON)
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
}
|
||||
@@ -5438,6 +5486,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
|
||||
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
|
||||
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
|
||||
{Name: "conversation-id", Property: "openConversationId"},
|
||||
},
|
||||
},
|
||||
@@ -5452,6 +5502,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("text")
|
||||
chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)")
|
||||
chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
|
||||
chatMessageReplyCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)")
|
||||
chatMessageReplyCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效;正文缺少对应 <@id> 时自动补齐,裸 @id 自动规范化)")
|
||||
cli.AttachRuntimeSchema(chatMessageReplyCmd, "chat", "reply_personal_message", "hardcoded:chat")
|
||||
|
||||
// ── message forward: 转发单条消息 ────────────────────────
|
||||
@@ -8166,5 +8218,11 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
|
||||
|
||||
// Keep the v1.0.56 command surface recognizable while directing callers to
|
||||
// the supported nested commands. The chat root's "im" alias makes these
|
||||
// compatibility hints available through both chat and im.
|
||||
root.AddCommand(chatCompatibilityHintSubCmd("send", "use: dws chat message send"))
|
||||
root.AddCommand(chatCompatibilityHintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -81,24 +81,33 @@ func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalB
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMisroutedPathsRemainUnknownSubcommands(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *testing.T) {
|
||||
root := newChatCommand()
|
||||
if len(root.Aliases) != 1 || root.Aliases[0] != "im" {
|
||||
t.Fatalf("chat aliases = %v, want [im]", root.Aliases)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
flag string
|
||||
args []string
|
||||
hint string
|
||||
}{
|
||||
{path: "send", flag: "--group"},
|
||||
{path: "history", flag: "--group"},
|
||||
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
caller := &productExampleCaller{}
|
||||
err := runChatCoverageCommand(t, caller, tc.path, tc.flag, "cid")
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown command") || !strings.Contains(err.Error(), tc.path) {
|
||||
t.Fatalf("chat %s error = %v, want unknown command", tc.path, err)
|
||||
command, remaining, err := root.Find([]string{tc.path})
|
||||
if err != nil {
|
||||
t.Fatalf("find chat %s: %v", tc.path, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Fatalf("chat %s was misreported as a flag error: %v", tc.path, err)
|
||||
if len(remaining) != 0 || command.Name() != tc.path {
|
||||
t.Fatalf("find chat %s = command %q, remaining %v", tc.path, command.Name(), remaining)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("chat %s tool calls = %d, want 0", tc.path, caller.calls)
|
||||
if !command.Hidden || !command.Runnable() {
|
||||
t.Fatalf("chat %s compatibility contract: hidden=%v runnable=%v", tc.path, command.Hidden, command.Runnable())
|
||||
}
|
||||
root.SetArgs(tc.args)
|
||||
err = root.ExecuteContext(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
|
||||
t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -285,6 +286,150 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
contentField string
|
||||
wantContent string
|
||||
wantAtAll bool
|
||||
wantOpenIDs []string
|
||||
}{
|
||||
{
|
||||
name: "send",
|
||||
args: []string{
|
||||
"message", "send", "--group", "cid",
|
||||
"--text", "收到 @D-target 和 <@D-second>",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second",
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "text",
|
||||
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target", "D-second"},
|
||||
},
|
||||
{
|
||||
name: "send keeps missing member placeholders unchanged",
|
||||
args: []string{
|
||||
"message", "send", "--group", "cid",
|
||||
"--text", "DWS 发消息自测",
|
||||
"--at-open-dingtalk-ids", "D-target",
|
||||
},
|
||||
contentField: "text",
|
||||
wantContent: "DWS 发消息自测",
|
||||
wantOpenIDs: []string{"D-target"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "收到 @D-target 和 <@D-second>",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second",
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target", "D-second"},
|
||||
},
|
||||
{
|
||||
name: "reply adds missing member placeholders",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "DWS 回复艾特前津(非主用)自测",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second,D-target",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测",
|
||||
wantOpenIDs: []string{"D-target", "D-second", "D-target"},
|
||||
},
|
||||
{
|
||||
name: "reply adds missing member placeholders after at-all",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "请大家确认",
|
||||
"--at-open-dingtalk-ids", "D-target",
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@all> <@D-target> 请大家确认",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target"},
|
||||
},
|
||||
{
|
||||
name: "reply at-all preserves alliance word",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "联系 @alliance",
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@all> 联系 @alliance",
|
||||
wantAtAll: true,
|
||||
},
|
||||
{
|
||||
name: "reply without at flags preserves alliance word",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "联系 @alliance",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "联系 @alliance",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
args := caller.calls[0].args
|
||||
gotAtAll, hasAtAll := args["atAll"]
|
||||
if tc.wantAtAll {
|
||||
if !hasAtAll || gotAtAll != true {
|
||||
t.Fatalf("atAll = %#v, present = %v; want true", gotAtAll, hasAtAll)
|
||||
}
|
||||
} else if hasAtAll {
|
||||
t.Fatalf("atAll = %#v; want absent", gotAtAll)
|
||||
}
|
||||
gotOpenIDs, hasOpenIDs := args["atOpenDingTalkIds"]
|
||||
if len(tc.wantOpenIDs) > 0 {
|
||||
if !hasOpenIDs || !reflect.DeepEqual(gotOpenIDs, tc.wantOpenIDs) {
|
||||
t.Fatalf("atOpenDingTalkIds = %#v, present = %v; want %#v", gotOpenIDs, hasOpenIDs, tc.wantOpenIDs)
|
||||
}
|
||||
} else if hasOpenIDs {
|
||||
t.Fatalf("atOpenDingTalkIds = %#v; want absent", gotOpenIDs)
|
||||
}
|
||||
var content map[string]string
|
||||
if err := json.Unmarshal([]byte(args["content"].(string)), &content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := content[tc.contentField]; got != tc.wantContent {
|
||||
t.Fatalf("content[%q] = %q; want %q", tc.contentField, got, tc.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
|
||||
|
||||
@@ -144,7 +144,6 @@ var (
|
||||
"clear": {},
|
||||
"refresh": {},
|
||||
"recover": {},
|
||||
"recovery": {},
|
||||
"login": {},
|
||||
"logout": {},
|
||||
"register": {},
|
||||
|
||||
@@ -310,6 +310,44 @@ func newContactUserUpdateSelfCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactUserUpdateOwnnessCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update-ownness",
|
||||
Aliases: []string{"set-ownness"},
|
||||
Short: "更新用户个人状态",
|
||||
Long: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)。执行前需要确认,自动化场景在用户明确授权后传 --yes。",
|
||||
Example: ` dws contact user update-ownness --user-id user001 --ownness-text "居家办公中"`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "user-id", "id", "userid", "userId"); err != nil {
|
||||
return err
|
||||
}
|
||||
userID := strings.TrimSpace(flagOrFallback(cmd, "user-id", "id", "userid", "userId"))
|
||||
if userID == "" {
|
||||
return fmt.Errorf("--user-id 不能为空")
|
||||
}
|
||||
if err := validateRequiredFlagWithAliases(cmd, "ownness-text", "ownnessText"); err != nil {
|
||||
return err
|
||||
}
|
||||
ownnessText := strings.TrimSpace(flagOrFallback(cmd, "ownness-text", "ownnessText"))
|
||||
if ownnessText == "" {
|
||||
return fmt.Errorf("--ownness-text 不能为空")
|
||||
}
|
||||
return callMCPTool("user_ownness_update", map[string]any{
|
||||
"userId": userID,
|
||||
"ownnessText": ownnessText,
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("user-id", "", "要更新个人状态的用户 userId (必填)")
|
||||
cmd.Flags().String("id", "", "--user-id 的别名")
|
||||
cmd.Flags().String("userid", "", "--user-id 的别名")
|
||||
_ = cmd.Flags().MarkHidden("id")
|
||||
_ = cmd.Flags().MarkHidden("userid")
|
||||
cmd.Flags().String("ownness-text", "", "个人状态文本 (必填),如 \"居家办公中\"")
|
||||
cli.AnnotateRuntimeRequiredFlags(cmd, "user-id", "ownness-text")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactAccountUpdateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
@@ -391,7 +429,7 @@ func newContactCommand() *cobra.Command {
|
||||
|
||||
通讯录功能:
|
||||
- contact user get-self/search/search-mobile/get: 通讯录用户查询
|
||||
- contact user invite/update/update-self: 邀请与更新员工
|
||||
- contact user invite/update/update-self/update-ownness: 邀请与更新员工
|
||||
- contact dept search/get-info/list-children/list-members/create/update: 部门查询与管理
|
||||
- contact relation list-my-followings: 特别关注人查询
|
||||
|
||||
@@ -414,6 +452,7 @@ func newContactCommand() *cobra.Command {
|
||||
- 查询用户的部门、主管、管理员权限 → contact user get
|
||||
- 修改员工信息(姓名 / 部门 / 直属主管) → contact user update
|
||||
- 更新当前用户自己的 profile(昵称 / 头像) → contact user update-self
|
||||
- 更新用户个人状态(如「居家办公中」) → contact user update-ownness
|
||||
- 邀请员工加入企业 → contact user invite
|
||||
- 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get
|
||||
- 查询离职员工列表 → contact user dismission search`,
|
||||
@@ -1355,6 +1394,40 @@ contact user profile fields 获取可用字段列表。
|
||||
},
|
||||
},
|
||||
})
|
||||
contactUserUpdateOwnnessCmd := newContactUserUpdateOwnnessCommand()
|
||||
DeclareLeafMetadata(contactUserUpdateOwnnessCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "contact",
|
||||
Name: "user_ownness_update",
|
||||
CanonicalPath: "contact.user_ownness_update",
|
||||
CLIPath: "contact user update-ownness",
|
||||
PrimaryCLIPath: "contact user update-ownness",
|
||||
},
|
||||
Description: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed unpinned remote adapter: the executable CLI maps personal-status update flags to contact/user_ownness_update, which is absent from the pinned MCP metadata snapshot.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新指定用户的个人状态文本(如「居家办公中」)",
|
||||
UseWhen: []string{"用户明确要求设置或修改自己/指定用户的个人状态文本,且已确认目标 userId 和状态内容"},
|
||||
AvoidWhen: []string{"修改员工组织信息(姓名 / 部门 / 主管)应使用 contact user update;修改当前用户昵称或头像应使用 contact user update-self"},
|
||||
Examples: []string{"dws contact user update-ownness --user-id user001 --ownness-text \"居家办公中\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "userId", Required: boolPtr(false)},
|
||||
{Name: "ownness-text", Property: "ownnessText", Required: boolPtr(true)},
|
||||
{Name: "user-id", Property: "userId", Required: boolPtr(true)},
|
||||
{Name: "userid", Property: "userId", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ── flags 注册 ───────────────────────────────────────────────
|
||||
contactUserSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
@@ -1372,11 +1445,12 @@ contact user profile fields 获取可用字段列表。
|
||||
_ = contactUserGetCmd.Flags().MarkHidden("userid")
|
||||
userCmd.AddCommand(
|
||||
contactUserGetSelfCmd, contactUserSearchCmd, contactUserSearchMobileCmd, contactUserGetCmd,
|
||||
contactUserInviteCmd, // 邀请员工加入企业
|
||||
contactUserUpdateCmd, // 修改员工信息
|
||||
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
|
||||
contactUserProfileCmd, // 花名册档案
|
||||
contactUserDismissionCmd, // 离职员工
|
||||
contactUserInviteCmd, // 邀请员工加入企业
|
||||
contactUserUpdateCmd, // 修改员工信息
|
||||
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
|
||||
contactUserUpdateOwnnessCmd, // 更新用户个人状态
|
||||
contactUserProfileCmd, // 花名册档案
|
||||
contactUserDismissionCmd, // 离职员工
|
||||
)
|
||||
|
||||
contactDeptSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
|
||||
@@ -48,6 +48,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsExposeExpectedFlags(t *testin
|
||||
{[]string{"dept", "update"}, []string{"dept", "name", "parent"}},
|
||||
{[]string{"user", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id"}},
|
||||
{[]string{"user", "update-self"}, []string{"nick", "avatar-file-id"}},
|
||||
{[]string{"user", "update-ownness"}, []string{"user-id", "ownness-text"}},
|
||||
{[]string{"account", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id", "nick", "avatar-file-id"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -98,6 +99,18 @@ func TestCrossPlatformCoverageContactUpdateCommandsMapMCPArguments(t *testing.T)
|
||||
toolName: "self_user_profile_update",
|
||||
wantArgs: map[string]any{"nick": "新昵称", "avatarFileId": "file-1"},
|
||||
},
|
||||
{
|
||||
name: "update user ownness",
|
||||
args: []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中", "--yes"},
|
||||
toolName: "user_ownness_update",
|
||||
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "居家办公中"},
|
||||
},
|
||||
{
|
||||
name: "update user ownness with aliases",
|
||||
args: []string{"user", "set-ownness", "--userId", "user-1", "--ownnessText", "专注开发中", "--yes"},
|
||||
toolName: "user_ownness_update",
|
||||
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "专注开发中"},
|
||||
},
|
||||
{
|
||||
name: "update enterprise account",
|
||||
args: []string{"account", "edit", "--user-id", "user-2", "--org-user-name", "李四", "--depts", `[{"deptId":2}]`, "--master-user-id", "manager-2", "--nick", "小李", "--avatar-file-id", "file-2", "--yes"},
|
||||
@@ -139,6 +152,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsRequireConfirmation(t *testin
|
||||
{"dept", "update", "--dept", "7", "--name", "研发中心"},
|
||||
{"user", "update", "--user-id", "user-1", "--org-user-name", "张三"},
|
||||
{"user", "update-self", "--nick", "新昵称"},
|
||||
{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中"},
|
||||
{"account", "update", "--user-id", "user-2", "--nick", "小李"},
|
||||
}
|
||||
for _, args := range tests {
|
||||
@@ -174,6 +188,10 @@ func TestCrossPlatformCoverageContactUpdateCommandsValidateInput(t *testing.T) {
|
||||
{"employee no changes", []string{"user", "update", "--user-id", "user-1", "--org-user-name", " ", "--depts", " ", "--master-user-id", " ", "--yes"}, "至少需要一个修改项"},
|
||||
{"employee invalid departments", []string{"user", "update", "--user-id", "user-1", "--depts", "bad", "--yes"}, "--depts JSON 解析失败"},
|
||||
{"self no changes", []string{"user", "update-self", "--nick", " ", "--avatar-file-id", " ", "--yes"}, "至少需要一个修改项"},
|
||||
{"ownness missing id", []string{"user", "update-ownness", "--ownness-text", "居家办公中", "--yes"}, "required"},
|
||||
{"ownness blank id", []string{"user", "update-ownness", "--user-id", " ", "--ownness-text", "居家办公中", "--yes"}, "不能为空"},
|
||||
{"ownness missing text", []string{"user", "update-ownness", "--user-id", "user-1", "--yes"}, "required"},
|
||||
{"ownness blank text", []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", " ", "--yes"}, "不能为空"},
|
||||
{"account missing id", []string{"account", "update", "--nick", "小李", "--yes"}, "required"},
|
||||
{"account blank id", []string{"account", "update", "--user-id", " ", "--nick", "小李", "--yes"}, "不能为空"},
|
||||
{"account no changes", []string{"account", "update", "--user-id", "user-2", "--nick", " ", "--yes"}, "至少需要一个修改项"},
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -80,6 +81,101 @@ func requireTypedConfirmationError(t *testing.T, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
type guardedStepCaller struct {
|
||||
calls []guardedMutationCall
|
||||
steps []string
|
||||
index int
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *guardedStepCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, guardedMutationCall{productID: productID, toolName: toolName, args: args})
|
||||
text := `{}`
|
||||
if c.index < len(c.steps) {
|
||||
text = c.steps[c.index]
|
||||
}
|
||||
c.index++
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (*guardedStepCaller) Format() string { return "json" }
|
||||
func (c *guardedStepCaller) DryRun() bool { return c.dryRun }
|
||||
func (*guardedStepCaller) Fields() string { return "" }
|
||||
func (*guardedStepCaller) JQ() string { return "" }
|
||||
|
||||
func executeGuardedMailMutationCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = append([]string{"dws", "mail"}, args...)
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
root := newMailCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
if root.InOrStdin() == os.Stdin {
|
||||
root.SetIn(strings.NewReader(""))
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestMailMessageShareToChatRequiresConfirmationBeforeToolCall(t *testing.T) {
|
||||
baseArgs := []string{
|
||||
"message", "share-to-chat",
|
||||
"--email", "user@company.com",
|
||||
"--id", "msg-1",
|
||||
"--users", "uid1",
|
||||
}
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMailMutationCommand(t, caller, baseArgs...)
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls = %#v, want none before confirmation", caller.calls)
|
||||
}
|
||||
|
||||
stepCaller := &guardedStepCaller{steps: []string{
|
||||
`{"result":{"sign":"sig","riskMessage":"careful"}}`,
|
||||
`{"ok":true}`,
|
||||
}}
|
||||
err = executeGuardedMailMutationCommand(t, stepCaller, append(append([]string(nil), baseArgs...), "--yes")...)
|
||||
if err != nil {
|
||||
t.Fatalf("confirmed share with sign returned error: %v", err)
|
||||
}
|
||||
if len(stepCaller.calls) != 2 {
|
||||
t.Fatalf("tool calls = %d, want 2 for sign retry", len(stepCaller.calls))
|
||||
}
|
||||
if stepCaller.calls[0].toolName != "share_message_to_chat" || stepCaller.calls[1].toolName != "share_message_to_chat" {
|
||||
t.Fatalf("tool calls = %#v, want share_message_to_chat twice", stepCaller.calls)
|
||||
}
|
||||
if sign, _ := stepCaller.calls[1].args["sign"].(string); sign != "sig" {
|
||||
t.Fatalf("second call sign = %q, want sig", sign)
|
||||
}
|
||||
|
||||
directCaller := &guardedStepCaller{steps: []string{`{"ok":true}`}}
|
||||
err = executeGuardedMailMutationCommand(t, directCaller, append(append([]string(nil), baseArgs...), "--yes")...)
|
||||
if err != nil {
|
||||
t.Fatalf("confirmed direct success returned error: %v", err)
|
||||
}
|
||||
if len(directCaller.calls) != 1 {
|
||||
t.Fatalf("tool calls = %d, want 1 for direct success", len(directCaller.calls))
|
||||
}
|
||||
if directCaller.calls[0].toolName != "share_message_to_chat" {
|
||||
t.Fatalf("tool call = %#v, want share_message_to_chat", directCaller.calls[0])
|
||||
}
|
||||
|
||||
dryRunCaller := &guardedMutationCaller{dryRun: true}
|
||||
err = executeGuardedMailMutationCommand(t, dryRunCaller, baseArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run without --yes returned error: %v", err)
|
||||
}
|
||||
if len(dryRunCaller.calls) != 0 {
|
||||
t.Fatalf("dry-run tool calls = %#v, want none", dryRunCaller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatDismissGroupRequiresConfirmationBeforeToolCall(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// Copyright 2022 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file is adapted from Go standard library's internal/diff package.
|
||||
// The original source can be found at /usr/local/go/src/internal/diff/diff.go.
|
||||
//
|
||||
// Modifications:
|
||||
// - Package changed from "diff" to "products"
|
||||
// - Diff() renamed to UnifiedDiff() and accepts a contextLines parameter
|
||||
// - const C replaced with the contextLines parameter
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A pair is a pair of values tracked for both the x and y side of a diff.
|
||||
// It is typically a pair of line indexes.
|
||||
type diffPair struct{ x, y int }
|
||||
|
||||
func nonNeg(v int) int {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// UnifiedDiff returns an anchored diff of the two texts old and new
|
||||
// in the "unified diff" format. If old and new are identical,
|
||||
// UnifiedDiff returns a nil slice (no output).
|
||||
//
|
||||
// Unix diff implementations typically look for a diff with
|
||||
// the smallest number of lines inserted and removed,
|
||||
// which can in the worst case take time quadratic in the
|
||||
// number of lines in the texts. As a result, many implementations
|
||||
// either can be made to run for a long time or cut off the search
|
||||
// after a predetermined amount of work.
|
||||
//
|
||||
// In contrast, this implementation looks for a diff with the
|
||||
// smallest number of "unique" lines inserted and removed,
|
||||
// where unique means a line that appears just once in both old and new.
|
||||
// We call this an "anchored diff" because the unique lines anchor
|
||||
// the chosen matching regions. An anchored diff is usually clearer
|
||||
// than a standard diff, because the algorithm does not try to
|
||||
// reuse unrelated blank lines or closing braces.
|
||||
// The algorithm also guarantees to run in O(n log n) time
|
||||
// instead of the standard O(n²) time.
|
||||
//
|
||||
// Some systems call this approach a "patience diff," named for
|
||||
// the "patience sorting" algorithm, itself named for a solitaire card game.
|
||||
// We avoid that name for two reasons. First, the name has been used
|
||||
// for a few different variants of the algorithm, so it is imprecise.
|
||||
// Second, the name is frequently interpreted as meaning that you have
|
||||
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
|
||||
// when in fact the algorithm is faster than the standard one.
|
||||
func UnifiedDiff(oldName string, old []byte, newName string, new []byte, contextLines int) []byte {
|
||||
if bytes.Equal(old, new) {
|
||||
return nil
|
||||
}
|
||||
x := diffLines(old)
|
||||
y := diffLines(new)
|
||||
|
||||
// Print diff header.
|
||||
var out bytes.Buffer
|
||||
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
|
||||
fmt.Fprintf(&out, "--- %s\n", oldName)
|
||||
fmt.Fprintf(&out, "+++ %s\n", newName)
|
||||
|
||||
// Loop over matches to consider,
|
||||
// expanding each match to include surrounding lines,
|
||||
// and then printing diff chunks.
|
||||
// To avoid setup/teardown cases outside the loop,
|
||||
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
|
||||
// in the sequence of matches.
|
||||
var (
|
||||
done diffPair // printed up to x[:done.x] and y[:done.y]
|
||||
chunk diffPair // start lines of current chunk
|
||||
count diffPair // number of lines from each side in current chunk
|
||||
ctext []string // lines for current chunk
|
||||
)
|
||||
for _, m := range diffTgs(x, y) {
|
||||
if m.x < done.x {
|
||||
// Already handled scanning forward from earlier match.
|
||||
continue
|
||||
}
|
||||
|
||||
// Expand matching lines as far as possible,
|
||||
// establishing that x[start.x:end.x] == y[start.y:end.y].
|
||||
// Note that on the first (or last) iteration we may (or definitely do)
|
||||
// have an empty match: start.x==end.x and start.y==end.y.
|
||||
start := m
|
||||
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
|
||||
start.x--
|
||||
start.y--
|
||||
}
|
||||
end := m
|
||||
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
|
||||
end.x++
|
||||
end.y++
|
||||
}
|
||||
|
||||
// Emit the mismatched lines before start into this chunk.
|
||||
// (No effect on first sentinel iteration, when start = {0,0}.)
|
||||
for _, s := range x[done.x:start.x] {
|
||||
ctext = append(ctext, "-"+s)
|
||||
count.x++
|
||||
}
|
||||
for _, s := range y[done.y:start.y] {
|
||||
ctext = append(ctext, "+"+s)
|
||||
count.y++
|
||||
}
|
||||
|
||||
// If we're not at EOF and have too few common lines,
|
||||
// the chunk includes all the common lines and continues.
|
||||
C := contextLines
|
||||
if C < 0 {
|
||||
// 防御性兼容:负值会破坏下方区间判定,按无上下文处理;0 是合法值,直接生效
|
||||
C = 0
|
||||
}
|
||||
if (end.x < len(x) || end.y < len(y)) &&
|
||||
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
|
||||
for _, s := range x[start.x:end.x] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = end
|
||||
continue
|
||||
}
|
||||
|
||||
// End chunk with common lines for context.
|
||||
if len(ctext) > 0 {
|
||||
n := end.x - start.x
|
||||
if n > C {
|
||||
n = C
|
||||
}
|
||||
for _, s := range x[start.x : start.x+n] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = diffPair{start.x + n, start.y + n}
|
||||
|
||||
// Format and emit chunk.
|
||||
// Convert line numbers to 1-indexed.
|
||||
// Special case: empty file shows up as 0,0 not 1,0.
|
||||
if count.x > 0 {
|
||||
chunk.x++
|
||||
}
|
||||
if count.y > 0 {
|
||||
chunk.y++
|
||||
}
|
||||
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
|
||||
for _, s := range ctext {
|
||||
out.WriteString(s)
|
||||
}
|
||||
count.x = 0
|
||||
count.y = 0
|
||||
ctext = ctext[:0]
|
||||
}
|
||||
|
||||
// If we reached EOF, we're done.
|
||||
if end.x >= len(x) && end.y >= len(y) {
|
||||
break
|
||||
}
|
||||
|
||||
// Otherwise start a new chunk.
|
||||
// C is clamped to >= 0 above; nonNeg saturates end-C when the next
|
||||
// hunk would start before line 0 (defensive; exercised via nonNeg tests).
|
||||
chunk = diffPair{nonNeg(end.x - C), nonNeg(end.y - C)}
|
||||
for _, s := range x[chunk.x:end.x] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = end
|
||||
}
|
||||
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// diffLines returns the lines in the file x, including newlines.
|
||||
// If the file does not end in a newline, one is supplied
|
||||
// along with a warning about the missing newline.
|
||||
func diffLines(x []byte) []string {
|
||||
l := strings.SplitAfter(string(x), "\n")
|
||||
if l[len(l)-1] == "" {
|
||||
l = l[:len(l)-1]
|
||||
} else {
|
||||
// Treat last line as having a message about the missing newline attached,
|
||||
// using the same text as BSD/GNU diff (including the leading backslash).
|
||||
l[len(l)-1] += "\n\\ No newline at end of file\n"
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// diffTgs returns the pairs of indexes of the longest common subsequence
|
||||
// of unique lines in x and y, where a unique line is one that appears
|
||||
// once in x and once in y.
|
||||
//
|
||||
// The longest common subsequence algorithm is as described in
|
||||
// Thomas G. Szymanski, "A Special Case of the Maximal Common
|
||||
// Subsequence Problem," Princeton TR #170 (January 1975),
|
||||
// available at https://research.swtch.com/tgs170.pdf.
|
||||
func diffTgs(x, y []string) []diffPair {
|
||||
// Count the number of times each string appears in a and b.
|
||||
// We only care about 0, 1, many, counted as 0, -1, -2
|
||||
// for the x side and 0, -4, -8 for the y side.
|
||||
// Using negative numbers now lets us distinguish positive line numbers later.
|
||||
m := make(map[string]int)
|
||||
for _, s := range x {
|
||||
if c := m[s]; c > -2 {
|
||||
m[s] = c - 1
|
||||
}
|
||||
}
|
||||
for _, s := range y {
|
||||
if c := m[s]; c > -8 {
|
||||
m[s] = c - 4
|
||||
}
|
||||
}
|
||||
|
||||
// Now unique strings can be identified by m[s] = -1+-4.
|
||||
//
|
||||
// Gather the indexes of those strings in x and y, building:
|
||||
// xi[i] = increasing indexes of unique strings in x.
|
||||
// yi[i] = increasing indexes of unique strings in y.
|
||||
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
|
||||
var xi, yi, inv []int
|
||||
for i, s := range y {
|
||||
if m[s] == -1+-4 {
|
||||
m[s] = len(yi)
|
||||
yi = append(yi, i)
|
||||
}
|
||||
}
|
||||
for i, s := range x {
|
||||
if j, ok := m[s]; ok && j >= 0 {
|
||||
xi = append(xi, i)
|
||||
inv = append(inv, j)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Algorithm A from Szymanski's paper.
|
||||
// In those terms, A = J = inv and B = [0, n).
|
||||
// We add sentinel pairs {0,0}, and {len(x),len(y)}
|
||||
// to the returned sequence, to help the processing loop.
|
||||
J := inv
|
||||
n := len(xi)
|
||||
T := make([]int, n)
|
||||
L := make([]int, n)
|
||||
for i := range T {
|
||||
T[i] = n + 1
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
k := sort.Search(n, func(k int) bool {
|
||||
return T[k] >= J[i]
|
||||
})
|
||||
T[k] = J[i]
|
||||
L[i] = k + 1
|
||||
}
|
||||
k := 0
|
||||
for _, v := range L {
|
||||
if k < v {
|
||||
k = v
|
||||
}
|
||||
}
|
||||
seq := make([]diffPair, 2+k)
|
||||
seq[1+k] = diffPair{len(x), len(y)} // sentinel at end
|
||||
lastj := n
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if L[i] == k && J[i] < lastj {
|
||||
seq[k] = diffPair{xi[i], yi[J[i]]}
|
||||
k--
|
||||
}
|
||||
}
|
||||
seq[0] = diffPair{0, 0} // sentinel at start
|
||||
return seq
|
||||
}
|
||||
+64
-7
@@ -816,6 +816,8 @@ func newDocCommand() *cobra.Command {
|
||||
dws doc create 创建文档
|
||||
dws doc update 更新文档内容
|
||||
dws doc block [list|insert|update|delete] 块级编辑
|
||||
dws doc whiteboard insert 插入空白板卡片 (返回 blockId 与白板 partId)
|
||||
dws doc media [upload|download] 文档媒体资源 (上传可复用资源 / 下载附件)
|
||||
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
|
||||
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
|
||||
dws doc export get 查询导出任务结果 (手动兜底)
|
||||
@@ -2525,6 +2527,54 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
|
||||
mediaDownloadCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||||
mediaDownloadCmd.Flags().String("resource-id", "", "附件资源 ID,可通过 dws doc block list 获取 (必填)")
|
||||
|
||||
mediaUploadCmd := &cobra.Command{
|
||||
Use: "upload",
|
||||
Short: "上传可复用的文档媒体资源",
|
||||
Long: `将本地文件上传为绑定到目标 nodeId 的文档媒体资源,但不插入文档正文。
|
||||
|
||||
成功输出稳定的 resourceId 和 resourceUrl,可供同一 nodeId 下的白板 Vector/SVG
|
||||
等后续写入使用;临时 uploadUrl 不会输出。`,
|
||||
Example: ` dws doc media upload --node DOC_ID --file ./icon.svg --mime-type image/svg+xml --format json`,
|
||||
RunE: runDocMediaUpload,
|
||||
}
|
||||
DeclareLeafMetadata(mediaUploadCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "doc",
|
||||
Name: "media_upload",
|
||||
CanonicalPath: "doc.media_upload",
|
||||
CLIPath: "doc media upload",
|
||||
PrimaryCLIPath: "doc media upload",
|
||||
},
|
||||
Description: "上传可复用的文档媒体资源",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "命令先获取临时文档上传凭证,再在本地执行 OSS PUT,并仅暴露稳定的 node 绑定资源契约,不能绑定为单一 interface_ref",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "经用户确认后上传绑定到文档 nodeId 的可复用媒体资源而不插入正文",
|
||||
UseWhen: []string{"为同一文档内白板的 Vector/SVG 写入准备 resourceId 和 resourceUrl 时"},
|
||||
AvoidWhen: []string{"需要把附件直接插入文档正文时用 doc media insert;不要跨 nodeId 复用资源"},
|
||||
Examples: []string{"dws doc media upload --node <DOC_ID> --file ./icon.svg --mime-type image/svg+xml --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
{Name: "file", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
mediaUploadCmd.Flags().String("node", "", "绑定媒体资源的文档标识,支持传入 URL 或 ID (必填)")
|
||||
mediaUploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||||
mediaUploadCmd.Flags().String("name", "", "资源文件名 (默认使用本地文件名)")
|
||||
mediaUploadCmd.Flags().String("mime-type", "", "文件 MIME 类型 (默认根据扩展名推断)")
|
||||
mediaUploadCmd.Flags().Bool("yes", false, "确认上传可复用文档媒体资源")
|
||||
|
||||
mediaInsertCmd := &cobra.Command{
|
||||
Use: "insert",
|
||||
Short: "上传附件并插入文档",
|
||||
@@ -2587,7 +2637,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
|
||||
mediaInsertCmd.Flags().String("ref-block", "", "参考块 ID (配合 --where)")
|
||||
|
||||
// media 子命令的 --node 隐藏别名
|
||||
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaInsertCmd}
|
||||
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd}
|
||||
for _, c := range mediaNodeAliasCmds {
|
||||
c.Flags().String("url", "", "--node 的别名")
|
||||
c.Flags().String("id", "", "--node 的别名")
|
||||
@@ -2601,7 +2651,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
|
||||
_ = c.Flags().MarkHidden("file-id")
|
||||
}
|
||||
|
||||
mediaCmd.AddCommand(mediaDownloadCmd, mediaInsertCmd)
|
||||
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
|
||||
|
||||
// ── comment (文档评论) ──────────────────────────────────
|
||||
commentCmd := &cobra.Command{
|
||||
@@ -3607,11 +3657,13 @@ CLI 内部自动完成全部流程:
|
||||
PROCESSING 处理中
|
||||
SUCCESS 导出成功,返回 downloadUrl
|
||||
FAILED 导出失败`,
|
||||
Example: ` dws doc export get --job-id <JOB_ID>`,
|
||||
Example: ` dws doc export get --job-id <JOB_ID>
|
||||
dws doc export get --task-id <TASK_ID>`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
jobID := mustGetFlag(cmd, "job-id")
|
||||
if jobID == "" {
|
||||
return fmt.Errorf("flag --job-id is required")
|
||||
// Keep --job-id as the visible primary; --task-id is an add-only synonym.
|
||||
jobID, err := mustFlagOrFallback(cmd, "job-id", "task-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
@@ -3678,9 +3730,14 @@ CLI 内部自动完成全部流程:
|
||||
AvoidWhen: []string{"常规导出请直接 dws doc export(一体化提交+轮询+下载),不要先查 job"},
|
||||
Examples: []string{"dws doc export get --job-id <JOB_ID> --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "job-id", Property: "jobId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
exportGetCmd.Flags().String("job-id", "", "导出任务 ID (必填)")
|
||||
exportGetCmd.Flags().String("task-id", "", "--job-id 的别名")
|
||||
_ = exportGetCmd.Flags().MarkHidden("task-id")
|
||||
|
||||
// --node 的隐藏别名(与 doc 下其他命令保持一致)
|
||||
exportCmd.Flags().String("url", "", "--node 的别名")
|
||||
@@ -4227,7 +4284,7 @@ CLI 内部自动完成全部流程:
|
||||
folderCmd.Hidden = true
|
||||
permissionCmd.Hidden = true
|
||||
|
||||
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand())
|
||||
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand(), newDocWhiteboardCommand())
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// runDocMediaUpload 上传绑定到文档 nodeId 的可复用媒体资源,但不插入正文块。
|
||||
// 白板 Vector/SVG 使用返回的 resourceId 与 resourceUrl 引用同一文档下的资源。
|
||||
func runDocMediaUpload(cmd *cobra.Command, _ []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filePath := mustGetFlag(cmd, "file")
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("flag --file is required")
|
||||
}
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read file %s: %w", filePath, err)
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return fmt.Errorf("%s is a directory, not a file", filePath)
|
||||
}
|
||||
|
||||
fileName, _ := cmd.Flags().GetString("name")
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
} else if filepath.Ext(fileName) == "" {
|
||||
fileName += filepath.Ext(filePath)
|
||||
}
|
||||
mimeType, _ := cmd.Flags().GetString("mime-type")
|
||||
if mimeType == "" {
|
||||
mimeType = inferMimeType(fileName)
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("doc", "get_doc_attachment_upload_info", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"fileName": fileName,
|
||||
"fileSize": float64(fileInfo.Size()),
|
||||
"mimeType": mimeType,
|
||||
})
|
||||
}
|
||||
|
||||
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
|
||||
// 推迟到首次 deps.Caller.CallTool(下方 get_doc_attachment_upload_info),
|
||||
// 避免与门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
|
||||
text, err := callMCPToolReturnTextOnServer(cmd.Context(), "doc", "get_doc_attachment_upload_info", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"fileName": fileName,
|
||||
"fileSize": float64(fileInfo.Size()),
|
||||
"mimeType": mimeType,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uploadURL, resourceID, resourceURL, err := parseAttachmentUploadInfo(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resourceURL == "" {
|
||||
return fmt.Errorf("incomplete attachment upload info: missing resourceUrl")
|
||||
}
|
||||
if err := httpPutFile(cmd.Context(), uploadURL, map[string]string{"Content-Type": mimeType}, filePath, fileInfo.Size()); err != nil {
|
||||
message := strings.ReplaceAll(err.Error(), uploadURL, "<redacted upload URL>")
|
||||
return fmt.Errorf("document media upload failed: %s", message)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"resourceId": resourceID,
|
||||
"resourceUrl": resourceURL,
|
||||
"fileName": fileName,
|
||||
"mimeType": mimeType,
|
||||
"size": fileInfo.Size(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// RunDocImportShortcut exposes the existing, fully-tested Doc import pipeline
|
||||
// to the Shortcut application layer. The Cobra leaf still owns its own flags
|
||||
// and Contract; this bridge only shares the raw/API execution primitive.
|
||||
func RunDocImportShortcut(cmd *cobra.Command) error {
|
||||
return runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
}
|
||||
|
||||
// RunDocMediaInsertShortcut shares the existing prepare + OSS PUT + block
|
||||
// insertion implementation with the canonical Doc Shortcut.
|
||||
func RunDocMediaInsertShortcut(cmd *cobra.Command) error {
|
||||
return runMediaInsert(cmd, nil)
|
||||
}
|
||||
|
||||
// RunDocResourceUpdateShortcut shares the cover upload/transfer pipeline.
|
||||
func RunDocResourceUpdateShortcut(cmd *cobra.Command) error {
|
||||
return runDocStyleCoverSet(cmd, nil)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
const (
|
||||
whiteboardDrawPluginType = "application/x-alidocs-plugin-draw"
|
||||
whiteboardDefaultHeight = 600
|
||||
)
|
||||
|
||||
// errWhiteboardBlockPending 标记「块查询成功但目标块尚不可见」这一最终一致性场景。
|
||||
// 只有它允许插入后回查退化成 soft success;鉴权失败、MCP 错误、响应/JSONML 解析失败
|
||||
// 都是硬失败,必须 fail-closed,否则 Agent 会把它误判成最终一致性并带着空 partId
|
||||
// 继续调用 whiteboard query/update。
|
||||
var errWhiteboardBlockPending = errors.New("whiteboard card block is not visible yet")
|
||||
|
||||
var (
|
||||
whiteboardRetryDelays = []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second}
|
||||
whiteboardSleep = time.Sleep
|
||||
whiteboardJSONMarshal = json.Marshal
|
||||
prepareWhiteboardCard = prepareJsonMLNode
|
||||
)
|
||||
|
||||
func buildWhiteboardCardJSONML(blockUUID, whiteboardID string) string {
|
||||
node := []any{
|
||||
"card",
|
||||
map[string]any{
|
||||
"uuid": blockUUID,
|
||||
"cardType": "hetu",
|
||||
"height": whiteboardDefaultHeight,
|
||||
"metadata": map[string]any{"type": whiteboardDrawPluginType, "id": whiteboardID},
|
||||
},
|
||||
[]any{"span", map[string]any{"data-type": "text"},
|
||||
[]any{"span", map[string]any{"data-type": "leaf"}, ""}},
|
||||
}
|
||||
out, err := whiteboardJSONMarshal(node)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func extractWhiteboardID(attrs map[string]any) string {
|
||||
meta, _ := attrs["metadata"].(map[string]any)
|
||||
if meta == nil {
|
||||
return ""
|
||||
}
|
||||
id, _ := meta["id"].(string)
|
||||
return id
|
||||
}
|
||||
|
||||
func queryWhiteboardCardNode(ctx context.Context, nodeID, blockID string) ([]any, error) {
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"blockId": blockID,
|
||||
"format": "jsonml",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||||
return nil, fmt.Errorf("parse list_document_blocks response: %w", err)
|
||||
}
|
||||
if result, ok := data["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
blocksField, ok := data["blocks"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("list_document_blocks 响应缺少 blocks 字段")
|
||||
}
|
||||
blocks, ok := blocksField.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("list_document_blocks 响应的 blocks 字段不是数组")
|
||||
}
|
||||
var raw string
|
||||
for _, block := range blocks {
|
||||
entry, _ := block.(map[string]any)
|
||||
if entry == nil || entry["blockId"] != blockID {
|
||||
continue
|
||||
}
|
||||
raw, _ = entry["jsonml"].(string)
|
||||
break
|
||||
}
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("块 %s 不存在或查询无结果: %w", blockID, errWhiteboardBlockPending)
|
||||
}
|
||||
var node []any
|
||||
if err := json.Unmarshal([]byte(raw), &node); err != nil {
|
||||
return nil, fmt.Errorf("parse block jsonml: %w", err)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func queryWhiteboardCardAttrs(ctx context.Context, nodeID, blockID string) (map[string]any, error) {
|
||||
node, err := queryWhiteboardCardNode(ctx, nodeID, blockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(node) < 2 {
|
||||
return nil, fmt.Errorf("块 %s 的 jsonml 节点缺少 attrs", blockID)
|
||||
}
|
||||
attrs, _ := node[1].(map[string]any)
|
||||
if attrs == nil {
|
||||
return nil, fmt.Errorf("块 %s 的 jsonml attrs 不是对象", blockID)
|
||||
}
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
func runWhiteboardInsert(cmd *cobra.Command, _ []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blockUUID := uuid.New().String()
|
||||
whiteboardID := uuid.New().String()
|
||||
element := buildWhiteboardCardJSONML(blockUUID, whiteboardID)
|
||||
normalized, err := prepareWhiteboardCard(cmd, element)
|
||||
if err != nil {
|
||||
return fmt.Errorf("内部错误: 白板卡片模板未通过 JSONML 校验: %w", err)
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"jsonml": normalized,
|
||||
"format": "jsonml",
|
||||
}
|
||||
// --ref-block 与 --parent-block 已由 MarkFlagsMutuallyExclusive 保证互斥,
|
||||
// 这里用 else if 让「只有一条定位分支会写 referenceBlockId/where」在代码上自证。
|
||||
if v, _ := cmd.Flags().GetString("ref-block"); v != "" {
|
||||
toolArgs["referenceBlockId"] = v
|
||||
where, _ := cmd.Flags().GetString("where")
|
||||
if where == "" {
|
||||
where = "after"
|
||||
}
|
||||
toolArgs["where"] = where
|
||||
} else if v, _ := cmd.Flags().GetString("parent-block"); v != "" {
|
||||
toolArgs["referenceBlockId"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("index") {
|
||||
index, _ := cmd.Flags().GetInt("index")
|
||||
toolArgs["index"] = index
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("doc", "insert_document_block", toolArgs)
|
||||
}
|
||||
|
||||
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
|
||||
// 推迟到首次 deps.Caller.CallTool(下方 insert_document_block),避免与
|
||||
// 门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
|
||||
ctx := cmd.Context()
|
||||
deps.Out.PrintProgress("[1/2] 插入白板卡片...")
|
||||
if _, err := callMCPToolReturnTextOnServer(ctx, "doc", "insert_document_block", toolArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deps.Out.PrintProgress("[2/2] 验证白板资源 ID 落库...")
|
||||
persistedID := ""
|
||||
for attempt := 0; attempt <= len(whiteboardRetryDelays); attempt++ {
|
||||
attrs, queryErr := queryWhiteboardCardAttrs(ctx, nodeID, blockUUID)
|
||||
switch {
|
||||
case queryErr == nil:
|
||||
// 块已可见;metadata.id 仍可能未落库,交给下方 soft success 分支重试。
|
||||
persistedID = extractWhiteboardID(attrs)
|
||||
case errors.Is(queryErr, errWhiteboardBlockPending):
|
||||
// 块暂不可见,属于最终一致性,继续重试。
|
||||
default:
|
||||
// 查询本身失败(鉴权 / MCP / 响应解析),不是最终一致性:
|
||||
// 必须 fail-closed,同时带出已插入的 blockId 供人工或后续回查复原。
|
||||
return fmt.Errorf(
|
||||
"白板卡片已插入 (blockId=%s),但回查验证失败,无法确认 whiteboardId: %w",
|
||||
blockUUID, queryErr)
|
||||
}
|
||||
if persistedID != "" {
|
||||
break
|
||||
}
|
||||
if attempt < len(whiteboardRetryDelays) {
|
||||
whiteboardSleep(whiteboardRetryDelays[attempt])
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]any{"blockId": blockUUID}
|
||||
if persistedID == "" {
|
||||
result["whiteboardId"] = nil
|
||||
deps.Out.PrintWarning(fmt.Sprintf(
|
||||
"白板已插入但未验证到 whiteboardId 落库,可稍后回查: dws doc block list --node %s --content-format jsonml --block-id %s",
|
||||
nodeID, blockUUID))
|
||||
} else {
|
||||
result["whiteboardId"] = persistedID
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{"success": true, "result": result})
|
||||
}
|
||||
|
||||
func newDocWhiteboardCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "whiteboard",
|
||||
Short: "白板卡片管理",
|
||||
Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
insertCmd := &cobra.Command{
|
||||
Use: "insert",
|
||||
Short: "插入白板卡片",
|
||||
Long: `向文档插入一个空白板卡片(hetu draw card),并返回 blockId 与 whiteboardId。
|
||||
|
||||
CLI 生成卡片块 UUID 与白板资源 ID,插入后按块 UUID 回查并验证 metadata.id 落库。
|
||||
如果块暂不可见或 metadata.id 尚未落库,插入仍成功并返回 blockId,whiteboardId 为 null。
|
||||
如果回查本身失败(鉴权 / MCP 错误 / 响应解析失败),命令报错并在错误中带出已插入的 blockId。
|
||||
|
||||
定位方式互斥: --ref-block(配合 --where 同级插入)与 --parent-block(配合 --index 容器内插入)
|
||||
不能同时使用。`,
|
||||
Example: ` dws doc whiteboard insert --node DOC_ID
|
||||
dws doc whiteboard insert --node DOC_ID --ref-block BLOCK_ID --where before
|
||||
dws doc whiteboard insert --node DOC_ID --parent-block PARENT_ID --index 2`,
|
||||
RunE: runWhiteboardInsert,
|
||||
}
|
||||
insertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||||
insertCmd.Flags().String("ref-block", "", "参照块 UUID(同级插入,配合 --where)")
|
||||
insertCmd.Flags().String("where", "", "插入方向: before / after (默认 after,配合 --ref-block)")
|
||||
insertCmd.Flags().String("parent-block", "", "父容器 UUID(容器内插入,与 --index 配合)")
|
||||
insertCmd.Flags().Int("index", 0, "位置索引 (从 0 开始)")
|
||||
insertCmd.Flags().Bool("yes", false, "确认插入白板卡片")
|
||||
|
||||
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者会让 parent 静默
|
||||
// 覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。显式互斥而非静默取舍。
|
||||
insertCmd.MarkFlagsMutuallyExclusive("ref-block", "parent-block")
|
||||
insertCmd.MarkFlagsMutuallyExclusive("where", "parent-block")
|
||||
|
||||
for _, name := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
|
||||
insertCmd.Flags().String(name, "", "--node 的兼容别名")
|
||||
_ = insertCmd.Flags().MarkHidden(name)
|
||||
}
|
||||
|
||||
DeclareLeafMetadata(insertCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "doc",
|
||||
Name: "whiteboard_insert",
|
||||
CanonicalPath: "doc.whiteboard_insert",
|
||||
CLIPath: "doc whiteboard insert",
|
||||
PrimaryCLIPath: "doc whiteboard insert",
|
||||
},
|
||||
Description: "向文档插入空白板卡片并返回块 ID 与白板 part ID",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "命令生成卡片与白板 UUID、插入规范 JSONML,再回读块验证 metadata.id,不能绑定为单一 interface_ref",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "经用户确认后向钉钉文档插入空白板卡片并返回块 ID 与白板 part ID",
|
||||
UseWhen: []string{"目标文档还没有可操作白板,需要创建空白板卡片并取得后续 query/update 使用的 partId 时"},
|
||||
AvoidWhen: []string{"已有白板只需读取或编辑时使用 whiteboard query/update;删除卡片使用 doc block delete"},
|
||||
Examples: []string{"dws doc whiteboard insert --node <DOC_ID> --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root.AddCommand(insertCmd)
|
||||
return root
|
||||
}
|
||||
@@ -316,12 +316,24 @@ func newDriveCommand() *cobra.Command {
|
||||
Example: ` dws drive list --limit 20
|
||||
dws drive list --folder <dentryUuid> --order-by name --order asc
|
||||
dws drive list --workspace <workspaceId>
|
||||
dws drive list --workspace <workspaceId> --folder <folderId>`,
|
||||
dws drive list --workspace <workspaceId> --folder <folderId>
|
||||
dws drive list --latest 5
|
||||
dws drive list --folder <dentryUuid> --latest 3 --pattern "*.docx"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pattern, _ := cmd.Flags().GetString("pattern")
|
||||
|
||||
depth, _ := cmd.Flags().GetInt("depth")
|
||||
|
||||
latest, _ := cmd.Flags().GetInt("latest")
|
||||
if cmd.Flags().Changed("latest") {
|
||||
if err := validateDriveListLatest(cmd, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Flags().Changed("versions") {
|
||||
return &CLIError{Code: CodeInvalidParam, Message: "--latest 不能与 --versions 同时使用"}
|
||||
}
|
||||
}
|
||||
|
||||
// --versions 模式:列出文件历史版本(仅普通文件)
|
||||
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
|
||||
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
|
||||
@@ -363,7 +375,7 @@ func newDriveCommand() *cobra.Command {
|
||||
if workspaceID != "" {
|
||||
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
|
||||
// 知识库无对应参数,静默忽略。
|
||||
if depth > 1 {
|
||||
if depth > 1 || latest > 0 {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{"workspaceId": workspaceID}
|
||||
rootFolder := docFolderFlag(cmd, "node", "file-id")
|
||||
@@ -372,7 +384,7 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
}
|
||||
if pattern != "" {
|
||||
return &CLIError{
|
||||
@@ -417,10 +429,27 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
}
|
||||
|
||||
// 默认路由:钉盘文件列表
|
||||
if latest > 0 {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{}
|
||||
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
|
||||
baseArgs["spaceId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
|
||||
baseArgs["withThumbnail"] = true
|
||||
}
|
||||
rootFolder := flagOrFallback(cmd, "folder", "parent-id")
|
||||
if rootFolder != "" {
|
||||
if err := validateDriveParentID(rootFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListLatest(cmd, baseArgs, rootFolder, latest, pattern, quiet)
|
||||
}
|
||||
maxResults, _ := cmd.Flags().GetInt("limit")
|
||||
if !cmd.Flags().Changed("limit") {
|
||||
if v, _ := cmd.Flags().GetInt("max"); v > 0 {
|
||||
@@ -491,7 +520,7 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
Examples: []string{
|
||||
"dws drive list --limit 20 --format json",
|
||||
"dws drive list --folder <dentryUuid> --limit 20 --format json",
|
||||
"dws drive list --latest 5 --format json",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -701,6 +730,8 @@ func newDriveCommand() *cobra.Command {
|
||||
{Name: "part-size", Description: "分片下载的分片大小(如 8MB/16MB/1GB)"},
|
||||
{Name: "parallel", Description: "分片下载并发数(1-8)"},
|
||||
{Name: "no-resume", Description: "关闭断点续传"},
|
||||
// Wukong compat alias: routes to download-version; not a download_file property.
|
||||
{Name: "version", Description: "下载指定历史版本号(兼容别名,等价 download-version)"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1052,7 +1083,8 @@ func newDriveCommand() *cobra.Command {
|
||||
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
|
||||
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
|
||||
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (仅 --depth>1 时有效) (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
|
||||
|
||||
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
|
||||
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
|
||||
@@ -1060,6 +1092,7 @@ func newDriveCommand() *cobra.Command {
|
||||
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
|
||||
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
|
||||
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
|
||||
driveDownloadCmd.Flags().Int("version", 0, "下载指定历史版本号(兼容别名,等价 download-version)")
|
||||
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
|
||||
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
|
||||
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
|
||||
@@ -1074,6 +1107,14 @@ func newDriveCommand() *cobra.Command {
|
||||
driveDownloadVersionCmd.Flags().String(alias, "", "")
|
||||
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
// Wukong compat: `drive download --version N` routes to download-version.
|
||||
origDriveDownloadRunE := driveDownloadCmd.RunE
|
||||
driveDownloadCmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if cmd.Flags().Changed("version") {
|
||||
return driveDownloadVersionCmd.RunE(cmd, args)
|
||||
}
|
||||
return origDriveDownloadRunE(cmd, args)
|
||||
}
|
||||
|
||||
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
|
||||
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
|
||||
@@ -2019,6 +2060,8 @@ func newDriveCommand() *cobra.Command {
|
||||
limit := 0
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ = cmd.Flags().GetInt("limit")
|
||||
} else if cmd.Flags().Changed("max-results") {
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
}
|
||||
if limit > 0 {
|
||||
toolArgs["maxResults"] = limit
|
||||
@@ -2070,6 +2113,8 @@ func newDriveCommand() *cobra.Command {
|
||||
})
|
||||
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
drivePermListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = drivePermListCmd.Flags().MarkHidden("max-results")
|
||||
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
|
||||
@@ -2733,7 +2778,7 @@ func newDriveCommand() *cobra.Command {
|
||||
|
||||
// ── cross-product hidden aliases ──
|
||||
for _, cmd := range []*cobra.Command{
|
||||
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
|
||||
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd, driveDownloadVersionCmd,
|
||||
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
|
||||
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
|
||||
driveFolderCreateCmd,
|
||||
@@ -3035,6 +3080,11 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
driveCoverCmd.Flags().String(alias, "", "--node 的别名")
|
||||
_ = driveCoverCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
RegisterCrossProductAliases(driveCoverCmd)
|
||||
|
||||
// ── drive revert (回滚文件到指定历史版本) ──
|
||||
driveRevertCmd := &cobra.Command{
|
||||
@@ -3087,6 +3137,21 @@ func newDriveCommand() *cobra.Command {
|
||||
})
|
||||
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
driveRevertCmd.Flags().String(alias, "", "--node 的别名")
|
||||
_ = driveRevertCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
RegisterCrossProductAliases(driveRevertCmd)
|
||||
|
||||
for _, child := range driveStarCmd.Commands() {
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
if child.Flags().Lookup(alias) == nil {
|
||||
child.Flags().String(alias, "", "--node 的别名")
|
||||
_ = child.Flags().MarkHidden(alias)
|
||||
}
|
||||
}
|
||||
RegisterCrossProductAliases(child)
|
||||
}
|
||||
|
||||
driveCmd.AddCommand(
|
||||
driveListCmd,
|
||||
|
||||
@@ -178,7 +178,7 @@ func newDocDepthRoute() driveDepthRoute {
|
||||
}
|
||||
|
||||
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool) error {
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return printDriveDepthDryRun(route, baseArgs, maxDepth)
|
||||
}
|
||||
@@ -209,7 +209,7 @@ func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[s
|
||||
bfs:
|
||||
for len(queue) > 0 {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
folder := queue[0]
|
||||
queue = queue[1:]
|
||||
@@ -220,7 +220,7 @@ bfs:
|
||||
pages := 0
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
pages++
|
||||
if pages > maxPagesPerFolder {
|
||||
@@ -233,7 +233,7 @@ bfs:
|
||||
args := route.buildArgs(baseArgs, folder.id, pageToken)
|
||||
text, err := route.fetchPage(ctx, args)
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
if err != nil {
|
||||
folderErr = err
|
||||
@@ -249,6 +249,12 @@ bfs:
|
||||
item["depth"] = folder.depth + 1
|
||||
item["parentId"] = folder.id // 根级为空串
|
||||
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
|
||||
// 时间戳归一:钉盘 modifyTime / 知识库 updateTime 统一为 sortTime(毫秒 int64)
|
||||
if ms, ok := driveItemModifiedMillis(item); ok {
|
||||
item["sortTime"] = ms
|
||||
} else {
|
||||
item["sortTime"] = int64(0)
|
||||
}
|
||||
collected = append(collected, item)
|
||||
if len(collected) >= driveDepthMaxItems {
|
||||
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
|
||||
@@ -291,7 +297,7 @@ bfs:
|
||||
if driveDepthUnrecoverable(folderErr) {
|
||||
// partial 照吐 stdout,错误详情走 stderr,非零退出
|
||||
errs = append(errs, newDriveDepthError(folder, folderErr))
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern); emitErr != nil {
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
return folderErr
|
||||
@@ -324,18 +330,26 @@ bfs:
|
||||
}
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern)
|
||||
if truncated && latest > 0 {
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
|
||||
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
|
||||
}
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
|
||||
}
|
||||
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern); err != nil {
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth); err != nil {
|
||||
return err
|
||||
}
|
||||
return &driveDepthCancelledError{}
|
||||
}
|
||||
|
||||
// depth>1 不输出 nextToken。
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string) error {
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
|
||||
if pattern != "" {
|
||||
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
@@ -350,21 +364,28 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ri, _ := items[i]["rel_path"].(string)
|
||||
rj, _ := items[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
|
||||
})
|
||||
if latest > 0 {
|
||||
items = applyDriveListLatest(items, latest)
|
||||
} else {
|
||||
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ri, _ := items[i]["rel_path"].(string)
|
||||
rj, _ := items[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
|
||||
})
|
||||
}
|
||||
maxDepth := 0
|
||||
for _, item := range items {
|
||||
if d, ok := item["depth"].(int); ok && d > maxDepth {
|
||||
maxDepth = d
|
||||
}
|
||||
}
|
||||
if latest > 0 && reqDepth == 1 {
|
||||
stripDriveDepthDecorations(items)
|
||||
}
|
||||
if items == nil {
|
||||
items = []map[string]any{}
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
|
||||
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
|
||||
}
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx"); err != nil {
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
@@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(nil, nil, false, ""); err != nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result = decodeDepthResult(t, out)
|
||||
@@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
|
||||
}
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(samePath, nil, false, ""); err != nil {
|
||||
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = decodeDepthResult(t, out)["items"].([]any)
|
||||
@@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
deps.Out.w = failingWriter{}
|
||||
if err := emitDriveDepthResult(nil, nil, false, ""); err == nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err == nil {
|
||||
t.Fatal("failing writer returned nil")
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute
|
||||
t.Helper()
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
|
||||
return decodeDepthResult(t, out), err
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
@@ -413,7 +413,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
@@ -548,7 +548,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 3 {
|
||||
@@ -593,7 +593,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil {
|
||||
t.Fatal("root failure returned nil")
|
||||
}
|
||||
@@ -610,7 +610,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil {
|
||||
t.Fatal("unrecoverable returned nil")
|
||||
}
|
||||
@@ -635,7 +635,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testi
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -648,7 +648,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
|
||||
t.Fatalf("err = %v, want pagination anomaly", err)
|
||||
}
|
||||
@@ -690,7 +690,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -710,7 +710,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -724,7 +724,7 @@ func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -772,7 +772,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *test
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -801,7 +801,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T)
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
// --latest 上限与 --limit 服务端每页硬上限 50 对齐。
|
||||
driveLatestMax = 50
|
||||
// 钉盘单层 latest 扫描上限(50×20 页)。
|
||||
driveLatestScanMax = 1000
|
||||
)
|
||||
|
||||
// validateDriveListLatest --latest 边界与互斥校验。
|
||||
func validateDriveListLatest(cmd *cobra.Command, latest int) error {
|
||||
if latest < 1 || latest > driveLatestMax {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--latest 必须为 1~%d 的整数,当前: %d", driveLatestMax, latest),
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"order-by", "order"} {
|
||||
if cmd.Flags().Changed(f) {
|
||||
return driveLatestExclusiveError(f, latest)
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
|
||||
return driveLatestExclusiveError("limit", latest)
|
||||
}
|
||||
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
|
||||
return driveLatestExclusiveError("cursor", latest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func driveLatestExclusiveError(flag string, latest int) error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--latest 不能与 --%s 同时使用:Top-N 排序语义由 latest 独占;如需自定义排序请改用 --order-by modifyTime --order desc --limit %d", flag, latest),
|
||||
}
|
||||
}
|
||||
|
||||
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
|
||||
files := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
|
||||
continue
|
||||
}
|
||||
files = append(files, item)
|
||||
}
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
ti, _ := files[i]["sortTime"].(int64)
|
||||
tj, _ := files[j]["sortTime"].(int64)
|
||||
if ti != tj {
|
||||
return ti > tj
|
||||
}
|
||||
ri, _ := files[i]["rel_path"].(string)
|
||||
rj, _ := files[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(files[i]) < driveDepthItemID(files[j])
|
||||
})
|
||||
if len(files) > latest {
|
||||
files = files[:latest]
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func stripDriveDepthDecorations(items []map[string]any) {
|
||||
for _, item := range items {
|
||||
delete(item, "depth")
|
||||
delete(item, "parentId")
|
||||
delete(item, "rel_path")
|
||||
delete(item, "sortTime")
|
||||
}
|
||||
}
|
||||
|
||||
func driveItemModifiedMillis(item map[string]any) (int64, bool) {
|
||||
for _, k := range []string{"modifiedTime", "modifyTime", "modified_time", "gmtModified", "lastModifiedTime", "updateTime"} {
|
||||
if v, ok := item[k]; ok {
|
||||
if ms, ok := toMillis(v); ok {
|
||||
return ms, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func toMillis(v any) (int64, bool) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
if t <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return int64(t), true
|
||||
case json.Number:
|
||||
if n, err := t.Int64(); err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
if tm, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return tm.UnixMilli(), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// runDriveListLatest 钉盘单层 --latest 扫描。
|
||||
func runDriveListLatest(cmd *cobra.Command, baseArgs map[string]any, rootFolder string, latest int, pattern string, quiet bool) error {
|
||||
buildArgs := func(pageToken string) map[string]any {
|
||||
args := map[string]any{
|
||||
"maxResults": float64(driveDepthPageSize),
|
||||
"orderBy": "modifyTime",
|
||||
"order": "desc",
|
||||
}
|
||||
for k, v := range baseArgs {
|
||||
args[k] = v
|
||||
}
|
||||
if rootFolder != "" {
|
||||
args["parentId"] = rootFolder
|
||||
}
|
||||
if pageToken != "" {
|
||||
args["nextToken"] = pageToken
|
||||
}
|
||||
return args
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"tool": "list_files",
|
||||
"args": buildArgs(""),
|
||||
"latest": latest,
|
||||
"note": "dry-run:latest 为客户端能力,凑够 N 条即停,最多扫描 1000 条",
|
||||
})
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
collected := make([]map[string]any, 0, latest)
|
||||
scanned := 0
|
||||
pageToken := ""
|
||||
maxPages := driveLatestScanMax/driveDepthPageSize + 1
|
||||
for pages := 0; pages < maxPages; pages++ {
|
||||
text, err := callMCPToolReturnText(ctx, "list_files", buildArgs(pageToken))
|
||||
if err != nil {
|
||||
return fmt.Errorf("latest 扫描第 %d 页失败: %w", pages+1, err)
|
||||
}
|
||||
items, next, _ := parseDriveDepthPage(text)
|
||||
for _, item := range items {
|
||||
scanned++
|
||||
if isDriveDepthFolder(item) {
|
||||
continue
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
if name == "" {
|
||||
name, _ = item["fileName"].(string)
|
||||
}
|
||||
if pattern != "" && !matchDriveNamePattern(name, pattern) {
|
||||
continue
|
||||
}
|
||||
collected = append(collected, item)
|
||||
if len(collected) >= latest {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(collected) >= latest || next == "" || scanned >= driveLatestScanMax {
|
||||
break
|
||||
}
|
||||
pageToken = next
|
||||
if !quiet {
|
||||
fmt.Fprintf(os.Stderr, "[drive-list] latest 扫描中: 已扫 %d 条,命中 %d/%d\n", scanned, len(collected), latest)
|
||||
}
|
||||
}
|
||||
if len(collected) < latest {
|
||||
hint := fmt.Sprintf("dws drive list --folder <子目录ID> --latest %d", latest)
|
||||
if pattern != "" {
|
||||
hint = fmt.Sprintf("dws drive list --folder <子目录ID> --pattern %q --latest %d", pattern, latest)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[drive-list] 已扫描 %d 条,找到 %d/%d 条;建议缩小范围:%s\n", scanned, len(collected), latest, hint)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{"items": collected})
|
||||
}
|
||||
@@ -66,6 +66,8 @@ var (
|
||||
driveFileStat = (*os.File).Stat
|
||||
)
|
||||
|
||||
var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() }
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// HTTP 状态错误
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -629,7 +631,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for part := range jobs {
|
||||
if runCtx.Err() != nil {
|
||||
if driveWorkerContextErr(runCtx) != nil {
|
||||
return
|
||||
}
|
||||
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -2868,48 +2870,32 @@ func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) {
|
||||
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
|
||||
// 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。
|
||||
// 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。
|
||||
// transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。
|
||||
|
||||
totalSize := int64(200)
|
||||
content := makeTestContent(int(totalSize))
|
||||
|
||||
origClient := driveRangeClient
|
||||
t.Cleanup(func() { driveRangeClient = origClient })
|
||||
|
||||
driveRangeClient = &http.Client{
|
||||
// 通过结构化 seam 让 worker 在收到唯一分片后确定性观察到取消状态;
|
||||
// 不再依赖微秒级 timeout 与 goroutine 调度概率。
|
||||
var checks atomic.Int32
|
||||
testseam.Swap(t, &driveWorkerContextErr, func(context.Context) error {
|
||||
checks.Add(1)
|
||||
return context.Canceled
|
||||
})
|
||||
var requests atomic.Int32
|
||||
testseam.Swap(t, &driveRangeClient, &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
// 每次请求加小延迟,让总处理时间接近 deadline
|
||||
time.Sleep(50 * time.Microsecond)
|
||||
var start, end int64
|
||||
if _, err := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil {
|
||||
return &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader("bad"))}, nil
|
||||
}
|
||||
if end >= int64(len(content)) {
|
||||
end = int64(len(content)) - 1
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusPartialContent,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(string(content[start : end+1]))),
|
||||
}
|
||||
resp.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content)))
|
||||
return resp, nil
|
||||
requests.Add(1)
|
||||
return nil, errors.New("worker context guard did not stop the request")
|
||||
}),
|
||||
})
|
||||
|
||||
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
|
||||
dest := filepath.Join(t.TempDir(), "worker-context-guard.bin")
|
||||
opts := driveDownloadOptions{partSize: 1, parallel: 1, resume: false, knownSize: 1}
|
||||
if err := downloadRangedParts(context.Background(), creds, dest, 1, opts); err != nil {
|
||||
t.Fatalf("downloadRangedParts context guard: %v", err)
|
||||
}
|
||||
|
||||
// 多次尝试以确保覆盖(goroutine 调度非确定性)
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
// timeout 设为约为总处理时间的50%,确保在处理过程中过期
|
||||
// 40分片/4workers=10轮*50μs=500μs,timeout设300μs使其在中间过期
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Microsecond)
|
||||
|
||||
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
|
||||
dest := filepath.Join(t.TempDir(), fmt.Sprintf("wkr-%d.bin", attempt))
|
||||
opts := driveDownloadOptions{partSize: 5, parallel: 4, resume: false, knownSize: totalSize}
|
||||
_ = downloadRangedParts(ctx, creds, dest, totalSize, opts)
|
||||
cancel()
|
||||
if checks.Load() != 1 {
|
||||
t.Fatalf("worker context checks = %d, want 1", checks.Load())
|
||||
}
|
||||
if requests.Load() != 0 {
|
||||
t.Fatalf("worker requests = %d, want 0", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+495
-3
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -195,7 +196,72 @@ func newMailCommand() *cobra.Command {
|
||||
|
||||
mailboxProfileCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
|
||||
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd)
|
||||
mailboxSharedWithMeCmd := &cobra.Command{
|
||||
Use: "shared-with-me",
|
||||
Short: "查询共享给我的邮箱",
|
||||
Long: `查询他人共享给当前用户的邮箱账号列表,包含共享关系类型。
|
||||
|
||||
共享关系(relationships)取值:
|
||||
LOGIN 登录(可登录该共享邮箱)
|
||||
SEND_AS 代发(以该邮箱身份发送邮件)
|
||||
SEND_ON_BEHALF 代表发送(代表该邮箱发送邮件)
|
||||
|
||||
返回字段:
|
||||
total 可访问的共享账号总数
|
||||
targets 可访问的共享账号列表`,
|
||||
Example: ` dws mail mailbox shared-with-me
|
||||
dws mail mailbox shared-with-me --limit 20 --offset 0`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
toolArgs["limit"] = limit
|
||||
}
|
||||
if cmd.Flags().Changed("offset") {
|
||||
offset, _ := cmd.Flags().GetInt("offset")
|
||||
toolArgs["offset"] = offset
|
||||
}
|
||||
if len(toolArgs) == 0 {
|
||||
return callMCPTool("list_shared_with_me", nil)
|
||||
}
|
||||
return callMCPTool("list_shared_with_me", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(mailboxSharedWithMeCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_shared_with_me",
|
||||
CanonicalPath: "mail.list_shared_with_me",
|
||||
CLIPath: "mail mailbox shared-with-me",
|
||||
PrimaryCLIPath: "mail mailbox shared-with-me",
|
||||
},
|
||||
Description: "查询共享给我的邮箱",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_shared_with_me"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询他人共享给当前用户的邮箱",
|
||||
UseWhen: []string{"需要列出登录/代发/代表发送权限的共享邮箱时"},
|
||||
AvoidWhen: []string{"列出自己邮箱用 mail mailbox list"},
|
||||
Examples: []string{"dws mail mailbox shared-with-me"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "limit", Property: "limit", InterfaceType: "integer"},
|
||||
{Name: "offset", Property: "offset", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
mailboxSharedWithMeCmd.Flags().Int("limit", 0, "返回数量上限 (可选)")
|
||||
mailboxSharedWithMeCmd.Flags().Int("offset", 0, "偏移量 (可选)")
|
||||
|
||||
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd)
|
||||
|
||||
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
|
||||
|
||||
@@ -2126,9 +2192,256 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
messageVerifyCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
|
||||
messageVerifyCmd.Flags().String("internet-message-id", "", "邮件的 internetMessageId (必填),取自发送类命令返回值")
|
||||
|
||||
messageExportCmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "导出/备份邮件(EML格式)",
|
||||
Long: `导出指定邮件为 EML 格式文件并保存到本地。
|
||||
|
||||
不指定 --filename 时,默认以邮件主题作为文件名。
|
||||
文件保存在当前工作目录下,扩展名为 .eml。
|
||||
默认不覆盖同名文件,使用 --overwrite 强制覆盖。
|
||||
|
||||
注意:目前仅支持 100KB 以内的邮件导出。
|
||||
|
||||
编排流程:
|
||||
1. 调用 get_email_by_message_id 获取邮件主题(用作默认文件名)
|
||||
2. 调用 export_message_mime 获取 EML 内容
|
||||
3. 将 EML 内容原子写入本地文件`,
|
||||
Example: ` dws mail message export --email user@company.com --id <messageId>
|
||||
dws mail message export --email user@company.com --id <messageId> --filename my-mail`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
email := mustGetFlag(cmd, "email")
|
||||
messageID := mustGetFlag(cmd, "id")
|
||||
filename := mustGetFlag(cmd, "filename")
|
||||
overwrite, _ := cmd.Flags().GetBool("overwrite")
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
|
||||
// evidence classifies "操作:" + audited DryRun() as plan.
|
||||
deps.Out.PrintKeyValue("操作", "导出邮件为 EML 文件")
|
||||
deps.Out.PrintKeyValue("email", email)
|
||||
deps.Out.PrintKeyValue("messageId", messageID)
|
||||
if filename != "" {
|
||||
deps.Out.PrintKeyValue("filename", filename)
|
||||
}
|
||||
deps.Out.PrintKeyValue("overwrite", fmt.Sprintf("%v", overwrite))
|
||||
deps.Out.PrintKeyValue("编排", "get_email_by_message_id → export_message_mime → 写入本地 .eml 文件")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
if filename == "" {
|
||||
msgText, err := callMCPToolReturnText(ctx, "get_email_by_message_id", map[string]any{
|
||||
"email": email,
|
||||
"messageId": messageID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取邮件信息失败: %w", err)
|
||||
}
|
||||
var msgData map[string]any
|
||||
if err := json.Unmarshal([]byte(msgText), &msgData); err == nil {
|
||||
data := msgData
|
||||
if result, ok := data["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
if msg, ok := data["message"].(map[string]any); ok {
|
||||
data = msg
|
||||
}
|
||||
if subj, ok := data["subject"].(string); ok && subj != "" {
|
||||
filename = subj
|
||||
}
|
||||
}
|
||||
if filename == "" {
|
||||
filename = messageID
|
||||
}
|
||||
}
|
||||
filename = sanitizeMailFilename(filename)
|
||||
|
||||
exportText, err := callMCPToolReturnText(ctx, "export_message_mime", map[string]any{
|
||||
"email": email,
|
||||
"id": messageID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("导出邮件失败: %w", err)
|
||||
}
|
||||
var exportData map[string]any
|
||||
if err := json.Unmarshal([]byte(exportText), &exportData); err != nil {
|
||||
return fmt.Errorf("解析导出结果失败: %w", err)
|
||||
}
|
||||
if result, ok := exportData["result"].(map[string]any); ok {
|
||||
exportData = result
|
||||
}
|
||||
emlContent, _ := exportData["emlContent"].(string)
|
||||
if emlContent == "" {
|
||||
return fmt.Errorf("导出结果为空: %s", exportText)
|
||||
}
|
||||
destPath := filename + ".eml"
|
||||
if err := atomicWriteFile(destPath, []byte(emlContent), 0600, overwrite); err != nil {
|
||||
if os.IsExist(err) {
|
||||
return fmt.Errorf("文件 %s 已存在,使用 --overwrite 覆盖", destPath)
|
||||
}
|
||||
return fmt.Errorf("保存文件失败: %w", err)
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("邮件已导出到: %s", destPath))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(messageExportCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "export_message_mime",
|
||||
CanonicalPath: "mail.export_message_mime",
|
||||
CLIPath: "mail message export",
|
||||
PrimaryCLIPath: "mail message export",
|
||||
},
|
||||
Description: "导出/备份邮件为本地 EML 文件",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Orchestrates get_email_by_message_id + export_message_mime + local file write.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "导出邮件为本地 EML 备份",
|
||||
UseWhen: []string{"需要把单封邮件备份为本地 .eml 文件时"},
|
||||
AvoidWhen: []string{"仅查看正文用 mail message get;分享到 IM 用 mail message share-to-chat"},
|
||||
Examples: []string{"dws mail message export --email user@company.com --id <messageId>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "filename", Property: "filename"},
|
||||
{Name: "overwrite", Property: "overwrite", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
messageExportCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
messageExportCmd.Flags().String("id", "", "邮件ID (必填)")
|
||||
messageExportCmd.Flags().String("filename", "", "导出文件名(不含扩展名),默认使用邮件主题")
|
||||
messageExportCmd.Flags().Bool("overwrite", false, "是否覆盖同名文件,默认 false")
|
||||
|
||||
messageShareToChatCmd := &cobra.Command{
|
||||
Use: "share-to-chat",
|
||||
Short: "[危险] 分享邮件至IM聊天",
|
||||
Long: `将指定邮件分享到钉钉单聊。
|
||||
|
||||
参数说明:
|
||||
--users 目标用户UID列表,逗号分隔(规范名),兼容 --uids
|
||||
--yes 确认执行此危险操作 (必填)
|
||||
|
||||
默认需要 --yes 确认才能执行;--dry-run 仅预览分享计划,不发起真实请求。
|
||||
服务端可能返回风险提示(riskMessage)和 sign;在 --yes 已通过的前提下,
|
||||
将展示风险提示并自动携带 sign 重新请求。`,
|
||||
Example: ` dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1,uid2 --yes
|
||||
dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1 --yes`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
mcpArgs := map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"id": mustGetFlag(cmd, "id"),
|
||||
}
|
||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||
mcpArgs["uids"] = parseRecipients(users)
|
||||
}
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
if deps.Caller.DryRun() {
|
||||
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
|
||||
// evidence classifies "操作:" + audited DryRun() as plan.
|
||||
deps.Out.PrintKeyValue("操作", "分享邮件至 IM 聊天")
|
||||
deps.Out.PrintKeyValue("email", mustGetFlag(cmd, "email"))
|
||||
deps.Out.PrintKeyValue("messageId", mustGetFlag(cmd, "id"))
|
||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||
deps.Out.PrintKeyValue("users", users)
|
||||
}
|
||||
deps.Out.PrintKeyValue("yes", fmt.Sprintf("%v", yes))
|
||||
deps.Out.PrintKeyValue("说明", "仅预览分享计划,不发起真实分享请求")
|
||||
return nil
|
||||
}
|
||||
if !commandBoolFlag(cmd, "yes") {
|
||||
return apperrors.NewValidation(
|
||||
"分享邮件至 IM 为高风险操作;获得用户确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先确认目标用户与邮件内容;用户明确同意后以相同参数追加 --yes"),
|
||||
apperrors.WithActions("确认目标用户与邮件", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
firstText, err := callMCPToolReturnText(ctx, "share_message_to_chat", mcpArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("分享邮件失败: %w", err)
|
||||
}
|
||||
var firstResult map[string]any
|
||||
if err := json.Unmarshal([]byte(firstText), &firstResult); err != nil {
|
||||
return fmt.Errorf("解析分享结果失败: %w", err)
|
||||
}
|
||||
if result, ok := firstResult["result"].(map[string]any); ok {
|
||||
firstResult = result
|
||||
}
|
||||
if sign, ok := firstResult["sign"].(string); ok && sign != "" {
|
||||
if riskMsg, _ := firstResult["riskMessage"].(string); riskMsg != "" {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
|
||||
}
|
||||
mcpArgs["sign"] = sign
|
||||
return callMCPTool("share_message_to_chat", mcpArgs)
|
||||
}
|
||||
// firstResult is already a parsed object (possibly unwrapped from result).
|
||||
return deps.Out.PrintJSON(firstResult)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(messageShareToChatCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "share_message_to_chat",
|
||||
CanonicalPath: "mail.share_message_to_chat",
|
||||
CLIPath: "mail message share-to-chat",
|
||||
PrimaryCLIPath: "mail message share-to-chat",
|
||||
},
|
||||
Description: "分享邮件至 IM 单聊",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "share_message_to_chat"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "把邮件分享到钉钉单聊",
|
||||
UseWhen: []string{"需要将指定邮件分享给钉钉用户(单聊)时"},
|
||||
AvoidWhen: []string{"仅导出本地备份用 mail message export;群聊发消息用 chat message send"},
|
||||
Examples: []string{"dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "users", Property: "uids"},
|
||||
{Name: "yes", Property: "yes", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
messageShareToChatCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
messageShareToChatCmd.Flags().String("id", "", "邮件ID (必填)")
|
||||
messageShareToChatCmd.Flags().String("users", "", "目标用户UID列表,逗号分隔")
|
||||
messageShareToChatCmd.Flags().String("uids", "", "--users 的别名")
|
||||
_ = messageShareToChatCmd.Flags().MarkHidden("uids")
|
||||
messageShareToChatCmd.Flags().Bool("yes", false, "确认执行此危险操作 (必填)")
|
||||
|
||||
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
|
||||
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
|
||||
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd)
|
||||
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd)
|
||||
|
||||
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
|
||||
|
||||
@@ -3400,7 +3713,129 @@ object 与 operation 合法组合:
|
||||
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
|
||||
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
|
||||
|
||||
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd)
|
||||
calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}
|
||||
calendarListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出用户可访问的日历列表",
|
||||
Long: `列出用户可访问的日历列表,包括用户自己创建以及接受共享后生成的日历。
|
||||
返回的 id 可作为 calendar-event list 的 --id / --folder-id 使用。`,
|
||||
Example: ` dws mail calendar list --email user@company.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("list_mailbox_calendars", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(calendarListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_mailbox_calendars",
|
||||
CanonicalPath: "mail.list_mailbox_calendars",
|
||||
CLIPath: "mail calendar list",
|
||||
PrimaryCLIPath: "mail calendar list",
|
||||
},
|
||||
Description: "列出邮箱日历文件夹",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendars"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出邮箱可访问的日历文件夹",
|
||||
UseWhen: []string{"查询邮箱日历文件夹 id,以便继续查日程时"},
|
||||
AvoidWhen: []string{"钉钉主日历日程请用 dws calendar event list"},
|
||||
Examples: []string{"dws mail calendar list --email user@company.com"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
calendarCmd.AddCommand(calendarListCmd)
|
||||
|
||||
calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}
|
||||
calendarEventListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询指定日历时间范围内的日程",
|
||||
Long: `查询指定邮箱日历文件夹在 UTC 时间区间 [startTime, endTime) 内出现的日程,支持 cursor 分页。循环日程会展开为该时间范围内的单次日程。`,
|
||||
Example: ` dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z"
|
||||
dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z" --cursor <cursor>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "start", "end"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlagWithAliases(cmd, "id", "folder-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"id": flagOrFallback(cmd, "id", "folder-id"),
|
||||
"startTime": flagOrFallback(cmd, "start", "start-time"),
|
||||
"endTime": flagOrFallback(cmd, "end", "end-time"),
|
||||
}
|
||||
if cursor := mustGetFlag(cmd, "cursor"); cursor != "" {
|
||||
toolArgs["cursor"] = cursor
|
||||
}
|
||||
return callMCPTool("list_mailbox_calendar_events", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(calendarEventListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_mailbox_calendar_events",
|
||||
CanonicalPath: "mail.list_mailbox_calendar_events",
|
||||
CLIPath: "mail calendar-event list",
|
||||
PrimaryCLIPath: "mail calendar-event list",
|
||||
},
|
||||
Description: "查询邮箱日历文件夹时间范围内的日程",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendar_events"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询邮箱日历日程列表",
|
||||
UseWhen: []string{"已知邮箱日历文件夹 id,需要按 UTC 时间窗列出日程时"},
|
||||
AvoidWhen: []string{"钉钉主日历请用 dws calendar event list;未知文件夹 id 时先 mail calendar list"},
|
||||
Examples: []string{"dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start \"2026-07-01T00:00:00Z\" --end \"2026-07-31T23:59:59Z\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "start", Property: "startTime", Required: boolPtr(true)},
|
||||
{Name: "end", Property: "endTime", Required: boolPtr(true)},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
},
|
||||
},
|
||||
})
|
||||
calendarEventListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
calendarEventListCmd.Flags().String("id", "", "日历文件夹ID (必填)")
|
||||
calendarEventListCmd.Flags().String("folder-id", "", "--id 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("folder-id")
|
||||
calendarEventListCmd.Flags().String("start", "", "视图开始UTC时间 (必填)")
|
||||
calendarEventListCmd.Flags().String("start-time", "", "--start 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("start-time")
|
||||
calendarEventListCmd.Flags().String("end", "", "视图结束UTC时间 (必填)")
|
||||
calendarEventListCmd.Flags().String("end-time", "", "--end 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("end-time")
|
||||
calendarEventListCmd.Flags().String("cursor", "", "分页光标 (可选)")
|
||||
calendarEventCmd.AddCommand(calendarEventListCmd)
|
||||
|
||||
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd, calendarCmd, calendarEventCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
@@ -3935,3 +4370,60 @@ func runMailAttachmentDownload(cmd *cobra.Command) error {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("附件已保存到: %s", destPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeMailFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
name = strings.ReplaceAll(name, "\x00", "")
|
||||
if name == "" {
|
||||
return "mail"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// mailAtomicLink is the no-clobber commit for atomicWriteFile (test-injectable).
|
||||
var mailAtomicLink = os.Link
|
||||
|
||||
// atomicWriteFile 原子写入文件:先写同目录临时文件,成功后提交到目标路径。
|
||||
// overwrite=false 使用 link(2) 实现存在即失败;overwrite=true 使用 rename 覆盖。
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode, overwrite bool) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := atomicCreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
_ = tmp.Close()
|
||||
_ = atomicRemove(tmpName)
|
||||
}
|
||||
}()
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
return fmt.Errorf("设置文件权限失败: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
return fmt.Errorf("写入数据失败: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return fmt.Errorf("同步磁盘失败: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("关闭临时文件失败: %w", err)
|
||||
}
|
||||
if overwrite {
|
||||
if err := atomicRename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("重命名文件失败: %w", err)
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
if err := mailAtomicLink(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = atomicRemove(tmpName)
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -49,12 +49,13 @@ func newMarkdownCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "markdown",
|
||||
Short: "Markdown 文件处理",
|
||||
Long: "创建、覆盖、修补和获取钉盘或文档空间中的原生 Markdown 文件。",
|
||||
Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
root.AddCommand(
|
||||
newMarkdownFetchCmd(),
|
||||
newMarkdownCreateCmd(),
|
||||
newMarkdownDiffCmd(),
|
||||
newMarkdownOverwriteCmd(),
|
||||
newMarkdownPatchCmd(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws markdown diff
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// diffResult 是 diff 命令的输出结构。
|
||||
type diffResult struct {
|
||||
Mode string `json:"mode"`
|
||||
Changed bool `json:"changed"`
|
||||
AddedLines int `json:"added_lines"`
|
||||
DeletedLines int `json:"deleted_lines"`
|
||||
Hunks int `json:"hunks"`
|
||||
Diff string `json:"diff"`
|
||||
}
|
||||
|
||||
// diff 命令的限制(包级变量以便覆盖测试注入更小阈值)。
|
||||
var (
|
||||
maxDiffFileSize int64 = 10 * 1024 * 1024 // 单侧文件大小上限 10MB
|
||||
diffDownloadTimeout = 10 * time.Minute // 下载远端内容超时(与项目其他下载命令一致)
|
||||
diffComputeTimeout = 30 * time.Second // 本地 diff 计算超时
|
||||
diffJSONMarshalIndent = json.MarshalIndent
|
||||
runMarkdownUnifiedDiff = computeUnifiedDiff
|
||||
)
|
||||
|
||||
// formatFileSize 返回人类可读的文件大小。
|
||||
func formatFileSize(size int64) string {
|
||||
if size >= 1024*1024 {
|
||||
return fmt.Sprintf("%.1f MB", float64(size)/float64(1024*1024))
|
||||
}
|
||||
if size >= 1024 {
|
||||
return fmt.Sprintf("%.1f KB", float64(size)/float64(1024))
|
||||
}
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
|
||||
// checkFileSize 校验文件大小是否超过限制。
|
||||
func checkFileSize(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxDiffFileSize {
|
||||
return fmt.Errorf("文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(info.Size()), formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadRemoteContent 下载远端文件内容并返回其文本。
|
||||
// versionNum <= 0 时走 download_file(用 fileId),> 0 时走 download_file_version(用 nodeId + version)。
|
||||
func downloadRemoteContent(ctx context.Context, fileID string, versionNum int) (string, error) {
|
||||
var text string
|
||||
var err error
|
||||
if versionNum > 0 {
|
||||
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
})
|
||||
} else {
|
||||
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file", map[string]any{
|
||||
"fileId": fileID,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resourceURL, dlHeaders, err := parseDownloadInfo(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 下载时即限制大小,避免完整下载超大文件后才拦截(约束网络流量与内存占用)
|
||||
content, err := diffDownloadLimited(ctx, resourceURL, dlHeaders)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// diffDownloadLimited 下载远端内容,并在下载过程中强制执行 maxDiffFileSize 上限。
|
||||
// 包级变量以便测试注入。
|
||||
var diffDownloadLimited = defaultDiffDownloadLimited
|
||||
|
||||
// defaultDiffDownloadLimited 通过 HTTP GET 下载内容:先用 Content-Length 预检,
|
||||
// 再用 io.LimitReader 将实际读取量限制为 maxDiffFileSize+1 字节,超限即报错,
|
||||
// 从而约束网络流量与内存占用,而非在完整下载后才校验。
|
||||
func defaultDiffDownloadLimited(ctx context.Context, url string, headers map[string]string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: diffDownloadTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Content-Length 预检:可在读取 body 前提前拦截超大文件
|
||||
if resp.ContentLength > maxDiffFileSize {
|
||||
return nil, fmt.Errorf("远端文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(resp.ContentLength), formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
|
||||
// 实际读取限制为 maxDiffFileSize+1 字节,读满即判定超限(防止 Content-Length 缺失或造假)
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDiffFileSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxDiffFileSize {
|
||||
return nil, fmt.Errorf("远端文件大小超过限制 %s,请使用更小的文件", formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// computeUnifiedDiff 使用 Go stdlib patience diff 算法计算 unified diff,并统计变更行数。
|
||||
func computeUnifiedDiff(left, right string, contextLines int) (string, int, int, int, bool) {
|
||||
out := UnifiedDiff("left", []byte(left), "right", []byte(right), contextLines)
|
||||
if len(out) == 0 {
|
||||
return "", 0, 0, 0, false
|
||||
}
|
||||
|
||||
text := string(out)
|
||||
added, deleted, hunks := 0, 0, 0
|
||||
// 只统计首个 @@ 之后的 hunk 区行:头部三行(diff/---/+++)不参与计数,
|
||||
// hunk 区内每行必带单字符前缀,内容行以 --/++ 开头也不会被误判为文件头而漏计
|
||||
inHunk := false
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
hunks++
|
||||
inHunk = true
|
||||
continue
|
||||
}
|
||||
if !inHunk {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "+") {
|
||||
added++
|
||||
} else if strings.HasPrefix(line, "-") {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
changed := added > 0 || deleted > 0
|
||||
return text, added, deleted, hunks, changed
|
||||
}
|
||||
|
||||
// ensureMarkdownDiffType 校验 markdown diff 的目标文件类型。
|
||||
// markdown 产品域面向 .md 文件,非 md 文件拦截并回引到对应产品命令。
|
||||
// 类型探测复用 fetchFileInfo(显式路由 drive server 的 get_file_info),
|
||||
// 探测失败或类型未知时不阻断,让后续 MCP 工具自行报错。
|
||||
func ensureMarkdownDiffType(ctx context.Context, nodeID string) error {
|
||||
info := fetchFileInfo(ctx, nodeID)
|
||||
switch info.extension {
|
||||
case "", "md", "markdown":
|
||||
return nil
|
||||
case "adoc":
|
||||
return fmt.Errorf("该文件为钉钉在线文档 (adoc),不支持 markdown diff\n请使用 dws doc 对应命令(如 dws doc version list / dws doc export)")
|
||||
case "axls":
|
||||
return fmt.Errorf("该文件为钉钉在线表格 (axls),不支持 markdown diff\n请使用 dws sheet 对应命令")
|
||||
case "amind", "adraw":
|
||||
return fmt.Errorf("该文件为钉钉在线%s (%s),暂不支持历史版本管理\nmarkdown diff 与 dws drive list --versions / dws drive download --version 均不支持该类型", describeDingTalkDocType(info.extension), info.extension)
|
||||
default:
|
||||
return fmt.Errorf("该文件为 %s 文件,markdown diff 仅支持 .md 文件\n普通文件的历史版本请使用 dws drive list --versions / dws drive download --version / dws drive revert", info.extension)
|
||||
}
|
||||
}
|
||||
|
||||
// newMarkdownDiffCmd 创建 markdown diff 子命令。
|
||||
func newMarkdownDiffCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "diff",
|
||||
Short: "比较 Markdown 内容差异",
|
||||
Long: `比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff。
|
||||
|
||||
模式:
|
||||
remote_vs_remote: --version V1 --version2 V2 (两个历史版本)
|
||||
remote_vs_remote: --version V1 (历史版本 vs 最新)
|
||||
remote_vs_local: --file ./local.md (最新 vs 本地)
|
||||
remote_vs_local: --version V1 --file ./local.md (历史版本 vs 本地)
|
||||
|
||||
历史版本号通过 dws drive list --versions 获取。
|
||||
--file 与 --version2 不能同时使用。
|
||||
|
||||
限制:
|
||||
- 仅支持 .md 文件(在线文档/表格请使用 doc/sheet 命令)
|
||||
- 单侧文件大小上限: 10 MB
|
||||
- 下载超时: 10 分钟
|
||||
- diff 计算超时: 30 秒`,
|
||||
Example: ` # 比较两个历史版本
|
||||
dws markdown diff --node <dentryUuid> --version 3 --version2 5
|
||||
|
||||
# 历史版本 vs 最新版本
|
||||
dws markdown diff --node <dentryUuid> --version 3
|
||||
|
||||
# 最新版本 vs 本地文件
|
||||
dws markdown diff --node <dentryUuid> --file ./draft.md
|
||||
|
||||
# 历史版本 vs 本地文件
|
||||
dws markdown diff --node <dentryUuid> --version 3 --file ./draft.md`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version1, _ := cmd.Flags().GetInt("version")
|
||||
version2, _ := cmd.Flags().GetInt("version2")
|
||||
localFile, _ := cmd.Flags().GetString("file")
|
||||
contextLines, _ := cmd.Flags().GetInt("context")
|
||||
|
||||
// fail-fast 参数校验(置于 dry-run 之前):显式传了版本号但非正整数时立即报错,
|
||||
// 避免静默降级为“最新版本”;--context 允许 0(无上下文),仅拒绝负值
|
||||
if cmd.Flags().Changed("version") && version1 <= 0 {
|
||||
return fmt.Errorf("--version 必须为正整数,当前值: %d", version1)
|
||||
}
|
||||
if cmd.Flags().Changed("version2") && version2 <= 0 {
|
||||
return fmt.Errorf("--version2 必须为正整数,当前值: %d", version2)
|
||||
}
|
||||
if contextLines < 0 {
|
||||
return fmt.Errorf("--context 不能为负数,当前值: %d", contextLines)
|
||||
}
|
||||
|
||||
// 互斥校验:--file 与 --version2 不能同时使用
|
||||
if localFile != "" && version2 > 0 {
|
||||
return fmt.Errorf("--file 与 --version2 不能同时使用")
|
||||
}
|
||||
|
||||
// 模式判定
|
||||
var mode string
|
||||
if localFile != "" {
|
||||
mode = "remote_vs_local"
|
||||
} else {
|
||||
mode = "remote_vs_remote"
|
||||
}
|
||||
|
||||
// remote_vs_remote 模式至少需要一个版本号,否则两侧均取最新版本,diff 必为空
|
||||
if localFile == "" && version1 == 0 && version2 == 0 {
|
||||
return fmt.Errorf("remote_vs_remote 模式至少需要指定 --version 或 --version2 之一")
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "Markdown 内容 Diff")
|
||||
deps.Out.PrintKeyValue("模式", mode)
|
||||
deps.Out.PrintKeyValue("节点ID", nodeID)
|
||||
if version1 > 0 {
|
||||
deps.Out.PrintKeyValue("左侧版本", fmt.Sprintf("%d", version1))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("左侧版本", "最新")
|
||||
}
|
||||
if localFile != "" {
|
||||
deps.Out.PrintKeyValue("右侧", localFile)
|
||||
} else if version2 > 0 {
|
||||
deps.Out.PrintKeyValue("右侧版本", fmt.Sprintf("%d", version2))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("右侧版本", "最新")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 下载超时 context(与项目其他下载命令一致:10 分钟)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), diffDownloadTimeout)
|
||||
defer cancel()
|
||||
|
||||
// 类型守卫置于 dry-run 之后,确保 dry-run 不产生任何服务端调用
|
||||
if err := ensureMarkdownDiffType(ctx, nodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remote_vs_local 模式:先校验本地文件大小,避免下载后才发现过大
|
||||
if localFile != "" {
|
||||
if err := checkFileSize(localFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 输出格式读全局 --format(默认 json),json 时输出结构化结果,其余输出文本摘要
|
||||
isJSON := deps.Caller.Format() == "json"
|
||||
// 进度属带外诊断信息,统一写入 stderr,保证 stdout 在两种模式下都是纯净输出
|
||||
progress := func(msg string) { fmt.Fprintln(os.Stderr, msg) }
|
||||
|
||||
// 下载左侧内容
|
||||
progress("[1/3] 获取左侧内容...")
|
||||
leftContent, err := downloadRemoteContent(ctx, nodeID, version1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取右侧内容
|
||||
var rightContent string
|
||||
if localFile != "" {
|
||||
// remote_vs_local: 读取本地文件(大小已校验)
|
||||
progress("[2/3] 读取本地文件...")
|
||||
data, err := os.ReadFile(localFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取本地文件失败: %w", err)
|
||||
}
|
||||
rightContent = string(data)
|
||||
} else {
|
||||
// remote_vs_remote: 下载右侧远端内容
|
||||
progress("[2/3] 获取右侧内容...")
|
||||
rightContent, err = downloadRemoteContent(ctx, nodeID, version2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 diff(带超时保护)
|
||||
progress("[3/3] 计算差异...")
|
||||
type diffOutput struct {
|
||||
text string
|
||||
added int
|
||||
deleted int
|
||||
hunks int
|
||||
changed bool
|
||||
}
|
||||
resultCh := make(chan diffOutput, 1)
|
||||
// Capture seams before the goroutine so test restorers cannot race
|
||||
// against a still-running compute after the timeout path returns.
|
||||
computeDiff := runMarkdownUnifiedDiff
|
||||
marshalIndent := diffJSONMarshalIndent
|
||||
go func() {
|
||||
text, added, deleted, hunks, changed := computeDiff(leftContent, rightContent, contextLines)
|
||||
resultCh <- diffOutput{text, added, deleted, hunks, changed}
|
||||
}()
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
diffText, added, deleted, hunks, changed := res.text, res.added, res.deleted, res.hunks, res.changed
|
||||
result := diffResult{
|
||||
Mode: mode,
|
||||
Changed: changed,
|
||||
AddedLines: added,
|
||||
DeletedLines: deleted,
|
||||
Hunks: hunks,
|
||||
Diff: diffText,
|
||||
}
|
||||
|
||||
// 输出
|
||||
if isJSON {
|
||||
data, err := marshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSON 序列化失败: %w", err)
|
||||
}
|
||||
deps.Out.PrintRaw(string(data))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("模式", result.Mode)
|
||||
if result.Changed {
|
||||
deps.Out.PrintKeyValue("是否有变更", "是")
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("是否有变更", "否")
|
||||
}
|
||||
deps.Out.PrintKeyValue("新增行数", fmt.Sprintf("%d", result.AddedLines))
|
||||
deps.Out.PrintKeyValue("删除行数", fmt.Sprintf("%d", result.DeletedLines))
|
||||
deps.Out.PrintKeyValue("差异块数", fmt.Sprintf("%d", result.Hunks))
|
||||
if result.Changed {
|
||||
deps.Out.PrintRaw("")
|
||||
deps.Out.PrintRaw(result.Diff)
|
||||
}
|
||||
}
|
||||
case <-time.After(diffComputeTimeout):
|
||||
return fmt.Errorf("diff 计算超时(%s),文件可能过大,请尝试减小 --context 或使用更小的文件", diffComputeTimeout)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
cmd.Flags().Int("version", 0, "左侧历史版本号 (可选,不传=最新版本)")
|
||||
cmd.Flags().Int("version2", 0, "右侧历史版本号 (可选,不传=最新版本;不能与 --file 同时使用)")
|
||||
cmd.Flags().String("file", "", "本地 .md 文件路径 (可选,指定后进入 remote_vs_local 模式)")
|
||||
cmd.Flags().Int("context", 3, "diff 上下文行数 (默认 3)")
|
||||
|
||||
// --node 隐藏别名(与 version/fetch 子命令一致)
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("id", "", "")
|
||||
cmd.Flags().String("node-id", "", "")
|
||||
cmd.Flags().String("doc-id", "", "")
|
||||
cmd.Flags().String("file-id", "", "")
|
||||
_ = cmd.Flags().MarkHidden("url")
|
||||
_ = cmd.Flags().MarkHidden("id")
|
||||
_ = cmd.Flags().MarkHidden("node-id")
|
||||
_ = cmd.Flags().MarkHidden("doc-id")
|
||||
_ = cmd.Flags().MarkHidden("file-id")
|
||||
|
||||
RegisterCrossProductAliases(cmd)
|
||||
|
||||
cli.AnnotateRuntimeRequiredFlags(cmd, "node")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "markdown",
|
||||
Name: "diff",
|
||||
CanonicalPath: "markdown.diff",
|
||||
CLIPath: "markdown diff",
|
||||
PrimaryCLIPath: "markdown diff",
|
||||
},
|
||||
Description: "比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Local diff workflow: download remote version(s) and/or read a local file, then compute unified diff client-side.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "比较 Markdown 文件版本或本地草稿差异",
|
||||
UseWhen: []string{"需要对比远端 .md 历史版本,或远端最新/历史版本与本地草稿的差异时"},
|
||||
AvoidWhen: []string{"在线文档/表格差异请用 doc/sheet;普通二进制文件版本请用 drive list --versions / drive download --version"},
|
||||
Examples: []string{"dws markdown diff --node <nodeId> --version 3 --version2 5"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
{Name: "version", Property: "version", InterfaceType: "integer"},
|
||||
{Name: "version2", Property: "version2", InterfaceType: "integer"},
|
||||
{Name: "file", Property: "file"},
|
||||
{Name: "context", Property: "context", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// fetchFileInfo 通过 get_file_info 获取扩展名(markdown diff 类型守卫用)。
|
||||
// 探测失败返回零值,由调用方决定是否阻断。
|
||||
type markdownFileInfo struct {
|
||||
name string
|
||||
extension string
|
||||
}
|
||||
|
||||
func fetchFileInfo(ctx context.Context, nodeID string) (info markdownFileInfo) {
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", map[string]any{"fileId": nodeID})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &resp); err != nil {
|
||||
return
|
||||
}
|
||||
data := resp
|
||||
if result, ok := resp["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
if name, ok := data["name"].(string); ok {
|
||||
info.name = name
|
||||
}
|
||||
if ext, ok := data["extension"].(string); ok {
|
||||
info.extension = strings.ToLower(strings.TrimPrefix(ext, "."))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func describeDingTalkDocType(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case "adoc":
|
||||
return "文档"
|
||||
case "axls":
|
||||
return "表格"
|
||||
case "amind":
|
||||
return "脑图"
|
||||
case "adraw":
|
||||
return "画图"
|
||||
default:
|
||||
return "文件"
|
||||
}
|
||||
}
|
||||
+260
-10
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -18,7 +19,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "minutes",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结及文件上传会话。",
|
||||
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结、语音备忘及文件上传会话。",
|
||||
UseWhen: []string{
|
||||
"用户要查找、读取、编辑或管理钉钉听记及其录音、转写、摘要和衍生内容。",
|
||||
},
|
||||
@@ -1282,7 +1283,10 @@ func newMinutesCommand() *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。",
|
||||
UseWhen: []string{"需要添加听记个人热词以优化专有名词/人名识别时(单词不超过约10汉字)"},
|
||||
AvoidWhen: []string{"要查看已有热词时改用 dws minutes hot-word list"},
|
||||
AvoidWhen: []string{
|
||||
"要查看已有热词时改用 dws minutes hot-word list",
|
||||
"要删除热词时改用 dws minutes hot-word delete",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes hot-word add --words \"钉钉\"",
|
||||
"dws minutes hot-word add --words \"OKR,钉钉,Copilot\"",
|
||||
@@ -1328,13 +1332,70 @@ func newMinutesCommand() *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询当前用户配置的所有听记热词列表。",
|
||||
UseWhen: []string{"需要查看当前用户已配置的听记个人热词列表时"},
|
||||
AvoidWhen: []string{"要添加热词时改用 hot-word add"},
|
||||
Examples: []string{"dws minutes hot-word list"},
|
||||
AvoidWhen: []string{
|
||||
"要添加热词时改用 hot-word add",
|
||||
"要删除热词时改用 hot-word delete",
|
||||
},
|
||||
Examples: []string{"dws minutes hot-word list"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd)
|
||||
hotWordDeleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "批量删除个人热词",
|
||||
Long: `批量删除听记个人热词。
|
||||
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
|
||||
Example: ` dws minutes hot-word delete --words "钉钉"
|
||||
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "words"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("delete_personal_hotword", map[string]any{
|
||||
"hotWordList": parseCSVValues(mustGetFlag(cmd, "words")),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(hotWordDeleteCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "delete_personal_hotword",
|
||||
CanonicalPath: "minutes.delete_personal_hotword",
|
||||
CLIPath: "minutes hot-word delete",
|
||||
PrimaryCLIPath: "minutes hot-word delete",
|
||||
},
|
||||
Description: "批量删除听记个人热词。删除后对应热词不再参与后续语音识别优化。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "delete_personal_hotword"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "批量删除听记个人热词,清理误加或过时热词。",
|
||||
UseWhen: []string{"用户要删除/移除已配置的听记个人热词时"},
|
||||
AvoidWhen: []string{
|
||||
"要添加热词时改用 hot-word add",
|
||||
"不确定现有热词时先用 hot-word list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes hot-word delete --words \"钉钉\"",
|
||||
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "words", Property: "hotWordList"},
|
||||
},
|
||||
},
|
||||
})
|
||||
hotWordDeleteCmd.Flags().String("words", "", "要删除的热词,多个用逗号分隔 (必填)")
|
||||
|
||||
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd, hotWordDeleteCmd)
|
||||
|
||||
// ── replace-text 命令 ───────────────────────────────────────
|
||||
replaceTextCmd := &cobra.Command{
|
||||
@@ -1618,8 +1679,8 @@ func newMinutesCommand() *cobra.Command {
|
||||
uploadCmd.AddCommand(uploadCreateCmd, uploadCompleteCmd, uploadCancelCmd)
|
||||
|
||||
// ── permission 子组 ─────────────────────────────────────────
|
||||
// 听记成员权限管理:批量添加/移除成员及其权限。
|
||||
// 对应 MCP 工具 add_member_permission / remove_member_permission。
|
||||
// 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。
|
||||
// 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。
|
||||
permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}
|
||||
|
||||
// permission add — 对应 MCP 工具 add_member_permission
|
||||
@@ -1709,6 +1770,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
UseWhen: []string{"已知听记 uuid,需要批量给听记增加成员并设置权限(policy 0管理员/1所有者/2可编辑/3可查看下载/4仅查看)时"},
|
||||
AvoidWhen: []string{
|
||||
"要移除成员权限时改用 dws minutes permission remove",
|
||||
"当前用户自己申请访问权限时改用 dws minutes permission apply",
|
||||
"成员、权限策略或听记 id 未确认时不要添加",
|
||||
},
|
||||
Examples: []string{
|
||||
@@ -1784,6 +1846,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
UseWhen: []string{"用户明确要求批量移除听记成员权限,使其失去访问时"},
|
||||
AvoidWhen: []string{
|
||||
"要添加权限时改用 permission add",
|
||||
"当前用户自己申请访问权限时改用 permission apply",
|
||||
"成员或听记 id 未确认时不要移除",
|
||||
},
|
||||
Examples: []string{
|
||||
@@ -1803,7 +1866,85 @@ func newMinutesCommand() *cobra.Command {
|
||||
_ = permissionRemoveCmd.Flags().MarkHidden("task-uuids")
|
||||
permissionRemoveCmd.Flags().String("member-uids", "", "成员钉钉 UID 列表,逗号分隔 (必填)")
|
||||
|
||||
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd)
|
||||
// permission apply — 对应 MCP 工具 apply_minutes_permission
|
||||
permissionApplyCmd := &cobra.Command{
|
||||
Use: "apply",
|
||||
Short: "为当前用户申请听记权限",
|
||||
Long: `为当前登录用户申请指定听记的权限。
|
||||
适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。
|
||||
|
||||
权限类型 (--policy):
|
||||
2 = 可编辑
|
||||
3 = 可查看/下载
|
||||
4 = 仅查看`,
|
||||
Example: ` dws minutes permission apply --id <taskUuid> --policy 4
|
||||
dws minutes permission apply --id <taskUuid> --policy 2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "policy"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
|
||||
if err != nil || policyID < 2 || policyID > 4 {
|
||||
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
|
||||
}
|
||||
|
||||
return callMCPTool("apply_minutes_permission", map[string]any{
|
||||
"taskUuid": flagOrFallback(cmd, "id", "url", "task-uuid", "uuid"),
|
||||
"policyId": float64(policyID),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(permissionApplyCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "apply_minutes_permission",
|
||||
CanonicalPath: "minutes.apply_minutes_permission",
|
||||
CLIPath: "minutes permission apply",
|
||||
PrimaryCLIPath: "minutes permission apply",
|
||||
},
|
||||
Description: "为当前登录用户申请指定听记的权限。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "apply_minutes_permission"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为当前登录用户申请指定听记的访问权限(可编辑/可查看下载/仅查看)。",
|
||||
UseWhen: []string{"当前用户对某听记无权限,需要向所有者申请访问(policy 2/3/4)时"},
|
||||
AvoidWhen: []string{
|
||||
"所有者批量给他人加权限时改用 permission add",
|
||||
"要移除他人权限时改用 permission remove",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes permission apply --id <taskUuid> --policy 4",
|
||||
"dws minutes permission apply --id <taskUuid> --policy 2",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "taskUuid"},
|
||||
{Name: "policy", Property: "policyId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
permissionApplyCmd.Flags().String("id", "", "听记 taskUuid (必填)")
|
||||
permissionApplyCmd.Flags().String("url", "", "--id 的别名")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("url")
|
||||
permissionApplyCmd.Flags().String("task-uuid", "", "--id 的别名 (兼容 OpenAPI 字段名)")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
|
||||
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
|
||||
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
|
||||
|
||||
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
|
||||
|
||||
// ── tag 子组 ────────────────────────────────────────────────
|
||||
// 听记标签/分组管理:查询用户标签列表、按标签查询听记。
|
||||
@@ -1928,13 +2069,122 @@ func newMinutesCommand() *cobra.Command {
|
||||
|
||||
tagCmd.AddCommand(tagListCmd, tagQueryCmd)
|
||||
|
||||
// ── audio-memo 子组 ────────────────────────────
|
||||
// 语音备忘查询:对应 MCP 工具 list_audio_memos。
|
||||
// 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。
|
||||
// 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用
|
||||
// callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。
|
||||
audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}
|
||||
|
||||
audioMemoListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询语音备忘列表",
|
||||
Long: `查询当前用户的语音备忘列表,支持分页和时间范围筛选。
|
||||
分页:首页 --cursor 留空(或 0),后续把上一页返回的 nextCursor 回填到 --cursor。
|
||||
时间范围:--start/--end 为 ISO-8601(可选),不传默认查询近一年。`,
|
||||
Example: ` dws minutes audio-memo list
|
||||
dws minutes audio-memo list --max 500
|
||||
dws minutes audio-memo list --start "2026-01-01T00:00:00+08:00" --end "2026-07-21T23:59:59+08:00"
|
||||
dws minutes audio-memo list --cursor 1740000000000`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
|
||||
max, _ := cmd.Flags().GetFloat64("max")
|
||||
if max <= 0 || max > 1000 {
|
||||
return fmt.Errorf("flag --max must be between 1 and 1000")
|
||||
}
|
||||
toolArgs["pageSize"] = max
|
||||
|
||||
if cmd.Flags().Changed("cursor") {
|
||||
cursor, _ := cmd.Flags().GetInt64("cursor")
|
||||
if cursor < 0 {
|
||||
return fmt.Errorf("flag --cursor must be >= 0")
|
||||
}
|
||||
toolArgs["cursor"] = float64(cursor)
|
||||
}
|
||||
|
||||
startStr, _ := cmd.Flags().GetString("start")
|
||||
endStr, _ := cmd.Flags().GetString("end")
|
||||
// China Standard Time has no DST; FixedZone avoids zoneinfo nil-fallback branches.
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
var startMs, endMs int64
|
||||
if startStr != "" {
|
||||
var err error
|
||||
startMs, err = parseISOTimeToMillis("start", startStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["startTime"] = time.UnixMilli(startMs).In(loc).Format(time.RFC3339)
|
||||
}
|
||||
if endStr != "" {
|
||||
var err error
|
||||
endMs, err = parseISOTimeToMillis("end", endStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["endTime"] = time.UnixMilli(endMs).In(loc).Format(time.RFC3339)
|
||||
}
|
||||
if startStr != "" && endStr != "" {
|
||||
if err := validateTimeRange(startMs, endMs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return callMCPToolUnescaped("list_audio_memos", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(audioMemoListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "list_audio_memos",
|
||||
CanonicalPath: "minutes.list_audio_memos",
|
||||
CLIPath: "minutes audio-memo list",
|
||||
PrimaryCLIPath: "minutes audio-memo list",
|
||||
},
|
||||
Description: "查询当前用户的语音备忘列表,支持分页和时间范围筛选。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "list_audio_memos"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询当前用户的语音备忘列表(独立于听记列表与 get audio)。",
|
||||
UseWhen: []string{"用户要查看语音备忘/录音备忘列表时(可带时间范围或翻页)"},
|
||||
AvoidWhen: []string{
|
||||
"要查听记列表改用 minutes list",
|
||||
"只要某篇听记的音频地址改用 minutes get audio",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes audio-memo list",
|
||||
"dws minutes audio-memo list --start \"2026-01-01T00:00:00+08:00\" --end \"2026-07-21T23:59:59+08:00\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "max", Property: "pageSize"},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
{Name: "start", Property: "startTime"},
|
||||
{Name: "end", Property: "endTime"},
|
||||
},
|
||||
},
|
||||
})
|
||||
audioMemoListCmd.Flags().Float64("max", 200, "每页数据条数 (默认 200,上限 1000)")
|
||||
audioMemoListCmd.Flags().Int64("cursor", 0, "翻页游标,回填上一页返回的 nextCursor (首页留空)")
|
||||
audioMemoListCmd.Flags().String("start", "", "开始时间 ISO-8601 (可选,默认近一年)")
|
||||
audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)")
|
||||
audioMemoCmd.AddCommand(audioMemoListCmd)
|
||||
|
||||
minutesCmd := &cobra.Command{
|
||||
Use: "minutes",
|
||||
Short: "AI 听记 / 会议纪要",
|
||||
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理,以及修改标题和纪要内容。`,
|
||||
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, uploadCmd, permissionCmd, tagCmd)
|
||||
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd)
|
||||
return minutesCmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executeMarkdownDiff(t *testing.T, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = append([]string{"dws", "markdown"}, args...)
|
||||
root := newMarkdownCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownDiffCommand(t *testing.T) {
|
||||
t.Run("oversized local file", func(t *testing.T) {
|
||||
testseam.Swap(t, &maxDiffFileSize, int64(8))
|
||||
big := filepath.Join(t.TempDir(), "big.md")
|
||||
if err := os.WriteFile(big, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkFileSize(big); err == nil {
|
||||
t.Fatal("expected oversized local file")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download helpers and ensure type", func(t *testing.T) {
|
||||
testseam.Protect(t, &diffDownloadLimited)
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("left\n"), nil
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://example.test/a","headers":{"X":"1"}}`},
|
||||
}})
|
||||
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err != nil {
|
||||
t.Fatalf("download latest: %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://example.test/a"}`},
|
||||
}})
|
||||
if _, err := downloadRemoteContent(context.Background(), "nid", 3); err != nil {
|
||||
t.Fatalf("download version: %v", err)
|
||||
}
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return nil, errors.New("dl boom")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://example.test/a"}`},
|
||||
}})
|
||||
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err == nil {
|
||||
t.Fatal("expected download limited error")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
ext string
|
||||
ok bool
|
||||
}{
|
||||
{"md", true}, {"markdown", true}, {"", true},
|
||||
{"adoc", false}, {"axls", false}, {"amind", false}, {"adraw", false}, {"pdf", false},
|
||||
} {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"result":{"name":"x","extension":%q}}`, tc.ext)},
|
||||
}})
|
||||
err := ensureMarkdownDiffType(context.Background(), "n1")
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("ext %q: %v", tc.ext, err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatalf("ext %q: expected type error", tc.ext)
|
||||
}
|
||||
}
|
||||
for _, ext := range []string{"adoc", "axls", "amind", "adraw", "other"} {
|
||||
if describeDingTalkDocType(ext) == "" {
|
||||
t.Fatalf("describe %q empty", ext)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaultDiffDownloadLimited http paths", func(t *testing.T) {
|
||||
okSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "4")
|
||||
_, _ = w.Write([]byte("data"))
|
||||
}))
|
||||
t.Cleanup(okSrv.Close)
|
||||
got, err := defaultDiffDownloadLimited(context.Background(), okSrv.URL, map[string]string{"X-Test": "1"})
|
||||
if err != nil || string(got) != "data" {
|
||||
t.Fatalf("ok download: %v %q", err, got)
|
||||
}
|
||||
|
||||
badStatus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("nope"))
|
||||
}))
|
||||
t.Cleanup(badStatus.Close)
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), badStatus.URL, nil); err == nil {
|
||||
t.Fatal("expected non-200")
|
||||
}
|
||||
|
||||
testseam.Swap(t, &maxDiffFileSize, int64(3))
|
||||
tooBigHeader := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Length", "100")
|
||||
_, _ = w.Write([]byte("xxxx"))
|
||||
}))
|
||||
t.Cleanup(tooBigHeader.Close)
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), tooBigHeader.URL, nil); err == nil {
|
||||
t.Fatal("expected content-length guard")
|
||||
}
|
||||
|
||||
chunkedBig := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// No Content-Length: force LimitReader path to observe oversize body.
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "no hijack", 500)
|
||||
return
|
||||
}
|
||||
conn, bufrw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
payload := strings.Repeat("x", 16)
|
||||
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
|
||||
_, _ = bufrw.WriteString(fmt.Sprintf("%x\r\n%s\r\n0\r\n\r\n", len(payload), payload))
|
||||
_ = bufrw.Flush()
|
||||
}))
|
||||
t.Cleanup(chunkedBig.Close)
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), chunkedBig.URL, nil); err == nil {
|
||||
t.Fatal("expected body size guard")
|
||||
}
|
||||
|
||||
readFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
conn, bufrw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n")
|
||||
_ = bufrw.Flush()
|
||||
_ = conn.Close() // truncate body → ReadAll error
|
||||
}))
|
||||
t.Cleanup(readFail.Close)
|
||||
maxDiffFileSize = 1000
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), readFail.URL, nil); err == nil {
|
||||
t.Fatal("expected read error")
|
||||
}
|
||||
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), "http://%\x00", nil); err == nil {
|
||||
t.Fatal("expected bad url")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := defaultDiffDownloadLimited(ctx, okSrv.URL, nil); err == nil {
|
||||
t.Fatal("expected canceled ctx")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("computeUnifiedDiff deletes", func(t *testing.T) {
|
||||
text, add, del, hunks, changed := computeUnifiedDiff("a\nb\n", "a\n", 2)
|
||||
if !changed || del < 1 || hunks < 1 || add != 0 || text == "" {
|
||||
t.Fatalf("delete-only diff: changed=%v add=%d del=%d hunks=%d", changed, add, del, hunks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validation and dry-run modes", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executeMarkdownDiff(t, "diff"); err == nil {
|
||||
t.Fatal("expected missing node")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "0"); err == nil {
|
||||
t.Fatal("expected version>0")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "0"); err == nil {
|
||||
t.Fatal("expected version2>0")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--context", "-1"); err == nil {
|
||||
t.Fatal("expected context>=0")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", "a.md", "--version2", "2"); err == nil {
|
||||
t.Fatal("expected file/version2 mutex")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1"); err == nil {
|
||||
t.Fatal("expected remote needs version")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2"); err != nil {
|
||||
t.Fatalf("dry-run remote: %v", err)
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--url", "n1", "--file", "local.md"); err != nil {
|
||||
t.Fatalf("dry-run local latest: %v", err)
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2", "--file", "local.md"); err != nil {
|
||||
t.Fatalf("dry-run local version: %v", err)
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "3"); err != nil {
|
||||
t.Fatalf("dry-run version2-only: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote vs local and remote vs remote execute", func(t *testing.T) {
|
||||
testseam.Protect(t, &diffDownloadLimited)
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("alpha\n"), nil
|
||||
}
|
||||
local := filepath.Join(t.TempDir(), "right.md")
|
||||
if err := os.WriteFile(local, []byte("beta\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err != nil {
|
||||
t.Fatalf("local json: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
diffDownloadLimited = func(_ context.Context, url string, _ map[string]string) ([]byte, error) {
|
||||
if strings.Contains(url, "/r") {
|
||||
return []byte("alpha\n"), nil
|
||||
}
|
||||
return []byte("gamma\n"), nil
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
|
||||
t.Fatalf("remote text changed: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("same\n"), nil
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
|
||||
t.Fatalf("remote text unchanged: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"adoc"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1"); err == nil {
|
||||
t.Fatal("expected type guard")
|
||||
}
|
||||
|
||||
testseam.Protect(t, &maxDiffFileSize)
|
||||
maxDiffFileSize = 2
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err == nil {
|
||||
t.Fatal("expected local size fail")
|
||||
}
|
||||
maxDiffFileSize = 10 * 1024 * 1024
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
}})
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("x\n"), nil
|
||||
}
|
||||
missingLocal := filepath.Join(t.TempDir(), "missing.md")
|
||||
// pass size check by writing then removing after checkFileSize... actually RunE checks size first then reads.
|
||||
// Create file for size check, then make ReadFile fail via directory path.
|
||||
dirAsFile := t.TempDir()
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", dirAsFile); err == nil {
|
||||
t.Fatal("expected read local dir failure")
|
||||
}
|
||||
_ = missingLocal
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{err: errors.New("right fail")},
|
||||
}})
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("x\n"), nil
|
||||
}
|
||||
// second download uses version tool — make parse fail on second call via empty resource
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil {
|
||||
t.Fatal("expected right download parse fail")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json marshal and compute timeout", func(t *testing.T) {
|
||||
testseam.Protect(t, &diffDownloadLimited)
|
||||
testseam.Protect(t, &diffJSONMarshalIndent)
|
||||
testseam.Protect(t, &runMarkdownUnifiedDiff)
|
||||
testseam.Protect(t, &diffComputeTimeout)
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("a\n"), nil
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
diffJSONMarshalIndent = func(any, string, string) ([]byte, error) {
|
||||
return nil, errors.New("marshal boom")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "JSON") {
|
||||
t.Fatalf("expected marshal err, got %v", err)
|
||||
}
|
||||
|
||||
diffJSONMarshalIndent = json.MarshalIndent
|
||||
runMarkdownUnifiedDiff = func(string, string, int) (string, int, int, int, bool) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return "", 0, 0, 0, false
|
||||
}
|
||||
diffComputeTimeout = time.Millisecond
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "超时") {
|
||||
t.Fatalf("expected timeout, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fetchFileInfo extension field", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":".MD","name":"x.md"}`},
|
||||
}})
|
||||
info := fetchFileInfo(context.Background(), "n")
|
||||
if info.extension != "md" || info.name != "x.md" {
|
||||
t.Fatalf("info=%+v", info)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailExportShareAndAtomicWrite(t *testing.T) {
|
||||
t.Run("message export dry-run and execute", func(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "named"); err != nil {
|
||||
t.Fatalf("export dry-run: %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
|
||||
t.Fatalf("export dry-run default name: %v", err)
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"message":{"subject":"Hello/World"}}}`},
|
||||
{text: `{"result":{"emlContent":"From: a\r\n\r\nbody"}}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
if _, err := os.Stat("Hello_World.eml"); err != nil {
|
||||
t.Fatalf("missing eml: %v", err)
|
||||
}
|
||||
|
||||
// exist without overwrite
|
||||
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"emlContent":"x"}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller2)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World"); err == nil {
|
||||
t.Fatal("expected exist error")
|
||||
}
|
||||
|
||||
caller3 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"emlContent":"y"}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller3)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World", "--overwrite"); err != nil {
|
||||
t.Fatalf("overwrite: %v", err)
|
||||
}
|
||||
|
||||
// subject fallback to message id when missing
|
||||
caller4 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{}}`},
|
||||
{text: `{"emlContent":"z"}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller4)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "msg-fallback"); err != nil {
|
||||
t.Fatalf("fallback name: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("share-to-chat paths", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err != nil {
|
||||
t.Fatalf("share dry-run: %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1"); err != nil {
|
||||
t.Fatalf("share dry-run no users: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
|
||||
// Piped stdin "yes" must not bypass the explicit --yes gate.
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"sign":"sig","riskMessage":"careful"}}`},
|
||||
}})
|
||||
if err := executeMailShare(t, strings.NewReader("yes\n"),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil {
|
||||
t.Fatal("expected confirmation_required without --yes")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"sign":"sig"}`},
|
||||
}})
|
||||
if err := executeMailShare(t, strings.NewReader("yes\n"),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil {
|
||||
t.Fatal("expected confirmation_required without --yes")
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"sign":"sig","riskMessage":"careful"}`},
|
||||
{text: `{"ok":true}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
|
||||
t.Fatalf("share with yes: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"ok":true}`}}})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
|
||||
t.Fatalf("json success path: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("calendar-event missing folder id", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"calendar-event", "list",
|
||||
"--email", "u@c.com",
|
||||
"--start", "2026-07-01T00:00:00Z",
|
||||
"--end", "2026-07-31T23:59:59Z",
|
||||
); err == nil {
|
||||
t.Fatal("expected missing id/folder-id")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sanitize and atomic write", func(t *testing.T) {
|
||||
if got := sanitizeMailFilename(" a/b\\c\x00 "); got != "a_b_c" {
|
||||
t.Fatalf("sanitize=%q", got)
|
||||
}
|
||||
if got := sanitizeMailFilename(" "); got != "mail" {
|
||||
t.Fatalf("empty sanitize=%q", got)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "out.eml")
|
||||
if err := atomicWriteFile(path, []byte("one"), 0o600, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := atomicWriteFile(path, []byte("two"), 0o600, false); err == nil {
|
||||
t.Fatal("expected exist without overwrite")
|
||||
}
|
||||
if err := atomicWriteFile(path, []byte("two"), 0o600, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "no-such", "x.eml"), []byte("x"), 0o600, false); err == nil {
|
||||
t.Fatal("expected create temp fail")
|
||||
}
|
||||
|
||||
testseam.Protect(t, &atomicCreateTemp)
|
||||
testseam.Protect(t, &atomicRemove)
|
||||
testseam.Protect(t, &atomicRename)
|
||||
atomicRemove = func(string) error { return nil }
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{chmodErr: errors.New("chmod boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "c.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "权限") {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{writeErr: errors.New("write boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "w.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "写入") {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{syncErr: errors.New("sync boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "s.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "同步") {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{closeErr: errors.New("close boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "cl.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "关闭") {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{}, nil
|
||||
}
|
||||
atomicRename = func(string, string) error { return errors.New("rename boom") }
|
||||
if err := atomicWriteFile(filepath.Join(dir, "r.eml"), []byte("x"), 0o600, true); err == nil || !strings.Contains(err.Error(), "重命名") {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("export save non-exist failure", func(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
testseam.Swap(t, &atomicCreateTemp, func(string, string) (atomicTempFile, error) {
|
||||
return nil, errors.New("nospc")
|
||||
})
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"emlContent":"body"}`},
|
||||
}})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "x"); err == nil || !strings.Contains(err.Error(), "保存文件失败") {
|
||||
t.Fatalf("expected save failure, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func executeMailShare(t *testing.T, in io.Reader, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = append([]string{"dws", "mail"}, args...)
|
||||
root := newMailCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetIn(in)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDiffEngineEdges(t *testing.T) {
|
||||
// Expand matches backward + negative context clamp + missing newline marker.
|
||||
old := []byte("a\nb\nc\nd\ne")
|
||||
neu := []byte("a\nb\nC\nd\ne\n")
|
||||
diff := UnifiedDiff("o", old, "n", neu, -3)
|
||||
if len(diff) == 0 {
|
||||
t.Fatal("expected diff with negative context")
|
||||
}
|
||||
diff2 := UnifiedDiff("o", []byte("same"), "n", []byte("same\nextra\n"), 2)
|
||||
if !strings.Contains(string(diff2), "No newline at end of file") && !strings.Contains(string(diff2), "+extra") {
|
||||
t.Fatalf("unexpected diff2=%q", diff2)
|
||||
}
|
||||
_ = UnifiedDiff("o", []byte("1\n2\n3\n4\n5\n6\n7\n"), "n", []byte("1\n2\n3\nX\n5\n6\n7\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("only-old\n"), "n", []byte("only-new\n"), 0)
|
||||
|
||||
// Non-unique "common" lines before a unique anchor → backward expand (L90).
|
||||
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\n"), 1)
|
||||
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\ntail\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\ntail\nextra\n"), 2)
|
||||
// Large context after an early emitted chunk → chunk.x/y clamps + new-chunk prefix.
|
||||
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("U\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("U\nZ\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("A\n\n\nU\nrest\n"), "n", []byte("B\n\n\nU\nrest\nmore\n"), 5)
|
||||
if nonNeg(-3) != 0 || nonNeg(0) != 0 || nonNeg(4) != 4 {
|
||||
t.Fatalf("nonNeg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveLatestRemaining(t *testing.T) {
|
||||
items := []map[string]any{
|
||||
{"name": "a", "sortTime": int64(5), "rel_path": "same", "fileId": "2", "type": "file"},
|
||||
{"name": "b", "sortTime": int64(5), "rel_path": "same", "fileId": "1", "type": "file"},
|
||||
{"name": "c", "sortTime": int64(5), "rel_path": "z", "fileId": "3", "type": "file"},
|
||||
{"nodeType": "Folder", "name": "folder"},
|
||||
}
|
||||
got := applyDriveListLatest(items, 10)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len=%d", len(got))
|
||||
}
|
||||
if n, err := json.Number("99").Int64(); err != nil || n != 99 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ms, ok := toMillis(json.Number("99")); !ok || ms != 99 {
|
||||
t.Fatalf("json.Number millis=%v %v", ms, ok)
|
||||
}
|
||||
if _, ok := toMillis(json.Number("-1")); ok {
|
||||
t.Fatal("negative json.Number")
|
||||
}
|
||||
if _, ok := toMillis(struct{}{}); ok {
|
||||
t.Fatal("unknown type")
|
||||
}
|
||||
|
||||
// pagination + quiet=false progress + shortfall hint with pattern
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"x.bin","fileId":"2","type":"file"},{"fileId":"3","type":"file"}],"nextToken":"n1"}`},
|
||||
{text: `{"items":[{"name":"b.md","fileName":"b.md","fileId":"4","type":"file"}],"nextToken":""}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "drive", "list"}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(context.Background())
|
||||
if err := runDriveListLatest(cmd, map[string]any{"spaceId": "s"}, "folder", 5, "*.md", false); err != nil {
|
||||
t.Fatalf("latest paginate: %v", err)
|
||||
}
|
||||
|
||||
// nil context uses Background
|
||||
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"}],"nextToken":""}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller2)
|
||||
cmd2 := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListLatest(cmd2, nil, "", 1, "", true); err != nil {
|
||||
t.Fatalf("nil ctx: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListLatestBadFolder(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "list", "--latest", "2", "--folder", "12345"); err == nil {
|
||||
t.Fatal("expected numeric folder rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"items":[`)
|
||||
for i := 0; i < driveDepthMaxItems; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i)
|
||||
}
|
||||
sb.WriteString(`]}`)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2)
|
||||
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("err=%v, want LATEST_SCAN_TRUNCATED", err)
|
||||
}
|
||||
_ = out
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWhiteboardSeams(t *testing.T) {
|
||||
testseam.Swap(t, &whiteboardJSONMarshal, func(any) ([]byte, error) { return nil, errors.New("boom") })
|
||||
if buildWhiteboardCardJSONML("b", "w") != "" {
|
||||
t.Fatal("expected empty on marshal fail")
|
||||
}
|
||||
|
||||
testseam.Swap(t, &prepareWhiteboardCard, func(*cobra.Command, string) (string, error) {
|
||||
return "", errors.New("bad template")
|
||||
})
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err == nil || !strings.Contains(err.Error(), "白板卡片模板") {
|
||||
t.Fatalf("expected prepare fail, got %v", err)
|
||||
}
|
||||
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "doc", "whiteboard"}
|
||||
|
||||
// nil entry in blocks list
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"blocks":[null,{"blockId":"b","jsonml":"[\"card\",{\"metadata\":{\"id\":\"w\"}}]"}]}`},
|
||||
}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err != nil {
|
||||
t.Fatalf("nil entry skip: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executePR868Command(t *testing.T, root *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
os.Args = append([]string{"dws", root.Name()}, args...)
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
|
||||
t.Run("hot-word delete dry-run", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil {
|
||||
t.Fatalf("hot-word delete dry-run: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("hot-word delete missing words", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete"); err == nil {
|
||||
t.Fatal("expected missing --words error")
|
||||
}
|
||||
})
|
||||
t.Run("hot-word delete executes", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉"); err != nil {
|
||||
t.Fatalf("hot-word delete: %v", err)
|
||||
}
|
||||
if caller.tool != "delete_personal_hotword" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("permission apply dry-run", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task-1", "--policy", "4"); err != nil {
|
||||
t.Fatalf("permission apply dry-run: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("permission apply alias uuid", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--uuid", "task-2", "--policy", "2"); err != nil {
|
||||
t.Fatalf("permission apply alias: %v", err)
|
||||
}
|
||||
if caller.tool != "apply_minutes_permission" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
if caller.args["taskUuid"] != "task-2" || caller.args["policyId"] != float64(2) {
|
||||
t.Fatalf("args=%#v", caller.args)
|
||||
}
|
||||
})
|
||||
t.Run("permission apply invalid policy", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "1"); err == nil {
|
||||
t.Fatal("expected invalid policy")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "x"); err == nil {
|
||||
t.Fatal("expected non-numeric policy")
|
||||
}
|
||||
})
|
||||
t.Run("permission apply missing flags", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--policy", "4"); err == nil {
|
||||
t.Fatal("expected missing id")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task"); err == nil {
|
||||
t.Fatal("expected missing policy")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("audio-memo list default", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list"); err != nil {
|
||||
t.Fatalf("audio-memo list: %v", err)
|
||||
}
|
||||
if caller.tool != "list_audio_memos" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
if caller.args["pageSize"] != float64(200) {
|
||||
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
|
||||
}
|
||||
})
|
||||
t.Run("audio-memo list with range and cursor", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newMinutesCommand(),
|
||||
"audio-memo", "list",
|
||||
"--max", "10",
|
||||
"--cursor", "1740000000000",
|
||||
"--start", "2026-01-01T00:00:00+08:00",
|
||||
"--end", "2026-07-21T23:59:59+08:00",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("audio-memo list ranged: %v", err)
|
||||
}
|
||||
if caller.args["cursor"] != float64(1740000000000) {
|
||||
t.Fatalf("cursor=%#v", caller.args["cursor"])
|
||||
}
|
||||
start, _ := caller.args["startTime"].(string)
|
||||
end, _ := caller.args["endTime"].(string)
|
||||
if !strings.Contains(start, "2026-01-01") || !strings.Contains(end, "2026-07-21") {
|
||||
t.Fatalf("start/end=%q/%q", start, end)
|
||||
}
|
||||
})
|
||||
t.Run("audio-memo list validation", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "0"); err == nil {
|
||||
t.Fatal("expected max validation")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "1001"); err == nil {
|
||||
t.Fatal("expected max upper bound")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--cursor", "-1"); err == nil {
|
||||
t.Fatal("expected cursor validation")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list",
|
||||
"--start", "2026-07-21T00:00:00+08:00", "--end", "2026-01-01T00:00:00+08:00"); err == nil {
|
||||
t.Fatal("expected reversed range error")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--start", "bad"); err == nil {
|
||||
t.Fatal("expected bad start")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--end", "bad"); err == nil {
|
||||
t.Fatal("expected bad end")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocExportGetTaskIDAlias(t *testing.T) {
|
||||
// Existing primary --job-id must remain usable.
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"SUCCESS","downloadUrl":"https://x"}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDocCommand(), "export", "get", "--job-id", "job-legacy"); err != nil {
|
||||
t.Fatalf("export get --job-id: %v", err)
|
||||
}
|
||||
if caller.tool != "query_export_job" || caller.args["jobId"] != "job-legacy" {
|
||||
t.Fatalf("tool/args=%q %#v", caller.tool, caller.args)
|
||||
}
|
||||
|
||||
// Add-only synonym --task-id.
|
||||
caller2 := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"PROCESSING"}`}}}
|
||||
installScriptedCaller(t, caller2)
|
||||
if err := executePR868Command(t, newDocCommand(), "export", "get", "--task-id", "job-123"); err != nil {
|
||||
t.Fatalf("export get --task-id: %v", err)
|
||||
}
|
||||
if caller2.args["jobId"] != "job-123" {
|
||||
t.Fatalf("task-id args=%#v", caller2.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveAliasAndDownloadVersion(t *testing.T) {
|
||||
t.Run("permission list max-results", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--max-results", "10"); err != nil {
|
||||
t.Fatalf("permission list: %v", err)
|
||||
}
|
||||
if caller.args["maxResults"] != 10 {
|
||||
t.Fatalf("maxResults=%#v", caller.args["maxResults"])
|
||||
}
|
||||
})
|
||||
t.Run("cover file-id alias", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "cover", "--file-id", "n1"); err != nil {
|
||||
t.Fatalf("cover --file-id: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("revert doc-id alias dry-run", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "revert", "--doc-id", "n1", "--version", "3"); err != nil {
|
||||
t.Fatalf("revert --doc-id: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("star add url alias", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "star", "add", "--url", "https://example/n1"); err != nil {
|
||||
t.Fatalf("star add --url: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("download --version routes", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
out := filepath.Join(t.TempDir(), "out.pdf")
|
||||
if err := executePR868Command(t, newDriveCommand(), "download", "--node", "n1", "--version", "3", "--output", out); err != nil {
|
||||
t.Fatalf("download --version: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailCalendarEventFolderID(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
err := executePR868Command(t, newMailCommand(),
|
||||
"calendar-event", "list",
|
||||
"--email", "a@b.com",
|
||||
"--folder-id", "cal-1",
|
||||
"--start", "2026-07-01T00:00:00Z",
|
||||
"--end", "2026-07-31T23:59:59Z",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("calendar-event list --folder-id: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnifiedDiffEngine(t *testing.T) {
|
||||
if got := UnifiedDiff("a", []byte("same\n"), "b", []byte("same\n"), 3); got != nil {
|
||||
t.Fatalf("identical should be nil, got %q", got)
|
||||
}
|
||||
old := []byte("one\ntwo\nthree\n")
|
||||
neu := []byte("one\nTWO\nthree\nfour\n")
|
||||
diff := UnifiedDiff("old.txt", old, "new.txt", neu, 2)
|
||||
if len(diff) == 0 || !strings.Contains(string(diff), "@@") {
|
||||
t.Fatalf("expected hunk diff, got %q", diff)
|
||||
}
|
||||
_ = UnifiedDiff("o", []byte("a\nb\n"), "n", []byte("a\nc\n"), 0)
|
||||
_ = UnifiedDiff("o", []byte("{\n\n}\n"), "n", []byte("{\n x\n}\n"), 3)
|
||||
_ = UnifiedDiff("o", []byte("alpha\n"), "n", []byte("beta\n"), 1)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveLatestHelpers(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.Flags().Int("latest", 0, "")
|
||||
cmd.Flags().String("order-by", "", "")
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().Int("limit", 0, "")
|
||||
cmd.Flags().Int("max", 0, "")
|
||||
cmd.Flags().String("cursor", "", "")
|
||||
cmd.Flags().String("next-token", "", "")
|
||||
|
||||
if err := validateDriveListLatest(cmd, 0); err == nil {
|
||||
t.Fatal("expected latest lower bound")
|
||||
}
|
||||
if err := validateDriveListLatest(cmd, 51); err == nil {
|
||||
t.Fatal("expected latest upper bound")
|
||||
}
|
||||
_ = cmd.ParseFlags([]string{"--order-by=name"})
|
||||
if err := validateDriveListLatest(cmd, 3); err == nil {
|
||||
t.Fatal("expected exclusive order-by")
|
||||
}
|
||||
|
||||
cmd2 := &cobra.Command{Use: "list"}
|
||||
cmd2.Flags().Int("latest", 0, "")
|
||||
cmd2.Flags().String("order-by", "", "")
|
||||
cmd2.Flags().String("order", "", "")
|
||||
cmd2.Flags().Int("limit", 0, "")
|
||||
cmd2.Flags().Int("max", 0, "")
|
||||
cmd2.Flags().String("cursor", "", "")
|
||||
cmd2.Flags().String("next-token", "", "")
|
||||
_ = cmd2.ParseFlags([]string{"--limit=10"})
|
||||
if err := validateDriveListLatest(cmd2, 3); err == nil {
|
||||
t.Fatal("expected exclusive limit")
|
||||
}
|
||||
|
||||
cmd3 := &cobra.Command{Use: "list"}
|
||||
cmd3.Flags().Int("latest", 0, "")
|
||||
cmd3.Flags().String("order-by", "", "")
|
||||
cmd3.Flags().String("order", "", "")
|
||||
cmd3.Flags().Int("limit", 0, "")
|
||||
cmd3.Flags().Int("max", 0, "")
|
||||
cmd3.Flags().String("cursor", "", "")
|
||||
cmd3.Flags().String("next-token", "", "")
|
||||
_ = cmd3.ParseFlags([]string{"--cursor=tok"})
|
||||
if err := validateDriveListLatest(cmd3, 3); err == nil {
|
||||
t.Fatal("expected exclusive cursor")
|
||||
}
|
||||
if err := validateDriveListLatest(cmd3, 3); err == nil {
|
||||
// already failed above
|
||||
}
|
||||
_ = validateDriveListLatest(&cobra.Command{Use: "x"}, 3) // no exclusive flags
|
||||
|
||||
items := []map[string]any{
|
||||
{"name": "b.txt", "sortTime": int64(1), "rel_path": "b", "fileId": "2", "type": "file"},
|
||||
{"name": "a.txt", "sortTime": int64(2), "rel_path": "a", "fileId": "1", "type": "file"},
|
||||
{"name": "dir", "sortTime": int64(9), "type": "folder", "dentryType": "folder"},
|
||||
}
|
||||
got := applyDriveListLatest(items, 1)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("latest len=%d", len(got))
|
||||
}
|
||||
stripDriveDepthDecorations(got)
|
||||
if _, ok := got[0]["sortTime"]; ok {
|
||||
t.Fatal("sortTime should be stripped")
|
||||
}
|
||||
|
||||
if ms, ok := driveItemModifiedMillis(map[string]any{"modifiedTime": float64(123)}); !ok || ms != 123 {
|
||||
t.Fatalf("float millis=%v %v", ms, ok)
|
||||
}
|
||||
if ms, ok := toMillis("2026-01-02T03:04:05Z"); !ok || ms <= 0 {
|
||||
t.Fatalf("rfc3339 millis=%v %v", ms, ok)
|
||||
}
|
||||
if _, ok := toMillis(""); ok {
|
||||
t.Fatal("empty string should fail")
|
||||
}
|
||||
if _, ok := toMillis(float64(-1)); ok {
|
||||
t.Fatal("negative float should fail")
|
||||
}
|
||||
if ms, ok := toMillis("42"); !ok || ms != 42 {
|
||||
t.Fatalf("int string=%v %v", ms, ok)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
listCmd := &cobra.Command{Use: "list"}
|
||||
listCmd.SetContext(context.Background())
|
||||
if err := runDriveListLatest(listCmd, map[string]any{"spaceId": "s"}, "folder", 2, "*.md", true); err != nil {
|
||||
t.Fatalf("dry-run latest: %v", err)
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{
|
||||
text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"skip.bin","fileId":"2","type":"file"},{"name":"dir","type":"FOLDER"}],"nextToken":""}`,
|
||||
}}}
|
||||
installScriptedCaller(t, caller)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "drive", "list"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
listCmd2 := &cobra.Command{Use: "list"}
|
||||
listCmd2.SetContext(context.Background())
|
||||
if err := runDriveListLatest(listCmd2, nil, "", 5, "*.md", false); err != nil {
|
||||
t.Fatalf("latest scan: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func executeWhiteboardCommand(t *testing.T, args ...string) error {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
os.Args = append([]string{"dws", "doc", "whiteboard"}, args...)
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
root := newDocWhiteboardCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWhiteboardInsertPaths(t *testing.T) {
|
||||
t.Run("dry-run plan", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1"); err != nil {
|
||||
t.Fatalf("whiteboard insert dry-run: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("insert with placement and verify", func(t *testing.T) {
|
||||
caller := &pr868FlexibleCaller{whiteboardID: "wb-persisted"}
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
|
||||
if err := executeWhiteboardCommand(t,
|
||||
"insert", "--node", "doc-1", "--ref-block", "ref", "--where", "before", "--index", "2", "--yes"); err != nil {
|
||||
t.Fatalf("whiteboard insert: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("soft success when verify empty", func(t *testing.T) {
|
||||
// Empty blocks are eventual-consistency pending, not hard query failure.
|
||||
caller := &pr868FlexibleCaller{emptyVerify: true}
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
|
||||
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err != nil {
|
||||
t.Fatalf("pending-block soft success: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("helpers", func(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc", "whiteboard", "insert"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
if buildWhiteboardCardJSONML("b", "w") == "" {
|
||||
t.Fatal("empty jsonml")
|
||||
}
|
||||
if extractWhiteboardID(nil) != "" {
|
||||
t.Fatal("nil attrs")
|
||||
}
|
||||
if extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": "x"}}) != "x" {
|
||||
t.Fatal("extract id")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: context.Canceled}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected query error")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"other","jsonml":"[]"}]}`}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected missing block")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"not-json"}]}`}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected jsonml parse error")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"blocks":[{"blockId":"b","jsonml":"[\"card\"]"}]}}`}}})
|
||||
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected missing attrs")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",null]"}]}`}}})
|
||||
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected nil attrs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type pr868FlexibleCaller struct {
|
||||
whiteboardID string
|
||||
emptyVerify bool
|
||||
format string
|
||||
dry bool
|
||||
}
|
||||
|
||||
func (c *pr868FlexibleCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
if tool == "insert_document_block" {
|
||||
return textToolResult(`{}`), nil
|
||||
}
|
||||
if tool == "list_document_blocks" {
|
||||
if c.emptyVerify {
|
||||
return textToolResult(`{"blocks":[]}`), nil
|
||||
}
|
||||
blockID, _ := args["blockId"].(string)
|
||||
payload := `{"blocks":[{"blockId":"` + blockID + `","jsonml":"[\"card\",{\"metadata\":{\"type\":\"hetu/draw\",\"id\":\"` + c.whiteboardID + `\"}},[\"span\",{},[\"span\",{},\"\"]]]"}]}`
|
||||
return textToolResult(payload), nil
|
||||
}
|
||||
return textToolResult(`{}`), nil
|
||||
}
|
||||
func (c *pr868FlexibleCaller) Format() string { return c.format }
|
||||
func (c *pr868FlexibleCaller) DryRun() bool { return c.dry }
|
||||
func (*pr868FlexibleCaller) Fields() string { return "" }
|
||||
func (*pr868FlexibleCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownDiffHelpers(t *testing.T) {
|
||||
if formatFileSize(100) == "" || formatFileSize(2048) == "" || formatFileSize(2*1024*1024) == "" {
|
||||
t.Fatal("formatFileSize")
|
||||
}
|
||||
small := filepath.Join(t.TempDir(), "ok.md")
|
||||
if err := os.WriteFile(small, []byte("hi\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkFileSize(small); err != nil {
|
||||
t.Fatalf("checkFileSize small: %v", err)
|
||||
}
|
||||
if err := checkFileSize(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("expected missing file")
|
||||
}
|
||||
|
||||
diff, add, _, hunks, changed := computeUnifiedDiff("a\n", "a\nb\n", 2)
|
||||
if !changed || add < 1 || hunks < 1 || diff == "" {
|
||||
t.Fatalf("computeUnifiedDiff=%v %d %d %q", changed, add, hunks, diff)
|
||||
}
|
||||
if _, _, _, _, ch := computeUnifiedDiff("x\n", "x\n", 2); ch {
|
||||
t.Fatal("identical should be unchanged")
|
||||
}
|
||||
|
||||
right := filepath.Join(t.TempDir(), "right.md")
|
||||
if err := os.WriteFile(right, []byte("hi\nthere\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if describeDingTalkDocType("adoc") == "" {
|
||||
t.Fatal("describeDingTalkDocType")
|
||||
}
|
||||
_ = right
|
||||
}
|
||||
@@ -141,18 +141,23 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te
|
||||
previousStdin := os.Stdin
|
||||
previousPut := httpPutFile
|
||||
previousGet := httpGetFile
|
||||
previousWhiteboardSleep := whiteboardSleep
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
os.Stdin = previousStdin
|
||||
httpPutFile = previousPut
|
||||
httpGetFile = previousGet
|
||||
whiteboardSleep = previousWhiteboardSleep
|
||||
})
|
||||
|
||||
caller := &productExampleCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
// Product examples execute real RunE paths; whiteboard insert retries must
|
||||
// not burn the suite timeout on real sleep (race CI uses a 12m package cap).
|
||||
whiteboardSleep = func(time.Duration) {}
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error {
|
||||
if destPath == "" {
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t
|
||||
for _, want := range []string{
|
||||
"agoal", "aisearch", "aitable", "attendance", "calendar", "chat",
|
||||
"contact", "devdoc", "ding", "doc", "drive", "live", "mail",
|
||||
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki",
|
||||
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard",
|
||||
} {
|
||||
if !seenProducts[want] {
|
||||
t.Errorf("public product %q was not registered", want)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
// 白板是显式编排的公开命令,不依赖 Wukong 的生成式产品注册表。
|
||||
func init() {
|
||||
RegisterPublic(func() Handler {
|
||||
return wukongHandler{name: "whiteboard", buildFn: newWhiteboardCommand}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
const (
|
||||
whiteboardServerID = "whiteboard"
|
||||
whiteboardQueryTool = "read_whiteboard_content"
|
||||
whiteboardUpdateTool = "update_whiteboard"
|
||||
)
|
||||
|
||||
type whiteboardUpdateFile struct {
|
||||
Overwrite bool `json:"overwrite"`
|
||||
Source *whiteboardOpenSource `json:"source"`
|
||||
}
|
||||
|
||||
type whiteboardOpenSource struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
CatalogVersion string `json:"catalogVersion"`
|
||||
Nodes json.RawMessage `json:"nodes"`
|
||||
}
|
||||
|
||||
var compactWhiteboardJSON = json.Compact
|
||||
|
||||
func newWhiteboardCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "whiteboard",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "读取和更新钉钉在线文档中的内嵌白板",
|
||||
UseWhen: []string{"操作已有文档内嵌白板的 OpenNodes 内容时"},
|
||||
AvoidWhen: []string{"普通文档正文和块使用 doc;创建白板卡片先用 doc whiteboard insert"},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
Use: "whiteboard",
|
||||
Short: "钉钉文档内嵌白板管理",
|
||||
Long: `读取或更新钉钉在线文档中已经存在的内嵌白板。
|
||||
|
||||
当前仅支持单页白板。每次操作都必须同时提供文档 ID 或 URL 和白板 part ID;
|
||||
本命令不负责创建白板(请使用 dws doc whiteboard insert),也不支持通过已有节点 ID 做局部修改。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
queryCmd := &cobra.Command{
|
||||
Use: "query",
|
||||
Short: "读取白板内容",
|
||||
Example: ` dws whiteboard query --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --format json`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "node", "part-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callWhiteboardTool(cmd, whiteboardQueryTool, map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"partId": mustGetFlag(cmd, "part-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
queryCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
|
||||
queryCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
|
||||
DeclareLeafMetadata(queryCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "whiteboard",
|
||||
Name: "query",
|
||||
CanonicalPath: "whiteboard.query",
|
||||
CLIPath: "whiteboard query",
|
||||
PrimaryCLIPath: "whiteboard query",
|
||||
},
|
||||
Description: "读取钉钉文档内已有白板的 OpenNodes 内容",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "白板端点通过显式服务适配器调用并解码 resultJson,不能绑定为单一 interface_ref",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "读取钉钉文档内已有白板的 OpenNodes 内容",
|
||||
UseWhen: []string{"已知承载文档 nodeId 和白板 partId,需要检查当前白板节点、布局或写入支持时"},
|
||||
AvoidWhen: []string{"创建新白板卡片用 doc whiteboard insert;缺少 partId 时先从文档 card metadata.id 定位"},
|
||||
Examples: []string{"dws whiteboard query --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
updateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "追加或整页重建白板内容",
|
||||
Long: `从 JSON 文件读取 OpenNodes V1 更新请求并更新已有白板。
|
||||
|
||||
更新模式由文件顶层的 overwrite 字段决定。overwrite=false 表示追加,
|
||||
overwrite=true 表示整页重建。两种模式都会写入远端白板,必须同时传入 --yes。`,
|
||||
Example: ` dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./whiteboard.json --format json
|
||||
dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./overwrite.json --yes --format json`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "node", "part-id", "source"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
input, nodesJSON, err := loadWhiteboardUpdateFile(mustGetFlag(cmd, "source"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mode := "append"
|
||||
if input.Overwrite {
|
||||
mode = "overwrite"
|
||||
}
|
||||
return callWhiteboardTool(cmd, whiteboardUpdateTool, map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"partId": mustGetFlag(cmd, "part-id"),
|
||||
"mode": mode,
|
||||
"nodes": nodesJSON,
|
||||
})
|
||||
},
|
||||
}
|
||||
updateCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
|
||||
updateCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
|
||||
updateCmd.Flags().String("source", "", "OpenNodes V1 更新请求 JSON 文件(必填)")
|
||||
updateCmd.Flags().Bool("yes", false, "确认写入远端白板")
|
||||
updateExampleIndex := 0
|
||||
DeclareLeafMetadata(updateCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "whiteboard",
|
||||
Name: "update",
|
||||
CanonicalPath: "whiteboard.update",
|
||||
CLIPath: "whiteboard update",
|
||||
PrimaryCLIPath: "whiteboard update",
|
||||
},
|
||||
Description: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "命令包含本地 OpenNodes 校验、显式白板服务路由与结构化结果解码,不能绑定为单一 interface_ref",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
|
||||
UseWhen: []string{"已有 nodeId、partId 和合规 OpenNodes V1 文件,用户确认后要追加图形、文本、连接线或整页替换时"},
|
||||
AvoidWhen: []string{"只读取内容用 whiteboard query;创建白板卡片用 doc whiteboard insert;不要用真实节点 ID 做局部修改"},
|
||||
Examples: []string{"dws whiteboard update --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --source ./whiteboard.json --format json"},
|
||||
ExampleDispositions: []contract.ExampleDisposition{{
|
||||
Index: &updateExampleIndex,
|
||||
Mode: contract.ExampleDispositionModeContractOnly,
|
||||
ReasonCode: contract.ExampleDispositionReasonLocalState,
|
||||
Reason: "运行时需要用户提供可读且通过 OpenNodes V1 校验的本地 JSON 文件",
|
||||
Reviewed: true,
|
||||
}},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
|
||||
{Name: "source", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root.AddCommand(queryCmd, updateCmd)
|
||||
return root
|
||||
}
|
||||
|
||||
func rejectWhiteboardOutputFilters(cmd *cobra.Command) error {
|
||||
for _, name := range []string{"jq", "fields"} {
|
||||
flag := cmd.Flags().Lookup(name)
|
||||
if flag == nil {
|
||||
flag = cmd.InheritedFlags().Lookup(name)
|
||||
}
|
||||
if flag != nil && flag.Changed {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("whiteboard 命令不支持 --%s", name),
|
||||
Suggestion: "直接读取命令返回的结构化 JSON",
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadWhiteboardUpdateFile(path string) (*whiteboardUpdateFile, string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
code := CodeInvalidPath
|
||||
if os.IsNotExist(err) {
|
||||
code = CodeFileNotFound
|
||||
}
|
||||
return nil, "", &CLIError{
|
||||
Code: code,
|
||||
Message: fmt.Sprintf("无法读取白板更新文件 %q", path),
|
||||
Suggestion: "确认 --source 指向可读的 UTF-8 JSON 文件",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var input whiteboardUpdateFile
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
return nil, "", invalidWhiteboardSourceJSON(err)
|
||||
}
|
||||
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
|
||||
return nil, "", invalidWhiteboardSourceJSON(err)
|
||||
}
|
||||
if input.Source == nil {
|
||||
return nil, "", invalidWhiteboardSourceParam("source is required")
|
||||
}
|
||||
if input.Source.SchemaVersion != "1.0" {
|
||||
return nil, "", invalidWhiteboardSourceParam(`source.schemaVersion must be "1.0"`)
|
||||
}
|
||||
if input.Source.CatalogVersion != "dml-v1" {
|
||||
return nil, "", invalidWhiteboardSourceParam(`source.catalogVersion must be "dml-v1"`)
|
||||
}
|
||||
|
||||
nodesJSON, nodeCount, err := validateWhiteboardNodes(input.Source.Nodes)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !input.Overwrite && nodeCount == 0 {
|
||||
return nil, "", invalidWhiteboardSourceParam("append requires at least one source.nodes item")
|
||||
}
|
||||
return &input, nodesJSON, nil
|
||||
}
|
||||
|
||||
func ensureWhiteboardJSONEOF(decoder *json.Decoder) error {
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err == nil {
|
||||
return fmt.Errorf("multiple JSON values are not allowed")
|
||||
} else if !errors.Is(err, io.EOF) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWhiteboardNodes(raw json.RawMessage) (string, int, error) {
|
||||
if len(raw) == 0 || !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
|
||||
return "", 0, invalidWhiteboardSourceParam("source.nodes must be an array")
|
||||
}
|
||||
|
||||
var nodes []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &nodes); err != nil {
|
||||
return "", 0, invalidWhiteboardSourceJSON(err)
|
||||
}
|
||||
for i, node := range nodes {
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(node, &object); err != nil || object == nil {
|
||||
return "", 0, invalidWhiteboardSourceParam(fmt.Sprintf("source.nodes[%d] must be an object", i))
|
||||
}
|
||||
}
|
||||
|
||||
var compact bytes.Buffer
|
||||
if err := compactWhiteboardJSON(&compact, raw); err != nil {
|
||||
return "", 0, invalidWhiteboardSourceJSON(err)
|
||||
}
|
||||
return compact.String(), len(nodes), nil
|
||||
}
|
||||
|
||||
func invalidWhiteboardSourceJSON(err error) error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidJSON,
|
||||
Message: "白板更新文件不是合法的 OpenNodes V1 JSON",
|
||||
Suggestion: "检查 JSON 语法、未知字段以及 source 对象结构",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
func invalidWhiteboardSourceParam(message string) error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: message,
|
||||
Suggestion: "参考 whiteboard Skill 中的 OpenNodes V1 文件格式",
|
||||
}
|
||||
}
|
||||
|
||||
func callWhiteboardTool(cmd *cobra.Command, toolName string, args map[string]any) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer(whiteboardServerID, toolName, args)
|
||||
}
|
||||
|
||||
text, err := callMCPToolReturnTextOnServer(cmd.Context(), whiteboardServerID, toolName, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var response map[string]any
|
||||
decoder := json.NewDecoder(strings.NewReader(text))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&response); err != nil {
|
||||
return invalidWhiteboardToolResult(toolName, err)
|
||||
}
|
||||
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
|
||||
return invalidWhiteboardToolResult(toolName, err)
|
||||
}
|
||||
if response == nil {
|
||||
return invalidWhiteboardToolResult(toolName, fmt.Errorf("response must be a JSON object"))
|
||||
}
|
||||
|
||||
if encoded, ok := response["resultJson"].(string); ok && strings.TrimSpace(encoded) != "" {
|
||||
var result any
|
||||
resultDecoder := json.NewDecoder(strings.NewReader(encoded))
|
||||
resultDecoder.UseNumber()
|
||||
if err := resultDecoder.Decode(&result); err != nil {
|
||||
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
|
||||
}
|
||||
if err := ensureWhiteboardJSONEOF(resultDecoder); err != nil {
|
||||
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
|
||||
}
|
||||
response["resultJson"] = result
|
||||
}
|
||||
return deps.Out.PrintJSON(response)
|
||||
}
|
||||
|
||||
func invalidWhiteboardToolResult(toolName string, err error) error {
|
||||
return &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: "白板服务返回了无法解析的 JSON",
|
||||
Suggestion: "使用 --debug 获取调用信息并联系白板服务维护者",
|
||||
Operation: whiteboardServerID + "/" + toolName,
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWhiteboardInjectedEncodingFailures(t *testing.T) {
|
||||
previousMarshal := whiteboardJSONMarshal
|
||||
whiteboardJSONMarshal = func(any) ([]byte, error) { return nil, errors.New("marshal") }
|
||||
if got := buildWhiteboardCardJSONML("b", "w"); got != "" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
whiteboardJSONMarshal = previousMarshal
|
||||
|
||||
previousPrepare := prepareWhiteboardCard
|
||||
prepareWhiteboardCard = func(*cobra.Command, string) (string, error) { return "", errors.New("prepare") }
|
||||
t.Cleanup(func() { prepareWhiteboardCard = previousPrepare })
|
||||
caller := &whiteboardTestCaller{}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "模板未通过") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
previousCompact := compactWhiteboardJSON
|
||||
compactWhiteboardJSON = func(*bytes.Buffer, []byte) error { return errors.New("compact") }
|
||||
t.Cleanup(func() { compactWhiteboardJSON = previousCompact })
|
||||
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[{"id":"n"}]`)); err == nil {
|
||||
t.Fatal("expected compact error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocWhiteboardInsertDryRun(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{dry: true}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"insert", "--node", "n"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeWhiteboardFixture(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "whiteboard.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadWhiteboardUpdateFileRejectsInvalidInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{name: "invalid json", content: `{`},
|
||||
{name: "trailing value", content: `{}` + ` {}`},
|
||||
{name: "missing source", content: `{}`},
|
||||
{name: "schema version", content: `{"source":{"schemaVersion":"2.0","catalogVersion":"dml-v1","nodes":[]}}`},
|
||||
{name: "catalog version", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"v2","nodes":[]}}`},
|
||||
{name: "nodes missing", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1"}}`},
|
||||
{name: "nodes malformed", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[}}`},
|
||||
{name: "node primitive", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[1]}}`},
|
||||
{name: "append empty", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`},
|
||||
{name: "unknown field", content: `{"unknown":true}`},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, _, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t, test.content)); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, _, err := loadWhiteboardUpdateFile(filepath.Join(t.TempDir(), "missing.json")); err == nil {
|
||||
t.Fatal("expected missing-file error")
|
||||
}
|
||||
if _, _, err := loadWhiteboardUpdateFile(t.TempDir()); err == nil {
|
||||
t.Fatal("expected directory read error")
|
||||
}
|
||||
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[`)); err == nil {
|
||||
t.Fatal("expected malformed nodes array error")
|
||||
}
|
||||
input, nodes, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t,
|
||||
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`))
|
||||
if err != nil || !input.Overwrite || nodes != "[]" {
|
||||
t.Fatalf("input=%#v nodes=%q err=%v", input, nodes, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardOutputFiltersAndToolResponseErrors(t *testing.T) {
|
||||
for _, name := range []string{"jq", "fields"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String(name, "", "")
|
||||
if err := cmd.Flags().Set(name, ".result"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rejectWhiteboardOutputFilters(cmd); err == nil {
|
||||
t.Fatal("expected rejected output filter")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
responses := []string{
|
||||
`{`,
|
||||
`{} {}`,
|
||||
`null`,
|
||||
`{"resultJson":"{"}`,
|
||||
`{"resultJson":"{} {}"}`,
|
||||
}
|
||||
for _, response := range responses {
|
||||
caller := &whiteboardTestCaller{format: "json", response: func(whiteboardTestCall, int) string { return response }}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err == nil {
|
||||
t.Fatalf("response %q should fail", response)
|
||||
}
|
||||
}
|
||||
|
||||
caller := &whiteboardTestCaller{dry: true}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, map[string]any{"partId": "p"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller = &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return "" }}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardDocumentQueryValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
attrs bool
|
||||
}{
|
||||
{name: "invalid response", response: `{`},
|
||||
{name: "missing block", response: `{"blocks":[]}`},
|
||||
{name: "non object block", response: `{"blocks":[1]}`},
|
||||
{name: "invalid jsonml", response: `{"blocks":[{"blockId":"b","jsonml":"{"}]}`},
|
||||
{name: "missing attrs", response: `{"blocks":[{"blockId":"b","jsonml":"[]"}]}`, attrs: true},
|
||||
{name: "attrs not object", response: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",1]"}]}`, attrs: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return test.response }}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
var err error
|
||||
if test.attrs {
|
||||
_, err = queryWhiteboardCardAttrs(context.Background(), "n", "b")
|
||||
} else {
|
||||
_, err = queryWhiteboardCardNode(context.Background(), "n", "b")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected query validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
caller := &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("boom") }}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected caller error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardCommandValidationBranches(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{format: "json"}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
for _, args := range [][]string{
|
||||
{"query", "--node", "n"},
|
||||
{"query", "--node", "n", "--part-id", "p", "--jq", "."},
|
||||
{"update", "--node", "n", "--part-id", "p"},
|
||||
{"update", "--node", "n", "--part-id", "p", "--fields", "result"},
|
||||
} {
|
||||
cmd := newWhiteboardCommand()
|
||||
cmd.PersistentFlags().String("jq", "", "")
|
||||
cmd.PersistentFlags().String("fields", "", "")
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatalf("args %v should fail", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateOverwriteAndSourceErrors(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{format: "json"}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
cmd := newWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", writeWhiteboardFixture(t,
|
||||
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`), "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls[0].args["mode"] != "overwrite" {
|
||||
t.Fatalf("args=%#v", caller.calls[0].args)
|
||||
}
|
||||
|
||||
cmd = newWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", filepath.Join(t.TempDir(), "missing")})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected source error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaUploadValidationAndSuccess(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
|
||||
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r","resourceUrl":"https://resource.example.test/icon"}`
|
||||
}}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
previousPut := httpPutFile
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
t.Cleanup(func() { httpPutFile = previousPut })
|
||||
file := writeWhiteboardFixture(t, "svg")
|
||||
cmd := newDocCommand()
|
||||
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--name", "icon", "--mime-type", "image/custom", "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls[0].args["fileName"] != "icon.json" || caller.calls[0].args["mimeType"] != "image/custom" {
|
||||
t.Fatalf("args=%#v", caller.calls[0].args)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"media", "upload", "--node", "n"},
|
||||
{"media", "upload", "--node", "n", "--file", filepath.Join(t.TempDir(), "missing")},
|
||||
{"media", "upload", "--node", "n", "--file", t.TempDir()},
|
||||
} {
|
||||
cmd = newDocCommand()
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatalf("args %v should fail", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaUploadRemainingBranches(t *testing.T) {
|
||||
file := writeWhiteboardFixture(t, "svg")
|
||||
|
||||
caller := &whiteboardTestCaller{dry: true}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
cmd := newDocCommand()
|
||||
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
caller *whiteboardTestCaller
|
||||
response string
|
||||
}{
|
||||
{name: "caller error", caller: &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("call") }}},
|
||||
{name: "missing resource url", caller: &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
|
||||
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r"}`
|
||||
}}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
installWhiteboardTestCaller(t, test.caller)
|
||||
cmd := newDocCommand()
|
||||
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--yes"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected upload error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocWhiteboardInsertCallerError(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{err: func(call whiteboardTestCall, index int) error {
|
||||
if index == 0 {
|
||||
return errors.New("insert")
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected insert error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWhiteboardIDAndJSONEOF(t *testing.T) {
|
||||
if got := extractWhiteboardID(nil); got != "" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": 1}}); got != "" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(`{} trailing`))
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureWhiteboardJSONEOF(decoder); err == nil {
|
||||
t.Fatal("expected trailing token error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type whiteboardTestCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type whiteboardTestCaller struct {
|
||||
dry bool
|
||||
format string
|
||||
err func(whiteboardTestCall, int) error
|
||||
response func(whiteboardTestCall, int) string
|
||||
calls []whiteboardTestCall
|
||||
}
|
||||
|
||||
func (c *whiteboardTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
call := whiteboardTestCall{server: server, tool: tool, args: args}
|
||||
c.calls = append(c.calls, call)
|
||||
if c.err != nil {
|
||||
if err := c.err(call, len(c.calls)-1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
text := `{}`
|
||||
if c.response != nil {
|
||||
text = c.response(call, len(c.calls)-1)
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (c *whiteboardTestCaller) Format() string { return c.format }
|
||||
func (c *whiteboardTestCaller) DryRun() bool { return c.dry }
|
||||
func (*whiteboardTestCaller) Fields() string { return "" }
|
||||
func (*whiteboardTestCaller) JQ() string { return "" }
|
||||
|
||||
func installWhiteboardTestCaller(t *testing.T, caller *whiteboardTestCaller) *bytes.Buffer {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(caller)
|
||||
output := &bytes.Buffer{}
|
||||
deps.Out.w = output
|
||||
deps.Out.errW = &bytes.Buffer{}
|
||||
return output
|
||||
}
|
||||
|
||||
func TestWhiteboardQueryRoutesAndDecodesResultJSON(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{
|
||||
format: "json",
|
||||
response: func(whiteboardTestCall, int) string {
|
||||
return `{"success":true,"resultJson":"{\"nodes\":[{\"type\":\"text\"}]}"}`
|
||||
},
|
||||
}
|
||||
output := installWhiteboardTestCaller(t, caller)
|
||||
cmd := newWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"query", "--node", "doc-1", "--part-id", "part-1"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].server != "whiteboard" || caller.calls[0].tool != whiteboardQueryTool {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["nodeId"] != "doc-1" || caller.calls[0].args["partId"] != "part-1" {
|
||||
t.Fatalf("args = %#v", caller.calls[0].args)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output = %q: %v", output.String(), err)
|
||||
}
|
||||
if _, ok := payload["resultJson"].(map[string]any); !ok {
|
||||
t.Fatalf("resultJson was not decoded: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateValidatesSourceAndRequiresConfirmation(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "whiteboard.json")
|
||||
if err := os.WriteFile(path, []byte(`{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &whiteboardTestCaller{format: "json"}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
|
||||
cmd := newWhiteboardCommand()
|
||||
cmd.SetIn(strings.NewReader("no\n"))
|
||||
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "用户取消了操作") {
|
||||
t.Fatalf("err = %v, want cancellation", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote call happened before confirmation: %#v", caller.calls)
|
||||
}
|
||||
|
||||
cmd = newWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path, "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != whiteboardUpdateTool {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["mode"] != "append" || caller.calls[0].args["nodes"] != `[{"id":"n1","type":"text"}]` {
|
||||
t.Fatalf("args = %#v", caller.calls[0].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocWhiteboardInsertBuildsCardAndReturnsPersistedPartID(t *testing.T) {
|
||||
var blockID string
|
||||
caller := &whiteboardTestCaller{
|
||||
format: "json",
|
||||
response: func(call whiteboardTestCall, index int) string {
|
||||
if index == 0 {
|
||||
var node []any
|
||||
if err := json.Unmarshal([]byte(call.args["jsonml"].(string)), &node); err != nil {
|
||||
t.Fatalf("jsonml: %v", err)
|
||||
}
|
||||
attrs := node[1].(map[string]any)
|
||||
blockID = attrs["uuid"].(string)
|
||||
return `{}`
|
||||
}
|
||||
jsonml := fmt.Sprintf(`["card",{"uuid":%q,"cardType":"hetu","metadata":{"id":"part-real"}}]`, blockID)
|
||||
encoded, _ := json.Marshal(jsonml)
|
||||
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
|
||||
},
|
||||
}
|
||||
output := installWhiteboardTestCaller(t, caller)
|
||||
previousDelays := whiteboardRetryDelays
|
||||
whiteboardRetryDelays = nil
|
||||
t.Cleanup(func() { whiteboardRetryDelays = previousDelays })
|
||||
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[0].tool != "insert_document_block" || caller.calls[1].tool != "list_document_blocks" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].server != "doc" || caller.calls[1].server != "doc" {
|
||||
t.Fatalf("unexpected servers: %#v", caller.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output = %q: %v", output.String(), err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
if result["whiteboardId"] != "part-real" {
|
||||
t.Fatalf("output = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// whiteboardCardBlockID 从 insert_document_block 的请求里取出 CLI 生成的卡片块 UUID,
|
||||
// 让回查桩可以用真实块 ID 组装响应。
|
||||
func whiteboardCardBlockID(t *testing.T, call whiteboardTestCall) string {
|
||||
t.Helper()
|
||||
raw, _ := call.args["jsonml"].(string)
|
||||
var node []any
|
||||
if err := json.Unmarshal([]byte(raw), &node); err != nil {
|
||||
t.Fatalf("jsonml: %v", err)
|
||||
}
|
||||
if len(node) < 2 {
|
||||
t.Fatalf("jsonml node missing attrs: %q", raw)
|
||||
}
|
||||
attrs, _ := node[1].(map[string]any)
|
||||
id, _ := attrs["uuid"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("jsonml node missing uuid: %q", raw)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// stubWhiteboardRetries 把重试节奏换成可观测的桩,返回已休眠次数的读取器。
|
||||
func stubWhiteboardRetries(t *testing.T, delays int) func() int {
|
||||
t.Helper()
|
||||
previousDelays := whiteboardRetryDelays
|
||||
previousSleep := whiteboardSleep
|
||||
stub := make([]time.Duration, delays)
|
||||
for i := range stub {
|
||||
stub[i] = time.Millisecond
|
||||
}
|
||||
slept := 0
|
||||
whiteboardRetryDelays = stub
|
||||
whiteboardSleep = func(time.Duration) { slept++ }
|
||||
t.Cleanup(func() {
|
||||
whiteboardRetryDelays = previousDelays
|
||||
whiteboardSleep = previousSleep
|
||||
})
|
||||
return func() int { return slept }
|
||||
}
|
||||
|
||||
// 插入成功后的回查如果自身失败(鉴权 / MCP 错误 / 响应解析失败),不能退化成
|
||||
// “暂未落库” 的 soft success,否则 Agent 会把硬失败误判成最终一致性,
|
||||
// 继续带着空 partId 调用 whiteboard query/update。
|
||||
func TestDocWhiteboardInsertFailsClosedWhenVerificationQueryFails(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
queryErr error
|
||||
queryBody func(blockID string) string
|
||||
}{
|
||||
{name: "mcp call failed", queryErr: errors.New("unauthorized")},
|
||||
{
|
||||
name: "response missing blocks field",
|
||||
queryBody: func(string) string { return `{"success":true}` },
|
||||
},
|
||||
{
|
||||
name: "blocks field is not an array",
|
||||
queryBody: func(string) string { return `{"blocks":{}}` },
|
||||
},
|
||||
{
|
||||
name: "block jsonml unparsable",
|
||||
queryBody: func(blockID string) string {
|
||||
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":"{"}]}`, blockID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "card node without attrs",
|
||||
queryBody: func(blockID string) string {
|
||||
encoded, _ := json.Marshal(`[]`)
|
||||
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
blockID := ""
|
||||
caller := &whiteboardTestCaller{format: "json"}
|
||||
caller.response = func(call whiteboardTestCall, index int) string {
|
||||
if index == 0 {
|
||||
blockID = whiteboardCardBlockID(t, call)
|
||||
return `{}`
|
||||
}
|
||||
if test.queryBody == nil {
|
||||
return `{}`
|
||||
}
|
||||
return test.queryBody(blockID)
|
||||
}
|
||||
if test.queryErr != nil {
|
||||
caller.err = func(_ whiteboardTestCall, index int) error {
|
||||
if index == 0 {
|
||||
return nil
|
||||
}
|
||||
return test.queryErr
|
||||
}
|
||||
}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
slept := stubWhiteboardRetries(t, 2)
|
||||
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "回查验证失败") {
|
||||
t.Fatalf("err = %v, want fail-closed verification error", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), blockID) {
|
||||
t.Fatalf("err = %v, want inserted blockId %s carried in the message", err, blockID)
|
||||
}
|
||||
if len(caller.calls) != 2 || slept() != 0 {
|
||||
t.Fatalf("calls = %d, slept = %d, want a single query and no retry on hard failure",
|
||||
len(caller.calls), slept())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 块暂不可见是真正的最终一致性:重试耗尽后仍按 soft success 返回 blockId,
|
||||
// whiteboardId 为 null。
|
||||
func TestDocWhiteboardInsertSoftSucceedsWhenBlockNotYetVisible(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{
|
||||
format: "json",
|
||||
response: func(_ whiteboardTestCall, index int) string {
|
||||
if index == 0 {
|
||||
return `{}`
|
||||
}
|
||||
return `{"blocks":[]}`
|
||||
},
|
||||
}
|
||||
output := installWhiteboardTestCaller(t, caller)
|
||||
slept := stubWhiteboardRetries(t, 2)
|
||||
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("block-not-visible must stay a soft success: %v", err)
|
||||
}
|
||||
// 1 次插入 + 3 次回查(attempt 0..2),其间休眠 2 次。
|
||||
if len(caller.calls) != 4 || slept() != 2 {
|
||||
t.Fatalf("calls = %d, slept = %d, want retries to be exhausted", len(caller.calls), slept())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output = %q: %v", output.String(), err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
whiteboardID, present := result["whiteboardId"]
|
||||
if payload["success"] != true || !present || whiteboardID != nil {
|
||||
t.Fatalf("output = %#v, want soft success with an explicit null whiteboardId", payload)
|
||||
}
|
||||
if result["blockId"] == "" || result["blockId"] == nil {
|
||||
t.Fatalf("output = %#v, want blockId preserved on soft success", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者过去会让 parent
|
||||
// 静默覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。现在必须显式报错。
|
||||
func TestDocWhiteboardInsertRejectsConflictingBlockAnchors(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "ref-block with parent-block",
|
||||
args: []string{"insert", "--node", "doc-1", "--ref-block", "b1", "--parent-block", "p1", "--yes"},
|
||||
},
|
||||
{
|
||||
name: "where with parent-block",
|
||||
args: []string{"insert", "--node", "doc-1", "--parent-block", "p1", "--where", "before", "--yes"},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{format: "json"}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetArgs(test.args)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("args %v must be rejected as mutually exclusive", test.args)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("args %v reached a remote call: %#v", test.args, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaUploadReturnsStableResourceContract(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "icon.svg")
|
||||
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &whiteboardTestCaller{
|
||||
format: "json",
|
||||
response: func(whiteboardTestCall, int) string {
|
||||
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`
|
||||
},
|
||||
}
|
||||
output := installWhiteboardTestCaller(t, caller)
|
||||
previousPut := httpPutFile
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
t.Cleanup(func() { httpPutFile = previousPut })
|
||||
|
||||
cmd := newDocCommand()
|
||||
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].server != "doc" || caller.calls[0].tool != "get_doc_attachment_upload_info" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output = %q: %v", output.String(), err)
|
||||
}
|
||||
if strings.Contains(output.String(), "upload.example.test") || payload["resourceId"] != "res-1" {
|
||||
t.Fatalf("output = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaUploadRedactsTemporaryURLFromUploadError(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "icon.svg")
|
||||
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uploadURL := "https://upload.example.test/secret-token"
|
||||
caller := &whiteboardTestCaller{
|
||||
format: "json",
|
||||
response: func(whiteboardTestCall, int) string {
|
||||
return fmt.Sprintf(`{"uploadUrl":%q,"resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`, uploadURL)
|
||||
},
|
||||
}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
previousPut := httpPutFile
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error {
|
||||
return fmt.Errorf("PUT %s: connection reset", uploadURL)
|
||||
}
|
||||
t.Cleanup(func() { httpPutFile = previousPut })
|
||||
|
||||
cmd := newDocCommand()
|
||||
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || strings.Contains(err.Error(), uploadURL) || !strings.Contains(err.Error(), "<redacted upload URL>") {
|
||||
t.Fatalf("err = %v, want redacted temporary upload URL", err)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,96 @@
|
||||
{
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " The organization admin has not enabled \"Allow members to access their personal data via CLI\".",
|
||||
" 组织主管理员:": " Organization super admins: ",
|
||||
" 请检查网络连接后重试。": " Please check your network connection and retry.",
|
||||
" 请联系组织主管理员开启后重新登录。": " Please contact the organization super admin to enable it and re-login.",
|
||||
" (每 %d 秒轮询一次)": " (polling every %d seconds)",
|
||||
" 授权码: %s": " authorization code: %s",
|
||||
" 链接: %s": " link: %s",
|
||||
"%s (第 %d 次尝试)\\\n": "%s (attempt %d)\\\n",
|
||||
"--title 的别名": "Alias for --title",
|
||||
"AI 表格 Base": "AI table Base",
|
||||
"AI 表格 Base 管理": "AI table Base management",
|
||||
"AITable 多维表格管理": "AITable multi-dimensional table management",
|
||||
"AI诚聘": "AI Recruitment",
|
||||
"Base ID (必填)": "Base ID (required)",
|
||||
"DNS 解析失败。请检查域名拼写和网络 DNS 配置。": "DNS resolution failed. Check the domain spelling and network DNS settings.",
|
||||
"HTTP 请求超时(等待服务端响应超时)。可通过 --timeout 增大超时时间,或检查服务端是否正常。": "HTTP request timed out waiting for the server. Increase --timeout or check the server.",
|
||||
"MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。": "MCP service returned an unparseable protocol response; check service version or upstream proxy.",
|
||||
"OSS 上传失败 HTTP %d: %s": "OSS upload failed HTTP %d: %s",
|
||||
"TLS 握手超时。请检查网络连接或代理设置。": "TLS handshake timed out. Check the network connection or proxy settings.",
|
||||
"[%d] 轮询中... (%ds)": "[%d] polling ... (%ds)",
|
||||
"prepare_attachment_upload 返回格式异常": "prepare_attachment_upload returned abnormal format",
|
||||
"refresh_token 刷新失败": "refresh_token refresh failed",
|
||||
"refresh_token 刷新失败,将尝试扫码登录": "refresh_token refresh failed, will attempt QR code login",
|
||||
"true=已完成, false=未完成": "true=completed, false=not completed",
|
||||
"⏳ 等待授权中...": "⏳ Waiting for authorization...",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ CLI data access is not enabled for this organization, please submit an authorization request in the browser...",
|
||||
"⚠️ 即将删除 %s: %s\\\n": "⚠️ About to delete %s: %s\\\n",
|
||||
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ Unable to verify CLI data access permission status",
|
||||
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ CLI data access is not enabled for this organization",
|
||||
"✅ 权限已开启,继续登录...": "✅ Permission enabled, continuing login...",
|
||||
"上传失败: %w": "upload failed: %w",
|
||||
"上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。": "Upstream service error; retry later. If persistent, check recovery snapshot.",
|
||||
"上游服务异常;可稍后重试。": "Upstream service error; retry later.",
|
||||
"下载失败 (HTTP %d): %s": "download failed (HTTP %d): %s",
|
||||
"下载失败: %w": "Download failed: %w",
|
||||
"不是文件: ": "not a file: ",
|
||||
"人才查询": "Talent query",
|
||||
"优先级: 10低/20普通/30较高/40紧急": "Priority: 10=low/20=normal/30=high/40=urgent",
|
||||
"使用授权码换取 Access Token...": "Exchanging authorization code for Access Token...",
|
||||
"保存 token 失败": "Failed to save token",
|
||||
"保存Keyword subscription规则": "Save keyword subscription rule",
|
||||
"修改待办任务": "Update todo task",
|
||||
"修改执行者的待办完成状态": "Update executor todo done status",
|
||||
"写入文件失败: %w": "write file failed: %w",
|
||||
"准备上传失败: %w": "prepare upload failed: %w",
|
||||
"分页查询审批实例": "Paginated query approval instances",
|
||||
"列出已安装的插件": "List installed plugins",
|
||||
"列出插件所有配置项": "List all config values for a plugin",
|
||||
"创建 / 查询 / 更新 / 删除待办": "Create / query / update / delete todo",
|
||||
"创建待办": "Create todo",
|
||||
"创建文件失败: %w": "create file failed: %w",
|
||||
"创建请求失败": "Failed to create request",
|
||||
"删除待办": "Delete todo",
|
||||
"删除 AI 表格": "Delete AI table",
|
||||
"删除原因(可选)": "Delete reason (optional)",
|
||||
"删除字段": "Delete field",
|
||||
"删除待办": "Delete todo",
|
||||
"删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified Base (high risk, irreversible). Use --yes to skip confirmation.",
|
||||
"删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified field (high risk, irreversible). Use --yes to skip confirmation.",
|
||||
"删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified data table (high risk, irreversible). Use --yes to skip confirmation.",
|
||||
"删除插件配置项": "Remove a plugin config value",
|
||||
"删除数据表": "Delete data table",
|
||||
"删除行记录": "Delete row records",
|
||||
"卸载已安装的插件": "Remove an installed plugin",
|
||||
"参数不符合工具输入 schema;请检查 --json/--params/flags。": "Parameters do not match tool input schema; check --json/--params/flags.",
|
||||
"发送请求失败": "Failed to send request",
|
||||
"启用插件": "Enable a plugin",
|
||||
"回调中未收到授权码": "Callback did not receive authorization code",
|
||||
"如果浏览器未自动打开,请手动访问:\n %s\n\n": "If the browser did not open automatically, please visit:\n %s\n\n",
|
||||
"字段": "field",
|
||||
"字段 ID (必填)": "field ID (required)",
|
||||
"字段管理": "Field management",
|
||||
"安装插件": "Install a plugin",
|
||||
"完成状态: true/false": "Done status: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)",
|
||||
"审批实例管理": "Approval instance management",
|
||||
"将插件 stdio server 编译为原生二进制": "Build plugin's stdio server into a native binary",
|
||||
"将本地目录注册为开发态插件": "Register a local directory as a dev plugin",
|
||||
"岗位查询": "Position query",
|
||||
"工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。": "Tool protocol incompatible; check service version, tool name, or upgrade to a dws version with the latest static endpoints.",
|
||||
"工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.",
|
||||
"已取消操作": "Operation cancelled",
|
||||
"当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s",
|
||||
"待办": "todo",
|
||||
"待办任务 ID (必填)": "Todo task ID (required)",
|
||||
"待办任务管理": "Todo task management",
|
||||
"待办标题 (必填)": "Todo title (required)",
|
||||
"待办详情": "Todo detail",
|
||||
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "Recurring todo (requires --due); format: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"或者直接打开以下链接:": "Or open the following link:",
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "Due time ISO-8601 (e.g. 2026-03-10T18:00:00+08:00)",
|
||||
"所有凭证已失效,请运行 dws auth login 重新登录": "All credentials have expired, please run dws auth login to re-authenticate",
|
||||
"打开 zip 失败: %w": "open zip failed: %w",
|
||||
"执行者 userId 列表 (必填)": "Executor userId list (required)",
|
||||
"批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。": "Batch delete records (high risk, irreversible), max 100 per batch. Use --yes to skip confirmation.",
|
||||
"换取 token 失败": "Failed to exchange token",
|
||||
"授权失败:未收到授权码": "Authorization failed: no authorization code received",
|
||||
@@ -65,12 +98,15 @@
|
||||
"授权码将在 %d 秒后过期。": "Authorization code will expire in %d seconds.",
|
||||
"授权码已过期,正在重新发起设备授权流程...": "Authorization code expired, restarting device authorization flow...",
|
||||
"授权超时(5分钟),请重试": "Authorization timeout (5 minutes), please retry",
|
||||
"插件管理": "Manage plugins",
|
||||
"操作超时,请重新登录": "Operation timed out, please re-login",
|
||||
"数据表": "data table",
|
||||
"数据表 ID (必填)": "data table ID (required)",
|
||||
"数据表管理": "Data table management",
|
||||
"文件不存在: ": "File not found: ",
|
||||
"文件为空": "File is empty",
|
||||
"文件过大 (%d 字节,限制 %d 字节)": "File too large (%d bytes, limit %d bytes)",
|
||||
"新标题": "New title",
|
||||
"无法打开文件: %w": "Cannot open file: %w",
|
||||
"无法自动打开浏览器": "Cannot automatically open browser",
|
||||
"无法获取当前二进制路径: %w": "Cannot get current binary path: %w",
|
||||
@@ -78,10 +114,10 @@
|
||||
"无法解析符号链接: %w": "Cannot resolve symlink: %w",
|
||||
"无法读取当前二进制信息: %w": "Cannot read current binary info: %w",
|
||||
"无法连接到更新服务器: %w": "Cannot connect to update server: %w",
|
||||
"静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。": "Static endpoint/negotiation failed; check network, service version, or upgrade to a dws version with the latest static endpoints.",
|
||||
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "Help provides help for any command in the application.\nSimply type dws help [path to command] for full details.",
|
||||
"服务端返回了空的 device_code 或 user_code": "Server returned empty device_code or user_code",
|
||||
"服务端返回错误": "Server returned error",
|
||||
"服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。": "Service returned empty results; retry later. Check recovery snapshot if persistent.",
|
||||
"服务返回了空结果;请稍后重试。": "Service returned empty results; please retry later.",
|
||||
"未找到 MCP Server URL": "MCP Server URL not found",
|
||||
"未找到认证信息,请运行 dws auth login": "No credentials found, please run dws auth login",
|
||||
"未登录,请运行 dws auth login": "Not logged in, please run dws auth login",
|
||||
@@ -90,42 +126,43 @@
|
||||
"本地文件路径 (必填)": "Local file path (required)",
|
||||
"权限不足;请检查当前身份是否有权限访问该服务或工具。": "Insufficient permissions; check if current identity has access to this service or tool.",
|
||||
"构建上传请求失败: %w": "Failed to build upload request: %w",
|
||||
"查看任意命令的帮助信息": "Help about any command",
|
||||
"查看插件详情": "Show plugin details",
|
||||
"查询入职人才": "Query onboarding candidates",
|
||||
"查询在招岗位": "Query open positions",
|
||||
"修改待办任务": "Update todo task",
|
||||
"修改执行者的待办完成状态": "Update executor todo done status",
|
||||
"优先级: 10低/20普通/30较高/40紧急": "Priority: 10=low/20=normal/30=high/40=urgent",
|
||||
"查询待办列表": "List todos",
|
||||
"待办": "todo",
|
||||
"待办任务 ID (必填)": "Todo task ID (required)",
|
||||
"待办任务管理": "Todo task management",
|
||||
"待办标题 (必填)": "Todo title (required)",
|
||||
"待办详情": "Todo detail",
|
||||
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "Recurring todo (requires --due); format: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "Due time ISO-8601 (e.g. 2026-03-10T18:00:00+08:00)",
|
||||
"执行者 userId 列表 (必填)": "Executor userId list (required)",
|
||||
"检查登录状态后重试": "Check login status and retry",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "Check network, proxy, and DNS settings, then retry the original command",
|
||||
"校验 plugin.json": "Validate a plugin.json",
|
||||
"检查 CLI 授权状态失败": "Failed to check CLI auth status",
|
||||
"检查 internal/syncdata 静态端点生成物是否包含目标 server": "Check whether internal/syncdata static endpoint artifacts include the target server",
|
||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "Check service connectivity and retry; if persistent, verify MCP service is responding.",
|
||||
"检查服务连通性和协议版本后重试": "Check service connectivity and protocol version, then retry",
|
||||
"检查登录状态后重试": "Check login status and retry",
|
||||
"检查组织 CLI 授权状态...": "Checking organization CLI auth status...",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "Check network, proxy, and DNS settings, then retry the original command",
|
||||
"检查认证、权限和参数后重试原命令": "Check authentication, permissions, and parameters, then retry",
|
||||
"新标题": "New title",
|
||||
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "Manage DingTalk personal todos: create, list, view detail, update, mark done, delete.",
|
||||
"步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n": "Step 1/3: Preparing upload %s (%d bytes, %s)...\\\n",
|
||||
"步骤 2/3: 上传文件到 OSS...": "Step 2/3: Uploading file to OSS...",
|
||||
"步骤 3/3: 上传完成!": "Step 3/3: Upload complete!",
|
||||
"用户拒绝了授权请求": "User rejected the authorization request",
|
||||
"完成状态: true/false": "Done status: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)",
|
||||
"确认删除? (yes/no): ": "Confirm delete? (yes/no): ",
|
||||
"确认 MCP 服务可访问;若持续失败请稍后重试": "Verify the MCP service is reachable; if the failure persists, retry later",
|
||||
"确认删除? (yes/no): ": "Confirm delete? (yes/no): ",
|
||||
"禁用插件": "Disable a plugin",
|
||||
"等待提交申请中": "Waiting to submit request",
|
||||
"等待用户授权...": "Waiting for user authorization...",
|
||||
"等待管理员审批中": "Waiting for admin approval",
|
||||
"管理员操作入口:": "Admin settings: ",
|
||||
"管理插件配置": "Manage plugin configuration",
|
||||
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "Manage DingTalk personal todos: create, list, view detail, update, mark done, delete.",
|
||||
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "Result format does not match client expectations; check service protocol changes or rollback to latest working version.",
|
||||
"网络 I/O 超时。可通过 --timeout 增大超时时间,或检查网络连接。": "Network I/O timed out. Increase --timeout or check the network.",
|
||||
"网络错误,继续重试...": "Network error, retrying...",
|
||||
"脚手架生成新插件目录": "Scaffold a new plugin directory",
|
||||
"获取使用指引": "Get usage guide",
|
||||
"获取审批实例详情": "Get approval instance details",
|
||||
"获取数量,超过 20 自动分页 (默认 20)": "Fetch count, auto-paginate if over 20 (default 20)",
|
||||
"获取版本信息失败 (HTTP %d)": "Get version info failed (HTTP %d)",
|
||||
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "Override OAuth client ID (DingTalk AppKey)",
|
||||
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "Override OAuth client secret (DingTalk AppSecret)",
|
||||
"解析令牌数据失败": "Failed to parse token data",
|
||||
"解析响应失败": "Failed to parse response",
|
||||
"解析版本信息失败: %w": "Parse version info failed: %w",
|
||||
@@ -138,59 +175,32 @@
|
||||
"设备授权流程失败(已重试 %d 次)": "Device authorization flow failed (retried %d times)",
|
||||
"设备授权码已过期": "Device authorization code has expired",
|
||||
"设备授权码已过期(%d 秒),请重试": "Device authorization code expired (%d seconds), please retry",
|
||||
"设置插件配置项": "Set a plugin config value",
|
||||
"设置权限失败: %w": "Set permission failed: %w",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "CLI data access is not enabled for this organization, please contact admin to enable it",
|
||||
"请在浏览器中完成扫码授权。": "Please complete QR code authorization in the browser.",
|
||||
"请在浏览器中打开以下链接,并输入授权码:": "Please open the following link in your browser and enter the authorization code:",
|
||||
"请检查服务 endpoint 是否为空或格式不合法。": "Please check if the service endpoint is empty or has invalid format.",
|
||||
"请检查网络连通性和 MCP 服务状态后重试。": "Check network connectivity and MCP service status, then retry.",
|
||||
"请求在重试过程中被取消;请检查调用侧超时设置。": "Request was cancelled during retry; check caller-side timeout settings.",
|
||||
"请求已取消。如果非手动取消,请检查调用侧超时设置。": "Request cancelled. If not cancelled manually, check the caller timeout settings.",
|
||||
"请求被上游服务拒绝;请检查参数、认证和权限配置。": "Request was rejected by upstream service; check parameters, authentication, and permissions.",
|
||||
"请求设备授权码...": "Requesting device authorization code...",
|
||||
"请求设备授权码失败": "Failed to request device authorization code",
|
||||
"请求超时(上下文截止时间已到)。可通过 --timeout 增大超时时间,或检查网络连接。": "Request timed out (context deadline exceeded). Increase --timeout or check the network.",
|
||||
"读取 zip 条目失败: %w": "Failed to read zip entry: %w",
|
||||
"读取响应失败": "Failed to read response",
|
||||
"读取版本信息失败: %w": "Read version info failed: %w",
|
||||
"跳过确认直接删除": "Skip confirmation and delete directly",
|
||||
"--title 的别名": "Alias for --title",
|
||||
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "Call rejected; check authentication status, tenant identity, or access permissions.",
|
||||
"轮询过快,间隔增加至 %ds": "Polling too fast, interval increased to %ds",
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "Response data missing uploadUrl or fileToken",
|
||||
"附件工作流": "Attachment workflow",
|
||||
"页码 (必填)": "page number (required)",
|
||||
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ Unable to verify CLI data access permission status",
|
||||
" 请检查网络连接后重试。": " Please check your network connection and retry.",
|
||||
"检查 CLI 授权状态失败": "Failed to check CLI auth status",
|
||||
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ CLI data access is not enabled for this organization",
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " The organization admin has not enabled \"Allow members to access their personal data via CLI\".",
|
||||
" 组织主管理员:": " Organization super admins: ",
|
||||
" 请联系组织主管理员开启后重新登录。": " Please contact the organization super admin to enable it and re-login.",
|
||||
"管理员操作入口:": "Admin settings: ",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "CLI data access is not enabled for this organization, please contact admin to enable it",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ CLI data access is not enabled for this organization, please submit an authorization request in the browser...",
|
||||
"✅ 权限已开启,继续登录...": "✅ Permission enabled, continuing login...",
|
||||
"等待管理员审批中": "Waiting for admin approval",
|
||||
"等待提交申请中": "Waiting to submit request",
|
||||
"操作超时,请重新登录": "Operation timed out, please re-login",
|
||||
"检查组织 CLI 授权状态...": "Checking organization CLI auth status...",
|
||||
"🔐 登录钉钉": "🔐 Login to DingTalk",
|
||||
"插件管理": "Manage plugins",
|
||||
"列出已安装的插件": "List installed plugins",
|
||||
"安装插件": "Install a plugin",
|
||||
"查看插件详情": "Show plugin details",
|
||||
"启用插件": "Enable a plugin",
|
||||
"禁用插件": "Disable a plugin",
|
||||
"卸载已安装的插件": "Remove an installed plugin",
|
||||
"校验 plugin.json": "Validate a plugin.json",
|
||||
"脚手架生成新插件目录": "Scaffold a new plugin directory",
|
||||
"将本地目录注册为开发态插件": "Register a local directory as a dev plugin",
|
||||
"管理插件配置": "Manage plugin configuration",
|
||||
"设置插件配置项": "Set a plugin config value",
|
||||
"读取插件配置项": "Get a plugin config value",
|
||||
"列出插件所有配置项": "List all config values for a plugin",
|
||||
"删除插件配置项": "Remove a plugin config value",
|
||||
"将插件 stdio server 编译为原生二进制": "Build plugin's stdio server into a native binary",
|
||||
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "Override OAuth client ID (DingTalk AppKey)",
|
||||
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "Override OAuth client secret (DingTalk AppSecret)",
|
||||
"查看任意命令的帮助信息": "Help about any command",
|
||||
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "Help provides help for any command in the application.\nSimply type dws help [path to command] for full details."
|
||||
"读取版本信息失败: %w": "Read version info failed: %w",
|
||||
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "Call rejected; check authentication status, tenant identity, or access permissions.",
|
||||
"跳过确认直接删除": "Skip confirmation and delete directly",
|
||||
"轮询过快,间隔增加至 %ds": "Polling too fast, interval increased to %ds",
|
||||
"运行 dws auth status 确认凭证有效,必要时重新登录": "Run dws auth status to confirm credentials are valid; re-login if needed",
|
||||
"运行 sync-oss 重新生成静态端点与路由后重试": "Run sync-oss to regenerate static endpoints and routes, then retry",
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "Response data missing uploadUrl or fileToken",
|
||||
"连接被拒绝。请确认服务端已启动并正在监听。": "Connection refused. Confirm the server is running and listening.",
|
||||
"附件工作流": "Attachment workflow",
|
||||
"静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。": "Static endpoint/negotiation failed; check network, service version, or upgrade to a dws version with the latest static endpoints.",
|
||||
"页码 (必填)": "page number (required)",
|
||||
"🔐 登录钉钉": "🔐 Login to DingTalk"
|
||||
}
|
||||
|
||||
@@ -1,63 +1,96 @@
|
||||
{
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。",
|
||||
" 组织主管理员:": " 组织主管理员:",
|
||||
" 请检查网络连接后重试。": " 请检查网络连接后重试。",
|
||||
" 请联系组织主管理员开启后重新登录。": " 请联系组织主管理员开启后重新登录。",
|
||||
" (每 %d 秒轮询一次)": " (每 %d 秒轮询一次)",
|
||||
" 授权码: %s": " 授权码: %s",
|
||||
" 链接: %s": " 链接: %s",
|
||||
"%s (第 %d 次尝试)\\\n": "%s (第 %d 次尝试)\\\n",
|
||||
"--title 的别名": "--title 的别名",
|
||||
"AI 表格 Base": "AI 表格 Base",
|
||||
"AI 表格 Base 管理": "AI 表格 Base 管理",
|
||||
"AITable 多维表格管理": "AITable 多维表格管理",
|
||||
"AI诚聘": "AI诚聘",
|
||||
"Base ID (必填)": "Base ID (必填)",
|
||||
"DNS 解析失败。请检查域名拼写和网络 DNS 配置。": "DNS 解析失败。请检查域名拼写和网络 DNS 配置。",
|
||||
"HTTP 请求超时(等待服务端响应超时)。可通过 --timeout 增大超时时间,或检查服务端是否正常。": "HTTP 请求超时(等待服务端响应超时)。可通过 --timeout 增大超时时间,或检查服务端是否正常。",
|
||||
"MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。": "MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。",
|
||||
"OSS 上传失败 HTTP %d: %s": "OSS 上传失败 HTTP %d: %s",
|
||||
"TLS 握手超时。请检查网络连接或代理设置。": "TLS 握手超时。请检查网络连接或代理设置。",
|
||||
"[%d] 轮询中... (%ds)": "[%d] 轮询中... (%ds)",
|
||||
"prepare_attachment_upload 返回格式异常": "prepare_attachment_upload 返回格式异常",
|
||||
"refresh_token 刷新失败": "refresh_token 刷新失败",
|
||||
"refresh_token 刷新失败,将尝试扫码登录": "refresh_token 刷新失败,将尝试扫码登录",
|
||||
"true=已完成, false=未完成": "true=已完成, false=未完成",
|
||||
"⏳ 等待授权中...": "⏳ 等待授权中...",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...",
|
||||
"⚠️ 即将删除 %s: %s\\\n": "⚠️ 即将删除 %s: %s\\\n",
|
||||
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ 无法检查 CLI 数据访问权限状态",
|
||||
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ 该组织尚未开启 CLI 数据访问权限",
|
||||
"✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...",
|
||||
"上传失败: %w": "上传失败: %w",
|
||||
"上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。": "上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。",
|
||||
"上游服务异常;可稍后重试。": "上游服务异常;可稍后重试。",
|
||||
"下载失败 (HTTP %d): %s": "下载失败 (HTTP %d): %s",
|
||||
"下载失败: %w": "下载失败: %w",
|
||||
"不是文件: ": "不是文件: ",
|
||||
"人才查询": "人才查询",
|
||||
"优先级: 10低/20普通/30较高/40紧急": "优先级: 10低/20普通/30较高/40紧急",
|
||||
"使用授权码换取 Access Token...": "使用授权码换取 Access Token...",
|
||||
"保存 token 失败": "保存 token 失败",
|
||||
"保存Keyword subscription规则": "保存Keyword subscription规则",
|
||||
"修改待办任务": "修改待办任务",
|
||||
"修改执行者的待办完成状态": "修改执行者的待办完成状态",
|
||||
"写入文件失败: %w": "写入文件失败: %w",
|
||||
"准备上传失败: %w": "准备上传失败: %w",
|
||||
"分页查询审批实例": "分页查询审批实例",
|
||||
"列出已安装的插件": "列出已安装的插件",
|
||||
"列出插件所有配置项": "列出插件所有配置项",
|
||||
"创建 / 查询 / 更新 / 删除待办": "创建 / 查询 / 更新 / 删除待办",
|
||||
"创建待办": "创建待办",
|
||||
"创建文件失败: %w": "创建文件失败: %w",
|
||||
"创建请求失败": "创建请求失败",
|
||||
"删除待办": "删除待办",
|
||||
"删除 AI 表格": "删除 AI 表格",
|
||||
"删除原因(可选)": "删除原因(可选)",
|
||||
"删除字段": "删除字段",
|
||||
"删除待办": "删除待办",
|
||||
"删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。",
|
||||
"删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。",
|
||||
"删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。",
|
||||
"删除插件配置项": "删除插件配置项",
|
||||
"删除数据表": "删除数据表",
|
||||
"删除行记录": "删除行记录",
|
||||
"卸载已安装的插件": "卸载已安装的插件",
|
||||
"参数不符合工具输入 schema;请检查 --json/--params/flags。": "参数不符合工具输入 schema;请检查 --json/--params/flags。",
|
||||
"发送请求失败": "发送请求失败",
|
||||
"启用插件": "启用插件",
|
||||
"回调中未收到授权码": "回调中未收到授权码",
|
||||
"如果浏览器未自动打开,请手动访问:\n %s\n\n": "如果浏览器未自动打开,请手动访问:\n %s\n\n",
|
||||
"字段": "字段",
|
||||
"字段 ID (必填)": "字段 ID (必填)",
|
||||
"字段管理": "字段管理",
|
||||
"安装插件": "安装插件",
|
||||
"完成状态: true/false": "完成状态: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)",
|
||||
"审批实例管理": "审批实例管理",
|
||||
"将插件 stdio server 编译为原生二进制": "将插件 stdio server 编译为原生二进制",
|
||||
"将本地目录注册为开发态插件": "将本地目录注册为开发态插件",
|
||||
"岗位查询": "岗位查询",
|
||||
"工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。": "工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。",
|
||||
"工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。",
|
||||
"已取消操作": "已取消操作",
|
||||
"当前平台 %s 没有可用的预编译二进制": "当前平台 %s 没有可用的预编译二进制",
|
||||
"待办": "待办",
|
||||
"待办任务 ID (必填)": "待办任务 ID (必填)",
|
||||
"待办任务管理": "待办任务管理",
|
||||
"待办标题 (必填)": "待办标题 (必填)",
|
||||
"待办详情": "待办详情",
|
||||
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"或者直接打开以下链接:": "或者直接打开以下链接:",
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)",
|
||||
"所有凭证已失效,请运行 dws auth login 重新登录": "所有凭证已失效,请运行 dws auth login 重新登录",
|
||||
"打开 zip 失败: %w": "打开 zip 失败: %w",
|
||||
"执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)",
|
||||
"批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。": "批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。",
|
||||
"换取 token 失败": "换取 token 失败",
|
||||
"授权失败:未收到授权码": "授权失败:未收到授权码",
|
||||
@@ -65,12 +98,15 @@
|
||||
"授权码将在 %d 秒后过期。": "授权码将在 %d 秒后过期。",
|
||||
"授权码已过期,正在重新发起设备授权流程...": "授权码已过期,正在重新发起设备授权流程...",
|
||||
"授权超时(5分钟),请重试": "授权超时(5分钟),请重试",
|
||||
"插件管理": "插件管理",
|
||||
"操作超时,请重新登录": "操作超时,请重新登录",
|
||||
"数据表": "数据表",
|
||||
"数据表 ID (必填)": "数据表 ID (必填)",
|
||||
"数据表管理": "数据表管理",
|
||||
"文件不存在: ": "文件不存在: ",
|
||||
"文件为空": "文件为空",
|
||||
"文件过大 (%d 字节,限制 %d 字节)": "文件过大 (%d 字节,限制 %d 字节)",
|
||||
"新标题": "新标题",
|
||||
"无法打开文件: %w": "无法打开文件: %w",
|
||||
"无法自动打开浏览器": "无法自动打开浏览器",
|
||||
"无法获取当前二进制路径: %w": "无法获取当前二进制路径: %w",
|
||||
@@ -78,10 +114,10 @@
|
||||
"无法解析符号链接: %w": "无法解析符号链接: %w",
|
||||
"无法读取当前二进制信息: %w": "无法读取当前二进制信息: %w",
|
||||
"无法连接到更新服务器: %w": "无法连接到更新服务器: %w",
|
||||
"静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。": "静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。",
|
||||
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。",
|
||||
"服务端返回了空的 device_code 或 user_code": "服务端返回了空的 device_code 或 user_code",
|
||||
"服务端返回错误": "服务端返回错误",
|
||||
"服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。": "服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。",
|
||||
"服务返回了空结果;请稍后重试。": "服务返回了空结果;请稍后重试。",
|
||||
"未找到 MCP Server URL": "未找到 MCP Server URL",
|
||||
"未找到认证信息,请运行 dws auth login": "未找到认证信息,请运行 dws auth login",
|
||||
"未登录,请运行 dws auth login": "未登录,请运行 dws auth login",
|
||||
@@ -90,42 +126,43 @@
|
||||
"本地文件路径 (必填)": "本地文件路径 (必填)",
|
||||
"权限不足;请检查当前身份是否有权限访问该服务或工具。": "权限不足;请检查当前身份是否有权限访问该服务或工具。",
|
||||
"构建上传请求失败: %w": "构建上传请求失败: %w",
|
||||
"查看任意命令的帮助信息": "查看任意命令的帮助信息",
|
||||
"查看插件详情": "查看插件详情",
|
||||
"查询入职人才": "查询入职人才",
|
||||
"查询在招岗位": "查询在招岗位",
|
||||
"修改待办任务": "修改待办任务",
|
||||
"修改执行者的待办完成状态": "修改执行者的待办完成状态",
|
||||
"优先级: 10低/20普通/30较高/40紧急": "优先级: 10低/20普通/30较高/40紧急",
|
||||
"查询待办列表": "查询待办列表",
|
||||
"待办": "待办",
|
||||
"待办任务 ID (必填)": "待办任务 ID (必填)",
|
||||
"待办任务管理": "待办任务管理",
|
||||
"待办标题 (必填)": "待办标题 (必填)",
|
||||
"待办详情": "待办详情",
|
||||
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)",
|
||||
"执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)",
|
||||
"检查登录状态后重试": "检查登录状态后重试",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "检查网络、代理和 DNS 配置后重试原命令",
|
||||
"校验 plugin.json": "校验 plugin.json",
|
||||
"检查 CLI 授权状态失败": "检查 CLI 授权状态失败",
|
||||
"检查 internal/syncdata 静态端点生成物是否包含目标 server": "检查 internal/syncdata 静态端点生成物是否包含目标 server",
|
||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。",
|
||||
"检查服务连通性和协议版本后重试": "检查服务连通性和协议版本后重试",
|
||||
"检查登录状态后重试": "检查登录状态后重试",
|
||||
"检查组织 CLI 授权状态...": "检查组织 CLI 授权状态...",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "检查网络、代理和 DNS 配置后重试原命令",
|
||||
"检查认证、权限和参数后重试原命令": "检查认证、权限和参数后重试原命令",
|
||||
"新标题": "新标题",
|
||||
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。",
|
||||
"步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n": "步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n",
|
||||
"步骤 2/3: 上传文件到 OSS...": "步骤 2/3: 上传文件到 OSS...",
|
||||
"步骤 3/3: 上传完成!": "步骤 3/3: 上传完成!",
|
||||
"用户拒绝了授权请求": "用户拒绝了授权请求",
|
||||
"完成状态: true/false": "完成状态: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)",
|
||||
"确认删除? (yes/no): ": "确认删除? (yes/no): ",
|
||||
"确认 MCP 服务可访问;若持续失败请稍后重试": "确认 MCP 服务可访问;若持续失败请稍后重试",
|
||||
"确认删除? (yes/no): ": "确认删除? (yes/no): ",
|
||||
"禁用插件": "禁用插件",
|
||||
"等待提交申请中": "等待提交申请中",
|
||||
"等待用户授权...": "等待用户授权...",
|
||||
"等待管理员审批中": "等待管理员审批中",
|
||||
"管理员操作入口:": "管理员操作入口:",
|
||||
"管理插件配置": "管理插件配置",
|
||||
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。",
|
||||
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。",
|
||||
"网络 I/O 超时。可通过 --timeout 增大超时时间,或检查网络连接。": "网络 I/O 超时。可通过 --timeout 增大超时时间,或检查网络连接。",
|
||||
"网络错误,继续重试...": "网络错误,继续重试...",
|
||||
"脚手架生成新插件目录": "脚手架生成新插件目录",
|
||||
"获取使用指引": "获取使用指引",
|
||||
"获取审批实例详情": "获取审批实例详情",
|
||||
"获取数量,超过 20 自动分页 (默认 20)": "获取数量,超过 20 自动分页 (默认 20)",
|
||||
"获取版本信息失败 (HTTP %d)": "获取版本信息失败 (HTTP %d)",
|
||||
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "覆盖 OAuth 客户端 ID (钉钉 AppKey)",
|
||||
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "覆盖 OAuth 客户端密钥 (钉钉 AppSecret)",
|
||||
"解析令牌数据失败": "解析令牌数据失败",
|
||||
"解析响应失败": "解析响应失败",
|
||||
"解析版本信息失败: %w": "解析版本信息失败: %w",
|
||||
@@ -138,59 +175,32 @@
|
||||
"设备授权流程失败(已重试 %d 次)": "设备授权流程失败(已重试 %d 次)",
|
||||
"设备授权码已过期": "设备授权码已过期",
|
||||
"设备授权码已过期(%d 秒),请重试": "设备授权码已过期(%d 秒),请重试",
|
||||
"设置插件配置项": "设置插件配置项",
|
||||
"设置权限失败: %w": "设置权限失败: %w",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启",
|
||||
"请在浏览器中完成扫码授权。": "请在浏览器中完成扫码授权。",
|
||||
"请在浏览器中打开以下链接,并输入授权码:": "请在浏览器中打开以下链接,并输入授权码:",
|
||||
"请检查服务 endpoint 是否为空或格式不合法。": "请检查服务 endpoint 是否为空或格式不合法。",
|
||||
"请检查网络连通性和 MCP 服务状态后重试。": "请检查网络连通性和 MCP 服务状态后重试。",
|
||||
"请求在重试过程中被取消;请检查调用侧超时设置。": "请求在重试过程中被取消;请检查调用侧超时设置。",
|
||||
"请求已取消。如果非手动取消,请检查调用侧超时设置。": "请求已取消。如果非手动取消,请检查调用侧超时设置。",
|
||||
"请求被上游服务拒绝;请检查参数、认证和权限配置。": "请求被上游服务拒绝;请检查参数、认证和权限配置。",
|
||||
"请求设备授权码...": "请求设备授权码...",
|
||||
"请求设备授权码失败": "请求设备授权码失败",
|
||||
"请求超时(上下文截止时间已到)。可通过 --timeout 增大超时时间,或检查网络连接。": "请求超时(上下文截止时间已到)。可通过 --timeout 增大超时时间,或检查网络连接。",
|
||||
"读取 zip 条目失败: %w": "读取 zip 条目失败: %w",
|
||||
"读取响应失败": "读取响应失败",
|
||||
"读取版本信息失败: %w": "读取版本信息失败: %w",
|
||||
"跳过确认直接删除": "跳过确认直接删除",
|
||||
"--title 的别名": "--title 的别名",
|
||||
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "调用被拒绝;请检查认证状态、租户身份或访问权限。",
|
||||
"轮询过快,间隔增加至 %ds": "轮询过快,间隔增加至 %ds",
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "返回数据缺少 uploadUrl 或 fileToken",
|
||||
"附件工作流": "附件工作流",
|
||||
"页码 (必填)": "页码 (必填)",
|
||||
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ 无法检查 CLI 数据访问权限状态",
|
||||
" 请检查网络连接后重试。": " 请检查网络连接后重试。",
|
||||
"检查 CLI 授权状态失败": "检查 CLI 授权状态失败",
|
||||
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ 该组织尚未开启 CLI 数据访问权限",
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。",
|
||||
" 组织主管理员:": " 组织主管理员:",
|
||||
" 请联系组织主管理员开启后重新登录。": " 请联系组织主管理员开启后重新登录。",
|
||||
"管理员操作入口:": "管理员操作入口:",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...",
|
||||
"✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...",
|
||||
"等待管理员审批中": "等待管理员审批中",
|
||||
"等待提交申请中": "等待提交申请中",
|
||||
"操作超时,请重新登录": "操作超时,请重新登录",
|
||||
"检查组织 CLI 授权状态...": "检查组织 CLI 授权状态...",
|
||||
"🔐 登录钉钉": "🔐 登录钉钉",
|
||||
"插件管理": "插件管理",
|
||||
"列出已安装的插件": "列出已安装的插件",
|
||||
"安装插件": "安装插件",
|
||||
"查看插件详情": "查看插件详情",
|
||||
"启用插件": "启用插件",
|
||||
"禁用插件": "禁用插件",
|
||||
"卸载已安装的插件": "卸载已安装的插件",
|
||||
"校验 plugin.json": "校验 plugin.json",
|
||||
"脚手架生成新插件目录": "脚手架生成新插件目录",
|
||||
"将本地目录注册为开发态插件": "将本地目录注册为开发态插件",
|
||||
"管理插件配置": "管理插件配置",
|
||||
"设置插件配置项": "设置插件配置项",
|
||||
"读取插件配置项": "读取插件配置项",
|
||||
"列出插件所有配置项": "列出插件所有配置项",
|
||||
"删除插件配置项": "删除插件配置项",
|
||||
"将插件 stdio server 编译为原生二进制": "将插件 stdio server 编译为原生二进制",
|
||||
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "覆盖 OAuth 客户端 ID (钉钉 AppKey)",
|
||||
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "覆盖 OAuth 客户端密钥 (钉钉 AppSecret)",
|
||||
"查看任意命令的帮助信息": "查看任意命令的帮助信息",
|
||||
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。"
|
||||
"读取版本信息失败: %w": "读取版本信息失败: %w",
|
||||
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "调用被拒绝;请检查认证状态、租户身份或访问权限。",
|
||||
"跳过确认直接删除": "跳过确认直接删除",
|
||||
"轮询过快,间隔增加至 %ds": "轮询过快,间隔增加至 %ds",
|
||||
"运行 dws auth status 确认凭证有效,必要时重新登录": "运行 dws auth status 确认凭证有效,必要时重新登录",
|
||||
"运行 sync-oss 重新生成静态端点与路由后重试": "运行 sync-oss 重新生成静态端点与路由后重试",
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "返回数据缺少 uploadUrl 或 fileToken",
|
||||
"连接被拒绝。请确认服务端已启动并正在监听。": "连接被拒绝。请确认服务端已启动并正在监听。",
|
||||
"附件工作流": "附件工作流",
|
||||
"静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。": "静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。",
|
||||
"页码 (必填)": "页码 (必填)",
|
||||
"🔐 登录钉钉": "🔐 登录钉钉"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package localio owns safe local artifact publication shared by product
|
||||
// shortcuts. Remote names and URLs are always treated as untrusted input.
|
||||
package localio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
downloadTimeout = 10 * time.Minute
|
||||
maxDownloadBytes = int64(512 << 20)
|
||||
)
|
||||
|
||||
type downloadTempFile interface {
|
||||
io.Writer
|
||||
Sync() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
var (
|
||||
createDownloadTemp = createDownloadTempInRoot
|
||||
lookupDownloadIPs = net.DefaultResolver.LookupIPAddr
|
||||
dialDownloadIP = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
||||
localGetwd = os.Getwd
|
||||
localAbs = filepath.Abs
|
||||
localEvalSymlinks = filepath.EvalSymlinks
|
||||
openDownloadRoot = os.OpenRoot
|
||||
openDownloadParent = func(root *os.Root, name string) (*os.Root, error) { return root.OpenRoot(name) }
|
||||
downloadRootStat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Stat(name) }
|
||||
downloadRootLstat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Lstat(name) }
|
||||
downloadRootMkdir = func(root *os.Root, name string, mode os.FileMode) error { return root.Mkdir(name, mode) }
|
||||
downloadRootLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) }
|
||||
downloadRootRemove = func(root *os.Root, name string) error { return root.Remove(name) }
|
||||
)
|
||||
|
||||
var downloadTempCounter atomic.Uint64
|
||||
|
||||
// DownloadOptions controls safe, atomic publication beneath BaseDir.
|
||||
type DownloadOptions struct {
|
||||
BaseDir string
|
||||
Output string
|
||||
PreferredName string
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// DownloadResult describes the published local artifact.
|
||||
type DownloadResult struct {
|
||||
AbsolutePath string
|
||||
RelativePath string
|
||||
SizeBytes int64
|
||||
}
|
||||
|
||||
// Download validates a platform-owned HTTPS URL, resolves a workspace-relative
|
||||
// output path without following symlink escapes, streams into a sibling temp
|
||||
// file, fsyncs it, and atomically publishes the completed file.
|
||||
func Download(ctx context.Context, rawURL string, opts DownloadOptions) (DownloadResult, error) {
|
||||
return downloadWithClient(ctx, rawURL, opts, secureHTTPClient())
|
||||
}
|
||||
|
||||
func downloadWithClient(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client) (DownloadResult, error) {
|
||||
return downloadWithClientLimit(ctx, rawURL, opts, client, maxDownloadBytes)
|
||||
}
|
||||
|
||||
func downloadWithClientLimit(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client, maxBytes int64) (DownloadResult, error) {
|
||||
parsed, err := ValidateDownloadURL(rawURL)
|
||||
if err != nil {
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
target, err := openDownloadTarget(opts.BaseDir, opts.Output, parsed.String(), opts.PreferredName)
|
||||
if err != nil {
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
defer target.close()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) // URL was fully validated above
|
||||
for key, value := range opts.Headers {
|
||||
if strings.TrimSpace(key) != "" {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return DownloadResult{}, fmt.Errorf("下载资源失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return DownloadResult{}, fmt.Errorf("下载资源失败: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
if resp.ContentLength > maxBytes {
|
||||
return DownloadResult{}, fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 响应大小 %d 超过上限 %d 字节", resp.ContentLength, maxBytes)
|
||||
}
|
||||
if err := target.verifyParent(); err != nil {
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
|
||||
tmp, tmpName, err := createDownloadTemp(target.parentRoot)
|
||||
if err != nil {
|
||||
return DownloadResult{}, fmt.Errorf("创建下载临时文件失败: %w", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = tmp.Close()
|
||||
_ = target.parentRoot.Remove(tmpName)
|
||||
}
|
||||
size, copyErr := io.Copy(tmp, io.LimitReader(resp.Body, maxBytes+1))
|
||||
if copyErr == nil && size > maxBytes {
|
||||
copyErr = fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 下载内容超过上限 %d 字节", maxBytes)
|
||||
}
|
||||
if copyErr == nil {
|
||||
copyErr = tmp.Sync()
|
||||
}
|
||||
if closeErr := tmp.Close(); copyErr == nil {
|
||||
copyErr = closeErr
|
||||
}
|
||||
if copyErr != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, fmt.Errorf("写入下载临时文件失败: %w", copyErr)
|
||||
}
|
||||
if err := target.verifyParent(); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
if err := publishTempFile(target.parentRoot, tmpName, target.destinationName); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
return DownloadResult{AbsolutePath: target.absolutePath, RelativePath: filepath.ToSlash(target.relativePath), SizeBytes: size}, nil
|
||||
}
|
||||
|
||||
// ValidateOutput rejects absolute paths and portable `..` escapes.
|
||||
func ValidateOutput(output string) error {
|
||||
output = strings.TrimSpace(output)
|
||||
if output == "" {
|
||||
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不能为空")
|
||||
}
|
||||
portable := strings.ReplaceAll(output, "\\", "/")
|
||||
if filepath.IsAbs(output) || pathpkg.IsAbs(portable) ||
|
||||
(len(portable) >= 2 && portable[1] == ':' && ((portable[0] >= 'a' && portable[0] <= 'z') || (portable[0] >= 'A' && portable[0] <= 'Z'))) {
|
||||
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 只接受工作目录内的相对路径")
|
||||
}
|
||||
clean := pathpkg.Clean(portable)
|
||||
if clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不允许使用 .. 逃逸工作目录")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveOutputPath returns a symlink-safe destination below baseDir.
|
||||
type downloadTarget struct {
|
||||
baseRoot *os.Root
|
||||
parentRoot *os.Root
|
||||
parentInfo os.FileInfo
|
||||
parentRelative string
|
||||
destinationName string
|
||||
absolutePath string
|
||||
relativePath string
|
||||
}
|
||||
|
||||
func (target *downloadTarget) close() {
|
||||
_ = target.parentRoot.Close()
|
||||
_ = target.baseRoot.Close()
|
||||
}
|
||||
|
||||
func (target *downloadTarget) verifyParent() error {
|
||||
current, err := downloadRootStat(target.baseRoot, target.parentRelative)
|
||||
if err != nil || !os.SameFile(target.parentInfo, current) {
|
||||
return fmt.Errorf("LOCAL_PATH_CHANGED: 下载期间输出目录被替换")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ResolveOutputPath(baseDir, output, rawURL, preferredName string) (string, string, error) {
|
||||
target, err := openDownloadTarget(baseDir, output, rawURL, preferredName)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer target.close()
|
||||
return target.absolutePath, target.relativePath, nil
|
||||
}
|
||||
|
||||
func openDownloadTarget(baseDir, output, rawURL, preferredName string) (*downloadTarget, error) {
|
||||
if err := ValidateOutput(output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(baseDir) == "" {
|
||||
var err error
|
||||
baseDir, err = localGetwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取工作目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
absBase, err := localAbs(baseDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析工作目录失败: %w", err)
|
||||
}
|
||||
realBase, err := localEvalSymlinks(absBase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析工作目录失败: %w", err)
|
||||
}
|
||||
baseRoot, err := openDownloadRoot(realBase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开工作目录失败: %w", err)
|
||||
}
|
||||
fail := func(err error) (*downloadTarget, error) {
|
||||
_ = baseRoot.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawOutput := strings.TrimSpace(output)
|
||||
directoryIntent := rawOutput == "." || strings.HasSuffix(rawOutput, "/") || strings.HasSuffix(rawOutput, string(os.PathSeparator))
|
||||
candidate := filepath.Clean(rawOutput)
|
||||
if info, statErr := downloadRootStat(baseRoot, candidate); statErr == nil && info.IsDir() {
|
||||
directoryIntent = true
|
||||
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
||||
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: 检查输出路径失败: %w", statErr))
|
||||
}
|
||||
if directoryIntent {
|
||||
candidate = filepath.Join(candidate, SafeFilename(preferredName, rawURL))
|
||||
}
|
||||
parent := filepath.Dir(candidate)
|
||||
if err := ensureSafeParent(baseRoot, parent); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
parentRoot, err := openDownloadParent(baseRoot, parent)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("固定输出目录失败: %w", err))
|
||||
}
|
||||
parentInfo, err := downloadRootStat(parentRoot, ".")
|
||||
if err != nil {
|
||||
_ = parentRoot.Close()
|
||||
return fail(fmt.Errorf("读取输出目录身份失败: %w", err))
|
||||
}
|
||||
currentParent, err := downloadRootStat(baseRoot, parent)
|
||||
if err != nil || !os.SameFile(parentInfo, currentParent) {
|
||||
_ = parentRoot.Close()
|
||||
return fail(fmt.Errorf("LOCAL_PATH_CHANGED: 输出目录在解析期间被替换"))
|
||||
}
|
||||
destinationName := filepath.Base(candidate)
|
||||
if info, statErr := downloadRootLstat(parentRoot, destinationName); statErr == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
_ = parentRoot.Close()
|
||||
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标不能是符号链接"))
|
||||
}
|
||||
if info.IsDir() {
|
||||
_ = parentRoot.Close()
|
||||
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标是目录"))
|
||||
}
|
||||
_ = parentRoot.Close()
|
||||
return fail(fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在;请选择新的输出路径"))
|
||||
} else if !errors.Is(statErr, os.ErrNotExist) {
|
||||
_ = parentRoot.Close()
|
||||
return fail(fmt.Errorf("检查输出文件失败: %w", statErr))
|
||||
}
|
||||
return &downloadTarget{
|
||||
baseRoot: baseRoot,
|
||||
parentRoot: parentRoot,
|
||||
parentInfo: parentInfo,
|
||||
parentRelative: parent,
|
||||
destinationName: destinationName,
|
||||
absolutePath: filepath.Join(realBase, candidate),
|
||||
relativePath: candidate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SafeFilename selects a portable basename from a preferred server name or URL.
|
||||
func SafeFilename(preferredName, rawURL string) string {
|
||||
if name := sanitizeFilename(preferredName); name != "" {
|
||||
return name
|
||||
}
|
||||
if parsed, err := url.Parse(rawURL); err == nil {
|
||||
if decoded, decodeErr := url.PathUnescape(filepath.Base(parsed.Path)); decodeErr == nil {
|
||||
if name := sanitizeFilename(decoded); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return "download"
|
||||
}
|
||||
|
||||
// ValidateDownloadURL accepts only public DingTalk and Aliyun OSS HTTPS hosts.
|
||||
func ValidateDownloadURL(rawURL string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
||||
return nil, fmt.Errorf("下载地址必须是受信任域名上的 HTTPS URL")
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
|
||||
if host == "" || net.ParseIP(host) != nil || !allowedDownloadHost(host) {
|
||||
return nil, fmt.Errorf("下载地址域名 %q 不属于受信任的钉钉或 OSS 域名", host)
|
||||
}
|
||||
if port := parsed.Port(); port != "" && port != "443" {
|
||||
return nil, fmt.Errorf("下载地址只允许 HTTPS 默认端口")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func secureHTTPClient() *http.Client {
|
||||
transport := &http.Transport{
|
||||
// Do not use environment proxies here. DialContext must resolve and dial
|
||||
// the validated download host itself; with a proxy it would receive the
|
||||
// proxy address and could not enforce the target host's public-IP policy.
|
||||
Proxy: nil,
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, err := lookupDownloadIPs(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, resolved := range ips {
|
||||
if !publicIP(resolved.IP) {
|
||||
return nil, fmt.Errorf("下载域名解析到非公网地址 %s", resolved.IP)
|
||||
}
|
||||
}
|
||||
// Dial the already validated address, not the hostname, to avoid a
|
||||
// second DNS lookup opening a rebinding window.
|
||||
var lastErr error
|
||||
for _, resolved := range ips {
|
||||
conn, dialErr := dialDownloadIP(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
return nil, lastErr
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: downloadTimeout}
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("下载重定向次数超过上限")
|
||||
}
|
||||
if _, err := ValidateDownloadURL(req.URL.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
// net/http copies arbitrary request headers from the initial request to
|
||||
// every redirect. Never forward service-provided download credentials to
|
||||
// a different origin, even when both hosts are on the download allowlist.
|
||||
if len(via) > 0 && !sameDownloadOrigin(via[0].URL, req.URL) {
|
||||
req.Header = make(http.Header)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func sameDownloadOrigin(left, right *url.URL) bool {
|
||||
return downloadOrigin(left) == downloadOrigin(right)
|
||||
}
|
||||
|
||||
func downloadOrigin(parsed *url.URL) string {
|
||||
port := parsed.Port()
|
||||
if port == "" {
|
||||
port = "443"
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
|
||||
return strings.ToLower(parsed.Scheme) + "://" + net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
func allowedDownloadHost(host string) bool {
|
||||
return host == "dingtalk.com" || strings.HasSuffix(host, ".dingtalk.com") ||
|
||||
(strings.HasSuffix(host, ".aliyuncs.com") && strings.Contains(host, "oss") && !strings.Contains(host, "internal"))
|
||||
}
|
||||
|
||||
func publicIP(ip net.IP) bool {
|
||||
addr, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsMulticast() || addr.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range nonPublicPrefixes {
|
||||
if prefix.Contains(addr) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var nonPublicPrefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT
|
||||
netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments
|
||||
netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1
|
||||
netip.MustParsePrefix("198.18.0.0/15"), // benchmark networks
|
||||
netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2
|
||||
netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3
|
||||
netip.MustParsePrefix("240.0.0.0/4"), // reserved
|
||||
netip.MustParsePrefix("2001:db8::/32"), // IPv6 documentation
|
||||
}
|
||||
|
||||
func ensureSafeParent(root *os.Root, parent string) error {
|
||||
if parent == "." {
|
||||
return nil
|
||||
}
|
||||
current := "."
|
||||
for _, part := range strings.Split(parent, string(os.PathSeparator)) {
|
||||
current = filepath.Join(current, part)
|
||||
info, statErr := downloadRootLstat(root, current)
|
||||
if errors.Is(statErr, os.ErrNotExist) {
|
||||
if err := downloadRootMkdir(root, current, 0o755); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return fmt.Errorf("创建输出目录失败: %w", err)
|
||||
}
|
||||
info, statErr = downloadRootLstat(root, current)
|
||||
}
|
||||
if statErr != nil {
|
||||
return fmt.Errorf("检查输出目录失败: %w", statErr)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 父路径必须是非符号链接目录")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createDownloadTempInRoot(root *os.Root) (downloadTempFile, string, error) {
|
||||
name := fmt.Sprintf(".dws-download-%d-%d", os.Getpid(), downloadTempCounter.Add(1))
|
||||
file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return file, name, nil
|
||||
}
|
||||
|
||||
func publishTempFile(root *os.Root, tempName, destinationName string) error {
|
||||
if err := downloadRootLink(root, tempName, destinationName); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在")
|
||||
}
|
||||
return fmt.Errorf("发布下载文件失败: %w", err)
|
||||
}
|
||||
if err := downloadRootRemove(root, tempName); err != nil {
|
||||
return fmt.Errorf("清理下载临时文件失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeFilename(raw string) string {
|
||||
normalized := strings.ReplaceAll(raw, "\\", "/")
|
||||
if strings.TrimSpace(normalized) != normalized {
|
||||
return ""
|
||||
}
|
||||
name := filepath.Base(normalized)
|
||||
if name == "" || name == "." || name == ".." || strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
|
||||
return ""
|
||||
}
|
||||
for _, char := range name {
|
||||
if char < 0x20 || char == 0x7f || strings.ContainsRune(`<>:"/\|?*`, char) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
stem := strings.ToUpper(strings.TrimRight(strings.SplitN(name, ".", 2)[0], " ."))
|
||||
if stem == "CON" || stem == "PRN" || stem == "AUX" || stem == "NUL" ||
|
||||
(len(stem) == 4 && (strings.HasPrefix(stem, "COM") || strings.HasPrefix(stem, "LPT")) && stem[3] >= '1' && stem[3] <= '9') {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) }
|
||||
|
||||
type failingBody struct{}
|
||||
|
||||
func (failingBody) Read([]byte) (int, error) { return 0, errors.New("read failed") }
|
||||
func (failingBody) Close() error { return nil }
|
||||
|
||||
type coverageTempFile struct {
|
||||
file *os.File
|
||||
writeErr error
|
||||
syncErr error
|
||||
closeErr error
|
||||
onClose func()
|
||||
}
|
||||
|
||||
func (f *coverageTempFile) Write(value []byte) (int, error) {
|
||||
if f.writeErr != nil {
|
||||
return 0, f.writeErr
|
||||
}
|
||||
return f.file.Write(value)
|
||||
}
|
||||
func (f *coverageTempFile) Name() string { return f.file.Name() }
|
||||
func (f *coverageTempFile) Sync() error {
|
||||
if f.syncErr != nil {
|
||||
return f.syncErr
|
||||
}
|
||||
return f.file.Sync()
|
||||
}
|
||||
func (f *coverageTempFile) Close() error {
|
||||
err := f.file.Close()
|
||||
if f.onClose != nil {
|
||||
f.onClose()
|
||||
f.onClose = nil
|
||||
}
|
||||
if f.closeErr != nil {
|
||||
return f.closeErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadURLAndPublicIPPolicy(t *testing.T) {
|
||||
valid := []string{
|
||||
"https://alidocs.dingtalk.com/file.docx",
|
||||
"https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md",
|
||||
}
|
||||
for _, raw := range valid {
|
||||
if _, err := ValidateDownloadURL(raw); err != nil {
|
||||
t.Errorf("ValidateDownloadURL(%q): %v", raw, err)
|
||||
}
|
||||
}
|
||||
invalid := []string{
|
||||
"http://alidocs.dingtalk.com/file.docx",
|
||||
"https://127.0.0.1/file.docx",
|
||||
"https://evil.example/file.docx",
|
||||
"https://oss-cn-hangzhou-internal.aliyuncs.com/file.docx",
|
||||
"https://user@alidocs.dingtalk.com/file.docx",
|
||||
"https://alidocs.dingtalk.com:8443/file.docx",
|
||||
}
|
||||
for _, raw := range invalid {
|
||||
if _, err := ValidateDownloadURL(raw); err == nil {
|
||||
t.Errorf("ValidateDownloadURL(%q) unexpectedly succeeded", raw)
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range []string{"127.0.0.1", "10.0.0.1", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "224.0.0.1", "2001:db8::1"} {
|
||||
if publicIP(net.ParseIP(raw)) {
|
||||
t.Errorf("publicIP(%s) = true", raw)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"} {
|
||||
if !publicIP(net.ParseIP(raw)) {
|
||||
t.Errorf("publicIP(%s) = false", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOutputPathPolicy(t *testing.T) {
|
||||
for _, output := range []string{"", "../escape", "nested/../../escape", "/tmp/absolute", `C:\\absolute\\file`} {
|
||||
if err := ValidateOutput(output); err == nil {
|
||||
t.Errorf("ValidateOutput(%q) unexpectedly succeeded", output)
|
||||
}
|
||||
}
|
||||
|
||||
base := t.TempDir()
|
||||
destination, rel, err := ResolveOutputPath(base, "nested/file.md", "https://alidocs.dingtalk.com/file.md", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realBase, err := filepath.EvalSymlinks(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rel != filepath.Join("nested", "file.md") || filepath.Dir(destination) != filepath.Join(realBase, "nested") {
|
||||
t.Fatalf("destination=%q rel=%q", destination, rel)
|
||||
}
|
||||
if err := os.WriteFile(destination, []byte("existing"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := ResolveOutputPath(base, "nested/file.md", "https://alidocs.dingtalk.com/file.md", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
|
||||
t.Fatalf("no-clobber error = %v", err)
|
||||
}
|
||||
|
||||
outside := t.TempDir()
|
||||
link := filepath.Join(base, "outside-link")
|
||||
if err := os.Symlink(outside, link); err == nil {
|
||||
if _, _, err := ResolveOutputPath(base, "outside-link/file", "https://alidocs.dingtalk.com/file", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_UNSAFE") {
|
||||
t.Fatalf("symlink escape error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := SafeFilename("../evil", "https://alidocs.dingtalk.com/"); got != "evil" {
|
||||
t.Errorf("SafeFilename traversal basename = %q", got)
|
||||
}
|
||||
for _, name := range []string{"CON", "bad?.txt", " trailing.txt"} {
|
||||
if got := SafeFilename(name, "https://alidocs.dingtalk.com/"); got != "download" {
|
||||
t.Errorf("SafeFilename(%q) = %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadAtomicNoClobber(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
payload := "first payload"
|
||||
requests := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requests++
|
||||
if req.URL.Host != "alidocs.oss-cn-zhangjiakou.aliyuncs.com" {
|
||||
return nil, errors.New("unexpected host")
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(payload)), Header: make(http.Header)}, nil
|
||||
})}
|
||||
result, err := downloadWithClient(context.Background(), "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", DownloadOptions{
|
||||
BaseDir: base, Output: "nested/result.md",
|
||||
}, client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.RelativePath != "nested/result.md" || result.SizeBytes != int64(len(payload)) {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
got, err := os.ReadFile(result.AbsolutePath)
|
||||
if err != nil || string(got) != payload {
|
||||
t.Fatalf("published content = %q, err=%v", got, err)
|
||||
}
|
||||
if _, err := downloadWithClient(context.Background(), "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", DownloadOptions{
|
||||
BaseDir: base, Output: "nested/result.md",
|
||||
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
|
||||
t.Fatalf("second download error = %v", err)
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Fatalf("existing destination performed %d network requests, want 1 total", requests)
|
||||
}
|
||||
|
||||
got, err = os.ReadFile(result.AbsolutePath)
|
||||
if err != nil || string(got) != payload {
|
||||
t.Fatalf("no-clobber content = %q, err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadSizeLimitCleansPartialFiles(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
contentLength int64
|
||||
}{
|
||||
{name: "declared", contentLength: 6},
|
||||
{name: "streamed", contentLength: -1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("123456")),
|
||||
Header: make(http.Header),
|
||||
ContentLength: tc.contentLength,
|
||||
}, nil
|
||||
})}
|
||||
output := tc.name + ".bin"
|
||||
if _, err := downloadWithClientLimit(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
|
||||
BaseDir: base, Output: output,
|
||||
}, client, 5); err == nil || !strings.Contains(err.Error(), "LOCAL_DOWNLOAD_TOO_LARGE") {
|
||||
t.Fatalf("oversized download error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, output)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("oversized destination exists: %v", err)
|
||||
}
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".dws-download-") {
|
||||
t.Fatalf("oversized download left temp file %q", entry.Name())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadRejectsParentReplacementDuringNetwork(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
parent := filepath.Join(base, "nested")
|
||||
if err := os.Mkdir(parent, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := filepath.Join(base, "original-parent")
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
if err := os.Rename(parent, original); err != nil {
|
||||
t.Skipf("platform cannot replace an open directory: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(parent, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
if _, err := downloadWithClient(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
|
||||
BaseDir: base, Output: "nested/result.bin",
|
||||
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_CHANGED") {
|
||||
t.Fatalf("parent replacement error = %v", err)
|
||||
}
|
||||
for _, candidate := range []string{filepath.Join(parent, "result.bin"), filepath.Join(original, "result.bin")} {
|
||||
if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("parent replacement wrote %q: %v", candidate, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadRejectsParentReplacementBeforePublish(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
parent := filepath.Join(base, "nested")
|
||||
if err := os.Mkdir(parent, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := filepath.Join(base, "original-parent")
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
testseam.Swap(t, &createDownloadTemp, func(root *os.Root) (downloadTempFile, string, error) {
|
||||
created, name, err := createDownloadTempInRoot(root)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &coverageTempFile{file: created.(*os.File), onClose: func() {
|
||||
if renameErr := os.Rename(parent, original); renameErr != nil {
|
||||
t.Skipf("platform cannot replace an open directory: %v", renameErr)
|
||||
}
|
||||
if mkdirErr := os.Mkdir(parent, 0o700); mkdirErr != nil {
|
||||
t.Fatal(mkdirErr)
|
||||
}
|
||||
}}, name, nil
|
||||
})
|
||||
if _, err := downloadWithClient(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
|
||||
BaseDir: base, Output: "nested/result.bin",
|
||||
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_CHANGED") {
|
||||
t.Fatalf("parent replacement error = %v", err)
|
||||
}
|
||||
for _, candidate := range []string{filepath.Join(parent, "result.bin"), filepath.Join(original, "result.bin")} {
|
||||
if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("parent replacement wrote %q: %v", candidate, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadFailureBoundaries(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
validURL := "https://download.dingtalk.com/file.bin"
|
||||
if _, err := Download(context.Background(), "bad", DownloadOptions{BaseDir: base, Output: "x"}); err == nil {
|
||||
t.Fatal("invalid URL download succeeded")
|
||||
}
|
||||
if _, err := Download(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "../x"}); err == nil {
|
||||
t.Fatal("unsafe output download succeeded")
|
||||
}
|
||||
|
||||
clientError := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("transport") })}
|
||||
statusClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get("x-test") != "ok" || req.Header.Get("") != "" {
|
||||
t.Errorf("headers = %#v", req.Header)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("backend")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
bodyErrorClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: failingBody{}, Header: make(http.Header)}, nil
|
||||
})}
|
||||
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "transport.bin"}, clientError); err == nil {
|
||||
t.Fatal("transport error was ignored")
|
||||
}
|
||||
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "status.bin", Headers: map[string]string{"x-test": "ok", " ": "ignored"}}, statusClient); err == nil {
|
||||
t.Fatal("HTTP status error was ignored")
|
||||
}
|
||||
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "copy.bin"}, bodyErrorClient); err == nil {
|
||||
t.Fatal("body read error was ignored")
|
||||
}
|
||||
|
||||
okClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
makeTemp func(*os.Root) (downloadTempFile, string, error)
|
||||
}{
|
||||
{"create", func(*os.Root) (downloadTempFile, string, error) { return nil, "", errors.New("create") }},
|
||||
{"sync", func(root *os.Root) (downloadTempFile, string, error) {
|
||||
created, name, err := createDownloadTempInRoot(root)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &coverageTempFile{file: created.(*os.File), syncErr: errors.New("sync")}, name, nil
|
||||
}},
|
||||
{"close", func(root *os.Root) (downloadTempFile, string, error) {
|
||||
created, name, err := createDownloadTempInRoot(root)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &coverageTempFile{file: created.(*os.File), closeErr: errors.New("close")}, name, nil
|
||||
}},
|
||||
{"publish-race", func(root *os.Root) (downloadTempFile, string, error) {
|
||||
created, name, err := createDownloadTempInRoot(root)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &coverageTempFile{file: created.(*os.File), onClose: func() {
|
||||
file, createErr := root.OpenFile("publish-race.bin", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if createErr == nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
}}, name, nil
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &createDownloadTemp, tc.makeTemp)
|
||||
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: tc.name + ".bin"}, okClient); err == nil {
|
||||
t.Fatalf("%s failure was ignored", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSecureHTTPClientAndFilesystemEdges(t *testing.T) {
|
||||
client := secureHTTPClient()
|
||||
transport := client.Transport.(*http.Transport)
|
||||
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, make([]*http.Request, 5)); err == nil {
|
||||
t.Fatal("redirect limit accepted")
|
||||
}
|
||||
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://evil.example/x")}, nil); err == nil {
|
||||
t.Fatal("unsafe redirect accepted")
|
||||
}
|
||||
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := transport.DialContext(context.Background(), "tcp", "bad-address"); err == nil {
|
||||
t.Fatal("bad address dial succeeded")
|
||||
}
|
||||
|
||||
t.Run("lookup error", func(t *testing.T) {
|
||||
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) { return nil, errors.New("lookup") })
|
||||
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
|
||||
t.Fatal("lookup error ignored")
|
||||
}
|
||||
})
|
||||
t.Run("private answer", func(t *testing.T) {
|
||||
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
|
||||
return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
|
||||
})
|
||||
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
|
||||
t.Fatal("private DNS answer accepted")
|
||||
}
|
||||
})
|
||||
t.Run("public dial fallback and success", func(t *testing.T) {
|
||||
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
|
||||
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}, {IP: net.ParseIP("1.1.1.1")}}, nil
|
||||
})
|
||||
left, right := net.Pipe()
|
||||
t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
|
||||
calls := 0
|
||||
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, errors.New("first")
|
||||
}
|
||||
return left, nil
|
||||
})
|
||||
if conn, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
_ = conn.Close()
|
||||
}
|
||||
})
|
||||
t.Run("all public dials fail", func(t *testing.T) {
|
||||
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
|
||||
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil
|
||||
})
|
||||
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) { return nil, errors.New("dial") })
|
||||
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
|
||||
t.Fatal("dial failure ignored")
|
||||
}
|
||||
})
|
||||
|
||||
base := t.TempDir()
|
||||
if _, _, err := ResolveOutputPath("", "default-base.tmp", "https://download.dingtalk.com/x", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := ResolveOutputPath(filepath.Join(base, "missing"), "x", "https://download.dingtalk.com/x", ""); err == nil {
|
||||
t.Fatal("missing base succeeded")
|
||||
}
|
||||
dir := filepath.Join(base, "directory")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, output := range []string{".", "directory/", "directory"} {
|
||||
if _, _, err := ResolveOutputPath(base, output, "https://download.dingtalk.com/path/name.txt", "preferred.txt"); err != nil {
|
||||
t.Errorf("directory output %q: %v", output, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := ResolveOutputPath(base, "directory", "https://download.dingtalk.com/x", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
targetDir := filepath.Join(base, "target-dir")
|
||||
_ = os.Mkdir(targetDir, 0o700)
|
||||
if _, _, err := ResolveOutputPath(base, "target-dir", "https://download.dingtalk.com/x", "x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
targetFile := filepath.Join(base, "existing.txt")
|
||||
_ = os.WriteFile(targetFile, []byte("x"), 0o600)
|
||||
if _, _, err := ResolveOutputPath(base, "existing.txt", "https://download.dingtalk.com/x", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
|
||||
t.Fatalf("existing destination error = %v", err)
|
||||
}
|
||||
link := filepath.Join(base, "target-link")
|
||||
if err := os.Symlink(targetFile, link); err == nil {
|
||||
if _, _, err := ResolveOutputPath(base, "target-link", "https://download.dingtalk.com/x", ""); err == nil {
|
||||
t.Fatal("symlink destination accepted")
|
||||
}
|
||||
}
|
||||
fileParent := filepath.Join(base, "file-parent")
|
||||
_ = os.WriteFile(fileParent, []byte("x"), 0o600)
|
||||
if _, _, err := ResolveOutputPath(base, "file-parent/child", "https://download.dingtalk.com/x", ""); err == nil {
|
||||
t.Fatal("file parent accepted")
|
||||
}
|
||||
root, err := os.OpenRoot(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = root.Close() })
|
||||
if err := ensureSafeParent(root, "../escape"); err == nil {
|
||||
t.Fatal("escaping parent accepted")
|
||||
}
|
||||
if err := ensureSafeParent(root, "."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
source, err := root.OpenFile("source.tmp", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = source.WriteString("x")
|
||||
_ = source.Close()
|
||||
destination := filepath.Join(base, "publish.txt")
|
||||
_ = os.WriteFile(destination, []byte("old"), 0o600)
|
||||
if err := publishTempFile(root, "source.tmp", "publish.txt"); err == nil {
|
||||
t.Fatal("publish existing destination succeeded")
|
||||
}
|
||||
if err := publishTempFile(root, "missing-source", "new.txt"); err == nil {
|
||||
t.Fatal("publish missing source succeeded")
|
||||
}
|
||||
symlinkDestination := filepath.Join(base, "publish-link")
|
||||
if err := os.Symlink(destination, symlinkDestination); err == nil {
|
||||
if err := publishTempFile(root, "source.tmp", "publish-link"); err == nil {
|
||||
t.Fatal("publish to symlink succeeded")
|
||||
}
|
||||
}
|
||||
closedRoot, err := os.OpenRoot(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = closedRoot.Close()
|
||||
if _, _, err := createDownloadTempInRoot(closedRoot); err == nil {
|
||||
t.Fatal("temp creation in closed root succeeded")
|
||||
}
|
||||
|
||||
for _, name := range []string{"", ".", "..", "name.", "name ", "bad\x00", "AUX", "COM1", "LPT9"} {
|
||||
_ = sanitizeFilename(name)
|
||||
}
|
||||
_ = SafeFilename("", "https://download.dingtalk.com/path/fallback.txt")
|
||||
_ = SafeFilename("", "https://download.dingtalk.com/%zz")
|
||||
_ = SafeFilename("", "://bad")
|
||||
_ = publicIP(net.IP{1, 2, 3})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSecureHTTPClientDisablesEnvironmentProxy(t *testing.T) {
|
||||
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:3128")
|
||||
transport := secureHTTPClient().Transport.(*http.Transport)
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("secure download client accepted an environment proxy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSecureHTTPClientStripsCrossOriginHeaders(t *testing.T) {
|
||||
client := secureHTTPClient()
|
||||
original := &http.Request{
|
||||
URL: mustURL(t, "https://download.dingtalk.com/source"),
|
||||
Header: http.Header{
|
||||
"X-Oss-Security-Token": []string{"credential-a"},
|
||||
"X-Download-Auth": []string{"credential-b"},
|
||||
},
|
||||
}
|
||||
|
||||
sameOrigin := &http.Request{
|
||||
URL: mustURL(t, "https://DOWNLOAD.dingtalk.com.:443/next"),
|
||||
Header: original.Header.Clone(),
|
||||
}
|
||||
if err := client.CheckRedirect(sameOrigin, []*http.Request{original}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sameOrigin.Header.Get("X-Oss-Security-Token") == "" {
|
||||
t.Fatal("same-origin redirect unexpectedly stripped request headers")
|
||||
}
|
||||
|
||||
crossOrigin := &http.Request{
|
||||
URL: mustURL(t, "https://attacker-bucket.oss-cn-hangzhou.aliyuncs.com/next"),
|
||||
Header: original.Header.Clone(),
|
||||
}
|
||||
if err := client.CheckRedirect(crossOrigin, []*http.Request{original}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(crossOrigin.Header) != 0 {
|
||||
t.Fatalf("cross-origin redirect retained %d request headers", len(crossOrigin.Header))
|
||||
}
|
||||
|
||||
multiHop := &http.Request{
|
||||
URL: mustURL(t, "https://attacker-bucket.oss-cn-hangzhou.aliyuncs.com/final"),
|
||||
Header: original.Header.Clone(),
|
||||
}
|
||||
if err := client.CheckRedirect(multiHop, []*http.Request{original, crossOrigin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(multiHop.Header) != 0 {
|
||||
t.Fatalf("later cross-origin redirect restored %d initial request headers", len(multiHop.Header))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFilesystemInjectedFailures(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
validURL := "https://download.dingtalk.com/x"
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := Download(cancelled, validURL, DownloadOptions{BaseDir: base, Output: "default-client.bin"}); err == nil {
|
||||
t.Fatal("cancelled default client download succeeded")
|
||||
}
|
||||
|
||||
t.Run("getwd", func(t *testing.T) {
|
||||
testseam.Swap(t, &localGetwd, func() (string, error) { return "", errors.New("getwd") })
|
||||
_, _, _ = ResolveOutputPath("", "x", validURL, "")
|
||||
})
|
||||
t.Run("abs", func(t *testing.T) {
|
||||
testseam.Swap(t, &localAbs, func(string) (string, error) { return "", errors.New("abs") })
|
||||
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
|
||||
})
|
||||
t.Run("eval base", func(t *testing.T) {
|
||||
testseam.Swap(t, &localEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") })
|
||||
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
|
||||
})
|
||||
t.Run("open base", func(t *testing.T) {
|
||||
testseam.Swap(t, &openDownloadRoot, func(string) (*os.Root, error) { return nil, errors.New("open root") })
|
||||
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
|
||||
})
|
||||
t.Run("mkdir", func(t *testing.T) {
|
||||
testseam.Swap(t, &downloadRootMkdir, func(*os.Root, string, os.FileMode) error { return errors.New("mkdir") })
|
||||
_, _, _ = ResolveOutputPath(base, "new/target", validURL, "")
|
||||
})
|
||||
t.Run("lstat after mkdir", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == "new-after" {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
return nil, errors.New("after mkdir")
|
||||
}
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
_, _, _ = ResolveOutputPath(base, "new-after/target", validURL, "")
|
||||
})
|
||||
t.Run("open parent", func(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(base, "open-parent"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &openDownloadParent, func(*os.Root, string) (*os.Root, error) { return nil, errors.New("open parent") })
|
||||
_, _, _ = ResolveOutputPath(base, "open-parent/target", validURL, "")
|
||||
})
|
||||
t.Run("parent stat", func(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(base, "parent-stat"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &downloadRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == "." {
|
||||
return nil, errors.New("parent stat")
|
||||
}
|
||||
return root.Stat(name)
|
||||
})
|
||||
_, _, _ = ResolveOutputPath(base, "parent-stat/target", validURL, "")
|
||||
})
|
||||
t.Run("parent identity", func(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(base, "parent-identity"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherInfo, err := os.Stat(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &downloadRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == "parent-identity" {
|
||||
return otherInfo, nil
|
||||
}
|
||||
return root.Stat(name)
|
||||
})
|
||||
_, _, _ = ResolveOutputPath(base, "parent-identity/target", validURL, "")
|
||||
})
|
||||
t.Run("destination directory", func(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(base, "destination-directory"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dirInfo, err := os.Stat(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == "target" {
|
||||
return dirInfo, nil
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
_, _, _ = ResolveOutputPath(base, "destination-directory/target", validURL, "")
|
||||
})
|
||||
t.Run("destination symlink", func(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(base, "destination-symlink"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(base, "coverage-link")
|
||||
if err := os.Symlink(filepath.Join(base, "destination-symlink"), link); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
linkInfo, err := os.Lstat(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == "target" {
|
||||
return linkInfo, nil
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
_, _, _ = ResolveOutputPath(base, "destination-symlink/target", validURL, "")
|
||||
})
|
||||
t.Run("destination lstat", func(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(base, "destination-lstat"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == "target" {
|
||||
return nil, errors.New("destination lstat")
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
_, _, _ = ResolveOutputPath(base, "destination-lstat/target", validURL, "")
|
||||
})
|
||||
t.Run("unsafe parent type", func(t *testing.T) {
|
||||
filePath := filepath.Join(base, "unsafe-parent")
|
||||
if err := os.WriteFile(filePath, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := os.OpenRoot(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
if err := ensureSafeParent(root, "unsafe-parent"); err == nil {
|
||||
t.Fatal("regular file accepted as output parent")
|
||||
}
|
||||
})
|
||||
t.Run("publish remove", func(t *testing.T) {
|
||||
root, err := os.OpenRoot(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
file, err := root.OpenFile("remove-source", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = file.Close()
|
||||
testseam.Swap(t, &downloadRootRemove, func(*os.Root, string) error { return errors.New("remove") })
|
||||
if err := publishTempFile(root, "remove-source", "remove-destination"); err == nil {
|
||||
t.Fatal("publish remove error ignored")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -1,821 +0,0 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryModelEdges(t *testing.T) {
|
||||
typedCases := []struct {
|
||||
name string
|
||||
err error
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"validation required", apperrors.NewValidation("required value"), "", cliMissingParamCode},
|
||||
{"validation invalid", apperrors.NewValidation("bad json"), "", cliInvalidJSONCode},
|
||||
{"auth permission reason", apperrors.NewAuth("denied", apperrors.WithReason("http_403")), "", cliPermissionCode},
|
||||
{"auth forbidden", apperrors.NewAuth("forbidden"), "", cliPermissionCode},
|
||||
{"auth expired", apperrors.NewAuth("expired"), "", cliAuthExpiredCode},
|
||||
{"api rate reason", apperrors.NewAPI("limited", apperrors.WithReason("http_429")), "", cliRateLimitCode},
|
||||
{"api rate message", apperrors.NewAPI("rate limit", apperrors.WithRetryable(true)), "", cliRateLimitCode},
|
||||
{"api timeout reason", apperrors.NewAPI("failed", apperrors.WithReason("request_timeout")), "", cliTimeoutCode},
|
||||
{"api timeout message", apperrors.NewDiscovery("connection reset"), "", cliTimeoutCode},
|
||||
{"api invalid params", apperrors.NewAPI("failed", apperrors.WithReason("invalid_params")), "", cliInvalidJSONCode},
|
||||
{"api method missing", apperrors.NewAPI("failed", apperrors.WithReason("method_not_found")), "", cliResourceNotFoundCode},
|
||||
{"api 404", apperrors.NewAPI("failed", apperrors.WithReason("http_404")), "", cliResourceNotFoundCode},
|
||||
{"raw auth", nil, "user_token_illegal", cliAuthExpiredCode},
|
||||
{"raw permission", nil, "permission denied", cliPermissionCode},
|
||||
{"raw rate", nil, "too many requests", cliRateLimitCode},
|
||||
{"raw timeout", nil, "deadline exceeded", cliTimeoutCode},
|
||||
{"raw missing", nil, "base_not_found", cliResourceNotFoundCode},
|
||||
{"unknown", nil, "strange", ""},
|
||||
}
|
||||
for _, tt := range typedCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := canonicalCLIErrorCode(tt.err, tt.raw); got != tt.want {
|
||||
t.Fatalf("canonicalCLIErrorCode = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, name := range []string{"get_x", "list_x", "search_x", "query_x", "status_x", "download_x"} {
|
||||
if InferOperationKind(name) != OperationRead {
|
||||
t.Errorf("%q should be read", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"create_x", "update_x", "delete_x", "send_x", "generate_image", "edit_image", "upscale", "isolate"} {
|
||||
if InferOperationKind(name) != OperationWrite {
|
||||
t.Errorf("%q should be write", name)
|
||||
}
|
||||
}
|
||||
if InferOperationKind("") != OperationUnknown || InferOperationKind("execute") != OperationUnknown {
|
||||
t.Fatal("unknown operation inference failed")
|
||||
}
|
||||
|
||||
input := map[string]any{
|
||||
"short": "value",
|
||||
"long": strings.Repeat("x", 121),
|
||||
"text": "secret body",
|
||||
"names": []string{"a", "b"},
|
||||
"textItems": []string{"a"},
|
||||
"items": []any{"a", map[string]any{"x": 1}},
|
||||
"records": []map[string]any{{"name": "n"}},
|
||||
"objects": []map[string]any{{"name": "n", "token": "drop"}},
|
||||
"payload": map[string]any{"name": "n", "token": "drop"},
|
||||
"fields": map[string]any{"z": 1, "a": 2},
|
||||
"stringMap": map[string]string{"name": "n", "token": "drop"},
|
||||
"jsonMap": map[string]string{"z": "1", "a": "2"},
|
||||
"number": 7,
|
||||
"token": "drop",
|
||||
}
|
||||
summary := SummarizeArgs(input)
|
||||
if len(summary) == 0 || summary["number"] != 7 {
|
||||
t.Fatalf("summary = %#v", summary)
|
||||
}
|
||||
replayed := sanitizeReplayMap(input)
|
||||
if _, ok := replayed["token"]; ok {
|
||||
t.Fatal("sensitive replay field retained")
|
||||
}
|
||||
for _, value := range []any{
|
||||
map[string]any{"safe": "x", "token": "drop"},
|
||||
map[string]string{"safe": "x", "token": "drop"},
|
||||
[]any{map[string]any{"safe": "x"}},
|
||||
[]string{"a"},
|
||||
[]map[string]any{{"safe": "x"}},
|
||||
42,
|
||||
} {
|
||||
if sanitizeReplayValue(value) == nil {
|
||||
t.Fatalf("sanitizeReplayValue(%T) returned nil", value)
|
||||
}
|
||||
}
|
||||
if sanitizeReplayMap(nil) != nil || sanitizeReplayStringMap(nil) != nil {
|
||||
t.Fatal("empty replay maps should be nil")
|
||||
}
|
||||
if got := sanitizeReplayField("data", map[string]string{"safe": "x"}); got == nil {
|
||||
t.Fatal("string map sanitization failed")
|
||||
}
|
||||
if got := sanitizeReplayField("data", []map[string]any{{"safe": "x"}}); got == nil {
|
||||
t.Fatal("map slice sanitization failed")
|
||||
}
|
||||
if got := sanitizeReplayField("data", strings.Repeat("x", 121)); got == nil {
|
||||
t.Fatal("long string sanitization failed")
|
||||
}
|
||||
|
||||
argv := sanitizeArgv([]string{
|
||||
"dws", "--token=one", "--auth-code=two", "--refresh-token=three",
|
||||
"--auth-code", "four", "--refresh-token", "five", "--plain",
|
||||
})
|
||||
if len(argv) != 9 || strings.Contains(strings.Join(argv, " "), "one") {
|
||||
t.Fatalf("sanitized argv = %#v", argv)
|
||||
}
|
||||
if sanitizeArgv(nil) != nil {
|
||||
t.Fatal("nil argv should remain nil")
|
||||
}
|
||||
if value, ok := redactSensitiveArg("--plain=x"); ok || value != "" {
|
||||
t.Fatalf("plain arg redacted: %q %v", value, ok)
|
||||
}
|
||||
|
||||
if keys := sortedKeys(map[string]any{"b": 1, "a": 2}); strings.Join(keys, ",") != "a,b" {
|
||||
t.Fatalf("sorted keys = %#v", keys)
|
||||
}
|
||||
if keys := sortedStringKeys(map[string]string{"b": "1", "a": "2"}); strings.Join(keys, ",") != "a,b" {
|
||||
t.Fatalf("sorted string keys = %#v", keys)
|
||||
}
|
||||
normalized := normalizeRawError(" HTTPS://example.test/x?q=secret 123456 2026-07-13T01:02:03Z abcdefghijklmnopqrstuvwxyz ")
|
||||
if strings.Contains(normalized, "secret") || strings.Contains(normalized, "123456") {
|
||||
t.Fatalf("normalizeRawError = %q", normalized)
|
||||
}
|
||||
|
||||
ctx := BuildContext(CaptureInput{ToolName: "get_x"})
|
||||
if ctx.OperationKind != OperationRead || ctx.RawError != "" {
|
||||
t.Fatalf("BuildContext defaults = %#v", ctx)
|
||||
}
|
||||
replay := BuildReplay(CaptureInput{ToolName: "create_x"})
|
||||
if replay.OperationKind != OperationWrite || replay.RedactedCommand != "" {
|
||||
t.Fatalf("BuildReplay defaults = %#v", replay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePlannerRuleAndSearchEdges(t *testing.T) {
|
||||
rules := []struct {
|
||||
name string
|
||||
rc RecoveryContext
|
||||
category string
|
||||
retry bool
|
||||
}{
|
||||
{"input", RecoveryContext{CLIErrorCode: cliInvalidJSONCode}, "input", false},
|
||||
{"resource suffix", RecoveryContext{CLIErrorCode: "THING_NOT_FOUND"}, "resource", false},
|
||||
{"permission", RecoveryContext{HTTPStatus: 403}, "permission", false},
|
||||
{"rate read", RecoveryContext{HTTPStatus: 429, OperationKind: OperationRead}, "rate_limit", true},
|
||||
{"rate write http", RecoveryContext{HTTPStatus: 429, OperationKind: OperationWrite, CallStage: "http"}, "rate_limit", true},
|
||||
{"rate write non-http", RecoveryContext{HTTPStatus: 429, OperationKind: OperationWrite, CallStage: "decode"}, "rate_limit", false},
|
||||
{"network read", RecoveryContext{HTTPStatus: 503, OperationKind: OperationRead}, "network", true},
|
||||
{"network write", RecoveryContext{RawError: "timeout", OperationKind: OperationWrite, CallStage: "http"}, "network", false},
|
||||
}
|
||||
for _, tt := range rules {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plan := NewPlanner(nil).PlanWithOptions(context.Background(), tt.rc, PlanOptions{EnableDocSearch: false})
|
||||
if plan.Category != tt.category || plan.ShouldRetry != tt.retry {
|
||||
t.Fatalf("plan = %#v", plan)
|
||||
}
|
||||
})
|
||||
}
|
||||
if safeToRetry(RecoveryContext{OperationKind: OperationUnknown}, "network") {
|
||||
t.Fatal("unknown operation should not retry")
|
||||
}
|
||||
if normalizeDecisionOwner(RecoveryPlan{DecisionOwner: DecisionOwnerAgent}) != DecisionOwnerAgent ||
|
||||
normalizeDecisionOwner(RecoveryPlan{Category: "unknown"}) != DecisionOwnerAgent ||
|
||||
normalizeDecisionOwner(RecoveryPlan{Category: "auth"}) != DecisionOwnerBuiltinRule {
|
||||
t.Fatal("decision owner normalization failed")
|
||||
}
|
||||
|
||||
queryRC := RecoveryContext{CLIErrorCode: "CODE", ToolName: "get_x", CommandPath: []string{"product", "get"}, RawError: "Error alpha alpha beta 123456"}
|
||||
if query := BuildFallbackQuery(queryRC); !strings.Contains(query, "CODE") || !strings.Contains(query, "alpha") {
|
||||
t.Fatalf("fallback query = %q", query)
|
||||
}
|
||||
if got := stableKeywords(""); got != nil {
|
||||
t.Fatalf("empty stable keywords = %#v", got)
|
||||
}
|
||||
many := stableKeywords("one two three four five six seven eight")
|
||||
if len(many) != 6 {
|
||||
t.Fatalf("stable keyword cap = %#v", many)
|
||||
}
|
||||
if containsAny("Hello WORLD", "none", "world") != true || containsAny("hello", "x") {
|
||||
t.Fatal("containsAny failed")
|
||||
}
|
||||
if got := normalizeSafeActions([]string{"wait_and_retry", "unsafe", "wait_and_retry"}); len(got) != 1 {
|
||||
t.Fatalf("safe actions = %#v", got)
|
||||
}
|
||||
|
||||
hit := KBHit{
|
||||
Source: "docs", Title: "errors", URL: "https://docs",
|
||||
Snippet: "| httpCode | code | message | reason | action |\n| --- | --- | --- | --- | --- |\n| 403 | Forbidden | denied | permission | Request access |\n| 404 | Missing | gone | deleted | %s |\n| bad | short | row |",
|
||||
}
|
||||
retrieval := KnowledgeRetrieval{
|
||||
KBHits: []KBHit{hit, hit},
|
||||
DocSearch: DocSearch{
|
||||
Request: &ToolCallRecord{ServerID: "docs", ToolName: "search", Arguments: map[string]any{"q": "x"}},
|
||||
Response: &ToolResponse{Content: []ToolResponseBlock{{Type: "text", Text: "ok"}}},
|
||||
Items: []DocSearchItem{{Title: "one"}},
|
||||
},
|
||||
}
|
||||
plan := NewPlanner(fakeRetriever{retrieval: retrieval}).Plan(context.Background(), RecoveryContext{RawError: "unknown issue"})
|
||||
if plan.DocSearch.Status != "success" || len(plan.KBHits) != 1 || len(plan.DocActions) != 1 {
|
||||
t.Fatalf("retrieved plan = %#v", plan)
|
||||
}
|
||||
if !plan.DecisionHints.PermissionSensitive || !plan.DecisionHints.ResourceStateRelated {
|
||||
t.Fatalf("decision hints = %#v", plan.DecisionHints)
|
||||
}
|
||||
|
||||
emptyPlan := NewPlanner(fakeRetriever{}).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
|
||||
if emptyPlan.DocSearch.Status != "empty" {
|
||||
t.Fatalf("empty search = %#v", emptyPlan.DocSearch)
|
||||
}
|
||||
errPlan := NewPlanner(fakeRetriever{err: errors.New("docs down")}).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
|
||||
if errPlan.DocSearch.Status != "error" || errPlan.DocSearch.Error != "docs down" {
|
||||
t.Fatalf("error search = %#v", errPlan.DocSearch)
|
||||
}
|
||||
customErr := NewPlanner(fakeRetriever{
|
||||
retrieval: KnowledgeRetrieval{DocSearch: DocSearch{Status: "error", Error: "custom"}},
|
||||
err: errors.New("docs down"),
|
||||
}).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
|
||||
if customErr.DocSearch.Error != "custom" {
|
||||
t.Fatalf("custom search error = %#v", customErr.DocSearch)
|
||||
}
|
||||
emptyErr := NewPlanner(fakeRetriever{
|
||||
retrieval: KnowledgeRetrieval{DocSearch: DocSearch{Status: "error"}},
|
||||
}).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
|
||||
if emptyErr.DocSearch.Status != "error" {
|
||||
t.Fatalf("empty search error = %#v", emptyErr.DocSearch)
|
||||
}
|
||||
skipped := NewPlanner(nil).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
|
||||
if skipped.DocSearch.Status != "skipped" {
|
||||
t.Fatalf("nil retriever = %#v", skipped.DocSearch)
|
||||
}
|
||||
var nilPlanner *Planner
|
||||
if got := nilPlanner.searchKnowledge(context.Background(), "query", RecoveryContext{}); got.DocSearch.Status != "skipped" {
|
||||
t.Fatalf("nil planner search = %#v", got)
|
||||
}
|
||||
if got := NewPlanner(nil).searchKnowledge(context.Background(), "", RecoveryContext{}); got.DocSearch.Status != "skipped" {
|
||||
t.Fatalf("empty query search = %#v", got)
|
||||
}
|
||||
|
||||
actions := extractDocActions([]KBHit{{Snippet: ""}, hit})
|
||||
if len(actions) != 1 || len(splitTableRow("| a | b |")) != 2 {
|
||||
t.Fatalf("doc actions = %#v", actions)
|
||||
}
|
||||
if len(dedupeDocActions(append(actions, actions...))) != 1 ||
|
||||
len(dedupeKBHits([]KBHit{hit, hit})) != 1 {
|
||||
t.Fatal("dedupe failed")
|
||||
}
|
||||
if cloneDocActions(nil) != nil || cloneKBHits(nil) != nil {
|
||||
t.Fatal("nil clone should remain nil")
|
||||
}
|
||||
if got := cloneDocSearch(retrieval.DocSearch); got.Request == retrieval.DocSearch.Request || got.Response == retrieval.DocSearch.Response {
|
||||
t.Fatal("doc search clone retained pointers")
|
||||
}
|
||||
if got := stableKeywords("a ok https://example.test/path?q=x valid"); len(got) != 1 || got[0] != "valid" {
|
||||
t.Fatalf("placeholder/small keyword filtering = %#v", got)
|
||||
}
|
||||
if got := uniqueStrings([]string{" ", "one", "one"}); len(got) != 1 {
|
||||
t.Fatalf("unique strings = %#v", got)
|
||||
}
|
||||
if got := extractDocActions([]KBHit{{Snippet: "ordinary text\n| 1 | 2 | 3 | 4 | act |"}}); len(got) != 1 {
|
||||
t.Fatalf("non-table doc line handling = %#v", got)
|
||||
}
|
||||
var nilPlan *RecoveryPlan
|
||||
HydratePlanForEvent("evt", RecoveryContext{}, Replay{}, nilPlan)
|
||||
}
|
||||
|
||||
type fakeRecoveryInvoker struct {
|
||||
result *transport.ToolCallResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeRecoveryInvoker) CallToolDirect(context.Context, string, string, map[string]any) (*transport.ToolCallResult, error) {
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecutorAndProbeEdges(t *testing.T) {
|
||||
last := LastError{
|
||||
EventID: "evt-1",
|
||||
Context: RecoveryContext{
|
||||
CommandPath: []string{"aitable", "base", "get"},
|
||||
ServerID: "aitable",
|
||||
ToolName: "get_base",
|
||||
OperationKind: OperationRead,
|
||||
ArgsSummary: map[string]any{"baseId": "base_1"},
|
||||
RawError: "unknown",
|
||||
},
|
||||
Replay: Replay{
|
||||
ServerID: "aitable",
|
||||
ToolName: "get_base",
|
||||
OperationKind: OperationRead,
|
||||
ToolArgs: map[string]any{"baseId": "base_1"},
|
||||
RedactedCommand: "dws aitable base get",
|
||||
},
|
||||
}
|
||||
if got := (*Executor)(nil).Execute(context.Background(), last); got.Status != "analysis_failed" {
|
||||
t.Fatalf("nil executor = %#v", got)
|
||||
}
|
||||
if got := (&Executor{}).Execute(context.Background(), last); got.Status != "analysis_failed" {
|
||||
t.Fatalf("nil planner executor = %#v", got)
|
||||
}
|
||||
|
||||
result := &transport.ToolCallResult{Blocks: []transport.ContentBlock{{Type: "text", Text: "{\"bases\":[1]}"}}}
|
||||
executor := NewExecutor(NewPlanner(fakeRetriever{}), fakeRecoveryInvoker{result: result})
|
||||
bundle := executor.Execute(context.Background(), last)
|
||||
if bundle.Status != "needs_agent_action" || len(bundle.ProbeResults) != 2 ||
|
||||
bundle.Plan.AgentRoute.Payload == nil || len(bundle.Plan.AgentRoute.Payload.ProbeResults) != 2 {
|
||||
t.Fatalf("successful bundle = %#v", bundle)
|
||||
}
|
||||
if bundle.FinalizeHint.Command == "" || len(bundle.AgentTask.MustReadRefs) != 4 {
|
||||
t.Fatalf("agent handoff metadata = %#v %#v", bundle.FinalizeHint, bundle.AgentTask)
|
||||
}
|
||||
|
||||
failing := NewExecutor(NewPlanner(fakeRetriever{err: errors.New("docs")}), fakeRecoveryInvoker{err: errors.New("probe")})
|
||||
failedBundle := failing.Execute(context.Background(), LastError{EventID: "evt-2", Context: last.Context})
|
||||
if failedBundle.Status != "analysis_failed" ||
|
||||
!strings.Contains(failedBundle.AnalysisError, "replay data missing") ||
|
||||
!strings.Contains(failedBundle.AnalysisError, "doc search failed") {
|
||||
t.Fatalf("failed bundle = %#v", failedBundle)
|
||||
}
|
||||
|
||||
executor.probes = nil
|
||||
if got := executor.runProbes(context.Background(), last.Context, last.Replay, RecoveryPlan{}); got != nil {
|
||||
t.Fatalf("empty probes = %#v", got)
|
||||
}
|
||||
executor.probes = []Probe{
|
||||
func(context.Context, ToolInvoker, RecoveryContext, Replay, RecoveryPlan) *ProbeResult { return nil },
|
||||
func(context.Context, ToolInvoker, RecoveryContext, Replay, RecoveryPlan) *ProbeResult {
|
||||
return &ProbeResult{Name: "custom", Status: "success"}
|
||||
},
|
||||
}
|
||||
if got := executor.runProbes(context.Background(), last.Context, last.Replay, RecoveryPlan{}); len(got) != 1 {
|
||||
t.Fatalf("custom probes = %#v", got)
|
||||
}
|
||||
|
||||
if got := probeUnknownContextAudit(context.Background(), nil, RecoveryContext{}, Replay{}, RecoveryPlan{Category: "auth"}); got != nil {
|
||||
t.Fatalf("built-in plan audit = %#v", got)
|
||||
}
|
||||
audit := probeUnknownContextAudit(context.Background(), nil, RecoveryContext{}, Replay{}, RecoveryPlan{Category: "unknown"})
|
||||
if audit == nil || !strings.Contains(audit.Summary, "no identifier") {
|
||||
t.Fatalf("empty audit = %#v", audit)
|
||||
}
|
||||
fullAudit := probeUnknownContextAudit(
|
||||
context.Background(),
|
||||
nil,
|
||||
RecoveryContext{
|
||||
CommandPath: []string{"x"},
|
||||
ArgsSummary: map[string]any{"empty_id": "", "name": "n", "record_id": "r1"},
|
||||
},
|
||||
Replay{ToolArgs: map[string]any{"uuid": "u1"}, RedactedCommand: "dws x"},
|
||||
RecoveryPlan{
|
||||
Category: "unknown",
|
||||
DocSearch: DocSearch{Status: "success"},
|
||||
KBHits: []KBHit{{Title: "hit"}},
|
||||
DocActions: []DocAction{{Action: "act"}},
|
||||
},
|
||||
)
|
||||
if fullAudit == nil || !strings.Contains(fullAudit.Summary, "found 2") {
|
||||
t.Fatalf("full audit = %#v", fullAudit)
|
||||
}
|
||||
if got := probeAITableBaseCatalog(context.Background(), nil, RecoveryContext{}, Replay{}, RecoveryPlan{}); got != nil {
|
||||
t.Fatalf("unrelated probe = %#v", got)
|
||||
}
|
||||
if got := probeAITableBaseCatalog(context.Background(), nil, RecoveryContext{CommandPath: []string{"aitable", "base", "list"}}, Replay{}, RecoveryPlan{}); got.Status != "skipped" {
|
||||
t.Fatalf("unsupported aitable probe = %#v", got)
|
||||
}
|
||||
if got := probeAITableBaseCatalog(context.Background(), nil, last.Context, last.Replay, RecoveryPlan{}); got.Status != "skipped" {
|
||||
t.Fatalf("nil invoker probe = %#v", got)
|
||||
}
|
||||
|
||||
outputs := []struct {
|
||||
result *transport.ToolCallResult
|
||||
summary string
|
||||
}{
|
||||
{nil, "no output"},
|
||||
{&transport.ToolCallResult{Blocks: []transport.ContentBlock{{Type: "image"}, {Type: "text", Text: " plain "}}}, "text payload"},
|
||||
{&transport.ToolCallResult{Content: map[string]any{"x": 1}}, "content payload"},
|
||||
{&transport.ToolCallResult{StructuredContent: map[string]any{"x": 1}}, "non-text payload"},
|
||||
}
|
||||
for _, tt := range outputs {
|
||||
_, summary := summarizeProbeOutput(tt.result)
|
||||
if !strings.Contains(summary, tt.summary) {
|
||||
t.Errorf("summarizeProbeOutput = %q, want %q", summary, tt.summary)
|
||||
}
|
||||
}
|
||||
if len(buildMustReadRefs(nil)) != 3 || productReferenceFor("unknown") != "" {
|
||||
t.Fatal("must-read references failed")
|
||||
}
|
||||
for _, product := range []string{"aitable", "attendance", "calendar", "chat", "contact", "devdoc", "ding", "report", "todo", "workbench", "approval"} {
|
||||
if productReferenceFor(product) == "" {
|
||||
t.Errorf("missing product reference for %q", product)
|
||||
}
|
||||
}
|
||||
if cloneProbeResults(nil) != nil {
|
||||
t.Fatal("nil probe clone should remain nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStoreRuntimeAndPruningEdges(t *testing.T) {
|
||||
ResetRuntimeState()
|
||||
if LatestCapture() != nil {
|
||||
t.Fatal("latest capture should start nil")
|
||||
}
|
||||
setLatestCapture(nil)
|
||||
last := &LastError{
|
||||
EventID: "evt",
|
||||
Context: RecoveryContext{
|
||||
CommandPath: []string{"x"},
|
||||
ArgsSummary: map[string]any{"nested": map[string]any{"x": 1}},
|
||||
},
|
||||
Replay: Replay{
|
||||
ToolArgs: map[string]any{"slice": []any{map[string]any{"x": 1}}, "strings": []string{"x"}},
|
||||
RedactedArgv: []string{"dws"},
|
||||
},
|
||||
}
|
||||
setLatestCapture(last)
|
||||
got := LatestCapture()
|
||||
if got == last || got.Context.ArgsSummary == nil || got.Replay.ToolArgs == nil {
|
||||
t.Fatalf("LatestCapture clone = %#v", got)
|
||||
}
|
||||
ResetRuntimeState()
|
||||
if cloneMap(nil) != nil || cloneSlice(nil) != nil {
|
||||
t.Fatal("nil clones should remain nil")
|
||||
}
|
||||
if cloneValue(7) != 7 {
|
||||
t.Fatal("scalar clone changed")
|
||||
}
|
||||
if !isEmptyReplay(Replay{}) || isEmptyReplay(Replay{ToolName: "x"}) {
|
||||
t.Fatal("empty replay detection failed")
|
||||
}
|
||||
|
||||
var disabled *Store
|
||||
if disabled.Enabled() || NewStore("").Enabled() {
|
||||
t.Fatal("disabled store reported enabled")
|
||||
}
|
||||
if captured, err := disabled.Capture(RecoveryContext{}); err != nil || captured != nil {
|
||||
t.Fatalf("disabled capture = %#v, %v", captured, err)
|
||||
}
|
||||
for name, call := range map[string]func() error{
|
||||
"load": func() error { _, err := disabled.LoadLastError(); return err },
|
||||
"event": func() error { _, err := disabled.LoadErrorByEvent("evt"); return err },
|
||||
"ensure": disabled.ensureDir,
|
||||
} {
|
||||
if err := call(); err == nil {
|
||||
t.Errorf("disabled %s unexpectedly succeeded", name)
|
||||
}
|
||||
}
|
||||
if err := disabled.SavePlan("evt", RecoveryPlan{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := disabled.SaveAnalysis("evt", RecoveryPlan{}, RecoveryBundle{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := disabled.Finalize("evt", "failed", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := disabled.pruneExpiredArtifacts(time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
if _, err := store.LoadErrorByEvent(""); err == nil {
|
||||
t.Fatal("empty event id succeeded")
|
||||
}
|
||||
if _, err := store.LoadErrorByEvent("missing"); err == nil {
|
||||
t.Fatal("missing event id succeeded")
|
||||
}
|
||||
if _, err := store.LoadLastError(); err == nil {
|
||||
t.Fatal("missing last error succeeded")
|
||||
}
|
||||
badLast := store.lastErrorPath()
|
||||
if err := os.MkdirAll(store.dir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(badLast, []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.LoadLastError(); err == nil {
|
||||
t.Fatal("malformed last error succeeded")
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().Add(-recoveryMaxAge)
|
||||
old := cutoff.Add(-time.Hour).Format(time.RFC3339Nano)
|
||||
recent := cutoff.Add(time.Hour).Format(time.RFC3339Nano)
|
||||
if _, ok := parseRecordedAt(""); ok {
|
||||
t.Fatal("empty timestamp parsed")
|
||||
}
|
||||
if _, ok := parseRecordedAt("invalid"); ok {
|
||||
t.Fatal("invalid timestamp parsed")
|
||||
}
|
||||
if _, ok := parseRecordedAt(time.Now().UTC().Format(time.RFC3339)); !ok {
|
||||
t.Fatal("RFC3339 timestamp did not parse")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(badLast, []byte("{\"recorded_at\":\""+old+"\"}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.pruneLastError(cutoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(badLast); !os.IsNotExist(err) {
|
||||
t.Fatalf("old last error remains: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(badLast, []byte("{\"recorded_at\":\""+recent+"\"}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.pruneLastError(cutoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
events := strings.Join([]string{
|
||||
"",
|
||||
"{",
|
||||
"{\"event_id\":\"old\",\"recorded_at\":\"" + old + "\"}",
|
||||
"{\"event_id\":\"recent\",\"recorded_at\":\"" + recent + "\"}",
|
||||
}, "\n") + "\n"
|
||||
if err := os.WriteFile(store.eventsPath(), []byte(events), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.pruneEvents(cutoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(store.eventsPath())
|
||||
if err != nil || strings.Contains(string(data), "old") || !strings.Contains(string(data), "recent") {
|
||||
t.Fatalf("pruned events = %q, %v", data, err)
|
||||
}
|
||||
if err := os.WriteFile(store.eventsPath(), []byte("{\"event_id\":\"old\",\"recorded_at\":\""+old+"\"}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.pruneEvents(cutoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(store.eventsPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("empty events file remains: %v", err)
|
||||
}
|
||||
|
||||
oldArtifact := filepath.Join(store.dir(), "old-artifact")
|
||||
recentArtifact := filepath.Join(store.dir(), "recent-artifact")
|
||||
if err := os.MkdirAll(oldArtifact, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(recentArtifact, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldTime := cutoff.Add(-time.Hour)
|
||||
if err := os.Chtimes(oldArtifact, oldTime, oldTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.pruneOtherArtifacts(cutoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(oldArtifact); !os.IsNotExist(err) {
|
||||
t.Fatalf("old artifact remains: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(recentArtifact); err != nil {
|
||||
t.Fatalf("recent artifact removed: %v", err)
|
||||
}
|
||||
|
||||
emptyReplay, err := store.Capture(RecoveryContext{RawError: "x"})
|
||||
if err != nil || emptyReplay == nil {
|
||||
t.Fatalf("empty replay capture = %#v, %v", emptyReplay, err)
|
||||
}
|
||||
if _, err := store.LoadErrorByEvent(emptyReplay.EventID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStoreMarshalErrors(t *testing.T) {
|
||||
store := NewStore(t.TempDir())
|
||||
if err := store.writeJSON(filepath.Join(store.dir(), "bad.json"), map[string]any{"bad": func() {}}); err == nil {
|
||||
t.Fatal("expected writeJSON marshal error")
|
||||
}
|
||||
event := RecoveryEvent{
|
||||
EventID: "evt",
|
||||
Bundle: &RecoveryBundle{
|
||||
ProbeResults: []ProbeResult{{Output: func() {}}},
|
||||
},
|
||||
}
|
||||
if err := store.appendEvent(event); err == nil {
|
||||
t.Fatal("expected appendEvent marshal error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLoadErrorByEventFallback(t *testing.T) {
|
||||
store := NewStore(t.TempDir())
|
||||
last := LastError{EventID: "fallback", RecordedAt: time.Now().UTC().Format(time.RFC3339Nano)}
|
||||
data, err := json.Marshal(last)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(store.dir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(store.lastErrorPath(), data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := store.LoadErrorByEvent("fallback"); err != nil || got.EventID != "fallback" {
|
||||
t.Fatalf("fallback event = %#v, %v", got, err)
|
||||
}
|
||||
otherEvent := RecoveryEvent{
|
||||
EventID: "other",
|
||||
Phase: "planned",
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
eventData, err := json.Marshal(otherEvent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(store.eventsPath(), append(eventData, '\n'), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := store.LoadErrorByEvent("fallback"); err != nil || got.EventID != "fallback" {
|
||||
t.Fatalf("EOF fallback event = %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStoreSystemCallEdges(t *testing.T) {
|
||||
oldReadFile := recoveryReadFile
|
||||
oldWriteFile := recoveryWriteFile
|
||||
oldOpen := recoveryOpen
|
||||
oldOpenFile := recoveryOpenFile
|
||||
oldMkdirAll := recoveryMkdirAll
|
||||
oldReadDir := recoveryReadDir
|
||||
oldStat := recoveryStat
|
||||
oldRemove := recoveryRemove
|
||||
oldRemoveAll := recoveryRemoveAll
|
||||
oldFileWrite := recoveryFileWrite
|
||||
t.Cleanup(func() {
|
||||
recoveryReadFile = oldReadFile
|
||||
recoveryWriteFile = oldWriteFile
|
||||
recoveryOpen = oldOpen
|
||||
recoveryOpenFile = oldOpenFile
|
||||
recoveryMkdirAll = oldMkdirAll
|
||||
recoveryReadDir = oldReadDir
|
||||
recoveryStat = oldStat
|
||||
recoveryRemove = oldRemove
|
||||
recoveryRemoveAll = oldRemoveAll
|
||||
recoveryFileWrite = oldFileWrite
|
||||
})
|
||||
reset := func() {
|
||||
recoveryReadFile = oldReadFile
|
||||
recoveryWriteFile = oldWriteFile
|
||||
recoveryOpen = oldOpen
|
||||
recoveryOpenFile = oldOpenFile
|
||||
recoveryMkdirAll = oldMkdirAll
|
||||
recoveryReadDir = oldReadDir
|
||||
recoveryStat = oldStat
|
||||
recoveryRemove = oldRemove
|
||||
recoveryRemoveAll = oldRemoveAll
|
||||
recoveryFileWrite = oldFileWrite
|
||||
}
|
||||
|
||||
store := NewStore(t.TempDir())
|
||||
recoveryWriteFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
|
||||
if _, err := store.Capture(RecoveryContext{}); err == nil {
|
||||
t.Fatal("expected capture snapshot write error")
|
||||
}
|
||||
reset()
|
||||
|
||||
recoveryOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, errors.New("open event") }
|
||||
if _, err := store.Capture(RecoveryContext{}); err == nil {
|
||||
t.Fatal("expected capture event append error")
|
||||
}
|
||||
reset()
|
||||
|
||||
recoveryReadFile = func(string) ([]byte, error) { return nil, errors.New("prune") }
|
||||
if _, err := store.LoadLastError(); err == nil {
|
||||
t.Fatal("expected LoadLastError prune error")
|
||||
}
|
||||
if _, err := store.LoadErrorByEvent("evt"); err == nil {
|
||||
t.Fatal("expected LoadErrorByEvent prune error")
|
||||
}
|
||||
if err := store.pruneExpiredArtifacts(time.Now()); err == nil {
|
||||
t.Fatal("expected last-error prune failure")
|
||||
}
|
||||
reset()
|
||||
|
||||
recoveryReadFile = func(path string) ([]byte, error) {
|
||||
if path == store.lastErrorPath() {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
if path == store.eventsPath() {
|
||||
return nil, errors.New("events")
|
||||
}
|
||||
return oldReadFile(path)
|
||||
}
|
||||
if err := store.pruneExpiredArtifacts(time.Now()); err == nil {
|
||||
t.Fatal("expected event prune failure")
|
||||
}
|
||||
reset()
|
||||
|
||||
recoveryOpen = func(string) (*os.File, error) { return nil, errors.New("open") }
|
||||
if _, err := store.LoadErrorByEvent("evt"); err == nil {
|
||||
t.Fatal("expected event open error")
|
||||
}
|
||||
reset()
|
||||
|
||||
if err := os.MkdirAll(store.dir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(store.eventsPath(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recoveryReadFile = func(path string) ([]byte, error) {
|
||||
if path == store.lastErrorPath() || path == store.eventsPath() {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return oldReadFile(path)
|
||||
}
|
||||
if _, err := store.LoadErrorByEvent("evt"); err == nil || os.IsNotExist(err) {
|
||||
t.Fatalf("expected non-EOF reader error, got %v", err)
|
||||
}
|
||||
if err := os.RemoveAll(store.eventsPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reset()
|
||||
|
||||
recoveryMkdirAll = func(string, os.FileMode) error { return errors.New("mkdir") }
|
||||
if err := store.writeJSON(store.lastErrorPath(), map[string]any{"x": 1}); err == nil {
|
||||
t.Fatal("expected writeJSON ensure error")
|
||||
}
|
||||
if err := store.appendEvent(RecoveryEvent{}); err == nil {
|
||||
t.Fatal("expected appendEvent ensure error")
|
||||
}
|
||||
if err := store.ensureDir(); err == nil {
|
||||
t.Fatal("expected ensureDir mkdir error")
|
||||
}
|
||||
reset()
|
||||
|
||||
recoveryOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, errors.New("open file") }
|
||||
if err := store.appendEvent(RecoveryEvent{}); err == nil {
|
||||
t.Fatal("expected appendEvent open error")
|
||||
}
|
||||
reset()
|
||||
|
||||
recoveryFileWrite = func(*os.File, []byte) (int, error) { return 0, errors.New("write event") }
|
||||
if err := store.appendEvent(RecoveryEvent{}); err == nil {
|
||||
t.Fatal("expected appendEvent write error")
|
||||
}
|
||||
reset()
|
||||
|
||||
cutoff := time.Now().UTC().Add(-recoveryMaxAge)
|
||||
if err := os.MkdirAll(store.dir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(store.lastErrorPath(), []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldTime := cutoff.Add(-time.Hour)
|
||||
if err := os.Chtimes(store.lastErrorPath(), oldTime, oldTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.pruneLastError(cutoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reset()
|
||||
|
||||
if err := os.WriteFile(store.lastErrorPath(), []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recoveryStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||
if err := store.pruneLastError(cutoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recoveryStat = func(string) (os.FileInfo, error) { return nil, errors.New("stat") }
|
||||
if err := store.pruneLastError(cutoff); err == nil {
|
||||
t.Fatal("expected last-error stat error")
|
||||
}
|
||||
reset()
|
||||
|
||||
recoveryReadDir = func(string) ([]os.DirEntry, error) { return nil, errors.New("read dir") }
|
||||
if err := store.pruneOtherArtifacts(cutoff); err == nil {
|
||||
t.Fatal("expected artifact read error")
|
||||
}
|
||||
reset()
|
||||
|
||||
artifact := filepath.Join(store.dir(), "artifact")
|
||||
if err := os.WriteFile(artifact, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recoveryStat = func(path string) (os.FileInfo, error) {
|
||||
if path == artifact {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return oldStat(path)
|
||||
}
|
||||
if err := store.pruneOtherArtifacts(cutoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recoveryStat = func(path string) (os.FileInfo, error) {
|
||||
if path == artifact {
|
||||
return nil, errors.New("stat")
|
||||
}
|
||||
return oldStat(path)
|
||||
}
|
||||
if err := store.pruneOtherArtifacts(cutoff); err == nil {
|
||||
t.Fatal("expected artifact stat error")
|
||||
}
|
||||
reset()
|
||||
|
||||
if err := os.Chtimes(artifact, oldTime, oldTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recoveryRemoveAll = func(string) error { return errors.New("remove") }
|
||||
if err := store.pruneOtherArtifacts(cutoff); err == nil {
|
||||
t.Fatal("expected artifact removal error")
|
||||
}
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
type ToolInvoker interface {
|
||||
CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error)
|
||||
}
|
||||
|
||||
type Probe func(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult
|
||||
|
||||
type Executor struct {
|
||||
planner *Planner
|
||||
invoker ToolInvoker
|
||||
probes []Probe
|
||||
}
|
||||
|
||||
func NewExecutor(planner *Planner, invoker ToolInvoker) *Executor {
|
||||
return &Executor{
|
||||
planner: planner,
|
||||
invoker: invoker,
|
||||
probes: defaultProbes(),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) Execute(ctx context.Context, last LastError) RecoveryBundle {
|
||||
bundle := RecoveryBundle{
|
||||
Status: "needs_agent_action",
|
||||
EventID: last.EventID,
|
||||
Context: cloneRecoveryContext(last.Context),
|
||||
Replay: cloneReplay(last.Replay),
|
||||
}
|
||||
if e == nil || e.planner == nil {
|
||||
bundle.Status = "analysis_failed"
|
||||
bundle.AnalysisError = "recovery executor unavailable"
|
||||
return bundle
|
||||
}
|
||||
|
||||
plan := e.planner.PlanWithOptions(ctx, last.Context, PlanOptions{
|
||||
EventID: last.EventID,
|
||||
EnableDocSearch: true,
|
||||
})
|
||||
HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
|
||||
|
||||
bundle.Plan = plan
|
||||
bundle.DocSearch = cloneDocSearch(plan.DocSearch)
|
||||
bundle.KBHits = cloneKBHits(plan.KBHits)
|
||||
bundle.DocActions = cloneDocActions(plan.DocActions)
|
||||
bundle.HumanActions = nonNilStrings(plan.HumanActions)
|
||||
bundle.ProbeResults = e.runProbes(ctx, last.Context, last.Replay, plan)
|
||||
if bundle.Plan.AgentRoute.Payload != nil {
|
||||
bundle.Plan.AgentRoute.Payload.ProbeResults = cloneProbeResults(bundle.ProbeResults)
|
||||
}
|
||||
bundle.AgentTask = buildAgentTask(last.EventID, last.Context, last.Replay, plan, bundle.ProbeResults)
|
||||
bundle.FinalizeHint = buildFinalizeHint(last.EventID)
|
||||
|
||||
analysisErrors := make([]string, 0, 3)
|
||||
if isEmptyReplay(last.Replay) {
|
||||
analysisErrors = append(analysisErrors, "replay data missing")
|
||||
}
|
||||
if plan.DocSearch.Status == "error" {
|
||||
analysisErrors = append(analysisErrors, "doc search failed")
|
||||
}
|
||||
for _, probe := range bundle.ProbeResults {
|
||||
if probe.Status == "error" {
|
||||
analysisErrors = append(analysisErrors, fmt.Sprintf("probe %s failed", probe.Name))
|
||||
}
|
||||
}
|
||||
if len(analysisErrors) > 0 {
|
||||
bundle.Status = "analysis_failed"
|
||||
bundle.AnalysisError = strings.Join(uniqueStrings(analysisErrors), "; ")
|
||||
}
|
||||
return bundle
|
||||
}
|
||||
|
||||
func (e *Executor) runProbes(ctx context.Context, rc RecoveryContext, replay Replay, plan RecoveryPlan) []ProbeResult {
|
||||
if len(e.probes) == 0 {
|
||||
return nil
|
||||
}
|
||||
results := make([]ProbeResult, 0, len(e.probes))
|
||||
for _, probe := range e.probes {
|
||||
result := probe(ctx, e.invoker, rc, replay, plan)
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, *result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func defaultProbes() []Probe {
|
||||
return []Probe{probeUnknownContextAudit, probeAITableBaseCatalog}
|
||||
}
|
||||
|
||||
func probeUnknownContextAudit(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult {
|
||||
if normalizeDecisionOwner(plan) != DecisionOwnerAgent {
|
||||
return nil
|
||||
}
|
||||
|
||||
sourcesChecked := []string{
|
||||
"recovery_context.command_path",
|
||||
"recovery_context.args_summary",
|
||||
"replay.tool_args",
|
||||
"replay.redacted_command",
|
||||
"plan.doc_search",
|
||||
"plan.kb_hits",
|
||||
"plan.doc_actions",
|
||||
}
|
||||
availableSources := make([]string, 0, len(sourcesChecked))
|
||||
missingSources := make([]string, 0, len(sourcesChecked))
|
||||
candidateIdentifiers := make([]map[string]any, 0)
|
||||
|
||||
appendCandidates := func(source string, values map[string]any) {
|
||||
for key, value := range values {
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
lowerKey := strings.ToLower(key)
|
||||
if !strings.Contains(lowerKey, "id") && !strings.Contains(lowerKey, "uuid") {
|
||||
continue
|
||||
}
|
||||
candidateIdentifiers = append(candidateIdentifiers, map[string]any{
|
||||
"source": source,
|
||||
"field": key,
|
||||
"value": text,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
appendCandidates("context.args_summary", rc.ArgsSummary)
|
||||
appendCandidates("replay.tool_args", replay.ToolArgs)
|
||||
|
||||
if len(rc.CommandPath) > 0 {
|
||||
availableSources = append(availableSources, "recovery_context.command_path")
|
||||
} else {
|
||||
missingSources = append(missingSources, "recovery_context.command_path")
|
||||
}
|
||||
if len(rc.ArgsSummary) > 0 {
|
||||
availableSources = append(availableSources, "recovery_context.args_summary")
|
||||
} else {
|
||||
missingSources = append(missingSources, "recovery_context.args_summary")
|
||||
}
|
||||
if len(replay.ToolArgs) > 0 {
|
||||
availableSources = append(availableSources, "replay.tool_args")
|
||||
} else {
|
||||
missingSources = append(missingSources, "replay.tool_args")
|
||||
}
|
||||
if strings.TrimSpace(replay.RedactedCommand) != "" {
|
||||
availableSources = append(availableSources, "replay.redacted_command")
|
||||
} else {
|
||||
missingSources = append(missingSources, "replay.redacted_command")
|
||||
}
|
||||
if plan.DocSearch.Status != "" && plan.DocSearch.Status != "skipped" {
|
||||
availableSources = append(availableSources, "plan.doc_search")
|
||||
} else {
|
||||
missingSources = append(missingSources, "plan.doc_search")
|
||||
}
|
||||
if len(plan.KBHits) > 0 {
|
||||
availableSources = append(availableSources, "plan.kb_hits")
|
||||
} else {
|
||||
missingSources = append(missingSources, "plan.kb_hits")
|
||||
}
|
||||
if len(plan.DocActions) > 0 {
|
||||
availableSources = append(availableSources, "plan.doc_actions")
|
||||
} else {
|
||||
missingSources = append(missingSources, "plan.doc_actions")
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("audited %d local recovery source(s)", len(sourcesChecked))
|
||||
if len(candidateIdentifiers) == 0 {
|
||||
summary += "; no identifier candidates found"
|
||||
} else {
|
||||
summary += fmt.Sprintf("; found %d identifier candidate(s)", len(candidateIdentifiers))
|
||||
}
|
||||
|
||||
return &ProbeResult{
|
||||
Name: "unknown_context_audit",
|
||||
Status: "success",
|
||||
Summary: summary,
|
||||
Output: map[string]any{
|
||||
"sources_checked": sourcesChecked,
|
||||
"available_sources": availableSources,
|
||||
"missing_sources": missingSources,
|
||||
"candidate_identifiers": candidateIdentifiers,
|
||||
"decision_owner": string(normalizeDecisionOwner(plan)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func probeAITableBaseCatalog(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult {
|
||||
if len(rc.CommandPath) < 2 || rc.CommandPath[0] != "aitable" || rc.CommandPath[1] != "base" {
|
||||
return nil
|
||||
}
|
||||
if len(rc.CommandPath) < 3 || rc.CommandPath[2] != "get" {
|
||||
return &ProbeResult{Name: "aitable_base_catalog_probe", Status: "skipped", Summary: "no registered read-only probe for this aitable command"}
|
||||
}
|
||||
if invoker == nil {
|
||||
return &ProbeResult{Name: "aitable_base_catalog_probe", Status: "skipped", Summary: "tool invoker unavailable"}
|
||||
}
|
||||
result, err := invoker.CallToolDirect(ctx, replay.ServerID, "list_bases", map[string]any{"limit": 5})
|
||||
if err != nil {
|
||||
return &ProbeResult{
|
||||
Name: "aitable_base_catalog_probe",
|
||||
Status: "error",
|
||||
ServerID: replay.ServerID,
|
||||
ToolName: "list_bases",
|
||||
Error: err.Error(),
|
||||
Summary: "failed to run read-only aitable base catalog probe",
|
||||
}
|
||||
}
|
||||
payload, summary := summarizeProbeOutput(result)
|
||||
return &ProbeResult{
|
||||
Name: "aitable_base_catalog_probe",
|
||||
Status: "success",
|
||||
ServerID: replay.ServerID,
|
||||
ToolName: "list_bases",
|
||||
ArgsSummary: SummarizeArgs(map[string]any{"limit": 5}),
|
||||
Summary: summary,
|
||||
Output: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeProbeOutput(result *transport.ToolCallResult) (any, string) {
|
||||
if result == nil {
|
||||
return nil, "probe returned no output"
|
||||
}
|
||||
for _, block := range result.Blocks {
|
||||
if block.Type != "text" || strings.TrimSpace(block.Text) == "" {
|
||||
continue
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(block.Text), &parsed); err == nil {
|
||||
return parsed, "probe returned JSON payload"
|
||||
}
|
||||
return block.Text, "probe returned text payload"
|
||||
}
|
||||
if len(result.Content) > 0 {
|
||||
return result.Content, "probe returned content payload"
|
||||
}
|
||||
return result, "probe returned non-text payload"
|
||||
}
|
||||
|
||||
func buildAgentTask(eventID string, rc RecoveryContext, replay Replay, plan RecoveryPlan, probes []ProbeResult) AgentTask {
|
||||
whyParts := []string{fmt.Sprintf("recovery category=%s", plan.Category)}
|
||||
if len(probes) > 0 {
|
||||
whyParts = append(whyParts, fmt.Sprintf("probe results=%d", len(probes)))
|
||||
}
|
||||
if plan.DocSearch.Status != "" && plan.DocSearch.Status != "skipped" {
|
||||
whyParts = append(whyParts, "doc search results included")
|
||||
}
|
||||
if replay.RedactedCommand != "" {
|
||||
whyParts = append(whyParts, "failed command="+replay.RedactedCommand)
|
||||
}
|
||||
|
||||
return AgentTask{
|
||||
Goal: "阅读完整 RecoveryBundle,并基于 dws skill 对 unknown 恢复场景做整体分析后再决定是否发起 grounded 的下一次 dws 尝试",
|
||||
Why: strings.Join(whyParts, "; "),
|
||||
MustReadRefs: buildMustReadRefs(rc.CommandPath),
|
||||
AllowedActions: []string{
|
||||
"读取完整 recovery bundle、recovery-guide 和对应产品参考文档",
|
||||
"基于 doc_actions、kb_hits、probe_results 与 replay/context 整体判断下一步",
|
||||
"仅在修复依据明确时重新发起新的 dws 命令",
|
||||
},
|
||||
ForbiddenActions: []string{
|
||||
"编造 ID、UUID、token、URL 或其他业务参数",
|
||||
"绕过 dws 直接调用 HTTP API、curl 或浏览器",
|
||||
"未确认前把失败命令替换成另一套业务流程",
|
||||
},
|
||||
StopConditions: []string{
|
||||
"新的 dws 命令已经成功并可回写 recovered",
|
||||
"没有 grounded 修复依据,只能回写 handoff",
|
||||
"继续尝试会要求猜测或扩大副作用边界",
|
||||
},
|
||||
FinalizeRequirement: buildFinalizeHint(eventID).Command,
|
||||
}
|
||||
}
|
||||
|
||||
func buildFinalizeHint(eventID string) FinalizeHint {
|
||||
return FinalizeHint{
|
||||
Required: true,
|
||||
Command: fmt.Sprintf(
|
||||
"dws recovery finalize --event-id %s --outcome <recovered|failed|handoff> --execution-file <file.json> --format json",
|
||||
eventID,
|
||||
),
|
||||
ExecutionFileFields: []string{"actions", "attempts", "result", "error_summary"},
|
||||
AllowedOutcomes: []string{"recovered", "failed", "handoff"},
|
||||
}
|
||||
}
|
||||
|
||||
func buildMustReadRefs(commandPath []string) []string {
|
||||
refs := []string{
|
||||
"skills/references/recovery-guide.md",
|
||||
"skills/references/error-codes.md",
|
||||
"skills/references/global-reference.md",
|
||||
}
|
||||
if len(commandPath) > 0 {
|
||||
if ref := productReferenceFor(commandPath[0]); ref != "" {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func productReferenceFor(commandRoot string) string {
|
||||
refs := map[string]string{
|
||||
"aitable": "skills/references/products/aitable.md",
|
||||
"attendance": "skills/references/products/attendance.md",
|
||||
"calendar": "skills/references/products/calendar.md",
|
||||
"chat": "skills/references/products/chat.md",
|
||||
"contact": "skills/references/products/contact.md",
|
||||
"devdoc": "skills/references/products/simple.md",
|
||||
"ding": "skills/references/products/ding.md",
|
||||
"report": "skills/references/products/report.md",
|
||||
"todo": "skills/references/products/todo.md",
|
||||
"workbench": "skills/references/products/workbench.md",
|
||||
"approval": "skills/references/products/simple.md",
|
||||
}
|
||||
return refs[commandRoot]
|
||||
}
|
||||
|
||||
func cloneProbeResults(results []ProbeResult) []ProbeResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ProbeResult, len(results))
|
||||
for i, result := range results {
|
||||
out[i] = result
|
||||
out[i].ArgsSummary = cloneMap(result.ArgsSummary)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,679 +0,0 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
type OperationKind string
|
||||
|
||||
const (
|
||||
OperationRead OperationKind = "read"
|
||||
OperationWrite OperationKind = "write"
|
||||
OperationUnknown OperationKind = "unknown"
|
||||
)
|
||||
|
||||
type DecisionOwner string
|
||||
|
||||
const (
|
||||
DecisionOwnerBuiltinRule DecisionOwner = "builtin_rule"
|
||||
DecisionOwnerAgent DecisionOwner = "agent"
|
||||
)
|
||||
|
||||
type RecoveryContext struct {
|
||||
CommandPath []string `json:"command_path"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
OperationKind OperationKind `json:"operation_kind"`
|
||||
CLIErrorCode string `json:"cli_error_code,omitempty"`
|
||||
RawError string `json:"raw_error"`
|
||||
CallStage string `json:"call_stage,omitempty"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
RetryAfter string `json:"retry_after,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
ArgsSummary map[string]any `json:"args_summary,omitempty"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
type Replay struct {
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
OperationKind OperationKind `json:"operation_kind"`
|
||||
ToolArgs map[string]any `json:"tool_args,omitempty"`
|
||||
RedactedArgv []string `json:"redacted_argv,omitempty"`
|
||||
RedactedCommand string `json:"redacted_command,omitempty"`
|
||||
}
|
||||
|
||||
type RecoveryPlan struct {
|
||||
Category string `json:"category"`
|
||||
DecisionOwner DecisionOwner `json:"decision_owner,omitempty"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
AutoActions []string `json:"auto_actions"`
|
||||
SafeActions []string `json:"safe_actions"`
|
||||
DocActions []DocAction `json:"doc_actions,omitempty"`
|
||||
DocSearch DocSearch `json:"doc_search"`
|
||||
DecisionHints DecisionHints `json:"decision_hints"`
|
||||
HumanActions []string `json:"human_actions"`
|
||||
Evidence []string `json:"evidence"`
|
||||
KBHits []KBHit `json:"kb_hits"`
|
||||
ShouldRetry bool `json:"should_retry"`
|
||||
ShouldStop bool `json:"should_stop"`
|
||||
RuleHints RuleHints `json:"rule_hints"`
|
||||
AgentRoute AgentRoute `json:"agent_route"`
|
||||
}
|
||||
|
||||
type KBHit struct {
|
||||
Source string `json:"source"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
type DocAction struct {
|
||||
Action string `json:"action"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
SourceTitle string `json:"source_title,omitempty"`
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
}
|
||||
|
||||
type DocSearch struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
Size int `json:"size,omitempty"`
|
||||
CurrentPage int `json:"current_page,omitempty"`
|
||||
TotalCount int `json:"total_count,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Request *ToolCallRecord `json:"request,omitempty"`
|
||||
Response *ToolResponse `json:"response,omitempty"`
|
||||
Items []DocSearchItem `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type DocSearchItem struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCallRecord struct {
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
type ToolResponse struct {
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
Content []ToolResponseBlock `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type ToolResponseBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type RuleHints struct {
|
||||
Category string `json:"category"`
|
||||
DecisionOwner DecisionOwner `json:"decision_owner,omitempty"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
SafeActions []string `json:"safe_actions"`
|
||||
HumanActions []string `json:"human_actions"`
|
||||
Evidence []string `json:"evidence"`
|
||||
ShouldRetry bool `json:"should_retry"`
|
||||
ShouldStop bool `json:"should_stop"`
|
||||
}
|
||||
|
||||
type DecisionHints struct {
|
||||
Retryable bool `json:"retryable"`
|
||||
PermissionSensitive bool `json:"permission_sensitive"`
|
||||
AuthRelated bool `json:"auth_related"`
|
||||
ResourceStateRelated bool `json:"resource_state_related"`
|
||||
}
|
||||
|
||||
type AgentRoute struct {
|
||||
Required bool `json:"required"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Executor string `json:"executor,omitempty"`
|
||||
Reasons []string `json:"reasons"`
|
||||
Payload *AgentRoutePayload `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type AgentRoutePayload struct {
|
||||
EventID string `json:"event_id"`
|
||||
Context RecoveryContext `json:"context"`
|
||||
Replay Replay `json:"replay"`
|
||||
RawError string `json:"raw_error"`
|
||||
Category string `json:"category"`
|
||||
DecisionOwner DecisionOwner `json:"decision_owner,omitempty"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
ShouldRetry bool `json:"should_retry"`
|
||||
ShouldStop bool `json:"should_stop"`
|
||||
SafeActions []string `json:"safe_actions"`
|
||||
DocActions []DocAction `json:"doc_actions,omitempty"`
|
||||
KBHits []KBHit `json:"kb_hits"`
|
||||
DocSearch DocSearch `json:"doc_search"`
|
||||
HumanActions []string `json:"human_actions"`
|
||||
DecisionHints DecisionHints `json:"decision_hints"`
|
||||
Evidence []string `json:"evidence"`
|
||||
RuleHints RuleHints `json:"rule_hints"`
|
||||
ProbeResults []ProbeResult `json:"probe_results,omitempty"`
|
||||
}
|
||||
|
||||
type ProbeResult struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
ArgsSummary map[string]any `json:"args_summary,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Output any `json:"output,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type AgentTask struct {
|
||||
Goal string `json:"goal"`
|
||||
Why string `json:"why"`
|
||||
MustReadRefs []string `json:"must_read_refs,omitempty"`
|
||||
AllowedActions []string `json:"allowed_actions,omitempty"`
|
||||
ForbiddenActions []string `json:"forbidden_actions,omitempty"`
|
||||
StopConditions []string `json:"stop_conditions,omitempty"`
|
||||
FinalizeRequirement string `json:"finalize_requirement,omitempty"`
|
||||
}
|
||||
|
||||
type FinalizeHint struct {
|
||||
Required bool `json:"required"`
|
||||
Command string `json:"command,omitempty"`
|
||||
ExecutionFileFields []string `json:"execution_file_fields,omitempty"`
|
||||
AllowedOutcomes []string `json:"allowed_outcomes,omitempty"`
|
||||
}
|
||||
|
||||
type RecoveryBundle struct {
|
||||
Status string `json:"status"`
|
||||
EventID string `json:"event_id"`
|
||||
Context RecoveryContext `json:"context"`
|
||||
Replay Replay `json:"replay"`
|
||||
Plan RecoveryPlan `json:"plan"`
|
||||
DocSearch DocSearch `json:"doc_search"`
|
||||
KBHits []KBHit `json:"kb_hits,omitempty"`
|
||||
DocActions []DocAction `json:"doc_actions,omitempty"`
|
||||
HumanActions []string `json:"human_actions,omitempty"`
|
||||
ProbeResults []ProbeResult `json:"probe_results,omitempty"`
|
||||
AgentTask AgentTask `json:"agent_task"`
|
||||
FinalizeHint FinalizeHint `json:"finalize_hint"`
|
||||
AnalysisError string `json:"analysis_error,omitempty"`
|
||||
}
|
||||
|
||||
type LastError struct {
|
||||
EventID string `json:"event_id"`
|
||||
RecordedAt string `json:"recorded_at"`
|
||||
Context RecoveryContext `json:"context"`
|
||||
Replay Replay `json:"replay,omitempty"`
|
||||
}
|
||||
|
||||
type RecoveryEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
Phase string `json:"phase"`
|
||||
RecordedAt string `json:"recorded_at"`
|
||||
Context *RecoveryContext `json:"context,omitempty"`
|
||||
Replay *Replay `json:"replay,omitempty"`
|
||||
Plan *RecoveryPlan `json:"plan,omitempty"`
|
||||
Bundle *RecoveryBundle `json:"bundle,omitempty"`
|
||||
Execution *RecoveryExecution `json:"execution,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
}
|
||||
|
||||
type RecoveryAttempt struct {
|
||||
CommandSummary string `json:"command_summary,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
ErrorSummary string `json:"error_summary,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type RecoveryExecution struct {
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
Attempts []RecoveryAttempt `json:"attempts,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
ErrorSummary string `json:"error_summary,omitempty"`
|
||||
}
|
||||
|
||||
type CaptureInput struct {
|
||||
CommandPath []string
|
||||
ServerID string
|
||||
ToolName string
|
||||
OperationKind OperationKind
|
||||
Args map[string]any
|
||||
Argv []string
|
||||
RawErr error
|
||||
WrappedErr error
|
||||
}
|
||||
|
||||
type KnowledgeRetrieval struct {
|
||||
KBHits []KBHit `json:"kb_hits"`
|
||||
DocSearch DocSearch `json:"doc_search"`
|
||||
}
|
||||
|
||||
func BuildContext(input CaptureInput) RecoveryContext {
|
||||
operationKind := input.OperationKind
|
||||
if operationKind == "" || operationKind == OperationUnknown {
|
||||
operationKind = InferOperationKind(input.ToolName)
|
||||
}
|
||||
|
||||
rawError := ""
|
||||
if input.RawErr != nil {
|
||||
rawError = input.RawErr.Error()
|
||||
}
|
||||
|
||||
ctx := RecoveryContext{
|
||||
CommandPath: append([]string(nil), input.CommandPath...),
|
||||
ServerID: input.ServerID,
|
||||
ToolName: input.ToolName,
|
||||
OperationKind: operationKind,
|
||||
RawError: rawError,
|
||||
ArgsSummary: SummarizeArgs(input.Args),
|
||||
}
|
||||
|
||||
if code := canonicalCLIErrorCode(input.WrappedErr, rawError); code != "" {
|
||||
ctx.CLIErrorCode = code
|
||||
}
|
||||
|
||||
var callErr *transport.CallError
|
||||
if stderrors.As(input.RawErr, &callErr) || stderrors.As(input.WrappedErr, &callErr) {
|
||||
ctx.CallStage = string(callErr.Stage)
|
||||
ctx.HTTPStatus = callErr.HTTPStatus
|
||||
ctx.RetryAfter = callErr.RetryAfter
|
||||
ctx.TraceID = callErr.TraceID
|
||||
ctx.RequestID = callErr.RequestID
|
||||
}
|
||||
|
||||
ctx.Fingerprint = ComputeFingerprint(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
func canonicalCLIErrorCode(err error, rawError string) string {
|
||||
var typed *apperrors.Error
|
||||
if stderrors.As(err, &typed) {
|
||||
reason := strings.ToLower(strings.TrimSpace(typed.Reason))
|
||||
message := strings.ToLower(strings.TrimSpace(typed.Message + " " + rawError))
|
||||
switch typed.Category {
|
||||
case apperrors.CategoryValidation:
|
||||
if strings.Contains(message, "required") || strings.Contains(message, "missing") {
|
||||
return cliMissingParamCode
|
||||
}
|
||||
return cliInvalidJSONCode
|
||||
case apperrors.CategoryAuth:
|
||||
if reason == "http_403" || strings.Contains(message, "forbidden") || strings.Contains(message, "permission") || strings.Contains(message, "无权限") {
|
||||
return cliPermissionCode
|
||||
}
|
||||
return cliAuthExpiredCode
|
||||
case apperrors.CategoryAPI, apperrors.CategoryDiscovery:
|
||||
switch {
|
||||
case reason == "http_429" || typed.Retryable && strings.Contains(message, "rate limit"):
|
||||
return cliRateLimitCode
|
||||
case strings.Contains(reason, "timeout") || strings.Contains(message, "timeout") || strings.Contains(message, "deadline exceeded") || strings.Contains(message, "connection refused") || strings.Contains(message, "connection reset"):
|
||||
return cliTimeoutCode
|
||||
case strings.Contains(reason, "invalid_params"):
|
||||
return cliInvalidJSONCode
|
||||
case strings.Contains(reason, "method_not_found"):
|
||||
return cliResourceNotFoundCode
|
||||
case strings.Contains(reason, "http_404"):
|
||||
return cliResourceNotFoundCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalized := strings.ToLower(strings.TrimSpace(rawError))
|
||||
switch {
|
||||
case strings.Contains(normalized, "token验证失败") || strings.Contains(normalized, "user_token_illegal"):
|
||||
return cliAuthExpiredCode
|
||||
case strings.Contains(normalized, "forbidden") || strings.Contains(normalized, "permission") || strings.Contains(normalized, "无权限"):
|
||||
return cliPermissionCode
|
||||
case strings.Contains(normalized, "too many requests") || strings.Contains(normalized, "rate limit"):
|
||||
return cliRateLimitCode
|
||||
case strings.Contains(normalized, "timeout") || strings.Contains(normalized, "deadline exceeded") || strings.Contains(normalized, "connection refused") || strings.Contains(normalized, "connection reset"):
|
||||
return cliTimeoutCode
|
||||
case strings.Contains(normalized, "not found") || strings.Contains(normalized, "资源不存在") || strings.Contains(normalized, "base_not_found"):
|
||||
return cliResourceNotFoundCode
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func BuildReplay(input CaptureInput) Replay {
|
||||
operationKind := input.OperationKind
|
||||
if operationKind == "" || operationKind == OperationUnknown {
|
||||
operationKind = InferOperationKind(input.ToolName)
|
||||
}
|
||||
|
||||
replay := Replay{
|
||||
ServerID: input.ServerID,
|
||||
ToolName: input.ToolName,
|
||||
OperationKind: operationKind,
|
||||
ToolArgs: sanitizeReplayMap(input.Args),
|
||||
}
|
||||
if len(input.Argv) > 0 {
|
||||
replay.RedactedArgv = sanitizeArgv(input.Argv)
|
||||
replay.RedactedCommand = strings.Join(replay.RedactedArgv, " ")
|
||||
}
|
||||
return replay
|
||||
}
|
||||
|
||||
func InferOperationKind(toolName string) OperationKind {
|
||||
toolName = strings.ToLower(strings.TrimSpace(toolName))
|
||||
if toolName == "" {
|
||||
return OperationUnknown
|
||||
}
|
||||
|
||||
readPrefixes := []string{"get_", "list_", "search_", "query_", "status_", "download_"}
|
||||
for _, prefix := range readPrefixes {
|
||||
if strings.HasPrefix(toolName, prefix) {
|
||||
return OperationRead
|
||||
}
|
||||
}
|
||||
|
||||
writePrefixes := []string{
|
||||
"create_", "update_", "delete_", "send_", "approve_", "reject_", "revoke_",
|
||||
"upload_", "import_", "commit_", "add_", "remove_", "modify_", "set_",
|
||||
"assign_", "insert_", "recall_", "batch_send_", "batch_recall_", "save_",
|
||||
"generate", "edit", "upscale", "isolate",
|
||||
}
|
||||
for _, prefix := range writePrefixes {
|
||||
if strings.HasPrefix(toolName, prefix) {
|
||||
return OperationWrite
|
||||
}
|
||||
}
|
||||
return OperationUnknown
|
||||
}
|
||||
|
||||
func SummarizeArgs(args map[string]any) map[string]any {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(args))
|
||||
for key := range args {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
summary := make(map[string]any, len(keys))
|
||||
for _, key := range keys {
|
||||
if isSensitiveKey(key) {
|
||||
continue
|
||||
}
|
||||
summary[key] = summarizeValue(key, args[key])
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func sanitizeReplayMap(args map[string]any) map[string]any {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
if isSensitiveKey(key) {
|
||||
continue
|
||||
}
|
||||
out[key] = sanitizeReplayField(key, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sanitizeReplayField(key string, value any) any {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if isContentKey(lowerKey) {
|
||||
return summarizeValue(lowerKey, value)
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if len(v) > 120 {
|
||||
return summarizeValue(lowerKey, v)
|
||||
}
|
||||
return v
|
||||
case map[string]string:
|
||||
return sanitizeReplayStringMap(v)
|
||||
case []map[string]any:
|
||||
out := make([]map[string]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
out = append(out, sanitizeReplayMap(item))
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return sanitizeReplayValue(v)
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeReplayValue(value any) any {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
return sanitizeReplayMap(v)
|
||||
case map[string]string:
|
||||
return sanitizeReplayStringMap(v)
|
||||
case []any:
|
||||
out := make([]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
out = append(out, sanitizeReplayValue(item))
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
out := make([]string, len(v))
|
||||
copy(out, v)
|
||||
return out
|
||||
case []map[string]any:
|
||||
out := make([]map[string]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
out = append(out, sanitizeReplayMap(item))
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeReplayStringMap(args map[string]string) map[string]any {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
if isSensitiveKey(key) {
|
||||
continue
|
||||
}
|
||||
out[key] = sanitizeReplayField(key, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sanitizeArgv(argv []string) []string {
|
||||
if len(argv) == 0 {
|
||||
return nil
|
||||
}
|
||||
sensitiveFlags := map[string]struct{}{
|
||||
"--token": {},
|
||||
"--auth-code": {},
|
||||
"--refresh-token": {},
|
||||
}
|
||||
out := make([]string, 0, len(argv))
|
||||
skipValue := false
|
||||
for _, arg := range argv {
|
||||
if skipValue {
|
||||
out = append(out, "<redacted>")
|
||||
skipValue = false
|
||||
continue
|
||||
}
|
||||
if value, ok := redactSensitiveArg(arg); ok {
|
||||
out = append(out, value)
|
||||
continue
|
||||
}
|
||||
if _, ok := sensitiveFlags[arg]; ok {
|
||||
out = append(out, arg)
|
||||
skipValue = true
|
||||
continue
|
||||
}
|
||||
out = append(out, arg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func redactSensitiveArg(arg string) (string, bool) {
|
||||
for _, prefix := range []string{"--token=", "--auth-code=", "--refresh-token="} {
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
return prefix + "<redacted>", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func summarizeValue(key string, value any) any {
|
||||
lowerKey := strings.ToLower(key)
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if isContentKey(lowerKey) || len(v) > 120 {
|
||||
return map[string]any{"kind": "string", "length": float64(len(v))}
|
||||
}
|
||||
return v
|
||||
case []string:
|
||||
if isContentKey(lowerKey) {
|
||||
return map[string]any{"kind": "array", "count": float64(len(v))}
|
||||
}
|
||||
out := make([]string, len(v))
|
||||
copy(out, v)
|
||||
return out
|
||||
case []any:
|
||||
return map[string]any{"kind": "array", "count": float64(len(v))}
|
||||
case []map[string]any:
|
||||
if isContentKey(lowerKey) {
|
||||
return map[string]any{"kind": "array", "count": float64(len(v))}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
nested := make(map[string]any, len(item))
|
||||
for nestedKey, nestedValue := range item {
|
||||
if isSensitiveKey(nestedKey) {
|
||||
continue
|
||||
}
|
||||
nested[nestedKey] = summarizeValue(nestedKey, nestedValue)
|
||||
}
|
||||
out = append(out, nested)
|
||||
}
|
||||
return out
|
||||
case map[string]any:
|
||||
if isContentKey(lowerKey) {
|
||||
return map[string]any{"kind": "object", "keys": sortedKeys(v)}
|
||||
}
|
||||
out := make(map[string]any, len(v))
|
||||
for nestedKey, nestedValue := range v {
|
||||
if isSensitiveKey(nestedKey) {
|
||||
continue
|
||||
}
|
||||
out[nestedKey] = summarizeValue(nestedKey, nestedValue)
|
||||
}
|
||||
return out
|
||||
case map[string]string:
|
||||
if isContentKey(lowerKey) {
|
||||
return map[string]any{"kind": "object", "keys": sortedStringKeys(v)}
|
||||
}
|
||||
out := make(map[string]any, len(v))
|
||||
for nestedKey, nestedValue := range v {
|
||||
if isSensitiveKey(nestedKey) {
|
||||
continue
|
||||
}
|
||||
out[nestedKey] = summarizeValue(nestedKey, nestedValue)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func ComputeFingerprint(ctx RecoveryContext) string {
|
||||
base := strings.Join([]string{
|
||||
ctx.ServerID,
|
||||
ctx.ToolName,
|
||||
ctx.CLIErrorCode,
|
||||
fmt.Sprintf("%d", ctx.HTTPStatus),
|
||||
normalizeRawError(ctx.RawError),
|
||||
}, "|")
|
||||
sum := sha1.Sum([]byte(base))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
var (
|
||||
reURLQuery = regexp.MustCompile(`https?://[^\s?]+(?:\?[^\s]+)?`)
|
||||
reUUID = regexp.MustCompile(`\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`)
|
||||
reLongDigits = regexp.MustCompile(`\b\d{6,}\b`)
|
||||
reTimestamp = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}[t\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:z|[+-]\d{2}:?\d{2})?\b`)
|
||||
reLongToken = regexp.MustCompile(`\b[a-z0-9_-]{18,}\b`)
|
||||
reResourceID = regexp.MustCompile(`\b(?:base|tbl|fld|rec|conv|user|task)_[a-z0-9_-]+\b`)
|
||||
reMultiWhitespace = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
func normalizeRawError(raw string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
replacements := []struct {
|
||||
re *regexp.Regexp
|
||||
repl string
|
||||
}{
|
||||
{reURLQuery, "<url>"},
|
||||
{reUUID, "<uuid>"},
|
||||
{reTimestamp, "<ts>"},
|
||||
{reResourceID, "<id>"},
|
||||
{reLongDigits, "<num>"},
|
||||
{reLongToken, "<token>"},
|
||||
}
|
||||
for _, item := range replacements {
|
||||
normalized = item.re.ReplaceAllString(normalized, item.repl)
|
||||
}
|
||||
normalized = reMultiWhitespace.ReplaceAllString(normalized, " ")
|
||||
return strings.TrimSpace(normalized)
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func sortedStringKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func isSensitiveKey(key string) bool {
|
||||
key = strings.ToLower(key)
|
||||
sensitive := []string{"token", "authcode", "refresh", "cookie", "header", "authorization", "x-user-access-token"}
|
||||
for _, token := range sensitive {
|
||||
if strings.Contains(key, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isContentKey(key string) bool {
|
||||
key = strings.ToLower(key)
|
||||
content := []string{"text", "body", "markdown", "json", "records", "fields"}
|
||||
for _, token := range content {
|
||||
if strings.Contains(key, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type KnowledgeRetriever interface {
|
||||
Search(ctx context.Context, query string, rc RecoveryContext) (KnowledgeRetrieval, error)
|
||||
}
|
||||
|
||||
type PlanOptions struct {
|
||||
EventID string
|
||||
EnableDocSearch bool
|
||||
}
|
||||
|
||||
type Planner struct {
|
||||
retriever KnowledgeRetriever
|
||||
}
|
||||
|
||||
func NewPlanner(retriever KnowledgeRetriever) *Planner {
|
||||
return &Planner{retriever: retriever}
|
||||
}
|
||||
|
||||
func (p *Planner) Plan(ctx context.Context, rc RecoveryContext) RecoveryPlan {
|
||||
return p.PlanWithOptions(ctx, rc, PlanOptions{EnableDocSearch: true})
|
||||
}
|
||||
|
||||
func (p *Planner) PlanWithOptions(ctx context.Context, rc RecoveryContext, opts PlanOptions) RecoveryPlan {
|
||||
plan, exact := planExact(rc)
|
||||
if !exact {
|
||||
plan = RecoveryPlan{
|
||||
Category: "unknown",
|
||||
DecisionOwner: DecisionOwnerAgent,
|
||||
Confidence: 0.35,
|
||||
Evidence: []string{"未命中内置高频恢复规则"},
|
||||
}
|
||||
}
|
||||
|
||||
routeReasons := buildAgentRouteReasons(plan)
|
||||
if opts.EnableDocSearch && len(routeReasons) > 0 {
|
||||
query := BuildFallbackQuery(rc)
|
||||
retrieval := p.searchKnowledge(ctx, query, rc)
|
||||
plan.DocSearch = retrieval.DocSearch
|
||||
if query != "" {
|
||||
plan.Evidence = append(plan.Evidence, fmt.Sprintf("fallback_query=%s", query))
|
||||
}
|
||||
switch plan.DocSearch.Status {
|
||||
case "success":
|
||||
plan.Evidence = append(plan.Evidence, "命中开放平台文档检索结果")
|
||||
case "empty":
|
||||
plan.Evidence = append(plan.Evidence, "开放平台文档检索无结果")
|
||||
case "error":
|
||||
if strings.TrimSpace(plan.DocSearch.Error) != "" {
|
||||
plan.Evidence = append(plan.Evidence, fmt.Sprintf("开放平台文档检索失败=%s", plan.DocSearch.Error))
|
||||
} else {
|
||||
plan.Evidence = append(plan.Evidence, "开放平台文档检索失败")
|
||||
}
|
||||
}
|
||||
if len(retrieval.KBHits) > 0 {
|
||||
plan.KBHits = append(plan.KBHits, retrieval.KBHits...)
|
||||
if plan.Category == "unknown" {
|
||||
plan.Confidence = 0.55
|
||||
}
|
||||
}
|
||||
} else {
|
||||
plan.DocSearch = DocSearch{Status: "skipped"}
|
||||
}
|
||||
|
||||
plan.KBHits = dedupeKBHits(plan.KBHits)
|
||||
plan.DocActions = dedupeDocActions(extractDocActions(plan.KBHits))
|
||||
plan.SafeActions = nonNilStrings(normalizeSafeActions(plan.AutoActions))
|
||||
plan.AutoActions = nonNilStrings(plan.SafeActions)
|
||||
plan.DecisionOwner = normalizeDecisionOwner(plan)
|
||||
plan.DecisionHints = buildDecisionHints(plan, rc)
|
||||
plan.HumanActions = nonNilStrings(uniqueStrings(plan.HumanActions))
|
||||
plan.Evidence = nonNilStrings(uniqueStrings(plan.Evidence))
|
||||
plan.RuleHints = buildRuleHints(plan)
|
||||
plan.AgentRoute = buildAgentRoute(opts.EventID, rc, Replay{}, plan, routeReasons)
|
||||
return plan
|
||||
}
|
||||
|
||||
func (p *Planner) searchKnowledge(ctx context.Context, query string, rc RecoveryContext) KnowledgeRetrieval {
|
||||
retrieval := KnowledgeRetrieval{DocSearch: DocSearch{Query: query, Status: "skipped"}}
|
||||
if query == "" || p == nil || p.retriever == nil {
|
||||
return retrieval
|
||||
}
|
||||
result, err := p.retriever.Search(ctx, query, rc)
|
||||
if result.DocSearch.Query == "" {
|
||||
result.DocSearch.Query = query
|
||||
}
|
||||
if err != nil {
|
||||
if result.DocSearch.Status == "" {
|
||||
result.DocSearch.Status = "error"
|
||||
}
|
||||
if strings.TrimSpace(result.DocSearch.Error) == "" {
|
||||
result.DocSearch.Error = err.Error()
|
||||
}
|
||||
return result
|
||||
}
|
||||
if result.DocSearch.Status == "" {
|
||||
if len(result.DocSearch.Items) > 0 {
|
||||
result.DocSearch.Status = "success"
|
||||
} else {
|
||||
result.DocSearch.Status = "empty"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func dedupeKBHits(hits []KBHit) []KBHit {
|
||||
if len(hits) <= 1 {
|
||||
return hits
|
||||
}
|
||||
seen := make(map[string]struct{}, len(hits))
|
||||
out := make([]KBHit, 0, len(hits))
|
||||
for _, hit := range hits {
|
||||
key := strings.TrimSpace(hit.Source) + "|" + strings.TrimSpace(hit.Title) + "|" + strings.TrimSpace(hit.URL)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, hit)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func planExact(rc RecoveryContext) (RecoveryPlan, bool) {
|
||||
evidence := []string{}
|
||||
if rc.CLIErrorCode != "" {
|
||||
evidence = append(evidence, "cli_error_code="+rc.CLIErrorCode)
|
||||
}
|
||||
if rc.HTTPStatus != 0 {
|
||||
evidence = append(evidence, fmt.Sprintf("http_status=%d", rc.HTTPStatus))
|
||||
}
|
||||
if rc.CallStage != "" {
|
||||
evidence = append(evidence, "call_stage="+rc.CallStage)
|
||||
}
|
||||
|
||||
switch {
|
||||
case rc.CLIErrorCode == cliAuthExpiredCode || strings.Contains(strings.ToLower(rc.RawError), "token验证失败") || strings.Contains(strings.ToLower(rc.RawError), "user_token_illegal"):
|
||||
shouldRetry := safeToRetry(rc, "auth")
|
||||
plan := RecoveryPlan{
|
||||
Category: "auth",
|
||||
DecisionOwner: DecisionOwnerBuiltinRule,
|
||||
Confidence: 0.98,
|
||||
HumanActions: []string{"如果重复执行两次仍失败,再运行 dws auth login 重新登录"},
|
||||
Evidence: append(evidence, "命中认证失效规则"),
|
||||
KBHits: []KBHit{{Source: "builtin", Title: "认证失效恢复规则", Score: 1}},
|
||||
ShouldRetry: shouldRetry,
|
||||
ShouldStop: !shouldRetry,
|
||||
}
|
||||
if shouldRetry {
|
||||
plan.AutoActions = []string{"rerun_original_command"}
|
||||
}
|
||||
return plan, true
|
||||
case rc.CLIErrorCode == cliInvalidJSONCode || rc.CLIErrorCode == cliMissingParamCode || strings.Contains(strings.ToLower(rc.RawError), "json 解析失败"):
|
||||
return RecoveryPlan{
|
||||
Category: "input",
|
||||
DecisionOwner: DecisionOwnerBuiltinRule,
|
||||
Confidence: 0.96,
|
||||
HumanActions: []string{"检查 JSON 结构、必填参数和字段名后再重试原命令"},
|
||||
Evidence: append(evidence, "命中输入参数规则"),
|
||||
KBHits: []KBHit{{Source: "builtin", Title: "输入参数修正规则", Score: 1}},
|
||||
ShouldStop: true,
|
||||
}, true
|
||||
case strings.HasSuffix(rc.CLIErrorCode, "_NOT_FOUND") || rc.CLIErrorCode == cliResourceNotFoundCode || containsAny(rc.RawError, "not found", "资源不存在", "不存在", "does not exist", "has been deleted", "deleted", "base_not_found", "document.notfound", "resource.notfound", "specified base does not exist"):
|
||||
return RecoveryPlan{
|
||||
Category: "resource",
|
||||
DecisionOwner: DecisionOwnerBuiltinRule,
|
||||
Confidence: 0.92,
|
||||
HumanActions: []string{"先确认资源是否仍存在、ID 是否正确,再决定是否重试原命令"},
|
||||
Evidence: append(evidence, "命中资源不存在规则"),
|
||||
KBHits: []KBHit{{Source: "builtin", Title: "资源不存在恢复规则", Score: 1}},
|
||||
ShouldStop: true,
|
||||
}, true
|
||||
case rc.CLIErrorCode == cliPermissionCode || rc.HTTPStatus == 403 || containsAny(rc.RawError, "forbidden", "permission", "权限不足", "无权限", "operationillegal"):
|
||||
return RecoveryPlan{
|
||||
Category: "permission",
|
||||
DecisionOwner: DecisionOwnerBuiltinRule,
|
||||
Confidence: 0.95,
|
||||
HumanActions: []string{"确认当前账号对目标资源有访问权限,必要时申请授权后再重试"},
|
||||
Evidence: append(evidence, "命中权限规则"),
|
||||
KBHits: []KBHit{{Source: "builtin", Title: "权限不足恢复规则", Score: 1}},
|
||||
ShouldStop: true,
|
||||
}, true
|
||||
case rc.CLIErrorCode == cliRateLimitCode || rc.HTTPStatus == 429 || containsAny(rc.RawError, "too many requests", "rate limit"):
|
||||
shouldRetry := safeToRetry(rc, "rate_limit")
|
||||
plan := RecoveryPlan{
|
||||
Category: "rate_limit",
|
||||
DecisionOwner: DecisionOwnerBuiltinRule,
|
||||
Confidence: 0.94,
|
||||
HumanActions: []string{"如仍失败,请降低调用频率后再重试"},
|
||||
Evidence: append(evidence, "命中限流规则"),
|
||||
KBHits: []KBHit{{Source: "builtin", Title: "限流恢复规则", Score: 1}},
|
||||
ShouldRetry: shouldRetry,
|
||||
ShouldStop: !shouldRetry,
|
||||
}
|
||||
if shouldRetry {
|
||||
plan.AutoActions = []string{"wait_and_retry"}
|
||||
}
|
||||
return plan, true
|
||||
case rc.CLIErrorCode == cliTimeoutCode || rc.HTTPStatus >= 500 || containsAny(rc.RawError, "timeout", "deadline exceeded", "connection refused", "connection reset", "broken pipe", "502", "503", "504"):
|
||||
shouldRetry := safeToRetry(rc, "network")
|
||||
plan := RecoveryPlan{
|
||||
Category: "network",
|
||||
DecisionOwner: DecisionOwnerBuiltinRule,
|
||||
Confidence: 0.9,
|
||||
HumanActions: []string{"如果重试后仍失败,请检查网络或稍后再试"},
|
||||
Evidence: append(evidence, "命中网络/服务异常规则"),
|
||||
KBHits: []KBHit{{Source: "builtin", Title: "网络异常恢复规则", Score: 1}},
|
||||
ShouldRetry: shouldRetry,
|
||||
ShouldStop: !shouldRetry,
|
||||
}
|
||||
if shouldRetry {
|
||||
plan.AutoActions = []string{"retry_original_command"}
|
||||
}
|
||||
return plan, true
|
||||
}
|
||||
|
||||
return RecoveryPlan{}, false
|
||||
}
|
||||
|
||||
func BuildFallbackQuery(rc RecoveryContext) string {
|
||||
parts := make([]string, 0, 6)
|
||||
if rc.CLIErrorCode != "" {
|
||||
parts = append(parts, rc.CLIErrorCode)
|
||||
}
|
||||
if rc.ToolName != "" {
|
||||
parts = append(parts, rc.ToolName)
|
||||
}
|
||||
if len(rc.CommandPath) > 0 {
|
||||
parts = append(parts, strings.Join(rc.CommandPath, " "))
|
||||
}
|
||||
parts = append(parts, stableKeywords(rc.RawError)...)
|
||||
return strings.Join(uniqueStrings(parts), " ")
|
||||
}
|
||||
|
||||
func safeToRetry(rc RecoveryContext, category string) bool {
|
||||
switch rc.OperationKind {
|
||||
case OperationRead:
|
||||
return category == "auth" || category == "rate_limit" || category == "network"
|
||||
case OperationWrite:
|
||||
if rc.CallStage != "http" {
|
||||
return false
|
||||
}
|
||||
return category == "auth" || category == "rate_limit"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDecisionOwner(plan RecoveryPlan) DecisionOwner {
|
||||
if plan.DecisionOwner != "" {
|
||||
return plan.DecisionOwner
|
||||
}
|
||||
if plan.Category == "unknown" {
|
||||
return DecisionOwnerAgent
|
||||
}
|
||||
return DecisionOwnerBuiltinRule
|
||||
}
|
||||
|
||||
func stableKeywords(raw string) []string {
|
||||
normalized := normalizeRawError(raw)
|
||||
if normalized == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.FieldsFunc(normalized, func(r rune) bool {
|
||||
return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_' && r != '<' && r != '>'
|
||||
})
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if len(part) < 3 || strings.HasPrefix(part, "<") {
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
if len(out) >= 6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return uniqueStrings(out)
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsAny(value string, targets ...string) bool {
|
||||
value = strings.ToLower(value)
|
||||
for _, target := range targets {
|
||||
if strings.Contains(value, strings.ToLower(target)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const (
|
||||
cliAuthExpiredCode = "AUTH_TOKEN_EXPIRED"
|
||||
cliInvalidJSONCode = "INPUT_INVALID_JSON"
|
||||
cliMissingParamCode = "INPUT_MISSING_PARAM"
|
||||
cliPermissionCode = "AUTH_PERMISSION_DENIED"
|
||||
cliResourceNotFoundCode = "RESOURCE_NOT_FOUND"
|
||||
cliRateLimitCode = "NETWORK_RATE_LIMITED"
|
||||
cliTimeoutCode = "NETWORK_TIMEOUT"
|
||||
)
|
||||
|
||||
func normalizeSafeActions(actions []string) []string {
|
||||
allowed := make([]string, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
switch action {
|
||||
case "rerun_original_command", "retry_original_command", "wait_and_retry":
|
||||
allowed = append(allowed, action)
|
||||
}
|
||||
}
|
||||
return uniqueStrings(allowed)
|
||||
}
|
||||
|
||||
func buildDecisionHints(plan RecoveryPlan, rc RecoveryContext) DecisionHints {
|
||||
signals := []string{rc.CLIErrorCode, rc.RawError}
|
||||
for _, hit := range plan.KBHits {
|
||||
signals = append(signals, hit.Title, hit.Snippet)
|
||||
}
|
||||
for _, action := range plan.DocActions {
|
||||
signals = append(signals, action.Action, action.Reason)
|
||||
}
|
||||
text := strings.Join(signals, " ")
|
||||
return DecisionHints{
|
||||
Retryable: plan.ShouldRetry || len(plan.SafeActions) > 0,
|
||||
PermissionSensitive: plan.Category == "permission" || containsAny(text, "permission", "forbidden", "无权限", "operatorid has permission", "有访问权限", "inaccessible"),
|
||||
AuthRelated: plan.Category == "auth" || containsAny(text, "auth_token_expired", "token验证失败", "user_token_illegal", "认证失效", "token 已过期"),
|
||||
ResourceStateRelated: plan.Category == "resource" || containsAny(text, "not found", "does not exist", "deleted", "document.notfound", "resource.notfound", "资源不存在", "文档不存在", "彻底删除", "base_not_found"),
|
||||
}
|
||||
}
|
||||
|
||||
func buildRuleHints(plan RecoveryPlan) RuleHints {
|
||||
return RuleHints{
|
||||
Category: plan.Category,
|
||||
DecisionOwner: normalizeDecisionOwner(plan),
|
||||
Confidence: plan.Confidence,
|
||||
SafeActions: nonNilStrings(plan.SafeActions),
|
||||
HumanActions: nonNilStrings(plan.HumanActions),
|
||||
Evidence: nonNilStrings(plan.Evidence),
|
||||
ShouldRetry: plan.ShouldRetry,
|
||||
ShouldStop: plan.ShouldStop,
|
||||
}
|
||||
}
|
||||
|
||||
func buildAgentRouteReasons(plan RecoveryPlan) []string {
|
||||
reasons := make([]string, 0, 2)
|
||||
if plan.Category == "unknown" {
|
||||
reasons = append(reasons, "unknown_category")
|
||||
}
|
||||
return nonNilStrings(uniqueStrings(reasons))
|
||||
}
|
||||
|
||||
func buildAgentRoute(eventID string, rc RecoveryContext, replay Replay, plan RecoveryPlan, reasons []string) AgentRoute {
|
||||
required := len(reasons) > 0
|
||||
var payload *AgentRoutePayload
|
||||
decisionOwner := normalizeDecisionOwner(plan)
|
||||
if required {
|
||||
payload = &AgentRoutePayload{
|
||||
EventID: eventID,
|
||||
Context: cloneRecoveryContext(rc),
|
||||
Replay: cloneReplay(replay),
|
||||
RawError: rc.RawError,
|
||||
Category: plan.Category,
|
||||
DecisionOwner: decisionOwner,
|
||||
Confidence: plan.Confidence,
|
||||
ShouldRetry: plan.ShouldRetry,
|
||||
ShouldStop: plan.ShouldStop,
|
||||
SafeActions: nonNilStrings(plan.SafeActions),
|
||||
DocActions: cloneDocActions(plan.DocActions),
|
||||
KBHits: cloneKBHits(plan.KBHits),
|
||||
DocSearch: cloneDocSearch(plan.DocSearch),
|
||||
HumanActions: nonNilStrings(plan.HumanActions),
|
||||
DecisionHints: plan.DecisionHints,
|
||||
Evidence: nonNilStrings(plan.Evidence),
|
||||
RuleHints: plan.RuleHints,
|
||||
}
|
||||
}
|
||||
return AgentRoute{
|
||||
Required: required,
|
||||
Target: ternaryString(required, "agent_llm", ""),
|
||||
Executor: ternaryString(required, "dingtalk-workspace", ""),
|
||||
Reasons: nonNilStrings(reasons),
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func HydratePlanForEvent(eventID string, rc RecoveryContext, replay Replay, plan *RecoveryPlan) {
|
||||
if plan == nil {
|
||||
return
|
||||
}
|
||||
plan.RuleHints = buildRuleHints(*plan)
|
||||
plan.AgentRoute = buildAgentRoute(eventID, rc, replay, *plan, buildAgentRouteReasons(*plan))
|
||||
}
|
||||
|
||||
func extractDocActions(hits []KBHit) []DocAction {
|
||||
out := make([]DocAction, 0)
|
||||
for _, hit := range hits {
|
||||
if strings.TrimSpace(hit.Snippet) == "" {
|
||||
continue
|
||||
}
|
||||
lines := strings.Split(hit.Snippet, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "|") {
|
||||
continue
|
||||
}
|
||||
cells := splitTableRow(line)
|
||||
if len(cells) < 5 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(cells[0], "httpcode") || strings.HasPrefix(cells[0], "--") {
|
||||
continue
|
||||
}
|
||||
action := strings.TrimSpace(cells[len(cells)-1])
|
||||
if action == "" || action == "%s" {
|
||||
continue
|
||||
}
|
||||
reasonParts := make([]string, 0, 2)
|
||||
if len(cells) > 1 && cells[1] != "" {
|
||||
reasonParts = append(reasonParts, cells[1])
|
||||
}
|
||||
if len(cells) > 3 && cells[3] != "" && cells[3] != "%s" {
|
||||
reasonParts = append(reasonParts, cells[3])
|
||||
}
|
||||
out = append(out, DocAction{
|
||||
Action: action,
|
||||
Reason: strings.Join(reasonParts, ": "),
|
||||
SourceTitle: hit.Title,
|
||||
SourceURL: hit.URL,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitTableRow(line string) []string {
|
||||
parts := strings.Split(line, "|")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupeDocActions(actions []DocAction) []DocAction {
|
||||
if len(actions) <= 1 {
|
||||
return actions
|
||||
}
|
||||
seen := make(map[string]struct{}, len(actions))
|
||||
out := make([]DocAction, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
key := strings.TrimSpace(action.Action) + "|" + strings.TrimSpace(action.Reason) + "|" + strings.TrimSpace(action.SourceTitle) + "|" + strings.TrimSpace(action.SourceURL)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, action)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDocActions(actions []DocAction) []DocAction {
|
||||
if len(actions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]DocAction, len(actions))
|
||||
copy(out, actions)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneKBHits(hits []KBHit) []KBHit {
|
||||
if len(hits) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]KBHit, len(hits))
|
||||
copy(out, hits)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDocSearch(search DocSearch) DocSearch {
|
||||
cloned := search
|
||||
if search.Request != nil {
|
||||
cloned.Request = &ToolCallRecord{
|
||||
ServerID: search.Request.ServerID,
|
||||
ToolName: search.Request.ToolName,
|
||||
Arguments: cloneMap(search.Request.Arguments),
|
||||
}
|
||||
}
|
||||
if search.Response != nil {
|
||||
cloned.Response = &ToolResponse{IsError: search.Response.IsError}
|
||||
if len(search.Response.Content) > 0 {
|
||||
cloned.Response.Content = make([]ToolResponseBlock, len(search.Response.Content))
|
||||
copy(cloned.Response.Content, search.Response.Content)
|
||||
}
|
||||
}
|
||||
if len(search.Items) > 0 {
|
||||
cloned.Items = make([]DocSearchItem, len(search.Items))
|
||||
copy(cloned.Items, search.Items)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func ternaryString(ok bool, yes, no string) string {
|
||||
if ok {
|
||||
return yes
|
||||
}
|
||||
return no
|
||||
}
|
||||
|
||||
func nonNilStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
return append([]string{}, values...)
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestBuildContext_SanitizesArgsAndPreservesTypedFields(t *testing.T) {
|
||||
rawErr := &transport.CallError{
|
||||
Stage: transport.CallStageHTTP,
|
||||
HTTPStatus: 429,
|
||||
RetryAfter: "3",
|
||||
TraceID: "trace-1",
|
||||
RequestID: "req-1",
|
||||
Cause: errors.New("HTTP 429: too many requests"),
|
||||
}
|
||||
wrapped := apperrors.NewAPI(
|
||||
"request failed",
|
||||
apperrors.WithReason("http_429"),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithCause(rawErr),
|
||||
)
|
||||
|
||||
ctx := BuildContext(CaptureInput{
|
||||
CommandPath: []string{"chat", "message", "send"},
|
||||
ServerID: "chat",
|
||||
ToolName: "send_message_as_user",
|
||||
Args: map[string]any{
|
||||
"text": "very secret text body",
|
||||
"token": "secret-token",
|
||||
"payload": map[string]any{"header": "sensitive", "name": "demo"},
|
||||
},
|
||||
RawErr: rawErr,
|
||||
WrappedErr: wrapped,
|
||||
})
|
||||
|
||||
if ctx.CLIErrorCode != cliRateLimitCode {
|
||||
t.Fatalf("expected CLI error code %q, got %q", cliRateLimitCode, ctx.CLIErrorCode)
|
||||
}
|
||||
if ctx.CallStage != string(transport.CallStageHTTP) || ctx.HTTPStatus != 429 {
|
||||
t.Fatalf("expected typed call metadata, got stage=%q status=%d", ctx.CallStage, ctx.HTTPStatus)
|
||||
}
|
||||
if ctx.RetryAfter != "3" || ctx.TraceID != "trace-1" || ctx.RequestID != "req-1" {
|
||||
t.Fatalf("expected transport metadata, got %#v", ctx)
|
||||
}
|
||||
if _, ok := ctx.ArgsSummary["token"]; ok {
|
||||
t.Fatal("sensitive args must be dropped")
|
||||
}
|
||||
textSummary, ok := ctx.ArgsSummary["text"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected text summary, got %#v", ctx.ArgsSummary["text"])
|
||||
}
|
||||
if int(textSummary["length"].(float64)) != len("very secret text body") {
|
||||
t.Fatalf("expected text summary length, got %#v", textSummary)
|
||||
}
|
||||
if ctx.Fingerprint == "" {
|
||||
t.Fatal("expected fingerprint to be computed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReplay_SummarizesContentArgsAndSanitizesNestedValues(t *testing.T) {
|
||||
replay := BuildReplay(CaptureInput{
|
||||
CommandPath: []string{"chat", "message", "send"},
|
||||
ServerID: "chat",
|
||||
ToolName: "send_message_as_user",
|
||||
Args: map[string]any{
|
||||
"text": "very secret text body",
|
||||
"records": []map[string]any{{
|
||||
"title": "demo record",
|
||||
"token": "secret-token",
|
||||
}},
|
||||
"payload": map[string]any{
|
||||
"header": "sensitive",
|
||||
"name": "demo",
|
||||
"text": "nested body",
|
||||
},
|
||||
"baseId": "base_demo",
|
||||
},
|
||||
Argv: []string{"chat", "message", "send", "--token", "secret-token"},
|
||||
})
|
||||
|
||||
textSummary, ok := replay.ToolArgs["text"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected text summary in replay, got %#v", replay.ToolArgs["text"])
|
||||
}
|
||||
if int(textSummary["length"].(float64)) != len("very secret text body") {
|
||||
t.Fatalf("expected text length summary, got %#v", textSummary)
|
||||
}
|
||||
|
||||
recordsSummary, ok := replay.ToolArgs["records"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected records summary in replay, got %#v", replay.ToolArgs["records"])
|
||||
}
|
||||
if recordsSummary["kind"] != "array" || int(recordsSummary["count"].(float64)) != 1 {
|
||||
t.Fatalf("expected records count summary, got %#v", recordsSummary)
|
||||
}
|
||||
|
||||
payload, ok := replay.ToolArgs["payload"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected nested payload in replay, got %#v", replay.ToolArgs["payload"])
|
||||
}
|
||||
if _, ok := payload["header"]; ok {
|
||||
t.Fatalf("expected nested sensitive field to be removed, got %#v", payload)
|
||||
}
|
||||
if payload["name"] != "demo" {
|
||||
t.Fatalf("expected non-sensitive nested field to remain, got %#v", payload)
|
||||
}
|
||||
nestedText, ok := payload["text"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected nested text summary, got %#v", payload["text"])
|
||||
}
|
||||
if int(nestedText["length"].(float64)) != len("nested body") {
|
||||
t.Fatalf("expected nested text length summary, got %#v", nestedText)
|
||||
}
|
||||
|
||||
if replay.ToolArgs["baseId"] != "base_demo" {
|
||||
t.Fatalf("expected replayable identifier to remain, got %#v", replay.ToolArgs["baseId"])
|
||||
}
|
||||
if len(replay.RedactedArgv) != 5 || replay.RedactedArgv[4] != "<redacted>" {
|
||||
t.Fatalf("expected argv token to be redacted, got %#v", replay.RedactedArgv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeFingerprint_NormalizesVolatileSegments(t *testing.T) {
|
||||
ctxA := RecoveryContext{
|
||||
ServerID: "aitable",
|
||||
ToolName: "get_base",
|
||||
CLIErrorCode: cliResourceNotFoundCode,
|
||||
HTTPStatus: 404,
|
||||
RawError: "resource base_abcd1234 not found at 2026-03-19T10:10:10Z trace=550e8400-e29b-41d4-a716-446655440000",
|
||||
}
|
||||
ctxB := RecoveryContext{
|
||||
ServerID: "aitable",
|
||||
ToolName: "get_base",
|
||||
CLIErrorCode: cliResourceNotFoundCode,
|
||||
HTTPStatus: 404,
|
||||
RawError: "resource base_zzzz9999 not found at 2026-03-20T10:10:10Z trace=660e8400-e29b-41d4-a716-446655440000",
|
||||
}
|
||||
if gotA, gotB := ComputeFingerprint(ctxA), ComputeFingerprint(ctxB); gotA != gotB {
|
||||
t.Fatalf("expected normalized fingerprint to stay stable, got %q vs %q", gotA, gotB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanner_ExactRulesAndFallback(t *testing.T) {
|
||||
planner := NewPlanner(fakeRetriever{retrieval: KnowledgeRetrieval{
|
||||
KBHits: []KBHit{{Source: "docs", Title: "doc"}},
|
||||
DocSearch: DocSearch{
|
||||
Provider: "open_platform_docs",
|
||||
Query: "get_approval_instance approval approval get weird upstream issue",
|
||||
Status: "success",
|
||||
Items: []DocSearchItem{{
|
||||
Title: "doc",
|
||||
URL: "https://open.dingtalk.com/doc",
|
||||
Desc: "审批接口文档",
|
||||
}},
|
||||
},
|
||||
}})
|
||||
|
||||
authPlan := planner.Plan(context.Background(), RecoveryContext{
|
||||
CLIErrorCode: cliAuthExpiredCode,
|
||||
OperationKind: OperationRead,
|
||||
})
|
||||
if authPlan.Category != "auth" || !authPlan.ShouldRetry {
|
||||
t.Fatalf("expected auth plan with retry, got %#v", authPlan)
|
||||
}
|
||||
if len(authPlan.SafeActions) != 1 || authPlan.SafeActions[0] != "rerun_original_command" {
|
||||
t.Fatalf("expected safe action alias for auth plan, got %#v", authPlan.SafeActions)
|
||||
}
|
||||
if !authPlan.DecisionHints.AuthRelated || !authPlan.DecisionHints.Retryable {
|
||||
t.Fatalf("expected auth decision hints, got %#v", authPlan.DecisionHints)
|
||||
}
|
||||
if authPlan.AgentRoute.Required {
|
||||
t.Fatalf("expected auth retryable plan to stay on rule path, got %#v", authPlan.AgentRoute)
|
||||
}
|
||||
|
||||
unknownPlan := planner.Plan(context.Background(), RecoveryContext{
|
||||
CommandPath: []string{"approval", "approval", "get"},
|
||||
ToolName: "get_approval_instance",
|
||||
OperationKind: OperationUnknown,
|
||||
RawError: "weird upstream issue",
|
||||
})
|
||||
if unknownPlan.Category != "unknown" {
|
||||
t.Fatalf("expected unknown category, got %s", unknownPlan.Category)
|
||||
}
|
||||
if unknownPlan.DecisionOwner != DecisionOwnerAgent {
|
||||
t.Fatalf("expected unknown plan to be agent-owned, got %#v", unknownPlan.DecisionOwner)
|
||||
}
|
||||
if len(unknownPlan.KBHits) == 0 {
|
||||
t.Fatal("expected fallback retriever hits")
|
||||
}
|
||||
if !strings.Contains(strings.Join(unknownPlan.Evidence, " "), "fallback_query=") {
|
||||
t.Fatalf("expected fallback query evidence, got %#v", unknownPlan.Evidence)
|
||||
}
|
||||
if unknownPlan.ShouldRetry || unknownPlan.ShouldStop {
|
||||
t.Fatalf("expected unknown plan to stay undecided, got %#v", unknownPlan)
|
||||
}
|
||||
if len(unknownPlan.HumanActions) != 0 {
|
||||
t.Fatalf("expected unknown plan to avoid built-in human actions, got %#v", unknownPlan.HumanActions)
|
||||
}
|
||||
if !unknownPlan.AgentRoute.Required {
|
||||
t.Fatalf("expected unknown plan to force agent route, got %#v", unknownPlan.AgentRoute)
|
||||
}
|
||||
if len(unknownPlan.AgentRoute.Reasons) != 1 || unknownPlan.AgentRoute.Reasons[0] != "unknown_category" {
|
||||
t.Fatalf("expected unknown_category route reason, got %#v", unknownPlan.AgentRoute.Reasons)
|
||||
}
|
||||
if unknownPlan.AgentRoute.Payload == nil || unknownPlan.AgentRoute.Payload.DecisionOwner != DecisionOwnerAgent {
|
||||
t.Fatalf("expected unknown payload to include agent decision owner, got %#v", unknownPlan.AgentRoute.Payload)
|
||||
}
|
||||
if unknownPlan.DocSearch.Status != "success" || len(unknownPlan.DocSearch.Items) != 1 {
|
||||
t.Fatalf("expected doc search payload to be preserved, got %#v", unknownPlan.DocSearch)
|
||||
}
|
||||
if unknownPlan.RuleHints.DecisionOwner != DecisionOwnerAgent || unknownPlan.RuleHints.Confidence != unknownPlan.Confidence {
|
||||
t.Fatalf("expected rule hints to mirror final unknown plan state, got %#v", unknownPlan.RuleHints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_CaptureLoadAndFinalizeLifecycle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
ctx := RecoveryContext{
|
||||
CommandPath: []string{"aitable", "base", "get"},
|
||||
ServerID: "aitable",
|
||||
ToolName: "get_base",
|
||||
OperationKind: OperationRead,
|
||||
CLIErrorCode: cliResourceNotFoundCode,
|
||||
RawError: "base_not_found",
|
||||
Fingerprint: "fp-1",
|
||||
}
|
||||
replay := Replay{
|
||||
ServerID: "aitable",
|
||||
ToolName: "get_base",
|
||||
OperationKind: OperationRead,
|
||||
ToolArgs: map[string]any{"baseId": "base_1"},
|
||||
}
|
||||
|
||||
last, err := store.Capture(ctx, replay)
|
||||
if err != nil {
|
||||
t.Fatalf("Capture() error = %v", err)
|
||||
}
|
||||
if last == nil || last.EventID == "" {
|
||||
t.Fatalf("Capture() = %#v, want event id", last)
|
||||
}
|
||||
|
||||
loaded, err := store.LoadLastError()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLastError() error = %v", err)
|
||||
}
|
||||
if loaded.EventID != last.EventID {
|
||||
t.Fatalf("LoadLastError().EventID = %q, want %q", loaded.EventID, last.EventID)
|
||||
}
|
||||
|
||||
byEvent, err := store.LoadErrorByEvent(last.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadErrorByEvent() error = %v", err)
|
||||
}
|
||||
if byEvent.EventID != last.EventID {
|
||||
t.Fatalf("LoadErrorByEvent().EventID = %q, want %q", byEvent.EventID, last.EventID)
|
||||
}
|
||||
|
||||
plan := RecoveryPlan{Category: "resource", Confidence: 0.92}
|
||||
if err := store.SavePlan(last.EventID, plan); err != nil {
|
||||
t.Fatalf("SavePlan() error = %v", err)
|
||||
}
|
||||
bundle := RecoveryBundle{EventID: last.EventID, Plan: plan, Status: "needs_agent_action"}
|
||||
if err := store.SaveAnalysis(last.EventID, plan, bundle); err != nil {
|
||||
t.Fatalf("SaveAnalysis() error = %v", err)
|
||||
}
|
||||
exec := &RecoveryExecution{
|
||||
Actions: []string{"inspect_bundle"},
|
||||
Result: "handoff",
|
||||
}
|
||||
if err := store.Finalize(last.EventID, "handoff", exec); err != nil {
|
||||
t.Fatalf("Finalize() error = %v", err)
|
||||
}
|
||||
|
||||
eventsPath := filepath.Join(dir, "recovery", "recovery_events.jsonl")
|
||||
data, err := os.ReadFile(eventsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(events) error = %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("expected 4 recovery event lines, got %d", len(lines))
|
||||
}
|
||||
var lastEvent RecoveryEvent
|
||||
if err := json.Unmarshal([]byte(lines[len(lines)-1]), &lastEvent); err != nil {
|
||||
t.Fatalf("json.Unmarshal(final event) error = %v", err)
|
||||
}
|
||||
if lastEvent.Phase != "finalized" || lastEvent.Outcome != "handoff" {
|
||||
t.Fatalf("unexpected final event %#v", lastEvent)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRetriever struct {
|
||||
retrieval KnowledgeRetrieval
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeRetriever) Search(ctx context.Context, query string, rc RecoveryContext) (KnowledgeRetrieval, error) {
|
||||
return f.retrieval, f.err
|
||||
}
|
||||
@@ -1,476 +0,0 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
recoveryDirName = "recovery"
|
||||
lastErrorFile = "last_error.json"
|
||||
eventsFile = "recovery_events.jsonl"
|
||||
recoveryMaxAge = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
baseDir string
|
||||
}
|
||||
|
||||
var runtimeState struct {
|
||||
mu sync.Mutex
|
||||
lastCapture *LastError
|
||||
}
|
||||
|
||||
var (
|
||||
recoveryReadFile = os.ReadFile
|
||||
recoveryWriteFile = os.WriteFile
|
||||
recoveryOpen = os.Open
|
||||
recoveryOpenFile = os.OpenFile
|
||||
recoveryMkdirAll = os.MkdirAll
|
||||
recoveryReadDir = os.ReadDir
|
||||
recoveryStat = os.Stat
|
||||
recoveryRemove = os.Remove
|
||||
recoveryRemoveAll = os.RemoveAll
|
||||
recoveryFileWrite = func(file *os.File, data []byte) (int, error) {
|
||||
return file.Write(data)
|
||||
}
|
||||
)
|
||||
|
||||
func NewStore(configDir string) *Store {
|
||||
return &Store{baseDir: configDir}
|
||||
}
|
||||
|
||||
func (s *Store) Enabled() bool {
|
||||
return s != nil && s.baseDir != ""
|
||||
}
|
||||
|
||||
func (s *Store) Capture(ctx RecoveryContext, replay ...Replay) (*LastError, error) {
|
||||
if !s.Enabled() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
last := &LastError{
|
||||
EventID: newEventID(),
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Context: ctx,
|
||||
}
|
||||
if len(replay) > 0 {
|
||||
last.Replay = cloneReplay(replay[0])
|
||||
}
|
||||
if err := s.writeJSON(s.lastErrorPath(), last); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var replayPtr *Replay
|
||||
if !isEmptyReplay(last.Replay) {
|
||||
copied := cloneReplay(last.Replay)
|
||||
replayPtr = &copied
|
||||
}
|
||||
if err := s.appendEvent(RecoveryEvent{
|
||||
EventID: last.EventID,
|
||||
Phase: "captured",
|
||||
RecordedAt: last.RecordedAt,
|
||||
Context: &last.Context,
|
||||
Replay: replayPtr,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setLatestCapture(last)
|
||||
return last, nil
|
||||
}
|
||||
|
||||
func (s *Store) LoadLastError() (*LastError, error) {
|
||||
if !s.Enabled() {
|
||||
return nil, fmt.Errorf("recovery store disabled")
|
||||
}
|
||||
if err := s.pruneExpiredArtifacts(time.Now().UTC()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := recoveryReadFile(s.lastErrorPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var last LastError
|
||||
if err := json.Unmarshal(data, &last); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &last, nil
|
||||
}
|
||||
|
||||
func (s *Store) LoadErrorByEvent(eventID string) (*LastError, error) {
|
||||
if !s.Enabled() {
|
||||
return nil, fmt.Errorf("recovery store disabled")
|
||||
}
|
||||
if eventID == "" {
|
||||
return nil, fmt.Errorf("event id is required")
|
||||
}
|
||||
if err := s.pruneExpiredArtifacts(time.Now().UTC()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := recoveryOpen(s.eventsPath())
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
file = nil
|
||||
}
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
reader := bufio.NewReader(file)
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
var event RecoveryEvent
|
||||
if json.Unmarshal(line, &event) == nil && event.EventID == eventID && event.Phase == "captured" && event.Context != nil {
|
||||
last := &LastError{
|
||||
EventID: event.EventID,
|
||||
RecordedAt: event.RecordedAt,
|
||||
Context: cloneRecoveryContext(*event.Context),
|
||||
}
|
||||
if event.Replay != nil {
|
||||
last.Replay = cloneReplay(*event.Replay)
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last, err := s.LoadLastError()
|
||||
if err == nil && last != nil && last.EventID == eventID {
|
||||
return last, nil
|
||||
}
|
||||
return nil, fmt.Errorf("未找到 event_id=%s 对应的失败快照", eventID)
|
||||
}
|
||||
|
||||
func (s *Store) SavePlan(eventID string, plan RecoveryPlan) error {
|
||||
if !s.Enabled() {
|
||||
return nil
|
||||
}
|
||||
return s.appendEvent(RecoveryEvent{
|
||||
EventID: eventID,
|
||||
Phase: "planned",
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Plan: &plan,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) SaveAnalysis(eventID string, plan RecoveryPlan, bundle RecoveryBundle) error {
|
||||
if !s.Enabled() {
|
||||
return nil
|
||||
}
|
||||
return s.appendEvent(RecoveryEvent{
|
||||
EventID: eventID,
|
||||
Phase: "analyzed",
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Plan: &plan,
|
||||
Bundle: &bundle,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) Finalize(eventID, outcome string, exec *RecoveryExecution) error {
|
||||
if !s.Enabled() {
|
||||
return nil
|
||||
}
|
||||
return s.appendEvent(RecoveryEvent{
|
||||
EventID: eventID,
|
||||
Phase: "finalized",
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Execution: exec,
|
||||
Outcome: outcome,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) writeJSON(path string, payload any) error {
|
||||
if err := s.ensureDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return recoveryWriteFile(path, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
func (s *Store) appendEvent(event RecoveryEvent) error {
|
||||
if err := s.ensureDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := recoveryOpenFile(s.eventsPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := recoveryFileWrite(file, append(data, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ensureDir() error {
|
||||
if !s.Enabled() {
|
||||
return fmt.Errorf("recovery store disabled")
|
||||
}
|
||||
if err := recoveryMkdirAll(s.dir(), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.pruneExpiredArtifacts(time.Now().UTC())
|
||||
}
|
||||
|
||||
func (s *Store) dir() string {
|
||||
return filepath.Join(s.baseDir, recoveryDirName)
|
||||
}
|
||||
|
||||
func (s *Store) lastErrorPath() string {
|
||||
return filepath.Join(s.dir(), lastErrorFile)
|
||||
}
|
||||
|
||||
func (s *Store) eventsPath() string {
|
||||
return filepath.Join(s.dir(), eventsFile)
|
||||
}
|
||||
|
||||
func newEventID() string {
|
||||
return fmt.Sprintf("evt_%d", time.Now().UTC().UnixNano())
|
||||
}
|
||||
|
||||
func LatestCapture() *LastError {
|
||||
runtimeState.mu.Lock()
|
||||
defer runtimeState.mu.Unlock()
|
||||
if runtimeState.lastCapture == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *runtimeState.lastCapture
|
||||
copied.Context = cloneRecoveryContext(copied.Context)
|
||||
copied.Replay = cloneReplay(copied.Replay)
|
||||
return &copied
|
||||
}
|
||||
|
||||
func ResetRuntimeState() {
|
||||
runtimeState.mu.Lock()
|
||||
defer runtimeState.mu.Unlock()
|
||||
runtimeState.lastCapture = nil
|
||||
}
|
||||
|
||||
func setLatestCapture(last *LastError) {
|
||||
runtimeState.mu.Lock()
|
||||
defer runtimeState.mu.Unlock()
|
||||
if last == nil {
|
||||
runtimeState.lastCapture = nil
|
||||
return
|
||||
}
|
||||
copied := *last
|
||||
copied.Context = cloneRecoveryContext(copied.Context)
|
||||
copied.Replay = cloneReplay(copied.Replay)
|
||||
runtimeState.lastCapture = &copied
|
||||
}
|
||||
|
||||
func cloneRecoveryContext(ctx RecoveryContext) RecoveryContext {
|
||||
cloned := ctx
|
||||
if len(ctx.CommandPath) > 0 {
|
||||
cloned.CommandPath = append([]string(nil), ctx.CommandPath...)
|
||||
}
|
||||
if len(ctx.ArgsSummary) > 0 {
|
||||
cloned.ArgsSummary = cloneMap(ctx.ArgsSummary)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneReplay(replay Replay) Replay {
|
||||
cloned := replay
|
||||
if len(replay.ToolArgs) > 0 {
|
||||
cloned.ToolArgs = cloneMap(replay.ToolArgs)
|
||||
}
|
||||
if len(replay.RedactedArgv) > 0 {
|
||||
cloned.RedactedArgv = append([]string(nil), replay.RedactedArgv...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func isEmptyReplay(replay Replay) bool {
|
||||
return replay.ServerID == "" &&
|
||||
replay.ToolName == "" &&
|
||||
replay.OperationKind == "" &&
|
||||
len(replay.ToolArgs) == 0 &&
|
||||
len(replay.RedactedArgv) == 0 &&
|
||||
replay.RedactedCommand == ""
|
||||
}
|
||||
|
||||
func cloneMap(src map[string]any) map[string]any {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make(map[string]any, len(src))
|
||||
for key, value := range src {
|
||||
dst[key] = cloneValue(value)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func cloneSlice(src []any) []any {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]any, len(src))
|
||||
for i, value := range src {
|
||||
dst[i] = cloneValue(value)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func cloneValue(value any) any {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
return cloneMap(v)
|
||||
case []any:
|
||||
return cloneSlice(v)
|
||||
case []string:
|
||||
out := make([]string, len(v))
|
||||
copy(out, v)
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) pruneExpiredArtifacts(now time.Time) error {
|
||||
if !s.Enabled() {
|
||||
return nil
|
||||
}
|
||||
cutoff := now.Add(-recoveryMaxAge)
|
||||
if err := s.pruneLastError(cutoff); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.pruneEvents(cutoff); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.pruneOtherArtifacts(cutoff)
|
||||
}
|
||||
|
||||
func (s *Store) pruneLastError(cutoff time.Time) error {
|
||||
path := s.lastErrorPath()
|
||||
data, err := recoveryReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var last LastError
|
||||
if err := json.Unmarshal(data, &last); err == nil {
|
||||
if recordedAt, ok := parseRecordedAt(last.RecordedAt); ok && recordedAt.Before(cutoff) {
|
||||
return recoveryRemove(path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
info, statErr := recoveryStat(path)
|
||||
if statErr != nil {
|
||||
if os.IsNotExist(statErr) {
|
||||
return nil
|
||||
}
|
||||
return statErr
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
return recoveryRemove(path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) pruneEvents(cutoff time.Time) error {
|
||||
path := s.eventsPath()
|
||||
data, err := recoveryReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
kept := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var event RecoveryEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
kept = append(kept, line)
|
||||
continue
|
||||
}
|
||||
recordedAt, ok := parseRecordedAt(event.RecordedAt)
|
||||
if ok && recordedAt.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
|
||||
if len(kept) == 0 {
|
||||
return recoveryRemove(path)
|
||||
}
|
||||
content := strings.Join(kept, "\n") + "\n"
|
||||
return recoveryWriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
|
||||
func (s *Store) pruneOtherArtifacts(cutoff time.Time) error {
|
||||
entries, err := recoveryReadDir(s.dir())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if name == lastErrorFile || name == eventsFile {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(s.dir(), name)
|
||||
info, err := recoveryStat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
if err := recoveryRemoveAll(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRecordedAt(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -77,11 +77,28 @@ func FromShortcut(s Shortcut) corecmd.Spec {
|
||||
}
|
||||
|
||||
func fromShortcutPostMount(s Shortcut) func(*cobra.Command) {
|
||||
if len(s.Aliases) == 0 && strings.TrimSpace(s.SinglePositionalAliasFor) == "" {
|
||||
hasVisibleFlagAliases := false
|
||||
for _, flag := range s.Flags {
|
||||
if flag.AliasesVisible && len(flag.Aliases) > 0 {
|
||||
hasVisibleFlagAliases = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(s.Aliases) == 0 && strings.TrimSpace(s.SinglePositionalAliasFor) == "" && !hasVisibleFlagAliases {
|
||||
return nil
|
||||
}
|
||||
return func(cmd *cobra.Command) {
|
||||
cmd.Aliases = append([]string(nil), s.Aliases...)
|
||||
for _, flag := range s.Flags {
|
||||
if !flag.AliasesVisible {
|
||||
continue
|
||||
}
|
||||
for _, alias := range flag.Aliases {
|
||||
if mounted := cmd.Flags().Lookup(alias); mounted != nil {
|
||||
mounted.Hidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
name := strings.TrimSpace(s.SinglePositionalAliasFor)
|
||||
if name == "" {
|
||||
return
|
||||
@@ -117,6 +134,16 @@ func safetySpecDeclared(safety contract.SafetySpec) bool {
|
||||
strings.TrimSpace(safety.Idempotency) != ""
|
||||
}
|
||||
|
||||
// EffectiveSafety returns the exact safety declaration used by the runtime and
|
||||
// ContractFinal. Management/listing projections must use this instead of
|
||||
// re-inferring confirmation from the legacy Risk enum.
|
||||
func EffectiveSafety(s Shortcut) contract.SafetySpec {
|
||||
if safetySpecDeclared(s.Safety) {
|
||||
return s.Safety
|
||||
}
|
||||
return shortcutSafetySpec(s.risk())
|
||||
}
|
||||
|
||||
func shortcutExamples(tips []string) string {
|
||||
if len(tips) == 0 {
|
||||
return ""
|
||||
@@ -188,6 +215,7 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec {
|
||||
ValidationMode: corecmd.ValidationShortcut,
|
||||
RequiredError: fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc),
|
||||
Enum: append([]string(nil), f.Enum...),
|
||||
Aliases: append([]string(nil), f.Aliases...),
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user