diff --git a/.changes/1006-aitable-pagination-minutes-unshare.md b/.changes/1006-aitable-pagination-minutes-unshare.md new file mode 100644 index 00000000..3275bd7a --- /dev/null +++ b/.changes/1006-aitable-pagination-minutes-unshare.md @@ -0,0 +1,11 @@ +--- +category: Fixed +--- + +- **Aitable pagination and Minutes unshare verification** (#1006) — keeps + record queries on the service's 20-record page boundary so multi-page reads + and mutation readbacks no longer report false retryable failures, preserves + `totalCount` when supplied, validates `--dry-run` plans before transport, + follows active deletion readback continuations before proving absence, and + rejects Minutes unshare success until the listening note exists and the + service acknowledges the exact task and member targets. diff --git a/.changes/960-doc-readback-verification.md b/.changes/960-doc-readback-verification.md new file mode 100644 index 00000000..3dd1f89c --- /dev/null +++ b/.changes/960-doc-readback-verification.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result. diff --git a/.changes/released/1.0.59-beta.2/1009-clitrack-telemetry.md b/.changes/released/1.0.59-beta.2/1009-clitrack-telemetry.md new file mode 100644 index 00000000..f5289dce --- /dev/null +++ b/.changes/released/1.0.59-beta.2/1009-clitrack-telemetry.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting. diff --git a/.changes/released/1.0.59-beta.2/1019-help-feedback-entry.md b/.changes/released/1.0.59-beta.2/1019-help-feedback-entry.md new file mode 100644 index 00000000..72602203 --- /dev/null +++ b/.changes/released/1.0.59-beta.2/1019-help-feedback-entry.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form. diff --git a/.changes/954-chat-im-id-flags.md b/.changes/released/1.0.59-beta.2/954-chat-im-id-flags.md similarity index 100% rename from .changes/954-chat-im-id-flags.md rename to .changes/released/1.0.59-beta.2/954-chat-im-id-flags.md diff --git a/.changes/released/1.0.59-beta.2/chat-reference-card-verification.md b/.changes/released/1.0.59-beta.2/chat-reference-card-verification.md new file mode 100644 index 00000000..e1d4ec14 --- /dev/null +++ b/.changes/released/1.0.59-beta.2/chat-reference-card-verification.md @@ -0,0 +1,6 @@ +--- +category: Fixed +--- + +- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write. +- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent. diff --git a/.changes/released/1.0.59-beta.2/schema-catalog-json-projection.md b/.changes/released/1.0.59-beta.2/schema-catalog-json-projection.md new file mode 100644 index 00000000..21a5b1a6 --- /dev/null +++ b/.changes/released/1.0.59-beta.2/schema-catalog-json-projection.md @@ -0,0 +1,8 @@ +--- +category: Changed +--- + +- **Faster Schema Catalog assembly** — projects typed values into payload JSON + without re-running a validation scan over documents `json.Marshal` has just + produced, cutting roughly a third of the projection work across the full tool + set. Untrusted JSON input keeps its existing validation. diff --git a/.changes/released/1.0.59-beta.2/wiki-shortcut-alignment.md b/.changes/released/1.0.59-beta.2/wiki-shortcut-alignment.md new file mode 100644 index 00000000..44a9e6db --- /dev/null +++ b/.changes/released/1.0.59-beta.2/wiki-shortcut-alignment.md @@ -0,0 +1,9 @@ +--- +category: Added +--- + +- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and + activity shortcuts with strict collection validation, cursor handling, + write-terminal evidence, safe read-backs where the backend supports them, + task-oriented routing, and documented backend + boundaries. diff --git a/.changes/sheet-source-range-dropdowns.md b/.changes/sheet-source-range-dropdowns.md new file mode 100644 index 00000000..91159e61 --- /dev/null +++ b/.changes/sheet-source-range-dropdowns.md @@ -0,0 +1,6 @@ +--- +category: Added +--- + +- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported. +- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads. diff --git a/.changes/windows-event-bus-lifecycle.md b/.changes/windows-event-bus-lifecycle.md new file mode 100644 index 00000000..f158d0ad --- /dev/null +++ b/.changes/windows-event-bus-lifecycle.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c1007d..dc437165 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,6 +151,10 @@ jobs: filename.startsWith('scripts/') || filename.startsWith('verify/') || filename.startsWith('internal/helpers/') || + // Shortcut declarations feed the live command tree and Schema + // assembly. Their reverse dependencies include the expensive + // app and generator packages, which must run in separate shards. + filename.startsWith('internal/shortcut/') || filename.startsWith('internal/generator/') || filename.startsWith('internal/cli/schema') || // Parameter aliases are reduced against the live command tree. @@ -509,11 +513,38 @@ jobs: run: node .github/reviewer-routing.test.js test-focused: - name: Test (changed packages) + name: "Test (focused: ${{ matrix.shard }})" needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }} runs-on: ubuntu-latest + # Each shard owns one bounded slice of the impacted set, so no single job + # carries internal/app together with every reverse dependency. The shard + # list and per-shard execution below mirror test-race, which runs the same + # shards at full-suite scope; release-scripts is included because its + # dedicated job only runs at full-suite or release-sensitive scope, and + # dropping it here would stop testing test/scripts changes entirely. + # internal/app is carried by one shard per bounded partition rather than a + # single app shard: the partitions used to run end to end inside one job, + # where the Schema partition alone owned most of the wall clock. The + # app- names are pinned to the helper's partition set by + # TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose + # its job silently. timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: + - app-schema + - app-a-b + - app-c + - app-d-r + - app-s-z-example-fuzz + - generators + - helpers + - cli + - smoke + - remaining + - release-scripts steps: - name: Check out repository uses: actions/checkout@v4 @@ -542,37 +573,110 @@ jobs: with: go-version-file: go.mod - - name: Test changed packages and reverse dependencies + - name: Select impacted packages for shard + id: select + shell: bash + env: + TEST_SHARD: ${{ matrix.shard }} + run: | + set -euo pipefail + # Every app partition shard tests the same single internal/app + # package, so the impacted-package query uses the base shard name and + # the partition only selects which tests run. + package_shard="$TEST_SHARD" + case "$TEST_SHARD" in + app-*) package_shard=app ;; + esac + package_output="$( + ./scripts/ci/changed-test-packages.sh \ + list-shard "$package_shard" "$TEST_BASE_REF" "$TEST_HEAD_REF" + )" + if [ -z "$package_output" ]; then + echo "No buildable Go package in shard $TEST_SHARD is affected by this revision." \ + >> "$GITHUB_STEP_SUMMARY" + echo "affected=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + # The package list travels through a file rather than a step output: + # reading it with `mapfile < file` has unambiguous line semantics, + # whereas a here-string over a multi-line output would append an extra + # empty element if the value ever carried a trailing newline, and an + # empty element would reach go test as an empty package argument. + printf '%s\n' "$package_output" > "$RUNNER_TEMP/focused-shard-packages.txt" + echo "affected=true" >> "$GITHUB_OUTPUT" + + - name: Build + if: ${{ matrix.shard == 'remaining' && steps.select.outputs.affected == 'true' }} + run: make build + + - name: Install archive tooling + if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }} + run: sudo apt-get update && sudo apt-get install -y zip unzip + + - name: Test shard with Race Detection + if: ${{ steps.select.outputs.affected == 'true' }} shell: bash env: DWS_PACKAGE_VERSION: 0.0.0-test + TEST_SHARD: ${{ matrix.shard }} run: | set -euo pipefail - package_output="$( - ./scripts/ci/changed-test-packages.sh \ - list "$TEST_BASE_REF" "$TEST_HEAD_REF" - )" - if [ -z "$package_output" ]; then - echo "No buildable Go package is affected by this revision." \ - >> "$GITHUB_STEP_SUMMARY" + mapfile -t packages < "$RUNNER_TEMP/focused-shard-packages.txt" + test "${#packages[@]}" -gt 0 + for package in "${packages[@]}"; do + test -n "$package" || { + echo "shard package list contains an empty entry" >&2 + exit 1 + } + done + case "$TEST_SHARD" in + app-*) + # A single long-lived app test process retains every constructed + # command tree in framework registries. Each partition is its own + # job, so that state is released when the process exits and the + # partitions run concurrently instead of end to end. The helper + # still verifies that the partition patterns cover every top-level + # test exactly once before running the one it was asked for. + test "${#packages[@]}" -eq 1 + ./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}" + exit 0 + ;; + esac + if [ "$TEST_SHARD" = "release-scripts" ]; then + # Mirror the dedicated release-contract job: these suites shell out + # to archive tooling and are not race-instrumented there. + go test -v -count=1 -timeout=10m "${packages[@]}" exit 0 fi - mapfile -t packages <<< "$package_output" - go test -v -race -count=1 -timeout=15m "${packages[@]}" + # cli/smoke own heavy NewRootCommand / Schema assembly under -race; + # give them a dedicated package timeout on slower hosted runners. + timeout_budget=12m + if [ "$TEST_SHARD" = "cli" ] || + [ "$TEST_SHARD" = "smoke" ]; then + timeout_budget=15m + fi + go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}" test-race: name: "Test (race: ${{ matrix.shard }})" needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }} runs-on: ubuntu-latest - # app runs several independently bounded processes; cli/smoke need headroom - # beyond go test -timeout for setup + assembly. + # internal/app is split across one shard per bounded partition so the + # partitions run concurrently and each releases its framework registries + # when the process exits; cli/smoke need headroom beyond go test -timeout for + # setup + assembly. The app- names are pinned to the helper's + # partition set by TestCIAppRacePartitionMatrixMatchesHelper. timeout-minutes: 20 strategy: fail-fast: false matrix: shard: - - app + - app-schema + - app-a-b + - app-c + - app-d-r + - app-s-z-example-fuzz - generators - helpers - cli @@ -598,18 +702,30 @@ jobs: TEST_SHARD: ${{ matrix.shard }} run: | set -euo pipefail - package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")" + # Every app partition shard tests the same single internal/app + # package, so the package query uses the base shard name and the + # partition only selects which tests run. + package_shard="$TEST_SHARD" + case "$TEST_SHARD" in + app-*) package_shard=app ;; + esac + package_output="$(./scripts/ci/test-packages.sh list "$package_shard")" test -n "$package_output" mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 - if [ "$TEST_SHARD" = "app" ]; then - # A single long-lived app test process retains every constructed - # command tree in framework registries. Isolate Schema assembly and - # bounded name ranges so each process releases that state on exit. - test "${#packages[@]}" -eq 1 - ./scripts/ci/run-app-race-tests.sh run "${packages[0]}" - exit 0 - fi + case "$TEST_SHARD" in + app-*) + # A single long-lived app test process retains every constructed + # command tree in framework registries. Each partition is its own + # job, so that state is released when the process exits and the + # partitions run concurrently instead of end to end. The helper + # still verifies that the partition patterns cover every top-level + # test exactly once before running the one it was asked for. + test "${#packages[@]}" -eq 1 + ./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}" + exit 0 + ;; + esac # cli/smoke own heavy NewRootCommand / Schema assembly under -race; # give them a dedicated package timeout on slower hosted runners. timeout_budget=12m @@ -711,7 +827,7 @@ jobs: failed=0 if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then for shard in \ - "changed packages:$FOCUSED_RESULT" \ + "focused shards:$FOCUSED_RESULT" \ "race shards:$RACE_RESULT" \ "release scripts:$RELEASE_SCRIPTS_RESULT" \ "cross-platform compile:$CROSS_PLATFORM_RESULT" \ @@ -740,7 +856,7 @@ jobs: release_expected=success fi for shard in \ - "changed packages:$FOCUSED_RESULT:$focused_expected" \ + "focused shards:$FOCUSED_RESULT:$focused_expected" \ "race shards:$RACE_RESULT:$race_expected" \ "release scripts:$RELEASE_SCRIPTS_RESULT:$release_expected" \ "cross-platform compile:$CROSS_PLATFORM_RESULT:success" @@ -911,7 +1027,7 @@ jobs: coverage-current: name: Coverage (current) needs: lint - if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }} + if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -926,10 +1042,6 @@ jobs: with: go-version-file: go.mod - - name: Install archive tooling - if: needs.lint.outputs.full_suite == 'true' - run: sudo apt-get update && sudo apt-get install -y zip unzip - - name: Resolve authoritative coverage base env: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} @@ -951,41 +1063,33 @@ jobs: - name: Build run: make build - - name: Run current unit tests with coverage + - name: Run scoped unit tests with coverage shell: bash env: DWS_PACKAGE_VERSION: 0.0.0-test - FULL_SUITE: ${{ needs.lint.outputs.full_suite }} run: | set -euo pipefail - if [ "$FULL_SUITE" = true ]; then + changed_output="$( + ./scripts/ci/changed-test-packages.sh \ + changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF" + )" + impacted_output="$( + ./scripts/ci/changed-test-packages.sh \ + list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF" + )" + if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then + printf 'mode: atomic\n' > coverage.txt + echo "No buildable Go package needs scoped coverage." \ + >> "$GITHUB_STEP_SUMMARY" + else + mapfile -t changed_packages <<< "$changed_output" + mapfile -t impacted_packages <<< "$impacted_output" + coverpkg="$(IFS=,; echo "${changed_packages[*]}")" go test -count=1 -p 1 \ + -coverpkg="$coverpkg" \ -coverprofile=coverage.txt \ -covermode=atomic \ - ./ ./cmd/... ./internal/... ./skills/... - else - changed_output="$( - ./scripts/ci/changed-test-packages.sh \ - changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF" - )" - impacted_output="$( - ./scripts/ci/changed-test-packages.sh \ - list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF" - )" - if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then - printf 'mode: atomic\n' > coverage.txt - echo "No buildable Go package needs scoped coverage." \ - >> "$GITHUB_STEP_SUMMARY" - else - mapfile -t changed_packages <<< "$changed_output" - mapfile -t impacted_packages <<< "$impacted_output" - coverpkg="$(IFS=,; echo "${changed_packages[*]}")" - go test -count=1 -p 1 \ - -coverpkg="$coverpkg" \ - -coverprofile=coverage.txt \ - -covermode=atomic \ - "${impacted_packages[@]}" - fi + "${impacted_packages[@]}" fi if [ "$(wc -l < coverage.txt)" -gt 1 ]; then go tool cover -func=coverage.txt @@ -998,6 +1102,66 @@ jobs: path: coverage.txt retention-days: 1 + coverage-current-full: + name: "Coverage (current: ${{ matrix.shard }})" + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: + - app + - cli + - generators + - helpers + - remaining + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install archive tooling + run: sudo apt-get update && sudo apt-get install -y zip unzip + + - name: Build + run: make build + + # Each shard keeps -p 1 so the authoritative measurement stays serial + # inside one instrumented process group; shards run on isolated runners, + # and scripts/ci/test-packages.sh verify proves the shard union equals + # the previous single full-suite package set exactly once. + - name: Run current shard tests with coverage + shell: bash + env: + DWS_PACKAGE_VERSION: 0.0.0-test + COVERAGE_SHARD: ${{ matrix.shard }} + run: | + set -euo pipefail + package_output="$(./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD")" + test -n "$package_output" + mapfile -t packages <<< "$package_output" + test "${#packages[@]}" -gt 0 + go test -count=1 -p 1 \ + -coverprofile="coverage-shard-$COVERAGE_SHARD.txt" \ + -covermode=atomic \ + "${packages[@]}" + go tool cover -func="coverage-shard-$COVERAGE_SHARD.txt" | tail -n 1 + + - name: Upload current shard coverage profile + uses: actions/upload-artifact@v4 + with: + name: coverage-current-shard-${{ matrix.shard }} + path: coverage-shard-${{ matrix.shard }}.txt + retention-days: 1 + coverage-supporting: name: Coverage (supporting) needs: lint @@ -1051,14 +1215,11 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Set up Go + id: setup-go uses: actions/setup-go@v5 with: go-version-file: go.mod - - name: Install archive tooling - if: needs.lint.outputs.full_suite == 'true' - run: sudo apt-get update && sudo apt-get install -y zip unzip - - name: Resolve authoritative coverage base env: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} @@ -1076,7 +1237,34 @@ jobs: git rev-parse --verify "${base_ref}^{commit}" >/dev/null echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV" + # The merge-base full-suite profile is a pure function of the base + # commit. Reuse the profile published by the last green push run of + # exactly that commit instead of re-running the whole suite; any key + # mismatch falls back to authoritative recomputation. Exact key only, + # never prefix fallback: a near-miss profile would compare the + # candidate against the wrong commit. + - name: Restore cached merge-base coverage profile + id: baseline-cache + if: needs.lint.outputs.full_suite == 'true' + uses: actions/cache/restore@v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }} + + - name: Materialize cached merge-base coverage profile + if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit == 'true' + run: | + set -eu + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + cp coverage-cache.txt coverage-base.txt + + - name: Install archive tooling + if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true' + run: sudo apt-get update && sudo apt-get install -y zip unzip + - name: Run baseline unit tests with coverage + if: steps.baseline-cache.outputs.cache-hit != 'true' shell: bash env: DWS_PACKAGE_VERSION: 0.0.0-test @@ -1125,6 +1313,21 @@ jobs: fi ) + - name: Prepare merge-base coverage profile cache + if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true' + run: | + set -eu + test -s coverage-base.txt + test "$(head -n 1 coverage-base.txt)" = "mode: atomic" + cp coverage-base.txt coverage-cache.txt + + - name: Save merge-base coverage profile cache + if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }} + - name: Upload baseline coverage profile uses: actions/upload-artifact@v4 with: @@ -1137,6 +1340,7 @@ jobs: needs: - lint - coverage-current + - coverage-current-full - coverage-supporting - coverage-baseline - coverage-darwin @@ -1152,6 +1356,7 @@ jobs: FULL_SUITE: ${{ needs.lint.outputs.full_suite }} PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }} CURRENT_RESULT: ${{ needs.coverage-current.result }} + CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }} SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }} BASELINE_RESULT: ${{ needs.coverage-baseline.result }} DARWIN_RESULT: ${{ needs.coverage-darwin.result }} @@ -1159,6 +1364,7 @@ jobs: run: | failed=0 current_expected=success + current_full_expected=skipped supporting_expected=skipped baseline_expected=success native_expected=skipped @@ -1166,6 +1372,8 @@ jobs: current_expected=skipped baseline_expected=skipped elif [ "$FULL_SUITE" = true ]; then + current_expected=skipped + current_full_expected=success supporting_expected=success fi if [ "$CHANGELOG_ONLY" != true ] && @@ -1176,6 +1384,7 @@ jobs: for profile in \ "current:$CURRENT_RESULT:$current_expected" \ + "current shards:$CURRENT_FULL_RESULT:$current_full_expected" \ "supporting:$SUPPORTING_RESULT:$supporting_expected" \ "baseline:$BASELINE_RESULT:$baseline_expected" do @@ -1211,6 +1420,7 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Set up Go + id: setup-go if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' uses: actions/setup-go@v5 with: @@ -1234,11 +1444,12 @@ jobs: git rev-parse --verify "${base_ref}^{commit}" >/dev/null echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV" - - name: Download current coverage profile + - name: Download current coverage profiles if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' uses: actions/download-artifact@v4 with: - name: coverage-current-profile + pattern: coverage-current-* + merge-multiple: true path: . - name: Download supporting coverage profiles @@ -1255,6 +1466,26 @@ jobs: name: coverage-baseline-profile path: . + # Shard profiles cover disjoint package sets, so their block-level + # concatenation is the same candidate profile one serial run produced. + # Every expected shard must be present; a missing shard would silently + # shrink the scope-matched overall comparison. + - name: Assemble full-suite coverage profile + if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' + shell: bash + run: | + set -euo pipefail + test ! -f coverage.txt + for shard in app cli generators helpers remaining; do + profile="coverage-shard-$shard.txt" + test -f "$profile" + test "$(head -n 1 "$profile")" = "mode: atomic" + done + printf 'mode: atomic\n' > coverage.txt + for shard in app cli generators helpers remaining; do + tail -n +2 "coverage-shard-$shard.txt" >> coverage.txt + done + - name: Enforce coverage gate if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' env: @@ -1275,6 +1506,26 @@ jobs: COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \ make coverage-gate BASE_REF="$COVERAGE_BASE_REF" + # Publish this push's full-suite profile as the merge-base cache for + # future PRs whose merge-base is exactly this commit. Saved only after + # the gate passed so a broken run never becomes a baseline. Both producer + # and consumer use coverage-cache.txt because the cache version includes + # the configured path as well as the compression tool. + - name: Prepare push coverage profile as merge-base cache + if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' + run: | + set -eu + test -s coverage.txt + test "$(head -n 1 coverage.txt)" = "mode: atomic" + cp coverage.txt coverage-cache.txt + + - name: Save push coverage profile as merge-base cache + if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' + uses: actions/cache/save@v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }} + - name: Generate coverage report if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' run: | diff --git a/.gitignore b/.gitignore index 2f5bbe69..dfb5ee3d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ test/cli_compat/testdata/ .gitignore .worktrees/ .qoder/ +_logs/ +_docs/ +_output/ +vendor/ # Secrets & credentials .env diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a1f2dfc..9afe33b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.59-beta.2] - 2026-08-17 + +### Added + +- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting. + +- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form. + +- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and + activity shortcuts with strict collection validation, cursor handling, + write-terminal evidence, safe read-backs where the backend supports them, + task-oriented routing, and documented backend + boundaries. + +### Changed + +- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags. +- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces. +- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name. + +- **Faster Schema Catalog assembly** — projects typed values into payload JSON + without re-running a validation scan over documents `json.Marshal` has just + produced, cutting roughly a third of the projection work across the full tool + set. Untrusted JSON input keeps its existing validation. + +### Fixed + +- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write. +- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent. + + ## [1.0.59-beta.1] - 2026-08-14 ### Added diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 98fc206f..1d76aa1f 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCliBeta < Formula desc "Automate DingTalk workspace tasks from the terminal (beta channel)" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.59-beta.1" + version "1.0.59-beta.2" license "Apache-2.0" keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-arm64.tar.gz" - sha256 "36a30f3496e0f759c15c0b09f67dbd23b8ecdfff2eebe572f88125b26485830f" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-arm64.tar.gz" + sha256 "7f11218d3222f3e93c3b1e94b3a004c061eb0a297b206447ea95fe6b2b1ec674" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-amd64.tar.gz" - sha256 "e7a04906380efd8da88cd112e6a512bb6470a3956dc370150037ed6e314db445" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-amd64.tar.gz" + sha256 "72f06a334cf29d23123639fabe13bf2951765066136e47ccc6453667c382f8c2" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-arm64.tar.gz" - sha256 "f59ab055f3e841e4cebc964ae3ef969668475548abaaf8bede44afdca9a3e28d" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-arm64.tar.gz" + sha256 "3edbfabb7718b53914a2d9efe7b1152e9ebd70765ff0b6f19ad3125e4a2458ed" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-amd64.tar.gz" - sha256 "2c8f919489d958c7d49262615e81faac70a9fbcae2d589ab54a0bb3c5700a057" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-amd64.tar.gz" + sha256 "e1c610070a9c1b3763656cbd53c818290f199097adc07cb9fe629ed765c5e1e0" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-skills.zip" - sha256 "25f4a7e1d01fa4d771d79201b34b11ee8a24182bdcdc94bfb98d2bd5845bed3b" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-skills.zip" + sha256 "aa2854651eaa2c857b526aaec73fd1358d31b11999fe7fd3e99e5060b2522a1f" end def install diff --git a/Makefile b/Makefile index 75b4356a..73913b28 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ help: @printf "Available targets:\n" @printf " make build - Build the dws CLI binary\n" @printf " make test - Run the Go test suite\n" - @printf " make test-plan - Verify every default Go package belongs to one CI test shard\n" + @printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n" @printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n" @printf " make lint - Run formatting checks, go vet, and staticcheck\n" @printf " make format-check - Check all repository Go source files with gofmt\n" diff --git a/cmd/main.go b/cmd/main.go index 4ac7a562..f5ed1cc9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -15,12 +15,76 @@ package main import ( "os" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack" ) -var exit = os.Exit +var ( + appExecute = app.ExecuteWithTelemetry + resolveTelemetryIdentity = app.ResolveTelemetryIdentity + trackRun = func(cfg clitrack.Config, execute func() error, exitCode func(error) int) { + clitrack.New(cfg).Run(execute, exitCode) + } +) + +// trackedExitError tells clitrack that the command failed without asking it to +// print the error a second time. The already-rendered message is published via +// ExtraFields c5, while app.Execute remains the sole owner of presentation. +type trackedExitError struct{} + +func (trackedExitError) Error() string { return "" } + +func trackerConfig(identity app.TelemetryIdentity, commandPath, errorMessage *string) clitrack.Config { + return clitrack.Config{ + PID: "wcCRwZ", + App: "dws", + Version: app.RawVersion(), + UID: identity.UserID, + Username: identity.UserName, + NoCommandLine: true, + NoCwd: true, + NoAutomaticDimensions: true, + ExtraFields: func() map[string]string { + fields := map[string]string{"c9": *commandPath} + if identity.CorpID != "" { + fields["c10"] = identity.CorpID + } + if *errorMessage != "" { + fields["c5"] = *errorMessage + } + return fields + }, + } +} + +func telemetryOptedOut() bool { + return strings.TrimSpace(os.Getenv("DO_NOT_TRACK")) != "" +} func main() { - exit(app.Execute()) + optedOut := telemetryOptedOut() + identity := app.TelemetryIdentity{} + if !optedOut { + identity = resolveTelemetryIdentity(os.Args[1:]) + } + exitCode := 0 + commandPath := "dws" + errorMessage := "" + cfg := trackerConfig(identity, &commandPath, &errorMessage) + if optedOut { + cfg.PID = "" + } + trackRun( + cfg, + func() error { + exitCode, commandPath, errorMessage = appExecute() + if exitCode != 0 { + return trackedExitError{} + } + return nil + }, + func(error) int { return exitCode }, + ) } diff --git a/cmd/main_test.go b/cmd/main_test.go index 6b9fd350..babd7aae 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -1,27 +1,220 @@ package main import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" "os" + "slices" + "sort" + "strings" "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack" ) -func TestCrossPlatformCoverageMainExitsWithSuccessfulVersionCommand(t *testing.T) { - previousExit := exit - previousArgs := os.Args - t.Cleanup(func() { - exit = previousExit - os.Args = previousArgs - }) +func TestCrossPlatformCoverageMainRunsThroughCLITracker(t *testing.T) { + for _, wantCode := range []int{0, 1, 3, 5} { + t.Run(fmt.Sprintf("exit_%d", wantCode), func(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + wantError := "" + if wantCode != 0 { + wantError = "synthetic failure" + } + testseam.Swap(t, &os.Args, []string{"dws", "sheet", "read", "--profile", "corp-a"}) + testseam.Swap(t, &resolveTelemetryIdentity, func(args []string) app.TelemetryIdentity { + if strings.Join(args, " ") != "sheet read --profile corp-a" { + t.Fatalf("telemetry identity args = %#v", args) + } + return app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"} + }) + testseam.Swap(t, &appExecute, func() (int, string, string) { return wantCode, "sheet read", wantError }) + called := false + testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) { + called = true + if cfg.PID != "wcCRwZ" || cfg.App != "dws" { + t.Fatalf("tracker identity = PID %q App %q", cfg.PID, cfg.App) + } + if cfg.Version != app.RawVersion() { + t.Fatalf("tracker Version = %q, want %q", cfg.Version, app.RawVersion()) + } + if !cfg.NoCommandLine || !cfg.NoCwd || !cfg.NoAutomaticDimensions || cfg.CaptureOutput { + t.Fatalf("tracker privacy config = NoCommandLine %v NoCwd %v NoAutomaticDimensions %v CaptureOutput %v", cfg.NoCommandLine, cfg.NoCwd, cfg.NoAutomaticDimensions, cfg.CaptureOutput) + } + if cfg.Env != "" || cfg.EventID != "" || cfg.Endpoint != "" || cfg.FlushTimeout != 0 || cfg.OutputMaxLen != 0 { + t.Fatalf("tracker SDK defaults were overridden: %#v", cfg) + } + if cfg.UID != "user-1" || cfg.Username != "Alice" || cfg.UserType != "" { + t.Fatalf("tracker user identity = UID %q Username %q UserType %q", cfg.UID, cfg.Username, cfg.UserType) + } + err := execute() + if wantCode == 0 && err != nil { + t.Fatalf("successful tracked execute error = %v", err) + } + if wantCode != 0 && (err == nil || err.Error() != "") { + t.Fatalf("failed tracked execute error = %#v, want empty sentinel", err) + } + if gotCode := exitCode(err); gotCode != wantCode { + t.Fatalf("tracked exit code = %d, want %d", gotCode, wantCode) + } + fields := cfg.ExtraFields() + if fields["c9"] != "sheet read" || fields["c10"] != "corp-1" || fields["c5"] != wantError { + t.Fatalf("tracker extra fields = %#v, want command path, corp ID, and error %q", fields, wantError) + } + if (wantError == "" && len(fields) != 2) || (wantError != "" && len(fields) != 3) { + t.Fatalf("tracker extra field count = %d for error %q", len(fields), wantError) + } + }) + + main() + if !called { + t.Fatalf("trackRun was not called for exit code %d", wantCode) + } + }) + } +} + +func TestCrossPlatformCoverageTrackerConfigOmitsEmptyOrganization(t *testing.T) { + commandPath := "version" + errorMessage := "" + cfg := trackerConfig(app.TelemetryIdentity{}, &commandPath, &errorMessage) + if cfg.UID != "" { + t.Fatalf("empty identity UID = %q", cfg.UID) + } + if cfg.Username != "" { + t.Fatalf("empty identity Username = %q", cfg.Username) + } + if fields := cfg.ExtraFields(); len(fields) != 1 || fields["c9"] != "version" { + t.Fatalf("empty organization fields = %#v", fields) + } +} + +func TestCrossPlatformCoverageDefaultTrackRunNoopTracker(t *testing.T) { called := false - code := -1 - exit = func(value int) { + trackRun(clitrack.Config{}, func() error { called = true - code = value + return nil + }, nil) + if !called { + t.Fatal("default tracker did not execute callback") } - os.Args = []string{"dws", "version"} +} + +func TestCrossPlatformCoverageMainRespectsDoNotTrack(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "1") + testseam.Swap(t, &os.Args, []string{"dws", "version"}) + testseam.Swap(t, &resolveTelemetryIdentity, func([]string) app.TelemetryIdentity { + t.Fatal("DO_NOT_TRACK must skip telemetry identity reads") + return app.TelemetryIdentity{} + }) + testseam.Swap(t, &appExecute, func() (int, string, string) { return 0, "version", "" }) + testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) { + if cfg.PID != "" || cfg.UID != "" || cfg.Username != "" { + t.Fatalf("opted-out tracker config = %#v", cfg) + } + if err := execute(); err != nil { + t.Fatalf("opted-out execution failed: %v", err) + } + if code := exitCode(nil); code != 0 { + t.Fatalf("opted-out exit code = %d, want 0", code) + } + }) + main() - if !called || code != 0 { - t.Fatalf("main exit = called %v, code %d", called, code) +} + +func TestCrossPlatformCoverageTrackerPayloadUsesReviewedFieldWhitelist(t *testing.T) { + testseam.Protect(t, &os.Args) + os.Args = []string{"dws", "sheet", "read", "--access-token", "must-not-leak"} + t.Setenv("SHELL", "/bin/zsh") + t.Setenv("TERM_SESSION_ID", "stable-session") + t.Setenv("TMUX_PANE", "%42") + t.Setenv("LANG", "zh_CN.UTF-8") + t.Setenv("LC_ALL", "zh_CN.UTF-8") + t.Chdir(t.TempDir()) + + requestBody := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + requestBody <- body + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + commandPath := "sheet read" + errorMessage := "" + cfg := trackerConfig(app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}, &commandPath, &errorMessage) + cfg.Endpoint = server.URL + cfg.FlushTimeout = time.Second + clitrack.New(cfg).Run(func() error { return nil }, nil) + + var body []byte + select { + case body = <-requestBody: + case <-time.After(time.Second): + t.Fatal("timed out waiting for telemetry request") + } + var envelope map[string]string + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatalf("decode telemetry request %q: %v", body, err) + } + decoded, err := url.QueryUnescape(envelope["gokey"]) + if err != nil { + t.Fatalf("decode gokey: %v", err) + } + globalFields, err := url.ParseQuery(decoded) + if err != nil { + t.Fatalf("parse global telemetry fields: %v", err) + } + eventFields, err := url.ParseQuery(globalFields.Get("msg")) + if err != nil { + t.Fatalf("parse event telemetry fields: %v", err) + } + + assertTelemetryKeys(t, globalFields, []string{"app_name", "app_version", "env", "msg", "pid", "platform", "uid", "username", "version"}) + assertTelemetryKeys(t, eventFields, []string{"c1", "c10", "c3", "c4", "c9", "p1", "p4", "ts", "type"}) + for key, want := range map[string]string{ + "app_name": "dws", "app_version": app.RawVersion(), "env": "prod", "pid": "wcCRwZ", + "platform": "cli", "uid": "user-1", "username": "Alice", "version": app.RawVersion(), + } { + if got := globalFields.Get(key); got != want { + t.Fatalf("global telemetry field %s = %q, want %q", key, got, want) + } + } + for key, want := range map[string]string{ + "type": "event", "p1": "cli.exec", "p4": "SYS", "c1": "dws", "c3": "0", "c9": "sheet read", "c10": "corp-1", + } { + if got := eventFields.Get(key); got != want { + t.Fatalf("event telemetry field %s = %q, want %q", key, got, want) + } + } + for _, key := range []string{"device_id", "ext", "os", "os_version", "pv_id", "sdk_version", "sid", "timezone_offset"} { + if globalFields.Has(key) { + t.Fatalf("global telemetry leaked %s: %q", key, decoded) + } + } + for _, key := range []string{"c2", "c5", "c6", "c7", "c8"} { + if eventFields.Has(key) { + t.Fatalf("event telemetry leaked %s: %q", key, globalFields.Get("msg")) + } + } +} + +func assertTelemetryKeys(t *testing.T, fields url.Values, want []string) { + t.Helper() + got := make([]string, 0, len(fields)) + for key := range fields { + got = append(got, key) + } + sort.Strings(got) + if !slices.Equal(got, want) { + t.Fatalf("telemetry keys = %v, want %v", got, want) } } diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index 5244b214..d34f3679 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -201,8 +201,21 @@ base_ref=$(git merge-base HEAD origin/main) standard PR, CI derives changed packages and their reverse-dependency test closure, then generates candidate and merge-base profiles with the same test scope and `coverpkg`. High-risk and protected-main runs use the complete -profiles. Supporting and (when platform-selected) native profiles are -generated before the aggregate `Coverage` context evaluates them. The +profiles. The complete candidate profile is produced by disjoint per-shard +helper jobs (`scripts/ci/test-packages.sh list-coverage`, kept serial with +`-p 1` inside each shard; `verify` proves the shard union equals the +full-suite scope exactly once) and concatenated in the aggregate job before +enforcement. The complete merge-base profile is restored from an exact-key +cache written by the last green `main` push of that same commit (key: +merge-base SHA plus resolved Go version); any miss falls back to recomputing +it in a merge-base worktree. The trusted `main` producer and PR consumer use +the same dedicated cache profile path because GitHub includes that path in the +cache version; the runtime-facing candidate and baseline filenames remain +separate. Near-miss reuse is forbidden — the caches carry no prefix restore +keys, because a neighbouring commit's profile would compare the candidate +against the wrong baseline. Supporting and (when +platform-selected) native profiles are generated before the aggregate +`Coverage` context evaluates them. The aggregate and native gates require 100% coverage for changed executable Go statements. Overall coverage remains an unrounded, zero-tolerance, scope-matched merge-base non-regression check. Candidate and baseline profiles diff --git a/docs/flag-help-schema-homology.md b/docs/flag-help-schema-homology.md index 47b65466..591d543d 100644 --- a/docs/flag-help-schema-homology.md +++ b/docs/flag-help-schema-homology.md @@ -92,6 +92,7 @@ command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷 | `Required` / `MarkRequired` | 非空校验 / cobra 硬必填 | 是(`required`) | | `RequiredHint`, `Aliases`, `EnvVar` | 校验提示、隐藏别名、环境回退 | 否(执行细节;别名不上主 parameter 表) | | `ArgDefault`, `Bind`, `OmitEmpty`, `Trim`, `Transform` | toolArgs 装配语义 | 否(载荷细节;`Bind` 可进 property 映射,但不另造 flag) | +| `Input` | 额外取值来源:`@path` 读文件 / `-` 读 stdin,在 required/enum/约束/`Validate` 之前原地解析 | 否(今日:能力由作者写进 `Usage` / `SchemaDescription` 文案,是已声明事实而非推断;不另造 flag。目标形态收敛为类型化投影字段,见 RFC §5.3) | #### 1.2.2 编排 / 执行字段(不算声明) diff --git a/docs/rfc-command-framework-convergence.md b/docs/rfc-command-framework-convergence.md index fd66356d..cb12c50b 100644 --- a/docs/rfc-command-framework-convergence.md +++ b/docs/rfc-command-framework-convergence.md @@ -292,7 +292,7 @@ Definition(仅声明;不可编译) 下列字段**是**框架声明面(经 `corecmd.New` 生效并嵌入 `dws.schema.*`): -- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面) +- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面;`Input` 是取值来源声明,经 `corecmd.New` 生效但**不**嵌入 `dws.schema.*`,能力靠 `Usage` 文案声明,见 §5.3) - `Constraints` - **非空** `Risk`(空值 = 运行时当只读确认,且**不**嵌入 `dws.schema.risk`) - `ConstParams`(载荷声明;不上用户 flag 表) @@ -673,6 +673,52 @@ func (k Key[T]) Declare(opts ...FlagOption[T]) FlagSpec - 构造时拒绝 `InputSourceInvalid`。 - 当前没有任何 Shortcut 或 Leaf 声明 `Input`,因此 M1 增加能力且零上线表面变化。让现有命令采用它属于 §9 下的用户可见变更。 +`Input` 的框架能力今日已在 `corecmd` 落地(声明即执行的过渡形态,语义与上文目标一致),使用指南: + +**今日声明形态**:`FlagSpec.Input []string`,源常量 `corecmd.InputFile`(`"file"`)/ `corecmd.InputStdin`(`"stdin"`)。`helpers.LeafFlag` 是 `corecmd.FlagSpec` 别名,直接可用;`shortcut.Flag.Input` 同形声明,经 `FromShortcut` 映射到 `FlagSpec`。 + +```go +// LeafSpec / helpers +Flags: []helpers.LeafFlag{ + { + Name: "content", + Usage: "文档内容(支持 @文件路径 或 - 读 stdin)", + Bind: "content", + Input: []string{corecmd.InputFile, corecmd.InputStdin}, + }, +} + +// shortcut +Flags: []shortcut.Flag{ + {Name: "markdown", Desc: "Markdown 内容(支持 @文件路径 或 -)", + Input: []string{"file", "stdin"}}, +} +``` + +**运行时语义**(`resolveInputFlags`,在 `runDeclaredPreflight` 内、required/enum/约束/Validate 之前执行,原地改写 cobra flag 值): + +- `--flag @path`:文件内容替换取值;`--flag -`:stdin 内容替换取值。 +- `--flag @@value`:转义为字面 `@value`,不做来源解析。 +- 只解析显式 CLI token(主名或别名);EnvVar 回落与注册默认值透传不解析。 +- 内容前置剥离 UTF-8 BOM;`Trim` 等既有语义照常作用于解析后的值。 +- 读取失败、源不支持、`@` 后空路径都是类型化校验错误(退出码 3);同时声明两种源而文件读取失败时附 stdin 引导 hint。 + +**作者守则**: + +- 声明即全部能力:required/enum/约束/Validate 校验的已是解析后的真实内容,`Execute`/`Invoke` 无需任何额外代码。 +- `Usage`/`Desc` 必须写明支持 `@路径`/`-`;框架不自动改写 help 文案,今日也不向 Schema 投影(新增投影字段须先过 homology 评审,避免 catalog drift)。 +- `user_required` 确认的写命令若声明 `InputStdin`:stdin 在校验阶段被消费,交互确认将 fail-closed 为 `confirmation_required`,此类调用必须显式 `--yes`(或 `--dry-run`)。 +- **声明前先确认取值空间不会被前缀吃掉**:声明 `InputFile` 后,任何以 `@` 开头的合法值都会被当成文件路径(本产品尤其常见的是 at 提及类取值,如 `--at-user @zhangsan` 会报读取文件失败),用户只能改用 `@@` 转义;声明 `InputStdin` 后字面值 `-` 不可达(与 curl 等约定一致)。若该 flag 的正常取值可能命中这两种形态,就不要声明对应来源。 +- 声明在构造期校验(fail-closed panic):仅限 `KindString`;源值必须是 `file`/`stdin` 且不重复。 + +**今日实现与目标形态的差异**(迁移到本节目标 `FlagSpec` 时收敛): + +| 维度 | 今日 | 目标 | +|---|---|---| +| 源类型 | `[]string` 常量 | 类型化 `InputSource` | +| 路径边界 | 直接本地文件 IO | 复用 §5.5.2 本地文件 effect 边界 | +| Schema 投影 | 无(靠作者在 Usage 声明) | 声明即最终源,随 Catalog 透传 | + 核心 FlagSpec 故意没有: - `Bind`; diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 8e025520..7d447fc7 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-12T00:10:44.511794", - "count": 399, + "generated_at": "2026-08-14T13:50:36.324505", + "count": 418, "results": [ { "suite": "semantic", @@ -3659,11 +3659,204 @@ "status": "real-ok" }, { - "suite": "read", + "suite": "semantic", + "service": "wiki", + "command": "+delete-space", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "删除前读取影响目标,经高风险确认后只接受 success=true 终态。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+feed-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "补充知识库协作动态查询,严格验证 feeds 并保留游标。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+member-add", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+member-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+member-remove", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+member-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+move", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+move-to-drive", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+node-copy", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+node-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+node-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+node-get", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+node-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+node-search", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "补充库内关键词和扩展名搜索,并拒绝假空结果。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+resolve-space", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+space-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "创建后要求 workspaceId 并通过空间详情读回验证真实落库。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+space-get", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "补充知识库详情入口,并要求 workspaceId 业务证据。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+space-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。", + "availability": "available" + }, + { + "suite": "semantic", "service": "wiki", "command": "+space-search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+wiki-new-doc", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。", + "availability": "available" } ] } \ No newline at end of file diff --git a/docs/wiki-shortcut-business-review.html b/docs/wiki-shortcut-business-review.html new file mode 100644 index 00000000..9428f968 --- /dev/null +++ b/docs/wiki-shortcut-business-review.html @@ -0,0 +1,117 @@ + + + + + + DWS Wiki Shortcut 全景评审 + + +
+
+

DWS Wiki Shortcut 全景评审

+

以 13 项成熟 Wiki 用户任务为基线,重新审视 DWS 的空间、成员、节点与动态能力。本次不是按命令名凑数:每个入口都要求真实业务证据,缺失数组、畸形响应、空确认或读回不一致一律失败。

+
评审日期 2026-08-14独立 worktree / 独立分支真实组织数据 E2E 28/28报告已去标识化
+
+ +
+
20公开 Wiki Shortcuts
+
13/13基线用户任务有对应路径
+
7DWS 额外场景
+
20/20真实数据能力已触达
+
+ +
+

结论先行

+
DWS 已形成比“API 快捷别名”更完整的 Wiki 任务层。 基线中的 13 个用户任务均有对应入口;DWS 还提供空间搜索/详情/唯一解析、成员角色更新、库内节点搜索、协作动态和按空间名新建文档。创建、复制、移动等关键写能力从“请求发出”升级为“终态 + ID + 读回”成功标准。
+
能力边界必须诚实表达。 DingTalk 成员接口不提供游标,单次真实上限是 50,因此不能实现成员 --page-all;成员身份只接受同组织可用的 userId,无法提供 email/open_id 等多种身份模式;节点创建也没有等价的 origin/shortcut 模式。这些差异保留为明确边界,而不是用本地循环或空结果伪装。
+
+ +
+

13 项基线任务逐条映射

+
+ + + + + + + + + + + + + +
基线任务DWS 主入口结论DWS 视角与边界
+space-listwiki +space-list完整对齐严格空集合、游标续传、自动翻页、停滞检测;支持组织/我的知识库。
+space-createwiki +space-create超过公开真实 32 字符名称上限;创建后按 workspaceId 读回。
+delete-spacewiki +delete-space超过预读目标、高风险确认、只接受 success=true;兼容 +space-delete。
+member-addwiki +member-add任务对齐支持 1–30 个 userId 与四种角色;以写接口终态作为成功证据,不把最多 50 条的名单误作精确读回。
+member-listwiki +member-list任务对齐严格成员数组、角色过滤、真实上限 50;后端无游标,不能提供诚实的 page-all。
+member-removewiki +member-remove任务对齐支持批量 userId;只接受写接口明确终态,并公开无法进行精确成员读回的边界。
+node-listwiki +node-list完整对齐正确跨域路由 doc/list_nodes,严格空目录、分页与自动翻页。
+node-getwiki +node-get任务对齐支持 DingTalk 节点 ID/在线文档 URL 并返回文档域元数据;不接受跨平台专用的 token/type 组合。
+node-createwiki +node-create任务对齐支持 adoc/axls/able/appt/adraw/amind/folder 并读回;无 origin/shortcut 等价接口。
+node-copywiki +node-copy超过确认后要求新 nodeId 并读取副本;底层面向在线节点,不把 .dlink 当独立副本。
+movewiki +move任务对齐同一入口支持 Wiki 内移动和“我的文档”在线节点入 Wiki,读回 workspace/folder;底层接口没有 apply 权限迁移开关。
+move-to-drivewiki +move-to-drive超过DWS 当前接口同步完成并读回 workspace 变化,无需暴露异步 task 轮询。
+node-deletewiki +node-delete超过预读并核对 workspace,高风险确认,要求删除终态。
+
+ +
+

DWS 可挖掘的 7 个额外场景

+
+

定位与创建链

  • +space-search:严格关键词搜索。
  • +space-get:空间详情与 workspaceId 证据。
  • +resolve-space:唯一命中直出 ID,多命中拒绝猜测。
  • +wiki-new-doc:空间名解析 → 创建 → 文档读回。
+

治理与巡检链

  • +member-update:角色变更终态与不可精确读回声明。
  • +node-search:库内关键词/扩展名搜索,严格零命中。
  • +feed-list:知识库动态时间线与服务端 exclude-file 过滤。
+
+
+ +
+

隐藏问题与修复

+ + + + + + + + + +
原问题错误风险本次修复
5 个旧 Wiki Shortcut 可直接执行,但只有 1 个进入公开目录。Help、Schema、Skill 发现链与运行面漂移。20 项统一评审 全部具备 Contract/Safety/Result 与语义目录记录。
列表投影找不到数组或遇到坏元素时返回空 slice。把内部错误、字段漂移误报为“没有数据”。失败关闭 只有响应中真实存在的 [] 才是合法空集合。
节点列表 Shortcut 调错 Wiki MCP 服务。真实后端 success=false,Mock/静态检查看不出。跨域路由 明确调用 doc/list_nodes,并纳入真实 E2E。
成员帮助宣称最大 200。真实接口超过 50 直接参数错误。真实上限 Shortcut 与原子 Help 均改为 50,并在本地提前拒绝。
成员写操作从最多 50 条、不可分页的名单推断成员存在或缺失。目标在截断部分时会误报写失败,或把未验证的移除报告为已读回。终态证据 只接受写接口 success=true,并在结果中明确 readbackAvailable=false。
空间搜索的稳定工作流属性名与实际请求属性名不同。直接改写已发布的 query/limit 会造成无版本 Schema 破坏;继续隐式转换又会让审计者误以为请求同名透传。显式复合适配 最终 Schema 保留兼容属性并明确声明转换为 keyword/pageSize;回归测试同时锁定最终交付和精确请求参数。
知识库名称帮助宣称最大 100。真实接口超过 32 失败。真实上限 Help 与 Shortcut 校验统一为 32。
复制/移动/创建只把无异常视为成功。空确认、未知远端效果或移动未到目标仍可能被接受。读回证明 在后端具备精确查询能力时检查 success、业务 ID、workspace/folder 等最终状态。
+
+ +
+

真实数据 E2E 证据矩阵

+

28 项业务断言全部通过。测试使用一次性空知识库、临时在线文档与一名同组织内部测试成员;所有对象在 finally 清理。报告不保存对象 ID、成员身份、组织信息、URL、trace 或原始响应。

+
+
+ + + + + + + + + + + + + + + + + + + + +
分组Shortcut实际业务断言状态
空间+space-list真实 count、hasMore、nextCursor;自动翻页返回两页结果。PASS
空间+space-search等待搜索索引后命中一次性 workspaceId。PASS
空间+space-get读回 workspaceId 与创建结果一致。PASS
空间+resolve-space唯一名称解析为同一 workspaceId。PASS
空间+space-createsuccess=true、workspaceId 非空、详情读回一致。PASS
空间+delete-space目标预读、确认、success=true;兼容别名执行 finally 清理。PASS
成员+member-list真实 owner 条目与显式 members 数组,limit=50。PASS
成员+member-add命令只报告写终态;一次性小规模空间另行确认名单完整且角色为 READER。PASS
成员+member-update命令只报告写终态;一次性小规模空间另行确认角色变为 EDITOR。PASS
成员+member-remove命令只报告写终态;一次性小规模空间另行确认完整名单中不存在该 userId。PASS
节点+node-list空库返回真实 nodes:[];有数据时验证游标与自动翻页。PASS
节点+node-get读回 nodeId 与请求一致。PASS
节点+node-search等待索引后按标题命中真实 nodeId。PASS
节点+node-create分别创建 folder/adoc,均取得 nodeId 和元数据读回。PASS
节点+node-copy取得不同的新 nodeId,副本元数据可读。PASS
节点+move读回 workspaceId 与 folderId 均等于目标;兼容 +node-move。PASS
节点+move-to-drive移动后读回 workspace 发生变化,再通过 +move 移回。PASS
节点+node-delete目标预读与 workspace 核对后收到 success=true。PASS
节点+wiki-new-doc按唯一空间名创建,nodeId 与文档详情读回一致。PASS
动态+feed-list创建/移动操作后返回真实 feeds 数组,缺字段不会被接受。PASS
+

可复跑入口:make build 后设置临时 DWS_WIKI_E2E_MEMBER_ID,在交互终端运行 ./scripts/dev/wiki-shortcut-e2e.py。脚本只输出能力标签,不输出业务对象;受保护操作及最终清理均由命令逐项获取终端确认,非交互环境会在创建测试数据前拒绝运行。

+
+ +
+

成功判定与发布门

+

运行时证据层

  1. 传输/MCP 调用成功。
  2. 响应契约存在且类型正确。
  3. 写操作必须有 success=true;创建类操作还必须有业务 ID。
  4. 后端具备精确查询时必须读回;不具备时明确发布不可读回,而非从截断集合推断。
  5. 集合只有显式数组才允许为空。

交付门

  1. 20/20 语义目录与注册面精确覆盖。
  2. Contract、Safety、Result、统一输出完整。
  3. 生成漂移、Schema、确认真值、全量 Go 测试。
  4. 独立真实数据 E2E 与 finally 清理。
  5. diff PII/密钥/本地绝对路径扫描。
+
+ +
+ diff --git a/go.mod b/go.mod index c26fe31f..62bec438 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli go 1.25.9 +replace gitlab.alibaba-inc.com/aes/aem-go-sdk => ./third_party/aem-go-sdk + require ( github.com/Microsoft/go-winio v0.6.2 github.com/RealAlexandreAI/json-repair v0.0.15 @@ -16,7 +18,9 @@ require ( github.com/muesli/termenv v0.16.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1 github.com/spf13/cobra v1.10.2 + github.com/yuin/goldmark v1.8.5 github.com/zalando/go-keyring v0.2.8 + gitlab.alibaba-inc.com/aes/aem-go-sdk v0.3.0 golang.org/x/crypto v0.49.0 golang.org/x/sys v0.42.0 golang.org/x/text v0.35.0 diff --git a/go.sum b/go.sum index 99e029e0..06a5b621 100644 --- a/go.sum +++ b/go.sum @@ -105,6 +105,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= diff --git a/internal/app/event_command.go b/internal/app/event_command.go index 533c95a3..f0cc1e24 100644 --- a/internal/app/event_command.go +++ b/internal/app/event_command.go @@ -1220,7 +1220,8 @@ func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command { editionName := editionNameOrDefault() clientIDHash := dwsevent.ClientIDHash(clientID) workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash) - if err := eventStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil { + ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash) + if err := eventStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil { if errors.Is(err, busctl.ErrNotRunning) { fmt.Fprintln(c.OutOrStdout(), "bus is not running") return nil diff --git a/internal/app/event_personal_command.go b/internal/app/event_personal_command.go index b2b1dc03..20ff6207 100644 --- a/internal/app/event_personal_command.go +++ b/internal/app/event_personal_command.go @@ -1220,7 +1220,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error { } busState := "personal bus stopped" - if err := personalStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil { + if err := personalStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil { if errors.Is(err, busctl.ErrNotRunning) { busState = "personal bus is not running" } else { diff --git a/internal/app/flag_error_test.go b/internal/app/flag_error_test.go index c3580e24..9b44eb91 100644 --- a/internal/app/flag_error_test.go +++ b/internal/app/flag_error_test.go @@ -82,6 +82,46 @@ func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) { } } +func TestFlagErrorWithSuggestionsChatFromExplainsBothMeanings(t *testing.T) { + t.Parallel() + root := &cobra.Command{Use: "dws"} + chat := &cobra.Command{Use: "chat"} + search := &cobra.Command{Use: "+search-msg", Run: func(*cobra.Command, []string) {}} + search.Flags().String("sender", "", "sender target") + search.Flags().String("start", "", "start time") + root.AddCommand(chat) + chat.AddCommand(search) + + orig := fmt.Errorf("unknown flag: --from") + err := flagErrorWithSuggestions(search, orig) + var ae *apperrors.Error + if !stderrors.As(err, &ae) { + t.Fatalf("want *apperrors.Error, got %T", err) + } + if ae.Reason != "ambiguous_flag" || !strings.Contains(ae.Hint, "--sender") || !strings.Contains(ae.Hint, "--start") { + t.Fatalf("structured error = reason %q hint %q", ae.Reason, ae.Hint) + } + if !strings.HasSuffix(ae.Message, "See 'dws chat +search-msg --help' for usage.") { + t.Fatalf("Message = %q", ae.Message) + } + + for _, flag := range []string{"from-file", "from-user"} { + t.Run(flag, func(t *testing.T) { + err := flagErrorWithSuggestions(search, fmt.Errorf("unknown flag: --%s", flag)) + var structured *apperrors.Error + if stderrors.As(err, &structured) && structured.Reason == "ambiguous_flag" { + t.Fatalf("--%s incorrectly used --from ambiguity handling: %#v", flag, structured) + } + if strings.Contains(err.Error(), "--from 在消息查询中含义不明确") { + t.Fatalf("--%s incorrectly received --from ambiguity hint: %v", flag, err) + } + if !strings.Contains(err.Error(), "unknown flag: --"+flag) { + t.Fatalf("error = %q, want original flag --%s", err, flag) + } + }) + } +} + // TestFlagErrorWithSuggestions_fallbackTailHint 验证 fallback 路径(非 unknown flag 类错误, // 如 missing required flag / ambiguous shorthand)也带尾部 See ' --help' for usage. // 这是 wukong / docker / kubectl 的通用 UX——任何 flag 解析错误都给用户一条 help 入口。 diff --git a/internal/app/framework_coverage_test.go b/internal/app/framework_coverage_test.go index 26064c39..d0efe79a 100644 --- a/internal/app/framework_coverage_test.go +++ b/internal/app/framework_coverage_test.go @@ -268,7 +268,7 @@ type frameworkFailWriter struct{} func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") } -func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) { +func TestCrossPlatformCoverageFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) { for _, failWriter := range []bool{false, true} { t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) { testseam.Protect(t, &os.Args) @@ -470,6 +470,118 @@ func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T) }) } +func TestCrossPlatformCoverageExecuteDeterministicInterruptionBranches(t *testing.T) { + install := func(t *testing.T, state *processSignalState, stdout, stderr io.Writer) { + t.Helper() + testseam.Protect(t, &os.Args) + os.Args = []string{"dws"} + testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} }) + testseam.Swap(t, &rootStopAllStdioClients, func() {}) + testseam.Swap(t, &rootInstallProcessSignalContext, func(ctx context.Context, _ *output.ResultStore) (context.Context, *processSignalState, func()) { + return ctx, state, func() {} + }) + testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command { + cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + output.SetCommandRollout(cmd, output.RolloutUnifiedActive) + cmd.SetContext(ctx) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + return cmd + }) + } + interrupted := func(primaryCompleted bool) *processSignalState { + return &processSignalState{ + interruption: &processInterruption{signal: os.Interrupt}, + primaryCompletedAtSignal: primaryCompleted, + } + } + + t.Run("preparse interruption emits unified failure", func(t *testing.T) { + var stdout bytes.Buffer + install(t, interrupted(false), &stdout, io.Discard) + testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("preparse failed") }) + testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) { + t.Fatal("preparse failure reached command execution") + return nil, nil + }) + if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) { + t.Fatalf("preparse interruption = code %d summary %q stdout %q", code, summary, stdout.String()) + } + }) + + t.Run("interruption before emission becomes primary error", func(t *testing.T) { + var stdout bytes.Buffer + install(t, interrupted(false), &stdout, io.Discard) + testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil }) + testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }) + if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) { + t.Fatalf("pre-emission interruption = code %d summary %q stdout %q", code, summary, stdout.String()) + } + }) + + t.Run("late hook error preserves emitted result", func(t *testing.T) { + install(t, interrupted(true), io.Discard, io.Discard) + testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil }) + testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) { + if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil { + t.Fatal(err) + } + if _, _, err := output.EmitStoredResult(cmd); err != nil { + t.Fatal(err) + } + return cmd, errors.New("late hook failed") + }) + if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary != "late hook failed" { + t.Fatalf("late hook result = code %d summary %q", code, summary) + } + }) + + t.Run("interruption after emission preserves emitted result", func(t *testing.T) { + install(t, interrupted(false), io.Discard, io.Discard) + testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil }) + testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) { + if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil { + t.Fatal(err) + } + if _, _, err := output.EmitStoredResult(cmd); err != nil { + t.Fatal(err) + } + return cmd, nil + }) + if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary == "" { + t.Fatalf("post-emission interruption = code %d summary %q", code, summary) + } + }) + + t.Run("publication failure replaces unobservable result", func(t *testing.T) { + var original bytes.Buffer + install(t, interrupted(false), io.Discard, io.Discard) + testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil }) + testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) { + if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil { + t.Fatal(err) + } + if _, _, err := output.EmitStoredResult(cmd); err != nil { + t.Fatal(err) + } + file, err := os.CreateTemp(t.TempDir(), "finished-output-*") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = file.Close() }) + cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{file: file, original: &original, finished: true})) + publicationErr := newOutputPublicationError("publish", errors.New("rename failed")) + if _, handled, emitErr := emitOutputPublicationFailure(cmd, publicationErr); !handled || emitErr != nil { + t.Fatalf("precondition publication failure = handled %v error %v unified %v state %v", handled, emitErr, output.UsesUnifiedResult(cmd), outputSinkForCommand(cmd) != nil) + } + return cmd, publicationErr + }) + if code, _, summary := ExecuteWithTelemetry(); code != 5 || summary == "" { + t.Fatalf("publication failure = code %d summary %q output %q", code, summary, original.String()) + } + }) +} + type frameworkPanicWriter struct{} func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") } diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go index 4717d2b1..d1f2df97 100644 --- a/internal/app/param_alias_e2e_test.go +++ b/internal/app/param_alias_e2e_test.go @@ -52,6 +52,8 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string switch tool { case "list_calendar_events": return `{"result":{"events":[]}}` + case "query_records": + return `{"success":true,"status":"success","error":{},"data":{}}` case "search_mail_users": return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}` case "search_dept_by_keyword": @@ -63,7 +65,7 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string case "list_doc_versions": return `{"result":{"items":[{"version":3}]}}` case "revert_doc_version": - return `{"version":3}` + return `{"revertedToVersion":3}` case "search_doc_templates": return `{"result":[{"templateId":"fixture-template-id"}]}` case "create_document": diff --git a/internal/app/profile_args_test.go b/internal/app/profile_args_test.go index 031992df..da9bc645 100644 --- a/internal/app/profile_args_test.go +++ b/internal/app/profile_args_test.go @@ -66,6 +66,32 @@ func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) { } } +func TestCrossPlatformCoveragePreparseProfileFlagUsesLastOccurrence(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want string + valid bool + }{ + {name: "space then equals", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, want: "corp-b", valid: true}, + {name: "equals then space", args: []string{"--profile=corp-a", "version", "--profile", "corp-b"}, want: "corp-b", valid: true}, + {name: "last multi", args: []string{"--profile=corp-a", "--profile", "corp-b,", "corp-c", "version"}, want: "corp-b,corp-c", valid: true}, + {name: "empty equals clears earlier", args: []string{"--profile=corp-a", "version", "--profile="}}, + {name: "missing value clears earlier", args: []string{"--profile=corp-a", "version", "--profile"}}, + {name: "next flag is not profile value", args: []string{"--profile=corp-a", "--profile", "--debug", "version"}}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := preparseProfileFlag(tc.args); got != tc.want { + t.Fatalf("preparseProfileFlag(%#v) = %q, want %q", tc.args, got, tc.want) + } + _, specified, valid := preparseProfileSelection(tc.args) + if !specified || valid != tc.valid { + t.Fatalf("preparseProfileSelection(%#v) = specified %v valid %v, want true/%v", tc.args, specified, valid, tc.valid) + } + }) + } +} + func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) { oldArgs := os.Args t.Cleanup(func() { os.Args = oldArgs }) diff --git a/internal/app/root.go b/internal/app/root.go index cf42e616..c35a2979 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -80,10 +80,19 @@ var ( rootAuthLoadTokenData = authpkg.LoadTokenData rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags rootEmitResult = output.EmitResult + rootInstallProcessSignalContext = installProcessSignalContext ) // Execute runs the root command and returns the process exit code. -func Execute() (exitCode int) { +func Execute() int { + exitCode, _, _ := ExecuteWithTelemetry() + return exitCode +} + +// ExecuteWithTelemetry runs the root command and additionally returns a +// privacy-safe command path and error summary for the official CLI entrypoint. +func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage string) { + commandPath = "dws" var ( root *cobra.Command executed *cobra.Command @@ -91,12 +100,16 @@ func Execute() (exitCode int) { ) defer func() { if r := recover(); r != nil { + errorMessage = "internal panic" target := executed if target == nil && root != nil { if found, _, err := root.Find(os.Args[1:]); err == nil { target = found } } + if target != nil { + commandPath = telemetryCommandPath(target) + } if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted { exitCode = code if target != nil { @@ -121,6 +134,7 @@ func Execute() (exitCode int) { CloseFileLogger() if executed != nil { if err := closeOutputSink(executed); err != nil { + errorMessage = telemetryErrorSummary(err) if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil { exitCode = code } else { @@ -144,7 +158,9 @@ func Execute() (exitCode int) { agentMetadata := readAgentMetadataSnapshot() if err := agentMetadata.validationError(); err != nil { emitEarlyAgentMetadataValidationError(err, os.Args[1:]) - return apperrors.ExitCode(err) + errorMessage = telemetryErrorSummary(err) + exitCode = apperrors.ExitCode(err) + return } timing := NewTimingCollector() @@ -162,12 +178,13 @@ func Execute() (exitCode int) { ctx, resultStore = output.WithResultStore(ctx) var signalState *processSignalState var stopSignals func() - ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore) + ctx, signalState, stopSignals = rootInstallProcessSignalContext(ctx, resultStore) defer stopSignals() initStart := time.Now() engine := newPipelineEngine() root = rootNewRootCommandWithEngine(ctx, engine) + commandPath = telemetryCommandPath(root) timing.Record("cmd_init", time.Since(initStart)) // Run PreParse handlers on raw argv before Cobra parses flags. @@ -182,15 +199,23 @@ func Execute() (exitCode int) { result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err)) code, emitErr := output.EmitResult(target, result) if emitErr == nil { - return code + errorMessage = telemetryErrorSummary(err) + exitCode = code + return } } _ = printExecutionError(root, os.Stdout, os.Stderr, err) - return apperrors.ExitCode(err) + errorMessage = telemetryErrorSummary(err) + exitCode = apperrors.ExitCode(err) + return } + commandPath = telemetryCommandPathForArgs(root, os.Args[1:]) var err error executed, err = rootExecuteCommand(root) + if executed != nil { + commandPath = telemetryCommandPath(executed) + } // PersistentPostRunE normally commits or aborts the transactional output // sink. Finalize once more at the process boundary so custom execution // seams, embedding callers, or future hook changes cannot leave publication @@ -222,7 +247,9 @@ func Execute() (exitCode int) { // successfully emitted result into a contradictory 130/143 process // status; likewise, a failed publication must retain its internal // error code instead of being relabelled as cancellation. - return code + errorMessage = telemetryErrorSummary(interrupted) + exitCode = code + return } } var publicationErr *outputPublicationError @@ -233,20 +260,26 @@ func Execute() (exitCode int) { if err != nil { if executed == nil { executed = root + commandPath = telemetryCommandPath(root) } if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted { var publicationErr *outputPublicationError if stderrors.As(err, &publicationErr) { + errorMessage = telemetryErrorSummary(publicationErr) if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled { if emitErr == nil { - return failureCode + exitCode = failureCode + return } fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr) } - return apperrors.ExitCode(publicationErr) + exitCode = apperrors.ExitCode(publicationErr) + return } fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err) - return code + errorMessage = telemetryErrorSummary(err) + exitCode = code + return } err = rewordRequiredFlagError(err) var raw apperrors.RawStderrError @@ -254,7 +287,9 @@ func Execute() (exitCode int) { result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err)) code, emitErr := output.EmitResult(executed, result) if emitErr == nil { - return code + errorMessage = telemetryErrorSummary(err) + exitCode = code + return } err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr)) } @@ -264,12 +299,42 @@ func Execute() (exitCode int) { _, _ = fmt.Fprintln(os.Stderr) } _ = printExecutionError(executed, os.Stdout, os.Stderr, err) - return apperrors.ExitCode(err) + errorMessage = telemetryErrorSummary(err) + exitCode = apperrors.ExitCode(err) + return } if code, emitted := output.StoredExitCode(resultStore); emitted { - return code + exitCode = code + return } - return 0 + return +} + +func telemetryCommandPath(command *cobra.Command) string { + if command == nil { + return "dws" + } + path := strings.TrimSpace(command.CommandPath()) + root := command.Root() + rootName := strings.TrimSpace(root.Name()) + if path == rootName { + return rootName + } + if rootName != "" { + path = strings.TrimSpace(strings.TrimPrefix(path, rootName+" ")) + } + return path +} + +func telemetryCommandPathForArgs(root *cobra.Command, args []string) string { + if root == nil { + return "dws" + } + command, _, err := root.Find(args) + if err != nil || command == nil { + return telemetryCommandPath(root) + } + return telemetryCommandPath(command) } // emitEarlyAgentMetadataValidationError preserves each built-in command's @@ -511,6 +576,22 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error { // 无论哪种格式,子串 "--help' for usage." 都可被检索到。 tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath()) msgWithTail := errMsg + tail + if flag, ok := unknownFlagName(errMsg); ok && flag == "from" { + switch cmd.CommandPath() { + case "dws chat +search-msg", "dws chat +chat-messages": + return apperrors.NewValidation( + msgWithTail, + apperrors.WithHint("--from 在消息查询中含义不明确:按发送者过滤请使用 --sender <姓名|userId|openDingTalkId>;指定时间起点请使用 --start "), + apperrors.WithReason("ambiguous_flag"), + apperrors.WithCause(err), + apperrors.WithActions( + "Use --sender <姓名|userId|openDingTalkId> to filter by sender", + "Use --start together with --end to set a time range", + ), + apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...), + ) + } + } if flag, protection, ok := reviewedFlagProtection(cmd, errMsg); ok { hint := fmt.Sprintf("Parameter --%s is blocked from automatic normalization on %q; choose an explicit flag from --help.", flag, cmd.CommandPath()) reason := "blocked_flag" @@ -579,15 +660,10 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline if cmd == nil { return "", "", false } - const prefix = "unknown flag: --" - idx := strings.Index(errMsg, prefix) - if idx < 0 { + flag, ok := unknownFlagName(errMsg) + if !ok { return "", "", false } - flag := strings.TrimSpace(errMsg[idx+len(prefix):]) - if i := strings.IndexAny(flag, " =\n\t"); i >= 0 { - flag = flag[:i] - } entry, ok := cli.LookupParamAlias(cmd.CommandPath()) if !ok { return "", "", false @@ -602,6 +678,19 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline return "", "", false } +func unknownFlagName(errMsg string) (string, bool) { + const prefix = "unknown flag: --" + idx := strings.Index(errMsg, prefix) + if idx < 0 { + return "", false + } + flag := strings.TrimSpace(errMsg[idx+len(prefix):]) + if i := strings.IndexAny(flag, " =\n\t"); i >= 0 { + flag = flag[:i] + } + return flag, flag != "" +} + func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error { var raw apperrors.RawStderrError if stderrors.As(err, &raw) { @@ -935,17 +1024,36 @@ func installReviewedFlagProtectionHandlers(root *cobra.Command) { } func preparseProfileFlag(args []string) string { + profile, _, valid := preparseProfileSelection(args) + if !valid { + return "" + } + return profile +} + +func preparseProfileSelection(args []string) (profile string, specified, valid bool) { args, _ = normalizeProfileFlagArgs(args) + valid = true for i := 0; i < len(args); i++ { arg := strings.TrimSpace(args[i]) switch { - case arg == "--profile" && i+1 < len(args): - return strings.TrimSpace(args[i+1]) + case arg == "--profile": + specified = true + if i+1 >= len(args) || strings.HasPrefix(strings.TrimSpace(args[i+1]), "-") { + profile = "" + valid = false + continue + } + profile = strings.TrimSpace(args[i+1]) + valid = profile != "" + i++ case strings.HasPrefix(arg, "--profile="): - return strings.TrimSpace(strings.TrimPrefix(arg, "--profile=")) + specified = true + profile = strings.TrimSpace(strings.TrimPrefix(arg, "--profile=")) + valid = profile != "" } } - return "" + return profile, specified, valid } func normalizeProcessProfileArgs() func() { diff --git a/internal/app/root_full_coverage_test.go b/internal/app/root_full_coverage_test.go index a0a0af46..f4c5477a 100644 --- a/internal/app/root_full_coverage_test.go +++ b/internal/app/root_full_coverage_test.go @@ -37,29 +37,64 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) { rootNormalizeProcessProfileArgs = func() func() { return func() {} } rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil } rootStopAllStdioClients = func() {} + var executedLeaf *cobra.Command rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command { - return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + sheet := &cobra.Command{Use: "sheet"} + executedLeaf = &cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}} + sheet.AddCommand(executedLeaf) + root.AddCommand(sheet) + return root } - rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil } - if code := Execute(); code != 0 { - t.Fatalf("successful Execute code = %d", code) + rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return executedLeaf, nil } + if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 0 || commandPath != "sheet read" || errorMessage != "" { + t.Fatalf("successful ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage) } rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return errors.New("alias/canonical conflict") } - if code := Execute(); code == 0 { - t.Fatal("pre-parse conflict returned zero") + if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "alias/canonical conflict" { + t.Fatalf("pre-parse conflict = code %d error %q", code, errorMessage) } rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil } wantErr := errors.New("unknown command missing") rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr } - if code := Execute(); code == 0 { - t.Fatal("failed Execute returned zero") + if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "unknown command" { + t.Fatalf("failed ExecuteWithTelemetry = code %d error %q", code, errorMessage) } rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { panic("boom") } - if code := Execute(); code != 5 { - t.Fatalf("panic Execute code = %d", code) + os.Args = []string{"dws", "sheet", "read"} + if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 5 || commandPath != "sheet read" || errorMessage != "internal panic" { + t.Fatalf("panic ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage) + } +} + +func TestCrossPlatformCoverageTelemetryCommandPath(t *testing.T) { + if got := telemetryCommandPath(nil); got != "dws" { + t.Fatalf("nil command path = %q, want dws", got) + } + if got := telemetryCommandPathForArgs(nil, nil); got != "dws" { + t.Fatalf("nil root command path = %q, want dws", got) + } + root := &cobra.Command{Use: "dws"} + sheet := &cobra.Command{Use: "sheet"} + read := &cobra.Command{Use: "read "} + sheet.AddCommand(read) + root.AddCommand(sheet) + if got := telemetryCommandPath(root); got != "dws" { + t.Fatalf("root command path = %q, want dws", got) + } + if got := telemetryCommandPath(read); got != "sheet read" { + t.Fatalf("leaf command path = %q, want sheet read", got) + } + root.PersistentFlags().String("profile", "", "") + read.Aliases = []string{"get"} + if got := telemetryCommandPathForArgs(root, []string{"--profile", "corp-a", "sheet", "get", "A1:B2"}); got != "sheet read" { + t.Fatalf("pre-execution command path = %q, want sheet read", got) + } + if got := telemetryCommandPathForArgs(root, []string{"missing"}); got != "dws" { + t.Fatalf("unknown pre-execution command path = %q, want dws", got) } } diff --git a/internal/app/root_help.go b/internal/app/root_help.go index 6bbb1a69..7ce84908 100644 --- a/internal/app/root_help.go +++ b/internal/app/root_help.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "io" "strings" "text/tabwriter" @@ -13,6 +14,12 @@ import ( "github.com/spf13/pflag" ) +// feedbackFormURL points at the DingTalk Notable form collecting dws CLI +// user-experience feedback. The source parameter tags submissions that +// originated from the CLI help output so they can be told apart from +// responses arriving through other channels. +const feedbackFormURL = "https://alidocs.dingtalk.com/notable/share/form/v01eLbnj1bw1ELb0laN_dv19yqvsgs3oebp3pcjys_1qX0QQ0?source=dws-cli" + func configureRootHelp(root *cobra.Command) { if root == nil { return @@ -101,6 +108,29 @@ func renderRootHelp(root *cobra.Command) { _, _ = fmt.Fprintln(w) _, _ = fmt.Fprintln(w, tui.Dim(long)) } + + // Keep the feedback entry last: everything above it is operational guidance + // an agent acts on, while the survey is addressed to human readers who + // scroll to the end. + _, _ = fmt.Fprintln(w) + renderRootFeedback(w) +} + +// renderRootFeedback prints the user-experience survey entry. The URL occupies +// its own line and is never wrapped or padded through a tabwriter: it is longer +// than the help rule width, and breaking it would stop terminals from +// recognizing it as a clickable hyperlink. Soft wrapping performed by the +// terminal itself keeps the link intact. +// +// The label is intentionally not routed through i18n. Everything surrounding it +// in this listing — service descriptions, utility descriptions, global flag +// usage — is hardcoded Chinese, so translating this one line would render it in +// English on any host whose LANG is not zh_*, leaving a single English line +// inside an otherwise Chinese screen. +func renderRootFeedback(w io.Writer) { + _, _ = fmt.Fprintln(w, tui.Section("Feedback:")) + _, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.Dim("使用体验反馈问卷(1 分钟)")) + _, _ = fmt.Fprintf(w, " %s\n", tui.Cyan(feedbackFormURL)) } func renderRootGlobalFlags(root *cobra.Command) { diff --git a/internal/app/root_help_test.go b/internal/app/root_help_test.go index 09fc432e..cce294d9 100644 --- a/internal/app/root_help_test.go +++ b/internal/app/root_help_test.go @@ -53,6 +53,49 @@ func TestRootHelpHidesCompatibilityOnlyCommands(t *testing.T) { } } +func TestRootHelpShowsFeedbackEntry(t *testing.T) { + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("root help: %v\n%s", err, out.String()) + } + help := out.String() + // The label stays Chinese regardless of the host locale: the rest of this + // listing is hardcoded Chinese, so a translated label would show up as a + // lone English line on any host whose LANG is not zh_*. + for _, want := range []string{"Feedback:", "使用体验反馈问卷", feedbackFormURL} { + if !strings.Contains(help, want) { + t.Fatalf("root help missing %q:\n%s", want, help) + } + } + // The form URL is longer than the help rule width; it must stay on a + // single unbroken line so terminals keep recognizing it as a hyperlink. + if !strings.Contains(help, "\n "+feedbackFormURL+"\n") { + t.Fatalf("feedback URL must occupy one unwrapped line:\n%s", help) + } +} + +// The feedback entry is deliberately root-only: this CLI is driven mostly by +// AI agents, and repeating a survey link in every subcommand help would be +// pure context noise. Guard the boundary so a future refactor cannot move the +// rendering into the shared subcommand help path unnoticed. +func TestSubcommandHelpOmitsFeedbackEntry(t *testing.T) { + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"chat", "--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("chat help: %v\n%s", err, out.String()) + } + if help := out.String(); strings.Contains(help, feedbackFormURL) { + t.Fatalf("subcommand help must not carry the feedback URL:\n%s", help) + } +} + func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) { cmd := NewRootCommand() var out bytes.Buffer diff --git a/internal/app/root_lifecycle_regression_test.go b/internal/app/root_lifecycle_regression_test.go index 4f705ac2..51261f0f 100644 --- a/internal/app/root_lifecycle_regression_test.go +++ b/internal/app/root_lifecycle_regression_test.go @@ -181,7 +181,7 @@ func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) { } } -func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) { +func TestCrossPlatformCoverageExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) { oldNormalize := rootNormalizeProcessProfileArgs oldExecute := rootExecuteCommand oldNewRoot := rootNewRootCommandWithEngine diff --git a/internal/app/root_output_atomic_test.go b/internal/app/root_output_atomic_test.go index 83025397..d666a35d 100644 --- a/internal/app/root_output_atomic_test.go +++ b/internal/app/root_output_atomic_test.go @@ -230,13 +230,20 @@ func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing assertNoOutputTemps(t, target) } -func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) { +func TestCrossPlatformCoverageExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) { testseam.Protect(t, &os.Args) dir := t.TempDir() - target := filepath.Join(dir, "result.json") + t.Chdir(dir) + // Keep argv portable: an absolute Windows path contains a volume colon, + // which the CLI intentionally rejects as unsafe user-supplied output. + target := "result.json" if err := os.WriteFile(target, []byte("original"), 0o640); err != nil { t.Fatal(err) } + originalInfo, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"} testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") }) testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} }) @@ -277,7 +284,7 @@ func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 { t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String()) } - assertOutputFile(t, target, "original", 0o640) + assertOutputFile(t, target, "original", originalInfo.Mode().Perm()) assertNoOutputTemps(t, target) } diff --git a/internal/app/root_unified_output_test.go b/internal/app/root_unified_output_test.go index d64d4016..67d02061 100644 --- a/internal/app/root_unified_output_test.go +++ b/internal/app/root_unified_output_test.go @@ -27,7 +27,7 @@ import ( "github.com/spf13/cobra" ) -func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) { +func TestCrossPlatformCoverageExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) { oldNormalize := rootNormalizeProcessProfileArgs oldExecute := rootExecuteCommand oldNewRoot := rootNewRootCommandWithEngine diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 55950aa0..7b05cc89 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 399 + publicShortcutCount = 418 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including the hidden historical minutes.shortcut_minutes_search contract. - schemaPublishedShortcutCount = 401 + schemaPublishedShortcutCount = 420 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 399 + publiclyDeliveredShortcutCount = 418 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { @@ -140,6 +140,78 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) assertChatCatalogCompleteLeafContracts(t) } +func TestDeliveryWikiSpaceSearchDeclaresCompatibilityAdapter(t *testing.T) { + leaf := executeShortcutSchemaQuery(t, "--cli-path", "wiki +space-search") + if got := schemaContractString(leaf["interface_mode"]); got != "composite" { + t.Fatalf("wiki +space-search interface_mode = %q, want composite", got) + } + reason := schemaContractString(leaf["interface_reason"]) + for _, fragment := range []string{"query/limit", "search_wikiSpaces.keyword/pageSize", "versioned Schema migration"} { + if !strings.Contains(reason, fragment) { + t.Fatalf("wiki +space-search interface_reason = %q, want fragment %q", reason, fragment) + } + } + parameters := schemaContractMap(leaf["parameters"]) + for name, want := range map[string]string{"query": "query", "limit": "limit"} { + parameter := parameters[name] + if parameter == nil { + t.Fatalf("wiki +space-search missing --%s parameter: %#v", name, parameters) + } + if got := schemaContractString(parameter["property"]); got != want { + t.Fatalf("wiki +space-search --%s property = %q, want compatibility value %q", name, got, want) + } + } +} + +func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) { + tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand()) + checked := 0 + for _, declared := range shortcut.All() { + if declared.Service != "wiki" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) { + continue + } + checked++ + canonical := shortcutSchemaCanonical(declared) + tool := tools[canonical] + if tool == nil { + t.Fatalf("delivery schema --all is missing %s", canonical) + } + examples := schemaContractStringSlice(tool["examples"]) + if len(examples) == 0 { + t.Fatalf("%s has no delivered examples", canonical) + } + for _, example := range examples { + argv, err := cli.ParseAgentExampleArgv(example) + if err != nil { + t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err) + } + for _, flag := range declared.Flags { + if !flag.Required { + continue + } + names := append([]string{flag.Name}, flag.Aliases...) + if !schemaExampleHasLongFlag(argv, names...) { + t.Errorf("%s example %q is missing required --%s", canonical, example, flag.Name) + } + } + } + } + if checked != 20 { + t.Fatalf("checked Wiki shortcut examples = %d, want 20", checked) + } +} + +func schemaExampleHasLongFlag(argv []string, names ...string) bool { + for _, argument := range argv { + for _, name := range names { + if argument == "--"+name || strings.HasPrefix(argument, "--"+name+"=") { + return true + } + } + } + return false +} + func assertSchemaSummarySafety( t testing.TB, summaries map[string]map[string]any, diff --git a/internal/app/signal_test.go b/internal/app/signal_test.go index 3706cbd3..33a544b9 100644 --- a/internal/app/signal_test.go +++ b/internal/app/signal_test.go @@ -103,7 +103,7 @@ func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Wri }) } -func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) { +func TestCrossPlatformCoverageExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) { for _, tc := range []struct { name string signal syscall.Signal @@ -197,7 +197,7 @@ func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing } } -func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) { +func TestCrossPlatformCoverageSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) { var stdout bytes.Buffer installSignalExecuteSeams(t, true, &stdout, io.Discard) testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) { @@ -229,7 +229,7 @@ func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) { } } -func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) { +func TestCrossPlatformCoverageSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) { var stdout bytes.Buffer installSignalExecuteSeams(t, true, &stdout, io.Discard) testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) { diff --git a/internal/app/telemetry_error.go b/internal/app/telemetry_error.go new file mode 100644 index 00000000..6a16e289 --- /dev/null +++ b/internal/app/telemetry_error.go @@ -0,0 +1,143 @@ +// 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 ( + stderrors "errors" + "fmt" + "regexp" + "strings" + "unicode" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" +) + +const maxTelemetryErrorRunes = 200 + +var ( + telemetryUnknownFlagPattern = regexp.MustCompile(`(?i)unknown flag:\s*(--[a-z0-9][a-z0-9-]*)`) + telemetryAuthPattern = regexp.MustCompile(`(?i)\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+`) + telemetrySensitiveFlag = regexp.MustCompile(`(?i)(--(?:access-token|refresh-token|token|client-secret|client-id|password|api-key|authorization|cookie|credential|secret))(?:=|\s+)\S+`) + telemetrySensitiveValue = regexp.MustCompile(`(?i)\b(authorization|client[-_]?secret|client[-_]?id|access[-_]?token|refresh[-_]?token|api[-_]?key|password|cookie|credential|secret|token)\b\s*[:=]\s*[^\s,;]+`) + telemetryURLPattern = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s]+`) + telemetryJSONPattern = regexp.MustCompile(`(?s)[\[{].*[\]}]`) + telemetryUnixPathPattern = regexp.MustCompile(`(^|[\s=:])(?:~/|/)[^\s]+`) + telemetryWindowsPathPattern = regexp.MustCompile(`(?i)(^|[\s=])[a-z]:[\\/][^\s]+`) + telemetryRelativePathPattern = regexp.MustCompile(`(^|[\s=:])\.\.?/[^\s]+`) + telemetryEmailPattern = regexp.MustCompile(`(?i)\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b`) + telemetryPhonePattern = regexp.MustCompile(`\b\+?\d[\d -]{7,}\d\b`) + telemetryOpaqueTokenPattern = regexp.MustCompile(`\b[a-zA-Z0-9_-]{16,}\b`) +) + +func telemetryErrorSummary(err error) string { + if err == nil { + return "" + } + var patError *apperrors.PATError + if stderrors.As(err, &patError) { + return "permission error" + } + var rawError apperrors.RawStderrError + if stderrors.As(err, &rawError) { + return "raw stderr error" + } + if isUnknownCommandError(err) { + return "unknown command" + } + if match := telemetryUnknownFlagPattern.FindStringSubmatch(err.Error()); len(match) == 2 { + return "unknown flag: " + match[1] + } + return sanitizeTelemetryErrorText(err.Error()) +} + +func telemetryPanicMessages(value any) (display, summary string) { + return fmt.Sprintf("internal panic: %v", value), "internal panic" +} + +func sanitizeTelemetryErrorText(message string) string { + message = output.SanitizeForTerminal(message) + message = telemetryAuthPattern.ReplaceAllString(message, "") + message = telemetrySensitiveFlag.ReplaceAllString(message, "$1=") + message = telemetrySensitiveValue.ReplaceAllString(message, "$1=") + message = telemetryURLPattern.ReplaceAllString(message, "") + message = telemetryJSONPattern.ReplaceAllString(message, "") + message = redactTelemetryQuotedText(message) + message = telemetryUnixPathPattern.ReplaceAllString(message, "$1") + message = telemetryWindowsPathPattern.ReplaceAllString(message, "$1") + message = telemetryRelativePathPattern.ReplaceAllString(message, "$1") + message = telemetryEmailPattern.ReplaceAllString(message, "") + message = telemetryPhonePattern.ReplaceAllString(message, "") + message = telemetryOpaqueTokenPattern.ReplaceAllStringFunc(message, func(value string) string { + var hasLetter, hasDigit bool + for _, r := range value { + hasLetter = hasLetter || unicode.IsLetter(r) + hasDigit = hasDigit || unicode.IsDigit(r) + } + if hasLetter && hasDigit { + return "" + } + return value + }) + message = strings.Join(strings.Fields(message), " ") + return truncateTelemetryText(message, maxTelemetryErrorRunes) +} + +func redactTelemetryQuotedText(message string) string { + var result strings.Builder + runes := []rune(message) + for index := 0; index < len(runes); { + quote := runes[index] + if quote != '\'' && quote != '"' && quote != '`' { + result.WriteRune(quote) + index++ + continue + } + result.WriteRune(quote) + result.WriteString("") + index++ + escaped := false + for index < len(runes) { + current := runes[index] + index++ + if escaped { + escaped = false + continue + } + if current == '\\' && quote != '`' { + escaped = true + continue + } + if current == quote { + result.WriteRune(quote) + break + } + } + } + return result.String() +} + +func truncateTelemetryText(message string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + runes := []rune(message) + if len(runes) <= maxRunes { + return message + } + if maxRunes <= 3 { + return string(runes[:maxRunes]) + } + return string(runes[:maxRunes-3]) + "..." +} diff --git a/internal/app/telemetry_error_test.go b/internal/app/telemetry_error_test.go new file mode 100644 index 00000000..08b411c6 --- /dev/null +++ b/internal/app/telemetry_error_test.go @@ -0,0 +1,99 @@ +// 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 ( + "errors" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +type telemetryRawError string + +func (e telemetryRawError) Error() string { return string(e) } +func (e telemetryRawError) RawStderr() string { return string(e) } + +func TestCrossPlatformCoverageTelemetryErrorSummaryFixedFamilies(t *testing.T) { + for _, tc := range []struct { + name string + err error + want string + }{ + {name: "nil", want: ""}, + {name: "PAT", err: &apperrors.PATError{RawJSON: `{"token":"secret"}`}, want: "permission error"}, + {name: "raw stderr", err: telemetryRawError("raw secret"), want: "raw stderr error"}, + {name: "unknown command", err: errors.New(`unknown command "secret-value" for "dws"`), want: "unknown command"}, + {name: "unknown flag", err: errors.New("unknown flag: --token=secret-value"), want: "unknown flag: --token"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := telemetryErrorSummary(tc.err); got != tc.want { + t.Fatalf("telemetryErrorSummary() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCrossPlatformCoverageSanitizeTelemetryErrorText(t *testing.T) { + message := "\x1b[31mfailed\x1b[0m " + + "--client-secret very-secret " + + "--access-token access-secret " + + "Authorization: Bearer abcdefghijklmnop1234 " + + "url=https://example.test/path?token=secret " + + `body={"access_token":"secret"} ` + + `user="Alice" email=alice@example.test phone=13800138000 ` + + "path=/Users/alice/private.txt relative=./private/secrets.txt id=abcDEF1234567890XYZ" + got := sanitizeTelemetryErrorText(message) + for _, secret := range []string{ + "very-secret", "access-secret", "abcdefghijklmnop1234", "example.test", "access_token", + "Alice", "alice@example.test", "13800138000", "/Users/alice", "abcDEF1234567890XYZ", "\x1b", + "./private/secrets.txt", + } { + if strings.Contains(got, secret) { + t.Fatalf("sanitized telemetry error leaked %q: %q", secret, got) + } + } + for _, marker := range []string{"failed", "", "", "", "", ""} { + if !strings.Contains(got, marker) { + t.Fatalf("sanitized telemetry error missing %q: %q", marker, got) + } + } +} + +func TestCrossPlatformCoverageTelemetryErrorTruncationAndPanic(t *testing.T) { + message := strings.Repeat("错", maxTelemetryErrorRunes+1) + got := sanitizeTelemetryErrorText(message) + if len([]rune(got)) != maxTelemetryErrorRunes || !strings.HasSuffix(got, "...") { + t.Fatalf("truncated telemetry error rune length = %d suffix = %q", len([]rune(got)), got[len(got)-3:]) + } + display, summary := telemetryPanicMessages("token-secret") + if display != "internal panic: token-secret" || summary != "internal panic" || strings.Contains(summary, "token-secret") { + t.Fatalf("panic messages = display %q summary %q", display, summary) + } + if got := truncateTelemetryText("value", 0); got != "" { + t.Fatalf("zero-limit truncation = %q", got) + } + if got := truncateTelemetryText("value", 3); got != "val" { + t.Fatalf("short-limit truncation = %q", got) + } +} + +func TestCrossPlatformCoverageTelemetryErrorEscapesAndOpaqueWords(t *testing.T) { + const opaqueWord = "abcdefghijklmnop" + got := sanitizeTelemetryErrorText(`failed "quoted \"value" ` + opaqueWord) + if strings.Contains(got, "value") || !strings.Contains(got, opaqueWord) { + t.Fatalf("escaped quote sanitization = %q", got) + } +} diff --git a/internal/app/telemetry_identity.go b/internal/app/telemetry_identity.go new file mode 100644 index 00000000..4ea93a96 --- /dev/null +++ b/internal/app/telemetry_identity.go @@ -0,0 +1,86 @@ +// 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 ( + "fmt" + "strings" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" +) + +// TelemetryIdentity is the privacy-reviewed subset of the local authentication +// record that may be attached to a CLI execution event. +type TelemetryIdentity struct { + UserID string + UserName string + CorpID string +} + +var telemetryResolveProfileMetadata = authpkg.ResolveProfileMetadataReadOnly + +// ResolveTelemetryIdentity returns a pre-execution snapshot of the identity +// selected by args. Multi-profile executions are attributed to the current +// default profile. Resolution is deliberately best-effort: telemetry must not +// refresh credentials or change command behavior when local auth data is +// missing, invalid, or unreadable. +func ResolveTelemetryIdentity(args []string) (identity TelemetryIdentity) { + defer func() { + if recover() != nil { + identity = TelemetryIdentity{} + } + }() + + selector, specified, valid := preparseProfileSelection(args) + if specified && !valid { + return TelemetryIdentity{} + } + profile, err := resolveTelemetryProfileMetadata(defaultConfigDir(), selector) + if err != nil || profile == nil { + return TelemetryIdentity{} + } + return TelemetryIdentity{ + UserID: strings.TrimSpace(profile.UserID), + UserName: strings.TrimSpace(profile.UserName), + CorpID: strings.TrimSpace(profile.CorpID), + } +} + +func resolveTelemetryProfileMetadata(configDir, selector string) (*authpkg.ProfileMetadata, error) { + selector = strings.TrimSpace(selector) + if selector == "" || !strings.Contains(selector, ",") { + return telemetryResolveProfileMetadata(configDir, selector) + } + + // A local profile name may itself contain a comma. Match the runtime + // resolver by trying the full selector before interpreting it as CSV. + if profile, err := telemetryResolveProfileMetadata(configDir, selector); err == nil && profile != nil { + return profile, nil + } + + for _, part := range strings.Split(selector, ",") { + part = strings.TrimSpace(part) + if part == "" { + return nil, fmt.Errorf("--profile contains an empty profile selector: %q", selector) + } + profile, err := telemetryResolveProfileMetadata(configDir, part) + if err != nil { + return nil, err + } + if profile == nil { + return nil, fmt.Errorf("profile %q not found", part) + } + } + return telemetryResolveProfileMetadata(configDir, "") +} diff --git a/internal/app/telemetry_identity_test.go b/internal/app/telemetry_identity_test.go new file mode 100644 index 00000000..076fb10a --- /dev/null +++ b/internal/app/telemetry_identity_test.go @@ -0,0 +1,147 @@ +// 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 ( + "errors" + "reflect" + "strings" + "testing" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageResolveTelemetryIdentityProfileSelection(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", "/telemetry-config") + profiles := map[string]*authpkg.ProfileMetadata{ + "": {UserID: " default-user ", UserName: " Default User ", CorpID: " default-corp "}, + "corp-a": {UserID: "user-a", UserName: "Alice", CorpID: "corp-a"}, + "corp-b": {UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}, + "alpha,beta": {UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"}, + } + + for _, tc := range []struct { + name string + args []string + profiles map[string]*authpkg.ProfileMetadata + wantCalls []string + want TelemetryIdentity + }{ + {name: "default profile", args: []string{"version"}, profiles: profiles, wantCalls: []string{""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}}, + {name: "single profile", args: []string{"--profile", "corp-a", "version"}, profiles: profiles, wantCalls: []string{"corp-a"}, want: TelemetryIdentity{UserID: "user-a", UserName: "Alice", CorpID: "corp-a"}}, + {name: "equals form after command", args: []string{"version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}}, + {name: "last repeated profile", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}}, + {name: "comma profile name", args: []string{"--profile", "alpha,beta", "version"}, profiles: profiles, wantCalls: []string{"alpha,beta"}, want: TelemetryIdentity{UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"}}, + {name: "multi profile uses default", args: []string{"--profile", "corp-a,corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}}, + {name: "unquoted multi profile", args: []string{"--profile", "corp-a,", "corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}}, + } { + t.Run(tc.name, func(t *testing.T) { + var calls []string + testseam.Swap(t, &telemetryResolveProfileMetadata, func(configDir, selector string) (*authpkg.ProfileMetadata, error) { + if configDir != "/telemetry-config" { + t.Fatalf("resolver config dir = %q", configDir) + } + calls = append(calls, selector) + profile := tc.profiles[selector] + if profile == nil { + return nil, errors.New("profile not found") + } + clone := *profile + return &clone, nil + }) + + if got := ResolveTelemetryIdentity(tc.args); got != tc.want { + t.Fatalf("ResolveTelemetryIdentity() = %#v, want %#v", got, tc.want) + } + if !reflect.DeepEqual(calls, tc.wantCalls) { + t.Fatalf("metadata selectors = %#v, want %#v", calls, tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsMissingProfileValue(t *testing.T) { + for _, args := range [][]string{ + {"version", "--profile"}, + {"--profile=corp-a", "version", "--profile="}, + {"--profile", "--debug", "version"}, + } { + t.Run(strings.Join(args, "_"), func(t *testing.T) { + testseam.Swap(t, &telemetryResolveProfileMetadata, func(string, string) (*authpkg.ProfileMetadata, error) { + t.Fatal("invalid profile syntax attempted metadata resolution") + return nil, nil + }) + if got := ResolveTelemetryIdentity(args); got != (TelemetryIdentity{}) { + t.Fatalf("invalid profile identity = %#v", got) + } + }) + } +} + +func TestCrossPlatformCoverageResolveTelemetryIdentityFailsClosed(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", "/telemetry-config") + fail := errors.New("metadata unavailable") + + for _, tc := range []struct { + name string + resolve func(string, string) (*authpkg.ProfileMetadata, error) + }{ + {name: "read error", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, fail }}, + {name: "missing profile", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, nil }}, + {name: "empty fields", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return &authpkg.ProfileMetadata{}, nil }}, + {name: "resolver panic", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { panic("metadata failure") }}, + } { + t.Run(tc.name, func(t *testing.T) { + testseam.Swap(t, &telemetryResolveProfileMetadata, tc.resolve) + if got := ResolveTelemetryIdentity(nil); got != (TelemetryIdentity{}) { + t.Fatalf("failed-closed identity = %#v", got) + } + }) + } +} + +func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsInvalidMultiProfile(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", "/telemetry-config") + testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) { + if selector == "corp-a" { + return &authpkg.ProfileMetadata{UserID: "user-a", CorpID: "corp-a"}, nil + } + return nil, errors.New("profile not found") + }) + if got := ResolveTelemetryIdentity([]string{"--profile", "corp-a,missing", "version"}); got != (TelemetryIdentity{}) { + t.Fatalf("invalid multi-profile identity = %#v", got) + } +} + +func TestCrossPlatformCoverageResolveTelemetryProfileMetadataRejectsMalformedMulti(t *testing.T) { + testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) { + switch selector { + case "corp-a,,corp-b": + return nil, errors.New("not a literal profile") + case "corp-a": + return &authpkg.ProfileMetadata{UserID: "user-a"}, nil + case "missing": + return nil, nil + default: + return nil, errors.New("unexpected selector") + } + }) + if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,,corp-b"); err == nil || !strings.Contains(err.Error(), "empty profile selector") { + t.Fatalf("empty multi-profile selector error = %v", err) + } + if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,missing"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("missing multi-profile selector error = %v", err) + } +} diff --git a/internal/auth/profile_metadata_readonly.go b/internal/auth/profile_metadata_readonly.go new file mode 100644 index 00000000..d6f857f0 --- /dev/null +++ b/internal/auth/profile_metadata_readonly.go @@ -0,0 +1,70 @@ +// 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 auth + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// ProfileMetadata is the minimal, non-sensitive identity projection exposed to +// telemetry callers. It intentionally excludes profile names, client IDs, +// organization names, token material, and credential status. +type ProfileMetadata struct { + UserID string + UserName string + CorpID string +} + +// ResolveProfileMetadataReadOnly resolves one identity exclusively from the +// non-sensitive profiles.json metadata. It deliberately avoids auth locks, +// token stores, Keychain access, migrations, quarantine renames, and writes. +// A missing metadata file or an empty current profile returns (nil, nil). +func ResolveProfileMetadataReadOnly(configDir, selector string) (*ProfileMetadata, error) { + data, err := profilesReadFile(ProfilesPath(configDir)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read profile metadata: %w", err) + } + + var cfg ProfilesConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse profile metadata: %w", err) + } + if cfg.Version > profilesMaxVersion { + return nil, fmt.Errorf("profile metadata version %d is newer than supported version %d", cfg.Version, profilesMaxVersion) + } + normalizeProfilesConfig(&cfg) + + selector = strings.TrimSpace(selector) + if selector == "" { + selector = strings.TrimSpace(cfg.CurrentProfile) + if selector == "" { + return nil, nil + } + } + profile, _, err := resolveProfileSelection("", &cfg, selector) + if err != nil { + return nil, err + } + return &ProfileMetadata{ + UserID: profile.UserID, + UserName: profile.UserName, + CorpID: profile.CorpID, + }, nil +} diff --git a/internal/auth/profile_metadata_readonly_test.go b/internal/auth/profile_metadata_readonly_test.go new file mode 100644 index 00000000..ca5aa67c --- /dev/null +++ b/internal/auth/profile_metadata_readonly_test.go @@ -0,0 +1,97 @@ +// 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 auth + +import ( + "errors" + "os" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageResolveProfileMetadataReadOnly(t *testing.T) { + const metadata = `{ + "version": 3, + "currentProfile": "corp-a:user-a", + "profiles": [ + {"name":"alpha","corpId":"corp-a","userId":"user-a","userName":"Alice"}, + {"name":"beta","corpId":"corp-b","userId":"user-b","userName":"Bob"} + ] +}` + reads := 0 + testseam.Swap(t, &profilesReadFile, func(path string) ([]byte, error) { + reads++ + if !strings.HasSuffix(path, profilesJSONFile) { + t.Fatalf("metadata path = %q", path) + } + return []byte(metadata), nil + }) + + current, err := ResolveProfileMetadataReadOnly("/config", "") + if err != nil || current == nil || current.UserID != "user-a" || current.UserName != "Alice" || current.CorpID != "corp-a" { + t.Fatalf("current metadata profile = %#v, %v", current, err) + } + explicit, err := ResolveProfileMetadataReadOnly("/config", "beta") + if err != nil || explicit == nil || explicit.UserID != "user-b" || explicit.CorpID != "corp-b" { + t.Fatalf("explicit metadata profile = %#v, %v", explicit, err) + } + if reads != 2 { + t.Fatalf("profile metadata reads = %d, want 2", reads) + } +} + +func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyFailsClosed(t *testing.T) { + fail := errors.New("read failed") + for _, tc := range []struct { + name string + read func(string) ([]byte, error) + wantErr string + }{ + {name: "missing", read: func(string) ([]byte, error) { return nil, os.ErrNotExist }}, + {name: "read error", read: func(string) ([]byte, error) { return nil, fail }, wantErr: "read profile metadata"}, + {name: "corrupt", read: func(string) ([]byte, error) { return []byte("{"), nil }, wantErr: "parse profile metadata"}, + {name: "forward version", read: func(string) ([]byte, error) { return []byte(`{"version":999}`), nil }, wantErr: "newer than supported"}, + {name: "no current", read: func(string) ([]byte, error) { return []byte(`{"version":3,"profiles":[]}`), nil }}, + } { + t.Run(tc.name, func(t *testing.T) { + testseam.Swap(t, &profilesReadFile, tc.read) + testseam.Swap(t, &profilesRename, func(string, string) error { + t.Fatal("read-only metadata resolution attempted a quarantine rename") + return nil + }) + got, err := ResolveProfileMetadataReadOnly("/config", "") + if tc.wantErr == "" { + if err != nil || got != nil { + t.Fatalf("read-only metadata = %#v, %v", got, err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) || got != nil { + t.Fatalf("read-only metadata = %#v, %v; want %q", got, err, tc.wantErr) + } + }) + } +} + +func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyRejectsUnknownSelector(t *testing.T) { + testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { + return []byte(`{"version":3,"currentProfile":"corp-a:user-a","profiles":[{"name":"alpha","corpId":"corp-a","userId":"user-a"}]}`), nil + }) + profile, err := ResolveProfileMetadataReadOnly("/config", "missing") + if err == nil || profile != nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("unknown read-only profile = %#v, %v", profile, err) + } +} diff --git a/internal/cli/schema_contract_model.go b/internal/cli/schema_contract_model.go index 204b3761..e4d0e568 100644 --- a/internal/cli/schema_contract_model.go +++ b/internal/cli/schema_contract_model.go @@ -1127,21 +1127,37 @@ func putRawJSON(payload map[string]any, key string, raw json.RawMessage) error { return nil } +// rawJSONValue decodes a JSON value that may come from an untrusted source, so +// it validates before decoding. Callers holding output that json.Marshal just +// produced should use typedJSONValue instead of paying the validation scan. func rawJSONValue(raw json.RawMessage) (any, error) { if !json.Valid(raw) { return nil, fmt.Errorf("invalid JSON value") } + return decodeValidJSONValue(raw), nil +} + +// decodeValidJSONValue decodes JSON whose validity the caller has already +// established, either by json.Valid or by having just marshaled it. Decode +// errors are unreachable under that precondition and are therefore discarded, +// exactly as this path behaved when the decode was inlined into rawJSONValue. +func decodeValidJSONValue(raw json.RawMessage) any { decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() var value any _ = decoder.Decode(&value) - return value, nil + return value } +// typedJSONValue projects a typed value into the generic JSON shape the payload +// renderers consume. json.Marshal output is valid by construction, so this path +// decodes it directly: routing through rawJSONValue re-scanned every marshaled +// document with json.Valid, which measured ~34% of Schema Catalog assembly time +// across the 1121-tool set (26.0s -> 17.2s for the internal/app schema suite). func typedJSONValue(value any) (any, error) { data, err := json.Marshal(value) if err != nil { return nil, err } - return rawJSONValue(data) + return decodeValidJSONValue(data), nil } diff --git a/internal/corecmd/corecmd.go b/internal/corecmd/corecmd.go index 5d0fbe59..04741dce 100644 --- a/internal/corecmd/corecmd.go +++ b/internal/corecmd/corecmd.go @@ -145,6 +145,15 @@ type FlagSpec struct { // alike) and makes a whitespace-only value count as empty in required checks. Trim bool + // Input declares extra input sources for a KindString flag beyond the + // literal command-line value: InputFile enables @path (value replaced by + // the file content), InputStdin enables - (value replaced by stdin). + // "@@value" always escapes to the literal "@value". Only explicit CLI + // tokens are resolved; EnvVar fallback and registration defaults pass + // through unchanged. Resolution runs before required/enum/constraint/ + // Validate checks, so they see the payload content. Empty = flag value only. + Input []string + // Schema parameter final facts (embedded to dws.schema.*; assembly pass-through). Enum []string // accepted values Format string // machine-readable format (e.g. uri) @@ -365,6 +374,7 @@ func New(spec Spec) *cobra.Command { validateDispatchDecl(spec) validateSafetySpec(spec) validateContractDecl(spec) + validateInputSpecs(spec.Use, spec.Flags) // Help prose inherits the declaration when not authored separately: // Selection.Examples (already contract-validated against the real flags) // double as the --help Example block, keeping one authored source. @@ -476,6 +486,11 @@ func runDeclaredPreflight(cmd *cobra.Command, args []string, spec Spec) error { return err } } + // Input resolution rewrites explicit @file / stdin values in place so the + // required/enum/constraint/Validate stages below check the payload content. + if err := resolveInputFlags(cmd, spec.Flags); err != nil { + return err + } if err := ValidateRequired(cmd, spec.Flags); err != nil { return err } diff --git a/internal/corecmd/input.go b/internal/corecmd/input.go new file mode 100644 index 00000000..a3bbbe18 --- /dev/null +++ b/internal/corecmd/input.go @@ -0,0 +1,191 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package corecmd + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" +) + +// FlagSpec.Input source constants. They declare extra input sources for a +// KindString flag beyond the literal command-line value, mirroring the +// lark-cli Flag.Input capability so large payloads (markdown, JSON, CSV) +// never need shell quoting. +const ( + // InputFile allows the flag value @path to be replaced by the file content. + InputFile = "file" + // InputStdin allows the flag value - to be replaced by the stdin content. + InputStdin = "stdin" +) + +// utf8BOM is stripped from file/stdin content so a Windows-edited payload +// cannot corrupt the first CSV cell or break JSON parsing downstream. +const utf8BOM = "\ufeff" + +// validateInputSpecs rejects malformed Input declarations at build time. Like +// the other declaration checks this panics: a bad Input spec is a programming +// error every test and startup path should trip immediately. +func validateInputSpecs(use string, flags []FlagSpec) { + for _, flag := range flags { + if len(flag.Input) == 0 { + continue + } + if flag.Kind != KindString { + panic(fmt.Sprintf( + "command %q flag %q: Input is only supported on KindString flags", + use, flag.Name)) + } + seen := map[string]bool{} + for _, source := range flag.Input { + if source != InputFile && source != InputStdin { + panic(fmt.Sprintf( + "command %q flag %q: unknown Input source %q (allowed: %s, %s)", + use, flag.Name, source, InputFile, InputStdin)) + } + if seen[source] { + panic(fmt.Sprintf( + "command %q flag %q: duplicate Input source %q", + use, flag.Name, source)) + } + seen[source] = true + } + } +} + +// inputSupports reports whether the flag declared the given input source. +func inputSupports(flag FlagSpec, source string) bool { + for _, s := range flag.Input { + if s == source { + return true + } + } + return false +} + +// explicitInputFlagName picks the declared name that carries the user's +// explicit token, mirroring rawValue's order and usability judgement exactly: +// the main flag wins only when changed and usable (trim-judged only when +// Trim is set), then declared aliases in order. Diverging here could rewrite +// an alias that rawValue would shadow (or vice versa). EnvVar fallback and +// registration defaults are never input-resolved — @file only applies to what +// the user literally typed. +func explicitInputFlagName(cmd *cobra.Command, flag FlagSpec) string { + usable := func(v string) bool { + if flag.Trim { + v = strings.TrimSpace(v) + } + return v != "" + } + for _, name := range append([]string{flag.Name}, flag.Aliases...) { + if cmd.Flags().Changed(name) && usable(cmdutil.MustGetFlag(cmd, name)) { + return name + } + } + return "" +} + +// resolveInputFlags rewrites the explicit values of Input-declaring flags +// before required/enum/constraint/Validate checks, so every downstream stage +// sees the real payload content. Semantics: +// +// - "-" reads stdin; a process has a single stdin, so a second Input flag +// using "-" in the same invocation is rejected. +// - "@path" reads the named file (leading/trailing whitespace trimmed). +// - "@@value" escapes to the literal "@value" and is not source-resolved. +// +// Resolution runs once per invocation inside runDeclaredPreflight. It +// rewrites the cobra flag value in place (Set keeps Changed=true), so +// fallback-chain readers (EffectiveValue/BuildArgs) observe the content. +// +// Interaction with confirmation: stdin is consumed here, before the Safety +// prompt of a user_required write would read it; such an interactive prompt +// then sees EOF and fails closed with confirmation_required. Write commands +// declaring InputStdin must be invoked with --yes (or --dry-run). +func resolveInputFlags(cmd *cobra.Command, flags []FlagSpec) error { + stdinConsumed := false + for _, flag := range flags { + if len(flag.Input) == 0 { + continue + } + name := explicitInputFlagName(cmd, flag) + if name == "" { + continue + } + raw := cmdutil.MustGetFlag(cmd, name) + // Trim flags judge usability on the trimmed value (rawValue), so the + // prefix check must agree or " @path" would slip through unresolved. + if flag.Trim { + raw = strings.TrimSpace(raw) + } + + switch { + case raw == "-": + if !inputSupports(flag, InputStdin) { + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s 不支持 stdin 输入(-)", flag.Name)) + } + if stdinConsumed { + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s:stdin(-)只能被一个参数使用", flag.Name), + apperrors.WithHint(fmt.Sprintf( + "一个进程只有一份 stdin,其余参数请内联传值或用 @文件路径(如 --%s @./payload.json)", + flag.Name))) + } + stdinConsumed = true + data, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s 读取 stdin 失败:%v", flag.Name, err)) + } + // Setting a registered string flag cannot fail. + _ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM)) + + case strings.HasPrefix(raw, "@@"): + // Escape: strip the first @, keep the rest as a literal inline value. + _ = cmd.Flags().Set(name, raw[1:]) + + case strings.HasPrefix(raw, "@"): + if !inputSupports(flag, InputFile) { + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s 不支持文件输入(@路径)", flag.Name)) + } + path := strings.TrimSpace(raw[1:]) + if path == "" { + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s:@ 后的文件路径不能为空", flag.Name)) + } + data, err := os.ReadFile(path) + if err != nil { + var opts []apperrors.Option + if inputSupports(flag, InputStdin) { + // Rejected @file paths are usually absolute (temp files). + // Steer toward stdin rather than copying the file around. + opts = append(opts, apperrors.WithHint(fmt.Sprintf( + "该参数也支持 stdin:把文件内容管道进命令并传 --%s -", flag.Name))) + } + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s 读取文件 %q 失败:%v", flag.Name, path, err), opts...) + } + _ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM)) + } + } + return nil +} diff --git a/internal/corecmd/input_test.go b/internal/corecmd/input_test.go new file mode 100644 index 00000000..3edab9ab --- /dev/null +++ b/internal/corecmd/input_test.go @@ -0,0 +1,334 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package corecmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// newInputCommand builds a Spec whose Invoke captures toolArgs, so tests can +// assert what the full pipeline (resolution → validation → BuildArgs) ships. +func newInputCommand(flags []FlagSpec, captured *map[string]any) *cobra.Command { + return New(Spec{ + Use: "t", + Short: "t", + Flags: flags, + Invoke: func(c *Ctx, toolArgs map[string]any) error { + *captured = toolArgs + return nil + }, + }) +} + +func runInputCommand(t *testing.T, cmd *cobra.Command, args ...string) error { + t.Helper() + cmd.SetArgs(args) + return cmd.Execute() +} + +func writeInputFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "payload.txt") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + return path +} + +func TestCrossPlatformCoverageResolveInputFlagsFile(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}}, + }, &got) + path := writeInputFile(t, "# hello\nworld") + + if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "# hello\nworld" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +func TestCrossPlatformCoverageResolveInputFlagsStdin(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}}, + }, &got) + cmd.SetIn(strings.NewReader("piped payload")) + + if err := runInputCommand(t, cmd, "--content", "-"); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "piped payload" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +// The @@ escape keeps a literal leading @ inline and must not be treated as a +// source reference even when Input is declared. +func TestCrossPlatformCoverageResolveInputFlagsEscapedAtStaysInline(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}}, + }, &got) + + if err := runInputCommand(t, cmd, "--content", "@@literal@x"); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "@literal@x" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +func TestCrossPlatformCoverageResolveInputFlagsBOMStripped(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}}, + }, &got) + path := writeInputFile(t, "\ufeff{\"a\":1}") + + if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "{\"a\":1}" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +// Required must be satisfied by the resolved payload, proving resolution runs +// before the required stage. +func TestCrossPlatformCoverageResolveInputFlagsSatisfiesRequired(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Required: true, Input: []string{InputFile}}, + }, &got) + path := writeInputFile(t, "payload") + + if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "payload" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +// Enum validation sees the resolved content, not the "@path" token. +func TestCrossPlatformCoverageResolveInputFlagsEnumValidatesResolvedContent(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "mode", Usage: "M", Bind: "mode", Enum: []string{"asc", "desc"}, Input: []string{InputFile}}, + }, &got) + path := writeInputFile(t, "sideways") + + err := runInputCommand(t, cmd, "--mode", "@"+path) + if err == nil || !strings.Contains(err.Error(), "不合法") { + t.Fatalf("expected enum rejection on resolved content, got %v", err) + } +} + +// A value passed through a declared alias is resolved exactly like the main +// name (fallback-chain parity). +func TestCrossPlatformCoverageResolveInputFlagsAliasResolved(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}}, + }, &got) + path := writeInputFile(t, "via alias") + + if err := runInputCommand(t, cmd, "--body", "@"+path); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "via alias" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +// When the main flag shadows a changed alias (rawValue usability order), the +// shadowed alias must not be input-resolved: resolution targets exactly the +// name the fallback chain will read. The whitespace main value is usable for +// a non-Trim flag, and the alias path does not exist on purpose — a resolver +// that wrongly picked the alias would fail the read instead of shipping " ". +func TestCrossPlatformCoverageResolveInputFlagsShadowedAliasNotResolved(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}}, + }, &got) + missing := filepath.Join(t.TempDir(), "missing.txt") + + if err := runInputCommand(t, cmd, "--content", " ", "--body", "@"+missing); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != " " { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +func TestCrossPlatformCoverageResolveInputFlagsFileNotSupported(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}}, + }, &got) + + err := runInputCommand(t, cmd, "--content", "@/tmp/whatever.txt") + if err == nil || !strings.Contains(err.Error(), "不支持文件输入") { + t.Fatalf("expected file-input rejection, got %v", err) + } +} + +func TestCrossPlatformCoverageResolveInputFlagsStdinNotSupported(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}}, + }, &got) + cmd.SetIn(strings.NewReader("x")) + + err := runInputCommand(t, cmd, "--content", "-") + if err == nil || !strings.Contains(err.Error(), "不支持 stdin") { + t.Fatalf("expected stdin rejection, got %v", err) + } +} + +func TestCrossPlatformCoverageResolveInputFlagsSingleStdinConsumer(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "first", Usage: "F", Bind: "first", Input: []string{InputStdin}}, + {Name: "second", Usage: "S", Bind: "second", Input: []string{InputStdin}}, + }, &got) + cmd.SetIn(strings.NewReader("x")) + + err := runInputCommand(t, cmd, "--first", "-", "--second", "-") + if err == nil || !strings.Contains(err.Error(), "只能被一个参数使用") { + t.Fatalf("expected single-stdin rejection, got %v", err) + } +} + +// failingReader (corecmd_test.go) fails every read, making the stdin error +// branch reachable. +func TestCrossPlatformCoverageResolveInputFlagsStdinReadFailure(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}}, + }, &got) + cmd.SetIn(failingReader{}) + + err := runInputCommand(t, cmd, "--content", "-") + if err == nil || !strings.Contains(err.Error(), "读取 stdin 失败") { + t.Fatalf("expected stdin read failure, got %v", err) + } +} + +func TestCrossPlatformCoverageResolveInputFlagsFileNotFound(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}}, + }, &got) + + err := runInputCommand(t, cmd, "--content", "@"+filepath.Join(t.TempDir(), "missing.txt")) + if err == nil || !strings.Contains(err.Error(), "读取文件") { + t.Fatalf("expected read failure, got %v", err) + } +} + +func TestCrossPlatformCoverageResolveInputFlagsEmptyPathAfterAt(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}}, + }, &got) + + err := runInputCommand(t, cmd, "--content", "@ ") + if err == nil || !strings.Contains(err.Error(), "文件路径不能为空") { + t.Fatalf("expected empty-path rejection, got %v", err) + } +} + +// A flag without Input keeps its literal value even when it looks like a +// source reference — resolution is strictly opt-in per declaration. +func TestCrossPlatformCoverageResolveInputFlagsNoInputSpecPassthrough(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "token", Usage: "T", Bind: "token"}, + }, &got) + + if err := runInputCommand(t, cmd, "--token", "@not-a-file"); err != nil { + t.Fatalf("execute: %v", err) + } + if got["token"] != "@not-a-file" { + t.Fatalf("toolArgs[token] = %q", got["token"]) + } +} + +// Registration defaults and env fallback are never input-resolved: @file only +// applies to what the user literally typed on the command line. +func TestCrossPlatformCoverageResolveInputFlagsDefaultNotResolved(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Default: "@not-a-file", Input: []string{InputFile}}, + }, &got) + + if err := runInputCommand(t, cmd); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "@not-a-file" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +// Trim flags judge usability on the trimmed value (rawValue), so a leading +// whitespace before @ must still resolve instead of shipping as a literal. +func TestCrossPlatformCoverageResolveInputFlagsTrimmedLeadingSpace(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Trim: true, Input: []string{InputFile}}, + }, &got) + path := writeInputFile(t, "trimmed payload") + + if err := runInputCommand(t, cmd, "--content", " @"+path); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "trimmed payload" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +func TestCrossPlatformCoverageValidateInputSpecsPanics(t *testing.T) { + cases := []struct { + name string + flag FlagSpec + }{ + {"non-string kind", FlagSpec{Name: "n", Kind: KindInt, Input: []string{InputFile}}}, + {"unknown source", FlagSpec{Name: "s", Input: []string{"url"}}}, + {"duplicate source", FlagSpec{Name: "s", Input: []string{InputFile, InputFile}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + New(Spec{ + Use: "t", + Short: "t", + Flags: []FlagSpec{tc.flag}, + Invoke: func(c *Ctx, toolArgs map[string]any) error { return nil }, + }) + }) + } +} diff --git a/internal/event/bus/coverage_edges_test.go b/internal/event/bus/coverage_edges_test.go index 8086e4d7..ea0f9cbb 100644 --- a/internal/event/bus/coverage_edges_test.go +++ b/internal/event/bus/coverage_edges_test.go @@ -361,13 +361,18 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) { } return nil, net.ErrClosed }} - d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{})} + d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{}), stopReq: make(chan string, 1)} d.acceptLoop(context.Background()) d.shuttingDown.Store(true) d.acceptLoop(context.Background()) d.triggerShutdown("test") - if !l.closed { - t.Fatal("trigger did not close listener") + select { + case reason := <-d.stopReq: + if reason != "test" { + t.Fatalf("stop reason = %q", reason) + } + default: + t.Fatal("trigger did not notify lifecycle loop") } d = &daemon{listener: &scriptedListener{accept: func() (net.Conn, error) { return nil, net.ErrClosed }}, log: logger, hub: NewHub(1), idleStop: make(chan struct{})} diff --git a/internal/event/bus/daemon.go b/internal/event/bus/daemon.go index 7fac9aff..0c43d908 100644 --- a/internal/event/bus/daemon.go +++ b/internal/event/bus/daemon.go @@ -186,6 +186,7 @@ func Run(ctx context.Context, cfg Config) error { dedup: dd, started: time.Now().UTC(), idleStop: make(chan struct{}), + stopReq: make(chan string, 1), } // 4. Signal ready BEFORE accepting consumers (avoids a slow-fork @@ -263,6 +264,9 @@ func Run(ctx context.Context, cfg Config) error { } case <-d.idleStop: log.Info("bus: idle timeout reached, shutting down") + case reason := <-d.stopReq: + shutdownReason = reason + log.Info("bus: shutdown requested via IPC", "reason", reason) } // 7. Graceful shutdown — cancel runCtx first so all background @@ -292,6 +296,7 @@ type daemon struct { shutdownMu sync.Mutex shuttingDown atomic.Bool idleStop chan struct{} + stopReq chan string credentialHandoffMu sync.Mutex terminalMu sync.RWMutex @@ -749,15 +754,17 @@ func (d *daemon) idleWatch(ctx context.Context) { } } -// triggerShutdown is called from RPC handlers that want to end the bus. -// It works by closing the listener (which unblocks Run's select via the -// source error path, indirectly). For v1 a full ctx-cancellation hook is -// out of scope; busctl/stop also sends SIGTERM which is the authoritative -// shutdown path. +// triggerShutdown is called from RPC handlers that want to end the bus. The +// buffered request wakes Run's lifecycle select, which then cancels the cloud +// source and performs the same graceful shutdown used for signals and idle +// expiry. It is intentionally non-blocking so repeated stop requests cannot +// strand connection handlers. func (d *daemon) triggerShutdown(reason string) { d.log.Info("bus: shutdown triggered via IPC", "reason", reason) - _ = d.listener.Close() // unblocks Accept(), but doesn't kill Source - // Best-effort: a future version wires a context.CancelFunc here. + select { + case d.stopReq <- reason: + default: + } } // shutdown performs the graceful tear-down sequence: diff --git a/internal/event/bus/daemon_test.go b/internal/event/bus/daemon_test.go index 49a46d20..d99e4aa7 100644 --- a/internal/event/bus/daemon_test.go +++ b/internal/event/bus/daemon_test.go @@ -141,6 +141,67 @@ func TestDaemon_RunStartsAndShutsDownCleanly(t *testing.T) { } } +func TestCrossPlatformCoverageDaemonIPCStopShutsDownCleanly(t *testing.T) { + workDir := shortTempDir(t) + identityHash := dwsevent.IdentityHash(workDir) + endpoint := dwsevent.IPCEndpoint(workDir, "open", dwsevent.SourceKindAppStream, identityHash) + runDone := make(chan error, 1) + go func() { + runDone <- Run(context.Background(), Config{ + WorkDir: workDir, + IPCEndpoint: endpoint, + ClientID: "ding_ipc_stop_test", + Edition: "open", + SourceKind: dwsevent.SourceKindAppStream, + IdentityHash: identityHash, + Source: &fakeSource{}, + }) + }() + + var conn net.Conn + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + var err error + conn, err = transport.Dial(endpoint) + if err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if conn == nil { + t.Fatal("bus IPC endpoint did not become ready") + } + w := transport.NewWriter(conn) + r := transport.NewReader(conn) + if err := w.WriteJSON(transport.Hello{ + Type: transport.FrameTypeHello, + ConsumerPID: os.Getpid(), + Role: transport.HelloRoleStop, + }); err != nil { + t.Fatalf("write stop hello: %v", err) + } + var bye transport.Bye + if err := r.ReadJSON(&bye); err != nil { + t.Fatalf("read stop response: %v", err) + } + _ = conn.Close() + if bye.Type != transport.FrameTypeBye || bye.Reason != "stop_request" { + t.Fatalf("stop response = %#v", bye) + } + + select { + case err := <-runDone: + if err != nil { + t.Fatalf("Run returned after IPC stop: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("Run did not return after IPC stop") + } + if pid := ReadHolderPID(filepath.Join(workDir, LockFileName)); pid != 0 { + t.Fatalf("bus lock retained pid %d after IPC stop", pid) + } +} + func TestDaemon_ConsumerReceivesEvents(t *testing.T) { skipOnWindows(t, "uses Unix socket dial") workDir := shortTempDir(t) diff --git a/internal/event/bus/lockfile.go b/internal/event/bus/lockfile.go index 0349a8c9..40dbaaee 100644 --- a/internal/event/bus/lockfile.go +++ b/internal/event/bus/lockfile.go @@ -114,6 +114,47 @@ func ReadHolderPID(path string) int { return parsePID(b) } +// ValidateHolderOwnership reports whether pid still owns the exclusive lock at +// path. If the lock can be acquired, no daemon owns it: the PID payload is +// stale and is cleared while the probe holds the lock. The PID is read again +// after a busy result so a daemon replacement cannot be mistaken for the +// original owner. +// +// Callers use this immediately before sending a process-level termination +// fallback. A live PID by itself is not proof of ownership because operating +// systems may reuse PIDs after an unclean daemon exit. +func ValidateHolderOwnership(path string, pid int) (bool, error) { + if pid <= 0 || ReadHolderPID(path) != pid { + return false, nil + } + + probe, err := busTryAcquire(path) + if err != nil { + if errors.Is(err, ErrBusy) { + return ReadHolderPID(path) == pid, nil + } + return false, fmt.Errorf("bus: verify lock owner: %w", err) + } + defer probe.Close() + + // We own the lock, so the recorded PID cannot own it. Clear only the PID + // we validated above; a changed payload is left untouched defensively. + f := probe.File() + if _, err := busSeek(f, 0, io.SeekStart); err != nil { + return false, fmt.Errorf("bus: seek stale lock: %w", err) + } + current, err := busReadAll(f) + if err != nil { + return false, fmt.Errorf("bus: read stale lock: %w", err) + } + if parsePID(current) == pid { + if err := truncateAndWritePID(f, 0); err != nil { + return false, fmt.Errorf("bus: clear stale PID: %w", err) + } + } + return false, nil +} + // Close releases the flock and best-effort blanks the PID body so a stale // reader (e.g. `event status` racing our shutdown) does not see our // long-dead PID and try to signal it. The lock file itself is NOT removed diff --git a/internal/event/bus/lockfile_test.go b/internal/event/bus/lockfile_test.go index 6ec4e86d..f5b9a29a 100644 --- a/internal/event/bus/lockfile_test.go +++ b/internal/event/bus/lockfile_test.go @@ -16,9 +16,13 @@ package bus import ( "errors" "fmt" + "io" "os" "path/filepath" "testing" + + eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) func TestAcquire_WritesOurPID(t *testing.T) { @@ -112,6 +116,109 @@ func TestReadHolderPID_MalformedReturnsZero(t *testing.T) { } } +func TestCrossPlatformCoverageValidateHolderOwnershipHeldLock(t *testing.T) { + path := filepath.Join(t.TempDir(), LockFileName) + held, err := busTryAcquire(path) + if err != nil { + t.Fatalf("hold lock: %v", err) + } + defer held.Close() + if err := truncateAndWritePID(held.File(), os.Getpid()); err != nil { + t.Fatalf("write holder PID: %v", err) + } + + owned, err := ValidateHolderOwnership(path, os.Getpid()) + if err != nil { + t.Fatalf("ValidateHolderOwnership: %v", err) + } + if !owned { + t.Fatal("held lock was not attributed to its recorded PID") + } +} + +func TestCrossPlatformCoverageValidateHolderOwnershipClearsReusedPID(t *testing.T) { + path := filepath.Join(t.TempDir(), LockFileName) + // The current PID is alive but deliberately does not hold this lock. This + // models a stale daemon PID that the OS has reassigned to another process. + if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil { + t.Fatalf("write stale PID: %v", err) + } + + owned, err := ValidateHolderOwnership(path, os.Getpid()) + if err != nil { + t.Fatalf("ValidateHolderOwnership: %v", err) + } + if owned { + t.Fatal("live reused PID without the bus lock was accepted as owner") + } + if got := ReadHolderPID(path); got != 0 { + t.Fatalf("stale PID after validation = %d, want cleared", got) + } +} + +func TestCrossPlatformCoverageValidateHolderOwnershipRejectsMismatch(t *testing.T) { + path := filepath.Join(t.TempDir(), LockFileName) + if err := os.WriteFile(path, []byte("123\n"), 0o600); err != nil { + t.Fatal(err) + } + owned, err := ValidateHolderOwnership(path, 456) + if err != nil || owned { + t.Fatalf("ValidateHolderOwnership mismatch = %v, %v", owned, err) + } +} + +func TestCrossPlatformCoverageValidateHolderOwnershipErrors(t *testing.T) { + errInjected := errors.New("injected ownership validation failure") + writeLivePID := func(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), LockFileName) + if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil { + t.Fatal(err) + } + return path + } + + t.Run("probe", func(t *testing.T) { + path := writeLivePID(t) + testseam.Swap(t, &busTryAcquire, func(string) (*eventlock.File, error) { + return nil, errInjected + }) + if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) { + t.Fatalf("probe error = %v", err) + } + }) + + t.Run("seek", func(t *testing.T) { + path := writeLivePID(t) + testseam.Swap(t, &busSeek, func(*os.File, int64, int) (int64, error) { + return 0, errInjected + }) + if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) { + t.Fatalf("seek error = %v", err) + } + }) + + t.Run("read", func(t *testing.T) { + path := writeLivePID(t) + testseam.Swap(t, &busReadAll, func(io.Reader) ([]byte, error) { + return nil, errInjected + }) + if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) { + t.Fatalf("read error = %v", err) + } + }) + + t.Run("clear", func(t *testing.T) { + path := writeLivePID(t) + testseam.Swap(t, &busTruncate, func(*os.File, int64) error { + return errInjected + }) + if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) { + t.Fatalf("clear error = %v", err) + } + }) +} + func TestAcquire_AfterReleaseReclaimable(t *testing.T) { path := filepath.Join(t.TempDir(), LockFileName) l1, err := Acquire(path) diff --git a/internal/event/busctl/coverage_edges_test.go b/internal/event/busctl/coverage_edges_test.go index f84ed54e..09de121c 100644 --- a/internal/event/busctl/coverage_edges_test.go +++ b/internal/event/busctl/coverage_edges_test.go @@ -284,11 +284,13 @@ func TestCrossPlatformCoverageQueryStatusAndEntryEdges(t *testing.T) { func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) { origRead := stopReadHolderPID + origValidateOwner := stopValidateHolderOwner origAlive := stopAlive origFind := stopFindProcess origSignal := stopSignalProcess t.Cleanup(func() { stopReadHolderPID = origRead + stopValidateHolderOwner = origValidateOwner stopAlive = origAlive stopFindProcess = origFind stopSignalProcess = origSignal @@ -307,6 +309,7 @@ func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) { t.Fatal(err) } stopFindProcess = func(int) (*os.Process, error) { return proc, nil } + stopValidateHolderOwner = func(string, int) (bool, error) { return true, nil } stopSignalProcess = func(*os.Process, os.Signal) error { return os.ErrProcessDone } if err := Stop(StopConfig{WorkDir: "x"}); err != nil { t.Fatalf("done signal = %v", err) diff --git a/internal/event/busctl/discover.go b/internal/event/busctl/discover.go index 2bbbb863..56fd1c64 100644 --- a/internal/event/busctl/discover.go +++ b/internal/event/busctl/discover.go @@ -112,8 +112,9 @@ func Discover(cfg DiscoverConfig) (net.Conn, error) { return nil, fmt.Errorf("busctl: mkdir workdir: %w", err) } _, spawnErr := cfg.Spawn(SpawnConfig{ - ClientID: cfg.ClientID, - ExtraArgs: cfg.SpawnExtraArgs, + ClientID: cfg.ClientID, + IPCEndpoint: cfg.IPCEndpoint, + ExtraArgs: cfg.SpawnExtraArgs, }) if spawnErr != nil && !errors.Is(spawnErr, ErrSpawnFailed) { // Hard error (couldn't even exec the child). Stop here — no bus diff --git a/internal/event/busctl/discover_test.go b/internal/event/busctl/discover_test.go index a4b42bcb..7e2e2dbc 100644 --- a/internal/event/busctl/discover_test.go +++ b/internal/event/busctl/discover_test.go @@ -122,7 +122,10 @@ func TestDiscover_NoBus_SpawnSucceeds(t *testing.T) { closer() } }) - fakeSpawn := func(SpawnConfig) (int, error) { + fakeSpawn := func(cfg SpawnConfig) (int, error) { + if cfg.IPCEndpoint != sock { + t.Fatalf("spawn IPC endpoint = %q, want %q", cfg.IPCEndpoint, sock) + } closer = startStubBus(t, sock) return 12345, nil } diff --git a/internal/event/busctl/spawn.go b/internal/event/busctl/spawn.go index 5c3c589f..f3b89165 100644 --- a/internal/event/busctl/spawn.go +++ b/internal/event/busctl/spawn.go @@ -43,18 +43,14 @@ var ( spawnPipe = os.Pipe ) -// ErrSpawnFailed is returned when the child reports startup failure via -// the ready pipe ('E' byte). The child's exit error / log file holds the -// actual cause; this sentinel just lets the caller distinguish "ready -// pipe said no" from "ready pipe timed out / closed early". -var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure on ready pipe") +// ErrSpawnFailed is returned when the child reports startup failure through +// the Unix ready pipe or exits before binding the Windows named pipe. +var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure") // ErrSpawnTimeout is returned when ReadyTimeout elapses without any signal. var ErrSpawnTimeout = errors.New("busctl: bus child did not signal readiness within deadline") -// SpawnConfig describes one spawn attempt. ClientID is the only field -// inspected by the child; the rest govern process attributes the parent -// applies before exec. +// SpawnConfig describes one spawn attempt. type SpawnConfig struct { // ExecPath is the dws binary to exec. Default os.Executable(). ExecPath string @@ -62,18 +58,23 @@ type SpawnConfig struct { // ClientID is passed as `--client-id` to `dws event _bus`. Required. ClientID string + // IPCEndpoint is the bus endpoint the child binds. Windows uses the + // existing named pipe as its readiness handshake because os/exec does not + // support ExtraFiles there. Unix keeps the inherited ready-pipe protocol. + IPCEndpoint string + // ExtraArgs are appended after `--client-id`. Empty for normal use; tests // pass `--extra-flag-for-test` etc. ExtraArgs []string - // Env to pass to the child. Defaults to os.Environ(). The ReadyFDEnv - // entry is appended automatically. + // Env to pass to the child. Defaults to os.Environ(). Unix appends the + // ReadyFDEnv entry automatically; Windows removes any inherited copy. Env []string } -// Spawn forks a detached `dws event _bus --client-id ` child process and -// waits for it to signal readiness via the ready pipe. Returns the child's -// PID on success — the caller can then dial the bus IPC endpoint. +// spawnWithReadyPipe is the Unix implementation behind Spawn. It forks a +// detached `dws event _bus --client-id ` child and waits for the inherited +// ready pipe. Windows has a named-pipe implementation in spawn_windows.go. // // stdio detach (plan invariant #7): // - cmd.Stdout / cmd.Stderr set to nil so the child's own writes don't @@ -89,7 +90,7 @@ type SpawnConfig struct { // Parent (this function): // - Holds the read end open until either 1 byte is read or ReadyTimeout // - Returns ErrSpawnFailed for 'E', ErrSpawnTimeout otherwise -func Spawn(cfg SpawnConfig) (pid int, err error) { +func spawnWithReadyPipe(cfg SpawnConfig) (pid int, err error) { if cfg.ClientID == "" { return 0, errors.New("busctl: SpawnConfig.ClientID is required") } diff --git a/internal/event/busctl/spawn_test.go b/internal/event/busctl/spawn_test.go index 42a1c532..dc931700 100644 --- a/internal/event/busctl/spawn_test.go +++ b/internal/event/busctl/spawn_test.go @@ -22,6 +22,8 @@ import ( "strings" "testing" "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" ) // Test child mode: when DWS_BUSCTL_TEST_CHILD is set, this test binary @@ -35,7 +37,8 @@ import ( // env-marker pattern is what Go's own os/exec tests use and stays // confined to this file. const ( - childEnvMarker = "DWS_BUSCTL_TEST_CHILD" + childEnvMarker = "DWS_BUSCTL_TEST_CHILD" + childEndpointEnv = "DWS_BUSCTL_TEST_ENDPOINT" // values: // "ready" — write 'R' then sleep 30s (parent should see ready) // "fail" — write 'E' then exit (parent should see ErrSpawnFailed) @@ -70,6 +73,26 @@ func TestMain(m *testing.M) { writeReady('R') time.Sleep(30 * time.Second) os.Exit(0) + case "windows-ready": + listener, err := transport.Listen(os.Getenv(childEndpointEnv)) + if err != nil { + os.Exit(3) + } + defer listener.Close() + conn, err := listener.Accept() + if err != nil { + os.Exit(4) + } + _ = conn.Close() + time.Sleep(30 * time.Second) + os.Exit(0) + case "windows-fail": + os.Exit(5) + case "windows-exit": + os.Exit(0) + case "windows-stall": + time.Sleep(30 * time.Second) + os.Exit(0) } os.Exit(m.Run()) } diff --git a/internal/event/busctl/spawn_unix.go b/internal/event/busctl/spawn_unix.go index b568c524..d882808c 100644 --- a/internal/event/busctl/spawn_unix.go +++ b/internal/event/busctl/spawn_unix.go @@ -20,6 +20,13 @@ import ( "syscall" ) +// Spawn starts the detached bus and waits for its inherited ready pipe. +// ExtraFiles is intentionally confined to Unix: Go does not support it on +// Windows. +func Spawn(cfg SpawnConfig) (pid int, err error) { + return spawnWithReadyPipe(cfg) +} + // applyDetach configures the child to live past parent death and not share // the parent's controlling terminal. Setsid puts the child in a new // session, so SIGHUP on the parent's controlling tty (e.g. SSH disconnect) diff --git a/internal/event/busctl/spawn_windows.go b/internal/event/busctl/spawn_windows.go index ee0174b1..1cdf6958 100644 --- a/internal/event/busctl/spawn_windows.go +++ b/internal/event/busctl/spawn_windows.go @@ -16,14 +16,101 @@ package busctl import ( + "fmt" + "os" "os/exec" + "strings" "syscall" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" ) // CREATE_NEW_PROCESS_GROUP (0x00000200) prevents the child from receiving // the parent's Ctrl+C signal, similar in spirit to Setsid on Unix. const createNewProcessGroup = 0x00000200 +var ( + spawnWindowsDial = transport.Dial + spawnWindowsPollInterval = 25 * time.Millisecond +) + +// Spawn starts a detached Windows bus without cmd.ExtraFiles (unsupported by +// Go on Windows). Readiness is confirmed by dialing the bus's existing named +// pipe. The child is reaped in the background after readiness, while an early +// process exit is surfaced as ErrSpawnFailed. +func Spawn(cfg SpawnConfig) (pid int, err error) { + if strings.TrimSpace(cfg.ClientID) == "" { + return 0, fmt.Errorf("busctl: SpawnConfig.ClientID is required") + } + if strings.TrimSpace(cfg.IPCEndpoint) == "" { + return 0, fmt.Errorf("busctl: SpawnConfig.IPCEndpoint is required on Windows") + } + if cfg.ExecPath == "" { + execPath, err := spawnExecutable() + if err != nil { + return 0, fmt.Errorf("busctl: locate executable: %w", err) + } + cfg.ExecPath = execPath + } + if cfg.Env == nil { + cfg.Env = os.Environ() + } + + args := append([]string{"event", "_bus", "--client-id", cfg.ClientID}, cfg.ExtraArgs...) + cmd := exec.Command(cfg.ExecPath, args...) + cmd.Env = withoutReadyFDEnv(cfg.Env) + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + applyDetach(cmd) + if err := cmd.Start(); err != nil { + return 0, fmt.Errorf("busctl: start %s: %w", cfg.ExecPath, err) + } + pid = cmd.Process.Pid + + waitDone := make(chan error, 1) + go func() { waitDone <- cmd.Wait() }() + timer := time.NewTimer(ReadyTimeout) + defer timer.Stop() + ticker := time.NewTicker(spawnWindowsPollInterval) + defer ticker.Stop() + + for { + if conn, dialErr := spawnWindowsDial(cfg.IPCEndpoint); dialErr == nil { + _ = conn.Close() + return pid, nil + } + select { + case waitErr := <-waitDone: + if waitErr == nil { + return pid, fmt.Errorf("%w: child exited before binding named pipe", ErrSpawnFailed) + } + return pid, fmt.Errorf("%w: %v", ErrSpawnFailed, waitErr) + case <-timer.C: + _ = cmd.Process.Kill() + select { + case <-waitDone: + case <-time.After(time.Second): + } + return pid, ErrSpawnTimeout + case <-ticker.C: + } + } +} + +func withoutReadyFDEnv(env []string) []string { + prefix := strings.ToUpper(ReadyFDEnv) + "=" + out := make([]string, 0, len(env)) + for _, entry := range env { + if strings.HasPrefix(strings.ToUpper(entry), prefix) { + continue + } + out = append(out, entry) + } + return out +} + func applyDetach(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{ CreationFlags: createNewProcessGroup, diff --git a/internal/event/busctl/spawn_windows_test.go b/internal/event/busctl/spawn_windows_test.go new file mode 100644 index 00000000..c133f08b --- /dev/null +++ b/internal/event/busctl/spawn_windows_test.go @@ -0,0 +1,132 @@ +// 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. + +//go:build windows + +package busctl + +import ( + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageWindowsSpawnUsesNamedPipeReadiness(t *testing.T) { + endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-test-%d-%d`, os.Getpid(), time.Now().UnixNano()) + pid, err := spawnWithMarker(t, "windows-ready", func(cfg *SpawnConfig) { + cfg.IPCEndpoint = endpoint + cfg.Env = append(cfg.Env, childEndpointEnv+"="+endpoint) + }) + if err != nil { + t.Fatalf("Spawn() = %v", err) + } + if pid <= 0 { + t.Fatalf("Spawn() pid = %d", pid) + } + proc, err := os.FindProcess(pid) + if err != nil { + t.Fatal(err) + } + _ = proc.Kill() +} + +func TestCrossPlatformCoverageWindowsSpawnReportsEarlyExit(t *testing.T) { + endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-fail-%d-%d`, os.Getpid(), time.Now().UnixNano()) + _, err := spawnWithMarker(t, "windows-fail", func(cfg *SpawnConfig) { + cfg.IPCEndpoint = endpoint + }) + if !errors.Is(err, ErrSpawnFailed) { + t.Fatalf("Spawn() error = %v, want ErrSpawnFailed", err) + } +} + +func TestCrossPlatformCoverageWindowsSpawnReportsCleanEarlyExit(t *testing.T) { + endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-exit-%d-%d`, os.Getpid(), time.Now().UnixNano()) + _, err := spawnWithMarker(t, "windows-exit", func(cfg *SpawnConfig) { + cfg.IPCEndpoint = endpoint + }) + if !errors.Is(err, ErrSpawnFailed) { + t.Fatalf("Spawn() error = %v, want ErrSpawnFailed", err) + } +} + +func TestCrossPlatformCoverageWindowsSpawnResolvesExecutable(t *testing.T) { + endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-resolve-%d-%d`, os.Getpid(), time.Now().UnixNano()) + testseam.Swap(t, &spawnExecutable, func() (string, error) { return os.Args[0], nil }) + pid, err := spawnWithMarker(t, "windows-ready", func(cfg *SpawnConfig) { + cfg.ExecPath = "" + cfg.IPCEndpoint = endpoint + cfg.Env = append(cfg.Env, childEndpointEnv+"="+endpoint) + }) + if err != nil { + t.Fatalf("Spawn() = %v", err) + } + proc, err := os.FindProcess(pid) + if err != nil { + t.Fatal(err) + } + _ = proc.Kill() +} + +func TestCrossPlatformCoverageWindowsSpawnValidationAndStartErrors(t *testing.T) { + endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-errors-%d-%d`, os.Getpid(), time.Now().UnixNano()) + if _, err := Spawn(SpawnConfig{ClientID: "client"}); err == nil { + t.Fatal("missing endpoint unexpectedly succeeded") + } + testseam.Swap(t, &spawnExecutable, func() (string, error) { + return "", errors.New("executable unavailable") + }) + if _, err := Spawn(SpawnConfig{ClientID: "client", IPCEndpoint: endpoint}); err == nil { + t.Fatal("executable lookup unexpectedly succeeded") + } + if _, err := Spawn(SpawnConfig{ + ClientID: "client", + IPCEndpoint: endpoint, + ExecPath: `C:\\definitely-missing-dws.exe`, + }); err == nil { + t.Fatal("missing executable unexpectedly started") + } +} + +func TestCrossPlatformCoverageWindowsSpawnReadyTimeoutTerminatesChild(t *testing.T) { + endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-stall-%d-%d`, os.Getpid(), time.Now().UnixNano()) + testseam.Swap(t, &ReadyTimeout, 100*time.Millisecond) + _, err := spawnWithMarker(t, "windows-stall", func(cfg *SpawnConfig) { + cfg.IPCEndpoint = endpoint + }) + if !errors.Is(err, ErrSpawnTimeout) { + t.Fatalf("Spawn() error = %v, want ErrSpawnTimeout", err) + } +} + +func TestCrossPlatformCoverageWindowsSpawnRemovesInheritedReadyFD(t *testing.T) { + env := withoutReadyFDEnv([]string{ + "PATH=C:\\Windows", + ReadyFDEnv + "=3", + strings.ToLower(ReadyFDEnv) + "=4", + }) + if len(env) != 1 || !strings.HasPrefix(env[0], "PATH=") { + t.Fatalf("withoutReadyFDEnv() = %#v", env) + } +} + +func TestCrossPlatformCoverageWindowsUnixReadyPipeValidation(t *testing.T) { + if _, err := spawnWithReadyPipe(SpawnConfig{}); err == nil { + t.Fatal("spawnWithReadyPipe() unexpectedly accepted an empty client ID") + } +} diff --git a/internal/event/busctl/stop.go b/internal/event/busctl/stop.go index 8b9bbdb1..8779a94f 100644 --- a/internal/event/busctl/stop.go +++ b/internal/event/busctl/stop.go @@ -17,15 +17,17 @@ import ( "errors" "fmt" "os" + "strings" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" ) -// DefaultStopTimeout is the wall-clock budget Stop waits for the bus to -// exit after the signal is sent. 5s covers the bus's own graceful tear-down -// (broadcast Bye → consumer goroutines drain → cleanup) with margin. +// DefaultStopTimeout is the wall-clock budget Stop waits for each graceful or +// fallback termination phase. 5s covers the bus's own tear-down (broadcast +// Bye → consumer goroutines drain → cleanup) with margin. const DefaultStopTimeout = 5 * time.Second // ErrNotRunning indicates bus.lock either does not exist or its recorded @@ -33,20 +35,32 @@ const DefaultStopTimeout = 5 * time.Second // distinguish "nothing to stop" from "failed to stop". var ErrNotRunning = errors.New("busctl: bus is not running") +// ErrOwnerUnverified indicates the PID from bus.lock is alive but no longer +// owns that lock. Stop refuses to send a process-level signal in this state +// because the operating system may have reused the stale PID. +var ErrOwnerUnverified = errors.New("busctl: bus process ownership could not be verified") + var ( - stopReadHolderPID = bus.ReadHolderPID - stopAlive = process.Alive - stopFindProcess = os.FindProcess - stopSignalProcess = func(proc *os.Process, signal os.Signal) error { return proc.Signal(signal) } + stopReadHolderPID = bus.ReadHolderPID + stopValidateHolderOwner = bus.ValidateHolderOwnership + stopAlive = process.Alive + stopFindProcess = os.FindProcess + stopSignalProcess = func(proc *os.Process, signal os.Signal) error { return proc.Signal(signal) } + stopDial = transport.Dial + stopRequest = requestBusStop + stopWaitForBusExit = waitForBusExit ) // StopConfig identifies the target bus and tunes timing. type StopConfig struct { // WorkDir holds bus.lock; Stop reads the PID from there. WorkDir string - // Timeout is the total wall-clock budget for graceful exit. After this, - // Stop returns an error; it does NOT escalate to SIGKILL — leave that - // to the operator. + // IPCEndpoint enables the cross-platform graceful stop RPC. Callers that + // know the bus identity should always provide it. Empty preserves the + // legacy signal-only fallback for compatibility and focused tests. + IPCEndpoint string + // Timeout is the wall-clock budget for graceful exit and, if required, + // the subsequent platform termination fallback. Timeout time.Duration } @@ -54,13 +68,12 @@ type StopConfig struct { // for the process to actually die. Returns ErrNotRunning if no bus is // running for that work dir. // -// Implementation note: on Unix we send SIGTERM. The bus daemon's Run loop -// watches its parent ctx for cancellation; the cobra `event _bus` command -// wires signal.NotifyContext so SIGTERM triggers ctx.Done() → graceful -// shutdown path. On Windows we use os.Process.Signal(os.Interrupt) which -// the Go runtime maps to TerminateProcess for processes outside our -// console group; for v1 that's acceptable (Windows graceful shutdown is -// future work — plan §16 v2). +// Stop first asks the bus to shut down through its owner-only IPC endpoint. +// This is the normal path on every platform and lets the daemon cancel its +// cloud source, notify consumers, and release its lock. If the endpoint is +// unavailable (for example an older bus) or graceful shutdown times out, the +// platform signal is used as a compatibility fallback: SIGTERM on Unix and +// TerminateProcess via os.Kill on Windows. func Stop(cfg StopConfig) error { if cfg.WorkDir == "" { return errors.New("busctl: StopConfig.WorkDir is required") @@ -81,6 +94,32 @@ func Stop(cfg StopConfig) error { if err != nil { return fmt.Errorf("busctl: find process %d: %w", pid, err) } + if strings.TrimSpace(cfg.IPCEndpoint) != "" { + if err := stopRequest(cfg.IPCEndpoint); err == nil && stopWaitForBusExit(pid, cfg.Timeout) { + return nil + } + } + + // The bus may finish shutting down immediately after the graceful wait + // reaches its deadline (or after a failed IPC request). Recheck before + // inspecting ownership so a completed stop is not reported as stale. + if !stopAlive(pid) { + return nil + } + owner, err := stopValidateHolderOwner(LockPath(cfg.WorkDir), pid) + if err != nil { + return fmt.Errorf("busctl: verify bus pid=%d ownership: %w", pid, err) + } + if !owner { + // ValidateHolderOwnership observes a released lock both for a stale, + // reused PID and for a bus that exits during the ownership probe. Only + // the still-alive case is unsafe to signal. + if !stopAlive(pid) { + return nil + } + return fmt.Errorf("%w: pid=%d does not own %s; stale PID was not signalled", ErrOwnerUnverified, pid, LockPath(cfg.WorkDir)) + } + if err := stopSignalProcess(proc, stopSignal()); err != nil { // On many Unix platforms Signal returns "process already finished" // when the bus has just exited on its own — treat that as success. @@ -90,13 +129,45 @@ func Stop(cfg StopConfig) error { return fmt.Errorf("busctl: signal bus pid=%d: %w", pid, err) } - // Poll for actual exit. - deadline := time.Now().Add(cfg.Timeout) - for time.Now().Before(deadline) { - if !stopAlive(pid) { - return nil - } - time.Sleep(50 * time.Millisecond) + if stopWaitForBusExit(pid, cfg.Timeout) { + return nil } return fmt.Errorf("busctl: bus pid=%d did not exit within %s", pid, cfg.Timeout) } + +func requestBusStop(endpoint string) error { + conn, err := stopDial(endpoint) + if err != nil { + return fmt.Errorf("busctl: dial bus for stop: %w", err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(DefaultStatusRPCTimeout)) + w := transport.NewWriter(conn) + r := transport.NewReader(conn) + if err := w.WriteJSON(transport.Hello{ + Type: transport.FrameTypeHello, + ConsumerPID: os.Getpid(), + Role: transport.HelloRoleStop, + }); err != nil { + return fmt.Errorf("busctl: write stop hello: %w", err) + } + var bye transport.Bye + if err := r.ReadJSON(&bye); err != nil { + return fmt.Errorf("busctl: read stop response: %w", err) + } + if bye.Type != transport.FrameTypeBye || bye.Reason != "stop_request" { + return fmt.Errorf("busctl: unexpected stop response type=%q reason=%q", bye.Type, bye.Reason) + } + return nil +} + +func waitForBusExit(pid int, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !stopAlive(pid) { + return true + } + time.Sleep(50 * time.Millisecond) + } + return !stopAlive(pid) +} diff --git a/internal/event/busctl/stop_test.go b/internal/event/busctl/stop_test.go index e4cd99de..8154dd64 100644 --- a/internal/event/busctl/stop_test.go +++ b/internal/event/busctl/stop_test.go @@ -16,6 +16,7 @@ package busctl import ( "context" "errors" + "net" "os" "os/exec" "path/filepath" @@ -23,7 +24,10 @@ import ( "testing" "time" + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) func TestStop_NotRunningWhenLockMissing(t *testing.T) { @@ -34,7 +38,7 @@ func TestStop_NotRunningWhenLockMissing(t *testing.T) { } } -func TestStop_NotRunningWhenPIDDead(t *testing.T) { +func TestCrossPlatformCoverageStopNotRunningWhenPIDDead(t *testing.T) { dir := shortTempDir(t) // Write a definitely-dead PID into bus.lock. if err := os.WriteFile(LockPath(dir), []byte("2147483646\n"), 0o600); err != nil { @@ -46,6 +50,269 @@ func TestStop_NotRunningWhenPIDDead(t *testing.T) { } } +func TestCrossPlatformCoverageStopPrefersGracefulIPC(t *testing.T) { + const pid = 4242 + stopped := false + signals := 0 + testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid }) + testseam.Swap(t, &stopAlive, func(int) bool { return !stopped }) + proc, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil }) + testseam.Swap(t, &stopRequest, func(endpoint string) error { + if endpoint != "test-endpoint" { + t.Fatalf("stop endpoint = %q", endpoint) + } + stopped = true + return nil + }) + testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error { + signals++ + return nil + }) + + if err := Stop(StopConfig{ + WorkDir: "test-workdir", + IPCEndpoint: "test-endpoint", + Timeout: time.Second, + }); err != nil { + t.Fatalf("Stop() = %v", err) + } + if signals != 0 { + t.Fatalf("graceful IPC stop used %d process signals", signals) + } +} + +func TestCrossPlatformCoverageStopDoesNotSignalUnverifiedReusedPID(t *testing.T) { + const pid = 4242 + testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid }) + testseam.Swap(t, &stopAlive, func(int) bool { return true }) + proc, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil }) + testseam.Swap(t, &stopRequest, func(string) error { return errors.New("stale endpoint") }) + testseam.Swap(t, &stopValidateHolderOwner, func(path string, gotPID int) (bool, error) { + if path != LockPath("test-workdir") || gotPID != pid { + t.Fatalf("ownership check path=%q pid=%d", path, gotPID) + } + return false, nil + }) + signals := 0 + testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error { + signals++ + return nil + }) + + err = Stop(StopConfig{ + WorkDir: "test-workdir", + IPCEndpoint: "stale-endpoint", + Timeout: time.Millisecond, + }) + if !errors.Is(err, ErrOwnerUnverified) { + t.Fatalf("Stop() error = %v, want ErrOwnerUnverified", err) + } + if signals != 0 { + t.Fatalf("unverified reused PID received %d signals", signals) + } +} + +func TestCrossPlatformCoverageStopAcceptsExitAtGracefulTimeoutBoundary(t *testing.T) { + const pid = 4242 + + t.Run("before ownership validation", func(t *testing.T) { + exited := false + validated := false + testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid }) + testseam.Swap(t, &stopAlive, func(int) bool { return !exited }) + proc, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil }) + testseam.Swap(t, &stopRequest, func(string) error { return nil }) + testseam.Swap(t, &stopWaitForBusExit, func(int, time.Duration) bool { + exited = true + return false + }) + testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { + validated = true + return false, nil + }) + + if err := Stop(StopConfig{WorkDir: "test-workdir", IPCEndpoint: "test-endpoint"}); err != nil { + t.Fatalf("Stop() = %v, want success after bus exit", err) + } + if validated { + t.Fatal("ownership was validated after the bus had already exited") + } + }) + + t.Run("during ownership validation", func(t *testing.T) { + exited := false + signals := 0 + testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid }) + testseam.Swap(t, &stopAlive, func(int) bool { return !exited }) + proc, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil }) + testseam.Swap(t, &stopRequest, func(string) error { return nil }) + testseam.Swap(t, &stopWaitForBusExit, func(int, time.Duration) bool { return false }) + testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { + exited = true + return false, nil + }) + testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error { + signals++ + return nil + }) + + if err := Stop(StopConfig{WorkDir: "test-workdir", IPCEndpoint: "test-endpoint"}); err != nil { + t.Fatalf("Stop() = %v, want success after exit during ownership validation", err) + } + if signals != 0 { + t.Fatalf("exited bus received %d fallback signals", signals) + } + }) +} + +func TestCrossPlatformCoverageStopReportsOwnershipValidationError(t *testing.T) { + const pid = 4242 + errInjected := errors.New("ownership validation failed") + testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid }) + testseam.Swap(t, &stopAlive, func(int) bool { return true }) + proc, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil }) + testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { + return false, errInjected + }) + signals := 0 + testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error { + signals++ + return nil + }) + + err = Stop(StopConfig{WorkDir: "test-workdir"}) + if !errors.Is(err, errInjected) { + t.Fatalf("Stop() error = %v, want injected ownership error", err) + } + if signals != 0 { + t.Fatalf("ownership validation error sent %d signals", signals) + } +} + +func TestCrossPlatformCoverageRequestBusStopProtocol(t *testing.T) { + dir := shortTempDir(t) + endpoint := dwsevent.IPCEndpoint( + dir, + "open", + dwsevent.SourceKindAppStream, + dwsevent.IdentityHash(dir), + ) + listener, err := transport.Listen(endpoint) + if err != nil { + t.Fatalf("Listen() = %v", err) + } + defer listener.Close() + serverDone := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + serverDone <- err + return + } + defer conn.Close() + r := transport.NewReader(conn) + w := transport.NewWriter(conn) + var hello transport.Hello + if err := r.ReadJSON(&hello); err != nil { + serverDone <- err + return + } + if hello.Type != transport.FrameTypeHello || hello.Role != transport.HelloRoleStop { + serverDone <- errors.New("unexpected stop hello") + return + } + serverDone <- w.WriteJSON(transport.Bye{ + Type: transport.FrameTypeBye, + Reason: "stop_request", + }) + }() + + if err := requestBusStop(endpoint); err != nil { + t.Fatalf("requestBusStop() = %v", err) + } + if err := <-serverDone; err != nil { + t.Fatalf("stop protocol server = %v", err) + } +} + +func TestCrossPlatformCoverageRequestBusStopErrors(t *testing.T) { + t.Run("dial", func(t *testing.T) { + testseam.Swap(t, &stopDial, func(string) (net.Conn, error) { + return nil, errors.New("dial failed") + }) + if err := requestBusStop("test-endpoint"); err == nil { + t.Fatal("requestBusStop() unexpectedly succeeded") + } + }) + + t.Run("write", func(t *testing.T) { + testseam.Swap(t, &stopDial, func(string) (net.Conn, error) { + return &queryErrorConn{failAt: 1}, nil + }) + if err := requestBusStop("test-endpoint"); err == nil { + t.Fatal("requestBusStop() unexpectedly succeeded") + } + }) + + t.Run("read", func(t *testing.T) { + testseam.Swap(t, &stopDial, func(string) (net.Conn, error) { + return &queryErrorConn{}, nil + }) + if err := requestBusStop("test-endpoint"); err == nil { + t.Fatal("requestBusStop() unexpectedly succeeded") + } + }) + + t.Run("unexpected response", func(t *testing.T) { + client, server := net.Pipe() + t.Cleanup(func() { + _ = client.Close() + _ = server.Close() + }) + testseam.Swap(t, &stopDial, func(string) (net.Conn, error) { + return client, nil + }) + serverDone := make(chan error, 1) + go func() { + var hello transport.Hello + if err := transport.NewReader(server).ReadJSON(&hello); err != nil { + serverDone <- err + return + } + serverDone <- transport.NewWriter(server).WriteJSON(transport.Bye{ + Type: transport.FrameTypeBye, + Reason: "unexpected", + }) + }() + if err := requestBusStop("test-endpoint"); err == nil { + t.Fatal("requestBusStop() unexpectedly succeeded") + } + if err := <-serverDone; err != nil { + t.Fatalf("stop protocol server = %v", err) + } + }) +} + func TestStop_SignalsLiveProcess(t *testing.T) { skipOnWindows(t) dir := shortTempDir(t) @@ -63,6 +330,7 @@ func TestStop_SignalsLiveProcess(t *testing.T) { } }() pid := cmd.Process.Pid + testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { return true, nil }) // Reap the child in background so Wait doesn't leave a zombie. waited := make(chan error, 1) @@ -111,6 +379,7 @@ func TestStop_TimeoutWhenChildIgnoresSignal(t *testing.T) { _, _ = cmd.Process.Wait() }() pid := cmd.Process.Pid + testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { return true, nil }) if err := os.WriteFile(LockPath(dir), []byte(strconv.Itoa(pid)+"\n"), 0o600); err != nil { t.Fatal(err) diff --git a/internal/event/busctl/stop_windows.go b/internal/event/busctl/stop_windows.go index 2e7223f6..630ee330 100644 --- a/internal/event/busctl/stop_windows.go +++ b/internal/event/busctl/stop_windows.go @@ -17,8 +17,7 @@ package busctl import "os" -// stopSignal returns the graceful-shutdown signal for Windows. The Go -// runtime maps os.Interrupt to TerminateProcess for non-console-group -// processes — not truly graceful, but acceptable for v1 (Windows graceful -// shutdown via Ctrl+Break is in the v2 backlog, plan §16). -func stopSignal() os.Signal { return os.Interrupt } +// stopSignal is only the fallback after the graceful IPC stop path fails or +// times out. Go maps os.Kill to TerminateProcess on Windows; os.Interrupt is +// unsupported and returns syscall.EWINDOWS. +func stopSignal() os.Signal { return os.Kill } diff --git a/internal/event/busctl/stop_windows_test.go b/internal/event/busctl/stop_windows_test.go new file mode 100644 index 00000000..fcdc94aa --- /dev/null +++ b/internal/event/busctl/stop_windows_test.go @@ -0,0 +1,27 @@ +// 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. + +//go:build windows + +package busctl + +import ( + "os" + "testing" +) + +func TestCrossPlatformCoverageWindowsStopFallbackUsesKill(t *testing.T) { + if stopSignal() != os.Kill { + t.Fatalf("stopSignal() = %v, want os.Kill", stopSignal()) + } +} diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index 1897aa18..19c0241e 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "sort" "strconv" "strings" "time" @@ -492,10 +493,11 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri return nil } -// runMediaInsert implements the three-step flow for inserting an attachment into a document: +// runMediaInsert implements the four-step flow for inserting an attachment into a document: // 1. get_doc_attachment_upload_info → obtain uploadUrl + resourceId // 2. HTTP PUT file content to OSS // 3. insert_document_block with attachment element +// 4. list_document_blocks → prove the uploaded resource is visible in the document func runMediaInsert(cmd *cobra.Command, _ []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { @@ -551,7 +553,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() // Step 1: get upload credentials (uploadUrl + resourceId) - deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize)) + deps.Out.PrintInfo(fmt.Sprintf("[1/4] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize)) credText, err := callMCPToolReturnText(ctx, "get_doc_attachment_upload_info", map[string]any{ "nodeId": nodeID, @@ -569,7 +571,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { } // Step 2: HTTP PUT file to OSS - deps.Out.PrintInfo("[2/3] 上传文件到 OSS...") + deps.Out.PrintInfo("[2/4] 上传文件到 OSS...") ossHeaders := map[string]string{ "Content-Type": mimeType, @@ -592,7 +594,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { } // Step 3: insert block into document - deps.Out.PrintInfo("[3/3] 插入块到文档...") + deps.Out.PrintInfo("[3/4] 插入块到文档...") const maxInlineImageSize = 20 * 1024 * 1024 // 20MB @@ -644,7 +646,8 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { insertArgs["referenceBlockId"] = v } - if err := callMCPTool("insert_document_block", insertArgs); err != nil { + insertText, err := callMCPToolReturnText(ctx, "insert_document_block", insertArgs) + if err != nil { return apperrors.NewAPI( "附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入", apperrors.WithOperation("doc.media_insert"), @@ -665,6 +668,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { apperrors.WithCause(err), ) } + insertResult := map[string]any{} + if strings.TrimSpace(insertText) != "" { + if err := json.Unmarshal([]byte(insertText), &insertResult); err != nil { + return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName, + fmt.Errorf("解析 insert_document_block 响应失败: %w", err)) + } + } + insertedBlockID := insertedDocBlockID(insertResult) + + deps.Out.PrintInfo("[4/4] 回读验证媒体块...") + verifiedBlockID, verifyErr := verifyInsertedDocMedia(ctx, nodeID, insertedBlockID, resourceID, resourceURL) + if verifyErr != nil { + return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName, verifyErr) + } + if insertedBlockID == "" { + insertedBlockID = verifiedBlockID + } return deps.Out.PrintJSON(map[string]any{ "contractVersion": "doc.operation.v1", @@ -674,16 +694,271 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { "operation": "doc.media_insert", "data": map[string]any{ "nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL, - "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true, + "blockId": insertedBlockID, "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, + "inserted": true, "verified": true, }, "steps": []map[string]any{ {"name": "resolve_upload", "status": "success"}, {"name": "upload_oss", "status": "success"}, {"name": "insert_block", "status": "success"}, + {"name": "verify", "status": "success"}, }, }) } +func docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName string, cause error) error { + return apperrors.NewAPI( + "附件已上传且插块请求已执行,但回读未能证明媒体块落库;不要直接重试上传或插入", + apperrors.WithOperation("doc.media_insert"), + apperrors.WithReason("doc_media_insert_verification_failed"), + apperrors.WithFailureStage("verify"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithActions("运行 dws doc +media-list 检查 resourceId", "确认媒体不存在后再决定是否重新执行"), + apperrors.WithDetails(map[string]any{ + "contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID, + "resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName, "verified": false, + "steps": []map[string]any{ + {"name": "resolve_upload", "status": "success"}, + {"name": "upload_oss", "status": "success"}, + {"name": "insert_block", "status": "success"}, + {"name": "verify", "status": "failed"}, + }, + }), + apperrors.WithCause(cause), + ) +} + +var docMediaVerifyWait = waitForDocVerification + +func verifyInsertedDocMedia(ctx context.Context, nodeID, blockID, resourceID, resourceURL string) (string, error) { + delays := []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second} + var lastErr error + for attempt := 0; attempt <= len(delays); attempt++ { + blocks, err := readAllDocBlocksForVerification(ctx, nodeID) + if err != nil { + lastErr = err + } else { + lastErr = nil + if found := findVerifiedMediaBlock(blocks, blockID, resourceID, resourceURL); found != "" { + return found, nil + } + } + if attempt < len(delays) { + if err := docMediaVerifyWait(ctx, delays[attempt]); err != nil { + return "", err + } + } + } + if lastErr != nil { + return "", fmt.Errorf("媒体资源在有界回读窗口内仍无法读取: %w", lastErr) + } + return "", fmt.Errorf("媒体资源在有界回读窗口内仍不可见") +} + +func waitForDocVerification(ctx context.Context, delay time.Duration) error { + if ctx == nil { + ctx = context.Background() + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func readAllDocBlocksForVerification(ctx context.Context, nodeID string) ([]any, error) { + const pageSize = 50 + const maxItems = 5000 + all := make([]any, 0, pageSize) + seenPageIdentities := map[string]bool{} + for start := 0; start < maxItems; start += pageSize { + text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{ + "nodeId": nodeID, "format": "jsonml", "startIndex": start, "endIndex": start + pageSize - 1, + }) + if err != nil { + return nil, err + } + var payload map[string]any + if err := json.Unmarshal([]byte(text), &payload); err != nil { + return nil, fmt.Errorf("解析 list_document_blocks 回读失败: %w", err) + } + payload = nestedDocMap(payload) + blocks, ok := payload["blocks"].([]any) + if !ok { + return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组") + } + pageIdentity := docBlockPageIdentity(blocks) + if pageIdentity != "" && seenPageIdentities[pageIdentity] { + return nil, fmt.Errorf("list_document_blocks 分页停滞") + } + if pageIdentity != "" { + seenPageIdentities[pageIdentity] = true + } + all = append(all, blocks...) + hasMore, hasMoreKnown := payload["hasMore"].(bool) + if hasMoreKnown && !hasMore { + return all, nil + } + if !hasMoreKnown { + if total, ok := docNumberAsInt(payload["totalCount"]); ok && len(all) >= total { + return all, nil + } + } + if !hasMoreKnown && len(blocks) < pageSize { + return all, nil + } + if len(blocks) == 0 { + return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空") + } + } + return nil, fmt.Errorf("文档块超过安全回读上限") +} + +func docBlockPageIdentity(blocks []any) string { + if len(blocks) == 0 { + return "" + } + ids := make([]string, 0, len(blocks)) + for _, value := range blocks { + id := "" + switch block := value.(type) { + case map[string]any: + id = directDocBlockIdentity(block) + if id == "" { + if element, ok := block["element"].(map[string]any); ok { + id = directDocBlockIdentity(element) + } + } + case []any: + if len(block) > 1 { + if attributes, ok := block[1].(map[string]any); ok { + id = directDocBlockIdentity(attributes) + } + } + } + if id == "" { + return "" + } + ids = append(ids, id) + } + encoded, _ := json.Marshal(ids) + return string(encoded) +} + +func directDocBlockIdentity(block map[string]any) string { + for _, key := range []string{"blockId", "id", "uuid", "elementId"} { + if text, ok := block[key].(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + } + return "" +} + +func nestedDocMap(data map[string]any) map[string]any { + for _, key := range []string{"result", "data"} { + if nested, ok := data[key].(map[string]any); ok { + return nestedDocMap(nested) + } + } + return data +} + +func nestedDocString(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) + } + } + orderedKeys := make([]string, 0, len(typed)) + for key := range typed { + orderedKeys = append(orderedKeys, key) + } + sort.Strings(orderedKeys) + for _, key := range orderedKeys { + if text := nestedDocString(typed[key], keys...); text != "" { + return text + } + } + case []any: + for _, child := range typed { + if text := nestedDocString(child, keys...); text != "" { + return text + } + } + } + return "" +} + +// insertedDocBlockID only accepts explicit block IDs from the insert result or +// known response wrappers. Arbitrary IDs may belong to the document, operator, +// or request and must not become a hard constraint for the media readback. +func insertedDocBlockID(data map[string]any) string { + for _, key := range []string{"blockId", "elementId"} { + if text, ok := data[key].(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + } + for _, wrapper := range []string{"result", "data", "content"} { + if inner, ok := data[wrapper].(map[string]any); ok { + if text := insertedDocBlockID(inner); text != "" { + return text + } + } + } + return "" +} + +func findVerifiedMediaBlock(blocks []any, blockID, resourceID, resourceURL string) string { + for _, value := range blocks { + candidateID := nestedDocString(value, "blockId", "id", "uuid") + if candidateID == "" || (blockID != "" && candidateID != blockID) { + continue + } + mediaValue := docMediaReadbackValue(value) + if resourceID != "" && nestedDocString(mediaValue, "resourceId") == resourceID { + return candidateID + } + if resourceURL != "" && nestedDocString(mediaValue, "resourceUrl", "src") == resourceURL { + return candidateID + } + } + return "" +} + +func docMediaReadbackValue(value any) any { + block, ok := value.(map[string]any) + if !ok { + return value + } + encoded, ok := block["jsonml"].(string) + if !ok || strings.TrimSpace(encoded) == "" { + return value + } + var decoded any + if json.Unmarshal([]byte(encoded), &decoded) != nil { + return value + } + return decoded +} + +func docNumberAsInt(value any) (int, bool) { + switch typed := value.(type) { + case float64: + if typed >= 0 && typed == float64(int(typed)) { + return int(typed), true + } + case int: + return typed, typed >= 0 + } + return 0, false +} + // parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response. func parseAttachmentUploadInfo(text string) (uploadURL, resourceID, resourceURL string, err error) { var data map[string]any diff --git a/internal/helpers/doc_command_edges_test.go b/internal/helpers/doc_command_edges_test.go index d781efed..1d2918c2 100644 --- a/internal/helpers/doc_command_edges_test.go +++ b/internal/helpers/doc_command_edges_test.go @@ -3,7 +3,9 @@ package helpers import ( "bytes" "context" + "encoding/json" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -13,6 +15,7 @@ import ( "testing" "time" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -74,6 +77,8 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) { t.Cleanup(func() { os.Args = oldArgs }) oldPut, oldGet := httpPutFile, httpGetFile t.Cleanup(func() { httpPutFile, httpGetFile = oldPut, oldGet }) + testseam.Swap(t, &helperSleep, func(time.Duration) {}) + testseam.Swap(t, &docMediaVerifyWait, func(context.Context, time.Duration) error { return nil }) file := filepath.Join(t.TempDir(), "file.txt") if err := os.WriteFile(file, []byte("content"), 0o600); err != nil { t.Fatal(err) @@ -193,6 +198,47 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) { t.Fatal("media insert failure returned nil") } }) + t.Run("media insert response parse failure", func(t *testing.T) { + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"uploadUrl":"https://upload","resourceId":"resource"}`}, + {text: `{`}, + }} + if err := mediaCommand(t, caller, file, "text/plain"); err == nil { + t.Fatal("invalid media insert response returned nil") + } + }) + t.Run("small image verifies src readback when upload also returns resource ID", func(t *testing.T) { + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, + {text: `{"blockId":"media-block"}`}, + {text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`}, + }} + if err := mediaCommand(t, caller, file, "image/png"); err != nil { + t.Fatal(err) + } + if caller.calls != 3 { + t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls) + } + if caller.args["format"] != "jsonml" { + t.Fatalf("media readback format = %#v, want jsonml", caller.args["format"]) + } + }) + t.Run("unrelated insert response IDs do not constrain media readback", func(t *testing.T) { + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, + {text: `{"id":"document-id","operator":{"id":"operator-id"},"data":{"request":{"id":"request-id"}}}`}, + {text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`}, + }} + if err := mediaCommand(t, caller, file, "image/png"); err != nil { + t.Fatal(err) + } + if caller.calls != 3 { + t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls) + } + }) t.Run("large image becomes attachment", func(t *testing.T) { httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } large := filepath.Join(t.TempDir(), "large.png") @@ -202,7 +248,11 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) { if err := os.Truncate(large, 21*1024*1024); err != nil { t.Fatal(err) } - caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, {text: `{}`}}} + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, + {text: `{}`}, + {text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`}, + }} if err := mediaCommand(t, caller, large, "image/png"); err != nil { t.Fatal(err) } @@ -240,6 +290,181 @@ func TestCrossPlatformCoverageDefaultDocHTTPTransportEdges(t *testing.T) { } } +func TestCrossPlatformCoverageDocMediaReadbackDefensiveEdges(t *testing.T) { + testseam.Swap(t, &helperSleep, func(time.Duration) {}) + testseam.Swap(t, &docMediaVerifyWait, func(context.Context, time.Duration) error { return nil }) + ctx := context.Background() + + for _, tc := range []struct { + name string + steps []scriptedToolStep + }{ + {"call failure", []scriptedToolStep{{err: errors.New("read")}}}, + {"invalid json", []scriptedToolStep{{text: `{`}}}, + {"missing blocks", []scriptedToolStep{{text: `{}`}}}, + {"stalled page", []scriptedToolStep{{text: `{"blocks":[{"id":"a"}],"hasMore":true}`}, {text: `{"blocks":[{"id":"a"}],"hasMore":true}`}}}, + {"empty continued page", []scriptedToolStep{{text: `{"blocks":[],"hasMore":true}`}}}, + } { + t.Run(tc.name, func(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps}) + if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil { + t.Fatal("defensive readback returned nil") + } + }) + } + + t.Run("total count and nested payload", func(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"data":{"blocks":[{"id":"a"}],"totalCount":1}}`}}}) + blocks, err := readAllDocBlocksForVerification(ctx, "node") + if err != nil || len(blocks) != 1 { + t.Fatalf("blocks=%#v err=%v", blocks, err) + } + }) + + t.Run("identical adjacent pages advance by requested indexes", func(t *testing.T) { + blocks := make([]any, 50) + for index := range blocks { + blocks[index] = map[string]any{"blockType": "paragraph"} + } + first, err := json.Marshal(map[string]any{"blocks": blocks, "hasMore": true, "totalCount": 100}) + if err != nil { + t.Fatal(err) + } + second, err := json.Marshal(map[string]any{"blocks": blocks, "hasMore": false, "totalCount": 100}) + if err != nil { + t.Fatal(err) + } + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: string(first)}, {text: string(second)}}} + installScriptedCaller(t, caller) + got, err := readAllDocBlocksForVerification(ctx, "node") + if err != nil || len(got) != 100 || caller.calls != 2 { + t.Fatalf("blocks=%d calls=%d err=%v, want 100 blocks from two pages", len(got), caller.calls, err) + } + }) + + t.Run("explicit has more overrides inconsistent total count", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"blocks":[{"id":"first"}],"hasMore":true,"totalCount":1}`}, + {text: `{"blocks":[{"id":"second"}],"hasMore":false,"totalCount":2}`}, + }} + installScriptedCaller(t, caller) + got, err := readAllDocBlocksForVerification(ctx, "node") + if err != nil || len(got) != 2 || caller.calls != 2 { + t.Fatalf("blocks=%d calls=%d err=%v, want both explicitly advertised pages", len(got), caller.calls, err) + } + }) + + t.Run("page identity accepts nested and JSONML block IDs", func(t *testing.T) { + if got := docBlockPageIdentity([]any{map[string]any{"element": map[string]any{"blockId": "nested-block"}}}); got != `["nested-block"]` { + t.Fatalf("nested block page identity = %q", got) + } + if got := docBlockPageIdentity([]any{[]any{"p", map[string]any{"uuid": "jsonml-block"}}}); got != `["jsonml-block"]` { + t.Fatalf("JSONML block page identity = %q", got) + } + }) + + t.Run("unknown pagination short page", func(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"id":"a"}]}`}}}) + if blocks, err := readAllDocBlocksForVerification(ctx, "node"); err != nil || len(blocks) != 1 { + t.Fatalf("blocks=%#v err=%v", blocks, err) + } + }) + + t.Run("bounded retry failure", func(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[],"hasMore":false}`}}}) + if _, err := verifyInsertedDocMedia(ctx, "node", "", "missing", ""); err == nil { + t.Fatal("missing media unexpectedly verified") + } + }) + + t.Run("transient read failure retries", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {err: errors.New("temporary read failure")}, + {text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`}, + }} + installScriptedCaller(t, caller) + blockID, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", "") + if err != nil || blockID != "media-block" || caller.calls != 2 { + t.Fatalf("blockID=%q calls=%d err=%v", blockID, caller.calls, err) + } + }) + + t.Run("block identity alone does not verify media", func(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"other-resource"}}}],"hasMore":false}`}}}) + if _, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", ""); err == nil { + t.Fatal("matching block ID with a different resource unexpectedly verified") + } + }) + + t.Run("block read safety limit", func(t *testing.T) { + steps := make([]scriptedToolStep, 100) + for index := range steps { + steps[index] = scriptedToolStep{text: fmt.Sprintf(`{"blocks":[{"id":"block-%d"}],"hasMore":true}`, index)} + } + installScriptedCaller(t, &scriptedToolCaller{steps: steps}) + if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil { + t.Fatal("oversized block read returned nil") + } + }) + + if got := nestedDocMap(map[string]any{"result": map[string]any{"data": map[string]any{"ok": true}}}); got["ok"] != true { + t.Fatalf("nested map=%#v", got) + } + if nestedDocString(map[string]any{"x": []any{map[string]any{"id": " nested "}}}, "id") != "nested" || nestedDocString(3, "id") != "" { + t.Fatal("nested string traversal failed") + } + if got := nestedDocString(map[string]any{"z": map[string]any{"id": "last"}, "a": map[string]any{"id": "first"}}, "id"); got != "first" { + t.Fatalf("nested string traversal = %q, want deterministic key order", got) + } + if got := insertedDocBlockID(map[string]any{"id": "document", "result": map[string]any{"data": map[string]any{"blockId": " block "}}}); got != "block" { + t.Fatalf("trusted inserted block ID = %q, want block", got) + } + if got := insertedDocBlockID(map[string]any{"id": "document", "operator": map[string]any{"id": "operator"}, "data": map[string]any{"request": map[string]any{"id": "request"}}}); got != "" { + t.Fatalf("untrusted inserted block ID = %q, want empty", got) + } + blocks := []any{map[string]any{"id": "block", "resourceId": "rid"}, map[string]any{"id": "url-block", "resourceUrl": "https://media"}, map[string]any{"id": "src-block", "jsonml": `["p",{},["img",{"src":"https://image"}]]`}} + if findVerifiedMediaBlock(blocks, "block", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "", "https://media") != "url-block" || findVerifiedMediaBlock(blocks, "src-block", "upload-resource", "https://image") != "src-block" || findVerifiedMediaBlock(blocks, "other-block", "upload-resource", "https://image") != "" || findVerifiedMediaBlock(blocks, "block", "wrong", "") != "" || findVerifiedMediaBlock(blocks, "", "missing", "") != "" { + t.Fatal("media block matching failed") + } + if findVerifiedMediaBlock([]any{map[string]any{"id": "bad-jsonml", "jsonml": `[`}}, "bad-jsonml", "", "https://image") != "" { + t.Fatal("invalid JSONML unexpectedly verified") + } + if got := docMediaReadbackValue("plain"); got != "plain" { + t.Fatalf("non-object media readback = %#v, want unchanged value", got) + } + for _, tc := range []struct { + value any + want bool + }{ + {float64(3), true}, {float64(-1), false}, {1.5, false}, {3, true}, {-1, false}, {"3", false}, + } { + _, ok := docNumberAsInt(tc.value) + if ok != tc.want { + t.Fatalf("docNumberAsInt(%#v) ok=%v want=%v", tc.value, ok, tc.want) + } + } +} + +func TestCrossPlatformCoverageDocMediaReadbackStopsOnCancellation(t *testing.T) { + if err := waitForDocVerification(nil, time.Nanosecond); err != nil { + t.Fatalf("completed verification wait = %v", err) + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if err := waitForDocVerification(cancelled, time.Hour); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled verification wait = %v, want context.Canceled", err) + } + + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[],"hasMore":false}`}}} + installScriptedCaller(t, caller) + if _, err := verifyInsertedDocMedia(cancelled, "node", "", "missing", ""); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled media verification = %v, want context.Canceled", err) + } + if caller.calls != 1 { + t.Fatalf("cancelled media verification calls = %d, want 1", caller.calls) + } +} + func TestCrossPlatformCoverageDocCreateUpdateAndBlockCommandEdges(t *testing.T) { oldDeps, oldArgs, oldPut, oldGet := deps, os.Args, httpPutFile, httpGetFile t.Cleanup(func() { diff --git a/internal/helpers/doc_coverage_more_test.go b/internal/helpers/doc_coverage_more_test.go index c375ac40..674d74f1 100644 --- a/internal/helpers/doc_coverage_more_test.go +++ b/internal/helpers/doc_coverage_more_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/spf13/cobra" @@ -194,6 +195,7 @@ func TestCrossPlatformCoverageDocDeprecationWrappersCoverage(t *testing.T) { } func TestCrossPlatformCoverageRunDocUploadDownloadAndMediaCoverage(t *testing.T) { + testseam.Swap(t, &docMediaVerifyWait, func(context.Context, time.Duration) error { return nil }) oldArgs := os.Args os.Args = []string{"dws", "doc"} t.Cleanup(func() { os.Args = oldArgs }) diff --git a/internal/helpers/sheet_batch.go b/internal/helpers/sheet_batch.go index 218a88fd..7ce6e17d 100644 --- a/internal/helpers/sheet_batch.go +++ b/internal/helpers/sheet_batch.go @@ -60,6 +60,11 @@ func translateBatchOp(op map[string]any) (map[string]any, error) { if !ok { return nil, fmt.Errorf("unsupported toolName %q: must be a CLI command name (e.g. \"range clear\", \"range update\", \"merge-cells\"). Run 'dws sheet batch-update --help' for the full list", toolName) } + if toolName == "set-dropdown" { + if err := validateBatchSetDropdownInput(input); err != nil { + return nil, err + } + } return map[string]any{ "toolName": mapping.mcpTool, @@ -67,6 +72,33 @@ func translateBatchOp(op map[string]any) (map[string]any, error) { }, nil } +func validateBatchSetDropdownInput(input map[string]any) error { + if _, exists := input["source-colors"]; exists { + return fmt.Errorf("set-dropdown: 顶层 source-colors 不受支持;Inline 颜色请写入 options[].color,SourceRange 颜色写入暂不支持") + } + if _, exists := input["colors"]; exists { + return fmt.Errorf("set-dropdown: 顶层 colors 不受支持;Inline 颜色请写入 options[].color,SourceRange 颜色写入暂不支持") + } + + options, hasOptions := input["options"] + hasOptions = hasOptions && options != nil + sourceRange := batchStr(input, "source-range") + sourceSheetID := batchStr(input, "source-sheet-id") + hasSourceRange := sourceRange != "" + if hasOptions == hasSourceRange { + return fmt.Errorf("set-dropdown: options 与 source-range 必须且只能指定一个") + } + if hasSourceRange != (sourceSheetID != "") { + return fmt.Errorf("set-dropdown: source-range 与 source-sheet-id 必须同时指定") + } + if hasSourceRange { + if err := validateDropdownSourceRangeInput(sourceSheetID, sourceRange); err != nil { + return fmt.Errorf("set-dropdown: %w", err) + } + } + return nil +} + // ── BuildXxxArgs: CLI flag → MCP param 转换函数 ────────────────────────────────── // 每个函数接收 CLI flag 名(kebab-case)的 map,输出 MCP 参数名(camelCase)的 map。 // 目前集中放在此文件;后续拆分命令文件时可移到各命令所在文件。 @@ -215,7 +247,15 @@ func BuildSetDropdownArgs(input map[string]any) map[string]any { args := map[string]any{ "sheetId": batchStr(input, "sheet-id"), "range": batchStr(input, "range"), - "options": input["options"], + } + if options, ok := input["options"]; ok && options != nil { + args["options"] = options + } + if sourceRange := batchStr(input, "source-range"); sourceRange != "" { + args["sourceRange"] = map[string]any{ + "sheetId": batchStr(input, "source-sheet-id"), + "a1Notation": sourceRange, + } } if v, ok := input["multi-select"]; ok { args["enableMultiSelect"] = v @@ -417,6 +457,8 @@ func newBatchUpdateCmd() *cobra.Command { toolName 使用 CLI 命令名(与原子命令一致),input 的键用 CLI flag 名去掉 --。 CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。 +source-range 的语义按 toolName 隔离:set-dropdown 中表示下拉候选项来源, +range fill/copy-to/move-to 中表示待填充、复制或移动的数据源区域。 支持的 CLI 命令名: range clear / range update / merge-cells / unmerge-cells / update-dimension @@ -425,6 +467,8 @@ CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。 set-dropdown / delete-dropdown / csv-put / delete-float-image 其中 csv-put 与独立命令语义一致:CSV 字段值以 = 开头时按公式解析, 前加单引号(例如 "'=1+1")时写入以 = 开头的字面文本。 +set-dropdown 不接受顶层 colors/source-colors;Inline 颜色应写在 options[].color, +SourceRange 颜色写入暂不支持。 注意:batch-update 中 group-dimension 适合默认展开分组;需要 --group-state fold 时请使用独立 dws sheet group-dimension 命令。 diff --git a/internal/helpers/sheet_batch_test.go b/internal/helpers/sheet_batch_test.go index f7038d08..edb85887 100644 --- a/internal/helpers/sheet_batch_test.go +++ b/internal/helpers/sheet_batch_test.go @@ -23,7 +23,15 @@ func TestCrossPlatformCoverageSheetBatchOperationTranslationCoversEveryMapping(t "group-state": "fold", } for name, mapping := range batchOpDispatch { - got, err := translateBatchOp(map[string]any{"toolName": name, "input": input}) + opInput := input + if name == "set-dropdown" { + opInput = make(map[string]any, len(input)) + for key, value := range input { + opInput[key] = value + } + delete(opInput, "source-range") + } + got, err := translateBatchOp(map[string]any{"toolName": name, "input": opInput}) if err != nil { t.Errorf("translateBatchOp(%q): %v", name, err) continue diff --git a/internal/helpers/sheet_data.go b/internal/helpers/sheet_data.go index 3cdb77b0..be6e9d2d 100644 --- a/internal/helpers/sheet_data.go +++ b/internal/helpers/sheet_data.go @@ -388,7 +388,10 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。 csv 带 [row=N] 行号前缀的 CSV 文本 colIndices 列字母映射数组(定位列用 colIndices[j],禁止手数逗号) rowIndices 行号映射数组 - hasMore 是否因 maxChars 截断 + hasMore 目标范围是否还有未返回数据 + truncationReasons 部分返回原因:max_cells / max_chars + resolvedRange 未传 --range 时底层解析出的完整目标范围 + returnedRange 本次实际完整返回的范围 取值模式(--value-render-option): formatted_value 格式化后的展示值(默认),如 ¥1,000.00、2025-06-01 @@ -398,9 +401,14 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。 与 range read 的区别: - CSV 格式 token 消耗约为 JSON 的 1/3 - 支持选择取值模式 - - 自动防爆(max-chars 截断 + has_more 标志) + - 自动防爆(30,000 单元格 / max-chars 上限 + hasMore 标志) - [row=N] 前缀防止行号计算错误 +当 hasMore=true 时本命令不会自动续读;请结合目标范围和 +returnedRange 从下一行显式传 --range。max_cells 不能通过增大 +--max-chars 解决。收到 forbidden.document.sizeOverLimit 表示工作簿整体 +无法装载,应创建更小副本或拆分工作簿,缩小 --range 不能解决。 + 注意:csv-get 不返回合并单元格结构。查看合并范围请使用 dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 mergedRanges。`, Example: ` dws sheet csv-get --node NODE_ID diff --git a/internal/helpers/sheet_dimension.go b/internal/helpers/sheet_dimension.go index 4ce8d7ba..4afaf750 100644 --- a/internal/helpers/sheet_dimension.go +++ b/internal/helpers/sheet_dimension.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/spf13/cobra" ) @@ -18,6 +19,23 @@ func sizeTypeEnumHint(dimension string) string { return "pixel / standard" } +func validateDropdownSourceRangeInput(sourceSheetID, sourceRange string) error { + if strings.TrimSpace(sourceSheetID) == "" { + return fmt.Errorf("--source-sheet-id 不能为空") + } + sourceRange = strings.TrimSpace(sourceRange) + if sourceRange == "" { + return fmt.Errorf("--source-range 不能为空") + } + if strings.Contains(sourceRange, "!") { + return fmt.Errorf("--source-range 不能包含工作表前缀;请通过 --source-sheet-id 指定来源工作表") + } + if strings.HasPrefix(sourceRange, "=") || strings.Contains(sourceRange, ",") { + return fmt.Errorf("--source-range 必须是单一连续区域,不能是公式、表达式或多区域引用") + } + return nil +} + // newDimensionCmds creates dimension-related commands: insert/delete/update/move/add-dimension, // merge-cells, unmerge-cells, and dropdown commands (set/get/delete-dropdown). func newDimensionCmds() []*cobra.Command { @@ -775,15 +793,24 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 Short: "设置下拉列表", Long: `在钉钉表格的指定单元格范围内设置下拉列表。 -设置后,用户可以在这些单元格中从预定义的选项列表中选择值。 -支持自定义每个选项的颜色和是否允许多选。 +Inline 模式通过 --options 提供静态选项,支持选项颜色。 +SourceRange 模式通过 --source-sheet-id + --source-range 引用同一工作簿内的来源区域, +支持跨工作表、整行和整列引用;读取配置时不会展开来源区域的当前值或颜色。 +两种模式都支持 --multi-select,并且 --options 与 --source-range 必须且只能指定一个。 如果目标范围已存在下拉列表,会被新的配置覆盖。 nodeId 支持传入文档链接 URL 或文档 ID(dentryUuid),系统自动识别。 sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获取。 --options 为 JSON 数组,每个元素包含 value(必填)和 color(可选)。 -选项值不能包含英文逗号。`, +选项值不能包含英文逗号。 + +--source-range 只接受不含工作表前缀的 A1 区域,例如 T1:T3、T:T 或 1:3; +来源工作表由 --source-sheet-id 单独指定。SourceRange 颜色写入暂不支持。 + +已验证行为:工作表重命名、在引用前插入行/列、删除引用前的行会自动调整引用并保持 valid; +已验证的 move-dimension 场景会使引用变为 invalid。列删除、删除整个来源区域或来源工作表等未覆盖场景不能预设结果; +结构操作后应先回读 sourceRangeStatus,只有 invalid 时才重新选择来源并写入。`, Example: ` # 设置单选下拉列表 dws sheet set-dropdown --node NODE_ID --sheet-id SHEET_ID --range "A2:A100" \ --options '[{"value":"选项1"},{"value":"选项2"},{"value":"选项3"}]' @@ -793,32 +820,63 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 --options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \ --multi-select + # 引用同一工作簿内另一工作表的区域作为候选项来源 + dws sheet set-dropdown --node NODE_ID --sheet-id TARGET_SHEET_ID --range "C2:C100" \ + --source-sheet-id SOURCE_SHEET_ID --source-range "T1:T3" + # 使用文档链接 URL dws sheet set-dropdown --node "https://alidocs.dingtalk.com/i/nodes/" \ --sheet-id SHEET_ID --range "C1:C10" --options '[{"value":"是"},{"value":"否"}]'`, RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil { + return err + } optionsStr := mustGetFlag(cmd, "options") - var options []map[string]any - if err := json.Unmarshal([]byte(optionsStr), &options); err != nil { - return fmt.Errorf("--options JSON 解析失败: %w", err) + sourceSheetID := mustGetFlag(cmd, "source-sheet-id") + sourceRange := mustGetFlag(cmd, "source-range") + hasOptions := optionsStr != "" + hasSourceRange := sourceRange != "" + if hasOptions == hasSourceRange { + return fmt.Errorf("--options 与 --source-range 必须且只能指定一个") } - if len(options) == 0 { - return fmt.Errorf("--options 至少包含 1 个选项") + if hasSourceRange && sourceSheetID == "" { + return fmt.Errorf("使用 --source-range 时必须同时指定 --source-sheet-id") } - for i, opt := range options { - val, ok := opt["value"].(string) - if !ok || val == "" { - return fmt.Errorf("--options[%d] 缺少必填的 value 字段或 value 为空", i) - } - if strings.Contains(val, ",") { - return fmt.Errorf("--options[%d].value 不能包含英文逗号: %q", i, val) - } + if !hasSourceRange && sourceSheetID != "" { + return fmt.Errorf("使用 --source-sheet-id 时必须同时指定 --source-range") } + toolArgs := map[string]any{ "nodeId": mustGetFlag(cmd, "node"), "sheetId": mustGetFlag(cmd, "sheet-id"), "range": mustGetFlag(cmd, "range"), - "options": options, + } + if hasOptions { + var options []map[string]any + if err := json.Unmarshal([]byte(optionsStr), &options); err != nil { + return fmt.Errorf("--options JSON 解析失败: %w", err) + } + if len(options) == 0 { + return fmt.Errorf("--options 至少包含 1 个选项") + } + for i, opt := range options { + val, ok := opt["value"].(string) + if !ok || val == "" { + return fmt.Errorf("--options[%d] 缺少必填的 value 字段或 value 为空", i) + } + if strings.Contains(val, ",") { + return fmt.Errorf("--options[%d].value 不能包含英文逗号: %q", i, val) + } + } + toolArgs["options"] = options + } else { + if err := validateDropdownSourceRangeInput(sourceSheetID, sourceRange); err != nil { + return err + } + toolArgs["sourceRange"] = map[string]any{ + "sheetId": sourceSheetID, + "a1Notation": sourceRange, + } } if multiSelect, _ := cmd.Flags().GetBool("multi-select"); multiSelect { toolArgs["enableMultiSelect"] = true @@ -839,36 +897,49 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 CLIPath: "sheet set-dropdown", PrimaryCLIPath: "sheet set-dropdown", }, - Description: "为指定范围设置下拉列表(可多选、可带颜色)。", + Description: "为指定范围设置 Inline 或 SourceRange 下拉列表(可多选;颜色仅 Inline 支持)。", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "set_dropdown_lists"}, }, Selection: contract.SelectionSpec{ - AgentSummary: "为指定范围设置下拉列表(可多选、可带颜色)。", + AgentSummary: "为指定范围设置 Inline 或 SourceRange 下拉列表;SourceRange 可跨同一工作簿内的工作表。", UseWhen: []string{"需要给单元格配置可选值下拉约束时"}, AvoidWhen: []string{"查看已有下拉用 get-dropdown;移除下拉用 delete-dropdown"}, - Examples: []string{"dws sheet set-dropdown --node --sheet-id --range \"A2:A100\" --options '[{\"value\":\"选项1\"},{\"value\":\"选项2\"}]'"}, + Examples: []string{ + "dws sheet set-dropdown --node --sheet-id --range \"A2:A100\" --options '[{\"value\":\"选项1\"},{\"value\":\"选项2\"}]'", + "dws sheet set-dropdown --node --sheet-id --range \"B2:B100\" --source-sheet-id --source-range \"T1:T3\"", + }, }, Parameters: []contract.ParamDecl{ {Name: "multi-select", Property: "enableMultiSelect"}, {Name: "node", Property: "nodeId"}, + {Name: "source-sheet-id", Property: "sourceRange.sheetId", Required: boolPtr(false), RequiredWhen: "--source-range is provided"}, + {Name: "source-range", Property: "sourceRange.a1Notation", Required: boolPtr(false), RequiredWhen: "exactly one of --options or --source-range must be provided"}, }, }, }) setDropdownCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") setDropdownCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)") setDropdownCmd.Flags().String("range", "", "目标单元格范围,A1 表示法,如 A2:A100 (必填)") - setDropdownCmd.Flags().String("options", "", `下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]'`) + setDropdownCmd.Flags().String("options", "", `Inline 下拉选项 JSON 数组,与 --source-range 二选一,如 '[{"value":"选项1","color":"#ff0000"}]'`) + setDropdownCmd.Flags().String("source-sheet-id", "", "SourceRange 来源工作表 ID;使用 --source-range 时必填") + setDropdownCmd.Flags().String("source-range", "", "SourceRange 来源区域,与 --options 二选一;不含工作表前缀,如 T1:T3、T:T 或 1:3") setDropdownCmd.Flags().Bool("multi-select", false, "是否允许多选(默认单选)") + cli.AnnotateRuntimeFlagFormat(setDropdownCmd, "source-range", "a1-range") + setDropdownCmd.MarkFlagsOneRequired("options", "source-range") + setDropdownCmd.MarkFlagsMutuallyExclusive("options", "source-range") + setDropdownCmd.MarkFlagsRequiredTogether("source-sheet-id", "source-range") getDropdownCmd := &cobra.Command{ Use: "get-dropdown", Short: "获取下拉列表配置", Long: `查询钉钉表格指定范围内的下拉列表配置。 -返回范围内所有单元格的下拉列表配置信息,包括选项值和颜色。 +Inline 配置返回 conditionValues 和 options;SourceRange 配置返回 +sourceType/sourceRangeStatus/enableMultiSelect,且仅在 sourceRangeStatus="valid" 时返回 +sourceRange。invalid 结果仍保留配置组,但省略 sourceRange;两种状态都不展开来源区域的候选值或颜色。 如果范围内存在多个不同的下拉列表配置,会分别返回每组配置及其覆盖的单元格列表。 如果范围内没有设置下拉列表,返回空。 diff --git a/internal/helpers/sheet_dropdown_source_range_test.go b/internal/helpers/sheet_dropdown_source_range_test.go new file mode 100644 index 00000000..086b7da6 --- /dev/null +++ b/internal/helpers/sheet_dropdown_source_range_test.go @@ -0,0 +1,248 @@ +package helpers + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" +) + +func TestCrossPlatformCoverageSetDropdownSourceRangeCommand(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}} + installScriptedCaller(t, caller) + err := executeDimensionCoverage(t, "set-dropdown", + "--node", "node-1", + "--sheet-id", "target-sheet", + "--range", "B2:B100", + "--source-sheet-id", "source-sheet", + "--source-range", "$t$1:$t$3", + "--multi-select", + ) + if err != nil { + t.Fatalf("set SourceRange dropdown: %v", err) + } + if caller.tool != "set_dropdown_lists" { + t.Fatalf("tool = %q, want set_dropdown_lists", caller.tool) + } + if _, exists := caller.args["options"]; exists { + t.Fatalf("SourceRange request must omit options: %#v", caller.args) + } + source, ok := caller.args["sourceRange"].(map[string]any) + if !ok { + t.Fatalf("sourceRange = %#v", caller.args["sourceRange"]) + } + if source["sheetId"] != "source-sheet" || source["a1Notation"] != "$t$1:$t$3" { + t.Fatalf("sourceRange = %#v", source) + } + if caller.args["enableMultiSelect"] != true { + t.Fatalf("enableMultiSelect = %#v", caller.args["enableMultiSelect"]) + } +} + +func TestCrossPlatformCoverageSetDropdownInlineCommandStillOmitsSourceRange(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}} + installScriptedCaller(t, caller) + err := executeDimensionCoverage(t, "set-dropdown", + "--node", "node-1", + "--sheet-id", "sheet-1", + "--range", "A1:A3", + "--options", `[{"value":"one","color":"#ff0000"}]`, + ) + if err != nil { + t.Fatalf("set inline dropdown: %v", err) + } + if _, exists := caller.args["sourceRange"]; exists { + t.Fatalf("inline request must omit sourceRange: %#v", caller.args) + } + options, ok := caller.args["options"].([]map[string]any) + if !ok || len(options) != 1 || options[0]["value"] != "one" { + t.Fatalf("options = %#v", caller.args["options"]) + } +} + +func TestCrossPlatformCoverageSetDropdownModeConstraints(t *testing.T) { + base := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1"} + tests := []struct { + name string + args []string + want string + }{ + {name: "neither", want: "one of the flags"}, + {name: "both", args: []string{"--options", `[{"value":"one"}]`, "--source-sheet-id", "source", "--source-range", "A1:A3"}, want: "none of the others"}, + {name: "source sheet without range", args: []string{"--options", `[{"value":"one"}]`, "--source-sheet-id", "source"}, want: "must all be set"}, + {name: "range without source sheet", args: []string{"--source-range", "A1:A3"}, want: "must all be set"}, + {name: "sheet prefix", args: []string{"--source-sheet-id", "source", "--source-range", "Sheet2!A1:A3"}, want: "不能包含工作表前缀"}, + {name: "formula", args: []string{"--source-sheet-id", "source", "--source-range", "=A1:A3"}, want: "必须是单一连续区域"}, + {name: "multi region", args: []string{"--source-sheet-id", "source", "--source-range", "A1:A3,C1:C3"}, want: "必须是单一连续区域"}, + {name: "blank source range", args: []string{"--source-sheet-id", "source", "--source-range", " \t "}, want: "--source-range 不能为空"}, + {name: "blank source sheet", args: []string{"--source-sheet-id", " \t ", "--source-range", "A1:A3"}, want: "--source-sheet-id 不能为空"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + installScriptedCaller(t, caller) + args := append(append([]string{}, base...), tc.args...) + err := executeDimensionCoverage(t, "set-dropdown", args...) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want contains %q", err, tc.want) + } + if caller.calls != 0 { + t.Fatalf("calls = %d, validation must fail before request", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageSetDropdownRunEValidation(t *testing.T) { + tests := []struct { + name string + options string + source string + rangeID string + want string + }{ + {name: "neither mode", want: "必须且只能指定一个"}, + {name: "source range without sheet", rangeID: "A1:A3", want: "必须同时指定 --source-sheet-id"}, + {name: "source sheet without range", options: `[{"value":"one"}]`, source: "source", want: "必须同时指定 --source-range"}, + {name: "invalid options JSON", options: "{", want: "JSON 解析失败"}, + {name: "missing option value", options: `[{}]`, want: "value 为空"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cmd := dimensionCoverageCommand(t, "set-dropdown") + for name, value := range map[string]string{ + "node": "node", "sheet-id": "sheet", "range": "A1", + "options": tc.options, "source-sheet-id": tc.source, "source-range": tc.rangeID, + } { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + err := cmd.RunE(cmd, nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want contains %q", err, tc.want) + } + }) + } +} + +func TestCrossPlatformCoverageSetDropdownPreservesSchemaConstraints(t *testing.T) { + cmd := dimensionCoverageCommand(t, "set-dropdown") + if constraints := runtimeannotate.CommandConstraints(cmd); !runtimeannotate.ConstraintsEmpty(constraints) { + t.Fatalf("constraints = %#v, want no new schema-level constraints", constraints) + } +} + +func TestCrossPlatformCoverageBatchSetDropdownSourceRange(t *testing.T) { + got, err := translateBatchOp(map[string]any{ + "toolName": "set-dropdown", + "input": map[string]any{ + "sheet-id": "target-sheet", + "range": "B2:B100", + "source-sheet-id": "source-sheet", + "source-range": "T:T", + "multi-select": true, + }, + }) + if err != nil { + t.Fatalf("translate SourceRange batch op: %v", err) + } + input := got["input"].(map[string]any) + if _, exists := input["options"]; exists { + t.Fatalf("SourceRange batch input must omit options: %#v", input) + } + source := input["sourceRange"].(map[string]any) + if source["sheetId"] != "source-sheet" || source["a1Notation"] != "T:T" { + t.Fatalf("sourceRange = %#v", source) + } + + invalid := []map[string]any{ + {}, + {"options": []any{map[string]any{"value": "one"}}, "source-sheet-id": "source", "source-range": "A1:A3"}, + {"source-range": "A1:A3"}, + {"source-sheet-id": "source", "source-range": "A1:A3", "colors": []any{"#fff"}}, + {"source-sheet-id": "source", "source-range": "A1:A3", "source-colors": []any{"#fff"}}, + {"source-sheet-id": "source", "source-range": "Sheet2!A1:A3"}, + } + for _, value := range invalid { + if _, err := translateBatchOp(map[string]any{"toolName": "set-dropdown", "input": value}); err == nil { + t.Errorf("invalid batch SourceRange input %#v returned nil", value) + } + } +} + +func TestCrossPlatformCoverageBatchSetDropdownValidationGuidance(t *testing.T) { + tests := []struct { + name string + input map[string]any + want string + }{ + { + name: "inline top-level colors", + input: map[string]any{"options": []any{map[string]any{"value": "one"}}, "colors": []any{"#fff"}}, + want: "Inline 颜色请写入 options[].color", + }, + { + name: "source top-level colors", + input: map[string]any{"source-sheet-id": "source", "source-range": "A1:A3", "source-colors": []any{"#fff"}}, + want: "SourceRange 颜色写入暂不支持", + }, + { + name: "blank source range", + input: map[string]any{"source-sheet-id": "source", "source-range": " \t "}, + want: "--source-range 不能为空", + }, + { + name: "blank source sheet", + input: map[string]any{"source-sheet-id": " \t ", "source-range": "A1:A3"}, + want: "--source-sheet-id 不能为空", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := translateBatchOp(map[string]any{"toolName": "set-dropdown", "input": tc.input}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want contains %q", err, tc.want) + } + }) + } +} + +func TestCrossPlatformCoverageDropdownHelpDescribesDynamicAndInvalidContracts(t *testing.T) { + setDropdown := dimensionCoverageCommand(t, "set-dropdown") + if !strings.Contains(setDropdown.Long, "会自动调整引用并保持 valid") || + !strings.Contains(setDropdown.Long, "只有 invalid 时才重新选择来源并写入") { + t.Fatalf("set-dropdown help does not describe verified structural behavior:\n%s", setDropdown.Long) + } + getDropdown := dimensionCoverageCommand(t, "get-dropdown") + if !strings.Contains(getDropdown.Long, `仅在 sourceRangeStatus="valid" 时返回`) || + !strings.Contains(getDropdown.Long, "invalid 结果仍保留配置组,但省略 sourceRange") { + t.Fatalf("get-dropdown help does not describe conditional sourceRange readback:\n%s", getDropdown.Long) + } +} + +func TestCrossPlatformCoverageValidateSourceRangeDataValidation(t *testing.T) { + valid := []any{ + map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}}, + map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "missing-sheet", "a1Notation": "not-validated-locally"}, "enableMultiSelect": true}, + } + for _, value := range valid { + if err := validateDataValidation(value, "dv"); err != nil { + t.Errorf("valid SourceRange data validation %#v: %v", value, err) + } + } + + invalid := []any{ + map[string]any{"type": "dropdown", "options": []any{map[string]any{"value": "one"}}, "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}}, + map[string]any{"type": "dropdown", "sourceRange": "T1:T3"}, + map[string]any{"type": "dropdown", "sourceRange": map[string]any{"a1Notation": "T1:T3"}}, + map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source"}}, + map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3", "colors": []any{"#fff"}}}, + map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}, "enableMultiSelect": "yes"}, + } + for _, value := range invalid { + if err := validateDataValidation(value, "dv"); err == nil { + t.Errorf("invalid SourceRange data validation %#v returned nil", value) + } + } +} diff --git a/internal/helpers/sheet_range_ops.go b/internal/helpers/sheet_range_ops.go index 5d6c8217..1c3a8fb5 100644 --- a/internal/helpers/sheet_range_ops.go +++ b/internal/helpers/sheet_range_ops.go @@ -26,6 +26,16 @@ func newRangeCmd() *cobra.Command { value 单元格值(内容由 --value-render-option 决定) dataValidation 数据验证配置(下拉列表/复选框),无则为 null hyperlink 单元格级超链接(path/sheet/range),无则省略 +顶层还会返回 rowIndices / colIndices 与完成度字段: + hasMore 目标范围是否还有未返回数据 + truncationReasons 部分返回原因(max_cells) + resolvedRange 未传 --range 时底层解析出的完整目标范围 + returnedRange 本次实际完整返回的范围 + +单次最多返回 30,000 个单元格。hasMore=true 是部分成功,本命令 +不会自动续读;请结合目标范围和 returnedRange 从下一行显式传 +--range。收到 forbidden.document.sizeOverLimit 则表示工作簿整体无法装载, +应创建更小副本或拆分工作簿,缩小 --range 不能解决。 注意:range read/get 不返回合并单元格结构。查看合并范围请使用 dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 mergedRanges。 @@ -157,7 +167,12 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg 2) {"dataValidation":{"type":"none"}} → 显式清除该单元格 DV 3) {"dataValidation":{"type":"dropdown",...}} → 写新 dropdown(覆盖) {"dataValidation":{"type":"checkbox",...}} → 写新 checkbox(覆盖) - dropdown: {"dataValidation":{"type":"dropdown","options":[{"value":"选项1"}],"enableMultiSelect":false}} + Inline dropdown: + {"dataValidation":{"type":"dropdown","options":[{"value":"选项1"}],"enableMultiSelect":false}} + SourceRange dropdown(同一工作簿内可跨工作表;不展开来源 values,不支持 colors): + {"dataValidation":{"type":"dropdown","sourceRange":{"sheetId":"SOURCE_SHEET_ID","a1Notation":"T1:T3"},"enableMultiSelect":false}} + dropdown 的 options 与 sourceRange 必须且只能传一个。SourceRange 支持普通区域、整行和整列; + a1Notation 不带工作表前缀,来源工作表单独写在 sheetId。 checkbox: {"dataValidation":{"type":"checkbox","checked":true}} 可与 text/richText 共存,也可单独使用(如 {dataValidation:{type:"none"}} 仅清除 DV 不写值) @@ -169,6 +184,8 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg - 只设样式或批量刷整片区域样式请用 dws sheet range set-style;写值同时设置少量 cell 样式可用 cellStyles - 目标范围与已有合并区域冲突时,range update 会返回 MERGED_CELLS_CONFLICT;先用 sheet info 查看 mergedRanges,取消合并后写入,必要时再重新合并 - csv-put 的合并处理不同:目标区域含合并单元格时会打散合并并写入 CSV 值或公式 + - SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;move-dimension 及其他未覆盖的删除/移动场景后先回读 sourceRangeStatus,仅 invalid 时重新选源写入 + - 同一 cell 的 value/style 已写入但 SourceRange 校验失败时,服务端可返回 success=true,并通过 message 明确下拉未创建;必须检查 message,必要时重新读取确认 - 清空整片区域请用 dws sheet range clear`, Example: ` # 写入文本 dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "A1:B2" \ diff --git a/internal/helpers/sheet_read_completion_test.go b/internal/helpers/sheet_read_completion_test.go new file mode 100644 index 00000000..dca4bfdc --- /dev/null +++ b/internal/helpers/sheet_read_completion_test.go @@ -0,0 +1,202 @@ +package helpers + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" +) + +func executeSheetReadCompletionCommand(t *testing.T, caller *scriptedToolCaller, args ...string) (*bytes.Buffer, error) { + t.Helper() + installScriptedCaller(t, caller) + installSheetProductArgs(t) + + stdout := &bytes.Buffer{} + deps.Out.w = stdout + + cmd := newSheetCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs(args) + return stdout, cmd.Execute() +} + +func decodeSheetReadOutput(t *testing.T, stdout *bytes.Buffer) map[string]any { + t.Helper() + var got map[string]any + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode sheet read output %q: %v", stdout.String(), err) + } + return got +} + +func assertSheetReadCompletionMetadata(t *testing.T, got map[string]any) { + t.Helper() + if hasMore, ok := got["hasMore"].(bool); !ok || !hasMore { + t.Fatalf("hasMore = %#v, want true", got["hasMore"]) + } + if got["resolvedRange"] != "A1:A30001" { + t.Fatalf("resolvedRange = %#v", got["resolvedRange"]) + } + if got["returnedRange"] != "A1:A30000" { + t.Fatalf("returnedRange = %#v", got["returnedRange"]) + } + reasons, ok := got["truncationReasons"].([]any) + if !ok || len(reasons) == 0 || reasons[0] != "max_cells" { + t.Fatalf("truncationReasons = %#v", got["truncationReasons"]) + } +} + +// csv-get intentionally projects the MCP response rather than inventing a +// client-side page model. Keep all completion metadata visible and make one +// request only, even when the server reports a partial success. +func TestSheetCSVGetPreservesCompletionMetadataWithoutAutoPagination(t *testing.T) { + const response = `{"csv":"[row=1]1\n","colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells","max_chars"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}` + caller := &scriptedToolCaller{ + format: "raw", + steps: []scriptedToolStep{{text: response}}, + } + + stdout, err := executeSheetReadCompletionCommand(t, caller, + "csv-get", "--node", "NODE") + if err != nil { + t.Fatalf("csv-get: %v", err) + } + if caller.calls != 1 || caller.tool != "get_range_as_csv" { + t.Fatalf("calls/tool = %d/%q, want one get_range_as_csv call", caller.calls, caller.tool) + } + if _, exists := caller.args["range"]; exists { + t.Fatalf("omitted range was unexpectedly synthesized by the CLI: %#v", caller.args["range"]) + } + if got := strings.TrimSpace(stdout.String()); got != response { + t.Fatalf("raw csv-get response changed:\n got: %s\nwant: %s", got, response) + } + + got := decodeSheetReadOutput(t, stdout) + assertSheetReadCompletionMetadata(t, got) + reasons := got["truncationReasons"].([]any) + if len(reasons) != 2 || reasons[1] != "max_chars" { + t.Fatalf("truncationReasons = %#v, want max_cells and max_chars", reasons) + } +} + +// range read parses the get_cell_infos envelope to remove empty per-cell +// schema shells. That cleanup must not discard new top-level completion fields. +func TestSheetRangeReadPreservesCompletionMetadataWithoutAutoPagination(t *testing.T) { + const response = `{"cells":[[{"value":"1","dataValidation":{"type":null},"hyperlink":{"url":null}}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}` + caller := &scriptedToolCaller{ + format: "json", + steps: []scriptedToolStep{{text: response}}, + } + + stdout, err := executeSheetReadCompletionCommand(t, caller, + "range", "read", "--node", "NODE") + if err != nil { + t.Fatalf("range read: %v", err) + } + if caller.calls != 1 || caller.tool != "get_cell_infos" { + t.Fatalf("calls/tool = %d/%q, want one get_cell_infos call", caller.calls, caller.tool) + } + if _, exists := caller.args["range"]; exists { + t.Fatalf("omitted range was unexpectedly synthesized by the CLI: %#v", caller.args["range"]) + } + + got := decodeSheetReadOutput(t, stdout) + assertSheetReadCompletionMetadata(t, got) + cells := got["cells"].([]any) + row := cells[0].([]any) + cell := row[0].(map[string]any) + if _, exists := cell["dataValidation"]; exists { + t.Fatalf("empty dataValidation shell was not removed: %#v", cell) + } + if _, exists := cell["hyperlink"]; exists { + t.Fatalf("empty hyperlink shell was not removed: %#v", cell) + } +} + +// sheet +read calls get_cell_infos through this direct passthrough instead of +// callMCPToolCellInfos. Lock that third read surface to the same raw envelope +// contract and to one request only. +func TestSheetCellInfosDirectPassthroughPreservesCompletionMetadata(t *testing.T) { + const response = `{"cells":[[{"value":"1"}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}` + caller := &scriptedToolCaller{ + format: "raw", + steps: []scriptedToolStep{{text: response}}, + } + installScriptedCaller(t, caller) + stdout := &bytes.Buffer{} + deps.Out.w = stdout + + if err := CallMCPToolOnServer("sheet", "get_cell_infos", map[string]any{"nodeId": "NODE"}); err != nil { + t.Fatalf("direct get_cell_infos: %v", err) + } + if caller.calls != 1 || caller.server != "sheet" || caller.tool != "get_cell_infos" { + t.Fatalf("calls/server/tool = %d/%q/%q", caller.calls, caller.server, caller.tool) + } + if got := strings.TrimSpace(stdout.String()); got != response { + t.Fatalf("raw get_cell_infos response changed:\n got: %s\nwant: %s", got, response) + } + assertSheetReadCompletionMetadata(t, decodeSheetReadOutput(t, stdout)) +} + +// Large-CP failure is not a pageable success. Both the raw csv-get path and +// both get_cell_infos paths must retain the backend code and safe user message +// exactly, matching the generic get_all_sheets error-classification path. +func TestSheetReadPreservesWorkbookSizeOverLimitError(t *testing.T) { + const response = `{"success":false,"errorCode":"forbidden.document.sizeOverLimit","errorMessage":"The workbook data is too large to process. Use a smaller copy or split the workbook, then try again."}` + tests := []struct { + name string + args []string + tool string + direct bool + }{ + {name: "csv-get", args: []string{"csv-get", "--node", "NODE"}, tool: "get_range_as_csv"}, + {name: "range-read", args: []string{"range", "read", "--node", "NODE"}, tool: "get_cell_infos"}, + {name: "cell-infos-direct", tool: "get_cell_infos", direct: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{ + format: "json", + steps: []scriptedToolStep{{text: response}}, + } + var stdout *bytes.Buffer + var err error + if test.direct { + installScriptedCaller(t, caller) + stdout = &bytes.Buffer{} + deps.Out.w = stdout + err = CallMCPToolOnServer("sheet", test.tool, map[string]any{"nodeId": "NODE"}) + } else { + stdout, err = executeSheetReadCompletionCommand(t, caller, test.args...) + } + if err == nil { + t.Fatal("large-CP response was accepted as success") + } + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError { + t.Fatalf("error = %T %v, want MCP tool error", err, err) + } + if cliErr.Message != response { + t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, response) + } + for _, want := range []string{ + "forbidden.document.sizeOverLimit", + "The workbook data is too large to process.", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not preserve %q", err, want) + } + } + if stdout.Len() != 0 { + t.Fatalf("failure wrote a success body: %q", stdout.String()) + } + if caller.calls != 1 || caller.tool != test.tool { + t.Fatalf("calls/tool = %d/%q, want one %s call", caller.calls, caller.tool, test.tool) + } + }) + } +} diff --git a/internal/helpers/sheet_validate.go b/internal/helpers/sheet_validate.go index e40a7cb3..f32e6836 100644 --- a/internal/helpers/sheet_validate.go +++ b/internal/helpers/sheet_validate.go @@ -239,22 +239,48 @@ func validateDataValidation(dvRaw any, path string) error { } switch dvType { case "dropdown": - optionsRaw, ok := dv["options"] - if !ok { - return fmt.Errorf("%s: type=dropdown 必须包含 options 数组", path) + optionsRaw, hasOptions := dv["options"] + sourceRangeRaw, hasSourceRange := dv["sourceRange"] + hasOptions = hasOptions && optionsRaw != nil + hasSourceRange = hasSourceRange && sourceRangeRaw != nil + if hasOptions == hasSourceRange { + return fmt.Errorf("%s: type=dropdown 的 options 与 sourceRange 必须且只能包含一个", path) } - options, ok := optionsRaw.([]any) - if !ok || len(options) == 0 { - return fmt.Errorf("%s.options 必须为非空数组", path) - } - for i, opt := range options { - optMap, ok := opt.(map[string]any) - if !ok { - return fmt.Errorf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i) + if hasOptions { + options, ok := optionsRaw.([]any) + if !ok || len(options) == 0 { + return fmt.Errorf("%s.options 必须为非空数组", path) } - val, ok := optMap["value"].(string) - if !ok || val == "" { - return fmt.Errorf("%s.options[%d].value 必须为非空字符串", path, i) + for i, opt := range options { + optMap, ok := opt.(map[string]any) + if !ok { + return fmt.Errorf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i) + } + val, ok := optMap["value"].(string) + if !ok || val == "" { + return fmt.Errorf("%s.options[%d].value 必须为非空字符串", path, i) + } + } + } else { + sourceRange, ok := sourceRangeRaw.(map[string]any) + if !ok { + return fmt.Errorf("%s.sourceRange 必须为 object", path) + } + if _, exists := sourceRange["colors"]; exists { + return fmt.Errorf("%s.sourceRange.colors 暂不支持", path) + } + sheetID, ok := sourceRange["sheetId"].(string) + if !ok || strings.TrimSpace(sheetID) == "" { + return fmt.Errorf("%s.sourceRange.sheetId 必须为非空字符串", path) + } + a1Notation, ok := sourceRange["a1Notation"].(string) + if !ok || strings.TrimSpace(a1Notation) == "" { + return fmt.Errorf("%s.sourceRange.a1Notation 必须为非空字符串", path) + } + } + if multi, exists := dv["enableMultiSelect"]; exists { + if _, ok := multi.(bool); !ok { + return fmt.Errorf("%s.enableMultiSelect 必须为 boolean", path) } } case "checkbox": diff --git a/internal/helpers/wiki.go b/internal/helpers/wiki.go index 88c0272e..3c8dc9cf 100644 --- a/internal/helpers/wiki.go +++ b/internal/helpers/wiki.go @@ -428,7 +428,7 @@ func newWikiCommand() *cobra.Command { }) // space create flags - spaceCreateCmd.Flags().String("name", "", "知识库名称 (必填,不超过 100 字符)") + spaceCreateCmd.Flags().String("name", "", "知识库名称 (必填,不超过 32 字符)") spaceCreateCmd.Flags().String("desc", "", "知识库描述 (选填,不超过 500 字符)") spaceCreateCmd.Flags().String("icon", "", "知识库图标标识 (选填)") @@ -696,7 +696,7 @@ func newWikiCommand() *cobra.Command { Short: "查询知识库成员列表", Long: `查询指定知识库的成员列表,返回每位成员的 userId、姓名、角色等信息。 -注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。 +注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 50)。 若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围; ORG 类型授权不会出现在查询结果中。`, Example: ` dws wiki member list --workspace @@ -717,6 +717,9 @@ ORG 类型授权不会出现在查询结果中。`, limit, _ = cmd.Flags().GetInt("max-results") } if limit > 0 { + if limit > 50 { + return fmt.Errorf("--limit 不能超过 50;底层成员接口不提供游标续页") + } toolArgs["maxResults"] = limit } if v := mustGetFlag(cmd, "filter-role"); v != "" { @@ -762,7 +765,7 @@ ORG 类型授权不会出现在查询结果中。`, }) memberListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)") - memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200") + memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50;底层不支持游标续页") memberListCmd.Flags().Int("max-results", 0, "") _ = memberListCmd.Flags().MarkHidden("max-results") memberListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER") diff --git a/internal/shortcut/adapter.go b/internal/shortcut/adapter.go index fd1f968d..7b870b83 100644 --- a/internal/shortcut/adapter.go +++ b/internal/shortcut/adapter.go @@ -217,6 +217,7 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec { RequiredError: fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc), Enum: append([]string(nil), f.Enum...), Aliases: append([]string(nil), f.Aliases...), + Input: append([]string(nil), f.Input...), }) } return out diff --git a/internal/shortcut/aitable/aitable.go b/internal/shortcut/aitable/aitable.go index 0077dda9..2d1a9d64 100644 --- a/internal/shortcut/aitable/aitable.go +++ b/internal/shortcut/aitable/aitable.go @@ -733,7 +733,7 @@ var RecordQuery = shortcut.Shortcut{ if rt.Changed("cursor") { params["cursor"] = rt.Str("cursor") } - return rt.CallMCP("query_records", params) + return executeRecordQuery(rt, params) }, } diff --git a/internal/shortcut/aitable/record_batch.go b/internal/shortcut/aitable/record_batch.go index 8c26c3b1..63f7d92f 100644 --- a/internal/shortcut/aitable/record_batch.go +++ b/internal/shortcut/aitable/record_batch.go @@ -98,7 +98,7 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error { step.Status = "unknown" step.Error = writeErr.Error() } - remaining, verifyErr := queryRecordsByIDs(rt, baseID, tableID, batch) + remaining, verifyErr := queryDeletedRecordsByIDs(rt, baseID, tableID, batch) if verifyErr == nil && len(remaining) > 0 { verifyErr = fmt.Errorf("read-back still contains deleted record IDs: %s", strings.Join(recordIDs(remaining), ",")) } @@ -303,20 +303,33 @@ func verifyUpsertBatch(rt *shortcut.RuntimeContext, baseID, tableID string, batc } func queryRecordsByIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]map[string]any, error) { - data, err := rt.CallMCPData(serverMain, "query_records", map[string]any{ - "baseId": baseID, "tableId": tableID, "recordIds": ids, "limit": len(ids), - }) + window, err := queryRecordWindow(rt, map[string]any{ + "baseId": baseID, "tableId": tableID, "recordIds": ids, + }, len(ids)) if err != nil { return nil, err } - records, found := findRecords(data) - if !found { - return nil, fmt.Errorf("query_records read-back is missing records") + // Exact-ID verification below compares every requested ID with the returned + // records. The service can publish a continuation even after all requested + // IDs are present, so hasMore is not evidence that this bounded read-back is + // incomplete. + return window.Records, nil +} + +func queryDeletedRecordsByIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]map[string]any, error) { + remaining := make([]map[string]any, 0) + for offset := 0; offset < len(ids); offset += recordQueryServicePageSize { + end := minInt(offset+recordQueryServicePageSize, len(ids)) + chunk := ids[offset:end] + window, err := queryRecordWindow(rt, map[string]any{ + "baseId": baseID, "tableId": tableID, "recordIds": chunk, + }, len(chunk)) + if err != nil { + return nil, err + } + remaining = append(remaining, window.Records...) } - if responseHasMore(data) { - return nil, fmt.Errorf("query_records read-back is incomplete") - } - return records, nil + return remaining, nil } func recordIDs(records []map[string]any) []string { diff --git a/internal/shortcut/aitable/record_query_all.go b/internal/shortcut/aitable/record_query_all.go index 0606abba..95ef48b4 100644 --- a/internal/shortcut/aitable/record_query_all.go +++ b/internal/shortcut/aitable/record_query_all.go @@ -10,13 +10,245 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) +// query_records is backed by a service pager whose stable typed-envelope +// boundary is 20 records. Asking that layer to aggregate more than one page can +// drop the envelope fields used by the record projector, turning a successful +// read into an empty collection. Keep every remote request on one service page +// and aggregate only after each page has passed the CLI's shape validation. +const ( + recordQueryServicePageSize = 20 + recordQueryMaxConsecutiveEmptyPages = 3 +) + +type recordQueryWindow struct { + Records []map[string]any + HasMore bool + NextCursor string + Pages int + TotalCount any + HasTotalCount bool +} + +func queryRecordWindow(rt *shortcut.RuntimeContext, params map[string]any, limit int) (recordQueryWindow, error) { + if limit <= 0 { + return recordQueryWindow{}, fmt.Errorf("record query limit must be positive, got %d", limit) + } + request := cloneAnyMap(params) + cursor, _ := request["cursor"].(string) + cursor = strings.TrimSpace(cursor) + seen := map[string]bool{} + if cursor != "" { + seen[cursor] = true + } + window := recordQueryWindow{Records: make([]map[string]any, 0, limit)} + consecutiveEmptyPages := 0 + + for len(window.Records) < limit { + pageSize := minInt(recordQueryServicePageSize, limit-len(window.Records)) + request["limit"] = pageSize + if cursor == "" { + delete(request, "cursor") + } else { + request["cursor"] = cursor + } + data, err := rt.CallMCPData(serverMain, "query_records", request) + if err != nil { + return recordQueryWindow{}, err + } + if !window.HasTotalCount { + window.TotalCount, window.HasTotalCount = responseTotalCount(data) + } + records, found := findRecords(data) + if !found { + if explicitEmptyRecordQuery(data) { + window.Pages++ + window.HasMore = false + window.NextCursor = "" + return window, nil + } + return recordQueryWindow{}, fmt.Errorf("query_records page %d is missing the records collection", window.Pages+1) + } + window.Records = append(window.Records, records...) + window.Pages++ + + if !responseHasMore(data) { + window.HasMore = false + window.NextCursor = "" + return window, nil + } + if len(records) == 0 { + consecutiveEmptyPages++ + if consecutiveEmptyPages >= recordQueryMaxConsecutiveEmptyPages { + return recordQueryWindow{}, fmt.Errorf( + "query_records made no progress for %d consecutive pages", + consecutiveEmptyPages, + ) + } + } else { + consecutiveEmptyPages = 0 + } + next := responseCursor(data) + if next == "" { + return recordQueryWindow{}, fmt.Errorf("query_records page %d reports more data but no next cursor", window.Pages) + } + if seen[next] { + return recordQueryWindow{}, fmt.Errorf("query_records cursor cycle detected at %q", next) + } + seen[next] = true + cursor = next + window.HasMore = true + window.NextCursor = next + } + + return window, nil +} + +// explicitEmptyRecordQuery recognizes the service's reviewed zero-match wire +// shape. It is deliberately stricter than "records is absent": only a complete +// success envelope with an empty data object is accepted as an empty set. +func explicitEmptyRecordQuery(data map[string]any) bool { + if data == nil || data["success"] != true || data["status"] != "success" { + return false + } + payload, ok := data["data"].(map[string]any) + if !ok || len(payload) != 0 { + return false + } + if rawError, exists := data["error"]; exists && rawError != nil { + errorObject, ok := rawError.(map[string]any) + if !ok || len(errorObject) != 0 { + return false + } + } + return true +} + +func executeRecordQuery(rt *shortcut.RuntimeContext, params map[string]any) error { + limit := 100 + if rt.Changed("limit") { + limit = rt.Int("limit") + } + if limit < 1 || limit > recordBatchSize { + return fmt.Errorf("--limit must be in [1,%d], got %d", recordBatchSize, limit) + } + requestedIDs, exactIDQuery, err := recordQueryRequestedIDs(params) + if err != nil { + return err + } + if exactIDQuery { + if len(requestedIDs) > recordBatchSize { + return fmt.Errorf("--record-ids accepts at most %d unique IDs, got %d", recordBatchSize, len(requestedIDs)) + } + params = cloneAnyMap(params) + params["recordIds"] = requestedIDs + limit = minInt(limit, len(requestedIDs)) + } + if rt.DryRun() { + return rt.Output(map[string]any{ + "dry_run": true, + "executed": false, + "tool": "query_records", + "arguments": params, + }) + } + window, err := queryRecordWindow(rt, params, limit) + if err != nil { + return err + } + if exactIDQuery { + complete, err := validateExactRecordQuery(window.Records, requestedIDs) + if err != nil { + return err + } + if complete { + // The service may advertise unrelated continuation after every + // requested ID is already present. Exact-ID completion is stronger + // evidence than that residual pager state. + window.HasMore = false + window.NextCursor = "" + } + } + records := make([]any, 0, len(window.Records)) + for _, record := range window.Records { + records = append(records, record) + } + data := map[string]any{ + "records": records, + "hasMore": window.HasMore, + "page": window.Pages, + "size": len(window.Records), + } + if window.NextCursor != "" { + data["nextCursor"] = window.NextCursor + } + if window.HasTotalCount { + data["totalCount"] = window.TotalCount + } + return rt.Output(map[string]any{ + "success": true, + "status": "success", + "data": data, + }) +} + +func responseTotalCount(data map[string]any) (any, bool) { + for { + if totalCount, exists := data["totalCount"]; exists { + return totalCount, true + } + nested, ok := data["data"].(map[string]any) + if !ok { + return nil, false + } + data = nested + } +} + +func recordQueryRequestedIDs(params map[string]any) ([]string, bool, error) { + raw, exists := params["recordIds"] + if !exists { + return nil, false, nil + } + values, ok := raw.([]string) + if !ok { + return nil, false, fmt.Errorf("recordIds must be a string list, got %T", raw) + } + ids, err := parseRecordIDs(values) + if err != nil { + return nil, false, err + } + return ids, true, nil +} + +func validateExactRecordQuery(records []map[string]any, requestedIDs []string) (bool, error) { + wanted := make(map[string]bool, len(requestedIDs)) + for _, id := range requestedIDs { + wanted[id] = true + } + seen := make(map[string]bool, len(records)) + for index, record := range records { + id := recordID(record) + if id == "" { + return false, fmt.Errorf("query_records exact-ID result %d is missing recordId", index) + } + if !wanted[id] { + return false, fmt.Errorf("query_records exact-ID result contains unexpected recordId %q", id) + } + if seen[id] { + return false, fmt.Errorf("query_records exact-ID result contains duplicate recordId %q", id) + } + seen[id] = true + } + return len(seen) == len(wanted), nil +} + func queryAllRecords(rt *shortcut.RuntimeContext, params map[string]any, maxRecords int) ([]map[string]any, error) { all := make([]map[string]any, 0) cursor := "" seen := map[string]bool{} for page := 0; ; page++ { request := cloneAnyMap(params) - request["limit"] = recordBatchSize + request["limit"] = recordQueryServicePageSize if cursor != "" { request["cursor"] = cursor } diff --git a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go index 64bf759a..2fc9333f 100644 --- a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go +++ b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "testing" @@ -265,6 +266,446 @@ func recordListJSON(t *testing.T, records []map[string]any) string { return string(raw) } +func pagedRecordQueryResponse(t *testing.T, records []map[string]any, args map[string]any) string { + t.Helper() + limit, ok := args["limit"].(int) + if !ok || limit < 1 || limit > recordQueryServicePageSize { + t.Fatalf("query_records limit = %#v, want 1..%d", args["limit"], recordQueryServicePageSize) + } + offset := 0 + if cursor, _ := args["cursor"].(string); cursor != "" { + if _, err := fmt.Sscanf(cursor, "offset-%d", &offset); err != nil { + t.Fatalf("invalid test cursor %q: %v", cursor, err) + } + } + end := minInt(offset+limit, len(records)) + pageRecords := records[offset:end] + data := map[string]any{"records": pageRecords, "hasMore": end < len(records), "totalCount": len(records)} + if end < len(records) { + data["nextCursor"] = fmt.Sprintf("offset-%d", end) + } + raw, err := json.Marshal(map[string]any{ + "success": true, + "hasMore": end < len(records), + "page": offset/recordQueryServicePageSize + 1, + "size": limit, + "data": data, + }) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func runRecordQueryShortcutCLI(t *testing.T, caller *upsertByKeyCaller, limit int, extra ...string) (map[string]any, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.AddCommand(shortcut.Commands()...) + stdout := &bytes.Buffer{} + root.SetOut(stdout) + root.SetErr(&bytes.Buffer{}) + args := []string{"aitable", "+record-query", "--base-id", "base", "--table-id", "table", "--limit", fmt.Sprint(limit)} + root.SetArgs(append(args, extra...)) + err := root.Execute() + if stdout.Len() == 0 { + return nil, err + } + var payload map[string]any + if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil { + t.Fatalf("decode record query output %q: %v", stdout.String(), decodeErr) + } + return payload, err +} + +func TestCrossPlatformCoverageRecordQueryServicePageBoundariesE2E(t *testing.T) { + for _, size := range []int{20, 21, 22, 100} { + t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) { + records := updateFixtureRecords(0, size, "可见") + caller := &upsertByKeyCaller{} + caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) { + if tool != "query_records" { + return "", fmt.Errorf("unexpected tool %s", tool) + } + return pagedRecordQueryResponse(t, records, args), nil + } + payload, err := runRecordQueryShortcutCLI(t, caller, size) + if err != nil { + t.Fatalf("record query size %d: %v", size, err) + } + data, ok := payload["data"].(map[string]any) + if !ok || payload["success"] != true || data["hasMore"] != false || data["totalCount"] != float64(size) || len(data["records"].([]any)) != size { + t.Fatalf("record query size %d payload = %#v", size, payload) + } + wantCalls := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize + if len(caller.calls) != wantCalls || data["page"] != float64(wantCalls) || data["size"] != float64(size) { + t.Fatalf("record query size %d calls=%d payload=%#v, want calls=%d", size, len(caller.calls), payload, wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageRecordQueryDryRunStopsBeforeTransportE2E(t *testing.T) { + caller := &upsertByKeyCaller{} + payload, err := runRecordQueryShortcutCLI(t, caller, 5, "--query", "needle", "--dry-run") + if err != nil { + t.Fatalf("record query dry-run error = %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("record query dry-run crossed transport: %#v", caller.calls) + } + arguments, _ := payload["arguments"].(map[string]any) + if payload["dry_run"] != true || payload["executed"] != false || payload["tool"] != "query_records" || + arguments["baseId"] != "base" || arguments["tableId"] != "table" || arguments["keyword"] != "needle" || arguments["limit"] != float64(5) { + t.Fatalf("record query dry-run payload = %#v", payload) + } +} + +func TestCrossPlatformCoverageRecordQueryDryRunRejectsInvalidLocalPlanE2E(t *testing.T) { + for _, testCase := range []struct { + name string + limit int + extra []string + want string + }{ + {name: "zero limit", limit: 0, extra: []string{"--dry-run"}, want: "--limit must be in [1,100]"}, + {name: "excessive limit", limit: recordBatchSize + 1, extra: []string{"--dry-run"}, want: "--limit must be in [1,100]"}, + {name: "empty record IDs", limit: 100, extra: []string{"--record-ids", " ", "--dry-run"}, want: "至少包含一个非空 recordId"}, + { + name: "excessive record IDs", + limit: 100, + extra: []string{"--record-ids", strings.Join(recordIDFixtures(recordBatchSize+1), ","), "--dry-run"}, + want: "at most 100 unique IDs", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + caller := &upsertByKeyCaller{} + payload, err := runRecordQueryShortcutCLI(t, caller, testCase.limit, testCase.extra...) + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("record query dry-run error = %v, want %q (payload=%#v)", err, testCase.want, payload) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid record query dry-run crossed transport: %#v", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageRecordQueryExactIDsIgnoreResidualContinuationE2E(t *testing.T) { + caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) { + if tool != "query_records" { + return "", fmt.Errorf("unexpected tool %s", tool) + } + if call != 0 { + return "", errors.New("exact-ID query followed residual continuation") + } + if args["limit"] != 2 { + t.Fatalf("exact-ID service limit = %#v, want 2", args["limit"]) + } + ids, ok := args["recordIds"].([]string) + if !ok || !slices.Equal(ids, []string{"r1", "r2"}) { + t.Fatalf("exact-ID request IDs = %#v", args["recordIds"]) + } + return `{"success":true,"data":{"records":[{"recordId":"r1"},{"recordId":"r2"}],"hasMore":true,"nextCursor":"residual"}}`, nil + }} + payload, err := runRecordQueryShortcutCLI(t, caller, 100, "--record-ids", "r2,r1,r2") + data, _ := payload["data"].(map[string]any) + if err != nil || len(caller.calls) != 1 || data["hasMore"] != false || data["size"] != float64(2) { + t.Fatalf("exact-ID payload=%#v calls=%d err=%v", payload, len(caller.calls), err) + } + if _, exists := data["nextCursor"]; exists { + t.Fatalf("exact-ID payload retained residual cursor: %#v", payload) + } +} + +func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testing.T) { + t.Run("non-positive window limit", func(t *testing.T) { + caller := &upsertByKeyCaller{} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery) + if _, err := queryRecordWindow(rt, nil, 0); err == nil { + t.Fatal("non-positive record query window limit accepted") + } + }) + + t.Run("initial cursor cycle", func(t *testing.T) { + caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) { + if tool != "query_records" { + return "", fmt.Errorf("unexpected tool %s", tool) + } + return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":true,"nextCursor":"seed"}}`, nil + }} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery) + if _, err := queryRecordWindow(rt, map[string]any{"cursor": " seed "}, 2); err == nil || !strings.Contains(err.Error(), "cursor cycle") { + t.Fatalf("initial cursor cycle error = %v", err) + } + }) + + t.Run("strict explicit empty shape", func(t *testing.T) { + for _, data := range []map[string]any{ + {"success": true, "status": "success", "data": map[string]any{"unexpected": true}}, + {"success": true, "status": "success", "data": map[string]any{}, "error": "bad"}, + } { + if explicitEmptyRecordQuery(data) { + t.Fatalf("invalid empty-query envelope accepted: %#v", data) + } + } + }) + + t.Run("explicit empty page", func(t *testing.T) { + caller := &upsertByKeyCaller{callFn: func(_ int, _, _ string, _ map[string]any) (string, error) { + return `{"success":true,"status":"success","error":{},"data":{}}`, nil + }} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery) + window, err := queryRecordWindow(rt, map[string]any{}, 1) + if err != nil || len(window.Records) != 0 || window.Pages != 1 || window.HasMore || window.NextCursor != "" { + t.Fatalf("explicit empty window=%#v err=%v", window, err) + } + }) + + t.Run("missing records collection", func(t *testing.T) { + caller := &upsertByKeyCaller{callFn: func(_ int, _, _ string, _ map[string]any) (string, error) { + return `{}`, nil + }} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery) + if _, err := queryRecordWindow(rt, map[string]any{}, 1); err == nil || !strings.Contains(err.Error(), "missing the records collection") { + t.Fatalf("missing records error = %v", err) + } + }) + + t.Run("changing cursors cannot hide sustained empty pages", func(t *testing.T) { + caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) { + if tool != "query_records" { + return "", fmt.Errorf("unexpected tool %s", tool) + } + return fmt.Sprintf( + `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"empty-%d"}}`, + call, + ), nil + }} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery) + if _, err := queryRecordWindow(rt, map[string]any{}, 1); err == nil || !strings.Contains(err.Error(), "no progress") { + t.Fatalf("sustained empty pages error = %v", err) + } + if len(caller.calls) != recordQueryMaxConsecutiveEmptyPages { + t.Fatalf("query calls = %d, want %d", len(caller.calls), recordQueryMaxConsecutiveEmptyPages) + } + }) + + t.Run("invalid shortcut limit", func(t *testing.T) { + caller := &upsertByKeyCaller{} + helpers.InitDepsForTest(t, caller) + cmd := &cobra.Command{Use: "query"} + cmd.Flags().Int("limit", 0, "") + if err := cmd.Flags().Set("limit", "0"); err != nil { + t.Fatal(err) + } + rt := shortcut.RuntimeContextForTest(cmd, RecordQuery) + if err := executeRecordQuery(rt, map[string]any{}); err == nil { + t.Fatal("invalid shortcut limit accepted") + } + }) + + t.Run("invalid exact-ID request", func(t *testing.T) { + for _, testCase := range []struct { + name string + params map[string]any + want string + }{ + {name: "wrong type", params: map[string]any{"recordIds": 1}, want: "string list"}, + {name: "empty", params: map[string]any{"recordIds": []string{" "}}, want: "至少包含一个非空 recordId"}, + {name: "over service limit", params: map[string]any{"recordIds": recordIDFixtures(recordBatchSize + 1)}, want: "at most 100 unique IDs"}, + } { + t.Run(testCase.name, func(t *testing.T) { + caller := &upsertByKeyCaller{} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery) + err := executeRecordQuery(rt, testCase.params) + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("exact-ID request error = %v, want %q", err, testCase.want) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid exact-ID request made %d service calls", len(caller.calls)) + } + }) + } + }) + + t.Run("invalid exact-ID response", func(t *testing.T) { + caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) { + if tool != "query_records" { + return "", fmt.Errorf("unexpected tool %s", tool) + } + return `{"success":true,"data":{"records":[{"recordId":"other"}]}}`, nil + }} + if _, err := runRecordQueryShortcutCLI(t, caller, 100, "--record-ids", "r1"); err == nil || !strings.Contains(err.Error(), "unexpected recordId") { + t.Fatalf("unexpected exact-ID response error = %v", err) + } + + if _, err := validateExactRecordQuery([]map[string]any{{}}, []string{"r1"}); err == nil || !strings.Contains(err.Error(), "missing recordId") { + t.Fatalf("missing exact-ID response error = %v", err) + } + duplicate := []map[string]any{{"recordId": "r1"}, {"recordId": "r1"}} + if _, err := validateExactRecordQuery(duplicate, []string{"r1", "r2"}); err == nil || !strings.Contains(err.Error(), "duplicate recordId") { + t.Fatalf("duplicate exact-ID response error = %v", err) + } + }) + + t.Run("shortcut query failure", func(t *testing.T) { + caller := &upsertByKeyCaller{callFn: func(_ int, _, _ string, _ map[string]any) (string, error) { + return "", errors.New("query unavailable") + }} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery) + if err := executeRecordQuery(rt, map[string]any{}); err == nil || !strings.Contains(err.Error(), "query unavailable") { + t.Fatalf("shortcut query failure = %v", err) + } + }) + + t.Run("bounded continuation is published", func(t *testing.T) { + records := updateFixtureRecords(0, 21, "visible") + caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) { + if tool != "query_records" { + return "", fmt.Errorf("unexpected tool %s", tool) + } + return pagedRecordQueryResponse(t, records, args), nil + }} + payload, err := runRecordQueryShortcutCLI(t, caller, 20) + data, _ := payload["data"].(map[string]any) + if err != nil || data["hasMore"] != true || data["nextCursor"] != "offset-20" { + t.Fatalf("bounded continuation payload=%#v err=%v", payload, err) + } + }) + + t.Run("delete readback follows active continuation to absence", func(t *testing.T) { + caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) { + if tool != "query_records" { + return `{"deletedCount":1}`, nil + } + if call == 1 { + if _, exists := args["cursor"]; exists { + t.Fatalf("first deletion readback unexpectedly has cursor: %#v", args) + } + return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil + } + if args["cursor"] != "c1" { + t.Fatalf("continued deletion readback cursor = %#v", args["cursor"]) + } + return `{"success":true,"data":{"records":[],"hasMore":false}}`, nil + }} + out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) + if err != nil || out == "" || len(caller.calls) != 3 { + t.Fatalf("delete continued absence output=%q error=%v calls=%#v", out, err, caller.calls) + } + }) + + t.Run("delete readback follows active continuation to remaining record", func(t *testing.T) { + caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) { + if tool != "query_records" { + return `{"deletedCount":1}`, nil + } + if call == 1 { + return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil + } + return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil + }} + out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) + if err == nil || out != "" || len(caller.calls) != 3 { + t.Fatalf("delete continued remaining output=%q error=%v calls=%#v", out, err, caller.calls) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" { + t.Fatalf("delete continued remaining error = %#v", err) + } + }) + + t.Run("delete readback fails closed on empty continuation stall", func(t *testing.T) { + caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) { + if tool != "query_records" { + return `{"deletedCount":1}`, nil + } + return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, call), nil + }} + out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) + if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+1 { + t.Fatalf("delete stalled continuation output=%q error=%v calls=%#v", out, err, caller.calls) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" { + t.Fatalf("delete stalled continuation error = %#v", err) + } + }) +} + +func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *testing.T) { + for _, operation := range []string{"update", "upsert", "delete"} { + for _, size := range []int{21, 22, 100} { + t.Run(fmt.Sprintf("%s_%d", operation, size), func(t *testing.T) { + records := updateFixtureRecords(0, size, "完成") + caller := &upsertByKeyCaller{} + queryCalls := 0 + caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) { + if tool != "query_records" { + return `{"success":true}`, nil + } + queryCalls++ + if operation == "delete" { + ids := args["recordIds"].([]string) + empty := make([]map[string]any, len(ids)) + response := pagedRecordQueryResponse(t, empty, args) + var payload map[string]any + if err := json.Unmarshal([]byte(response), &payload); err != nil { + t.Fatal(err) + } + page := payload["data"].(map[string]any) + page["records"] = []any{} + raw, _ := json.Marshal(payload) + return string(raw), nil + } + response := pagedRecordQueryResponse(t, records, args) + var payload map[string]any + if err := json.Unmarshal([]byte(response), &payload); err != nil { + t.Fatal(err) + } + page := payload["data"].(map[string]any) + if len(page["records"].([]any))+recordQueryServicePageSize*(queryCalls-1) >= size { + // The live service can retain a continuation after it has + // returned every requested record ID. Exact-ID verification + // must use ID coverage, not this advisory bit. + page["hasMore"] = true + page["nextCursor"] = "ignored-after-complete-id-set" + payload["hasMore"] = true + } + raw, _ := json.Marshal(payload) + return string(raw), nil + } + + var out string + var err error + if operation == "delete" { + out, err = runRecordDeleteCLI(t, caller, recordIDs(records)) + } else { + out, err = runRecordBatchCLI(t, caller, "+record-"+operation, records) + } + if err != nil || out == "" { + t.Fatalf("%s size %d false negative: output=%q err=%v", operation, size, out, err) + } + wantQueries := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize + if queryCalls != wantQueries { + t.Fatalf("%s size %d query calls=%d, want %d", operation, size, queryCalls, wantQueries) + } + }) + } + } +} + func TestCrossPlatformCoverageRecordUpdateAutoChunksAndVerifiesE2E(t *testing.T) { records := updateFixtureRecords(0, 101, "完成") caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ @@ -395,6 +836,10 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ {text: `{"deletedCount":100}`}, {text: `{"data":{"records":[]}}`}, + {text: `{"data":{"records":[]}}`}, + {text: `{"data":{"records":[]}}`}, + {text: `{"data":{"records":[]}}`}, + {text: `{"data":{"records":[]}}`}, {text: `{"deletedCount":1}`}, {text: `{"data":{"records":[]}}`}, }} @@ -407,7 +852,7 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi t.Fatalf("delete output missing %s: %s", want, out) } } - if len(caller.calls) != 4 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" { + if len(caller.calls) != 8 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" || caller.calls[6].tool != "delete_records" { t.Fatalf("delete call sequence = %#v", caller.calls) } } @@ -421,6 +866,17 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t } }) + t.Run("explicit service success with empty data proves deletion", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"deletedCount":1}`}, + {text: `{"success":true,"status":"success","error":{},"data":{}}`}, + }} + out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) + if err != nil || !strings.Contains(out, `"status": "verified_absent"`) { + t.Fatalf("delete explicit empty success = output:%q err:%v", out, err) + } + }) + t.Run("missing records contract is unknown", func(t *testing.T) { caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"deletedCount":1}`}, {text: `{"data":{}}`}}} out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) @@ -450,6 +906,10 @@ func TestCrossPlatformCoverageRecordDeletePartialCheckpointE2E(t *testing.T) { caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ {text: `{"deletedCount":100}`}, {text: `{"records":[]}`}, + {text: `{"records":[]}`}, + {text: `{"records":[]}`}, + {text: `{"records":[]}`}, + {text: `{"records":[]}`}, {text: `{"deletedCount":0}`}, {text: `{"records":[{"recordId":"r100","cells":{}}]}`}, }} diff --git a/internal/shortcut/builtin/wiki_semantic_catalog_test.go b/internal/shortcut/builtin/wiki_semantic_catalog_test.go new file mode 100644 index 00000000..afe58c14 --- /dev/null +++ b/internal/shortcut/builtin/wiki_semantic_catalog_test.go @@ -0,0 +1,71 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageWikiSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_wiki.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "wiki" { + t.Fatalf("service = %q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "wiki" { + if _, exists := registered[item.Command]; exists { + t.Fatalf("duplicate %s", item.Command) + } + registered[item.Command] = item + } + } + if len(registered) != 20 || len(source.Shortcuts) != 20 { + t.Fatalf("registered/catalog = %d/%d, want 20/20", len(registered), len(source.Shortcuts)) + } + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + if !record.Reviewed || !item.SemanticReviewed || item.Hidden || !record.Public { + t.Errorf("%s: not publicly reviewed", command) + } + if strings.TrimSpace(record.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta || item.Disposition != record.Disposition { + t.Errorf("%s: semantic facts drifted", command) + } + if item.Risk != record.Risk { + t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk) + } + if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s: incomplete contract/safety/result/output", command) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } +} diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 553b604c..974f29d3 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -1644,13 +1644,20 @@ var MessagesSendCard = shortcut.Shortcut{ if err != nil { return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err) } - if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil { + verification, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated) + if err != nil { return fmt.Errorf("卡片已创建(bizId=%s),但自动更新结果不可信: %w", bizID, cardUpdateVerificationError(bizID, err)) } payload := chatmsg.ProjectStreamingCardReceipt(created, bizID) payload["bizId"] = bizID payload["flowStatus"] = status payload["updated"] = updated + payload["updateAccepted"] = verification.Accepted + payload["updateVerified"] = verification.Verified + payload["updateVerificationEvidence"] = verification.Evidence + if verification.Accepted && !verification.Verified { + payload["updateWarning"] = "服务端已接受卡片更新请求,但未返回可独立证明可见内容已更新的字段;不要重复执行相同更新" + } return rt.Output(payload) }, } @@ -1793,11 +1800,11 @@ var MessagesUpdateCard = shortcut.Shortcut{ if err != nil { return err } - proof, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated) + verification, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated) if err != nil { return cardUpdateVerificationError(bizID, err) } - return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, proof)) + return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, verification)) }, } diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index 7cd22e11..2526af79 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -1049,6 +1049,14 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test }, wantError: "biz-preserved", }, + { + name: "unverified update preserves id", + fake: &larkAlignmentCaller{responses: map[string]string{ + "im/create_and_send_card": `{"bizId":"biz-unverified"}`, + "im/update_streaming_card": `{"result":{"updated":false}}`, + }}, + wantError: "biz-unverified", + }, } { t.Run(tc.name, func(t *testing.T) { helpers.InitDeps(tc.fake) @@ -1073,6 +1081,8 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test }} helpers.InitDeps(fake) root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) root.SetArgs([]string{ "chat", "+messages-send-card", "--group", "cid", @@ -1085,6 +1095,13 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test if len(fake.calls) != 2 || fake.calls[1].tool != "update_streaming_card" { t.Fatalf("calls = %#v", fake.calls) } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["updateAccepted"] != true || payload["updateVerified"] != false || payload["updateWarning"] == "" { + t.Fatalf("card payload = %#v", payload) + } }) for _, args := range [][]string{ @@ -1157,12 +1174,14 @@ func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) { } }) - t.Run("success acknowledgement is verified", func(t *testing.T) { + t.Run("success acknowledgement is accepted but unverified", func(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ "im/update_streaming_card": `{"success":true,"errorCode":null}`, }} helpers.InitDeps(fake) root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) root.SetArgs([]string{ "chat", "+messages-update-card", "--biz-id", "中文乱串", @@ -1176,6 +1195,13 @@ func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) { if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" { t.Fatalf("calls = %#v", fake.calls) } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["accepted"] != true || payload["verified"] != false || payload["warning"] == "" { + t.Fatalf("payload = %#v", payload) + } }) t.Run("explicit update evidence succeeds", func(t *testing.T) { diff --git a/internal/shortcut/chatmsg/card_ref.go b/internal/shortcut/chatmsg/card_ref.go index 03dcbfba..636fede4 100644 --- a/internal/shortcut/chatmsg/card_ref.go +++ b/internal/shortcut/chatmsg/card_ref.go @@ -55,11 +55,15 @@ func ProjectStreamingCardReceipt(created map[string]any, bizID string) map[strin // ProjectStreamingCardUpdate preserves the lower response while making the // verified target explicit for downstream consumers. -func ProjectStreamingCardUpdate(updated map[string]any, bizID, proof string) map[string]any { +func ProjectStreamingCardUpdate(updated map[string]any, bizID string, verification CardUpdateVerification) map[string]any { payload := cloneSendStatusMap(updated) payload["contractVersion"] = StreamingCardContractVersion payload["cardRef"] = map[string]any{"bizId": strings.TrimSpace(bizID)} - payload["verified"] = true - payload["verificationEvidence"] = proof + payload["accepted"] = verification.Accepted + payload["verified"] = verification.Verified + payload["verificationEvidence"] = verification.Evidence + if verification.Accepted && !verification.Verified { + payload["warning"] = "服务端已接受卡片更新请求,但未返回可独立证明可见内容已更新的字段;不要重复执行相同更新" + } return payload } diff --git a/internal/shortcut/chatmsg/card_ref_test.go b/internal/shortcut/chatmsg/card_ref_test.go index baa65687..fa92da6e 100644 --- a/internal/shortcut/chatmsg/card_ref_test.go +++ b/internal/shortcut/chatmsg/card_ref_test.go @@ -32,7 +32,7 @@ func TestCrossPlatformCoverageProjectStreamingCardReceipt(t *testing.T) { } func TestCrossPlatformCoverageProjectStreamingCardUpdate(t *testing.T) { - payload := ProjectStreamingCardUpdate(map[string]any{"result": map[string]any{"updated": true}}, "biz-1", "updated=true") + payload := ProjectStreamingCardUpdate(map[string]any{"result": map[string]any{"updated": true}}, "biz-1", CardUpdateVerification{Accepted: true, Verified: true, Evidence: "updated=true"}) if payload["contractVersion"] != StreamingCardContractVersion || payload["verified"] != true || payload["verificationEvidence"] != "updated=true" { t.Fatalf("payload = %#v", payload) } @@ -40,3 +40,10 @@ func TestCrossPlatformCoverageProjectStreamingCardUpdate(t *testing.T) { t.Fatal("lower response was not preserved") } } + +func TestCrossPlatformCoverageProjectAcceptedUnverifiedStreamingCardUpdate(t *testing.T) { + payload := ProjectStreamingCardUpdate(map[string]any{"success": true}, "biz-1", CardUpdateVerification{Accepted: true, Verified: false, Evidence: "success=true"}) + if payload["accepted"] != true || payload["verified"] != false || payload["warning"] == "" { + t.Fatalf("payload = %#v", payload) + } +} diff --git a/internal/shortcut/chatmsg/card_update.go b/internal/shortcut/chatmsg/card_update.go index ac4dc6f1..d6f205f3 100644 --- a/internal/shortcut/chatmsg/card_update.go +++ b/internal/shortcut/chatmsg/card_update.go @@ -27,6 +27,16 @@ var ( ErrCardUpdateBizIDDrift = errors.New("streaming card update returned a different bizId") ) +// CardUpdateVerification distinguishes an accepted write from an independently +// verified write. Some server versions return success=true without an updated +// flag or affected count; that is sufficient to report acceptance, but not to +// claim that the card's visible content was observed after the write. +type CardUpdateVerification struct { + Accepted bool + Verified bool + Evidence string +} + // NormalizeCardBizID performs only format-independent checks. bizId is an // opaque server-issued identifier; a stricter character or prefix contract // must not be invented by the CLI without an authoritative API declaration. @@ -58,33 +68,40 @@ func isCardBizIDPlaceholder(value string) bool { } } -// VerifyStreamingCardUpdate requires affirmative evidence that the requested -// write took effect. update_streaming_card may acknowledge an applied write -// with success=true without returning an updated flag or affected count. -func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) { +// VerifyStreamingCardUpdate separates server acceptance from affirmative +// evidence that the requested write took effect. +func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (CardUpdateVerification, error) { requestedBizID = strings.TrimSpace(requestedBizID) observation := cardUpdateObservation{bizIDs: map[string]struct{}{}} observeCardUpdate(response, &observation) for responseBizID := range observation.bizIDs { if requestedBizID != "" && responseBizID != requestedBizID { - return "", fmt.Errorf("%w: requested %q, response %q", ErrCardUpdateBizIDDrift, requestedBizID, responseBizID) + return CardUpdateVerification{}, fmt.Errorf("%w: requested %q, response %q", ErrCardUpdateBizIDDrift, requestedBizID, responseBizID) } } - if observation.positiveEvidence != "" && observation.negativeEvidence != "" { - return "", fmt.Errorf("%w: conflicting evidence %s and %s", ErrCardUpdateUnverified, observation.positiveEvidence, observation.negativeEvidence) + if observation.negativeEvidence != "" && (observation.positiveEvidence != "" || observation.acceptedEvidence != "") { + positive := observation.positiveEvidence + if positive == "" { + positive = observation.acceptedEvidence + } + return CardUpdateVerification{}, fmt.Errorf("%w: conflicting evidence %s and %s", ErrCardUpdateUnverified, positive, observation.negativeEvidence) } if observation.positiveEvidence != "" { - return observation.positiveEvidence, nil + return CardUpdateVerification{Accepted: true, Verified: true, Evidence: observation.positiveEvidence}, nil } if observation.negativeEvidence != "" { - return "", fmt.Errorf("%w: %s", ErrCardUpdateNotApplied, observation.negativeEvidence) + return CardUpdateVerification{}, fmt.Errorf("%w: %s", ErrCardUpdateNotApplied, observation.negativeEvidence) } - return "", ErrCardUpdateUnverified + if observation.acceptedEvidence != "" { + return CardUpdateVerification{Accepted: true, Verified: false, Evidence: observation.acceptedEvidence}, nil + } + return CardUpdateVerification{}, ErrCardUpdateUnverified } type cardUpdateObservation struct { bizIDs map[string]struct{} + acceptedEvidence string positiveEvidence string negativeEvidence string } @@ -107,6 +124,9 @@ func observeCardUpdate(value any, observation *cardUpdateObservation) { } func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservation) { + if accepted, ok := value["success"].(bool); ok && accepted && observation.acceptedEvidence == "" { + observation.acceptedEvidence = "success=true" + } for _, key := range []string{"bizId", "bizID", "biz_id"} { if candidate, ok := value[key].(string); ok && strings.TrimSpace(candidate) != "" { observation.bizIDs[strings.TrimSpace(candidate)] = struct{}{} @@ -137,14 +157,7 @@ func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservati setNegativeCardUpdateEvidence(observation, "errorCode=non-empty") } if success, ok := value["success"].(bool); ok { - if success { - // Record success=true only when the same response envelope explicitly - // includes its business-error field. A non-empty code is already - // negative evidence above, so the two signals reject the conflict. - if hasErrorCode { - setPositiveCardUpdateEvidence(observation, "success=true") - } - } else { + if !success { setNegativeCardUpdateEvidence(observation, "success=false") } } diff --git a/internal/shortcut/chatmsg/card_update_test.go b/internal/shortcut/chatmsg/card_update_test.go index 23afae54..920a75a0 100644 --- a/internal/shortcut/chatmsg/card_update_test.go +++ b/internal/shortcut/chatmsg/card_update_test.go @@ -52,19 +52,19 @@ func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) { for _, test := range []struct { name string response map[string]any - wantProof string + want CardUpdateVerification wantErrIs error }{ - {name: "updated", response: map[string]any{"result": map[string]any{"updated": true}}, wantProof: "updated=true"}, - {name: "affected", response: map[string]any{"data": map[string]any{"affectedCount": float64(1)}}, wantProof: "affectedCount=1"}, - {name: "boolean result", response: map[string]any{"result": true}, wantProof: "result=true"}, + {name: "updated", response: map[string]any{"result": map[string]any{"updated": true}}, want: CardUpdateVerification{Accepted: true, Verified: true, Evidence: "updated=true"}}, + {name: "affected", response: map[string]any{"data": map[string]any{"affectedCount": float64(1)}}, want: CardUpdateVerification{Accepted: true, Verified: true, Evidence: "affectedCount=1"}}, + {name: "boolean result", response: map[string]any{"result": true}, want: CardUpdateVerification{Accepted: true, Verified: true, Evidence: "result=true"}}, {name: "boolean false result", response: map[string]any{"result": false}, wantErrIs: ErrCardUpdateNotApplied}, - {name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, wantProof: "applied=true"}, + {name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, want: CardUpdateVerification{Accepted: true, Verified: true, Evidence: "applied=true"}}, {name: "conflicting evidence", response: map[string]any{"updated": true, "applied": false}, wantErrIs: ErrCardUpdateUnverified}, {name: "zero affected", response: map[string]any{"affectedCount": 0}, wantErrIs: ErrCardUpdateNotApplied}, - {name: "success acknowledgement", response: map[string]any{"success": true, "errorCode": nil}, wantProof: "success=true"}, - {name: "success acknowledgement with empty error code", response: map[string]any{"success": true, "errorCode": " "}, wantProof: "success=true"}, - {name: "success without explicit error code", response: map[string]any{"success": true}, wantErrIs: ErrCardUpdateUnverified}, + {name: "success acknowledgement", response: map[string]any{"success": true, "errorCode": nil}, want: CardUpdateVerification{Accepted: true, Verified: false, Evidence: "success=true"}}, + {name: "success acknowledgement with empty error code", response: map[string]any{"success": true, "errorCode": " "}, want: CardUpdateVerification{Accepted: true, Verified: false, Evidence: "success=true"}}, + {name: "success without explicit error code", response: map[string]any{"success": true}, want: CardUpdateVerification{Accepted: true, Verified: false, Evidence: "success=true"}}, {name: "success conflicts with error code", response: map[string]any{"success": true, "errorCode": "InternalError"}, wantErrIs: ErrCardUpdateUnverified}, {name: "success conflicts with numeric error code", response: map[string]any{"success": true, "errorCode": float64(500)}, wantErrIs: ErrCardUpdateUnverified}, {name: "error code without success", response: map[string]any{"errorCode": "InternalError"}, wantErrIs: ErrCardUpdateNotApplied}, @@ -74,15 +74,15 @@ func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) { {name: "unrelated extension ignored", response: map[string]any{"extension": map[string]any{"updated": true}}, wantErrIs: ErrCardUpdateUnverified}, } { t.Run(test.name, func(t *testing.T) { - proof, err := VerifyStreamingCardUpdate("biz-1", test.response) + got, err := VerifyStreamingCardUpdate("biz-1", test.response) if test.wantErrIs != nil { if !errors.Is(err, test.wantErrIs) { t.Fatalf("VerifyStreamingCardUpdate error = %v, want errors.Is(_, %v)", err, test.wantErrIs) } return } - if err != nil || proof != test.wantProof { - t.Fatalf("VerifyStreamingCardUpdate = %q, %v; want %q", proof, err, test.wantProof) + if err != nil || got != test.want { + t.Fatalf("VerifyStreamingCardUpdate = %#v, %v; want %#v", got, err, test.want) } }) } diff --git a/internal/shortcut/doc/content_shortcuts.go b/internal/shortcut/doc/content_shortcuts.go index 6d3d781d..df6fd28e 100644 --- a/internal/shortcut/doc/content_shortcuts.go +++ b/internal/shortcut/doc/content_shortcuts.go @@ -4,10 +4,14 @@ package doc import ( + "bytes" + "context" "encoding/json" "fmt" + "io" "os" "path/filepath" + "reflect" "strings" "time" "unicode" @@ -18,6 +22,9 @@ import ( "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/shortcut/docresolver" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/renderer/html" ) var ( @@ -28,6 +35,21 @@ var ( docMkdirTemp = os.MkdirTemp docRemoveAll = os.RemoveAll docDownload = localio.Download + docVerifyWait = waitForDocVerification + docVerifyDelays = []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second} + docMarkdown = goldmark.New( + goldmark.WithExtensions(extension.Table), + goldmark.WithRendererOptions(html.WithUnsafe()), + ) + docMarkdownConvert = func(source []byte, writer io.Writer) error { + return docMarkdown.Convert(source, writer) + } +) + +const ( + docBlockReadPageSize = 50 + docBlockReadMaxItems = 5000 + docMarkdownVerifyMax = 2 * 1024 * 1024 ) var Create = shortcut.Shortcut{ @@ -133,7 +155,9 @@ var Create = shortcut.Shortcut{ verifyTool = "get_document_content" verifyParams["format"] = format } - verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams) + verification, err := readDocVerification(rt, verifyTool, verifyParams, func(data map[string]any) bool { + return content == "" || verifyUpdatedDocumentContent(data, content, "overwrite", format) + }) if err != nil { return docVerificationError("doc.create", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"})) } @@ -422,7 +446,9 @@ var CheckpointUpdate = shortcut.Shortcut{ 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"}) - verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"}) + verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"}, func(data map[string]any) bool { + return verifyUpdatedDocumentContent(data, content, rt.Str("mode"), "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"})) @@ -539,7 +565,7 @@ func executeUpdate(rt *shortcut.RuntimeContext) error { } referenceBlockID := rt.Str("after-block-id") return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block", params, node, - "list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat}, + "list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat, "__allBlocks": true}, func(result, data map[string]any) bool { return verifyInsertedBlock(result, data, referenceBlockID, content, rt.Str("doc-format")) }) @@ -553,14 +579,14 @@ func executeUpdate(rt *shortcut.RuntimeContext) error { params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}} } return executeVerifiedDocMutation(rt, "doc.update", "update_document_block", params, node, - "list_document_blocks", map[string]any{"nodeId": node, "blockId": blockID, "format": verificationFormat}, + "list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat, "__allBlocks": true}, func(_, data map[string]any) bool { return blockContentEquals(data, blockID, content, rt.Str("doc-format")) }) case "block_delete": blockID := rt.Str("block-id") return executeVerifiedDocMutation(rt, "doc.update", "delete_document_block", map[string]any{"nodeId": node, "blockId": blockID}, node, - "list_document_blocks", map[string]any{"nodeId": node, "format": "element"}, + "list_document_blocks", map[string]any{"nodeId": node, "format": "element", "__allBlocks": true}, func(_, data map[string]any) bool { return findBlock(data, blockID) == nil }) case "str_replace": return executePlainTextReplace(rt, node) @@ -609,7 +635,7 @@ func nestedRevision(value any) (int, bool) { } func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error { - data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"}) + data, err := readAllDocumentBlocks(rt, map[string]any{"nodeId": nodeID, "format": "element"}) if err != nil { return err } @@ -643,12 +669,12 @@ func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error { blockID := matches[0].blockID return executeVerifiedDocMutation(rt, "doc.update", "update_document_block", map[string]any{"nodeId": nodeID, "blockId": blockID, "element": map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": updated}}}, nodeID, - "list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": blockID, "format": "element"}, + "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element", "__allBlocks": true}, func(_, data map[string]any) bool { return blockContentEquals(data, blockID, updated, "markdown") }) } 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"}) + data, err := readAllDocumentBlocks(rt, map[string]any{"nodeId": nodeID, "format": "element"}) if err != nil { return err } @@ -664,7 +690,7 @@ func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error { referenceBlockID := rt.Str("after-block-id") return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block", map[string]any{"nodeId": nodeID, "referenceBlockId": referenceBlockID, "where": "after", "element": block}, nodeID, - "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"}, + "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element", "__allBlocks": true}, func(result, data map[string]any) bool { return verifyInsertedCanonicalBlock(result, data, referenceBlockID, expectedContent, "markdown") }) @@ -684,7 +710,9 @@ func executeVerifiedDocMutation( return docUnknownWriteError(operation, tool, nodeID, err) } steps[0]["status"] = "success" - verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams) + verification, err := readDocVerification(rt, verifyTool, verifyParams, func(data map[string]any) bool { + return verify == nil || verify(result, data) + }) if err != nil { return docVerificationError(operation, "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"})) } @@ -732,7 +760,9 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams } steps = append(steps, map[string]any{"name": stepName, "status": "success"}) } - verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": nodeID, "format": format}) + verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": nodeID, "format": format}, func(data map[string]any) bool { + return verifyUpdatedDocumentContent(data, content, mode, format) + }) if err != nil { return docVerificationError("doc.update", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"})) } @@ -745,6 +775,180 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams }, steps...)) } +func readDocVerification(rt *shortcut.RuntimeContext, tool string, rawParams map[string]any, verify func(map[string]any) bool) (map[string]any, error) { + params := cloneMap(rawParams) + allBlocks, _ := params["__allBlocks"].(bool) + delete(params, "__allBlocks") + var last map[string]any + var lastErr error + for attempt := 0; attempt <= len(docVerifyDelays); attempt++ { + var data map[string]any + var err error + if allBlocks && tool == "list_document_blocks" { + data, err = readAllDocumentBlocks(rt, params) + } else { + data, err = rt.CallMCPData(productDoc, tool, params) + } + if err != nil { + lastErr = err + } else { + last = data + lastErr = nil + if verify == nil || verify(data) { + return data, nil + } + } + if attempt < len(docVerifyDelays) { + if err := docVerifyWait(rt.Command().Context(), docVerifyDelays[attempt]); err != nil { + return nil, err + } + } + } + if lastErr != nil { + return nil, lastErr + } + return last, nil +} + +func waitForDocVerification(ctx context.Context, delay time.Duration) error { + if ctx == nil { + ctx = context.Background() + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func readAllDocumentBlocks(rt *shortcut.RuntimeContext, base map[string]any) (map[string]any, error) { + all := make([]any, 0, docBlockReadPageSize) + seenPageIdentities := map[string]bool{} + for start := 0; start < docBlockReadMaxItems; start += docBlockReadPageSize { + params := cloneMap(base) + params["startIndex"] = start + params["endIndex"] = start + docBlockReadPageSize - 1 + page, err := rt.CallMCPData(productDoc, "list_document_blocks", params) + if err != nil { + return nil, err + } + blocks, ok := documentBlockEntries(page) + if !ok { + return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组") + } + pageIdentity := documentBlockPageIdentity(blocks) + if pageIdentity != "" && seenPageIdentities[pageIdentity] { + return nil, fmt.Errorf("list_document_blocks 分页停滞,无法证明回读完整") + } + if pageIdentity != "" { + seenPageIdentities[pageIdentity] = true + } + all = append(all, blocks...) + hasMore, known, _ := docPageState(page) + if known && !hasMore { + return map[string]any{"blocks": all, "hasMore": false, "totalCount": len(all)}, nil + } + if !known { + if total, ok := nestedNonNegativeInt(page, "totalCount", "total_count"); ok && len(all) >= total { + return map[string]any{"blocks": all, "hasMore": false, "totalCount": total}, nil + } + } + if !known && len(blocks) < docBlockReadPageSize { + return map[string]any{"blocks": all, "hasMore": false, "totalCount": len(all)}, nil + } + if len(blocks) == 0 { + return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空,无法证明回读完整") + } + } + return nil, fmt.Errorf("list_document_blocks 超过 %d 个块,无法在安全上限内完成回读", docBlockReadMaxItems) +} + +func documentBlockPageIdentity(blocks []any) string { + if len(blocks) == 0 { + return "" + } + ids := make([]string, 0, len(blocks)) + for _, value := range blocks { + id := "" + switch block := value.(type) { + case map[string]any: + id = blockIdentity(block, "") + if id == "" { + if element, ok := block["element"].(map[string]any); ok { + id = blockIdentity(element, "") + } + } + case []any: + id = jsonMLBlockIdentity(block) + } + if id == "" { + return "" + } + ids = append(ids, id) + } + encoded, _ := json.Marshal(ids) + return string(encoded) +} + +func documentBlockEntries(value any) ([]any, bool) { + switch typed := value.(type) { + case map[string]any: + for _, key := range []string{"blocks", "items"} { + if blocks, ok := typed[key].([]any); ok { + return blocks, true + } + } + if encoded, ok := typed["jsonml"].(string); ok { + var decoded any + if json.Unmarshal([]byte(encoded), &decoded) == nil { + blocks := orderedJSONMLBlocks(decoded) + values := make([]any, len(blocks)) + for index := range blocks { + values[index] = blocks[index] + } + return values, true + } + } + for _, key := range []string{"result", "data"} { + if nested, ok := typed[key]; ok { + if blocks, found := documentBlockEntries(nested); found { + return blocks, true + } + } + } + } + return nil, false +} + +func nestedNonNegativeInt(value any, keys ...string) (int, bool) { + switch typed := value.(type) { + case map[string]any: + for _, key := range keys { + if raw, ok := typed[key]; ok { + switch number := raw.(type) { + case float64: + if number >= 0 && number == float64(int(number)) { + return int(number), true + } + case int: + if number >= 0 { + return number, true + } + } + } + } + for _, key := range []string{"result", "data"} { + if result, ok := nestedNonNegativeInt(typed[key], keys...); ok { + return result, true + } + } + } + return 0, false +} + func splitDocMarkdown(content string, maxRunes int) []string { if maxRunes <= 0 { return []string{content} @@ -796,11 +1000,17 @@ func containsText(value any, needle string) bool { } func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool { - expected = normalizeDocumentContentForVerification(expected, format) + expectedRaw := expected + expected = normalizeDocumentContentForVerification(expectedRaw, format) for _, candidate := range documentContentCandidates(value, format) { - actual := normalizeDocumentContentForVerification(candidate, format) + actualRaw := candidate + actual := normalizeDocumentContentForVerification(actualRaw, format) if mode == "overwrite" { - if actual == expected { + if actual == expected || (format == "markdown" && stripReadbackDocumentTitle(actual) == expected) { + return true + } + if format == "markdown" && (markdownSemanticallyEquivalent(actualRaw, expectedRaw) || + markdownSemanticallyEquivalent(stripReadbackDocumentTitle(actualRaw), expectedRaw)) { return true } continue @@ -808,10 +1018,48 @@ func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool if actual == expected || strings.HasSuffix(actual, "\n"+expected) { return true } + if format == "markdown" && markdownSemanticallyEndsWith(actualRaw, expectedRaw) { + return true + } } return false } +func markdownSemanticallyEquivalent(left, right string) bool { + leftFingerprint, leftOK := markdownSemanticFingerprint(left) + rightFingerprint, rightOK := markdownSemanticFingerprint(right) + return leftOK && rightOK && leftFingerprint == rightFingerprint +} + +func markdownSemanticallyEndsWith(content, suffix string) bool { + contentFingerprint, contentOK := markdownSemanticFingerprint(content) + suffixFingerprint, suffixOK := markdownSemanticFingerprint(suffix) + return contentOK && suffixOK && strings.HasSuffix(contentFingerprint, suffixFingerprint) +} + +func markdownSemanticFingerprint(source string) (string, bool) { + if len(source) > docMarkdownVerifyMax { + return "", false + } + var rendered bytes.Buffer + if err := docMarkdownConvert([]byte(source), &rendered); err != nil { + return "", false + } + return rendered.String(), true +} + +func stripReadbackDocumentTitle(content string) string { + lines := strings.Split(content, "\n") + if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "# ") { + return content + } + lines = lines[1:] + for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { + lines = lines[1:] + } + return strings.Join(lines, "\n") +} + func verifyInsertedBlock(result, data map[string]any, referenceBlockID, expected, format string) bool { return verifyInsertedCanonicalBlock(result, data, referenceBlockID, normalizeDocumentContentForVerification(expected, format), format) } @@ -848,6 +1096,11 @@ func blockContentEquals(data map[string]any, blockID, expected, format string) b } func canonicalBlockContent(value any, format string) string { + if values, ok := value.(map[string]any); ok { + if element, ok := values["element"].(map[string]any); ok { + value = element + } + } if format == "jsonml" { if values, ok := value.(map[string]any); ok { if encoded, ok := values["jsonml"].(string); ok { @@ -871,7 +1124,7 @@ func canonicalBlockContent(value any, format string) string { return } for key, child := range typed { - if key == "id" || key == "blockId" || key == "uuid" { + if key == "id" || key == "blockId" || key == "uuid" || key == "blockType" { continue } walk(child) @@ -1005,6 +1258,10 @@ func orderedDocumentBlocks(value any) []map[string]any { walk = func(current any) { switch typed := current.(type) { case map[string]any: + if element, ok := typed["element"].(map[string]any); ok && blockIdentity(element, "") != "" { + blocks = append(blocks, element) + return + } if blockIdentity(typed, "") != "" { blocks = append(blocks, typed) return @@ -1065,9 +1322,23 @@ func normalizeDocumentContentForVerification(raw, format string) string { func normalizeMarkdownForVerification(raw string) string { raw = strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n") lines := make([]string, 0, strings.Count(raw, "\n")+1) + inFence := false for _, line := range strings.Split(raw, "\n") { - line = strings.TrimSpace(line) + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { + inFence = !inFence + lines = append(lines, trimmed) + continue + } + if inFence { + lines = append(lines, strings.TrimRight(line, " \t")) + continue + } + line = trimmed if line == "" { + if len(lines) > 0 && lines[len(lines)-1] != "" { + lines = append(lines, "") + } continue } if strings.Contains(line, "|") { @@ -1081,6 +1352,9 @@ func normalizeMarkdownForVerification(raw string) string { } lines = append(lines, line) } + for len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } return strings.Join(lines, "\n") } @@ -1089,33 +1363,108 @@ func normalizeJSONMLForVerification(raw string) string { if err := json.Unmarshal([]byte(raw), &value); err != nil { return normalizeMarkdownForVerification(raw) } - var tokens []string - var walk func(any) - walk = func(current any) { + var normalize func(any) any + normalize = func(current any) any { switch typed := current.(type) { case []any: - start := 0 - if len(typed) > 0 { - if tag, ok := typed[0].(string); ok { - tokens = append(tokens, "<"+strings.ToLower(strings.TrimSpace(tag))+">") - start = 1 + if len(typed) == 0 { + return []any{} + } + tag, isElement := typed[0].(string) + if !isElement { + out := make([]any, 0, len(typed)) + for _, child := range typed { + out = append(out, normalize(child)) + } + return out + } + start := 1 + attrs := map[string]any{} + if len(typed) > 1 { + if declared, ok := typed[1].(map[string]any); ok { + attrs, _ = normalize(declared).(map[string]any) + attrs = removeGeneratedJSONMLDefaults(tag, attrs) + start = 2 } } + children := make([]any, 0, len(typed)-start) for _, child := range typed[start:] { - walk(child) + normalized := normalize(child) + if normalized != nil { + children = append(children, normalized) + } } + if strings.EqualFold(tag, "span") && isGeneratedTextSpan(attrs) { + if len(children) == 1 { + return children[0] + } + return children + } + out := []any{strings.ToLower(tag), attrs} + out = append(out, children...) + return out case map[string]any: - // JSONML maps contain element attributes. Server-generated UUIDs and - // default attributes do not change the authored document content. - return - case string: - if text := normalizeMarkdownForVerification(typed); text != "" { - tokens = append(tokens, text) + out := make(map[string]any, len(typed)) + for key, child := range typed { + normalizedKey := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key)) + if normalizedKey == "uuid" || normalizedKey == "blockid" || normalizedKey == "elementid" || normalizedKey == "index" { + continue + } + out[normalizedKey] = normalize(child) } + return out + case string: + return strings.ReplaceAll(strings.ReplaceAll(typed, "\r\n", "\n"), "\r", "\n") } + return current } - walk(value) - return strings.Join(tokens, "\n") + // normalize only receives values decoded by encoding/json, so the resulting + // tree is always JSON-marshalable. + encoded, _ := json.Marshal(normalize(value)) + return string(encoded) +} + +var generatedJSONMLAttributeDefaults = map[string]map[string]any{ + "hr": { + "sz": float64(1), + }, + "tc": { + "colspan": float64(1), "rowspan": float64(1), "valign": "middle", + }, + "code": { + "code": "", "syntax": "plaintext", "theme": "default", + "wrap": true, "showlinenumber": true, "fold": false, + }, +} + +// removeGeneratedJSONMLDefaults drops only defaults declared by the reviewed +// JSONML schema, plus empty server style objects. Other attributes remain part +// of the semantic fingerprint so links, formatting, and table layout stay +// strict. +func removeGeneratedJSONMLDefaults(tag string, attrs map[string]any) map[string]any { + defaults := generatedJSONMLAttributeDefaults[strings.ToLower(tag)] + out := make(map[string]any, len(attrs)) + for key, value := range attrs { + if object, ok := value.(map[string]any); ok && len(object) == 0 { + continue + } + if defaultValue, ok := defaults[key]; ok && reflect.DeepEqual(value, defaultValue) { + continue + } + out[key] = value + } + return out +} + +func isGeneratedTextSpan(attrs map[string]any) bool { + if len(attrs) == 0 { + return true + } + if len(attrs) != 1 { + return false + } + value, ok := attrs["datatype"].(string) + return ok && (value == "text" || value == "leaf") } func executeExport(rt *shortcut.RuntimeContext) error { diff --git a/internal/shortcut/doc/doc_readback_verification_test.go b/internal/shortcut/doc/doc_readback_verification_test.go new file mode 100644 index 00000000..63be4455 --- /dev/null +++ b/internal/shortcut/doc/doc_readback_verification_test.go @@ -0,0 +1,401 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package doc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "testing" + "time" + + "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 TestCrossPlatformCoverageDocReadbackRetriesStaleContent(t *testing.T) { + testseam.Swap(t, &docVerifyWait, func(context.Context, time.Duration) error { return nil }) + testseam.Swap(t, &docVerifyDelays, []time.Duration{time.Millisecond}) + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "get_document_content": {{"markdown": "old"}, {"markdown": "old\nnew"}}, + }} + if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "append", "--content", "new", "--yes"); err != nil { + t.Fatal(err) + } + reads := 0 + for _, call := range caller.history { + if call.tool == "get_document_content" { + reads++ + } + } + if reads != 2 { + t.Fatalf("readback calls = %d, want 2; history=%#v", reads, caller.history) + } +} + +func TestCrossPlatformCoverageDocReadbackStopsOnCancellation(t *testing.T) { + if err := waitForDocVerification(nil, time.Nanosecond); err != nil { + t.Fatalf("completed verification wait = %v", err) + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if err := waitForDocVerification(cancelled, time.Hour); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled verification wait = %v, want context.Canceled", err) + } + + cmd := &cobra.Command{Use: "verify"} + cmd.SetContext(cancelled) + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "get_document_content": {{"markdown": "stale"}}, + }} + helpers.InitDeps(caller) + rt := shortcut.RuntimeContextForTest(cmd, Update) + _, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": "n"}, func(map[string]any) bool { return false }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled document verification = %v, want context.Canceled", err) + } + if len(caller.history) != 1 { + t.Fatalf("cancelled document verification calls = %d, want 1", len(caller.history)) + } +} + +func TestCrossPlatformCoverageDocDeleteReadbackConsumesEveryPage(t *testing.T) { + testseam.Swap(t, &docVerifyWait, func(context.Context, time.Duration) error { return nil }) + testseam.Swap(t, &docVerifyDelays, []time.Duration{time.Millisecond}) + firstPage := make([]any, 50) + for index := range firstPage { + firstPage[index] = map[string]any{"id": fmt.Sprintf("block-%d", index), "text": "body"} + } + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "list_document_blocks": { + {"blocks": firstPage, "hasMore": true, "totalCount": 51}, + {"blocks": []any{map[string]any{"id": "target", "text": "stale"}}, "hasMore": false, "totalCount": 51}, + {"blocks": firstPage, "hasMore": false, "totalCount": 50}, + }, + }} + if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil { + t.Fatal(err) + } + starts := []int{} + for _, call := range caller.history { + if call.tool == "list_document_blocks" { + starts = append(starts, call.params["startIndex"].(int)) + } + } + if fmt.Sprint(starts) != "[0 50 0]" { + t.Fatalf("pagination starts = %v, want [0 50 0]", starts) + } +} + +func TestCrossPlatformCoverageDocReplacePreflightIsGloballyUnique(t *testing.T) { + firstPage := make([]any, 50) + for index := range firstPage { + text := "body" + if index == 0 { + text = "unique needle" + } + firstPage[index] = map[string]any{"id": fmt.Sprintf("block-%d", index), "text": text} + } + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "list_document_blocks": { + {"blocks": firstPage, "hasMore": true, "totalCount": 51}, + {"blocks": []any{map[string]any{"id": "block-50", "text": "another needle"}}, "hasMore": false, "totalCount": 51}, + }, + }} + err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "str_replace", "--old", "needle", "--new", "changed", "--yes") + if err == nil { + t.Fatal("str_replace accepted a second match on a later page") + } + for _, call := range caller.history { + if call.tool == "update_document_block" { + t.Fatalf("ambiguous replace executed a write: %#v", caller.history) + } + } +} + +func TestCrossPlatformCoverageDocVerificationPreservesMeaning(t *testing.T) { + expected := "[\"p\",{},\"text\"]" + serverExpanded := "[\"p\",{\"uuid\":\"generated\",\"style\":{}},[\"span\",{\"data-type\":\"text\"},[\"span\",{\"data-type\":\"leaf\"},\"text\"]]]" + if normalizeJSONMLForVerification(expected) != normalizeJSONMLForVerification(serverExpanded) { + t.Fatal("generated JSONML text wrappers should not change document meaning") + } + defaultFree := `[["hr",{}],["code",{}],["table",{},["tr",{},["tc",{},["p",{},"cell"]]]]]` + serverDefaulted := `[["hr",{"sz":1}],["code",{"code":"","syntax":"plaintext","theme":"default","wrap":true,"showLineNumber":true,"fold":false}],["table",{},["tr",{},["tc",{"colSpan":1,"rowSpan":1,"vAlign":"middle"},["p",{},"cell"]]]]]` + if !verifyUpdatedDocumentContent(map[string]any{"jsonml": serverDefaulted}, defaultFree, "overwrite", "jsonml") { + t.Fatal("server-generated JSONML schema defaults should not fail readback verification") + } + linkA := "[\"a\",{\"href\":\"https://example.com/a\"},\"text\"]" + linkB := "[\"a\",{\"href\":\"https://example.com/b\"},\"text\"]" + if normalizeJSONMLForVerification(linkA) == normalizeJSONMLForVerification(linkB) { + t.Fatal("semantic JSONML attributes were ignored") + } + tableA := `[["table",{"jc":"center"},["tr",{},["tc",{},["p",{},"cell"]]]]]` + tableB := `[["table",{"jc":"right"},["tr",{},["tc",{},["p",{},"cell"]]]]]` + if normalizeJSONMLForVerification(tableA) == normalizeJSONMLForVerification(tableB) { + t.Fatal("semantic JSONML table alignment was ignored") + } + codeA := "~~~go\n return nil\n~~~" + codeB := "~~~go\nreturn nil\n~~~" + if normalizeMarkdownForVerification(codeA) == normalizeMarkdownForVerification(codeB) { + t.Fatal("fenced code indentation was ignored") + } + if !verifyUpdatedDocumentContent(map[string]any{"markdown": "# Server title\n\nbody"}, "body", "overwrite", "markdown") { + t.Fatal("server-generated document title prevented body verification") + } +} + +func TestCrossPlatformCoverageMarkdownSemanticRoundTrip(t *testing.T) { + input := strings.Join([]string{ + "sales_data.xlsx", + "### 1.", + "+10.22%", + "| name | value |", + "| -------- | -------- |", + "| sales_data.xlsx | +10.22% |", + }, "\n") + server := strings.Join([]string{ + `sales\_data.xlsx`, + `### 1\.`, + `\+10.22%`, + "|name|value|", + "|---|---|", + `|sales\_data.xlsx|\+10.22%|`, + }, "\n") + if !markdownSemanticallyEquivalent(input, server) { + t.Fatal("server Markdown escaping and table delimiter normalization changed the semantic fingerprint") + } + if !verifyUpdatedDocumentContent(map[string]any{"markdown": server}, input, "overwrite", "markdown") { + t.Fatal("equivalent server Markdown failed overwrite verification") + } + if !verifyUpdatedDocumentContent(map[string]any{"markdown": "existing\n\n" + server}, input, "append", "markdown") { + t.Fatal("equivalent server Markdown failed append verification") + } +} + +func TestCrossPlatformCoverageMarkdownSemanticDifferencesRemainStrict(t *testing.T) { + for _, test := range []struct { + name string + left string + right string + }{ + {name: "emphasis", left: `*important*`, right: `\*important\*`}, + {name: "inline code", left: "`sales_data`", right: "`sales\\_data`"}, + {name: "fenced code", left: "```\nsales_data\n```", right: "```\nsales\\_data\n```"}, + {name: "table alignment", left: "|a|\n|---|\n|x|", right: "|a|\n|:---|\n|x|"}, + {name: "table columns", left: "|a|b|\n|---|---|\n|x|y|", right: "|a|\n|---|\n|x|"}, + {name: "table content", left: "|a|\n|---|\n|x|", right: "|a|\n|---|\n|y|"}, + } { + t.Run(test.name, func(t *testing.T) { + if markdownSemanticallyEquivalent(test.left, test.right) { + t.Fatal("meaningful Markdown difference was ignored") + } + }) + } + oversized := strings.Repeat("x", docMarkdownVerifyMax+1) + if _, ok := markdownSemanticFingerprint(oversized); ok { + t.Fatal("oversized Markdown entered semantic verification") + } + testseam.Swap(t, &docMarkdownConvert, func([]byte, io.Writer) error { return errors.New("render") }) + if _, ok := markdownSemanticFingerprint("body"); ok { + t.Fatal("failed Markdown render produced a semantic fingerprint") + } +} + +func TestCrossPlatformCoverageDocElementReadbackUsesNestedElement(t *testing.T) { + wrapper := map[string]any{ + "blockType": "paragraph", + "element": map[string]any{"id": "inserted", "blockType": "paragraph", "paragraph": map[string]any{"text": "body"}}, + } + if got := canonicalBlockContent(wrapper, "markdown"); got != "body" { + t.Fatalf("nested element content = %q, want body", got) + } + blocks := orderedDocumentBlocks(map[string]any{"blocks": []any{wrapper}}) + if len(blocks) != 1 || blockIdentity(blocks[0], "") != "inserted" { + t.Fatalf("nested element blocks = %#v", blocks) + } +} + +func TestCrossPlatformCoverageVersionRevertRequiresTargetEvidence(t *testing.T) { + if revertResultMatchesVersion(map[string]any{"ok": true}, 3) || currentDocumentMatchesRestoredVersion(map[string]any{"version": 99}, 3) { + t.Fatal("readability or an unrelated current version must not prove a revert") + } + if revertResultMatchesVersion(map[string]any{"version": 3}, 3) { + t.Fatal("the request version parameter must not prove its own revert") + } + if revertResultMatchesVersion(map[string]any{"data": map[string]any{"request": map[string]any{"targetVersion": 3}}}, 3) { + t.Fatal("request echo containers must not provide version evidence") + } + for _, failed := range []map[string]any{ + {"data": map[string]any{"success": "false", "revertedToVersion": 3}}, + {"data": map[string]any{"ok": false, "revertedToVersion": 3}}, + {"data": map[string]any{"status": "FAILED", "revertedToVersion": 3}}, + {"data": map[string]any{"state": "failure", "revertedToVersion": 3}}, + {"data": []any{map[string]any{"error_code": "REVERT_FAILED", "revertedToVersion": 3}}}, + {"data": map[string]any{"errorCode": 500.0, "revertedToVersion": 3}}, + {"data": map[string]any{"code": json.Number("500"), "revertedToVersion": 3}}, + } { + if revertResultMatchesVersion(failed, 3) { + t.Fatalf("explicit failure %#v must override target-version evidence", failed) + } + } + for _, succeeded := range []map[string]any{ + {"revertedToVersion": 3}, + {"status": "SUCCESS", "revertedToVersion": 3}, + {"state": "succeeded", "revertedToVersion": 3}, + {"errorCode": "0", "revertedToVersion": 3}, + {"code": 200, "revertedToVersion": 3}, + {"code": "204", "revertedToVersion": 3}, + } { + if !revertResultMatchesVersion(succeeded, 3) { + t.Fatalf("explicit success %#v suppressed target-version evidence", succeeded) + } + } + for _, absentOrSuccess := range []any{nil, "", "OK", "SUCCESS", json.Number("0"), json.Number("200"), 0.0, 201.0, 0, 202} { + if revertErrorCodeIsFailure(absentOrSuccess) { + t.Fatalf("success code %#v was treated as an explicit failure", absentOrSuccess) + } + } + for _, failedCode := range []any{json.Number("bad"), json.Number("500"), 1.0, 500.0, 1, 500} { + if !revertErrorCodeIsFailure(failedCode) { + t.Fatalf("failure code %#v was not treated as an explicit failure", failedCode) + } + } + if revertStatusIsFailure(500) || revertStatusIsFailure("PROCESSING") { + t.Fatal("non-failure status was treated as an explicit failure") + } +} + +func TestCrossPlatformCoverageDocReadbackDefensiveEdges(t *testing.T) { + testseam.Swap(t, &docVerifyWait, func(context.Context, time.Duration) error { return nil }) + for _, tc := range []struct { + name string + responses []map[string]any + failAt int + }{ + {"call failure", nil, 1}, + {"missing blocks", []map[string]any{{"ok": true}}, 0}, + {"stalled page", []map[string]any{{"blocks": []any{map[string]any{"id": "a"}}, "hasMore": true}, {"blocks": []any{map[string]any{"id": "a"}}, "hasMore": true}}, 0}, + {"empty continued page", []map[string]any{{"blocks": []any{}, "hasMore": true}}, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + testseam.Swap(t, &docVerifyDelays, []time.Duration{}) + caller := &docCoverageCaller{failAt: tc.failAt, responses: map[string][]map[string]any{"list_document_blocks": tc.responses}} + err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes") + if err == nil { + t.Fatal("defensive readback unexpectedly succeeded") + } + }) + } + + t.Run("identical adjacent pages advance by requested indexes", func(t *testing.T) { + testseam.Swap(t, &docVerifyDelays, []time.Duration{}) + blocks := make([]any, docBlockReadPageSize) + for index := range blocks { + blocks[index] = map[string]any{"blockType": "paragraph"} + } + caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": { + {"blocks": blocks, "hasMore": true, "totalCount": 2 * docBlockReadPageSize}, + {"blocks": blocks, "hasMore": false, "totalCount": 2 * docBlockReadPageSize}, + }}} + if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil { + t.Fatal(err) + } + starts := []int{} + for _, call := range caller.history { + if call.tool == "list_document_blocks" { + starts = append(starts, call.params["startIndex"].(int)) + } + } + if fmt.Sprint(starts) != "[0 50]" { + t.Fatalf("pagination starts = %v, want [0 50]", starts) + } + }) + + t.Run("total count terminates pagination", func(t *testing.T) { + testseam.Swap(t, &docVerifyDelays, []time.Duration{}) + caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"blocks": []any{map[string]any{"id": "other"}}, "totalCount": 1}}}} + if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil { + t.Fatal(err) + } + }) + + t.Run("explicit has more overrides inconsistent total count", func(t *testing.T) { + testseam.Swap(t, &docVerifyDelays, []time.Duration{}) + caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": { + {"blocks": []any{map[string]any{"id": "first"}}, "hasMore": true, "totalCount": 1}, + {"blocks": []any{map[string]any{"id": "second"}}, "hasMore": false, "totalCount": 2}, + }}} + if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil { + t.Fatal(err) + } + reads := 0 + for _, call := range caller.history { + if call.tool == "list_document_blocks" { + reads++ + } + } + if reads != 2 { + t.Fatalf("read calls = %d, want both explicitly advertised pages", reads) + } + }) + + t.Run("block read safety limit", func(t *testing.T) { + testseam.Swap(t, &docVerifyDelays, []time.Duration{}) + pages := make([]map[string]any, docBlockReadMaxItems/docBlockReadPageSize) + for index := range pages { + pages[index] = map[string]any{"blocks": []any{map[string]any{"id": fmt.Sprintf("block-%d", index)}}, "hasMore": true} + } + caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": pages}} + if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err == nil { + t.Fatal("oversized block read returned nil") + } + }) + + if blocks, ok := documentBlockEntries(map[string]any{"jsonml": `["root",{},["p",{"uuid":"a"},"x"]]`}); !ok || len(blocks) == 0 { + t.Fatalf("jsonml blocks=%#v ok=%v", blocks, ok) + } + if _, ok := documentBlockEntries(map[string]any{"jsonml": `{`}); ok { + t.Fatal("invalid jsonml produced blocks") + } + if blocks, ok := documentBlockEntries(map[string]any{"data": map[string]any{"items": []any{"x"}}}); !ok || len(blocks) != 1 { + t.Fatalf("nested items=%#v ok=%v", blocks, ok) + } + if _, ok := documentBlockEntries(nil); ok { + t.Fatal("nil produced blocks") + } + + for _, tc := range []struct { + value any + want bool + }{ + {map[string]any{"totalCount": float64(2)}, true}, + {map[string]any{"totalCount": float64(-1)}, false}, + {map[string]any{"totalCount": 2.5}, false}, + {map[string]any{"data": map[string]any{"total_count": 2}}, true}, + {map[string]any{"totalCount": -1}, false}, + {nil, false}, + } { + _, ok := nestedNonNegativeInt(tc.value, "totalCount", "total_count") + if ok != tc.want { + t.Fatalf("nestedNonNegativeInt(%#v) ok=%v want=%v", tc.value, ok, tc.want) + } + } + + for _, raw := range []string{ + `[]`, `[1,["p",{},"x"]]`, `["span",{},"a","b"]`, + `["p",{"block_id":"x","custom":true},"x"]`, `true`, + } { + if normalizeJSONMLForVerification(raw) == "" { + t.Fatalf("empty normalized JSONML for %s", raw) + } + } + if !isGeneratedTextSpan(nil) || isGeneratedTextSpan(map[string]any{"a": 1, "b": 2}) || isGeneratedTextSpan(map[string]any{"data-type": 3}) || isGeneratedTextSpan(map[string]any{"data-type": "other"}) { + t.Fatal("generated span classification failed") + } +} diff --git a/internal/shortcut/doc/doc_shortcuts_test.go b/internal/shortcut/doc/doc_shortcuts_test.go index 0ef06fab..8b3da279 100644 --- a/internal/shortcut/doc/doc_shortcuts_test.go +++ b/internal/shortcut/doc/doc_shortcuts_test.go @@ -15,6 +15,7 @@ import ( "strings" "sync" "testing" + "time" "unicode/utf8" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -57,7 +58,7 @@ func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params m } value := docCoveragePayload(tool) if tool == "revert_doc_version" { - value = map[string]any{"version": params["version"]} + value = map[string]any{"revertedToVersion": params["version"]} } if queue := f.responses[tool]; len(queue) > 0 { value = queue[0] @@ -112,6 +113,7 @@ func runDocCoverageInput(t *testing.T, declaration shortcut.Shortcut, caller *do func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, commandPath string, args ...string) error { t.Helper() + testseam.Swap(t, &docVerifyWait, func(context.Context, time.Duration) error { return nil }) helpers.InitDeps(caller) root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} root.PersistentFlags().Bool("yes", false, "") @@ -491,6 +493,7 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T) } func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification(t *testing.T) { + testseam.Swap(t, &docVerifyDelays, []time.Duration{}) unknown := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}} err := runDocCoverage(t, Update, unknown, "--node", "n", "--command", "append", "--content", "x", "--yes") var typed *apperrors.Error @@ -512,6 +515,7 @@ func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification } func TestCrossPlatformCoverageDocCreateRejectsSuccessfulMismatchedReadback(t *testing.T) { + testseam.Swap(t, &docVerifyDelays, []time.Duration{}) caller := &docCoverageCaller{responses: map[string][]map[string]any{ "get_document_content": {{"markdown": "truncated"}}, }} @@ -577,11 +581,59 @@ func TestCrossPlatformCoverageDocVersionRevertPaginationAndVerification(t *testi "revert_doc_version": {{}}, "get_document_info": {{"nodeId": "n", "revision": 99.0}}, }} - if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil { - t.Fatal(err) + err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes") + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "doc_history_revert_target_unproven" || typed.FailureStage != "verify" || typed.Details["status"] != "partial_success" { + t.Fatalf("unproven revert error = %#v", err) + } + data, _ := typed.Details["data"].(map[string]any) + steps, _ := typed.Details["steps"].([]map[string]any) + if data["verified"] != false || len(steps) != 3 || steps[2]["status"] != "failed" { + t.Fatalf("unproven revert details = %#v", typed.Details) } }) + for _, test := range []struct { + name string + response map[string]any + current map[string]any + wantOK bool + }{ + {name: "explicit server evidence", response: map[string]any{"data": map[string]any{"revertResult": map[string]any{"revertedToVersion": 3}}}, wantOK: true}, + {name: "bare request parameter echo", response: map[string]any{"version": 3}}, + {name: "nested request parameter echo", response: map[string]any{"data": map[string]any{"request": map[string]any{"nodeId": "n", "version": 3}}}}, + {name: "accepted field inside request echo", response: map[string]any{"data": map[string]any{"request": map[string]any{"targetVersion": 3}}}}, + {name: "nested business failure with request echo", response: map[string]any{"data": map[string]any{"success": false, "errorCode": "REVERT_FAILED", "request": map[string]any{"version": 3}}}}, + {name: "failed status overrides target evidence", response: map[string]any{"data": map[string]any{"status": "FAILED", "revertResult": map[string]any{"revertedToVersion": 3}}}}, + {name: "failed code overrides target evidence", response: map[string]any{"data": map[string]any{"code": "REVERT_FAILED", "revertResult": map[string]any{"revertedToVersion": 3}}}}, + { + name: "nested business failure overrides all evidence", + response: map[string]any{"data": map[string]any{"state": "FAILURE", "revertResult": map[string]any{"revertedToVersion": 3}}}, + current: map[string]any{"targetVersion": 3}, + }, + } { + t.Run(test.name, func(t *testing.T) { + responses := map[string][]map[string]any{ + "revert_doc_version": {test.response}, + } + if test.current != nil { + responses["get_document_info"] = []map[string]any{test.current} + } + caller := &docCoverageCaller{responses: responses} + err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes") + if test.wantOK { + if err != nil { + t.Fatal(err) + } + return + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "doc_history_revert_target_unproven" || typed.Details["status"] != "partial_success" { + t.Fatalf("request echo response %#v produced %#v", test.response, err) + } + }) + } + for _, test := range []struct { name string responses []map[string]any diff --git a/internal/shortcut/doc/history_template_shortcuts.go b/internal/shortcut/doc/history_template_shortcuts.go index ea9ac94d..10a88eb7 100644 --- a/internal/shortcut/doc/history_template_shortcuts.go +++ b/internal/shortcut/doc/history_template_shortcuts.go @@ -178,15 +178,51 @@ func executeHistoryRevert(rt *shortcut.RuntimeContext) error { map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"}, ) } + verified := !revertResponseHasExplicitFailure(reverted) && + (revertResultMatchesVersion(reverted, target) || currentDocumentMatchesRestoredVersion(current, target)) + if !verified { + return docPartialWriteError( + "doc.history_revert", "doc_history_revert_target_unproven", "verify", + fmt.Sprintf("版本 %d 的回滚请求已执行且文档可读,但响应没有提供目标版本证据;不要直接重试回滚", target), + fmt.Errorf("回读缺少目标版本 %d 的明确证据", target), + map[string]any{ + "nodeId": nodeID, "version": target, "reverted": true, "verified": false, + "revertResult": reverted, "current": current, + }, + []map[string]any{ + {"name": "preflight", "status": "success"}, + {"name": "revert", "status": "success"}, + {"name": "verify", "status": "failed"}, + }, + map[string]any{"available": false, "reason": "the revert may have completed; inspect version history before any further revert"}, + ) + } return rt.Output(docEnvelope("doc.history_revert", map[string]any{ "version": target, "revertResult": reverted, "current": current, "verified": true, - "verification": "revert_acknowledged_and_document_readable", + "verification": "target_version_proven", }, map[string]any{"name": "preflight", "status": "success"}, map[string]any{"name": "revert", "status": "success"}, map[string]any{"name": "verify", "status": "success"})) } +func revertResultMatchesVersion(value map[string]any, target int) bool { + if revertResponseHasExplicitFailure(value) { + return false + } + return versionEvidenceMatches(value, target, map[string]bool{ + "targetversion": true, "appliedversion": true, + "restoredversion": true, "revertedversion": true, "revertedtoversion": true, "sourceversion": true, + }) +} + +func currentDocumentMatchesRestoredVersion(value map[string]any, target int) bool { + return versionEvidenceMatches(value, target, map[string]bool{ + "restoredfromversion": true, "revertedfromversion": true, + "sourceversion": true, "appliedversion": true, "targetversion": true, + }) +} + func findHistoryVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (bool, error) { const maxPages = 20 cursor := "" @@ -240,6 +276,9 @@ func versionEvidenceMatches(value any, target int, acceptedKeys map[string]bool) case map[string]any: for key, child := range typed { normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key)) + if versionEvidenceRequestEchoKeys[normalized] { + continue + } if acceptedKeys[normalized] { if versionNumberMatches(child, target) { return true @@ -259,6 +298,87 @@ func versionEvidenceMatches(value any, target int, acceptedKeys map[string]bool) return false } +var versionEvidenceRequestEchoKeys = map[string]bool{ + "args": true, "arguments": true, "input": true, "inputs": true, + "params": true, "parameters": true, "request": true, "requestbody": true, + "requestparams": true, "toolargs": true, "toolarguments": true, +} + +func revertResponseHasExplicitFailure(value any) bool { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key)) + if versionEvidenceRequestEchoKeys[normalized] { + continue + } + if normalized == "success" || normalized == "ok" { + if success, ok := child.(bool); ok && !success { + return true + } + if success, ok := child.(string); ok && strings.EqualFold(strings.TrimSpace(success), "false") { + return true + } + } + if (normalized == "status" || normalized == "state") && revertStatusIsFailure(child) { + return true + } + if normalized == "errorcode" || normalized == "code" { + if revertErrorCodeIsFailure(child) { + return true + } + } + if revertResponseHasExplicitFailure(child) { + return true + } + } + case []any: + for _, child := range typed { + if revertResponseHasExplicitFailure(child) { + return true + } + } + } + return false +} + +func revertStatusIsFailure(value any) bool { + status, ok := value.(string) + if !ok { + return false + } + switch strings.ToLower(strings.TrimSpace(status)) { + case "fail", "failed", "failure", "error", "errored", "reject", "rejected", "deny", "denied", "cancel", "cancelled", "canceled", "abort", "aborted": + return true + default: + return false + } +} + +func revertErrorCodeIsFailure(value any) bool { + switch typed := value.(type) { + case string: + normalized := strings.ToLower(strings.TrimSpace(typed)) + switch normalized { + case "", "ok", "success", "succeed", "succeeded": + return false + } + if parsed, err := strconv.Atoi(normalized); err == nil { + return parsed != 0 && (parsed < 200 || parsed >= 300) + } + return true + case float64: + return typed != 0 && (typed < 200 || typed >= 300) + case json.Number: + parsed, err := typed.Int64() + return err != nil || (parsed != 0 && (parsed < 200 || parsed >= 300)) + case int: + return typed != 0 && (typed < 200 || typed >= 300) + default: + return false + } +} + func versionNumberMatches(value any, target int) bool { switch number := value.(type) { case float64: diff --git a/internal/shortcut/minutes/alignment.go b/internal/shortcut/minutes/alignment.go index 21d35464..7a3d85f1 100644 --- a/internal/shortcut/minutes/alignment.go +++ b/internal/shortcut/minutes/alignment.go @@ -618,15 +618,11 @@ func completeMinutesUpload(rt *shortcut.RuntimeContext, sessionID string, timeou } message := strings.ToLower(err.Error()) retryable := strings.Contains(message, "still uploading") || strings.Contains(message, "wait and retry") || strings.Contains(message, "正在上传") - if !retryable || time.Now().Add(interval).After(deadline) { + if !retryable || minutesPollDeadlineReached(deadline, interval) { return nil, attempts, err } - timer := time.NewTimer(interval) - select { - case <-rt.Command().Context().Done(): - timer.Stop() - return nil, attempts, rt.Command().Context().Err() - case <-timer.C: + if err := waitMinutesInterval(rt, interval); err != nil { + return nil, attempts, err } } } diff --git a/internal/shortcut/minutes/workflows.go b/internal/shortcut/minutes/workflows.go index 68c11c8e..7ba980d3 100644 --- a/internal/shortcut/minutes/workflows.go +++ b/internal/shortcut/minutes/workflows.go @@ -4,6 +4,7 @@ package minutes import ( + "context" "encoding/json" "fmt" "os" @@ -372,7 +373,7 @@ func runMinutesMindmap(rt *shortcut.RuntimeContext, id string, timeout, interval payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "result": result, "recovery": map[string]any{"taskUuid": id, "nextAction": "inspect source transcript; do not assume an empty mind map"}} return payload, minutesCompositeError("minutes_mindmap_failed", "poll", payload) } - if time.Now().Add(interval).After(deadline) { + if minutesPollDeadlineReached(deadline, interval) { payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "recovery": map[string]any{"taskUuid": id, "nextAction": "dws minutes mind-graph status --id "}} return payload, minutesCompositeError("minutes_mindmap_timeout", "poll", payload) } @@ -422,7 +423,7 @@ func runMinutesSpeakerInsights(rt *shortcut.RuntimeContext, id string, timeout, payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids "}} return payload, callErr } - if time.Now().Add(interval).After(deadline) { + if minutesPollDeadlineReached(deadline, interval) { payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids "}} return payload, minutesCompositeError("minutes_speaker_insights_timeout", "poll", payload) } @@ -614,6 +615,17 @@ func executeMinutesShare(rt *shortcut.RuntimeContext) error { } func executeMinutesUnshare(rt *shortcut.RuntimeContext) error { + if !rt.DryRun() { + for _, id := range minutesIDs(rt) { + data, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id}) + if err != nil { + return fmt.Errorf("minutes unshare preflight for %s: %w", id, err) + } + if _, err := minutesdata.Basic(id, data); err != nil { + return fmt.Errorf("minutes unshare preflight for %s: %w", id, err) + } + } + } return executeMinutesPermissionLedger(rt, "unshare", "remove_member_permission", func(member string) map[string]any { return map[string]any{"uuids": minutesIDs(rt), "memberUids": []string{member}} }) @@ -631,7 +643,11 @@ func executeMinutesPermissionLedger(rt *shortcut.RuntimeContext, operation, tool for index, member := range members { data, err := rt.CallMCPWriteDataStrict("minutes", tool, params(member)) if err == nil { - err = minutesdata.RequireWriteAcknowledgement(operation, data) + if operation == "unshare" { + err = minutesdata.RequirePermissionMutationAcknowledgement(operation, minutesIDs(rt), []string{member}, data) + } else { + err = minutesdata.RequireWriteAcknowledgement(operation, data) + } } if err != nil { failures = append(failures, map[string]any{"memberUid": member, "error": err.Error()}) @@ -708,7 +724,7 @@ func waitMinutesArtifacts(rt *shortcut.RuntimeContext, id string, artifacts []st for { attempts++ bundle, failures := collectMinutesArtifactsOnce(rt, id, artifacts, pageLimit) - if len(failures) == 0 || time.Now().Add(interval).After(deadline) { + if len(failures) == 0 || minutesPollDeadlineReached(deadline, interval) { return bundle, failures, attempts } if err := waitMinutesInterval(rt, interval); err != nil { @@ -720,14 +736,22 @@ func waitMinutesArtifacts(rt *shortcut.RuntimeContext, id string, artifacts []st func waitMinutesInterval(rt *shortcut.RuntimeContext, interval time.Duration) error { timer := time.NewTimer(interval) defer timer.Stop() + commandContext := rt.Command().Context() + if commandContext == nil { + commandContext = context.Background() + } select { - case <-rt.Command().Context().Done(): - return rt.Command().Context().Err() + case <-commandContext.Done(): + return commandContext.Err() case <-timer.C: return nil } } +func minutesPollDeadlineReached(deadline time.Time, interval time.Duration) bool { + return !time.Now().Add(interval).Before(deadline) +} + func outputWorkflowResult(rt *shortcut.RuntimeContext, payload map[string]any, failed bool, reason, stage string) error { if err := rt.Output(payload); err != nil { return err diff --git a/internal/shortcut/minutes/workflows_coverage_test.go b/internal/shortcut/minutes/workflows_coverage_test.go index aa05eb6d..34c47c74 100644 --- a/internal/shortcut/minutes/workflows_coverage_test.go +++ b/internal/shortcut/minutes/workflows_coverage_test.go @@ -323,7 +323,10 @@ func TestCrossPlatformCoverageMinutesPermissionLedgerBranchesE2E(t *testing.T) { if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--dry-run"); err == nil { t.Fatal("unshare dry-run output failure accepted") } - unshare := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"success":true,"result":{}}`}}} + unshare := &minutesE2ECaller{responses: map[string][]string{ + "minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`}, + "minutes/remove_member_permission": {`{"success":true,"result":{"resultMap":{"u1":["m1"]}}}`}, + }} payload, _, err := runMinutesAlignmentCLI(t, unshare, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--yes") if err != nil || payload["complete"] != true || payload["succeeded"] != float64(1) { t.Fatalf("unshare payload=%#v err=%v", payload, err) @@ -337,11 +340,31 @@ func TestCrossPlatformCoverageMinutesPermissionLedgerBranchesE2E(t *testing.T) { if args["coverPermission"] != "true" || len(args["roleSubResourceIds"].([]string)) != 2 { t.Fatalf("share args=%#v", args) } - continueFailure := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"result":{}}`, `{"success":true,"result":{}}`}}} + continueFailure := &minutesE2ECaller{responses: map[string][]string{ + "minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`}, + "minutes/remove_member_permission": { + `{"result":{}}`, + `{"success":true,"result":{"resultMap":{"u1":["m2"]}}}`, + }, + }} payload, output, err := runMinutesAlignmentCLI(t, continueFailure, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1,m2", "--failure-policy", "continue", "--yes") if err == nil || output == "" || payload["failed"] != float64(1) || payload["succeeded"] != float64(1) { t.Fatalf("continue ledger payload=%#v err=%v", payload, err) } + + missing := &minutesE2ECaller{responses: map[string][]string{ + "minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`}, + }} + payload, output, err = runMinutesAlignmentCLI(t, missing, "minutes", "+unshare", "--id", "missing", "--member-uids", "m1", "--yes") + if err == nil || payload != nil || output != "" || missing.counts["minutes/remove_member_permission"] != 0 { + t.Fatalf("missing minutes reached unshare: payload=%#v output=%q err=%v calls=%#v", payload, output, err, missing.counts) + } + + preflightFailure := &minutesE2ECaller{failAt: map[string]int{"minutes/get_minutes_basic_info": 1}} + payload, output, err = runMinutesAlignmentCLI(t, preflightFailure, "minutes", "+unshare", "--id", "unavailable", "--member-uids", "m1", "--yes") + if err == nil || payload != nil || output != "" || preflightFailure.counts["minutes/remove_member_permission"] != 0 { + t.Fatalf("failed preflight reached unshare: payload=%#v output=%q err=%v calls=%#v", payload, output, err, preflightFailure.counts) + } } func TestCrossPlatformCoverageMinutesArtifactCollectorBranches(t *testing.T) { @@ -419,10 +442,9 @@ func TestCrossPlatformCoverageMinutesArtifactWaitAndOutput(t *testing.T) { t.Fatalf("cancel failures=%#v", failures) } cmd = &cobra.Command{Use: "wait"} - cmd.SetContext(context.Background()) rt = shortcut.RuntimeContextForTest(cmd, ExportPack) if err := waitMinutesInterval(rt, 0); err != nil { - t.Fatalf("timer wait: %v", err) + t.Fatalf("timer wait with unset command context: %v", err) } cmd.SetOut(minutesFailWriter{}) @@ -564,7 +586,7 @@ func TestCrossPlatformCoverageMinutesExportPackBranchesE2E(t *testing.T) { } func TestCrossPlatformCoverageMinutesExportPathAndJSONFaults(t *testing.T) { - if _, _, _, err := prepareExportTarget("../escape"); err == nil { + if _, _, _, err := prepareExportTarget(filepath.Join("..", "escape")); err == nil { t.Fatal("unsafe target accepted") } for _, test := range []struct { @@ -590,7 +612,7 @@ func TestCrossPlatformCoverageMinutesExportPathAndJSONFaults(t *testing.T) { } }}, {name: "escape", run: func(t *testing.T) { - testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "../escape", nil }) + testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return filepath.Join("..", "escape"), nil }) if _, _, _, err := prepareExportTarget("pack"); err == nil { t.Fatal("rel escape accepted") } @@ -602,7 +624,7 @@ func TestCrossPlatformCoverageMinutesExportPathAndJSONFaults(t *testing.T) { } }}, {name: "parent", run: func(t *testing.T) { - testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "parent/pack", nil }) + testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return filepath.Join("parent", "pack"), nil }) testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("parent") }) if _, _, _, err := prepareExportTarget("pack"); err == nil { t.Fatal("unsafe parent accepted") diff --git a/internal/shortcut/minutesdata/coverage_completion_test.go b/internal/shortcut/minutesdata/coverage_completion_test.go index e4d53737..b0bbcfbc 100644 --- a/internal/shortcut/minutesdata/coverage_completion_test.go +++ b/internal/shortcut/minutesdata/coverage_completion_test.go @@ -224,6 +224,33 @@ func TestCrossPlatformCoverageMinutesWorkflowCompletion(t *testing.T) { if err := RequireWriteAcknowledgement("write", map[string]any{"result": map[string]any{"updated": true}}); err != nil { t.Fatal(err) } + if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"202397"}, map[string]any{ + "success": true, + "result": map[string]any{"resultMap": map[string]any{"u1": []any{float64(202397)}}}, + }); err != nil { + t.Fatalf("valid permission acknowledgement rejected: %v", err) + } + for _, data := range []map[string]any{ + {"success": false, "errorMsg": "denied"}, + {"success": "yes"}, + {"success": true, "result": "bad"}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": map[string]any{"resultMap": map[string]any{}}}, + {"success": true, "result": map[string]any{"resultMap": map[string]any{"other": []any{"202397"}}}}, + {"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": "bad"}}}, + {"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": []any{nil}}}}, + {"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": []any{"other"}}}}, + } { + if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"202397"}, data); err == nil { + t.Fatalf("invalid permission acknowledgement accepted: %#v", data) + } + } + if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"202397", "other"}, map[string]any{ + "success": true, + "result": map[string]any{"resultMap": map[string]any{"u1": []any{"202397"}}}, + }); err == nil { + t.Fatal("incomplete member coverage accepted") + } for _, tc := range []struct { cmd, id string data map[string]any diff --git a/internal/shortcut/minutesdata/workflow.go b/internal/shortcut/minutesdata/workflow.go index 6cfd1347..4ffc9a83 100644 --- a/internal/shortcut/minutesdata/workflow.go +++ b/internal/shortcut/minutesdata/workflow.go @@ -30,6 +30,61 @@ func RequireWriteAcknowledgement(operation string, data map[string]any) error { return fmt.Errorf("minutes %s response has no explicit successful acknowledgement", operation) } +// RequirePermissionMutationAcknowledgement validates the target-level payload +// returned by add/remove_member_permission. The backend's top-level +// success=true is not sufficient: historically it also accompanied a +// resultMap that merely echoed nonexistent task UUIDs. +func RequirePermissionMutationAcknowledgement(operation string, taskUUIDs, memberUIDs []string, data map[string]any) error { + if err := validateEnvelope(data); err != nil { + return err + } + if success, ok := data["success"].(bool); !ok || !success { + return fmt.Errorf("minutes %s response has no explicit success=true", operation) + } + result, ok := data["result"].(map[string]any) + if !ok { + return fmt.Errorf("minutes %s response has no result object", operation) + } + resultMap, ok := result["resultMap"].(map[string]any) + if !ok { + return fmt.Errorf("minutes %s response has no result.resultMap object", operation) + } + if len(resultMap) != len(taskUUIDs) { + return fmt.Errorf("minutes %s resultMap covers %d task UUIDs, want %d", operation, len(resultMap), len(taskUUIDs)) + } + wantMembers := make(map[string]bool, len(memberUIDs)) + for _, member := range memberUIDs { + wantMembers[strings.TrimSpace(member)] = true + } + for _, taskUUID := range taskUUIDs { + rawMembers, exists := resultMap[taskUUID] + if !exists { + return fmt.Errorf("minutes %s resultMap is missing task UUID %s", operation, taskUUID) + } + members, ok := rawMembers.([]any) + if !ok { + return fmt.Errorf("minutes %s resultMap[%s] has type %T, want array", operation, taskUUID, rawMembers) + } + gotMembers := make(map[string]bool, len(members)) + for index, member := range members { + value := stringField(map[string]any{"member": member}, "member") + if value == "" { + return fmt.Errorf("minutes %s resultMap[%s][%d] is not a member UID", operation, taskUUID, index) + } + gotMembers[value] = true + } + if len(gotMembers) != len(wantMembers) { + return fmt.Errorf("minutes %s resultMap[%s] member coverage mismatch", operation, taskUUID) + } + for member := range wantMembers { + if !gotMembers[member] { + return fmt.Errorf("minutes %s resultMap[%s] is missing member UID %s", operation, taskUUID, member) + } + } + } + return nil +} + // RecordResult validates the gateway's observed listening-note command result. func RecordResult(expectedCmd, taskUUID string, data map[string]any) (map[string]any, error) { if err := RequireWriteAcknowledgement("record "+expectedCmd, data); err != nil { diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 67a12d41..4ec9e1b7 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -404,6 +404,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "todo\u0000+overdue": {}, "todo\u0000+remind": {}, "todo\u0000+todo-done": {}, + "wiki\u0000+delete-space": {}, + "wiki\u0000+feed-list": {}, + "wiki\u0000+member-add": {}, + "wiki\u0000+member-list": {}, + "wiki\u0000+member-remove": {}, + "wiki\u0000+member-update": {}, + "wiki\u0000+move": {}, + "wiki\u0000+move-to-drive": {}, + "wiki\u0000+node-copy": {}, + "wiki\u0000+node-create": {}, + "wiki\u0000+node-delete": {}, + "wiki\u0000+node-get": {}, + "wiki\u0000+node-list": {}, + "wiki\u0000+node-search": {}, + "wiki\u0000+resolve-space": {}, + "wiki\u0000+space-create": {}, + "wiki\u0000+space-get": {}, + "wiki\u0000+space-list": {}, "wiki\u0000+space-search": {}, + "wiki\u0000+wiki-new-doc": {}, } } diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 96cb8593..6c04f845 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -25,6 +25,9 @@ var minutesSemanticCatalogJSON []byte //go:embed semantic_catalog_drive.json var driveSemanticCatalogJSON []byte +//go:embed semantic_catalog_wiki.json +var wikiSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -48,6 +51,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( aitableSemanticCatalogJSON, minutesSemanticCatalogJSON, driveSemanticCatalogJSON, + wikiSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_wiki.json b/internal/shortcut/semantic_catalog_wiki.json new file mode 100644 index 00000000..70481b90 --- /dev/null +++ b/internal/shortcut/semantic_catalog_wiki.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "service": "wiki", + "default_availability": "available", + "shortcuts": { + "+space-list": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。","risk":"read","public":true,"reviewed":true}, + "+space-search": {"disposition":"semantic_adapter","semantic_delta":"按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。","risk":"read","public":true,"reviewed":true}, + "+resolve-space": {"disposition":"primary_smart","semantic_delta":"把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。","risk":"read","public":true,"reviewed":true}, + "+space-get": {"disposition":"schema_leaf","semantic_delta":"补充知识库详情入口,并要求 workspaceId 业务证据。","risk":"read","public":true,"reviewed":true}, + "+space-create": {"disposition":"primary_smart","semantic_delta":"创建后要求 workspaceId 并通过空间详情读回验证真实落库。","risk":"write","public":true,"reviewed":true}, + "+delete-space": {"disposition":"semantic_adapter","semantic_delta":"删除前读取影响目标,经高风险确认后只接受 success=true 终态。","risk":"high-risk-write","public":true,"reviewed":true}, + + "+member-add": {"disposition":"semantic_adapter","semantic_delta":"支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true}, + "+member-update": {"disposition":"semantic_adapter","semantic_delta":"补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true}, + "+member-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。","risk":"read","public":true,"reviewed":true}, + "+member-remove": {"disposition":"semantic_adapter","semantic_delta":"支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true}, + + "+node-list": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。","risk":"read","public":true,"reviewed":true}, + "+node-get": {"disposition":"schema_leaf","semantic_delta":"统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。","risk":"read","public":true,"reviewed":true}, + "+node-search": {"disposition":"semantic_adapter","semantic_delta":"补充库内关键词和扩展名搜索,并拒绝假空结果。","risk":"read","public":true,"reviewed":true}, + "+node-create": {"disposition":"primary_smart","semantic_delta":"支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。","risk":"write","public":true,"reviewed":true}, + "+node-copy": {"disposition":"primary_smart","semantic_delta":"高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。","risk":"high-risk-write","public":true,"reviewed":true}, + "+move": {"disposition":"primary_smart","semantic_delta":"同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。","risk":"write","public":true,"reviewed":true}, + "+move-to-drive": {"disposition":"primary_smart","semantic_delta":"同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。","risk":"write","public":true,"reviewed":true}, + "+node-delete": {"disposition":"semantic_adapter","semantic_delta":"删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。","risk":"high-risk-write","public":true,"reviewed":true}, + + "+feed-list": {"disposition":"semantic_adapter","semantic_delta":"补充知识库协作动态查询,严格验证 feeds 并保留游标。","risk":"read","public":true,"reviewed":true}, + "+wiki-new-doc": {"disposition":"primary_smart","semantic_delta":"按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。","risk":"write","public":true,"reviewed":true} + } +} diff --git a/internal/shortcut/smart/mail_space_todo_projection_test.go b/internal/shortcut/smart/mail_space_todo_projection_test.go index e5af1037..80869469 100644 --- a/internal/shortcut/smart/mail_space_todo_projection_test.go +++ b/internal/shortcut/smart/mail_space_todo_projection_test.go @@ -23,6 +23,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" @@ -103,6 +104,8 @@ func runShortcut(t *testing.T, fake *stubMailboxCaller, argv ...string) string { root.PersistentFlags().Bool("yes", false, "") root.PersistentFlags().Bool("dry-run", false, "") root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) root.AddCommand(shortcut.Commands()...) var buf bytes.Buffer root.SetOut(&buf) @@ -121,6 +124,8 @@ func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error root.PersistentFlags().Bool("yes", false, "") root.PersistentFlags().Bool("dry-run", false, "") root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) root.AddCommand(shortcut.Commands()...) root.SetOut(io.Discard) root.SetErr(io.Discard) diff --git a/internal/shortcut/smart/resolve_space.go b/internal/shortcut/smart/resolve_space.go index 496c13c8..0008151c 100644 --- a/internal/shortcut/smart/resolve_space.go +++ b/internal/shortcut/smart/resolve_space.go @@ -14,7 +14,13 @@ package smart import ( + "encoding/json" + "fmt" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -35,15 +41,25 @@ import ( // // dws wiki +resolve-space --name 产品文档 var ResolveSpace = shortcut.Shortcut{ - Service: "wiki", - Command: "+resolve-space", - Product: "wiki", - Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)", + OutputRollout: output.RolloutUnifiedActive, + Service: "wiki", + Command: "+resolve-space", + Product: "wiki", + Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)", Intent: "当你只知道某个知识空间(wiki space)的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 spaceId 时使用;" + "内部按 --name 关键词调用 search_wikiSpaces 搜索知识空间,再在本地投影出每个候选的 spaceId 和 name。" + "如果只命中一个知识空间就直接返回它的 spaceId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。" + "这是纯只读操作,只做搜索与本地投影,不会修改任何知识空间。", - Risk: shortcut.RiskRead, + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, + Contract: corecmd.ContractDecl{ + Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)", + Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"知识库名称解析结果","properties":{"resolved":{"type":"boolean","description":"是否唯一解析"},"spaceId":{"type":"string","description":"唯一知识库 ID"},"name":{"type":"string","description":"唯一知识库名称"},"count":{"type":"integer","description":"候选数量"},"candidates":{"type":"array","description":"需要消歧的候选知识库","items":{"type":"object","description":"知识库候选","additionalProperties":true}}},"required":["resolved"],"additionalProperties":true}`)}, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Reviewed Wiki resolver: the executable CLI strictly validates search results and refuses to guess when multiple spaces match."}, + Selection: contract.SelectionSpec{AgentSummary: "按名称搜索知识空间并解析出唯一 spaceId(只读)", UseWhen: []string{"当你只知道某个知识空间(wiki space)的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 spaceId 时使用;内部按 --name 关键词调用 search_wikiSpaces 搜索知识空间,再在本地投影出每个候选的 spaceId 和 name。如果只命中一个知识空间就直接返回它的 spaceId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。这是纯只读操作,只做搜索与本地投影,不会修改任何知识空间。"}, AvoidWhen: []string{"只想浏览所有匹配项用 wiki +space-search;已知 workspaceId 时无需解析"}, Examples: []string{`dws wiki +resolve-space --name "产品文档"`}}, + Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: "shortcut_resolve_space", CanonicalPath: "wiki.shortcut_resolve_space", CLIPath: "wiki +resolve-space", PrimaryCLIPath: "wiki +resolve-space"}, + Parameters: []contract.ParamDecl{{Name: "name", Property: "keyword"}}, + }, Flags: []shortcut.Flag{ {Name: "name", Type: shortcut.FlagString, Desc: "要搜索的知识空间名称关键词(必填)", Required: true}, }, @@ -62,7 +78,10 @@ var ResolveSpace = shortcut.Shortcut{ } // Project candidates to {spaceId, name}, defensively unwrapping the list. - items := resolveSpaceItems(data) + items, err := resolveSpaceItemsStrict(data) + if err != nil { + return err + } candidates := make([]map[string]any, 0, len(items)) for _, s := range items { candidates = append(candidates, map[string]any{ @@ -123,7 +142,7 @@ func resolveSpaceItems(data map[string]any) []map[string]any { // resolveSpaceID reads a space's identifier, tolerating the common id keys. func resolveSpaceID(s map[string]any) string { - for _, key := range []string{"spaceId", "space_id", "id"} { + for _, key := range []string{"workspaceId", "spaceId", "space_id", "id"} { if v, ok := s[key].(string); ok && v != "" { return v } @@ -131,6 +150,53 @@ func resolveSpaceID(s map[string]any) string { return "" } +func resolveSpaceItemsStrict(data map[string]any) ([]map[string]any, error) { + if len(data) == 0 { + return nil, apperrors.NewAPI("search_wikiSpaces 返回空响应,不能当作零命中", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("empty_tool_response")) + } + if success, present := data["success"]; present { + value, ok := success.(bool) + if !ok || !value { + return nil, apperrors.NewAPI("search_wikiSpaces 未成功", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("remote_failure")) + } + } + containers := []map[string]any{data} + for _, wrapper := range []string{"result", "data"} { + if raw, present := data[wrapper]; present { + inner, ok := raw.(map[string]any) + if !ok { + return nil, apperrors.NewAPI("search_wikiSpaces 响应包装不是对象", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_envelope")) + } + containers = append(containers, inner) + } + } + for _, container := range containers { + for _, key := range []string{"wikiSpaces", "spaces", "items", "list", "records"} { + raw, present := container[key] + if !present { + continue + } + list, ok := raw.([]any) + if !ok { + return nil, apperrors.NewAPI("search_wikiSpaces 业务集合不是数组", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection")) + } + out := make([]map[string]any, 0, len(list)) + for index, item := range list { + object, ok := item.(map[string]any) + if !ok { + return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 第 %d 项不是对象", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item")) + } + if resolveSpaceID(object) == "" || resolveSpaceName(object) == "" { + return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 第 %d 项缺少名称或 workspaceId", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item")) + } + out = append(out, object) + } + return out, nil + } + } + return nil, apperrors.NewAPI("search_wikiSpaces 缺少 wikiSpaces 数组,不能投影为空", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("missing_collection")) +} + // resolveSpaceName reads a space's display name, tolerating the common name keys. func resolveSpaceName(s map[string]any) string { for _, key := range []string{"name", "spaceName", "title"} { diff --git a/internal/shortcut/smart/wiki_new_doc.go b/internal/shortcut/smart/wiki_new_doc.go index ebac09f6..85bde831 100644 --- a/internal/shortcut/smart/wiki_new_doc.go +++ b/internal/shortcut/smart/wiki_new_doc.go @@ -14,10 +14,14 @@ 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/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -33,15 +37,25 @@ import ( // // dws wiki +wiki-new-doc --space "产品文档库" --title "需求评审纪要" var WikiNewDoc = shortcut.Shortcut{ - Service: "wiki", - Command: "+wiki-new-doc", - Product: "wiki", - Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)", + OutputRollout: output.RolloutUnifiedActive, + Service: "wiki", + Command: "+wiki-new-doc", + Product: "wiki", + Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)", Intent: "当你只知道知识库(知识空间)的名字、想直接在它下面新建一篇文档,却不想先搜索空间、复制 workspaceId 再建节点时使用;" + "内部先按空间名搜索知识库,若唯一命中则拿到它的 workspaceId,再在该库根目录下创建一个在线文档节点。" + "如果这个名字没有匹配到任何知识库,或匹配到多个,会报错让你用更精确的名字,绝不乱猜。" + "这会真实创建一个新的文档节点。", - Risk: shortcut.RiskWrite, + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "non_idempotent"}, + Contract: corecmd.ContractDecl{ + Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)", + Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的新建 Wiki 文档","properties":{"success":{"type":"boolean","description":"是否成功"},"nodeId":{"type":"string","description":"新文档节点 ID"},"space":{"type":"string","description":"请求的知识库名称"},"title":{"type":"string","description":"请求的文档标题"},"document":{"type":"object","description":"读回的文档元数据","additionalProperties":true}},"required":["success","nodeId","space","title","document"],"additionalProperties":true}`)}, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Reviewed Wiki smart Shortcut: the executable CLI strictly resolves one exact space, creates a document, and verifies it through a metadata read-back."}, + Selection: contract.SelectionSpec{AgentSummary: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)", UseWhen: []string{"当你只知道知识库(知识空间)的名字、想直接在它下面新建一篇文档,却不想先搜索空间、复制 workspaceId 再建节点时使用;内部先按空间名搜索知识库,若唯一命中则拿到它的 workspaceId,再在该库根目录下创建一个在线文档节点。如果这个名字没有匹配到任何知识库,或匹配到多个,会报错让你用更精确的名字,绝不乱猜。这会真实创建一个新的文档节点。"}, AvoidWhen: []string{"已知 workspaceId 时用 wiki +node-create;空间名不唯一时先用 wiki +space-search"}, Examples: []string{`dws wiki +wiki-new-doc --space "产品文档库" --title "需求评审纪要"`}}, + Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: "shortcut_wiki_new_doc", CanonicalPath: "wiki.shortcut_wiki_new_doc", CLIPath: "wiki +wiki-new-doc", PrimaryCLIPath: "wiki +wiki-new-doc"}, + Parameters: []contract.ParamDecl{{Name: "space", Property: "keyword"}, {Name: "title", Property: "name"}}, + }, Flags: []shortcut.Flag{ {Name: "space", Type: shortcut.FlagString, Desc: "知识库(知识空间)名称", Required: true}, {Name: "title", Type: shortcut.FlagString, Desc: "新建文档的标题", Required: true}, @@ -88,7 +102,7 @@ var WikiNewDoc = shortcut.Shortcut{ "wouldCreateIn": workspaceID, }) } - created, err := rt.CallMCPWriteData("doc", "create_file", map[string]any{ + created, err := rt.CallMCPWriteDataStrict("doc", "create_file", map[string]any{ "workspaceId": workspaceID, "name": title, "type": "adoc", @@ -96,13 +110,28 @@ var WikiNewDoc = shortcut.Shortcut{ if err != nil { return err } - // Surface the created doc (id/url in the response) instead of returning - // silently — previously the caller got no confirmation of the new doc. + if success, ok := created["success"].(bool); !ok || !success { + return apperrors.NewAPI("create_file 未返回 success=true,无法证明文档已创建", apperrors.WithOperation("doc/create_file"), apperrors.WithReason("missing_terminal_success")) + } + nodeID := wikiNewDocFirstString(created, "nodeId", "fileId", "id") + if nodeID == "" { + return apperrors.NewAPI("create_file 未返回 nodeId,远端效果未知", apperrors.WithOperation("doc/create_file"), apperrors.WithReason("missing_created_id")) + } + verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": nodeID}) + if err != nil { + return err + } + if success, present := verified["success"]; present { + value, ok := success.(bool) + if !ok || !value { + return apperrors.NewAPI("新建文档读回未成功", apperrors.WithOperation("doc/get_document_info"), apperrors.WithReason("readback_failed")) + } + } + if wikiNewDocFirstString(verified, "nodeId", "fileId", "id") != nodeID { + return apperrors.NewAPI("新建文档读回 nodeId 不一致", apperrors.WithOperation("doc/get_document_info"), apperrors.WithReason("readback_id_mismatch")) + } return rt.Output(map[string]any{ - "created": true, - "space": spaceName, - "title": title, - "result": created, + "success": true, "nodeId": nodeID, "space": spaceName, "title": title, "document": verified, }) }, } @@ -117,7 +146,10 @@ type wikiSpaceCandidate struct { // out of a search_wikiSpaces response and returns its workspaceId. It errors // clearly when nothing matches or when the name is ambiguous, never guessing. func wikiNewDocResolveSpaceID(data map[string]any, spaceName string) (string, error) { - spaces := wikiNewDocExtractSpaces(data) + spaces, err := wikiNewDocExtractSpaces(data) + if err != nil { + return "", err + } if len(spaces) == 0 { return "", apperrors.NewValidation(fmt.Sprintf( "没找到名为 %q 的知识库;换个更完整/精确的空间名再试。", spaceName)) @@ -156,9 +188,15 @@ func wikiNewDocResolveSpaceID(data map[string]any, spaceName string) (string, er // wikiNewDocExtractSpaces flattens the several shapes a search_wikiSpaces // response may take into a list of {id, name} candidates. The gateway wraps the // list under one of several common container keys, so probe them defensively. -func wikiNewDocExtractSpaces(data map[string]any) []wikiSpaceCandidate { +func wikiNewDocExtractSpaces(data map[string]any) ([]wikiSpaceCandidate, error) { if data == nil { - return nil + return nil, apperrors.NewAPI("search_wikiSpaces 返回空响应,不能当作零命中", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("empty_tool_response")) + } + if success, present := data["success"]; present { + value, ok := success.(bool) + if !ok || !value { + return nil, apperrors.NewAPI("search_wikiSpaces 未成功", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("remote_failure")) + } } for _, key := range []string{"result", "data", "list", "wikiSpaces", "spaces", "items", "records"} { switch v := data[key].(type) { @@ -166,21 +204,25 @@ func wikiNewDocExtractSpaces(data map[string]any) []wikiSpaceCandidate { return wikiNewDocToCandidates(v) case map[string]any: for _, k2 := range []string{"list", "wikiSpaces", "spaces", "items", "records", "result"} { - if arr, ok := v[k2].([]any); ok { + if raw, present := v[k2]; present { + arr, ok := raw.([]any) + if !ok { + return nil, apperrors.NewAPI("search_wikiSpaces 业务集合不是数组", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection")) + } return wikiNewDocToCandidates(arr) } } } } - return nil + return nil, apperrors.NewAPI("search_wikiSpaces 缺少 wikiSpaces 数组,不能投影为空", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("missing_collection")) } -func wikiNewDocToCandidates(arr []any) []wikiSpaceCandidate { +func wikiNewDocToCandidates(arr []any) ([]wikiSpaceCandidate, error) { out := make([]wikiSpaceCandidate, 0, len(arr)) - for _, it := range arr { + for index, it := range arr { m, ok := it.(map[string]any) if !ok { - continue + return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 结果第 %d 项不是对象", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item")) } id := "" for _, k := range []string{"workspaceId", "spaceId", "id"} { @@ -196,12 +238,30 @@ func wikiNewDocToCandidates(arr []any) []wikiSpaceCandidate { break } } - if id == "" && name == "" { - continue + if id == "" || name == "" { + return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 结果第 %d 项缺少名称或 workspaceId", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item")) } out = append(out, wikiSpaceCandidate{id: id, name: name}) } - return out + return out, nil +} + +func wikiNewDocFirstString(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"} { + if inner, ok := data[wrapper].(map[string]any); ok { + for _, key := range keys { + if value, ok := inner[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + } + } + return "" } func wikiNewDocLabels(spaces []wikiSpaceCandidate) []string { diff --git a/internal/shortcut/smart/wiki_workflow_coverage_test.go b/internal/shortcut/smart/wiki_workflow_coverage_test.go new file mode 100644 index 00000000..06812531 --- /dev/null +++ b/internal/shortcut/smart/wiki_workflow_coverage_test.go @@ -0,0 +1,187 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "strings" + "testing" +) + +func TestCrossPlatformCoverageResolveSpaceStrictContracts(t *testing.T) { + invalid := []map[string]any{ + nil, + {"success": false}, + {"success": "yes"}, + {"result": "bad"}, + {"wikiSpaces": "bad"}, + {"wikiSpaces": []any{"bad"}}, + {"wikiSpaces": []any{map[string]any{"workspaceId": "w"}}}, + {"wikiSpaces": []any{map[string]any{"name": "Docs"}}}, + {"success": true}, + } + for index, data := range invalid { + if _, err := resolveSpaceItemsStrict(data); err == nil { + t.Fatalf("invalid response %d succeeded: %#v", index, data) + } + } + + for _, data := range []map[string]any{ + {"wikiSpaces": []any{}}, + {"spaces": []any{map[string]any{"spaceId": "s", "spaceName": "Docs"}}}, + {"result": map[string]any{"items": []any{map[string]any{"id": "i", "title": "Plan"}}}}, + {"data": map[string]any{"records": []any{map[string]any{"workspaceId": "w", "name": "Roadmap"}}}}, + } { + if _, err := resolveSpaceItemsStrict(data); err != nil { + t.Fatalf("valid response rejected: %#v: %v", data, err) + } + } + + for _, tc := range []struct { + data map[string]any + id string + name string + }{ + {map[string]any{"workspaceId": "w", "name": "N"}, "w", "N"}, + {map[string]any{"spaceId": "s", "spaceName": "S"}, "s", "S"}, + {map[string]any{"space_id": "legacy", "title": "T"}, "legacy", "T"}, + {map[string]any{"id": "i"}, "i", ""}, + {map[string]any{}, "", ""}, + } { + if got := resolveSpaceID(tc.data); got != tc.id { + t.Fatalf("resolveSpaceID(%#v)=%q want %q", tc.data, got, tc.id) + } + if got := resolveSpaceName(tc.data); got != tc.name { + t.Fatalf("resolveSpaceName(%#v)=%q want %q", tc.data, got, tc.name) + } + } +} + +func TestCrossPlatformCoverageResolveSpaceExecution(t *testing.T) { + for _, tc := range []struct { + name string + fake *stubMailboxCaller + ok bool + }{ + {"transport error", &stubMailboxCaller{errTool: "search_wikiSpaces"}, false}, + {"malformed result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"success":true}`}}, false}, + {"zero result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[]}`}}, false}, + {"one result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[{"workspaceId":"w","name":"Docs"}]}`}}, true}, + {"multiple results", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[{"workspaceId":"w1","name":"Docs"},{"workspaceId":"w2","name":"Docs 2"}]}`}}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + err := runShortcutErr(t, tc.fake, "wiki", "+resolve-space", "--name", "Docs", "--format", "json") + if (err == nil) != tc.ok { + t.Fatalf("success=%v want %v; err=%v", err == nil, tc.ok, err) + } + }) + } +} + +func TestCrossPlatformCoverageWikiNewDocParsing(t *testing.T) { + invalid := []map[string]any{ + nil, + {"success": false}, + {"success": "yes"}, + {"result": map[string]any{"wikiSpaces": "bad"}}, + {"wikiSpaces": []any{"bad"}}, + {"wikiSpaces": []any{map[string]any{"workspaceId": "w"}}}, + {"success": true}, + } + for index, data := range invalid { + if _, err := wikiNewDocExtractSpaces(data); err == nil { + t.Fatalf("invalid response %d succeeded: %#v", index, data) + } + } + + for _, data := range []map[string]any{ + {"wikiSpaces": []any{}}, + {"list": []any{map[string]any{"workspaceId": "w", "name": "Docs"}}}, + {"data": map[string]any{"spaces": []any{map[string]any{"spaceId": "s", "spaceName": "Plan"}}}}, + {"result": map[string]any{"result": []any{map[string]any{"id": "i", "title": "Roadmap"}}}}, + } { + if _, err := wikiNewDocExtractSpaces(data); err != nil { + t.Fatalf("valid response rejected: %#v: %v", data, err) + } + } + + if got := wikiNewDocFirstString(map[string]any{"nodeId": " n "}, "nodeId"); got != "n" { + t.Fatalf("direct node id=%q", got) + } + if got := wikiNewDocFirstString(map[string]any{"result": map[string]any{"fileId": " f "}}, "nodeId", "fileId"); got != "f" { + t.Fatalf("nested node id=%q", got) + } + if got := wikiNewDocFirstString(map[string]any{"data": map[string]any{"id": " i "}}, "id"); got != "i" { + t.Fatalf("data node id=%q", got) + } + if got := wikiNewDocFirstString(map[string]any{"id": 1}, "id"); got != "" { + t.Fatalf("invalid node id=%q", got) + } + labels := wikiNewDocLabels([]wikiSpaceCandidate{{id: "w1", name: "Docs"}, {id: "w2", name: "Plan"}}) + if strings.Join(labels, ",") != "Docs(w1),Plan(w2)" { + t.Fatalf("labels=%v", labels) + } +} + +func TestCrossPlatformCoverageWikiNewDocResolution(t *testing.T) { + cases := []struct { + name string + data map[string]any + spaceName string + want string + ok bool + }{ + {"parser error", nil, "Docs", "", false}, + {"zero", map[string]any{"wikiSpaces": []any{}}, "Docs", "", false}, + {"unique exact", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": " docs "}, map[string]any{"workspaceId": "w2", "name": "Plan"}}}, "Docs", "w1", true}, + {"unique fallback", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": "Docs Team"}}}, "Docs", "w1", true}, + {"multiple", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": "Docs 1"}, map[string]any{"workspaceId": "w2", "name": "Docs 2"}}}, "Docs", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := wikiNewDocResolveSpaceID(tc.data, tc.spaceName) + if (err == nil) != tc.ok || got != tc.want { + t.Fatalf("got=%q err=%v want=%q ok=%v", got, err, tc.want, tc.ok) + } + }) + } +} + +func TestCrossPlatformCoverageWikiNewDocExecution(t *testing.T) { + if err := runShortcutErr(t, &stubMailboxCaller{}, "wiki", "+wiki-new-doc", "--space", " ", "--title", "Doc"); err == nil { + t.Fatal("blank space succeeded") + } + if err := runShortcutErr(t, &stubMailboxCaller{}, "wiki", "+wiki-new-doc", "--space", "Docs", "--title", " "); err == nil { + t.Fatal("blank title succeeded") + } + + validSearch := `{"wikiSpaces":[{"workspaceId":"w","name":"Docs"}]}` + cases := []struct { + name string + fake *stubMailboxCaller + args []string + ok bool + }{ + {"search error", &stubMailboxCaller{errTool: "search_wikiSpaces"}, nil, false}, + {"resolve error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[]}`}}, nil, false}, + {"dry run", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch}}, []string{"--dry-run"}, true}, + {"create error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch}, errTool: "create_file"}, nil, false}, + {"missing terminal success", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"result":{"fileId":"n"}}`}}, nil, false}, + {"missing created id", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true}`}}, nil, false}, + {"readback error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`}, errTool: "get_document_info"}, nil, false}, + {"readback failed", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":false}`}}, nil, false}, + {"readback malformed success", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":"yes"}`}}, nil, false}, + {"readback id mismatch", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":true,"nodeId":"other"}`}}, nil, false}, + {"verified", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"data":{"fileId":"n"}}`, "get_document_info": `{"success":true,"result":{"nodeId":"n"}}`}}, nil, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := []string{"wiki", "+wiki-new-doc", "--space", "Docs", "--title", "Doc", "--format", "json"} + args = append(args, tc.args...) + err := runShortcutErr(t, tc.fake, args...) + if (err == nil) != tc.ok { + t.Fatalf("success=%v want %v; err=%v", err == nil, tc.ok, err) + } + }) + } +} diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index 8e46ec3a..b3545f36 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -107,6 +107,12 @@ type Flag struct { // compatibility escape hatch for aliases that were historically public. Aliases []string `json:"-"` AliasesVisible bool `json:"-"` + // Input declares extra input sources for a string flag beyond the literal + // command-line value: "file" enables @path (value replaced by the file + // content), "stdin" enables - (value replaced by stdin). "@@value" escapes + // to the literal "@value". Resolution happens before Required/Enum/Validate + // checks. Empty = flag value only. + Input []string `json:"input,omitempty"` } // ConstraintKind is a machine-readable cross-parameter or custom validation diff --git a/internal/shortcut/wiki/common.go b/internal/shortcut/wiki/common.go new file mode 100644 index 00000000..ab57746d --- /dev/null +++ b/internal/shortcut/wiki/common.go @@ -0,0 +1,314 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package wiki + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const wikiCompositeReason = "Reviewed Wiki Shortcut composite: the executable CLI owns strict response validation, pagination projection, optional multi-step orchestration, read-back verification, and confirmation; no single MCP interface represents the complete command contract." + +const wikiSpaceSearchCompositeReason = "Reviewed Wiki space-search compatibility adapter: the published workflow properties query/limit remain stable while execution translates them to search_wikiSpaces.keyword/pageSize; redirecting an existing non-empty property requires a versioned Schema migration." + +func wikiContract(command, description, useWhen string, avoidWhen, examples []string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params ...contract.ParamDecl) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "wiki " + command + return corecmd.ContractDecl{ + Description: description, Parameters: params, Result: result, Pagination: pagination, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: wikiCompositeReason}, + Selection: contract.SelectionSpec{AgentSummary: description, UseWhen: []string{useWhen}, AvoidWhen: avoidWhen, Examples: examples}, + Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: name, CanonicalPath: "wiki." + name, CLIPath: cliPath, PrimaryCLIPath: cliPath}, + } +} + +func wikiWithInterfaceReason(declared shortcut.Shortcut, reason string) shortcut.Shortcut { + declared.Contract.Interface.Reason = reason + return declared +} + +func wikiObjectResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"additionalProperties":true}`, description))} +} + +func wikiCollectionResult(collection, description string) *contract.ResultSpec { + return &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"有效结果数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"Wiki 业务条目","additionalProperties":true}},"nextCursor":{"type":"string","description":"下一页游标"},"hasMore":{"type":"boolean","description":"服务端是否仍有下一页"}},"required":["count",%q],"additionalProperties":true}`, + description, collection, description, collection))} +} + +func wikiCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor", MetaPath: contract.PaginationMetaPath, EndpointExhaustedPath: contract.PaginationExhaustedPath, NextTokenPath: contract.PaginationNextTokenPath} +} + +func requireWikiResponse(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, wikiResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功") + } + if value, present := data["success"]; present { + success, ok := value.(bool) + if !ok { + return nil, wikiResponseError(operation, "malformed_success", "响应 success 字段不是布尔值") + } + if !success { + message := firstWikiString(data, "errorMsg", "message", "error") + if message == "" { + message = "服务明确返回 success=false" + } + return nil, wikiResponseError(operation, "remote_failure", message) + } + } + return data, nil +} + +func requireWikiWrite(data map[string]any, operation string) (map[string]any, error) { + data, err := requireWikiResponse(data, operation) + if err != nil { + return nil, err + } + if success, ok := data["success"].(bool); !ok || !success { + return nil, wikiResponseError(operation, "missing_terminal_success", "写操作响应没有 success=true 终态证据") + } + return data, nil +} + +func requireWikiObject(data map[string]any, operation string) (map[string]any, error) { + data, err := requireWikiResponse(data, operation) + if err != nil { + return nil, err + } + for _, wrapper := range []string{"result", "data"} { + if value, present := data[wrapper]; present { + object, ok := value.(map[string]any) + if !ok || len(object) == 0 { + return nil, wikiResponseError(operation, "malformed_object", "响应业务对象缺失或畸形") + } + return object, nil + } + } + if len(data) == 1 { + return nil, wikiResponseError(operation, "missing_business_result", "响应没有可验证的业务对象") + } + return data, nil +} + +func requireWikiCollection(data map[string]any, operation string, keys ...string) ([]any, map[string]any, error) { + data, err := requireWikiResponse(data, operation) + if err != nil { + return nil, nil, err + } + containers := []map[string]any{data} + for _, wrapper := range []string{"result", "data"} { + if value, present := data[wrapper]; present { + inner, ok := value.(map[string]any) + if !ok { + return nil, nil, wikiResponseError(operation, "malformed_envelope", fmt.Sprintf("响应 %s 字段不是对象", wrapper)) + } + containers = append(containers, inner) + } + } + for _, container := range containers { + for _, key := range keys { + value, present := container[key] + if !present { + continue + } + items, ok := value.([]any) + if !ok { + return nil, nil, wikiResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key)) + } + for index, item := range items { + if _, ok := item.(map[string]any); !ok { + return nil, nil, wikiResponseError(operation, "malformed_collection_item", fmt.Sprintf("响应 %s[%d] 不是对象", key, index)) + } + } + return items, container, nil + } + } + return nil, nil, wikiResponseError(operation, "missing_collection", "响应缺少声明的业务数组;不能把缺字段或内部错误投影成空结果") +} + +func projectWikiRows(items []any, aliases map[string][]string) []map[string]any { + rows := make([]map[string]any, 0, len(items)) + for _, item := range items { + source := item.(map[string]any) + row := make(map[string]any) + for canonical, candidates := range aliases { + for _, candidate := range candidates { + if value, ok := source[candidate]; ok && value != nil { + row[canonical] = value + break + } + } + } + rows = append(rows, row) + } + return rows +} + +func addWikiPagination(out, page map[string]any) { + for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"pageToken", "nextCursor"}, {"hasMore", "hasMore"}, {"truncated", "truncated"}, {"totalCount", "totalCount"}, {"autoPageComplete", "autoPageComplete"}, {"autoPageStopReason", "autoPageStopReason"}, {"pagesFetched", "pagesFetched"}} { + if value, ok := page[pair[0]]; ok && value != nil { + out[pair[1]] = value + } + } +} + +func firstWikiString(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) + } + } + return "" +} + +func nestedWikiString(data map[string]any, keys ...string) string { + if value := firstWikiString(data, keys...); value != "" { + return value + } + for _, wrapper := range []string{"result", "data"} { + if inner, ok := data[wrapper].(map[string]any); ok { + if value := firstWikiString(inner, keys...); value != "" { + return value + } + } + } + return "" +} + +func wikiStringInt(rt *shortcut.RuntimeContext, name string, fallback, min, max int) (int, error) { + raw := rt.Str(name) + if raw == "" { + return fallback, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value < min || value > max { + return 0, fmt.Errorf("--%s 必须是 %d-%d 之间的整数", name, min, max) + } + return value, nil +} + +func wikiStringSliceFirst(rt *shortcut.RuntimeContext, primary string, aliases ...string) []string { + if rt.Changed(primary) { + return rt.StrSlice(primary) + } + for _, alias := range aliases { + if rt.Changed(alias) { + values, _ := rt.Command().Flags().GetStringSlice(alias) + return values + } + } + return rt.StrSlice(primary) +} + +func wikiResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, apperrors.WithOperation(operation), apperrors.WithOrigin("mcp"), apperrors.WithFailureStage("response_validation"), apperrors.WithRetryable(false), apperrors.WithReason(reason)) +} + +func wikiReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} +func wikiWriteSafety(confirm bool) contract.SafetySpec { + confirmation := "not_required" + if confirm { + confirmation = "user_required" + } + return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: confirmation, Idempotency: "unknown"} +} +func wikiDeleteSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"} +} + +func wikiAutoPageFlags() []shortcut.Flag { + const evidence = "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0" + return append([]shortcut.Flag{{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动沿游标取完所有页。" + evidence}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "自动翻页最多请求页数。" + evidence}}, shortcut.AutoPageControlFlags()...) +} + +func enableWikiAutoPage(item *shortcut.Shortcut) { + item.Flags = append(item.Flags, wikiAutoPageFlags()...) + item.Constraints = append(item.Constraints, shortcut.AutoPageControlConstraints()...) + item.Validate = func(rt *shortcut.RuntimeContext) error { + if err := shortcut.ValidateAutoPageControls(rt); err != nil { + return err + } + if rt.Bool("page-all") && rt.Int("page-limit") < 1 { + return fmt.Errorf("--page-limit 必须大于 0") + } + return nil + } +} + +type wikiPageFetcher func(cursor string, pageSize int) (map[string]any, error) + +func collectWikiPages(rt *shortcut.RuntimeContext, operation string, pageSize int, keys []string, fetch wikiPageFetcher) ([]any, map[string]any, error) { + if !rt.Bool("page-all") { + data, err := fetch(rt.StrFirst("cursor", "page-token"), pageSize) + if err != nil { + return nil, nil, err + } + return requireWikiCollection(data, operation, keys...) + } + all := make([]any, 0) + cursor := rt.StrFirst("cursor", "page-token") + lastPage := map[string]any{} + for pageNumber := 1; pageNumber <= rt.Int("page-limit"); pageNumber++ { + requestSize := shortcut.AutoPageRequestSize(rt, pageSize, len(all)) + data, err := fetch(cursor, requestSize) + if err != nil { + return nil, nil, err + } + items, page, err := requireWikiCollection(data, operation, keys...) + if err != nil { + return nil, nil, err + } + maxItems := rt.Int("max-items") + if maxItems > 0 { + remaining := maxItems - len(all) + if len(items) > remaining { + items = items[:remaining] + } + } + all = append(all, items...) + lastPage = page + if maxItems > 0 && len(all) >= maxItems { + lastPage["autoPageComplete"] = false + lastPage["autoPageStopReason"] = "max_items" + lastPage["pagesFetched"] = pageNumber + return all, lastPage, nil + } + hasMore, present := page["hasMore"] + if !present { + return nil, nil, wikiResponseError(operation, "missing_has_more", "--page-all 要求每页响应提供 hasMore 布尔值") + } + more, ok := hasMore.(bool) + if !ok { + return nil, nil, wikiResponseError(operation, "malformed_has_more", "分页响应 hasMore 不是布尔值") + } + if !more { + lastPage["autoPageComplete"] = true + lastPage["pagesFetched"] = pageNumber + return all, lastPage, nil + } + next := firstWikiString(page, "nextCursor", "nextToken", "nextPageToken", "pageToken") + if next == "" { + return nil, nil, wikiResponseError(operation, "missing_next_cursor", "hasMore=true 但响应缺少下一页游标") + } + if next == cursor { + return nil, nil, wikiResponseError(operation, "stalled_cursor", "下一页游标未变化,已停止以避免死循环") + } + cursor = next + if err := shortcut.WaitAutoPageDelay(rt); err != nil { + return nil, nil, err + } + } + return nil, nil, wikiResponseError(operation, "page_limit_reached", "达到 --page-limit 时服务端仍有下一页;提高页数上限或使用返回游标续传") +} diff --git a/internal/shortcut/wiki/nodes.go b/internal/shortcut/wiki/nodes.go new file mode 100644 index 00000000..aa893c87 --- /dev/null +++ b/internal/shortcut/wiki/nodes.go @@ -0,0 +1,257 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package wiki + +import ( + "fmt" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var NodeList = readShortcut("+node-list", "严格分页列出知识库节点", "浏览知识库根目录或指定文件夹;只有显式 nodes:[] 才表示空目录,并完整保留 nextCursor/hasMore。", "nodes", "dws wiki +node-list --workspace --format json", []shortcut.Flag{ + {Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "父节点 ID"}, {Name: "limit", Type: shortcut.FlagInt, Default: "50", Desc: "每页数量 1-50"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true}, +}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "folder", Property: "folderId"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error { + items, page, err := collectWikiPages(rt, "wiki/list_nodes", rt.Int("limit"), []string{"nodes", "items", "list"}, func(cursor string, size int) (map[string]any, error) { + params := map[string]any{"workspaceId": rt.Str("workspace"), "pageSize": size} + if rt.Changed("folder") { + params["folderId"] = rt.Str("folder") + } + if cursor != "" { + params["pageToken"] = cursor + } + return rt.CallMCPData("doc", "list_nodes", params) + }) + if err != nil { + return err + } + nodes := projectWikiRows(items, nodeAliases()) + out := map[string]any{"count": len(nodes), "nodes": nodes} + addWikiPagination(out, page) + return rt.Output(out) +}) + +func nodeAliases() map[string][]string { + return map[string][]string{"nodeId": {"nodeId", "id", "dentryUuid", "fileId"}, "name": {"name", "title", "nodeName", "fileName"}, "type": {"type", "nodeType", "docType", "fileType"}, "contentType": {"contentType"}, "folderId": {"folderId", "parentId"}, "workspaceId": {"workspaceId", "spaceId"}, "url": {"docUrl", "url", "webUrl"}} +} + +var NodeGet = readShortcut("+node-get", "获取知识库节点详情", "已知节点 ID 或在线文档 URL 时读取元数据,并在节点信息之外统一返回文档/文件属性。", "", "dws wiki +node-get --node --format json", []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID 或 URL"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")}) + if err != nil { + return err + } + object, err := requireWikiObject(data, "doc/get_document_info") + if err != nil { + return err + } + if firstWikiString(object, "nodeId", "id", "fileId") == "" { + return wikiResponseError("doc/get_document_info", "missing_node_id", "节点详情缺少 nodeId") + } + return rt.Output(object) +}) + +var NodeSearch = readShortcut("+node-search", "严格搜索知识库节点", "在指定知识库内按关键词和扩展名搜索节点;零命中必须来自显式 documents:[]。", "nodes", "dws wiki +node-search --workspace --query \"方案\" --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "关键词"}, {Name: "extensions", Type: shortcut.FlagStringSlice, Desc: "扩展名过滤"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-30"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceIds"}, {Name: "query", Property: "keyword"}, {Name: "extensions", Property: "extensions"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"workspaceIds": []string{rt.Str("workspace")}, "keyword": rt.Str("query"), "pageSize": rt.Int("limit")} + if rt.Changed("extensions") { + params["extensions"] = rt.StrSlice("extensions") + } + if rt.Changed("cursor") { + params["pageToken"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("doc", "search_documents", params) + if err != nil { + return err + } + items, page, err := requireWikiCollection(data, "doc/search_documents", "documents", "docs", "nodes", "items", "list") + if err != nil { + return err + } + nodes := projectWikiRows(items, nodeAliases()) + out := map[string]any{"count": len(nodes), "nodes": nodes} + addWikiPagination(out, page) + return rt.Output(out) +}) + +var NodeCreate = writeShortcut("+node-create", "创建知识库节点并读回验证", "在知识库根目录或文件夹中创建文档、表格、白板、脑图或文件夹;取得 nodeId 并读回后才成功。", "dws wiki +node-create --workspace --name \"新文档\" --format json", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "name", Type: shortcut.FlagString, Required: true, Desc: "节点名称"}, {Name: "type", Type: shortcut.FlagString, Default: "adoc", Desc: "节点类型", Enum: []string{"adoc", "axls", "able", "appt", "adraw", "amind", "folder"}}, {Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "name", Property: "name"}, {Name: "type", Property: "type"}, {Name: "folder", Property: "folderId"}}, func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"workspaceId": rt.Str("workspace"), "name": rt.Str("name"), "type": rt.Str("type")} + if rt.Changed("folder") { + params["folderId"] = rt.Str("folder") + } + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/create_file", "arguments": params}) + } + written, err := rt.CallMCPWriteDataStrict("doc", "create_file", params) + if err != nil { + return err + } + written, err = requireWikiWrite(written, "doc/create_file") + if err != nil { + return err + } + id := nestedWikiString(written, "nodeId", "fileId", "id") + if id == "" { + return wikiResponseError("doc/create_file", "missing_created_id", "创建响应没有 nodeId;远端效果未知") + } + verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": id}) + if err != nil { + return err + } + verified, err = requireWikiObject(verified, "doc/get_document_info") + if err != nil { + return err + } + if firstWikiString(verified, "nodeId", "id", "fileId") != id { + return wikiResponseError("doc/create_file", "readback_id_mismatch", "创建后读回节点 ID 不一致") + } + return rt.Output(map[string]any{"success": true, "nodeId": id, "node": verified}) +}) + +var NodeCopy = writeShortcut("+node-copy", "复制知识库节点并读回验证", "复制现有在线节点到目标知识库/文件夹;高风险确认后要求新 nodeId 并读取副本元数据。", "dws wiki +node-copy --workspace --node --format json", shortcut.RiskHighWrite, contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "non_idempotent"}, []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "源节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"workspaceId": rt.Str("workspace"), "nodeId": rt.Str("node")} + if rt.Changed("folder") { + params["targetFolderId"] = rt.Str("folder") + } + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/copy_document", "arguments": params}) + } + written, err := rt.CallMCPWriteDataStrict("doc", "copy_document", params) + if err != nil { + return err + } + written, err = requireWikiWrite(written, "doc/copy_document") + if err != nil { + return err + } + id := nestedWikiString(written, "nodeId", "fileId", "id") + if id == "" { + return wikiResponseError("doc/copy_document", "missing_created_id", "复制响应没有新 nodeId;远端效果未知") + } + verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": id}) + if err != nil { + return err + } + verified, err = requireWikiObject(verified, "doc/get_document_info") + if err != nil { + return err + } + if firstWikiString(verified, "nodeId", "id", "fileId") != id { + return wikiResponseError("doc/copy_document", "readback_id_mismatch", "复制后读回节点 ID 不一致") + } + return rt.Output(map[string]any{"success": true, "sourceNodeId": rt.Str("node"), "nodeId": id, "copy": verified}) +}) + +func executeMove(rt *shortcut.RuntimeContext, toDrive bool) error { + preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")}) + if err != nil { + return err + } + preflight, err = requireWikiObject(preflight, "doc/get_document_info") + if err != nil { + return err + } + beforeWorkspace := firstWikiString(preflight, "workspaceId", "spaceId") + params := map[string]any{"nodeId": rt.Str("node")} + if !toDrive { + params["workspaceId"] = rt.Str("workspace") + } + if rt.Changed("folder") { + params["targetFolderId"] = rt.Str("folder") + } + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/move_document", "arguments": params, "target": preflight}) + } + written, err := rt.CallMCPWriteDataStrict("doc", "move_document", params) + if err != nil { + return err + } + if _, err = requireWikiWrite(written, "doc/move_document"); err != nil { + return err + } + verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")}) + if err != nil { + return err + } + verified, err = requireWikiObject(verified, "doc/get_document_info") + if err != nil { + return err + } + if firstWikiString(verified, "nodeId", "id", "fileId") != rt.Str("node") { + return wikiResponseError("doc/move_document", "readback_id_mismatch", "移动后读回节点 ID 不一致") + } + afterWorkspace := firstWikiString(verified, "workspaceId", "spaceId") + if toDrive { + if beforeWorkspace == "" || afterWorkspace == "" || beforeWorkspace == afterWorkspace { + return wikiResponseError("doc/move_document", "drive_move_not_verified", "移动到我的文档后 workspace 未发生可验证变化") + } + } else if afterWorkspace != rt.Str("workspace") { + return wikiResponseError("doc/move_document", "workspace_readback_mismatch", "移动后读回的目标知识库不一致") + } + if rt.Changed("folder") && firstWikiString(verified, "folderId", "parentId") != rt.Str("folder") { + return wikiResponseError("doc/move_document", "folder_readback_mismatch", "移动后读回的目标文件夹不一致") + } + return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "node": verified}) +} + +var Move = writeShortcut("+move", "移动节点到知识库并读回验证", "将 Wiki 节点或我的文档在线节点移动到目标知识库/文件夹;同一入口覆盖库内移动与在线文档入库场景。", "dws wiki +move --workspace --node --format json", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, false) }) + +var MoveToDrive = writeShortcut("+move-to-drive", "移动 Wiki 节点到我的文档", "将 Wiki 在线节点同步移动到我的文档或指定文件夹,并以元数据读回证明任务完成。", "dws wiki +move-to-drive --node --format json", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "Wiki 节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "我的文档目标文件夹 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, true) }) + +var NodeDelete = writeShortcut("+node-delete", "删除知识库节点", "明确确认后将节点移入回收站;先读取目标,且删除响应必须提供 success=true 终态证据。", "dws wiki +node-delete --workspace --node --format json", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID,用于确认影响范围"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error { + preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")}) + if err != nil { + return err + } + preflight, err = requireWikiObject(preflight, "doc/get_document_info") + if err != nil { + return err + } + if workspace := firstWikiString(preflight, "workspaceId", "spaceId"); workspace != "" && workspace != rt.Str("workspace") { + return wikiResponseError("doc/delete_document", "workspace_preflight_mismatch", "节点不属于请求确认的知识库") + } + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/delete_document", "target": preflight}) + } + written, err := rt.CallMCPWriteDataStrict("doc", "delete_document", map[string]any{"nodeId": rt.Str("node")}) + if err != nil { + return err + } + if _, err = requireWikiWrite(written, "doc/delete_document"); err != nil { + return err + } + return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "deleted": true}) +}) + +var FeedList = readShortcut("+feed-list", "严格分页列出知识库动态", "查看谁在何时创建、更新或评论了知识库内容;严格验证 feeds 数组并保留游标。", "feeds", "dws wiki +feed-list --workspace --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-20"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, {Name: "exclude-file", Type: shortcut.FlagBool, Desc: "排除普通文件动态"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "cursor", Property: "nextToken"}, {Name: "exclude-file", Property: "excludeFile"}}, func(rt *shortcut.RuntimeContext) error { + items, page, err := collectWikiPages(rt, "wiki/list_workspace_feeds", rt.Int("limit"), []string{"feeds", "items", "list"}, func(cursor string, size int) (map[string]any, error) { + params := map[string]any{"workspaceId": rt.Str("workspace"), "maxResults": size} + if cursor != "" { + params["nextToken"] = cursor + } + if rt.Changed("exclude-file") { + params["excludeFile"] = rt.Bool("exclude-file") + } + return rt.CallMCPData("wiki", "list_workspace_feeds", params) + }) + if err != nil { + return err + } + feeds := projectWikiRows(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}}) + out := map[string]any{"count": len(feeds), "feeds": feeds} + addWikiPagination(out, page) + return rt.Output(out) +}) + +func init() { + Move.Aliases = []string{"+node-move"} + for _, item := range []*shortcut.Shortcut{&NodeList, &FeedList} { + enableWikiAutoPage(item) + } + for _, item := range []*shortcut.Shortcut{&NodeList, &NodeSearch, &FeedList} { + item.Contract.Pagination = wikiCursorPagination() + } + shortcut.Register(NodeList, NodeGet, NodeSearch, NodeCreate, NodeCopy, Move, MoveToDrive, NodeDelete, FeedList) + _ = fmt.Sprintf + _ = output.RolloutUnifiedActive +} diff --git a/internal/shortcut/wiki/space_list_projection_test.go b/internal/shortcut/wiki/space_list_projection_test.go index da5602f0..69648bf2 100644 --- a/internal/shortcut/wiki/space_list_projection_test.go +++ b/internal/shortcut/wiki/space_list_projection_test.go @@ -18,11 +18,11 @@ import ( "testing" ) -// TestSpaceListProjectWikiSpacesShape guards against projection-data-loss: +// TestCrossPlatformCoverageWikiSpaceListShape guards against projection-data-loss: // list_wikiSpaces / search_wikiSpaces nest the list under result.wikiSpaces; // the resolver must probe "wikiSpaces" or +space-list / +space-search silently // return empty despite the backend returning spaces. -func TestSpaceListProjectWikiSpacesShape(t *testing.T) { +func TestCrossPlatformCoverageWikiSpaceListShape(t *testing.T) { const raw = `{"result":{"hasMore":false,"wikiSpaces":[ {"workspaceId":"w1","name":"R&D wiki"}, {"workspaceId":"w2","name":"product wiki"} @@ -31,19 +31,47 @@ func TestSpaceListProjectWikiSpacesShape(t *testing.T) { if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("unmarshal fixture: %v", err) } - if spaces := spaceListProject(data); len(spaces) != 2 { + items, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces") + if err != nil { + t.Fatal(err) + } + spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId"}, "name": {"name"}}) + if len(spaces) != 2 { t.Fatalf("lower/upper mismatch: result.wikiSpaces has 2 entries, projection returned %d (%v)", len(spaces), spaces) } } // TestSpaceListProjectTopLevelWikiSpaces covers the already-unwrapped shape. -func TestSpaceListProjectTopLevelWikiSpaces(t *testing.T) { +func TestCrossPlatformCoverageWikiSpaceListTopLevelShape(t *testing.T) { const raw = `{"wikiSpaces":[{"workspaceId":"w1","name":"R&D wiki"}]}` var data map[string]any if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("unmarshal fixture: %v", err) } - if spaces := spaceListProject(data); len(spaces) != 1 { + items, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces") + if err != nil { + t.Fatal(err) + } + spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId"}, "name": {"name"}}) + if len(spaces) != 1 { t.Fatalf("top-level wikiSpaces: want 1, got %d (%v)", len(spaces), spaces) } } + +func TestCrossPlatformCoverageWikiCollectionsRejectFalseEmptySuccess(t *testing.T) { + for name, data := range map[string]map[string]any{ + "missing": {"success": true, "hasMore": false}, + "malformed": {"success": true, "wikiSpaces": map[string]any{}}, + "bad item": {"success": true, "wikiSpaces": []any{"not-an-object"}}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces"); err == nil { + t.Fatal("malformed response was accepted as an empty success") + } + }) + } + items, _, err := requireWikiCollection(map[string]any{"success": true, "wikiSpaces": []any{}}, "wiki/list_wikiSpaces", "wikiSpaces") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty array must remain valid: items=%v err=%v", items, err) + } +} diff --git a/internal/shortcut/wiki/wiki.go b/internal/shortcut/wiki/wiki.go index 4e8d0990..a36eb664 100644 --- a/internal/shortcut/wiki/wiki.go +++ b/internal/shortcut/wiki/wiki.go @@ -1,344 +1,237 @@ // 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. +// Licensed under the Apache License, Version 2.0 -// Package wiki declares high-fidelity shortcuts for the DingTalk wiki -// (knowledge base) service: space management, member management and node -// management. Tool names and parameters mirror internal/helpers/wiki.go. +// Package wiki declares reviewed, truth-preserving shortcuts for Wiki spaces, +// members, nodes, and activity feeds. package wiki import ( - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "fmt" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) -// ── space (知识库) ──────────────────────────────────────────── +var collectionAvoid = []string{"需要原始 MCP 响应或未公开底层参数时改用对应原子命令;缺失业务数组不是合法空结果"} -// SpaceCreate → create_wikiSpace -// SpaceGet → get_wikiSpace -// SpaceList → list_wikiSpaces -var SpaceList = shortcut.Shortcut{ - Service: "wiki", - Command: "+space-list", - Product: "wiki", - Description: "列出组织 / 个人知识库", - Intent: "当你想浏览自己有权限访问的知识库、拿到目标知识库的 workspaceId 却不确定具体名称时使用;可按类型(组织知识库或我的知识库)分页列出,返回知识库列表,是定位知识库的常用入口。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "type", Type: shortcut.FlagString, Default: "orgWikiSpace", Desc: "知识库类型: orgWikiSpace(默认) / myWikiSpace", Enum: []string{"orgWikiSpace", "myWikiSpace"}}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页数量 1-50 (默认 20)"}, - {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (首页留空)"}, - }, - Tips: []string{ - `dws wiki +space-list`, - `dws wiki +space-list --type myWikiSpace`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{} - if rt.Changed("type") { - params["wikiSpaceType"] = rt.Str("type") +func readShortcut(command, description, intent, collection, example string, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut { + result := wikiObjectResult(description) + if collection != "" { + result = wikiCollectionResult(collection, description) + } + return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: shortcut.RiskRead, Safety: wikiReadSafety(), Contract: wikiContract(command, description, intent, collectionAvoid, []string{example}, result, nil, params...), Flags: flags, Execute: execute} +} + +func writeShortcut(command, description, intent, example string, risk shortcut.Risk, safety contract.SafetySpec, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut { + return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: risk, Safety: safety, Contract: wikiContract(command, description, intent, []string{"只需读取或影响范围未确认时不要执行写操作"}, []string{example}, wikiObjectResult(description), nil, params...), Flags: flags, Execute: execute} +} + +var SpaceList = readShortcut("+space-list", "严格分页列出知识库", "浏览有权访问的组织或个人知识库,并保留服务端分页证据;只有显式 wikiSpaces:[] 才是空结果。", "spaces", "dws wiki +space-list --limit 20 --format json", []shortcut.Flag{ + {Name: "type", Type: shortcut.FlagString, Default: "orgWikiSpace", Desc: "知识库类型", Enum: []string{"orgWikiSpace", "myWikiSpace"}}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页数量 1-50(默认 20)"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true}, +}, []contract.ParamDecl{{Name: "type", Property: "wikiSpaceType"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error { + pageSize, err := wikiStringInt(rt, "limit", 20, 1, 50) + if err != nil { + return err + } + items, page, err := collectWikiPages(rt, "wiki/list_wikiSpaces", pageSize, []string{"wikiSpaces", "spaces"}, func(cursor string, size int) (map[string]any, error) { + params := map[string]any{"wikiSpaceType": rt.Str("type"), "pageSize": size} + if cursor != "" { + params["pageToken"] = cursor } - if rt.Changed("limit") { - params["pageSize"] = rt.Str("limit") + return rt.CallMCPData("wiki", "list_wikiSpaces", params) + }) + if err != nil { + return err + } + spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId", "spaceId", "id"}, "name": {"name", "spaceName", "title"}, "description": {"description", "desc"}, "createTime": {"createTime", "createdAt"}, "url": {"spaceUrl", "url"}}) + out := map[string]any{"count": len(spaces), "spaces": spaces} + addWikiPagination(out, page) + return rt.Output(out) +}) + +var SpaceSearch = wikiWithInterfaceReason(readShortcut("+space-search", "严格搜索知识库", "按名称关键词定位知识库;严格验证搜索数组,避免内部异常被误报为零命中。", "spaces", "dws wiki +space-search --query \"产品文档\" --format json", []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "搜索关键词"}, {Name: "limit", Type: shortcut.FlagString, Desc: "返回数量 1-20(默认 10)"}, +}, []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "limit", Property: "limit"}}, func(rt *shortcut.RuntimeContext) error { + pageSize, err := wikiStringInt(rt, "limit", 10, 1, 20) + if err != nil { + return err + } + data, err := rt.CallMCPData("wiki", "search_wikiSpaces", map[string]any{"keyword": rt.Str("query"), "pageSize": pageSize}) + if err != nil { + return err + } + items, page, err := requireWikiCollection(data, "wiki/search_wikiSpaces", "wikiSpaces", "spaces") + if err != nil { + return err + } + spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId", "spaceId", "id"}, "name": {"name", "spaceName", "title"}, "description": {"description", "desc"}, "url": {"spaceUrl", "url"}}) + out := map[string]any{"count": len(spaces), "spaces": spaces} + addWikiPagination(out, page) + return rt.Output(out) +}), wikiSpaceSearchCompositeReason) + +var SpaceGet = readShortcut("+space-get", "获取知识库详情", "已知 workspace ID 或知识库 URL 时读取并验证空间详情。", "", "dws wiki +space-get --workspace --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")}) + if err != nil { + return err + } + object, err := requireWikiObject(data, "wiki/get_wikiSpace") + if err != nil { + return err + } + if firstWikiString(object, "workspaceId", "spaceId", "id") == "" { + return wikiResponseError("wiki/get_wikiSpace", "missing_workspace_id", "空间详情缺少 workspaceId") + } + return rt.Output(object) +}) + +var SpaceCreate = writeShortcut("+space-create", "创建知识库并读回验证", "创建新的知识库容器;必须取得 workspaceId 并通过详情读回后才报告成功。", "dws wiki +space-create --name \"产品文档库\" --format json", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "name", Type: shortcut.FlagString, Required: true, Desc: "知识库名称,不超过 32 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "desc", Type: shortcut.FlagString, Desc: "知识库描述,不超过 500 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "icon", Type: shortcut.FlagString, Desc: "图标标识"}}, []contract.ParamDecl{{Name: "name", Property: "name"}, {Name: "desc", Property: "description"}, {Name: "icon", Property: "icon"}}, func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"name": rt.Str("name")} + if rt.Changed("desc") { + params["description"] = rt.Str("desc") + } + if rt.Changed("icon") { + params["icon"] = rt.Str("icon") + } + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/create_wikiSpace", "arguments": params}) + } + written, err := rt.CallMCPWriteDataStrict("wiki", "create_wikiSpace", params) + if err != nil { + return err + } + written, err = requireWikiWrite(written, "wiki/create_wikiSpace") + if err != nil { + return err + } + id := nestedWikiString(written, "workspaceId", "spaceId", "id") + if id == "" { + return wikiResponseError("wiki/create_wikiSpace", "missing_created_id", "创建响应没有 workspaceId;远端效果未知") + } + verified, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": id}) + if err != nil { + return err + } + verified, err = requireWikiObject(verified, "wiki/get_wikiSpace") + if err != nil { + return err + } + if firstWikiString(verified, "workspaceId", "spaceId", "id") != id { + return wikiResponseError("wiki/create_wikiSpace", "readback_id_mismatch", "创建后读回的 workspaceId 不一致") + } + return rt.Output(map[string]any{"success": true, "workspaceId": id, "space": verified}) +}) + +var DeleteSpace = writeShortcut("+delete-space", "删除知识库", "用户明确确认后将整个知识库移入回收站;删除前读取目标,删除响应必须有 success=true。", "dws wiki +delete-space --workspace --format json", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error { + preflight, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")}) + if err != nil { + return err + } + preflight, err = requireWikiObject(preflight, "wiki/get_wikiSpace") + if err != nil { + return err + } + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/delete_wikiSpace", "target": preflight}) + } + written, err := rt.CallMCPWriteDataStrict("wiki", "delete_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")}) + if err != nil { + return err + } + written, err = requireWikiWrite(written, "wiki/delete_wikiSpace") + if err != nil { + return err + } + return rt.Output(map[string]any{"success": true, "workspaceId": rt.Str("workspace"), "deleted": true}) +}) + +func memberFlags(withRole bool) []shortcut.Flag { + flags := []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "users", Type: shortcut.FlagStringSlice, Required: true, Desc: "用户 userId,最多 30 个", Aliases: []string{"user"}, AliasesVisible: true}} + if withRole { + flags = append(flags, shortcut.Flag{Name: "role", Type: shortcut.FlagString, Required: true, Desc: "角色", Enum: []string{"MANAGER", "EDITOR", "DOWNLOADER", "READER"}}) + } + return flags +} + +func memberWrite(command, tool, description, intent, example string, withRole bool) shortcut.Shortcut { + params := []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "users", Property: "userIds"}} + if withRole { + params = append(params, contract.ParamDecl{Name: "role", Property: "roleId"}) + } + return writeShortcut(command, description, intent, example, shortcut.RiskWrite, wikiWriteSafety(false), memberFlags(withRole), params, func(rt *shortcut.RuntimeContext) error { + users := wikiStringSliceFirst(rt, "users", "user") + if len(users) == 0 || len(users) > 30 { + return fmt.Errorf("--users 必须包含 1-30 个 userId") } - if rt.Changed("cursor") { - params["pageToken"] = rt.Str("cursor") + args := map[string]any{"workspaceId": rt.Str("workspace"), "userIds": users} + if withRole { + args["roleId"] = strings.ToUpper(rt.Str("role")) } - data, err := rt.CallMCPData("wiki", "list_wikiSpaces", params) + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/" + tool, "arguments": args}) + } + written, err := rt.CallMCPWriteDataStrict("wiki", tool, args) if err != nil { return err } - spaces := spaceListProject(data) - return rt.Output(map[string]any{"count": len(spaces), "spaces": spaces}) - }, -} - -// spaceListProject reshapes list_wikiSpaces into a clean space list -// ({workspaceId, name, description, createTime}) — output-projection fidelity -// for clean output. The list container and per-item field names are probed defensively -// across candidate keys, so an unrecognized shape yields an empty list. -func spaceListProject(data map[string]any) []map[string]any { - raw := wikiSpaceRawList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v := wikiSpaceFirst(m, "workspaceId", "workspace_id", "spaceId", "space_id", "id"); v != nil { - row["workspaceId"] = v - } - if v := wikiSpaceFirst(m, "name", "title", "spaceName"); v != nil { - row["name"] = v - } - if v := wikiSpaceFirst(m, "description", "desc"); v != nil { - row["description"] = v - } - if v := wikiSpaceFirst(m, "createTime", "create_time", "gmtCreate", "createdAt"); v != nil { - row["createTime"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// wikiSpaceRawList locates the space array across candidate container keys, -// tolerating a nested {result|data:{list|items|spaces}} wrapper. -func wikiSpaceRawList(data map[string]any) []any { - // list_wikiSpaces / search_wikiSpaces nest the space list under - // result.wikiSpaces (or a top-level wikiSpaces once unwrapped); "wikiSpaces" - // MUST be probed or +space-list / +space-search silently return empty. - for _, k := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "workspaces"} { - if arr, ok := data[k].([]any); ok { - return arr - } - if inner, ok := data[k].(map[string]any); ok { - for _, ik := range []string{"list", "items", "wikiSpaces", "spaces", "workspaces", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return nil -} - -// wikiSpaceFirst returns the first present value among candidate keys. -func wikiSpaceFirst(m map[string]any, keys ...string) any { - for _, k := range keys { - if v, ok := m[k]; ok { - return v - } - } - return nil -} - -// SpaceSearch → search_wikiSpaces -var SpaceSearch = shortcut.Shortcut{ - Service: "wiki", - Command: "+space-search", - Product: "wiki", - Description: "搜索知识库", - Intent: "当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "wiki", - Name: "shortcut_space_search", - CanonicalPath: "wiki.shortcut_space_search", - CLIPath: "wiki +space-search", - PrimaryCLIPath: "wiki +space-search", - }, - Description: "搜索知识库", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "搜索知识库", - UseWhen: []string{"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws wiki +space-search --query \"产品文档\""}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true}, - {Name: "limit", Type: shortcut.FlagString, Desc: "返回数量 1-20 (默认 10)"}, - }, - Tips: []string{`dws wiki +space-search --query "产品文档"`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"keyword": rt.Str("query")} - if rt.Changed("limit") { - params["pageSize"] = rt.Str("limit") - } - data, err := rt.CallMCPData("wiki", "search_wikiSpaces", params) + written, err = requireWikiWrite(written, "wiki/"+tool) if err != nil { return err } - spaces := spaceListProject(data) - return rt.Output(map[string]any{"count": len(spaces), "spaces": spaces}) - }, + return rt.Output(map[string]any{ + "success": true, "workspaceId": rt.Str("workspace"), "userCount": len(users), "operation": tool, + "verifiedBy": "write_terminal_success", + "verification": map[string]any{ + "status": "terminal_response_only", + "readbackAvailable": false, + "reason": "member_list_is_capped_and_has_no_cursor", + }, + }) + }) } -// SpaceDelete → delete_wikiSpace -// ── member (知识库成员) ─────────────────────────────────────── +var MemberAdd = memberWrite("+member-add", "add_member", "添加知识库成员", "向知识库授予一个或多个用户容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", "dws wiki +member-add --workspace --users --role READER --format json", true) +var MemberUpdate = memberWrite("+member-update", "update_member", "更新知识库成员角色", "调整已有成员的知识库容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", "dws wiki +member-update --workspace --users --role EDITOR --format json", true) +var MemberRemove = memberWrite("+member-remove", "remove_member", "移除知识库成员", "移除一个或多个用户的知识库容器级访问;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", "dws wiki +member-remove --workspace --users --format json", false) -// MemberAdd → add_member -// MemberUpdate → update_member -// MemberList → list_member -// MemberRemove → remove_member -// ── node (知识库节点,路由到 doc MCP server) ────────────────── - -// NodeList → list_nodes (doc) -var NodeList = shortcut.Shortcut{ - Service: "wiki", - Command: "+node-list", - Product: "doc", - Description: "列出知识库节点", - Intent: "当你要浏览某个知识库的目录结构、查看某文件夹下有哪些文档/子文件夹并拿到它们的 nodeId 时使用;输入 workspace(可选父节点 folder),分页返回该层级的节点列表,是逐层进入知识库定位文档的常用方式。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true}, - {Name: "folder", Type: shortcut.FlagString, Desc: "父节点 nodeId (不传则列出根目录)"}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"}, - {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, - }, - Tips: []string{`dws wiki +node-list --workspace --folder `}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"workspaceId": rt.Str("workspace")} - if rt.Changed("folder") { - params["folderId"] = rt.Str("folder") - } - if rt.Changed("limit") { - params["pageSize"] = rt.Int("limit") - } - if rt.Changed("cursor") { - params["pageToken"] = rt.Str("cursor") - } - data, err := rt.CallMCPData("wiki", "list_nodes", params) - if err != nil { - return err - } - nodes := nodeListProject(data) - return rt.Output(map[string]any{"count": len(nodes), "nodes": nodes}) - }, -} - -// nodeListProject reshapes list_nodes into a clean node list (name/nodeId/type) -// — clean output projection. Container and field keys are probed -// defensively across candidate aliases; an unrecognized shape yields an empty list. -func nodeListProject(data map[string]any) []map[string]any { - raw := nodeListRawList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v := nodeListFirst(m, "name", "title", "nodeName"); v != nil { - row["name"] = v - } - if v := nodeListFirst(m, "nodeId", "node_id", "id", "uuid", "dentryUuid"); v != nil { - row["nodeId"] = v - } - if v := nodeListFirst(m, "type", "nodeType", "docType", "fileType"); v != nil { - row["type"] = v - } - if len(row) > 0 { - out = append(out, row) - } +var MemberList = readShortcut("+member-list", "严格列出知识库成员", "列出知识库成员及角色;后端不提供可续游标且单次真实上限为 50,不伪造 page-all。", "members", "dws wiki +member-list --workspace --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "30", Desc: "返回上限 1-50"}, {Name: "filter-role", Type: shortcut.FlagStringSlice, Desc: "角色过滤"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "filter-role", Property: "filterRoleIds"}}, func(rt *shortcut.RuntimeContext) error { + if rt.Int("limit") < 1 || rt.Int("limit") > 50 { + return fmt.Errorf("--limit 必须在 1-50 之间;服务端不支持超过 50 或游标续页") } - return out -} - -// nodeListRawList locates the node array across candidate container keys, -// tolerating a nested {result|data:{list|items|nodes}} wrapper. -func nodeListRawList(data map[string]any) []any { - for _, k := range []string{"result", "data", "list", "items", "nodes"} { - if arr, ok := data[k].([]any); ok { - return arr - } - if inner, ok := data[k].(map[string]any); ok { - for _, ik := range []string{"list", "items", "nodes", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } + params := map[string]any{"workspaceId": rt.Str("workspace"), "maxResults": rt.Int("limit")} + if rt.Changed("filter-role") { + params["filterRoleIds"] = rt.StrSlice("filter-role") } - return nil -} - -// nodeListFirst returns the first present value among candidate keys. -func nodeListFirst(m map[string]any, keys ...string) any { - for _, k := range keys { - if v, ok := m[k]; ok { - return v - } + data, err := rt.CallMCPData("wiki", "list_member", params) + if err != nil { + return err } - return nil -} + items, page, err := requireWikiCollection(data, "wiki/list_member", "members") + if err != nil { + return err + } + members := projectWikiRows(items, map[string][]string{"id": {"id", "userId"}, "name": {"name", "nick"}, "role": {"role", "roleId"}, "type": {"type"}, "outer": {"outer"}}) + out := map[string]any{"count": len(members), "members": members} + addWikiPagination(out, page) + return rt.Output(out) +}) -// NodeCreate → create_file (doc) -// NodeCopy → copy_document (doc) -var NodeCopy = shortcut.Shortcut{ - Service: "wiki", - Command: "+node-copy", - Product: "doc", - Description: "复制知识库节点", - Intent: "当你想基于已有文档/文件夹快速生成一份副本(如用模板起草新文档、留档备份)时使用;指定源 node 和目标 folder,会实际在知识库中复制出一个新节点,原节点保持不变。", - Risk: shortcut.RiskWrite, - Flags: []shortcut.Flag{ - {Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true}, - {Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true}, - {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId (不传则复制到根目录)"}, - }, - Tips: []string{`dws wiki +node-copy --workspace --node --folder `}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "nodeId": rt.Str("node"), - "workspaceId": rt.Str("workspace"), - } - if rt.Changed("folder") { - params["targetFolderId"] = rt.Str("folder") - } - return rt.CallMCP("copy_document", params) - }, -} - -// NodeMove → move_document (doc) -var NodeMove = shortcut.Shortcut{ - Service: "wiki", - Command: "+node-move", - Product: "doc", - Description: "移动知识库节点", - Intent: "当你要重新整理知识库目录、把某个文档或文件夹从当前位置挪到另一个文件夹(或根目录)下时使用;指定源 node 和目标 folder,会实际改变该节点在知识库中的所属位置。", - Risk: shortcut.RiskWrite, - Flags: []shortcut.Flag{ - {Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true}, - {Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true}, - {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId (不传则移动到根目录)"}, - }, - Tips: []string{`dws wiki +node-move --workspace --node --folder `}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "nodeId": rt.Str("node"), - "workspaceId": rt.Str("workspace"), - } - if rt.Changed("folder") { - params["targetFolderId"] = rt.Str("folder") - } - return rt.CallMCP("move_document", params) - }, -} - -// NodeDelete → delete_document (doc) -// NodeSearch → search_documents (doc) func init() { - shortcut.Register( - SpaceList, - SpaceSearch, - NodeList, - NodeCopy, - NodeMove, - ) + DeleteSpace.Aliases = []string{"+space-delete"} + SpaceCreate.Validate = func(rt *shortcut.RuntimeContext) error { + if len([]rune(rt.Str("name"))) > 32 { + return fmt.Errorf("--name 不能超过 32 个字符") + } + if len([]rune(rt.Str("desc"))) > 500 { + return fmt.Errorf("--desc 不能超过 500 个字符") + } + return nil + } + SpaceCreate.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"name", "desc"}, Description: "--name 不超过 32 个字符;--desc 不超过 500 个字符"}} + enableWikiAutoPage(&SpaceList) + SpaceList.Contract.Pagination = wikiCursorPagination() + shortcut.Register(SpaceList, SpaceSearch, SpaceGet, SpaceCreate, DeleteSpace, MemberAdd, MemberUpdate, MemberList, MemberRemove) } diff --git a/internal/shortcut/wiki/workflow_coverage_test.go b/internal/shortcut/wiki/workflow_coverage_test.go new file mode 100644 index 00000000..457ceec9 --- /dev/null +++ b/internal/shortcut/wiki/workflow_coverage_test.go @@ -0,0 +1,700 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package wiki + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type wikiCoverageCall struct { + product string + tool string + args map[string]any +} + +type wikiCoverageCaller struct { + responses map[string][]string + errors map[string][]error + indexes map[string]int + calls []wikiCoverageCall + dryRun bool +} + +func (c *wikiCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + key := product + "/" + tool + c.calls = append(c.calls, wikiCoverageCall{product: product, tool: tool, args: args}) + if c.indexes == nil { + c.indexes = map[string]int{} + } + index := c.indexes[key] + c.indexes[key]++ + if sequence := c.errors[key]; index < len(sequence) && sequence[index] != nil { + return nil, sequence[index] + } + sequence := c.responses[key] + if len(sequence) == 0 { + return nil, fmt.Errorf("unexpected MCP call %s", key) + } + if index >= len(sequence) { + index = len(sequence) - 1 + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: sequence[index]}}}, nil +} + +func (c *wikiCoverageCaller) Format() string { return "json" } +func (c *wikiCoverageCaller) DryRun() bool { return c.dryRun } +func (c *wikiCoverageCaller) Fields() string { return "" } +func (c *wikiCoverageCaller) JQ() string { return "" } + +func runWikiCoverageCLI(t *testing.T, caller *wikiCoverageCaller, args ...string) (map[string]any, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("jq", "", "") + root.PersistentFlags().String("fields", "", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.AddCommand(shortcut.Commands()...) + root.SetIn(strings.NewReader("")) + stdout := &bytes.Buffer{} + root.SetOut(stdout) + root.SetErr(&bytes.Buffer{}) + root.SetArgs(append([]string{"wiki"}, args...)) + executed, err := root.ExecuteC() + if err == nil { + if _, _, emitErr := output.EmitStoredResult(executed); emitErr != nil { + err = emitErr + } + } + if stdout.Len() == 0 { + return nil, err + } + var payload map[string]any + if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil { + t.Fatalf("decode output %q: %v", stdout.String(), decodeErr) + } + if data, ok := payload["data"].(map[string]any); ok { + return data, err + } + return payload, err +} + +func TestCrossPlatformCoverageWikiSpaceWorkflows(t *testing.T) { + t.Run("list default string limit", func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{ + "wiki/list_wikiSpaces": {`{"success":true,"result":{"wikiSpaces":[{"workspaceId":"w1","name":"Docs"}],"hasMore":false}}`}, + }} + out, err := runWikiCoverageCLI(t, caller, "+space-list") + if err != nil || out["count"] != float64(1) || caller.calls[0].args["pageSize"] != 20 { + t.Fatalf("list output=%#v err=%v calls=%#v", out, err, caller.calls) + } + }) + + t.Run("list explicit legacy string limit and cursor", func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{ + "wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":false}`}, + }} + out, err := runWikiCoverageCLI(t, caller, "+space-list", "--type", "myWikiSpace", "--limit", "7", "--page-token", "next") + if err != nil || out["count"] != float64(0) || caller.calls[0].args["pageSize"] != 7 || caller.calls[0].args["pageToken"] != "next" { + t.Fatalf("list output=%#v err=%v calls=%#v", out, err, caller.calls) + } + }) + + for _, value := range []string{"bad", "0", "51"} { + t.Run("list rejects "+value, func(t *testing.T) { + caller := &wikiCoverageCaller{} + if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", value); err == nil || len(caller.calls) != 0 { + t.Fatalf("limit %q err=%v calls=%#v", value, err, caller.calls) + } + }) + } + + t.Run("search parses string limit", func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{ + "wiki/search_wikiSpaces": {`{"success":true,"wikiSpaces":[{"spaceId":"w2","spaceName":"Plan"}]}`}, + }} + out, err := runWikiCoverageCLI(t, caller, "+space-search", "--query", "Plan", "--limit", "12") + if err != nil || out["count"] != float64(1) || len(caller.calls) != 1 { + t.Fatalf("search output=%#v err=%v calls=%#v", out, err, caller.calls) + } + if got := caller.calls[0]; got.product != "wiki" || got.tool != "search_wikiSpaces" || len(got.args) != 2 || got.args["keyword"] != "Plan" || got.args["pageSize"] != 12 { + t.Fatalf("search call = %#v, want exact keyword/pageSize request", got) + } + if _, exists := caller.calls[0].args["query"]; exists { + t.Fatalf("search request leaked compatibility property query: %#v", caller.calls[0].args) + } + if _, exists := caller.calls[0].args["limit"]; exists { + t.Fatalf("search request leaked compatibility property limit: %#v", caller.calls[0].args) + } + }) + + t.Run("get requires business id", func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"name":"missing id"}}`}}} + if _, err := runWikiCoverageCLI(t, caller, "+space-get", "--workspace", "w"); err == nil { + t.Fatal("space without id succeeded") + } + }) + + t.Run("create dry-run", func(t *testing.T) { + caller := &wikiCoverageCaller{} + out, err := runWikiCoverageCLI(t, caller, "+space-create", "--name", "Docs", "--desc", "D", "--icon", "I", "--dry-run") + if err != nil || out["executed"] != false || len(caller.calls) != 0 { + t.Fatalf("dry-run output=%#v err=%v calls=%#v", out, err, caller.calls) + } + }) + + t.Run("create and exact readback", func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{ + "wiki/create_wikiSpace": {`{"success":true,"result":{"workspaceId":"w3"}}`}, + "wiki/get_wikiSpace": {`{"success":true,"data":{"workspaceId":"w3","name":"Docs"}}`}, + }} + out, err := runWikiCoverageCLI(t, caller, "+space-create", "--name", "Docs") + if err != nil || out["workspaceId"] != "w3" || len(caller.calls) != 2 { + t.Fatalf("create output=%#v err=%v calls=%#v", out, err, caller.calls) + } + }) + + t.Run("delete preflight dry-run and terminal", func(t *testing.T) { + dry := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w4","name":"Docs"}`}}} + out, err := runWikiCoverageCLI(t, dry, "+delete-space", "--workspace", "w4", "--dry-run", "--yes") + if err != nil || out["executed"] != false || len(dry.calls) != 1 { + t.Fatalf("delete dry-run output=%#v err=%v calls=%#v", out, err, dry.calls) + } + caller := &wikiCoverageCaller{responses: map[string][]string{ + "wiki/get_wikiSpace": {`{"workspaceId":"w4","name":"Docs"}`}, + "wiki/delete_wikiSpace": {`{"success":true}`}, + }} + out, err = runWikiCoverageCLI(t, caller, "+space-delete", "--workspace", "w4", "--yes") + if err != nil || out["deleted"] != true || len(caller.calls) != 2 { + t.Fatalf("delete output=%#v err=%v calls=%#v", out, err, caller.calls) + } + }) + + for _, args := range [][]string{{"--name", strings.Repeat("界", 33)}, {"--name", "x", "--desc", strings.Repeat("界", 501)}} { + caller := &wikiCoverageCaller{} + if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-create"}, args...)...); err == nil || len(caller.calls) != 0 { + t.Fatalf("invalid create args succeeded: %v", args) + } + } +} + +func TestCrossPlatformCoverageWikiMemberWritesUseTerminalEvidenceOnly(t *testing.T) { + for _, tc := range []struct { + command string + tool string + extra []string + }{ + {command: "+member-add", tool: "add_member", extra: []string{"--role", "READER"}}, + {command: "+member-update", tool: "update_member", extra: []string{"--role", "EDITOR"}}, + {command: "+member-remove", tool: "remove_member"}, + } { + t.Run(tc.command, func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/" + tc.tool: {`{"success":true}`}}} + args := append([]string{tc.command, "--workspace", "w", "--users", "u1,u2"}, tc.extra...) + out, err := runWikiCoverageCLI(t, caller, args...) + verification, _ := out["verification"].(map[string]any) + if err != nil || out["verifiedBy"] != "write_terminal_success" || verification["readbackAvailable"] != false || len(caller.calls) != 1 { + t.Fatalf("member output=%#v err=%v calls=%#v", out, err, caller.calls) + } + if caller.calls[0].tool == "list_member" { + t.Fatal("member write used capped list as readback") + } + if tc.tool != "remove_member" && caller.calls[0].args["roleId"] != strings.ToUpper(tc.extra[1]) { + t.Fatalf("role was not normalized: %#v", caller.calls[0].args) + } + }) + } + + t.Run("dry-run has no write", func(t *testing.T) { + caller := &wikiCoverageCaller{} + out, err := runWikiCoverageCLI(t, caller, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER", "--dry-run") + if err != nil || out["executed"] != false || len(caller.calls) != 0 { + t.Fatalf("dry-run output=%#v err=%v calls=%#v", out, err, caller.calls) + } + }) + + t.Run("rejects missing terminal evidence", func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/add_member": {`{"result":{"accepted":true}}`}}} + if _, err := runWikiCoverageCLI(t, caller, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER"); err == nil { + t.Fatal("member write without success=true succeeded") + } + }) + + many := make([]string, 31) + for index := range many { + many[index] = fmt.Sprintf("u%d", index) + } + caller := &wikiCoverageCaller{} + if _, err := runWikiCoverageCLI(t, caller, "+member-remove", "--workspace", "w", "--users", strings.Join(many, ",")); err == nil || len(caller.calls) != 0 { + t.Fatal("more than 30 members reached MCP") + } +} + +func TestCrossPlatformCoverageWikiMemberListContracts(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{ + "wiki/list_member": {`{"success":true,"members":[{"userId":"u1","nick":"A","roleId":"READER","type":"USER","outer":false}],"truncated":false}`}, + }} + out, err := runWikiCoverageCLI(t, caller, "+member-list", "--workspace", "w", "--limit", "50", "--filter-role", "READER,EDITOR") + if err != nil || out["count"] != float64(1) || caller.calls[0].args["maxResults"] != 50 { + t.Fatalf("member list output=%#v err=%v calls=%#v", out, err, caller.calls) + } + for _, value := range []string{"0", "51"} { + caller = &wikiCoverageCaller{} + if _, err := runWikiCoverageCLI(t, caller, "+member-list", "--workspace", "w", "--limit", value); err == nil || len(caller.calls) != 0 { + t.Fatalf("invalid member limit %s reached MCP", value) + } + } +} + +func TestCrossPlatformCoverageWikiReadWorkflows(t *testing.T) { + cases := []struct { + name string + args []string + responses map[string][]string + wantTool string + wantCount float64 + }{ + {name: "node list", args: []string{"+node-list", "--workspace", "w", "--folder", "f", "--limit", "2", "--cursor", "c"}, responses: map[string][]string{"doc/list_nodes": {`{"success":true,"nodes":[{"id":"n","title":"Doc","parentId":"f","spaceId":"w"}],"hasMore":false}`}}, wantTool: "list_nodes", wantCount: 1}, + {name: "node search", args: []string{"+node-search", "--workspace", "w", "--query", "Doc", "--extensions", "adoc", "--limit", "2", "--cursor", "c"}, responses: map[string][]string{"doc/search_documents": {`{"success":true,"documents":[],"hasMore":false}`}}, wantTool: "search_documents", wantCount: 0}, + {name: "feed list", args: []string{"+feed-list", "--workspace", "w", "--limit", "2", "--cursor", "c", "--exclude-file"}, responses: map[string][]string{"wiki/list_workspace_feeds": {`{"success":true,"feeds":[{"feedId":"x","feedType":"update","fileId":"n"}],"hasMore":false}`}}, wantTool: "list_workspace_feeds", wantCount: 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &wikiCoverageCaller{responses: tc.responses} + out, err := runWikiCoverageCLI(t, caller, tc.args...) + if err != nil || out["count"] != tc.wantCount || caller.calls[0].tool != tc.wantTool { + t.Fatalf("output=%#v err=%v calls=%#v", out, err, caller.calls) + } + }) + } + + t.Run("node get", func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true,"result":{"nodeId":"n","title":"Doc"}}`}}} + out, err := runWikiCoverageCLI(t, caller, "+node-get", "--node", "n") + if err != nil || out["nodeId"] != "n" { + t.Fatalf("node get output=%#v err=%v", out, err) + } + }) +} + +func TestCrossPlatformCoverageWikiWriteWorkflows(t *testing.T) { + t.Run("confirmation gates precede every remote call", func(t *testing.T) { + for _, tc := range []struct { + name string + args []string + }{ + {name: "delete space", args: []string{"+delete-space", "--workspace", "w"}}, + {name: "copy node", args: []string{"+node-copy", "--workspace", "w", "--node", "source"}}, + {name: "move node", args: []string{"+move", "--workspace", "target", "--node", "n"}}, + {name: "move node to drive", args: []string{"+move-to-drive", "--node", "n"}}, + {name: "delete node", args: []string{"+node-delete", "--workspace", "w", "--node", "n"}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &wikiCoverageCaller{} + _, err := runWikiCoverageCLI(t, caller, tc.args...) + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "confirmation_required" { + t.Fatalf("unconfirmed error = %#v, want confirmation_required", err) + } + if len(caller.calls) != 0 { + t.Fatalf("unconfirmed shortcut reached MCP: %#v", caller.calls) + } + }) + } + }) + + t.Run("node create dry and verified", func(t *testing.T) { + dry := &wikiCoverageCaller{} + out, err := runWikiCoverageCLI(t, dry, "+node-create", "--workspace", "w", "--folder", "f", "--name", "Doc", "--type", "adoc", "--dry-run") + if err != nil || out["executed"] != false || len(dry.calls) != 0 { + t.Fatalf("create dry output=%#v err=%v calls=%#v", out, err, dry.calls) + } + caller := &wikiCoverageCaller{responses: map[string][]string{ + "doc/create_file": {`{"success":true,"data":{"fileId":"n"}}`}, + "doc/get_document_info": {`{"success":true,"nodeId":"n","workspaceId":"w","folderId":"f"}`}, + }} + out, err = runWikiCoverageCLI(t, caller, "+node-create", "--workspace", "w", "--folder", "f", "--name", "Doc") + if err != nil || out["nodeId"] != "n" || len(caller.calls) != 2 { + t.Fatalf("create output=%#v err=%v calls=%#v", out, err, caller.calls) + } + }) + + t.Run("node copy dry and verified", func(t *testing.T) { + dry := &wikiCoverageCaller{} + out, err := runWikiCoverageCLI(t, dry, "+node-copy", "--workspace", "w", "--folder", "f", "--node", "source", "--dry-run", "--yes") + if err != nil || out["executed"] != false || len(dry.calls) != 0 { + t.Fatalf("copy dry output=%#v err=%v calls=%#v", out, err, dry.calls) + } + caller := &wikiCoverageCaller{responses: map[string][]string{ + "doc/copy_document": {`{"success":true,"nodeId":"copy"}`}, + "doc/get_document_info": {`{"success":true,"nodeId":"copy","workspaceId":"w"}`}, + }} + out, err = runWikiCoverageCLI(t, caller, "+node-copy", "--workspace", "w", "--node", "source", "--yes") + if err != nil || out["nodeId"] != "copy" || len(caller.calls) != 2 { + t.Fatalf("copy output=%#v err=%v calls=%#v", out, err, caller.calls) + } + }) + + t.Run("move within wiki and to drive", func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{ + "doc/get_document_info": {`{"nodeId":"n","workspaceId":"old","folderId":"old-f"}`, `{"nodeId":"n","workspaceId":"target","folderId":"f"}`}, + "doc/move_document": {`{"success":true}`}, + }} + out, err := runWikiCoverageCLI(t, caller, "+move", "--workspace", "target", "--folder", "f", "--node", "n", "--yes") + if err != nil || out["nodeId"] != "n" || len(caller.calls) != 3 { + t.Fatalf("move output=%#v err=%v calls=%#v", out, err, caller.calls) + } + caller = &wikiCoverageCaller{responses: map[string][]string{ + "doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"drive"}`}, + "doc/move_document": {`{"success":true}`}, + }} + out, err = runWikiCoverageCLI(t, caller, "+move-to-drive", "--node", "n", "--yes") + if err != nil || out["nodeId"] != "n" { + t.Fatalf("move-to-drive output=%#v err=%v calls=%#v", out, err, caller.calls) + } + }) + + t.Run("move dry-run only preflights", func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}}} + out, err := runWikiCoverageCLI(t, caller, "+move", "--workspace", "target", "--node", "n", "--dry-run", "--yes") + if err != nil || out["executed"] != false || len(caller.calls) != 1 { + t.Fatalf("move dry output=%#v err=%v calls=%#v", out, err, caller.calls) + } + }) + + t.Run("node delete dry and terminal", func(t *testing.T) { + dry := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}}} + out, err := runWikiCoverageCLI(t, dry, "+node-delete", "--workspace", "w", "--node", "n", "--dry-run", "--yes") + if err != nil || out["executed"] != false || len(dry.calls) != 1 { + t.Fatalf("delete dry output=%#v err=%v calls=%#v", out, err, dry.calls) + } + caller := &wikiCoverageCaller{responses: map[string][]string{ + "doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}, + "doc/delete_document": {`{"success":true}`}, + }} + out, err = runWikiCoverageCLI(t, caller, "+node-delete", "--workspace", "w", "--node", "n", "--yes") + if err != nil || out["deleted"] != true || len(caller.calls) != 2 { + t.Fatalf("delete output=%#v err=%v calls=%#v", out, err, caller.calls) + } + }) +} + +func TestCrossPlatformCoverageWikiResponseValidationBranches(t *testing.T) { + if _, err := requireWikiResponse(nil, "op"); err == nil { + t.Fatal("empty response accepted") + } + for _, data := range []map[string]any{{"success": "yes"}, {"success": false}, {"success": false, "message": "denied"}} { + if _, err := requireWikiResponse(data, "op"); err == nil { + t.Fatalf("invalid response accepted: %#v", data) + } + } + if _, err := requireWikiWrite(map[string]any{"result": map[string]any{"id": "x"}}, "op"); err == nil { + t.Fatal("write without terminal success accepted") + } + if _, err := requireWikiWrite(map[string]any{"success": false}, "op"); err == nil { + t.Fatal("failed write response accepted") + } + for _, data := range []map[string]any{ + {"success": false}, {"result": "bad"}, {"result": map[string]any{}}, {"only": true}, + } { + if _, err := requireWikiObject(data, "op"); err == nil { + t.Fatalf("invalid object accepted: %#v", data) + } + } + if object, err := requireWikiObject(map[string]any{"success": true, "id": "x"}, "op"); err != nil || object["id"] != "x" { + t.Fatalf("direct object=%#v err=%v", object, err) + } + for _, data := range []map[string]any{ + {"success": false}, {"result": "bad"}, {"result": map[string]any{"items": "bad"}}, {"items": []any{1}}, {"success": true}, + } { + if _, _, err := requireWikiCollection(data, "op", "items"); err == nil { + t.Fatalf("invalid collection accepted: %#v", data) + } + } + if value := nestedWikiString(map[string]any{"data": map[string]any{"id": " nested "}}, "id"); value != "nested" { + t.Fatalf("nested string=%q", value) + } + if value := nestedWikiString(map[string]any{"id": " direct "}, "id"); value != "direct" { + t.Fatalf("direct string=%q", value) + } + if value := nestedWikiString(map[string]any{"id": 1}, "id"); value != "" { + t.Fatalf("non-string=%q", value) + } + + page := map[string]any{"nextToken": "n", "hasMore": true, "truncated": false, "totalCount": 1, "autoPageComplete": true, "autoPageStopReason": "done", "pagesFetched": 2} + out := map[string]any{} + addWikiPagination(out, page) + for _, key := range []string{"nextCursor", "hasMore", "truncated", "totalCount", "autoPageComplete", "autoPageStopReason", "pagesFetched"} { + if _, ok := out[key]; !ok { + t.Fatalf("pagination output missing %s: %#v", key, out) + } + } + rows := projectWikiRows([]any{map[string]any{"legacy": "x", "empty": nil}}, map[string][]string{"value": {"empty", "legacy"}}) + if len(rows) != 1 || rows[0]["value"] != "x" { + t.Fatalf("projected rows=%#v", rows) + } +} + +func TestCrossPlatformCoverageWikiAliasAndCancellationBranches(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/remove_member": {`{"success":true}`}}} + out, err := runWikiCoverageCLI(t, caller, "+member-remove", "--workspace", "w", "--user", "u1") + if err != nil || out["userCount"] != float64(1) { + t.Fatalf("visible member alias output=%#v err=%v calls=%#v", out, err, caller.calls) + } + + cmd := &cobra.Command{Use: "probe"} + cmd.Flags().StringSlice("users", nil, "") + cmd.Flags().StringSlice("user", nil, "") + rt := shortcut.RuntimeContextForTest(cmd, MemberRemove) + if got := wikiStringSliceFirst(rt, "users", "user"); len(got) != 0 { + t.Fatalf("unset string slice=%v", got) + } + + pageCmd := &cobra.Command{Use: "page"} + pageCmd.Flags().Bool("page-all", true, "") + pageCmd.Flags().Int("page-limit", 2, "") + pageCmd.Flags().Int("max-items", 0, "") + pageCmd.Flags().Int("page-delay", 1, "") + pageCmd.Flags().String("cursor", "", "") + pageCmd.Flags().String("page-token", "", "") + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + pageCmd.SetContext(cancelled) + pageRT := shortcut.RuntimeContextForTest(pageCmd, SpaceList) + _, _, err = collectWikiPages(pageRT, "probe", 1, []string{"items"}, func(string, int) (map[string]any, error) { + return map[string]any{"items": []any{}, "hasMore": true, "nextCursor": "next"}, nil + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled page delay err=%v", err) + } +} + +func TestCrossPlatformCoverageWikiPaginationFailureModes(t *testing.T) { + t.Run("two pages", func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{ + "wiki/list_wikiSpaces": { + `{"wikiSpaces":[{"workspaceId":"w1"}],"hasMore":true,"nextCursor":"next"}`, + `{"wikiSpaces":[{"workspaceId":"w2"}],"hasMore":false}`, + }, + }} + out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "1", "--page-all", "--page-limit", "2") + if err != nil || out["count"] != float64(2) || out["autoPageComplete"] != true { + t.Fatalf("pagination output=%#v err=%v calls=%#v", out, err, caller.calls) + } + }) + + t.Run("max items trims first page", func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{ + "wiki/list_wikiSpaces": {`{"wikiSpaces":[{"workspaceId":"w1"},{"workspaceId":"w2"}],"hasMore":true,"nextCursor":"next"}`}, + }} + out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "2", "--page-all", "--max-items", "1") + spaces, ok := out["spaces"].([]any) + if err != nil || !ok || out["count"] != float64(1) || len(spaces) != 1 || out["autoPageStopReason"] != "max_items" { + t.Fatalf("max-items output=%#v err=%v", out, err) + } + }) + + t.Run("max items trims remaining page", func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{ + "wiki/list_wikiSpaces": { + `{"wikiSpaces":[{"workspaceId":"w1"},{"workspaceId":"w2"}],"hasMore":true,"nextCursor":"next"}`, + `{"wikiSpaces":[{"workspaceId":"w3"},{"workspaceId":"w4"}],"hasMore":false}`, + }, + }} + out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "2", "--page-all", "--page-limit", "2", "--max-items", "3") + spaces, ok := out["spaces"].([]any) + if err != nil || !ok || out["count"] != float64(3) || len(spaces) != 3 || out["autoPageStopReason"] != "max_items" { + t.Fatalf("max-items output=%#v err=%v calls=%#v", out, err, caller.calls) + } + if len(caller.calls) != 2 || caller.calls[1].args["pageSize"] != 1 { + t.Fatalf("remaining page request=%#v, want pageSize=1", caller.calls) + } + third, ok := spaces[2].(map[string]any) + if !ok || third["workspaceId"] != "w3" { + t.Fatalf("trimmed spaces=%#v, want w3 as final item", spaces) + } + }) + + cases := []struct { + name string + response string + args []string + }{ + {name: "missing has more", response: `{"wikiSpaces":[]}`, args: []string{"--page-all"}}, + {name: "malformed has more", response: `{"wikiSpaces":[],"hasMore":"yes"}`, args: []string{"--page-all"}}, + {name: "missing cursor", response: `{"wikiSpaces":[],"hasMore":true}`, args: []string{"--page-all"}}, + {name: "stalled cursor", response: `{"wikiSpaces":[],"hasMore":true,"nextCursor":"same"}`, args: []string{"--cursor", "same", "--page-all"}}, + {name: "page limit", response: `{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`, args: []string{"--page-all", "--page-limit", "1"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/list_wikiSpaces": {tc.response}}} + if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-list"}, tc.args...)...); err == nil { + t.Fatalf("pagination failure %s succeeded", tc.name) + } + }) + } + + for _, args := range [][]string{{"--page-limit", "0", "--page-all"}, {"--max-items", "1"}, {"--page-delay", "1"}} { + caller := &wikiCoverageCaller{} + if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-list"}, args...)...); err == nil || len(caller.calls) != 0 { + t.Fatalf("invalid auto-page controls succeeded: %v", args) + } + } +} + +func TestCrossPlatformCoverageWikiMCPFailuresPropagate(t *testing.T) { + caller := &wikiCoverageCaller{ + responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[]}`}}, + errors: map[string][]error{"wiki/list_wikiSpaces": {errors.New("read failed")}}, + } + if _, err := runWikiCoverageCLI(t, caller, "+space-list"); err == nil { + t.Fatal("MCP failure was swallowed") + } +} + +func TestCrossPlatformCoverageWikiSpaceFailureBranches(t *testing.T) { + assertErr := func(name string, caller *wikiCoverageCaller, args ...string) { + t.Helper() + t.Run(name, func(t *testing.T) { + if _, err := runWikiCoverageCLI(t, caller, args...); err == nil { + t.Fatalf("%s unexpectedly succeeded; calls=%#v", name, caller.calls) + } + }) + } + backend := errors.New("backend failed") + assertErr("search invalid limit", &wikiCoverageCaller{}, "+space-search", "--query", "x", "--limit", "bad") + assertErr("search call", &wikiCoverageCaller{errors: map[string][]error{"wiki/search_wikiSpaces": {backend}}}, "+space-search", "--query", "x") + assertErr("search collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/search_wikiSpaces": {`{"success":true}`}}}, "+space-search", "--query", "x") + assertErr("get call", &wikiCoverageCaller{errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+space-get", "--workspace", "w") + assertErr("get object", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true}`}}}, "+space-get", "--workspace", "w") + caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w","name":"Docs"}`}}} + if out, err := runWikiCoverageCLI(t, caller, "+space-get", "--workspace", "w"); err != nil || out["workspaceId"] != "w" { + t.Fatalf("space get output=%#v err=%v", out, err) + } + + assertErr("create write call", &wikiCoverageCaller{errors: map[string][]error{"wiki/create_wikiSpace": {backend}}}, "+space-create", "--name", "Docs") + assertErr("create terminal", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"result":{"workspaceId":"w"}}`}}}, "+space-create", "--name", "Docs") + assertErr("create id", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true}`}}}, "+space-create", "--name", "Docs") + assertErr("create readback call", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}}, errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+space-create", "--name", "Docs") + assertErr("create readback object", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}, "wiki/get_wikiSpace": {`{"success":true}`}}}, "+space-create", "--name", "Docs") + assertErr("create readback mismatch", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}, "wiki/get_wikiSpace": {`{"workspaceId":"other"}`}}}, "+space-create", "--name", "Docs") + spaceMismatch := &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}, "wiki/get_wikiSpace": {`{"success":true,"data":{"workspaceId":"other"}}`}}} + if _, err := runWikiCoverageCLI(t, spaceMismatch, "+space-create", "--name", "Docs"); err == nil || !strings.Contains(err.Error(), "不一致") { + t.Fatalf("space create mismatch error=%v calls=%#v", err, spaceMismatch.calls) + } + + assertErr("delete preflight call", &wikiCoverageCaller{errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+delete-space", "--workspace", "w", "--yes") + assertErr("delete preflight object", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true}`}}}, "+delete-space", "--workspace", "w", "--yes") + assertErr("delete write call", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w"}`}}, errors: map[string][]error{"wiki/delete_wikiSpace": {backend}}}, "+delete-space", "--workspace", "w", "--yes") + assertErr("delete terminal", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w"}`}, "wiki/delete_wikiSpace": {`{"result":{}}`}}}, "+delete-space", "--workspace", "w", "--yes") + delWrite := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}}, errors: map[string][]error{"wiki/delete_wikiSpace": {backend}}} + if _, err := runWikiCoverageCLI(t, delWrite, "+delete-space", "--workspace", "w", "--yes"); err == nil || !strings.Contains(err.Error(), "backend failed") { + t.Fatalf("delete write error=%v calls=%#v", err, delWrite.calls) + } + delTerminal := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}, "wiki/delete_wikiSpace": {`{"result":{}}`}}} + if _, err := runWikiCoverageCLI(t, delTerminal, "+delete-space", "--workspace", "w", "--yes"); err == nil || !strings.Contains(err.Error(), "success=true") { + t.Fatalf("delete terminal error=%v calls=%#v", err, delTerminal.calls) + } + + assertErr("member list call", &wikiCoverageCaller{errors: map[string][]error{"wiki/list_member": {backend}}}, "+member-list", "--workspace", "w") + assertErr("member list collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/list_member": {`{"success":true}`}}}, "+member-list", "--workspace", "w") + assertErr("member write call", &wikiCoverageCaller{errors: map[string][]error{"wiki/add_member": {backend}}}, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER") +} + +func TestCrossPlatformCoverageWikiNodeFailureBranches(t *testing.T) { + assertErr := func(name string, caller *wikiCoverageCaller, args ...string) { + t.Helper() + t.Run(name, func(t *testing.T) { + if _, err := runWikiCoverageCLI(t, caller, args...); err == nil { + t.Fatalf("%s unexpectedly succeeded; calls=%#v", name, caller.calls) + } + }) + } + backend := errors.New("backend failed") + assertErr("list call", &wikiCoverageCaller{errors: map[string][]error{"doc/list_nodes": {backend}}}, "+node-list", "--workspace", "w") + assertErr("list collection", &wikiCoverageCaller{responses: map[string][]string{"doc/list_nodes": {`{"success":true}`}}}, "+node-list", "--workspace", "w") + assertErr("get call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, "+node-get", "--node", "n") + assertErr("get object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, "+node-get", "--node", "n") + assertErr("get id", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"name":"Doc"}`}}}, "+node-get", "--node", "n") + missingID := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true,"result":{"name":"Doc"}}`}}} + if _, err := runWikiCoverageCLI(t, missingID, "+node-get", "--node", "n"); err == nil || !strings.Contains(err.Error(), "nodeId") { + t.Fatalf("missing node id error=%v calls=%#v", err, missingID.calls) + } + assertErr("search call", &wikiCoverageCaller{errors: map[string][]error{"doc/search_documents": {backend}}}, "+node-search", "--workspace", "w", "--query", "x") + assertErr("search collection", &wikiCoverageCaller{responses: map[string][]string{"doc/search_documents": {`{"success":true}`}}}, "+node-search", "--workspace", "w", "--query", "x") + + createArgs := []string{"+node-create", "--workspace", "w", "--name", "Doc"} + assertErr("create write", &wikiCoverageCaller{errors: map[string][]error{"doc/create_file": {backend}}}, createArgs...) + assertErr("create terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"result":{"fileId":"n"}}`}}}, createArgs...) + assertErr("create id", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true}`}}}, createArgs...) + assertErr("create readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}}, errors: map[string][]error{"doc/get_document_info": {backend}}}, createArgs...) + assertErr("create readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"success":true}`}}}, createArgs...) + assertErr("create mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"nodeId":"other"}`}}}, createArgs...) + createMismatch := &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"result":{"fileId":"n"}}`}, "doc/get_document_info": {`{"success":true,"result":{"nodeId":"other"}}`}}} + if _, err := runWikiCoverageCLI(t, createMismatch, createArgs...); err == nil || !strings.Contains(err.Error(), "不一致") { + t.Fatalf("create mismatch error=%v calls=%#v", err, createMismatch.calls) + } + + copyArgs := []string{"+node-copy", "--workspace", "w", "--node", "source", "--yes"} + assertErr("copy write", &wikiCoverageCaller{errors: map[string][]error{"doc/copy_document": {backend}}}, copyArgs...) + assertErr("copy terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"result":{"fileId":"n"}}`}}}, copyArgs...) + assertErr("copy id", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true}`}}}, copyArgs...) + assertErr("copy readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"fileId":"n"}`}}, errors: map[string][]error{"doc/get_document_info": {backend}}}, copyArgs...) + assertErr("copy readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"success":true}`}}}, copyArgs...) + copyMismatch := &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"result":{"fileId":"n"}}`}, "doc/get_document_info": {`{"success":true,"result":{"nodeId":"other"}}`}}} + if _, err := runWikiCoverageCLI(t, copyMismatch, copyArgs...); err == nil || !strings.Contains(err.Error(), "不一致") { + t.Fatalf("copy mismatch error=%v calls=%#v", err, copyMismatch.calls) + } + + moveArgs := []string{"+move", "--workspace", "target", "--node", "n", "--yes"} + assertErr("move preflight call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, moveArgs...) + assertErr("move preflight object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, moveArgs...) + assertErr("move write", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}}, errors: map[string][]error{"doc/move_document": {backend}}}, moveArgs...) + assertErr("move terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}, "doc/move_document": {`{"result":{}}`}}}, moveArgs...) + assertErr("move readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}, "doc/move_document": {`{"success":true}`}}, errors: map[string][]error{"doc/get_document_info": {nil, backend}}}, moveArgs...) + assertErr("move readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"success":true}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...) + assertErr("move id mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"other","workspaceId":"target"}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...) + assertErr("move workspace mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"other"}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...) + assertErr("move folder mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"target","folderId":"other"}`}, "doc/move_document": {`{"success":true}`}}}, append(moveArgs, "--folder", "f")...) + assertErr("drive move unchanged", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"same"}`, `{"nodeId":"n","workspaceId":"same"}`}, "doc/move_document": {`{"success":true}`}}}, "+move-to-drive", "--node", "n", "--yes") + + deleteArgs := []string{"+node-delete", "--workspace", "w", "--node", "n", "--yes"} + assertErr("delete preflight call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, deleteArgs...) + assertErr("delete preflight object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, deleteArgs...) + assertErr("delete workspace", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"other"}`}}}, deleteArgs...) + assertErr("delete write", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}}, errors: map[string][]error{"doc/delete_document": {backend}}}, deleteArgs...) + assertErr("delete terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}, "doc/delete_document": {`{"result":{}}`}}}, deleteArgs...) + assertErr("feed collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/list_workspace_feeds": {`{"success":true}`}}}, "+feed-list", "--workspace", "w") +} + +func TestCrossPlatformCoverageWikiSecondPageFailures(t *testing.T) { + caller := &wikiCoverageCaller{ + responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`}}, + errors: map[string][]error{"wiki/list_wikiSpaces": {nil, errors.New("second page failed")}}, + } + if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--page-all"); err == nil { + t.Fatal("second-page transport error was swallowed") + } + caller = &wikiCoverageCaller{responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`, `{"success":true}`}}} + if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--page-all"); err == nil { + t.Fatal("second-page malformed collection was swallowed") + } +} diff --git a/scripts/ci/changed-test-packages.sh b/scripts/ci/changed-test-packages.sh index 3558b689..37d31e1c 100755 --- a/scripts/ci/changed-test-packages.sh +++ b/scripts/ci/changed-test-packages.sh @@ -7,16 +7,32 @@ set -eu ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" usage() { - printf '%s\n' "usage: $0 BASE_REF HEAD_REF" >&2 + printf '%s\n' \ + "usage: $0 BASE_REF HEAD_REF" \ + " $0 list-shard SHARD BASE_REF HEAD_REF" >&2 exit 2 } -[ "${1:-}" = changed ] || [ "${1:-}" = list ] || usage -[ "$#" -eq 3 ] || usage - -MODE="$1" -BASE_REF="$2" -HEAD_REF="$3" +# list-shard narrows the impacted set to one test shard so focused CI runs can +# fan the same package plan across the shard matrix instead of testing every +# impacted package in a single long-lived job. +SHARD="" +case "${1:-}" in + changed|list) + [ "$#" -eq 3 ] || usage + MODE="$1" + BASE_REF="$2" + HEAD_REF="$3" + ;; + list-shard) + [ "$#" -eq 4 ] || usage + MODE="$1" + SHARD="$2" + BASE_REF="$3" + HEAD_REF="$4" + ;; + *) usage ;; +esac cd "$ROOT" git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || { @@ -116,9 +132,29 @@ while IFS= read -r file; do [ "$embedded_owner_found" = true ] || continue done < "$files" +# emit_selection prints the planned packages, restricted to SHARD when the +# caller asked for one. Restriction reuses scripts/ci/test-packages.sh as the +# single source of shard membership, which also means an unknown shard name +# aborts there instead of being reported as an empty selection: a silently empty +# shard would let a mistyped CI shard name skip every test and still succeed. +emit_selection() { + selection="$1" + if [ -z "$SHARD" ]; then + cat "$selection" + return 0 + fi + shard_packages="$workdir/shard-packages" + "$ROOT/scripts/ci/test-packages.sh" list "$SHARD" > "$shard_packages" + LC_ALL=C sort -u "$shard_packages" -o "$shard_packages" + LC_ALL=C sort -u "$selection" -o "$selection" + # An empty intersection is a normal outcome for a shard no change touched, so + # comm's silence must not be treated as an error. + LC_ALL=C comm -12 "$selection" "$shard_packages" +} + LC_ALL=C sort -u "$changed_packages" -o "$changed_packages" if [ "$MODE" = changed ] || [ ! -s "$changed_packages" ]; then - cat "$changed_packages" + emit_selection "$changed_packages" exit 0 fi @@ -133,4 +169,5 @@ while IFS= read -r package; do done < "$all_packages" > "$impacted_packages" cat "$changed_packages" >> "$impacted_packages" -LC_ALL=C sort -u "$impacted_packages" +LC_ALL=C sort -u "$impacted_packages" -o "$impacted_packages" +emit_selection "$impacted_packages" diff --git a/scripts/ci/run-app-race-tests.sh b/scripts/ci/run-app-race-tests.sh index 9922e9ab..3f586dd0 100755 --- a/scripts/ci/run-app-race-tests.sh +++ b/scripts/ci/run-app-race-tests.sh @@ -6,16 +6,35 @@ ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" usage() { printf '%s\n' \ "usage: $0 verify " \ - " $0 run " >&2 + " $0 run [partition]" \ + " $0 list-partitions" >&2 exit 2 } -[ "$#" -eq 2 ] || usage -mode="$1" -app_package="$2" +# Single source of truth for the partition set. CI runs one job per partition and +# pins its shard names to this list, so a name that appears here without a +# dispatch entry below fails closed rather than silently skipping tests. +APP_PARTITIONS='schema a-b c d-r s-z-example-fuzz' +mode="${1:-}" +partition="" case "$mode" in - verify|run) ;; + list-partitions) + [ "$#" -eq 1 ] || usage + for name in $APP_PARTITIONS; do + printf '%s\n' "$name" + done + exit 0 + ;; + verify) + [ "$#" -eq 2 ] || usage + app_package="$2" + ;; + run) + [ "$#" -eq 2 ] || [ "$#" -eq 3 ] || usage + app_package="$2" + partition="${3:-}" + ;; *) usage ;; esac @@ -70,21 +89,49 @@ if [ "$unmatched_count" -ne 0 ]; then exit 1 fi -for partition in \ +# The loop variable is deliberately not named "partition": that name holds the +# partition requested on the command line, and run mode still executes this +# discovery pass before dispatching. +classified='' +for spec in \ "schema:$schema_count" \ "a-b:$ab_count" \ "c:$c_count" \ "d-r:$dr_count" \ "s-z-example-fuzz:$sz_count" do - name="${partition%%:*}" - count="${partition#*:}" + name="${spec%%:*}" + count="${spec#*:}" + classified="$classified $name" if [ "$count" -eq 0 ]; then printf 'app race partition %s is empty\n' "$name" >&2 exit 1 fi done +# The counters above and APP_PARTITIONS must describe the same set in both +# directions. A counted partition missing from APP_PARTITIONS would never be +# dispatched by any CI job, and a dispatchable partition with no counter would +# escape the exact-coverage check above. +for name in $APP_PARTITIONS; do + case " $classified " in + *" $name "*) ;; + *) + printf 'app partition %s has no coverage counter\n' "$name" >&2 + exit 1 + ;; + esac +done +for name in $classified; do + case " $APP_PARTITIONS " in + *" $name "*) ;; + *) + printf 'counted partition %s is not a dispatchable app partition\n' "$name" >&2 + exit 1 + ;; + esac +done + total_count="$(wc -l < "$tests" | tr -d ' ')" assigned_count=$((schema_count + ab_count + c_count + dr_count + sz_count)) if [ "$assigned_count" -ne "$total_count" ]; then @@ -101,26 +148,73 @@ fi run_partition() { name="$1" - run_pattern="$2" - skip_pattern="${3:-}" + instrumentation="$2" + run_pattern="$3" + skip_pattern="${4:-}" - printf 'running internal/app race partition %s\n' "$name" + # Fail closed on an unrecognized mode: a typo must not silently drop race + # instrumentation from a partition that is supposed to carry it. + case "$instrumentation" in + race|no-race) ;; + *) + printf 'unknown instrumentation %s for app partition %s\n' \ + "$instrumentation" "$name" >&2 + exit 1 + ;; + esac + + printf 'running internal/app %s partition %s\n' "$instrumentation" "$name" + set -- -v -count=1 -timeout=15m -run "$run_pattern" if [ -n "$skip_pattern" ]; then - go test -v -race -count=1 -timeout=15m \ - -run "$run_pattern" -skip "$skip_pattern" "$app_package" - else - go test -v -race -count=1 -timeout=15m \ - -run "$run_pattern" "$app_package" + set -- "$@" -skip "$skip_pattern" fi + if [ "$instrumentation" = race ]; then + set -- -race "$@" + fi + go test "$@" "$app_package" } # Schema assembly has the largest transient memory footprint. Run those tests # in a fresh process, then keep each remaining name range in its own process so # command trees retained by process-global registries are released between # partitions. The complementary run/skip patterns preserve the full test set. +# +# The schema partition runs uninstrumented. Its tests assert structural +# Schema-to-Cobra contracts over a single goroutine: none of them call +# t.Parallel or start a goroutine, so the race detector has no concurrent access +# to observe here. The process-global lazy metadata that does need race coverage +# (schema_source_root's atomic.Value, the parameter-binding lazy loaders) is +# exercised by internal/cli's concurrent tests, which stay instrumented. The +# instrumentation is not free on this partition: its shared sync.Once Catalog +# build is allocation-heavy, and -race made the partition roughly 11x slower +# (26s -> 291s locally, 357s in CI) without being able to report anything. schema_pattern='^Test.*Schema' -run_partition schema "$schema_pattern" -run_partition a-b '^Test[A-B]' "$schema_pattern" -run_partition c '^TestC' "$schema_pattern" -run_partition d-r '^Test[D-R]' "$schema_pattern" -run_partition s-z-example-fuzz '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern" + +# Dispatch table for the partition set declared in APP_PARTITIONS. CI passes one +# partition per job so they run concurrently; running without a partition keeps +# the original end-to-end behaviour for local use and for any caller that wants +# the whole package in one invocation. +run_named_partition() { + case "$1" in + schema) run_partition schema no-race "$schema_pattern" ;; + a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;; + c) run_partition c race '^TestC' "$schema_pattern" ;; + d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;; + s-z-example-fuzz) + run_partition s-z-example-fuzz race '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern" + ;; + *) + printf 'unknown app partition: %s\n' "$1" >&2 + exit 1 + ;; + esac +} + +if [ -n "$partition" ]; then + run_named_partition "$partition" + exit 0 +fi + +for name in $APP_PARTITIONS; do + run_named_partition "$name" +done diff --git a/scripts/ci/test-packages.sh b/scripts/ci/test-packages.sh index 6d57298c..fd36ed46 100755 --- a/scripts/ci/test-packages.sh +++ b/scripts/ci/test-packages.sh @@ -7,6 +7,7 @@ MODULE="$(cd "$ROOT" && go list -m)" usage() { printf '%s\n' \ "usage: $0 list " \ + " $0 list-coverage " \ " $0 verify" >&2 exit 2 } @@ -57,6 +58,52 @@ list_shard() { esac } +# Full-suite coverage measurement shards the same authoritative package set the +# single serial run used: ./ ./cmd/... ./internal/... ./skills/... (pkg/ and +# scripts/ belong to the supporting profile; test/ suites carry no production +# statements for the candidate profile). Shards stay disjoint so their merged +# profile is block-for-block equivalent to one serial invocation. +list_coverage_scope() { + cd "$ROOT" + go list ./ ./cmd/... ./internal/... ./skills/... +} + +list_coverage_shard() { + shard="$1" + cd "$ROOT" + + case "$shard" in + app) + go list ./internal/app/... + ;; + cli) + go list ./internal/cli/... + ;; + generators) + go list ./internal/generator/... + ;; + helpers) + go list ./internal/helpers/... + ;; + remaining) + scope_packages="$(list_coverage_scope)" + printf '%s\n' "$scope_packages" | while IFS= read -r package; do + case "$package" in + "$MODULE/internal/app"|"$MODULE/internal/app/"*) ;; + "$MODULE/internal/cli"|"$MODULE/internal/cli/"*) ;; + "$MODULE/internal/generator"|"$MODULE/internal/generator/"*) ;; + "$MODULE/internal/helpers"|"$MODULE/internal/helpers/"*) ;; + *) printf '%s\n' "$package" ;; + esac + done + ;; + *) + printf 'unknown coverage package shard: %s\n' "$shard" >&2 + exit 2 + ;; + esac +} + verify_plan() { workdir="$(mktemp -d "${TMPDIR:-/tmp}/dws-test-packages.XXXXXX")" trap 'rm -rf "$workdir"' EXIT HUP INT TERM @@ -114,6 +161,58 @@ verify_plan() { package_count="$(wc -l < "$expected" | tr -d ' ')" printf 'test package plan covers %s default packages exactly once\n' "$package_count" + + coverage_expected="$workdir/coverage-expected" + coverage_assigned="$workdir/coverage-assigned" + coverage_unique="$workdir/coverage-unique" + coverage_duplicates="$workdir/coverage-duplicates" + coverage_missing="$workdir/coverage-missing" + coverage_unexpected="$workdir/coverage-unexpected" + + list_coverage_scope > "$workdir/coverage-scope" + LC_ALL=C sort -u "$workdir/coverage-scope" > "$coverage_expected" + : > "$coverage_assigned" + + for shard in app cli generators helpers remaining; do + shard_packages="$workdir/coverage-$shard" + unsorted_shard_packages="$workdir/coverage-$shard.unsorted" + list_coverage_shard "$shard" > "$unsorted_shard_packages" + LC_ALL=C sort "$unsorted_shard_packages" > "$shard_packages" + if [ ! -s "$shard_packages" ]; then + printf 'coverage package shard is empty: %s\n' "$shard" >&2 + exit 1 + fi + cat "$shard_packages" >> "$coverage_assigned" + done + + LC_ALL=C sort "$coverage_assigned" -o "$coverage_assigned" + uniq -d "$coverage_assigned" > "$coverage_duplicates" + LC_ALL=C sort -u "$coverage_assigned" > "$coverage_unique" + comm -23 "$coverage_expected" "$coverage_unique" > "$coverage_missing" + comm -13 "$coverage_expected" "$coverage_unique" > "$coverage_unexpected" + + coverage_failed=0 + if [ -s "$coverage_duplicates" ]; then + printf '%s\n' 'coverage packages assigned to more than one shard:' >&2 + sed 's/^/ /' "$coverage_duplicates" >&2 + coverage_failed=1 + fi + if [ -s "$coverage_missing" ]; then + printf '%s\n' 'full-suite coverage packages missing from the CI coverage plan:' >&2 + sed 's/^/ /' "$coverage_missing" >&2 + coverage_failed=1 + fi + if [ -s "$coverage_unexpected" ]; then + printf '%s\n' 'CI coverage plan contains packages outside its scope:' >&2 + sed 's/^/ /' "$coverage_unexpected" >&2 + coverage_failed=1 + fi + if [ "$coverage_failed" -ne 0 ]; then + exit 1 + fi + + coverage_count="$(wc -l < "$coverage_expected" | tr -d ' ')" + printf 'coverage package plan covers %s full-suite packages exactly once\n' "$coverage_count" } case "${1:-}" in @@ -121,6 +220,10 @@ case "${1:-}" in [ "$#" -eq 2 ] || usage list_shard "$2" ;; + list-coverage) + [ "$#" -eq 2 ] || usage + list_coverage_shard "$2" + ;; verify) [ "$#" -eq 1 ] || usage verify_plan diff --git a/scripts/dev/wiki-shortcut-e2e.py b/scripts/dev/wiki-shortcut-e2e.py new file mode 100755 index 00000000..454ed3e4 --- /dev/null +++ b/scripts/dev/wiki-shortcut-e2e.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Run interactive, real-data Wiki Shortcut verification on a disposable space. + +Requires an authenticated `dws` session. Set DWS_WIKI_E2E_MEMBER_ID to a real +internal user ID that may be granted temporary access to the empty fixture. +The script prints capability labels only; business IDs, names, URLs, and raw +responses remain in memory. Commands that require confirmation use the normal +`dws` terminal prompt, including the disposable-space cleanup in `finally`. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +DWS = ROOT / "dws" +MEMBER_ID = os.environ.get("DWS_WIKI_E2E_MEMBER_ID", "").strip() + + +class E2EFailure(RuntimeError): + pass + + +def invoke(args: list[str], *, require_confirmation: bool = False) -> dict: + process = subprocess.run( + [str(DWS), "wiki", *args, "--format", "json"], + cwd=ROOT, + stdout=subprocess.PIPE, + # Confirmation prompts are written to stderr. Keep that stream on the + # terminal for guarded operations so dws itself obtains the user's + # answer; ordinary calls stay quiet and redact raw backend errors. + stderr=None if require_confirmation else subprocess.PIPE, + text=True, + check=False, + ) + try: + envelope = json.loads(process.stdout) + except json.JSONDecodeError as exc: + raise E2EFailure(f"non-JSON response (exit {process.returncode})") from exc + if process.returncode != 0 or envelope.get("ok") is not True: + error = envelope.get("error") or {} + reason = error.get("reason") or error.get("category") or "command_failed" + raise E2EFailure(f"{reason} (exit {process.returncode})") + data = envelope.get("data") + if not isinstance(data, dict): + raise E2EFailure("success envelope lacks object data") + return data + + +def check(label: str, condition: bool) -> None: + if not condition: + raise E2EFailure(f"{label}: business assertion failed") + print(f"PASS {label}") + + +def member_role(data: dict, user_id: str) -> str: + for member in data.get("members", []): + if member.get("id") == user_id: + return str(member.get("role") or "").upper() + return "" + + +def main() -> int: + if not sys.stdin.isatty() or not sys.stderr.isatty(): + raise E2EFailure( + "run in an interactive terminal; guarded operations require an explicit dws confirmation" + ) + if not DWS.exists(): + raise E2EFailure("build ./dws first with make build") + if not MEMBER_ID: + raise E2EFailure("set DWS_WIKI_E2E_MEMBER_ID to a temporary internal test member") + + stamp = time.strftime("%m%d%H%M%S") + space_name = f"DWS Wiki E2E {stamp}" # <= 32 characters + workspace = "" + disposable_nodes: list[str] = [] + member_added = False + try: + created = invoke(["+space-create", "--name", space_name, "--desc", "Disposable E2E fixture"]) + workspace = str(created.get("workspaceId") or "") + check("space-create-readback", bool(workspace) and created.get("space", {}).get("workspaceId") == workspace) + + page = invoke(["+space-list", "--limit", "1"]) + check("space-list-cursor", page.get("count") == 1 and isinstance(page.get("hasMore"), bool)) + all_spaces = invoke(["+space-list", "--limit", "1", "--page-all", "--max-items", "2"]) + check("space-list-auto-page", all_spaces.get("count") == 2 and len(all_spaces.get("spaces", [])) == 2) + search_ok = False + for _ in range(8): + searched = invoke(["+space-search", "--query", space_name]) + if any(row.get("workspaceId") == workspace for row in searched.get("spaces", [])): + search_ok = True + break + time.sleep(2) + check("space-search", search_ok) + detail = invoke(["+space-get", "--workspace", workspace]) + check("space-get", detail.get("workspaceId") == workspace) + resolved = invoke(["+resolve-space", "--name", space_name]) + check("resolve-space", resolved.get("resolved") is True and resolved.get("spaceId") == workspace) + + empty_nodes = invoke(["+node-list", "--workspace", workspace]) + check("node-list-explicit-empty", empty_nodes.get("count") == 0 and empty_nodes.get("nodes") == []) + + folder = invoke(["+node-create", "--workspace", workspace, "--name", "E2E Folder", "--type", "folder"]) + folder_id = str(folder.get("nodeId") or "") + disposable_nodes.append(folder_id) + check("node-create-folder-readback", bool(folder_id) and folder.get("node", {}).get("nodeId") == folder_id) + document = invoke(["+node-create", "--workspace", workspace, "--folder", folder_id, "--name", "E2E Document"]) + node_id = str(document.get("nodeId") or "") + disposable_nodes.append(node_id) + check("node-create-document-readback", bool(node_id) and document.get("node", {}).get("nodeId") == node_id) + + listed = invoke(["+node-list", "--workspace", workspace, "--limit", "1"]) + check("node-list-cursor", listed.get("count") == 1 and listed.get("hasMore") is True and bool(listed.get("nextCursor"))) + all_nodes = invoke(["+node-list", "--workspace", workspace, "--limit", "1", "--page-all"]) + check( + "node-list-auto-page", + all_nodes.get("count", 0) >= 1 + and (all_nodes.get("autoPageComplete") is True or all_nodes.get("hasMore") is False), + ) + info = invoke(["+node-get", "--node", node_id]) + check("node-get", info.get("nodeId") == node_id) + + # Search indexing can lag after a create; retry without accepting a + # malformed/missing collection as an empty success. + search_ok = False + for _ in range(6): + found = invoke(["+node-search", "--workspace", workspace, "--query", "E2E Document"]) + if any(row.get("nodeId") == node_id for row in found.get("nodes", [])): + search_ok = True + break + time.sleep(2) + check("node-search", search_ok) + + copied = invoke( + ["+node-copy", "--workspace", workspace, "--folder", folder_id, "--node", node_id], + require_confirmation=True, + ) + copy_id = str(copied.get("nodeId") or "") + disposable_nodes.append(copy_id) + check("node-copy-readback", bool(copy_id) and bool(copied.get("copy"))) + + moved = invoke( + ["+move", "--workspace", workspace, "--folder", folder_id, "--node", node_id], + require_confirmation=True, + ) + check("move-readback", moved.get("node", {}).get("workspaceId") == workspace and moved.get("node", {}).get("folderId") == folder_id) + moved_out = invoke(["+move-to-drive", "--node", node_id], require_confirmation=True) + check("move-to-drive-readback", moved_out.get("node", {}).get("workspaceId") != workspace) + moved_back = invoke( + ["+node-move", "--workspace", workspace, "--folder", folder_id, "--node", node_id], + require_confirmation=True, + ) + check("node-move-alias-readback", moved_back.get("node", {}).get("workspaceId") == workspace) + + named = invoke(["+wiki-new-doc", "--space", space_name, "--title", "E2E Name Resolved"]) + named_id = str(named.get("nodeId") or "") + disposable_nodes.append(named_id) + check("wiki-new-doc-readback", bool(named_id) and named.get("document", {}).get("nodeId") == named_id) + + members = invoke(["+member-list", "--workspace", workspace, "--limit", "50"]) + check("member-list", members.get("count", 0) >= 1 and isinstance(members.get("members"), list)) + added = invoke(["+member-add", "--workspace", workspace, "--users", MEMBER_ID, "--role", "READER"]) + member_added = True + check("member-add-terminal", added.get("success") is True and added.get("verifiedBy") == "write_terminal_success") + after_add = invoke(["+member-list", "--workspace", workspace, "--limit", "50"]) + check("member-add-fixture-readback", after_add.get("truncated") is not True and member_role(after_add, MEMBER_ID) == "READER") + updated = invoke(["+member-update", "--workspace", workspace, "--users", MEMBER_ID, "--role", "EDITOR"]) + check("member-update-terminal", updated.get("success") is True and updated.get("verifiedBy") == "write_terminal_success") + after_update = invoke(["+member-list", "--workspace", workspace, "--limit", "50"]) + check("member-update-fixture-readback", after_update.get("truncated") is not True and member_role(after_update, MEMBER_ID) == "EDITOR") + removed = invoke(["+member-remove", "--workspace", workspace, "--users", MEMBER_ID]) + member_added = False + check("member-remove-terminal", removed.get("success") is True and removed.get("verifiedBy") == "write_terminal_success") + after_remove = invoke(["+member-list", "--workspace", workspace, "--limit", "50"]) + check("member-remove-fixture-readback", after_remove.get("truncated") is not True and member_role(after_remove, MEMBER_ID) == "") + + feeds = invoke(["+feed-list", "--workspace", workspace, "--limit", "10"]) + check("feed-list", isinstance(feeds.get("feeds"), list)) + + # Exercise high-risk delete before final whole-space cleanup. + delete_target = disposable_nodes.pop() + deleted = invoke( + ["+node-delete", "--workspace", workspace, "--node", delete_target], + require_confirmation=True, + ) + check("node-delete-terminal", deleted.get("success") is True and deleted.get("deleted") is True) + return 0 + finally: + if workspace: + if member_added: + try: + invoke(["+member-remove", "--workspace", workspace, "--users", MEMBER_ID]) + except Exception: + pass + try: + deleted = invoke( + ["+space-delete", "--workspace", workspace], + require_confirmation=True, + ) + check("space-delete-alias-cleanup", deleted.get("success") is True and deleted.get("deleted") is True) + except Exception as exc: + print(f"CLEANUP FAILED: {exc}", file=sys.stderr) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except E2EFailure as exc: + print(f"FAIL {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 4528688b..2bdd2820 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -24,6 +24,7 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_aitable.json", ROOT / "internal" / "shortcut" / "semantic_catalog_minutes.json", ROOT / "internal" / "shortcut" / "semantic_catalog_drive.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_wiki.json", ] diff --git a/scripts/policy/check-skill-context-budget.sh b/scripts/policy/check-skill-context-budget.sh index 568e3e26..f9d643dc 100755 --- a/scripts/policy/check-skill-context-budget.sh +++ b/scripts/policy/check-skill-context-budget.sh @@ -12,7 +12,7 @@ event_skill="skills/multi/dingtalk-event/SKILL.md" mono_skill="skills/mono/SKILL.md" runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md" chat_target_bytes=10000 -chat_max_overage_percent=5 +chat_max_overage_percent=10 chat_max_bytes=$((chat_target_bytes * (100 + chat_max_overage_percent) / 100)) doc_max_bytes=10000 event_max_bytes=10000 diff --git a/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json b/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json index ce34ea80..a7bd92d3 100644 --- a/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json +++ b/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json @@ -25,8 +25,7 @@ ], "references": [ "skills/multi/dingtalk-chat/SKILL.md", - "skills/multi/dingtalk-chat/references/01-messaging.md", - "skills/multi/dingtalk-chat/references/chat/chat-message.md" + "skills/multi/dingtalk-chat/references/01-messaging.md" ] }, { @@ -37,8 +36,7 @@ ], "references": [ "skills/multi/dingtalk-chat/SKILL.md", - "skills/multi/dingtalk-chat/references/01-messaging.md", - "skills/multi/dingtalk-chat/references/chat/chat-message.md" + "skills/multi/dingtalk-chat/references/01-messaging.md" ] }, { @@ -53,7 +51,7 @@ "references": [ "skills/multi/dingtalk-chat/SKILL.md", "skills/multi/dingtalk-chat/references/01-messaging.md", - "skills/multi/dingtalk-chat/references/chat/chat-message.md", + "skills/multi/dingtalk-chat/references/chat/message-media.md", "skills/multi/dingtalk-chat/references/chat/chat-bot.md" ] }, @@ -72,7 +70,7 @@ "references": [ "skills/multi/dingtalk-chat/SKILL.md", "skills/multi/dingtalk-chat/references/01-messaging.md", - "skills/multi/dingtalk-chat/references/chat/chat-message.md", + "skills/multi/dingtalk-chat/references/chat/message-query.md", "skills/multi/dingtalk-chat/references/chat/chat-conversation.md" ] }, @@ -91,7 +89,7 @@ ], "references": [ "skills/multi/dingtalk-chat/SKILL.md", - "skills/multi/dingtalk-chat/references/chat/chat-message.md" + "skills/multi/dingtalk-chat/references/chat/message-query.md" ] }, { @@ -103,7 +101,7 @@ "references": [ "skills/multi/dingtalk-chat/SKILL.md", "skills/multi/dingtalk-chat/references/01-messaging.md", - "skills/multi/dingtalk-chat/references/chat/chat-group.md" + "skills/multi/dingtalk-chat/references/chat/group-admin.md" ] }, { @@ -115,7 +113,7 @@ "references": [ "skills/multi/dingtalk-chat/SKILL.md", "skills/multi/dingtalk-chat/references/01-messaging.md", - "skills/multi/dingtalk-chat/references/chat/chat-message.md" + "skills/multi/dingtalk-chat/references/chat/message-actions.md" ] }, { diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 3d916175..210215b5 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -58,7 +58,7 @@ cli_version: ">=1.0.15" | `report` | 2 | `dingtalk-misc` | | `sheet` | 2 | `dingtalk-misc` | | `todo` | 11 | `dingtalk-todo` | -| `wiki` | 1 | `dingtalk-wiki` | +| `wiki` | 20 | `dingtalk-wiki` | ## 多组织 / 多账号 diff --git a/skills/mono/references/best_practices/04-document.md b/skills/mono/references/best_practices/04-document.md index c884341e..9985c60d 100644 --- a/skills/mono/references/best_practices/04-document.md +++ b/skills/mono/references/best_practices/04-document.md @@ -14,7 +14,7 @@ | delete-old-doc | 1. `doc search --query "<关键词>"` 或 `doc list --folder ` 取 `nodeId`
2. **必须先向用户展示要删除的文档标题/路径**,等用户确认
3. `doc delete --node --yes`(注意:是 `doc delete`,不是 `doc block delete`;前者删整篇,后者删块)
4. 验证:`doc info --node ` 应返回 not-found | | export-doc-as-docx | 1. `doc search --query "<关键词>"` → 取 `nodeId`
2. `doc export --node --output /tmp/.docx --timeout-sec 600`(CLI 内置渐进式退避轮询,**不要自己拼 GET downloadUrl**)
3. 落盘失败兜底:`doc export get --job-id --output /tmp/.docx` 续等
4. (可选)`doc-to-message` 把 docx 路径作为附件发给用户 | | grant-doc-access | 1. `doc search --query "<关键词>"` → 取 `nodeId`
2. `contact user search --query "<姓名>"` → 取 `userId`(**注意**: doc permission 用 `userId`,不是 `openDingTalkId`)
3. **节点级**授权:`doc permission add --node --user --role EDITOR`
  role 取值: MANAGER / EDITOR / DOWNLOADER / READER(**不要传 OWNER**)
  单次最多 30 个 userId
4. 查权限:`doc permission list --node ` 确认
5. **不要混淆**: 给"知识库"加成员用 `wiki member add --workspace`(容器级);给"单个文档"加权限用 `doc permission add --node`(节点级)| -| insert-image-to-doc | 1. `doc search --query "<关键词>"` → 取 `nodeId`
2. `doc media insert --node --file ./image.png`(3 步一体化:获取凭证 → PUT OSS → 插入块;图片 ≤20MB 自动作为内联图片,其他作为附件块)
3. (可选)`doc read --node ` 回读确认
**禁止**: 自己拿 uploadUrl 写 PUT —— 90%+ 会因 Content-Type 未清空触发 SignatureDoesNotMatch | +| insert-image-to-doc | 1. `doc search --query "<关键词>"` → 取 `nodeId`
2. `doc media insert --node --file ./image.png`(4 步一体化:获取凭证 → PUT OSS → 插入块 → 自动回读验证;图片 ≤20MB 自动作为内联图片,其他作为附件块)
3. 检查结果中的 `verified=true`;若服务端无法提供明确回读证据,命令会返回 partial success
**禁止**: 自己拿 uploadUrl 写 PUT —— 90%+ 会因 Content-Type 未清空触发 SignatureDoesNotMatch | | download-doc-attachment | 1. `doc block list --node ` → 找到 `blockType=attachment` 的块取 `resourceId`
2. `doc media download --node --resource-id ` → 返回 OSS 临时 URL(**注意 expirationSeconds**)
3. 调用方自行 GET 该 URL 落盘 | --- diff --git a/skills/mono/references/products/sheet/sheet-batch-operations.md b/skills/mono/references/products/sheet/sheet-batch-operations.md index 8d922bec..66fafc90 100644 --- a/skills/mono/references/products/sheet/sheet-batch-operations.md +++ b/skills/mono/references/products/sheet/sheet-batch-operations.md @@ -83,7 +83,8 @@ Example: {"toolName":"range update","input":{"sheet-id":"Sheet1","range":"A1","values":[[{"type":"text","text":"hello"}]]}}, {"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}}, {"toolName":"update-dimension","input":{"sheet-id":"Sheet1","dimension":"ROWS","start-index":"1","length":1,"pixel-size":40}}, - {"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}} + {"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}}, + {"toolName":"set-dropdown","input":{"sheet-id":"Sheet1","range":"C2:C100","source-sheet-id":"SourceSheet","source-range":"T1:T3"}} ]' dws sheet batch-update --node --continue-on-error --operations '[...]' Flags: @@ -101,6 +102,9 @@ Notes: - operations 最多 20 条 - 当需要对多个区域执行相同清除时,优先使用 `range batch-clear`(更简洁) - `csv-put` 子操作与独立命令语义一致:CSV 字段值以 `=` 开头时按公式解析;前加单引号时写入以 `=` 开头的字面文本 + - `set-dropdown` 的 `input` 中,Inline 使用 `options`;SourceRange 使用 `source-sheet-id` + `source-range`,两种模式必须且只能选一个。顶层 `colors` / `source-colors` 会被拒绝;Inline 颜色写在 `options[].color`,SourceRange 颜色写入暂不支持 + - `set-dropdown` SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入 + - `source-range` 按 `toolName` 解释:`set-dropdown` 中是下拉候选项来源;`range fill` / `range copy-to` / `range move-to` 中是待填充、复制或移动的数据源区域 - 典型场景:先插入行列再写入数据、先清除再写入、批量合并+调整行高列宽 - `group-dimension` 在 batch 中只适合默认展开分组;需要 `--group-state fold` 时请使用独立 `dws sheet group-dimension` - `table-put` 不支持放进 batch-update;结构化 table 请用独立 `dws sheet table-put` diff --git a/skills/mono/references/products/sheet/sheet-dropdown.md b/skills/mono/references/products/sheet/sheet-dropdown.md index 0b135aa4..05b5f0c0 100644 --- a/skills/mono/references/products/sheet/sheet-dropdown.md +++ b/skills/mono/references/products/sheet/sheet-dropdown.md @@ -7,6 +7,7 @@ 用户说"设置下拉列表/下拉选项/下拉菜单/添加下拉/配置下拉": - 设置下拉列表 → `set-dropdown` - 设置多选下拉 → `set-dropdown --multi-select` +- 引用单元格区域作为候选项 → `set-dropdown --source-sheet-id ... --source-range ...` 用户说"查看下拉列表/获取下拉配置/下拉列表有哪些选项": - 获取下拉列表配置 → `get-dropdown` @@ -29,18 +30,25 @@ Example: dws sheet set-dropdown --node --sheet-id --range "B2:B50" \ --options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \ --multi-select + + # 引用同一工作簿内另一工作表的区域作为候选项来源 + dws sheet set-dropdown --node --sheet-id --range "C2:C100" \ + --source-sheet-id --source-range "T1:T3" Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 目标单元格范围,A1 表示法,如 A2:A100 (必填) - --options string 下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]' - --multi-select 是否允许多选(默认单选) + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 目标单元格范围,A1 表示法,如 A2:A100 (必填) + --options string Inline 下拉选项 JSON 数组,与 --source-range 二选一 + --source-sheet-id string SourceRange 来源工作表 ID,与 --source-range 同时指定 + --source-range string SourceRange 来源区域,与 --options 二选一;不带工作表前缀 + --multi-select 是否允许多选(默认单选) ``` -在指定单元格范围内设置下拉列表。设置后用户可从预定义选项中选择值。 -- **用途**:为单元格配置下拉列表,支持自定义选项颜色和多选。 +在指定单元格范围内设置下拉列表。Inline 模式直接存储选项;SourceRange 模式引用同一工作簿内的来源区域,可跨工作表,并支持普通区域、整行和整列。 +- **用途**:为单元格配置静态选项或区域来源下拉,两种模式都支持多选;颜色仅 Inline 支持。 - **场景**:规范数据输入,如状态选择(完成/进行中/待处理)、优先级(高/中/低)等。 -- **注意**:选项值不能包含英文逗号;如果目标范围已存在下拉列表,会被新配置覆盖。 +- **注意**:`--options` 与 `--source-range` 必须且只能指定一个。`--source-range` 只写 `T1:T3`、`T:T`、`1:3` 这类 A1 区域,来源工作表通过 `--source-sheet-id` 单独指定;不接受工作表前缀、公式或多区域。SourceRange 颜色写入暂不支持。 +- **结构操作行为**:已验证的工作表重命名、在引用前插入行/列、删除引用前的行会自动调整引用并保持 `valid`;已验证的 `move-dimension` 场景会使其变为 `invalid`。列删除、删除整个来源区域或来源工作表等场景未覆盖,不能预设结果;结构操作后先回读 `sourceRangeStatus`,仅在 `invalid` 时重新选择来源并写入。 ### 获取下拉列表配置 ``` @@ -55,10 +63,10 @@ Flags: --range string 查询范围,A1 表示法,如 A1:A100 (必填) ``` -查询指定范围内的下拉列表配置信息,包括选项值、颜色和是否多选。 +查询指定范围内的下拉列表配置信息。 - **用途**:查看单元格已设置的下拉列表选项和配置。 - **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。 -- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `enableMultiSelect` 和 `colorValueMap`)。范围内无下拉列表时 `hasDropdown` 为 false。 +- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。 ### 删除下拉列表 ``` @@ -81,14 +89,15 @@ Flags: | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 | -| `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 | +| `set-dropdown` | `range` 实际设置范围、`enableMultiSelect` 是否多选;仅 Inline 模式返回 `optionCount` | 确认下拉列表设置成功 | +| `get-dropdown` | `hasDropdown`、`dataValidations`;按 `sourceType` 区分 Inline 与 SourceRange | 查看已有下拉配置 | | `delete-dropdown` | `range` 实际删除范围 | 确认下拉列表删除完成 | | `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id | ## 注意事项 - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) -- `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖 -- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false +- `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉 +- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持 +- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值 - `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功 diff --git a/skills/mono/references/products/sheet/sheet-read-data.md b/skills/mono/references/products/sheet/sheet-read-data.md index 0e2ae054..f8008d0e 100644 --- a/skills/mono/references/products/sheet/sheet-read-data.md +++ b/skills/mono/references/products/sheet/sheet-read-data.md @@ -13,7 +13,7 @@ | 读取目的 | 推荐命令 | 说明 | |---------|---------|------| -| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 | +| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 30,000 单元格和 maxChars 防爆 | | 按 table/dataframe 协议读取 | `table-get` | 返回 `columns` / `data` / `dtypes` / `formats`;默认首行为表头 | | 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation | | 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) | @@ -52,7 +52,10 @@ Flags: - `csv` — CSV 文本,每逻辑行前加 `[row=N]` 前缀标注真实表格行号。行号一律从此前缀读取,禁止手算 - `colIndices` — 列字母映射数组(如 `["A","B","C"]`)。定位列字母用 `colIndices[j]`,禁止手数逗号 - `rowIndices` — 行号映射数组(如 `[1,2,3]`) -- `hasMore` — 是否因 maxChars 截断。为 true 时需要调整 `--range` 继续分页读取 +- `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完 +- `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现 +- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围 +- `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标 `csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node --sheet-id --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。 @@ -63,10 +66,15 @@ Flags: | `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 | | `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 | -**大表分批读取**:当 `hasMore=true` 或数据量很大时,按行窗口分批: -- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界 -- 分批读取:`--range "A1:J500"`、`--range "A501:J1000"` …… -- 单次建议 ≤5000 单元格 +**大表分批读取**:`csv-get` 不会自动发起后续请求。当 `hasMore=true` 时: +- 显式传了 `--range` 时,该范围是完整目标;未传时,以 `resolvedRange` 为完整目标 +- 根据 `returnedRange` 的结束行,从下一行开始显式构造新的 `--range`;例如目标是 `A1:A30001`、本次返回 `A1:A30000`,则继续读 `A30001:A30001` +- `max_cells` 不能通过增大 `--max-chars` 解决;`max_chars` 且没有 `returnedRange` 时,增大 `--max-chars` 或缩小列范围 +- 上述完成度协议覆盖未传 `--range` 和显式有限矩形范围;`A:A`、`1:1` 等非有限范围可能不返回完成度字段,建议先用 `sheet info` 的 `nonEmptyRange` 换算成有限范围再读 +- 即使未命中硬上限,单次仍建议控制在 5000 单元格以内,减少超时和 token 消耗 + +**工作簿过大失败与部分成功不同**:收到顶层 +`errorCode=forbidden.document.sizeOverLimit` 和对应 `errorMessage` 时,表示工作簿整体无法装载。应停止读取并提示创建更小副本或拆分工作簿;缩小 `--range` 不能解决这类错误。不要把它当成 `hasMore=true` 的可续读结果。 ### 以 table/dataframe 协议读取结构化数据 ``` @@ -130,6 +138,8 @@ Flags: ``` **返回字段说明**: +- `rowIndices` / `colIndices` — 本次返回的真实行号与列字母映射 +- `hasMore` / `truncationReasons` / `resolvedRange` / `returnedRange` — 语义与 `csv-get` 相同;`range read` 的 `truncationReasons` 主要为 `max_cells` - `cells` — 二维数组,第一维为行,第二维为列。每个元素为 per-cell 对象,字段如下: | 字段 | 类型 | 是否必有 | 说明 | @@ -146,10 +156,16 @@ Flags: | type | 字段 | 说明 | |------|------|------| -| `dropdown` | `options: [{value: string, color?: string}]` | 下拉选项列表 | -| `dropdown` | `enableMultiSelect: boolean` | 是否允许多选 | +| `dropdown` Inline | `sourceType: "inline"` | 静态选项模式 | +| `dropdown` Inline | `options: [{value: string, color?: string}]` | 下拉选项列表 | +| `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 | +| `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 | +| `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 | +| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` | | `checkbox` | `checked: boolean` | 当前勾选状态 | +SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。 + **hyperlink 结构**: | type | 字段 | 说明 | @@ -216,7 +232,7 @@ Flags: **公式校验建议**:写公式后不要只看写入返回结果。先用 `formula` 模式确认公式文本已落表,再运行 `formula-verify` 聚合扫描错误;关键业务数值继续用 `raw_value` 抽样对账。详见 [sheet-formula](./sheet-formula.md)。 -**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分页读取策略: +**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分批读取策略,每次都必须检查 `hasMore`: - 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界 - 按行分批读取,如 `A1:J500`、`A501:J1000`、`A1001:J1500` …… - 避免不传 `--range` 直接读取整个大工作表 @@ -232,7 +248,7 @@ dws sheet list --node --format json # 2. 查看工作表详情(行列数、最后非空位置、mergedRanges 等) dws sheet info --node --sheet-id --format json -# 3. 读取全部数据 +# 3. 读取数据(只有 hasMore=false 才代表目标范围已完整返回) dws sheet range read --node --sheet-id --format json # 4. 读取指定区域 @@ -251,6 +267,8 @@ dws sheet range read --node --sheet-id --range "A1:D10" --f - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败 - `range read` 不传 `--range` 时默认读取整个工作表的全部非空数据 - `range read` 的 `--range` 支持 `Sheet1!A1:D10` 格式直接指定工作表(此时忽略 `--sheet-id`) +- ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读 +- ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复 - ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges` - `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read` - ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度 diff --git a/skills/mono/references/products/sheet/sheet-write-data.md b/skills/mono/references/products/sheet/sheet-write-data.md index eaa5e695..9050fb43 100644 --- a/skills/mono/references/products/sheet/sheet-write-data.md +++ b/skills/mono/references/products/sheet/sheet-write-data.md @@ -386,9 +386,14 @@ Flags: **dropdown(下拉列表)**: ```json { "type": "text", "text": "High", "dataValidation": { "type": "dropdown", "options": [{"value":"High","color":"#00ff00"},{"value":"Low","color":"#ff0000"}], "enableMultiSelect": false } } +{ "dataValidation": { "type": "dropdown", "sourceRange": {"sheetId":"SOURCE_SHEET_ID","a1Notation":"T1:T3"}, "enableMultiSelect": false } } ``` -- `options`:必填,`[{value, color?}]` 数组 +- `options` 与 `sourceRange` 必须且只能提供一个 +- Inline:`options` 为非空 `[{value, color?}]` 数组 +- SourceRange:`sourceRange` 为 `{sheetId,a1Notation}`;可引用同一工作簿内其他工作表,支持普通区域、整行和整列。`a1Notation` 不带工作表前缀,不接受公式或多区域;颜色写入暂不支持 - `enableMultiSelect`:可选,是否多选,默认 false +- SourceRange 在已验证的工作表重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读 `sourceRangeStatus`,仅 `invalid` 时重新选源写入 +- 复合 cell 写入中,如果值或样式已成功落盘但 SourceRange 校验失败,服务端可返回 `success:true` 并通过 `message` 说明下拉未创建;必须检查 `message` 并按需回读 **checkbox(复选框)**: ```json diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index 8c952ece..101fe7af 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -72,6 +72,8 @@ metadata: - `+dm`:姓名目标的简单文本/Markdown,参数空间最小。 - `+send-to-group`:群名或稳定 ID 目标的简单文本/Markdown,避免暴露无关身份矩阵。 +- Markdown 中的公网图片必须写成 `![图片标题](https://example.com/image.png)` 才会内联展示; + 省略开头的 `!` 时只会显示为链接。 - `+messages-send`:文件、Bot、Webhook、复杂 @ 或幂等控制。user 已知 ID 可直接传,也可用 `--user-query` / `--chat-query` 运行同一只读解析链;Bot 多群使用 `--groups/--groups-file`,返回 `im.batch-write.v1`;bot/webhook 只使用下层真实支持的文本/Markdown 能力。 - 文件直接传 `+messages-send --file <相对路径>`;不要先独立上传并提取 mediaId。 - Webhook 使用 `+messages-send --as webhook --webhook-token `;不要退回原子 Webhook 命令。 @@ -92,10 +94,13 @@ metadata: | 场景 | Reference | |---|---| -| 复杂发送、跨会话转发、共同群或组合流程 | [01-messaging.md](references/01-messaging.md) | -| 编辑/撤回/引用/转发/卡片/reaction/Pin/Top/Favorite | [chat-message.md](references/chat/chat-message.md) | -| 建群、成员、管理员、群公告、群设置 | [chat-group.md](references/chat/chat-group.md) | -| Bot 搜索、入群、群发和撤回 | [chat-bot.md](references/chat/chat-bot.md) | +| 需要跨步骤传递真实结果的消息/群组合流程 | [01-messaging.md](references/01-messaging.md) | +| 消息读取与查询 | [message-query](references/chat/message-query.md) | +| 编辑、撤回、回复、转发、Pin、Top、Favorite 或 reaction 写入 | [message-actions](references/chat/message-actions.md) | +| 位置、联系人名片、底层媒体与资源下载 | [message-media](references/chat/message-media.md) | +| 群列表、群搜索、共同群、成员与群内机器人读取 | [group-discovery](references/chat/group-discovery.md) | +| 建群、成员或已知机器人增删、管理员、公告与群设置 | [group-admin](references/chat/group-admin.md) | +| 搜索未知机器人、机器人消息发送/撤回与 Webhook | [chat-bot.md](references/chat/chat-bot.md) | | 会话置顶、分类、红点、免打扰和隐藏 | [chat-conversation.md](references/chat/chat-conversation.md) | | 低频意图之间仍需消歧 | [intent-guide.md](references/intent-guide.md) | | 表情名称与 ID | [chat-emoji-list.md](references/chat-emoji-list.md) | diff --git a/skills/multi/dingtalk-chat/references/card/create.md b/skills/multi/dingtalk-chat/references/card/create.md index eff31a67..41257170 100644 --- a/skills/multi/dingtalk-chat/references/card/create.md +++ b/skills/multi/dingtalk-chat/references/card/create.md @@ -13,9 +13,11 @@ Runtime 会唯一解析为 openDingTalkId;已有 openDingTalkId 时传 或艾特占位符。 - `--dry-run` 仍执行只读 userId 解析,只输出两步计划,不执行写入。 -自动更新结果不确定时,不要再次更新或重复创建;保留返回结果并告知用户。若结果中已经 -包含 `openTaskId`,可以按用户需要查询一次投递状态;该查询只确认消息投递,不代表卡片 -正文已经更新成功。 +创建成功后保留真实 `bizId`。自动更新返回 `verified=true` 时已有明确生效证据;返回 +`accepted=true, verified=false` 时仅表示服务端已接受请求但未提供独立更新证据,应如实说明, +不要重复创建或重复执行相同更新。只有错误明确标记 `retryable=true` 时,才使用原 `bizId` +重试;明确未应用或 `bizId` 不一致时停止并保留真实错误。若结果中已经包含 `openTaskId`, +可以按用户需要查询一次投递状态;该查询只确认消息投递,不代表卡片正文已经更新成功。 当前内容仅为 streaming text,不接受 Lark Card JSON、组件树或按钮 callback。 diff --git a/skills/multi/dingtalk-chat/references/card/update.md b/skills/multi/dingtalk-chat/references/card/update.md index 567161f5..69c4d15c 100644 --- a/skills/multi/dingtalk-chat/references/card/update.md +++ b/skills/multi/dingtalk-chat/references/card/update.md @@ -7,3 +7,7 @@ 更新是写操作,confirmation 以精确 leaf Schema 与 Runtime gate 为准。失败后保留原 `bizId` 和状态,不创建新卡片来掩盖更新失败。 + +结果中 `verified=true` 表示已有明确更新证据;`accepted=true, verified=false` 仅表示服务端 +接受了请求但未提供独立生效证据,应如实说明且不得重复执行相同更新。只有错误明确标记 +`retryable=true` 时才重试;明确未应用或 `bizId` 不一致时停止。 diff --git a/skills/multi/dingtalk-chat/references/chat.md b/skills/multi/dingtalk-chat/references/chat.md index e9087d32..a8a30538 100644 --- a/skills/multi/dingtalk-chat/references/chat.md +++ b/skills/multi/dingtalk-chat/references/chat.md @@ -19,9 +19,12 @@ | 用户终点 | 返回入口 | |---|---| | 姓名/群名简单发送、文件、Bot、Webhook、复杂 @ | 根 Skill Golden Route | -| 单会话消息、跨会话搜索、资源下载 | [消息任务级流程](01-messaging.md) | -| 引用、转发、卡片、reaction、Pin/Top/Favorite | [chat-message](chat/chat-message.md) | -| 基础建群、成员、公告、管理员和群设置 | [chat-group](chat/chat-group.md) | +| 消息读取、条件搜索、@我、Favorite/reaction 查询和批量详情 | [message-query](chat/message-query.md) | +| 编辑、撤回、引用、转发、reaction/Pin/Top/Favorite 写入 | [message-actions](chat/message-actions.md) | +| 位置、名片、资源下载和特殊媒体 fallback | [message-media](chat/message-media.md) | +| 群列表、群搜索、成员读取、Bot 列表和邀请链接 | [group-discovery](chat/group-discovery.md) | +| 建群、改群、成员写入、管理员、禁言、公告和群设置 | [group-admin](chat/group-admin.md) | +| 跨步骤消息/群组合流程 | [消息任务级流程](01-messaging.md) | | Bot 搜索、进群和撤回 | [chat-bot](chat/chat-bot.md) | | 会话置顶、状态和分组 | [chat-conversation](chat/chat-conversation.md) | | 相邻低频意图仍需消歧 | [intent-guide](intent-guide.md) | diff --git a/skills/multi/dingtalk-chat/references/chat/chat-group.md b/skills/multi/dingtalk-chat/references/chat/chat-group.md deleted file mode 100644 index a8e3cde4..00000000 --- a/skills/multi/dingtalk-chat/references/chat/chat-group.md +++ /dev/null @@ -1,223 +0,0 @@ -# chat-group:群聊、成员、设置与群身份 - -> 返回入口:[chat.md](../chat.md) - -## 适用场景 - -用于群搜索、建群、成员增删、机器人进群、群设置、群主转让、群邀请分享、群公告、入群审批、群身份和群禁言。 - -基础建群默认使用 `dws chat +chat-create`:已知成员 ID 传 -`--users`,姓名/花名传 `--member-query`;群主默认当前用户,也可传 -`--owner-open-dingtalk-id` 或 `--owner-query`。全部自然身份唯一解析并去重后才执行一次创建。 - -## 必读约束 - -- 群聊目标统一使用 `openConversationId`。只有数字群号时,先用 `chat group get-by-group-id` 转换。 -- 群搜索唯一推荐 `dws chat +chat-search --query <群名>`;`chat search`、`chat group search`、 - `+chat-group-search` 和 `+search-group` 仅是兼容拼法,不应被写成并列默认路线。 -- 群成员操作中,`--users` 常为逗号分隔列表;具体要求以命令 `--help` 为准。 -- 解散群、踢人、转让群主、禁言、管理员设置都是高影响操作,执行前必须确认目标群和用户。 -- 发布或修改群公告会触达群成员;公告正文是 Markdown,定时公告 `--run-at` 建议带时区。 - -## 命令明细 - -### 搜索与基础信息 - -| 命令 | 用途 | 示例与要点 | -|------|------|------------| -| `+chat-search` | 按关键词搜索群聊 | 默认一页;要求全部候选时用 `dws chat +chat-search --query "项目冲刺" --page-all`;可用 `--page-size/--page-token` 或兼容的 `--limit/--cursor`,并检查完整性 ledger;多候选必须消歧 | -| `chat search-common` | 搜索共同群 | `dws chat search-common --nicks "风雷,山乔" --match-mode AND --limit 20 --cursor 0` | -| `chat group get-by-group-id` | 数字群号转 openConversationId | `dws chat group get-by-group-id --group-id 12345678` | -| `+chat-bots` | 查看群内所有机器人 | `dws chat +chat-bots --group <群名或openConversationId>`;内部唯一解析自然群名 | - -`search-common` 中 `--match-mode AND` 表示所有人都在群里,`OR` 表示任一人在群里。 - -### 群创建与基础操作 - -#### `dws chat group create`(底层 fallback) - -只有需要 `+chat-create` 尚未发布的底层字段时才评估原子创建命令,并先读取精确 leaf -Schema。`+chat-create` 已支持 `--thread` 和显式群主;普通内部/外部群创建不得回流到手工 -`aisearch → group create` 链路。 - -```bash -dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3 -dws chat group create --name "外部合作群" --users userId1,userId2 --type EXTERNAL -dws chat group create --name "话题圈" --users userId1,userId2 --thread -``` - -关键 flags: - -| Flag | 说明 | -|------|------| -| `--name` | 群名称,必填 | -| `--users` | 成员 userId 或 openDingTalkId,逗号分隔,必填 | -| `--type` | `INTERNAL` / `EXTERNAL` / `NORMAL`,默认 `INTERNAL` | -| `--thread` | 开启话题模式,创建话题圈 | - -创建成功后提取 `openConversationId`,用于发消息、成员管理、群设置。 - -| 命令 | 用途 | 必填参数 | -|------|------|----------| -| `group rename` | 更新群名称 | `--id` `--name`;只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID,不猜 `+chat-rename` | -| `group quit` | 当前用户退出群聊 | `--group` | -| `group dismiss` | 解散群聊,不可逆 | `--group` | - -### 成员与机器人 - -| 命令 | 用途 | 示例 | -|------|------|------| -| `+chat-members-list` | 全量查看群成员并分桶用户/机器人 | `--group <群名或openConversationId>`;显式 ID 也可用 `--conversation-id`,检查 buckets/complete/failures | -| `group members add` | 添加群成员 | `dws chat group members add --id --users userId1,userId2` | -| `group members remove` | 移除群成员 | `dws chat group members remove --id --users userId1,userId2` | -| `group members list-by-ids` | 按 openDingTalkId 批量查成员详情 | `dws chat group members list-by-ids --id --users openDingTalkId1,openDingTalkId2` | -| `group members add-bot` | 将自定义机器人加入群 | `dws chat group members add-bot --id --robot-code ` | -| `group members remove-bot` | 从群内移除机器人 | `dws chat group members remove-bot --id --bot-id ` | - -机器人发群消息如果报“机器人不存在”,先 `group members add-bot` 再重发。 - -### 群设置与权限 - -| 命令 | 用途 | 必填参数 | -|------|------|----------| -| `group transfer-owner` | 转让群主 | `--group` + `--user`(userId) 或 `--new-owner`(openDingTalkId) | -| `group upgrade-to-external` | 将普通群升级为外部群(不可逆,需先确认) | `--group` `--yes`;可选 `--extension`(拓展字段) | -| `+chat-invite-url` | 获取群邀请链接 | `--group <群名或openConversationId>`;可选 `--expires-seconds` | -| `group share-invite` | 将指定群的邀请链接分享到另一个会话或单聊用户 | `--source` + `--target` / `--receiver` 二选一 | -| `group update-icon` | 更新群头像 | `--group` `--icon-media-id` | -| `group update-settings` | 更新管理员级别的群功能开关 | `--group` `--setting-key` `--status` | -| `group user-settings query` | 批量查询当前用户自己的群会话设置 | `--groups` | -| `group user-settings set` | 批量更新当前用户自己的群会话设置 | `--items` | -| `group update-nick` | 设置或清除当前用户群昵称 | `--group`,可选 `--nick`;不传则清除群昵称 | -| `group update-alias` | 设置当前用户群备注 | `--group` `--alias-title` | -| `group set-history` | 设置新成员可查看历史消息范围 | `--group` `--option` | -| `group get-mute-config` | 查询群用户禁言配置 | `--group` | -| `group-mute` | 全员禁言/解除全员禁言 | `--group`,默认禁言,`--off` 解除 | -| `group-mute-member` | 指定成员禁言/解除禁言 | `--group` `--user`/`--users`;禁言需 `--mute-time` | -| `group set-admin` | 设置/取消管理员 | `--group` `--user`/`--users`;`--off` 取消 | - -`update-settings` 是管理员级别的群功能开关操作,常用 settingKey:`authority`、`joinValidation`、`onlyAdminCanAtAll`、`searchable`、`addFriendForbidden`、`onlyAdminCanDING`、`onlyAdminCanPinMsg`、`onlyAdminCanSendFile`、`groupEmailDisabled`、`groupLiveAuthority`、`groupBillAuthority`。 - -`group user-settings` 是当前登录用户自己的群会话设置批量入口(置顶、免打扰、群昵称、群备注等),不是管理员级别的群功能开关;单个群昵称/群备注仍优先使用 `group update-nick` / `group update-alias`,管理员级别的群功能开关继续使用 `group update-settings`。 - -`group user-settings set --items` 传 JSON 数组,每个元素表示一个群会话的当前用户设置。字段含义: - -| 字段 | 含义 | 值说明 | -|------|------|--------| -| `openConversationId` | 群会话 ID | 必填,来自 `chat search` / `group list-all` 等真实返回 | -| `top` | 当前用户是否置顶该群会话 | `true`=置顶,`false`=取消置顶 | -| `mute` | 当前用户是否开启该群会话免打扰 | `true`=开启免打扰,`false`=关闭免打扰 | -| `groupNick` | 当前用户在该群里的群昵称 | 字符串;空字符串表示清空昵称 | -| `groupAlias` | 当前用户给该群设置的备注 | 字符串;空字符串表示清空备注 | - -只传本次要改的字段;不要补用户没要求修改的字段。批量设置多个群时,`items` 放多个对象。 - -`group-mute-member --mute-time` 单位毫秒,常用值:`300000`、`3600000`、`86400000`、`604800000`、`2592000000`。 - -`group share-invite` 的 `--source` 是被分享群的 `openConversationId`;`--target` 是接收分享消息的会话,`--receiver` 是接收分享消息的单聊用户 `openDingTalkId`,二者必须二选一。 - -```bash -dws chat group share-invite --source --target -dws chat group share-invite --source --receiver -dws chat group share-invite --source --target --expires-seconds 86400 --uuid -dws chat group user-settings query --groups , -dws chat group user-settings set --items '[{"openConversationId":"cid1","top":true,"mute":false}]' -``` - -### 群公告 - -群公告正文使用 Markdown。支持标题、加粗、斜体、删除线、行内代码、链接、代码块、列表、表格、引用、分割线、图片、段落和换行;下划线、字体色、背景色、字号属于编辑器专属能力,无法通过 Markdown 表达。 - -| 命令 | 用途 | 必填参数 | -|------|------|----------| -| `group notice create` | 发布群公告,支持置顶、DING 和定时发布 | `--group` `--content` | -| `group notice edit` | 整体替换指定群公告内容 | `--group` `--notice-id` `--content` | -| `group notice get` | 查询单条群公告详情 | `--group` `--notice-id` | -| `group notice list` | 分页拉取群公告列表 | `--group` | - -```bash -dws chat group notice create --group --content "今晚 22 点系统维护,请提前保存工作内容" -dws chat group notice create --group --content "# 重要通知\n\n请大家查收" --sticky --send-ding -dws chat group notice create --group --content "明早九点例会" --run-at "2026-07-03T09:00:00+08:00" -dws chat group notice list --group --limit 20 --cursor -dws chat group notice get --group --notice-id -dws chat group notice edit --group --notice-id --content "更新后的公告内容" -``` - -注意事项: - -- `notice edit` 会整体替换原公告正文,必须传完整的新内容。 -- `notice list --scheduled` 查询尚未到发布时间的定时公告;默认查询已发布公告。 -- `hasMore=true` 时,用返回的 `nextPageCursor` 继续翻页。 -- `notice get` 返回正文摘要、置顶状态、发布者、已读人数/应收人数、点赞/评论数、是否可编辑、是否已读、是否定时公告等信息。 - -### 群列表与入群审批 - -| 命令 | 用途 | 示例与要点 | -|------|------|------------| -| `group list-my-groups` | 拉取我创建/管理的群 | 可选 `--role OWNER/ADMIN`、`--limit`、`--exclude-muted` | -| `group list-all` | 分页拉取我加入的所有群 | `--limit` 默认 100,最大 200;翻页用 `nextCursor` | -| `group list-join-validations` | 拉取入群验证记录 | 包括自己被拒绝的记录以及作为审批者的记录 | -| `group audit-join-validation` | 审批入群验证 | `--group` `--record-id` `--applicant` `--inviter` `--status` | - -审批动作 `--status`:`AuditApprove`、`AuditDelete`、`AuditIgnore`、`AuditRefuse`、`AuditBlock`。 - -```bash -dws chat group list-join-validations --limit 20 -dws chat group audit-join-validation --group --record-id 123456 --applicant --inviter --status AuditApprove -``` - -### 群身份 - -`chat group-role` 管理群内自定义身份标签。 - -| 命令 | 用途 | 必填参数 | -|------|------|----------| -| `group-role list` | 查看群身份列表 | `--group` | -| `group-role add` | 新增群身份 | `--group` `--name` | -| `group-role update` | 修改群身份名称 | `--group` `--role-id` `--name` | -| `group-role remove` | 删除群身份 | `--group` `--role-id` | -| `group-role set-user` | 覆盖用户全部群身份,空 `--role-ids` 表示清除 | `--group` `--user` `--role-ids` | -| `group-role remove-user` | 移除用户指定群身份 | `--group` `--user` `--role-ids` | -| `group-role query-user` | 查询用户当前群身份 | `--group` `--user` | - -`openRoleId` 来自 `group-role list` 返回。 - -## 常见工作流 - -### 搜索群并发消息 - -```bash -dws chat +send-to-group --group "项目冲刺" --text "请大家看一下最新进展" --format json -``` - -### 建群并拉人 - -```bash -dws chat +chat-create --name "Q1 项目冲刺群" --member-query "张三,李四" --format json -dws chat +chat-create --name "合作群" --member-query "张三,李四" --owner-query "王五" --format json -dws chat group members add --id --users userId3,userId4 --format json -``` - -### 分享群邀请并发布公告 - -```bash -dws chat group share-invite --source --target --format json -dws chat group notice create --group --content "# 项目公告\n\n请大家关注最新安排" --send-ding --format json -``` - -### 设置管理员并禁言成员 - -```bash -dws chat group set-admin --group --users userId1,userId2 --format json -dws chat group-mute-member --group --users userId3 --mute-time 3600000 --format json -``` - -## 常见错误与回退 - -- 只有数字群号:先 `group get-by-group-id`,不要直接当 `--group`。 -- 找不到群:使用 `+chat-search --query` 扩大关键词;零命中或多候选时停止,不臆测 openConversationId。 -- 入群审批缺参数:从 `group list-join-validations` 提取 `record-id`、`applicant`、`inviter`。 -- 机器人进群失败:确认当前用户有群管理权限。 -- 分享群邀请目标不明确:`--target` 和 `--receiver` 只能二选一,先确认是发到群/会话还是发给个人。 -- 修改公告前没有完整新正文:先向用户确认完整公告内容,不要只传增量片段。 diff --git a/skills/multi/dingtalk-chat/references/chat/chat-message.md b/skills/multi/dingtalk-chat/references/chat/chat-message.md deleted file mode 100644 index 29c394f1..00000000 --- a/skills/multi/dingtalk-chat/references/chat/chat-message.md +++ /dev/null @@ -1,410 +0,0 @@ -# chat-message:消息、文件、搜索与卡片 - -> 返回入口:[chat.md](../chat.md) - -## 适用场景 - -用于当前用户发消息、拉消息、搜索消息、撤回、已读状态、文件/图片/音频/视频/位置/名片发送、话题回复、转发、Pin/Top、表情回应、文本翻译和流式卡片。 - -## 默认入口 - -| 用户终点 | 默认入口 | -|---|---| -| 按姓名发送简单文本/Markdown | `dws chat +dm` | -| 按群名发送简单文本/Markdown | `dws chat +send-to-group` | -| 文件、Bot、Webhook、复杂 @、已知 ID 或幂等发送 | `dws chat +messages-send` | -| 读取或导出指定群聊/单聊,可附带发送者姓名 | `dws chat +chat-messages`;姓名用非必填 `--sender-query` | -| 直接按发送者、关键词、@对象或消息类型搜索,可限定单个或跨多个会话 | `dws chat +search-msg` | -| 引用回复已有消息 | `dws chat +messages-reply` | -| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` | -| 撤回当前用户消息 | `dws chat +messages-recall --msg-id ` | - -以下原子命令只用于 Shortcut 未暴露的底层字段、原始响应或精确分页控制。不得把它们重新写成 -上述高频任务的默认路径;写入原子命令若与 Golden Shortcut 的 confirmation 不一致,停止并 -报告交付漂移,不以文档确认代替 Runtime gate。 - -## 必读约束 - -- 发消息前必须核对接收对象、消息内容、@ 对象、附件路径和消息类型;不明确时先问用户。 -- `--group`、`--user`、`--open-dingtalk-id` 通常互斥,群聊用 `--group`,单聊用 `--user` 或 `--open-dingtalk-id`。 -- 发送本地文件、音频、视频默认用 `dws chat +messages-send --as user --file <相对路径>`; - `audio` / `video` 的具体类型以 leaf Schema 为准。只有 Shortcut 未暴露的位置、名片等底层 - 类型才进入本文件的原子 fallback。 -- 发送位置消息前必须确认纬度、经度、地址名称;地图缩略图需先通过旧媒体上传链路拿到 mediaId。 -- 分享联系人名片前必须确认联系人 `openDingTalkId`,不要把 userId 直接当 `--contact-id`。 -- 消息内容按 Markdown 渲染,换行必须是真实换行符;需要换行效果时用空行、行尾两个空格或 `
`。 -- 图文混排 Markdown 中,公网图片 URL 需要写成 `![图片标题](https://example.com/image.png)` 才会以内联图片展示;省略开头的 `!` 时会按链接/URL 展示,不会渲染为图片。 -- 建议发送时带 `--uuid`,失败重试复用同一个值。 -- Bot/Webhook 只支持文本/Markdown;Bot 多群使用 `+messages-send --groups/--groups-file` 的逐项 - ledger。不要把 user 文件/图片能力外推到 Bot。 -- `+at-me` 和 `+messages-list-direct` 要求全量时使用 `--page-all`,并检查 `complete`、 - `hasMore`、`stopReason` 和 `failures`;`+messages-list-direct` 的续页时间来自下层毫秒 - `nextCursor`,不得用只有秒精度的消息展示时间手工拼接。 - -## 原子 fallback 命令明细 - -### 发送消息的底层 fallback - -#### `dws chat message send`(非默认入口) - -以当前用户身份发送群聊或单聊消息。 - -```bash -# 文本/Markdown -dws chat message send --group --text "hello" -dws chat message send --user --text "请查收" -dws chat message send --open-dingtalk-id --text "请查收" -dws chat message send --group --title "周报提醒" --text "请大家本周五前提交周报" --uuid -dws chat message send --group --text $'这是图文说明\n\n![这个是展示图片标题](https://down.dingtalk.com/media/lQLPM5jiBEiBNjswMLAKd_CTzm8eowpEWPT_7-cA_48_48.png)' - -# @ 群成员 -dws chat message send --group --at-all "<@all> 请大家注意" -dws chat message send --group --at-open-dingtalk-ids odt1,odt2 "<@odt1> <@odt2> 请查收" - -# 图片/文件/音频/视频,一条命令直发 -dws chat message send --group --msg-type file --file-path ./screenshot.png -dws chat message send --open-dingtalk-id --msg-type file --file-path ./report.pdf -dws chat message send --group --msg-type audio --file-path ./voice.mp3 -dws chat message send --group --msg-type video --file-path ./demo.mp4 - -# 位置/联系人名片 -dws chat message send --group --msg-type location --latitude <纬度> --longitude <经度> --location-name <地址名称> --map-thumbnail-url "@mediaId" -dws chat message send --group --msg-type profile --contact-id -``` - -关键 flags: - -| Flag | 说明 | -|------|------| -| `--group` | 群聊 openConversationId;别名 `--id` / `--chat` / `--conversation-id` | -| `--user` | 单聊接收人 userId | -| `--open-dingtalk-id` | 单聊接收人 openDingTalkId | -| `--text` | 消息内容,推荐使用;也支持位置参数 | -| `--title` | 消息标题,未传时使用安全标题 | -| `--at-all` | 群聊 @所有人,正文需含 `<@all>` | -| `--at-open-dingtalk-ids` | 群聊 @指定 openDingTalkId,正文需含 `<@id>` | -| `--msg-type` | `file` / `audio` / `video` / `image` / `location` / `profile`;本地音视频用 `audio` / `video`,底层按 `file` 发送 | -| `--file-path` | 本地文件路径,`msg-type=file/audio/video` 时自动上传并发送 | -| `--media-id` | 旧图片链路 mediaId | -| `--latitude` / `--longitude` / `--location-name` | 位置消息参数 | -| `--map-thumbnail-url` | 位置消息缩略图 mediaId,形如 `@mediaId` | -| `--contact-id` | 联系人名片 openDingTalkId | -| `--uuid` | 幂等 UUID,24h 内相同值不重复投递 | - -当前没有经过验证的 Thread writer。`openConvThreadId` 只用于 `+thread-replies` 读取或 -`+messages-forward-topic` 转发;不要把它作为普通 `--group` 猜测写入。引用回复使用 -`+messages-reply`,但这不等于 Thread 内新增回复。 - -读取话题回复可直接传话题主消息 `--message-id`,CLI 会先通过只读消息详情解析出 -`conversationId/threadId`;也可显式传 `--group` 加 `--thread-id/--topic-id`。前一种模式如果同时传 -`--group`,会校验它与消息解析出的会话一致;解析失败只会报错,不会错误转去查询通讯录。 - -默认只读一页;完整读取必须显式加 `--page-all`。可用 -`--limit/--page-size` 控制每页条数、用 `--page-limit` 限制最大页数;自动续页使用下层返回的 -毫秒级 `nextCursor` 无损生成下一次 `startTime`,不能使用只有秒级精度的回复 `createTime`。 -输出默认 `--order desc`(兼容 `--sort`)。由于下层的 `newer/older` 表示读取方向而不是结果排序, -`asc` 只允许与 `--page-all` 一起使用:完整拉取后对整体结果升序排列,避免把单个“最新页”的本地反转 -伪装为全局升序。结果中的 `orderScope=complete_result` 表示完整结果排序;读取被页数上限或错误截断时为 -`fetched_pages`,并仍须结合完整性 ledger 判断。 -必须检查 `complete`、`hasMore`、`stopReason` 和 -`failures`,`complete=false` 时不得声称已经拿到全部回复。 - -```bash -dws chat +thread-replies --message-id --page-all --order asc -dws chat +thread-replies --group --thread-id --page-all --page-limit 50 -``` - -### 拉取消息的底层 fallback - -默认使用 `dws chat +chat-messages`。群聊的 `--group` 可传群名或 openConversationId; -也可用 `--chat-query` 显式按群名解析、用 `--conversation-id` 显式传稳定 ID。全量读取加 `--page-all`,必要时用 `--page-limit`、 -`--max-results` 控制边界,用 `--output <相对.json>` 原子导出。只有需要原始响应或显式手工 -continuation 时才使用下表;原子 `message list` 不代表自动全量分页。 - -可附带非必填的 `--sender-query <姓名>` 做读取后筛选。未传姓名时正常返回全部;姓名未解析出稳定 ID 时保留全部并记录失败;唯一解析出 userId/openDingTalkId 后按消息 `senderId` 筛选,覆盖最终 `messages/count` 并返回 `resolvedFilters`。该调用已经完成读取、解析与筛选,不要补跑 `+search-msg`。 - -时间范围参数同样公开但非必填:`--start`(包含)、`--end`(不包含)、`--order asc|desc`, -兼容别名为 `--start-time/--end-time/--sort`。范围固定为 `[start,end)`;仅开始时间表示到本次执行当前时间, -仅结束时间只支持 `desc`,`asc` 必须提供开始时间。旧 `--time/--direction` 保持兼容但不能和范围模式混用。 - -```bash -dws chat +chat-messages --group "项目群" --sender-query "测试用户甲" --page-all --format json -dws chat +chat-messages --group "项目群" --start "2026-08-01T00:00:00+08:00" --end "2026-08-02T00:00:00+08:00" --order asc --page-all --format json -``` - -当会话已经确定、任务只需要完整读取结果中可由消息字段判断的子集时,不新增按条件专用的 -Shortcut 参数。在同一次 `+chat-messages` 调用中使用全局 `--jq`,让 Runtime 完成读取后、 -在 stdout 前筛选;表达式必须保留根信封、用筛选结果覆盖 `messages` 并同步重算 `count`, -不得丢失 `complete`、`hasMore`、`failures` 等完整性 ledger。不要先输出全量 JSON,再由 -Agent 或另一条命令二次处理。 - -```bash -# 例:读取完整会话后,只返回存在 reaction 的消息 -dws chat +chat-messages --group "项目群" --page-all --format json \ - --jq '. as $root | [.messages[] | select((.reactions // []) | length > 0)] as $matched | $root | .messages = $matched | .count = ($matched | length)' -``` - -发送者姓名不是普通结果字段条件:仍用 `--sender-query <姓名>` 先解析稳定身份,再按 -`senderId` 筛选,不能用 `--jq` 对展示名做字符串匹配。 - -| 命令 | 用途 | 示例与要点 | -|------|------|------------| -| `message list` | 拉取指定群聊或单聊消息 | `dws chat message list --group --time "2025-03-01 00:00:00" --direction older`;目标三选一,`--direction newer/older` 优先于旧 `--forward` | -| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start --end --limit 100 --cursor 0`;默认一页,完整遍历加 `--page-all`,保留并合并 `result.conversationMessagesList` | -| `message list-by-sender` | 查指定发送者消息 | `--sender-user-id` 与 `--sender-open-dingtalk-id` 二选一,跨单聊+群聊;完整遍历加 `--page-all`,同一会话跨页合并 messages | -| `message list-mentions` | 查 @ 我的消息 | 可传 `--group` 限定群,不传查全部;完整遍历加 `--page-all`,同一会话跨页合并 messages | -| `message list-focused` | 查特别关注人消息 | 零参数可用,可加 `--limit` / `--cursor`;完整遍历加 `--page-all`,cursor 为 int64 | -| `message list-unread-conversations` | 未读会话列表 | 可选 `--count` | -| `message list-topic-replies` | 拉取话题回复 | `--group --topic-id ` | -| `message list-by-ids` | 按消息 ID 批量查询 | `--msg-ids msgId1,msgId2`,最多 50 条 | - -Typed `chat message` 自动翻页只由 `--page-all` 触发;只传 `--page-limit`、`--max-items` 或 `--page-delay` 仍保持默认单页 fallback。`conversationMessagesList` 结构会保留并按 `openConversationId` 合并 messages。分页元数据输出到顶层 `paging`,包含 `truncated`、`hasMore`、`lastCursor`、`pages`、`total`;非第一页失败时会输出 partial 结果和 `failedPage` / `failedCursor` / `pagesFetched` / `itemsFetched`。 - -`message list` 注意事项: - -- `--group`、`--user`、`--open-dingtalk-id` 互斥且必须指定一个。 -- `--time` 格式为 `yyyy-MM-dd HH:mm:ss`。 -- `hasMore=true` 时,用结果中的边界 `createTime` 作为下次 `--time`。 -- 返回 `openConvThreadId` 表示话题消息,完整内容需再拉 `list-topic-replies`。 - -### 搜索消息的底层 fallback - -直接按发送者、关键词、@对象或消息类型检索时优先使用 `dws chat +search-msg`;搜索范围可以是单个、多个或全部会话。若已选择 `+chat-messages` 读取指定会话,可由其非必填 `--sender-query` 在同一次调用完成姓名解析和筛选。 - -- 搜索内容使用公开参数 `--query`。 -- 已知稳定会话 ID 使用 `--group` / `--groups`;已知稳定发送者 ID 使用 `--senders`。 -- 只有群名时使用非必填参数 `--chat-query`,由 CLI 唯一解析会话。 -- 只有发送者姓名时使用非必填参数 `--sender-query`,由 CLI 唯一解析人员。 -- 不要把群名传给只接受稳定 ID 的会话参数,也不要把姓名传给 `--senders`。零命中或多候选时停止,不选择第一项。 -- 不传会话过滤时搜索全部会话;`--page-all` 只翻完当前时间范围内的游标页,默认时间范围是最近 7 天。 -- 精确范围使用成对的 `--start/--end`(兼容 `--start-time/--end-time`);`--order`(兼容 `--sort`)稳定排列本次实际取得的结果。未 `--page-all` 或 `complete=false` 时不能称为完整范围的全局排序。 - -需要 Shortcut 未暴露的原始过滤字段或响应时,才评估 `message search-advanced`。它是原子 `message search` 的严格超集,但不是 Agent 高频默认入口。 - -```bash -# 单群 + 发送者姓名 -dws chat +search-msg --chat-query "项目群" --sender-query "测试用户甲" --page-all --format json - -# 单群 + 关键词 -dws chat +search-msg --chat-query "项目群" --query "发布计划" --page-all --format json - -# 跨全部会话 + 发送者姓名 -dws chat +search-msg --sender-query "测试用户甲" --page-all --format json -dws chat +search-msg --query "发布计划" --start-time "2026-08-01T00:00:00+08:00" --end-time "2026-08-02T00:00:00+08:00" --sort asc --page-all --format json -``` - -```bash -dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" -dws chat message search-advanced --user --start --end -dws chat message search-advanced --at-me --start --end -dws chat message search-advanced --conversation-ids , --query "合同" --limit 50 --cursor 0 -dws chat message search-advanced --query "周报" --start --end --page-all --max-items 200 -dws chat message search-advanced --message-type file --search-conv-type group_chat --query "附件" -dws chat message search-advanced --only-robot-messages --query "通知" -``` - -| 参数 | 说明 | -|------|------| -| `--query` | 搜索关键词,可选 | -| `--user` / `--users` | 发送者 userId | -| `--sender-ids` | 发送者 openDingTalkId | -| `--at-me` / `--at-ids` | @ 我 / @ 指定 openDingTalkId | -| `--conversation-ids` | 多个群聊或单聊 openConversationId;别名 `--groups` | -| `--message-type` | 按消息类型过滤,例如 `file` | -| `--search-conv-type` | 按会话类型过滤,例如 `group_chat` | -| `--only-robot-messages` | 只搜索机器人消息 | -| `--start` / `--end` | ISO-8601 时间范围 | -| `--cursor` / `--limit` | 分页,翻页用 `nextCursor` | -| `--page-all` / `--page-limit` / `--max-items` / `--page-delay` | 自动翻页;`--page-all` 是唯一触发开关 | - -仅简单关键词搜索时可用: - -```bash -dws chat message search --query "changefree" --start --end --limit 50 --cursor 0 -dws chat message search --query "codereview" --group --start --end -dws chat message search --query "发布计划" --start --end --page-all --page-delay 0 -``` - -### 消息状态与撤回 - -| 命令 | 用途 | 必填参数 | -|------|------|----------| -| `message query-send-status` | 查询当前用户发消息任务状态 | `--open-task-id`,来自 `message send` 返回 | -| `+messages-recall` | 撤回当前用户消息 | `--msg-id`;可选 `--conversation-id`,省略时 CLI 从消息详情补齐;兼容单值 `--message-ids` | -| `message edit` | 编辑已发送消息内容 | `--conversation-id` `--msg-id`,并在 `--text` / `--content` 中二选一;可选 `--title` `--at-all` `--at-open-dingtalk-ids` | -| `message read-status` | 查消息已读/未读状态 | `--group` `--message-id`;可选目标用户 | - -刚由 `message send` 发出的消息会返回 `openTaskId`。先用 `message query-send-status` 查询,成功结果中的 `openMessageId` 和 `openConversationId` 可直接传给 `message edit` 或 `message recall`,无需再按消息内容从列表反查 ID。 - -```bash -# 1. 发送后保留 openTaskId -dws chat message send --group --text "原始内容" -# 2. 查询得到 openMessageId 和 openConversationId -dws chat message query-send-status --open-task-id -# 3. 编辑消息 -dws chat message edit --conversation-id --msg-id --text "更新后的内容" - -# 发送后撤回使用同一 ID 链 -dws chat message send --group --text "待撤回的内容" -dws chat message query-send-status --open-task-id -dws chat message recall --conversation-id --msg-id -``` - -`+messages-recall` 与 `recall-by-bot` 不同:前者使用 `openMessageId`,缺少会话 ID 时先只读查询消息详情;后者撤回机器人消息,需要 `robot-code + processQueryKey`。不要把 `processQueryKey` 当 `openMessageId`。 - -编辑消息使用 `message edit`。推荐传 `--text`,CLI 会生成 markdown content JSON:`{"title":"标题","text":"正文"}`;可选 `--title`,不传时会从正文自动生成标题。高级场景可直接传 `--content`,此时必须是完整 markdown content JSON,且不能同时传 `--text`。 - -@ 规则:`--at-all` 会传 `atAll=true`,正文应包含 `<@all>`,未包含时 CLI 会自动补到开头;`--at-open-dingtalk-ids` 会传 `atOpenDingTalkIds`,正文需包含对应 `<@openDingTalkId>` 占位符,裸 `@openDingTalkId` 会自动补成尖括号格式。 - -```bash -dws chat message edit --conversation-id --msg-id --text "更新后的内容" -dws chat message edit --group --msg-id --title "标题" --text "更新后的内容" -dws chat message edit --group --msg-id --text "<@all> 请查看" --at-all -dws chat message edit --group --msg-id --text "<@openDingTalkId1> 请查看" --at-open-dingtalk-ids -dws chat message edit --group --msg-id --content '{"title":"标题","text":"更新后的内容"}' -``` - -### 回复与转发的底层 fallback - -引用回复默认使用 `dws chat +messages-reply`。以下原子 reply 只保留底层字段 fallback;转发 -仍按各自精确 Shortcut/leaf Schema 选择。 - -| 命令 | 用途 | 必填参数 | -|------|------|----------| -| `message reply` | 引用回复,单聊/群聊均可;群聊可 @指定成员或 @所有人 | `--conversation-id` `--ref-msg-id` `--ref-sender` `--text`;可选 `--at-open-dingtalk-ids` `--at-all` | -| `message forward` | 转发单条消息,源/目标均支持单聊/群聊 | `--src-conversation-id` `--msg-id` `--dest-conversation-id` | -| `message combine-forward` | 多条消息合并为一条转发 | `--src-conversation-id` `--msg-ids` `--dest-conversation-id`,可选 `--uuid` | -| `message forward-topic` | 转发话题消息 | `--src-msg-id` `--src-conversation-id` `--src-thread-id` `--dest-conversation-id` | - -群聊引用回复使用 `--at-open-dingtalk-ids` 传 `atOpenDingTalkIds`;正文缺少对应 `<@openDingTalkId>` 时自动补齐,已有裸 `@openDingTalkId` 会规范化。`--at-all` 会传 `atAll=true`,正文缺少 `<@all>` 时自动补齐。 - -```bash -dws chat message reply --conversation-id --ref-msg-id --ref-sender --text "请看一下" --at-open-dingtalk-ids -dws chat message reply --conversation-id --ref-msg-id --ref-sender --text "请大家确认" --at-all -``` - -### 话题与卡片 - -话题完整读取流程: - -1. `dws chat message list --group --time ...` 获取话题主消息。 -2. 如果返回 `openConvThreadId`,执行 `dws chat message list-topic-replies --group --topic-id `。 - -流式卡片优先使用公开 Shortcut;创建可选在同一次调用中写入内容: - -```bash -dws chat +messages-send-card --group --at-open-dingtalk-ids --content "开始处理" --flow-status 1 -dws chat +messages-update-card --biz-id --content "更新的卡片内容" --flow-status 2 -dws chat +messages-update-card --biz-id --content "最终内容" --flow-status 3 -``` - -`flow-status`:1=处理中,2=输入中,3=完成,4=执行中,5=错误,Runtime 拒绝范围外值。 -群聊还可传 `--at-all`;两种艾特参数只随创建请求发送。send-card 同时带正文时, -Runtime 会把创建响应的 `atTag` 自动放在正文前;不要自行写 ID 或占位符。 -当前只支持 streaming text;不支持 Card JSON 组件或 action callback。精确边界见 -[card references](../card/schema.md)。 - -### Pin / Top / Favorite - -| 命令 | 用途 | 必填参数 | -|------|------|----------| -| `message set-pin-msg` / `unset-pin-msg` | 钉住/取消钉住消息 | `--open-conversation-id` `--msg-id` | -| `message list-pin-msg` | 拉取钉住消息列表 | `--open-conversation-id`,可选 `--cursor` `--size` | -| `message set-top-msg` / `unset-top-msg` | 置顶/取消置顶会话内某条消息 | `--open-conversation-id` `--msg-id` | -| `message add-favorite` | 收藏消息 | `--open-message-id` `--open-conversation-id` | -| `message remove-favorite` | 取消收藏消息 | `--open-message-id` `--open-conversation-id` | -| `+flag-list` | 查询收藏消息列表的默认 Shortcut | 默认一页;要求全部时加 `--page-all`;`--page-size/--size` 范围为 1-30,可用 `--page-token` 或兼容的 `--cursor` 续页,检查 `complete` / `failures` | -| `message list-favorites` | typed 收藏消息列表 | 默认一页;完整遍历加 `--page-all`,数字 cursor,聚合 `result.items`;可选 `--cursor` `--size` | - -`+flag-list` 查询钉钉 message favorite,底层使用数字 cursor;它与消息 Pin、消息 Top 和会话置顶属于不同对象层级。 -`message list-favorites --page-all` 仍遵守 `--size` 1-30;`--page-limit` 只控制最多请求页数,`--max-items` 可精确截断输出。 -消息置顶 `set-top-msg` 与会话置顶 `chat set-top` 不同:前者置顶会话内消息,后者置顶整个会话。 - -### 表情回应 - -优先查 [chat-emoji-list.md](../chat-emoji-list.md) 中的默认表情名称。 - -| 命令 | 场景 | 必填参数 | -|------|------|----------| -| `message add-emoji` / `remove-emoji` | 默认表情命中时使用 | `--conversation-id` `--msg-id` `--emoji` | -| `message create-text-emotion` | 默认表情没有合适项时先创建 | `--emotion-name` `--text`,可选 `--background-id` | -| `message add-text-emotion` / `remove-text-emotion` | 添加/移除文字表情 | `--conversation-id` `--msg-id` `--emotion-id` `--emotion-name` `--text` `--background-id` | -| `message update-text-emotion` | 用新的文字表情替换消息上的原回应 | `--conversation-id` `--msg-id` `--old-emotion-id` `--emotion-id` `--emotion-name` `--text` `--background-id` | -| `message list-emotion-replies` | 批量查询消息的表情回复和文字回复 | `--msg-ids` | - -```bash -dws chat message list-emotion-replies --msg-ids msgId1,msgId2,msgId3 -``` - -消息 ID 可通过 `dws chat message list` 获取;该命令用于一次性查看多条消息上的 emoji 回应和文字表情回应。 - -### 文本工具 - -#### `dws chat text translate` - -将指定文本翻译成目标语言。用户只说“翻译这段文字”时使用;不要误走 `message send`。 - -```bash -dws chat text translate --query "你好世界" --to en_US -dws chat text translate --query "Hello World" --to zh_CN -dws chat text translate --query "Bonjour" --to ja_JP -``` - -关键 flags: - -| Flag | 说明 | -|------|------| -| `--query` | 待翻译文本,必填 | -| `--to` | 目标语言代码,必填,默认 `en_US` | - -### 文件与媒体 - -#### `dws chat message download-media` - -```bash -dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./downloads/ -``` - -`resource-id` 来自消息内容中的 mediaId,`message-id` 来自 `openMessageId`,会话 ID 来自 `chat search` 或 `conversation-info`。 - -公开 `+messages-resource-download` 使用工作目录内安全相对路径、默认不覆盖、整文件临时落盘后 -原子发布。当前没有 Range/断点续传;失败时保留 ledger 或错误,显式重试整个文件,不拼接残片。 - -## 常见工作流 - -### 群聊发文字与文件 - -```bash -dws chat +send-to-group --group "项目冲刺" --text "请大家本周五前提交周报" --format json -dws chat +messages-send --as user --chat-query "项目冲刺" --file ./report.pdf --uuid --format json -dws chat +messages-send --as user --chat-query "项目冲刺" --msg-type audio --file ./voice.mp3 --uuid --format json -dws chat +messages-send --as user --chat-query "项目冲刺" --msg-type video --file ./demo.mp4 --uuid --format json - -# 仅 Shortcut 尚未覆盖的位置/名片底层类型才使用原子 fallback -dws chat message send --group --msg-type location --latitude <纬度> --longitude <经度> --location-name <地址名称> --map-thumbnail-url "@mediaId" --format json -dws chat message send --group --msg-type profile --contact-id --format json -``` - -### 查消息并撤回 - -```bash -dws chat +chat-messages --group --direction older --format json -dws chat +messages-recall --msg-id --format json -``` - -### 多维度搜索 - -```bash -dws chat message search-advanced --query "合同" --conversation-ids , --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 --format json -dws chat message search-advanced --message-type file --search-conv-type group_chat --query "附件" --format json -``` - -## 常见错误与回退 - -- 发送目标不唯一:保留 resolver 返回的候选并让用户消歧;不要退回手工搜索后选择第一项。 -- `unknown flag`:立即执行对应命令 `--help`,不要猜参数。 -- 文件/音视频发送失败:确认本地路径可读;新链路使用 `--msg-type file|audio|video --file-path`。 -- 位置消息参数不完整:先确认经纬度、地址名称和缩略图 mediaId。 -- 名片发送失败:确认 `--contact-id` 是 openDingTalkId,不是 userId。 -- 话题回复缺失:检查是否只拉了主消息,需继续用 `list-topic-replies`。 -- `search-advanced` 无条件:至少提供 query、sender、@、conversation、时间等任一有效过滤条件。 diff --git a/skills/multi/dingtalk-chat/references/chat/group-admin.md b/skills/multi/dingtalk-chat/references/chat/group-admin.md new file mode 100644 index 00000000..1ae09c39 --- /dev/null +++ b/skills/multi/dingtalk-chat/references/chat/group-admin.md @@ -0,0 +1,144 @@ +# group-admin:群创建、成员写入与管理 + +> 返回入口:[DingTalk Chat Skill](../../SKILL.md) + +用于建群、修改群资料、成员增删、邀请卡片分享、群主和管理员、禁言、公告、群设置、 +入群审批、群身份、退出、解散和升级外部群。只读群发现、成员读取和邀请链接使用 +[group-discovery.md](group-discovery.md)。 + +## 安全与目标 + +- 群目标统一使用当前 profile 下真实 `openConversationId`;支持自然群名的 Shortcut 由 CLI + 唯一解析,多候选时停止。 +- 解散群、踢人、转让群主、禁言、管理员和外部群升级都是高影响操作;以最终 Runtime gate + 和精确 leaf Schema 为准确认对象、动作与影响。 +- 所有自然成员和群主必须先完成唯一解析并按稳定 ID 去重,再开始任何写入;不得边解析边 + 产生部分副作用。 +- 群公告会触达成员;`notice edit` 是整体替换,必须有完整新正文。 + +## 建群与基础资料 + +基础建群使用 `dws chat +chat-create`。已知成员 ID 传 `--users`, +姓名/花名传 `--member-query`;群主默认当前用户,也可传 `--owner-open-dingtalk-id` 或 +`--owner-query`。任一自然身份未唯一解析时,创建前整体停止。 + +```bash +dws chat +chat-create --name "项目冲刺群" --member-query "测试用户甲,测试用户乙" --format json +dws chat +chat-create --name "合作群" --member-query "测试用户甲" \ + --owner-query "测试用户乙" --type EXTERNAL --format json +``` + +修改群名称优先使用接受群名或稳定 ID 的 `+chat-update`: + +```bash +dws chat +chat-update --group <群名或openConversationId> --name "新群名" --format json +``` + +群头像和管理员级群开关使用 `+chat-update-icon`、`+chat-update-settings`;只有 Shortcut +尚未发布真实必需字段时才评估原子 `group rename/update-icon/update-settings`。 + +原子 `chat group create` 只用于 `+chat-create` 未发布的真实底层字段,并先读取精确 leaf +Schema。普通内部/外部群、话题群和显式群主已经由 `+chat-create` 覆盖,不回流到手工 +`aisearch → group create` 链路。 + +## 成员与机器人写入 + +| 动作 | 入口与关键参数 | +|---|---| +| 添加成员 | `group members add --id --users ` | +| 移除成员 | `group members remove --id --users ` | +| 添加已知机器人 | `+chat-add-bot` 或精确原子 `group members add-bot` | +| 查看群内机器人 | `+chat-bots --group <群名或cid>` | +| 移除群内机器人 | `+chat-remove-bot` 或精确原子 `group members remove-bot` | + +普通成员增删的 `--users` 只接受组织 `userId`,必须来自真实人员解析结果;不得把 +`+chat-members-list` / `+chat-members-get` 返回的 `openDingTalkId` 直接传入。添加已知机器人 +使用 `robotCode`;移除机器人使用当前群 `+chat-bots` 返回的真实 `openBotId`,两者不能互换。 +缺少 `openBotId` 时在同一流程中先执行 `+chat-bots`,不必额外读取群发现 reference。只有需要 +搜索未知机器人、区分 `bot search` / `bot find`、机器人发送或撤回、Webhook 时,才读取 +[chat-bot.md](chat-bot.md)。 + +## 邀请卡片、群主、管理员与禁言 + +邀请链接只读走 `+chat-invite-url`。实际分享邀请卡片使用 `group share-invite`:`--source` +是被分享群,接收端在 `--target` 会话和 `--receiver` 单聊用户之间二选一。 + +```bash +dws chat group share-invite --source --target --format json +dws chat group share-invite --source --receiver --format json +``` + +| 动作 | 入口与关键参数 | +|---|---| +| 转让群主 | `+chat-transfer-owner --group --new-owner <稳定ID>` | +| 设置/取消管理员 | `group set-admin --group --users [--off]` | +| 全员禁言/解除 | `group-mute --group [--off]` | +| 成员禁言/解除 | `+chat-mute-member` 或 `group-mute-member` | +| 查询禁言配置 | `group get-mute-config --group ` | + +原子 `group-mute-member --mute-time` 单位为毫秒。不要用展示名称代替稳定用户 ID,也不要 +在未确认影响时执行转让、踢人或禁言。 + +## 群设置与当前用户偏好 + +管理员级群开关使用 `+chat-update-settings` 或原子 `group update-settings`。常见 settingKey +包括 `authority`、`joinValidation`、`onlyAdminCanAtAll`、`searchable`、 +`addFriendForbidden`、`onlyAdminCanDING`、`onlyAdminCanPinMsg` 和 +`onlyAdminCanSendFile`、`groupEmailDisabled`、`groupLiveAuthority`、 +`groupBillAuthority`;只修改用户明确要求的字段。 + +新成员历史消息可见范围使用 `group set-history --group --option <值>`;`option` 只取 +精确 leaf Schema 发布值,不按自然语言猜枚举。 + +当前登录用户自己的置顶、免打扰、群昵称和群备注使用 `group user-settings query/set`, +不是管理员群开关。单个群昵称/备注优先 `group update-nick/update-alias`。 + +```bash +dws chat group user-settings query --groups , --format json +dws chat group user-settings set \ + --items '[{"openConversationId":"cid1","top":true,"mute":false}]' --format json +``` + +批量设置只传本次要改的字段;空字符串清除昵称或备注,不补用户未要求的值。 + +## 群公告 + +| 动作 | 原子入口 | +|---|---| +| 发布公告 | `group notice create --group --content <完整Markdown>` | +| 修改公告 | `group notice edit --group --notice-id --content <完整Markdown>` | +| 查询公告 | `group notice get/list` | + +定时公告 `--run-at` 使用带时区时间;`notice list --scheduled` 查询待发布公告。分页时沿真实 +`nextPageCursor` 继续。修改前必须取得完整替换正文,不把增量片段当整篇公告。 + +## 入群审批与群身份 + +先用 `group list-join-validations` 取得真实 `record-id/applicant/inviter`,再执行 +`group audit-join-validation` 或 `+chat-audit-join`。审批状态只使用精确 leaf Schema 发布值。 + +群身份使用 `group-role` / `+chat-role-*`: + +- `list/add/update/remove` 管理身份定义; +- `set-user/remove-user/query-user` 管理成员身份; +- `openRoleId` 必须来自真实身份列表。 + +覆盖或清除成员身份前确认用户、群和完整角色集合,不能用展示名称猜 `openRoleId`。 + +## 退出、解散与外部群升级 + +- 当前用户退出群:`+chat-quit` 或精确原子 `group quit`。 +- 解散群:`group dismiss`,不可逆。 +- 普通群升级外部群:`group upgrade-to-external`,不可逆。 + +这些动作必须以最终 Runtime gate 为准,不把示例中的确认参数当固定事实。 + +## 完成与错误 + +- 创建或更新后保留真实 `openConversationId` 和任务结果;只对查询结果真实返回的字段执行读回验证。 +- 写接口成功但现有查询未返回目标设置时,报告真实写入回执和不可独立读回的边界;不用群名、 + 成员数等其他字段代替验证,也不猜未发布的读回命令。 +- 任一自然目标零命中或多候选时,在写入前整体停止。 +- 逐项写入保留 succeeded/failed/unknown ledger,不用重试抹掉失败项。 +- 分享邀请时 `--target` 与 `--receiver` 只能二选一;接收对象不明确时先确认。 +- 机器人进群失败时确认机器人身份和当前用户管理权限,不连续切换同义原子命令。 diff --git a/skills/multi/dingtalk-chat/references/chat/group-discovery.md b/skills/multi/dingtalk-chat/references/chat/group-discovery.md new file mode 100644 index 00000000..7a091abc --- /dev/null +++ b/skills/multi/dingtalk-chat/references/chat/group-discovery.md @@ -0,0 +1,121 @@ +# group-discovery:群发现、列表与成员读取 + +> 返回入口:[DingTalk Chat Skill](../../SKILL.md) + +用于只读的群列表、群搜索、共同群、群成员、群机器人和邀请链接。建群、改群、成员增删、 +邀请卡片分享、公告、禁言和其他群管理写操作读取 [group-admin.md](group-admin.md)。 + +## 入口选择 + +| 用户终点 | 唯一推荐入口 | +|---|---| +| 我加入的全部群 | `dws chat +my-groups --page-all` | +| 我创建或管理的群 | `dws chat +chat-list-mine` | +| 只看群主群或管理员群 | `+chat-list-mine --role OWNER|ADMIN` | +| 按关键词搜索群 | `dws chat +chat-search --query <关键词>` | +| 查看指定群全部成员 | `dws chat +chat-members-list --group <群名或ID>` | +| 已知成员 openDingTalkId 批量查群内详情 | `dws chat +chat-members-get --id --users ` | +| 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` | +| 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` | + +“全部群”与“全部会话”不同:`+my-groups` 只列当前用户加入的群; +`+conversation-list --page-all` 可能同时包含单聊和群聊,不能替代群成员关系。 + +## 群列表、分页与角色 + +`+my-groups` 返回当前用户加入的群,包括作为群主、管理员和普通成员加入的群: + +- 要求完整列表时使用 `--page-all`;Runtime 沿真实 `nextCursor` 读取后续页,并按 + `openConversationId` 合并去重,读完后再应用可选 `--type` 本地过滤。 +- `--limit` 是每页数量,不是最终结果上限;`--cursor` 只用于从已有 `nextCursor` 手工续读。 +- `--page-limit` 只与 `--page-all` 一起使用,用于限制最多读取页数。达到上限后仍有下一页时, + 结果不完整。 +- 只有 `complete=true` 且 `hasMore=false` 才能声称已经读取全部;否则保留 `nextCursor`、 + `stopReason` 和 `failures` 并说明结果不完整。 + +```bash +dws chat +my-groups --page-all --page-limit 50 --format json +``` + +`+chat-list-mine` 只返回当前用户作为群主或管理员的群。只要群集合时不传 `--role`, +一次取得 OWNER 和 ADMIN。要求逐项标明身份时,直接分别查询 `--role OWNER` 和 +`--role ADMIN`,不先执行无角色查询或读取 Help;按 `openConversationId` 合并去重后, +再应用一次全局数量上限,不得把两个分支直接拼接。 + +```bash +dws chat +chat-list-mine --limit 20 --format json +dws chat +chat-list-mine --role OWNER --format json +dws chat +chat-list-mine --role ADMIN --exclude-muted --format json +``` + +`+my-groups` 不提供当前用户角色。用户明确要求普通成员群时,使用 +`chat group list-all --limit 200`;返回 `hasMore=true` 时,必须把真实 `nextCursor` +传给下一次调用并继续读取,直到 `hasMore=false`,不得把继续翻页交给用户。读完后按 +`openConversationId` 去重,仅筛选真实返回的 `myRole=普通成员`;不得给 `+my-groups` +编造 `--role MEMBER`,也不得用“全部群减去 OWNER/ADMIN 群”推断。 + +## 群搜索与稳定 ID + +群搜索默认使用 `+chat-search`。要求全部候选时加 `--page-all`;可用 +`--page-size/--page-token` 或兼容 `--limit/--cursor`。零命中或多候选时停止并展示候选, +不要选择第一项。 + +```bash +dws chat +chat-search --query "项目冲刺" --page-all --format json +``` + +只有数字群号时,使用 `chat group get-by-group-id --group-id <数字>` 转换为 +`openConversationId`。需要搜索共同群时使用原子 `chat search-common`;`AND` 表示所有人 +都在群里,`OR` 表示任一人在群里。自然人员必须先解析为当前 profile 的真实身份。 + +```bash +dws chat search-common --nicks "测试用户甲,测试用户乙" --match-mode AND --limit 20 --cursor 0 +``` + +## 群成员 + +`+chat-members-list` 接受群名或 `openConversationId`,唯一解析后全量读取,并把用户与机器人 +分桶。结果必须检查 `buckets/complete/failures`。 + +```bash +dws chat +chat-members-list --group "项目群" --format json +dws chat +chat-members-list --conversation-id --format json +``` + +先检查 `+chat-members-list` 的稳定结果。只有结果未包含用户要求的群昵称、角色或其他群内字段时, +才使用其中的真实 `openDingTalkId` 批量调用: + +```bash +dws chat +chat-members-get --id \ + --users , --format json +``` + +不要为了群内详情默认切换到企业通讯录;只有用户明确要求部门、岗位、直属主管等企业资料时, +才把真实 userId 交给 `dingtalk-contact`。 + +## 邀请链接与机器人 + +`+chat-invite-url` 是只读获取链接,可选 `--expires-seconds`;`group share-invite` 会实际把 +邀请卡片发送给另一个会话或用户,属于 [group-admin.md](group-admin.md)。 + +`+chat-bots` 返回稳定 `bots[]` 和 `openBotId`,供后续移除。搜索可用机器人、机器人发送和 +撤回读取 [chat-bot.md](chat-bot.md)。 + +## 原子 fallback + +| 原子命令 | 仅用于 | +|---|---| +| `chat search` / `search-common` | Shortcut 未发布的搜索字段或共同群 | +| `chat group get-by-group-id` | 数字群号转换 | +| `chat group members` | 需要原始成员分页响应 | +| `chat group members list-by-ids` | 需要原始批量成员详情 | +| `chat group list-all` / `list-my-groups` | 需要 Shortcut 未投影的真实底层字段 | + +使用原子 fallback 前读取精确 leaf Schema;不得把 fallback 写成与 Shortcut 并列的默认路线。 + +## 完成与错误 + +- 分页完成只以真实 `complete/hasMore/nextCursor/failures` 判断,不看过滤后的 `count` 猜测。 +- 所有稳定 ID 必须来自同一 profile 的真实返回。 +- 找不到群或出现多候选时停止,不臆测 `openConversationId`。 +- 任务从只读发现转为写操作时,使用 [group-admin.md](group-admin.md) 的目标和安全规则。 diff --git a/skills/multi/dingtalk-chat/references/chat/message-actions.md b/skills/multi/dingtalk-chat/references/chat/message-actions.md new file mode 100644 index 00000000..dc34498f --- /dev/null +++ b/skills/multi/dingtalk-chat/references/chat/message-actions.md @@ -0,0 +1,132 @@ +# message-actions:消息编辑、撤回、回复与对象操作 + +> 返回入口:[DingTalk Chat Skill](../../SKILL.md) + +用于对真实消息执行编辑、撤回、引用回复、转发、Pin、Top、Favorite 和表情回应写操作, +并包含必要的紧邻验证。需要跨多个阶段传递真实结果的组合流程由 +[01-messaging.md](../01-messaging.md) 说明;本文件不重复完整工作流。 + +## 入口选择 + +| 用户终点 | 推荐入口 | +|---|---| +| 撤回当前用户消息 | `dws chat +messages-recall --msg-id ` | +| 引用回复 | `dws chat +messages-reply` | +| 编辑已发送消息 | `dws chat message edit` | +| 单条/合并/话题转发 | `+messages-forward` / `+messages-combine-forward` / `+messages-forward-topic` | +| Pin / Unpin | `+messages-set-pin` / `+messages-unset-pin` | +| 消息 Top / 取消 Top | `+messages-set-top` / `+messages-unset-top` | +| Favorite / 取消 Favorite | `+flag-create` / `+flag-cancel` | +| 默认 emoji 回应 | `+messages-add-emoji` / `+messages-remove-emoji` | + +所有写操作以最终 Runtime gate 和精确 leaf Schema 为准。确认对象、消息和影响后再执行; +不要因为文档示例自行制造或省略 confirmation。 + +## 稳定 ID 规则 + +- `openTaskId` 是发送任务 ID,不是消息 ID。 +- 撤回、编辑、回复、转发、Pin、Top 和 reaction 使用真实查询结果中的 `messageId`。 +- 同时保留消息的 `conversationId`、thread、发送者和引用上下文。 +- 子消息使用自己的 `messageId`;只在缺会话 ID 时继承父消息 `conversationId`。 +- Bot 撤回使用 `processQueryKey`,不使用本文件的 `openMessageId` 路线。 + +刚由用户身份发送的消息如果只得到 `openTaskId`,先查询发送状态: + +```text ++messages-send 或 message send +→ openTaskId +→ message query-send-status +→ openMessageId + openConversationId +→ 编辑或撤回 +``` + +## 撤回与编辑 + +`+messages-recall` 可只传 `--msg-id`;省略会话 ID 时 CLI 会通过只读消息详情补齐。 +兼容单值 `--message-ids`,但不要把 `processQueryKey` 当消息 ID。 + +```bash +dws chat +messages-recall --msg-id --format json +dws chat +messages-recall --conversation-id --msg-id --format json +``` + +编辑使用 `message edit --conversation-id --msg-id `,并在 `--text` 与 `--content` +中二选一。`--text` 由 CLI 生成 Markdown content;`--content` 必须是完整 content JSON。 + +```bash +dws chat message edit --conversation-id --msg-id --text "更新后的内容" +dws chat message edit --conversation-id --msg-id --content '{"title":"标题","text":"更新后的内容"}' +``` + +群聊 @所有人使用 `--at-all`;指定人员使用 `--at-open-dingtalk-ids`。正文中的占位符以 +Runtime 规范化结果为准,不把裸展示名当稳定身份。 + +## 引用回复与转发 + +引用回复默认使用 `+messages-reply`。`--conversation-id` 和消息 ID 来自真实查询; +`--ref-sender` 可省略时让 CLI 只读补齐,不手工猜发送者身份。 + +```bash +dws chat +messages-reply --conversation-id \ + --message-id --text "收到" --format json +``` + +| 动作 | 入口 | 关键上下文 | +|---|---|---| +| 单条转发 | `+messages-forward` | 源消息 ID、源会话、目标会话 | +| 合并转发 | `+messages-combine-forward` | 多个真实消息 ID、源/目标会话 | +| 话题转发 | `+messages-forward-topic` | 源消息、源会话、源 thread、目标会话 | + +只有 Shortcut 尚未发布真实必需字段时,才评估原子 `message reply`、`forward`、 +`combine-forward` 或 `forward-topic`,并先读取精确 leaf Schema。不要复制正文伪装原生转发。 + +## Pin、Top 与 Favorite + +| 对象 | 写入入口 | 说明 | +|---|---|---| +| 消息 Pin | `+messages-set-pin` / `+messages-unset-pin` | 作用于一条消息 | +| 消息 Top | `+messages-set-top` / `+messages-unset-top` | 作用于会话内一条消息 | +| Favorite | `+flag-create` / `+flag-cancel` | 当前用户收藏 | +| 会话 Top | `+conversation-set-top` | 作用于整个会话,不属于本文件 | + +用户要求确认 Pin 已生效时,使用 `+messages-list-pin` 检查真实结果中的 `messageId`;取消 Pin +后仅在用户要求确认取消结果时再次查询。典型短链为:`+messages-set-pin` → +`+messages-list-pin` → `+messages-unset-pin`。 + +需要原子 fallback 时,消息 Pin 对应 `message set-pin-msg/unset-pin-msg`,消息 Top 对应 +`message set-top-msg/unset-top-msg`,Favorite 对应 `message add-favorite/remove-favorite`。 +四种对象不能互换,即使用户都使用“收藏、钉住、置顶”等自然语言。 + +## 表情回应 + +优先在 [chat-emoji-list.md](../chat-emoji-list.md) 按表情名称查默认 emoji,不必全文理解表格。 + +| 场景 | 入口 | +|---|---| +| 添加/移除默认 emoji | `+messages-add-emoji` / `+messages-remove-emoji` | +| 默认表情无合适项时创建文字表情 | `+messages-create-text-emotion` | +| 添加/移除文字表情 | `+messages-add-text-emotion` / `+messages-remove-text-emotion` | +| 替换文字表情 | `message update-text-emotion` | + +reaction 查询属于 [message-query.md](message-query.md),不要为了查看回应执行写命令。 + +## 流式卡片与文本工具 + +流式卡片使用根 Skill 直接链接的 [card/create.md](../card/create.md)、 +[card/update.md](../card/update.md) 和 [card/schema.md](../card/schema.md);本文件不复制卡片参数。 + +纯文本翻译使用: + +```bash +dws chat text translate --query "你好世界" --to en_US +``` + +用户只要求翻译文本时不要误走消息发送。 + +## 完成与错误 + +- 写操作检查任务级结果、投递状态和失败项,不只看退出码。 +- 投递状态 unknown 时保留幂等键,不自动换目标重发。 +- `unknown flag` 时读取精确 leaf Help,最多修正一次。 +- 目标消息不存在、会话不匹配或发送者上下文缺失时停止,不猜 ID。 +- 已知稳定消息 ID 的单一动作及其紧邻验证均在本文件完成。 diff --git a/skills/multi/dingtalk-chat/references/chat/message-media.md b/skills/multi/dingtalk-chat/references/chat/message-media.md new file mode 100644 index 00000000..d2d660f8 --- /dev/null +++ b/skills/multi/dingtalk-chat/references/chat/message-media.md @@ -0,0 +1,76 @@ +# message-media:特殊消息与资源下载 + +> 返回入口:[DingTalk Chat Skill](../../SKILL.md) + +只用于位置、联系人名片、底层 mediaId/fileId 和消息资源下载。普通文本、Markdown、文件、 +图片、音频和视频发送继续按根 Skill 使用 `+dm`、`+send-to-group` 或 `+messages-send --file`, +不读取本文件。 + +## 默认边界 + +- 用户身份普通文件/音视频:`dws chat +messages-send --as user --file <相对路径>`。 +- 已知资源引用单独下载:`dws chat +messages-resource-download`。 +- 从消息中定位并下载资源:在定位消息的 `+chat-messages`、`+search-msg` 或 + `+messages-mget` 同一次调用中加 `--download-resources`。 +- 只有 Shortcut 尚未发布的位置、联系人名片或真实底层媒体字段,才使用原子 fallback。 + +`dws chat +messages-send` 的 user 文件能力不能外推给 Bot/Webhook; +机器人富媒体边界读取 [chat-bot.md](chat-bot.md),不得静默改成当前用户身份。 + +## 位置与联系人名片 + +位置消息必须确认纬度、经度、地址名称和地图缩略图 mediaId: + +```bash +dws chat message send --group --msg-type location \ + --latitude <纬度> --longitude <经度> --location-name <地址名称> \ + --map-thumbnail-url "@mediaId" +``` + +联系人名片的 `--contact-id` 必须是联系人 `openDingTalkId`,不能把 userId 直接代入: + +```bash +dws chat message send --group \ + --msg-type profile --contact-id +``` + +用户要求真实发送结果时,保留发送返回的 `openTaskId`,再执行: + +```bash +dws chat message query-send-status --open-task-id --format json +``` + +检查真实 `sendStatus`、`openMessageId` 和 `openConversationId`。 + +原子 `message send` 只在 Shortcut 缺少真实必需字段时使用。群聊目标用 `--group`;单聊目标 +用 `--user` 或 `--open-dingtalk-id`,三者通常互斥。发送前核对接收对象、消息类型和资源来源。 + +## 资源下载 + +公开 `+messages-resource-download` 使用工作目录内安全相对路径,默认不覆盖;完整文件先写入 +临时落盘再原子发布。覆盖必须由用户显式传 `--overwrite`,读取和下载不需要 `--yes`。 + +任务要求从某条消息中定位并下载资源时,优先在限定会话、消息或时间范围的查询中加 +`--download-resources --output-dir <目录>`,并检查下载 ledger。`+messages-resource-download` +只用于已经持有完整、真实且属于当前组织/profile 的独立资源引用、无需再定位消息的场景。 +若 `fileId` 返回 `RESOURCE_NOT_FOUND`,不得把同一个 ID 改称 `mediaId` 重试,也不得原样 +重复调用;应回到消息查询并使用 `--download-resources`。 + +底层 fallback: + +```bash +dws chat message download-media --type mediaId --resource-id \ + --message-id --open-conversation-id \ + --output ./downloads/ +``` + +`resource-id`、`message-id` 和会话 ID 必须来自同一 profile 下的真实消息查询结果。 +当前没有 Range/断点续传;失败时保留 ledger 或错误,显式重试整个文件,不拼接残片。 + +## 完成与错误 + +- 查询并下载时同时检查消息完整性和每项下载 ledger;单项失败不抹掉已取得消息。 +- 文件/音视频发送失败先确认工作目录内相对路径可读,不恢复独立上传再提取 mediaId 的旧默认链路。 +- 位置参数不完整时先向用户确认,不猜经纬度或缩略图。 +- 名片发送失败时确认 `--contact-id` 是 openDingTalkId。 +- 下载目标存在时默认停止;只有用户明确允许覆盖时才传 `--overwrite`。 diff --git a/skills/multi/dingtalk-chat/references/chat/message-query.md b/skills/multi/dingtalk-chat/references/chat/message-query.md new file mode 100644 index 00000000..cc35ab04 --- /dev/null +++ b/skills/multi/dingtalk-chat/references/chat/message-query.md @@ -0,0 +1,132 @@ +# message-query:消息读取、搜索与查询 + +> 返回入口:[DingTalk Chat Skill](../../SKILL.md) + +用于浏览或导出指定会话、按条件搜索消息、按消息 ID 读取详情、查看 @我、话题回复、 +Favorite、Pin 和 reaction。只读任务优先使用 Shortcut;只有 Shortcut 未发布所需底层字段、 +原始响应或手工 continuation 时才读取精确原子 leaf Schema。 + +## 入口选择 + +| 用户终点 | 唯一推荐入口 | +|---|---| +| 浏览或导出一个指定群聊/单聊 | `dws chat +chat-messages` | +| 发送者、关键词、@对象或消息类型是主要条件 | `dws chat +search-msg` | +| 已知消息 IDs 读取详情 | `dws chat +messages-mget` | +| 查看 @我的消息 | `dws chat +at-me` | +| 查看 Favorite | `dws chat +flag-list` | +| 已知话题主消息或 thread/topic ID 读取回复 | `dws chat +thread-replies` | + +`+chat-messages` 是指定会话的粗粒度读取;`+search-msg` 是目标条件明确的单/跨会话检索。 +不要先读完整会话再补跑搜索,也不要把群名或姓名直接填入只接受稳定 ID 的参数。 + +## 指定会话读取 + +群聊 `--group` 可传群名或 `openConversationId`;也可用 `--chat-query` 显式解析群名、 +用 `--conversation-id` 显式传稳定 ID。单聊使用 `--user` 或 `--open-dingtalk-id`。 + +```bash +dws chat +chat-messages --group <群名或openConversationId> --format json +dws chat +chat-messages --group --page-all --page-limit 50 --format json +``` + +可附带非必填的 `--sender-query <姓名>`:未传时返回全部消息;未解析出稳定 ID 时保留 +全部消息并记录失败;唯一解析出 userId/openDingTalkId 后,按消息 `senderId` 筛选同一次 +读取结果,覆盖最终 `messages/count` 并返回 `resolvedFilters`。不得用展示名字符串比较否定 +已经成功解析的稳定发送者身份。 + +```bash +dws chat +chat-messages --group "项目群" --sender-query "测试用户甲" --page-all --format json +``` + +时间范围使用公开可选的 `--start`、`--end`、`--order asc|desc`,兼容别名为 +`--start-time/--end-time/--sort`。范围为 `[start,end)`;仅开始时间表示到本次执行当前时间; +仅结束时间只支持 `desc`,`asc` 必须提供开始时间。旧 `--time/--direction` 只用于兼容的 +单边界模式,不能与范围模式混用。 + +```bash +dws chat +chat-messages --group \ + --start "2026-08-01T00:00:00+08:00" --end "2026-08-02T00:00:00+08:00" \ + --order asc --page-all --format json +``` + +完整读取后只需消息字段可判断的子集时,在同一次调用中使用全局 `--jq`,保留根信封并 +同步改写 `messages/count`;不得丢失 `complete`、`hasMore`、`failures` 等 ledger。 +发送者姓名仍使用 `--sender-query` 解析稳定身份,不用 `--jq` 比较展示名。 + +```bash +dws chat +chat-messages --group "项目群" --page-all --format json \ + --jq '. as $root | [.messages[] | select((.reactions // []) | length > 0)] as $matched | $root | .messages = $matched | .count = ($matched | length)' +``` + +要求导出时用 `--output <工作目录内相对.json>` 原子写入;需要资源时在读取命令上加 +`--download-resources`,不要让 Agent 先输出全量 JSON 再手工遍历资源引用。 + +## 多维度搜索 + +- 关键词使用公开 `--query`。 +- 已知稳定会话 ID 使用 `--group` / `--groups`;稳定发送者 ID 使用 `--senders`。 +- 只有群名时使用 `--chat-query`,由 CLI 唯一解析会话。 +- 只有发送者姓名时使用 `--sender-query`,由 CLI 唯一解析人员。 +- 不传会话过滤时搜索全部会话;默认时间范围为最近 7 天。 +- `--page-all` 只翻完当前时间范围内的游标页;精确范围使用成对的 `--start/--end`。 +- `--order` 只稳定排列已经取得的结果;未全量或 `complete=false` 时不得称为完整范围全局排序。 + +```bash +dws chat +search-msg --chat-query "项目群" --sender-query "测试用户甲" --page-all --format json +dws chat +search-msg --chat-query "项目群" --query "发布计划" --page-all --format json +dws chat +search-msg --sender-query "测试用户甲" --page-all --format json +``` + +需要 Shortcut 未发布的原始过滤字段或响应时,才评估 `message search-advanced`。它支持 +发送者、@对象、多个会话、消息类型、会话类型、机器人消息和时间范围,但不是默认入口。 +至少提供一种真实过滤条件,完整遍历只有 `--page-all` 会触发。 + +## 其他查询 + +### 已知消息、@我与话题回复 + +- `+messages-mget --msg-ids `:最多 50 条;结果可直接用于回复、转发、撤回和资源下载。 +- `+at-me [--group <群名或ID>] --page-all`:群内或跨全部会话查看 @我的消息。 +- `+thread-replies --message-id `:自动只读解析 conversation/thread。 +- `+thread-replies --group --thread-id `:显式稳定上下文。 + +话题回复默认 `desc`;`asc` 必须与 `--page-all` 一起使用。自动续页使用下层毫秒级 +`nextCursor`,不得使用只有秒精度的展示时间手工拼 continuation。检查 `complete`、 +`hasMore`、`stopReason` 和 `failures`。 + +### Favorite、Pin 与 reaction 查询 + +| 任务 | 入口 | +|---|---| +| Favorite 列表 | `+flag-list`;要求全部时加 `--page-all`,页大小 1–30 | +| 消息 Pin 列表 | `message list-pin-msg --open-conversation-id ` | +| 批量 reaction/文字回应 | `message list-emotion-replies --msg-ids ` | +| 已读/未读状态 | `message read-status --group --message-id ` | + +Favorite、消息 Pin、消息 Top 和会话 Top 是不同对象。写入或取消这些状态读取 +[message-actions.md](message-actions.md),这里只负责查询。 + +## 原子 fallback + +| 原子命令 | 仅用于 | +|---|---| +| `message list` | 指定会话原始响应或显式手工 continuation | +| `message list-all` | 时间范围内全部会话的原始分页响应 | +| `message list-by-sender` | 已有稳定发送者 ID 且需要底层原始响应 | +| `message list-mentions` / `list-focused` | @我或特别关注的原始列表 | +| `message search` / `search-advanced` | Shortcut 未发布的真实过滤字段 | +| `message list-topic-replies` | 已知 conversation/thread 的原始话题回复 | +| `message list-by-ids` | 已知消息 ID 的原始详情响应 | + +Typed `chat message` 自动翻页只由 `--page-all` 触发;只传 `--page-limit`、`--max-items` +或 `--page-delay` 仍是单页。非第一页失败时保留 partial 结果、失败页和 continuation,不能 +把 partial result 表述成完整成功。 + +## 完成与错误 + +- 查询必须检查 `complete`、`hasMore`、`stopReason`、`failures` 和下载 ledger。 +- 发送者/群名零命中或多候选时停止,不选择第一项。 +- `unknown flag` 时读取精确 leaf Help,修正后最多重试一次。 +- 子消息优先使用自己的 `messageId`;只在缺会话 ID 时继承父消息的 `conversationId`。 +- 查到真实消息后需要写操作时,使用 [message-actions.md](message-actions.md) 中的稳定 ID 规则。 diff --git a/skills/multi/dingtalk-chat/references/intent-guide.md b/skills/multi/dingtalk-chat/references/intent-guide.md index c6c3feef..04146c60 100644 --- a/skills/multi/dingtalk-chat/references/intent-guide.md +++ b/skills/multi/dingtalk-chat/references/intent-guide.md @@ -22,12 +22,12 @@ | 用户终点 | 对象 | Reference | |---|---|---| -| 收藏或取消收藏 | 当前用户的 Favorite | [chat-message.md](chat/chat-message.md) | -| Pin/Unpin 一条消息 | 消息 Pin | [chat-message.md](chat/chat-message.md) | -| 置顶/取消置顶一条消息 | 消息 Top | [chat-message.md](chat/chat-message.md) | +| 收藏或取消收藏 | 当前用户的 Favorite | [message-actions.md](chat/message-actions.md) | +| Pin/Unpin 一条消息 | 消息 Pin | [message-actions.md](chat/message-actions.md) | +| 置顶/取消置顶一条消息 | 消息 Top | [message-actions.md](chat/message-actions.md) | | 置顶/取消置顶整个会话 | 会话 Top | [chat-conversation.md](chat/chat-conversation.md) | | 查看置顶会话 | 会话列表 | `+conversation-list-top` | -| 标记消息已读 | 消息读取状态 | [chat-message.md](chat/chat-message.md) | +| 标记消息已读 | 消息读取状态 | [message-actions.md](chat/message-actions.md) | | 清红点、标记会话未读 | 会话状态 | [chat-conversation.md](chat/chat-conversation.md) | Favorite、消息 Pin、消息 Top 和会话 Top 不能互换,即使用户都说“收藏/钉住/置顶”。 @@ -37,7 +37,8 @@ Favorite、消息 Pin、消息 Top 和会话 Top 不能互换,即使用户都 | 用户终点 | 选择 | |---|---| | 已有成员 IDs 创建群 | `+chat-create` | -| 加人、踢人、管理员、群公告、群设置 | [chat-group.md](chat/chat-group.md) | +| 查群、查看成员、邀请链接 | [group-discovery.md](chat/group-discovery.md) | +| 加人、踢人、管理员、群公告、群设置 | [group-admin.md](chat/group-admin.md) | | 找可用机器人并取得单聊 ID | `chat bot find`,不是只查自己创建机器人的 `bot search` | | 已知 robotCode 发送 | `+messages-send --as bot` | | 机器人入群、移除、批量群发或撤回 | [chat-bot.md](chat/chat-bot.md) | diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-batch-operations.md b/skills/multi/dingtalk-misc/references/sheet/sheet-batch-operations.md index 8d922bec..66fafc90 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-batch-operations.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-batch-operations.md @@ -83,7 +83,8 @@ Example: {"toolName":"range update","input":{"sheet-id":"Sheet1","range":"A1","values":[[{"type":"text","text":"hello"}]]}}, {"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}}, {"toolName":"update-dimension","input":{"sheet-id":"Sheet1","dimension":"ROWS","start-index":"1","length":1,"pixel-size":40}}, - {"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}} + {"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}}, + {"toolName":"set-dropdown","input":{"sheet-id":"Sheet1","range":"C2:C100","source-sheet-id":"SourceSheet","source-range":"T1:T3"}} ]' dws sheet batch-update --node --continue-on-error --operations '[...]' Flags: @@ -101,6 +102,9 @@ Notes: - operations 最多 20 条 - 当需要对多个区域执行相同清除时,优先使用 `range batch-clear`(更简洁) - `csv-put` 子操作与独立命令语义一致:CSV 字段值以 `=` 开头时按公式解析;前加单引号时写入以 `=` 开头的字面文本 + - `set-dropdown` 的 `input` 中,Inline 使用 `options`;SourceRange 使用 `source-sheet-id` + `source-range`,两种模式必须且只能选一个。顶层 `colors` / `source-colors` 会被拒绝;Inline 颜色写在 `options[].color`,SourceRange 颜色写入暂不支持 + - `set-dropdown` SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入 + - `source-range` 按 `toolName` 解释:`set-dropdown` 中是下拉候选项来源;`range fill` / `range copy-to` / `range move-to` 中是待填充、复制或移动的数据源区域 - 典型场景:先插入行列再写入数据、先清除再写入、批量合并+调整行高列宽 - `group-dimension` 在 batch 中只适合默认展开分组;需要 `--group-state fold` 时请使用独立 `dws sheet group-dimension` - `table-put` 不支持放进 batch-update;结构化 table 请用独立 `dws sheet table-put` diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md b/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md index 0b135aa4..05b5f0c0 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md @@ -7,6 +7,7 @@ 用户说"设置下拉列表/下拉选项/下拉菜单/添加下拉/配置下拉": - 设置下拉列表 → `set-dropdown` - 设置多选下拉 → `set-dropdown --multi-select` +- 引用单元格区域作为候选项 → `set-dropdown --source-sheet-id ... --source-range ...` 用户说"查看下拉列表/获取下拉配置/下拉列表有哪些选项": - 获取下拉列表配置 → `get-dropdown` @@ -29,18 +30,25 @@ Example: dws sheet set-dropdown --node --sheet-id --range "B2:B50" \ --options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \ --multi-select + + # 引用同一工作簿内另一工作表的区域作为候选项来源 + dws sheet set-dropdown --node --sheet-id --range "C2:C100" \ + --source-sheet-id --source-range "T1:T3" Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 目标单元格范围,A1 表示法,如 A2:A100 (必填) - --options string 下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]' - --multi-select 是否允许多选(默认单选) + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 目标单元格范围,A1 表示法,如 A2:A100 (必填) + --options string Inline 下拉选项 JSON 数组,与 --source-range 二选一 + --source-sheet-id string SourceRange 来源工作表 ID,与 --source-range 同时指定 + --source-range string SourceRange 来源区域,与 --options 二选一;不带工作表前缀 + --multi-select 是否允许多选(默认单选) ``` -在指定单元格范围内设置下拉列表。设置后用户可从预定义选项中选择值。 -- **用途**:为单元格配置下拉列表,支持自定义选项颜色和多选。 +在指定单元格范围内设置下拉列表。Inline 模式直接存储选项;SourceRange 模式引用同一工作簿内的来源区域,可跨工作表,并支持普通区域、整行和整列。 +- **用途**:为单元格配置静态选项或区域来源下拉,两种模式都支持多选;颜色仅 Inline 支持。 - **场景**:规范数据输入,如状态选择(完成/进行中/待处理)、优先级(高/中/低)等。 -- **注意**:选项值不能包含英文逗号;如果目标范围已存在下拉列表,会被新配置覆盖。 +- **注意**:`--options` 与 `--source-range` 必须且只能指定一个。`--source-range` 只写 `T1:T3`、`T:T`、`1:3` 这类 A1 区域,来源工作表通过 `--source-sheet-id` 单独指定;不接受工作表前缀、公式或多区域。SourceRange 颜色写入暂不支持。 +- **结构操作行为**:已验证的工作表重命名、在引用前插入行/列、删除引用前的行会自动调整引用并保持 `valid`;已验证的 `move-dimension` 场景会使其变为 `invalid`。列删除、删除整个来源区域或来源工作表等场景未覆盖,不能预设结果;结构操作后先回读 `sourceRangeStatus`,仅在 `invalid` 时重新选择来源并写入。 ### 获取下拉列表配置 ``` @@ -55,10 +63,10 @@ Flags: --range string 查询范围,A1 表示法,如 A1:A100 (必填) ``` -查询指定范围内的下拉列表配置信息,包括选项值、颜色和是否多选。 +查询指定范围内的下拉列表配置信息。 - **用途**:查看单元格已设置的下拉列表选项和配置。 - **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。 -- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `enableMultiSelect` 和 `colorValueMap`)。范围内无下拉列表时 `hasDropdown` 为 false。 +- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。 ### 删除下拉列表 ``` @@ -81,14 +89,15 @@ Flags: | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 | -| `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 | +| `set-dropdown` | `range` 实际设置范围、`enableMultiSelect` 是否多选;仅 Inline 模式返回 `optionCount` | 确认下拉列表设置成功 | +| `get-dropdown` | `hasDropdown`、`dataValidations`;按 `sourceType` 区分 Inline 与 SourceRange | 查看已有下拉配置 | | `delete-dropdown` | `range` 实际删除范围 | 确认下拉列表删除完成 | | `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id | ## 注意事项 - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) -- `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖 -- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false +- `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉 +- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持 +- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值 - `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md b/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md index 0e2ae054..f8008d0e 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md @@ -13,7 +13,7 @@ | 读取目的 | 推荐命令 | 说明 | |---------|---------|------| -| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 | +| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 30,000 单元格和 maxChars 防爆 | | 按 table/dataframe 协议读取 | `table-get` | 返回 `columns` / `data` / `dtypes` / `formats`;默认首行为表头 | | 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation | | 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) | @@ -52,7 +52,10 @@ Flags: - `csv` — CSV 文本,每逻辑行前加 `[row=N]` 前缀标注真实表格行号。行号一律从此前缀读取,禁止手算 - `colIndices` — 列字母映射数组(如 `["A","B","C"]`)。定位列字母用 `colIndices[j]`,禁止手数逗号 - `rowIndices` — 行号映射数组(如 `[1,2,3]`) -- `hasMore` — 是否因 maxChars 截断。为 true 时需要调整 `--range` 继续分页读取 +- `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完 +- `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现 +- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围 +- `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标 `csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node --sheet-id --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。 @@ -63,10 +66,15 @@ Flags: | `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 | | `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 | -**大表分批读取**:当 `hasMore=true` 或数据量很大时,按行窗口分批: -- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界 -- 分批读取:`--range "A1:J500"`、`--range "A501:J1000"` …… -- 单次建议 ≤5000 单元格 +**大表分批读取**:`csv-get` 不会自动发起后续请求。当 `hasMore=true` 时: +- 显式传了 `--range` 时,该范围是完整目标;未传时,以 `resolvedRange` 为完整目标 +- 根据 `returnedRange` 的结束行,从下一行开始显式构造新的 `--range`;例如目标是 `A1:A30001`、本次返回 `A1:A30000`,则继续读 `A30001:A30001` +- `max_cells` 不能通过增大 `--max-chars` 解决;`max_chars` 且没有 `returnedRange` 时,增大 `--max-chars` 或缩小列范围 +- 上述完成度协议覆盖未传 `--range` 和显式有限矩形范围;`A:A`、`1:1` 等非有限范围可能不返回完成度字段,建议先用 `sheet info` 的 `nonEmptyRange` 换算成有限范围再读 +- 即使未命中硬上限,单次仍建议控制在 5000 单元格以内,减少超时和 token 消耗 + +**工作簿过大失败与部分成功不同**:收到顶层 +`errorCode=forbidden.document.sizeOverLimit` 和对应 `errorMessage` 时,表示工作簿整体无法装载。应停止读取并提示创建更小副本或拆分工作簿;缩小 `--range` 不能解决这类错误。不要把它当成 `hasMore=true` 的可续读结果。 ### 以 table/dataframe 协议读取结构化数据 ``` @@ -130,6 +138,8 @@ Flags: ``` **返回字段说明**: +- `rowIndices` / `colIndices` — 本次返回的真实行号与列字母映射 +- `hasMore` / `truncationReasons` / `resolvedRange` / `returnedRange` — 语义与 `csv-get` 相同;`range read` 的 `truncationReasons` 主要为 `max_cells` - `cells` — 二维数组,第一维为行,第二维为列。每个元素为 per-cell 对象,字段如下: | 字段 | 类型 | 是否必有 | 说明 | @@ -146,10 +156,16 @@ Flags: | type | 字段 | 说明 | |------|------|------| -| `dropdown` | `options: [{value: string, color?: string}]` | 下拉选项列表 | -| `dropdown` | `enableMultiSelect: boolean` | 是否允许多选 | +| `dropdown` Inline | `sourceType: "inline"` | 静态选项模式 | +| `dropdown` Inline | `options: [{value: string, color?: string}]` | 下拉选项列表 | +| `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 | +| `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 | +| `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 | +| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` | | `checkbox` | `checked: boolean` | 当前勾选状态 | +SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。 + **hyperlink 结构**: | type | 字段 | 说明 | @@ -216,7 +232,7 @@ Flags: **公式校验建议**:写公式后不要只看写入返回结果。先用 `formula` 模式确认公式文本已落表,再运行 `formula-verify` 聚合扫描错误;关键业务数值继续用 `raw_value` 抽样对账。详见 [sheet-formula](./sheet-formula.md)。 -**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分页读取策略: +**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分批读取策略,每次都必须检查 `hasMore`: - 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界 - 按行分批读取,如 `A1:J500`、`A501:J1000`、`A1001:J1500` …… - 避免不传 `--range` 直接读取整个大工作表 @@ -232,7 +248,7 @@ dws sheet list --node --format json # 2. 查看工作表详情(行列数、最后非空位置、mergedRanges 等) dws sheet info --node --sheet-id --format json -# 3. 读取全部数据 +# 3. 读取数据(只有 hasMore=false 才代表目标范围已完整返回) dws sheet range read --node --sheet-id --format json # 4. 读取指定区域 @@ -251,6 +267,8 @@ dws sheet range read --node --sheet-id --range "A1:D10" --f - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败 - `range read` 不传 `--range` 时默认读取整个工作表的全部非空数据 - `range read` 的 `--range` 支持 `Sheet1!A1:D10` 格式直接指定工作表(此时忽略 `--sheet-id`) +- ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读 +- ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复 - ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges` - `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read` - ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-write-data.md b/skills/multi/dingtalk-misc/references/sheet/sheet-write-data.md index eaa5e695..9050fb43 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-write-data.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-write-data.md @@ -386,9 +386,14 @@ Flags: **dropdown(下拉列表)**: ```json { "type": "text", "text": "High", "dataValidation": { "type": "dropdown", "options": [{"value":"High","color":"#00ff00"},{"value":"Low","color":"#ff0000"}], "enableMultiSelect": false } } +{ "dataValidation": { "type": "dropdown", "sourceRange": {"sheetId":"SOURCE_SHEET_ID","a1Notation":"T1:T3"}, "enableMultiSelect": false } } ``` -- `options`:必填,`[{value, color?}]` 数组 +- `options` 与 `sourceRange` 必须且只能提供一个 +- Inline:`options` 为非空 `[{value, color?}]` 数组 +- SourceRange:`sourceRange` 为 `{sheetId,a1Notation}`;可引用同一工作簿内其他工作表,支持普通区域、整行和整列。`a1Notation` 不带工作表前缀,不接受公式或多区域;颜色写入暂不支持 - `enableMultiSelect`:可选,是否多选,默认 false +- SourceRange 在已验证的工作表重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读 `sourceRangeStatus`,仅 `invalid` 时重新选源写入 +- 复合 cell 写入中,如果值或样式已成功落盘但 SourceRange 校验失败,服务端可返回 `success:true` 并通过 `message` 说明下拉未创建;必须检查 `message` 并按需回读 **checkbox(复选框)**: ```json diff --git a/skills/multi/dingtalk-wiki/SKILL.md b/skills/multi/dingtalk-wiki/SKILL.md index a3dc5da7..320f4f76 100644 --- a/skills/multi/dingtalk-wiki/SKILL.md +++ b/skills/multi/dingtalk-wiki/SKILL.md @@ -24,7 +24,8 @@ metadata: | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws wiki +space-search` | read | 搜索知识库 | +| `dws wiki +resolve-space` | read | 按名称搜索知识空间并解析出唯一 spaceId(只读) | +| `dws wiki +wiki-new-doc` | write | 在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId) | ## 意图表 diff --git a/test/scripts/changed_test_packages_test.go b/test/scripts/changed_test_packages_test.go index 3f228c0a..f696e569 100644 --- a/test/scripts/changed_test_packages_test.go +++ b/test/scripts/changed_test_packages_test.go @@ -129,6 +129,245 @@ func Value() string { } } +// shardChangedPackagesShards mirrors the shard names scripts/ci/test-packages.sh +// asserts complete coverage for in its verify mode. Keeping the list here in +// full is deliberate: the union assertion below fails if a shard is added +// upstream without being selected, and an unknown-shard error fails if one is +// removed, so shard-plan drift cannot silently shrink focused-test coverage. +var shardChangedPackagesShards = []string{ + "app", + "generators", + "helpers", + "cli", + "smoke", + "remaining", + "release-scripts", +} + +func TestChangedTestPackagesShardSelectionCoversEveryImpactedPackageExactlyOnce(t *testing.T) { + repository := newShardChangedPackagesRepository(t) + baseRef := commitChangedTestPackagesFixture(t, repository, "initial shard fixture") + + writeChangedTestPackagesFixture(t, repository, "core/core.go", `package core + +func Value() string { + return "changed" +} +`) + headRef := commitChangedTestPackagesFixture(t, repository, "change core") + + impacted := runChangedTestPackages(t, repository, "list", baseRef, headRef) + if len(impacted) == 0 { + t.Fatal("shard fixture produced no impacted packages") + } + + owner := map[string]string{} + union := make([]string, 0, len(impacted)) + for _, shard := range shardChangedPackagesShards { + for _, pkg := range runChangedTestPackagesShard(t, repository, shard, baseRef, headRef) { + if previous, ok := owner[pkg]; ok { + t.Fatalf("package %q selected by both shard %q and %q", pkg, previous, shard) + } + owner[pkg] = shard + union = append(union, pkg) + } + } + + slices.Sort(union) + if !slices.Equal(union, impacted) { + t.Fatalf("shard selection union = %q, want every impacted package %q", union, impacted) + } +} + +func TestChangedTestPackagesShardSelectionFailsClosedOnUnknownShard(t *testing.T) { + repository := newShardChangedPackagesRepository(t) + baseRef := commitChangedTestPackagesFixture(t, repository, "initial shard fixture") + + writeChangedTestPackagesFixture(t, repository, "core/core.go", `package core + +func Value() string { + return "changed" +} +`) + headRef := commitChangedTestPackagesFixture(t, repository, "change core") + + script := filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh") + command := exec.Command(script, "list-shard", "nonexistent", baseRef, headRef) + command.Dir = repository + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("unknown shard unexpectedly succeeded:\n%s", output) + } + // An unknown shard must abort rather than report an empty selection: a + // silently empty shard would let a mistyped CI shard name skip every test + // while still reporting success. + if !strings.Contains(string(output), "unknown test package shard") { + t.Fatalf("unknown shard failure = %q, want fail-closed diagnostic", output) + } + if strings.TrimSpace(string(output)) == "" { + t.Fatal("unknown shard produced empty output instead of a diagnostic") + } +} + +func TestChangedTestPackagesShardSelectionIsEmptyForUnaffectedShard(t *testing.T) { + repository := newShardChangedPackagesRepository(t) + baseRef := commitChangedTestPackagesFixture(t, repository, "initial shard fixture") + + writeChangedTestPackagesFixture(t, repository, "internal/helpers/helpers.go", `package helpers + +import "example.com/shardfixture/core" + +func Value() string { + return core.Value() + " helpers changed" +} +`) + headRef := commitChangedTestPackagesFixture(t, repository, "change helpers only") + + if selected := runChangedTestPackagesShard(t, repository, "helpers", baseRef, headRef); len(selected) == 0 { + t.Fatal("helpers shard selected nothing for a helpers-only change") + } + // An unaffected shard is a normal outcome and must exit zero with no + // output so the CI step can no-op instead of failing the job. + if selected := runChangedTestPackagesShard(t, repository, "app", baseRef, headRef); len(selected) != 0 { + t.Fatalf("app shard = %q, want empty for a helpers-only change", selected) + } +} + +func TestChangedTestPackagesShardSelectionRejectsMissingShardArgument(t *testing.T) { + repository := newShardChangedPackagesRepository(t) + baseRef := commitChangedTestPackagesFixture(t, repository, "initial shard fixture") + + script := filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh") + command := exec.Command(script, "list-shard", baseRef, baseRef) + command.Dir = repository + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("list-shard without a shard argument unexpectedly succeeded:\n%s", output) + } + if !strings.Contains(string(output), "usage:") { + t.Fatalf("missing shard argument failure = %q, want usage diagnostic", output) + } +} + +func runChangedTestPackagesShard(t *testing.T, repository, shard, baseRef, headRef string) []string { + t.Helper() + + output := runChangedTestPackagesCommand( + t, + repository, + filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh"), + "list-shard", + shard, + baseRef, + headRef, + ) + return strings.Fields(output) +} + +// newShardChangedPackagesRepository builds a fixture whose directory layout +// matches every shard scripts/ci/test-packages.sh knows about, so shard +// selection can be exercised without depending on the real repository's working +// tree state. Every package imports core, letting a single core edit mark the +// whole module impacted. +func newShardChangedPackagesRepository(t *testing.T) string { + t.Helper() + + repository := t.TempDir() + for path, contents := range map[string]string{ + "go.mod": `module example.com/shardfixture + +go 1.22 +`, + "core/core.go": `package core + +func Value() string { + return "core" +} +`, + "internal/app/app.go": `package app + +import "example.com/shardfixture/core" + +func Value() string { + return core.Value() + " app" +} +`, + "internal/generator/gen/gen.go": `package gen + +import "example.com/shardfixture/core" + +func Value() string { + return core.Value() + " gen" +} +`, + "internal/helpers/helpers.go": `package helpers + +import "example.com/shardfixture/core" + +func Value() string { + return core.Value() + " helpers" +} +`, + "internal/cli/cli.go": `package cli + +import "example.com/shardfixture/core" + +func Value() string { + return core.Value() + " cli" +} +`, + "test/smoke/smoke.go": `package smoke + +import "example.com/shardfixture/core" + +func Value() string { + return core.Value() + " smoke" +} +`, + "test/scripts/scripts.go": `package scripts + +import "example.com/shardfixture/core" + +func Value() string { + return core.Value() + " scripts" +} +`, + "pkg/extra/extra.go": `package extra + +import "example.com/shardfixture/core" + +func Value() string { + return core.Value() + " extra" +} +`, + } { + writeChangedTestPackagesFixture(t, repository, path, contents) + } + + projectRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve project root: %v", err) + } + for _, name := range []string{"changed-test-packages.sh", "test-packages.sh"} { + script, err := os.ReadFile(filepath.Join(projectRoot, "scripts", "ci", name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + scriptPath := filepath.Join(repository, "scripts", "ci", name) + if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil { + t.Fatalf("create script directory: %v", err) + } + if err := os.WriteFile(scriptPath, script, 0o755); err != nil { + t.Fatalf("copy %s: %v", name, err) + } + } + + runChangedTestPackagesCommand(t, repository, "git", "init", "-q", "-b", "main") + runChangedTestPackagesCommand(t, repository, "git", "config", "user.name", "DWS CI") + runChangedTestPackagesCommand(t, repository, "git", "config", "user.email", "dws-ci@example.invalid") + return repository +} + func newChangedTestPackagesRepository(t *testing.T) string { t.Helper() diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index b94b25c3..5d8ef72b 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -1013,6 +1013,12 @@ if (!isHighRisk("internal/helpers/minutes.go")) { if (isHighRisk("internal/helpersx/minutes.go")) { throw new Error("helper high-risk classification must respect the path boundary"); } +if (!isHighRisk("internal/shortcut/wiki/wiki.go")) { + throw new Error("shortcut changes must use the sharded full suite"); +} +if (isHighRisk("internal/shortcuts/wiki/wiki.go")) { + throw new Error("shortcut high-risk classification must respect the path boundary"); +} for (const filename of [ "internal/cli/param_concepts.json", "internal/cli/param_concepts.schema.json", @@ -1137,6 +1143,7 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { "filename.startsWith('scripts/')", "filename.startsWith('verify/')", "filename.startsWith('internal/helpers/')", + "filename.startsWith('internal/shortcut/')", "filename === 'test/fixtures/cli-interface-baseline.txt'", "filename.startsWith('internal/interfacesnapshot/')", "filename.startsWith('internal/cobracmd/')", @@ -1144,13 +1151,13 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { "filename.startsWith('pkg/cmdutil/')", "filename === 'skills_embed.go'", "filename.startsWith('test/mock_mcp/')", - "name: Test (changed packages)", + `name: "Test (focused: ${{ matrix.shard }})"`, "changed-test-packages.sh", "Verify authoritative synthetic merge", `test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA"`, `test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA"`, `echo "TEST_HEAD_REF=$(git rev-parse HEAD)"`, - "list \"$TEST_BASE_REF\" \"$TEST_HEAD_REF\"", + `list-shard "$package_shard" "$TEST_BASE_REF" "$TEST_HEAD_REF"`, "needs.lint.outputs.full_suite != 'true'", `name: "Test (race: ${{ matrix.shard }})"`, "name: Test (workflow and release contracts)", @@ -1210,8 +1217,34 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { if !strings.Contains(focusedJob, "timeout-minutes: 20") { t.Error("focused test job must allow the scoped race suite up to 20 minutes") } - if !strings.Contains(focusedJob, `go test -v -race -count=1 -timeout=15m "${packages[@]}"`) { - t.Error("focused race tests must retain enough package-level time for internal/app") + // The focused path fans the impacted set across the same shards as test-race + // and runs each shard the way test-race runs it, so no single job carries + // internal/app together with its reverse dependencies. internal/app is split + // further into one shard per bounded partition, which keeps its package-level + // headroom through the process-isolating helper instead of one long -timeout + // and is strictly stronger than a single app job: every partition process + // releases the framework registries it populated, and the partitions run + // concurrently rather than end to end. Each partition shard still selects the + // same single internal/app package, so the impacted-package query maps the + // shard name back to app. release-scripts is asserted because its dedicated + // job only runs at full-suite or release-sensitive scope, so losing it here + // would silently stop testing test/scripts changes. + for _, want := range []string{ + `app-*) package_shard=app ;;`, + `test "${#packages[@]}" -eq 1`, + `./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"`, + `if [ "$TEST_SHARD" = "release-scripts" ]; then`, + `go test -v -count=1 -timeout=10m "${packages[@]}"`, + "timeout_budget=12m", + `if [ "$TEST_SHARD" = "cli" ] ||`, + `[ "$TEST_SHARD" = "smoke" ]; then`, + "timeout_budget=15m", + `go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"`, + "- release-scripts", + } { + if !strings.Contains(focusedJob, want) { + t.Errorf("focused test job missing shard contract %q", want) + } } raceStart := focusedEnd @@ -1220,14 +1253,16 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { t.Fatal("Code Admission workflow missing race test job boundaries") } raceJob := admission[raceStart:raceEnd] - // The app shard uses independently bounded test processes so process-global - // command registries are released before the Schema assembly peak. Other full - // race shards retain the dynamic package timeout: default/floor 12m, with - // cli/smoke raised to 15m on slower hosted runners. + // internal/app is carried by one shard per bounded partition, so process-global + // command registries are released with each partition process and the Schema + // assembly peak no longer sits in front of the other partitions. Each + // partition shard resolves back to the same single internal/app package. + // Other full race shards retain the dynamic package timeout: default/floor + // 12m, with cli/smoke raised to 15m on slower hosted runners. for _, want := range []string{ - `if [ "$TEST_SHARD" = "app" ]; then`, + `app-*) package_shard=app ;;`, `test "${#packages[@]}" -eq 1`, - `./scripts/ci/run-app-race-tests.sh run "${packages[0]}"`, + `./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"`, "timeout_budget=12m", `if [ "$TEST_SHARD" = "cli" ] ||`, `[ "$TEST_SHARD" = "smoke" ]; then`, diff --git a/test/scripts/ci_test_package_plan_test.go b/test/scripts/ci_test_package_plan_test.go index 136ce8ec..b8250c19 100644 --- a/test/scripts/ci_test_package_plan_test.go +++ b/test/scripts/ci_test_package_plan_test.go @@ -14,6 +14,39 @@ func TestCITestPackagePlanCoversDefaultPackagesExactlyOnce(t *testing.T) { if !strings.Contains(output, "default packages exactly once") { t.Fatalf("verify output = %q, want coverage summary", output) } + if !strings.Contains(output, "full-suite packages exactly once") { + t.Fatalf("verify output = %q, want coverage shard plan summary", output) + } +} + +func TestCICoveragePackagePlanRoutesFullSuiteScope(t *testing.T) { + root := testPackagePlanRoot(t) + remaining := strings.Fields(runTestPackagePlan(t, root, "list-coverage", "remaining")) + + for _, suffix := range []string{"/cmd", "/internal/output", "/skills"} { + if !containsPackageSuffix(remaining, suffix) { + t.Errorf("coverage remaining shard does not contain package ending in %q", suffix) + } + } + for _, suffix := range []string{ + "/internal/app", + "/internal/cli", + "/internal/generator", + "/internal/helpers", + "/test/smoke", + "/test/scripts", + "/pkg/cmdutil", + "/scripts/policy/coverage-gate", + } { + if containsPackageSuffix(remaining, suffix) { + t.Errorf("coverage remaining shard unexpectedly contains package ending in %q", suffix) + } + } + + app := strings.Fields(runTestPackagePlan(t, root, "list-coverage", "app")) + if !containsPackageSuffix(app, "/internal/app") { + t.Error("coverage app shard does not contain /internal/app") + } } func TestCITestPackagePlanRoutesPublicTestSuites(t *testing.T) { @@ -66,6 +99,74 @@ func TestCIAppRacePartitionsCoverTopLevelTestsExactlyOnce(t *testing.T) { } } +// TestCIAppRacePartitionMatrixMatchesHelper pins the workflow's app partition +// shards to the partition set the helper actually runs. The partitions are +// separate CI jobs now, so the helper's own "covered exactly once" check can no +// longer prove the whole package ran: a partition the helper knows about but no +// matrix shard dispatches would silently stop running while every job stays +// green. Both directions are asserted so a stale matrix shard fails too. +func TestCIAppRacePartitionMatrixMatchesHelper(t *testing.T) { + root := testPackagePlanRoot(t) + script := filepath.Join(root, "scripts", "ci", "run-app-race-tests.sh") + cmd := exec.Command("sh", script, "list-partitions") + cmd.Dir = root + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s list-partitions failed: %v\n%s", script, err, output) + } + partitions := strings.Fields(string(output)) + if len(partitions) == 0 { + t.Fatalf("list-partitions returned no partitions: %q", output) + } + + workflow, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml")) + if err != nil { + t.Fatalf("read ci.yml: %v", err) + } + admission := string(workflow) + + for _, job := range []struct { + name string + startMark string + endMark string + }{ + {"test-focused", "\n test-focused:\n", "\n test-race:\n"}, + {"test-race", "\n test-race:\n", "\n test-release-scripts:\n"}, + } { + start := strings.Index(admission, job.startMark) + end := strings.Index(admission, job.endMark) + if start < 0 || end <= start { + t.Fatalf("ci.yml is missing %s job boundaries", job.name) + } + body := admission[start:end] + + for _, partition := range partitions { + want := "- app-" + partition + if !strings.Contains(body, want) { + t.Errorf("%s matrix is missing shard %q for a partition the helper runs", job.name, want) + } + } + + for _, line := range strings.Split(body, "\n") { + shard := strings.TrimSpace(line) + if !strings.HasPrefix(shard, "- app-") { + continue + } + name := strings.TrimPrefix(shard, "- app-") + matched := false + for _, partition := range partitions { + if partition == name { + matched = true + break + } + } + if !matched { + t.Errorf("%s matrix shard %q has no matching helper partition", job.name, shard) + } + } + } +} + func TestCITestPackagePlanFailsClosedWhenGoListFails(t *testing.T) { root := testPackagePlanRoot(t) fakeBin := t.TempDir() diff --git a/test/scripts/coverage_workflow_contract_test.go b/test/scripts/coverage_workflow_contract_test.go index a591b93c..5ffb1298 100644 --- a/test/scripts/coverage_workflow_contract_test.go +++ b/test/scripts/coverage_workflow_contract_test.go @@ -118,3 +118,108 @@ esac assertDiffProfiles(t, runGate(t, &empty), "coverage.txt") }) } + +// TestCoverageWorkflowShardsAndBaselineCache pins the full-suite coverage +// architecture: the candidate profile is produced by disjoint per-shard +// helper jobs and reassembled before enforcement, and the merge-base profile +// is reused only through an exact-key cache written by a green main push of +// that same commit. Near-miss reuse (restore-keys) would compare the +// candidate against the wrong commit and must never appear. +func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml")) + if err != nil { + t.Fatalf("ReadFile(ci.yml) error = %v", err) + } + admission := string(data) + + currentStart := strings.Index(admission, "\n coverage-current:\n") + fullStart := strings.Index(admission, "\n coverage-current-full:\n") + supportingStart := strings.Index(admission, "\n coverage-supporting:\n") + baselineStart := strings.Index(admission, "\n coverage-baseline:\n") + gateStart := strings.Index(admission, "\n coverage:\n") + policyStart := strings.Index(admission, "\n policy:\n") + if currentStart < 0 || fullStart <= currentStart || supportingStart <= fullStart || + baselineStart <= supportingStart || gateStart <= baselineStart || policyStart <= gateStart { + t.Fatal("CI workflow missing ordered coverage job boundaries") + } + + currentJob := admission[currentStart:fullStart] + if !strings.Contains(currentJob, "needs.lint.outputs.full_suite != 'true'") { + t.Error("coverage-current must be scoped-tier only; the full suite belongs to the shard matrix") + } + if strings.Contains(currentJob, "./ ./cmd/... ./internal/... ./skills/...") { + t.Error("coverage-current must not retain the retired single serial full-suite run") + } + + fullJob := admission[fullStart:supportingStart] + for _, want := range []string{ + "needs.lint.outputs.full_suite == 'true'", + "fail-fast: false", + " - app", + " - cli", + " - generators", + " - helpers", + " - remaining", + `./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD"`, + "go test -count=1 -p 1", + `-coverprofile="coverage-shard-$COVERAGE_SHARD.txt"`, + "-covermode=atomic", + "name: coverage-current-shard-${{ matrix.shard }}", + } { + if !strings.Contains(fullJob, want) { + t.Errorf("coverage-current-full missing shard contract %q", want) + } + } + + baselineJob := admission[baselineStart:gateStart] + cachePath := "coverage-cache.txt" + baselineKey := "dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}" + for _, want := range []string{ + "uses: actions/cache/restore@v4", + "uses: actions/cache/save@v4", + "path: " + cachePath, + "key: " + baselineKey, + "if: steps.baseline-cache.outputs.cache-hit != 'true'", + "cp coverage-cache.txt coverage-base.txt", + "cp coverage-base.txt coverage-cache.txt", + } { + if !strings.Contains(baselineJob, want) { + t.Errorf("coverage-baseline missing cache contract %q", want) + } + } + if strings.Count(baselineJob, "key: "+baselineKey) != 2 { + t.Error("coverage-baseline restore and save must use the identical exact cache key") + } + if strings.Count(baselineJob, "path: "+cachePath) != 2 { + t.Error("coverage-baseline restore and save must use the identical cache path/version") + } + if strings.Contains(baselineJob, "restore-keys") { + t.Error("coverage baseline cache must stay exact-key; prefix restore-keys can resurrect a wrong-commit baseline") + } + + gateJob := admission[gateStart:policyStart] + for _, want := range []string{ + "pattern: coverage-current-*", + "merge-multiple: true", + "for shard in app cli generators helpers remaining; do", + "test ! -f coverage.txt", + `test "$(head -n 1 "$profile")" = "mode: atomic"`, + "github.event_name == 'push'", + "cp coverage.txt coverage-cache.txt", + "path: " + cachePath, + "key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}", + `"current shards:$CURRENT_FULL_RESULT:$current_full_expected"`, + } { + if !strings.Contains(gateJob, want) { + t.Errorf("coverage gate missing shard assembly contract %q", want) + } + } + + if strings.Count(gateJob, "path: "+cachePath) != 1 { + t.Error("green main push must save the candidate profile through the same cache path/version as baseline restore") + } +} diff --git a/test/scripts/wiki_shortcut_e2e_test.go b/test/scripts/wiki_shortcut_e2e_test.go new file mode 100644 index 00000000..f60aacb9 --- /dev/null +++ b/test/scripts/wiki_shortcut_e2e_test.go @@ -0,0 +1,43 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package scripts_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestWikiShortcutE2ERequiresInteractiveConfirmation(t *testing.T) { + script := filepath.Join("..", "..", "scripts", "dev", "wiki-shortcut-e2e.py") + source, err := os.ReadFile(script) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(source, []byte("--yes")) { + t.Fatal("real-data Wiki E2E script must not embed confirmation bypass flags") + } + + python, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 is unavailable") + } + cmd := exec.Command(python, script) + cmd.Stdin = strings.NewReader("") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err == nil { + t.Fatal("non-interactive real-data E2E unexpectedly succeeded") + } + if !strings.Contains(stderr.String(), "run in an interactive terminal") { + t.Fatalf("non-interactive failure = %q, want explicit terminal requirement", stderr.String()) + } + if strings.Contains(stdout.String(), "PASS ") { + t.Fatalf("non-interactive E2E performed work before refusing: %q", stdout.String()) + } +} diff --git a/third_party/aem-go-sdk/README.md b/third_party/aem-go-sdk/README.md new file mode 100644 index 00000000..226cb666 --- /dev/null +++ b/third_party/aem-go-sdk/README.md @@ -0,0 +1,20 @@ +# AEM Go SDK offline snapshot + +This directory contains the source packages required by the DWS CLI from the +private AEM Go SDK. The root module uses a local `replace` directive so builds +do not need access to `gitlab.alibaba-inc.com`. + +- Upstream module: `gitlab.alibaba-inc.com/aes/aem-go-sdk` +- Upstream version: `v0.3.0` +- Upstream commit: `2b5103b2f8899fa6e96611389c655e189a107f3b` +- Snapshot packages: `aem`, `clitrack`, `internal/encoder`, `internal/sender` + +DWS carries one reviewed privacy extension on top of the upstream snapshot: +`clitrack.Config.NoAutomaticDimensions` disables the SDK's device, operating +system, locale, session, and other automatic dimensions. The official DWS +entrypoint enables this mode and tests the final encoded payload as an exact +field whitelist. + +The upstream `v0.3.0` source tree did not contain a `LICENSE`, `NOTICE`, or +`COPYING` file. No replacement license text has been invented in this snapshot. +Redistribution authorization is managed by the repository owners. diff --git a/third_party/aem-go-sdk/aem/config.go b/third_party/aem-go-sdk/aem/config.go new file mode 100644 index 00000000..e40fd316 --- /dev/null +++ b/third_party/aem-go-sdk/aem/config.go @@ -0,0 +1,264 @@ +package aem + +import ( + "crypto/md5" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "os/exec" + "runtime" + "strconv" + "strings" + "time" + + "gitlab.alibaba-inc.com/aes/aem-go-sdk/internal/encoder" +) + +// AES 协议固定常量(不可修改,与 JS @ali/aes-tracker v3.3.18 对齐)。 +const ( + sdkVersion = "3.3.18" + platformGo = "go" + uuidCharset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz" // 60 字符(缺 Y 和 j) + uuidLength = 20 +) + +const ( + configPID = "pid" + configAppName = "app_name" + configEnv = "env" + configEndpoint = "endpoint" + configVersion = "version" + configAsync = "async" + configQueueSize = "queue_size" + // configDisableAutoDimensions is a local privacy control. It is never + // serialized; callers that enable it receive only explicitly configured + // dimensions plus app_version derived from version. + configDisableAutoDimensions = "disable_auto_dimensions" +) + +// Config 是 SDK 接入参数,key 与 AEM 协议字段保持一致。 +// +// 仅 pid 必填,其余字段为空时由 applyDefaults 填充默认值。Go SDK 会上报 +// 服务端适用的公共维度,例如 app_name、env、version、uid、username、 +// user_type、dim1~dim10、sid、bucket_id、ext。async、queue_size、endpoint +// 是 SDK 本地控制项,不会作为公共维度上报。 +type Config map[string]interface{} + +var supportedSendConfigKeys = map[string]struct{}{ + configPID: {}, + configAppName: {}, + configEnv: {}, + configVersion: {}, + "user_type": {}, + "uid": {}, + "username": {}, + "sid": {}, + "bucket_id": {}, + "ext": {}, + "platform": {}, +} + +func init() { + for i := 1; i <= 10; i++ { + supportedSendConfigKeys[fmt.Sprintf("dim%d", i)] = struct{}{} + } +} + +func cloneConfig(c Config) Config { + cloned := make(Config, len(c)) + for k, v := range c { + cloned[k] = v + } + return cloned +} + +// applyDefaults 为可选字段填充默认值,并返回新的配置副本。 +func applyDefaults(c Config) Config { + cloned := cloneConfig(c) + if stringValue(cloned, configAppName) == "" { + cloned[configAppName] = "unknown" + } + if stringValue(cloned, configEnv) == "" { + cloned[configEnv] = "prod" + } + if stringValue(cloned, configEndpoint) == "" { + cloned[configEndpoint] = "gm.mmstat.com" + } + if stringValue(cloned, configVersion) == "" { + cloned[configVersion] = "unknown" + } + if _, ok := cloned[configAsync]; !ok { + cloned[configAsync] = true + } + if intValue(cloned, configQueueSize) <= 0 { + cloned[configQueueSize] = 1000 + } + return cloned +} + +// buildSendConfig 构建 gokey 编码所需的全局维度 map。 +// +// 默认包含 AES 协议的 sdk_version/platform/device_id/os/os_version/app_name/ +// app_version/pv_id/timezone_offset 自动采集字段,加上用户配置的 pid/env/uid/ +// username/version/user_type/dim1~dim10/sid/bucket_id/ext 等公共维度。启用 +// disable_auto_dimensions 时仅保留显式配置字段和由 version 派生的 app_version。 +// 空值字段不会被写入;page_id、utm_* 等页面/浏览器字段不会在 Go SDK 中上报。 +func buildSendConfig(c Config) map[string]string { + m := make(map[string]string, len(c)+8) + for k, v := range c { + if _, ok := supportedSendConfigKeys[k]; !ok { + continue + } + if s, ok := encoder.ItemToString(v); ok { + m[k] = s + } + } + if boolValue(c, configDisableAutoDimensions) { + if v, ok := m["version"]; ok && v != "" { + m["app_version"] = v + } + return m + } + + m["sdk_version"] = sdkVersion + if _, ok := m["platform"]; !ok { + m["platform"] = platformGo + } + m["device_id"] = getDeviceID() + m["os"] = capitalizeOS() + m["os_version"] = getOSVersion() + if v, ok := m["version"]; ok && v != "" { + m["app_version"] = v + } else { + m["app_version"] = runtime.Version() + } + m["pv_id"] = generateUUID() + m["timezone_offset"] = getTimezoneOffset() + + return m +} + +func stringValue(c Config, key string) string { + if c == nil { + return "" + } + v, ok := c[key] + if !ok { + return "" + } + s, ok := encoder.ItemToString(v) + if !ok { + return "" + } + return s +} + +func boolValue(c Config, key string) bool { + if c == nil { + return false + } + v, ok := c[key] + if !ok { + return false + } + switch val := v.(type) { + case bool: + return val + case string: + b, err := strconv.ParseBool(val) + return err == nil && b + default: + return false + } +} + +func intValue(c Config, key string) int { + s := stringValue(c, key) + if s == "" { + return 0 + } + n, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return n +} + +// getMAC 返回第一个非零的网卡 MAC 地址;获取失败时返回 "00:00:00:00:00:00"。 +func getMAC() string { + ifaces, err := net.Interfaces() + if err != nil { + return "00:00:00:00:00:00" + } + for _, iface := range ifaces { + mac := iface.HardwareAddr + if len(mac) == 0 { + continue + } + macStr := mac.String() + if macStr != "" && macStr != "00:00:00:00:00:00" { + return macStr + } + } + return "00:00:00:00:00:00" +} + +// getDeviceID 用 MAC 地址的 MD5 hex 作为设备指纹。 +func getDeviceID() string { + hash := md5.Sum([]byte(getMAC())) + return hex.EncodeToString(hash[:]) +} + +// getOSVersion 调用系统命令获取内核版本字符串。 +func getOSVersion() string { + switch runtime.GOOS { + case "windows": + out, err := exec.Command("cmd", "/c", "ver").Output() + if err != nil { + return "Windows" + } + return strings.TrimSpace(string(out)) + default: + out, err := exec.Command("uname", "-r").Output() + if err != nil { + return "unknown" + } + return strings.TrimSpace(string(out)) + } +} + +// getTimezoneOffset 对齐 JS Date.getTimezoneOffset()。东八区返回 "-480"。 +func getTimezoneOffset() string { + _, offset := time.Now().Zone() + return fmt.Sprintf("%d", -(offset / 60)) +} + +// capitalizeOS 把 runtime.GOOS 首字母大写("darwin" → "Darwin")。 +func capitalizeOS() string { + osName := runtime.GOOS + if len(osName) == 0 { + return osName + } + return strings.ToUpper(osName[:1]) + osName[1:] +} + +// generateUUID 生成 20 字符的随机 ID,字符集与 JS 版严格一致。 +// +// 使用 rejection sampling 保证字符分布均匀(拒绝阈值 240 = 60*4)。 +func generateUUID() string { + charsetLen := len(uuidCharset) // 60 + maxByte := byte(256 - 256%charsetLen) // 240 + result := make([]byte, uuidLength) + buf := make([]byte, 1) + for i := 0; i < uuidLength; i++ { + for { + _, _ = rand.Read(buf) + if buf[0] < maxByte { + result[i] = uuidCharset[buf[0]%byte(charsetLen)] + break + } + } + } + return string(result) +} diff --git a/third_party/aem-go-sdk/aem/config_test.go b/third_party/aem-go-sdk/aem/config_test.go new file mode 100644 index 00000000..7efa54eb --- /dev/null +++ b/third_party/aem-go-sdk/aem/config_test.go @@ -0,0 +1,54 @@ +package aem + +import ( + "slices" + "sort" + "testing" +) + +func TestBuildSendConfigPublishesCLIIdentityAndVersion(t *testing.T) { + got := buildSendConfig(Config{ + "pid": "pid-1", + "app_name": "dws", + "version": "v1.2.3", + "uid": "user-1", + }) + + for key, want := range map[string]string{ + "version": "v1.2.3", + "app_version": "v1.2.3", + "uid": "user-1", + } { + if got[key] != want { + t.Fatalf("send config %s = %q, want %q", key, got[key], want) + } + } +} + +func TestBuildSendConfigCanDisableAutomaticDimensions(t *testing.T) { + got := buildSendConfig(Config{ + "pid": "pid-1", + "app_name": "dws", + "env": "prod", + "version": "v1.2.3", + "platform": "cli", + "uid": "user-1", + "username": "Alice", + configDisableAutoDimensions: true, + }) + + gotKeys := make([]string, 0, len(got)) + for key := range got { + gotKeys = append(gotKeys, key) + } + sort.Strings(gotKeys) + wantKeys := []string{"app_name", "app_version", "env", "pid", "platform", "uid", "username", "version"} + if !slices.Equal(gotKeys, wantKeys) { + t.Fatalf("privacy send config keys = %v, want %v", gotKeys, wantKeys) + } + for _, key := range []string{"device_id", "ext", "os", "os_version", "pv_id", "sdk_version", "sid", "timezone_offset"} { + if _, ok := got[key]; ok { + t.Fatalf("privacy send config contains automatic dimension %q: %#v", key, got) + } + } +} diff --git a/third_party/aem-go-sdk/aem/event.go b/third_party/aem-go-sdk/aem/event.go new file mode 100644 index 00000000..d920bc0a --- /dev/null +++ b/third_party/aem-go-sdk/aem/event.go @@ -0,0 +1,36 @@ +package aem + +// Event 是一次上报的载荷。Type 决定字段语义(api/event 等), +// Fields 携带 p1~p20、c1~c10、ext 以及 url、status 等业务维度。 +// +// 字段命名约定: +// - 通用字段:type、ts(毫秒) +// - 自定义事件:type=event,p1 为事件 ID,p4 为事件类型(EXP/CLK/SLD/INPUT/SYS/OTHER) +// - 平台保留:p1~p20(不同 type 含义不同,详见 AES 协议文档) +// - 用户自定义:c1~c10 +// - 扩展 JSON:ext +// +// Fields 中值为空字符串的键会在序列化时被自动丢弃。 +type Event struct { + // Type 事件类型,如 "api"、"event"。必填。 + Type string + + // Fields 业务字段。Track 时会补全 ts;其余键的语义由调用方决定。 + Fields map[string]string +} + +// toMap 把 Event 序列化为 SDK 内部使用的 map 形式,过滤空值。 +// +// 注意:返回的 map 是新分配的,调用方修改不会影响原 Event。 +func (e Event) toMap() map[string]string { + m := make(map[string]string, len(e.Fields)+1) + if e.Type != "" { + m["type"] = e.Type + } + for k, v := range e.Fields { + if v != "" { + m[k] = v + } + } + return m +} diff --git a/third_party/aem-go-sdk/aem/tracker.go b/third_party/aem-go-sdk/aem/tracker.go new file mode 100644 index 00000000..efbe8d49 --- /dev/null +++ b/third_party/aem-go-sdk/aem/tracker.go @@ -0,0 +1,173 @@ +// Package aem 提供阿里巴巴 AEM (Application Experience Management) 平台的 Go 上报 SDK。 +// +// 它适用于任何 Go 程序(web server、cron job、library 等),通过 Tracker.Track +// 将埋点事件加入后台队列并异步上报到 AES 后端。 +// +// 简单用法: +// +// tracker := aem.NewTracker(aem.Config{ +// "pid": "your_project_id", +// "app_name": "my-service", +// "env": "prod", +// "version": "1.0.0", +// }) +// defer tracker.Close() +// +// tracker.Track(aem.Event{ +// Type: "api", +// Fields: map[string]string{ +// "url": "/api/user", "status": "200", "duration": "120", +// }, +// }) +package aem + +import ( + "errors" + "fmt" + "sync" + "time" + + "gitlab.alibaba-inc.com/aes/aem-go-sdk/internal/encoder" + "gitlab.alibaba-inc.com/aes/aem-go-sdk/internal/sender" +) + +// ErrQueueFull 表示异步上报队列已满,本次事件没有入队。 +var ErrQueueFull = errors.New("aem: async queue is full") + +// ErrTrackerClosed 表示 Tracker 已关闭,不能再接收新的事件。 +var ErrTrackerClosed = errors.New("aem: tracker is closed") + +// Tracker 是 SDK 的核心入口,封装了配置、全局维度和上报通道。 +// +// 一个进程通常只创建一个 Tracker,并发调用 Track 是安全的。生命周期内 +// sendCfg 只构建一次以降低开销。 +type Tracker struct { + config Config + sendCfg map[string]string + + async bool + queue chan string + workerDone chan struct{} + + mu sync.RWMutex + closed bool + asyncErr error +} + +// NewTracker 创建并初始化一个 Tracker。默认会立刻填充并采集设备维度 +// (MAC、OS、PVID 等);启用 disable_auto_dimensions 时跳过这些采集。 +func NewTracker(c Config) *Tracker { + cfg := applyDefaults(c) + t := &Tracker{ + config: cfg, + sendCfg: buildSendConfig(cfg), + async: boolValue(cfg, configAsync), + } + if t.async { + t.queue = make(chan string, intValue(cfg, configQueueSize)) + t.workerDone = make(chan struct{}) + go t.runWorker() + } + return t +} + +// Track 上报一个事件,失败时返回 error。 +// +// 如果 Event 没有自带 ts 字段,Track 会自动补 time.Now().UnixMilli()。 +// 默认 async=true 时,Track 只负责把事件写入内存队列,不等待远端 HTTP 请求完成; +// 返回 error 仅表示参数校验、队列已满或 Tracker 已关闭。async=false 时,Track +// 会在当前 goroutine 内同步发送并返回远端发送结果。 +func (t *Tracker) Track(event Event) error { + if stringValue(t.config, configPID) == "" { + return errors.New(`aem: config field "pid" is required`) + } + if event.Type == "" { + return errors.New("aem: Event.Type is required") + } + + fields := event.toMap() + if _, ok := fields["ts"]; !ok { + fields["ts"] = fmt.Sprintf("%d", time.Now().UnixMilli()) + } + + gokey := encoder.ProcessData([]map[string]string{fields}, t.sendCfg) + + if !t.async { + t.mu.RLock() + closed := t.closed + t.mu.RUnlock() + if closed { + return ErrTrackerClosed + } + return t.send(gokey) + } + + t.mu.RLock() + defer t.mu.RUnlock() + if t.closed { + return ErrTrackerClosed + } + select { + case t.queue <- gokey: + return nil + default: + return ErrQueueFull + } +} + +func (t *Tracker) runWorker() { + defer close(t.workerDone) + for gokey := range t.queue { + if err := t.send(gokey); err != nil { + t.asyncErr = err + } + } +} + +// userAgent builds the User-Agent header from config: "app_name/version". +func (t *Tracker) userAgent() string { + name := stringValue(t.config, configAppName) + ver := stringValue(t.config, configVersion) + if name == "" || name == "unknown" { + return "" + } + if ver != "" && ver != "unknown" { + return name + "/" + ver + } + return name +} + +func (t *Tracker) send(gokey string) error { + return sender.Send(stringValue(t.config, configEndpoint), gokey, t.userAgent()) +} + +// Config 返回 Tracker 当前使用的配置(副本)。 +// +// 返回值是副本,修改它不会影响 Tracker 内部配置。 +func (t *Tracker) Config() Config { + return cloneConfig(t.config) +} + +// Close 释放 Tracker 持有的资源。 +// +// async=true 时,Close 会停止接收新事件,并等待队列中已入队的事件发送完成。 +// 如果后台发送发生错误,Close 返回最后一次发送错误。 +func (t *Tracker) Close() error { + if !t.async { + t.mu.Lock() + t.closed = true + t.mu.Unlock() + return nil + } + + t.mu.Lock() + if !t.closed { + t.closed = true + close(t.queue) + } + workerDone := t.workerDone + t.mu.Unlock() + + <-workerDone + return t.asyncErr +} diff --git a/third_party/aem-go-sdk/clitrack/capture.go b/third_party/aem-go-sdk/clitrack/capture.go new file mode 100644 index 00000000..e88ccbef --- /dev/null +++ b/third_party/aem-go-sdk/clitrack/capture.go @@ -0,0 +1,37 @@ +package clitrack + +import ( + "bytes" + "io" + "os" +) + +// captureStdout 拦截 stdout,将 fn 的输出同时写到原 stdout 和 buffer。 +// 返回捕获到的输出内容和 fn 的 error。Pipe 创建失败时降级为不捕获, +// 保证 fn 一定被执行。 +// +// 注意:这会临时替换 os.Stdout,对依赖 TTY 检测、进度条、ANSI 颜色的 CLI +// 可能有副作用——这正是 CaptureOutput 默认关闭的原因。 +func captureStdout(fn func() error) (string, error) { + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + return "", fn() + } + os.Stdout = w + + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = io.Copy(io.MultiWriter(old, &buf), r) + close(done) + }() + + execErr := fn() + + w.Close() + <-done + os.Stdout = old + + return buf.String(), execErr +} diff --git a/third_party/aem-go-sdk/clitrack/clitrack.go b/third_party/aem-go-sdk/clitrack/clitrack.go new file mode 100644 index 00000000..70df9882 --- /dev/null +++ b/third_party/aem-go-sdk/clitrack/clitrack.go @@ -0,0 +1,275 @@ +package clitrack + +import ( + "fmt" + "os" + "strconv" + "time" + + "gitlab.alibaba-inc.com/aes/aem-go-sdk/aem" +) + +// 默认值。 +const ( + defaultEventID = "cli.exec" // p1:AEM 自定义事件 ID + eventTypeSys = "SYS" // p4:AEM 事件类型,系统事件 + defaultOutputLen = 500 // c8 截断长度 + maxErrorLen = 200 // c5 截断长度 +) + +// defaultFlushTimeout 是 CLI 退出前等待异步队列 flush 的最长时间。 +// 到点未发完就放弃上报直接退出——宁可丢这条埋点,也不让用户等。 +const defaultFlushTimeout = 300 * time.Millisecond + +// Config 是 clitrack 接入配置。只有 PID 必填,其余都有合理默认值。 +type Config struct { + // —— 必填 —— + PID string // AEM 项目 ID + + // —— 应用维度 —— + App string // CLI 名称,默认 "unknown" + Env string // 环境:prod/pre/daily,默认 prod + Version string // CLI 版本号(建议用 ldflags 注入) + + // —— 用户维度(可选,接入方自己填,本包不读取任何凭据文件)—— + UID string // 用户 ID,如工号 + Username string // 用户名 + UserType string // 账号类型,如 "14" + + // —— 行为 —— + EventID string // p1 事件 ID,默认 "cli.exec" + Endpoint string // 上报域名,海外站点填 sg.mmstat.com;默认走 SDK 默认 + + // CaptureOutput 控制是否捕获 stdout 到 c8。默认 false。 + // 捕获会用 os.Pipe 劫持 os.Stdout,可能干扰进度条/TTY 检测/颜色输出, + // 仅在确认 CLI 输出适合采集时开启。 + CaptureOutput bool + OutputMaxLen int // c8 截断长度,默认 500;仅在 CaptureOutput 时生效 + + // FlushTimeout 是退出前等待上报完成的最长时间,默认 300ms。 + FlushTimeout time.Duration + + // —— 字段级隐私开关(给接入开发者的编译期选项,默认采集)—— + NoCommandLine bool // 不采 c2 完整命令行(命令行常带敏感参数时设 true) + NoCwd bool // 不采 c7 工作目录 + // NoAutomaticDimensions 只保留接入方显式配置的公共维度,并关闭 + // device_id、os、os_version、timezone_offset、pv_id、sdk_version、sid、 + // ext.language 与 c6 Shell 自动采集。 + NoAutomaticDimensions bool + + // —— 扩展钩子 —— + // ExtraFields 返回的字段会合并进事件,用于补充 c9/c10/ext 等自定义维度。 + // 不要覆盖 c1~c8 的约定语义,否则破坏跨 CLI 聚合。空值字段会被忽略。 + ExtraFields func() map[string]string +} + +// Tracker 是埋点实例,通过 New 创建,通过 Run 执行 CLI 并自动上报。 +type Tracker struct { + inner *aem.Tracker + + eventID string + captureOutput bool + outputMaxLen int + flushTimeout time.Duration + noCommandLine bool + noCwd bool + noAutomaticDimensions bool + extraFields func() map[string]string +} + +// New 根据配置创建 Tracker。 +// +// 如果 PID 为空,返回空实例:Run 仍可正常执行 CLI,只是不上报。这样接入方 +// 在缺少 PID(如本地开发)时无需加任何判断,埋点自动降级为 no-op。 +func New(cfg Config) *Tracker { + if cfg.PID == "" { + return &Tracker{} + } + + env := cfg.Env + if env == "" { + env = "prod" + } + app := cfg.App + if app == "" { + app = "unknown" + } + eventID := cfg.EventID + if eventID == "" { + eventID = defaultEventID + } + outputMaxLen := cfg.OutputMaxLen + if outputMaxLen <= 0 { + outputMaxLen = defaultOutputLen + } + flushTimeout := cfg.FlushTimeout + if flushTimeout <= 0 { + flushTimeout = defaultFlushTimeout + } + + aemCfg := aem.Config{ + "pid": cfg.PID, + "app_name": app, + "env": env, + "version": cfg.Version, + "platform": "cli", + } + if cfg.NoAutomaticDimensions { + aemCfg["disable_auto_dimensions"] = true + } + if cfg.Endpoint != "" { + aemCfg["endpoint"] = cfg.Endpoint + } + if cfg.UID != "" { + aemCfg["uid"] = cfg.UID + } + if cfg.Username != "" { + aemCfg["username"] = cfg.Username + } + if cfg.UserType != "" { + aemCfg["user_type"] = cfg.UserType + } + + if !cfg.NoAutomaticDimensions { + // sid:终端会话 ID,自动从环境变量采集(数据维度,非配置开关)。 + sid := os.Getenv("TERM_SESSION_ID") + if sid == "" { + sid = os.Getenv("TMUX_PANE") + } + if sid != "" { + aemCfg["sid"] = sid + } + + // ext.language:终端 locale,自动采集。 + lang := os.Getenv("LANG") + if lang == "" { + lang = os.Getenv("LC_ALL") + } + if lang != "" { + aemCfg["ext"] = fmt.Sprintf(`{"language":%q}`, lang) + } + } + + return &Tracker{ + inner: aem.NewTracker(aemCfg), + eventID: eventID, + captureOutput: cfg.CaptureOutput, + outputMaxLen: outputMaxLen, + flushTimeout: flushTimeout, + noCommandLine: cfg.NoCommandLine, + noCwd: cfg.NoCwd, + noAutomaticDimensions: cfg.NoAutomaticDimensions, + extraFields: cfg.ExtraFields, + } +} + +// Run 执行 CLI 主函数并自动上报埋点。 +// +// 自动完成:计时、从 os.Args 采集入参、推导退出码、(可选)捕获 stdout、 +// 错误输出到 stderr、best-effort flush(带超时,不阻塞退出)。 +// +// execute CLI 入口,返回 error;cobra 直接传 rootCmd.Execute。 +// exitCode 把 error 映射为退出码;传 nil 用默认映射(nil→0,其余→1)。 +// +// 与现状一致:退出码非 0 时调用 os.Exit;为 0 时正常 return,不调 os.Exit。 +func (t *Tracker) Run(execute func() error, exitCode func(error) int) { + if exitCode == nil { + exitCode = defaultExitCode + } + + start := time.Now() + + var output string + var err error + if t.captureOutput { + output, err = captureStdout(execute) + } else { + err = execute() + } + + code := 0 + var errStr string + if err != nil { + code = exitCode(err) + errStr = err.Error() + if errStr != "" { + fmt.Fprintln(os.Stderr, errStr) + } + } + + t.trackExec(code, time.Since(start), errStr, output) + t.close() + + if code != 0 { + os.Exit(code) + } +} + +// trackExec 上报一次命令执行事件。 +func (t *Tracker) trackExec(exitCode int, duration time.Duration, errMsg, output string) { + if t.inner == nil { + return + } + _ = t.inner.Track(aem.Event{ + Type: "event", + Fields: t.buildFields(exitCode, duration, errMsg, output), + }) +} + +// buildFields 按字段约定组装一次命令执行的事件字段(纯函数,便于测试)。 +func (t *Tracker) buildFields(exitCode int, duration time.Duration, errMsg, output string) map[string]string { + fields := map[string]string{ + "p1": t.eventID, + "p4": eventTypeSys, + "c1": command(), + "c3": strconv.Itoa(exitCode), + "c4": strconv.FormatInt(duration.Milliseconds(), 10), + } + if !t.noAutomaticDimensions { + fields["c6"] = shellType() + } + if !t.noCommandLine { + fields["c2"] = commandLine() + } + if !t.noCwd { + fields["c7"] = cwd() + } + if errMsg != "" { + fields["c5"] = truncate(errMsg, maxErrorLen) + } + if output != "" { + fields["c8"] = truncate(output, t.outputMaxLen) + } + if t.extraFields != nil { + for k, v := range t.extraFields() { + if v != "" { + fields[k] = v + } + } + } + return fields +} + +// close 关闭内部 Tracker,best-effort flush:最多等 flushTimeout,超时即放弃。 +func (t *Tracker) close() { + if t.inner == nil { + return + } + done := make(chan struct{}) + go func() { + _ = t.inner.Close() + close(done) + }() + select { + case <-done: + case <-time.After(t.flushTimeout): + } +} + +// defaultExitCode 是 exitCode 参数为 nil 时的默认映射。 +func defaultExitCode(err error) int { + if err == nil { + return 0 + } + return 1 +} diff --git a/third_party/aem-go-sdk/clitrack/clitrack_test.go b/third_party/aem-go-sdk/clitrack/clitrack_test.go new file mode 100644 index 00000000..92aee86c --- /dev/null +++ b/third_party/aem-go-sdk/clitrack/clitrack_test.go @@ -0,0 +1,21 @@ +package clitrack + +import ( + "testing" + "time" +) + +func TestBuildFieldsKeepsOrganizationDimension(t *testing.T) { + tracker := &Tracker{ + noCommandLine: true, + noCwd: true, + extraFields: func() map[string]string { + return map[string]string{"c9": "version", "c10": "corp-1"} + }, + } + + fields := tracker.buildFields(0, time.Millisecond, "", "") + if fields["c9"] != "version" || fields["c10"] != "corp-1" { + t.Fatalf("custom telemetry fields = %#v", fields) + } +} diff --git a/third_party/aem-go-sdk/clitrack/doc.go b/third_party/aem-go-sdk/clitrack/doc.go new file mode 100644 index 00000000..4c724540 --- /dev/null +++ b/third_party/aem-go-sdk/clitrack/doc.go @@ -0,0 +1,51 @@ +// Package clitrack 在 aem 核心 SDK 之上,为任意 Go CLI 提供零侵入的使用埋点。 +// +// 设计理念:CLI 入口只需一行 clitrack.New(cfg).Run(...),剩下全部自动完成—— +// 从 os.Args 采集原始入参、自动计时、自动推导命令路径和退出码、异步上报。 +// +// 埋点尽力而为,数据可丢,但绝不阻塞 CLI 退出(见 FlushTimeout)。接入方负责 +// 向最终用户披露采集范围并提供符合其产品要求的退出机制。 +// +// 最小接入示例(以 cobra CLI 为例): +// +// func Execute() { +// clitrack.New(clitrack.Config{ +// PID: "your-pid", App: "my-cli", Version: version, +// }).Run(rootCmd.Execute, nil) +// } +// +// 框架无关:Run 只要求一个 func() error 入口,cobra / urfave-cli / 标准库 flag +// 都能套。exitCode 传 nil 时用默认映射(nil→0,其余→1)。 +// +// AEM 字段映射约定(所有接入的 CLI 统一遵守,这是跨 CLI 聚合分析的基础): +// +// Config 维度(初始化时设一次): +// pid → Config.PID AEM 项目 ID(必填) +// app_name → Config.App CLI 名称 +// env → Config.Env 环境 (prod/pre/daily),默认 prod +// version → Config.Version CLI 版本号 +// uid → Config.UID 用户 ID(可选,接入方自己填) +// username → Config.Username 用户名(可选) +// user_type → Config.UserType 账号类型(可选) +// endpoint → Config.Endpoint 上报域名(可选,海外站点填 sg.mmstat.com) +// sid → $TERM_SESSION_ID 终端会话 ID(自动采集,NoAutomaticDimensions 可关) +// ext.language → $LANG 终端 locale(自动采集,NoAutomaticDimensions 可关) +// +// Event 维度(每次命令执行打一条,type = "event"): +// p1 → "cli.exec" AEM 自定义事件 ID(默认值,可用 Config.EventID 覆盖) +// p4 → "SYS" AEM 事件类型:系统事件(固定值) +// c1 → command filepath.Base(os.Args[0])(CLI 二进制名,如 "aem") +// c2 → command_line os.Args[1:] 拼接(完整参数);Config.NoCommandLine 可关 +// c3 → exit_code 退出码 +// c4 → duration_ms 执行耗时(毫秒) +// c5 → error_message 错误摘要,截断 200 字符 +// c6 → shell_type Shell 类型(zsh/bash 等);NoAutomaticDimensions 可关 +// c7 → cwd 当前工作目录;Config.NoCwd 可关 +// c8 → output CLI stdout 输出摘要;默认不采,Config.CaptureOutput 显式开启 +// c9/c10/ext → 自定义 由 Config.ExtraFields 钩子返回 +// +// Config.NoAutomaticDimensions 会关闭设备、操作系统、时区、随机会话、终端会话、 +// locale 和 Shell 等自动维度,只保留接入方显式配置的公共维度和事件字段。本包不读取 +// 产品级退出环境变量;接入应用应在创建 Tracker 前执行自己的退出策略。PID 为空时 +// Tracker 自动降级为 no-op,命令仍正常执行。 +package clitrack diff --git a/third_party/aem-go-sdk/clitrack/fields.go b/third_party/aem-go-sdk/clitrack/fields.go new file mode 100644 index 00000000..55c1c53c --- /dev/null +++ b/third_party/aem-go-sdk/clitrack/fields.go @@ -0,0 +1,44 @@ +package clitrack + +import ( + "os" + "path/filepath" + "strings" +) + +// command 返回 CLI 二进制名(c1),如 "aem"。 +func command() string { + return filepath.Base(os.Args[0]) +} + +// commandLine 返回完整命令行参数(c2),如 "login --env prod"。 +func commandLine() string { + return strings.Join(os.Args[1:], " ") +} + +// shellType 返回当前 Shell 类型(c6),取 $SHELL 的 basename(如 "zsh"、"bash")。 +func shellType() string { + shell := os.Getenv("SHELL") + if shell == "" { + return "" + } + return filepath.Base(shell) +} + +// cwd 返回当前工作目录(c7),失败返回空字符串。 +func cwd() string { + dir, _ := os.Getwd() + return dir +} + +// truncate 按 rune 截断字符串到 maxLen,超出部分用 "..." 替换。 +func truncate(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + if maxLen <= 3 { + return string(runes[:maxLen]) + } + return string(runes[:maxLen-3]) + "..." +} diff --git a/third_party/aem-go-sdk/go.mod b/third_party/aem-go-sdk/go.mod new file mode 100644 index 00000000..6fea714c --- /dev/null +++ b/third_party/aem-go-sdk/go.mod @@ -0,0 +1,3 @@ +module gitlab.alibaba-inc.com/aes/aem-go-sdk + +go 1.25.7 diff --git a/third_party/aem-go-sdk/internal/encoder/gokey.go b/third_party/aem-go-sdk/internal/encoder/gokey.go new file mode 100644 index 00000000..fb7e3173 --- /dev/null +++ b/third_party/aem-go-sdk/internal/encoder/gokey.go @@ -0,0 +1,97 @@ +package encoder + +import ( + "encoding/json" + "fmt" + "net/url" + "sort" + "strings" +) + +func EncodeURIComponent(s string) string { + result := url.QueryEscape(s) + result = strings.ReplaceAll(result, "+", "%20") + result = strings.ReplaceAll(result, "%21", "!") + result = strings.ReplaceAll(result, "%27", "'") + result = strings.ReplaceAll(result, "%28", "(") + result = strings.ReplaceAll(result, "%29", ")") + result = strings.ReplaceAll(result, "%2A", "*") + return result +} + +func ItemToString(v interface{}) (string, bool) { + if v == nil { + return "", false + } + switch val := v.(type) { + case string: + if val == "" { + return "", false + } + return val, true + case float64: + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)), true + } + return fmt.Sprintf("%g", val), true + case int: + return fmt.Sprintf("%d", val), true + case int64: + return fmt.Sprintf("%d", val), true + case bool: + if val { + return "true", true + } + return "false", true + case json.Number: + return val.String(), true + case map[string]interface{}, []interface{}: + b, err := json.Marshal(val) + if err != nil { + return "", false + } + return string(b), true + default: + return "", false + } +} + +func ObjToQS(m map[string]string) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + parts := make([]string, 0, len(m)) + for _, k := range keys { + v := m[k] + if v == "" { + continue + } + parts = append(parts, k+"="+EncodeURIComponent(v)) + } + return strings.Join(parts, "&") +} + +func ToStringMap(data map[string]interface{}) map[string]string { + result := make(map[string]string, len(data)) + for k, v := range data { + if s, ok := ItemToString(v); ok { + result[k] = s + } + } + return result +} + +func ProcessData(logs []map[string]string, config map[string]string) string { + configQS := ObjToQS(config) + + logParts := make([]string, 0, len(logs)) + for _, log := range logs { + logParts = append(logParts, ObjToQS(log)) + } + logsJoined := strings.Join(logParts, "|") + + return configQS + "&msg=" + EncodeURIComponent(logsJoined) +} diff --git a/third_party/aem-go-sdk/internal/sender/sender.go b/third_party/aem-go-sdk/internal/sender/sender.go new file mode 100644 index 00000000..09eb4bad --- /dev/null +++ b/third_party/aem-go-sdk/internal/sender/sender.go @@ -0,0 +1,68 @@ +package sender + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "gitlab.alibaba-inc.com/aes/aem-go-sdk/internal/encoder" +) + +const httpTimeout = 5 * time.Second + +var httpClient = &http.Client{Timeout: httpTimeout} + +// Send 单次 HTTP POST 上报 gokey 到 AES 后端,不重试。 +// +// endpoint 不带 scheme 时默认 https://;以 http:// 或 https:// 开头时按原样使用。 +// 路径固定为 /aes.1.1,请求体为 {"gokey": encodeURIComponent(gokey), "gmkey": "EXP"}。 +func Send(endpoint string, gokey string, userAgent string) error { + u := buildURL(endpoint) + + body, err := json.Marshal(map[string]string{ + "gokey": encoder.EncodeURIComponent(gokey), + "gmkey": "EXP", + }) + if err != nil { + return fmt.Errorf("marshal body: %w", err) + } + + req, err := http.NewRequest("POST", u, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if userAgent != "" { + req.Header.Set("User-Agent", userAgent) + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + return nil +} + +func buildURL(endpoint string) string { + endpoint = strings.TrimRight(endpoint, "/") + var u string + if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { + u = endpoint + } else { + u = fmt.Sprintf("https://%s", endpoint) + } + if strings.HasSuffix(u, "/aes.1.1") { + return u + } + return u + "/aes.1.1" +}