Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9fd38d9b9d |
@@ -152,7 +152,6 @@ jobs:
|
|||||||
filename.startsWith('internal/interfacesnapshot/') ||
|
filename.startsWith('internal/interfacesnapshot/') ||
|
||||||
filename.startsWith('internal/app/upgrade') ||
|
filename.startsWith('internal/app/upgrade') ||
|
||||||
filename.startsWith('internal/transport/') ||
|
filename.startsWith('internal/transport/') ||
|
||||||
filename.startsWith('internal/recovery/') ||
|
|
||||||
filename.startsWith('internal/syncdata/') ||
|
filename.startsWith('internal/syncdata/') ||
|
||||||
filename.includes('/testdata/') ||
|
filename.includes('/testdata/') ||
|
||||||
filename.startsWith('testdata/') ||
|
filename.startsWith('testdata/') ||
|
||||||
@@ -1176,7 +1175,7 @@ jobs:
|
|||||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||||
COVERAGE_TARGET: "100"
|
COVERAGE_TARGET: "100"
|
||||||
COVERAGE_ENFORCE_OVERALL: "false"
|
COVERAGE_ENFORCE_OVERALL: "false"
|
||||||
COVERAGE_OVERALL_TOLERANCE: "0"
|
COVERAGE_OVERALL_TOLERANCE: "0.1"
|
||||||
run: |
|
run: |
|
||||||
policy_profile=coverage-policy.txt
|
policy_profile=coverage-policy.txt
|
||||||
if [ "$FULL_SUITE" != true ]; then
|
if [ "$FULL_SUITE" != true ]; then
|
||||||
|
|||||||
@@ -302,9 +302,8 @@ on the leaf:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
dws auth status # token_valid should be true
|
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> --jq '{canonical_path,interface_ref,parameters}' -f json
|
||||||
dws schema <mcp-canonical> -f json
|
# or CLI path: dws schema --cli-path "drive copy" --jq '{canonical_path,interface_ref,parameters}' -f json
|
||||||
# or CLI path: dws schema --cli-path "drive copy" -f json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
|
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:
|
Split work by product groups. Each agent must:
|
||||||
|
|
||||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
|
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live
|
||||||
for its tools.
|
`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;
|
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
|
||||||
forbid wholesale JSON merges from review dumps.
|
forbid wholesale JSON merges from review dumps.
|
||||||
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
|
- 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
|
`parameters` object for commands without flags. Keep it suitable for the #602
|
||||||
compatibility baseline and fail rather than silently emitting a partial
|
compatibility baseline and fail rather than silently emitting a partial
|
||||||
export.
|
export.
|
||||||
- `schema --all` is not normal command discovery. Use overview -> product/group
|
- `schema --all` is not normal command discovery. Use overview -> compact
|
||||||
-> leaf for routine Agent work. `--compact` is supported for context-saving
|
product/group -> compact leaf for routine Agent work. `--compact` is the
|
||||||
projections, but a compact full export is not a complete compatibility
|
reviewed positive-field allowlist for Agent context: new full/audit fields
|
||||||
baseline.
|
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
|
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
|
||||||
executable accepts. A leaf Schema defines Agent selection, parameter mapping
|
executable accepts. A compact leaf defines Agent selection, CLI parameters,
|
||||||
and constraints, and safety/confirmation semantics. A conflict is contract
|
constraints, and safety/confirmation semantics. Full leaf fields such as
|
||||||
drift, not permission to guess.
|
`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.
|
- Schema and Help describe commands; neither returns DingTalk business data.
|
||||||
After discovery, execute the real read/search/list command to obtain data.
|
After discovery, execute the real read/search/list command to obtain data.
|
||||||
|
|||||||
@@ -6,6 +6,50 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- **`dws recovery` CLI** — keeps a visible Deprecated compatibility stub for
|
||||||
|
plan/execute/finalize that returns an explicit “不再支持” notice. The
|
||||||
|
`internal/recovery` package is removed; Skills must not teach this path.
|
||||||
|
Transport errors keep generic retry/auth guidance without pointing callers
|
||||||
|
at recovery.
|
||||||
|
|
||||||
|
## [1.0.57] - 2026-08-06
|
||||||
|
|
||||||
|
This stable release promotes the fully delivered `v1.0.57-beta.3` baseline.
|
||||||
|
It includes reviewed document shortcuts and chat reply mentions, together with
|
||||||
|
the v1.0.57 beta-line command-contract, document, chat, OA, Wiki, compatibility,
|
||||||
|
and CI reliability improvements validated through the prerelease channel.
|
||||||
|
|
||||||
|
- **Promote v1.0.57-beta.3** — publishes the validated prerelease baseline as
|
||||||
|
the stable `v1.0.57` release 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
|
## [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
|
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
|
||||||
@@ -59,6 +103,7 @@ and compatibility and CI reliability fixes.
|
|||||||
|
|
||||||
### Changed
|
### 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.
|
- **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.
|
- **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.
|
- **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
|
class DingtalkWorkspaceCliBeta < Formula
|
||||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||||
version "1.0.56-beta.4"
|
version "1.0.57-beta.3"
|
||||||
license "Apache-2.0"
|
license "Apache-2.0"
|
||||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||||
|
|
||||||
on_macos do
|
on_macos do
|
||||||
if Hardware::CPU.arm?
|
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"
|
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-arm64.tar.gz"
|
||||||
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
|
sha256 "b1ea300a76654751ea33540d8a244b0c81b5df3947786980f95a5a19362a097a"
|
||||||
else
|
else
|
||||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
|
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-amd64.tar.gz"
|
||||||
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
|
sha256 "68b5f6e38bec994fa4db4bef5db1629c7bce799bb9837c447008c3325fc886e3"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
on_linux do
|
on_linux do
|
||||||
if Hardware::CPU.arm?
|
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"
|
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-arm64.tar.gz"
|
||||||
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
|
sha256 "350e74f1a2611975e476e113e50264975a98185c11ee889a83d9480c0f10181b"
|
||||||
else
|
else
|
||||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
|
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-amd64.tar.gz"
|
||||||
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
|
sha256 "2c27a9a884650a7a60545d9447f1b966667216e94c333ebf9d69f9b2ead96e04"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resource "skills" do
|
resource "skills" do
|
||||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
|
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-skills.zip"
|
||||||
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
|
sha256 "abaa8feaa3c61fff048cfd1139e1fc5b91c329eb666d2eb852797a3f5c4c0cac"
|
||||||
end
|
end
|
||||||
|
|
||||||
def install
|
def install
|
||||||
|
|||||||
@@ -131,6 +131,9 @@ skill-context-budget:
|
|||||||
multi-im-skill-chain-integrity:
|
multi-im-skill-chain-integrity:
|
||||||
@./scripts/policy/check-multi-im-skill-chain.sh
|
@./scripts/policy/check-multi-im-skill-chain.sh
|
||||||
|
|
||||||
|
skill-mono-multi-content:
|
||||||
|
@./scripts/policy/check-mono-multi-skill-content.sh
|
||||||
|
|
||||||
cli-smoke:
|
cli-smoke:
|
||||||
@./scripts/policy/check-cli-smoke.sh
|
@./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 |
|
| Mode | What gets installed | Best for |
|
||||||
|------|----------------------|----------|
|
|------|----------------------|----------|
|
||||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
| **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** | 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.
|
|
||||||
|
|
||||||
How to pick:
|
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:
|
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 <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.
|
- 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.
|
- 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
|
dws aitable record query --help
|
||||||
|
|
||||||
# Discover within a product, then inspect the selected leaf contract
|
# Discover within a product, then inspect the selected leaf contract
|
||||||
dws schema aitable
|
dws schema aitable --compact
|
||||||
dws schema "aitable record query"
|
dws schema "aitable record query" --compact
|
||||||
|
|
||||||
# Execute the real business query
|
# Execute the real business query
|
||||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
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
|
### Agent Skills
|
||||||
|
|
||||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
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/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.
|
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) |
|
| 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 |
|
| 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 |
|
| 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) |
|
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
|
||||||
|
|
||||||
<details>
|
<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 |
|
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
|
||||||
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
|
| 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>
|
</details>
|
||||||
|
|
||||||
@@ -624,7 +621,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
|||||||
```bash
|
```bash
|
||||||
# Built-in jq expressions
|
# Built-in jq expressions
|
||||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
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
|
# Return only specific fields
|
||||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
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>
|
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dws schema aitable # discover product commands
|
dws schema aitable --compact # discover product commands
|
||||||
dws schema "aitable record query" # view the selected leaf contract
|
dws schema "aitable record query" --compact # view the selected Agent leaf contract
|
||||||
dws schema "aitable record query" --jq '.tool.required' # view required fields
|
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # view required fields
|
||||||
dws schema --all # full export for CI/audit/baselines
|
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,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||||
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||||
|
|
||||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
|
||||||
|
|
||||||
怎么选:
|
怎么选:
|
||||||
|
|
||||||
@@ -365,7 +363,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
|||||||
命令帮助和 Schema 分别负责命令契约的不同部分:
|
命令帮助和 Schema 分别负责命令契约的不同部分:
|
||||||
|
|
||||||
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
|
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
|
||||||
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
|
- `dws schema "<path>" --compact` 是 Agent 选命令、CLI 参数与约束、风险和确认语义的规范视图;映射或 provenance 审计使用 full leaf 配合 `--jq` 精确投影。
|
||||||
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
|
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
|
||||||
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
|
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
|
||||||
|
|
||||||
@@ -374,21 +372,21 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
|||||||
dws aitable record query --help
|
dws aitable record query --help
|
||||||
|
|
||||||
# 先在产品内发现命令,再查看选中 leaf 的契约
|
# 先在产品内发现命令,再查看选中 leaf 的契约
|
||||||
dws schema aitable
|
dws schema aitable --compact
|
||||||
dws schema "aitable record query"
|
dws schema "aitable record query" --compact
|
||||||
|
|
||||||
# 执行真实业务查询
|
# 执行真实业务查询
|
||||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
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 Skills
|
||||||
|
|
||||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||||
|
|
||||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
- `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 目录已完全退役,不得重新引入。
|
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/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
|
||||||
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
|
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
|
||||||
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
|
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
|
||||||
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
|
|
||||||
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
|
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -533,7 +530,7 @@ dws event stop <subscribe_id>
|
|||||||
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
|
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
|
||||||
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
|
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
|
||||||
|
|
||||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
|
Agent 工作流和事件参数详见 `skills/multi/dingtalk-misc/references/event.md`。
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -618,7 +615,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
|||||||
```bash
|
```bash
|
||||||
# 内置 jq 表达式
|
# 内置 jq 表达式
|
||||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
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
|
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>
|
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dws schema aitable # 发现产品命令
|
dws schema aitable --compact # 发现产品命令
|
||||||
dws schema "aitable record query" # 查看选中 leaf 契约
|
dws schema "aitable record query" --compact # 查看 Agent leaf 契约
|
||||||
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
|
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # 定向查看必填字段
|
||||||
dws schema --all # CI/审计/基线的全量导出
|
dws schema --all # CI/审计/基线的全量导出
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@
|
|||||||
- `internal/output`: response formatting (json, table, raw, pretty)
|
- `internal/output`: response formatting (json, table, raw, pretty)
|
||||||
- `internal/logging`: structured logging and argument sanitization
|
- `internal/logging`: structured logging and argument sanitization
|
||||||
- `internal/tui`: terminal UI helpers
|
- `internal/tui`: terminal UI helpers
|
||||||
- `internal/recovery`: panic recovery and graceful degradation
|
|
||||||
- `pkg/configmeta`: environment variable registry and documentation
|
- `pkg/configmeta`: environment variable registry and documentation
|
||||||
- `pkg/config`: configuration constants and paths
|
- `pkg/config`: configuration constants and paths
|
||||||
- `pkg/edition`: edition detection (oss vs enterprise)
|
- `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`
|
- Error message or category issues: inspect `internal/errors`
|
||||||
- Audit log issues: inspect `internal/audit`
|
- Audit log issues: inspect `internal/audit`
|
||||||
- Plugin loading or command surface: inspect `internal/plugin`
|
- 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
|
## Policy Checks
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
|
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
|
||||||
|
|
||||||
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
|
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
|
||||||
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
|
> 用途:评测 agent(加载 `dingtalk-misc` 的 `references/devapp.md` 后)能否正确处理开放平台 dev 任务。
|
||||||
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
|
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
|
||||||
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
|
> 约定:所有命令应带 `--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
|
### 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 下载对应平台的预编译二进制。
|
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
|
||||||
2. 安装 `dws` 到默认目录 `~/.local/bin`。
|
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_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
|
||||||
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
|
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
|
||||||
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
|
| `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`。
|
> `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 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 json # JSON (for agents and piping / 适合 agent)
|
||||||
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
|
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 / 试运行
|
## Dry Run / 试运行
|
||||||
@@ -119,27 +119,28 @@ Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
dws schema # 当前公开产品面的紧凑概览
|
dws schema # 当前公开产品面的紧凑概览
|
||||||
dws schema calendar # 展开一个产品
|
dws schema calendar --compact # Agent 产品视图
|
||||||
dws schema "calendar event" # 展开一个命令分组
|
dws schema "calendar event" --compact # Agent 分组视图
|
||||||
dws schema "calendar event create" # 按 CLI 空格路径查询工具
|
dws schema "calendar event create" --compact # Agent leaf(CLI 空格路径)
|
||||||
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
|
dws schema calendar.create_calendar_event --compact # Agent leaf(canonical path)
|
||||||
dws schema --cli-path "calendar event create" # 显式 CLI path
|
dws schema --cli-path "calendar event create" --compact # Agent leaf(显式 CLI path)
|
||||||
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
|
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
|
||||||
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
|
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
|
||||||
```
|
```
|
||||||
|
|
||||||
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 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 与业务数据的边界
|
### Schema、Help 与业务数据的边界
|
||||||
|
|
||||||
| 问题 | 事实源 |
|
| 问题 | 事实源 |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --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 命令 |
|
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||||
|
|
||||||
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
|
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## 第一步:安装 dws
|
## 第一步:安装 dws
|
||||||
|
|
||||||
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
|
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
|
||||||
|
|
||||||
### macOS / Linux
|
### macOS / Linux
|
||||||
|
|
||||||
|
|||||||
@@ -231,7 +231,8 @@ Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/prov
|
|||||||
| 问题 | 事实源 |
|
| 问题 | 事实源 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
|
| 当前二进制是否暴露命令、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 命令 |
|
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||||
|
|
||||||
Schema 和 Help 冲突是契约漂移,不能静默猜测:
|
Schema 和 Help 冲突是契约漂移,不能静默猜测:
|
||||||
@@ -246,10 +247,10 @@ Schema 和 Help 冲突是契约漂移,不能静默猜测:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
dws schema # 产品紧凑概览
|
dws schema # 产品紧凑概览
|
||||||
dws schema calendar # 产品摘要
|
dws schema calendar --compact # Agent 产品摘要
|
||||||
dws schema "calendar event" # 分组摘要
|
dws schema "calendar event" --compact # Agent 分组摘要
|
||||||
dws schema "calendar event create" # 完整 leaf
|
dws schema "calendar event create" --compact # Agent leaf
|
||||||
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
|
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
|
||||||
dws schema --all # 所有工具的完整 leaf 导出
|
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 渐进查询。
|
`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 查询业务数据。
|
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"generated_at": "2026-07-29T00:06:19.285348",
|
"generated_at": "2026-08-05T22:43:52.497190",
|
||||||
"count": 265,
|
"count": 294,
|
||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"suite": "read",
|
"suite": "read",
|
||||||
@@ -485,7 +485,7 @@
|
|||||||
"risk": "read",
|
"risk": "read",
|
||||||
"status": "reviewed_available",
|
"status": "reviewed_available",
|
||||||
"disposition": "primary_smart",
|
"disposition": "primary_smart",
|
||||||
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。",
|
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。",
|
||||||
"availability": "available"
|
"availability": "available"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -658,6 +658,16 @@
|
|||||||
"semantic_delta": "群邀请链接是一对一读取;Shortcut 未增加生命周期或分享编排。",
|
"semantic_delta": "群邀请链接是一对一读取;Shortcut 未增加生命周期或分享编排。",
|
||||||
"availability": "available"
|
"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",
|
"suite": "semantic",
|
||||||
"service": "chat",
|
"service": "chat",
|
||||||
@@ -715,7 +725,7 @@
|
|||||||
"risk": "read",
|
"risk": "read",
|
||||||
"status": "reviewed_available",
|
"status": "reviewed_available",
|
||||||
"disposition": "primary_smart",
|
"disposition": "primary_smart",
|
||||||
"semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。",
|
"semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。",
|
||||||
"availability": "available"
|
"availability": "available"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1215,7 +1225,7 @@
|
|||||||
"risk": "read",
|
"risk": "read",
|
||||||
"status": "reviewed_available",
|
"status": "reviewed_available",
|
||||||
"disposition": "semantic_adapter",
|
"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"
|
"availability": "available"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1295,7 +1305,7 @@
|
|||||||
"risk": "read",
|
"risk": "read",
|
||||||
"status": "reviewed_available",
|
"status": "reviewed_available",
|
||||||
"disposition": "primary_smart",
|
"disposition": "primary_smart",
|
||||||
"semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。",
|
"semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
|
||||||
"availability": "available"
|
"availability": "available"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1315,7 +1325,7 @@
|
|||||||
"risk": "write",
|
"risk": "write",
|
||||||
"status": "reviewed_available",
|
"status": "reviewed_available",
|
||||||
"disposition": "semantic_adapter",
|
"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"
|
"availability": "available"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1344,8 +1354,8 @@
|
|||||||
"command": "+messages-send-card",
|
"command": "+messages-send-card",
|
||||||
"risk": "write",
|
"risk": "write",
|
||||||
"status": "reviewed_available",
|
"status": "reviewed_available",
|
||||||
"disposition": "schema_leaf",
|
"disposition": "semantic_adapter",
|
||||||
"semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。",
|
"semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。",
|
||||||
"availability": "available"
|
"availability": "available"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1415,7 +1425,7 @@
|
|||||||
"risk": "read",
|
"risk": "read",
|
||||||
"status": "reviewed_available",
|
"status": "reviewed_available",
|
||||||
"disposition": "primary_smart",
|
"disposition": "primary_smart",
|
||||||
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。",
|
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。",
|
||||||
"availability": "available"
|
"availability": "available"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1435,7 +1445,7 @@
|
|||||||
"risk": "read",
|
"risk": "read",
|
||||||
"status": "reviewed_available",
|
"status": "reviewed_available",
|
||||||
"disposition": "primary_smart",
|
"disposition": "primary_smart",
|
||||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。",
|
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||||
"availability": "available"
|
"availability": "available"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1708,123 +1718,454 @@
|
|||||||
"status": "real-ok"
|
"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",
|
"service": "doc",
|
||||||
"command": "+comment-create",
|
"command": "+comment-create",
|
||||||
"risk": "write",
|
"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",
|
"service": "doc",
|
||||||
"command": "+comment-list",
|
"command": "+comment-list",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"status": "real-ok"
|
"status": "reviewed_available",
|
||||||
|
"disposition": "semantic_adapter",
|
||||||
|
"semantic_delta": "统一评论类型、解决状态与分页过滤。",
|
||||||
|
"availability": "available"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"suite": "write",
|
"suite": "semantic",
|
||||||
"service": "doc",
|
"service": "doc",
|
||||||
"command": "+comment-reply",
|
"command": "+comment-reply",
|
||||||
"risk": "write",
|
"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",
|
"service": "doc",
|
||||||
"command": "+copy",
|
"command": "+copy",
|
||||||
"risk": "write",
|
"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",
|
"service": "doc",
|
||||||
"command": "+doc-append",
|
"command": "+doc-append",
|
||||||
"risk": "write",
|
"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",
|
"service": "doc",
|
||||||
"command": "+export-get",
|
"command": "+export-get",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"status": "real-ok"
|
"status": "reviewed_available",
|
||||||
|
"disposition": "alias_internal",
|
||||||
|
"semantic_delta": "按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。",
|
||||||
|
"availability": "available"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"suite": "read",
|
"suite": "semantic",
|
||||||
"service": "doc",
|
"service": "doc",
|
||||||
"command": "+export-submit",
|
"command": "+export-submit",
|
||||||
"risk": "read",
|
"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",
|
"service": "doc",
|
||||||
"command": "+find-doc",
|
"command": "+find-doc",
|
||||||
"risk": "read",
|
"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",
|
"service": "doc",
|
||||||
"command": "+list",
|
"command": "+list",
|
||||||
"risk": "read",
|
"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",
|
"service": "doc",
|
||||||
"command": "+move",
|
"command": "+move",
|
||||||
"risk": "write",
|
"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",
|
"service": "doc",
|
||||||
"command": "+search",
|
"command": "+search",
|
||||||
"risk": "read",
|
"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",
|
"service": "doc",
|
||||||
"command": "+share-doc",
|
"command": "+share-doc",
|
||||||
"risk": "write",
|
"risk": "write",
|
||||||
"status": "real-ok"
|
"status": "reviewed_available",
|
||||||
|
"disposition": "alias_internal",
|
||||||
|
"semantic_delta": "保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。",
|
||||||
|
"availability": "available"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"suite": "read",
|
"suite": "semantic",
|
||||||
"service": "doc",
|
"service": "doc",
|
||||||
"command": "+template-list",
|
"command": "+template-list",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"status": "real-ok"
|
"status": "reviewed_available",
|
||||||
|
"disposition": "semantic_adapter",
|
||||||
|
"semantic_delta": "统一 MY/PUBLIC 模板浏览和分页参数。",
|
||||||
|
"availability": "available"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"suite": "read",
|
"suite": "semantic",
|
||||||
"service": "doc",
|
"service": "doc",
|
||||||
"command": "+template-search",
|
"command": "+template-search",
|
||||||
"risk": "read",
|
"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",
|
"service": "doc",
|
||||||
"command": "+version-list",
|
"command": "+version-list",
|
||||||
"risk": "read",
|
"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",
|
"service": "doc",
|
||||||
"command": "+version-revert",
|
"command": "+version-revert",
|
||||||
"risk": "high-risk-write",
|
"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",
|
"service": "doc",
|
||||||
"command": "+version-save",
|
"command": "+version-save",
|
||||||
"risk": "write",
|
"risk": "write",
|
||||||
"status": "real-ok"
|
"status": "reviewed_available",
|
||||||
|
"disposition": "alias_internal",
|
||||||
|
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
|
||||||
|
"availability": "available"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"suite": "write",
|
"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 "" }})
|
edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }})
|
||||||
captureRuntimeFailure(executor.Invocation{}, nil, nil)
|
captureRuntimeFailure(executor.Invocation{}, nil, nil)
|
||||||
captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), 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, "")
|
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||||
if hostControlProviderFromEnv() != "" {
|
if hostControlProviderFromEnv() != "" {
|
||||||
@@ -360,6 +354,10 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
|
|||||||
|
|
||||||
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
|
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
|
||||||
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
|
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)
|
command.SetOut(io.Discard)
|
||||||
if err := command.RunE(command, nil); err != nil {
|
if err := command.RunE(command, nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -389,18 +387,18 @@ func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.
|
|||||||
for _, format := range []string{"json", "pretty", "table"} {
|
for _, format := range []string{"json", "pretty", "table"} {
|
||||||
_ = cacheRoot.PersistentFlags().Set("format", format)
|
_ = cacheRoot.PersistentFlags().Set("format", format)
|
||||||
cacheCmd.SetOut(io.Discard)
|
cacheCmd.SetOut(io.Discard)
|
||||||
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
|
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fail := errors.New("write")
|
fail := errors.New("write")
|
||||||
cacheCmd.SetOut(appFailWriter{err: fail})
|
cacheCmd.SetOut(appFailWriter{err: fail})
|
||||||
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
|
_ = 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)
|
t.Fatalf("pretty write error = %v", err)
|
||||||
}
|
}
|
||||||
_ = cacheRoot.PersistentFlags().Set("format", "table")
|
_ = 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)
|
t.Fatalf("table write error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"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 {
|
type cacheCompatNotice struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
@@ -28,24 +32,32 @@ type cacheCompatNotice struct {
|
|||||||
Replacement string `json:"replacement,omitempty"`
|
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 {
|
func newCacheCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "cache",
|
Use: "cache",
|
||||||
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
|
Short: "不再支持:服务发现缓存兼容入口",
|
||||||
Hidden: true,
|
Long: "此命令组仅为历史 argv 兼容保留。静态端点模式下无需服务发现缓存;Skill / Agent 请勿引导此路径。",
|
||||||
|
Deprecated: "不再支持;" + cacheUnsupportedMessage,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return cmd.Help()
|
return printCacheCompatNotice(cmd, "dws cache")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, name := range []string{"refresh", "status", "clean"} {
|
for _, name := range []string{"refresh", "status", "clean"} {
|
||||||
|
subName := name
|
||||||
sub := &cobra.Command{
|
sub := &cobra.Command{
|
||||||
Use: name,
|
Use: subName,
|
||||||
Short: "已弃用:静态端点模式无需服务发现缓存",
|
Short: "不再支持:静态端点模式无需服务发现缓存",
|
||||||
|
Deprecated: "不再支持;" + cacheUnsupportedMessage,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return printCacheCompatNotice(cmd, name)
|
return printCacheCompatNotice(cmd, "dws cache "+subName)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.AddCommand(sub)
|
cmd.AddCommand(sub)
|
||||||
@@ -56,9 +68,9 @@ func newCacheCommand() *cobra.Command {
|
|||||||
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
||||||
notice := cacheCompatNotice{
|
notice := cacheCompatNotice{
|
||||||
Status: "deprecated",
|
Status: "deprecated",
|
||||||
Command: "dws cache " + command,
|
Command: command,
|
||||||
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
|
Message: cacheUnsupportedMessage,
|
||||||
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
|
Replacement: cacheReplacementHint,
|
||||||
}
|
}
|
||||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||||
switch strings.ToLower(strings.TrimSpace(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)
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
|
||||||
case "pretty":
|
case "pretty":
|
||||||
data, _ := json.MarshalIndent(notice, "", " ")
|
data, _ := json.MarshalIndent(notice, "", " ")
|
||||||
var err error
|
_, err := fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
|
||||||
return err
|
return err
|
||||||
default:
|
default:
|
||||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
|
_, 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/keychain"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
"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/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/safety"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
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) {
|
func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
|
||||||
RegisterPluginAuth("coverage-registry", &PluginAuth{Token: "token"})
|
RegisterPluginAuth("coverage-registry", &PluginAuth{Token: "token"})
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
@@ -511,59 +401,6 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
|||||||
_ = defaultPATMCPEndpoint()
|
_ = 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) {
|
func TestCrossPlatformCoverageEventCommandPureCoverage(t *testing.T) {
|
||||||
oldEdition := edition.Get()
|
oldEdition := edition.Get()
|
||||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||||
@@ -760,7 +597,7 @@ func TestCrossPlatformCoverageVersionCacheCompletionCoverage(t *testing.T) {
|
|||||||
child := &cobra.Command{Use: "child"}
|
child := &cobra.Command{Use: "child"}
|
||||||
root.AddCommand(child)
|
root.AddCommand(child)
|
||||||
child.SetOut(io.Discard)
|
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)
|
t.Fatalf("cache %s: %v", format, err)
|
||||||
}
|
}
|
||||||
root.RemoveCommand(child)
|
root.RemoveCommand(child)
|
||||||
@@ -1747,9 +1584,6 @@ func TestCrossPlatformCoverageDoctorCommandCoverage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, jsonOut := range []bool{false, true} {
|
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 {
|
if got := doctorCheckPerf(io.Discard, jsonOut); got.Status != statusPass {
|
||||||
t.Fatalf("perf check = %#v", got)
|
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"))
|
_ = os.Symlink(filepath.Join(mono, "SKILL.md"), filepath.Join(mono, "linked.md"))
|
||||||
multi := filepath.Join(t.TempDir(), "multi")
|
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)
|
dir := filepath.Join(multi, name)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
|
||||||
t.Fatal(err)
|
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 == "" {
|
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 / %q, %v", output, warnings, err)
|
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)
|
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") {
|
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) {
|
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 {
|
for _, tc := range []struct {
|
||||||
include []string
|
include []string
|
||||||
exclude []string
|
exclude []string
|
||||||
@@ -2238,7 +2072,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
|||||||
t.Errorf("filter %#v/%#v = %v", tc.include, tc.exclude, err)
|
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(selected, all)
|
||||||
}
|
}
|
||||||
_ = ensureMandatorySharedSkill([]string{"dingtalk-a"}, []string{"dingtalk-a"})
|
_ = ensureMandatorySharedSkill([]string{"dingtalk-a"}, []string{"dingtalk-a"})
|
||||||
|
|||||||
@@ -302,7 +302,6 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
|
|||||||
_ = newConfigCommand()
|
_ = newConfigCommand()
|
||||||
_ = newCacheCommand()
|
_ = newCacheCommand()
|
||||||
_ = newVersionCommand()
|
_ = newVersionCommand()
|
||||||
_ = newRecoveryCommand(&GlobalFlags{})
|
|
||||||
_ = newAPICommand(&GlobalFlags{})
|
_ = newAPICommand(&GlobalFlags{})
|
||||||
_ = NewRootCommand(context.Background())
|
_ = NewRootCommand(context.Background())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func newDoctorCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "doctor",
|
Use: "doctor",
|
||||||
Short: "环境健康检查",
|
Short: "环境健康检查",
|
||||||
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
|
Long: "一键检查登录态、网络连通性和版本更新,快速定位常见问题。",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: runDoctor,
|
RunE: runDoctor,
|
||||||
@@ -92,9 +92,6 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
|||||||
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
|
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
|
||||||
checks = append(checks, networkResult)
|
checks = append(checks, networkResult)
|
||||||
|
|
||||||
cacheResult := doctorCheckCache(w, jsonOut)
|
|
||||||
checks = append(checks, cacheResult)
|
|
||||||
|
|
||||||
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
|
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
|
||||||
checks = append(checks, versionResult)
|
checks = append(checks, versionResult)
|
||||||
|
|
||||||
@@ -297,24 +294,6 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
|||||||
return r
|
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 ───────────────────────────────────────────────────────
|
// ── Version check ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
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) {
|
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
|
||||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
|
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
|
||||||
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
|
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
|
||||||
// contract asserts the exact phrase below inside the installed
|
// 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.
|
// fail at PR time instead of at release time.
|
||||||
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
|
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
|
||||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||||
@@ -19,12 +19,12 @@ func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(cleanup)
|
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 {
|
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 = "禁止选择第一项、最近登录或最近使用账号"
|
const rule = "禁止选择第一项、最近登录或最近使用账号"
|
||||||
if !strings.Contains(string(data), 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
|
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) {
|
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)
|
all, err := listMultiSkillNames(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !contains(all, "dws-shared") {
|
if !contains(all, "dingtalk-shared") {
|
||||||
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
|
t.Fatalf("listMultiSkillNames did not enumerate dingtalk-shared: %v", all)
|
||||||
}
|
}
|
||||||
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
|
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if contains(filtered, "dws-shared") {
|
if contains(filtered, "dingtalk-shared") {
|
||||||
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
|
t.Fatalf("precondition: filter should drop dingtalk-shared for -s aitable: %v", filtered)
|
||||||
}
|
}
|
||||||
final := ensureMandatorySharedSkill(filtered, all)
|
final := ensureMandatorySharedSkill(filtered, all)
|
||||||
if !contains(final, "dws-shared") {
|
if !contains(final, "dingtalk-shared") {
|
||||||
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
|
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()
|
dest := t.TempDir()
|
||||||
var out, errOut bytes.Buffer
|
var out, errOut bytes.Buffer
|
||||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
|
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
|
||||||
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
|
t.Fatalf("dingtalk-shared not installed with -s aitable: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
|
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||||
t.Fatalf("dingtalk-aitable not installed: %v", err)
|
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) {
|
func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
||||||
src := writeMultiSkillSrc(t, "dingtalk-aitable")
|
src := writeMultiSkillSrc(t, "dingtalk-aitable")
|
||||||
all, err := listMultiSkillNames(src)
|
all, err := listMultiSkillNames(src)
|
||||||
@@ -77,7 +77,7 @@ func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
|
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
|
||||||
if contains(final, "dws-shared") {
|
if contains(final, "dingtalk-shared") {
|
||||||
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
|
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
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
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"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
const recoveryUnsupportedMessage = "dws recovery 不再支持:失败快照恢复计划/执行/闭环已下线,请改用 doctor / schema / 对应业务命令排查。"
|
||||||
recoverySavePlan = (*recovery.Store).SavePlan
|
|
||||||
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
|
|
||||||
)
|
|
||||||
|
|
||||||
func newRecoveryCommand(flags *GlobalFlags) *cobra.Command {
|
type recoveryCompatNotice struct {
|
||||||
var (
|
Status string `json:"status"`
|
||||||
planUseLast bool
|
Command string `json:"command"`
|
||||||
planEventID string
|
Message string `json:"message"`
|
||||||
executeUseLast bool
|
}
|
||||||
executeEventID string
|
|
||||||
finalEventID string
|
|
||||||
finalOutcome string
|
|
||||||
executionFile string
|
|
||||||
)
|
|
||||||
|
|
||||||
runtime := newRecoveryRuntime(flags)
|
|
||||||
|
|
||||||
|
// 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{
|
cmd := &cobra.Command{
|
||||||
Use: "recovery",
|
Use: "recovery",
|
||||||
Short: "错误恢复辅助命令",
|
Short: "不再支持:错误恢复辅助命令(兼容入口)",
|
||||||
Long: "读取失败快照,生成恢复分析,并回写恢复结果。",
|
Long: "此命令组仅为历史 argv 兼容保留,不再读取失败快照或生成恢复计划。Skill / Agent 请勿引导此路径。",
|
||||||
|
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return cmd.Help()
|
return printRecoveryUnsupported(cmd, "dws recovery")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
planCmd := &cobra.Command{
|
planCmd := &cobra.Command{
|
||||||
Use: "plan",
|
Use: "plan",
|
||||||
Short: "基于失败快照生成恢复计划",
|
Short: "不再支持:基于失败快照生成恢复计划",
|
||||||
|
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
store := recovery.NewStore(defaultConfigDir())
|
return printRecoveryUnsupported(cmd, "dws recovery plan")
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
planCmd.Flags().BoolVar(&planUseLast, "last", false, "读取最近一次失败快照")
|
planCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
|
||||||
planCmd.Flags().StringVar(&planEventID, "event-id", "", "按 event_id 读取失败快照")
|
planCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||||
|
|
||||||
executeCmd := &cobra.Command{
|
executeCmd := &cobra.Command{
|
||||||
Use: "execute",
|
Use: "execute",
|
||||||
Short: "生成面向 Agent 的恢复分析包",
|
Short: "不再支持:生成面向 Agent 的恢复分析包",
|
||||||
|
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
store := recovery.NewStore(defaultConfigDir())
|
return printRecoveryUnsupported(cmd, "dws recovery execute")
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
executeCmd.Flags().BoolVar(&executeUseLast, "last", false, "读取最近一次失败快照")
|
executeCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
|
||||||
executeCmd.Flags().StringVar(&executeEventID, "event-id", "", "按 event_id 读取失败快照")
|
executeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||||
|
|
||||||
finalizeCmd := &cobra.Command{
|
finalizeCmd := &cobra.Command{
|
||||||
Use: "finalize",
|
Use: "finalize",
|
||||||
Short: "回写恢复闭环结果",
|
Short: "不再支持:回写恢复闭环结果",
|
||||||
|
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if strings.TrimSpace(finalEventID) == "" {
|
return printRecoveryUnsupported(cmd, "dws recovery finalize")
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
finalizeCmd.Flags().StringVar(&finalEventID, "event-id", "", "恢复事件 ID")
|
finalizeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||||
finalizeCmd.Flags().StringVar(&finalOutcome, "outcome", "", "恢复结果: recovered|failed|handoff")
|
finalizeCmd.Flags().String("outcome", "", "旧版兼容参数;recovery 不再支持")
|
||||||
finalizeCmd.Flags().StringVar(&executionFile, "execution-file", "", "Agent 执行详情 JSON 文件")
|
finalizeCmd.Flags().String("execution-file", "", "旧版兼容参数;recovery 不再支持")
|
||||||
|
|
||||||
cmd.AddCommand(planCmd, executeCmd, finalizeCmd)
|
cmd.AddCommand(planCmd, executeCmd, finalizeCmd)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadRecoverySnapshot(store *recovery.Store, useLast bool, eventID string) (*recovery.LastError, error) {
|
func printRecoveryUnsupported(cmd *cobra.Command, command string) error {
|
||||||
if useLast && strings.TrimSpace(eventID) != "" {
|
notice := recoveryCompatNotice{
|
||||||
return nil, fmt.Errorf("--last 和 --event-id 不能同时使用")
|
Status: "unsupported",
|
||||||
|
Command: command,
|
||||||
|
Message: recoveryUnsupportedMessage,
|
||||||
}
|
}
|
||||||
switch {
|
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||||
case useLast:
|
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||||
last, err := store.LoadLastError()
|
case "", "json":
|
||||||
if err != nil {
|
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(notice); err != nil {
|
||||||
return nil, fmt.Errorf("读取失败快照失败: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
return last, nil
|
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||||
case strings.TrimSpace(eventID) != "":
|
case "pretty":
|
||||||
last, err := store.LoadErrorByEvent(strings.TrimSpace(eventID))
|
data, _ := json.MarshalIndent(notice, "", " ")
|
||||||
if err != nil {
|
if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
|
||||||
return nil, fmt.Errorf("读取失败快照失败: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
return last, nil
|
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("必须通过 --last 或 --event-id 指定失败快照")
|
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", notice.Command, notice.Message); err != nil {
|
||||||
}
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
return attempts, nil
|
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
"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) {
|
// captureRuntimeFailure previously persisted a recovery snapshot for
|
||||||
if rawErr == nil && wrappedErr == nil {
|
// `dws recovery`. The recovery package is gone; keep a no-op seam so runner
|
||||||
return
|
// failure paths stay stable while the visible Deprecated shim remains.
|
||||||
}
|
func captureRuntimeFailure(_ executor.Invocation, _, _ error) {}
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
+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"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
"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/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/internal/shortcut/usage"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||||
@@ -51,15 +50,11 @@ import (
|
|||||||
|
|
||||||
type outputFileContextKey struct{}
|
type outputFileContextKey struct{}
|
||||||
|
|
||||||
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
|
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
|
||||||
rootExecuteCommand = (*cobra.Command).ExecuteC
|
rootExecuteCommand = (*cobra.Command).ExecuteC
|
||||||
rootNewRootCommandWithEngine = NewRootCommandWithEngine
|
rootNewRootCommandWithEngine = NewRootCommandWithEngine
|
||||||
rootRunPreParse = pipeline.RunPreParse
|
rootRunPreParse = pipeline.RunPreParse
|
||||||
rootLatestRecoveryCapture = recovery.LatestCapture
|
|
||||||
rootResetRecoveryState = recovery.ResetRuntimeState
|
|
||||||
rootStopAllStdioClients = StopAllStdioClients
|
rootStopAllStdioClients = StopAllStdioClients
|
||||||
rootLoadPlugins = loadPlugins
|
rootLoadPlugins = loadPlugins
|
||||||
rootMkdirAll = os.MkdirAll
|
rootMkdirAll = os.MkdirAll
|
||||||
@@ -107,7 +102,6 @@ func Execute() (exitCode int) {
|
|||||||
ctx = WithTimingCollector(ctx, timing)
|
ctx = WithTimingCollector(ctx, timing)
|
||||||
|
|
||||||
initStart := time.Now()
|
initStart := time.Now()
|
||||||
rootResetRecoveryState()
|
|
||||||
engine := newPipelineEngine()
|
engine := newPipelineEngine()
|
||||||
root := rootNewRootCommandWithEngine(ctx, engine)
|
root := rootNewRootCommandWithEngine(ctx, engine)
|
||||||
timing.Record("cmd_init", time.Since(initStart))
|
timing.Record("cmd_init", time.Since(initStart))
|
||||||
@@ -133,9 +127,6 @@ func Execute() (exitCode int) {
|
|||||||
_, _ = fmt.Fprintln(os.Stderr)
|
_, _ = fmt.Fprintln(os.Stderr)
|
||||||
}
|
}
|
||||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
_ = 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 apperrors.ExitCode(err)
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
@@ -477,10 +468,10 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
|||||||
newCatalogCommand(),
|
newCatalogCommand(),
|
||||||
newConfigCommand(),
|
newConfigCommand(),
|
||||||
newDoctorCommand(),
|
newDoctorCommand(),
|
||||||
|
newRecoveryCommand(),
|
||||||
newEventCommand(),
|
newEventCommand(),
|
||||||
newAuditCommand(),
|
newAuditCommand(),
|
||||||
newCompletionCommand(root),
|
newCompletionCommand(root),
|
||||||
newRecoveryCommand(flags),
|
|
||||||
newUpgradeCommand(),
|
newUpgradeCommand(),
|
||||||
newVersionCommand(),
|
newVersionCommand(),
|
||||||
newPluginCommand(),
|
newPluginCommand(),
|
||||||
@@ -724,8 +715,8 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
|||||||
var builtinCommandNames = map[string]bool{
|
var builtinCommandNames = map[string]bool{
|
||||||
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
|
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
|
||||||
"doctor": true, "event": true, "completion": true, "skill": true,
|
"doctor": true, "event": true, "completion": true, "skill": true,
|
||||||
"plugin": true, "profile": true, "version": true, "help": true,
|
"plugin": true, "profile": true, "recovery": true, "version": true, "help": true,
|
||||||
"recovery": true, "schema": true, "mcp": true, "upgrade": true,
|
"schema": true, "mcp": true, "upgrade": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// commandNameSet returns a new set containing every name in base plus extras.
|
// 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/executor"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
"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/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/internal/transport"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||||
@@ -24,8 +23,6 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
|||||||
oldExecute := rootExecuteCommand
|
oldExecute := rootExecuteCommand
|
||||||
oldNewRoot := rootNewRootCommandWithEngine
|
oldNewRoot := rootNewRootCommandWithEngine
|
||||||
oldPreParse := rootRunPreParse
|
oldPreParse := rootRunPreParse
|
||||||
oldLatest := rootLatestRecoveryCapture
|
|
||||||
oldReset := rootResetRecoveryState
|
|
||||||
oldStop := rootStopAllStdioClients
|
oldStop := rootStopAllStdioClients
|
||||||
oldArgs := os.Args
|
oldArgs := os.Args
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
@@ -33,20 +30,16 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
|||||||
rootExecuteCommand = oldExecute
|
rootExecuteCommand = oldExecute
|
||||||
rootNewRootCommandWithEngine = oldNewRoot
|
rootNewRootCommandWithEngine = oldNewRoot
|
||||||
rootRunPreParse = oldPreParse
|
rootRunPreParse = oldPreParse
|
||||||
rootLatestRecoveryCapture = oldLatest
|
|
||||||
rootResetRecoveryState = oldReset
|
|
||||||
rootStopAllStdioClients = oldStop
|
rootStopAllStdioClients = oldStop
|
||||||
os.Args = oldArgs
|
os.Args = oldArgs
|
||||||
})
|
})
|
||||||
os.Args = []string{"dws"}
|
os.Args = []string{"dws"}
|
||||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||||
rootResetRecoveryState = func() {}
|
|
||||||
rootStopAllStdioClients = func() {}
|
rootStopAllStdioClients = func() {}
|
||||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
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 }
|
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
|
||||||
if code := Execute(); code != 0 {
|
if code := Execute(); code != 0 {
|
||||||
t.Fatalf("successful Execute code = %d", code)
|
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 }
|
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||||
|
|
||||||
wantErr := errors.New("unknown command missing")
|
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 }
|
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||||
if code := Execute(); code == 0 {
|
if code := Execute(); code == 0 {
|
||||||
t.Fatal("failed Execute returned zero")
|
t.Fatal("failed Execute returned zero")
|
||||||
|
|||||||
@@ -72,6 +72,39 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
|
|||||||
|
|
||||||
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||||
root := NewRootCommand()
|
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")
|
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
|
||||||
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
|
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
|
||||||
if listDirect.Flags().Lookup(flag) == nil {
|
if listDirect.Flags().Lookup(flag) == nil {
|
||||||
@@ -386,20 +419,14 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheRefreshCompatibilityStub(t *testing.T) {
|
func TestCacheCommandDeprecatedCompatStub(t *testing.T) {
|
||||||
cmd := NewRootCommand()
|
root := NewRootCommand()
|
||||||
var out bytes.Buffer
|
cmd, _, err := root.Find([]string{"cache", "refresh"})
|
||||||
cmd.SetOut(&out)
|
if err != nil || cmd == nil || cmd == root {
|
||||||
cmd.SetErr(&out)
|
t.Fatalf("dws cache refresh compatibility stub missing: %v", err)
|
||||||
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()
|
if cmd.Hidden || cmd.Deprecated == "" {
|
||||||
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "服务发现已下线"} {
|
t.Fatalf("cache refresh must be visible Deprecated: hidden=%v deprecated=%q", cmd.Hidden, cmd.Deprecated)
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
publicShortcutCount = 266
|
publicShortcutCount = 294
|
||||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||||
schemaPublishedShortcutCount = 267
|
schemaPublishedShortcutCount = 295
|
||||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||||
publiclyDeliveredShortcutCount = 266
|
publiclyDeliveredShortcutCount = 294
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||||
@@ -194,17 +194,9 @@ func assertDeliveryShortcutSafetyAndInterface(
|
|||||||
canonical string,
|
canonical string,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
risk := declared.Risk
|
safety := shortcut.EffectiveSafety(declared)
|
||||||
if risk == "" {
|
wantEffect, wantRisk := safety.Effect, safety.Risk
|
||||||
risk = shortcut.RiskRead
|
wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency
|
||||||
}
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
for field, want := range map[string]string{
|
for field, want := range map[string]string{
|
||||||
"effect": wantEffect,
|
"effect": wantEffect,
|
||||||
"risk": wantRisk,
|
"risk": wantRisk,
|
||||||
@@ -234,6 +226,15 @@ func assertDeliveryShortcutParameters(
|
|||||||
for _, flag := range declared.Flags {
|
for _, flag := range declared.Flags {
|
||||||
if !flag.Hidden {
|
if !flag.Hidden {
|
||||||
publicFlags = append(publicFlags, flag)
|
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 {
|
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 {
|
if flag.Name == flagName && flag.Required {
|
||||||
return true
|
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))
|
public := make(map[string]bool, len(declared.Flags))
|
||||||
for _, flag := range declared.Flags {
|
for _, flag := range declared.Flags {
|
||||||
|
|||||||
@@ -75,10 +75,8 @@ func newSkillSetupCommand() *cobra.Command {
|
|||||||
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
|
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
|
||||||
|
|
||||||
支持两种模式:
|
支持两种模式:
|
||||||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||||||
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
|
multi 多 skill—— 按产品拆 N 个独立 skill
|
||||||
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
|
|
||||||
生产前请评估,问题请提 issue 反馈
|
|
||||||
|
|
||||||
multi 模式支持按产品挑选:
|
multi 模式支持按产品挑选:
|
||||||
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
|
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
|
||||||
@@ -150,7 +148,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
|||||||
if filterErr != nil {
|
if filterErr != nil {
|
||||||
return filterErr
|
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.
|
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
|
||||||
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
||||||
}
|
}
|
||||||
@@ -177,8 +175,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
|||||||
fmt.Fprintln(out, "已取消。")
|
fmt.Fprintln(out, "已取消。")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
} else if mode == skillSetupModeMulti {
|
|
||||||
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var installed, skipped int
|
var installed, skipped int
|
||||||
@@ -205,8 +201,8 @@ const multiSkillPrefix = "dingtalk-"
|
|||||||
// multiSharedSkill is the shared, non-product skill that every per-product
|
// multiSharedSkill is the shared, non-product skill that every per-product
|
||||||
// skill declares as a PREREQUISITE. It must always be installed in multi mode
|
// skill declares as a PREREQUISITE. It must always be installed in multi mode
|
||||||
// regardless of --skill / --exclude, otherwise the product skills reference a
|
// regardless of --skill / --exclude, otherwise the product skills reference a
|
||||||
// dws-shared that was never installed.
|
// dingtalk-shared that was never installed.
|
||||||
const multiSharedSkill = "dws-shared"
|
const multiSharedSkill = "dingtalk-shared"
|
||||||
|
|
||||||
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
|
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
|
||||||
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
|
// 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.NewGroup(
|
||||||
huh.NewSelect[string]().
|
huh.NewSelect[string]().
|
||||||
Title("选择 dws skill 安装模式").
|
Title("选择 dws skill 安装模式").
|
||||||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
|
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分的独立 skill").
|
||||||
Options(
|
Options(
|
||||||
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
|
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
|
||||||
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
|
huh.NewOption("multi — 多 skill(按产品拆分)", skillSetupModeMulti),
|
||||||
).
|
).
|
||||||
Value(&choice),
|
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) {
|
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)
|
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
|
||||||
if mode == skillSetupModeMulti {
|
if mode == skillSetupModeMulti {
|
||||||
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
|
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
|
// 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.
|
// Schema hints are build inputs and must not become a third installable mode.
|
||||||
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
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)
|
t.Fatalf("extracted dir %s is not a valid multi skill source root", dir)
|
||||||
}
|
}
|
||||||
for _, rel := range []string{
|
for _, rel := range []string{
|
||||||
filepath.Join("dws-shared", "SKILL.md"),
|
filepath.Join("dingtalk-shared", "SKILL.md"),
|
||||||
filepath.Join("dingtalk-pat", "SKILL.md"),
|
filepath.Join("dingtalk-misc", "SKILL.md"),
|
||||||
filepath.Join("dingtalk-pat", "references", "pat.md"),
|
filepath.Join("dingtalk-misc", "references", "pat.md"),
|
||||||
} {
|
} {
|
||||||
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
|
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
|
||||||
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
|
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 {
|
if err := cmd.RunE(cmd, nil); err == nil {
|
||||||
t.Fatal("empty multi source should fail")
|
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 }
|
skillSetupFilterMulti = func([]string, []string, []string) ([]string, error) { return nil, fail }
|
||||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||||
if err := cmd.RunE(cmd, nil); err == nil {
|
if err := cmd.RunE(cmd, nil); err == nil {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
|
|||||||
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
|
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
|
||||||
Long: `查看当前可运行命令的 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),
|
Args: cobra.MaximumNArgs(1),
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
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("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 命令路径查询")
|
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,39 +24,6 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"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) {
|
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
|
||||||
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
|
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
|
||||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||||
@@ -318,7 +285,7 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalSafetyAnnotationFa
|
|||||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalParameterResolutionError(t *testing.T) {
|
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalParameterResolutionError(t *testing.T) {
|
||||||
oldParameters := resolveRuntimeParameters
|
oldParameters := resolveRuntimeParameters
|
||||||
t.Cleanup(func() { resolveRuntimeParameters = oldParameters })
|
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")
|
return nil, errors.New("parameters failed")
|
||||||
}
|
}
|
||||||
entry := runtimeSchemaEntry{
|
entry := runtimeSchemaEntry{
|
||||||
|
|||||||
+85
-184
@@ -89,9 +89,9 @@ func RegisterRuntimeSchemaConstraints(canonicalPath string, constraints RuntimeS
|
|||||||
}
|
}
|
||||||
|
|
||||||
// emptyPinnedMCPMetadata returns the retired pin shape with no tools.
|
// emptyPinnedMCPMetadata returns the retired pin shape with no tools.
|
||||||
// schema_mcp_metadata.json is deleted; production assembly does not embed or
|
// schema_mcp_metadata.json is deleted; Schema parameter assembly never loads
|
||||||
// load a pinned MCP snapshot. Test fixtures may still inject non-empty maps
|
// or ranks MCP pin candidates. Optional Interface-registry validators may
|
||||||
// through schemaRegistryForTestWithMetadata.
|
// still pass this empty shape when they only need ContractFinal self-checks.
|
||||||
func emptyPinnedMCPMetadata() embeddedMCPMetadata {
|
func emptyPinnedMCPMetadata() embeddedMCPMetadata {
|
||||||
return embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
|
return embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
|
||||||
}
|
}
|
||||||
@@ -217,62 +217,6 @@ func collectRuntimeSchemaEntriesFromBound(bound BoundCommandRegistry) ([]runtime
|
|||||||
return entries, nil
|
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) {
|
func runtimeSchemaAnnotations(cmd *cobra.Command) (productID, toolName, source string) {
|
||||||
if cmd == nil || cmd.Annotations == nil {
|
if cmd == nil || cmd.Annotations == nil {
|
||||||
return "", "", ""
|
return "", "", ""
|
||||||
@@ -322,7 +266,6 @@ const (
|
|||||||
runtimeSchemaRankDefault = 0
|
runtimeSchemaRankDefault = 0
|
||||||
runtimeSchemaRankDerived = 50
|
runtimeSchemaRankDerived = 50
|
||||||
runtimeSchemaRankInference = 100
|
runtimeSchemaRankInference = 100
|
||||||
runtimeSchemaRankMCP = 400
|
|
||||||
runtimeSchemaRankCobraHelp = 450
|
runtimeSchemaRankCobraHelp = 450
|
||||||
runtimeSchemaRankCobraDefault = 600
|
runtimeSchemaRankCobraDefault = 600
|
||||||
runtimeSchemaRankCobraContract = 610
|
runtimeSchemaRankCobraContract = 610
|
||||||
@@ -332,7 +275,7 @@ const (
|
|||||||
runtimeSchemaRankVersionedBinding = 650
|
runtimeSchemaRankVersionedBinding = 650
|
||||||
// ParamDecl.Property (dws.schema.property) outranks residual versioned
|
// ParamDecl.Property (dws.schema.property) outranks residual versioned
|
||||||
// binding candidates (active bindings JSON is empty after Phase 2).
|
// 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.
|
// cannot be overridden by a leaf ParamDecl that still carries a Property.
|
||||||
runtimeSchemaRankParamDeclProperty = 655
|
runtimeSchemaRankParamDeclProperty = 655
|
||||||
runtimeSchemaRankMappingExclusion = 660
|
runtimeSchemaRankMappingExclusion = 660
|
||||||
@@ -340,7 +283,6 @@ const (
|
|||||||
runtimeSchemaPrecedenceDefault = "default"
|
runtimeSchemaPrecedenceDefault = "default"
|
||||||
runtimeSchemaPrecedenceDerived = "derived_resolution"
|
runtimeSchemaPrecedenceDerived = "derived_resolution"
|
||||||
runtimeSchemaPrecedenceInference = "inference"
|
runtimeSchemaPrecedenceInference = "inference"
|
||||||
runtimeSchemaPrecedenceMCP = "mcp_metadata"
|
|
||||||
runtimeSchemaPrecedenceCobraHelp = "cobra_help"
|
runtimeSchemaPrecedenceCobraHelp = "cobra_help"
|
||||||
runtimeSchemaPrecedenceCobra = "cobra_contract"
|
runtimeSchemaPrecedenceCobra = "cobra_contract"
|
||||||
runtimeSchemaPrecedenceNativeAnnotation = "native_annotation"
|
runtimeSchemaPrecedenceNativeAnnotation = "native_annotation"
|
||||||
@@ -533,8 +475,6 @@ func runtimeSchemaSourcePriority(source string) (int, string) {
|
|||||||
return runtimeSchemaRankCobraDefault, runtimeSchemaPrecedenceCobra
|
return runtimeSchemaRankCobraDefault, runtimeSchemaPrecedenceCobra
|
||||||
}
|
}
|
||||||
return runtimeSchemaRankCobraContract, runtimeSchemaPrecedenceCobra
|
return runtimeSchemaRankCobraContract, runtimeSchemaPrecedenceCobra
|
||||||
case "mcp_metadata", "pinned_mcp_metadata":
|
|
||||||
return runtimeSchemaRankMCP, runtimeSchemaPrecedenceMCP
|
|
||||||
case "cobra_help":
|
case "cobra_help":
|
||||||
return runtimeSchemaRankCobraHelp, runtimeSchemaPrecedenceCobraHelp
|
return runtimeSchemaRankCobraHelp, runtimeSchemaPrecedenceCobraHelp
|
||||||
case "flag_name_inference", "usage_required_inference", "usage_format_inference":
|
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,
|
// runtimeSchemaParameterMappingCandidates resolves the two reviewed,
|
||||||
// versioned property-mapping inputs. An exclusion is an explicit statement
|
// versioned property-mapping inputs. An exclusion is an explicit statement
|
||||||
// that the CLI parameter is not a direct MCP property: it therefore supplies
|
// that the CLI parameter is not a direct RPC/interface property: it therefore
|
||||||
// a present empty candidate (rather than allowing name inference to survive)
|
// supplies a present empty candidate (rather than allowing name inference to
|
||||||
// and keeps the review reason in provenance.
|
// survive) and keeps the review reason in provenance.
|
||||||
func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnapshot, canonicalPath, flagName string) (runtimeSchemaFieldCandidate, runtimeSchemaFieldCandidate, error) {
|
func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnapshot, canonicalPath, flagName string) (runtimeSchemaFieldCandidate, runtimeSchemaFieldCandidate, error) {
|
||||||
binding := strings.TrimSpace(snapshot.Bindings[strings.TrimSpace(canonicalPath)][strings.TrimSpace(flagName)])
|
binding := strings.TrimSpace(snapshot.Bindings[strings.TrimSpace(canonicalPath)][strings.TrimSpace(flagName)])
|
||||||
bindingCandidate := runtimeSchemaStringCandidate(binding, "versioned_parameter_binding")
|
bindingCandidate := runtimeSchemaStringCandidate(binding, "versioned_parameter_binding")
|
||||||
@@ -605,34 +545,24 @@ func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnap
|
|||||||
type runtimeParameterFieldContext struct {
|
type runtimeParameterFieldContext struct {
|
||||||
flag *pflag.Flag
|
flag *pflag.Flag
|
||||||
metadata RuntimeSchemaParameterMetadata
|
metadata RuntimeSchemaParameterMetadata
|
||||||
pinnedParam embeddedMCPParamMeta
|
|
||||||
hasPinned bool
|
|
||||||
paramType string
|
paramType string
|
||||||
constraints RuntimeSchemaConstraints
|
constraints RuntimeSchemaConstraints
|
||||||
property string
|
property string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c runtimeParameterFieldContext) interfaceTypeCandidates() []runtimeSchemaFieldCandidate {
|
func (c runtimeParameterFieldContext) interfaceTypeCandidates() []runtimeSchemaFieldCandidate {
|
||||||
candidates := []runtimeSchemaFieldCandidate{
|
return []runtimeSchemaFieldCandidate{
|
||||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagTypeAnnotation), "native_annotation"),
|
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"),
|
runtimeSchemaStringCandidateAtRank(c.paramType, "cobra_flag_type", runtimeSchemaRankInference, "fallback"),
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c runtimeParameterFieldContext) descriptionCandidates() []runtimeSchemaFieldCandidate {
|
func (c runtimeParameterFieldContext) descriptionCandidates() []runtimeSchemaFieldCandidate {
|
||||||
candidates := []runtimeSchemaFieldCandidate{
|
return []runtimeSchemaFieldCandidate{
|
||||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagDescriptionAnnotation), "native_annotation"),
|
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagDescriptionAnnotation), "native_annotation"),
|
||||||
runtimeSchemaStringCandidate(c.flag.Usage, "cobra_usage"),
|
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 {
|
func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldCandidate {
|
||||||
@@ -649,7 +579,7 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
candidates := []runtimeSchemaFieldCandidate{
|
return []runtimeSchemaFieldCandidate{
|
||||||
constraintRequired,
|
constraintRequired,
|
||||||
runtimeSchemaCandidate(true, typedRequired, "typed_parameter_metadata"),
|
runtimeSchemaCandidate(true, typedRequired, "typed_parameter_metadata"),
|
||||||
runtimeSchemaAnnotatedBoolCandidate(c.flag, runtimeSchemaFlagMetadataRequiredAnnotation, "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(true, runtimeFlagCobraHardRequired(c.flag), "cobra_hard_required"),
|
||||||
runtimeSchemaCandidate(false, cobraDefaultOptional, "cobra_nonzero_default"),
|
runtimeSchemaCandidate(false, cobraDefaultOptional, "cobra_nonzero_default"),
|
||||||
runtimeSchemaCandidate(usageRequired, usageRequired, "usage_required_inference"),
|
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 {
|
func (c runtimeParameterFieldContext) requiredWhenCandidates() []runtimeSchemaFieldCandidate {
|
||||||
candidates := []runtimeSchemaFieldCandidate{
|
return []runtimeSchemaFieldCandidate{
|
||||||
runtimeSchemaStringCandidate(c.metadata.RequiredWhen[c.flag.Name], "typed_parameter_metadata"),
|
runtimeSchemaStringCandidate(c.metadata.RequiredWhen[c.flag.Name], "typed_parameter_metadata"),
|
||||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataRequiredWhenAnnotation), "typed_parameter_metadata"),
|
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataRequiredWhenAnnotation), "typed_parameter_metadata"),
|
||||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagRequiredWhenAnnotation), "native_annotation"),
|
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 {
|
func (c runtimeParameterFieldContext) formatCandidates() []runtimeSchemaFieldCandidate {
|
||||||
candidates := []runtimeSchemaFieldCandidate{
|
return []runtimeSchemaFieldCandidate{
|
||||||
runtimeSchemaStringCandidate(c.metadata.Formats[c.flag.Name], "typed_parameter_metadata"),
|
runtimeSchemaStringCandidate(c.metadata.Formats[c.flag.Name], "typed_parameter_metadata"),
|
||||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataFormatAnnotation), "typed_parameter_metadata"),
|
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataFormatAnnotation), "typed_parameter_metadata"),
|
||||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, "x-cli-format"), "native_annotation"),
|
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"),
|
runtimeSchemaStringCandidate(inferredRuntimeFlagFormat(c.flag), "usage_format_inference"),
|
||||||
runtimeSchemaCandidate("", true, "default"),
|
runtimeSchemaCandidate("", true, "default"),
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c runtimeParameterFieldContext) enumCandidates() []runtimeSchemaFieldCandidate {
|
func (c runtimeParameterFieldContext) enumCandidates() []runtimeSchemaFieldCandidate {
|
||||||
candidates := []runtimeSchemaFieldCandidate{
|
return []runtimeSchemaFieldCandidate{
|
||||||
runtimeSchemaEnumCandidate(c.metadata.Enums[c.flag.Name], "typed_parameter_metadata"),
|
runtimeSchemaEnumCandidate(c.metadata.Enums[c.flag.Name], "typed_parameter_metadata"),
|
||||||
runtimeSchemaEnumCandidate(runtimeFlagEnumAnnotation(c.flag, runtimeSchemaFlagMetadataEnumAnnotation), "typed_parameter_metadata"),
|
runtimeSchemaEnumCandidate(runtimeFlagEnumAnnotation(c.flag, runtimeSchemaFlagMetadataEnumAnnotation), "typed_parameter_metadata"),
|
||||||
runtimeSchemaEnumCandidate(runtimeFlagEnum(c.flag), "native_annotation"),
|
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 {
|
func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCandidate {
|
||||||
@@ -717,7 +633,8 @@ func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCa
|
|||||||
// source may intentionally raise or lower type/mapping/description semantics.
|
// source may intentionally raise or lower type/mapping/description semantics.
|
||||||
// required is different: Cobra MarkFlagRequired is a hard floor that no
|
// required is different: Cobra MarkFlagRequired is a hard floor that no
|
||||||
// lower-priority source may demote (see resolveRequiredProjection).
|
// 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 {
|
if cmd == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -758,18 +675,10 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
property, _ := propertyWinner.Value.(string)
|
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)
|
paramType := runtimeFlagCLIType(flag)
|
||||||
fieldCtx := runtimeParameterFieldContext{
|
fieldCtx := runtimeParameterFieldContext{
|
||||||
flag: flag,
|
flag: flag,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
pinnedParam: pinnedParam,
|
|
||||||
hasPinned: hasPinnedParam,
|
|
||||||
paramType: paramType,
|
paramType: paramType,
|
||||||
constraints: constraints,
|
constraints: constraints,
|
||||||
property: property,
|
property: property,
|
||||||
@@ -794,10 +703,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
description, _ := descriptionWinner.Value.(string)
|
description, _ := descriptionWinner.Value.(string)
|
||||||
interfaceDescription := ""
|
|
||||||
if hasPinnedParam {
|
|
||||||
interfaceDescription = strings.TrimSpace(pinnedParam.Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Required uses field-level safe merge: higher sources may raise required, but
|
// Required uses field-level safe merge: higher sources may raise required, but
|
||||||
// Cobra MarkFlagRequired cannot be projected away as optional.
|
// 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"),
|
runtimeSchemaCandidate(true, true, "cobra_hard_required"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if interfaceDescription != "" && interfaceDescription != description {
|
|
||||||
parameter.InterfaceDescription = interfaceDescription
|
|
||||||
}
|
|
||||||
if interfaceType != "" && interfaceType != paramType {
|
if interfaceType != "" && interfaceType != paramType {
|
||||||
parameter.InterfaceType = interfaceType
|
parameter.InterfaceType = interfaceType
|
||||||
fieldProvenance["interface_type"] = runtimeSchemaFieldProvenance(interfaceTypeWinner)
|
fieldProvenance["interface_type"] = runtimeSchemaFieldProvenance(interfaceTypeWinner)
|
||||||
@@ -855,12 +757,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
|||||||
if def := runtimeFlagDefault(flag); def != "" {
|
if def := runtimeFlagDefault(flag); def != "" {
|
||||||
parameter.Default = runtimeSchemaJSONString(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())
|
formatWinner, ok := resolveField("format", fieldCtx.formatCandidates())
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -991,19 +887,6 @@ func runtimeSchemaConstraintsEmpty(constraints RuntimeSchemaConstraints) bool {
|
|||||||
return runtimeannotate.ConstraintsEmpty(constraints)
|
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 {
|
func isGenericPayloadFlag(flag *pflag.Flag) bool {
|
||||||
if flag == nil {
|
if flag == nil {
|
||||||
return false
|
return false
|
||||||
@@ -1162,69 +1045,87 @@ func strconvQuote(value string) string {
|
|||||||
|
|
||||||
// ─── --compact mode ──────────────────────────────────────────────────────────
|
// ─── --compact mode ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// schemaCompactStripKeys are top-level tool/product keys removed in --compact mode.
|
// schemaCompactPayloadKeys is the reviewed Agent-view allowlist. Keep this a
|
||||||
var schemaCompactStripKeys = map[string]bool{
|
// positive list: a new full/audit field must not silently expand routine Agent
|
||||||
// provenance / debug
|
// context just because it was added to ToolSpec.ToPayload.
|
||||||
"agent_metadata_source": true,
|
var schemaCompactPayloadKeys = map[string]bool{
|
||||||
"agent_source_refs": true,
|
// Navigation envelopes.
|
||||||
"agent_summary_source": true,
|
"kind": true, "level": true, "count": true, "tool_count": true,
|
||||||
"effect_source": true,
|
"products": true, "product": true, "tools": true,
|
||||||
"metadata_source": true,
|
"id": true, "schema_path": true, "runtime": true,
|
||||||
"source": true,
|
// Leaf identity and execution semantics.
|
||||||
"agent_metadata": true,
|
"canonical_path": true, "cli_path": true,
|
||||||
"field_provenance": true,
|
"agent_summary": true, "description": true,
|
||||||
"reviewed": true,
|
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
|
||||||
// redundant with canonical_path / cli_path
|
"interface_mode": true, "availability": true, "interface_reason": true,
|
||||||
"name": true,
|
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
|
||||||
"path": true,
|
"examples": true, "use_when": true, "avoid_when": 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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// schemaCompactParamStripKeys are per-parameter keys removed in --compact mode.
|
// schemaCompactParamKeys is the reviewed parameter allowlist for Agent command
|
||||||
var schemaCompactParamStripKeys = map[string]bool{
|
// construction. RPC mapping and provenance fields intentionally remain in the
|
||||||
"interface_description": true,
|
// full/audit view.
|
||||||
"interface_type": true,
|
var schemaCompactParamKeys = map[string]bool{
|
||||||
"property": true,
|
"type": true, "description": true, "required": true,
|
||||||
"field_provenance": 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,
|
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
|
||||||
// debug and redundant keys so that only agent-essential fields remain.
|
// Agent-view allowlist. Structural product/tool children are projected
|
||||||
// It operates recursively on nested maps, slices, and parameter objects.
|
// 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 {
|
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
|
||||||
if payload == nil {
|
if payload == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
result := make(map[string]any, len(payload))
|
result := make(map[string]any, len(payload))
|
||||||
for k, v := range payload {
|
for k, v := range payload {
|
||||||
if schemaCompactStripKeys[k] {
|
if !schemaCompactPayloadKeys[k] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if k == "parameters" {
|
switch k {
|
||||||
|
case "parameters":
|
||||||
result[k] = stripSchemaParametersCompact(v)
|
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
|
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 {
|
func stripSchemaParametersCompact(value any) any {
|
||||||
parameters, ok := value.(map[string]any)
|
parameters, ok := value.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1278,7 +1179,7 @@ func stripSchemaValueCompact(v any) any {
|
|||||||
func stripSchemaParamCompact(param map[string]any) map[string]any {
|
func stripSchemaParamCompact(param map[string]any) map[string]any {
|
||||||
result := make(map[string]any, len(param))
|
result := make(map[string]any, len(param))
|
||||||
for k, v := range param {
|
for k, v := range param {
|
||||||
if schemaCompactParamStripKeys[k] {
|
if !schemaCompactParamKeys[k] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result[k] = v
|
result[k] = v
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
"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/corecmd/runtimeannotate"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -63,39 +62,6 @@ func TestCrossPlatformCoverageCollectRuntimeSchemaEntriesErrorsAndOrdering(t *te
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageRuntimeSchemaMetadataLookupEdges(t *testing.T) {
|
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 {
|
for _, test := range []struct {
|
||||||
value any
|
value any
|
||||||
want int
|
want int
|
||||||
@@ -155,13 +121,13 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
|||||||
cmd.Flags().String("value", "", "value")
|
cmd.Flags().String("value", "", "value")
|
||||||
flag := cmd.Flags().Lookup("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)
|
t.Fatalf("nil command specs = %#v, err = %v", specs, err)
|
||||||
}
|
}
|
||||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||||
return schemaParameterBindingSnapshot{}, errors.New("load failed")
|
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)
|
t.Fatalf("binding load error = %v", err)
|
||||||
}
|
}
|
||||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||||
@@ -179,7 +145,7 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
|||||||
MappingExclusions: map[string]string{"sample.run --value": " "},
|
MappingExclusions: map[string]string{"sample.run --value": " "},
|
||||||
}, nil
|
}, 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)
|
t.Fatalf("mapping exclusion error = %v", err)
|
||||||
}
|
}
|
||||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||||
@@ -194,22 +160,22 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return resolveRuntimeSchemaCandidate(field, candidates...)
|
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)
|
t.Fatalf("%s resolution error = %v", target, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resolveRuntimeSchemaField = realResolver
|
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)
|
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)
|
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
|
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)
|
t.Fatalf("payload serialization error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,32 +184,21 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
|||||||
t.Fatalf("required annotation = %v/%v", required, present)
|
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) {
|
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||||
return schemaParameterBindingSnapshot{
|
return schemaParameterBindingSnapshot{
|
||||||
Bindings: map[string]map[string]string{"sample.run": {"value": "clawType"}},
|
Bindings: map[string]map[string]string{"sample.run": {"value": "clawType"}},
|
||||||
}, nil
|
}, nil
|
||||||
})
|
})
|
||||||
requiredTrue := true
|
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{})
|
||||||
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", map[string]embeddedMCPParamMeta{
|
|
||||||
"clawType": {
|
|
||||||
Type: "string",
|
|
||||||
Description: "fixture description",
|
|
||||||
Required: &requiredTrue,
|
|
||||||
Default: "fixture-default",
|
|
||||||
},
|
|
||||||
}, RuntimeSchemaConstraints{})
|
|
||||||
if err != nil {
|
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" {
|
if len(specs) != 1 || specs[0].Property != "clawType" {
|
||||||
t.Fatalf("fixture pinned parameter specs = %#v", specs)
|
t.Fatalf("parameter specs = %#v", specs)
|
||||||
}
|
}
|
||||||
if len(specs[0].InterfaceDefault) == 0 {
|
if prov := specs[0].FieldProvenance["property"]; prov.Source == "" {
|
||||||
t.Fatalf("fixture interface_default missing: %#v", specs[0])
|
t.Fatalf("property provenance missing: %#v", specs[0].FieldProvenance)
|
||||||
}
|
|
||||||
if prov := specs[0].FieldProvenance["required"]; prov.Source == "" {
|
|
||||||
t.Fatalf("fixture required provenance missing: %#v", specs[0].FieldProvenance)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,9 +228,6 @@ func TestCrossPlatformCoverageRuntimeSchemaPureHelperEdges(t *testing.T) {
|
|||||||
if !reflect.DeepEqual(groups, [][]string{{"one"}}) {
|
if !reflect.DeepEqual(groups, [][]string{{"one"}}) {
|
||||||
t.Fatalf("normalized groups = %#v", groups)
|
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) {
|
if isGenericPayloadFlag(nil) {
|
||||||
t.Fatal("nil flag cannot be a generic payload")
|
t.Fatal("nil flag cannot be a generic payload")
|
||||||
}
|
}
|
||||||
@@ -322,4 +274,75 @@ func TestCrossPlatformCoverageSchemaCompactProjectionEdges(t *testing.T) {
|
|||||||
if _, exists := value["property"]; exists {
|
if _, exists := value["property"]; exists {
|
||||||
t.Fatalf("compact parameter value = %#v", value)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,25 +189,6 @@ func cloneFieldCandidates(source []contract.FieldCandidateProvenance) []contract
|
|||||||
return out
|
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
|
// agentMetadataSummaryFromProducts publishes Catalog-level Agent coverage from
|
||||||
// the assembled Schema surface (ContractFinal / ProductDecl). This keeps
|
// the assembled Schema surface (ContractFinal / ProductDecl). This keeps
|
||||||
// runtime delivery and CI dumps hash-aligned without requiring build-time
|
// runtime delivery and CI dumps hash-aligned without requiring build-time
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import (
|
|||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
"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"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,9 +93,8 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
|
|||||||
// longer participates in assembly.
|
// longer participates in assembly.
|
||||||
root := buildRuntimeSchemaTestRoot()
|
root := buildRuntimeSchemaTestRoot()
|
||||||
declareRuntimeSchemaTestRootDoc(t, root, nil)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("runtimeSchemaPayloadForTest(leaf): %v", err)
|
t.Fatalf("runtimeSchemaPayloadForTest(leaf): %v", err)
|
||||||
}
|
}
|
||||||
@@ -110,7 +108,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
|
|||||||
t.Fatalf("leaf examples = %#v", leaf["examples"])
|
t.Fatalf("leaf examples = %#v", leaf["examples"])
|
||||||
}
|
}
|
||||||
|
|
||||||
catalog, err := runtimeSchemaPayloadForTestWithMetadata(root, nil, emptyAgentMetadata(), mcpFixture)
|
catalog, err := runtimeSchemaPayloadForTest(root, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("runtimeSchemaPayloadForTest(catalog): %v", err)
|
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])
|
t.Fatalf("product summary must not include examples: %#v", tools[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), mcpFixture)
|
registry, err := schemaRegistryForTest(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("schemaRegistryForTest(): %v", err)
|
t.Fatalf("schemaRegistryForTest(): %v", err)
|
||||||
}
|
}
|
||||||
@@ -162,7 +160,7 @@ func TestRuntimeSchemaAllPayloadContainsFullLeafParameters(t *testing.T) {
|
|||||||
// exercises the production assembly path.
|
// exercises the production assembly path.
|
||||||
root := buildRuntimeSchemaTestRoot()
|
root := buildRuntimeSchemaTestRoot()
|
||||||
declareRuntimeSchemaTestRootDoc(t, root, nil)
|
declareRuntimeSchemaTestRootDoc(t, root, nil)
|
||||||
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), embeddedMCPMetadata{})
|
registry, err := schemaRegistryForTest(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -203,9 +201,8 @@ func schemaTestInt(value any) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
|
func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
|
||||||
// interface_ref declares on the leaf ContractFinal; the injected MCP
|
// interface_ref declares on the leaf ContractFinal; MCP pin is not a
|
||||||
// fixture participates through the gated fixture lookup (remapped via the
|
// parameter candidate source.
|
||||||
// declared Interface.Ref).
|
|
||||||
root := buildRuntimeSchemaTestRoot()
|
root := buildRuntimeSchemaTestRoot()
|
||||||
declareRuntimeSchemaTestRootDoc(t, root, func(payload *contract.ContractFinalPayload) {
|
declareRuntimeSchemaTestRootDoc(t, root, func(payload *contract.ContractFinalPayload) {
|
||||||
payload.Interface = &contract.InterfaceSpec{
|
payload.Interface = &contract.InterfaceSpec{
|
||||||
@@ -215,17 +212,8 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
|
|||||||
Ref: &contract.InterfaceRefSpec{ProductID: "documents", RPCName: "create_doc_v2"},
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -233,112 +221,11 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
|
|||||||
if ref["product_id"] != "documents" || ref["rpc_name"] != "create_doc_v2" {
|
if ref["product_id"] != "documents" || ref["rpc_name"] != "create_doc_v2" {
|
||||||
t.Fatalf("interface_ref = %#v", payload["interface_ref"])
|
t.Fatalf("interface_ref = %#v", payload["interface_ref"])
|
||||||
}
|
}
|
||||||
parameters, _ := payload["parameters"].(map[string]any)
|
if payload["interface_mode"] != contract.InterfaceModeMCP {
|
||||||
title, _ := parameters["title"].(map[string]any)
|
t.Fatalf("interface_mode = %#v", payload["interface_mode"])
|
||||||
if title["interface_description"] != "MCP document title" {
|
|
||||||
t.Fatalf("title metadata = %#v", title)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func findSchemaProduct(products []map[string]any, id string) map[string]any {
|
||||||
for _, product := range products {
|
for _, product := range products {
|
||||||
if product["id"] == id {
|
if product["id"] == id {
|
||||||
|
|||||||
@@ -508,6 +508,10 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
stripped := stripSchemaPayloadCompact(leaf)
|
||||||
|
|
||||||
// Must keep agent-essential fields.
|
// Must keep agent-essential fields.
|
||||||
@@ -518,7 +522,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Must strip provenance / redundant fields.
|
// 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 {
|
if _, ok := stripped[key]; ok {
|
||||||
t.Fatalf("compact leaf still contains stripped key %q", key)
|
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 {
|
if params, ok := stripped["parameters"].(map[string]any); ok {
|
||||||
for name, p := range params {
|
for name, p := range params {
|
||||||
if pm, ok := p.(map[string]any); ok {
|
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 {
|
if _, present := pm[stripped]; present {
|
||||||
t.Fatalf("compact param %q still contains %q", name, stripped)
|
t.Fatalf("compact param %q still contains %q", name, stripped)
|
||||||
}
|
}
|
||||||
@@ -1304,7 +1308,7 @@ func TestDeliveryCatalogContactParamDeclsMatchMergeBaseContract(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if want.interfaceType != "" {
|
if want.interfaceType != "" {
|
||||||
prov := schemaMap(param["field_provenance"])["interface_type"]
|
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)
|
t.Fatalf("%s --%s interface_type source = %#v", tc.path, flagName, prov)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ package cli
|
|||||||
var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||||
{
|
{
|
||||||
ID: "cli-management",
|
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,
|
Reviewed: true,
|
||||||
Commands: []string{
|
Commands: []string{
|
||||||
"api",
|
"api",
|
||||||
@@ -53,9 +53,6 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
|||||||
"profile list",
|
"profile list",
|
||||||
"profile switch",
|
"profile switch",
|
||||||
"profile use",
|
"profile use",
|
||||||
"recovery execute",
|
|
||||||
"recovery finalize",
|
|
||||||
"recovery plan",
|
|
||||||
"schema",
|
"schema",
|
||||||
"skill get",
|
"skill get",
|
||||||
"skill install",
|
"skill install",
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ func schemaToolSpecFromPayload(payload map[string]any) (ToolSpec, error) {
|
|||||||
|
|
||||||
// runtimeCommandParameters is the compatibility wire adapter used only by
|
// runtimeCommandParameters is the compatibility wire adapter used only by
|
||||||
// tests; resolution happens in runtimeCommandParameterSpecs.
|
// tests; resolution happens in runtimeCommandParameterSpecs.
|
||||||
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) (map[string]any, error) {
|
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) (map[string]any, error) {
|
||||||
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, pinnedParams, constraints)
|
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, constraints)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -969,16 +969,16 @@ func TestOverallCoverageGapDeliveryCompletenessAndDryRun(t *testing.T) {
|
|||||||
func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
|
func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
|
||||||
prevSpecs := runtimeCommandParameterSpecsForPayload
|
prevSpecs := runtimeCommandParameterSpecsForPayload
|
||||||
t.Cleanup(func() { runtimeCommandParameterSpecsForPayload = prevSpecs })
|
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")
|
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")
|
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
|
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 {
|
if err != nil || payload["ok"] == nil {
|
||||||
t.Fatalf("parameter payload = %#v err=%v", payload, err)
|
t.Fatalf("parameter payload = %#v err=%v", payload, err)
|
||||||
}
|
}
|
||||||
@@ -1070,24 +1070,6 @@ func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageOverallRegressionRecovery(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")
|
left := runtimeSchemaStringCandidateAtPriority("same", true, "z-source", 5, "p")
|
||||||
right := runtimeSchemaStringCandidateAtPriority("same", true, "a-source", 5, "p")
|
right := runtimeSchemaStringCandidateAtPriority("same", true, "a-source", 5, "p")
|
||||||
winner, err := resolveRuntimeSchemaCandidate("source-order", left, right)
|
winner, err := resolveRuntimeSchemaCandidate("source-order", left, right)
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ func TestRuntimeCommandParameterSpecsPreserveReviewedEmptyPropertyProvenance(t *
|
|||||||
cmd := &cobra.Command{Use: "query"}
|
cmd := &cobra.Command{Use: "query"}
|
||||||
cmd.Flags().Bool("all", false, "fetch every page")
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,18 +70,6 @@ func schemaRegistryForTest(root *cobra.Command) (SchemaRegistry, error) {
|
|||||||
return AssembleSchemaRegistryFromBound(bound)
|
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
|
// declareRuntimeSchemaTestRootDoc registers the ContractFinal / ProductDecl
|
||||||
// declarations for the synthetic doc.create_document tree built by
|
// declarations for the synthetic doc.create_document tree built by
|
||||||
// buildRuntimeSchemaTestRoot, so production-shaped assembly can resolve it.
|
// 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)
|
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) {
|
func runtimeSchemaAllPayloadForTest(root *cobra.Command) (map[string]any, error) {
|
||||||
registry, err := schemaRegistryForTest(root)
|
registry, err := schemaRegistryForTest(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type runtimeSchemaMetadataSources struct {
|
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
|
Agent agentMetadata
|
||||||
MCP embeddedMCPMetadata
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -62,10 +64,9 @@ func (resolved ResolvedSchemaBuild) CommandCount() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pinnedRuntimeSchemaMetadataSources() runtimeSchemaMetadataSources {
|
func pinnedRuntimeSchemaMetadataSources() runtimeSchemaMetadataSources {
|
||||||
return runtimeSchemaMetadataSources{
|
// Production pin and Agent inject are both retired; assembly is Contract /
|
||||||
Agent: runtimeAgentMetadata(),
|
// ParamDecl / Cobra only.
|
||||||
MCP: emptyPinnedMCPMetadata(),
|
return runtimeSchemaMetadataSources{}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveSchemaBuild is the only assembly path from executable Cobra commands
|
// 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
|
// assembleSchemaRegistryFromBound resolves every entry through the
|
||||||
// ContractFinal / ProductDecl production path. Missing declarations fail
|
// 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) {
|
func assembleSchemaRegistryFromBound(bound BoundCommandRegistry, metadata runtimeSchemaMetadataSources) (SchemaRegistry, error) {
|
||||||
entries, err := assembleCollectEntries(bound)
|
entries, err := assembleCollectEntries(bound)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -197,18 +198,12 @@ func assembleProductSelection(entry runtimeSchemaEntry) (contract.SelectionSpec,
|
|||||||
|
|
||||||
// runtimeToolSpecFromContractFinal pass-throughs Contract-authored Schema fields.
|
// runtimeToolSpecFromContractFinal pass-throughs Contract-authored Schema fields.
|
||||||
// Declared values are the final data source; hints/registry text does not merge.
|
// 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;
|
// MCP pin is retired: interface_type / interface_* facts come from ParamDecl /
|
||||||
// interface_type / interface_* facts come from ParamDecl / native annotations.
|
// native annotations only.
|
||||||
// Tests may still inject a non-empty MCP fixture map, which participates through
|
|
||||||
// pinnedMCPMetadataForEntryFrom.
|
|
||||||
func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.ContractFinalPayload, metadata runtimeSchemaMetadataSources) (ToolSpec, error) {
|
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
|
canonicalPath := entry.ProductID + "." + entry.ToolName
|
||||||
constraints := runtimeCommandConstraints(entry.Command)
|
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
|
// Apply parameter declarations from the contract.ContractFinalPayload before the
|
||||||
// resolver reads them. The decls were put there by AttachContract at
|
// resolver reads them. The decls were put there by AttachContract at
|
||||||
// DeclareLeafMetadata time; now that all flags exist on the fully-built
|
// 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 {
|
if err := ApplyParamDecls(entry.Command, final.Parameters); err != nil {
|
||||||
return ToolSpec{}, fmt.Errorf("apply Contract Schema ParamDecls for %s: %w", canonicalPath, err)
|
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 {
|
if err != nil {
|
||||||
return ToolSpec{}, fmt.Errorf("resolve Contract Schema parameters for %s: %w", canonicalPath, err)
|
return ToolSpec{}, fmt.Errorf("resolve Contract Schema parameters for %s: %w", canonicalPath, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,23 +278,10 @@ func TestCrossPlatformCoverageRenderSafetyAnnotationSuccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageMCPMetadataInterfaceRefEdges(t *testing.T) {
|
func TestCrossPlatformCoverageMCPMetadataInterfaceRefEdges(t *testing.T) {
|
||||||
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{}, " ", " "); ok {
|
// MCP pin lookup helpers are retired; keep this named coverage slot as a
|
||||||
t.Fatal("blank interface ref must miss")
|
// no-op marker so CrossPlatformCoverage* selection stays stable.
|
||||||
}
|
if got := emptyPinnedMCPMetadata(); got.Tools == nil || len(got.Tools) != 0 {
|
||||||
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}, "chat", "missing"); ok {
|
t.Fatalf("empty pinned metadata = %#v", got)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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{}
|
type nativeChatTargetReader struct{}
|
||||||
|
|
||||||
func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
|
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
|
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 {
|
func containsMessageMention(text, placeholder string) bool {
|
||||||
if strings.HasPrefix(placeholder, "<") {
|
if strings.HasPrefix(placeholder, "<") {
|
||||||
return strings.Contains(text, placeholder)
|
return strings.Contains(text, placeholder)
|
||||||
@@ -2029,29 +2081,15 @@ func newChatCommand() *cobra.Command {
|
|||||||
if groupID != "" {
|
if groupID != "" {
|
||||||
atAll, _ := cmd.Flags().GetBool("at-all")
|
atAll, _ := cmd.Flags().GetBool("at-all")
|
||||||
atOpenIdsStr, _ := cmd.Flags().GetString("at-open-dingtalk-ids")
|
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 @ 人接口。
|
// 群聊统一走 openDingTalkId @ 人接口。
|
||||||
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
|
|
||||||
newParams := map[string]any{
|
newParams := map[string]any{
|
||||||
"openConversationId": groupID,
|
"openConversationId": groupID,
|
||||||
"msgType": "markdown",
|
"msgType": "markdown",
|
||||||
"content": string(contentJSON),
|
|
||||||
"clawType": clawType,
|
"clawType": clawType,
|
||||||
}
|
}
|
||||||
if atAll {
|
text = applyCurrentUserGroupMentions(newParams, text, atOpenIdsStr, atAll)
|
||||||
newParams["atAll"] = true
|
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
|
||||||
}
|
newParams["content"] = string(contentJSON)
|
||||||
if len(atOpenIds) > 0 {
|
|
||||||
newParams["atOpenDingTalkIds"] = atOpenIds
|
|
||||||
}
|
|
||||||
if msgUuid != "" {
|
if msgUuid != "" {
|
||||||
newParams["uuid"] = msgUuid
|
newParams["uuid"] = msgUuid
|
||||||
}
|
}
|
||||||
@@ -5368,13 +5406,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
|||||||
chatMessageReplyCmd := &cobra.Command{
|
chatMessageReplyCmd := &cobra.Command{
|
||||||
Use: "reply",
|
Use: "reply",
|
||||||
Short: "引用回复消息(支持单聊/群聊)",
|
Short: "引用回复消息(支持单聊/群聊)",
|
||||||
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。
|
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。群聊回复可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人;正文中的裸 @openDingTalkId 会自动规范化为 <@openDingTalkId>,缺少对应成员或 <@all> 占位符时会自动补齐。
|
||||||
|
|
||||||
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
|
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
|
||||||
- 群聊:dws chat search --query "群名"
|
- 群聊:dws chat search --query "群名"
|
||||||
- 单聊:dws chat conversation-info --open-dingtalk-id <openDingTalkId>
|
- 单聊:dws chat conversation-info --open-dingtalk-id <openDingTalkId>
|
||||||
(人员信息可通过 dws contact user search --keyword "姓名" --format json 获取)`,
|
(人员信息可通过 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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
|
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -5387,13 +5426,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
|||||||
}
|
}
|
||||||
refSender = resolved
|
refSender = resolved
|
||||||
}
|
}
|
||||||
replyContent := map[string]string{
|
|
||||||
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
|
|
||||||
"srcMsgSendOpenDingTalkId": refSender,
|
|
||||||
"replyMsgType": "text",
|
|
||||||
"content": mustGetFlag(cmd, "text"),
|
|
||||||
}
|
|
||||||
contentJSON, _ := marshalJSONRaw(replyContent)
|
|
||||||
clawType := ""
|
clawType := ""
|
||||||
aiTag, _ := cmd.Flags().GetBool("ai-tag")
|
aiTag, _ := cmd.Flags().GetBool("ai-tag")
|
||||||
if aiTag {
|
if aiTag {
|
||||||
@@ -5402,9 +5434,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
|||||||
toolArgs := map[string]any{
|
toolArgs := map[string]any{
|
||||||
"openConversationId": mustGetFlag(cmd, "conversation-id"),
|
"openConversationId": mustGetFlag(cmd, "conversation-id"),
|
||||||
"msgType": "reply",
|
"msgType": "reply",
|
||||||
"content": string(contentJSON),
|
|
||||||
"clawType": clawType,
|
"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 != "" {
|
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||||
toolArgs["uuid"] = v
|
toolArgs["uuid"] = v
|
||||||
}
|
}
|
||||||
@@ -5438,6 +5486,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
|||||||
},
|
},
|
||||||
Parameters: []contract.ParamDecl{
|
Parameters: []contract.ParamDecl{
|
||||||
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
|
{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"},
|
{Name: "conversation-id", Property: "openConversationId"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -5452,6 +5502,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
|||||||
_ = chatMessageReplyCmd.MarkFlagRequired("text")
|
_ = chatMessageReplyCmd.MarkFlagRequired("text")
|
||||||
chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)")
|
chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)")
|
||||||
chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
|
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")
|
cli.AttachRuntimeSchema(chatMessageReplyCmd, "chat", "reply_personal_message", "hardcoded:chat")
|
||||||
|
|
||||||
// ── message forward: 转发单条消息 ────────────────────────
|
// ── 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)
|
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
|
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 {
|
for _, tc := range []struct {
|
||||||
path string
|
path string
|
||||||
flag string
|
args []string
|
||||||
|
hint string
|
||||||
}{
|
}{
|
||||||
{path: "send", flag: "--group"},
|
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||||
{path: "history", flag: "--group"},
|
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||||
} {
|
} {
|
||||||
caller := &productExampleCaller{}
|
command, remaining, err := root.Find([]string{tc.path})
|
||||||
err := runChatCoverageCommand(t, caller, tc.path, tc.flag, "cid")
|
if err != nil {
|
||||||
if err == nil || !strings.Contains(err.Error(), "unknown command") || !strings.Contains(err.Error(), tc.path) {
|
t.Fatalf("find chat %s: %v", tc.path, err)
|
||||||
t.Fatalf("chat %s error = %v, want unknown command", tc.path, err)
|
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "unknown flag") {
|
if len(remaining) != 0 || command.Name() != tc.path {
|
||||||
t.Fatalf("chat %s was misreported as a flag error: %v", tc.path, err)
|
t.Fatalf("find chat %s = command %q, remaining %v", tc.path, command.Name(), remaining)
|
||||||
}
|
}
|
||||||
if caller.calls != 0 {
|
if !command.Hidden || !command.Runnable() {
|
||||||
t.Fatalf("chat %s tool calls = %d, want 0", tc.path, caller.calls)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"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) {
|
func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) {
|
||||||
caller := &chatChangedContractCaller{}
|
caller := &chatChangedContractCaller{}
|
||||||
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
|
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
|
||||||
|
|||||||
@@ -144,7 +144,6 @@ var (
|
|||||||
"clear": {},
|
"clear": {},
|
||||||
"refresh": {},
|
"refresh": {},
|
||||||
"recover": {},
|
"recover": {},
|
||||||
"recovery": {},
|
|
||||||
"login": {},
|
"login": {},
|
||||||
"logout": {},
|
"logout": {},
|
||||||
"register": {},
|
"register": {},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
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/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
"github.com/spf13/cobra"
|
"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) {
|
func TestChatDismissGroupRequiresConfirmationBeforeToolCall(t *testing.T) {
|
||||||
caller := &guardedMutationCaller{}
|
caller := &guardedMutationCaller{}
|
||||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -66,6 +66,8 @@ var (
|
|||||||
driveFileStat = (*os.File).Stat
|
driveFileStat = (*os.File).Stat
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() }
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
// HTTP 状态错误
|
// HTTP 状态错误
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
@@ -629,7 +631,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for part := range jobs {
|
for part := range jobs {
|
||||||
if runCtx.Err() != nil {
|
if driveWorkerContextErr(runCtx) != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
|
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) {
|
||||||
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
|
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
|
||||||
// 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。
|
// 通过结构化 seam 让 worker 在收到唯一分片后确定性观察到取消状态;
|
||||||
// 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。
|
// 不再依赖微秒级 timeout 与 goroutine 调度概率。
|
||||||
// transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。
|
var checks atomic.Int32
|
||||||
|
testseam.Swap(t, &driveWorkerContextErr, func(context.Context) error {
|
||||||
totalSize := int64(200)
|
checks.Add(1)
|
||||||
content := makeTestContent(int(totalSize))
|
return context.Canceled
|
||||||
|
})
|
||||||
origClient := driveRangeClient
|
var requests atomic.Int32
|
||||||
t.Cleanup(func() { driveRangeClient = origClient })
|
testseam.Swap(t, &driveRangeClient, &http.Client{
|
||||||
|
|
||||||
driveRangeClient = &http.Client{
|
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
// 每次请求加小延迟,让总处理时间接近 deadline
|
requests.Add(1)
|
||||||
time.Sleep(50 * time.Microsecond)
|
return nil, errors.New("worker context guard did not stop the request")
|
||||||
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
|
|
||||||
}),
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
if checks.Load() != 1 {
|
||||||
// 多次尝试以确保覆盖(goroutine 调度非确定性)
|
t.Fatalf("worker context checks = %d, want 1", checks.Load())
|
||||||
for attempt := 0; attempt < 50; attempt++ {
|
}
|
||||||
// timeout 设为约为总处理时间的50%,确保在处理过程中过期
|
if requests.Load() != 0 {
|
||||||
// 40分片/4workers=10轮*50μs=500μs,timeout设300μs使其在中间过期
|
t.Fatalf("worker requests = %d, want 0", requests.Load())
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-14
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
@@ -2334,12 +2335,13 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
|||||||
|
|
||||||
参数说明:
|
参数说明:
|
||||||
--users 目标用户UID列表,逗号分隔(规范名),兼容 --uids
|
--users 目标用户UID列表,逗号分隔(规范名),兼容 --uids
|
||||||
--yes 跳过二次确认,直接执行分享
|
--yes 确认执行此危险操作 (必填)
|
||||||
|
|
||||||
服务端可能返回风险提示(riskMessage)和 sign,此时需要用户确认后
|
默认需要 --yes 确认才能执行;--dry-run 仅预览分享计划,不发起真实请求。
|
||||||
携带 sign 重新请求。默认会展示风险提示并中止,传入 --yes 可跳过确认。`,
|
服务端可能返回风险提示(riskMessage)和 sign;在 --yes 已通过的前提下,
|
||||||
Example: ` dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1,uid2
|
将展示风险提示并自动携带 sign 重新请求。`,
|
||||||
dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1`,
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
|
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -2351,7 +2353,7 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
|||||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||||
mcpArgs["uids"] = parseRecipients(users)
|
mcpArgs["uids"] = parseRecipients(users)
|
||||||
}
|
}
|
||||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
yes, _ := cmd.Flags().GetBool("yes")
|
||||||
if deps.Caller.DryRun() {
|
if deps.Caller.DryRun() {
|
||||||
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
|
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
|
||||||
// evidence classifies "操作:" + audited DryRun() as plan.
|
// evidence classifies "操作:" + audited DryRun() as plan.
|
||||||
@@ -2361,10 +2363,18 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
|||||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||||
deps.Out.PrintKeyValue("users", users)
|
deps.Out.PrintKeyValue("users", users)
|
||||||
}
|
}
|
||||||
deps.Out.PrintKeyValue("yes", fmt.Sprintf("%v", skipConfirm))
|
deps.Out.PrintKeyValue("yes", fmt.Sprintf("%v", yes))
|
||||||
deps.Out.PrintKeyValue("说明", "仅预览分享计划,不发起真实分享请求")
|
deps.Out.PrintKeyValue("说明", "仅预览分享计划,不发起真实分享请求")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if !commandBoolFlag(cmd, "yes") {
|
||||||
|
return apperrors.NewValidation(
|
||||||
|
"分享邮件至 IM 为高风险操作;获得用户确认后加 --yes 执行",
|
||||||
|
apperrors.WithReason("confirmation_required"),
|
||||||
|
apperrors.WithHint("先确认目标用户与邮件内容;用户明确同意后以相同参数追加 --yes"),
|
||||||
|
apperrors.WithActions("确认目标用户与邮件", "获得用户确认后使用 --yes 执行"),
|
||||||
|
)
|
||||||
|
}
|
||||||
ctx := cmd.Context()
|
ctx := cmd.Context()
|
||||||
firstText, err := callMCPToolReturnText(ctx, "share_message_to_chat", mcpArgs)
|
firstText, err := callMCPToolReturnText(ctx, "share_message_to_chat", mcpArgs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2378,12 +2388,8 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
|||||||
firstResult = result
|
firstResult = result
|
||||||
}
|
}
|
||||||
if sign, ok := firstResult["sign"].(string); ok && sign != "" {
|
if sign, ok := firstResult["sign"].(string); ok && sign != "" {
|
||||||
riskMsg, _ := firstResult["riskMessage"].(string)
|
if riskMsg, _ := firstResult["riskMessage"].(string); riskMsg != "" {
|
||||||
if !skipConfirm {
|
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
|
||||||
if riskMsg != "" {
|
|
||||||
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("服务端要求二次确认,请添加 --yes 参数确认后重新执行")
|
|
||||||
}
|
}
|
||||||
mcpArgs["sign"] = sign
|
mcpArgs["sign"] = sign
|
||||||
return callMCPTool("share_message_to_chat", mcpArgs)
|
return callMCPTool("share_message_to_chat", mcpArgs)
|
||||||
@@ -2431,7 +2437,7 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
|||||||
messageShareToChatCmd.Flags().String("users", "", "目标用户UID列表,逗号分隔")
|
messageShareToChatCmd.Flags().String("users", "", "目标用户UID列表,逗号分隔")
|
||||||
messageShareToChatCmd.Flags().String("uids", "", "--users 的别名")
|
messageShareToChatCmd.Flags().String("uids", "", "--users 的别名")
|
||||||
_ = messageShareToChatCmd.Flags().MarkHidden("uids")
|
_ = messageShareToChatCmd.Flags().MarkHidden("uids")
|
||||||
messageShareToChatCmd.Flags().Bool("yes", false, "跳过二次确认,直接执行分享")
|
messageShareToChatCmd.Flags().Bool("yes", false, "确认执行此危险操作 (必填)")
|
||||||
|
|
||||||
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
|
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
|
||||||
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
|
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
|
||||||
|
|||||||
@@ -442,21 +442,20 @@ func TestCrossPlatformCoverageMailExportShareAndAtomicWrite(t *testing.T) {
|
|||||||
t.Fatal("expected parse error")
|
t.Fatal("expected parse error")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stdin "yes" satisfies deferred ConfirmSafety without leaf --yes,
|
// Piped stdin "yes" must not bypass the explicit --yes gate.
|
||||||
// so skipConfirm=false and the server-sign reconfirm branch runs.
|
|
||||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||||
{text: `{"result":{"sign":"sig","riskMessage":"careful"}}`},
|
{text: `{"result":{"sign":"sig","riskMessage":"careful"}}`},
|
||||||
}})
|
}})
|
||||||
if err := executeMailShare(t, strings.NewReader("yes\n"),
|
if err := executeMailShare(t, strings.NewReader("yes\n"),
|
||||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil || !strings.Contains(err.Error(), "--yes") {
|
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil {
|
||||||
t.Fatalf("expected server reconfirm, got %v", err)
|
t.Fatal("expected confirmation_required without --yes")
|
||||||
}
|
}
|
||||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||||
{text: `{"sign":"sig"}`},
|
{text: `{"sign":"sig"}`},
|
||||||
}})
|
}})
|
||||||
if err := executeMailShare(t, strings.NewReader("yes\n"),
|
if err := executeMailShare(t, strings.NewReader("yes\n"),
|
||||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil || !strings.Contains(err.Error(), "--yes") {
|
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil {
|
||||||
t.Fatalf("expected reconfirm without riskMessage, got %v", err)
|
t.Fatal("expected confirmation_required without --yes")
|
||||||
}
|
}
|
||||||
|
|
||||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||||
|
|||||||
@@ -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)",
|
" (每 %d 秒轮询一次)": " (polling every %d seconds)",
|
||||||
" 授权码: %s": " authorization code: %s",
|
" 授权码: %s": " authorization code: %s",
|
||||||
" 链接: %s": " link: %s",
|
" 链接: %s": " link: %s",
|
||||||
"%s (第 %d 次尝试)\\\n": "%s (attempt %d)\\\n",
|
"%s (第 %d 次尝试)\\\n": "%s (attempt %d)\\\n",
|
||||||
|
"--title 的别名": "Alias for --title",
|
||||||
"AI 表格 Base": "AI table Base",
|
"AI 表格 Base": "AI table Base",
|
||||||
"AI 表格 Base 管理": "AI table Base management",
|
"AI 表格 Base 管理": "AI table Base management",
|
||||||
"AITable 多维表格管理": "AITable multi-dimensional table management",
|
"AITable 多维表格管理": "AITable multi-dimensional table management",
|
||||||
"AI诚聘": "AI Recruitment",
|
"AI诚聘": "AI Recruitment",
|
||||||
"Base ID (必填)": "Base ID (required)",
|
"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.",
|
"MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。": "MCP service returned an unparseable protocol response; check service version or upstream proxy.",
|
||||||
"OSS 上传失败 HTTP %d: %s": "OSS upload failed HTTP %d: %s",
|
"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)",
|
"[%d] 轮询中... (%ds)": "[%d] polling ... (%ds)",
|
||||||
"prepare_attachment_upload 返回格式异常": "prepare_attachment_upload returned abnormal format",
|
"prepare_attachment_upload 返回格式异常": "prepare_attachment_upload returned abnormal format",
|
||||||
"refresh_token 刷新失败": "refresh_token refresh failed",
|
"refresh_token 刷新失败": "refresh_token refresh failed",
|
||||||
"refresh_token 刷新失败,将尝试扫码登录": "refresh_token refresh failed, will attempt QR code login",
|
"refresh_token 刷新失败,将尝试扫码登录": "refresh_token refresh failed, will attempt QR code login",
|
||||||
"true=已完成, false=未完成": "true=completed, false=not completed",
|
"true=已完成, false=未完成": "true=completed, false=not completed",
|
||||||
"⏳ 等待授权中...": "⏳ Waiting for authorization...",
|
"⏳ 等待授权中...": "⏳ 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",
|
"⚠️ 即将删除 %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",
|
"上传失败: %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",
|
"下载失败 (HTTP %d): %s": "download failed (HTTP %d): %s",
|
||||||
"下载失败: %w": "Download failed: %w",
|
"下载失败: %w": "Download failed: %w",
|
||||||
"不是文件: ": "not a file: ",
|
"不是文件: ": "not a file: ",
|
||||||
"人才查询": "Talent query",
|
"人才查询": "Talent query",
|
||||||
|
"优先级: 10低/20普通/30较高/40紧急": "Priority: 10=low/20=normal/30=high/40=urgent",
|
||||||
"使用授权码换取 Access Token...": "Exchanging authorization code for Access Token...",
|
"使用授权码换取 Access Token...": "Exchanging authorization code for Access Token...",
|
||||||
"保存 token 失败": "Failed to save token",
|
"保存 token 失败": "Failed to save token",
|
||||||
"保存Keyword subscription规则": "Save keyword subscription rule",
|
"保存Keyword subscription规则": "Save keyword subscription rule",
|
||||||
|
"修改待办任务": "Update todo task",
|
||||||
|
"修改执行者的待办完成状态": "Update executor todo done status",
|
||||||
"写入文件失败: %w": "write file failed: %w",
|
"写入文件失败: %w": "write file failed: %w",
|
||||||
"准备上传失败: %w": "prepare upload failed: %w",
|
"准备上传失败: %w": "prepare upload failed: %w",
|
||||||
"分页查询审批实例": "Paginated query approval instances",
|
"分页查询审批实例": "Paginated query approval instances",
|
||||||
|
"列出已安装的插件": "List installed plugins",
|
||||||
|
"列出插件所有配置项": "List all config values for a plugin",
|
||||||
"创建 / 查询 / 更新 / 删除待办": "Create / query / update / delete todo",
|
"创建 / 查询 / 更新 / 删除待办": "Create / query / update / delete todo",
|
||||||
"创建待办": "Create todo",
|
"创建待办": "Create todo",
|
||||||
"创建文件失败: %w": "create file failed: %w",
|
"创建文件失败: %w": "create file failed: %w",
|
||||||
"创建请求失败": "Failed to create request",
|
"创建请求失败": "Failed to create request",
|
||||||
"删除待办": "Delete todo",
|
|
||||||
"删除 AI 表格": "Delete AI table",
|
"删除 AI 表格": "Delete AI table",
|
||||||
"删除原因(可选)": "Delete reason (optional)",
|
"删除原因(可选)": "Delete reason (optional)",
|
||||||
"删除字段": "Delete field",
|
"删除字段": "Delete field",
|
||||||
|
"删除待办": "Delete todo",
|
||||||
"删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified Base (high risk, irreversible). Use --yes to skip confirmation.",
|
"删除指定 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 field (high risk, irreversible). Use --yes to skip confirmation.",
|
||||||
"删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified data table (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 data table",
|
||||||
"删除行记录": "Delete row records",
|
"删除行记录": "Delete row records",
|
||||||
|
"卸载已安装的插件": "Remove an installed plugin",
|
||||||
"参数不符合工具输入 schema;请检查 --json/--params/flags。": "Parameters do not match tool input schema; check --json/--params/flags.",
|
"参数不符合工具输入 schema;请检查 --json/--params/flags。": "Parameters do not match tool input schema; check --json/--params/flags.",
|
||||||
"发送请求失败": "Failed to send request",
|
"发送请求失败": "Failed to send request",
|
||||||
|
"启用插件": "Enable a plugin",
|
||||||
"回调中未收到授权码": "Callback did not receive authorization code",
|
"回调中未收到授权码": "Callback did not receive authorization code",
|
||||||
"如果浏览器未自动打开,请手动访问:\n %s\n\n": "If the browser did not open automatically, please visit:\n %s\n\n",
|
"如果浏览器未自动打开,请手动访问:\n %s\n\n": "If the browser did not open automatically, please visit:\n %s\n\n",
|
||||||
"字段": "field",
|
"字段": "field",
|
||||||
"字段 ID (必填)": "field ID (required)",
|
"字段 ID (必填)": "field ID (required)",
|
||||||
"字段管理": "Field management",
|
"字段管理": "Field management",
|
||||||
|
"安装插件": "Install a plugin",
|
||||||
|
"完成状态: true/false": "Done status: true/false",
|
||||||
|
"完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)",
|
||||||
"审批实例管理": "Approval instance management",
|
"审批实例管理": "Approval instance management",
|
||||||
|
"将插件 stdio server 编译为原生二进制": "Build plugin's stdio server into a native binary",
|
||||||
|
"将本地目录注册为开发态插件": "Register a local directory as a dev plugin",
|
||||||
"岗位查询": "Position query",
|
"岗位查询": "Position query",
|
||||||
"工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。": "Tool protocol incompatible; check service version, tool name, or upgrade to a dws version with the latest static endpoints.",
|
"工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 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.",
|
"工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.",
|
||||||
"已取消操作": "Operation cancelled",
|
"已取消操作": "Operation cancelled",
|
||||||
"当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s",
|
"当前平台 %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:",
|
"或者直接打开以下链接:": "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",
|
"所有凭证已失效,请运行 dws auth login 重新登录": "All credentials have expired, please run dws auth login to re-authenticate",
|
||||||
"打开 zip 失败: %w": "open zip failed: %w",
|
"打开 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.",
|
"批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。": "Batch delete records (high risk, irreversible), max 100 per batch. Use --yes to skip confirmation.",
|
||||||
"换取 token 失败": "Failed to exchange token",
|
"换取 token 失败": "Failed to exchange token",
|
||||||
"授权失败:未收到授权码": "Authorization failed: no authorization code received",
|
"授权失败:未收到授权码": "Authorization failed: no authorization code received",
|
||||||
@@ -65,12 +98,15 @@
|
|||||||
"授权码将在 %d 秒后过期。": "Authorization code will expire in %d seconds.",
|
"授权码将在 %d 秒后过期。": "Authorization code will expire in %d seconds.",
|
||||||
"授权码已过期,正在重新发起设备授权流程...": "Authorization code expired, restarting device authorization flow...",
|
"授权码已过期,正在重新发起设备授权流程...": "Authorization code expired, restarting device authorization flow...",
|
||||||
"授权超时(5分钟),请重试": "Authorization timeout (5 minutes), please retry",
|
"授权超时(5分钟),请重试": "Authorization timeout (5 minutes), please retry",
|
||||||
|
"插件管理": "Manage plugins",
|
||||||
|
"操作超时,请重新登录": "Operation timed out, please re-login",
|
||||||
"数据表": "data table",
|
"数据表": "data table",
|
||||||
"数据表 ID (必填)": "data table ID (required)",
|
"数据表 ID (必填)": "data table ID (required)",
|
||||||
"数据表管理": "Data table management",
|
"数据表管理": "Data table management",
|
||||||
"文件不存在: ": "File not found: ",
|
"文件不存在: ": "File not found: ",
|
||||||
"文件为空": "File is empty",
|
"文件为空": "File is empty",
|
||||||
"文件过大 (%d 字节,限制 %d 字节)": "File too large (%d bytes, limit %d bytes)",
|
"文件过大 (%d 字节,限制 %d 字节)": "File too large (%d bytes, limit %d bytes)",
|
||||||
|
"新标题": "New title",
|
||||||
"无法打开文件: %w": "Cannot open file: %w",
|
"无法打开文件: %w": "Cannot open file: %w",
|
||||||
"无法自动打开浏览器": "Cannot automatically open browser",
|
"无法自动打开浏览器": "Cannot automatically open browser",
|
||||||
"无法获取当前二进制路径: %w": "Cannot get current binary path: %w",
|
"无法获取当前二进制路径: %w": "Cannot get current binary path: %w",
|
||||||
@@ -78,10 +114,10 @@
|
|||||||
"无法解析符号链接: %w": "Cannot resolve symlink: %w",
|
"无法解析符号链接: %w": "Cannot resolve symlink: %w",
|
||||||
"无法读取当前二进制信息: %w": "Cannot read current binary info: %w",
|
"无法读取当前二进制信息: %w": "Cannot read current binary info: %w",
|
||||||
"无法连接到更新服务器: %w": "Cannot connect to update server: %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",
|
"服务端返回了空的 device_code 或 user_code": "Server returned empty device_code or user_code",
|
||||||
"服务端返回错误": "Server returned error",
|
"服务端返回错误": "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",
|
"未找到 MCP Server URL": "MCP Server URL not found",
|
||||||
"未找到认证信息,请运行 dws auth login": "No credentials found, please run dws auth login",
|
"未找到认证信息,请运行 dws auth login": "No credentials found, please run dws auth login",
|
||||||
"未登录,请运行 dws auth login": "Not logged in, please run dws auth login",
|
"未登录,请运行 dws auth login": "Not logged in, please run dws auth login",
|
||||||
@@ -90,42 +126,43 @@
|
|||||||
"本地文件路径 (必填)": "Local file path (required)",
|
"本地文件路径 (必填)": "Local file path (required)",
|
||||||
"权限不足;请检查当前身份是否有权限访问该服务或工具。": "Insufficient permissions; check if current identity has access to this service or tool.",
|
"权限不足;请检查当前身份是否有权限访问该服务或工具。": "Insufficient permissions; check if current identity has access to this service or tool.",
|
||||||
"构建上传请求失败: %w": "Failed to build upload request: %w",
|
"构建上传请求失败: %w": "Failed to build upload request: %w",
|
||||||
|
"查看任意命令的帮助信息": "Help about any command",
|
||||||
|
"查看插件详情": "Show plugin details",
|
||||||
"查询入职人才": "Query onboarding candidates",
|
"查询入职人才": "Query onboarding candidates",
|
||||||
"查询在招岗位": "Query open positions",
|
"查询在招岗位": "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",
|
"查询待办列表": "List todos",
|
||||||
"待办": "todo",
|
"校验 plugin.json": "Validate a plugin.json",
|
||||||
"待办任务 ID (必填)": "Todo task ID (required)",
|
"检查 CLI 授权状态失败": "Failed to check CLI auth status",
|
||||||
"待办任务管理": "Todo task management",
|
"检查 internal/syncdata 静态端点生成物是否包含目标 server": "Check whether internal/syncdata static endpoint artifacts include the target server",
|
||||||
"待办标题 (必填)": "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",
|
|
||||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "Check service connectivity and retry; if persistent, verify MCP service is responding.",
|
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "Check service connectivity and retry; if persistent, verify MCP service is responding.",
|
||||||
"检查服务连通性和协议版本后重试": "Check service connectivity and protocol version, then retry",
|
"检查服务连通性和协议版本后重试": "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",
|
"检查认证、权限和参数后重试原命令": "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",
|
"步骤 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...",
|
"步骤 2/3: 上传文件到 OSS...": "Step 2/3: Uploading file to OSS...",
|
||||||
"步骤 3/3: 上传完成!": "Step 3/3: Upload complete!",
|
"步骤 3/3: 上传完成!": "Step 3/3: Upload complete!",
|
||||||
"用户拒绝了授权请求": "User rejected the authorization request",
|
"用户拒绝了授权请求": "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",
|
"确认 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 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.",
|
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "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...",
|
"网络错误,继续重试...": "Network error, retrying...",
|
||||||
|
"脚手架生成新插件目录": "Scaffold a new plugin directory",
|
||||||
"获取使用指引": "Get usage guide",
|
"获取使用指引": "Get usage guide",
|
||||||
"获取审批实例详情": "Get approval instance details",
|
"获取审批实例详情": "Get approval instance details",
|
||||||
"获取数量,超过 20 自动分页 (默认 20)": "Fetch count, auto-paginate if over 20 (default 20)",
|
"获取数量,超过 20 自动分页 (默认 20)": "Fetch count, auto-paginate if over 20 (default 20)",
|
||||||
"获取版本信息失败 (HTTP %d)": "Get version info failed (HTTP %d)",
|
"获取版本信息失败 (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 token data",
|
||||||
"解析响应失败": "Failed to parse response",
|
"解析响应失败": "Failed to parse response",
|
||||||
"解析版本信息失败: %w": "Parse version info failed: %w",
|
"解析版本信息失败: %w": "Parse version info failed: %w",
|
||||||
@@ -138,59 +175,32 @@
|
|||||||
"设备授权流程失败(已重试 %d 次)": "Device authorization flow failed (retried %d times)",
|
"设备授权流程失败(已重试 %d 次)": "Device authorization flow failed (retried %d times)",
|
||||||
"设备授权码已过期": "Device authorization code has expired",
|
"设备授权码已过期": "Device authorization code has expired",
|
||||||
"设备授权码已过期(%d 秒),请重试": "Device authorization code expired (%d seconds), please retry",
|
"设备授权码已过期(%d 秒),请重试": "Device authorization code expired (%d seconds), please retry",
|
||||||
|
"设置插件配置项": "Set a plugin config value",
|
||||||
"设置权限失败: %w": "Set permission failed: %w",
|
"设置权限失败: %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 complete QR code authorization in the browser.",
|
||||||
"请在浏览器中打开以下链接,并输入授权码:": "Please open the following link in your browser and enter the authorization code:",
|
"请在浏览器中打开以下链接,并输入授权码:": "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.",
|
"请检查服务 endpoint 是否为空或格式不合法。": "Please check if the service endpoint is empty or has invalid format.",
|
||||||
"请检查网络连通性和 MCP 服务状态后重试。": "Check network connectivity and MCP service status, then retry.",
|
"请检查网络连通性和 MCP 服务状态后重试。": "Check network connectivity and MCP service status, then retry.",
|
||||||
"请求在重试过程中被取消;请检查调用侧超时设置。": "Request was cancelled during retry; check caller-side timeout settings.",
|
"请求在重试过程中被取消;请检查调用侧超时设置。": "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.",
|
"请求被上游服务拒绝;请检查参数、认证和权限配置。": "Request was rejected by upstream service; check parameters, authentication, and permissions.",
|
||||||
"请求设备授权码...": "Requesting device authorization code...",
|
"请求设备授权码...": "Requesting device authorization code...",
|
||||||
"请求设备授权码失败": "Failed to request 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",
|
"读取 zip 条目失败: %w": "Failed to read zip entry: %w",
|
||||||
"读取响应失败": "Failed to read response",
|
"读取响应失败": "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",
|
"读取插件配置项": "Get a plugin config value",
|
||||||
"列出插件所有配置项": "List all config values for a plugin",
|
"读取版本信息失败: %w": "Read version info failed: %w",
|
||||||
"删除插件配置项": "Remove a plugin config value",
|
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "Call rejected; check authentication status, tenant identity, or access permissions.",
|
||||||
"将插件 stdio server 编译为原生二进制": "Build plugin's stdio server into a native binary",
|
"跳过确认直接删除": "Skip confirmation and delete directly",
|
||||||
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "Override OAuth client ID (DingTalk AppKey)",
|
"轮询过快,间隔增加至 %ds": "Polling too fast, interval increased to %ds",
|
||||||
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "Override OAuth client secret (DingTalk AppSecret)",
|
"运行 dws auth status 确认凭证有效,必要时重新登录": "Run dws auth status to confirm credentials are valid; re-login if needed",
|
||||||
"查看任意命令的帮助信息": "Help about any command",
|
"运行 sync-oss 重新生成静态端点与路由后重试": "Run sync-oss to regenerate static endpoints and routes, then retry",
|
||||||
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "Help provides help for any command in the application.\nSimply type dws help [path to command] for full details."
|
"返回数据缺少 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 秒轮询一次)",
|
" (每 %d 秒轮询一次)": " (每 %d 秒轮询一次)",
|
||||||
" 授权码: %s": " 授权码: %s",
|
" 授权码: %s": " 授权码: %s",
|
||||||
" 链接: %s": " 链接: %s",
|
" 链接: %s": " 链接: %s",
|
||||||
"%s (第 %d 次尝试)\\\n": "%s (第 %d 次尝试)\\\n",
|
"%s (第 %d 次尝试)\\\n": "%s (第 %d 次尝试)\\\n",
|
||||||
|
"--title 的别名": "--title 的别名",
|
||||||
"AI 表格 Base": "AI 表格 Base",
|
"AI 表格 Base": "AI 表格 Base",
|
||||||
"AI 表格 Base 管理": "AI 表格 Base 管理",
|
"AI 表格 Base 管理": "AI 表格 Base 管理",
|
||||||
"AITable 多维表格管理": "AITable 多维表格管理",
|
"AITable 多维表格管理": "AITable 多维表格管理",
|
||||||
"AI诚聘": "AI诚聘",
|
"AI诚聘": "AI诚聘",
|
||||||
"Base ID (必填)": "Base ID (必填)",
|
"Base ID (必填)": "Base ID (必填)",
|
||||||
|
"DNS 解析失败。请检查域名拼写和网络 DNS 配置。": "DNS 解析失败。请检查域名拼写和网络 DNS 配置。",
|
||||||
|
"HTTP 请求超时(等待服务端响应超时)。可通过 --timeout 增大超时时间,或检查服务端是否正常。": "HTTP 请求超时(等待服务端响应超时)。可通过 --timeout 增大超时时间,或检查服务端是否正常。",
|
||||||
"MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。": "MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。",
|
"MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。": "MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。",
|
||||||
"OSS 上传失败 HTTP %d: %s": "OSS 上传失败 HTTP %d: %s",
|
"OSS 上传失败 HTTP %d: %s": "OSS 上传失败 HTTP %d: %s",
|
||||||
|
"TLS 握手超时。请检查网络连接或代理设置。": "TLS 握手超时。请检查网络连接或代理设置。",
|
||||||
"[%d] 轮询中... (%ds)": "[%d] 轮询中... (%ds)",
|
"[%d] 轮询中... (%ds)": "[%d] 轮询中... (%ds)",
|
||||||
"prepare_attachment_upload 返回格式异常": "prepare_attachment_upload 返回格式异常",
|
"prepare_attachment_upload 返回格式异常": "prepare_attachment_upload 返回格式异常",
|
||||||
"refresh_token 刷新失败": "refresh_token 刷新失败",
|
"refresh_token 刷新失败": "refresh_token 刷新失败",
|
||||||
"refresh_token 刷新失败,将尝试扫码登录": "refresh_token 刷新失败,将尝试扫码登录",
|
"refresh_token 刷新失败,将尝试扫码登录": "refresh_token 刷新失败,将尝试扫码登录",
|
||||||
"true=已完成, false=未完成": "true=已完成, false=未完成",
|
"true=已完成, false=未完成": "true=已完成, false=未完成",
|
||||||
"⏳ 等待授权中...": "⏳ 等待授权中...",
|
"⏳ 等待授权中...": "⏳ 等待授权中...",
|
||||||
|
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...",
|
||||||
"⚠️ 即将删除 %s: %s\\\n": "⚠️ 即将删除 %s: %s\\\n",
|
"⚠️ 即将删除 %s: %s\\\n": "⚠️ 即将删除 %s: %s\\\n",
|
||||||
|
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ 无法检查 CLI 数据访问权限状态",
|
||||||
|
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ 该组织尚未开启 CLI 数据访问权限",
|
||||||
|
"✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...",
|
||||||
"上传失败: %w": "上传失败: %w",
|
"上传失败: %w": "上传失败: %w",
|
||||||
"上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。": "上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。",
|
"上游服务异常;可稍后重试。": "上游服务异常;可稍后重试。",
|
||||||
"下载失败 (HTTP %d): %s": "下载失败 (HTTP %d): %s",
|
"下载失败 (HTTP %d): %s": "下载失败 (HTTP %d): %s",
|
||||||
"下载失败: %w": "下载失败: %w",
|
"下载失败: %w": "下载失败: %w",
|
||||||
"不是文件: ": "不是文件: ",
|
"不是文件: ": "不是文件: ",
|
||||||
"人才查询": "人才查询",
|
"人才查询": "人才查询",
|
||||||
|
"优先级: 10低/20普通/30较高/40紧急": "优先级: 10低/20普通/30较高/40紧急",
|
||||||
"使用授权码换取 Access Token...": "使用授权码换取 Access Token...",
|
"使用授权码换取 Access Token...": "使用授权码换取 Access Token...",
|
||||||
"保存 token 失败": "保存 token 失败",
|
"保存 token 失败": "保存 token 失败",
|
||||||
"保存Keyword subscription规则": "保存Keyword subscription规则",
|
"保存Keyword subscription规则": "保存Keyword subscription规则",
|
||||||
|
"修改待办任务": "修改待办任务",
|
||||||
|
"修改执行者的待办完成状态": "修改执行者的待办完成状态",
|
||||||
"写入文件失败: %w": "写入文件失败: %w",
|
"写入文件失败: %w": "写入文件失败: %w",
|
||||||
"准备上传失败: %w": "准备上传失败: %w",
|
"准备上传失败: %w": "准备上传失败: %w",
|
||||||
"分页查询审批实例": "分页查询审批实例",
|
"分页查询审批实例": "分页查询审批实例",
|
||||||
|
"列出已安装的插件": "列出已安装的插件",
|
||||||
|
"列出插件所有配置项": "列出插件所有配置项",
|
||||||
"创建 / 查询 / 更新 / 删除待办": "创建 / 查询 / 更新 / 删除待办",
|
"创建 / 查询 / 更新 / 删除待办": "创建 / 查询 / 更新 / 删除待办",
|
||||||
"创建待办": "创建待办",
|
"创建待办": "创建待办",
|
||||||
"创建文件失败: %w": "创建文件失败: %w",
|
"创建文件失败: %w": "创建文件失败: %w",
|
||||||
"创建请求失败": "创建请求失败",
|
"创建请求失败": "创建请求失败",
|
||||||
"删除待办": "删除待办",
|
|
||||||
"删除 AI 表格": "删除 AI 表格",
|
"删除 AI 表格": "删除 AI 表格",
|
||||||
"删除原因(可选)": "删除原因(可选)",
|
"删除原因(可选)": "删除原因(可选)",
|
||||||
"删除字段": "删除字段",
|
"删除字段": "删除字段",
|
||||||
|
"删除待办": "删除待办",
|
||||||
"删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。",
|
"删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。",
|
||||||
"删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。",
|
"删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。",
|
||||||
"删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。",
|
"删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。",
|
||||||
|
"删除插件配置项": "删除插件配置项",
|
||||||
"删除数据表": "删除数据表",
|
"删除数据表": "删除数据表",
|
||||||
"删除行记录": "删除行记录",
|
"删除行记录": "删除行记录",
|
||||||
|
"卸载已安装的插件": "卸载已安装的插件",
|
||||||
"参数不符合工具输入 schema;请检查 --json/--params/flags。": "参数不符合工具输入 schema;请检查 --json/--params/flags。",
|
"参数不符合工具输入 schema;请检查 --json/--params/flags。": "参数不符合工具输入 schema;请检查 --json/--params/flags。",
|
||||||
"发送请求失败": "发送请求失败",
|
"发送请求失败": "发送请求失败",
|
||||||
|
"启用插件": "启用插件",
|
||||||
"回调中未收到授权码": "回调中未收到授权码",
|
"回调中未收到授权码": "回调中未收到授权码",
|
||||||
"如果浏览器未自动打开,请手动访问:\n %s\n\n": "如果浏览器未自动打开,请手动访问:\n %s\n\n",
|
"如果浏览器未自动打开,请手动访问:\n %s\n\n": "如果浏览器未自动打开,请手动访问:\n %s\n\n",
|
||||||
"字段": "字段",
|
"字段": "字段",
|
||||||
"字段 ID (必填)": "字段 ID (必填)",
|
"字段 ID (必填)": "字段 ID (必填)",
|
||||||
"字段管理": "字段管理",
|
"字段管理": "字段管理",
|
||||||
|
"安装插件": "安装插件",
|
||||||
|
"完成状态: true/false": "完成状态: true/false",
|
||||||
|
"完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)",
|
||||||
"审批实例管理": "审批实例管理",
|
"审批实例管理": "审批实例管理",
|
||||||
|
"将插件 stdio server 编译为原生二进制": "将插件 stdio server 编译为原生二进制",
|
||||||
|
"将本地目录注册为开发态插件": "将本地目录注册为开发态插件",
|
||||||
"岗位查询": "岗位查询",
|
"岗位查询": "岗位查询",
|
||||||
"工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。": "工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。",
|
"工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。": "工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。",
|
||||||
"工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。",
|
"工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。",
|
||||||
"已取消操作": "已取消操作",
|
"已取消操作": "已取消操作",
|
||||||
"当前平台 %s 没有可用的预编译二进制": "当前平台 %s 没有可用的预编译二进制",
|
"当前平台 %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 重新登录",
|
"所有凭证已失效,请运行 dws auth login 重新登录": "所有凭证已失效,请运行 dws auth login 重新登录",
|
||||||
"打开 zip 失败: %w": "打开 zip 失败: %w",
|
"打开 zip 失败: %w": "打开 zip 失败: %w",
|
||||||
|
"执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)",
|
||||||
"批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。": "批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。",
|
"批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。": "批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。",
|
||||||
"换取 token 失败": "换取 token 失败",
|
"换取 token 失败": "换取 token 失败",
|
||||||
"授权失败:未收到授权码": "授权失败:未收到授权码",
|
"授权失败:未收到授权码": "授权失败:未收到授权码",
|
||||||
@@ -65,12 +98,15 @@
|
|||||||
"授权码将在 %d 秒后过期。": "授权码将在 %d 秒后过期。",
|
"授权码将在 %d 秒后过期。": "授权码将在 %d 秒后过期。",
|
||||||
"授权码已过期,正在重新发起设备授权流程...": "授权码已过期,正在重新发起设备授权流程...",
|
"授权码已过期,正在重新发起设备授权流程...": "授权码已过期,正在重新发起设备授权流程...",
|
||||||
"授权超时(5分钟),请重试": "授权超时(5分钟),请重试",
|
"授权超时(5分钟),请重试": "授权超时(5分钟),请重试",
|
||||||
|
"插件管理": "插件管理",
|
||||||
|
"操作超时,请重新登录": "操作超时,请重新登录",
|
||||||
"数据表": "数据表",
|
"数据表": "数据表",
|
||||||
"数据表 ID (必填)": "数据表 ID (必填)",
|
"数据表 ID (必填)": "数据表 ID (必填)",
|
||||||
"数据表管理": "数据表管理",
|
"数据表管理": "数据表管理",
|
||||||
"文件不存在: ": "文件不存在: ",
|
"文件不存在: ": "文件不存在: ",
|
||||||
"文件为空": "文件为空",
|
"文件为空": "文件为空",
|
||||||
"文件过大 (%d 字节,限制 %d 字节)": "文件过大 (%d 字节,限制 %d 字节)",
|
"文件过大 (%d 字节,限制 %d 字节)": "文件过大 (%d 字节,限制 %d 字节)",
|
||||||
|
"新标题": "新标题",
|
||||||
"无法打开文件: %w": "无法打开文件: %w",
|
"无法打开文件: %w": "无法打开文件: %w",
|
||||||
"无法自动打开浏览器": "无法自动打开浏览器",
|
"无法自动打开浏览器": "无法自动打开浏览器",
|
||||||
"无法获取当前二进制路径: %w": "无法获取当前二进制路径: %w",
|
"无法获取当前二进制路径: %w": "无法获取当前二进制路径: %w",
|
||||||
@@ -78,10 +114,10 @@
|
|||||||
"无法解析符号链接: %w": "无法解析符号链接: %w",
|
"无法解析符号链接: %w": "无法解析符号链接: %w",
|
||||||
"无法读取当前二进制信息: %w": "无法读取当前二进制信息: %w",
|
"无法读取当前二进制信息: %w": "无法读取当前二进制信息: %w",
|
||||||
"无法连接到更新服务器: %w": "无法连接到更新服务器: %w",
|
"无法连接到更新服务器: %w": "无法连接到更新服务器: %w",
|
||||||
"静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。": "静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。",
|
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。",
|
||||||
"服务端返回了空的 device_code 或 user_code": "服务端返回了空的 device_code 或 user_code",
|
"服务端返回了空的 device_code 或 user_code": "服务端返回了空的 device_code 或 user_code",
|
||||||
"服务端返回错误": "服务端返回错误",
|
"服务端返回错误": "服务端返回错误",
|
||||||
"服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。": "服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。",
|
"服务返回了空结果;请稍后重试。": "服务返回了空结果;请稍后重试。",
|
||||||
"未找到 MCP Server URL": "未找到 MCP Server URL",
|
"未找到 MCP Server URL": "未找到 MCP Server URL",
|
||||||
"未找到认证信息,请运行 dws auth login": "未找到认证信息,请运行 dws auth login",
|
"未找到认证信息,请运行 dws auth login": "未找到认证信息,请运行 dws auth login",
|
||||||
"未登录,请运行 dws auth login": "未登录,请运行 dws auth login",
|
"未登录,请运行 dws auth login": "未登录,请运行 dws auth login",
|
||||||
@@ -90,42 +126,43 @@
|
|||||||
"本地文件路径 (必填)": "本地文件路径 (必填)",
|
"本地文件路径 (必填)": "本地文件路径 (必填)",
|
||||||
"权限不足;请检查当前身份是否有权限访问该服务或工具。": "权限不足;请检查当前身份是否有权限访问该服务或工具。",
|
"权限不足;请检查当前身份是否有权限访问该服务或工具。": "权限不足;请检查当前身份是否有权限访问该服务或工具。",
|
||||||
"构建上传请求失败: %w": "构建上传请求失败: %w",
|
"构建上传请求失败: %w": "构建上传请求失败: %w",
|
||||||
|
"查看任意命令的帮助信息": "查看任意命令的帮助信息",
|
||||||
|
"查看插件详情": "查看插件详情",
|
||||||
"查询入职人才": "查询入职人才",
|
"查询入职人才": "查询入职人才",
|
||||||
"查询在招岗位": "查询在招岗位",
|
"查询在招岗位": "查询在招岗位",
|
||||||
"修改待办任务": "修改待办任务",
|
|
||||||
"修改执行者的待办完成状态": "修改执行者的待办完成状态",
|
|
||||||
"优先级: 10低/20普通/30较高/40紧急": "优先级: 10低/20普通/30较高/40紧急",
|
|
||||||
"查询待办列表": "查询待办列表",
|
"查询待办列表": "查询待办列表",
|
||||||
"待办": "待办",
|
"校验 plugin.json": "校验 plugin.json",
|
||||||
"待办任务 ID (必填)": "待办任务 ID (必填)",
|
"检查 CLI 授权状态失败": "检查 CLI 授权状态失败",
|
||||||
"待办任务管理": "待办任务管理",
|
"检查 internal/syncdata 静态端点生成物是否包含目标 server": "检查 internal/syncdata 静态端点生成物是否包含目标 server",
|
||||||
"待办标题 (必填)": "待办标题 (必填)",
|
|
||||||
"待办详情": "待办详情",
|
|
||||||
"循环待办 (需先设置 --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 配置后重试原命令",
|
|
||||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。",
|
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。",
|
||||||
"检查服务连通性和协议版本后重试": "检查服务连通性和协议版本后重试",
|
"检查服务连通性和协议版本后重试": "检查服务连通性和协议版本后重试",
|
||||||
|
"检查登录状态后重试": "检查登录状态后重试",
|
||||||
|
"检查组织 CLI 授权状态...": "检查组织 CLI 授权状态...",
|
||||||
|
"检查网络、代理和 DNS 配置后重试原命令": "检查网络、代理和 DNS 配置后重试原命令",
|
||||||
"检查认证、权限和参数后重试原命令": "检查认证、权限和参数后重试原命令",
|
"检查认证、权限和参数后重试原命令": "检查认证、权限和参数后重试原命令",
|
||||||
"新标题": "新标题",
|
|
||||||
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。",
|
|
||||||
"步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n": "步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n",
|
"步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n": "步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n",
|
||||||
"步骤 2/3: 上传文件到 OSS...": "步骤 2/3: 上传文件到 OSS...",
|
"步骤 2/3: 上传文件到 OSS...": "步骤 2/3: 上传文件到 OSS...",
|
||||||
"步骤 3/3: 上传完成!": "步骤 3/3: 上传完成!",
|
"步骤 3/3: 上传完成!": "步骤 3/3: 上传完成!",
|
||||||
"用户拒绝了授权请求": "用户拒绝了授权请求",
|
"用户拒绝了授权请求": "用户拒绝了授权请求",
|
||||||
"完成状态: true/false": "完成状态: true/false",
|
|
||||||
"完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)",
|
|
||||||
"确认删除? (yes/no): ": "确认删除? (yes/no): ",
|
|
||||||
"确认 MCP 服务可访问;若持续失败请稍后重试": "确认 MCP 服务可访问;若持续失败请稍后重试",
|
"确认 MCP 服务可访问;若持续失败请稍后重试": "确认 MCP 服务可访问;若持续失败请稍后重试",
|
||||||
|
"确认删除? (yes/no): ": "确认删除? (yes/no): ",
|
||||||
|
"禁用插件": "禁用插件",
|
||||||
|
"等待提交申请中": "等待提交申请中",
|
||||||
"等待用户授权...": "等待用户授权...",
|
"等待用户授权...": "等待用户授权...",
|
||||||
|
"等待管理员审批中": "等待管理员审批中",
|
||||||
|
"管理员操作入口:": "管理员操作入口:",
|
||||||
|
"管理插件配置": "管理插件配置",
|
||||||
|
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。",
|
||||||
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。",
|
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。",
|
||||||
|
"网络 I/O 超时。可通过 --timeout 增大超时时间,或检查网络连接。": "网络 I/O 超时。可通过 --timeout 增大超时时间,或检查网络连接。",
|
||||||
"网络错误,继续重试...": "网络错误,继续重试...",
|
"网络错误,继续重试...": "网络错误,继续重试...",
|
||||||
|
"脚手架生成新插件目录": "脚手架生成新插件目录",
|
||||||
"获取使用指引": "获取使用指引",
|
"获取使用指引": "获取使用指引",
|
||||||
"获取审批实例详情": "获取审批实例详情",
|
"获取审批实例详情": "获取审批实例详情",
|
||||||
"获取数量,超过 20 自动分页 (默认 20)": "获取数量,超过 20 自动分页 (默认 20)",
|
"获取数量,超过 20 自动分页 (默认 20)": "获取数量,超过 20 自动分页 (默认 20)",
|
||||||
"获取版本信息失败 (HTTP %d)": "获取版本信息失败 (HTTP %d)",
|
"获取版本信息失败 (HTTP %d)": "获取版本信息失败 (HTTP %d)",
|
||||||
|
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "覆盖 OAuth 客户端 ID (钉钉 AppKey)",
|
||||||
|
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "覆盖 OAuth 客户端密钥 (钉钉 AppSecret)",
|
||||||
"解析令牌数据失败": "解析令牌数据失败",
|
"解析令牌数据失败": "解析令牌数据失败",
|
||||||
"解析响应失败": "解析响应失败",
|
"解析响应失败": "解析响应失败",
|
||||||
"解析版本信息失败: %w": "解析版本信息失败: %w",
|
"解析版本信息失败: %w": "解析版本信息失败: %w",
|
||||||
@@ -138,59 +175,32 @@
|
|||||||
"设备授权流程失败(已重试 %d 次)": "设备授权流程失败(已重试 %d 次)",
|
"设备授权流程失败(已重试 %d 次)": "设备授权流程失败(已重试 %d 次)",
|
||||||
"设备授权码已过期": "设备授权码已过期",
|
"设备授权码已过期": "设备授权码已过期",
|
||||||
"设备授权码已过期(%d 秒),请重试": "设备授权码已过期(%d 秒),请重试",
|
"设备授权码已过期(%d 秒),请重试": "设备授权码已过期(%d 秒),请重试",
|
||||||
|
"设置插件配置项": "设置插件配置项",
|
||||||
"设置权限失败: %w": "设置权限失败: %w",
|
"设置权限失败: %w": "设置权限失败: %w",
|
||||||
|
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启",
|
||||||
"请在浏览器中完成扫码授权。": "请在浏览器中完成扫码授权。",
|
"请在浏览器中完成扫码授权。": "请在浏览器中完成扫码授权。",
|
||||||
"请在浏览器中打开以下链接,并输入授权码:": "请在浏览器中打开以下链接,并输入授权码:",
|
"请在浏览器中打开以下链接,并输入授权码:": "请在浏览器中打开以下链接,并输入授权码:",
|
||||||
"请检查服务 endpoint 是否为空或格式不合法。": "请检查服务 endpoint 是否为空或格式不合法。",
|
"请检查服务 endpoint 是否为空或格式不合法。": "请检查服务 endpoint 是否为空或格式不合法。",
|
||||||
"请检查网络连通性和 MCP 服务状态后重试。": "请检查网络连通性和 MCP 服务状态后重试。",
|
"请检查网络连通性和 MCP 服务状态后重试。": "请检查网络连通性和 MCP 服务状态后重试。",
|
||||||
"请求在重试过程中被取消;请检查调用侧超时设置。": "请求在重试过程中被取消;请检查调用侧超时设置。",
|
"请求在重试过程中被取消;请检查调用侧超时设置。": "请求在重试过程中被取消;请检查调用侧超时设置。",
|
||||||
|
"请求已取消。如果非手动取消,请检查调用侧超时设置。": "请求已取消。如果非手动取消,请检查调用侧超时设置。",
|
||||||
"请求被上游服务拒绝;请检查参数、认证和权限配置。": "请求被上游服务拒绝;请检查参数、认证和权限配置。",
|
"请求被上游服务拒绝;请检查参数、认证和权限配置。": "请求被上游服务拒绝;请检查参数、认证和权限配置。",
|
||||||
"请求设备授权码...": "请求设备授权码...",
|
"请求设备授权码...": "请求设备授权码...",
|
||||||
"请求设备授权码失败": "请求设备授权码失败",
|
"请求设备授权码失败": "请求设备授权码失败",
|
||||||
|
"请求超时(上下文截止时间已到)。可通过 --timeout 增大超时时间,或检查网络连接。": "请求超时(上下文截止时间已到)。可通过 --timeout 增大超时时间,或检查网络连接。",
|
||||||
"读取 zip 条目失败: %w": "读取 zip 条目失败: %w",
|
"读取 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",
|
|
||||||
"脚手架生成新插件目录": "脚手架生成新插件目录",
|
|
||||||
"将本地目录注册为开发态插件": "将本地目录注册为开发态插件",
|
|
||||||
"管理插件配置": "管理插件配置",
|
|
||||||
"设置插件配置项": "设置插件配置项",
|
|
||||||
"读取插件配置项": "读取插件配置项",
|
"读取插件配置项": "读取插件配置项",
|
||||||
"列出插件所有配置项": "列出插件所有配置项",
|
"读取版本信息失败: %w": "读取版本信息失败: %w",
|
||||||
"删除插件配置项": "删除插件配置项",
|
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "调用被拒绝;请检查认证状态、租户身份或访问权限。",
|
||||||
"将插件 stdio server 编译为原生二进制": "将插件 stdio server 编译为原生二进制",
|
"跳过确认直接删除": "跳过确认直接删除",
|
||||||
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "覆盖 OAuth 客户端 ID (钉钉 AppKey)",
|
"轮询过快,间隔增加至 %ds": "轮询过快,间隔增加至 %ds",
|
||||||
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "覆盖 OAuth 客户端密钥 (钉钉 AppSecret)",
|
"运行 dws auth status 确认凭证有效,必要时重新登录": "运行 dws auth status 确认凭证有效,必要时重新登录",
|
||||||
"查看任意命令的帮助信息": "查看任意命令的帮助信息",
|
"运行 sync-oss 重新生成静态端点与路由后重试": "运行 sync-oss 重新生成静态端点与路由后重试",
|
||||||
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。"
|
"返回数据缺少 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) {
|
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 nil
|
||||||
}
|
}
|
||||||
return func(cmd *cobra.Command) {
|
return func(cmd *cobra.Command) {
|
||||||
cmd.Aliases = append([]string(nil), s.Aliases...)
|
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)
|
name := strings.TrimSpace(s.SinglePositionalAliasFor)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return
|
return
|
||||||
@@ -117,6 +134,16 @@ func safetySpecDeclared(safety contract.SafetySpec) bool {
|
|||||||
strings.TrimSpace(safety.Idempotency) != ""
|
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 {
|
func shortcutExamples(tips []string) string {
|
||||||
if len(tips) == 0 {
|
if len(tips) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -188,6 +215,7 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec {
|
|||||||
ValidationMode: corecmd.ValidationShortcut,
|
ValidationMode: corecmd.ValidationShortcut,
|
||||||
RequiredError: fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc),
|
RequiredError: fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc),
|
||||||
Enum: append([]string(nil), f.Enum...),
|
Enum: append([]string(nil), f.Enum...),
|
||||||
|
Aliases: append([]string(nil), f.Aliases...),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
|
|||||||
cs.Safety.Confirmation != "user_required" || cs.Safety.Idempotency != "unknown" {
|
cs.Safety.Confirmation != "user_required" || cs.Safety.Idempotency != "unknown" {
|
||||||
t.Fatalf("adapter safety = %#v, want destructive/high/user_required/unknown", cs.Safety)
|
t.Fatalf("adapter safety = %#v, want destructive/high/user_required/unknown", cs.Safety)
|
||||||
}
|
}
|
||||||
|
if got := EffectiveSafety(Shortcut{Risk: RiskWrite}); got.Effect != "write" || got.Confirmation != "user_required" {
|
||||||
|
t.Fatalf("legacy effective safety = %#v", got)
|
||||||
|
}
|
||||||
|
explicit := contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
||||||
|
if got := EffectiveSafety(Shortcut{Risk: RiskHighWrite, Safety: explicit}); got != explicit {
|
||||||
|
t.Fatalf("explicit effective safety = %#v, want %#v", got, explicit)
|
||||||
|
}
|
||||||
if cs.Orchestrate == nil {
|
if cs.Orchestrate == nil {
|
||||||
t.Fatal("multi-step Execute must project into Orchestrate")
|
t.Fatal("multi-step Execute must project into Orchestrate")
|
||||||
}
|
}
|
||||||
@@ -142,7 +149,7 @@ func TestCrossPlatformCoverageFromShortcutAliasesAndPositionalAlias(t *testing.T
|
|||||||
ProductID: "chat", Name: "shortcut_search", CanonicalPath: "chat.shortcut_search", CLIPath: "chat +search", PrimaryCLIPath: "chat +search",
|
ProductID: "chat", Name: "shortcut_search", CanonicalPath: "chat.shortcut_search", CLIPath: "chat +search", PrimaryCLIPath: "chat +search",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Flags: []Flag{{Name: "query", Desc: "关键词", Required: true}},
|
Flags: []Flag{{Name: "query", Desc: "关键词", Required: true, Aliases: []string{"keyword"}, AliasesVisible: true}},
|
||||||
Execute: func(rt *RuntimeContext) error { executed = rt.Str("query"); return nil },
|
Execute: func(rt *RuntimeContext) error { executed = rt.Str("query"); return nil },
|
||||||
}
|
}
|
||||||
spec := FromShortcut(s)
|
spec := FromShortcut(s)
|
||||||
@@ -153,6 +160,9 @@ func TestCrossPlatformCoverageFromShortcutAliasesAndPositionalAlias(t *testing.T
|
|||||||
if !cmd.HasAlias("+search-group") {
|
if !cmd.HasAlias("+search-group") {
|
||||||
t.Fatalf("cobra aliases = %#v", cmd.Aliases)
|
t.Fatalf("cobra aliases = %#v", cmd.Aliases)
|
||||||
}
|
}
|
||||||
|
if alias := cmd.Flags().Lookup("keyword"); alias == nil || alias.Hidden {
|
||||||
|
t.Fatalf("historically public flag alias = %#v, want visible", alias)
|
||||||
|
}
|
||||||
cmd.SetArgs([]string{"项目群"})
|
cmd.SetArgs([]string{"项目群"})
|
||||||
if err := cmd.Execute(); err != nil || executed != "项目群" {
|
if err := cmd.Execute(); err != nil || executed != "项目群" {
|
||||||
t.Fatalf("positional execute err=%v value=%q", err, executed)
|
t.Fatalf("positional execute err=%v value=%q", err, executed)
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
// Package builtin aggregates all built-in shortcut service packages via blank
|
// Package builtin aggregates all built-in shortcut service packages via blank
|
||||||
// imports so their init() registrations run, then re-exports the compiled cobra
|
// imports so their init() registrations run, applies the reviewed semantic and
|
||||||
// commands. The host application depends only on this package, keeping the
|
// public-catalog decorations in the core registry, then re-exports the compiled
|
||||||
// service packages free to import the core shortcut package without a cycle.
|
// cobra commands. The host application depends only on this package, keeping
|
||||||
|
// the service packages free to import the core shortcut package without a cycle.
|
||||||
//
|
//
|
||||||
// Add a blank import here when a new service package is generated under
|
// Add a blank import here when a new service package is generated under
|
||||||
// internal/shortcut/<service>/.
|
// internal/shortcut/<service>/.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import (
|
|||||||
// TestCmdcoreMountPreservesEveryBuiltInShortcutSurface is the differential
|
// TestCmdcoreMountPreservesEveryBuiltInShortcutSurface is the differential
|
||||||
// guard for the live mount migration. It derives the historical Cobra surface
|
// guard for the live mount migration. It derives the historical Cobra surface
|
||||||
// directly from each Shortcut declaration and checks the command-built tree.
|
// directly from each Shortcut declaration and checks the command-built tree.
|
||||||
func TestCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
|
func TestCrossPlatformCoverageCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
|
||||||
mounted := map[string]*cobra.Command{}
|
mounted := map[string]*cobra.Command{}
|
||||||
for _, service := range builtin.BaseCommands() {
|
for _, service := range builtin.BaseCommands() {
|
||||||
for _, command := range service.Commands() {
|
for _, command := range service.Commands() {
|
||||||
@@ -83,6 +83,18 @@ func TestCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
|
|||||||
spec.Service, spec.Command, flag.Name, got.Hidden, flag.Hidden)
|
spec.Service, spec.Command, flag.Name, got.Hidden, flag.Hidden)
|
||||||
}
|
}
|
||||||
assertShortcutDefault(t, command, spec, flag)
|
assertShortcutDefault(t, command, spec, flag)
|
||||||
|
for _, alias := range flag.Aliases {
|
||||||
|
declaredFlags[alias] = flag
|
||||||
|
gotAlias := command.Flags().Lookup(alias)
|
||||||
|
if gotAlias == nil {
|
||||||
|
t.Errorf("%s %s: flag alias --%s is not mounted", spec.Service, spec.Command, alias)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wantHidden := !flag.AliasesVisible
|
||||||
|
if gotAlias.Hidden != wantHidden {
|
||||||
|
t.Errorf("%s %s: flag alias --%s hidden = %v, want %v", spec.Service, spec.Command, alias, gotAlias.Hidden, wantHidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
command.Flags().VisitAll(func(flag *pflag.Flag) {
|
command.Flags().VisitAll(func(flag *pflag.Flag) {
|
||||||
if flag.Name == "help" {
|
if flag.Name == "help" {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
package builtin_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../semantic_catalog_doc.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var source chatSemanticCatalogFixture
|
||||||
|
if err := json.Unmarshal(raw, &source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if source.Service != "doc" {
|
||||||
|
t.Fatalf("semantic catalog service = %q", source.Service)
|
||||||
|
}
|
||||||
|
registered := map[string]shortcut.Shortcut{}
|
||||||
|
for _, item := range shortcut.All() {
|
||||||
|
if item.Service == "doc" {
|
||||||
|
registered[item.Command] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(registered) != 47 || len(source.Shortcuts) != 47 {
|
||||||
|
t.Fatalf("registered/catalog = %d/%d, want 47/47", len(registered), len(source.Shortcuts))
|
||||||
|
}
|
||||||
|
var missing, stale []string
|
||||||
|
public, hidden := 0, 0
|
||||||
|
for command, item := range registered {
|
||||||
|
record, ok := source.Shortcuts[command]
|
||||||
|
if !ok {
|
||||||
|
missing = append(missing, command)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !record.Reviewed || !item.SemanticReviewed || record.SemanticDelta != item.SemanticDelta || record.Disposition != item.Disposition {
|
||||||
|
t.Errorf("%s: reviewed semantic delivery mismatch", command)
|
||||||
|
}
|
||||||
|
if got := shortcut.InPublicCatalog("doc", command); got != record.Public || item.Hidden == record.Public {
|
||||||
|
t.Errorf("%s: public/hidden mismatch: catalog=%v runtimeHidden=%v", command, record.Public, item.Hidden)
|
||||||
|
}
|
||||||
|
if record.Public {
|
||||||
|
public++
|
||||||
|
if item.Contract.Empty() {
|
||||||
|
t.Errorf("%s: public Doc shortcut has empty Contract", command)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hidden++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for command := range source.Shortcuts {
|
||||||
|
if _, ok := registered[command]; !ok {
|
||||||
|
stale = append(stale, command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(missing)
|
||||||
|
sort.Strings(stale)
|
||||||
|
if len(missing) > 0 || len(stale) > 0 {
|
||||||
|
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||||
|
}
|
||||||
|
if public != 45 || hidden != 2 {
|
||||||
|
t.Fatalf("public/hidden = %d/%d, want 45/2", public, hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPrimaries := map[string]string{
|
||||||
|
"+find-doc": "+search", "+doc-append": "+update", "+version-save": "+history-save",
|
||||||
|
"+version-list": "+history-list", "+version-revert": "+history-revert", "+share-doc": "+share",
|
||||||
|
}
|
||||||
|
for command, primary := range wantPrimaries {
|
||||||
|
item := registered[command]
|
||||||
|
if item.Disposition != shortcut.DispositionAliasInternal || item.PrimaryCommand != primary {
|
||||||
|
t.Errorf("%s compatibility routing = %s/%s, want alias_internal/%s", command, item.Disposition, item.PrimaryCommand, primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
package doc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||||
|
)
|
||||||
|
|
||||||
|
const compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
|
||||||
|
|
||||||
|
func docContract(command, description, intent string, examples []string, params ...contract.ParamDecl) corecmd.ContractDecl {
|
||||||
|
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||||
|
cliPath := "doc " + command
|
||||||
|
return corecmd.ContractDecl{
|
||||||
|
Description: description,
|
||||||
|
Parameters: params,
|
||||||
|
Interface: &contract.InterfaceSpec{
|
||||||
|
Mode: contract.InterfaceModeComposite,
|
||||||
|
Availability: contract.InterfaceAvailable,
|
||||||
|
Reason: compositeInterfaceReason,
|
||||||
|
},
|
||||||
|
Selection: contract.SelectionSpec{
|
||||||
|
AgentSummary: description,
|
||||||
|
UseWhen: []string{intent},
|
||||||
|
AvoidWhen: []string{
|
||||||
|
"需要文件树移动、复制或普通钉盘文件操作时改用 drive;非文字文档按对象类型路由到 sheet、aitable、slides 或 wiki",
|
||||||
|
},
|
||||||
|
Examples: examples,
|
||||||
|
},
|
||||||
|
Identity: contract.ToolIdentitySpec{
|
||||||
|
ProductID: "doc",
|
||||||
|
Name: name,
|
||||||
|
CanonicalPath: "doc." + name,
|
||||||
|
CLIPath: cliPath,
|
||||||
|
PrimaryCLIPath: cliPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withDryRun(decl corecmd.ContractDecl, kind string, remoteReads bool) corecmd.ContractDecl {
|
||||||
|
decl.DryRun = &contract.DryRunSpec{PreviewKind: kind, RemoteReads: remoteReads}
|
||||||
|
return decl
|
||||||
|
}
|
||||||
|
|
||||||
|
func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, error) {
|
||||||
|
raw := rt.Str(flag)
|
||||||
|
if raw == "-" {
|
||||||
|
data, err := io.ReadAll(rt.Command().InOrStdin())
|
||||||
|
if err != nil {
|
||||||
|
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取 stdin 失败: %v", flag, err))
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(raw, "@") {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
|
||||||
|
if path == "" || filepath.IsAbs(path) {
|
||||||
|
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径", flag))
|
||||||
|
}
|
||||||
|
cwd, err := docGetwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
|
||||||
|
}
|
||||||
|
realBase, err := docEvalSymlinks(cwd)
|
||||||
|
if err != nil {
|
||||||
|
return "", apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
|
||||||
|
}
|
||||||
|
realPath, err := docEvalSymlinks(filepath.Join(realBase, filepath.Clean(path)))
|
||||||
|
if err != nil {
|
||||||
|
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
|
||||||
|
}
|
||||||
|
rel, err := docRel(realBase, realPath)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
|
||||||
|
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录", flag))
|
||||||
|
}
|
||||||
|
data, err := docReadFile(realPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateJSONML(raw string) (string, error) {
|
||||||
|
var value any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||||
|
return "", apperrors.NewValidation(fmt.Sprintf("JSONML 解析失败: %v", err))
|
||||||
|
}
|
||||||
|
if _, ok := value.([]any); !ok {
|
||||||
|
return "", apperrors.NewValidation("JSONML 顶层必须是数组")
|
||||||
|
}
|
||||||
|
normalized, _ := json.Marshal(value) // decoded JSON trees are always marshalable
|
||||||
|
return string(normalized), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func docEnvelope(operation string, data any, steps ...map[string]any) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"status": "success",
|
||||||
|
"operation": operation,
|
||||||
|
"steps": steps,
|
||||||
|
"data": data,
|
||||||
|
"warnings": []string{},
|
||||||
|
"compensation": map[string]any{"available": false, "reason": ""},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func docPartialWriteError(operation, reason, stage, message string, cause error, data map[string]any, steps []map[string]any, compensation map[string]any) error {
|
||||||
|
return apperrors.NewAPI(
|
||||||
|
message,
|
||||||
|
apperrors.WithOperation(operation),
|
||||||
|
apperrors.WithReason(reason),
|
||||||
|
apperrors.WithFailureStage(stage),
|
||||||
|
apperrors.WithExecutionStarted(true),
|
||||||
|
apperrors.WithRetryable(false),
|
||||||
|
apperrors.WithActions("inspect the completed steps before retrying", "use the compensation details to clean up or restore the document"),
|
||||||
|
apperrors.WithDetails(map[string]any{
|
||||||
|
"status": "partial_success",
|
||||||
|
"data": data,
|
||||||
|
"steps": steps,
|
||||||
|
"compensation": compensation,
|
||||||
|
}),
|
||||||
|
apperrors.WithCause(cause),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestedString(data map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, wrapper := range []string{"result", "data", "content"} {
|
||||||
|
if inner, ok := data[wrapper].(map[string]any); ok {
|
||||||
|
if value := nestedString(inner, keys...); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestedMap(data map[string]any) map[string]any {
|
||||||
|
for _, wrapper := range []string{"result", "data"} {
|
||||||
|
if inner, ok := data[wrapper].(map[string]any); ok {
|
||||||
|
return nestedMap(inner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringSliceNonEmpty(values []string) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if value = strings.TrimSpace(value); value != "" {
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockIdentity normalizes the currently observed block response shapes. The
|
||||||
|
// element API returns element.id, while JSONML and older payloads use blockId
|
||||||
|
// or uuid. Callers pass an inherited parent identity for nested text maps.
|
||||||
|
func blockIdentity(values map[string]any, inherited string) string {
|
||||||
|
for _, key := range []string{"blockId", "id", "uuid"} {
|
||||||
|
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inherited
|
||||||
|
}
|
||||||
@@ -0,0 +1,795 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
package doc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
docGetwd = os.Getwd
|
||||||
|
docEvalSymlinks = filepath.EvalSymlinks
|
||||||
|
docRel = filepath.Rel
|
||||||
|
docReadFile = os.ReadFile
|
||||||
|
docMkdirTemp = os.MkdirTemp
|
||||||
|
docRemoveAll = os.RemoveAll
|
||||||
|
docDownload = localio.Download
|
||||||
|
)
|
||||||
|
|
||||||
|
var Create = shortcut.Shortcut{
|
||||||
|
Service: "doc",
|
||||||
|
Command: "+create",
|
||||||
|
Product: productDoc,
|
||||||
|
Description: "从 Markdown 或 JSONML 创建在线文字文档",
|
||||||
|
Intent: "当用户要新建钉钉在线文字文档,并可同时写入 Markdown/JSONML 初始内容、指定文件夹或知识库位置时使用;不会用于普通文件上传或其他在线对象类型。",
|
||||||
|
Risk: shortcut.RiskWrite,
|
||||||
|
Safety: contract.SafetySpec{
|
||||||
|
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
|
||||||
|
},
|
||||||
|
Contract: docContract(
|
||||||
|
"+create", "从 Markdown 或 JSONML 创建在线文字文档",
|
||||||
|
"当用户要新建钉钉在线文字文档,并可同时写入 Markdown/JSONML 初始内容、指定文件夹或知识库位置时使用;不会用于普通文件上传或其他在线对象类型。",
|
||||||
|
[]string{`dws doc +create --name "项目周报" --content "# 本周进展"`, `dws doc +create --name "模板" --content @body.json --doc-format jsonml`},
|
||||||
|
contract.ParamDecl{Name: "folder", Property: "folderId"},
|
||||||
|
contract.ParamDecl{Name: "workspace", Property: "workspaceId"},
|
||||||
|
),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "name", Type: shortcut.FlagString, Desc: "新文档名称", Required: true},
|
||||||
|
{Name: "content", Type: shortcut.FlagString, Desc: "内容字面量、@工作目录相对文件或 - 表示 stdin"},
|
||||||
|
{Name: "doc-format", Type: shortcut.FlagString, Default: "markdown", Desc: "内容格式", Enum: []string{"markdown", "jsonml"}},
|
||||||
|
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文档文件夹 ID"},
|
||||||
|
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
|
||||||
|
},
|
||||||
|
Tips: []string{`dws doc +create --name "项目周报" --content "# 本周进展"`, `dws doc +create --name "模板" --content @body.json --doc-format jsonml`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
content, err := readShortcutContent(rt, "content")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
format := rt.Str("doc-format")
|
||||||
|
if format == "jsonml" && content != "" {
|
||||||
|
content, err = validateJSONML(content)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params := map[string]any{"name": rt.Str("name")}
|
||||||
|
if rt.Str("folder") != "" {
|
||||||
|
params["folderId"] = rt.Str("folder")
|
||||||
|
}
|
||||||
|
if rt.Str("workspace") != "" {
|
||||||
|
params["workspaceId"] = rt.Str("workspace")
|
||||||
|
}
|
||||||
|
if format == "markdown" && content != "" {
|
||||||
|
params["markdown"] = content
|
||||||
|
}
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docEnvelope("doc.create", map[string]any{"executed": false, "previewKind": "plan", "create": params, "docFormat": format, "contentBytes": len(content)}))
|
||||||
|
}
|
||||||
|
created, err := rt.CallMCPWriteData(productDoc, "create_document", params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nodeID := nestedString(created, "nodeId", "documentId", "id")
|
||||||
|
steps := []map[string]any{{"name": "create_document", "status": "success"}}
|
||||||
|
if format == "jsonml" && content != "" {
|
||||||
|
if nodeID == "" {
|
||||||
|
return docPartialWriteError(
|
||||||
|
"doc.create", "doc_create_missing_node_id", "resolve_created_document",
|
||||||
|
"创建文档成功但响应缺少 nodeId;JSONML 尚未写入,请先在钉钉中定位新文档,不要直接重试",
|
||||||
|
nil,
|
||||||
|
map[string]any{"nodeId": "", "docFormat": format},
|
||||||
|
append(steps, map[string]any{"name": "write_jsonml", "status": "not_started"}),
|
||||||
|
map[string]any{"available": false, "reason": "create_document did not return nodeId; locate the new document in DingTalk"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if _, err := rt.CallMCPWriteData(productDoc, "update_document", map[string]any{"nodeId": nodeID, "format": "jsonml", "jsonml": content, "mode": "overwrite"}); err != nil {
|
||||||
|
return docPartialWriteError(
|
||||||
|
"doc.create", "doc_create_initial_content_failed", "write_jsonml",
|
||||||
|
fmt.Sprintf("文档已创建但 JSONML 写入失败(nodeId=%s);不要直接重试创建", nodeID),
|
||||||
|
err,
|
||||||
|
map[string]any{"nodeId": nodeID, "docFormat": format},
|
||||||
|
append(steps, map[string]any{"name": "write_jsonml", "status": "failed"}),
|
||||||
|
map[string]any{"available": true, "action": "delete_created_document", "nodeId": nodeID, "reason": "remove the empty document before retrying create"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
steps = append(steps, map[string]any{"name": "write_jsonml", "status": "success"})
|
||||||
|
}
|
||||||
|
return rt.Output(docEnvelope("doc.create", map[string]any{"nodeId": nodeID, "result": created}, steps...))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var Fetch = shortcut.Shortcut{
|
||||||
|
Service: "doc",
|
||||||
|
Command: "+fetch",
|
||||||
|
Product: productDoc,
|
||||||
|
Description: "读取完整或局部文档内容,并按 detail 控制保真度",
|
||||||
|
Intent: "当用户要读取在线文字文档正文,或需要 block ID、JSONML、outline/range/section/keyword/tags 局部内容用于精确编辑和评论时使用;非最新历史 revision 会明确拒绝。",
|
||||||
|
Risk: shortcut.RiskRead,
|
||||||
|
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||||
|
Contract: docContract(
|
||||||
|
"+fetch", "读取完整或局部文档内容,并按 detail 控制保真度",
|
||||||
|
"当用户要读取在线文字文档正文,或需要 block ID、JSONML、outline/range/section/keyword/tags 局部内容用于精确编辑和评论时使用;非最新历史 revision 会明确拒绝。",
|
||||||
|
[]string{`dws doc +fetch --node <DOC_ID>`, `dws doc +fetch --node <DOC_ID> --detail with-ids --scope keyword --keyword "结论"`},
|
||||||
|
contract.ParamDecl{Name: "node", Property: "nodeId"},
|
||||||
|
),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "detail", Type: shortcut.FlagString, Default: "simple", Desc: "输出细节", Enum: []string{"simple", "with-ids", "full"}},
|
||||||
|
{Name: "scope", Type: shortcut.FlagString, Default: "full", Desc: "读取范围;keyword 时 --keyword 不能为空", Enum: []string{"full", "outline", "range", "section", "keyword", "tags"}},
|
||||||
|
{Name: "start-block-id", Type: shortcut.FlagString, Desc: "range/section 起始块 ID"},
|
||||||
|
{Name: "end-block-id", Type: shortcut.FlagString, Desc: "range 结束块 ID"},
|
||||||
|
{Name: "keyword", Type: shortcut.FlagString, Desc: "keyword 范围搜索词,不能为空,支持 foo|bar"},
|
||||||
|
{Name: "tags", Type: shortcut.FlagStringSlice, Desc: "tags 范围的 JSONML tag"},
|
||||||
|
{Name: "context-before", Type: shortcut.FlagInt, Desc: "关键词命中前的上下文字符数"},
|
||||||
|
{Name: "context-after", Type: shortcut.FlagInt, Desc: "关键词命中后的上下文字符数"},
|
||||||
|
{Name: "max-depth", Type: shortcut.FlagInt, Desc: "outline/section 最大深度"},
|
||||||
|
{Name: "revision", Type: shortcut.FlagInt, Desc: "只接受当前最新版;历史 revision 暂不支持"},
|
||||||
|
},
|
||||||
|
Tips: []string{`dws doc +fetch --node <DOC_ID>`, `dws doc +fetch --node <DOC_ID> --detail with-ids --scope keyword --keyword "结论"`},
|
||||||
|
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
if rt.Changed("revision") {
|
||||||
|
return apperrors.NewValidation("HISTORICAL_READ_UNSUPPORTED: 当前接口不能读取指定历史 revision")
|
||||||
|
}
|
||||||
|
if rt.Str("scope") == "keyword" && rt.Str("keyword") == "" {
|
||||||
|
return apperrors.NewValidation("--scope keyword 时必须提供 --keyword")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"scope", "keyword"}, Description: "--scope keyword 时 --keyword 不能为空"}},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
format := "markdown"
|
||||||
|
if rt.Str("detail") != "simple" || rt.Str("scope") != "full" {
|
||||||
|
format = "jsonml"
|
||||||
|
}
|
||||||
|
params := map[string]any{"nodeId": rt.Str("node"), "format": format}
|
||||||
|
scope := rt.Str("scope")
|
||||||
|
if scope != "keyword" && scope != "full" {
|
||||||
|
params["scope"] = scope
|
||||||
|
}
|
||||||
|
if value := rt.Str("start-block-id"); value != "" {
|
||||||
|
params["startBlockId"] = value
|
||||||
|
}
|
||||||
|
if value := rt.Str("end-block-id"); value != "" {
|
||||||
|
params["endBlockId"] = value
|
||||||
|
}
|
||||||
|
if rt.Changed("tags") {
|
||||||
|
params["tags"] = rt.StrSlice("tags")
|
||||||
|
}
|
||||||
|
if rt.Changed("max-depth") {
|
||||||
|
params["maxDepth"] = rt.Int("max-depth")
|
||||||
|
}
|
||||||
|
data, err := rt.CallMCPData(productDoc, "get_document_content", params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if scope == "keyword" {
|
||||||
|
return rt.Output(projectKeywordMatches(data, rt.Str("keyword"), rt.Int("context-before"), rt.Int("context-after")))
|
||||||
|
}
|
||||||
|
return rt.Output(data)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var Inspect = shortcut.Shortcut{
|
||||||
|
Service: "doc",
|
||||||
|
Command: "+inspect",
|
||||||
|
Product: productDoc,
|
||||||
|
Description: "聚合文档元信息,并按需附带样式、权限、历史、媒体和评论",
|
||||||
|
Intent: "当用户需要在一次调用中了解文档类型、标题、链接和可选的协作/样式/历史/媒体/评论状态,而不是读取正文时使用。",
|
||||||
|
Risk: shortcut.RiskRead,
|
||||||
|
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||||
|
Contract: docContract("+inspect", "聚合文档元信息,并按需附带样式、权限、历史、媒体和评论",
|
||||||
|
"当用户需要在一次调用中了解文档类型、标题、链接和可选的协作/样式/历史/媒体/评论状态,而不是读取正文时使用。",
|
||||||
|
[]string{`dws doc +inspect --node <DOC_ID>`, `dws doc +inspect --node <DOC_ID> --include-style --include-permissions --include-comments`}),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "include-style", Type: shortcut.FlagBool, Desc: "附带封面和背景"},
|
||||||
|
{Name: "include-permissions", Type: shortcut.FlagBool, Desc: "附带权限列表"},
|
||||||
|
{Name: "include-history", Type: shortcut.FlagBool, Desc: "附带最近历史版本"},
|
||||||
|
{Name: "include-media", Type: shortcut.FlagBool, Desc: "附带正文媒体列表"},
|
||||||
|
{Name: "include-comments", Type: shortcut.FlagBool, Desc: "附带评论列表"},
|
||||||
|
},
|
||||||
|
Tips: []string{`dws doc +inspect --node <DOC_ID>`, `dws doc +inspect --node <DOC_ID> --include-style --include-permissions --include-comments`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
node := rt.Str("node")
|
||||||
|
result := map[string]any{}
|
||||||
|
info, err := rt.CallMCPData(productDoc, "get_document_info", map[string]any{"nodeId": node})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result["document"] = info
|
||||||
|
reads := []struct {
|
||||||
|
flag, key, product, tool string
|
||||||
|
params map[string]any
|
||||||
|
}{
|
||||||
|
{"include-style", "style", productDoc, "get_document_style", map[string]any{"nodeId": node}},
|
||||||
|
{"include-permissions", "permissions", productDoc, "list_permission", map[string]any{"nodeId": node}},
|
||||||
|
{"include-history", "history", productDoc, "list_doc_versions", map[string]any{"nodeId": node}},
|
||||||
|
{"include-media", "media", productDoc, "list_document_blocks", map[string]any{"nodeId": node, "format": "jsonml"}},
|
||||||
|
{"include-comments", "comments", productComment, "list_comments", map[string]any{"nodeId": node}},
|
||||||
|
}
|
||||||
|
for _, read := range reads {
|
||||||
|
if !rt.Bool(read.flag) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, callErr := rt.CallMCPData(read.product, read.tool, read.params)
|
||||||
|
if callErr != nil {
|
||||||
|
return callErr
|
||||||
|
}
|
||||||
|
result[read.key] = value
|
||||||
|
}
|
||||||
|
return rt.Output(docEnvelope("doc.inspect", result, map[string]any{"name": "inspect", "status": "success"}))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var Update = shortcut.Shortcut{
|
||||||
|
Service: "doc",
|
||||||
|
Command: "+update",
|
||||||
|
Product: productDoc,
|
||||||
|
Description: "追加、覆盖或按 block 精确更新文档内容",
|
||||||
|
Intent: "当用户要修改已有在线文字文档时使用;支持整篇 append/overwrite、block 插入/替换/删除,以及受限的唯一纯文本 str_replace,所有模式统一经过静态确认门禁。",
|
||||||
|
Risk: shortcut.RiskWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||||
|
Contract: docContract("+update", "追加、覆盖或按 block 精确更新文档内容",
|
||||||
|
"当用户要修改已有在线文字文档时使用;支持整篇 append/overwrite、block 插入/替换/删除,以及受限的唯一纯文本 str_replace,所有模式统一经过静态确认门禁。",
|
||||||
|
[]string{`dws doc +update --node <DOC_ID> --command append --content "补充说明"`, `dws doc +update --node <DOC_ID> --command block_replace --block-id <BLOCK_ID> --content "新内容"`},
|
||||||
|
contract.ParamDecl{Name: "doc", Property: "node"},
|
||||||
|
contract.ParamDecl{Name: "text", Property: "content"}),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true, Aliases: []string{"doc"}, AliasesVisible: true},
|
||||||
|
{Name: "command", Type: shortcut.FlagString, Desc: "更新动作;不能为空", Enum: []string{"append", "overwrite", "block_insert_after", "block_replace", "block_delete", "str_replace", "block_copy_insert_after"}},
|
||||||
|
{Name: "content", Type: shortcut.FlagString, Desc: "内容字面量、@相对文件或 - 表示 stdin;相关动作要求时不能为空", Aliases: []string{"text"}, AliasesVisible: true},
|
||||||
|
{Name: "doc-format", Type: shortcut.FlagString, Default: "markdown", Desc: "内容格式", Enum: []string{"markdown", "jsonml"}},
|
||||||
|
{Name: "block-id", Type: shortcut.FlagString, Desc: "目标或源 block ID;相关动作要求时不能为空"},
|
||||||
|
{Name: "after-block-id", Type: shortcut.FlagString, Desc: "插入位置参考 block ID"},
|
||||||
|
{Name: "old", Type: shortcut.FlagString, Desc: "str_replace 原文字,不能为空"},
|
||||||
|
{Name: "new", Type: shortcut.FlagString, Desc: "str_replace 新文字;--old 不能为空,新值可为空但参数必须显式提供"},
|
||||||
|
{Name: "expected-revision", Type: shortcut.FlagInt, Desc: "best-effort 乐观 revision 检查"},
|
||||||
|
},
|
||||||
|
Tips: []string{`dws doc +update --node <DOC_ID> --command append --content "补充说明"`, `dws doc +update --node <DOC_ID> --command block_replace --block-id <BLOCK_ID> --content "新内容"`},
|
||||||
|
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
command := rt.Str("command")
|
||||||
|
if command == "" {
|
||||||
|
return apperrors.NewValidation("缺少 --command")
|
||||||
|
}
|
||||||
|
if rt.StrFirst("node", "doc") == "" {
|
||||||
|
return apperrors.NewValidation("缺少 --node")
|
||||||
|
}
|
||||||
|
if command == "append" || command == "overwrite" || command == "block_insert_after" || command == "block_replace" {
|
||||||
|
if rt.StrFirst("content", "text") == "" {
|
||||||
|
return apperrors.NewValidation("该更新动作的 --content 不能为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(command, "block_") && command != "block_insert_after" && rt.Str("block-id") == "" {
|
||||||
|
return apperrors.NewValidation("该 block 操作必须提供 --block-id")
|
||||||
|
}
|
||||||
|
if command == "str_replace" && (rt.Str("old") == "" || !rt.Changed("new")) {
|
||||||
|
return apperrors.NewValidation("--command str_replace 必须同时提供 --old 和 --new")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"command", "content", "block-id", "old", "new"}, Description: "依 command 校验,所需文本参数不能为空"}},
|
||||||
|
Execute: executeUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
var CheckpointUpdate = shortcut.Shortcut{
|
||||||
|
Service: "doc",
|
||||||
|
Command: "+checkpoint-update",
|
||||||
|
Product: productDoc,
|
||||||
|
Description: "先保存可回滚版本,再更新并读回验证",
|
||||||
|
Intent: "当用户要进行重要追加或整篇覆盖,并希望自动创建恢复点、执行更新、再读回确认时使用;任一步失败都会返回已经完成的步骤。",
|
||||||
|
Risk: shortcut.RiskWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||||
|
Contract: docContract("+checkpoint-update", "先保存可回滚版本,再更新并读回验证",
|
||||||
|
"当用户要进行重要追加或整篇覆盖,并希望自动创建恢复点、执行更新、再读回确认时使用;任一步失败都会返回已经完成的步骤。",
|
||||||
|
[]string{`dws doc +checkpoint-update --node <DOC_ID> --mode append --content @section.md`, `dws doc +checkpoint-update --node <DOC_ID> --mode overwrite --content @document.md`}),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "mode", Type: shortcut.FlagString, Default: "append", Desc: "更新模式", Enum: []string{"append", "overwrite"}},
|
||||||
|
{Name: "content", Type: shortcut.FlagString, Desc: "内容字面量、@相对文件或 - 表示 stdin", Required: true},
|
||||||
|
},
|
||||||
|
Tips: []string{`dws doc +checkpoint-update --node <DOC_ID> --mode append --content @section.md`, `dws doc +checkpoint-update --node <DOC_ID> --mode overwrite --content @document.md`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
content, err := readShortcutContent(rt, "content")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
plan := map[string]any{"nodeId": rt.Str("node"), "mode": rt.Str("mode"), "contentBytes": len(content), "steps": []string{"save_doc_version", "update_document", "get_document_content"}}
|
||||||
|
if rt.DryRun() {
|
||||||
|
plan["executed"] = false
|
||||||
|
return rt.Output(docEnvelope("doc.checkpoint_update", plan))
|
||||||
|
}
|
||||||
|
steps := []map[string]any{}
|
||||||
|
checkpoint, err := rt.CallMCPWriteData(productDoc, "save_doc_version", map[string]any{"nodeId": rt.Str("node")})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
steps = append(steps, map[string]any{"name": "checkpoint", "status": "success"})
|
||||||
|
if _, err := rt.CallMCPWriteData(productDoc, "update_document", map[string]any{"nodeId": rt.Str("node"), "markdown": content, "mode": rt.Str("mode")}); err != nil {
|
||||||
|
return checkpointPartialWriteError(rt.Str("node"), checkpoint, "update", "doc_checkpoint_update_failed", err,
|
||||||
|
append(steps, map[string]any{"name": "update", "status": "failed"}, map[string]any{"name": "verify", "status": "not_started"}))
|
||||||
|
}
|
||||||
|
steps = append(steps, map[string]any{"name": "update", "status": "success"})
|
||||||
|
verified, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"})
|
||||||
|
if err != nil {
|
||||||
|
return checkpointPartialWriteError(rt.Str("node"), checkpoint, "verify", "doc_checkpoint_verification_failed", err,
|
||||||
|
append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||||
|
}
|
||||||
|
steps = append(steps, map[string]any{"name": "verify", "status": "success"})
|
||||||
|
return rt.Output(docEnvelope("doc.checkpoint_update", map[string]any{"verified": verified}, steps...))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var Export = shortcut.Shortcut{
|
||||||
|
Service: "doc",
|
||||||
|
Command: "+export",
|
||||||
|
Product: productDoc,
|
||||||
|
Description: "提交、轮询并安全下载在线文档导出文件",
|
||||||
|
Intent: "当用户要把在线文档导出成 docx、markdown 或 PDF 并保存到工作目录时使用;自动完成 job 提交、轮询与 no-clobber 原子下载。",
|
||||||
|
Risk: shortcut.RiskRead,
|
||||||
|
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||||
|
Contract: docContract("+export", "提交、轮询并安全下载在线文档导出文件",
|
||||||
|
"当用户要把在线文档导出成 docx、markdown 或 PDF 并保存到工作目录时使用;自动完成 job 提交、轮询与 no-clobber 原子下载。",
|
||||||
|
[]string{`dws doc +export --node <DOC_ID> --export-format docx --output ./exports/`, `dws doc +export --node <DOC_ID> --export-format markdown --output ./document.md`}),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式", Enum: []string{"docx", "markdown", "pdf"}},
|
||||||
|
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内相对路径(文件或目录)"},
|
||||||
|
{Name: "max-polls", Type: shortcut.FlagInt, Default: "30", Desc: "最大轮询次数"},
|
||||||
|
},
|
||||||
|
Tips: []string{`dws doc +export --node <DOC_ID> --export-format docx --output ./exports/`, `dws doc +export --node <DOC_ID> --export-format markdown --output ./document.md`},
|
||||||
|
Validate: func(rt *shortcut.RuntimeContext) error { return localio.ValidateOutput(rt.Str("output")) },
|
||||||
|
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "--output 必须是工作目录内相对路径;默认 no-clobber"}},
|
||||||
|
Execute: executeExport,
|
||||||
|
}
|
||||||
|
|
||||||
|
var Import = shortcut.Shortcut{
|
||||||
|
Service: "doc",
|
||||||
|
Command: "+import",
|
||||||
|
Product: productDoc,
|
||||||
|
Description: "上传本地文件并等待转换成在线文档对象",
|
||||||
|
Intent: "当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。",
|
||||||
|
Risk: shortcut.RiskWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
|
||||||
|
Contract: docContract("+import", "上传本地文件并等待转换成在线文档对象",
|
||||||
|
"当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。",
|
||||||
|
[]string{`dws doc +import --file ./report.docx --folder <FOLDER_ID>`, `dws doc +import --file ./notes.md --workspace <WORKSPACE_ID> --name "会议纪要"`}),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
|
||||||
|
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
|
||||||
|
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
|
||||||
|
{Name: "name", Type: shortcut.FlagString, Desc: "导入后名称"},
|
||||||
|
},
|
||||||
|
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"folder", "workspace"}, Description: "--folder 与 --workspace 至少提供一个导入目标"}},
|
||||||
|
Tips: []string{`dws doc +import --file ./report.docx --folder <FOLDER_ID>`, `dws doc +import --file ./notes.md --workspace <WORKSPACE_ID> --name "会议纪要"`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocImportShortcut(rt.Command()) },
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeUpdate(rt *shortcut.RuntimeContext) error {
|
||||||
|
command := rt.Str("command")
|
||||||
|
contentFlag := "content"
|
||||||
|
if rt.Str("content") == "" && rt.Str("text") != "" {
|
||||||
|
contentFlag = "text"
|
||||||
|
}
|
||||||
|
content, err := readShortcutContent(rt, contentFlag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rt.Str("doc-format") == "jsonml" && content != "" {
|
||||||
|
content, err = validateJSONML(content)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodeID := rt.StrFirst("node", "doc")
|
||||||
|
currentRevision := 0
|
||||||
|
if rt.Changed("expected-revision") {
|
||||||
|
current, revisionErr := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": nodeID, "format": "jsonml"})
|
||||||
|
if revisionErr != nil {
|
||||||
|
return revisionErr
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
currentRevision, found = nestedRevision(current)
|
||||||
|
if !found {
|
||||||
|
return apperrors.NewAPI("REVISION_CONFLICT: 服务响应缺少当前 revision,无法安全执行乐观更新")
|
||||||
|
}
|
||||||
|
if expected := rt.Int("expected-revision"); currentRevision != expected {
|
||||||
|
return apperrors.NewValidation(fmt.Sprintf("REVISION_CONFLICT: 期望 revision %d,当前为 %d", expected, currentRevision))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plan := map[string]any{"nodeId": nodeID, "command": command, "blockId": rt.Str("block-id"), "afterBlockId": rt.Str("after-block-id"), "contentBytes": len(content)}
|
||||||
|
if rt.Changed("expected-revision") {
|
||||||
|
plan["expectedRevision"] = rt.Int("expected-revision")
|
||||||
|
plan["currentRevision"] = currentRevision
|
||||||
|
plan["optimisticCheck"] = "best_effort"
|
||||||
|
}
|
||||||
|
if rt.DryRun() {
|
||||||
|
plan["executed"] = false
|
||||||
|
return rt.Output(docEnvelope("doc.update", plan))
|
||||||
|
}
|
||||||
|
node := nodeID
|
||||||
|
switch command {
|
||||||
|
case "append", "overwrite":
|
||||||
|
params := map[string]any{"nodeId": node, "mode": command}
|
||||||
|
if rt.Str("doc-format") == "jsonml" {
|
||||||
|
if command == "append" {
|
||||||
|
return apperrors.NewValidation("JSONML 当前不支持 append")
|
||||||
|
}
|
||||||
|
params["format"], params["jsonml"] = "jsonml", content
|
||||||
|
} else {
|
||||||
|
params["markdown"] = content
|
||||||
|
}
|
||||||
|
return rt.CallMCP("update_document", params)
|
||||||
|
case "block_insert_after":
|
||||||
|
params := map[string]any{"nodeId": node, "referenceBlockId": rt.Str("after-block-id"), "where": "after"}
|
||||||
|
if rt.Str("doc-format") == "jsonml" {
|
||||||
|
params["format"], params["jsonml"] = "jsonml", content
|
||||||
|
} else {
|
||||||
|
params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}}
|
||||||
|
}
|
||||||
|
return rt.CallMCP("insert_document_block", params)
|
||||||
|
case "block_replace":
|
||||||
|
params := map[string]any{"nodeId": node, "blockId": rt.Str("block-id")}
|
||||||
|
if rt.Str("doc-format") == "jsonml" {
|
||||||
|
params["format"], params["jsonml"] = "jsonml", content
|
||||||
|
} else {
|
||||||
|
params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}}
|
||||||
|
}
|
||||||
|
return rt.CallMCP("update_document_block", params)
|
||||||
|
case "block_delete":
|
||||||
|
return rt.CallMCP("delete_document_block", map[string]any{"nodeId": node, "blockId": rt.Str("block-id")})
|
||||||
|
case "str_replace":
|
||||||
|
return executePlainTextReplace(rt, node)
|
||||||
|
case "block_copy_insert_after":
|
||||||
|
return executeBlockCopy(rt, node)
|
||||||
|
default:
|
||||||
|
return apperrors.NewValidation(fmt.Sprintf("不支持的 update command %q", command))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestedRevision(value any) (int, bool) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, child := range typed {
|
||||||
|
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
|
||||||
|
if normalized == "revision" || normalized == "version" || normalized == "versionnumber" {
|
||||||
|
switch number := child.(type) {
|
||||||
|
case float64:
|
||||||
|
if number >= 0 && number == float64(int(number)) {
|
||||||
|
return int(number), true
|
||||||
|
}
|
||||||
|
case json.Number:
|
||||||
|
parsed, err := number.Int64()
|
||||||
|
if err == nil && parsed >= 0 {
|
||||||
|
return int(parsed), true
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
var parsed int
|
||||||
|
if _, err := fmt.Sscan(strings.TrimSpace(number), &parsed); err == nil && parsed >= 0 {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if revision, ok := nestedRevision(child); ok {
|
||||||
|
return revision, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
if revision, ok := nestedRevision(child); ok {
|
||||||
|
return revision, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error {
|
||||||
|
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
oldText := rt.Str("old")
|
||||||
|
type match struct{ blockID, text string }
|
||||||
|
matches := []match{}
|
||||||
|
var walk func(any, string)
|
||||||
|
walk = func(value any, inheritedID string) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
blockID := blockIdentity(typed, inheritedID)
|
||||||
|
for key, child := range typed {
|
||||||
|
if key == "text" {
|
||||||
|
if text, ok := child.(string); ok && strings.Contains(text, oldText) && blockID != "" {
|
||||||
|
matches = append(matches, match{blockID: blockID, text: text})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(child, blockID)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
walk(child, inheritedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(data, "")
|
||||||
|
if len(matches) != 1 {
|
||||||
|
return apperrors.NewValidation(fmt.Sprintf("UNSAFE_RICH_TEXT_REPLACE: 需要唯一普通文本块匹配,实际 %d 处", len(matches)))
|
||||||
|
}
|
||||||
|
updated := strings.Replace(matches[0].text, oldText, rt.Str("new"), 1)
|
||||||
|
return rt.CallMCP("update_document_block", map[string]any{"nodeId": nodeID, "blockId": matches[0].blockID, "element": map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": updated}}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error {
|
||||||
|
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": rt.Str("block-id"), "format": "element"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
block := findBlock(data, rt.Str("block-id"))
|
||||||
|
if block == nil {
|
||||||
|
return apperrors.NewValidation("DOCUMENT_NOT_FOUND: 未找到要复制的 block")
|
||||||
|
}
|
||||||
|
if containsResourceReference(block) {
|
||||||
|
return apperrors.NewValidation("UNSUPPORTED_RESOURCE_TYPE: 含资源引用的 block 暂不支持复制")
|
||||||
|
}
|
||||||
|
stripBlockIDs(block)
|
||||||
|
return rt.CallMCP("insert_document_block", map[string]any{"nodeId": nodeID, "referenceBlockId": rt.Str("after-block-id"), "where": "after", "element": block})
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeExport(rt *shortcut.RuntimeContext) error {
|
||||||
|
plan := map[string]any{"nodeId": rt.Str("node"), "exportFormat": rt.Str("export-format"), "output": rt.Str("output")}
|
||||||
|
if rt.DryRun() {
|
||||||
|
plan["executed"] = false
|
||||||
|
plan["steps"] = []string{"submit_export_job", "query_export_job", "safe_atomic_download"}
|
||||||
|
return rt.Output(docEnvelope("doc.export", plan))
|
||||||
|
}
|
||||||
|
submit, err := rt.CallMCPData(productDoc, "submit_export_job", map[string]any{"nodeId": rt.Str("node"), "exportFormat": rt.Str("export-format")})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
jobID := nestedString(submit, "jobId", "jobID")
|
||||||
|
if jobID == "" {
|
||||||
|
return apperrors.NewAPI("导出任务响应缺少 jobId")
|
||||||
|
}
|
||||||
|
maxPolls := rt.Int("max-polls")
|
||||||
|
if maxPolls <= 0 {
|
||||||
|
maxPolls = 30
|
||||||
|
}
|
||||||
|
var query map[string]any
|
||||||
|
for attempt := 1; attempt <= maxPolls; attempt++ {
|
||||||
|
query, err = rt.CallMCPData(productDoc, "query_export_job", map[string]any{"jobId": jobID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status := strings.ToUpper(nestedString(query, "status"))
|
||||||
|
if status == "SUCCESS" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if status != "PROCESSING" {
|
||||||
|
return apperrors.NewAPI(fmt.Sprintf("导出任务失败 (jobId=%s, status=%s): %s", jobID, status, nestedString(query, "message")))
|
||||||
|
}
|
||||||
|
if attempt == maxPolls {
|
||||||
|
return apperrors.NewAPI(fmt.Sprintf("导出任务超时 (jobId=%s),可用 doc +export-get 恢复查询", jobID))
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(time.Duration(min(attempt, 5)) * time.Second)
|
||||||
|
select {
|
||||||
|
case <-rt.Command().Context().Done():
|
||||||
|
timer.Stop()
|
||||||
|
return rt.Command().Context().Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloadURL := nestedString(query, "downloadUrl", "resourceUrl")
|
||||||
|
if downloadURL == "" {
|
||||||
|
return apperrors.NewAPI(fmt.Sprintf("导出成功但响应缺少 downloadUrl (jobId=%s)", jobID))
|
||||||
|
}
|
||||||
|
cwd, err := docGetwd()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ext := map[string]string{"docx": ".docx", "markdown": ".md", "pdf": ".pdf"}[rt.Str("export-format")]
|
||||||
|
preferred := "document" + ext
|
||||||
|
result, err := docDownload(rt.Command().Context(), downloadURL, localio.DownloadOptions{BaseDir: cwd, Output: rt.Str("output"), PreferredName: preferred})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rt.Output(docEnvelope("doc.export", map[string]any{"jobId": jobID, "localPath": result.RelativePath, "sizeBytes": result.SizeBytes},
|
||||||
|
map[string]any{"name": "submit", "status": "success"}, map[string]any{"name": "poll", "status": "success"}, map[string]any{"name": "download", "status": "success"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectKeywordMatches(data map[string]any, rawQuery string, before, after int) map[string]any {
|
||||||
|
queries := stringSliceNonEmpty(strings.Split(rawQuery, "|"))
|
||||||
|
if before <= 0 {
|
||||||
|
before = 80
|
||||||
|
}
|
||||||
|
if after <= 0 {
|
||||||
|
after = 120
|
||||||
|
}
|
||||||
|
matches := []map[string]any{}
|
||||||
|
appendTextMatch := func(text, blockID string) {
|
||||||
|
textRunes := []rune(text)
|
||||||
|
foldedText := foldRunes(textRunes)
|
||||||
|
for _, query := range queries {
|
||||||
|
foldedQuery := foldRunes([]rune(query))
|
||||||
|
index := indexRunes(foldedText, foldedQuery)
|
||||||
|
if index < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start, end := max(0, index-before), min(len(textRunes), index+len(foldedQuery)+after)
|
||||||
|
matches = append(matches, map[string]any{
|
||||||
|
"blockId": blockID, "topBlockId": blockID, "parentBlockPath": []string{},
|
||||||
|
"content": string(textRunes[start:end]), "truncated": start > 0 || end < len(textRunes),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var walk func(any, string)
|
||||||
|
walk = func(value any, inheritedID string) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
blockID := blockIdentity(typed, inheritedID)
|
||||||
|
for key, child := range typed {
|
||||||
|
if key == "jsonml" {
|
||||||
|
if raw, ok := child.(string); ok {
|
||||||
|
var decoded any
|
||||||
|
if json.Unmarshal([]byte(raw), &decoded) == nil {
|
||||||
|
walk(decoded, blockID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key == "text" {
|
||||||
|
if text, ok := child.(string); ok {
|
||||||
|
appendTextMatch(text, blockID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(child, blockID)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
blockID := inheritedID
|
||||||
|
start := 0
|
||||||
|
if len(typed) >= 2 {
|
||||||
|
if _, isTag := typed[0].(string); isTag {
|
||||||
|
start = 2
|
||||||
|
if attrs, ok := typed[1].(map[string]any); ok {
|
||||||
|
blockID = blockIdentity(attrs, blockID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range typed[start:] {
|
||||||
|
walk(child, blockID)
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
appendTextMatch(typed, inheritedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(data, "")
|
||||||
|
return map[string]any{"count": len(matches), "matches": matches}
|
||||||
|
}
|
||||||
|
|
||||||
|
func foldRunes(value []rune) []rune {
|
||||||
|
folded := make([]rune, len(value))
|
||||||
|
for index, char := range value {
|
||||||
|
folded[index] = unicode.ToLower(char)
|
||||||
|
}
|
||||||
|
return folded
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexRunes(value, target []rune) int {
|
||||||
|
if len(target) == 0 || len(target) > len(value) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
for start := 0; start+len(target) <= len(value); start++ {
|
||||||
|
matched := true
|
||||||
|
for offset := range target {
|
||||||
|
if value[start+offset] != target[offset] {
|
||||||
|
matched = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
return start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointPartialWriteError(nodeID string, checkpoint map[string]any, stage, reason string, cause error, steps []map[string]any) error {
|
||||||
|
data := map[string]any{"nodeId": nodeID, "checkpointSaved": true}
|
||||||
|
compensation := map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"action": "revert_to_checkpoint",
|
||||||
|
"nodeId": nodeID,
|
||||||
|
"reason": "a checkpoint was saved before the update started",
|
||||||
|
}
|
||||||
|
if version, ok := nestedRevision(checkpoint); ok {
|
||||||
|
data["checkpointVersion"] = version
|
||||||
|
compensation["version"] = version
|
||||||
|
}
|
||||||
|
return docPartialWriteError(
|
||||||
|
"doc.checkpoint_update", reason, stage,
|
||||||
|
fmt.Sprintf("checkpoint-update 在 %s 阶段失败;恢复点已保存,nodeId=%s,请勿直接重试整个复合命令", stage, nodeID),
|
||||||
|
cause, data, steps, compensation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findBlock(value any, target string) map[string]any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if blockIdentity(typed, "") == target {
|
||||||
|
copy := map[string]any{}
|
||||||
|
for key, value := range typed {
|
||||||
|
copy[key] = value
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
for _, child := range typed {
|
||||||
|
if found := findBlock(child, target); found != nil {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
if found := findBlock(child, target); found != nil {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsResourceReference(value any) bool {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, child := range typed {
|
||||||
|
if (key == "resourceId" || key == "resourceUrl" || key == "src") && fmt.Sprint(child) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if containsResourceReference(child) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
if containsResourceReference(child) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripBlockIDs(value any) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for _, key := range []string{"blockId", "id", "uuid"} {
|
||||||
|
delete(typed, key)
|
||||||
|
}
|
||||||
|
for _, child := range typed {
|
||||||
|
stripBlockIDs(child)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
stripBlockIDs(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_ = json.Valid
|
||||||
|
_ = filepath.Separator
|
||||||
|
shortcut.Register(Create, Fetch, Inspect, Update, CheckpointUpdate, Export, Import)
|
||||||
|
}
|
||||||
@@ -982,6 +982,11 @@ var TemplateApply = shortcut.Shortcut{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
// Expert/recovery leaves remain callable without entering Agent discovery.
|
||||||
|
CommentCreateInline.Contract = corecmd.ContractDecl{}
|
||||||
|
TemplateApply.Contract = corecmd.ContractDecl{}
|
||||||
|
canonicalizeHistoryShortcuts()
|
||||||
|
canonicalizeCommentShortcuts()
|
||||||
shortcut.Register(
|
shortcut.Register(
|
||||||
Search,
|
Search,
|
||||||
List,
|
List,
|
||||||
@@ -993,6 +998,9 @@ func init() {
|
|||||||
CommentCreateInline,
|
CommentCreateInline,
|
||||||
ExportSubmit,
|
ExportSubmit,
|
||||||
ExportGet,
|
ExportGet,
|
||||||
|
legacyVersionSave,
|
||||||
|
legacyVersionList,
|
||||||
|
legacyVersionRevert,
|
||||||
VersionSave,
|
VersionSave,
|
||||||
VersionList,
|
VersionList,
|
||||||
VersionRevert,
|
VersionRevert,
|
||||||
|
|||||||
@@ -0,0 +1,763 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
package doc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type docCoverageCaller struct {
|
||||||
|
failAt int
|
||||||
|
calls int
|
||||||
|
responses map[string][]map[string]any
|
||||||
|
ctx context.Context
|
||||||
|
history []docCoverageCall
|
||||||
|
}
|
||||||
|
|
||||||
|
type docCoverageCall struct {
|
||||||
|
tool string
|
||||||
|
params map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type docCoverageErrorReader struct{}
|
||||||
|
|
||||||
|
func (docCoverageErrorReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
|
||||||
|
|
||||||
|
func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) {
|
||||||
|
f.calls++
|
||||||
|
f.history = append(f.history, docCoverageCall{tool: tool, params: params})
|
||||||
|
if f.failAt == f.calls {
|
||||||
|
return nil, errors.New("injected doc coverage failure")
|
||||||
|
}
|
||||||
|
value := docCoveragePayload(tool)
|
||||||
|
if queue := f.responses[tool]; len(queue) > 0 {
|
||||||
|
value = queue[0]
|
||||||
|
f.responses[tool] = queue[1:]
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *docCoverageCaller) Format() string { return "json" }
|
||||||
|
func (f *docCoverageCaller) DryRun() bool { return false }
|
||||||
|
func (f *docCoverageCaller) Fields() string { return "" }
|
||||||
|
func (f *docCoverageCaller) JQ() string { return "" }
|
||||||
|
|
||||||
|
func docCoveragePayload(tool string) map[string]any {
|
||||||
|
switch tool {
|
||||||
|
case "create_document":
|
||||||
|
return map[string]any{"data": map[string]any{"nodeId": "node-1"}}
|
||||||
|
case "get_document_content":
|
||||||
|
return map[string]any{"data": map[string]any{"revision": 1, "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta"]]`}}
|
||||||
|
case "list_document_blocks":
|
||||||
|
return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}}
|
||||||
|
case "submit_export_job":
|
||||||
|
return map[string]any{"jobId": "job-1"}
|
||||||
|
case "query_export_job":
|
||||||
|
return map[string]any{"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/export.docx"}
|
||||||
|
case "list_doc_versions":
|
||||||
|
return map[string]any{"versions": []any{map[string]any{"version": 3.0}, map[string]any{"versionNumber": "4"}}}
|
||||||
|
case "search_doc_templates":
|
||||||
|
return map[string]any{"templates": []any{map[string]any{"templateId": "template-1"}}}
|
||||||
|
case "get_document_style":
|
||||||
|
return map[string]any{"data": map[string]any{"cover": map[string]any{"resourceId": "resource-1", "imageUrl": "https://download.dingtalk.com/cover.png"}}}
|
||||||
|
case "download_doc_attachment":
|
||||||
|
return map[string]any{"downloadUrl": "https://download.dingtalk.com/file.bin", "fileName": "file.bin", "headers": map[string]any{"x-test": "ok", "ignored": 1}}
|
||||||
|
case "list_comments":
|
||||||
|
return map[string]any{"commentList": []any{map[string]any{"commentKey": "comment-1", "content": "review", "quote": "alpha"}}}
|
||||||
|
default:
|
||||||
|
return map[string]any{"ok": true, "result": map[string]any{"id": "id-1"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDocCoverage(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, args ...string) error {
|
||||||
|
return runDocCoverageInput(t, declaration, caller, strings.NewReader(""), args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDocCoverageInput(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, args ...string) error {
|
||||||
|
return runDocCoveragePath(t, declaration, caller, input, declaration.Command, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, commandPath string, args ...string) error {
|
||||||
|
t.Helper()
|
||||||
|
helpers.InitDeps(caller)
|
||||||
|
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||||
|
root.PersistentFlags().Bool("yes", false, "")
|
||||||
|
root.PersistentFlags().Bool("dry-run", false, "")
|
||||||
|
root.PersistentFlags().String("format", "json", "")
|
||||||
|
service := &cobra.Command{Use: "doc"}
|
||||||
|
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||||
|
root.AddCommand(service)
|
||||||
|
root.SetOut(io.Discard)
|
||||||
|
root.SetErr(io.Discard)
|
||||||
|
root.SetIn(input)
|
||||||
|
if caller.ctx != nil {
|
||||||
|
root.SetContext(caller.ctx)
|
||||||
|
}
|
||||||
|
root.SetArgs(append([]string{"doc", commandPath}, args...))
|
||||||
|
return root.Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testing.T) {
|
||||||
|
revisionPayload := map[string]any{"data": map[string]any{"revision": json.Number("9")}}
|
||||||
|
if got, ok := nestedRevision(revisionPayload); !ok || got != 9 {
|
||||||
|
t.Fatalf("nestedRevision = %d/%v", got, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks := map[string]any{"blocks": []any{
|
||||||
|
map[string]any{"element": map[string]any{
|
||||||
|
"id": "block-1", "paragraph": map[string]any{"text": "前缀😀真实追加:beta。后缀"},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
matches := findSelectionMatches(blocks, "真实追加:beta。")
|
||||||
|
if len(matches) != 1 {
|
||||||
|
t.Fatalf("matches = %#v", matches)
|
||||||
|
}
|
||||||
|
if got := matches[0]; got.blockID != "block-1" || got.start != 4 || got.end != 14 {
|
||||||
|
t.Fatalf("selection match = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonml := `["root",{},["p",{"uuid":"block-jsonml"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"旧入口兼容追加:gamma。"]]]]`
|
||||||
|
projected := projectKeywordMatches(map[string]any{"jsonml": jsonml}, "gamma", 80, 120)
|
||||||
|
if projected["count"] != 1 {
|
||||||
|
t.Fatalf("keyword projection = %#v", projected)
|
||||||
|
}
|
||||||
|
rows := projected["matches"].([]map[string]any)
|
||||||
|
if rows[0]["blockId"] != "block-jsonml" || rows[0]["content"] != "旧入口兼容追加:gamma。" {
|
||||||
|
t.Fatalf("keyword row = %#v", rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
unicodeProjection := projectKeywordMatches(map[string]any{"id": "unicode-block", "text": "KABtargetCD"}, "TARGET", 1, 1)
|
||||||
|
unicodeRows := unicodeProjection["matches"].([]map[string]any)
|
||||||
|
if len(unicodeRows) != 1 || unicodeRows[0]["content"] != "BtargetC" || !utf8.ValidString(unicodeRows[0]["content"].(string)) {
|
||||||
|
t.Fatalf("unicode keyword projection = %#v", unicodeProjection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
|
||||||
|
assertPartial := func(t *testing.T, err error, reason, stage, nodeID string, wantSteps int) *apperrors.Error {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("partial write unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
var typed *apperrors.Error
|
||||||
|
if !errors.As(err, &typed) {
|
||||||
|
t.Fatalf("partial write error = %#v", err)
|
||||||
|
}
|
||||||
|
if typed.Reason != reason || typed.FailureStage != stage || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
|
||||||
|
t.Fatalf("partial write metadata = %#v", typed)
|
||||||
|
}
|
||||||
|
if typed.Details["status"] != "partial_success" {
|
||||||
|
t.Fatalf("partial write details = %#v", typed.Details)
|
||||||
|
}
|
||||||
|
data, _ := typed.Details["data"].(map[string]any)
|
||||||
|
if data["nodeId"] != nodeID {
|
||||||
|
t.Fatalf("partial write data = %#v", data)
|
||||||
|
}
|
||||||
|
steps, _ := typed.Details["steps"].([]map[string]any)
|
||||||
|
if len(steps) != wantSteps || steps[0]["status"] != "success" || steps[len(steps)-1]["status"] == "success" {
|
||||||
|
t.Fatalf("partial write steps = %#v", steps)
|
||||||
|
}
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
|
||||||
|
create := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
|
||||||
|
err := runDocCoverage(t, Create, create, "--name", "n", "--content", `[]`, "--doc-format", "jsonml")
|
||||||
|
typed := assertPartial(t, err, "doc_create_initial_content_failed", "write_jsonml", "node-1", 2)
|
||||||
|
compensation, _ := typed.Details["compensation"].(map[string]any)
|
||||||
|
if compensation["available"] != true || compensation["nodeId"] != "node-1" || len(create.history) != 2 {
|
||||||
|
t.Fatalf("create compensation=%#v history=%#v", compensation, create.history)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkpointUpdate := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{
|
||||||
|
"save_doc_version": {{"version": 7.0}},
|
||||||
|
}}
|
||||||
|
err = runDocCoverage(t, CheckpointUpdate, checkpointUpdate, "--node", "n", "--content", "body", "--yes")
|
||||||
|
typed = assertPartial(t, err, "doc_checkpoint_update_failed", "update", "n", 3)
|
||||||
|
data, _ := typed.Details["data"].(map[string]any)
|
||||||
|
compensation, _ = typed.Details["compensation"].(map[string]any)
|
||||||
|
if data["checkpointVersion"] != 7 || compensation["version"] != 7 {
|
||||||
|
t.Fatalf("checkpoint recovery metadata data=%#v compensation=%#v", data, compensation)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkpointVerify := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}}
|
||||||
|
err = runDocCoverage(t, CheckpointUpdate, checkpointVerify, "--node", "n", "--content", "body", "--yes")
|
||||||
|
assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3)
|
||||||
|
|
||||||
|
historyVerify := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}}
|
||||||
|
err = runDocCoverage(t, VersionRevert, historyVerify, "--node", "n", "--version", "3", "--yes")
|
||||||
|
assertPartial(t, err, "doc_history_revert_verification_failed", "verify", "n", 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantTools []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "plain text replace",
|
||||||
|
args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"},
|
||||||
|
wantTools: []string{"list_document_blocks", "update_document_block"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "block copy",
|
||||||
|
args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"},
|
||||||
|
wantTools: []string{"list_document_blocks", "insert_document_block"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
|
||||||
|
if err := runDocCoverage(t, Update, caller, tc.args...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(caller.history) != len(tc.wantTools) {
|
||||||
|
t.Fatalf("calls = %#v", caller.history)
|
||||||
|
}
|
||||||
|
for index, call := range caller.history {
|
||||||
|
if call.tool != tc.wantTools[index] || call.params["nodeId"] != "alias-node" {
|
||||||
|
t.Fatalf("call %d = %#v, want tool=%s nodeId=alias-node", index, call, tc.wantTools[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageSelectionMatchesEnumerateEveryCandidate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
selection string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{name: "repeated omitted range", text: "left A right; left B right", selection: "left...right", want: 3},
|
||||||
|
{name: "empty prefix", text: "one right; two right", selection: "...right", want: 2},
|
||||||
|
{name: "empty suffix", text: "left one; left two", selection: "left...", want: 2},
|
||||||
|
{name: "both empty anchors", text: "whole block", selection: "...", want: 1},
|
||||||
|
{name: "empty block", text: "", selection: "...", want: 0},
|
||||||
|
{name: "overlapping literal", text: "aaa", selection: "aa", want: 2},
|
||||||
|
{name: "empty selection", text: "text", selection: "", want: 0},
|
||||||
|
{name: "missing prefix", text: "text", selection: "left...right", want: 0},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
matches := findSelectionMatches(map[string]any{"id": "block", "text": tc.text}, tc.selection)
|
||||||
|
if len(matches) != tc.want {
|
||||||
|
t.Fatalf("matches = %#v, want %d", matches, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||||
|
"list_document_blocks": {{"items": []any{map[string]any{"id": "block", "text": "left A right; left B right"}}}},
|
||||||
|
}}
|
||||||
|
err := runDocCoverage(t, CommentCreate, caller, "--node", "n", "--content", "review", "--selection", "left...right", "--yes")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "AMBIGUOUS_SELECTION") {
|
||||||
|
t.Fatalf("same-block ambiguity error = %v", err)
|
||||||
|
}
|
||||||
|
if len(caller.history) != 1 || caller.history[0].tool != "list_document_blocks" {
|
||||||
|
t.Fatalf("ambiguous selection reached a write: %#v", caller.history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocDestructiveConfirmationBoundaries(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
decl shortcut.Shortcut
|
||||||
|
args []string
|
||||||
|
want []docCoverageCall
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "comment delete",
|
||||||
|
decl: CommentDelete,
|
||||||
|
args: []string{"--node", "n", "--comment-key", "c"},
|
||||||
|
want: []docCoverageCall{{tool: "delete_comment", params: map[string]any{"nodeId": "n", "commentKey": "c"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resource delete",
|
||||||
|
decl: ResourceDelete,
|
||||||
|
args: []string{"--node", "n"},
|
||||||
|
want: []docCoverageCall{{tool: "update_document_style", params: map[string]any{"nodeId": "n", "cover": map[string]any{"action": "clear"}}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "history revert",
|
||||||
|
decl: VersionRevert,
|
||||||
|
args: []string{"--node", "n", "--version", "3"},
|
||||||
|
want: []docCoverageCall{
|
||||||
|
{tool: "list_doc_versions", params: map[string]any{"nodeId": "n"}},
|
||||||
|
{tool: "revert_doc_version", params: map[string]any{"nodeId": "n", "version": 3}},
|
||||||
|
{tool: "get_document_info", params: map[string]any{"nodeId": "n"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
unconfirmed := &docCoverageCaller{responses: map[string][]map[string]any{}}
|
||||||
|
if err := runDocCoverage(t, tc.decl, unconfirmed, tc.args...); err == nil {
|
||||||
|
t.Fatal("destructive shortcut without --yes must reject")
|
||||||
|
}
|
||||||
|
if unconfirmed.calls != 0 || len(unconfirmed.history) != 0 {
|
||||||
|
t.Fatalf("unconfirmed shortcut called MCP: %#v", unconfirmed.history)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmed := &docCoverageCaller{responses: map[string][]map[string]any{}}
|
||||||
|
if err := runDocCoverage(t, tc.decl, confirmed, append(tc.args, "--yes")...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(confirmed.history, tc.want) {
|
||||||
|
t.Fatalf("confirmed calls = %#v, want %#v", confirmed.history, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageReviewInfersInlineBlockFromUniqueQuote(t *testing.T) {
|
||||||
|
comments := map[string]any{"commentList": []any{
|
||||||
|
map[string]any{"commentKey": "inline", "content": "review", "isGlobal": false, "quote": "真实追加:beta。"},
|
||||||
|
map[string]any{"commentKey": "global", "content": "global", "isGlobal": true},
|
||||||
|
}}
|
||||||
|
blocks := map[string]any{"blocks": []any{
|
||||||
|
map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "真实追加:beta。"}}},
|
||||||
|
}}
|
||||||
|
items := projectReviewComments(comments, blocks)
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("review items = %#v", items)
|
||||||
|
}
|
||||||
|
if items[0]["blockId"] != "block-1" || items[0]["context"] != "真实追加:beta。" {
|
||||||
|
t.Fatalf("inline review = %#v", items[0])
|
||||||
|
}
|
||||||
|
if items[1]["blockId"] != "" {
|
||||||
|
t.Fatalf("global review = %#v", items[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.T) {
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
if err := os.WriteFile("body.json", []byte(`["root",{},"body"]`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("body.md", []byte("from file"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cmd shortcut.Shortcut
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{"create markdown", Create, []string{"--name", "n", "--content", "body", "--folder", "f", "--workspace", "w"}},
|
||||||
|
{"create dry", Create, []string{"--name", "n", "--content", "body", "--dry-run"}},
|
||||||
|
{"create jsonml file", Create, []string{"--name", "n", "--content", "@body.json", "--doc-format", "jsonml"}},
|
||||||
|
{"create stdin", Create, []string{"--name", "n", "--content", "-"}},
|
||||||
|
{"fetch simple", Fetch, []string{"--node", "n"}},
|
||||||
|
{"fetch keyword", Fetch, []string{"--node", "n", "--detail", "full", "--scope", "keyword", "--keyword", "alpha|none", "--context-before", "1", "--context-after", "1"}},
|
||||||
|
{"fetch scoped", Fetch, []string{"--node", "n", "--scope", "range", "--start-block-id", "a", "--end-block-id", "b", "--tags", "p,h1", "--max-depth", "2"}},
|
||||||
|
{"inspect base", Inspect, []string{"--node", "n"}},
|
||||||
|
{"inspect all", Inspect, []string{"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"}},
|
||||||
|
{"update append", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--yes"}},
|
||||||
|
{"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
|
||||||
|
{"update insert text", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", "x", "--yes"}},
|
||||||
|
{"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
|
||||||
|
{"update replace text", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", "x", "--yes"}},
|
||||||
|
{"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
|
||||||
|
{"update delete", Update, []string{"--node", "n", "--command", "block_delete", "--block-id", "b", "--yes"}},
|
||||||
|
{"update replace", Update, []string{"--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"}},
|
||||||
|
{"update copy", Update, []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "b", "--yes"}},
|
||||||
|
{"update revision", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"}},
|
||||||
|
{"update dry", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--dry-run", "--yes"}},
|
||||||
|
{"checkpoint dry", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--dry-run", "--yes"}},
|
||||||
|
{"checkpoint success", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--yes"}},
|
||||||
|
{"export dry", Export, []string{"--node", "n", "--output", "out.docx", "--dry-run"}},
|
||||||
|
{"export success", Export, []string{"--node", "n", "--output", "out.docx"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||||
|
return localio.DownloadResult{RelativePath: "out.docx", SizeBytes: 7}, nil
|
||||||
|
})
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
|
||||||
|
var err error
|
||||||
|
if tc.name == "create stdin" {
|
||||||
|
err = runDocCoverageInput(t, tc.cmd, caller, strings.NewReader("stdin body"), tc.args...)
|
||||||
|
} else {
|
||||||
|
err = runDocCoverage(t, tc.cmd, caller, tc.args...)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, command := range []shortcut.Shortcut{Create, Fetch, Inspect, Update, CheckpointUpdate, Export} {
|
||||||
|
for failAt := 1; failAt <= 7; failAt++ {
|
||||||
|
args := map[string][]string{
|
||||||
|
"+create": {"--name", "n", "--content", `[]`, "--doc-format", "jsonml"},
|
||||||
|
"+fetch": {"--node", "n", "--scope", "keyword", "--keyword", "x"},
|
||||||
|
"+inspect": {"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"},
|
||||||
|
"+update": {"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"},
|
||||||
|
"+checkpoint-update": {"--node", "n", "--content", "x", "--yes"},
|
||||||
|
"+export": {"--node", "n", "--output", "out.docx"},
|
||||||
|
}[command.Command]
|
||||||
|
_ = runDocCoverage(t, command, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
if err := os.WriteFile("body.md", []byte("body"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outside := filepath.Join(filepath.Dir(t.TempDir()), "outside.md")
|
||||||
|
_ = outside
|
||||||
|
badCases := []struct {
|
||||||
|
cmd shortcut.Shortcut
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{Create, []string{"--name", "n", "--content", "@"}},
|
||||||
|
{Create, []string{"--name", "n", "--content", "@/absolute"}},
|
||||||
|
{Create, []string{"--name", "n", "--content", "not-json", "--doc-format", "jsonml"}},
|
||||||
|
{Create, []string{"--name", "n", "--content", `{}`, "--doc-format", "jsonml"}},
|
||||||
|
{Fetch, []string{"--node", "n", "--revision", "1"}},
|
||||||
|
{Fetch, []string{"--node", "n", "--scope", "keyword"}},
|
||||||
|
{Update, []string{"--node", "n"}},
|
||||||
|
{Update, []string{"--command", "append", "--content", "x", "--yes"}},
|
||||||
|
{Update, []string{"--node", "n", "--command", "append", "--yes"}},
|
||||||
|
{Update, []string{"--node", "n", "--command", "block_delete", "--yes"}},
|
||||||
|
{Update, []string{"--node", "n", "--command", "str_replace", "--old", "x", "--yes"}},
|
||||||
|
{Update, []string{"--node", "n", "--command", "append", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
|
||||||
|
}
|
||||||
|
for _, tc := range badCases {
|
||||||
|
if err := runDocCoverage(t, tc.cmd, &docCoverageCaller{responses: map[string][]map[string]any{}}, tc.args...); err == nil {
|
||||||
|
t.Errorf("%s %#v unexpectedly succeeded", tc.cmd.Command, tc.args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createNoNode := &docCoverageCaller{responses: map[string][]map[string]any{"create_document": {{"ok": true}}}}
|
||||||
|
if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `[]`, "--doc-format", "jsonml"); err == nil {
|
||||||
|
t.Fatal("jsonml create without node id succeeded")
|
||||||
|
}
|
||||||
|
conflict := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"revision": 2}}}}
|
||||||
|
if err := runDocCoverage(t, Update, conflict, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
|
||||||
|
t.Fatal("revision conflict succeeded")
|
||||||
|
}
|
||||||
|
missingRevision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"ok": true}}}}
|
||||||
|
if err := runDocCoverage(t, Update, missingRevision, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
|
||||||
|
t.Fatal("missing revision succeeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, value := range []any{
|
||||||
|
map[string]any{"revision": 2.5}, map[string]any{"revision": json.Number("bad")}, map[string]any{"revision": "bad"},
|
||||||
|
map[string]any{"data": []any{map[string]any{"versionNumber": "3"}}}, []any{map[string]any{"version": 4.0}}, "none",
|
||||||
|
} {
|
||||||
|
_, _ = nestedRevision(value)
|
||||||
|
}
|
||||||
|
if _, err := validateJSONML(`[`); err == nil {
|
||||||
|
t.Fatal("invalid jsonml succeeded")
|
||||||
|
}
|
||||||
|
if _, err := validateJSONML(`{}`); err == nil {
|
||||||
|
t.Fatal("object jsonml succeeded")
|
||||||
|
}
|
||||||
|
if nestedMap(map[string]any{"result": map[string]any{"data": map[string]any{"x": 1}}})["x"] != 1 {
|
||||||
|
t.Fatal("nestedMap did not unwrap")
|
||||||
|
}
|
||||||
|
_ = stringSliceNonEmpty([]string{"", " a "})
|
||||||
|
|
||||||
|
blocks := map[string]any{"items": []any{map[string]any{"id": "b", "text": "alpha alpha"}, map[string]any{"id": "resource", "src": "x"}}}
|
||||||
|
_ = projectKeywordMatches(blocks, "alpha|beta", -1, -1)
|
||||||
|
_ = projectKeywordMatches(map[string]any{"jsonml": "bad"}, "none", 1, 1)
|
||||||
|
_ = findBlock(blocks, "b")
|
||||||
|
_ = findBlock([]any{blocks}, "missing")
|
||||||
|
_ = containsResourceReference(blocks)
|
||||||
|
_ = containsResourceReference([]any{map[string]any{"x": "y"}})
|
||||||
|
stripBlockIDs(blocks)
|
||||||
|
|
||||||
|
if err := runDocCoveragePath(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil {
|
||||||
|
t.Fatalf("visible update flag aliases: %v", err)
|
||||||
|
}
|
||||||
|
missingNode := Update
|
||||||
|
missingNode.Flags = append([]shortcut.Flag(nil), Update.Flags...)
|
||||||
|
missingNode.Flags[0].Required = false
|
||||||
|
if err := runDocCoverage(t, missingNode, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--command", "append", "--content", "x", "--yes"); err == nil {
|
||||||
|
t.Fatal("custom missing-node validation was not reached")
|
||||||
|
}
|
||||||
|
unknown := Update
|
||||||
|
unknown.Flags = append([]shortcut.Flag(nil), Update.Flags...)
|
||||||
|
unknown.Flags[1].Enum = append(append([]string(nil), unknown.Flags[1].Enum...), "bogus")
|
||||||
|
if err := runDocCoverage(t, unknown, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--command", "bogus", "--yes"); err == nil {
|
||||||
|
t.Fatal("unknown update command succeeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = runDocCoverageInput(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, docCoverageErrorReader{}, "--name", "n", "--content", "-")
|
||||||
|
for _, seamCase := range []struct {
|
||||||
|
name string
|
||||||
|
run func(*testing.T)
|
||||||
|
}{
|
||||||
|
{"getwd", func(t *testing.T) {
|
||||||
|
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") })
|
||||||
|
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
|
||||||
|
}},
|
||||||
|
{"eval-base", func(t *testing.T) {
|
||||||
|
testseam.Swap(t, &docEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") })
|
||||||
|
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
|
||||||
|
}},
|
||||||
|
{"eval-file", func(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
testseam.Swap(t, &docEvalSymlinks, func(value string) (string, error) {
|
||||||
|
calls++
|
||||||
|
if calls == 2 {
|
||||||
|
return "", errors.New("eval file")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
})
|
||||||
|
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
|
||||||
|
}},
|
||||||
|
{"rel", func(t *testing.T) {
|
||||||
|
testseam.Swap(t, &docRel, func(string, string) (string, error) { return "", errors.New("rel") })
|
||||||
|
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
|
||||||
|
}},
|
||||||
|
{"read", func(t *testing.T) {
|
||||||
|
testseam.Swap(t, &docReadFile, func(string) ([]byte, error) { return nil, errors.New("read") })
|
||||||
|
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(seamCase.name, seamCase.run)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = runDocCoverage(t, CheckpointUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--content", "@missing", "--yes")
|
||||||
|
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--command", "append", "--content", "@missing", "--yes")
|
||||||
|
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--command", "overwrite", "--content", "bad", "--doc-format", "jsonml", "--yes")
|
||||||
|
_ = runDocCoverage(t, Update, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "x", "--yes")
|
||||||
|
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"items": []any{map[string]any{"id": "a", "text": "alpha"}, map[string]any{"id": "b", "text": "alpha"}}}}}}, "--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "x", "--yes")
|
||||||
|
_ = runDocCoverage(t, Update, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--yes")
|
||||||
|
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"ok": true}}}}, "--node", "n", "--command", "block_copy_insert_after", "--block-id", "missing", "--yes")
|
||||||
|
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "block-1", "resourceId": "r"}}}}, "--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--yes")
|
||||||
|
|
||||||
|
for _, response := range []map[string]any{
|
||||||
|
{"ok": true},
|
||||||
|
{"jobId": "j"},
|
||||||
|
} {
|
||||||
|
caller := &docCoverageCaller{responses: map[string][]map[string]any{"submit_export_job": {response}, "query_export_job": {{"status": "FAILED", "message": "bad"}}}}
|
||||||
|
_ = runDocCoverage(t, Export, caller, "--node", "n", "--output", "x", "--max-polls", "1")
|
||||||
|
}
|
||||||
|
timeout := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
|
||||||
|
_ = runDocCoverage(t, Export, timeout, "--node", "n", "--output", "x", "--max-polls", "1")
|
||||||
|
processingThenSuccess := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}, {"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/x"}}}}
|
||||||
|
_ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--output", "x", "--max-polls", "2")
|
||||||
|
cancelled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
cancelledCaller := &docCoverageCaller{ctx: cancelled, responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
|
||||||
|
_ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--output", "x", "--max-polls", "2")
|
||||||
|
_ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--output", "x", "--max-polls", "0")
|
||||||
|
missingURL := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}}
|
||||||
|
_ = runDocCoverage(t, Export, missingURL, "--node", "n", "--output", "x")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||||
|
return localio.DownloadResult{RelativePath: "artifact.bin", SizeBytes: 9}, nil
|
||||||
|
})
|
||||||
|
commands := []struct {
|
||||||
|
decl shortcut.Shortcut
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{VersionList, []string{"--node", "n", "--page-size", "2", "--page-token", "p"}},
|
||||||
|
{VersionList, []string{"--node", "n", "--limit", "2", "--cursor", "p"}},
|
||||||
|
{VersionRevert, []string{"--node", "n", "--version", "3", "--yes"}},
|
||||||
|
{VersionRevert, []string{"--node", "n", "--version", "3", "--dry-run", "--yes"}},
|
||||||
|
{CreateFromTemplate, []string{"--template-id", "t", "--name", "n", "--folder", "f", "--workspace", "w"}},
|
||||||
|
{CreateFromTemplate, []string{"--query", "q", "--source", "PUBLIC", "--dry-run"}},
|
||||||
|
{Review, []string{"--node", "n"}},
|
||||||
|
{CommentUpdate, []string{"--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u"}},
|
||||||
|
{CommentDelete, []string{"--node", "n", "--comment-key", "c", "--dry-run", "--yes"}},
|
||||||
|
{CommentCreate, []string{"--node", "n", "--content", "x", "--yes"}},
|
||||||
|
{CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}},
|
||||||
|
{CommentCreate, []string{"--node", "n", "--content", "x", "--selection", "alpha", "--yes"}},
|
||||||
|
{MediaList, []string{"--node", "n"}},
|
||||||
|
{MediaPreview, []string{"--node", "n", "--resource-id", "r"}},
|
||||||
|
{MediaPreview, []string{"--node", "n", "--resource-id", "r", "--dry-run"}},
|
||||||
|
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "m.bin"}},
|
||||||
|
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "m.bin", "--dry-run"}},
|
||||||
|
{ResourceDownload, []string{"--node", "n", "--output", "cover.png"}},
|
||||||
|
{ResourceDownload, []string{"--node", "n", "--output", "cover.png", "--dry-run"}},
|
||||||
|
{ResourceDelete, []string{"--node", "n", "--dry-run", "--yes"}},
|
||||||
|
{BackgroundUpdate, []string{"--node", "n", "--color", "#ABCDEF"}},
|
||||||
|
{BackgroundDelete, []string{"--node", "n", "--dry-run", "--yes"}},
|
||||||
|
{BackgroundDelete, []string{"--node", "n", "--yes"}},
|
||||||
|
{ResourceDelete, []string{"--node", "n", "--yes"}},
|
||||||
|
{CommentDelete, []string{"--node", "n", "--comment-key", "c", "--yes"}},
|
||||||
|
}
|
||||||
|
for _, item := range commands {
|
||||||
|
if err := runDocCoverage(t, item.decl, &docCoverageCaller{responses: map[string][]map[string]any{}}, item.args...); err != nil {
|
||||||
|
t.Errorf("%s: %v", item.decl.Command, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, declaration := range []shortcut.Shortcut{VersionRevert, CreateFromTemplate, Review, MediaList, MediaDownload, ResourceDownload} {
|
||||||
|
args := map[string][]string{
|
||||||
|
"+history-revert": {"--node", "n", "--version", "3", "--yes"},
|
||||||
|
"+create-from-template": {"--query", "q"},
|
||||||
|
"+review": {"--node", "n"},
|
||||||
|
"+media-list": {"--node", "n"},
|
||||||
|
"+media-download": {"--node", "n", "--resource-id", "r", "--output", "m.bin"},
|
||||||
|
"+resource-download": {"--node", "n", "--output", "cover.png"},
|
||||||
|
}[declaration.Command]
|
||||||
|
for failAt := 1; failAt <= 4; failAt++ {
|
||||||
|
_ = runDocCoverage(t, declaration, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, value := range []any{
|
||||||
|
map[string]any{"version": 3.0}, map[string]any{"version": 3.5}, map[string]any{"version": "3"}, map[string]any{"version": "bad"},
|
||||||
|
[]any{map[string]any{"revision": 3.0}}, "none",
|
||||||
|
} {
|
||||||
|
_ = containsVersion(value, 3)
|
||||||
|
}
|
||||||
|
_ = collectTemplateIDs(map[string]any{"template_id": "t1", "nested": []any{map[string]any{"templateId": "t1"}, map[string]any{"templateId": "t2"}, "x"}})
|
||||||
|
_ = collectTemplateIDs("none")
|
||||||
|
_ = collectMediaItems(map[string]any{"id": "b", "resourceId": "r", "src": "u", "name": "n", "type": "file", "mimeType": "x", "viewType": "v", "children": []any{map[string]any{"resourceUrl": "u2"}}})
|
||||||
|
_ = nestedStringDeep([]any{map[string]any{"x": map[string]any{"url": " u "}}}, "url")
|
||||||
|
_ = nestedStringDeep("none", "url")
|
||||||
|
|
||||||
|
badComments := [][]string{
|
||||||
|
{"--node", "n", "--content", "x", "--block-id", "b", "--yes"},
|
||||||
|
{"--node", "n", "--content", "x", "--block-id", "b", "--start", "2", "--end", "1", "--yes"},
|
||||||
|
{"--node", "n", "--content", "x", "--block-id", "b", "--start", "0", "--end", "1", "--selection", "x", "--yes"},
|
||||||
|
}
|
||||||
|
for _, args := range badComments {
|
||||||
|
if err := runDocCoverage(t, CommentCreate, &docCoverageCaller{responses: map[string][]map[string]any{}}, args...); err == nil {
|
||||||
|
t.Errorf("invalid comment args succeeded: %#v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"items": []any{map[string]any{"id": "a", "text": "x"}, map[string]any{"id": "b", "text": "x"}}}}}}
|
||||||
|
if err := runDocCoverage(t, CommentCreate, ambiguous, "--node", "n", "--content", "c", "--selection", "x", "--yes"); err == nil {
|
||||||
|
t.Fatal("ambiguous comment selection succeeded")
|
||||||
|
}
|
||||||
|
_ = findSelectionMatches(map[string]any{"id": "b", "text": "left middle right"}, "left...right")
|
||||||
|
_ = findSelectionMatches([]any{map[string]any{"id": "b", "text": "none"}}, "x")
|
||||||
|
_ = runDocCoverage(t, CommentCreate, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--content", "c", "--selection", "x", "--yes")
|
||||||
|
|
||||||
|
globalReview := &docCoverageCaller{responses: map[string][]map[string]any{"list_comments": {{"comments": []any{map[string]any{"commentKey": "global", "content": "g"}}}}}}
|
||||||
|
_ = runDocCoverage(t, Review, globalReview, "--node", "n")
|
||||||
|
_ = runDocCoverage(t, VersionRevert, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--version", "3", "--yes")
|
||||||
|
_ = runDocCoverage(t, VersionRevert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--version", "99", "--yes")
|
||||||
|
_ = runDocCoverage(t, CreateFromTemplate, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--template-id", "t")
|
||||||
|
multipleTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "a"}, map[string]any{"templateId": "b"}}}}}}
|
||||||
|
_ = runDocCoverage(t, CreateFromTemplate, multipleTemplates, "--query", "q")
|
||||||
|
|
||||||
|
if err := os.WriteFile("media.bin", []byte("media"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes")
|
||||||
|
_ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--image", "https://example.com/cover.png", "--dry-run", "--yes")
|
||||||
|
_ = runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run")
|
||||||
|
|
||||||
|
resourceOnly := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_style": {{"resourceId": "r"}}}}
|
||||||
|
_ = runDocCoverage(t, ResourceDownload, resourceOnly, "--node", "n", "--output", "cover.png")
|
||||||
|
emptyStyle := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_style": {{"ok": true}}}}
|
||||||
|
_ = runDocCoverage(t, ResourceDownload, emptyStyle, "--node", "n", "--output", "cover.png")
|
||||||
|
_ = runDocCoverage(t, ResourceDownload, &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{"get_document_style": {{"resourceId": "r"}}}}, "--node", "n", "--output", "cover.png")
|
||||||
|
_, _ = downloadResolvedResource(nil, map[string]any{}, ".", "x")
|
||||||
|
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
|
||||||
|
_ = runDocCoverage(t, BackgroundUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--color", "bad")
|
||||||
|
_ = runDocCoverage(t, BackgroundUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--color", "#ABCDEG")
|
||||||
|
|
||||||
|
t.Run("preview mkdir failure", func(t *testing.T) {
|
||||||
|
testseam.Swap(t, &docMkdirTemp, func(string, string) (string, error) { return "", errors.New("mkdir") })
|
||||||
|
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
|
||||||
|
})
|
||||||
|
t.Run("preview download cleanup", func(t *testing.T) {
|
||||||
|
removed := false
|
||||||
|
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||||
|
return localio.DownloadResult{}, errors.New("download")
|
||||||
|
})
|
||||||
|
testseam.Swap(t, &docRemoveAll, func(string) error { removed = true; return nil })
|
||||||
|
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
|
||||||
|
if !removed {
|
||||||
|
t.Fatal("preview failure did not clean temporary directory")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T) {
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||||
|
return localio.DownloadResult{}, errors.New("download failed")
|
||||||
|
})
|
||||||
|
for _, item := range []struct {
|
||||||
|
decl shortcut.Shortcut
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{Export, []string{"--node", "n", "--output", "out.docx"}},
|
||||||
|
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
|
||||||
|
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
|
||||||
|
} {
|
||||||
|
if err := runDocCoverage(t, item.decl, &docCoverageCaller{responses: map[string][]map[string]any{}}, item.args...); err == nil {
|
||||||
|
t.Errorf("%s download error was ignored", item.decl.Command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd failed") })
|
||||||
|
for _, item := range []struct {
|
||||||
|
decl shortcut.Shortcut
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{Export, []string{"--node", "n", "--output", "out.docx"}},
|
||||||
|
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
|
||||||
|
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
|
||||||
|
} {
|
||||||
|
_ = runDocCoverage(t, item.decl, &docCoverageCaller{responses: map[string][]map[string]any{}}, item.args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocDownloadsHaveNoOverwriteEscape(t *testing.T) {
|
||||||
|
for _, item := range []struct {
|
||||||
|
decl shortcut.Shortcut
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{Export, []string{"--node", "n", "--output", "out.docx"}},
|
||||||
|
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
|
||||||
|
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
|
||||||
|
} {
|
||||||
|
t.Run(item.decl.Command, func(t *testing.T) {
|
||||||
|
for _, flag := range item.decl.Flags {
|
||||||
|
if flag.Name == "overwrite" {
|
||||||
|
t.Fatal("download shortcut still declares --overwrite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
|
||||||
|
err := runDocCoverage(t, item.decl, caller, append(item.args, "--overwrite")...)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("--overwrite unexpectedly accepted")
|
||||||
|
}
|
||||||
|
if caller.calls != 0 {
|
||||||
|
t.Fatalf("rejected --overwrite performed %d MCP calls", caller.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
package doc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
legacyVersionSave shortcut.Shortcut
|
||||||
|
legacyVersionList shortcut.Shortcut
|
||||||
|
legacyVersionRevert shortcut.Shortcut
|
||||||
|
)
|
||||||
|
|
||||||
|
func canonicalizeHistoryShortcuts() {
|
||||||
|
legacyVersionSave = VersionSave
|
||||||
|
legacyVersionList = VersionList
|
||||||
|
legacyVersionRevert = VersionRevert
|
||||||
|
|
||||||
|
VersionSave.Command = "+history-save"
|
||||||
|
VersionSave.Aliases = nil
|
||||||
|
VersionSave.Description = "手动保存当前文档版本快照"
|
||||||
|
VersionSave.Intent = "当用户要在重要修改前后手动建立一个可回滚的文档历史快照时使用;保存快照本身无需交互确认。"
|
||||||
|
VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"}
|
||||||
|
VersionSave.Contract = docContract("+history-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +history-save --node <DOC_ID>`})
|
||||||
|
VersionSave.Tips = []string{`dws doc +history-save --node <DOC_ID>`}
|
||||||
|
|
||||||
|
VersionList.Command = "+history-list"
|
||||||
|
VersionList.Aliases = nil
|
||||||
|
VersionList.Description = "分页列出文档历史版本"
|
||||||
|
VersionList.Intent = "当用户要查看文档已有版本、选择回滚目标或审计版本时间线时使用;返回版本号和分页游标。"
|
||||||
|
VersionList.Contract = docContract("+history-list", VersionList.Description, VersionList.Intent, []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`})
|
||||||
|
VersionList.Flags = []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "page-size", Type: shortcut.FlagInt, Desc: "每页版本数量"},
|
||||||
|
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标"},
|
||||||
|
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的兼容别名"},
|
||||||
|
{Name: "cursor", Type: shortcut.FlagString, Desc: "--page-token 的兼容别名"},
|
||||||
|
}
|
||||||
|
VersionList.Tips = []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`}
|
||||||
|
VersionList.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||||
|
params := map[string]any{"nodeId": rt.Str("node")}
|
||||||
|
if size := rt.IntFirst("page-size", "limit"); size > 0 {
|
||||||
|
params["maxResults"] = size
|
||||||
|
}
|
||||||
|
if token := rt.StrFirst("page-token", "cursor"); token != "" {
|
||||||
|
params["nextCursor"] = token
|
||||||
|
}
|
||||||
|
return rt.CallMCP("list_doc_versions", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
VersionRevert.Command = "+history-revert"
|
||||||
|
VersionRevert.Aliases = nil
|
||||||
|
VersionRevert.Description = "预检并回滚文档到指定历史版本"
|
||||||
|
VersionRevert.Intent = "当用户明确要把整篇文档恢复到某个历史版本时使用;先确认目标版本存在,再执行高风险回滚并读回验证。"
|
||||||
|
VersionRevert.Contract = docContract("+history-revert", VersionRevert.Description, VersionRevert.Intent, []string{`dws doc +history-revert --node <DOC_ID> --version 3`})
|
||||||
|
VersionRevert.Tips = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
|
||||||
|
VersionRevert.Execute = executeHistoryRevert
|
||||||
|
|
||||||
|
TemplateList.Description = "浏览当前用户可用的 MY/PUBLIC 文档模板"
|
||||||
|
TemplateList.Intent = "当用户要浏览自己的或公开的文档模板并获取 templateId 时使用;若已知名称可改用 template-search。"
|
||||||
|
TemplateList.Contract = docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`})
|
||||||
|
TemplateSearch.Description = "按名称检索文档模板"
|
||||||
|
TemplateSearch.Intent = "当用户知道模板名称关键词、要快速定位唯一 templateId 后继续创建文档时使用。"
|
||||||
|
TemplateSearch.Contract = docContract("+template-search", TemplateSearch.Description, TemplateSearch.Intent, []string{`dws doc +template-search --query "周报"`})
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeCommentShortcuts() {
|
||||||
|
// Preserve the historical confirmation contract for comment writes. The
|
||||||
|
// richer canonical implementations must not silently weaken that gate.
|
||||||
|
CommentCreate.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
|
||||||
|
CommentReply.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
|
||||||
|
CommentCreate.Description = "创建全文评论,或按 selection 创建划词评论"
|
||||||
|
CommentCreate.Intent = "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。"
|
||||||
|
CommentCreate.Contract = docContract("+comment-create", CommentCreate.Description, CommentCreate.Intent, []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`})
|
||||||
|
CommentCreate.Flags = []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true},
|
||||||
|
{Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀 selection;使用时不能为空且必须唯一匹配"},
|
||||||
|
{Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;使用时不能为空且须与 start/end 一起提供"},
|
||||||
|
{Name: "start", Type: shortcut.FlagInt, Desc: "块内起始字符偏移;高级通道参数不能为空且须一起提供"},
|
||||||
|
{Name: "end", Type: shortcut.FlagInt, Desc: "块内结束字符偏移;高级通道参数不能为空且须一起提供"},
|
||||||
|
{Name: "selected-text", Type: shortcut.FlagString, Desc: "高级通道引用原文"},
|
||||||
|
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
|
||||||
|
}
|
||||||
|
CommentCreate.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: "selection/高级通道参数不能为空;selection 必须唯一匹配,block-id/start/end 必须一起提供"}}
|
||||||
|
CommentCreate.Validate = validateCommentCreate
|
||||||
|
CommentCreate.Execute = executeCommentCreate
|
||||||
|
CommentCreate.Tips = []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`}
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
|
||||||
|
nodeID := rt.Str("node")
|
||||||
|
target := rt.Int("version")
|
||||||
|
versions, err := rt.CallMCPData(productDoc, "list_doc_versions", map[string]any{"nodeId": nodeID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !containsVersion(versions, target) {
|
||||||
|
return apperrors.NewValidation(fmt.Sprintf("目标版本 %d 不存在,已停止回滚", target))
|
||||||
|
}
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"executed": false, "nodeId": nodeID, "version": target, "preflight": "version_exists"}))
|
||||||
|
}
|
||||||
|
if _, err := rt.CallMCPWriteData(productDoc, "revert_doc_version", map[string]any{"nodeId": nodeID, "version": target}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
current, err := rt.CallMCPData(productDoc, "get_document_info", map[string]any{"nodeId": nodeID})
|
||||||
|
if err != nil {
|
||||||
|
return docPartialWriteError(
|
||||||
|
"doc.history_revert", "doc_history_revert_verification_failed", "verify", fmt.Sprintf("版本 %d 已回滚,但读回验证失败(nodeId=%s);不要直接重试回滚", target, nodeID), err,
|
||||||
|
map[string]any{"nodeId": nodeID, "version": target, "reverted": true},
|
||||||
|
[]map[string]any{
|
||||||
|
{"name": "preflight", "status": "success"},
|
||||||
|
{"name": "revert", "status": "success"},
|
||||||
|
{"name": "verify", "status": "failed"},
|
||||||
|
},
|
||||||
|
map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"version": target, "current": current},
|
||||||
|
map[string]any{"name": "preflight", "status": "success"},
|
||||||
|
map[string]any{"name": "revert", "status": "success"},
|
||||||
|
map[string]any{"name": "verify", "status": "success"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsVersion(value any, target int) bool {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, child := range typed {
|
||||||
|
normalized := strings.ToLower(strings.ReplaceAll(key, "_", ""))
|
||||||
|
if normalized == "version" || normalized == "versionnumber" || normalized == "revision" {
|
||||||
|
switch number := child.(type) {
|
||||||
|
case float64:
|
||||||
|
if int(number) == target && number == float64(target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(number))
|
||||||
|
if err == nil && parsed == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if containsVersion(child, target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
if containsVersion(child, target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var CreateFromTemplate = shortcut.Shortcut{
|
||||||
|
Service: "doc",
|
||||||
|
Command: "+create-from-template",
|
||||||
|
Product: productDoc,
|
||||||
|
Description: "按 templateId 直达或搜索消歧后创建文档",
|
||||||
|
Intent: "当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
|
||||||
|
Risk: shortcut.RiskWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
|
||||||
|
Contract: docContract("+create-from-template", "按 templateId 直达或搜索消歧后创建文档",
|
||||||
|
"当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
|
||||||
|
[]string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`}),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "template-id", Type: shortcut.FlagString, Desc: "模板 ID"},
|
||||||
|
{Name: "query", Type: shortcut.FlagString, Desc: "模板搜索名称"},
|
||||||
|
{Name: "source", Type: shortcut.FlagString, Desc: "模板来源", Enum: []string{"MY", "PUBLIC"}},
|
||||||
|
{Name: "name", Type: shortcut.FlagString, Desc: "新文档名称"},
|
||||||
|
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
|
||||||
|
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
|
||||||
|
},
|
||||||
|
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"template-id", "query"}, Description: "--template-id 与 --query 必须且只能提供一个"}},
|
||||||
|
Tips: []string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
templateID := rt.Str("template-id")
|
||||||
|
if templateID == "" {
|
||||||
|
params := map[string]any{"searchName": rt.Str("query")}
|
||||||
|
if rt.Str("source") != "" {
|
||||||
|
params["templateSource"] = rt.Str("source")
|
||||||
|
}
|
||||||
|
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids := collectTemplateIDs(found)
|
||||||
|
if len(ids) != 1 {
|
||||||
|
return apperrors.NewValidation(fmt.Sprintf("模板搜索需要唯一命中,实际 %d 个候选: %v", len(ids), ids))
|
||||||
|
}
|
||||||
|
templateID = ids[0]
|
||||||
|
}
|
||||||
|
params := map[string]any{"templateId": templateID}
|
||||||
|
for flag, property := range map[string]string{"name": "name", "folder": "folderId", "workspace": "workspaceId"} {
|
||||||
|
if value := rt.Str(flag); value != "" {
|
||||||
|
params[property] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docEnvelope("doc.create_from_template", map[string]any{"executed": false, "params": params}))
|
||||||
|
}
|
||||||
|
result, err := rt.CallMCPWriteData(productDoc, "apply_doc_template", params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rt.Output(docEnvelope("doc.create_from_template", result, map[string]any{"name": "apply_template", "status": "success"}))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectTemplateIDs(value any) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
var walk func(any)
|
||||||
|
walk = func(current any) {
|
||||||
|
switch typed := current.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, child := range typed {
|
||||||
|
if strings.EqualFold(key, "templateId") || strings.EqualFold(key, "template_id") {
|
||||||
|
if id, ok := child.(string); ok && strings.TrimSpace(id) != "" && !seen[id] {
|
||||||
|
seen[id] = true
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(value)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
shortcut.Register(CreateFromTemplate)
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
package doc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||||
|
)
|
||||||
|
|
||||||
|
var MediaList = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+media-list", Product: productDoc,
|
||||||
|
Description: "列出文档正文中的图片和附件资源",
|
||||||
|
Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
|
||||||
|
Risk: shortcut.RiskRead,
|
||||||
|
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||||
|
Contract: docContract("+media-list", "列出文档正文中的图片和附件资源",
|
||||||
|
"当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
|
||||||
|
[]string{`dws doc +media-list --node <DOC_ID>`}),
|
||||||
|
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
|
||||||
|
Tips: []string{`dws doc +media-list --node <DOC_ID>`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "format": "element"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items := collectMediaItems(data)
|
||||||
|
return rt.Output(map[string]any{"count": len(items), "media": items})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var MediaInsert = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+media-insert", Product: productDoc,
|
||||||
|
Description: "上传本地图片或文件并插入文档正文",
|
||||||
|
Intent: "当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
|
||||||
|
Risk: shortcut.RiskWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||||
|
Contract: withDryRun(docContract("+media-insert", "上传本地图片或文件并插入文档正文",
|
||||||
|
"当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
|
||||||
|
[]string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`}), contract.DryRunPreviewPlan, false),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
|
||||||
|
{Name: "name", Type: shortcut.FlagString, Desc: "显示名称"},
|
||||||
|
{Name: "mime-type", Type: shortcut.FlagString, Desc: "MIME 类型"},
|
||||||
|
{Name: "index", Type: shortcut.FlagInt, Desc: "顶层插入索引"},
|
||||||
|
{Name: "where", Type: shortcut.FlagString, Desc: "相对参考块的位置", Enum: []string{"before", "after"}},
|
||||||
|
{Name: "ref-block", Type: shortcut.FlagString, Desc: "参考 block ID"},
|
||||||
|
},
|
||||||
|
Tips: []string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocMediaInsertShortcut(rt.Command()) },
|
||||||
|
}
|
||||||
|
|
||||||
|
var MediaDownload = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+media-download", Product: productDoc,
|
||||||
|
Description: "安全下载文档正文附件到工作目录",
|
||||||
|
Intent: "当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
|
||||||
|
Risk: shortcut.RiskRead,
|
||||||
|
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||||
|
Contract: docContract("+media-download", "安全下载文档正文附件到工作目录",
|
||||||
|
"当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
|
||||||
|
[]string{`dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./downloads/`}),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "resource-id", Type: shortcut.FlagString, Desc: "附件 resourceId", Required: true},
|
||||||
|
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内相对路径(文件或目录)"},
|
||||||
|
},
|
||||||
|
Validate: func(rt *shortcut.RuntimeContext) error { return localio.ValidateOutput(rt.Str("output")) },
|
||||||
|
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "--output 必须是工作目录内相对路径;默认 no-clobber"}},
|
||||||
|
Tips: []string{`dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./downloads/`},
|
||||||
|
Execute: executeMediaDownload,
|
||||||
|
}
|
||||||
|
|
||||||
|
var MediaPreview = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+media-preview", Product: productDoc,
|
||||||
|
Description: "下载正文媒体到受控临时目录并返回预览路径",
|
||||||
|
Intent: "当用户要临时查看文档附件或图片内容而不指定持久保存路径时使用;下载到独立临时目录并返回 artifact 路径。",
|
||||||
|
Risk: shortcut.RiskRead,
|
||||||
|
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||||
|
Contract: docContract("+media-preview", "下载正文媒体到受控临时目录并返回预览路径",
|
||||||
|
"当用户要临时查看文档附件或图片内容而不指定持久保存路径时使用;下载到独立临时目录并返回 artifact 路径。",
|
||||||
|
[]string{`dws doc +media-preview --node <DOC_ID> --resource-id <RESOURCE_ID>`}),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "resource-id", Type: shortcut.FlagString, Desc: "附件 resourceId", Required: true},
|
||||||
|
},
|
||||||
|
Tips: []string{`dws doc +media-preview --node <DOC_ID> --resource-id <RESOURCE_ID>`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"executed": false, "nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "output": "managed_temp_dir"}))
|
||||||
|
}
|
||||||
|
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir, err := docMkdirTemp("", "dws-doc-preview-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := downloadResolvedResource(rt, data, dir, ".")
|
||||||
|
if err != nil {
|
||||||
|
_ = docRemoveAll(dir)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"previewPath": result.AbsolutePath, "sizeBytes": result.SizeBytes}))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var ResourceUpdate = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+resource-update", Product: productDoc,
|
||||||
|
Description: "从本地图片或 HTTPS URL 设置文档封面",
|
||||||
|
Intent: "当用户要设置或替换文档顶部封面图时使用;本地图片会先上传,HTTPS URL 由服务端转存。",
|
||||||
|
Risk: shortcut.RiskWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
|
||||||
|
Contract: withDryRun(docContract("+resource-update", "从本地图片或 HTTPS URL 设置文档封面",
|
||||||
|
"当用户要设置或替换文档顶部封面图时使用;本地图片会先上传,HTTPS URL 由服务端转存。",
|
||||||
|
[]string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`}), contract.DryRunPreviewRequest, false),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "image", Type: shortcut.FlagString, Desc: "HTTPS 封面图片 URL"},
|
||||||
|
{Name: "file", Type: shortcut.FlagString, Desc: "本地封面图片"},
|
||||||
|
},
|
||||||
|
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"image", "file"}, Description: "--image 与 --file 必须且只能提供一个"}},
|
||||||
|
Tips: []string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocResourceUpdateShortcut(rt.Command()) },
|
||||||
|
}
|
||||||
|
|
||||||
|
var ResourceDownload = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+resource-download", Product: productDoc,
|
||||||
|
Description: "读取并安全下载当前文档封面",
|
||||||
|
Intent: "当用户要把当前文档封面保存到本地时使用;先读 style,必要时用 resourceId 换临时链接,再按安全本地下载策略保存。",
|
||||||
|
Risk: shortcut.RiskRead,
|
||||||
|
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||||
|
Contract: docContract("+resource-download", "读取并安全下载当前文档封面",
|
||||||
|
"当用户要把当前文档封面保存到本地时使用;先读 style,必要时用 resourceId 换临时链接,再按安全本地下载策略保存。",
|
||||||
|
[]string{`dws doc +resource-download --node <DOC_ID> --output ./cover.png`}),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内相对路径(文件或目录)"},
|
||||||
|
},
|
||||||
|
Validate: func(rt *shortcut.RuntimeContext) error { return localio.ValidateOutput(rt.Str("output")) },
|
||||||
|
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "--output 必须是工作目录内相对路径;默认 no-clobber"}},
|
||||||
|
Tips: []string{`dws doc +resource-download --node <DOC_ID> --output ./cover.png`},
|
||||||
|
Execute: executeResourceDownload,
|
||||||
|
}
|
||||||
|
|
||||||
|
var ResourceDelete = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+resource-delete", Product: productDoc,
|
||||||
|
Description: "幂等清除文档封面",
|
||||||
|
Intent: "当用户明确要移除文档当前封面时使用;发送 cover clear,重复执行保持无封面状态。",
|
||||||
|
Risk: shortcut.RiskHighWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "idempotent"},
|
||||||
|
Contract: docContract("+resource-delete", "幂等清除文档封面",
|
||||||
|
"当用户明确要移除文档当前封面时使用;发送 cover clear,重复执行保持无封面状态。",
|
||||||
|
[]string{`dws doc +resource-delete --node <DOC_ID>`}),
|
||||||
|
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
|
||||||
|
Tips: []string{`dws doc +resource-delete --node <DOC_ID>`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
params := map[string]any{"nodeId": rt.Str("node"), "cover": map[string]any{"action": "clear"}}
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docEnvelope("doc.resource_delete", map[string]any{"executed": false, "params": params}))
|
||||||
|
}
|
||||||
|
return rt.CallMCP("update_document_style", params)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var BackgroundUpdate = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+background-update", Product: productDoc,
|
||||||
|
Description: "设置文档 #RRGGBB 背景纯色",
|
||||||
|
Intent: "当用户要设置在线文档背景纯色时使用;只接受 #RRGGBB,不支持背景图片。",
|
||||||
|
Risk: shortcut.RiskWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||||
|
Contract: docContract("+background-update", "设置文档 #RRGGBB 背景纯色",
|
||||||
|
"当用户要设置在线文档背景纯色时使用;只接受 #RRGGBB,不支持背景图片。",
|
||||||
|
[]string{`dws doc +background-update --node <DOC_ID> --color "#E8F2FE"`}),
|
||||||
|
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, {Name: "color", Type: shortcut.FlagString, Desc: "#RRGGBB 背景色", Required: true}},
|
||||||
|
Tips: []string{`dws doc +background-update --node <DOC_ID> --color "#E8F2FE"`},
|
||||||
|
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
color := rt.Str("color")
|
||||||
|
if len(color) != 7 || color[0] != '#' {
|
||||||
|
return apperrors.NewValidation("--color 必须是 #RRGGBB")
|
||||||
|
}
|
||||||
|
for _, char := range color[1:] {
|
||||||
|
if !strings.ContainsRune("0123456789abcdefABCDEF", char) {
|
||||||
|
return apperrors.NewValidation("--color 必须是 #RRGGBB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"color"}, Description: "#RRGGBB"}},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
return rt.CallMCP("update_document_style", map[string]any{"nodeId": rt.Str("node"), "background": map[string]any{"action": "set", "backgroundColor": rt.Str("color")}})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var BackgroundDelete = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+background-delete", Product: productDoc,
|
||||||
|
Description: "清除文档背景色",
|
||||||
|
Intent: "当用户明确要恢复文档默认背景、移除当前背景色时使用;执行 background clear 并要求确认。",
|
||||||
|
Risk: shortcut.RiskWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
|
||||||
|
Contract: docContract("+background-delete", "清除文档背景色",
|
||||||
|
"当用户明确要恢复文档默认背景、移除当前背景色时使用;执行 background clear 并要求确认。",
|
||||||
|
[]string{`dws doc +background-delete --node <DOC_ID>`}),
|
||||||
|
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
|
||||||
|
Tips: []string{`dws doc +background-delete --node <DOC_ID>`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
params := map[string]any{"nodeId": rt.Str("node"), "background": map[string]any{"action": "clear"}}
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docEnvelope("doc.background_delete", map[string]any{"executed": false, "params": params}))
|
||||||
|
}
|
||||||
|
return rt.CallMCP("update_document_style", params)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeMediaDownload(rt *shortcut.RuntimeContext) error {
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docEnvelope("doc.media_download", map[string]any{"executed": false, "nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "output": rt.Str("output")}))
|
||||||
|
}
|
||||||
|
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cwd, err := docGetwd()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := downloadResolvedResource(rt, data, cwd, rt.Str("output"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rt.Output(docEnvelope("doc.media_download", map[string]any{"resourceId": rt.Str("resource-id"), "localPath": result.RelativePath, "sizeBytes": result.SizeBytes}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeResourceDownload(rt *shortcut.RuntimeContext) error {
|
||||||
|
style, err := rt.CallMCPData(productDoc, "get_document_style", map[string]any{"nodeId": rt.Str("node")})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docEnvelope("doc.resource_download", map[string]any{"executed": false, "styleResolved": true, "output": rt.Str("output")}))
|
||||||
|
}
|
||||||
|
resourceURL := nestedStringDeep(style, "imageUrl", "resourceUrl", "downloadUrl", "url")
|
||||||
|
resourceID := nestedStringDeep(style, "resourceId")
|
||||||
|
data := style
|
||||||
|
if resourceURL == "" && resourceID != "" {
|
||||||
|
data, err = rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": resourceID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if resourceURL != "" {
|
||||||
|
data = map[string]any{"downloadUrl": resourceURL}
|
||||||
|
}
|
||||||
|
if nestedStringDeep(data, "downloadUrl", "resourceUrl", "imageUrl", "url") == "" {
|
||||||
|
return apperrors.NewAPI("当前文档没有可下载的封面,或 style 响应缺少资源地址")
|
||||||
|
}
|
||||||
|
cwd, err := docGetwd()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := downloadResolvedResource(rt, data, cwd, rt.Str("output"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rt.Output(docEnvelope("doc.resource_download", map[string]any{"localPath": result.RelativePath, "sizeBytes": result.SizeBytes}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadResolvedResource(rt *shortcut.RuntimeContext, data map[string]any, baseDir, output string) (localio.DownloadResult, error) {
|
||||||
|
resourceURL := nestedStringDeep(data, "downloadUrl", "resourceUrl", "imageUrl", "url")
|
||||||
|
if resourceURL == "" {
|
||||||
|
return localio.DownloadResult{}, apperrors.NewAPI("附件下载响应缺少 downloadUrl/resourceUrl")
|
||||||
|
}
|
||||||
|
headers := map[string]string{}
|
||||||
|
if raw := nestedMap(data)["headers"]; raw != nil {
|
||||||
|
if values, ok := raw.(map[string]any); ok {
|
||||||
|
for key, value := range values {
|
||||||
|
if text, ok := value.(string); ok {
|
||||||
|
headers[key] = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return docDownload(rt.Command().Context(), resourceURL, localio.DownloadOptions{BaseDir: baseDir, Output: output, PreferredName: nestedStringDeep(data, "fileName", "name"), Headers: headers})
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectMediaItems(value any) []map[string]any {
|
||||||
|
var out []map[string]any
|
||||||
|
var walk func(any, string)
|
||||||
|
walk = func(current any, inheritedID string) {
|
||||||
|
switch typed := current.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
blockID := blockIdentity(typed, inheritedID)
|
||||||
|
resourceID := fmt.Sprint(typed["resourceId"])
|
||||||
|
resourceURL := ""
|
||||||
|
for _, key := range []string{"resourceUrl", "src", "imageUrl", "downloadUrl"} {
|
||||||
|
if text, ok := typed[key].(string); ok && text != "" {
|
||||||
|
resourceURL = text
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resourceID != "" && resourceID != "<nil>") || resourceURL != "" {
|
||||||
|
row := map[string]any{"blockId": blockID}
|
||||||
|
if resourceID != "" && resourceID != "<nil>" {
|
||||||
|
row["resourceId"] = resourceID
|
||||||
|
}
|
||||||
|
if resourceURL != "" {
|
||||||
|
row["resourceUrl"] = resourceURL
|
||||||
|
}
|
||||||
|
for _, key := range []string{"name", "type", "mimeType", "viewType"} {
|
||||||
|
if value, ok := typed[key]; ok {
|
||||||
|
row[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
for _, child := range typed {
|
||||||
|
walk(child, blockID)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
walk(child, inheritedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(value, "")
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestedStringDeep(value any, keys ...string) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for _, key := range keys {
|
||||||
|
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range typed {
|
||||||
|
if found := nestedStringDeep(child, keys...); found != "" {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
if found := nestedStringDeep(child, keys...); found != "" {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
shortcut.Register(MediaList, MediaInsert, MediaDownload, MediaPreview, ResourceUpdate, ResourceDownload, ResourceDelete, BackgroundUpdate, BackgroundDelete)
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
package doc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf16"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Review = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+review", Product: productComment,
|
||||||
|
Description: "聚合未解决评论、引用原文和块上下文",
|
||||||
|
Intent: "当用户要确定性查看一篇文档仍待处理的 review 意见时使用;聚合 unresolved 评论与 block 上下文,不调用模型生成总结。",
|
||||||
|
Risk: shortcut.RiskRead,
|
||||||
|
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||||
|
Contract: docContract("+review", "聚合未解决评论、引用原文和块上下文",
|
||||||
|
"当用户要确定性查看一篇文档仍待处理的 review 意见时使用;聚合 unresolved 评论与 block 上下文,不调用模型生成总结。",
|
||||||
|
[]string{`dws doc +review --node <DOC_ID>`}),
|
||||||
|
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
|
||||||
|
Tips: []string{`dws doc +review --node <DOC_ID>`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
node := rt.Str("node")
|
||||||
|
comments, err := rt.CallMCPData(productComment, "list_comments", map[string]any{"nodeId": node, "resolveStatus": "unresolved"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": node, "format": "element"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items := projectReviewComments(comments, blocks)
|
||||||
|
global, inline := 0, 0
|
||||||
|
for _, item := range items {
|
||||||
|
if item["blockId"] == "" {
|
||||||
|
global++
|
||||||
|
} else {
|
||||||
|
inline++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rt.Output(map[string]any{"status": "unresolved", "counts": map[string]any{"total": len(items), "global": global, "inline": inline}, "comments": items})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var CommentUpdate = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+comment-update", Product: productComment,
|
||||||
|
Description: "更新指定文档评论正文和 mention",
|
||||||
|
Intent: "当用户要修改一条已有评论的文字内容或 @ 用户列表,且已知 commentKey 时使用;不会创建回复或改变解决状态。",
|
||||||
|
Risk: shortcut.RiskWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
|
||||||
|
Contract: docContract("+comment-update", "更新指定文档评论正文和 mention",
|
||||||
|
"当用户要修改一条已有评论的文字内容或 @ 用户列表,且已知 commentKey 时使用;不会创建回复或改变解决状态。",
|
||||||
|
[]string{`dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`}),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "comment-key", Type: shortcut.FlagString, Desc: "评论 commentKey", Required: true},
|
||||||
|
{Name: "content", Type: shortcut.FlagString, Desc: "更新后的评论正文", Required: true},
|
||||||
|
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
|
||||||
|
},
|
||||||
|
Tips: []string{`dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
params := map[string]any{"nodeId": rt.Str("node"), "commentKey": rt.Str("comment-key"), "content": rt.Str("content")}
|
||||||
|
if rt.Changed("mention") {
|
||||||
|
params["mentionedUserIds"] = rt.StrSlice("mention")
|
||||||
|
}
|
||||||
|
return rt.CallMCP("update_comment", params)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var CommentDelete = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+comment-delete", Product: productComment,
|
||||||
|
Description: "永久删除指定文档评论",
|
||||||
|
Intent: "当用户明确要求永久删除某条文档评论,且已核对 node 与 commentKey 时使用;不可用于标记 resolved。",
|
||||||
|
Risk: shortcut.RiskHighWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"},
|
||||||
|
Contract: docContract("+comment-delete", "永久删除指定文档评论",
|
||||||
|
"当用户明确要求永久删除某条文档评论,且已核对 node 与 commentKey 时使用;不可用于标记 resolved。",
|
||||||
|
[]string{`dws doc +comment-delete --node <DOC_ID> --comment-key <COMMENT_KEY>`}),
|
||||||
|
Flags: []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "comment-key", Type: shortcut.FlagString, Desc: "评论 commentKey", Required: true},
|
||||||
|
},
|
||||||
|
Tips: []string{`dws doc +comment-delete --node <DOC_ID> --comment-key <COMMENT_KEY>`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
params := map[string]any{"nodeId": rt.Str("node"), "commentKey": rt.Str("comment-key")}
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docEnvelope("doc.comment_delete", map[string]any{"executed": false, "params": params}))
|
||||||
|
}
|
||||||
|
return rt.CallMCP("delete_comment", params)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCommentCreate(rt *shortcut.RuntimeContext) error {
|
||||||
|
hasBlock := rt.Str("block-id") != ""
|
||||||
|
hasOffsets := rt.Changed("start") || rt.Changed("end")
|
||||||
|
if hasBlock != hasOffsets || (hasOffsets && (!rt.Changed("start") || !rt.Changed("end"))) {
|
||||||
|
return apperrors.NewValidation("--block-id、--start、--end 必须一起提供")
|
||||||
|
}
|
||||||
|
if hasBlock && rt.Int("end") <= rt.Int("start") {
|
||||||
|
return apperrors.NewValidation("--end 必须大于 --start")
|
||||||
|
}
|
||||||
|
if hasBlock && rt.Str("selection") != "" {
|
||||||
|
return apperrors.NewValidation("--selection 与 block-id/start/end 高级通道不能同时提供")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeCommentCreate(rt *shortcut.RuntimeContext) error {
|
||||||
|
params := map[string]any{"nodeId": rt.Str("node"), "content": rt.Str("content")}
|
||||||
|
if rt.Changed("mention") {
|
||||||
|
params["mentionedUserIds"] = rt.StrSlice("mention")
|
||||||
|
}
|
||||||
|
if rt.Str("block-id") != "" {
|
||||||
|
params["blockId"], params["start"], params["end"] = rt.Str("block-id"), rt.Int("start"), rt.Int("end")
|
||||||
|
if rt.Str("selected-text") != "" {
|
||||||
|
params["selectedText"] = rt.Str("selected-text")
|
||||||
|
}
|
||||||
|
return rt.CallMCP("create_inline_comment", params)
|
||||||
|
}
|
||||||
|
if selection := rt.Str("selection"); selection != "" {
|
||||||
|
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "format": "element"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
matches := findSelectionMatches(blocks, selection)
|
||||||
|
if len(matches) != 1 {
|
||||||
|
candidates := make([]map[string]any, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
candidates = append(candidates, map[string]any{"blockId": match.blockID, "excerpt": match.text})
|
||||||
|
}
|
||||||
|
return apperrors.NewValidation(fmt.Sprintf("AMBIGUOUS_SELECTION: selection 需要唯一匹配,实际 %d 处;候选=%v", len(matches), candidates))
|
||||||
|
}
|
||||||
|
params["blockId"], params["start"], params["end"], params["selectedText"] = matches[0].blockID, matches[0].start, matches[0].end, matches[0].selected
|
||||||
|
return rt.CallMCP("create_inline_comment", params)
|
||||||
|
}
|
||||||
|
return rt.CallMCP("create_comment", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
type selectionMatch struct {
|
||||||
|
blockID string
|
||||||
|
text, selected string
|
||||||
|
start, end int
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSelectionMatches(value any, selection string) []selectionMatch {
|
||||||
|
if selection == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var prefix, suffix string
|
||||||
|
omitted := false
|
||||||
|
if parts := strings.SplitN(selection, "...", 2); len(parts) == 2 {
|
||||||
|
prefix, suffix = parts[0], parts[1]
|
||||||
|
omitted = true
|
||||||
|
}
|
||||||
|
var out []selectionMatch
|
||||||
|
appendMatch := func(blockID, text string, startByte, endByte int) {
|
||||||
|
out = append(out, selectionMatch{
|
||||||
|
blockID: blockID, text: text, selected: text[startByte:endByte],
|
||||||
|
start: utf16Length(text[:startByte]), end: utf16Length(text[:endByte]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var walk func(any, string)
|
||||||
|
walk = func(current any, inheritedID string) {
|
||||||
|
switch typed := current.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
blockID := blockIdentity(typed, inheritedID)
|
||||||
|
if text, ok := typed["text"].(string); ok && blockID != "" {
|
||||||
|
if !omitted {
|
||||||
|
for searchStart := 0; searchStart < len(text); {
|
||||||
|
index := strings.Index(text[searchStart:], selection)
|
||||||
|
if index < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
startByte := searchStart + index
|
||||||
|
endByte := startByte + len(selection)
|
||||||
|
appendMatch(blockID, text, startByte, endByte)
|
||||||
|
searchStart = startByte + 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
prefixStarts := []int{0}
|
||||||
|
if prefix != "" {
|
||||||
|
prefixStarts = nil
|
||||||
|
for searchStart := 0; searchStart < len(text); {
|
||||||
|
index := strings.Index(text[searchStart:], prefix)
|
||||||
|
if index < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
startByte := searchStart + index
|
||||||
|
prefixStarts = append(prefixStarts, startByte)
|
||||||
|
searchStart = startByte + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, startByte := range prefixStarts {
|
||||||
|
suffixSearch := startByte + len(prefix)
|
||||||
|
if suffix == "" {
|
||||||
|
if startByte < len(text) {
|
||||||
|
appendMatch(blockID, text, startByte, len(text))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for suffixSearch <= len(text) {
|
||||||
|
index := strings.Index(text[suffixSearch:], suffix)
|
||||||
|
if index < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
suffixStart := suffixSearch + index
|
||||||
|
appendMatch(blockID, text, startByte, suffixStart+len(suffix))
|
||||||
|
suffixSearch = suffixStart + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range typed {
|
||||||
|
walk(child, blockID)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
walk(child, inheritedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(value, "")
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func utf16Length(value string) int {
|
||||||
|
return len(utf16.Encode([]rune(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectReviewComments(comments, blocks map[string]any) []map[string]any {
|
||||||
|
blockText := map[string]string{}
|
||||||
|
collectBlockText(blocks, "", blockText)
|
||||||
|
var out []map[string]any
|
||||||
|
var walk func(any)
|
||||||
|
walk = func(value any) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
key := firstString(typed, "commentKey", "commentId")
|
||||||
|
if key != "" {
|
||||||
|
blockID := firstString(typed, "blockId")
|
||||||
|
selectedText := firstString(typed, "selectedText", "quote")
|
||||||
|
// list_comments currently omits blockId for inline comments but
|
||||||
|
// includes isGlobal=false plus the selected quote. Resolve the
|
||||||
|
// block deterministically when that quote is unique.
|
||||||
|
if blockID == "" && selectedText != "" {
|
||||||
|
if matches := findSelectionMatches(blocks, selectedText); len(matches) == 1 {
|
||||||
|
blockID = matches[0].blockID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"commentKey": key,
|
||||||
|
"content": firstString(typed, "content", "text"),
|
||||||
|
"selectedText": selectedText,
|
||||||
|
"blockId": blockID,
|
||||||
|
"context": blockText[blockID],
|
||||||
|
"replies": typed["replies"],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, child := range typed {
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(comments)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectBlockText(value any, inheritedID string, out map[string]string) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
blockID := blockIdentity(typed, inheritedID)
|
||||||
|
if text, ok := typed["text"].(string); ok && blockID != "" {
|
||||||
|
out[blockID] = text
|
||||||
|
}
|
||||||
|
for _, child := range typed {
|
||||||
|
collectBlockText(child, blockID, out)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range typed {
|
||||||
|
collectBlockText(child, inheritedID, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstString(values map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if text, ok := values[key].(string); ok {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
shortcut.Register(Review, CommentUpdate, CommentDelete)
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
|||||||
"chat\u0000+chat-dismiss": {},
|
"chat\u0000+chat-dismiss": {},
|
||||||
"chat\u0000+chat-get-by-id": {},
|
"chat\u0000+chat-get-by-id": {},
|
||||||
"chat\u0000+chat-invite-url": {},
|
"chat\u0000+chat-invite-url": {},
|
||||||
|
"chat\u0000+chat-list": {},
|
||||||
"chat\u0000+chat-list-all": {},
|
"chat\u0000+chat-list-all": {},
|
||||||
"chat\u0000+chat-list-join-requests": {},
|
"chat\u0000+chat-list-join-requests": {},
|
||||||
"chat\u0000+chat-list-mine": {},
|
"chat\u0000+chat-list-mine": {},
|
||||||
@@ -208,20 +209,48 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
|||||||
"ding\u0000+recall-personal": {},
|
"ding\u0000+recall-personal": {},
|
||||||
"ding\u0000+receiver-status": {},
|
"ding\u0000+receiver-status": {},
|
||||||
"ding\u0000+send-personal": {},
|
"ding\u0000+send-personal": {},
|
||||||
|
"doc\u0000+access-change": {},
|
||||||
|
"doc\u0000+access-grant": {},
|
||||||
|
"doc\u0000+access-revoke": {},
|
||||||
|
"doc\u0000+background-delete": {},
|
||||||
|
"doc\u0000+background-update": {},
|
||||||
|
"doc\u0000+checkpoint-update": {},
|
||||||
"doc\u0000+comment-create": {},
|
"doc\u0000+comment-create": {},
|
||||||
|
"doc\u0000+comment-delete": {},
|
||||||
"doc\u0000+comment-list": {},
|
"doc\u0000+comment-list": {},
|
||||||
"doc\u0000+comment-reply": {},
|
"doc\u0000+comment-reply": {},
|
||||||
|
"doc\u0000+comment-update": {},
|
||||||
"doc\u0000+copy": {},
|
"doc\u0000+copy": {},
|
||||||
|
"doc\u0000+create": {},
|
||||||
|
"doc\u0000+create-from-template": {},
|
||||||
"doc\u0000+doc-append": {},
|
"doc\u0000+doc-append": {},
|
||||||
|
"doc\u0000+export": {},
|
||||||
"doc\u0000+export-get": {},
|
"doc\u0000+export-get": {},
|
||||||
"doc\u0000+export-submit": {},
|
"doc\u0000+export-submit": {},
|
||||||
|
"doc\u0000+fetch": {},
|
||||||
"doc\u0000+find-doc": {},
|
"doc\u0000+find-doc": {},
|
||||||
|
"doc\u0000+grant-and-share": {},
|
||||||
|
"doc\u0000+history-list": {},
|
||||||
|
"doc\u0000+history-revert": {},
|
||||||
|
"doc\u0000+history-save": {},
|
||||||
|
"doc\u0000+import": {},
|
||||||
|
"doc\u0000+inspect": {},
|
||||||
"doc\u0000+list": {},
|
"doc\u0000+list": {},
|
||||||
|
"doc\u0000+media-download": {},
|
||||||
|
"doc\u0000+media-insert": {},
|
||||||
|
"doc\u0000+media-list": {},
|
||||||
|
"doc\u0000+media-preview": {},
|
||||||
"doc\u0000+move": {},
|
"doc\u0000+move": {},
|
||||||
|
"doc\u0000+resource-delete": {},
|
||||||
|
"doc\u0000+resource-download": {},
|
||||||
|
"doc\u0000+resource-update": {},
|
||||||
|
"doc\u0000+review": {},
|
||||||
"doc\u0000+search": {},
|
"doc\u0000+search": {},
|
||||||
|
"doc\u0000+share": {},
|
||||||
"doc\u0000+share-doc": {},
|
"doc\u0000+share-doc": {},
|
||||||
"doc\u0000+template-list": {},
|
"doc\u0000+template-list": {},
|
||||||
"doc\u0000+template-search": {},
|
"doc\u0000+template-search": {},
|
||||||
|
"doc\u0000+update": {},
|
||||||
"doc\u0000+version-list": {},
|
"doc\u0000+version-list": {},
|
||||||
"doc\u0000+version-revert": {},
|
"doc\u0000+version-revert": {},
|
||||||
"doc\u0000+version-save": {},
|
"doc\u0000+version-save": {},
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
//go:embed semantic_catalog.json
|
//go:embed semantic_catalog.json
|
||||||
var semanticCatalogJSON []byte
|
var semanticCatalogJSON []byte
|
||||||
|
|
||||||
|
//go:embed semantic_catalog_doc.json
|
||||||
|
var docSemanticCatalogJSON []byte
|
||||||
|
|
||||||
type semanticCatalogFile struct {
|
type semanticCatalogFile struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Service string `json:"service"`
|
Service string `json:"service"`
|
||||||
@@ -30,17 +33,34 @@ type semanticCatalogRecord struct {
|
|||||||
Reviewed bool `json:"reviewed"`
|
Reviewed bool `json:"reviewed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var reviewedSemanticCatalog = mustLoadSemanticCatalog()
|
var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||||
|
semanticCatalogJSON,
|
||||||
|
docSemanticCatalogJSON,
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
|
||||||
|
out := make(map[string]semanticCatalogRecord)
|
||||||
|
for _, raw := range sources {
|
||||||
|
loadSemanticCatalog(raw, out)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustLoadSemanticCatalog is retained for focused validation tests of the
|
||||||
|
// legacy single-source loader. Production loads every reviewed product source
|
||||||
|
// through mustLoadSemanticCatalogs above.
|
||||||
func mustLoadSemanticCatalog() map[string]semanticCatalogRecord {
|
func mustLoadSemanticCatalog() map[string]semanticCatalogRecord {
|
||||||
|
return mustLoadSemanticCatalogs(semanticCatalogJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) {
|
||||||
var source semanticCatalogFile
|
var source semanticCatalogFile
|
||||||
if err := json.Unmarshal(semanticCatalogJSON, &source); err != nil {
|
if err := json.Unmarshal(raw, &source); err != nil {
|
||||||
panic(fmt.Sprintf("invalid shortcut semantic catalog: %v", err))
|
panic(fmt.Sprintf("invalid shortcut semantic catalog: %v", err))
|
||||||
}
|
}
|
||||||
if source.Version != 1 || strings.TrimSpace(source.Service) == "" {
|
if source.Version != 1 || strings.TrimSpace(source.Service) == "" {
|
||||||
panic("invalid shortcut semantic catalog header")
|
panic("invalid shortcut semantic catalog header")
|
||||||
}
|
}
|
||||||
out := make(map[string]semanticCatalogRecord, len(source.Shortcuts))
|
|
||||||
for command, record := range source.Shortcuts {
|
for command, record := range source.Shortcuts {
|
||||||
if !strings.HasPrefix(command, "+") {
|
if !strings.HasPrefix(command, "+") {
|
||||||
panic(fmt.Sprintf("semantic catalog command %q lacks + prefix", command))
|
panic(fmt.Sprintf("semantic catalog command %q lacks + prefix", command))
|
||||||
@@ -76,9 +96,12 @@ func mustLoadSemanticCatalog() map[string]semanticCatalogRecord {
|
|||||||
panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q",
|
panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q",
|
||||||
command, record.Availability))
|
command, record.Availability))
|
||||||
}
|
}
|
||||||
out[publicCatalogKey(source.Service, command)] = record
|
key := publicCatalogKey(source.Service, command)
|
||||||
|
if _, exists := out[key]; exists {
|
||||||
|
panic(fmt.Sprintf("duplicate shortcut semantic catalog entry %s %s", source.Service, command))
|
||||||
|
}
|
||||||
|
out[key] = record
|
||||||
}
|
}
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) {
|
func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) {
|
||||||
|
|||||||
@@ -137,6 +137,29 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageSemanticCatalogRejectsCrossSourceDuplicates(t *testing.T) {
|
||||||
|
valid := []byte(`{
|
||||||
|
"version": 1,
|
||||||
|
"service": "duplicate-test",
|
||||||
|
"default_availability": "available",
|
||||||
|
"shortcuts": {
|
||||||
|
"+same": {
|
||||||
|
"disposition": "semantic_adapter",
|
||||||
|
"semantic_delta": "reviewed",
|
||||||
|
"risk": "read",
|
||||||
|
"public": true,
|
||||||
|
"reviewed": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Fatal("duplicate semantic records did not panic")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_ = mustLoadSemanticCatalogs(valid, valid)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoveragePublicCatalogSemanticAndGeneratedLookups(t *testing.T) {
|
func TestCrossPlatformCoveragePublicCatalogSemanticAndGeneratedLookups(t *testing.T) {
|
||||||
if !InPublicCatalog("chat", "+messages-send") {
|
if !InPublicCatalog("chat", "+messages-send") {
|
||||||
t.Fatal("reviewed public semantic shortcut is missing")
|
t.Fatal("reviewed public semantic shortcut is missing")
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"service": "doc",
|
||||||
|
"default_availability": "available",
|
||||||
|
"shortcuts": {
|
||||||
|
"+search": {"disposition":"semantic_adapter","semantic_delta":"统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。","risk":"read","public":true,"reviewed":true},
|
||||||
|
"+create": {"disposition":"semantic_adapter","semantic_delta":"统一 Markdown/JSONML 内容输入、目标位置与创建后保真写入。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+fetch": {"disposition":"semantic_adapter","semantic_delta":"统一 simple/with-ids/full 细节层级与 full/outline/range/section/keyword/tags 局部读取。","risk":"read","public":true,"reviewed":true},
|
||||||
|
"+inspect": {"disposition":"primary_smart","semantic_delta":"聚合文档元信息,并按需读取样式、权限、历史、媒体和评论。","risk":"read","public":true,"reviewed":true},
|
||||||
|
"+update": {"disposition":"primary_smart","semantic_delta":"统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+checkpoint-update": {"disposition":"primary_smart","semantic_delta":"写入前保存版本快照,更新后读回验证并输出逐步 ledger。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+export": {"disposition":"primary_smart","semantic_delta":"一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。","risk":"read","public":true,"reviewed":true},
|
||||||
|
"+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+history-save": {"disposition":"semantic_adapter","semantic_delta":"以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+history-list": {"disposition":"semantic_adapter","semantic_delta":"统一历史版本分页参数并返回可用于回滚的版本列表。","risk":"read","public":true,"reviewed":true},
|
||||||
|
"+history-revert": {"disposition":"primary_smart","semantic_delta":"先验证目标版本存在,再执行回滚并读回当前文档状态。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||||
|
"+template-list": {"disposition":"semantic_adapter","semantic_delta":"统一 MY/PUBLIC 模板浏览和分页参数。","risk":"read","public":true,"reviewed":true},
|
||||||
|
"+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称检索模板并返回可继续创建的 templateId。","risk":"read","public":true,"reviewed":true},
|
||||||
|
"+create-from-template": {"disposition":"primary_smart","semantic_delta":"支持 templateId 直达或按名称搜索消歧后创建文档。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+media-list": {"disposition":"semantic_adapter","semantic_delta":"从文档块中提取图片、附件及其 block/resource 标识。","risk":"read","public":true,"reviewed":true},
|
||||||
|
"+media-insert": {"disposition":"primary_smart","semantic_delta":"组合本地文件校验、上传凭证、OSS PUT、插块和验证。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+media-download": {"disposition":"primary_smart","semantic_delta":"解析附件临时链接并通过受控相对路径、no-clobber、原子发布安全下载。","risk":"read","public":true,"reviewed":true},
|
||||||
|
"+media-preview": {"disposition":"primary_smart","semantic_delta":"将正文媒体下载到受控临时目录并返回本地预览 artifact。","risk":"read","public":true,"reviewed":true},
|
||||||
|
"+resource-update": {"disposition":"primary_smart","semantic_delta":"支持本地图片或 HTTPS 图片转存后设置文档封面。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+resource-download": {"disposition":"primary_smart","semantic_delta":"读取当前文档封面配置并安全下载资源到本地。","risk":"read","public":true,"reviewed":true},
|
||||||
|
"+resource-delete": {"disposition":"semantic_adapter","semantic_delta":"以幂等 clear 语义移除当前文档封面。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||||
|
"+background-update": {"disposition":"semantic_adapter","semantic_delta":"校验并设置 #RRGGBB 文档背景纯色。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+background-delete": {"disposition":"semantic_adapter","semantic_delta":"以 clear 语义移除文档背景色。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+comment-list": {"disposition":"semantic_adapter","semantic_delta":"统一评论类型、解决状态与分页过滤。","risk":"read","public":true,"reviewed":true},
|
||||||
|
"+review": {"disposition":"primary_smart","semantic_delta":"聚合未解决评论、划词引用和确定性上下文,不调用模型生成总结。","risk":"read","public":true,"reviewed":true},
|
||||||
|
"+comment-create": {"disposition":"primary_smart","semantic_delta":"无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+comment-reply": {"disposition":"semantic_adapter","semantic_delta":"统一评论回复、表情回复和 mention 参数。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+comment-update": {"disposition":"semantic_adapter","semantic_delta":"更新指定评论正文与 mention。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+comment-delete": {"disposition":"semantic_adapter","semantic_delta":"永久删除指定评论,并由静态安全契约强制确认。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||||
|
"+access-grant": {"disposition":"primary_smart","semantic_delta":"在第一次写入前解析全部接收人,再批量授予文档权限并输出逐项 ledger。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+access-change": {"disposition":"primary_smart","semantic_delta":"读取当前权限后再变更角色,避免把不存在的协作者当作成功更新。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+access-revoke": {"disposition":"primary_smart","semantic_delta":"预检目标协作者权限后移除并输出逐项结果。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||||
|
"+share": {"disposition":"primary_smart","semantic_delta":"按姓名解析唯一用户后发送文档链接,不改变文档权限。","risk":"write","public":true,"reviewed":true},
|
||||||
|
"+grant-and-share": {"disposition":"primary_smart","semantic_delta":"先确保目标角色,再发送链接;消息失败保留逐人 ledger,并以非零退出报告 failed/partial_success。","risk":"write","public":true,"reviewed":true},
|
||||||
|
|
||||||
|
"+find-doc": {"disposition":"alias_internal","semantic_delta":"保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。","risk":"read","primary":"+search","public":true,"reviewed":true},
|
||||||
|
"+doc-append": {"disposition":"alias_internal","semantic_delta":"保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。","risk":"write","primary":"+update","public":true,"reviewed":true},
|
||||||
|
"+version-save": {"disposition":"alias_internal","semantic_delta":"保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。","risk":"write","primary":"+history-save","public":true,"reviewed":true},
|
||||||
|
"+version-list": {"disposition":"alias_internal","semantic_delta":"保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。","risk":"read","primary":"+history-list","public":true,"reviewed":true},
|
||||||
|
"+version-revert": {"disposition":"alias_internal","semantic_delta":"保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。","risk":"high-risk-write","primary":"+history-revert","public":true,"reviewed":true},
|
||||||
|
"+share-doc": {"disposition":"alias_internal","semantic_delta":"保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。","risk":"write","primary":"+share","public":true,"reviewed":true},
|
||||||
|
|
||||||
|
"+list": {"disposition":"alias_internal","semantic_delta":"旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。","risk":"read","primary":"drive list","public":true,"reviewed":true},
|
||||||
|
"+copy": {"disposition":"alias_internal","semantic_delta":"旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。","risk":"write","primary":"drive copy","public":true,"reviewed":true},
|
||||||
|
"+move": {"disposition":"alias_internal","semantic_delta":"旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。","risk":"write","primary":"drive move","public":true,"reviewed":true},
|
||||||
|
"+export-submit": {"disposition":"alias_internal","semantic_delta":"导出中断恢复所需的专家入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
|
||||||
|
"+export-get": {"disposition":"alias_internal","semantic_delta":"按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
|
||||||
|
"+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"已知 block/start/end 时使用的低级批注入口;常规场景使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true},
|
||||||
|
"+template-apply": {"disposition":"alias_internal","semantic_delta":"已知 templateId 时使用的低级入口;常规场景使用 +create-from-template。","risk":"write","primary":"+create-from-template","public":false,"reviewed":true}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,19 +20,24 @@ type smartCoverageCaller struct {
|
|||||||
responses map[string][]string
|
responses map[string][]string
|
||||||
failAt map[string]int
|
failAt map[string]int
|
||||||
counts map[string]int
|
counts map[string]int
|
||||||
|
arguments map[string][]map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *smartCoverageCaller) CallTool(
|
func (c *smartCoverageCaller) CallTool(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
product, tool string,
|
product, tool string,
|
||||||
_ map[string]any,
|
args map[string]any,
|
||||||
) (*edition.ToolResult, error) {
|
) (*edition.ToolResult, error) {
|
||||||
if c.counts == nil {
|
if c.counts == nil {
|
||||||
c.counts = map[string]int{}
|
c.counts = map[string]int{}
|
||||||
}
|
}
|
||||||
|
if c.arguments == nil {
|
||||||
|
c.arguments = map[string][]map[string]any{}
|
||||||
|
}
|
||||||
key := product + "/" + tool
|
key := product + "/" + tool
|
||||||
c.counts[key]++
|
c.counts[key]++
|
||||||
if c.failAt[key] == c.counts[key] {
|
c.arguments[key] = append(c.arguments[key], args)
|
||||||
|
if c.failAt[key] == -1 || c.failAt[key] == c.counts[key] {
|
||||||
return nil, errors.New("fixture failure")
|
return nil, errors.New("fixture failure")
|
||||||
}
|
}
|
||||||
responses := c.responses[key]
|
responses := c.responses[key]
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
package smart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
resolveDocPermissionUser = resolveUser
|
||||||
|
resolveDocShareUser = resolveOpenDingTalkUser
|
||||||
|
legacyShareDoc shortcut.Shortcut
|
||||||
|
)
|
||||||
|
|
||||||
|
func docSmartContract(command, description, intent string, examples []string, dryRun bool) corecmd.ContractDecl {
|
||||||
|
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||||
|
cliPath := "doc " + command
|
||||||
|
decl := corecmd.ContractDecl{
|
||||||
|
Description: description,
|
||||||
|
Interface: &contract.InterfaceSpec{
|
||||||
|
Mode: contract.InterfaceModeComposite,
|
||||||
|
Availability: contract.InterfaceAvailable,
|
||||||
|
Reason: "Reviewed cross-product Doc Shortcut composite: contact resolution, document permissions, messaging, confirmation, and output ledger cannot be represented by one MCP interface.",
|
||||||
|
},
|
||||||
|
Selection: contract.SelectionSpec{
|
||||||
|
AgentSummary: description,
|
||||||
|
UseWhen: []string{intent},
|
||||||
|
AvoidWhen: []string{
|
||||||
|
"已知 userId 且只需原子权限变更时可直接使用 doc permission 命令;知识库容器成员使用 wiki;普通文件权限使用 drive",
|
||||||
|
},
|
||||||
|
Examples: examples,
|
||||||
|
},
|
||||||
|
Identity: contract.ToolIdentitySpec{
|
||||||
|
ProductID: "doc", Name: name, CanonicalPath: "doc." + name,
|
||||||
|
CLIPath: cliPath, PrimaryCLIPath: cliPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if dryRun {
|
||||||
|
decl.DryRun = &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: true}
|
||||||
|
}
|
||||||
|
return decl
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeShareDoc() {
|
||||||
|
legacyShareDoc = ShareDoc
|
||||||
|
// Preserve the historical identity and string-typed --to flag while using
|
||||||
|
// the same pre-resolve-and-send implementation as the canonical command.
|
||||||
|
legacyShareDoc.Execute = executeShare
|
||||||
|
ShareDoc.Command = "+share"
|
||||||
|
ShareDoc.Aliases = nil
|
||||||
|
ShareDoc.Description = "按姓名发送文档链接,不改变文档权限"
|
||||||
|
ShareDoc.Intent = "当用户已有文档 URL、要按姓名私信给一个或多个人但不改变权限时使用;第一次发消息前先完成全部姓名唯一解析。"
|
||||||
|
ShareDoc.Contract = docSmartContract("+share", ShareDoc.Description, ShareDoc.Intent,
|
||||||
|
[]string{`dws doc +share --to 张三 --url https://alidocs.dingtalk.com/i/nodes/<DOC_ID> --note "请帮忙 review"`}, false)
|
||||||
|
ShareDoc.Flags = []shortcut.Flag{
|
||||||
|
{Name: "to", Type: shortcut.FlagString, Desc: "接收人姓名列表(多个姓名用逗号分隔)", Required: true},
|
||||||
|
{Name: "url", Type: shortcut.FlagString, Desc: "文档链接", Required: true},
|
||||||
|
{Name: "note", Type: shortcut.FlagString, Desc: "附言"},
|
||||||
|
shortcut.AIMessageTagFlag(),
|
||||||
|
}
|
||||||
|
ShareDoc.Tips = []string{`dws doc +share --to 张三 --url https://alidocs.dingtalk.com/i/nodes/<DOC_ID> --note "请帮忙 review"`}
|
||||||
|
ShareDoc.Execute = executeShare
|
||||||
|
}
|
||||||
|
|
||||||
|
var AccessGrant = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+access-grant", Product: "doc",
|
||||||
|
Description: "按姓名解析后批量授予文档权限",
|
||||||
|
Intent: "当用户要给一个或多位同事授予单篇文档 READER/DOWNLOADER/EDITOR/MANAGER 权限时使用;所有姓名唯一解析成功后才执行一次批量授权。",
|
||||||
|
Risk: shortcut.RiskWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||||
|
Contract: docSmartContract("+access-grant", "按姓名解析后批量授予文档权限",
|
||||||
|
"当用户要给一个或多位同事授予单篇文档 READER/DOWNLOADER/EDITOR/MANAGER 权限时使用;所有姓名唯一解析成功后才执行一次批量授权。",
|
||||||
|
[]string{`dws doc +access-grant --node <DOC_ID> --to 张三,李四 --role READER`}, false),
|
||||||
|
Flags: permissionFlags(true),
|
||||||
|
Tips: []string{`dws doc +access-grant --node <DOC_ID> --to 张三,李四 --role READER`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
users, err := resolveDocUsers(rt, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
params := permissionParams(rt, users)
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docAccessEnvelope("doc.access_grant", map[string]any{"executed": false, "resolved": resolvedUserLedger(users), "params": params}))
|
||||||
|
}
|
||||||
|
result, err := rt.CallMCPWriteData("doc", "add_permission", params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rt.Output(docAccessEnvelope("doc.access_grant", map[string]any{"resolved": resolvedUserLedger(users), "result": result}))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var AccessChange = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+access-change", Product: "doc",
|
||||||
|
Description: "预检已有协作者后变更文档角色",
|
||||||
|
Intent: "当用户要修改文档上已有协作者的权限角色时使用;先按姓名解析并读取当前权限,目标不是现有协作者时停止,不把 update 当 add。",
|
||||||
|
Risk: shortcut.RiskWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||||
|
Contract: docSmartContract("+access-change", "预检已有协作者后变更文档角色",
|
||||||
|
"当用户要修改文档上已有协作者的权限角色时使用;先按姓名解析并读取当前权限,目标不是现有协作者时停止,不把 update 当 add。",
|
||||||
|
[]string{`dws doc +access-change --node <DOC_ID> --to 张三 --role EDITOR`}, false),
|
||||||
|
Flags: permissionFlags(true),
|
||||||
|
Tips: []string{`dws doc +access-change --node <DOC_ID> --to 张三 --role EDITOR`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
users, err := resolveDocUsers(rt, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
current, err := rt.CallMCPData("doc", "list_permission", map[string]any{"nodeId": rt.Str("node")})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
missing := usersMissingPermission(current, users)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return apperrors.NewValidation(fmt.Sprintf("以下用户不是当前直接协作者,不能 change;请改用 access-grant: %v", missing))
|
||||||
|
}
|
||||||
|
params := permissionParams(rt, users)
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docAccessEnvelope("doc.access_change", map[string]any{"executed": false, "preflight": "existing_collaborators", "params": params}))
|
||||||
|
}
|
||||||
|
result, err := rt.CallMCPWriteData("doc", "update_permission", params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rt.Output(docAccessEnvelope("doc.access_change", result))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var AccessRevoke = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+access-revoke", Product: "doc",
|
||||||
|
Description: "预检并移除指定协作者的文档权限",
|
||||||
|
Intent: "当用户明确要撤销一位或多位现有协作者对单篇文档的直接权限时使用;先解析姓名并读取权限预检,再执行高风险移除。",
|
||||||
|
Risk: shortcut.RiskHighWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"},
|
||||||
|
Contract: docSmartContract("+access-revoke", "预检并移除指定协作者的文档权限",
|
||||||
|
"当用户明确要撤销一位或多位现有协作者对单篇文档的直接权限时使用;先解析姓名并读取权限预检,再执行高风险移除。",
|
||||||
|
[]string{`dws doc +access-revoke --node <DOC_ID> --to 张三`}, false),
|
||||||
|
Flags: permissionFlags(false),
|
||||||
|
Tips: []string{`dws doc +access-revoke --node <DOC_ID> --to 张三`},
|
||||||
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
|
users, err := resolveDocUsers(rt, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
current, err := rt.CallMCPData("doc", "list_permission", map[string]any{"nodeId": rt.Str("node")})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
missing := usersMissingPermission(current, users)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return apperrors.NewValidation(fmt.Sprintf("以下用户没有可移除的直接权限: %v", missing))
|
||||||
|
}
|
||||||
|
params := map[string]any{"nodeId": rt.Str("node"), "userIds": docUserIDs(users)}
|
||||||
|
if rt.Str("workspace") != "" {
|
||||||
|
params["workspaceId"] = rt.Str("workspace")
|
||||||
|
}
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docAccessEnvelope("doc.access_revoke", map[string]any{"executed": false, "preflight": "existing_collaborators", "params": params}))
|
||||||
|
}
|
||||||
|
result, err := rt.CallMCPWriteData("doc", "remove_permission", params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rt.Output(docAccessEnvelope("doc.access_revoke", result))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var GrantAndShare = shortcut.Shortcut{
|
||||||
|
Service: "doc", Command: "+grant-and-share", Product: "doc",
|
||||||
|
Description: "确保目标角色后按姓名逐人发送文档链接",
|
||||||
|
Intent: "当用户要确保多人获得指定文档角色后再私信链接时使用;缺少权限时授权、角色不足时升级,无法识别当前角色则停止,再只向权限已经足够的人发送。",
|
||||||
|
Risk: shortcut.RiskWrite,
|
||||||
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||||
|
Contract: docSmartContract("+grant-and-share", "确保目标角色后按姓名逐人发送文档链接",
|
||||||
|
"当用户要确保多人获得指定文档角色后再私信链接时使用;缺少权限时授权、角色不足时升级,无法识别当前角色则停止,再只向权限已经足够的人发送。",
|
||||||
|
[]string{`dws doc +grant-and-share --node <DOC_ID> --url https://alidocs.dingtalk.com/i/nodes/<DOC_ID> --to 张三,李四 --role READER`}, false),
|
||||||
|
Flags: append(permissionFlags(true),
|
||||||
|
shortcut.Flag{Name: "url", Type: shortcut.FlagString, Desc: "文档链接", Required: true},
|
||||||
|
shortcut.Flag{Name: "note", Type: shortcut.FlagString, Desc: "附言"},
|
||||||
|
shortcut.AIMessageTagFlag(),
|
||||||
|
),
|
||||||
|
Tips: []string{`dws doc +grant-and-share --node <DOC_ID> --url https://alidocs.dingtalk.com/i/nodes/<DOC_ID> --to 张三,李四 --role READER`},
|
||||||
|
Execute: executeGrantAndShare,
|
||||||
|
}
|
||||||
|
|
||||||
|
func permissionFlags(withRole bool) []shortcut.Flag {
|
||||||
|
flags := []shortcut.Flag{
|
||||||
|
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||||
|
{Name: "to", Type: shortcut.FlagStringSlice, Desc: "协作者姓名列表", Required: true},
|
||||||
|
}
|
||||||
|
if withRole {
|
||||||
|
flags = append(flags, shortcut.Flag{Name: "role", Type: shortcut.FlagString, Default: "READER", Desc: "目标角色", Enum: []string{"READER", "DOWNLOADER", "EDITOR", "MANAGER"}})
|
||||||
|
}
|
||||||
|
flags = append(flags, shortcut.Flag{Name: "workspace", Type: shortcut.FlagString, Desc: "可选知识库 ID"})
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDocUsers(rt *shortcut.RuntimeContext, requireOpenID bool) ([]contactUser, error) {
|
||||||
|
names := rt.StrSlice("to")
|
||||||
|
if len(names) == 0 {
|
||||||
|
names = strings.Split(rt.Str("to"), ",")
|
||||||
|
}
|
||||||
|
users := make([]contactUser, 0, len(names))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, name := range names {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var user contactUser
|
||||||
|
var err error
|
||||||
|
if requireOpenID {
|
||||||
|
user, err = resolveDocShareUser(rt, name)
|
||||||
|
} else {
|
||||||
|
user, err = resolveDocPermissionUser(rt, name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user.userID == "" && !requireOpenID {
|
||||||
|
return nil, apperrors.NewValidation(fmt.Sprintf("%s 缺少 userId,不能管理文档权限", name))
|
||||||
|
}
|
||||||
|
key := user.userID + "\x00" + user.openDingTalkID
|
||||||
|
if !seen[key] {
|
||||||
|
seen[key] = true
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(users) == 0 {
|
||||||
|
return nil, apperrors.NewValidation("--to 至少需要一个可唯一解析的姓名")
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func permissionParams(rt *shortcut.RuntimeContext, users []contactUser) map[string]any {
|
||||||
|
params := map[string]any{"nodeId": rt.Str("node"), "roleId": strings.ToUpper(rt.Str("role")), "userIds": docUserIDs(users)}
|
||||||
|
if rt.Str("workspace") != "" {
|
||||||
|
params["workspaceId"] = rt.Str("workspace")
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
func docUserIDs(users []contactUser) []string {
|
||||||
|
ids := make([]string, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
ids = append(ids, user.userID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvedUserLedger(users []contactUser) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
out = append(out, map[string]any{"name": user.name, "userId": user.userID, "openDingTalkId": user.openDingTalkID})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func usersMissingPermission(current map[string]any, users []contactUser) []string {
|
||||||
|
missingUsers := usersWithoutPermission(current, users)
|
||||||
|
var missing []string
|
||||||
|
for _, user := range missingUsers {
|
||||||
|
missing = append(missing, user.name+"("+user.userID+")")
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
func usersWithoutPermission(current map[string]any, users []contactUser) []contactUser {
|
||||||
|
present := map[string]bool{}
|
||||||
|
collectPermissionUserIDs(current, present)
|
||||||
|
missing := make([]contactUser, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
if user.userID == "" || !present[user.userID] {
|
||||||
|
missing = append(missing, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectPermissionUserIDs(value any, into map[string]bool) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, item := range typed {
|
||||||
|
if (key == "userId" || key == "id") && item != nil {
|
||||||
|
if id, ok := item.(string); ok && strings.TrimSpace(id) != "" {
|
||||||
|
into[strings.TrimSpace(id)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collectPermissionUserIDs(item, into)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, item := range typed {
|
||||||
|
collectPermissionUserIDs(item, into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type permissionChangePlan struct {
|
||||||
|
missing []contactUser
|
||||||
|
upgrade []contactUser
|
||||||
|
unknown []contactUser
|
||||||
|
}
|
||||||
|
|
||||||
|
func planPermissionChanges(current map[string]any, users []contactUser, targetRole string) permissionChangePlan {
|
||||||
|
present := map[string]bool{}
|
||||||
|
collectPermissionUserIDs(current, present)
|
||||||
|
roles := map[string]string{}
|
||||||
|
collectPermissionRoles(current, roles)
|
||||||
|
targetRank := permissionRoleRank(targetRole)
|
||||||
|
plan := permissionChangePlan{}
|
||||||
|
for _, user := range users {
|
||||||
|
if user.userID == "" || !present[user.userID] {
|
||||||
|
plan.missing = append(plan.missing, user)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
currentRole, ok := roles[user.userID]
|
||||||
|
currentRank := permissionRoleRank(currentRole)
|
||||||
|
if !ok || currentRank == 0 || targetRank == 0 {
|
||||||
|
plan.unknown = append(plan.unknown, user)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if currentRank < targetRank {
|
||||||
|
plan.upgrade = append(plan.upgrade, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectPermissionRoles(value any, into map[string]string) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
role := firstPermissionString(typed, "roleId", "roleID", "role", "permissionRole", "permissionType", "roleType")
|
||||||
|
userID := firstPermissionString(typed, "userId", "userID", "memberId", "targetId", "uid")
|
||||||
|
if userID == "" && role != "" {
|
||||||
|
userID = firstPermissionString(typed, "id")
|
||||||
|
}
|
||||||
|
if userID != "" && role != "" {
|
||||||
|
previous := into[userID]
|
||||||
|
if previous == "" || permissionRoleRank(role) > permissionRoleRank(previous) {
|
||||||
|
into[userID] = role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range typed {
|
||||||
|
collectPermissionRoles(item, into)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, item := range typed {
|
||||||
|
collectPermissionRoles(item, into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstPermissionString(values map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func permissionRoleRank(role string) int {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(role)) {
|
||||||
|
case "READER":
|
||||||
|
return 1
|
||||||
|
case "DOWNLOADER":
|
||||||
|
return 2
|
||||||
|
case "EDITOR":
|
||||||
|
return 3
|
||||||
|
case "MANAGER":
|
||||||
|
return 4
|
||||||
|
case "OWNER":
|
||||||
|
return 5
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func permissionUserLabels(users []contactUser) []string {
|
||||||
|
labels := make([]string, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
labels = append(labels, user.name+"("+user.userID+")")
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeShare(rt *shortcut.RuntimeContext) error {
|
||||||
|
users, err := resolveDocUsers(rt, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docAccessEnvelope("doc.share", map[string]any{"executed": false, "resolved": resolvedUserLedger(users), "url": rt.Str("url")}))
|
||||||
|
}
|
||||||
|
return sendDocLinks(rt, users, "doc.share", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeGrantAndShare(rt *shortcut.RuntimeContext) error {
|
||||||
|
users, err := resolveDocUsers(rt, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
if user.openDingTalkID == "" {
|
||||||
|
return apperrors.NewValidation(fmt.Sprintf("%s 缺少 openDingTalkId,已在授权前停止", user.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current, err := rt.CallMCPData("doc", "list_permission", map[string]any{"nodeId": rt.Str("node")})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
plan := planPermissionChanges(current, users, rt.Str("role"))
|
||||||
|
if len(plan.unknown) > 0 {
|
||||||
|
return apperrors.NewValidation(fmt.Sprintf("以下用户的当前文档角色无法识别,已在授权和发消息前停止: %v", permissionUserLabels(plan.unknown)))
|
||||||
|
}
|
||||||
|
if rt.DryRun() {
|
||||||
|
return rt.Output(docAccessEnvelope("doc.grant_and_share", map[string]any{
|
||||||
|
"executed": false, "resolved": resolvedUserLedger(users),
|
||||||
|
"wouldGrant": permissionUserLabels(plan.missing), "wouldUpgrade": permissionUserLabels(plan.upgrade), "wouldMessage": len(users),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
permissionStatus := make(map[string]string, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
permissionStatus[user.userID] = "unchanged"
|
||||||
|
}
|
||||||
|
if len(plan.missing) > 0 {
|
||||||
|
if _, err := rt.CallMCPWriteData("doc", "add_permission", permissionParams(rt, plan.missing)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, user := range plan.missing {
|
||||||
|
permissionStatus[user.userID] = "granted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(plan.upgrade) > 0 {
|
||||||
|
if _, err := rt.CallMCPWriteData("doc", "update_permission", permissionParams(rt, plan.upgrade)); err != nil {
|
||||||
|
if len(plan.missing) > 0 {
|
||||||
|
return apperrors.NewAPI(
|
||||||
|
"部分新增权限已写入,但既有用户的角色升级失败;消息尚未发送,请勿直接重试整个命令",
|
||||||
|
apperrors.WithOperation("doc.grant_and_share"),
|
||||||
|
apperrors.WithReason("doc_grant_permission_partial_failure"),
|
||||||
|
apperrors.WithFailureStage("update_permission"),
|
||||||
|
apperrors.WithExecutionStarted(true),
|
||||||
|
apperrors.WithRetryable(false),
|
||||||
|
apperrors.WithActions("inspect current permissions before retrying", "revoke newly added permissions if the whole operation should be rolled back"),
|
||||||
|
apperrors.WithDetails(map[string]any{
|
||||||
|
"status": "partial_success",
|
||||||
|
"steps": []map[string]any{
|
||||||
|
{"name": "add_permission", "status": "success"},
|
||||||
|
{"name": "update_permission", "status": "failed"},
|
||||||
|
{"name": "send_messages", "status": "not_started"},
|
||||||
|
},
|
||||||
|
"data": map[string]any{
|
||||||
|
"granted": permissionUserLabels(plan.missing),
|
||||||
|
"upgradePending": permissionUserLabels(plan.upgrade),
|
||||||
|
"messagesSent": 0,
|
||||||
|
},
|
||||||
|
"compensation": map[string]any{"available": true, "action": "revoke_new_permissions", "users": permissionUserLabels(plan.missing)},
|
||||||
|
}),
|
||||||
|
apperrors.WithCause(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, user := range plan.upgrade {
|
||||||
|
permissionStatus[user.userID] = "upgraded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sendDocLinks(rt, users, "doc.grant_and_share", permissionStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendDocLinks(rt *shortcut.RuntimeContext, users []contactUser, operation string, permissionStatus map[string]string) error {
|
||||||
|
body := shareDocBuildText(rt.Str("url"), rt.Str("note"))
|
||||||
|
content, _ := json.Marshal(map[string]string{"title": "文档分享", "text": body})
|
||||||
|
ledger := make([]map[string]any, 0, len(users))
|
||||||
|
failed := 0
|
||||||
|
for _, user := range users {
|
||||||
|
params := rt.AddAIMessageTag(map[string]any{"receiverOpenDingTalkId": user.openDingTalkID, "msgType": "markdown", "content": string(content)})
|
||||||
|
result, err := rt.CallMCPWriteData("chat", "send_personal_message", params)
|
||||||
|
permission := permissionStatus[user.userID]
|
||||||
|
if permission == "" {
|
||||||
|
permission = "unchanged"
|
||||||
|
}
|
||||||
|
row := map[string]any{"name": user.name, "userId": user.userID, "permission": permission, "message": "success"}
|
||||||
|
if err != nil {
|
||||||
|
failed++
|
||||||
|
row["message"] = "failed"
|
||||||
|
row["error"] = err.Error()
|
||||||
|
} else {
|
||||||
|
row["result"] = result
|
||||||
|
}
|
||||||
|
ledger = append(ledger, row)
|
||||||
|
}
|
||||||
|
status := "success"
|
||||||
|
succeeded := len(users) - failed
|
||||||
|
if failed == len(users) {
|
||||||
|
status = "failed"
|
||||||
|
} else if failed > 0 {
|
||||||
|
status = "partial_success"
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"ok": failed == 0, "status": status, "operation": operation,
|
||||||
|
"data": map[string]any{
|
||||||
|
"requestedCount": len(users), "succeededCount": succeeded, "failedCount": failed,
|
||||||
|
"recipients": ledger,
|
||||||
|
},
|
||||||
|
"warnings": []string{"权限与消息不构成跨产品事务;消息失败不会自动撤销既有权限"},
|
||||||
|
}
|
||||||
|
if err := rt.Output(payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
return apperrors.NewAPI(
|
||||||
|
fmt.Sprintf("文档链接发送未全部完成:%d/%d 个接收人失败", failed, len(users)),
|
||||||
|
apperrors.WithOperation(operation),
|
||||||
|
apperrors.WithReason("doc_share_message_failed"),
|
||||||
|
apperrors.WithExecutionStarted(true),
|
||||||
|
apperrors.WithRetryable(false),
|
||||||
|
apperrors.WithDetails(map[string]any{
|
||||||
|
"requestedCount": len(users), "succeededCount": succeeded, "failedCount": failed,
|
||||||
|
"partial": succeeded > 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func docAccessEnvelope(operation string, data any) map[string]any {
|
||||||
|
return map[string]any{"ok": true, "status": "success", "operation": operation, "data": data, "warnings": []string{}, "compensation": map[string]any{"available": false, "reason": "cross-product writes are not transactional"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
shortcut.Register(AccessGrant, AccessChange, AccessRevoke, GrantAndShare)
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
package smart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func docAccessCoverageResponses(permission string) map[string][]string {
|
||||||
|
return map[string][]string{
|
||||||
|
"contact/search_contact_by_key_word": {`{"result":[{"userId":"u1","name":"张三","openDingTalkId":"open1"}]}`},
|
||||||
|
"doc/list_permission": {permission},
|
||||||
|
"doc/add_permission": {`{"result":{"ok":true}}`},
|
||||||
|
"doc/update_permission": {`{"result":{"ok":true}}`},
|
||||||
|
"doc/remove_permission": {`{"result":{"ok":true}}`},
|
||||||
|
"chat/send_personal_message": {`{"result":{"messageId":"m1"}}`},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type docAccessErrorWriter struct{}
|
||||||
|
|
||||||
|
func (docAccessErrorWriter) Write([]byte) (int, error) { return 0, errors.New("output failure") }
|
||||||
|
|
||||||
|
func runDocAccessCoverage(t *testing.T, caller *smartCoverageCaller, args ...string) error {
|
||||||
|
t.Helper()
|
||||||
|
helpers.InitDeps(caller)
|
||||||
|
root := newPlatformCoverageRoot()
|
||||||
|
root.SetIn(bytes.NewReader(nil))
|
||||||
|
root.SetArgs(args)
|
||||||
|
return root.Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDocAccessCoverageOutput(t *testing.T, caller *smartCoverageCaller, args ...string) (map[string]any, error) {
|
||||||
|
t.Helper()
|
||||||
|
helpers.InitDeps(caller)
|
||||||
|
root := newPlatformCoverageRoot()
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
root.SetOut(&stdout)
|
||||||
|
root.SetErr(io.Discard)
|
||||||
|
root.SetIn(bytes.NewReader(nil))
|
||||||
|
root.SetArgs(args)
|
||||||
|
err := root.Execute()
|
||||||
|
if stdout.Len() == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode output %q: %v", stdout.String(), err)
|
||||||
|
}
|
||||||
|
return payload, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDocAccessDeclaration(t *testing.T, declaration shortcut.Shortcut, caller *smartCoverageCaller, args ...string) error {
|
||||||
|
t.Helper()
|
||||||
|
helpers.InitDeps(caller)
|
||||||
|
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||||
|
root.SetOut(io.Discard)
|
||||||
|
root.SetErr(io.Discard)
|
||||||
|
root.PersistentFlags().Bool("yes", false, "")
|
||||||
|
root.PersistentFlags().Bool("dry-run", false, "")
|
||||||
|
root.PersistentFlags().String("format", "json", "")
|
||||||
|
service := &cobra.Command{Use: "doc"}
|
||||||
|
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||||
|
root.AddCommand(service)
|
||||||
|
root.SetArgs(append([]string{"doc", declaration.Command}, args...))
|
||||||
|
return root.Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocAccessSuccessDryRunAndPreflight(t *testing.T) {
|
||||||
|
if docSmartContract("+test", "d", "i", nil, true).DryRun == nil {
|
||||||
|
t.Fatal("explicit doc smart dry-run declaration missing")
|
||||||
|
}
|
||||||
|
present := `{"result":[{"userId":"u1","roleId":"READER"}]}`
|
||||||
|
empty := `{"result":[]}`
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
permission string
|
||||||
|
args []string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"grant", present, []string{"doc", "+access-grant", "--node", "n", "--to", "张三", "--role", "READER", "--workspace", "w", "--yes"}, false},
|
||||||
|
{"grant dry", present, []string{"doc", "+access-grant", "--node", "n", "--to", "张三", "--dry-run", "--yes"}, false},
|
||||||
|
{"change", present, []string{"doc", "+access-change", "--node", "n", "--to", "张三", "--role", "EDITOR", "--yes"}, false},
|
||||||
|
{"change dry", present, []string{"doc", "+access-change", "--node", "n", "--to", "张三", "--dry-run", "--yes"}, false},
|
||||||
|
{"change missing", empty, []string{"doc", "+access-change", "--node", "n", "--to", "张三", "--yes"}, true},
|
||||||
|
{"revoke", present, []string{"doc", "+access-revoke", "--node", "n", "--to", "张三", "--workspace", "w", "--yes"}, false},
|
||||||
|
{"revoke dry", present, []string{"doc", "+access-revoke", "--node", "n", "--to", "张三", "--dry-run", "--yes"}, false},
|
||||||
|
{"revoke missing", empty, []string{"doc", "+access-revoke", "--node", "n", "--to", "张三", "--yes"}, true},
|
||||||
|
{"share", present, []string{"doc", "+share", "--to", "张三", "--url", "https://example.com/doc", "--note", "看一下", "--yes"}, false},
|
||||||
|
{"share dry", present, []string{"doc", "+share", "--to", "张三", "--url", "https://example.com/doc", "--dry-run", "--yes"}, false},
|
||||||
|
{"grant share existing", present, []string{"doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes"}, false},
|
||||||
|
{"grant share missing", empty, []string{"doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes"}, false},
|
||||||
|
{"grant share dry", empty, []string{"doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--dry-run", "--yes"}, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
caller := &smartCoverageCaller{responses: docAccessCoverageResponses(tc.permission), failAt: map[string]int{}}
|
||||||
|
err := runDocAccessCoverage(t, caller, tc.args...)
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Fatalf("error = %v, wantErr %v", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocGrantAndShareProjectionMatchesWrite(t *testing.T) {
|
||||||
|
permission := func(payload map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
data, _ := payload["data"].(map[string]any)
|
||||||
|
recipients, _ := data["recipients"].([]any)
|
||||||
|
if len(recipients) != 1 {
|
||||||
|
t.Fatalf("recipients = %#v", data["recipients"])
|
||||||
|
}
|
||||||
|
row, _ := recipients[0].(map[string]any)
|
||||||
|
value, _ := row["permission"].(string)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
missingCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{}}
|
||||||
|
missing, err := runDocAccessCoverageOutput(t, missingCaller, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if missing["operation"] != "doc.grant_and_share" || permission(missing) != "granted" {
|
||||||
|
t.Fatalf("missing projection = %#v", missing)
|
||||||
|
}
|
||||||
|
if missingCaller.counts["doc/add_permission"] != 1 {
|
||||||
|
t.Fatalf("missing add calls = %d", missingCaller.counts["doc/add_permission"])
|
||||||
|
}
|
||||||
|
|
||||||
|
presentCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[{"id":"u1","roleId":"READER"}]}`), failAt: map[string]int{}}
|
||||||
|
present, err := runDocAccessCoverageOutput(t, presentCaller, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if present["operation"] != "doc.grant_and_share" || permission(present) != "unchanged" {
|
||||||
|
t.Fatalf("present projection = %#v", present)
|
||||||
|
}
|
||||||
|
if presentCaller.counts["doc/add_permission"] != 0 || presentCaller.counts["doc/update_permission"] != 0 {
|
||||||
|
t.Fatalf("present writes = %#v", presentCaller.counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
upgradeCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[{"userId":"u1","roleId":"READER"}]}`), failAt: map[string]int{}}
|
||||||
|
upgraded, err := runDocAccessCoverageOutput(t, upgradeCaller, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--role", "EDITOR", "--yes")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if permission(upgraded) != "upgraded" || upgradeCaller.counts["doc/add_permission"] != 0 || upgradeCaller.counts["doc/update_permission"] != 1 || upgradeCaller.counts["chat/send_personal_message"] != 1 {
|
||||||
|
t.Fatalf("upgrade projection=%#v calls=%#v", upgraded, upgradeCaller.counts)
|
||||||
|
}
|
||||||
|
wantUpgrade := map[string]any{"nodeId": "n", "roleId": "EDITOR", "userIds": []string{"u1"}}
|
||||||
|
if got := upgradeCaller.arguments["doc/update_permission"]; len(got) != 1 || !reflect.DeepEqual(got[0], wantUpgrade) {
|
||||||
|
t.Fatalf("upgrade params = %#v, want %#v", got, wantUpgrade)
|
||||||
|
}
|
||||||
|
|
||||||
|
unknownCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[{"userId":"u1"}]}`), failAt: map[string]int{}}
|
||||||
|
if _, err := runDocAccessCoverageOutput(t, unknownCaller, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes"); err == nil {
|
||||||
|
t.Fatal("unknown existing role must stop before permission writes and messaging")
|
||||||
|
}
|
||||||
|
if unknownCaller.counts["doc/add_permission"] != 0 || unknownCaller.counts["doc/update_permission"] != 0 || unknownCaller.counts["chat/send_personal_message"] != 0 {
|
||||||
|
t.Fatalf("unknown role continued: %#v", unknownCaller.counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
shareCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{}}
|
||||||
|
shared, err := runDocAccessCoverageOutput(t, shareCaller, "doc", "+share", "--to", "张三", "--url", "https://example.com/doc", "--yes")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if shared["operation"] != "doc.share" || permission(shared) != "unchanged" {
|
||||||
|
t.Fatalf("share projection = %#v", shared)
|
||||||
|
}
|
||||||
|
|
||||||
|
if missing := usersMissingPermission(map[string]any{"result": []any{map[string]any{"userId": "u10"}}}, []contactUser{{name: "张三", userID: "u1"}}); len(missing) != 1 {
|
||||||
|
t.Fatalf("substring permission match must not pass: %#v", missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
roles := map[string]int{"READER": 1, "DOWNLOADER": 2, "EDITOR": 3, "MANAGER": 4, "OWNER": 5, "unknown": 0}
|
||||||
|
for role, want := range roles {
|
||||||
|
if got := permissionRoleRank(role); got != want {
|
||||||
|
t.Fatalf("permissionRoleRank(%q) = %d, want %d", role, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collected := map[string]string{}
|
||||||
|
collectPermissionRoles(map[string]any{"result": []any{
|
||||||
|
map[string]any{"userID": "u2", "permissionRole": "READER"},
|
||||||
|
map[string]any{"memberId": "u2", "permissionType": "EDITOR"},
|
||||||
|
map[string]any{"targetId": "u3", "roleType": "DOWNLOADER"},
|
||||||
|
map[string]any{"uid": "u4", "roleID": "MANAGER"},
|
||||||
|
map[string]any{"id": "u5", "role": "OWNER"},
|
||||||
|
}}, collected)
|
||||||
|
if !reflect.DeepEqual(collected, map[string]string{"u2": "EDITOR", "u3": "DOWNLOADER", "u4": "MANAGER", "u5": "OWNER"}) {
|
||||||
|
t.Fatalf("collected roles = %#v", collected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocAccessRevokeConfirmationBoundary(t *testing.T) {
|
||||||
|
present := `{"result":[{"userId":"u1","roleId":"READER"}]}`
|
||||||
|
unconfirmed := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}
|
||||||
|
if err := runDocAccessCoverage(t, unconfirmed, "doc", "+access-revoke", "--node", "n", "--to", "张三"); err == nil {
|
||||||
|
t.Fatal("access revoke without --yes must reject")
|
||||||
|
}
|
||||||
|
if len(unconfirmed.counts) != 0 {
|
||||||
|
t.Fatalf("unconfirmed access revoke called MCP: %#v", unconfirmed.counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmed := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}
|
||||||
|
if err := runDocAccessCoverage(t, confirmed, "doc", "+access-revoke", "--node", "n", "--to", "张三", "--yes"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantCounts := map[string]int{
|
||||||
|
"contact/search_contact_by_key_word": 1,
|
||||||
|
"doc/list_permission": 1,
|
||||||
|
"doc/remove_permission": 1,
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(confirmed.counts, wantCounts) {
|
||||||
|
t.Fatalf("confirmed calls = %#v, want %#v", confirmed.counts, wantCounts)
|
||||||
|
}
|
||||||
|
wantRemove := map[string]any{"nodeId": "n", "userIds": []string{"u1"}}
|
||||||
|
if got := confirmed.arguments["doc/remove_permission"]; len(got) != 1 || !reflect.DeepEqual(got[0], wantRemove) {
|
||||||
|
t.Fatalf("remove params = %#v, want %#v", got, wantRemove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocShareKeepsLegacyStringFlagAndCSV(t *testing.T) {
|
||||||
|
testseam.Swap(t, &resolveDocShareUser, func(_ *shortcut.RuntimeContext, name string) (contactUser, error) {
|
||||||
|
return contactUser{name: name, userID: "user-" + name, openDingTalkID: "open-" + name}, nil
|
||||||
|
})
|
||||||
|
responses := docAccessCoverageResponses(`{"result":[]}`)
|
||||||
|
responses["chat/send_personal_message"] = []string{
|
||||||
|
`{"result":{"messageId":"m1"}}`,
|
||||||
|
`{"result":{"messageId":"m2"}}`,
|
||||||
|
}
|
||||||
|
caller := &smartCoverageCaller{responses: responses, failAt: map[string]int{}}
|
||||||
|
payload, err := runDocAccessCoverageOutput(t, caller, "doc", "+share-doc", "--to", "alice,bob", "--url", "https://example.com/doc", "--yes")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := payload["data"].(map[string]any)
|
||||||
|
recipients, _ := data["recipients"].([]any)
|
||||||
|
if len(recipients) != 2 || caller.counts["chat/send_personal_message"] != 2 {
|
||||||
|
t.Fatalf("recipients=%#v send calls=%d", recipients, caller.counts["chat/send_personal_message"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocShareFailureExitContracts(t *testing.T) {
|
||||||
|
resolve := func(_ *shortcut.RuntimeContext, name string) (contactUser, error) {
|
||||||
|
return contactUser{name: name, userID: "user-" + name, openDingTalkID: "open-" + name}, nil
|
||||||
|
}
|
||||||
|
testseam.Swap(t, &resolveDocShareUser, resolve)
|
||||||
|
testseam.Swap(t, &resolveDocPermissionUser, resolve)
|
||||||
|
|
||||||
|
assertFailure := func(t *testing.T, payload map[string]any, err error, wantStatus string, wantSucceeded, wantFailed int) {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("message failure must return a non-zero exit error")
|
||||||
|
}
|
||||||
|
var typed *apperrors.Error
|
||||||
|
if !errors.As(err, &typed) || typed.Reason != "doc_share_message_failed" || typed.ExitCode() != 1 {
|
||||||
|
t.Fatalf("message error = %#v", err)
|
||||||
|
}
|
||||||
|
if payload["ok"] != false || payload["status"] != wantStatus {
|
||||||
|
t.Fatalf("failure payload = %#v", payload)
|
||||||
|
}
|
||||||
|
data, _ := payload["data"].(map[string]any)
|
||||||
|
if data["succeededCount"] != float64(wantSucceeded) || data["failedCount"] != float64(wantFailed) {
|
||||||
|
t.Fatalf("failure counts = %#v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allShareCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{"chat/send_personal_message": -1}}
|
||||||
|
allShare, err := runDocAccessCoverageOutput(t, allShareCaller, "doc", "+share", "--to", "alice", "--url", "https://example.com/doc", "--yes")
|
||||||
|
assertFailure(t, allShare, err, "failed", 0, 1)
|
||||||
|
|
||||||
|
permissions := `{"result":[{"userId":"user-alice","roleId":"READER"},{"userId":"user-bob","roleId":"READER"}]}`
|
||||||
|
allGrantCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(permissions), failAt: map[string]int{"chat/send_personal_message": -1}}
|
||||||
|
allGrant, err := runDocAccessCoverageOutput(t, allGrantCaller, "doc", "+grant-and-share", "--node", "n", "--to", "alice,bob", "--url", "https://example.com/doc", "--yes")
|
||||||
|
assertFailure(t, allGrant, err, "failed", 0, 2)
|
||||||
|
|
||||||
|
partialCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(permissions), failAt: map[string]int{"chat/send_personal_message": 2}}
|
||||||
|
partial, err := runDocAccessCoverageOutput(t, partialCaller, "doc", "+grant-and-share", "--node", "n", "--to", "alice,bob", "--url", "https://example.com/doc", "--yes")
|
||||||
|
assertFailure(t, partial, err, "partial_success", 1, 1)
|
||||||
|
|
||||||
|
helpers.InitDeps(&smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{}})
|
||||||
|
root := newPlatformCoverageRoot()
|
||||||
|
root.SetOut(docAccessErrorWriter{})
|
||||||
|
root.SetErr(io.Discard)
|
||||||
|
root.SetIn(bytes.NewReader(nil))
|
||||||
|
root.SetArgs([]string{"doc", "+share", "--to", "alice", "--url", "https://example.com/doc", "--yes"})
|
||||||
|
if err := root.Execute(); err == nil || err.Error() != "output failure" {
|
||||||
|
t.Fatalf("output failure = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocGrantAndSharePartialPermissionFailure(t *testing.T) {
|
||||||
|
resolve := func(_ *shortcut.RuntimeContext, name string) (contactUser, error) {
|
||||||
|
return contactUser{name: name, userID: "user-" + name, openDingTalkID: "open-" + name}, nil
|
||||||
|
}
|
||||||
|
testseam.Swap(t, &resolveDocPermissionUser, resolve)
|
||||||
|
|
||||||
|
responses := docAccessCoverageResponses(`{"result":[{"userId":"user-bob","roleId":"READER"}]}`)
|
||||||
|
caller := &smartCoverageCaller{responses: responses, failAt: map[string]int{"doc/update_permission": 1}}
|
||||||
|
err := runDocAccessCoverage(t, caller, "doc", "+grant-and-share", "--node", "n", "--to", "alice,bob", "--url", "https://example.com/doc", "--role", "EDITOR", "--yes")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("partial permission write unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
var typed *apperrors.Error
|
||||||
|
if !errors.As(err, &typed) || typed.Reason != "doc_grant_permission_partial_failure" || typed.FailureStage != "update_permission" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
|
||||||
|
t.Fatalf("partial permission error = %#v", err)
|
||||||
|
}
|
||||||
|
if typed.Details["status"] != "partial_success" {
|
||||||
|
t.Fatalf("partial permission details = %#v", typed.Details)
|
||||||
|
}
|
||||||
|
steps, _ := typed.Details["steps"].([]map[string]any)
|
||||||
|
if len(steps) != 3 || steps[0]["status"] != "success" || steps[1]["status"] != "failed" || steps[2]["status"] != "not_started" {
|
||||||
|
t.Fatalf("partial permission steps = %#v", steps)
|
||||||
|
}
|
||||||
|
if caller.counts["doc/add_permission"] != 1 || caller.counts["doc/update_permission"] != 1 || caller.counts["chat/send_personal_message"] != 0 {
|
||||||
|
t.Fatalf("partial permission calls = %#v", caller.counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDocAccessFailureBoundaries(t *testing.T) {
|
||||||
|
present := `{"result":[{"userId":"u1","roleId":"READER"}]}`
|
||||||
|
commands := []struct {
|
||||||
|
args []string
|
||||||
|
fails []string
|
||||||
|
}{
|
||||||
|
{[]string{"doc", "+access-grant", "--node", "n", "--to", "张三", "--yes"}, []string{"contact/search_contact_by_key_word", "doc/add_permission"}},
|
||||||
|
{[]string{"doc", "+access-change", "--node", "n", "--to", "张三", "--yes"}, []string{"contact/search_contact_by_key_word", "doc/list_permission", "doc/update_permission"}},
|
||||||
|
{[]string{"doc", "+access-revoke", "--node", "n", "--to", "张三", "--yes"}, []string{"contact/search_contact_by_key_word", "doc/list_permission", "doc/remove_permission"}},
|
||||||
|
{[]string{"doc", "+share", "--to", "张三", "--url", "https://example.com/doc", "--yes"}, []string{"contact/search_contact_by_key_word", "chat/send_personal_message"}},
|
||||||
|
{[]string{"doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes"}, []string{"contact/search_contact_by_key_word", "doc/list_permission", "chat/send_personal_message"}},
|
||||||
|
}
|
||||||
|
for _, command := range commands {
|
||||||
|
for _, fail := range command.fails {
|
||||||
|
responses := docAccessCoverageResponses(present)
|
||||||
|
if fail == "doc/add_permission" {
|
||||||
|
responses["doc/list_permission"] = []string{`{"result":[]}`}
|
||||||
|
}
|
||||||
|
caller := &smartCoverageCaller{responses: responses, failAt: map[string]int{fail: 1}}
|
||||||
|
_ = runDocAccessCoverage(t, caller, command.args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
external := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}
|
||||||
|
external.responses["contact/search_contact_by_key_word"] = []string{`{"result":[{"name":"外部","openDingTalkId":"open-ext"}]}`}
|
||||||
|
_ = runDocAccessCoverage(t, external, "doc", "+access-grant", "--node", "n", "--to", "外部", "--yes")
|
||||||
|
noOpenID := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}
|
||||||
|
noOpenID.responses["contact/search_contact_by_key_word"] = []string{`{"result":[{"name":"张三","userId":"u1"}]}`}
|
||||||
|
_ = runDocAccessCoverage(t, noOpenID, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes")
|
||||||
|
emptyName := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}
|
||||||
|
_ = runDocAccessCoverage(t, emptyName, "doc", "+access-grant", "--node", "n", "--to", " ", "--yes")
|
||||||
|
duplicate := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}
|
||||||
|
_ = runDocAccessCoverage(t, duplicate, "doc", "+access-grant", "--node", "n", "--to", "张三,张三", "--yes")
|
||||||
|
_ = usersMissingPermission(map[string]any{}, []contactUser{{name: "empty"}})
|
||||||
|
|
||||||
|
optionalTo := AccessGrant
|
||||||
|
optionalTo.Flags = append([]shortcut.Flag(nil), AccessGrant.Flags...)
|
||||||
|
optionalTo.Flags[1].Required = false
|
||||||
|
_ = runDocAccessDeclaration(t, optionalTo, &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}, "--node", "n", "--to", " ", "--yes")
|
||||||
|
_ = runDocAccessDeclaration(t, optionalTo, &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}, "--node", "n", "--yes")
|
||||||
|
|
||||||
|
t.Run("permission resolver defensive identity check", func(t *testing.T) {
|
||||||
|
testseam.Swap(t, &resolveDocPermissionUser, func(*shortcut.RuntimeContext, string) (contactUser, error) {
|
||||||
|
return contactUser{name: "external", openDingTalkID: "open"}, nil
|
||||||
|
})
|
||||||
|
_ = runDocAccessCoverage(t, &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}, "doc", "+access-grant", "--node", "n", "--to", "external", "--yes")
|
||||||
|
})
|
||||||
|
t.Run("permission resolver error", func(t *testing.T) {
|
||||||
|
testseam.Swap(t, &resolveDocPermissionUser, func(*shortcut.RuntimeContext, string) (contactUser, error) {
|
||||||
|
return contactUser{}, errors.New("resolve")
|
||||||
|
})
|
||||||
|
_ = runDocAccessCoverage(t, &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}, "doc", "+access-grant", "--node", "n", "--to", "x", "--yes")
|
||||||
|
})
|
||||||
|
|
||||||
|
grantShareAddFailure := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{"doc/add_permission": 1}}
|
||||||
|
_ = runDocAccessCoverage(t, grantShareAddFailure, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes")
|
||||||
|
grantShareUpdateFailure := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[{"userId":"u1","roleId":"READER"}]}`), failAt: map[string]int{"doc/update_permission": 1}}
|
||||||
|
_ = runDocAccessCoverage(t, grantShareUpdateFailure, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--role", "EDITOR", "--yes")
|
||||||
|
if grantShareUpdateFailure.counts["chat/send_personal_message"] != 0 {
|
||||||
|
t.Fatalf("message sent after permission upgrade failure: %#v", grantShareUpdateFailure.counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,5 +102,7 @@ var DocAppend = shortcut.Shortcut{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
// Keep the historical command and Schema identity alongside the richer
|
||||||
|
// canonical doc +update surface for backwards compatibility.
|
||||||
shortcut.Register(DocAppend)
|
shortcut.Register(DocAppend)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,5 +170,7 @@ func shortcutFindDocStr(m map[string]any, keys ...string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
// Keep the historical command and Schema identity alongside the richer
|
||||||
|
// canonical doc +search surface for backwards compatibility.
|
||||||
shortcut.Register(FindDoc)
|
shortcut.Register(FindDoc)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,5 +116,6 @@ func shareDocBuildText(url, note string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
shortcut.Register(ShareDoc)
|
canonicalizeShareDoc()
|
||||||
|
shortcut.Register(legacyShareDoc, ShareDoc)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ type Flag struct {
|
|||||||
Enum []string `json:"enum"`
|
Enum []string `json:"enum"`
|
||||||
// Hidden hides the flag from --help while keeping it usable.
|
// Hidden hides the flag from --help while keeping it usable.
|
||||||
Hidden bool `json:"-"`
|
Hidden bool `json:"-"`
|
||||||
|
// Aliases are hidden executable flag spellings for compatibility. They do
|
||||||
|
// not create additional Schema parameters; validation and value fallback
|
||||||
|
// remain attached to the canonical Name. AliasesVisible is a narrow
|
||||||
|
// compatibility escape hatch for aliases that were historically public.
|
||||||
|
Aliases []string `json:"-"`
|
||||||
|
AliasesVisible bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConstraintKind is a machine-readable cross-parameter or custom validation
|
// ConstraintKind is a machine-readable cross-parameter or custom validation
|
||||||
|
|||||||
@@ -103,10 +103,7 @@ func newShortcutListRow(s shortcut.Shortcut) shortcutListRow {
|
|||||||
if risk == "" {
|
if risk == "" {
|
||||||
risk = string(shortcut.RiskRead)
|
risk = string(shortcut.RiskRead)
|
||||||
}
|
}
|
||||||
confirmation := "not_required"
|
confirmation := shortcut.EffectiveSafety(s).Confirmation
|
||||||
if risk != string(shortcut.RiskRead) {
|
|
||||||
confirmation = "user_required"
|
|
||||||
}
|
|
||||||
flags := make([]shortcut.Flag, 0, len(s.Flags))
|
flags := make([]shortcut.Flag, 0, len(s.Flags))
|
||||||
for _, flag := range s.Flags {
|
for _, flag := range s.Flags {
|
||||||
if flag.Hidden {
|
if flag.Hidden {
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
|
|||||||
fmt.Sprintf("JSON-RPC %s returned an empty result payload", request.Method),
|
fmt.Sprintf("JSON-RPC %s returned an empty result payload", request.Method),
|
||||||
apperrors.WithOperation(request.Method),
|
apperrors.WithOperation(request.Method),
|
||||||
apperrors.WithReason(reasonForMethod(request.Method, "empty_result")),
|
apperrors.WithReason(reasonForMethod(request.Method, "empty_result")),
|
||||||
apperrors.WithHint(i18n.T("服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。")),
|
apperrors.WithHint(i18n.T("服务返回了空结果;请稍后重试。")),
|
||||||
apperrors.WithActions(discoveryActions(snapshotPath)...),
|
apperrors.WithActions(discoveryActions(snapshotPath)...),
|
||||||
apperrors.WithSnapshot(snapshotPath),
|
apperrors.WithSnapshot(snapshotPath),
|
||||||
)
|
)
|
||||||
@@ -896,7 +896,7 @@ func httpStatusError(method, endpoint string, statusCode int, snapshotPath, head
|
|||||||
return apperrors.NewAPI(message, opts...)
|
return apperrors.NewAPI(message, opts...)
|
||||||
default:
|
default:
|
||||||
opts = append(opts,
|
opts = append(opts,
|
||||||
apperrors.WithHint(i18n.T("上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。")),
|
apperrors.WithHint(i18n.T("上游服务异常;可稍后重试。")),
|
||||||
apperrors.WithActions(actionsForMethod(method, snapshotPath)...),
|
apperrors.WithActions(actionsForMethod(method, snapshotPath)...),
|
||||||
)
|
)
|
||||||
if method == "tools/call" {
|
if method == "tools/call" {
|
||||||
@@ -1038,10 +1038,9 @@ func actionsForMethod(method, snapshotPath string) []string {
|
|||||||
func authActions(snapshotPath string) []string {
|
func authActions(snapshotPath string) []string {
|
||||||
actions := []string{
|
actions := []string{
|
||||||
i18n.T("检查登录状态后重试"),
|
i18n.T("检查登录状态后重试"),
|
||||||
|
i18n.T("运行 dws auth status 确认凭证有效,必要时重新登录"),
|
||||||
}
|
}
|
||||||
if snapshotPath != "" {
|
_ = snapshotPath
|
||||||
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
|
|
||||||
}
|
|
||||||
return actions
|
return actions
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,9 +1048,7 @@ func runtimeActions(snapshotPath string) []string {
|
|||||||
actions := []string{
|
actions := []string{
|
||||||
i18n.T("检查认证、权限和参数后重试原命令"),
|
i18n.T("检查认证、权限和参数后重试原命令"),
|
||||||
}
|
}
|
||||||
if snapshotPath != "" {
|
_ = snapshotPath
|
||||||
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
|
|
||||||
}
|
|
||||||
return actions
|
return actions
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,9 +1057,7 @@ func networkActions(snapshotPath string) []string {
|
|||||||
i18n.T("检查网络、代理和 DNS 配置后重试原命令"),
|
i18n.T("检查网络、代理和 DNS 配置后重试原命令"),
|
||||||
i18n.T("确认 MCP 服务可访问;若持续失败请稍后重试"),
|
i18n.T("确认 MCP 服务可访问;若持续失败请稍后重试"),
|
||||||
}
|
}
|
||||||
if snapshotPath != "" {
|
_ = snapshotPath
|
||||||
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
|
|
||||||
}
|
|
||||||
return actions
|
return actions
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1103,8 +1098,6 @@ func discoveryActions(snapshotPath string) []string {
|
|||||||
i18n.T("运行 sync-oss 重新生成静态端点与路由后重试"),
|
i18n.T("运行 sync-oss 重新生成静态端点与路由后重试"),
|
||||||
i18n.T("检查服务连通性和协议版本后重试"),
|
i18n.T("检查服务连通性和协议版本后重试"),
|
||||||
}
|
}
|
||||||
if snapshotPath != "" {
|
_ = snapshotPath
|
||||||
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
|
|
||||||
}
|
|
||||||
return actions
|
return actions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,9 +397,21 @@ func TestCrossPlatformCoverageHTTPStatusAndDiagnosticsEdges(t *testing.T) {
|
|||||||
t.Fatalf("HTTP %d returned nil", tc.code)
|
t.Fatalf("HTTP %d returned nil", tc.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := networkActions("snap"); len(got) != 3 {
|
if got := networkActions("snap"); len(got) != 2 {
|
||||||
t.Fatalf("network actions = %#v", got)
|
t.Fatalf("network actions = %#v", got)
|
||||||
}
|
}
|
||||||
|
if got := authActions("snap"); len(got) != 2 {
|
||||||
|
t.Fatalf("auth actions = %#v", got)
|
||||||
|
}
|
||||||
|
if got := runtimeActions("snap"); len(got) != 1 {
|
||||||
|
t.Fatalf("runtime actions = %#v", got)
|
||||||
|
}
|
||||||
|
if got := actionsForMethod("tools/call", "snap"); len(got) == 0 {
|
||||||
|
t.Fatal("tools/call actions empty")
|
||||||
|
}
|
||||||
|
if got := actionsForMethod("tools/list", "snap"); len(got) == 0 {
|
||||||
|
t.Fatal("tools/list actions empty")
|
||||||
|
}
|
||||||
if err := jsonrpcEnvelopeError("tools/list", &RPCError{Code: -1, Message: "failed"}, "", "header-trace"); err == nil {
|
if err := jsonrpcEnvelopeError("tools/list", &RPCError{Code: -1, Message: "failed"}, "", "header-trace"); err == nil {
|
||||||
t.Fatal("JSON-RPC trace fallback returned nil")
|
t.Fatal("JSON-RPC trace fallback returned nil")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -642,9 +642,9 @@ expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "corpId:userId"
|
|||||||
expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
|
expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
|
||||||
expect_contains "$MONO_HOME/.codex/skills/dws/references/global-reference.md" "userId/userName"
|
expect_contains "$MONO_HOME/.codex/skills/dws/references/global-reference.md" "userId/userName"
|
||||||
capture "$OUT_DIR/skill-multi-setup.txt" env HOME="$MULTI_HOME" "$BIN" skill setup --mode multi --target codex --source "$ROOT" --yes
|
capture "$OUT_DIR/skill-multi-setup.txt" env HOME="$MULTI_HOME" "$BIN" skill setup --mode multi --target codex --source "$ROOT" --yes
|
||||||
expect_contains "$MULTI_HOME/.codex/skills/dws-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
|
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
|
||||||
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-profile/SKILL.md" "corpId:userId"
|
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "corpId:userId"
|
||||||
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-profile/SKILL.md" "isOrgCurrent"
|
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "isOrgCurrent"
|
||||||
|
|
||||||
log "verifying empty profile list"
|
log "verifying empty profile list"
|
||||||
capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json
|
capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ GO_PATH = ROOT / "internal" / "shortcut" / "public_catalog_generated.go"
|
|||||||
CATALOG_PATH = ROOT / "docs" / "shortcut-public-catalog.json"
|
CATALOG_PATH = ROOT / "docs" / "shortcut-public-catalog.json"
|
||||||
FOLLOWUP_MD_PATH = ROOT / "docs" / "shortcut-real-test-followups.md"
|
FOLLOWUP_MD_PATH = ROOT / "docs" / "shortcut-real-test-followups.md"
|
||||||
FOLLOWUP_JSON_PATH = ROOT / "docs" / "shortcut-real-test-followups.json"
|
FOLLOWUP_JSON_PATH = ROOT / "docs" / "shortcut-real-test-followups.json"
|
||||||
SEMANTIC_PATH = ROOT / "internal" / "shortcut" / "semantic_catalog.json"
|
SEMANTIC_PATHS = [
|
||||||
|
ROOT / "internal" / "shortcut" / "semantic_catalog.json",
|
||||||
|
ROOT / "internal" / "shortcut" / "semantic_catalog_doc.json",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def load(path: Path) -> dict[str, Any]:
|
def load(path: Path) -> dict[str, Any]:
|
||||||
@@ -107,44 +110,46 @@ def collect() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|||||||
# whether the current account happened to have a fixture for a real run.
|
# whether the current account happened to have a fixture for a real run.
|
||||||
# Keep the real-run rows as evidence/follow-ups, but publish Chat entries
|
# Keep the real-run rows as evidence/follow-ups, but publish Chat entries
|
||||||
# exclusively from the reviewed semantic catalog.
|
# exclusively from the reviewed semantic catalog.
|
||||||
semantic = load(SEMANTIC_PATH)
|
semantics = [load(path) for path in SEMANTIC_PATHS]
|
||||||
service = semantic.get("service") or ""
|
semantic_services = {semantic.get("service") or "" for semantic in semantics}
|
||||||
if service != "chat":
|
if "" in semantic_services or len(semantic_services) != len(semantics):
|
||||||
raise ValueError(f"unexpected semantic catalog service: {service!r}")
|
raise ValueError(f"invalid or duplicate semantic catalog services: {semantic_services!r}")
|
||||||
public = [row for row in evidence_public if row["service"] != service]
|
public = [row for row in evidence_public if row["service"] not in semantic_services]
|
||||||
for command, record in semantic.get("shortcuts", {}).items():
|
for semantic in semantics:
|
||||||
if not record.get("public"):
|
service = semantic["service"]
|
||||||
continue
|
for command, record in semantic.get("shortcuts", {}).items():
|
||||||
if not record.get("reviewed"):
|
if not record.get("public"):
|
||||||
raise ValueError(f"public semantic shortcut is not reviewed: {command}")
|
continue
|
||||||
availability = (
|
if not record.get("reviewed"):
|
||||||
record.get("availability")
|
raise ValueError(f"public semantic shortcut is not reviewed: {service} {command}")
|
||||||
or semantic.get("default_availability")
|
availability = (
|
||||||
or ""
|
record.get("availability")
|
||||||
)
|
or semantic.get("default_availability")
|
||||||
if availability != "available":
|
or ""
|
||||||
raise ValueError(
|
|
||||||
f"public semantic shortcut is not available: {command}={availability}"
|
|
||||||
)
|
)
|
||||||
observed = evidence_by_key.get((service, command), {})
|
if availability != "available":
|
||||||
risk = record.get("risk") or ""
|
raise ValueError(
|
||||||
if not risk:
|
f"public semantic shortcut is not available: {service} {command}={availability}"
|
||||||
raise ValueError(f"public semantic shortcut lacks reviewed risk: {command}")
|
)
|
||||||
if observed.get("risk") and observed["risk"] != risk:
|
observed = evidence_by_key.get((service, command), {})
|
||||||
raise ValueError(
|
risk = record.get("risk") or ""
|
||||||
f"semantic shortcut risk drift: {command}: "
|
if not risk:
|
||||||
f"reviewed={risk} observed={observed['risk']}"
|
raise ValueError(f"public semantic shortcut lacks reviewed risk: {service} {command}")
|
||||||
)
|
if observed.get("risk") and observed["risk"] != risk:
|
||||||
public.append({
|
raise ValueError(
|
||||||
"suite": "semantic",
|
f"semantic shortcut risk drift: {service} {command}: "
|
||||||
"service": service,
|
f"reviewed={risk} observed={observed['risk']}"
|
||||||
"command": command,
|
)
|
||||||
"risk": risk,
|
public.append({
|
||||||
"status": "reviewed_available",
|
"suite": "semantic",
|
||||||
"disposition": record.get("disposition") or "",
|
"service": service,
|
||||||
"semantic_delta": record.get("semantic_delta") or "",
|
"command": command,
|
||||||
"availability": availability,
|
"risk": risk,
|
||||||
})
|
"status": "reviewed_available",
|
||||||
|
"disposition": record.get("disposition") or "",
|
||||||
|
"semantic_delta": record.get("semantic_delta") or "",
|
||||||
|
"availability": availability,
|
||||||
|
})
|
||||||
public.sort(key=lambda r: (r["service"], r["command"]))
|
public.sort(key=lambda r: (r["service"], r["command"]))
|
||||||
followups.sort(key=lambda r: (r["suite"], r["service"], r["command"]))
|
followups.sort(key=lambda r: (r["suite"], r["service"], r["command"]))
|
||||||
return public, followups
|
return public, followups
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user