Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecbd2e3009 | ||
|
|
33df6ee794 | ||
|
|
18e1c7870e | ||
|
|
1ff4941082 | ||
|
|
f3231ed2a8 | ||
|
|
7762abc1ae | ||
|
|
8d1b76caa7 | ||
|
|
9d0995a61d | ||
|
|
571eb20457 | ||
|
|
7937d09eed | ||
|
|
bc39d24559 | ||
|
|
3717053d24 | ||
|
|
2a3df50d0f | ||
|
|
03b3cf68e4 | ||
|
|
bbdf843ef3 | ||
|
|
eb2658ca68 | ||
|
|
a5ac09218b | ||
|
|
d41214988a | ||
|
|
bfb812bfa0 | ||
|
|
4b0f71eedc | ||
|
|
5c30d01522 | ||
|
|
c94190f90e | ||
|
|
6763ddd154 | ||
|
|
235cad4cc7 | ||
|
|
96d0d430e6 | ||
|
|
5783c4e82a | ||
|
|
baafd6fe7d | ||
|
|
23c3b74979 | ||
|
|
258e7ed872 | ||
|
|
69d813afef | ||
|
|
00305d941d | ||
|
|
ec7fdb0f0d | ||
|
|
a8e83e5e7e | ||
|
|
488d90b73c | ||
|
|
2c10be2a1a | ||
|
|
3985c4c98f | ||
|
|
196bf929c1 | ||
|
|
2dfc39f0d3 | ||
|
|
97a16c43b4 | ||
|
|
afe7d860ff | ||
|
|
63b3e72fad | ||
|
|
984529b4cb | ||
|
|
298ea5b341 | ||
|
|
3e4886fc71 | ||
|
|
72cb8f188e | ||
|
|
2f8614aa1f | ||
|
|
0e60d09980 | ||
|
|
4d182dea45 | ||
|
|
f56d3263e8 | ||
|
|
22c94900d7 | ||
|
|
0871c5d88c | ||
|
|
15a15a1c12 | ||
|
|
5c01ae845f | ||
|
|
36b58d08b0 | ||
|
|
0cc049eaa1 | ||
|
|
dd2d91ae7e | ||
|
|
98309fa239 | ||
|
|
b4e92f4e65 | ||
|
|
b34bbc9aa0 | ||
|
|
3749c6b793 | ||
|
|
40444a6f78 | ||
|
|
97248bf7c2 | ||
|
|
fd6b3be046 | ||
|
|
e2390c3385 | ||
|
|
835c9229fd | ||
|
|
ea7d8cc666 | ||
|
|
125bc88d52 | ||
|
|
d2e36a76e2 | ||
|
|
52cf261000 | ||
|
|
6b9fcf9289 | ||
|
|
8dac7d5fa5 | ||
|
|
4543e9935c | ||
|
|
e79c3ea5b9 | ||
|
|
ad2ba15a45 | ||
|
|
74350b3c7b | ||
|
|
02f66df599 | ||
|
|
883f082425 | ||
|
|
b1e7b43f85 | ||
|
|
571a096004 | ||
|
|
0d3fef0037 | ||
|
|
72934ddc39 | ||
|
|
5dcf95ce07 | ||
|
|
e70b21ae8a | ||
|
|
ad0582ea48 | ||
|
|
5a3617c21d | ||
|
|
0d866911e0 | ||
|
|
883f397554 | ||
|
|
2bf5401f55 | ||
|
|
c76c30a0b7 | ||
|
|
276d5bcd73 | ||
|
|
8321f1ffea | ||
|
|
5bdaad092a | ||
|
|
55cf6cfb71 | ||
|
|
58b4d6f9f4 | ||
|
|
a3478ad587 | ||
|
|
5d1092da73 | ||
|
|
46fe02f72c | ||
|
|
2dba64b880 | ||
|
|
1c9b09b23f | ||
|
|
e564c8d923 | ||
|
|
4cded1ef6c | ||
|
|
0182060757 | ||
|
|
c9cf1cc9c1 | ||
|
|
2e7e6a1010 | ||
|
|
f88bc32259 | ||
|
|
f3cce5f49b | ||
|
|
2e389fe27d | ||
|
|
b234015e7f | ||
|
|
dd112a845e | ||
|
|
ca6e520610 | ||
|
|
b731050dad | ||
|
|
bb2dd2ba76 | ||
|
|
c0cb81c12b | ||
|
|
51dc237d8a | ||
|
|
049af9fc30 | ||
|
|
d19a15a6ed | ||
|
|
2b76047164 | ||
|
|
241444e992 | ||
|
|
309f833f15 | ||
|
|
115cce7308 | ||
|
|
e85d9bc314 |
@@ -1,4 +0,0 @@
|
||||
# Default code owners for all files
|
||||
# These users will be automatically requested for review on PRs.
|
||||
|
||||
* @DingTalk-Real-AI/cli-maintainers
|
||||
@@ -3,22 +3,41 @@
|
||||
- What changed?
|
||||
- Why is this change needed?
|
||||
|
||||
## Risk tier
|
||||
|
||||
- [ ] Documentation-only: prose/assets only; no executable, generated, workflow,
|
||||
packaging, or interface behavior changed
|
||||
- [ ] Standard: ordinary implementation change with a stable package graph
|
||||
- [ ] High-risk: workflow/policy, package graph, generated Schema/registry,
|
||||
platform, auth/keychain, installer, packaging, release, transport, recovery,
|
||||
or another fail-closed infrastructure change
|
||||
|
||||
## Verification
|
||||
|
||||
For an exact in-place `CHANGELOG.md`-only pull request, the full-suite checks
|
||||
may be marked `N/A`, but the targeted CHANGELOG check is required. For every
|
||||
other pull request, mark the targeted check `N/A` and complete the applicable
|
||||
full-suite checks.
|
||||
Record the smallest targeted evidence that proves the changed behavior. Do not
|
||||
repeat the entire CI suite locally only to fill this checklist: CI expands the
|
||||
selected tier from documentation checks, through affected-package tests, to
|
||||
the complete high-risk suite.
|
||||
|
||||
- [ ] Exact `CHANGELOG.md`-only check (otherwise `N/A`):
|
||||
- [ ] Exact in-place `CHANGELOG.md`-only check (otherwise `N/A`):
|
||||
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
|
||||
- [ ] `make build`
|
||||
- [ ] `make lint`
|
||||
- [ ] `make test`
|
||||
- [ ] `make policy`
|
||||
- [ ] Targeted test/check commands and results:
|
||||
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
|
||||
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
|
||||
`N/A`)
|
||||
- [ ] Full local suite run because the change is high-risk (optional for other
|
||||
tiers; record command/result or `N/A`)
|
||||
- [ ] `./scripts/policy/check-generated-drift.sh`
|
||||
(when generator inputs or generated artifacts may change)
|
||||
- [ ] `./scripts/policy/check-command-surface.sh --strict` (if command surface changed)
|
||||
- [ ] `./scripts/release/verify-package-managers.sh`
|
||||
(after `make package`, if packaging or installer surfaces changed)
|
||||
|
||||
## Notes
|
||||
|
||||
- Any risks, follow-up work, or intentional scope cuts
|
||||
|
||||
The repository automatically requests one eligible peer reviewer, including
|
||||
after a new head push when another review is needed. Once the latest push has
|
||||
peer approval and all nine required checks are current and green, auto-merge
|
||||
completes the PR; authors do not need to coordinate a separate routine merge.
|
||||
|
||||
+479
-108
@@ -25,6 +25,12 @@ jobs:
|
||||
outputs:
|
||||
changelog_only: ${{ steps.classify.outputs.changelog_only }}
|
||||
changelog_changed: ${{ steps.classify.outputs.changelog_changed }}
|
||||
docs_only: ${{ steps.classify.outputs.docs_only }}
|
||||
full_suite: ${{ steps.classify.outputs.full_suite }}
|
||||
release_sensitive: ${{ steps.classify.outputs.release_sensitive }}
|
||||
interface_sensitive: ${{ steps.classify.outputs.interface_sensitive }}
|
||||
mcp_sensitive: ${{ steps.classify.outputs.mcp_sensitive }}
|
||||
edition_sensitive: ${{ steps.classify.outputs.edition_sensitive }}
|
||||
platform_sensitive: ${{ steps.classify.outputs.platform_sensitive }}
|
||||
steps:
|
||||
- name: Classify revision scope
|
||||
@@ -34,10 +40,176 @@ jobs:
|
||||
script: |
|
||||
let changelogOnly = false;
|
||||
let changelogChanged = false;
|
||||
let docsOnly = false;
|
||||
let fullSuite = context.eventName === 'push';
|
||||
let releaseSensitive = context.eventName === 'push';
|
||||
let interfaceSensitive = context.eventName === 'push';
|
||||
let mcpSensitive = context.eventName === 'push';
|
||||
let editionSensitive = context.eventName === 'push';
|
||||
let platformSensitive = context.eventName === 'push';
|
||||
let files = [];
|
||||
let fastPathTrust = 'not evaluated';
|
||||
|
||||
const isDocsOnly = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename === 'CHANGELOG.md' ||
|
||||
filename === 'README.md' ||
|
||||
filename === 'README_zh.md' ||
|
||||
filename === 'CONTRIBUTING.md' ||
|
||||
filename === 'SECURITY.md' ||
|
||||
filename === 'CODE_OF_CONDUCT.md' ||
|
||||
filename === 'LICENSE' ||
|
||||
filename === 'NOTICE' ||
|
||||
filename === '.github/PULL_REQUEST_TEMPLATE.md' ||
|
||||
filename.startsWith('.github/ISSUE_TEMPLATE/') ||
|
||||
filename.startsWith('docs/')
|
||||
);
|
||||
const isReleaseSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename.startsWith('scripts/install') ||
|
||||
filename.startsWith('Formula/') ||
|
||||
filename.startsWith('build/') ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === '.github/workflows/release.yml' ||
|
||||
filename === '.github/workflows/withdraw-release.yml'
|
||||
);
|
||||
const isPlatformSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('internal/auth/') ||
|
||||
filename.startsWith('internal/keychain/') ||
|
||||
/_(darwin|windows|linux|unix)(?:_[a-z0-9]+)*\.go$/.test(filename) ||
|
||||
filename === '.github/workflows/ci.yml' ||
|
||||
filename ===
|
||||
'scripts/policy/run-platform-coverage-gate.sh'
|
||||
);
|
||||
const isNativeGoChange = ({ filename, previous_filename, patch }) => {
|
||||
const paths = [filename, previous_filename].filter(
|
||||
(value) => typeof value === 'string' && value !== ''
|
||||
);
|
||||
if (paths.some(isPlatformSensitive)) {
|
||||
return true;
|
||||
}
|
||||
if (!paths.some((value) => value.endsWith('.go'))) {
|
||||
return false;
|
||||
}
|
||||
if (typeof patch !== 'string') {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
/\/\/go:build[^\n]*(darwin|windows|linux|unix)/.test(patch) ||
|
||||
/runtime\.GO(OS|ARCH)/.test(patch)
|
||||
);
|
||||
};
|
||||
const isInterfaceSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('cmd/') ||
|
||||
filename.startsWith('internal/app/') ||
|
||||
filename.startsWith('internal/cli/') ||
|
||||
filename.startsWith('internal/cobracmd/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
filename.startsWith('internal/i18n/') ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
filename.startsWith('internal/ir/') ||
|
||||
filename.startsWith('internal/shortcut/') ||
|
||||
filename.startsWith('pkg/cmdutil/') ||
|
||||
filename === 'skills_embed.go' ||
|
||||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
|
||||
filename.startsWith('skills/')
|
||||
);
|
||||
const isMCPSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('internal/mcp') ||
|
||||
filename.startsWith('internal/transport/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
filename.startsWith('internal/app/') ||
|
||||
filename.startsWith('test/mock_mcp/')
|
||||
);
|
||||
const isEditionSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('pkg/edition') ||
|
||||
filename.startsWith('internal/edition') ||
|
||||
filename === 'go.mod' ||
|
||||
filename === 'go.sum'
|
||||
);
|
||||
const isHighRisk = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
isPlatformSensitive(filename) ||
|
||||
isReleaseSensitive(filename) ||
|
||||
filename.startsWith('.github/workflows/') ||
|
||||
filename === '.github/actionlint.yaml' ||
|
||||
filename.startsWith('scripts/') ||
|
||||
filename.startsWith('verify/') ||
|
||||
filename.startsWith('internal/generator/') ||
|
||||
filename.startsWith('internal/cli/schema') ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
filename.startsWith('internal/app/upgrade') ||
|
||||
filename.startsWith('internal/transport/') ||
|
||||
filename.startsWith('internal/recovery/') ||
|
||||
filename.startsWith('internal/syncdata/') ||
|
||||
filename.includes('/testdata/') ||
|
||||
filename.startsWith('testdata/') ||
|
||||
filename.startsWith('test/fixtures/') ||
|
||||
filename === 'Makefile' ||
|
||||
filename === 'go.mod' ||
|
||||
filename === 'go.sum'
|
||||
);
|
||||
const classifyFiles = (complete) => {
|
||||
const paths = files.flatMap(({ filename, previous_filename }) =>
|
||||
[filename, previous_filename].filter(
|
||||
(value) => typeof value === 'string' && value !== ''
|
||||
)
|
||||
);
|
||||
const structuralChange = files.some(
|
||||
({ status, previous_filename }) =>
|
||||
status === 'removed' || Boolean(previous_filename)
|
||||
);
|
||||
const goPackageShapeChange = files.some(
|
||||
({ filename, previous_filename, status }) =>
|
||||
['added', 'removed'].includes(status) &&
|
||||
[filename, previous_filename].some(
|
||||
(value) =>
|
||||
typeof value === 'string' && value.endsWith('.go')
|
||||
)
|
||||
);
|
||||
docsOnly =
|
||||
complete &&
|
||||
files.length > 0 &&
|
||||
paths.length > 0 &&
|
||||
paths.every(isDocsOnly);
|
||||
releaseSensitive =
|
||||
!complete || paths.some(isReleaseSensitive);
|
||||
platformSensitive =
|
||||
!complete || files.some(isNativeGoChange);
|
||||
interfaceSensitive =
|
||||
!complete ||
|
||||
paths.some(isInterfaceSensitive) ||
|
||||
paths.some(isEditionSensitive);
|
||||
mcpSensitive =
|
||||
!complete || paths.some(isMCPSensitive);
|
||||
editionSensitive =
|
||||
!complete || paths.some(isEditionSensitive);
|
||||
fullSuite =
|
||||
!complete ||
|
||||
(
|
||||
!docsOnly &&
|
||||
(
|
||||
context.eventName === 'push' ||
|
||||
structuralChange ||
|
||||
goPackageShapeChange ||
|
||||
files.some(isNativeGoChange) ||
|
||||
paths.some(isHighRisk)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
if (context.eventName === 'pull_request') {
|
||||
const expectedHead = context.payload.pull_request.head.sha;
|
||||
const expectedBase = context.payload.pull_request.base.sha;
|
||||
@@ -85,28 +257,14 @@ jobs:
|
||||
filename === 'CHANGELOG.md' ||
|
||||
previous_filename === 'CHANGELOG.md'
|
||||
);
|
||||
|
||||
const isPlatformSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('internal/auth/') ||
|
||||
filename.startsWith('internal/keychain/') ||
|
||||
/_(darwin|windows|linux|unix)\.go$/.test(filename) ||
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename.startsWith('scripts/install') ||
|
||||
filename.startsWith('Formula/') ||
|
||||
filename.startsWith('build/npm/') ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === '.github/workflows/release.yml'
|
||||
);
|
||||
platformSensitive = files.some(
|
||||
({ filename, previous_filename }) =>
|
||||
isPlatformSensitive(filename) ||
|
||||
isPlatformSensitive(previous_filename)
|
||||
);
|
||||
classifyFiles(true);
|
||||
fastPathTrust = changelogOnly
|
||||
? 'exact pull-request revision and synthetic merge policy'
|
||||
: 'full admission required';
|
||||
: docsOnly
|
||||
? 'documentation-only focused admission'
|
||||
: fullSuite
|
||||
? 'high-risk full admission'
|
||||
: 'changed-package focused admission';
|
||||
} else if (context.eventName === 'push') {
|
||||
const expectedBefore = context.payload.before;
|
||||
const expectedAfter = context.payload.after;
|
||||
@@ -136,6 +294,7 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
files = Array.isArray(comparison.files) ? comparison.files : [];
|
||||
classifyFiles(files.length < 300);
|
||||
const linearFromValidatedTip =
|
||||
comparison.status === 'ahead' &&
|
||||
comparison.merge_base_commit?.sha === expectedBefore &&
|
||||
@@ -217,12 +376,24 @@ jobs:
|
||||
|
||||
core.setOutput('changelog_only', String(changelogOnly));
|
||||
core.setOutput('changelog_changed', String(changelogChanged));
|
||||
core.setOutput('docs_only', String(docsOnly));
|
||||
core.setOutput('full_suite', String(fullSuite));
|
||||
core.setOutput('release_sensitive', String(releaseSensitive));
|
||||
core.setOutput('interface_sensitive', String(interfaceSensitive));
|
||||
core.setOutput('mcp_sensitive', String(mcpSensitive));
|
||||
core.setOutput('edition_sensitive', String(editionSensitive));
|
||||
core.setOutput('platform_sensitive', String(platformSensitive));
|
||||
await core.summary
|
||||
.addHeading('Code Admission scope')
|
||||
.addRaw(`- Event: \`${context.eventName}\`\n`)
|
||||
.addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`)
|
||||
.addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`)
|
||||
.addRaw(`- Documentation-only: \`${docsOnly}\`\n`)
|
||||
.addRaw(`- Full suite: \`${fullSuite}\`\n`)
|
||||
.addRaw(`- Release-sensitive: \`${releaseSensitive}\`\n`)
|
||||
.addRaw(`- Interface-sensitive: \`${interfaceSensitive}\`\n`)
|
||||
.addRaw(`- MCP-sensitive: \`${mcpSensitive}\`\n`)
|
||||
.addRaw(`- Edition-sensitive: \`${editionSensitive}\`\n`)
|
||||
.addRaw(`- Native-platform risk paths touched: \`${platformSensitive}\`\n`)
|
||||
.addRaw(`- Changed files: \`${files.length}\`\n`)
|
||||
.addRaw(`- Fast-path trust: ${fastPathTrust}\n`)
|
||||
@@ -232,36 +403,92 @@ jobs:
|
||||
if: steps.classify.outputs.changelog_only == 'true'
|
||||
run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Record documentation-only fast path
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only == 'true'
|
||||
run: echo "Lint is satisfied by the non-executable documentation scope." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Check out repository
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Verify Test Package Plan
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
run: make test-plan
|
||||
|
||||
- name: Format Check
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
run: make format-check
|
||||
|
||||
- name: Go Vet
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
run: go vet ./...
|
||||
|
||||
- name: Check GitHub Actions workflows
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
||||
|
||||
test-focused:
|
||||
name: Test (changed packages)
|
||||
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: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify authoritative synthetic merge
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA" || {
|
||||
echo "focused test merge first parent does not match event base" >&2
|
||||
exit 1
|
||||
}
|
||||
test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA" || {
|
||||
echo "focused test merge second parent does not match event head" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "TEST_BASE_REF=$PR_BASE_SHA" >> "$GITHUB_ENV"
|
||||
echo "TEST_HEAD_REF=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Test changed packages and reverse dependencies
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
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"
|
||||
exit 0
|
||||
fi
|
||||
mapfile -t packages <<< "$package_output"
|
||||
go test -v -race -count=1 -timeout=8m "${packages[@]}"
|
||||
|
||||
test-race:
|
||||
name: "Test (race: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_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: 15
|
||||
strategy:
|
||||
@@ -299,9 +526,9 @@ jobs:
|
||||
go test -v -race -count=1 -timeout=10m "${packages[@]}"
|
||||
|
||||
test-release-scripts:
|
||||
name: Test (release scripts)
|
||||
name: Test (workflow and release contracts)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.release_sensitive == 'true') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -331,7 +558,7 @@ jobs:
|
||||
test-cross-platform:
|
||||
name: Test (cross-platform compile)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -363,6 +590,7 @@ jobs:
|
||||
name: Test
|
||||
needs:
|
||||
- lint
|
||||
- test-focused
|
||||
- test-race
|
||||
- test-release-scripts
|
||||
- test-cross-platform
|
||||
@@ -376,7 +604,10 @@ jobs:
|
||||
- name: Verify test shards
|
||||
env:
|
||||
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
|
||||
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||
DOCS_ONLY: ${{ needs.lint.outputs.docs_only }}
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
RELEASE_SENSITIVE: ${{ needs.lint.outputs.release_sensitive }}
|
||||
FOCUSED_RESULT: ${{ needs.test-focused.result }}
|
||||
RACE_RESULT: ${{ needs.test-race.result }}
|
||||
RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.result }}
|
||||
CROSS_PLATFORM_RESULT: ${{ needs.test-cross-platform.result }}
|
||||
@@ -384,8 +615,9 @@ jobs:
|
||||
WINDOWS_RESULT: ${{ needs.test-windows.result }}
|
||||
run: |
|
||||
failed=0
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
|
||||
for shard in \
|
||||
"changed packages:$FOCUSED_RESULT" \
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT" \
|
||||
@@ -403,21 +635,34 @@ jobs:
|
||||
exit
|
||||
fi
|
||||
|
||||
focused_expected=success
|
||||
race_expected=skipped
|
||||
if [ "$FULL_SUITE" = true ]; then
|
||||
focused_expected=skipped
|
||||
race_expected=success
|
||||
fi
|
||||
release_expected=skipped
|
||||
if [ "$FULL_SUITE" = true ] || [ "$RELEASE_SENSITIVE" = true ]; then
|
||||
release_expected=success
|
||||
fi
|
||||
for shard in \
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT"
|
||||
"changed packages:$FOCUSED_RESULT:$focused_expected" \
|
||||
"race shards:$RACE_RESULT:$race_expected" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT:$release_expected" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT:success"
|
||||
do
|
||||
name="${shard%%:*}"
|
||||
result="${shard#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
remainder="${shard#*:}"
|
||||
result="${remainder%%:*}"
|
||||
expected="${remainder#*:}"
|
||||
printf '%s: %s (expected %s)\n' "$name" "$result" "$expected"
|
||||
if [ "$result" != "$expected" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
native_expected=skipped
|
||||
if [ "$PLATFORM_SENSITIVE" = true ]; then
|
||||
if [ "$FULL_SUITE" = true ]; then
|
||||
native_expected=success
|
||||
fi
|
||||
for native in \
|
||||
@@ -436,7 +681,7 @@ jobs:
|
||||
test-darwin:
|
||||
name: Test (macOS auth/keychain)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -454,7 +699,7 @@ jobs:
|
||||
test-windows:
|
||||
name: Test (Windows)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -568,13 +813,14 @@ jobs:
|
||||
coverage-current:
|
||||
name: Coverage (current)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
@@ -583,15 +829,69 @@ jobs:
|
||||
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 }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
echo "COVERAGE_HEAD_REF=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Run current unit tests with coverage
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
run: |
|
||||
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/...
|
||||
go tool cover -func=coverage.txt
|
||||
set -euo pipefail
|
||||
if [ "$FULL_SUITE" = true ]; then
|
||||
go test -count=1 -p 1 \
|
||||
-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
|
||||
fi
|
||||
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
|
||||
go tool cover -func=coverage.txt
|
||||
fi
|
||||
|
||||
- name: Upload current coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -603,7 +903,7 @@ jobs:
|
||||
coverage-supporting:
|
||||
name: Coverage (supporting)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_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: 10
|
||||
steps:
|
||||
@@ -642,7 +942,7 @@ jobs:
|
||||
coverage-baseline:
|
||||
name: Coverage (baseline)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -658,6 +958,7 @@ jobs:
|
||||
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
|
||||
@@ -678,8 +979,24 @@ jobs:
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run baseline unit tests with coverage
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
run: |
|
||||
set -eu
|
||||
set -euo pipefail
|
||||
changed_output=
|
||||
impacted_output=
|
||||
if [ "$FULL_SUITE" != true ]; then
|
||||
changed_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
changed "$COVERAGE_BASE_REF" HEAD
|
||||
)"
|
||||
impacted_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$COVERAGE_BASE_REF" HEAD
|
||||
)"
|
||||
fi
|
||||
base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")"
|
||||
rmdir "$base_worktree"
|
||||
cleanup() {
|
||||
@@ -689,11 +1006,25 @@ jobs:
|
||||
git worktree add --detach "$base_worktree" "$COVERAGE_BASE_REF"
|
||||
(
|
||||
cd "$base_worktree"
|
||||
go test -count=1 \
|
||||
-p 1 \
|
||||
-coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
if [ "$FULL_SUITE" = true ]; then
|
||||
go test -count=1 \
|
||||
-p 1 \
|
||||
-coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
elif [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
|
||||
printf 'mode: atomic\n' > "$GITHUB_WORKSPACE/coverage-base.txt"
|
||||
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="$GITHUB_WORKSPACE/coverage-base.txt" \
|
||||
-covermode=atomic \
|
||||
"${impacted_packages[@]}"
|
||||
fi
|
||||
)
|
||||
|
||||
- name: Upload baseline coverage profile
|
||||
@@ -719,6 +1050,8 @@ jobs:
|
||||
- name: Verify coverage profile jobs
|
||||
env:
|
||||
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
|
||||
DOCS_ONLY: ${{ needs.lint.outputs.docs_only }}
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||
CURRENT_RESULT: ${{ needs.coverage-current.result }}
|
||||
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
||||
@@ -727,22 +1060,32 @@ jobs:
|
||||
WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
|
||||
run: |
|
||||
failed=0
|
||||
expected=success
|
||||
current_expected=success
|
||||
supporting_expected=skipped
|
||||
baseline_expected=success
|
||||
native_expected=skipped
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
expected=skipped
|
||||
elif [ "$PLATFORM_SENSITIVE" = true ]; then
|
||||
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
|
||||
current_expected=skipped
|
||||
baseline_expected=skipped
|
||||
elif [ "$FULL_SUITE" = true ]; then
|
||||
supporting_expected=success
|
||||
fi
|
||||
if [ "$CHANGELOG_ONLY" != true ] &&
|
||||
[ "$DOCS_ONLY" != true ] &&
|
||||
[ "$PLATFORM_SENSITIVE" = true ]; then
|
||||
native_expected=success
|
||||
fi
|
||||
|
||||
for profile in \
|
||||
"current:$CURRENT_RESULT" \
|
||||
"supporting:$SUPPORTING_RESULT" \
|
||||
"baseline:$BASELINE_RESULT"
|
||||
"current:$CURRENT_RESULT:$current_expected" \
|
||||
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
|
||||
"baseline:$BASELINE_RESULT:$baseline_expected"
|
||||
do
|
||||
name="${profile%%:*}"
|
||||
result="${profile#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
remainder="${profile#*:}"
|
||||
result="${remainder%%:*}"
|
||||
expected="${remainder#*:}"
|
||||
printf '%s: %s (expected %s)\n' "$name" "$result" "$expected"
|
||||
if [ "$result" != "$expected" ]; then
|
||||
failed=1
|
||||
fi
|
||||
@@ -754,10 +1097,8 @@ jobs:
|
||||
do
|
||||
name="${native%%:*}"
|
||||
result="${native#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
native_expected=skipped
|
||||
fi
|
||||
printf '%s: %s (expected %s)\n' \
|
||||
"$name" "$result" "$native_expected"
|
||||
if [ "$result" != "$native_expected" ]; then
|
||||
failed=1
|
||||
fi
|
||||
@@ -765,20 +1106,20 @@ jobs:
|
||||
test "$failed" -eq 0
|
||||
|
||||
- name: Check out repository
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -796,40 +1137,52 @@ jobs:
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current coverage profile
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-current-profile
|
||||
path: .
|
||||
|
||||
- name: Download supporting coverage profiles
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-supporting-profiles
|
||||
path: .
|
||||
|
||||
- name: Download baseline coverage profile
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-baseline-profile
|
||||
path: .
|
||||
|
||||
- name: Enforce coverage gate
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
env:
|
||||
COVERAGE_TARGET: "100"
|
||||
COVERAGE_ENFORCE_OVERALL: "false"
|
||||
COVERAGE_OVERALL_TOLERANCE: "0"
|
||||
run: COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
run: |
|
||||
additional_profile=
|
||||
if [ -f coverage-shortcut.txt ]; then
|
||||
additional_profile=coverage-shortcut.txt
|
||||
fi
|
||||
COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \
|
||||
make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
|
||||
- name: Generate coverage report
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: go tool cover -html=coverage.txt -o coverage.html
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
run: |
|
||||
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
|
||||
go tool cover -html=coverage.txt -o coverage.html
|
||||
else
|
||||
echo "No buildable Go package needed a coverage HTML report." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
@@ -839,6 +1192,7 @@ jobs:
|
||||
coverage-policy.txt
|
||||
coverage-shortcut.txt
|
||||
coverage.html
|
||||
if-no-files-found: error
|
||||
|
||||
policy:
|
||||
name: Policy
|
||||
@@ -852,7 +1206,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -930,12 +1284,29 @@ jobs:
|
||||
echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Validate scoped policy
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && (needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true')) }}
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$PR_BASE_SHA"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
git diff --check "$base_ref" HEAD
|
||||
./scripts/policy/check-open-source-assets.sh
|
||||
echo "Policy used the scoped content/source admission path." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make build
|
||||
|
||||
- name: Policy
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make policy
|
||||
|
||||
interface-integrity:
|
||||
@@ -945,24 +1316,24 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make build
|
||||
|
||||
- name: Resolve authoritative compatibility merge-base
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -987,7 +1358,7 @@ jobs:
|
||||
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check historical commands and help compatibility
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: |
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
@@ -997,16 +1368,16 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
|
||||
- name: Check skill command references
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make skill-command-integrity
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "Interface Integrity is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Record scoped fast path
|
||||
if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true') }}
|
||||
run: echo "Interface Integrity is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
cli-smoke:
|
||||
name: CLI Smoke
|
||||
@@ -1015,26 +1386,26 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make build
|
||||
|
||||
- name: Check public top-level commands
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make cli-smoke
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "CLI Smoke is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Record scoped fast path
|
||||
if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true') }}
|
||||
run: echo "CLI Smoke is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
mock-mcp-smoke:
|
||||
name: Mock MCP
|
||||
@@ -1043,22 +1414,22 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.mcp_sensitive == 'true') }}
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.mcp_sensitive == 'true') }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check HTTP and stdio MCP transport
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.mcp_sensitive == 'true') }}
|
||||
run: make mock-mcp-smoke
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "Mock MCP is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Record scoped fast path
|
||||
if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.mcp_sensitive != 'true') }}
|
||||
run: echo "Mock MCP is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
edition-tests:
|
||||
name: Edition
|
||||
@@ -1067,19 +1438,19 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.edition_sensitive == 'true') }}
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.edition_sensitive == 'true') }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run edition contract tests
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.edition_sensitive == 'true') }}
|
||||
run: go test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "Edition is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Record scoped fast path
|
||||
if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.edition_sensitive != 'true') }}
|
||||
run: echo "Edition is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -14,6 +14,11 @@ on:
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sync_release_version:
|
||||
description: "Sync a specific release version's assets to Gitee (e.g. v1.0.55-beta.3)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: gitee-code-mirror
|
||||
@@ -23,7 +28,6 @@ jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
@@ -76,3 +80,42 @@ jobs:
|
||||
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
|
||||
|
||||
- name: Download GitHub Release assets
|
||||
if: ${{ inputs.sync_release_version != '' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.sync_release_version }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "📥 Downloading release assets for ${VERSION}"
|
||||
mkdir -p dist
|
||||
gh release download "$VERSION" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist/
|
||||
|
||||
- name: Verify release artifacts
|
||||
if: ${{ inputs.sync_release_version != '' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.sync_release_version }}
|
||||
run: |
|
||||
set -eu
|
||||
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
||||
./scripts/release/verify-release-artifacts.sh "$VERSION"
|
||||
|
||||
- name: Sync release assets to Gitee
|
||||
if: ${{ inputs.sync_release_version != '' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.sync_release_version }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
DIST_DIR: ${{ github.workspace }}/dist
|
||||
run: |
|
||||
set -eu
|
||||
echo "📦 Syncing release assets for ${VERSION} to Gitee ${GITEE_REPO}"
|
||||
./scripts/release/sync-to-gitee.sh
|
||||
|
||||
+522
-168
@@ -2,9 +2,6 @@ name: Release
|
||||
run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_operation != 'none' && format('Release {0} {1} ({2})', inputs.release_operation, inputs.release_channel, inputs.release_bump) || github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.event_name == 'workflow_dispatch' && inputs.repair_oss_version != '' && format('Release OSS repair {0}', inputs.repair_oss_version) || github.workflow }}
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_operation:
|
||||
@@ -33,10 +30,6 @@ on:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
release_confirmation:
|
||||
description: "For publish, type PUBLISH beta or PUBLISH stable"
|
||||
required: false
|
||||
type: string
|
||||
repair_npm_version:
|
||||
description: "Recover npm for a public immutable release without moving latest/beta"
|
||||
required: false
|
||||
@@ -78,7 +71,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
recover_release_nonce:
|
||||
description: "Unique identifier for this protected recovery request"
|
||||
description: "Unique identifier for this machine-verified recovery request"
|
||||
required: false
|
||||
type: string
|
||||
recover_release_confirmation:
|
||||
@@ -122,7 +115,6 @@ jobs:
|
||||
RELEASE_OPERATION: ${{ inputs.release_operation }}
|
||||
RELEASE_CHANNEL: ${{ inputs.release_channel }}
|
||||
RELEASE_BUMP: ${{ inputs.release_bump }}
|
||||
RELEASE_CONFIRMATION: ${{ inputs.release_confirmation }}
|
||||
run: |
|
||||
set -eu
|
||||
release_flow=0
|
||||
@@ -157,16 +149,8 @@ jobs:
|
||||
exit 1
|
||||
esac
|
||||
if test "$RELEASE_OPERATION" = plan; then
|
||||
test -z "$RELEASE_CONFIRMATION" || {
|
||||
echo "release_confirmation must be empty for a plan" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "mode=plan_release" >> "$GITHUB_OUTPUT"
|
||||
elif test "$RELEASE_OPERATION" = publish; then
|
||||
test "$RELEASE_CONFIRMATION" = "PUBLISH $RELEASE_CHANNEL" || {
|
||||
echo "release_confirmation must be exactly: PUBLISH $RELEASE_CHANNEL" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "mode=create_release" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "release_operation must be none, plan, or publish" >&2
|
||||
@@ -219,42 +203,51 @@ jobs:
|
||||
echo "mode=recover_release" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
authorize-recovery:
|
||||
name: Approve existing tag recovery
|
||||
needs: dispatch-contract
|
||||
if: ${{ needs.dispatch-contract.outputs.mode == 'recover_release' }}
|
||||
environment: release-recovery
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- name: Verify break-glass environment protection
|
||||
- name: Authorize repository release requester
|
||||
if: ${{ steps.mode.outputs.mode == 'plan_release' || steps.mode.outputs.mode == 'create_release' }}
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
REQUESTED_BY: ${{ github.actor }}
|
||||
TRIGGERED_BY: ${{ github.triggering_actor }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const response = await github.request(
|
||||
"GET /repos/{owner}/{repo}/environments/{environment_name}",
|
||||
{ owner, repo, environment_name: "release-recovery" },
|
||||
);
|
||||
const reviewerRule = response.data.protection_rules.find(
|
||||
(rule) => rule.type === "required_reviewers",
|
||||
);
|
||||
if (
|
||||
!reviewerRule ||
|
||||
reviewerRule.prevent_self_review !== true ||
|
||||
!Array.isArray(reviewerRule.reviewers) ||
|
||||
reviewerRule.reviewers.length === 0
|
||||
) {
|
||||
core.setFailed("release-recovery must require a reviewer and prevent self-review");
|
||||
const expectedRepository = process.env.EXPECTED_REPOSITORY;
|
||||
if (context.payload.repository.full_name !== expectedRepository) {
|
||||
core.setFailed(`release requests are restricted to ${expectedRepository}`);
|
||||
return;
|
||||
}
|
||||
if (response.data.deployment_branch_policy?.protected_branches !== true) {
|
||||
core.setFailed("release-recovery must allow only protected branches");
|
||||
}
|
||||
if (response.data.can_admins_bypass !== false) {
|
||||
core.setFailed("release-recovery must not allow administrator bypass");
|
||||
|
||||
const actors = new Set([
|
||||
process.env.REQUESTED_BY,
|
||||
process.env.TRIGGERED_BY,
|
||||
]);
|
||||
for (const username of actors) {
|
||||
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(username || "")) {
|
||||
core.setFailed("release request contains an invalid GitHub actor");
|
||||
return;
|
||||
}
|
||||
let permission;
|
||||
try {
|
||||
const response = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
...context.repo,
|
||||
username,
|
||||
});
|
||||
permission = response.data.permission;
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.setFailed(`${username} is not an authorized repository collaborator`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// GitHub maps maintain to the legacy write permission here.
|
||||
if (!["write", "admin"].includes(permission)) {
|
||||
core.setFailed(
|
||||
`${username} needs repository write, maintain, or admin access to release`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
governance-preflight:
|
||||
@@ -410,24 +403,9 @@ jobs:
|
||||
core.setFailed("Immutable releases must be enabled before publishing");
|
||||
}
|
||||
|
||||
- name: Verify Homebrew PR automation permission
|
||||
env:
|
||||
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
|
||||
run: |
|
||||
test -n "$HOMEBREW_PR_TOKEN" || {
|
||||
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
|
||||
exit 1
|
||||
}
|
||||
test "$HOMEBREW_PR_TOKEN" != "$RELEASE_GOVERNANCE_TOKEN" || {
|
||||
echo "HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities" >&2
|
||||
exit 1
|
||||
}
|
||||
./scripts/release/verify-homebrew-pr-token.sh "$GITHUB_REPOSITORY" --canary
|
||||
|
||||
release-contract:
|
||||
needs: [dispatch-contract, authorize-recovery, governance-preflight, release-plan, seal-release]
|
||||
if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}
|
||||
needs: [dispatch-contract, governance-preflight, release-plan, seal-release]
|
||||
if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
@@ -568,6 +546,8 @@ jobs:
|
||||
if (isCloudSeal) {
|
||||
const expectedChannel = version.includes("-beta.") ? "prerelease" : "stable";
|
||||
const actorId = String(context.payload.sender?.id || "");
|
||||
const sealedRunAttempt = Number(tagFields.get("Release-Run-Attempt"));
|
||||
const currentRunAttempt = Number(process.env.GITHUB_RUN_ATTEMPT);
|
||||
if (
|
||||
tagFields.get("Channel") !== expectedChannel ||
|
||||
tagFields.get("Sealed-Commit") !== commit ||
|
||||
@@ -582,7 +562,10 @@ jobs:
|
||||
}
|
||||
if (isCreate && (
|
||||
tagFields.get("Release-Run") !== String(context.runId) ||
|
||||
tagFields.get("Release-Run-Attempt") !== process.env.GITHUB_RUN_ATTEMPT ||
|
||||
!Number.isSafeInteger(sealedRunAttempt) ||
|
||||
!Number.isSafeInteger(currentRunAttempt) ||
|
||||
sealedRunAttempt < 1 ||
|
||||
sealedRunAttempt > currentRunAttempt ||
|
||||
tagFields.get("Requested-By") !== context.actor ||
|
||||
tagFields.get("Requested-By-ID") !== actorId
|
||||
)) {
|
||||
@@ -642,6 +625,7 @@ jobs:
|
||||
if (failedByCloud && (
|
||||
tagFields.get("Release-Run") !== failedRunId ||
|
||||
tagFields.get("Release-Run-Attempt") !== failedRunAttempt ||
|
||||
tagFields.get("Requested-By") !== String(run.actor?.login || "") ||
|
||||
tagFields.get("Requested-By-ID") !== String(run.actor?.id || "")
|
||||
)) {
|
||||
core.setFailed(
|
||||
@@ -818,23 +802,6 @@ jobs:
|
||||
core.setFailed("Immutable releases must be enabled before publishing");
|
||||
}
|
||||
|
||||
- name: Verify Homebrew PR automation permission
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && (github.event_name == 'push' || needs.dispatch-contract.outputs.mode == 'recover_release') }}
|
||||
env:
|
||||
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$HOMEBREW_PR_TOKEN" ]; then
|
||||
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$HOMEBREW_PR_TOKEN" = "$RELEASE_GOVERNANCE_TOKEN" ]; then
|
||||
echo "HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-homebrew-pr-token.sh" \
|
||||
"$GITHUB_REPOSITORY" --canary
|
||||
|
||||
- name: Require successful beta delivery before stable promotion
|
||||
if: ${{ steps.contract.outputs.channel == 'stable' && (github.event_name == 'push' || needs.dispatch-contract.outputs.mode == 'recover_release') }}
|
||||
uses: actions/github-script@v7
|
||||
@@ -1146,6 +1113,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
|
||||
@@ -1499,29 +1467,189 @@ jobs:
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
|
||||
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
||||
|
||||
- name: Open stable Homebrew formula PR
|
||||
- name: Publish stable Homebrew formula
|
||||
id: homebrew-stable
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'stable' }}
|
||||
run: ./scripts/release/publish-homebrew-formula.sh
|
||||
run: DWS_PUBLISH_OUTPUT="$GITHUB_OUTPUT" "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/publish-homebrew-formula.sh"
|
||||
env:
|
||||
DWS_FORMULA_SOURCE: ${{ github.workspace }}/dist/homebrew/dingtalk-workspace-cli.rb
|
||||
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
DWS_TAP_BRANCH: main
|
||||
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
|
||||
DWS_TAP_PR_BRANCH: "automation/homebrew-${{ needs.release-contract.outputs.release_version }}"
|
||||
DWS_TAP_PR_TITLE: "chore: update Homebrew formula for ${{ needs.release-contract.outputs.release_version }}"
|
||||
DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ needs.release-contract.outputs.release_version }}"
|
||||
DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ needs.release-contract.outputs.release_version }} [skip ci]"
|
||||
DWS_GIT_NAME: github-actions[bot]
|
||||
DWS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
|
||||
- name: Open beta Homebrew formula PR
|
||||
- name: Publish beta Homebrew formula
|
||||
id: homebrew-beta
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'prerelease' }}
|
||||
run: ./scripts/release/publish-homebrew-formula.sh
|
||||
run: DWS_PUBLISH_OUTPUT="$GITHUB_OUTPUT" "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/publish-homebrew-formula.sh"
|
||||
env:
|
||||
DWS_FORMULA_SOURCE: dist/homebrew/dingtalk-workspace-cli-beta.rb
|
||||
DWS_FORMULA_SOURCE: ${{ github.workspace }}/dist/homebrew/dingtalk-workspace-cli-beta.rb
|
||||
DWS_TAP_FORMULA_PATH: Formula/dingtalk-workspace-cli-beta.rb
|
||||
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
DWS_TAP_BRANCH: main
|
||||
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
|
||||
DWS_TAP_PR_BRANCH: "automation/homebrew-beta-${{ needs.release-contract.outputs.release_version }}"
|
||||
DWS_TAP_PR_TITLE: "chore: update Homebrew beta formula for ${{ needs.release-contract.outputs.release_version }}"
|
||||
DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ needs.release-contract.outputs.release_version }}"
|
||||
DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ needs.release-contract.outputs.release_version }} [skip ci]"
|
||||
DWS_GIT_NAME: github-actions[bot]
|
||||
DWS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
|
||||
- name: Seal Formula-only Code Admission contexts
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
FORMULA_CHANGED: ${{ steps.homebrew-stable.outputs.formula_changed || steps.homebrew-beta.outputs.formula_changed }}
|
||||
FORMULA_COMMIT: ${{ steps.homebrew-stable.outputs.published_commit || steps.homebrew-beta.outputs.published_commit }}
|
||||
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const { owner, repo } = context.repo;
|
||||
const commit = process.env.FORMULA_COMMIT;
|
||||
const formulaChanged = process.env.FORMULA_CHANGED === "true";
|
||||
const channel = process.env.RELEASE_CHANNEL;
|
||||
const version = process.env.RELEASE_VERSION;
|
||||
const formulaPath = channel === "stable"
|
||||
? "Formula/dingtalk-workspace-cli.rb"
|
||||
: "Formula/dingtalk-workspace-cli-beta.rb";
|
||||
const sourcePath = channel === "stable"
|
||||
? "dist/homebrew/dingtalk-workspace-cli.rb"
|
||||
: "dist/homebrew/dingtalk-workspace-cli-beta.rb";
|
||||
const expectedMessage = channel === "stable"
|
||||
? `chore: update formula for ${version} [skip ci]`
|
||||
: `chore: update beta formula for ${version} [skip ci]`;
|
||||
|
||||
if (!/^[0-9a-f]{40}$/.test(commit)) {
|
||||
core.setFailed("Homebrew publisher did not return one full commit SHA");
|
||||
return;
|
||||
}
|
||||
const commitResponse = await github.rest.repos.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
ref: commit,
|
||||
per_page: 100,
|
||||
});
|
||||
const files = commitResponse.data.files || [];
|
||||
const requiredContexts = [
|
||||
"Lint",
|
||||
"Test",
|
||||
"Coverage",
|
||||
"Policy",
|
||||
"Edition",
|
||||
"Interface Integrity",
|
||||
"AI Behavior",
|
||||
"CLI Smoke",
|
||||
"Mock MCP",
|
||||
];
|
||||
const exactFormulaCommit =
|
||||
commitResponse.data.sha === commit &&
|
||||
commitResponse.data.parents.length === 1 &&
|
||||
commitResponse.data.commit.message === expectedMessage &&
|
||||
commitResponse.data.author?.login === "github-actions[bot]" &&
|
||||
commitResponse.data.committer?.login === "github-actions[bot]" &&
|
||||
files.length === 1 &&
|
||||
files[0].filename === formulaPath &&
|
||||
["added", "modified"].includes(files[0].status);
|
||||
if (!exactFormulaCommit) {
|
||||
if (formulaChanged) {
|
||||
core.setFailed(
|
||||
`${commit} is not the exact github-actions Formula-only commit for ${version}`,
|
||||
);
|
||||
} else {
|
||||
core.info("Formula was already current; no Formula-only check sealing is needed.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = commitResponse.data.parents[0].sha;
|
||||
const parentRuns = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: parent,
|
||||
filter: "latest",
|
||||
per_page: 100,
|
||||
});
|
||||
const latestParentRunByName = new Map();
|
||||
for (const run of parentRuns) {
|
||||
if (
|
||||
run.head_sha !== parent ||
|
||||
run.app?.slug !== "github-actions" ||
|
||||
!requiredContexts.includes(run.name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const current = latestParentRunByName.get(run.name);
|
||||
if (!current || run.id > current.id) {
|
||||
latestParentRunByName.set(run.name, run);
|
||||
}
|
||||
}
|
||||
const invalidParentContexts = requiredContexts.filter((name) => {
|
||||
const run = latestParentRunByName.get(name);
|
||||
return !run || run.conclusion !== "success";
|
||||
});
|
||||
if (invalidParentContexts.length > 0) {
|
||||
core.setFailed(
|
||||
`Formula commit parent ${parent} lacks successful Code Admission contexts: ` +
|
||||
invalidParentContexts.join(", "),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteFormula = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: formulaPath,
|
||||
ref: commit,
|
||||
});
|
||||
if (
|
||||
Array.isArray(remoteFormula.data) ||
|
||||
remoteFormula.data.type !== "file" ||
|
||||
remoteFormula.data.encoding !== "base64"
|
||||
) {
|
||||
core.setFailed(`${formulaPath} is not a regular base64 GitHub blob at ${commit}`);
|
||||
return;
|
||||
}
|
||||
const expectedFormula = fs.readFileSync(sourcePath);
|
||||
const actualFormula = Buffer.from(
|
||||
remoteFormula.data.content.replace(/\s/g, ""),
|
||||
"base64",
|
||||
);
|
||||
if (!actualFormula.equals(expectedFormula)) {
|
||||
core.setFailed(`${formulaPath} at ${commit} differs from the verified release Formula`);
|
||||
return;
|
||||
}
|
||||
|
||||
const branch = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: context.payload.repository.default_branch,
|
||||
});
|
||||
const comparison = await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${commit}...${branch.data.commit.sha}`,
|
||||
});
|
||||
if (!["ahead", "identical"].includes(comparison.data.status)) {
|
||||
core.setFailed(`${commit} is no longer contained in the default branch`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const name of requiredContexts) {
|
||||
await github.rest.checks.create({
|
||||
owner,
|
||||
repo,
|
||||
name,
|
||||
head_sha: commit,
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
output: {
|
||||
title: "Verified Formula-only release commit",
|
||||
summary:
|
||||
`${formulaPath} was generated from immutable ${version} assets, ` +
|
||||
"validated byte-for-byte, and was the commit's only changed file.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
- name: Reverify exact immutable npm package
|
||||
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION"
|
||||
@@ -1739,7 +1867,7 @@ jobs:
|
||||
set -eu
|
||||
. ./scripts/release/release-lib.sh
|
||||
attempt=1
|
||||
max_attempts=12
|
||||
max_attempts=60
|
||||
while :; do
|
||||
delivered="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG" \
|
||||
--registry=https://registry.npmjs.org --prefer-online)"
|
||||
@@ -1757,7 +1885,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "npm $NPM_TAG still reports older v$delivered; waiting for registry propagation ($attempt/$max_attempts)."
|
||||
sleep 5
|
||||
# incremental backoff: 5s for first 12 attempts (1 min), then 10s
|
||||
if [ "$attempt" -lt 12 ]; then
|
||||
sleep 5
|
||||
else
|
||||
sleep 10
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
@@ -2049,7 +2182,6 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
needs:
|
||||
- dispatch-contract
|
||||
- authorize-recovery
|
||||
- governance-preflight
|
||||
- release-contract
|
||||
- release-validation
|
||||
@@ -2072,7 +2204,6 @@ jobs:
|
||||
DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }}
|
||||
GITEE_FALLBACK_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK }}
|
||||
DISPATCH_RESULT: ${{ needs.dispatch-contract.result }}
|
||||
AUTHORIZE_RECOVERY_RESULT: ${{ needs.authorize-recovery.result }}
|
||||
GOVERNANCE_PREFLIGHT_RESULT: ${{ needs.governance-preflight.result }}
|
||||
RELEASE_CONTRACT_RESULT: ${{ needs.release-contract.result }}
|
||||
RELEASE_VALIDATION_RESULT: ${{ needs.release-validation.result }}
|
||||
@@ -2116,7 +2247,6 @@ jobs:
|
||||
case "$EVENT_NAME:$DISPATCH_MODE" in
|
||||
push:)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" skipped
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||
@@ -2128,7 +2258,6 @@ jobs:
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success
|
||||
require_result release-plan "$RELEASE_PLAN_RESULT" success
|
||||
require_result seal-release "$SEAL_RELEASE_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||
require_publication
|
||||
@@ -2137,7 +2266,6 @@ jobs:
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result release-plan "$RELEASE_PLAN_RESULT" success
|
||||
require_result seal-release "$SEAL_RELEASE_RESULT" skipped
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
|
||||
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
|
||||
@@ -2151,7 +2279,6 @@ jobs:
|
||||
;;
|
||||
workflow_dispatch:recover_release)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" success
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||
@@ -2161,7 +2288,6 @@ jobs:
|
||||
workflow_dispatch:governance_preflight)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
|
||||
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
|
||||
require_result release "$RELEASE_RESULT" skipped
|
||||
@@ -2176,7 +2302,6 @@ jobs:
|
||||
workflow_dispatch:repair_npm)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
|
||||
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
|
||||
@@ -2191,7 +2316,6 @@ jobs:
|
||||
workflow_dispatch:repair_gitee)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
|
||||
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
|
||||
@@ -2206,7 +2330,6 @@ jobs:
|
||||
workflow_dispatch:repair_oss)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
|
||||
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
|
||||
@@ -2754,15 +2877,98 @@ jobs:
|
||||
name: Seal cloud release tag
|
||||
needs: [dispatch-contract, governance-preflight, release-plan]
|
||||
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' }}
|
||||
environment:
|
||||
name: ${{ inputs.release_channel == 'stable' && 'release-stable' || 'release-beta' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
outputs:
|
||||
release_version: ${{ steps.seal.outputs.release_version }}
|
||||
release_commit: ${{ steps.seal.outputs.release_commit }}
|
||||
release_tag_object: ${{ steps.seal.outputs.release_tag_object }}
|
||||
steps:
|
||||
- name: Verify release environment governance before sealing
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_ENVIRONMENT: ${{ inputs.release_channel == 'stable' && 'release-stable' || 'release-beta' }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const environmentName = process.env.RELEASE_ENVIRONMENT;
|
||||
const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli";
|
||||
if (
|
||||
context.payload.repository.full_name !== expectedRepository ||
|
||||
!["release-beta", "release-stable"].includes(environmentName)
|
||||
) {
|
||||
core.setFailed("release environment validation is not bound to an official channel");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await github.request(
|
||||
"GET /repos/{owner}/{repo}/environments/{environment_name}",
|
||||
{ owner, repo, environment_name: environmentName },
|
||||
);
|
||||
const environment = response.data;
|
||||
const branchPolicy = environment.deployment_branch_policy;
|
||||
const protectionRules = environment.protection_rules || [];
|
||||
const reviewerRules = protectionRules.filter(
|
||||
(rule) => rule.type === "required_reviewers",
|
||||
);
|
||||
const waitRules = protectionRules.filter(
|
||||
(rule) => rule.type === "wait_timer",
|
||||
);
|
||||
if (
|
||||
environment.name !== environmentName ||
|
||||
environment.can_admins_bypass !== false ||
|
||||
branchPolicy?.protected_branches !== true ||
|
||||
branchPolicy?.custom_branch_policies !== false ||
|
||||
waitRules.length !== 0
|
||||
) {
|
||||
core.setFailed(`${environmentName} does not match the required release policy`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (environmentName === "release-beta") {
|
||||
if (reviewerRules.length !== 0) {
|
||||
core.setFailed("release-beta must not require a manual reviewer");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
reviewerRules.length !== 1 ||
|
||||
reviewerRules[0].prevent_self_review !== true ||
|
||||
!Array.isArray(reviewerRules[0].reviewers) ||
|
||||
reviewerRules[0].reviewers.length === 0
|
||||
) {
|
||||
core.setFailed(
|
||||
"release-stable requires a non-self reviewing administrator",
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const reviewer of reviewerRules[0].reviewers) {
|
||||
if (
|
||||
reviewer.type !== "User" ||
|
||||
!reviewer.reviewer?.login
|
||||
) {
|
||||
core.setFailed("release-stable reviewers must be repository administrators");
|
||||
return;
|
||||
}
|
||||
const permission = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner,
|
||||
repo,
|
||||
username: reviewer.reviewer.login,
|
||||
});
|
||||
if (permission.data.permission !== "admin") {
|
||||
core.setFailed(
|
||||
`${reviewer.reviewer.login} is no longer a repository administrator`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
- name: Create one immutable annotated release tag
|
||||
id: seal
|
||||
uses: actions/github-script@v7
|
||||
@@ -2816,10 +3022,94 @@ jobs:
|
||||
repo,
|
||||
branch: defaultBranch,
|
||||
});
|
||||
if (branch.data.commit.sha !== commit) {
|
||||
core.setFailed(`${defaultBranch} moved to ${branch.data.commit.sha}; run the release again`);
|
||||
const currentBranchCommit = branch.data.commit.sha;
|
||||
const branchMoved = branch.data.commit.sha !== commit;
|
||||
|
||||
const currentAttempt = Number(process.env.GITHUB_RUN_ATTEMPT);
|
||||
const requestedById = String(context.payload.sender?.id || "");
|
||||
if (!Number.isSafeInteger(currentAttempt) || currentAttempt < 1 || !/^[1-9]\d*$/.test(requestedById)) {
|
||||
core.setFailed("cloud release request contains an invalid run attempt or requester identity");
|
||||
return;
|
||||
}
|
||||
const messageForAttempt = (attempt) => [
|
||||
`Release ${version}`,
|
||||
"",
|
||||
`Channel: ${channel}`,
|
||||
...(fromBeta ? [`From-Beta: ${fromBeta}`] : []),
|
||||
`OSS-Mirror: ${ossMirror}`,
|
||||
`Release-Run: ${context.runId}`,
|
||||
`Release-Run-Attempt: ${attempt}`,
|
||||
`Requested-By: ${context.actor}`,
|
||||
`Requested-By-ID: ${context.payload.sender?.id || ""}`,
|
||||
`Sealed-Commit: ${commit}`,
|
||||
`Workflow-Commit: ${context.sha}`,
|
||||
`Allocation-Fingerprint: ${expectedFingerprint}`,
|
||||
].join("\n");
|
||||
const retryablePostWriteError = (error) =>
|
||||
error.status === 404 ||
|
||||
error.status === 429 ||
|
||||
(
|
||||
Number.isInteger(error.status) &&
|
||||
error.status >= 500 &&
|
||||
error.status < 600
|
||||
);
|
||||
const postWriteRetryDelaysMs = [0, 1000, 2000, 4000, 8000, 16000];
|
||||
const sleep = (delayMs) =>
|
||||
new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
const readAfterWrite = async (description, operation) => {
|
||||
let lastError;
|
||||
for (const delayMs of postWriteRetryDelaysMs) {
|
||||
if (delayMs > 0) {
|
||||
core.info(`${description} is not visible yet; retrying in ${delayMs}ms`);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!retryablePostWriteError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
};
|
||||
const verifyExactTag = async (refResponse, expectedTagObject = "") => {
|
||||
if (refResponse.data.object.type !== "tag") {
|
||||
throw new Error(`${version} does not point to an annotated tag object`);
|
||||
}
|
||||
const tagObject = refResponse.data.object.sha;
|
||||
if (expectedTagObject && tagObject !== expectedTagObject) {
|
||||
throw new Error(
|
||||
`${version} points to ${tagObject}, expected new tag object ${expectedTagObject}`,
|
||||
);
|
||||
}
|
||||
const tagResponse = await readAfterWrite(
|
||||
`${version} annotated tag object`,
|
||||
() => github.rest.git.getTag({ owner, repo, tag_sha: tagObject }),
|
||||
);
|
||||
const attemptMatches = [
|
||||
...tagResponse.data.message.matchAll(/^Release-Run-Attempt: ([1-9]\d*)$/gm),
|
||||
];
|
||||
if (attemptMatches.length !== 1) {
|
||||
throw new Error(`${version} has invalid Release-Run-Attempt metadata`);
|
||||
}
|
||||
const sealedAttempt = Number(attemptMatches[0][1]);
|
||||
if (
|
||||
!Number.isSafeInteger(sealedAttempt) ||
|
||||
sealedAttempt < 1 ||
|
||||
sealedAttempt > currentAttempt ||
|
||||
tagResponse.data.tag !== version ||
|
||||
tagResponse.data.object.type !== "commit" ||
|
||||
tagResponse.data.object.sha !== commit ||
|
||||
tagResponse.data.message !== messageForAttempt(sealedAttempt)
|
||||
) {
|
||||
throw new Error(
|
||||
`${version} existing annotated tag is not the exact seal from this workflow run`,
|
||||
);
|
||||
}
|
||||
return tagObject;
|
||||
};
|
||||
|
||||
const refs = [];
|
||||
for (const prefix of ["tags/v", "tags/withdrawn/v"]) {
|
||||
@@ -2830,6 +3120,12 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
for (const ref of matching) {
|
||||
// A failed-jobs rerun keeps the successful release-plan output.
|
||||
// Ignore only this run's candidate ref while recomputing that
|
||||
// pre-write fingerprint; verify its complete authority below.
|
||||
if (ref.ref === `refs/tags/${version}`) {
|
||||
continue;
|
||||
}
|
||||
refs.push(`${ref.ref}=${ref.object.sha}`);
|
||||
}
|
||||
}
|
||||
@@ -2843,10 +3139,13 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
let existingRef = null;
|
||||
try {
|
||||
await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` });
|
||||
core.setFailed(`release tag already exists: ${version}`);
|
||||
return;
|
||||
existingRef = await github.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `tags/${version}`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
@@ -2862,66 +3161,121 @@ jobs:
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
|
||||
const message = [
|
||||
`Release ${version}`,
|
||||
"",
|
||||
`Channel: ${channel}`,
|
||||
...(fromBeta ? [`From-Beta: ${fromBeta}`] : []),
|
||||
`OSS-Mirror: ${ossMirror}`,
|
||||
`Release-Run: ${context.runId}`,
|
||||
`Release-Run-Attempt: ${process.env.GITHUB_RUN_ATTEMPT}`,
|
||||
`Requested-By: ${context.actor}`,
|
||||
`Requested-By-ID: ${context.payload.sender?.id || ""}`,
|
||||
`Sealed-Commit: ${commit}`,
|
||||
`Workflow-Commit: ${context.sha}`,
|
||||
`Allocation-Fingerprint: ${expectedFingerprint}`,
|
||||
].join("\n");
|
||||
const tag = await github.rest.git.createTag({
|
||||
owner,
|
||||
repo,
|
||||
tag: version,
|
||||
message,
|
||||
object: commit,
|
||||
type: "commit",
|
||||
});
|
||||
await github.rest.git.createRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `refs/tags/${version}`,
|
||||
sha: tag.data.sha,
|
||||
});
|
||||
|
||||
const sealedRef = await github.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `tags/${version}`,
|
||||
});
|
||||
if (
|
||||
sealedRef.data.object.type !== "tag" ||
|
||||
sealedRef.data.object.sha !== tag.data.sha
|
||||
) {
|
||||
core.setFailed(`${version} did not seal to the new annotated tag object`);
|
||||
return;
|
||||
if (branchMoved) {
|
||||
if (!existingRef) {
|
||||
core.setFailed(
|
||||
`${defaultBranch} moved to ${currentBranchCommit} before the tag was created; run the release again`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const comparison = await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${commit}...${currentBranchCommit}`,
|
||||
});
|
||||
if (!["ahead", "identical"].includes(comparison.data.status)) {
|
||||
core.setFailed(
|
||||
`${version} sealed commit is no longer contained in ${defaultBranch}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const sealedTag = await github.rest.git.getTag({
|
||||
owner,
|
||||
repo,
|
||||
tag_sha: tag.data.sha,
|
||||
});
|
||||
if (
|
||||
sealedTag.data.tag !== version ||
|
||||
sealedTag.data.object.type !== "commit" ||
|
||||
sealedTag.data.object.sha !== commit ||
|
||||
sealedTag.data.message !== message
|
||||
) {
|
||||
core.setFailed(`${version} annotated tag authority differs after creation`);
|
||||
return;
|
||||
|
||||
let sealedTagObject = "";
|
||||
let reusedExistingSeal = false;
|
||||
if (existingRef) {
|
||||
sealedTagObject = await verifyExactTag(existingRef);
|
||||
reusedExistingSeal = true;
|
||||
core.info(
|
||||
`Reusing exact ${version} seal from run ${context.runId}; continuing failed jobs without recovery approval`,
|
||||
);
|
||||
} else {
|
||||
const message = messageForAttempt(currentAttempt);
|
||||
const tag = await github.rest.git.createTag({
|
||||
owner,
|
||||
repo,
|
||||
tag: version,
|
||||
message,
|
||||
object: commit,
|
||||
type: "commit",
|
||||
});
|
||||
if (
|
||||
tag.data.tag !== version ||
|
||||
tag.data.object.type !== "commit" ||
|
||||
tag.data.object.sha !== commit ||
|
||||
tag.data.message !== message
|
||||
) {
|
||||
core.setFailed(`${version} annotated tag creation response differs from the seal`);
|
||||
return;
|
||||
}
|
||||
|
||||
let createRefResponse = null;
|
||||
let createRefError = null;
|
||||
for (const delayMs of postWriteRetryDelaysMs) {
|
||||
if (delayMs > 0) {
|
||||
core.info(
|
||||
`${version} ref creation is not confirmed; retrying in ${delayMs}ms`,
|
||||
);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
try {
|
||||
createRefResponse = await github.rest.git.createRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `refs/tags/${version}`,
|
||||
sha: tag.data.sha,
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
createRefError = error;
|
||||
if (
|
||||
error.status !== 422 &&
|
||||
error.status !== 429 &&
|
||||
!(Number.isInteger(error.status) && error.status >= 500 && error.status < 600)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const observedRef = await github.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `tags/${version}`,
|
||||
});
|
||||
await verifyExactTag(observedRef, tag.data.sha);
|
||||
createRefResponse = observedRef;
|
||||
break;
|
||||
} catch (observeError) {
|
||||
if (!retryablePostWriteError(observeError)) {
|
||||
throw observeError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!createRefResponse) {
|
||||
throw createRefError;
|
||||
}
|
||||
if (
|
||||
createRefResponse.data.object.type !== "tag" ||
|
||||
createRefResponse.data.object.sha !== tag.data.sha
|
||||
) {
|
||||
core.setFailed(`${version} ref creation response differs from the new tag object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sealedRef = await readAfterWrite(
|
||||
`${version} tag ref`,
|
||||
() => github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }),
|
||||
);
|
||||
sealedTagObject = await verifyExactTag(sealedRef, tag.data.sha);
|
||||
}
|
||||
|
||||
core.setOutput("release_version", version);
|
||||
core.setOutput("release_commit", commit);
|
||||
core.setOutput("release_tag_object", tag.data.sha);
|
||||
core.setOutput("release_tag_object", sealedTagObject);
|
||||
await core.summary
|
||||
.addHeading("Cloud release sealed")
|
||||
.addRaw(`\`${version}\` → \`${commit}\` (run ${context.runId})`)
|
||||
.addRaw(
|
||||
`\`${version}\` → \`${commit}\` (run ${context.runId}` +
|
||||
`${reusedExistingSeal ? ", exact seal reused" : ""})`,
|
||||
)
|
||||
.write();
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
name: Reviewer routing
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
# pull_request_target deliberately runs only this workflow from the protected
|
||||
# base branch. Never check out or execute pull-request code here.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: reviewer-router-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
route:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Route review and enable auto-merge
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pullNumber = context.payload.pull_request.number;
|
||||
const eventHeadSha = context.payload.pull_request.head.sha;
|
||||
const reviewerPool = [
|
||||
'sczheng189',
|
||||
'shangguanxuan633-lab',
|
||||
'audanye-sudo',
|
||||
'wxianfeng',
|
||||
];
|
||||
|
||||
async function getReadyEventPull(phase) {
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (
|
||||
currentPull.head.sha !== eventHeadSha ||
|
||||
currentPull.state !== 'open' ||
|
||||
currentPull.draft ||
|
||||
currentPull.base.ref !== 'main'
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} state or revision no longer matches this ready-main event during ${phase}; routing stopped.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return currentPull;
|
||||
}
|
||||
const pullRequest = await getReadyEventPull('initial read');
|
||||
if (!pullRequest) {
|
||||
return;
|
||||
}
|
||||
const author = pullRequest.user.login.toLowerCase();
|
||||
const headSha = pullRequest.head.sha;
|
||||
const latestPusher =
|
||||
context.payload.action === 'synchronize'
|
||||
? context.payload.sender?.login?.toLowerCase()
|
||||
: author;
|
||||
|
||||
async function routeReview() {
|
||||
const eligible = reviewerPool.filter(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() !== author &&
|
||||
reviewer.toLowerCase() !== latestPusher,
|
||||
);
|
||||
if (eligible.length === 0) {
|
||||
core.warning(`No eligible reviewer remains for PR #${pullNumber}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyRequested =
|
||||
(pullRequest.requested_reviewers || []).length > 0 ||
|
||||
(pullRequest.requested_teams || []).length > 0;
|
||||
if (alreadyRequested) {
|
||||
core.info(`PR #${pullNumber} already has a requested reviewer; leaving it unchanged.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let reviews;
|
||||
try {
|
||||
reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not inspect existing reviews for PR #${pullNumber}; skipping reviewer routing to avoid a duplicate request (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestDecisionByLogin = new Map();
|
||||
for (const review of reviews) {
|
||||
const login = review.user?.login?.toLowerCase();
|
||||
if (
|
||||
!login ||
|
||||
!['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(
|
||||
review.state,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const previous = latestDecisionByLogin.get(login);
|
||||
if (!previous || review.id > previous.id) {
|
||||
latestDecisionByLogin.set(login, review);
|
||||
}
|
||||
}
|
||||
const currentHeadDecision = [...latestDecisionByLogin.values()].find(
|
||||
review =>
|
||||
review.commit_id === headSha &&
|
||||
eligible.some(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() ===
|
||||
review.user.login.toLowerCase(),
|
||||
) &&
|
||||
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
|
||||
);
|
||||
if (currentHeadDecision) {
|
||||
core.info(
|
||||
`PR #${pullNumber} already has a ${currentHeadDecision.state} review on its current head; leaving review ownership unchanged.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
|
||||
try {
|
||||
const openPullRequests = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
for (const openPullRequest of openPullRequests) {
|
||||
for (const reviewer of openPullRequest.requested_reviewers || []) {
|
||||
const candidate = eligible.find(
|
||||
login => login.toLowerCase() === reviewer.login.toLowerCase(),
|
||||
);
|
||||
if (candidate) {
|
||||
loads.set(candidate, loads.get(candidate) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not read current reviewer load; using deterministic rotation (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const offset = pullNumber % eligible.length;
|
||||
const rotated = eligible.slice(offset).concat(eligible.slice(0, offset));
|
||||
const tieOrder = new Map(rotated.map((reviewer, index) => [reviewer, index]));
|
||||
const staleChangeRequester = [...latestDecisionByLogin.values()]
|
||||
.filter(review => review.state === 'CHANGES_REQUESTED')
|
||||
.sort((left, right) => right.id - left.id)
|
||||
.map(review =>
|
||||
eligible.find(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() === review.user.login.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.find(Boolean);
|
||||
const ranked = [...eligible].sort(
|
||||
(left, right) =>
|
||||
Number(right === staleChangeRequester) -
|
||||
Number(left === staleChangeRequester) ||
|
||||
loads.get(left) - loads.get(right) ||
|
||||
tieOrder.get(left) - tieOrder.get(right),
|
||||
);
|
||||
|
||||
for (const reviewer of ranked) {
|
||||
try {
|
||||
const currentPull = await getReadyEventPull('review request');
|
||||
if (!currentPull) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(currentPull.requested_reviewers || []).length > 0 ||
|
||||
(currentPull.requested_teams || []).length > 0
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} received a reviewer while routing; leaving it unchanged.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await github.rest.pulls.requestReviewers({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
reviewers: [reviewer],
|
||||
});
|
||||
core.info(
|
||||
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
core.warning(`No reviewer request could be created for PR #${pullNumber}.`);
|
||||
}
|
||||
|
||||
async function enableAutoMerge() {
|
||||
try {
|
||||
const currentPull = await getReadyEventPull('auto-merge enable');
|
||||
if (!currentPull) {
|
||||
return;
|
||||
}
|
||||
if (currentPull.auto_merge) {
|
||||
core.info(`Auto-merge is already enabled for PR #${pullNumber}.`);
|
||||
return;
|
||||
}
|
||||
await github.graphql(
|
||||
`mutation EnableAutoMerge($pullRequestId: ID!) {
|
||||
enablePullRequestAutoMerge(
|
||||
input: {
|
||||
pullRequestId: $pullRequestId
|
||||
mergeMethod: MERGE
|
||||
}
|
||||
) {
|
||||
pullRequest {
|
||||
autoMergeRequest {
|
||||
enabledAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{pullRequestId: currentPull.node_id},
|
||||
);
|
||||
core.info(`Enabled native auto-merge for PR #${pullNumber}.`);
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not enable auto-merge for PR #${pullNumber}; checks and review can continue normally (${error.message}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await routeReview();
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Reviewer routing hit an unexpected error for PR #${pullNumber}; review can still proceed manually (${error.message}).`,
|
||||
);
|
||||
}
|
||||
await enableAutoMerge();
|
||||
@@ -7,7 +7,8 @@ unrelated work, and use `gofmt` for every modified Go file.
|
||||
|
||||
- Build: `go build ./cmd`
|
||||
- Full test suite: `DWS_PACKAGE_VERSION=0.0.0-test go test ./...`
|
||||
- Generate Schema assets: `go generate ./internal/cli`
|
||||
- Generate Schema assets: `go generate ./internal/cli` (entry point: `internal/cli/gen.go`)
|
||||
- Refresh pinned MCP metadata: `make fetch-mcp-metadata` (requires `dws auth login`)
|
||||
- Check generated drift: `./scripts/policy/check-generated-drift.sh`
|
||||
- Check the Schema contract: `./scripts/policy/check-schema-catalog.sh`
|
||||
|
||||
@@ -56,8 +57,16 @@ The Schema data flow is one way:
|
||||
|
||||
6. One-way publication
|
||||
SchemaRegistry
|
||||
└─ internal/cli/schema_catalog.json
|
||||
└─ internal/cli/schema_catalog/
|
||||
(catalog.json + tools/<product>.json; split per product so
|
||||
concurrent feature PRs only rewrite their own shard)
|
||||
└─ dws schema list/product/group/leaf/--all
|
||||
|
||||
7. Runtime consumption (unified API)
|
||||
ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}
|
||||
└─ internal/cli/command_meta.go
|
||||
└─ all consumers (help, schema, agent, skill-gen) call this one function
|
||||
└─ backed by embedded catalog (sync.Once lazy map, O(1) lookup)
|
||||
```
|
||||
|
||||
Parameter overlays from metadata are merged into `EffectiveCommandRegistry`
|
||||
@@ -73,11 +82,37 @@ Build-time gates and the snapshot serializer consume that source-resolved typed
|
||||
registry/index. Runtime projections and delivery gates consume the typed
|
||||
registry/index returned by the production snapshot loader. Neither path may
|
||||
reopen annotations, merge source records, or use a previous Catalog or other
|
||||
generated JSON as a source. `schema_catalog.json` is output-only in the
|
||||
generated JSON as a source. `schema_catalog/` (catalog.json + per-product
|
||||
tools/<product>.json shards) is output-only in the
|
||||
generation graph. The production loader decoding the embedded published
|
||||
snapshot is a delivery boundary, not source resolution; it must never create or
|
||||
repair a Cobra command, flag, registry entry, or later Catalog generation.
|
||||
|
||||
### Generation vs consumption separation
|
||||
|
||||
The Schema system has two physically separated processes:
|
||||
|
||||
**Generation** (build-time, slow, reviewed, one-way):
|
||||
- Entry point: `internal/cli/gen.go` (all `//go:generate` pragmas isolated here,
|
||||
not in business code).
|
||||
- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog`
|
||||
(standalone Go mains).
|
||||
- Inputs: 6 authored sources (registry + hints metadata + hints selection +
|
||||
MCP metadata + parameter bindings + cobra tree).
|
||||
- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/`.
|
||||
- Refresh MCP metadata: `make fetch-mcp-metadata` (iterates 26 MCP server
|
||||
endpoints, merges with previous data for cross-server interface_ref).
|
||||
- Gates: `make generate-schema` (byte guards on inputs), `check-generated-drift.sh`,
|
||||
`check-command-surface.sh` (catalog structure).
|
||||
|
||||
**Consumption** (runtime, fast, read-only, unified API):
|
||||
- Entry point: `ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}`
|
||||
in `internal/cli/command_meta.go`.
|
||||
- Backed by embedded catalog (`embeddedSchemaCatalog()`, sync.Once, O(1) map).
|
||||
- Consumers: `--help` (Safety annotation via `RenderSafetyAnnotation`),
|
||||
`dws schema`, agent selection, future skill generation.
|
||||
- `SafetyForCLIPath` delegates to `ResolveMeta` (backward compatible).
|
||||
|
||||
This split is architecturally isomorphic to Lark's typed metadata registry,
|
||||
navigation catalog, and schema renderer. DWS intentionally preserves its
|
||||
existing flat JSON wire contract for compatibility; do not treat architectural
|
||||
@@ -117,7 +152,8 @@ When adding or changing an Agent-visible command, review all relevant inputs:
|
||||
must never materialize, infer, or override registry identity.
|
||||
- Flag-to-interface property mappings and required/default semantics.
|
||||
- Generated files under `internal/cli/schema_agent_metadata/` and
|
||||
`internal/cli/schema_catalog.json` after running generation.
|
||||
`internal/cli/schema_catalog/` (catalog.json + tools/<product>.json) after
|
||||
running generation.
|
||||
|
||||
Run the reverse-completeness tests whenever the Cobra tree changes. A command
|
||||
that works through `dws <path>` but cannot be found through the matching
|
||||
@@ -177,7 +213,7 @@ For every curated tool:
|
||||
2. Edit `selection/<product>.json` for selection prose (`reviewed: true`,
|
||||
`review_reason`, `source_refs`).
|
||||
3. Run `make generate-schema`. Do not hand-edit generated
|
||||
`schema_agent_metadata/` or `schema_catalog.json`.
|
||||
`schema_agent_metadata/` or `schema_catalog/`.
|
||||
|
||||
### Pull live MCP descriptions (personal token)
|
||||
|
||||
|
||||
+50
-8
@@ -6,18 +6,59 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.55-beta.2] - 2026-07-23
|
||||
### Fixed
|
||||
|
||||
This beta validates Wukong capability parity across Chat, Contacts, documents,
|
||||
Drive, Markdown, and Todos, together with faster guarded releases and legacy
|
||||
authentication migration.
|
||||
- **Name→ID resolution kept external contacts** — the shared contact resolver (`chat +dm`, `+broadcast`, …) no longer drops `search_contact_by_key_word` rows that carry only an `openDingTalkId` (external / cross-org contacts have an empty `userId`), so those people are found instead of reported missing or collapsed into a wrong single match; the display name also falls back through `nick`/`showName`/`flowerName`/`staffName`/`userName`.
|
||||
- **`chat +messages-resource-url` flag aliases** — the media download-URL shortcut now accepts `--msg-id` / `--open-message-id` as aliases for `--message-id` (matching the `openMessageId`/`msgId` output field), so agents chaining from a message list no longer hit "unknown flag".
|
||||
|
||||
## [1.0.55-beta.5] - 2026-07-28
|
||||
|
||||
This beta validates expanded personal event consumption, complete Agent-visible
|
||||
Runtime Schema coverage for all 210 built-in shortcuts, Agent host
|
||||
observability, and hardened document, Drive, approval, and Todo command
|
||||
contracts on top of the `v1.0.55-beta.4` baseline.
|
||||
|
||||
### Added
|
||||
|
||||
- **Chat editing and group management** — adds `chat message edit`, conversation-category lookups through `chat category list-by-conv` and `chat category batch-info`, and the confirmed, irreversible `chat group upgrade-to-external` flow.
|
||||
- **Contact maintenance commands** — adds `contact user update`, `contact user update-self`, `contact dept create`, `contact dept update`, and `contact account update`, all with guarded write behavior.
|
||||
- **Markdown file workflows** — adds the `markdown` product with `fetch`, `create`, `overwrite`, and `patch` commands for Drive-native `.md` files, including explicit routing, dry-run previews, and confirmation for destructive writes.
|
||||
- **Todo labels** — adds `todo tag add`, `delete`, `update`, `list`, and `create`.
|
||||
- **Expanded personal event consumption** (#790) — adds eight IM personal event keys, supports subscribing to and consuming multiple event keys in one `dws event consume` invocation, and adds targeted local-consumer shutdown when a subscription is stopped so other consumers can continue on the shared event bus.
|
||||
- **Shortcut Runtime Schema delivery** (#802) — publishes all 210 public built-in shortcuts as reviewed Agent-visible leaf tools across 16 product groups, with stable canonical identities, executable `+shortcut` CLI paths, parameter and cross-parameter constraints, selection guidance, interface metadata, and runtime-aligned safety/confirmation semantics. `dws shortcut list` remains the lightweight batch-discovery view, while leaf Schema now carries the complete Agent contract; declared string-slice defaults are also preserved consistently in Cobra and Schema.
|
||||
- **Agent host observability** (#804) — accepts an optional, validated `DWS_AGENT_HOST` label and sends it as `x-dws-agent-host` for logs and BI only; invalid values fail before CLI network activity, and the label never participates in authentication or routing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Command contract edge cases** (#803) — approval revocation and document-version rollback now honor `--dry-run` before confirmation or remote preflight; `drive rename` removes only a suffix matching the node's current extension to avoid duplicate extensions while `doc rename` preserves the caller's exact display name; `doc info` keeps its stable MCP contract while `drive info` restores Drive-only metadata such as a non-null `fileSize`; and Todo reminder writes now reject invalid rule JSON while Help, Schema, and Skills distinguish a due time from an independently unreadable reminder rule.
|
||||
|
||||
## [1.0.55-beta.4] - 2026-07-27
|
||||
|
||||
This beta validates the shortcut projection fixes for group bots, bot search,
|
||||
and mail threads, together with hardened release delivery to Gitee and npm on
|
||||
top of the `v1.0.55-beta.3` baseline.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut projection fixes** (#795) — `chat +chat-bots` no longer projects a non-empty `list_group_bots` response to an empty list, `+bot-find` recognizes the `search_bots` response shape (`result.bots` entries with `botOpenDingTalkId`), and mail thread listings keep `lastUpdated` when the backend returns `lastModifiedDateTime`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Hardened release delivery** — the Gitee mirror workflow can synchronize a specific release's assets on demand, release lookup tolerates Gitee's HTTP 200 null-body response for missing releases, npm dist-tag verification waits through slow registry CDN propagation with incremental backoff, and beta/stable release operations are role-enforced (#791).
|
||||
|
||||
## [1.0.55-beta.3] - 2026-07-24
|
||||
|
||||
This beta validates the HR Brain command surface, smoother guarded release
|
||||
automation, and deterministic Markdown test coverage on top of the
|
||||
`v1.0.55-beta.2` baseline.
|
||||
|
||||
### Added
|
||||
|
||||
- **HR Brain (`dws hrbrain`) command surface** — adds 11 commands across three groups: `talent-pool list/detail/employees` for talent pool browsing, `profile metadata/query/labels/career/performance` for employee profile data, and `search employees/employees-structured/fields` for basic and advanced (rule-based) people search. Ships with bundled mono/multi Skill guidance (`dingtalk-hrbrain`, `cli_version: ">=1.0.54"`); `search employees-structured` validates `--origin-json` as a JSON object and `--fields` as a JSON array before dispatch.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Smoother guarded releases** — publishes verified stable and beta Homebrew Formula updates directly from the release workflow, retries transient tag-ref visibility failures, lets an exact same-run retry reuse its sealed tag, and allows machine-verified rebuild recovery without a separate approval wait.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Deterministic Markdown coverage** — replaces timing-dependent temporary-file deletion tests with synchronized file-stat failures so release admission no longer flakes on scheduler timing.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -47,6 +88,7 @@ records, encrypted messages, and media-download ID aliases.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut projection silent-empty returns** (#783) — a batch of read shortcuts returned an empty list with exit 0 and no error envelope even when the underlying MCP tool returned data, so agents misread "no data". The projection resolvers now probe the real container keys (`processCodeList`, `values`, `wikiSpaces`, `itemList`, `groupList`, `recentItems`, `emailAccounts`, `deptUserList`, `labelUserList`, `roles`, `report_list`, and the grouped `get_org_labels` `labels[]`), unwrap items nested under a VO wrapper (`shiftVO` / `entityVO` / `userInfo`), and `todo +created-todos` uses the shared pager (`pageSize=20`) because the backend silently returns an empty page for `pageSize>20`. Affects contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart shortcuts, each with a guard test asserting the real response shape projects non-empty. `scripts/shortcut_real_result.py` also gains an upper-vs-lower layer comparison so an exit-0 empty projection over a non-empty backend is scored as `projection-data-loss` in the real read-audit path rather than `real-ok`.
|
||||
- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag".
|
||||
|
||||
## [1.0.54] - 2026-07-21
|
||||
|
||||
+46
-8
@@ -27,7 +27,9 @@ notes that are intentionally kept out of the repository root.
|
||||
|
||||
## Local Checks
|
||||
|
||||
Run the verification commands that match the surface you changed before you hand work back.
|
||||
Run the verification commands that match the surface you changed before you
|
||||
hand work back. The goal is useful, change-specific evidence, not a second
|
||||
local execution of every CI job.
|
||||
|
||||
Common repository checks already used here include:
|
||||
|
||||
@@ -44,19 +46,55 @@ make lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Select the PR risk tier before choosing checks:
|
||||
|
||||
| Tier | Typical scope | Developer evidence | CI expansion |
|
||||
|---|---|---|---|
|
||||
| Documentation-only | Prose and documentation assets with no executable, generated, workflow, packaging, or interface change | Links/content/rendering plus repository asset checks | Lightweight documentation validation; all nine named contexts still report |
|
||||
| Standard | Ordinary implementation work with a stable package graph | Focused unit/integration tests and observable behavior for the changed path | Race tests for changed packages and their reverse dependencies, scope-matched HEAD/base coverage, and representative Darwin/Windows compilation |
|
||||
| High-risk | Workflow/policy, package graph, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure change | Relevant full or domain suite plus focused behavior evidence | Complete race suite, native platform tests, and all affected domain gates; protected `main` uses this tier |
|
||||
|
||||
Classification fails closed: an incomplete diff, package add/remove/rename, or
|
||||
uncertain dependency graph selects the high-risk suite. Native changed-code
|
||||
coverage is additionally selected for platform-sensitive code.
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
1. Keep implementation and tests in sync.
|
||||
2. Run `./scripts/dev/ci-local.sh`.
|
||||
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. CI also runs `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>` against both the target branch and latest stable release.
|
||||
4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change.
|
||||
5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first).
|
||||
6. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
7. Include verification evidence in your PR description.
|
||||
2. Select the documentation-only, standard, or high-risk tier and run the
|
||||
smallest checks that prove the change. Use `./scripts/dev/ci-local.sh` when
|
||||
a complete local pass is warranted; it is not required for every ordinary
|
||||
PR.
|
||||
3. Include both the commands/results and user-visible or contract-level
|
||||
behavior evidence in the PR description.
|
||||
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
|
||||
paths/flags change. CI also runs
|
||||
`./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`
|
||||
against both the target branch and latest stable release.
|
||||
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
|
||||
change.
|
||||
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
|
||||
installer surfaces change (run `make package` first).
|
||||
7. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
|
||||
## Submission Flow
|
||||
|
||||
1. Make the smallest atomic change that satisfies the task.
|
||||
2. Keep doc edits factual and limited to implemented behavior.
|
||||
3. Run the relevant verification commands.
|
||||
4. Report the validation results with the handoff.
|
||||
4. Report the validation results and risk tier with the handoff.
|
||||
5. Open a ready PR against `main`. Base-owned automation assigns one eligible
|
||||
peer reviewer, balancing the current open-review load and excluding the
|
||||
author. A new head push re-enters the same routing flow when the latest
|
||||
revision still needs review.
|
||||
6. After the latest push has one peer approval and the exact nine required
|
||||
contexts are current and green, auto-merge completes the PR. If `main`
|
||||
advances first, strict status checks revalidate the branch; no separate
|
||||
routine merge request is needed.
|
||||
|
||||
Contributors without repository write access stop at the PR flow. Explicitly
|
||||
authorized collaborators with `write`, `maintain`, or `admin` access can use
|
||||
[Actions → Release](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml)
|
||||
to publish beta releases without manual approval. The same internal roles may
|
||||
start a stable release, but a different repository administrator must approve
|
||||
the `release-stable` Environment deployment before publication continues.
|
||||
|
||||
@@ -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.55-beta.2"
|
||||
version "1.0.55-beta.5"
|
||||
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.55-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "fd23353c473419ce8cfaf316e7afff757cc4606291f4ad41a62c6f5625f45c8f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-arm64.tar.gz"
|
||||
sha256 "ac826a88062c6b839808eb28312dc026cc76bfc298cdedec34c468b87d5c22d6"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "0b429950a6449736338b504386d8009a4f2cdb0668f64f72a61cd503a25df193"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-amd64.tar.gz"
|
||||
sha256 "361faab5cae2299fa2d8d305fd12dde7a992d408cfd5b1ad54ecd99073cd0a45"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "c213a22d6187a8f68596b9f25d6277f5fd58cea5120f5b7caeef011fb41f1104"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-arm64.tar.gz"
|
||||
sha256 "a8d0d39f037d6cb73aae3e4f7432fc58d3430971ce25d74c3c78580377d4dade"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "4e041ba7b1d8038fa34deba3a3f78af8377f4361131d8bfdb1f1913e5f9454dc"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-amd64.tar.gz"
|
||||
sha256 "4a0fc75b9b81f2d8670b9f71bace510515963c4285e9ec62c21aacb3c645c939"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.2/dws-skills.zip"
|
||||
sha256 "1cec445c73b9ff569ca002b6548f4ee8383712447bc4cebd7701c6065774524a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-skills.zip"
|
||||
sha256 "7120a49c8bac90ea4c77668115b11edeca843e7fef0cc0ff2de538635a9884e1"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -8,7 +8,7 @@ POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -39,8 +39,8 @@ help:
|
||||
@printf " make package - Build all release artifacts locally\n"
|
||||
@printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n"
|
||||
@printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n"
|
||||
@printf " make release-pre VERSION=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish prerelease\n"
|
||||
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish stable\n"
|
||||
@printf " make release-pre VERSION=vX.Y.Z-beta.N - Validate prerelease; publish official releases from Actions\n"
|
||||
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Validate stable; publish official releases from Actions\n"
|
||||
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
|
||||
|
||||
build:
|
||||
@@ -129,16 +129,16 @@ test-schema-agent-examples:
|
||||
|
||||
generate-schema:
|
||||
@set -e; \
|
||||
registry_guard=$$(mktemp); \
|
||||
registry_guard=$$(mktemp -d); \
|
||||
metadata_guard=$$(mktemp -d); \
|
||||
selection_guard=$$(mktemp -d); \
|
||||
trap 'rm -rf "$$registry_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
|
||||
cp internal/cli/schema_command_registry.json "$$registry_guard"; \
|
||||
cp -R internal/cli/schema_command_registry/ "$$registry_guard/"; \
|
||||
cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \
|
||||
cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \
|
||||
$(GO) generate ./internal/cli; \
|
||||
cmp -s internal/cli/schema_command_registry.json "$$registry_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry.json' >&2; \
|
||||
diff -qr internal/cli/schema_command_registry "$$registry_guard" >/dev/null || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry/' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \
|
||||
@@ -153,14 +153,18 @@ generate-schema:
|
||||
generate-schema-agent-metadata:
|
||||
$(GO) run ./internal/generator/cmd_schema_agent_metadata \
|
||||
-root . \
|
||||
-registry internal/cli/schema_command_registry.json \
|
||||
-registry internal/cli/schema_command_registry \
|
||||
-output-dir internal/cli/schema_agent_metadata \
|
||||
-audit-output internal/cli/schema_agent_metadata_audit.json
|
||||
|
||||
generate-schema-catalog:
|
||||
$(GO) run -a ./internal/generator/cmd_schema_catalog \
|
||||
-root . \
|
||||
-output internal/cli/schema_catalog.json
|
||||
-output internal/cli/schema_catalog
|
||||
|
||||
fetch-mcp-metadata:
|
||||
@printf ' %sRefreshing MCP metadata from live server%s\n' "$(COLOR_RUN)" "$(COLOR_RESET)"
|
||||
@./scripts/dev/fetch_mcp_metadata.sh
|
||||
|
||||
package:
|
||||
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; VERSION="$${version#v}" ./scripts/dev/build-all.sh
|
||||
|
||||
@@ -474,7 +474,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group.
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, and group title/disband lifecycle events.
|
||||
|
||||
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
|
||||
|
||||
@@ -503,6 +503,25 @@ dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId
|
||||
# Listen for messages in a specified group
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# Listen for all one-to-one or all group messages
|
||||
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
|
||||
dws event consume user_im_message_receive_group_all --flatten -f ndjson
|
||||
|
||||
# Listen for a specified group's title changes, member changes, or disband event
|
||||
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# Listen for multiple events for the same user in one process
|
||||
dws event consume \
|
||||
user_im_message_receive_o2o \
|
||||
user_im_message_read_o2o \
|
||||
user_im_message_recall_o2o \
|
||||
--user <userId> \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# Inspect local consumers and cancel a subscription
|
||||
dws event status
|
||||
dws event stop <subscribe_id>
|
||||
@@ -514,6 +533,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
|
||||
|---------|---------|
|
||||
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
|
||||
| Shared connection | Consumers for the same user share one local bus and cloud connection |
|
||||
| Multi-event process | One consume process can listen for compatible events for the same target while retaining one subscription per event |
|
||||
| Subscription isolation | Normal consumers match both event type and `subscribe_id` |
|
||||
| Agent-friendly output | Stream events are written to stdout as NDJSON; status and diagnostics use stderr |
|
||||
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
|
||||
|
||||
+21
-1
@@ -468,7 +468,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
<details>
|
||||
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应,以及群标题变更和群解散事件。
|
||||
|
||||
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
|
||||
|
||||
@@ -497,6 +497,25 @@ dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId
|
||||
# 监听指定群的消息
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# 监听所有单聊或所有群消息
|
||||
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
|
||||
dws event consume user_im_message_receive_group_all --flatten -f ndjson
|
||||
|
||||
# 监听指定群标题变更、成员进退群或群解散
|
||||
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# 一个进程监听同一用户的多个事件
|
||||
dws event consume \
|
||||
user_im_message_receive_o2o \
|
||||
user_im_message_read_o2o \
|
||||
user_im_message_recall_o2o \
|
||||
--user <userId> \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 查看本地 consume,并取消指定订阅
|
||||
dws event status
|
||||
dws event stop <subscribe_id>
|
||||
@@ -508,6 +527,7 @@ dws event stop <subscribe_id>
|
||||
|------|------|
|
||||
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
|
||||
| 共享连接 | 同一用户的多个 consumer 共享本地 bus 和云端长连接 |
|
||||
| 多事件进程 | 同一目标的兼容事件可由一个 consume 进程监听,每个事件仍有独立订阅 |
|
||||
| 订阅隔离 | 正常 consumer 同时按事件类型和 `subscribe_id` 匹配 |
|
||||
| Agent 友好输出 | Stream 事件写入 stdout,连接状态和诊断信息写入 stderr |
|
||||
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
// Command fetch_mcp_metadata pulls tools/list from ALL live MCP server endpoints
|
||||
// and writes a refreshed schema_mcp_metadata.json. This is DWS's equivalent of
|
||||
// lark-cli's scripts/fetch_meta.py.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// dws auth login # ensure valid auth
|
||||
// make fetch-mcp-metadata # runs this tool
|
||||
//
|
||||
// The tool loads auth from the DWS keychain, iterates all 26 static server
|
||||
// endpoints (internal/syncdata.StaticServers), calls tools/list on each,
|
||||
// merges results, and writes schema_mcp_metadata.json.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// toolLister is the tools/list capability consumed by run; production code
|
||||
// uses transport.Client, tests inject fakes.
|
||||
type toolLister interface {
|
||||
ListTools(ctx context.Context, endpoint string) (transport.ToolsListResult, error)
|
||||
}
|
||||
|
||||
// Injection points so run() is fully testable without network/keychain/exit.
|
||||
var (
|
||||
osExit = os.Exit
|
||||
getenv = os.Getenv
|
||||
loadTokenData = auth.LoadTokenDataKeychain
|
||||
staticServers = syncdata.StaticServers
|
||||
registrySource = cli.EmbeddedCommandRegistryMergedJSON
|
||||
listToolsTimeout = 30 * time.Second
|
||||
gitHeadPath = ".git/HEAD"
|
||||
newToolLister = func(token string) toolLister {
|
||||
return transport.NewClient(&http.Client{Timeout: 60 * time.Second}).WithAuth(token, nil)
|
||||
}
|
||||
)
|
||||
|
||||
func main() {
|
||||
osExit(run(os.Args[1:], os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("fetch_mcp_metadata", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
output := flags.String("output", "internal/cli/schema_mcp_metadata.json", "output file path")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
|
||||
token := resolveToken(stderr)
|
||||
if token == "" {
|
||||
fmt.Fprintln(stderr, "fetch_mcp_metadata: no auth token. Run 'dws auth login' first.")
|
||||
return 1
|
||||
}
|
||||
|
||||
client := newToolLister(token)
|
||||
|
||||
// Iterate ALL static server endpoints (26 servers covering all products).
|
||||
servers := staticServers()
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: querying %d server endpoints\n", len(servers))
|
||||
|
||||
// Load CLI registry to build tool_name → interface_ref mapping.
|
||||
registryMap := loadRegistryInterfaceRefs(stderr)
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap))
|
||||
|
||||
// Load the previous schema_mcp_metadata.json to preserve hand-curated
|
||||
// cross-server interface_ref mappings that automated matching can't derive.
|
||||
prevData, prevErr := os.ReadFile(*output)
|
||||
prevTools := map[string]map[string]any{}
|
||||
if prevErr == nil {
|
||||
var prev struct {
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
if json.Unmarshal(prevData, &prev) == nil {
|
||||
prevTools = prev.Tools
|
||||
}
|
||||
}
|
||||
|
||||
// Start from previous data (preserves cross-server refs), then overwrite
|
||||
// with fresh MCP data where available.
|
||||
allTools := make(map[string]map[string]any)
|
||||
for k, v := range prevTools {
|
||||
allTools[k] = v
|
||||
}
|
||||
|
||||
// Reviewed cross-server interface_refs live only in the previous snapshot
|
||||
// (the registry stores canonical paths, not MCP identities). Build a
|
||||
// live-key → canonicals index so those tools get refreshed instead of
|
||||
// being skipped and frozen at the previous snapshot forever.
|
||||
crossRefs := buildCrossServerRefs(prevTools, registryMap)
|
||||
if len(crossRefs) > 0 {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: cross-server ref index: %d live keys\n", len(crossRefs))
|
||||
}
|
||||
// Canonicals with a reviewed cross-server identity must only be fed by
|
||||
// that identity; a same-named tool on another server is a coincidence,
|
||||
// not a data source.
|
||||
crossOwned := map[string]bool{}
|
||||
for _, canonicals := range crossRefs {
|
||||
for _, canonical := range canonicals {
|
||||
crossOwned[canonical] = true
|
||||
}
|
||||
}
|
||||
totalRaw := 0
|
||||
failedServices := []string{}
|
||||
|
||||
for _, srv := range servers {
|
||||
endpoint := strings.TrimSpace(srv.Endpoint)
|
||||
if endpoint == "" {
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), listToolsTimeout)
|
||||
result, err := client.ListTools(ctx, endpoint)
|
||||
cancel()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, " [skip] %s: %v\n", srv.ID, err)
|
||||
failedServices = append(failedServices, srv.ID)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(stderr, " [ok] %s: %d tools\n", srv.ID, len(result.Tools))
|
||||
totalRaw += len(result.Tools)
|
||||
for _, tool := range result.Tools {
|
||||
name := strings.TrimSpace(tool.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
// Direct match: CLI canonical equals server-prefixed tool name
|
||||
// (e.g., "doc.copy_document"). Cross-owned canonicals are skipped
|
||||
// here — their reviewed identity feeds them below.
|
||||
canonicalKey := srv.ID + "." + name
|
||||
if ref, hasRef := registryMap[canonicalKey]; hasRef && !crossOwned[canonicalKey] {
|
||||
mergeLiveMCPTool(allTools, canonicalKey, tool, ref)
|
||||
}
|
||||
// Cross-server match: registry canonicals whose reviewed
|
||||
// interface_ref points at this live tool (one live tool may feed
|
||||
// several canonicals, e.g. advperm_enable/disable → set_advanced_permission).
|
||||
for _, canonical := range crossRefs[canonicalKey] {
|
||||
mergeLiveMCPTool(allTools, canonical, tool, registryMap[canonical])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matched := 0
|
||||
for _, t := range allTools {
|
||||
if _, ok := t["interface_ref"]; ok {
|
||||
matched++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: MCP matched=%d, with interface_ref=%d\n", len(allTools), matched)
|
||||
|
||||
// Fill gaps: for registry canonicals not covered by MCP tools/list OR
|
||||
// previous data, add stub entries (interface_ref only).
|
||||
stubs := 0
|
||||
for canonicalKey, ref := range registryMap {
|
||||
if _, exists := allTools[canonicalKey]; exists {
|
||||
continue
|
||||
}
|
||||
allTools[canonicalKey] = map[string]any{
|
||||
"interface_ref": ref,
|
||||
}
|
||||
stubs++
|
||||
}
|
||||
if stubs > 0 {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: added %d registry stubs (no MCP data, interface_ref only)\n", stubs)
|
||||
}
|
||||
|
||||
// Compute coverage fields required by check-schema-catalog.sh. Failed
|
||||
// services must be reported honestly so policy can spot snapshot gaps.
|
||||
if len(failedServices) > 0 {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: %d/%d services unreachable: %s\n",
|
||||
len(failedServices), len(servers), strings.Join(failedServices, ", "))
|
||||
}
|
||||
|
||||
metadata := map[string]any{
|
||||
"version": 1,
|
||||
"source": "mcp-tools-list+cli-registry",
|
||||
"coverage": buildCoverage(len(servers), failedServices, totalRaw, len(allTools), stubs),
|
||||
"tools": allTools,
|
||||
}
|
||||
|
||||
// source_revision: git commit hash (proves provenance).
|
||||
if rev, err := os.ReadFile(gitHeadPath); err == nil {
|
||||
metadata["source_revision"] = strings.TrimSpace(string(rev))
|
||||
}
|
||||
|
||||
if err := writeMetadata(*output, metadata); err != nil {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: wrote %d tools to %s\n", len(allTools), *output)
|
||||
return 0
|
||||
}
|
||||
|
||||
// resolveToken returns the access token from DWS_ACCESS_TOKEN or, as a
|
||||
// fallback, the DWS keychain.
|
||||
func resolveToken(stderr io.Writer) string {
|
||||
token := strings.TrimSpace(getenv("DWS_ACCESS_TOKEN"))
|
||||
if token != "" {
|
||||
return token
|
||||
}
|
||||
td, err := loadTokenData()
|
||||
if err != nil || td == nil || td.AccessToken == "" {
|
||||
return ""
|
||||
}
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: loaded token from keychain (%d chars)\n", len(td.AccessToken))
|
||||
return td.AccessToken
|
||||
}
|
||||
|
||||
// writeMetadata marshals the snapshot and writes it to the output path.
|
||||
func writeMetadata(path string, metadata map[string]any) error {
|
||||
data, err := json.MarshalIndent(metadata, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal failed: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("write %s failed: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCoverage reports snapshot coverage honestly: snapshot_services only
|
||||
// counts services whose tools/list succeeded, missing_services names the
|
||||
// failures, and matched_tools excludes registry stubs (entries carrying no
|
||||
// live MCP metadata) so a stub-heavy snapshot cannot claim full matching.
|
||||
func buildCoverage(sourceServices int, failedServices []string, sourceTools, surfaceTools, stubs int) map[string]any {
|
||||
missing := failedServices
|
||||
if missing == nil {
|
||||
missing = []string{}
|
||||
}
|
||||
return map[string]any{
|
||||
"surface_scope": "source_revision",
|
||||
"source_services": sourceServices,
|
||||
"snapshot_services": sourceServices - len(missing),
|
||||
"missing_services": missing,
|
||||
"source_tools": sourceTools,
|
||||
"surface_tools": surfaceTools,
|
||||
"matched_tools": surfaceTools - stubs,
|
||||
"aliased_tools": 0,
|
||||
"unmatched_tools": stubs,
|
||||
}
|
||||
}
|
||||
|
||||
// mergeLiveMCPTool replaces stale live-derived fields while retaining an
|
||||
// existing reviewed interface_ref. Some CLI canonicals intentionally route to
|
||||
// a differently named product/RPC, so the previous cross-server mapping must
|
||||
// survive even though title, description, and parameters are refreshed.
|
||||
func mergeLiveMCPTool(allTools map[string]map[string]any, canonicalKey string, tool transport.ToolDescriptor, fallbackRef map[string]string) {
|
||||
interfaceRef := any(fallbackRef)
|
||||
if previous := allTools[canonicalKey]; previous != nil {
|
||||
if reviewedRef, ok := previous["interface_ref"]; ok && reviewedRef != nil {
|
||||
interfaceRef = reviewedRef
|
||||
}
|
||||
}
|
||||
|
||||
entry := map[string]any{
|
||||
"title": tool.Title,
|
||||
"description": tool.Description,
|
||||
"interface_ref": interfaceRef,
|
||||
}
|
||||
if tool.InputSchema != nil {
|
||||
entry["parameters"] = extractParams(tool.InputSchema)
|
||||
}
|
||||
allTools[canonicalKey] = entry
|
||||
}
|
||||
|
||||
// buildCrossServerRefs indexes reviewed cross-server mappings from the
|
||||
// previous snapshot: for every registry canonical whose interface_ref names a
|
||||
// different MCP identity (product_id.rpc_name != canonical), the live key is
|
||||
// mapped back to that canonical. One live tool may serve several canonicals,
|
||||
// so values are slices, sorted for deterministic merge order.
|
||||
func buildCrossServerRefs(prevTools map[string]map[string]any, registryMap map[string]map[string]string) map[string][]string {
|
||||
index := map[string][]string{}
|
||||
for canonical, entry := range prevTools {
|
||||
if _, inRegistry := registryMap[canonical]; !inRegistry {
|
||||
continue
|
||||
}
|
||||
ref, ok := entry["interface_ref"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
productID, _ := ref["product_id"].(string)
|
||||
rpcName, _ := ref["rpc_name"].(string)
|
||||
if productID == "" || rpcName == "" {
|
||||
continue
|
||||
}
|
||||
liveKey := productID + "." + rpcName
|
||||
if liveKey == canonical {
|
||||
continue
|
||||
}
|
||||
index[liveKey] = append(index[liveKey], canonical)
|
||||
}
|
||||
for _, canonicals := range index {
|
||||
sort.Strings(canonicals)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// loadRegistryInterfaceRefs loads the reviewed split CommandRegistry through
|
||||
// the cli package's reassembly API and builds a canonical_path →
|
||||
// {product_id, rpc_name} mapping for interface_ref injection.
|
||||
func loadRegistryInterfaceRefs(stderr io.Writer) map[string]map[string]string {
|
||||
data, err := registrySource()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot load registry: %v\n", err)
|
||||
return map[string]map[string]string{}
|
||||
}
|
||||
var reg struct {
|
||||
Products []struct {
|
||||
ID string `json:"id"`
|
||||
Tools []struct {
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
} `json:"tools"`
|
||||
} `json:"products"`
|
||||
}
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
// 与读文件失败同等告警:静默返回空映射会让所有 live tool 被丢弃、
|
||||
// 产出 stub-only 快照且零提示(P1#1 的故障模式)。
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot parse registry: %v\n", err)
|
||||
return map[string]map[string]string{}
|
||||
}
|
||||
out := make(map[string]map[string]string)
|
||||
for _, prod := range reg.Products {
|
||||
for _, tool := range prod.Tools {
|
||||
cp := strings.TrimSpace(tool.CanonicalPath)
|
||||
if cp == "" || !strings.Contains(cp, ".") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(cp, ".", 2)
|
||||
out[cp] = map[string]string{
|
||||
"product_id": parts[0],
|
||||
"rpc_name": parts[1],
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extractParams converts a JSON Schema inputSchema (from MCP tools/list) into
|
||||
// the flat param-name → metadata map used by schema_mcp_metadata.json.
|
||||
func extractParams(inputSchema map[string]any) map[string]map[string]any {
|
||||
if inputSchema == nil {
|
||||
return nil
|
||||
}
|
||||
properties, ok := inputSchema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
requiredSet := map[string]bool{}
|
||||
if req, ok := inputSchema["required"].([]any); ok {
|
||||
for _, r := range req {
|
||||
if s, ok := r.(string); ok {
|
||||
requiredSet[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
params := make(map[string]map[string]any, len(properties))
|
||||
for name, raw := range properties {
|
||||
prop, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
meta := map[string]any{}
|
||||
if t, ok := prop["type"].(string); ok {
|
||||
meta["type"] = t
|
||||
}
|
||||
if d, ok := prop["description"].(string); ok {
|
||||
meta["description"] = d
|
||||
}
|
||||
if d, ok := prop["default"].(string); ok {
|
||||
meta["default"] = d
|
||||
}
|
||||
if e, ok := prop["enum"].([]any); ok {
|
||||
enums := make([]string, 0, len(e))
|
||||
for _, v := range e {
|
||||
if s, ok := v.(string); ok {
|
||||
enums = append(enums, s)
|
||||
}
|
||||
}
|
||||
if len(enums) > 0 {
|
||||
meta["enum"] = enums
|
||||
}
|
||||
}
|
||||
meta["required"] = requiredSet[name]
|
||||
params[name] = meta
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestLoadRegistryInterfaceRefsUsesSplitRegistry(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
refs := loadRegistryInterfaceRefs(&stderr)
|
||||
if len(refs) == 0 {
|
||||
t.Fatal("loadRegistryInterfaceRefs() returned no reviewed commands")
|
||||
}
|
||||
|
||||
got, ok := refs["calendar.list_calendars"]
|
||||
if !ok {
|
||||
t.Fatal("calendar.list_calendars missing from reassembled split registry")
|
||||
}
|
||||
if got["product_id"] != "calendar" || got["rpc_name"] != "list_calendars" {
|
||||
t.Fatalf("calendar.list_calendars ref = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCrossServerRefs(t *testing.T) {
|
||||
registryMap := map[string]map[string]string{
|
||||
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
|
||||
"aitable.advperm_disable": {"product_id": "aitable", "rpc_name": "advperm_disable"},
|
||||
"doc.copy_document": {"product_id": "doc", "rpc_name": "copy_document"},
|
||||
}
|
||||
prevTools := map[string]map[string]any{
|
||||
// Fan-out: two canonicals share one live tool; insertion order must
|
||||
// not affect the sorted result.
|
||||
"aitable.advperm_enable": {
|
||||
"interface_ref": map[string]any{"product_id": "aitable-helper", "rpc_name": "set_advanced_permission"},
|
||||
},
|
||||
"aitable.advperm_disable": {
|
||||
"interface_ref": map[string]any{"product_id": "aitable-helper", "rpc_name": "set_advanced_permission"},
|
||||
},
|
||||
// Identity ref (live key == canonical) needs no cross entry.
|
||||
"doc.copy_document": {
|
||||
"interface_ref": map[string]any{"product_id": "doc", "rpc_name": "copy_document"},
|
||||
},
|
||||
// Not in the registry: must be ignored.
|
||||
"ghost.tool": {
|
||||
"interface_ref": map[string]any{"product_id": "ghost-helper", "rpc_name": "haunt"},
|
||||
},
|
||||
}
|
||||
got := buildCrossServerRefs(prevTools, registryMap)
|
||||
want := map[string][]string{
|
||||
"aitable-helper.set_advanced_permission": {"aitable.advperm_disable", "aitable.advperm_enable"},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("index = %#v, want %#v", got, want)
|
||||
}
|
||||
for k, v := range want {
|
||||
if gv := got[k]; len(gv) != len(v) || gv[0] != v[0] || gv[1] != v[1] {
|
||||
t.Fatalf("index[%q] = %v, want %v", k, gv, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCrossServerRefsSkipsMalformedRefs(t *testing.T) {
|
||||
registryMap := map[string]map[string]string{
|
||||
"a.x": {"product_id": "a", "rpc_name": "x"},
|
||||
"a.y": {"product_id": "a", "rpc_name": "y"},
|
||||
"a.z": {"product_id": "a", "rpc_name": "z"},
|
||||
}
|
||||
prevTools := map[string]map[string]any{
|
||||
"a.x": {"interface_ref": "not-a-map"},
|
||||
"a.y": {"interface_ref": map[string]any{"product_id": "", "rpc_name": "r"}},
|
||||
"a.z": {"title": "no ref at all"},
|
||||
}
|
||||
if got := buildCrossServerRefs(prevTools, registryMap); len(got) != 0 {
|
||||
t.Fatalf("index = %#v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRefreshesCrossServerTools(t *testing.T) {
|
||||
registry := func() ([]byte, error) {
|
||||
return []byte(`{"version":1,"products":[{"id":"aitable","tools":[{"canonical_path":"aitable.advperm_enable"},{"canonical_path":"aitable.advperm_disable"}]}]}`), nil
|
||||
}
|
||||
servers := []syncdata.ServerInfo{{ID: "aitable-helper", Endpoint: "https://helper.example"}}
|
||||
lister := &fakeLister{
|
||||
results: map[string]transport.ToolsListResult{
|
||||
"https://helper.example": {Tools: []transport.ToolDescriptor{
|
||||
{Name: "set_advanced_permission", Title: "live title", Description: "live desc"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
stubDeps(t, "env-token", nil, servers, lister, registry)
|
||||
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
prev := `{"tools":{
|
||||
"aitable.advperm_enable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}},
|
||||
"aitable.advperm_disable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}}
|
||||
}}`
|
||||
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "cross-server ref index: 1 live keys") {
|
||||
t.Fatalf("stderr = %q, want cross-server index log", stderr.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot struct {
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, canonical := range []string{"aitable.advperm_enable", "aitable.advperm_disable"} {
|
||||
entry := snapshot.Tools[canonical]
|
||||
if entry["title"] != "live title" || entry["description"] != "live desc" {
|
||||
t.Fatalf("%s = %#v, want live refresh", canonical, entry)
|
||||
}
|
||||
ref := entry["interface_ref"].(map[string]any)
|
||||
if ref["product_id"] != "aitable-helper" || ref["rpc_name"] != "set_advanced_permission" {
|
||||
t.Fatalf("%s reviewed ref lost: %#v", canonical, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCrossOwnedCanonicalIgnoresNameCoincidence:canonical 拥有评审过的
|
||||
// 跨 server 身份时,另一 server 上恰好同名的工具不得直连覆盖其元数据——
|
||||
// 数据源只能是评审身份指向的 live 工具。
|
||||
func TestRunCrossOwnedCanonicalIgnoresNameCoincidence(t *testing.T) {
|
||||
registry := func() ([]byte, error) {
|
||||
return []byte(`{"version":1,"products":[{"id":"aitable","tools":[{"canonical_path":"aitable.advperm_enable"}]}]}`), nil
|
||||
}
|
||||
servers := []syncdata.ServerInfo{
|
||||
{ID: "aitable", Endpoint: "https://aitable.example"},
|
||||
{ID: "aitable-helper", Endpoint: "https://helper.example"},
|
||||
}
|
||||
lister := &fakeLister{
|
||||
results: map[string]transport.ToolsListResult{
|
||||
// 同名巧合:aitable server 上恰好也有 advperm_enable。
|
||||
"https://aitable.example": {Tools: []transport.ToolDescriptor{
|
||||
{Name: "advperm_enable", Title: "coincidence title", Description: "coincidence desc"},
|
||||
}},
|
||||
"https://helper.example": {Tools: []transport.ToolDescriptor{
|
||||
{Name: "set_advanced_permission", Title: "owner title", Description: "owner desc"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
stubDeps(t, "env-token", nil, servers, lister, registry)
|
||||
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
prev := `{"tools":{"aitable.advperm_enable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}}}}`
|
||||
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot struct {
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := snapshot.Tools["aitable.advperm_enable"]
|
||||
if entry["title"] != "owner title" || entry["description"] != "owner desc" {
|
||||
t.Fatalf("entry = %#v, want reviewed-identity source to win over name coincidence", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeLiveMCPToolRefreshesExistingMetadata(t *testing.T) {
|
||||
const canonical = "calendar.list_calendars"
|
||||
reviewedRef := map[string]any{
|
||||
"product_id": "calendar-helper",
|
||||
"rpc_name": "list_user_calendars",
|
||||
}
|
||||
allTools := map[string]map[string]any{
|
||||
canonical: {
|
||||
"title": "old title",
|
||||
"description": "old description",
|
||||
"interface_ref": reviewedRef,
|
||||
"parameters": map[string]any{
|
||||
"stale": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}
|
||||
live := transport.ToolDescriptor{
|
||||
Name: "list_calendars",
|
||||
Title: "new title",
|
||||
Description: "new description",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"cursor": map[string]any{
|
||||
"type": "string",
|
||||
"description": "next page cursor",
|
||||
},
|
||||
},
|
||||
"required": []any{"cursor"},
|
||||
},
|
||||
}
|
||||
fallbackRef := map[string]string{
|
||||
"product_id": "calendar",
|
||||
"rpc_name": "list_calendars",
|
||||
}
|
||||
|
||||
mergeLiveMCPTool(allTools, canonical, live, fallbackRef)
|
||||
|
||||
got := allTools[canonical]
|
||||
if got["title"] != "new title" || got["description"] != "new description" {
|
||||
t.Fatalf("live metadata was not refreshed: %#v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(got["interface_ref"], reviewedRef) {
|
||||
t.Fatalf("interface_ref = %#v, want reviewed mapping %#v", got["interface_ref"], reviewedRef)
|
||||
}
|
||||
params, ok := got["parameters"].(map[string]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("parameters type = %T, want refreshed parameter map", got["parameters"])
|
||||
}
|
||||
if _, stale := params["stale"]; stale {
|
||||
t.Fatalf("stale parameter survived refresh: %#v", params)
|
||||
}
|
||||
if cursor := params["cursor"]; cursor["type"] != "string" || cursor["description"] != "next page cursor" || cursor["required"] != true {
|
||||
t.Fatalf("cursor parameter = %#v", cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCoverageReportsFailedServices(t *testing.T) {
|
||||
got := buildCoverage(26, []string{"doc", "sheet"}, 800, 813, 40)
|
||||
if got["source_services"] != 26 {
|
||||
t.Fatalf("source_services = %v, want 26", got["source_services"])
|
||||
}
|
||||
if got["snapshot_services"] != 24 {
|
||||
t.Fatalf("snapshot_services = %v, want 24 (26 sources - 2 failures)", got["snapshot_services"])
|
||||
}
|
||||
if !reflect.DeepEqual(got["missing_services"], []string{"doc", "sheet"}) {
|
||||
t.Fatalf("missing_services = %#v, want failed service IDs", got["missing_services"])
|
||||
}
|
||||
// matched 必须剔除 stub 占位,unmatched 据实等于 stub 数。
|
||||
if got["matched_tools"] != 773 || got["unmatched_tools"] != 40 {
|
||||
t.Fatalf("matched/unmatched = %v/%v, want 773/40 (813 surface - 40 stubs)", got["matched_tools"], got["unmatched_tools"])
|
||||
}
|
||||
if got["source_tools"] != 800 || got["surface_tools"] != 813 {
|
||||
t.Fatalf("tool counts = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCoverageFullSnapshotHasNoMissingServices(t *testing.T) {
|
||||
got := buildCoverage(26, nil, 813, 813, 0)
|
||||
if got["snapshot_services"] != 26 {
|
||||
t.Fatalf("snapshot_services = %v, want 26", got["snapshot_services"])
|
||||
}
|
||||
if !reflect.DeepEqual(got["missing_services"], []string{}) {
|
||||
t.Fatalf("missing_services = %#v, want empty non-nil slice", got["missing_services"])
|
||||
}
|
||||
if got["matched_tools"] != 813 || got["unmatched_tools"] != 0 {
|
||||
t.Fatalf("matched/unmatched = %v/%v, want 813/0 for stub-free snapshot", got["matched_tools"], got["unmatched_tools"])
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLister returns canned tools/list results per endpoint.
|
||||
type fakeLister struct {
|
||||
results map[string]transport.ToolsListResult
|
||||
errs map[string]error
|
||||
}
|
||||
|
||||
func (f *fakeLister) ListTools(_ context.Context, endpoint string) (transport.ToolsListResult, error) {
|
||||
if err := f.errs[endpoint]; err != nil {
|
||||
return transport.ToolsListResult{}, err
|
||||
}
|
||||
return f.results[endpoint], nil
|
||||
}
|
||||
|
||||
// stubDeps swaps every injection point for the duration of one test.
|
||||
func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, error), servers []syncdata.ServerInfo, lister toolLister, registry func() ([]byte, error)) {
|
||||
t.Helper()
|
||||
origGetenv, origLoad, origServers, origNew, origRegistry := getenv, loadTokenData, staticServers, newToolLister, registrySource
|
||||
t.Cleanup(func() {
|
||||
getenv, loadTokenData, staticServers, newToolLister, registrySource = origGetenv, origLoad, origServers, origNew, origRegistry
|
||||
})
|
||||
getenv = func(key string) string {
|
||||
if key == "DWS_ACCESS_TOKEN" {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
loadTokenData = keychain
|
||||
staticServers = func() []syncdata.ServerInfo { return servers }
|
||||
newToolLister = func(string) toolLister { return lister }
|
||||
registrySource = registry
|
||||
}
|
||||
|
||||
func testRegistryJSON() ([]byte, error) {
|
||||
return []byte(`{"version":1,"products":[{"id":"doc","tools":[{"canonical_path":"doc.copy_document"},{"canonical_path":"doc.get_document"},{"canonical_path":"bad-entry"}]}]}`), nil
|
||||
}
|
||||
|
||||
func TestRunNoTokenFails(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return nil, errors.New("no keychain") }, nil, &fakeLister{}, testRegistryJSON)
|
||||
var stderr bytes.Buffer
|
||||
if code := run(nil, &stderr); code != 1 {
|
||||
t.Fatalf("run() = %d, want 1", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "no auth token") {
|
||||
t.Fatalf("stderr = %q, want no-auth-token hint", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInvalidFlagFails(t *testing.T) {
|
||||
stubDeps(t, "tok", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--nonexistent"}, &stderr); code != 2 {
|
||||
t.Fatalf("run() = %d, want 2", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTokenKeychainFallback(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) {
|
||||
return &auth.TokenData{AccessToken: "kc-token"}, nil
|
||||
}, nil, &fakeLister{}, testRegistryJSON)
|
||||
var stderr bytes.Buffer
|
||||
if got := resolveToken(&stderr); got != "kc-token" {
|
||||
t.Fatalf("resolveToken() = %q, want kc-token", got)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "loaded token from keychain") {
|
||||
t.Fatalf("stderr = %q, want keychain log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTokenEmptyKeychainToken(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return &auth.TokenData{}, nil }, nil, &fakeLister{}, testRegistryJSON)
|
||||
var stderr bytes.Buffer
|
||||
if got := resolveToken(&stderr); got != "" {
|
||||
t.Fatalf("resolveToken() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWritesSnapshotWithHonestCoverage(t *testing.T) {
|
||||
servers := []syncdata.ServerInfo{
|
||||
{ID: "doc", Endpoint: "https://doc.example"},
|
||||
{ID: "sheet", Endpoint: "https://sheet.example"},
|
||||
{ID: "blank", Endpoint: " "},
|
||||
}
|
||||
lister := &fakeLister{
|
||||
results: map[string]transport.ToolsListResult{
|
||||
"https://doc.example": {Tools: []transport.ToolDescriptor{
|
||||
{Name: "copy_document", Title: "复制文档", Description: "copy", InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"doc_id": map[string]any{"type": "string", "description": "文档 ID", "default": "d", "enum": []any{"a", "b", 3}},
|
||||
"bogus": "not-a-map",
|
||||
},
|
||||
"required": []any{"doc_id", 42},
|
||||
}},
|
||||
{Name: " "},
|
||||
{Name: "not_in_registry"},
|
||||
}},
|
||||
},
|
||||
errs: map[string]error{"https://sheet.example": errors.New("boom")},
|
||||
}
|
||||
stubDeps(t, "env-token", nil, servers, lister, testRegistryJSON)
|
||||
|
||||
dir := t.TempDir()
|
||||
output := filepath.Join(dir, "snapshot.json")
|
||||
prev := `{"tools":{"doc.get_document":{"interface_ref":{"product_id":"doc-helper","rpc_name":"fetch_document"}}}}`
|
||||
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot struct {
|
||||
Version int `json:"version"`
|
||||
Coverage map[string]any `json:"coverage"`
|
||||
Tools map[string]map[string]any
|
||||
}
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Version != 1 {
|
||||
t.Fatalf("version = %d", snapshot.Version)
|
||||
}
|
||||
if got := snapshot.Coverage["snapshot_services"].(float64); got != 2 {
|
||||
t.Fatalf("snapshot_services = %v, want 2 (3 servers - 1 failed; blank endpoint not counted as failed)", got)
|
||||
}
|
||||
if got := snapshot.Coverage["missing_services"].([]any); len(got) != 1 || got[0] != "sheet" {
|
||||
t.Fatalf("missing_services = %v, want [sheet]", got)
|
||||
}
|
||||
live := snapshot.Tools["doc.copy_document"]
|
||||
if live == nil || live["title"] != "复制文档" {
|
||||
t.Fatalf("doc.copy_document = %#v, want live metadata", live)
|
||||
}
|
||||
params := live["parameters"].(map[string]any)
|
||||
docID := params["doc_id"].(map[string]any)
|
||||
if docID["type"] != "string" || docID["required"] != true || docID["default"] != "d" {
|
||||
t.Fatalf("doc_id = %#v", docID)
|
||||
}
|
||||
if enum := docID["enum"].([]any); len(enum) != 2 {
|
||||
t.Fatalf("enum = %v, want the 2 string members only", enum)
|
||||
}
|
||||
if _, ok := params["bogus"]; ok {
|
||||
t.Fatal("non-map property should be skipped")
|
||||
}
|
||||
prevRef := snapshot.Tools["doc.get_document"]["interface_ref"].(map[string]any)
|
||||
if prevRef["product_id"] != "doc-helper" {
|
||||
t.Fatalf("previous reviewed ref lost: %#v", prevRef)
|
||||
}
|
||||
if _, ok := snapshot.Tools["not_in_registry"]; ok {
|
||||
t.Fatal("tools outside the registry must be dropped")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "services unreachable: sheet") {
|
||||
t.Fatalf("stderr = %q, want unreachable log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunIgnoresCorruptPreviousSnapshot(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
if err := os.WriteFile(output, []byte("{corrupt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRegistryLoadFailureStillWritesStublessSnapshot(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return nil, errors.New("no registry") })
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "cannot load registry") {
|
||||
t.Fatalf("stderr = %q, want registry warning", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnparsableRegistryYieldsNoRefs(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return []byte("{bad"), nil })
|
||||
if refs := loadRegistryInterfaceRefs(&stderr); len(refs) != 0 {
|
||||
t.Fatalf("refs = %v, want empty for unparsable registry", refs)
|
||||
}
|
||||
// 解析失败必须有告警,不得静默产出空映射。
|
||||
if !strings.Contains(stderr.String(), "cannot parse registry") {
|
||||
t.Fatalf("stderr = %q, want parse warning", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWriteFailure(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
var stderr bytes.Buffer
|
||||
badPath := filepath.Join(t.TempDir(), "missing-dir", "snapshot.json")
|
||||
if code := run([]string{"--output", badPath}, &stderr); code != 1 {
|
||||
t.Fatalf("run() = %d, want 1 on write failure", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMetadataMarshalFailure(t *testing.T) {
|
||||
err := writeMetadata(filepath.Join(t.TempDir(), "out.json"), map[string]any{"bad": math.NaN()})
|
||||
if err == nil || !strings.Contains(err.Error(), "marshal failed") {
|
||||
t.Fatalf("err = %v, want marshal failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainDelegatesToRun(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
origExit, origArgs := osExit, os.Args
|
||||
t.Cleanup(func() { osExit, os.Args = origExit, origArgs })
|
||||
exitCode := -1
|
||||
osExit = func(code int) { exitCode = code }
|
||||
os.Args = []string{"fetch_mcp_metadata", "--output", filepath.Join(t.TempDir(), "snapshot.json")}
|
||||
main()
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("main() exited with %d, want 0", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractParamsNilAndNonObjectSchemas(t *testing.T) {
|
||||
if got := extractParams(nil); got != nil {
|
||||
t.Fatalf("extractParams(nil) = %v, want nil", got)
|
||||
}
|
||||
if got := extractParams(map[string]any{"type": "object"}); got != nil {
|
||||
t.Fatalf("extractParams(no properties) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewToolListerBuildsAuthedClient(t *testing.T) {
|
||||
if lister := newToolLister("tok"); lister == nil {
|
||||
t.Fatal("newToolLister returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecordsSourceRevision(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
dir := t.TempDir()
|
||||
head := filepath.Join(dir, "HEAD")
|
||||
if err := os.WriteFile(head, []byte("ref: refs/heads/feature\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origHead := gitHeadPath
|
||||
t.Cleanup(func() { gitHeadPath = origHead })
|
||||
gitHeadPath = head
|
||||
|
||||
output := filepath.Join(dir, "snapshot.json")
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot struct {
|
||||
SourceRevision string `json:"source_revision"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.SourceRevision != "ref: refs/heads/feature" {
|
||||
t.Fatalf("source_revision = %q", snapshot.SourceRevision)
|
||||
}
|
||||
}
|
||||
+20
-7
@@ -52,7 +52,13 @@ run.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
PR["Pull request"] --> CA["CI"]
|
||||
PR["Pull request"] --> CLASSIFY["Fail-closed risk classification"]
|
||||
CLASSIFY --> DOCS["Documentation-only<br/>asset/content validation"]
|
||||
CLASSIFY --> STANDARD["Standard<br/>affected + reverse-dependent race<br/>scope-matched HEAD/base coverage"]
|
||||
CLASSIFY --> HIGH["High-risk / main<br/>full race + native tests"]
|
||||
DOCS --> CA["CI"]
|
||||
STANDARD --> CA
|
||||
HIGH --> CA
|
||||
subgraph CA_CHECKS["Nine required contexts"]
|
||||
L["Lint"]
|
||||
T["Test"]
|
||||
@@ -72,9 +78,16 @@ flowchart TB
|
||||
PLATFORM --> RELEASE
|
||||
```
|
||||
|
||||
Complete Multi-profile E2E and the ordinary full native-platform matrix are
|
||||
downstream of PR admission. PRs still run primary-environment assurance and
|
||||
fast cross-platform compilation; auth, keychain, OS-specific, installer, and
|
||||
release changes additionally select native platform tests before merge. See
|
||||
[`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact context and ruleset
|
||||
contract.
|
||||
All nine named contexts are produced for every tier. Domain-specific helpers
|
||||
run when their owned surface is affected; otherwise the corresponding context
|
||||
records an explicit unaffected success. Standard code changes still receive
|
||||
representative Darwin/Windows compilation. High-risk PRs and protected `main`
|
||||
run the complete race and native test suites, while platform-sensitive diffs
|
||||
also receive native changed-code coverage.
|
||||
|
||||
Review orchestration is also base-owned: it requests one eligible peer without
|
||||
executing PR code, re-routes an updated head when needed, and auto-merge
|
||||
completes only after the latest push has peer approval plus the current
|
||||
revision's nine strict contexts. Complete Multi-profile E2E remains downstream
|
||||
of PR admission. See [`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact
|
||||
classification, context, reviewer, and ruleset contract.
|
||||
|
||||
+26
-32
@@ -62,32 +62,27 @@ make lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Homebrew Formula PR Automation
|
||||
## Homebrew Formula Delivery
|
||||
|
||||
Official tag releases require the repository Actions secret
|
||||
`HOMEBREW_PR_TOKEN`. Prefer a fine-grained personal access token owned by a
|
||||
maintainer or release-bot account, limited to this repository with
|
||||
`Contents: write` and `Pull requests: write`. If organization policy prevents
|
||||
that account from targeting the repository, use a dedicated classic token with
|
||||
only the `public_repo` scope. Do not reuse a broad developer token.
|
||||
Official releases use the Release workflow's built-in `GITHUB_TOKEN` to update
|
||||
exactly one tracked Formula after the immutable GitHub assets and their
|
||||
checksums have passed verification. The publisher validates the rendered Ruby,
|
||||
commits only the configured Formula path, never force-pushes `main`, and retries
|
||||
from a fresh clone up to three times when `main` advances concurrently. Normal
|
||||
stable and beta releases do not create a Formula PR or run a permission
|
||||
canary. The workflow uses the existing repository-scoped
|
||||
`HOMEBREW_PR_TOKEN` release identity because GitHub does not allow its built-in
|
||||
Actions App to bypass this repository's rulesets. That identity is the sole
|
||||
user bypass actor on the two default-branch rulesets. The workflow creates the
|
||||
nine Code Admission checks for the Formula-only commit only after proving its
|
||||
sole parent already has all nine successful checks and the committed Formula
|
||||
exactly matches this release's verified bytes.
|
||||
|
||||
Store the dedicated token as the `HOMEBREW_PR_TOKEN` repository Actions secret
|
||||
and rotate it before its configured expiration. Replace it immediately if it is
|
||||
exposed, its owner loses repository access, or the release-bot ownership
|
||||
changes. The Release workflow uses this
|
||||
dedicated token only to push an `automation/homebrew-*` branch and open the
|
||||
stable or beta Formula PR. It does not push Formula changes directly to `main`.
|
||||
The default-branch governance preflight and every tag contract authenticate the
|
||||
token before publication, reject over-scoped classic tokens, confirm its
|
||||
identity, and run a controlled write canary. The canary pushes a unique
|
||||
`automation/homebrew-token-canary-*` branch with a `[skip ci]` commit, creates a
|
||||
draft PR, closes it, and deletes the branch with the same token. This proves both
|
||||
Contents and Pull requests write access before publication without merging
|
||||
anything. The gate also rejects reuse of `RELEASE_GOVERNANCE_TOKEN`.
|
||||
No maintainer environment variable is required when creating a tag. Using the
|
||||
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
|
||||
Actions from creating pull requests, and its generated PR events may require
|
||||
separate workflow approval.
|
||||
Keep `HOMEBREW_PR_TOKEN` repository-scoped with `Contents: write` and
|
||||
`Pull requests: write` (the latter remains necessary for withdrawal rollback),
|
||||
keep its owner as the designated ruleset bypass actor, and do not reuse
|
||||
`RELEASE_GOVERNANCE_TOKEN`. The workflow and publisher provide the Formula-only
|
||||
path restriction; GitHub rulesets do not infer that restriction from the token.
|
||||
|
||||
## Release Governance and Recovery
|
||||
|
||||
@@ -98,15 +93,14 @@ administration setting and cannot be read by the workflow's built-in
|
||||
contract use this same credential so a missing or expired identity is detected
|
||||
before an irreversible tag is created.
|
||||
|
||||
Create a protected `release-recovery` environment limited to protected
|
||||
branches, with a required reviewer, self-review disabled, and administrator
|
||||
bypass disabled. The workflow reads the environment through the GitHub API and
|
||||
fails closed unless the required-reviewer, prevent-self-review, and protected-
|
||||
branch rules are present.
|
||||
Recovery is restricted to an existing annotated tag whose exact tag object,
|
||||
commit, and failed tag-push run all match; it then reuses the normal release
|
||||
jobs. Do not put publication secrets in temporary branches or create ad-hoc
|
||||
recovery workflows.
|
||||
commit, sealed metadata, original failed run/attempt, requester identity and
|
||||
Release state all match; it then reuses the normal release jobs without a
|
||||
second-person environment approval. A same-run “Re-run failed jobs” is even
|
||||
lighter: the seal job may adopt an existing tag only when its complete
|
||||
authority matches that run and its original attempt is not newer than the
|
||||
current attempt. Do not put publication secrets in temporary branches or
|
||||
create ad-hoc recovery workflows.
|
||||
|
||||
Cloud-sealed releases mirror to OSS only when the repository variable
|
||||
`ENABLE_OSS_MIRROR` is exactly `true`. Leave the variable unset while no Bucket
|
||||
|
||||
+59
-18
@@ -4,9 +4,9 @@ The pull-request admission layer has exactly nine required external contexts:
|
||||
|
||||
| Required context | Contract |
|
||||
|---|---|
|
||||
| `Lint` | Stable PR revision classification, formatting, `go vet`, and Actionlint |
|
||||
| `Test` | Race/unit/release-script tests plus fast cross-platform compilation |
|
||||
| `Coverage` | Overall non-regression and 100% changed-code coverage |
|
||||
| `Lint` | Stable PR revision/risk classification plus applicable formatting, `go vet`, and Actionlint |
|
||||
| `Test` | Tier-selected race/unit/release-script tests plus representative cross-platform compilation |
|
||||
| `Coverage` | Scope-matched overall non-regression and 100% changed-code coverage |
|
||||
| `Policy` | Repository policy and the fail-closed CHANGELOG contract |
|
||||
| `Edition` | Edition contract tests |
|
||||
| `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility |
|
||||
@@ -84,13 +84,28 @@ second file but still rejects invalid dates or versions, missing bullets,
|
||||
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
|
||||
Adding a second file therefore cannot bypass CHANGELOG validation.
|
||||
|
||||
## Platform and downstream boundaries
|
||||
## Risk tiers and downstream boundaries
|
||||
|
||||
Ordinary PRs run the primary Linux assurance plus fast Darwin/Windows compile
|
||||
checks. Full native macOS/Windows tests and platform coverage run on a PR only
|
||||
when its diff touches auth, keychain, OS-specific Go files, installers,
|
||||
packaging, Formulae, or release automation. Protected `main` pushes run the
|
||||
complete native matrix.
|
||||
`Lint` resolves the complete base/head diff before any helper is skipped.
|
||||
Unknown or truncated input fails closed into the high-risk tier.
|
||||
|
||||
| Tier | Selection | Admission work |
|
||||
|---|---|---|
|
||||
| Documentation-only | Only prose/documentation assets; no executable, generated, workflow, packaging, or interface surface | Documentation and repository-asset validation; expensive code helpers skip while every required context still succeeds |
|
||||
| Standard | Ordinary code change with a stable package graph | Race tests for changed Go packages and their reverse dependencies; candidate and merge-base coverage over the same impacted scope and `coverpkg`; representative Darwin/Windows compilation |
|
||||
| High-risk / protected `main` | Workflow/policy, package add/remove/rename, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure classification | Complete race suite and full native macOS/Windows tests, plus every affected domain gate |
|
||||
|
||||
Domain helpers (`Edition`, `Interface Integrity`, `CLI Smoke`, and `Mock MCP`,
|
||||
for example) execute their substantive suites when the diff can affect that
|
||||
contract or when the high-risk tier is selected. Otherwise their stable named
|
||||
contexts still report a successful, explicit unaffected result. Release-script
|
||||
tests follow the same impact rule. This preserves the ruleset contract without
|
||||
charging every developer for unrelated work.
|
||||
|
||||
Platform-sensitive changes additionally run native changed-code coverage.
|
||||
Protected `main` always runs native tests; generic portable changes are held to
|
||||
the Linux changed-code gate rather than being forced to manufacture
|
||||
platform-only coverage.
|
||||
|
||||
Complete `Multi-profile E2E` is not a PR admission context. It belongs to the
|
||||
`Main Integration — 主干集成` workflow and runs only after a push to `main` (or
|
||||
@@ -116,9 +131,28 @@ flowchart TB
|
||||
MAIN --> RELEASE["Release delivery"]
|
||||
```
|
||||
|
||||
## Review ownership and auto-merge
|
||||
|
||||
A base-owned `pull_request_target` workflow routes newly opened, updated,
|
||||
reopened, or newly ready PRs targeting `main` to one eligible peer reviewer. It
|
||||
does not check out or execute PR code, excludes both the author and the known
|
||||
latest pusher, and balances the open requested-review load across the reviewed
|
||||
maintainer pool. A current-head approval or change request is preserved; after
|
||||
a new push, stale activity does not suppress a fresh request, and an
|
||||
outstanding change requester is preferred for continuity.
|
||||
|
||||
The branch ruleset keeps one human approval and all nine strict required
|
||||
contexts, and requires someone other than the latest pusher to approve after
|
||||
the most recent head update. Repository auto-merge is enabled for ready PRs,
|
||||
so a PR merges after that approval and the current revision's nine checks are
|
||||
green. If `main` advances, strict checks rerun before merge. The reviewer
|
||||
router is orchestration, not a quality context, and must not be added to the
|
||||
ruleset.
|
||||
|
||||
## Running focused gates locally
|
||||
|
||||
Run the contracts relevant to the change:
|
||||
Run the contracts relevant to the change. Ordinary contributors are not
|
||||
expected to repeat every CI job locally:
|
||||
|
||||
```sh
|
||||
make build
|
||||
@@ -139,15 +173,18 @@ base_ref=$(git merge-base HEAD origin/main)
|
||||
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
|
||||
```
|
||||
|
||||
`make coverage-gate` is an enforcement step, not a profile generator. CI
|
||||
generates the candidate, supporting, merge-base, and (when risk-selected)
|
||||
native profiles before the aggregate `Coverage` context evaluates them. The
|
||||
`make coverage-gate` is an enforcement step, not a profile generator. For a
|
||||
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
|
||||
aggregate and native gates require 100% coverage for changed executable Go
|
||||
statements. Overall coverage remains an unrounded, zero-tolerance merge-base
|
||||
non-regression check. Candidate and baseline profiles are evaluated by the
|
||||
same block-deduplicating checker; supporting policy and shortcut profiles
|
||||
contribute to changed-code coverage only. The checked-in badge is presentation
|
||||
only and is never read as a gate input.
|
||||
statements. Overall coverage remains an unrounded, zero-tolerance,
|
||||
scope-matched merge-base non-regression check. Candidate and baseline profiles
|
||||
are evaluated by the same block-deduplicating checker; supporting policy and
|
||||
shortcut profiles contribute to changed-code coverage only. The checked-in
|
||||
badge is presentation only and is never read as a gate input.
|
||||
|
||||
Compatibility checks derive authoritative Interface snapshots from the PR
|
||||
merge-base and the latest reachable stable release. The candidate cannot bless
|
||||
@@ -175,3 +212,7 @@ Do not require helper jobs, `Multi-profile E2E`, or an aggregate admission
|
||||
alias. Update ruleset contexts only after the new names have appeared on the
|
||||
protected branch, so a rename cannot silently remove enforcement or leave an
|
||||
unproducible required context.
|
||||
|
||||
The branch ruleset also requires one approval after the latest push. Enable
|
||||
repository auto-merge and automatic head-branch deletion; keep the base-owned
|
||||
reviewer router outside the required-context list.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# 5分钟搭好团队知识库:DWS 让 IT 服务台自己开口答疑,你值得拥有
|
||||
|
||||
> 💡 **省流版摘要:**
|
||||
> 别再用鼠标一个个建文档了,真的没必要!本文带你用 DWS(钉钉命令行工具),5 分钟搭好一个团队知识库:建空间、搭目录、批量导入 FAQ、配权限,全程命令行一步到位。最后再花 30 秒把它挂到钉钉机器人上——同事在群里 @ 一下,知识库自己开口答疑。知识管理员从"搬运工"升级为"甩手掌柜",就是这么简单。
|
||||
>
|
||||
> (温馨提示:本文内容可以直接丢给智能体,让智能体一次性逐步安装和使用)
|
||||
|
||||
## 一、痛点吐槽:管知识库有多累?
|
||||
|
||||
身为企业 IT / 知识管理员,你肯定经历过这些"九九八十一难"……
|
||||
|
||||
- **点击马拉松**:建空间点 5 下、建文件夹点 3 下、建一篇文档再点 4 下。一个季度下来,鼠标点击次数比写的字还多。
|
||||
- **搬运工噩梦**:几百篇历史 FAQ 散落在本地 Word / Markdown 里,要搬进钉钉知识库?复制粘贴到天荒地老,格式还经常翻车。
|
||||
- **权限苦差**:新同事入职要加权限、转岗要改角色、离职要移除。逐个空间点进去操作,漏一个就是安全隐患。
|
||||
- **知识沉睡**:库是建好了,可同事还是习惯私聊问你"VPN 又连不上了怎么办"。知识库躺着吃灰,你继续当人肉客服。😭
|
||||
|
||||
今天,DWS(DingTalk Workspace CLI)闪亮登场!🌟
|
||||
|
||||
你不需要写一行代码,只要在终端敲几行命令,知识库的"建、搬、管、用"全链路一次搞定。更香的是:搭好的知识库可以直接挂到钉钉机器人上,让知识自己开口答疑。
|
||||
|
||||
## 二、DWS 是个啥?知识库的"遥控指挥中心"
|
||||
|
||||
DWS 是钉钉能力的原子化封装,把复杂的 OpenAPI 打包成简单指令。管知识库这件事,主要靠它的"三驾马车":
|
||||
|
||||
| 命令族 | 能干什么 |
|
||||
|---|---|
|
||||
| 🗂️ `dws wiki` | 知识库空间、目录节点、成员权限的全生命周期管理 |
|
||||
| 📄 `dws doc` | 文档内容读写、本地文件批量导入、模板套用 |
|
||||
| 📁 `dws drive` | 钉盘文件上传下载、全局搜索、归档备份 |
|
||||
|
||||
你可以把它想象成知识库的"遥控指挥中心" 🎮——既能你手动按(终端敲命令,比点界面快 10 倍),也能让 AI 帮你按(Claude Code、Qoder 等智能体直接听懂并调用)。
|
||||
|
||||
**适合谁用:**
|
||||
|
||||
- **企业 IT / 知识管理员**:批量建库、批量导入、批量管权限,脚本化解放双手
|
||||
- **开发者 / AI 玩家**:把知识库挂到机器人上,打造 24 小时答疑小能手
|
||||
- **重度钉钉用户 / 效率党**:一条命令搜全库,比在界面里翻目录快得多
|
||||
|
||||
## 三、搭好的知识库能帮你做什么?
|
||||
|
||||
| 场景 | 玩法 |
|
||||
|---|---|
|
||||
| 🛟 IT 服务台 FAQ 库 | VPN、邮箱、打印机常见问题集中沉淀,机器人自动答疑 |
|
||||
| 📜 制度流程库 | 报销、请假、采购制度批量导入,全文秒搜 |
|
||||
| 🎓 新人上岗手册 | 按部门建目录,入职即授权限,自助通关 |
|
||||
| 🤖 知识库 + 机器人 | `--knowledge-source wiki:<spaceId>` 一挂,群里 @ 它就答 |
|
||||
|
||||
所想即所得,拒绝画饼,直接上菜!🍽️
|
||||
|
||||
## 四、5分钟倒计时,搭好你的团队知识库
|
||||
|
||||
> 以下命令全部经过真实环境跑通验证,放心照抄。
|
||||
|
||||
### 0. 安装并登录 DWS(约 1 分钟)
|
||||
|
||||
把以下指令复制给你的智能体(Claude Code、Qoder、Codex 等)执行,或手动在终端跑:
|
||||
|
||||
macOS / Linux:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Windows(PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
登录(提示授权请扫码):
|
||||
|
||||
```bash
|
||||
dws auth login
|
||||
```
|
||||
|
||||
【截图位:dws auth status 显示 token_valid: true】
|
||||
|
||||
### 1. 建一个知识库空间(10 秒)
|
||||
|
||||
```bash
|
||||
dws wiki space create --name "IT服务台知识库" --desc "IT 常见问题与制度流程"
|
||||
```
|
||||
|
||||
返回里的 `workspaceId` 就是空间的身份证号,后面每步都要用它。
|
||||
|
||||
【截图位:返回 workspaceId 与 spaceUrl】
|
||||
|
||||
### 2. 搭目录结构(20 秒)
|
||||
|
||||
知识库的结构 = 文件夹节点 + 文档节点。先建分类文件夹:
|
||||
|
||||
```bash
|
||||
dws wiki node create --workspace <workspaceId> --name "常见问题FAQ" --type folder
|
||||
dws wiki node create --workspace <workspaceId> --name "制度流程" --type folder
|
||||
```
|
||||
|
||||
在文件夹下建一篇空文档(不加 `--folder` 就建在根目录):
|
||||
|
||||
```bash
|
||||
dws wiki node create --workspace <workspaceId> --name "VPN连接失败排查指南" --folder <文件夹nodeId>
|
||||
```
|
||||
|
||||
### 3. 批量导入历史文档(1 分钟,重头戏!)
|
||||
|
||||
几百篇本地 FAQ 不用复制粘贴,`doc import` 直接整批灌进知识库,Word、Excel、Markdown、txt 通吃:
|
||||
|
||||
```bash
|
||||
# 单篇导入到指定文件夹
|
||||
dws doc import --file ./vpn-faq.md --workspace <workspaceId> --folder <文件夹nodeId> --name "VPN连接失败排查指南"
|
||||
|
||||
# 批量导入整个目录(bash 一把梭)
|
||||
for f in ./faq/*.md; do
|
||||
dws doc import --file "$f" --workspace <workspaceId> --folder <文件夹nodeId>
|
||||
done
|
||||
```
|
||||
|
||||
已有在线文档想补内容?Markdown 直接写入:
|
||||
|
||||
```bash
|
||||
dws doc update --node <文档nodeId> --content-file ./补充内容.md --mode append
|
||||
```
|
||||
|
||||
【截图位:终端批量导入的滚动输出 + 知识库里齐刷刷的文档列表】
|
||||
|
||||
### 4. 配权限:把人拉进来(30 秒)
|
||||
|
||||
```bash
|
||||
# 先用通讯录查到同事的 userId
|
||||
dws contact user search --query "张三"
|
||||
|
||||
# 加为编辑者(--users 支持逗号分隔批量加)
|
||||
dws wiki member add --workspace <workspaceId> --users <userId1>,<userId2> --role EDITOR
|
||||
|
||||
# 随时盘点成员
|
||||
dws wiki member list --workspace <workspaceId>
|
||||
```
|
||||
|
||||
### 5. 验收:搜一下,秒级命中(10 秒)
|
||||
|
||||
```bash
|
||||
# 库内全文搜索
|
||||
dws wiki node search --workspace <workspaceId> --query "VPN"
|
||||
|
||||
# 全局搜知识库空间
|
||||
dws wiki space search --query "IT服务台"
|
||||
```
|
||||
|
||||
【截图位:搜索结果命中文档标题】
|
||||
|
||||
### 6. 封神一步:挂到机器人上,知识自己开口答疑(30 秒)
|
||||
|
||||
如果你已经按《5分钟抱走你的嘴替机器人》建好了钉钉机器人,只需加一个参数:
|
||||
|
||||
```bash
|
||||
dws dev connect --channel claudecode \
|
||||
--robot-client-id <你的机器人ID> --robot-client-secret <你的机器人密钥> \
|
||||
--knowledge-source wiki:<workspaceId>
|
||||
```
|
||||
|
||||
机器人会自动从知识库拉取知识并缓存。同事在群里 @ 它问"VPN 连不上怎么办",它直接引用你刚导入的排查指南回答——你,终于不用当复读机了。😎
|
||||
|
||||
## 五、进阶使用技巧
|
||||
|
||||
### 知识库管理速查表
|
||||
|
||||
| 操作 | 命令 |
|
||||
|---|---|
|
||||
| 列出我的个人空间 | `dws wiki space list --type myWikiSpace` |
|
||||
| 列出组织知识库 | `dws wiki space list --type orgWikiSpace` |
|
||||
| 浏览库内节点树 | `dws wiki node list --workspace <ID> [--folder <nodeId>]` |
|
||||
| 移动 / 复制节点 | `dws wiki node move` / `dws wiki node copy` |
|
||||
| 改成员角色 | `dws wiki member update --users <UID> --role VIEWER` |
|
||||
| 移除成员 | `dws wiki member remove --users <UID>` |
|
||||
| 删除整个空间 | `dws wiki space delete --workspace <ID>`(进回收站,可恢复) |
|
||||
|
||||
### 老手避坑指南 ⛳
|
||||
|
||||
- 建在线表格用 `--type axls`,**`asheet` 服务端不支持**,别踩坑。
|
||||
- `member list` 只返回姓名和角色、**不返回 userId**;要串联 `update` / `remove`,先用 `dws contact user search --query "<姓名>"` 反查。
|
||||
- 搜索关键词的 flag 是 `--query`,`--keyword` 是遗留别名,新脚本请用 `--query`。
|
||||
- 所有命令加 `--format json`,配合 `--jq` 过滤字段,写脚本时稳得一批。
|
||||
- 破坏性操作(删空间、覆盖写文档)前加 `--dry-run` 先预览,确认无误再执行。
|
||||
|
||||
### 让机器人答得更好
|
||||
|
||||
| 开关 | 作用 |
|
||||
|---|---|
|
||||
| `--knowledge-source wiki:<spaceId>` | 从钉钉知识库拉知识作为答疑来源(本文主角) |
|
||||
| `--knowledge-dir <目录>` | 挂本地 .md/.txt 知识目录,可与上面并存 |
|
||||
| `--allowed-groups / --allowed-users` | 白名单,只让指定群或人触发 |
|
||||
| `--daemon` | 后台常驻,关掉终端也不断线 |
|
||||
|
||||
## 六、更多 DWS 官方信息
|
||||
|
||||
- 钉钉 CLI 官网:https://open.dingtalk.com/dingtalk-cli
|
||||
- 开源仓库:https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
- 上一篇姊妹篇:《5分钟抱走你的嘴替机器人:启动钉钉DWS,你值得拥有》
|
||||
|
||||
## 七、欢迎加入交流群
|
||||
|
||||
【截图位:DWS 交流群二维码】
|
||||
|
||||
遇到问题来群里喊一声,官方同学在线答疑。下一篇想看什么?批量备份知识库?给知识库做权限审计?留言区点菜!🍻
|
||||
@@ -5,6 +5,7 @@
|
||||
| Variable | Purpose / 用途 |
|
||||
|---------|---------|
|
||||
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
|
||||
| `DWS_AGENT_HOST` | Optional Agent host observation label sent as `x-dws-agent-host` (for example `qwenwork_cloud`). Values are trimmed and must match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. Used only for logs and BI, never for authentication or routing. / 可选 Agent 宿主观测标识,经裁剪并校验后作为 `x-dws-agent-host` 发送;仅用于日志与 BI,不参与鉴权或路由 |
|
||||
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
|
||||
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
|
||||
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
|
||||
|
||||
+33
-28
@@ -1,19 +1,26 @@
|
||||
# 发布手册(预发 / 正式)
|
||||
|
||||
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew 以 workflow 自动创建的 Formula PR 经独立审核合入为交付边界。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
|
||||
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew Formula 在不可变 Release 资产及 checksum 通过校验后,由同一 workflow 直接写入 `main`,不再创建二次 PR。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
|
||||
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端和本地入口都会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端入口会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context、Environment 保护规则和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
|
||||
|
||||
## 推荐入口:GitHub 云端发布
|
||||
|
||||
任何具有仓库写权限、因而可以手动运行 Actions workflow 的成员,都可以基于当时最新的 `main` 发起发布:
|
||||
入口页面是 [GitHub Actions → Release](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml)。在仓库页面依次点击 `Actions` → `Release` → `Run workflow` 即可操作;不需要通过 Agent 或本地机器触发。
|
||||
|
||||
1. 在 GitHub Actions 打开 `Release`,选择 `Run workflow`,分支必须是默认分支 `main`。
|
||||
只有得到该仓库明确授权、最终权限为 `write`、`maintain` 或 `admin` 的协作者可以运行发布操作,workflow 还会对发起人和重新运行者做同样的权限复核。没有仓库写权限的外部贡献者以及仅有 `read` / `triage` 权限的成员不能规划或发布版本。两个渠道的授权边界如下:
|
||||
|
||||
- beta:上述任一内部成员都可以直接规划和发布,不需要人工审批。
|
||||
- stable:上述任一内部成员都可以规划并发起发布;完成只读规划和治理检查后,workflow 会在 `release-stable` Environment 等待另一名仓库管理员通过 `Review deployments` 签收。申请人不能批准自己的请求,批准后原 run 自动继续,无需重新触发。
|
||||
|
||||
基于当时最新的 `main` 发起发布:
|
||||
|
||||
1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。
|
||||
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
|
||||
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
|
||||
4. 再次运行,改为 `release_operation=publish`,并输入 `PUBLISH beta` 或 `PUBLISH stable`。
|
||||
4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
|
||||
@@ -64,7 +71,9 @@ dws-release
|
||||
dws-release config --remote origin
|
||||
```
|
||||
|
||||
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。只有显式增加 `--publish` 才会进入 tag 发布,且底层仍要求最终版本确认。
|
||||
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。官方仓库不再接受本地 `--publish`,命令会直接提示上述 Actions 页面;本地入口不能绕过 beta/stable 的统一授权。
|
||||
|
||||
Release workflow 不再监听新建的 `v*` tag;直接推 tag 不会发布 GitHub Release、npm 或镜像渠道。所有新 beta/stable 都必须从云端页面进入统一授权和审计链路;历史失败版本仍可通过受保护的 recovery 兼容处理。
|
||||
|
||||
## 发布模型
|
||||
|
||||
@@ -91,13 +100,7 @@ dws-release v1.2.3-beta.1
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
若源码、版本、远端身份和 stable 基线均未变化,`--publish` 会复用该证明,只执行远端契约、发布身份和最终治理复核,不再重复测试与打包。也可以直接运行 `--publish`;没有可复用证明时只会完整执行一次预检。命令在封 tag 前仍要求再次输入完整版本号,统一入口不提供跳过确认的参数。
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
|
||||
## 正式发布
|
||||
|
||||
@@ -107,14 +110,13 @@ beta 验证通过后,运行正式版入口:
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
```
|
||||
|
||||
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检,确认后增加 `--publish`:
|
||||
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
本地入口的 `FROM_BETA` 不会自动推断;云端入口会按上述规则唯一选择。两种入口都会把它写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
|
||||
预检通过后,在 Actions 页面选择 stable 和 `release_operation=publish`。云端入口会按上述规则唯一选择 beta,并把它写入 stable annotated tag 的 `From-Beta` 元数据;在创建 tag 前必须由另一名仓库管理员签收。
|
||||
|
||||
## CHANGELOG 契约
|
||||
|
||||
@@ -132,14 +134,14 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走受保护恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
- tag 必须是 annotated tag;本地脚本要求封板提交已通过 PR 合入并包含在远端 `main` 历史中,发布只推送 tag。CI 允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。
|
||||
- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时走受保护恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
|
||||
- 云端 seal 创建远端 tag 后,后续发布归同一 run 所有;发布中途失败时先重跑同一 run 的失败 jobs,必须跨 run 时走机器核验恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
|
||||
|
||||
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
|
||||
|
||||
@@ -166,32 +168,35 @@ dws-release recover v1.2.3-beta.1
|
||||
|
||||
- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。
|
||||
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。
|
||||
- `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。
|
||||
- 恢复不再进入人工审批 environment。workflow 会机器核验 tag object、commit、原失败 run/attempt、请求人、完整 seal metadata、`main` 祖先关系以及 Release 状态;任一事实不一致都会在构建前 fail closed。
|
||||
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm,以及已启用的 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
|
||||
- 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。
|
||||
|
||||
成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。
|
||||
|
||||
云端 seal 后不要使用 GitHub 的 “Re-run failed jobs” 作为交付修复:annotated tag 永久绑定最初的 run attempt,普通 rerun 不会成为可接受的交付证据。GitHub Release 尚未公开时走上述 protected recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
|
||||
seal job 写入 tag 后如果只因 GitHub API 瞬时 404/429/5xx 或后续 job 失败,可直接使用 GitHub 的 “Re-run failed jobs”。同一 run 会精确复用原 release-plan;seal 只在 version、tag object、commit、channel、beta 来源、OSS policy、请求人、run ID 和完整 message 全部匹配且原 attempt 不大于当前 attempt 时认领已有 tag。不同 run 或任一字段不匹配时不会认领。GitHub Release 尚未公开且必须跨 run 重建时走上述机器核验 recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
|
||||
|
||||
OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。启用 OSS 后,发布和撤回把它作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本;未启用时两条流程都明确跳过不存在的 OSS 渠道。
|
||||
|
||||
Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
|
||||
Release workflow 会生成 Darwin/Linux 双架构 Formula,并在不可变资产逐个校验后,由 `HOMEBREW_PR_TOKEN` 所属的受控发布身份只提交对应 stable 或 beta Formula 文件到 `main`,不再创建二次发布 PR;并发 `main` 更新会以全新 clone 最多重试三次,绝不 force push。该身份的提交不会依赖另一轮 CI 来补齐证明:workflow 只在确认该 commit 单父、唯一改动为目标 Formula、内容与本次已验证产物逐字节一致,且父 commit 九项 Code Admission 全绿后,直接为 Formula-only commit 封存同名九项成功 checks,避免下一次发布因缺失 contexts 被卡住。撤回 workflow 暂时仍使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
|
||||
|
||||
## 平台治理前置
|
||||
|
||||
仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则:
|
||||
|
||||
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;tag workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
|
||||
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;Release workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
|
||||
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
|
||||
- tag ruleset 还必须覆盖 `withdrawn/v*`:只允许受保护的撤回 workflow 创建墓碑,禁止更新或删除墓碑;同时应允许 Release workflow 创建新的 `v*`,允许撤回 workflow 在全部渠道回退后删除精确的问题 `v*`。若组织级规则阻止这两个 workflow 的预期动作,发布或撤回会 fail closed,不能靠手工移动 tag 绕过。
|
||||
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
|
||||
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和云端发布都使用这一个身份进行 fail-closed 验证。
|
||||
- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。
|
||||
- 启用 OSS 镜像时,先创建有效 Bucket,再设置仓库变量 `ENABLE_OSS_MIRROR=true`,并配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。启用后发布保持 fail-closed;撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。尚未 provision Bucket 时保持该变量未设置或不等于 `true`,新 tag 会封存 `OSS-Mirror: deferred` 并跳过 OSS;该版本不能通过现有 repair 流程事后改成启用。
|
||||
- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。
|
||||
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
|
||||
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
|
||||
- 正常 Homebrew 发布使用现有的 `HOMEBREW_PR_TOKEN` 直接提交 Formula-only commit,不再创建 Homebrew PR,也不跑权限 canary。GitHub 不允许内置 Actions App 作为当前仓库 ruleset 的 bypass actor,因此两个默认分支 ruleset 都只给该 token 所属的指定发布管理员用户 `always` bypass;仓库脚本仍会限制提交路径、校验 Ruby、禁止 force push,并在并发更新时重新基于最新 `main`。
|
||||
- `HOMEBREW_PR_TOKEN` 应保持仓库范围的 `Contents: write` 与 `Pull requests: write` 权限;后者仅供撤回流程创建回退 PR。不要与 `RELEASE_GOVERNANCE_TOKEN` 复用,并定期审计 token owner 与 ruleset bypass actor 一致。
|
||||
- 创建 `release-beta` environment,只允许受保护分支且不配置 required reviewer;仓库内部 `write`、`maintain`、`admin` 成员的 beta 发布会直接通过该边界。
|
||||
- 创建 `release-stable` environment,只允许受保护分支,以仓库管理员为 required reviewer,禁止申请人自审并关闭管理员绕过。内部成员可以发起 stable,但必须由另一名管理员签收后才能封 tag 和写入任何发布渠道。
|
||||
- Release workflow 会在封 tag 前回读并验证上述两套 Environment 规则;规则缺失、stable reviewer 不再是仓库管理员、或 beta 被误加人工审批时都会 fail closed。
|
||||
- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。
|
||||
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的 `contents: write`。若上述发布凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
|
||||
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的权限;正常发布由 `HOMEBREW_PR_TOKEN` 更新两个受控 Formula 路径,内置 token 只承担 workflow 自身声明的封板与校验写入。若撤回凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
|
||||
|
||||
immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
|
||||
@@ -8,7 +8,7 @@ DWS Schema 是当前二进制公开 CLI 的版本化 Agent 执行契约。它描
|
||||
|
||||
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、manual hint、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。
|
||||
2. **所有来源只解析一次。** 来源经过统一 resolver 进入 typed `SchemaRegistry`,所有查询、导出和门禁都消费同一个 `SchemaRegistry/SchemaIndex`。
|
||||
3. **Registry-first,Catalog 只出不进。** reviewed `CommandRegistry` 是稳定 command identity/navigation 的唯一事实源;`schema_catalog.json` 和其他生成 JSON 只是下游发布物,不能成为命令、metadata 或下一轮 Catalog 的来源。运行时 production loader 解码 embedded snapshot 只是交付边界,不是 source resolution。
|
||||
3. **Registry-first,Catalog 只出不进。** reviewed `CommandRegistry` 是稳定 command identity/navigation 的唯一事实源;`schema_catalog/`(`catalog.json` + 每产品 `tools/<product>.json`)和其他生成 JSON 只是下游发布物,不能成为命令、metadata 或下一轮 Catalog 的来源。运行时 production loader 解码 embedded snapshot 只是交付边界,不是 source resolution。
|
||||
|
||||
Schema 不调用 MCP `tools/list`,不访问网络,也不读取用户本地 discovery cache。
|
||||
|
||||
@@ -63,8 +63,9 @@ live Cobra flag facts / typed parameter metadata
|
||||
build-time typed gates snapshot serializer
|
||||
|
|
||||
v
|
||||
schema_catalog.json
|
||||
(release output only)
|
||||
schema_catalog/
|
||||
(catalog.json + tools/<product>.json,
|
||||
release output only)
|
||||
|
|
||||
v
|
||||
go:embed -> typed loader
|
||||
@@ -130,7 +131,7 @@ DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和
|
||||
| `schema_mcp_metadata.json` | pinned RPC identity、接口描述和脱敏参数事实 | CLI identity、运行时路由、risk 推断 |
|
||||
| `schema_hints/selection/*.json` | reviewed selection prose(summary / use_when / avoid_when / examples) | 创建 Cobra 命令或参数、改写 safety |
|
||||
| Skills/Markdown | 产品路由、工作流和使用建议 | 命令存在性和 flag 事实 |
|
||||
| `schema_catalog.json` 及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
|
||||
| `schema_catalog/`(catalog.json + tools/<product>.json)及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
|
||||
|
||||
`schema_command_registry.json` 承载 reviewed `CommandRegistry`。Manual command addition 先以确定性规则合并进 effective registry;从 binder 开始,下游只看到一个稳定 identity/navigation 模型。旧 wire 中的 `surface_hash` / `surface_tools` 字段仅为兼容名称,语义已经是 effective Registry hash/coverage,不构成第二事实源。
|
||||
|
||||
@@ -272,7 +273,8 @@ dws schema --all # 所有工具的完整 leaf 导
|
||||
- `internal/cli/schema_agent_metadata/index.json`
|
||||
- `internal/cli/schema_agent_metadata/<product>.json`
|
||||
- `internal/cli/schema_agent_metadata_audit.json`
|
||||
- `internal/cli/schema_catalog.json`
|
||||
- `internal/cli/schema_catalog/catalog.json`(全局信封 + Catalog)
|
||||
- `internal/cli/schema_catalog/tools/<product>.json`(每产品 leaf ToolSpecs,按产品分片以免并发 PR 冲突)
|
||||
|
||||
只编辑来源;不要手工编辑 Agent metadata 或 Catalog 输出。
|
||||
|
||||
@@ -303,7 +305,7 @@ go test ./internal/cli ./internal/app ./internal/generator/... -count=1
|
||||
## 10. 明确禁止
|
||||
|
||||
- 运行时调用 MCP `tools/list` 或访问网络生成 Schema。
|
||||
- 从旧 `schema_catalog.json` 或其他 generated JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
|
||||
- 从 `schema_catalog/` 等生成 JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
|
||||
- 把 native annotation、legacy registry 或 Catalog 当作 identity fallback;或在 `EffectiveCommandRegistry` 之后再次选择 identity winner。
|
||||
- renderer、query 或 gate 在 `SchemaRegistry` 之后重新读取 source 并做第二次 merge。
|
||||
- 用 prefix/wildcard exclusion 隐藏未来命令。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
// 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 (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
const (
|
||||
envDWSAgentHost = "DWS_AGENT_HOST"
|
||||
headerDWSAgentHost = "x-dws-agent-host"
|
||||
)
|
||||
|
||||
var agentHostPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: envDWSAgentHost,
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "调用 DWS 的 Agent 宿主标识;仅用于日志和 BI 观测",
|
||||
Example: "qwenwork_cloud",
|
||||
})
|
||||
}
|
||||
|
||||
// parseAgentHost normalizes and validates the caller-provided observation
|
||||
// label. CR/LF is rejected before trimming so it can never be hidden at the
|
||||
// edge of a value. An unset or whitespace-only value means "do not emit".
|
||||
func parseAgentHost(raw string) (string, error) {
|
||||
if strings.ContainsAny(raw, "\r\n") {
|
||||
return "", invalidAgentHostError()
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !agentHostPattern.MatchString(value) {
|
||||
return "", invalidAgentHostError()
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func invalidAgentHostError() error {
|
||||
// Do not include the raw environment value in the error: it is an
|
||||
// untrusted caller-controlled string and may contain sensitive data.
|
||||
return apperrors.NewValidation(
|
||||
"DWS_AGENT_HOST must match ^[a-z0-9][a-z0-9_-]*$",
|
||||
apperrors.WithReason("invalid_agent_host"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// 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"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestParseAgentHost(t *testing.T) {
|
||||
valid := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "unset", raw: "", want: ""},
|
||||
{name: "whitespace only", raw: " \t\u3000", want: ""},
|
||||
{name: "cloud", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
|
||||
{name: "desktop", raw: "qwenwork_desktop", want: "qwenwork_desktop"},
|
||||
{name: "trim", raw: " \tqwenwork_cloud\t ", want: "qwenwork_cloud"},
|
||||
{name: "generic", raw: "host-2_alpha", want: "host-2_alpha"},
|
||||
{name: "leading digit", raw: "2nd_host", want: "2nd_host"},
|
||||
}
|
||||
for _, tc := range valid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseAgentHost(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parseAgentHost() error = %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("parseAgentHost() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
invalid := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{name: "carriage return", raw: "qwenwork_cloud\r"},
|
||||
{name: "line feed", raw: "\nqwenwork_cloud"},
|
||||
{name: "uppercase", raw: "Qwenwork_cloud"},
|
||||
{name: "internal space", raw: "qwenwork cloud"},
|
||||
{name: "internal tab", raw: "qwenwork\tcloud"},
|
||||
{name: "unicode", raw: "千问办公"},
|
||||
{name: "leading dash", raw: "-qwenwork"},
|
||||
{name: "leading underscore", raw: "_qwenwork"},
|
||||
{name: "control character", raw: "qwenwork\x00cloud"},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseAgentHost(tc.raw)
|
||||
if err == nil {
|
||||
t.Fatalf("parseAgentHost(%q) = %q, want error", tc.raw, got)
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("parseAgentHost() error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("category = %q, want validation", appErr.Category)
|
||||
}
|
||||
if appErr.Reason != "invalid_agent_host" {
|
||||
t.Fatalf("reason = %q, want invalid_agent_host", appErr.Reason)
|
||||
}
|
||||
if tc.raw != "" && strings.Contains(err.Error(), tc.raw) {
|
||||
t.Fatalf("error must not echo invalid value %q: %v", tc.raw, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersAddsAgentHostBeforeEditionMerge(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envDWSAgentHost, " qwenwork_desktop ")
|
||||
t.Setenv(envDWSChannel, "channel-test")
|
||||
t.Setenv(envDingtalkAgent, "agent-test")
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agent-code-test")
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
|
||||
mergeSawAgentHost := ""
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
mergeSawAgentHost = headers[headerDWSAgentHost]
|
||||
headers["claw-type"] = "test-claw"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := mergeSawAgentHost; got != "qwenwork_desktop" {
|
||||
t.Fatalf("MergeHeaders saw agent host %q, want qwenwork_desktop", got)
|
||||
}
|
||||
if got := headers[headerDWSAgentHost]; got != "qwenwork_desktop" {
|
||||
t.Fatalf("%s = %q, want qwenwork_desktop", headerDWSAgentHost, got)
|
||||
}
|
||||
if got := headers["x-dingtalk-source"]; got != "github" {
|
||||
t.Fatalf("x-dingtalk-source = %q, want github", got)
|
||||
}
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "agent-code-test" {
|
||||
t.Fatalf("agentCode header = %q, want agent-code-test", got)
|
||||
}
|
||||
if got := headers["x-dws-channel"]; got != "channel-test" {
|
||||
t.Fatalf("channel header = %q, want channel-test", got)
|
||||
}
|
||||
if got := headers["claw-type"]; got != "test-claw" {
|
||||
t.Fatalf("claw-type = %q, want test-claw", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersOmitsAbsentOrInvalidAgentHost(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envDWSChannel, "channel-test")
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
for _, raw := range []string{"", " \t ", "DO_NOT_ECHO"} {
|
||||
t.Setenv(envDWSAgentHost, raw)
|
||||
headers := resolveIdentityHeaders()
|
||||
if _, ok := headers[headerDWSAgentHost]; ok {
|
||||
t.Fatalf("%s must be omitted for %q: %#v", headerDWSAgentHost, raw, headers)
|
||||
}
|
||||
if got := headers["x-dingtalk-source"]; got != "github" {
|
||||
t.Fatalf("x-dingtalk-source = %q, want github", got)
|
||||
}
|
||||
if got := headers["x-dws-channel"]; got != "channel-test" {
|
||||
t.Fatalf("channel header = %q, want channel-test", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
const invalidValue = "DO_NOT_ECHO"
|
||||
t.Setenv(envDWSAgentHost, invalidValue)
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
|
||||
hookCalled := false
|
||||
edition.Override(&edition.Hooks{
|
||||
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
|
||||
hookCalled = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"version"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("root command accepted invalid DWS_AGENT_HOST")
|
||||
}
|
||||
if hookCalled {
|
||||
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_HOST validation")
|
||||
}
|
||||
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("root error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != "invalid_agent_host" {
|
||||
t.Fatalf("root error = category %q reason %q", appErr.Category, appErr.Reason)
|
||||
}
|
||||
if strings.Contains(err.Error(), invalidValue) {
|
||||
t.Fatalf("root error must not echo invalid value: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) {
|
||||
@@ -36,6 +37,66 @@ func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallerAdapterDryRunAllowsOnlyExplicitReadCapability(t *testing.T) {
|
||||
runner := &readOnlyDryRunRunner{}
|
||||
caller := newToolCallerAdapter(runner, &GlobalFlags{DryRun: true, Format: "json"})
|
||||
|
||||
result, err := caller.(edition.ReadToolCaller).CallReadTool(
|
||||
context.Background(),
|
||||
"im",
|
||||
"search_groups",
|
||||
map[string]any{"keyword": "project"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CallReadTool() error = %v", err)
|
||||
}
|
||||
if got := runner.readCalls.Load(); got != 1 {
|
||||
t.Fatalf("read calls = %d, want 1", got)
|
||||
}
|
||||
if got := runner.regularCalls.Load(); got != 0 {
|
||||
t.Fatalf("regular calls = %d, want 0", got)
|
||||
}
|
||||
if runner.invocation.DryRun {
|
||||
t.Fatal("read-only invocation was left in dry-run mode")
|
||||
}
|
||||
if result == nil || len(result.Content) != 1 || !strings.Contains(result.Content[0].Text, `"read":true`) {
|
||||
t.Fatalf("read result = %#v", result)
|
||||
}
|
||||
|
||||
failClosed := newToolCallerAdapter(&countingErrorRunner{}, &GlobalFlags{DryRun: true})
|
||||
if _, err := failClosed.(edition.ReadToolCaller).CallReadTool(
|
||||
context.Background(), "im", "search_groups", nil,
|
||||
); err == nil {
|
||||
t.Fatal("runner without read-only capability was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadOnlyGuardErrorPaths(t *testing.T) {
|
||||
var nilAdapter *toolCallerAdapter
|
||||
if _, err := nilAdapter.CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
|
||||
t.Fatal("nil adapter accepted a read-only call")
|
||||
}
|
||||
|
||||
regularRunner := &capturingSuccessRunner{}
|
||||
regular := newToolCallerAdapter(regularRunner, &GlobalFlags{DryRun: false, Format: "json"})
|
||||
if _, err := regular.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err != nil {
|
||||
t.Fatalf("non-dry read should use the regular runner: %v", err)
|
||||
}
|
||||
if got := regularRunner.calls.Load(); got != 1 {
|
||||
t.Fatalf("regular runner calls = %d, want 1", got)
|
||||
}
|
||||
|
||||
readFailure := newToolCallerAdapter(&failingReadOnlyRunner{}, &GlobalFlags{DryRun: true, Format: "json"})
|
||||
if _, err := readFailure.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
|
||||
t.Fatal("read-only runner error was swallowed")
|
||||
}
|
||||
|
||||
var nilRuntime *runtimeRunner
|
||||
if _, err := nilRuntime.RunReadOnly(context.Background(), executor.Invocation{}); err == nil {
|
||||
t.Fatal("nil runtime runner accepted a read-only call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
|
||||
fallback := &countingErrorRunner{}
|
||||
runner := &runtimeRunner{globalFlags: &GlobalFlags{DryRun: true}, fallback: fallback}
|
||||
@@ -56,6 +117,38 @@ func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerReadOnlyClonePreservesGlobalDryRunBarrier(t *testing.T) {
|
||||
fallback := &capturingSuccessRunner{}
|
||||
flags := &GlobalFlags{DryRun: true}
|
||||
runner := &runtimeRunner{globalFlags: flags, fallback: fallback}
|
||||
invocation := executor.NewHelperInvocation(
|
||||
"test",
|
||||
"im",
|
||||
"search_groups",
|
||||
map[string]any{"keyword": "project"},
|
||||
)
|
||||
|
||||
if _, err := runner.RunReadOnly(context.Background(), invocation); err != nil {
|
||||
t.Fatalf("RunReadOnly() error = %v", err)
|
||||
}
|
||||
if got := fallback.calls.Load(); got != 1 {
|
||||
t.Fatalf("fallback calls = %d, want 1", got)
|
||||
}
|
||||
if fallback.invocation.DryRun {
|
||||
t.Fatal("read-only fallback invocation was left in dry-run mode")
|
||||
}
|
||||
if !flags.DryRun {
|
||||
t.Fatal("RunReadOnly mutated the process-wide dry-run flag")
|
||||
}
|
||||
|
||||
if _, err := runner.Run(context.Background(), invocation); err != nil {
|
||||
t.Fatalf("ordinary Run() error = %v", err)
|
||||
}
|
||||
if got := fallback.calls.Load(); got != 1 {
|
||||
t.Fatalf("ordinary dry-run reached fallback; calls = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type countingErrorRunner struct {
|
||||
calls atomic.Int64
|
||||
}
|
||||
@@ -64,3 +157,47 @@ func (r *countingErrorRunner) Run(context.Context, executor.Invocation) (executo
|
||||
r.calls.Add(1)
|
||||
return executor.Result{}, errors.New("runner must not be called")
|
||||
}
|
||||
|
||||
type readOnlyDryRunRunner struct {
|
||||
regularCalls atomic.Int64
|
||||
readCalls atomic.Int64
|
||||
invocation executor.Invocation
|
||||
}
|
||||
|
||||
func (r *readOnlyDryRunRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
|
||||
r.regularCalls.Add(1)
|
||||
return executor.Result{}, errors.New("regular runner must not be called")
|
||||
}
|
||||
|
||||
func (r *readOnlyDryRunRunner) RunReadOnly(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.readCalls.Add(1)
|
||||
r.invocation = invocation
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{"read": true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type capturingSuccessRunner struct {
|
||||
calls atomic.Int64
|
||||
invocation executor.Invocation
|
||||
}
|
||||
|
||||
func (r *capturingSuccessRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.calls.Add(1)
|
||||
r.invocation = invocation
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{"read": true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type failingReadOnlyRunner struct{}
|
||||
|
||||
func (*failingReadOnlyRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
|
||||
return executor.Result{}, errors.New("regular runner must not be called")
|
||||
}
|
||||
|
||||
func (*failingReadOnlyRunner) RunReadOnly(context.Context, executor.Invocation) (executor.Result, error) {
|
||||
return executor.Result{}, errors.New("read failed")
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "consume [event_key]",
|
||||
Use: "consume [event_key...]",
|
||||
Short: "订阅事件流并输出到 stdout",
|
||||
Long: `订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。
|
||||
|
||||
@@ -138,9 +138,14 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用
|
||||
SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览、确认后加
|
||||
--yes,绝不要 kill -9。
|
||||
|
||||
可提供多个 event_key。多事件会各自创建订阅和本地 consumer,但共享同一 bus、
|
||||
远程连接、输出和 duration/max-events。用户类事件必须共享一个 --user 或
|
||||
--open-dingtalk-id,群类事件必须共享一个 --group;用户类与群类不能混用。
|
||||
全部 consumer 就绪后 stderr 输出 [event] ready event_count=<n> bus_pid=<pid>。
|
||||
--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费
|
||||
通常不需要设置。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Args: cobra.ArbitraryArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
as, err := eventNormalizeAs(asIdentity)
|
||||
@@ -148,8 +153,17 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
return err
|
||||
}
|
||||
if as == "user" {
|
||||
personalOpts.EventKey = firstArg(args)
|
||||
personalOpts.EventKeys = dedupePersonalEventKeys(args)
|
||||
personalOpts.EventKey = firstArg(personalOpts.EventKeys)
|
||||
personalOpts.Flatten = flatten
|
||||
if len(personalOpts.EventKeys) > 1 {
|
||||
if err := rejectPersonalMultiEventFlags(c,
|
||||
"subscribe-id", "rule", "event-types", "filter",
|
||||
"foreground", "force", "debug-raw-events",
|
||||
); err != nil {
|
||||
return fmt.Errorf("event consume: %w", err)
|
||||
}
|
||||
}
|
||||
personalOpts.Common = commonConsumeOptions{
|
||||
EventTypes: eventTypes,
|
||||
Filter: filter,
|
||||
@@ -329,7 +343,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
f.StringVar(&personalOpts.GroupID, "group", "",
|
||||
"group 规则:openConversationId")
|
||||
f.StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "",
|
||||
"个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
|
||||
"个人事件控制面 base URL;默认由 MCP base 派生 /dws")
|
||||
f.BoolVar(&personalOpts.DebugRawEvents, "debug-raw-events", false,
|
||||
"个人事件联调:绕过本地 event type/subscribe_id 过滤,输出当前 personal stream bus 收到的所有事件")
|
||||
f.StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")),
|
||||
@@ -337,13 +351,14 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
f.StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
|
||||
"个人 Stream sourceId;开源版默认 open,可由 edition 覆盖")
|
||||
f.StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
|
||||
"个人 Stream 取票 URL;默认由 MCP base URL 派生")
|
||||
"个人 Stream 取票 URL;默认由 MCP base 派生 /stream/connections/ticket")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
Name: "event_key",
|
||||
Type: "string",
|
||||
Description: "要消费的个人事件码;省略时仅适用于显式配置其它事件来源的兼容模式",
|
||||
Description: "要消费的一个或多个个人事件码;多个事件必须共享同一目标和过滤上下文",
|
||||
Required: false,
|
||||
Variadic: true,
|
||||
Index: 0,
|
||||
})
|
||||
return cmd
|
||||
@@ -777,7 +792,7 @@ func newEventStatusCommand() *cobra.Command {
|
||||
cmd.Flags().StringVar(&personalOpts.EventKey, "event", "", "个人事件 event_key 过滤")
|
||||
cmd.Flags().StringVar(&personalOpts.Status, "status", "active", "个人订阅状态过滤: active|paused|error|deleted|all")
|
||||
cmd.Flags().StringVar(&personalOpts.SubscribeID, "subscribe-id", "", "个人订阅 ID 过滤")
|
||||
cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
|
||||
cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
|
||||
cmd.Flags().StringVar(&personalOpts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
|
||||
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
|
||||
hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id", "fail-on-orphan")
|
||||
@@ -1075,7 +1090,7 @@ func newEventStopCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
|
||||
cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
|
||||
cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
|
||||
cmd.Flags().StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
|
||||
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
|
||||
cmd.Flags().BoolVar(&opts.All, "all", false, "取消当前身份下本地记录的所有个人订阅")
|
||||
@@ -1192,6 +1207,19 @@ func rejectPersonalEventUnsupportedFlags(c *cobra.Command, names ...string) erro
|
||||
return fmt.Errorf("%s are not supported for personal events", strings.Join(changed, ", "))
|
||||
}
|
||||
|
||||
func rejectPersonalMultiEventFlags(c *cobra.Command, names ...string) error {
|
||||
changed := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
if f := c.Flags().Lookup(name); f != nil && f.Changed {
|
||||
changed = append(changed, "--"+name)
|
||||
}
|
||||
}
|
||||
if len(changed) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s are not supported when consuming multiple events", strings.Join(changed, ", "))
|
||||
}
|
||||
|
||||
func rejectChangedFlags(c *cobra.Command, supportedAs string, names ...string) error {
|
||||
changed := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
|
||||
@@ -61,6 +61,7 @@ type commonConsumeOptions struct {
|
||||
type personalConsumeOptions struct {
|
||||
Common commonConsumeOptions
|
||||
EventKey string
|
||||
EventKeys []string
|
||||
Flatten bool
|
||||
DebugRawEvents bool
|
||||
SubscribeID string
|
||||
@@ -112,6 +113,7 @@ type personalStreamSourceOptions struct {
|
||||
|
||||
var (
|
||||
personalResolveEventIdentity = resolvePersonalEventIdentity
|
||||
personalLookupDefinition = personal.Lookup
|
||||
personalEnsureSubscription = ensurePersonalSubscription
|
||||
personalGetSubscription = (*personal.Client).GetSubscription
|
||||
personalCreateSubscription = (*personal.Client).CreateSubscription
|
||||
@@ -121,6 +123,7 @@ var (
|
||||
personalRemoveRunStates = personal.RemoveRunStates
|
||||
personalLoadRunStates = personal.LoadRunStates
|
||||
personalConsumeRun = consume.Run
|
||||
personalConsumeRunMany = consume.RunMany
|
||||
personalValidateConsumeConfig = consume.ValidateConfig
|
||||
personalValidateNoOutputConflict = consume.ValidateNoOutputConflict
|
||||
personalNewStreamSource = newPersonalStreamSource
|
||||
@@ -129,6 +132,7 @@ var (
|
||||
personalQueryEntry = busctl.QueryEntry
|
||||
personalQueryStatus = busctl.QueryStatus
|
||||
personalStopBus = busctl.Stop
|
||||
personalStopConsumers = busctl.StopConsumers
|
||||
personalFindProcess = os.FindProcess
|
||||
personalSignalProcess = (*os.Process).Signal
|
||||
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
|
||||
@@ -206,6 +210,21 @@ func renderPersonalSchema(w io.Writer, def personal.Definition, format string, f
|
||||
}
|
||||
|
||||
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
keys := dedupePersonalEventKeys(opts.EventKeys)
|
||||
if len(keys) == 0 && strings.TrimSpace(opts.EventKey) != "" {
|
||||
keys = []string{strings.TrimSpace(opts.EventKey)}
|
||||
}
|
||||
if len(keys) <= 1 {
|
||||
if len(keys) == 1 {
|
||||
opts.EventKey = keys[0]
|
||||
}
|
||||
return runPersonalEventConsumeSingle(c, opts)
|
||||
}
|
||||
opts.EventKeys = keys
|
||||
return runPersonalEventConsumeMany(c, opts)
|
||||
}
|
||||
|
||||
func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
ctx := c.Context()
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return err
|
||||
@@ -375,6 +394,266 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
return err
|
||||
}
|
||||
|
||||
type personalMultiSubscription struct {
|
||||
Sub *personal.Subscription
|
||||
EventKey string
|
||||
RuleType string
|
||||
}
|
||||
|
||||
func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
plans, err := preparePersonalMultiOptions(opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
rawFormat = opts.Common.FormatRaw
|
||||
}
|
||||
normalised, fellback := consume.NormalizeFormat(rawFormat)
|
||||
if fellback && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
|
||||
}
|
||||
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
projector := personalEventProjector(false, opts.Flatten)
|
||||
|
||||
ctx := c.Context()
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
baseCfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
Format: normalised,
|
||||
Flatten: opts.Flatten,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Projector: projector,
|
||||
Stdout: c.OutOrStdout(),
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
}
|
||||
applyEventConsumeStdin(&baseCfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
|
||||
if err := personalValidateConsumeConfig(baseCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := personalValidateNoOutputConflict(baseCfg, o.Value.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.Common.DryRun {
|
||||
printPersonalMultiDryRun(c.ErrOrStderr(), baseCfg, plans)
|
||||
return nil
|
||||
}
|
||||
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
created := make([]personalMultiSubscription, 0, len(plans))
|
||||
cleanup := func() {
|
||||
ids := make([]string, 0, len(created))
|
||||
for i := len(created) - 1; i >= 0; i-- {
|
||||
id := strings.TrimSpace(created[i].Sub.SubscribeID)
|
||||
ids = append(ids, id)
|
||||
if err := personalDeleteSubscription(client, context.Background(), id); err != nil {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal subscription %s: %v\n", id, err)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
if err := personalRemoveRunStates(workDir, ids); err != nil {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal event run state: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
seenSubscribeIDs := make(map[string]struct{}, len(plans))
|
||||
for _, plan := range plans {
|
||||
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, plan)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("event consume --as user: create subscription for %s: %w", plan.EventKey, err)
|
||||
}
|
||||
if sub == nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("event consume --as user: server returned an empty subscription for %s", plan.EventKey)
|
||||
}
|
||||
id := strings.TrimSpace(sub.SubscribeID)
|
||||
if id == "" {
|
||||
cleanup()
|
||||
return fmt.Errorf("event consume --as user: server returned empty subscribe_id for %s", plan.EventKey)
|
||||
}
|
||||
if _, exists := seenSubscribeIDs[id]; exists {
|
||||
_ = personalDeleteSubscription(client, context.Background(), id)
|
||||
cleanup()
|
||||
return fmt.Errorf("event consume --as user: server returned duplicate subscribe_id %s", id)
|
||||
}
|
||||
seenSubscribeIDs[id] = struct{}{}
|
||||
item := personalMultiSubscription{Sub: sub, EventKey: eventKey, RuleType: ruleType}
|
||||
created = append(created, item)
|
||||
if err := personalUpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: id,
|
||||
EventKey: eventKey,
|
||||
RuleType: ruleType,
|
||||
ClientID: identity.ClientID,
|
||||
SourceID: identity.SourceID,
|
||||
IdentityHash: identityHash,
|
||||
}); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("event consume --as user: save run state for %s: %w", eventKey, err)
|
||||
}
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
specs := make([]consume.ConsumerSpec, 0, len(created))
|
||||
for _, item := range created {
|
||||
specs = append(specs, consume.ConsumerSpec{
|
||||
EventKey: item.EventKey,
|
||||
EventTypes: []string{item.EventKey},
|
||||
SubscribeID: item.Sub.SubscribeID,
|
||||
ReadySubscribeID: item.Sub.SubscribeID,
|
||||
})
|
||||
}
|
||||
if err := personalConsumeRunMany(ctx, baseCfg, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsumeOptions, error) {
|
||||
if strings.TrimSpace(opts.SubscribeID) != "" {
|
||||
return nil, errors.New("--subscribe-id is not supported when consuming multiple events")
|
||||
}
|
||||
if strings.TrimSpace(opts.Rule) != "" {
|
||||
return nil, errors.New("--rule is not supported when consuming multiple events")
|
||||
}
|
||||
if len(opts.Common.EventTypes) > 0 {
|
||||
return nil, errors.New("--event-types is not supported when consuming multiple events; use event_key positionals")
|
||||
}
|
||||
if strings.TrimSpace(opts.Common.Filter) != "" {
|
||||
return nil, errors.New("--filter is not supported when consuming multiple events; use event_key positionals")
|
||||
}
|
||||
if opts.Common.Foreground || opts.Common.Force {
|
||||
return nil, errors.New("--foreground/--force are not supported when consuming multiple events")
|
||||
}
|
||||
if opts.DebugRawEvents {
|
||||
return nil, errors.New("--debug-raw-events is not supported when consuming multiple events")
|
||||
}
|
||||
|
||||
keys := dedupePersonalEventKeys(opts.EventKeys)
|
||||
if len(keys) < 2 {
|
||||
return nil, errors.New("multiple event keys are required")
|
||||
}
|
||||
hasUserScope := false
|
||||
hasGroupScope := false
|
||||
for _, eventKey := range keys {
|
||||
def, ok := personalLookupDefinition(eventKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown personal event key %q", eventKey)
|
||||
}
|
||||
if !def.Public {
|
||||
return nil, personal.PublicAvailabilityError(eventKey)
|
||||
}
|
||||
switch def.RuleType {
|
||||
case "singleChat", "sender":
|
||||
hasUserScope = true
|
||||
case "group":
|
||||
hasGroupScope = true
|
||||
}
|
||||
if (strings.TrimSpace(opts.QueryCSV) != "" || strings.TrimSpace(opts.FilterJSON) != "") && !personal.SupportsMessageFilter(eventKey) {
|
||||
return nil, fmt.Errorf("--query/--filter-json require all selected events to be message receive events; %s is not", eventKey)
|
||||
}
|
||||
}
|
||||
if hasUserScope && hasGroupScope {
|
||||
return nil, errors.New("user-scoped and group-scoped events cannot be consumed in one command")
|
||||
}
|
||||
userID := strings.TrimSpace(opts.UserID)
|
||||
openID := strings.TrimSpace(opts.OpenDingTalkID)
|
||||
groupID := strings.TrimSpace(opts.GroupID)
|
||||
if userID != "" && openID != "" {
|
||||
return nil, errors.New("--user and --open-dingtalk-id are mutually exclusive")
|
||||
}
|
||||
switch {
|
||||
case hasUserScope:
|
||||
if groupID != "" {
|
||||
return nil, errors.New("--group cannot be used with user-scoped events")
|
||||
}
|
||||
if userID == "" && openID == "" {
|
||||
return nil, errors.New("one of --user or --open-dingtalk-id is required for the selected events")
|
||||
}
|
||||
case hasGroupScope:
|
||||
if userID != "" || openID != "" {
|
||||
return nil, errors.New("--user/--open-dingtalk-id cannot be used with group-scoped events")
|
||||
}
|
||||
if groupID == "" {
|
||||
return nil, errors.New("--group is required for the selected events")
|
||||
}
|
||||
default:
|
||||
if userID != "" || openID != "" || groupID != "" {
|
||||
return nil, errors.New("the selected events do not use --user, --open-dingtalk-id, or --group")
|
||||
}
|
||||
}
|
||||
|
||||
plans := make([]personalConsumeOptions, 0, len(keys))
|
||||
for _, eventKey := range keys {
|
||||
def, _ := personalLookupDefinition(eventKey)
|
||||
plan := opts
|
||||
plan.EventKey = eventKey
|
||||
plan.EventKeys = nil
|
||||
switch def.RuleType {
|
||||
case "at", "all":
|
||||
plan.UserID = ""
|
||||
plan.OpenDingTalkID = ""
|
||||
plan.GroupID = ""
|
||||
case "singleChat", "sender":
|
||||
plan.GroupID = ""
|
||||
case "group":
|
||||
plan.UserID = ""
|
||||
plan.OpenDingTalkID = ""
|
||||
}
|
||||
if err := validatePersonalSubscriptionOptions(plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
func printPersonalMultiDryRun(w io.Writer, cfg consume.Config, plans []personalConsumeOptions) {
|
||||
preview := cfg
|
||||
preview.EventTypes = make([]string, 0, len(plans))
|
||||
for _, plan := range plans {
|
||||
preview.EventTypes = append(preview.EventTypes, plan.EventKey)
|
||||
}
|
||||
consume.PrintDryRun(w, preview)
|
||||
for i, plan := range plans {
|
||||
ruleType, ruleParam, _ := personal.BuildRuleParam(plan.EventKey, personal.RuleOptions{
|
||||
UserID: plan.UserID, OpenDingTalkID: plan.OpenDingTalkID, GroupID: plan.GroupID,
|
||||
})
|
||||
_, filter, _ := personal.BuildFilter(plan.FilterJSON, plan.QueryCSV)
|
||||
ruleJSON, _ := personal.CanonicalJSON(ruleParam)
|
||||
fmt.Fprintf(w, " subscription[%d] : event_key=%s rule_type=%s rule_param=%s",
|
||||
i, plan.EventKey, ruleType, ruleJSON)
|
||||
if filter != "" {
|
||||
fmt.Fprintf(w, " filter=%s", filter)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func personalEventProjector(debugRawEvents, flatten bool) consume.Projector {
|
||||
if debugRawEvents {
|
||||
return func(ev transport.Event) (any, error) { return ev, nil }
|
||||
@@ -648,7 +927,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
if err := personalRemoveRunStates(workDir, subscribeIDs); err != nil {
|
||||
return fmt.Errorf("event stop --as user: update local state: %w", err)
|
||||
}
|
||||
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
|
||||
if err := stopPersonalConsumers(c.ErrOrStderr(), ipcEndpoint, subscribeIDs); err != nil {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
|
||||
}
|
||||
|
||||
@@ -736,6 +1015,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
|
||||
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
|
||||
return err
|
||||
} else {
|
||||
fmt.Fprintf(w, "WARN: running bus does not support targeted consumer stop; falling back to process signal: %v\n", err)
|
||||
}
|
||||
return interruptPersonalConsumers(ipcEndpoint, subscribeIDs)
|
||||
}
|
||||
|
||||
func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, busState string) {
|
||||
if single && len(subscribeIDs) == 1 {
|
||||
fmt.Fprintf(w, "cancelled personal subscription %s; %s\n", subscribeIDs[0], busState)
|
||||
@@ -968,6 +1258,23 @@ func firstNonEmptyPersonalString(values ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func dedupePersonalEventKeys(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func personalEventControlBaseURL(raw, configDir string) string {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
@@ -993,7 +1300,7 @@ func personalEventMCPBaseURL(configDir string) string {
|
||||
if v := configuredMCPBaseURL(configDir); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return config.DefaultMCPBaseURL
|
||||
return strings.TrimRight(config.DefaultMCPBaseURL, "/")
|
||||
}
|
||||
|
||||
func configuredMCPBaseURL(configDir string) string {
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestEventConsumeAcceptsOrderedVariadicEventKeys(t *testing.T) {
|
||||
oldRun := eventRunPersonalConsume
|
||||
defer func() { eventRunPersonalConsume = oldRun }()
|
||||
var got personalConsumeOptions
|
||||
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
|
||||
got = opts
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{
|
||||
personal.EventMention,
|
||||
personal.EventSingleChat,
|
||||
personal.EventMention,
|
||||
"--user", "test-user-001",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
want := []string{personal.EventMention, personal.EventSingleChat}
|
||||
if !reflect.DeepEqual(got.EventKeys, want) || got.EventKey != personal.EventMention {
|
||||
t.Fatalf("event keys = %#v, first = %q", got.EventKeys, got.EventKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparePersonalMultiOptionsCombinationMatrix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts personalConsumeOptions
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "no target events",
|
||||
opts: personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}},
|
||||
},
|
||||
{
|
||||
name: "user and no target",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O, personal.EventMention},
|
||||
UserID: "test-user-001",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "group and no target",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated, personal.EventMention},
|
||||
GroupID: "cid-test",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "open dingtalk id",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventSingleChat, personal.EventRecallO2O},
|
||||
OpenDingTalkID: "open-test-user",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user and group mixed",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventSingleChat, personal.EventInChat},
|
||||
UserID: "test-user-001",
|
||||
},
|
||||
wantErr: "cannot be consumed in one command",
|
||||
},
|
||||
{
|
||||
name: "duplicate keys collapse to one",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventMention},
|
||||
},
|
||||
wantErr: "multiple event keys are required",
|
||||
},
|
||||
{
|
||||
name: "unknown event",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, "user_im_unknown"},
|
||||
},
|
||||
wantErr: "unknown personal event key",
|
||||
},
|
||||
{
|
||||
name: "missing user target",
|
||||
opts: personalConsumeOptions{EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O}},
|
||||
wantErr: "one of --user or --open-dingtalk-id",
|
||||
},
|
||||
{
|
||||
name: "missing group target",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated},
|
||||
},
|
||||
wantErr: "--group is required",
|
||||
},
|
||||
{
|
||||
name: "user identity flags conflict",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
|
||||
UserID: "test-user-001",
|
||||
OpenDingTalkID: "open-test-user",
|
||||
},
|
||||
wantErr: "mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "group target on user events",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
|
||||
UserID: "test-user-001",
|
||||
GroupID: "cid-test",
|
||||
},
|
||||
wantErr: "--group cannot be used",
|
||||
},
|
||||
{
|
||||
name: "user target on group events",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated},
|
||||
UserID: "test-user-001",
|
||||
GroupID: "cid-test",
|
||||
},
|
||||
wantErr: "cannot be used with group-scoped events",
|
||||
},
|
||||
{
|
||||
name: "target on no target events",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
UserID: "test-user-001",
|
||||
},
|
||||
wantErr: "do not use --user",
|
||||
},
|
||||
{
|
||||
name: "filter message events",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllGroupChat},
|
||||
QueryCSV: "alarm",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filter mixed with action",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
|
||||
UserID: "test-user-001",
|
||||
QueryCSV: "alarm",
|
||||
},
|
||||
wantErr: "require all selected events to be message receive events",
|
||||
},
|
||||
{
|
||||
name: "invalid message filter",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
FilterJSON: "{",
|
||||
},
|
||||
wantErr: "filter",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
plans, err := preparePersonalMultiOptions(test.opts)
|
||||
if test.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("preparePersonalMultiOptions() error = %v", err)
|
||||
}
|
||||
if len(plans) != len(test.opts.EventKeys) {
|
||||
t.Fatalf("plans = %d, want %d", len(plans), len(test.opts.EventKeys))
|
||||
}
|
||||
for _, plan := range plans {
|
||||
def, _ := personal.Lookup(plan.EventKey)
|
||||
if def.RuleType == "at" || def.RuleType == "all" {
|
||||
if plan.UserID != "" || plan.OpenDingTalkID != "" || plan.GroupID != "" {
|
||||
t.Fatalf("no-target plan retained target: %#v", plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparePersonalMultiOptionsRejectsNonPublicEvent(t *testing.T) {
|
||||
oldLookup := personalLookupDefinition
|
||||
t.Cleanup(func() { personalLookupDefinition = oldLookup })
|
||||
personalLookupDefinition = func(eventKey string) (personal.Definition, bool) {
|
||||
def, ok := personal.Lookup(eventKey)
|
||||
if eventKey == personal.EventAllSingleChat {
|
||||
def.Public = false
|
||||
}
|
||||
return def, ok
|
||||
}
|
||||
|
||||
_, err := preparePersonalMultiOptions(personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "not publicly available") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupePersonalEventKeysSkipsEmptyValues(t *testing.T) {
|
||||
got := dedupePersonalEventKeys([]string{"", " event-a ", "event-a", "event-b"})
|
||||
if !reflect.DeepEqual(got, []string{"event-a", "event-b"}) {
|
||||
t.Fatalf("deduped keys = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparePersonalMultiOptionsRejectsSingleOnlyFlags(t *testing.T) {
|
||||
base := personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}}
|
||||
tests := []struct {
|
||||
name string
|
||||
set func(*personalConsumeOptions)
|
||||
}{
|
||||
{name: "subscribe-id", set: func(o *personalConsumeOptions) { o.SubscribeID = "sub" }},
|
||||
{name: "rule", set: func(o *personalConsumeOptions) { o.Rule = "all" }},
|
||||
{name: "event-types", set: func(o *personalConsumeOptions) { o.Common.EventTypes = []string{"x"} }},
|
||||
{name: "filter", set: func(o *personalConsumeOptions) { o.Common.Filter = "x" }},
|
||||
{name: "foreground", set: func(o *personalConsumeOptions) { o.Common.Foreground = true }},
|
||||
{name: "force", set: func(o *personalConsumeOptions) { o.Common.Force = true }},
|
||||
{name: "debug-raw-events", set: func(o *personalConsumeOptions) { o.DebugRawEvents = true }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
opts := base
|
||||
test.set(&opts)
|
||||
if _, err := preparePersonalMultiOptions(opts); err == nil {
|
||||
t.Fatal("option succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
|
||||
oldRun := eventRunPersonalConsume
|
||||
defer func() { eventRunPersonalConsume = oldRun }()
|
||||
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error {
|
||||
t.Fatal("personal consume ran after explicit multi-event flag")
|
||||
return nil
|
||||
}
|
||||
|
||||
flags := []string{
|
||||
"--subscribe-id=",
|
||||
"--rule=",
|
||||
"--event-types=",
|
||||
"--filter=",
|
||||
"--foreground=false",
|
||||
"--force=false",
|
||||
"--debug-raw-events=false",
|
||||
}
|
||||
for _, flag := range flags {
|
||||
t.Run(flag, func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{personal.EventMention, personal.EventAllSingleChat, flag})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported when consuming multiple events") {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersonalEventConsumeManyCreatesAndCleansAllSubscriptions(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
identity := personal.Identity{AccessToken: "token", CorpID: "corp", UserID: "user", ClientID: "client", SourceID: "open"}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil }
|
||||
createdKeys := make([]string, 0, 2)
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
createdKeys = append(createdKeys, opts.EventKey)
|
||||
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "all", nil
|
||||
}
|
||||
var states []personal.RunState
|
||||
personalUpsertRunState = func(_ string, state personal.RunState) error {
|
||||
states = append(states, state)
|
||||
return nil
|
||||
}
|
||||
var deleted []string
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
|
||||
deleted = append(deleted, id)
|
||||
return nil
|
||||
}
|
||||
var removed []string
|
||||
personalRemoveRunStates = func(_ string, ids []string) error {
|
||||
removed = append(removed, ids...)
|
||||
return nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
|
||||
if !cfg.Flatten || cfg.Projector == nil || len(specs) != 2 {
|
||||
t.Fatalf("consume config/specs = %#v / %#v", cfg, specs)
|
||||
}
|
||||
for i, spec := range specs {
|
||||
if spec.EventKey != createdKeys[i] || spec.SubscribeID != "sub-"+createdKeys[i] || !reflect.DeepEqual(spec.EventTypes, []string{createdKeys[i]}) {
|
||||
t.Fatalf("spec[%d] = %#v", i, spec)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newPersonalCoverageCommand()
|
||||
err := runPersonalEventConsume(cmd, personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
Flatten: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runPersonalEventConsume() error = %v", err)
|
||||
}
|
||||
if len(states) != 2 || len(deleted) != 2 || len(removed) != 2 {
|
||||
t.Fatalf("states=%#v deleted=%#v removed=%#v", states, deleted, removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersonalEventConsumeManyRollsBackPartialCreation(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
||||
}
|
||||
wantErr := errors.New("second subscription failed")
|
||||
calls := 0
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, "", "", wantErr
|
||||
}
|
||||
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
var deleted []string
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
|
||||
deleted = append(deleted, id)
|
||||
return nil
|
||||
}
|
||||
var removed []string
|
||||
personalRemoveRunStates = func(_ string, ids []string) error {
|
||||
removed = append(removed, ids...)
|
||||
return nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
|
||||
t.Fatal("RunMany called after partial creation failure")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
|
||||
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ensure func(int, personalConsumeOptions) *personal.Subscription
|
||||
upsertErr error
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "nil subscription",
|
||||
ensure: func(int, personalConsumeOptions) *personal.Subscription { return nil },
|
||||
wantErr: "empty subscription",
|
||||
},
|
||||
{
|
||||
name: "empty subscribe id",
|
||||
ensure: func(int, personalConsumeOptions) *personal.Subscription { return &personal.Subscription{} },
|
||||
wantErr: "empty subscribe_id",
|
||||
},
|
||||
{
|
||||
name: "duplicate subscribe id",
|
||||
ensure: func(int, personalConsumeOptions) *personal.Subscription {
|
||||
return &personal.Subscription{SubscribeID: "sub-duplicate"}
|
||||
},
|
||||
wantErr: "duplicate subscribe_id",
|
||||
},
|
||||
{
|
||||
name: "run state write failure",
|
||||
ensure: func(_ int, opts personalConsumeOptions) *personal.Subscription {
|
||||
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}
|
||||
},
|
||||
upsertErr: errors.New("state write failed"),
|
||||
wantErr: "save run state",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
||||
}
|
||||
calls := 0
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
calls++
|
||||
return test.ensure(calls, opts), opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return test.upsertErr }
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
|
||||
t.Fatal("RunMany called with invalid subscription result")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersonalEventConsumeManyDryRunDoesNotCreateSubscriptions(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
||||
}
|
||||
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
t.Fatal("dry-run created a subscription")
|
||||
return nil, "", "", nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
|
||||
cmd := newPersonalCoverageCommand()
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
err := runPersonalEventConsume(cmd, personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
QueryCSV: "alarm",
|
||||
Common: commonConsumeOptions{DryRun: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "subscription[0]") ||
|
||||
!strings.Contains(stderr.String(), "subscription[1]") ||
|
||||
!strings.Contains(stderr.String(), "filter=") {
|
||||
t.Fatalf("dry-run subscriptions missing:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t *testing.T) {
|
||||
valid := personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}}
|
||||
|
||||
t.Run("prepare error", func(t *testing.T) {
|
||||
err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "multiple event keys") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("format warning and identity error", func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
cmd := newPersonalCoverageCommand()
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
if err := cmd.Flags().Set("format", "bogus"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantErr := errors.New("identity")
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{}, wantErr
|
||||
}
|
||||
opts := valid
|
||||
opts.Common.FormatRaw = "bogus"
|
||||
if err := runPersonalEventConsumeMany(cmd, opts); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "using ndjson") {
|
||||
t.Fatalf("warning = %q", stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flatten raw conflict", func(t *testing.T) {
|
||||
cmd := newPersonalCoverageCommand()
|
||||
if err := cmd.Flags().Set("format", "raw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts := valid
|
||||
opts.Flatten = true
|
||||
opts.Common.FormatRaw = "raw"
|
||||
if err := runPersonalEventConsumeMany(cmd, opts); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("route validation and output conflict", func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
||||
}
|
||||
|
||||
opts := valid
|
||||
opts.Common.RoutesRaw = []string{"bad-route"}
|
||||
if err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), opts); err == nil {
|
||||
t.Fatal("invalid route succeeded")
|
||||
}
|
||||
|
||||
wantErr := errors.New("validate")
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return wantErr }
|
||||
if err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), valid); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("config validation error = %v", err)
|
||||
}
|
||||
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return wantErr }
|
||||
cmd := newPersonalCoverageCommand()
|
||||
if err := cmd.Flags().Set("output", "events.json"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runPersonalEventConsumeMany(cmd, valid); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("output conflict error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("runtime error reports cleanup failures", func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
||||
}
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
cleanupErr := errors.New("cleanup")
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return cleanupErr }
|
||||
personalRemoveRunStates = func(string, []string) error { return cleanupErr }
|
||||
runErr := errors.New("run many")
|
||||
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error { return runErr }
|
||||
|
||||
cmd := newPersonalCoverageCommand()
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
if err := runPersonalEventConsumeMany(cmd, valid); !errors.Is(err, runErr) {
|
||||
t.Fatalf("runtime error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "failed to clean personal subscription") ||
|
||||
!strings.Contains(stderr.String(), "failed to clean personal event run state") {
|
||||
t.Fatalf("cleanup warnings = %q", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
oldStop := personalStopConsumers
|
||||
oldQuery := personalQueryStatus
|
||||
oldFind := personalFindProcess
|
||||
oldSignal := personalSignalProcess
|
||||
defer func() {
|
||||
personalStopConsumers = oldStop
|
||||
personalQueryStatus = oldQuery
|
||||
personalFindProcess = oldFind
|
||||
personalSignalProcess = oldSignal
|
||||
}()
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
|
||||
}
|
||||
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
||||
t.Fatal("legacy status queried after targeted stop succeeded")
|
||||
return nil, nil
|
||||
}
|
||||
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"sub-a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
return transport.ConsumerStopResp{}, busctl.ErrConsumerStopUnsupported
|
||||
}
|
||||
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
||||
return &transport.StatusResp{Consumers: []transport.StatusConsumer{{PID: 321, SubscribeID: "sub-a"}}}, nil
|
||||
}
|
||||
proc := &os.Process{}
|
||||
personalFindProcess = func(int) (*os.Process, error) { return proc, nil }
|
||||
signals := 0
|
||||
personalSignalProcess = func(*os.Process, os.Signal) error { signals++; return nil }
|
||||
var warning bytes.Buffer
|
||||
if err := stopPersonalConsumers(&warning, "endpoint", []string{"sub-a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if signals != 1 || !strings.Contains(warning.String(), "falling back to process signal") {
|
||||
t.Fatalf("signals=%d warning=%q", signals, warning.String())
|
||||
}
|
||||
|
||||
wantErr := errors.New("targeted stop transport failed")
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
return transport.ConsumerStopResp{}, wantErr
|
||||
}
|
||||
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
||||
t.Fatal("legacy fallback ran for a non-compatibility error")
|
||||
return nil, nil
|
||||
}
|
||||
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"sub-a"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("transport error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func installPersonalManySeams(t *testing.T) func() {
|
||||
t.Helper()
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldLookup := personalLookupDefinition
|
||||
oldEnsure := personalEnsureSubscription
|
||||
oldUpsert := personalUpsertRunState
|
||||
oldDelete := personalDeleteSubscription
|
||||
oldRemove := personalRemoveRunStates
|
||||
oldRunMany := personalConsumeRunMany
|
||||
oldValidate := personalValidateConsumeConfig
|
||||
oldConflict := personalValidateNoOutputConflict
|
||||
return func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalLookupDefinition = oldLookup
|
||||
personalEnsureSubscription = oldEnsure
|
||||
personalUpsertRunState = oldUpsert
|
||||
personalDeleteSubscription = oldDelete
|
||||
personalRemoveRunStates = oldRemove
|
||||
personalConsumeRunMany = oldRunMany
|
||||
personalValidateConsumeConfig = oldValidate
|
||||
personalValidateNoOutputConflict = oldConflict
|
||||
}
|
||||
}
|
||||
@@ -47,12 +47,18 @@ func TestPersonalEventListHidesSchemaIDs(t *testing.T) {
|
||||
assertPersonalOutputHidesSchemaIDs(t, got)
|
||||
for _, eventKey := range []string{
|
||||
personal.EventFromUser,
|
||||
personal.EventAllSingleChat,
|
||||
personal.EventAllGroupChat,
|
||||
personal.EventReadO2O,
|
||||
personal.EventReadGroup,
|
||||
personal.EventRecallO2O,
|
||||
personal.EventRecallGroup,
|
||||
personal.EventReactionO2O,
|
||||
personal.EventReactionGroup,
|
||||
personal.EventGroupUpdated,
|
||||
personal.EventGroupMemberAdded,
|
||||
personal.EventGroupMemberExited,
|
||||
personal.EventGroupDisbanded,
|
||||
} {
|
||||
if !strings.Contains(got, eventKey) {
|
||||
t.Fatalf("list output missing %s: %s", eventKey, got)
|
||||
@@ -230,6 +236,8 @@ func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
personal.EventMention,
|
||||
personal.EventSingleChat,
|
||||
personal.EventInChat,
|
||||
personal.EventAllSingleChat,
|
||||
personal.EventAllGroupChat,
|
||||
} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
@@ -265,6 +273,8 @@ func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
"message_id",
|
||||
"create_time",
|
||||
"event_time",
|
||||
"quoted_message",
|
||||
"forward_messages",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("schema output for %s missing %q: %s", eventKey, want, got)
|
||||
@@ -309,6 +319,103 @@ func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
if _, ok := props["content"].(map[string]any); !ok {
|
||||
t.Fatalf("schema.properties.content = %#v, want object", props["content"])
|
||||
}
|
||||
quoted, ok := props["quoted_message"].(map[string]any)
|
||||
if !ok || quoted["type"] != "object" {
|
||||
t.Fatalf("schema.properties.quoted_message = %#v, want object", props["quoted_message"])
|
||||
}
|
||||
forward, ok := props["forward_messages"].(map[string]any)
|
||||
if !ok || forward["type"] != "array" {
|
||||
t.Fatalf("schema.properties.forward_messages = %#v, want array", props["forward_messages"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalGroupLifecycleEventSchemaUsesConservativePayload(t *testing.T) {
|
||||
for _, eventKey := range []string{personal.EventGroupUpdated, personal.EventGroupDisbanded} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{eventKey, "--flatten"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if doc["event_key"] != eventKey || doc["rule_type"] != "group" || doc["jq_root_path"] != "." {
|
||||
t.Fatalf("schema metadata = %#v", doc)
|
||||
}
|
||||
required, ok := doc["required_params"].([]any)
|
||||
if !ok || len(required) != 1 || required[0] != "group" {
|
||||
t.Fatalf("required_params = %#v, want [group]", doc["required_params"])
|
||||
}
|
||||
schema := doc["schema"].(map[string]any)
|
||||
properties := schema["properties"].(map[string]any)
|
||||
if len(properties) != 5 {
|
||||
t.Fatalf("schema.properties = %#v, want five conservative fields", properties)
|
||||
}
|
||||
payload, ok := properties["payload"].(map[string]any)
|
||||
if !ok || payload["type"] != "object" || payload["additionalProperties"] != true {
|
||||
t.Fatalf("schema.properties.payload = %#v", properties["payload"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalGroupMemberEventSchemaMatchesFlatOutput(t *testing.T) {
|
||||
wantProperties := []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "conversation_id",
|
||||
"operator", "operator_open_dingtalk_id", "members", "event_time",
|
||||
}
|
||||
for _, eventKey := range []string{personal.EventGroupMemberAdded, personal.EventGroupMemberExited} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{eventKey, "--flatten"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if doc["event_key"] != eventKey || doc["rule_type"] != "group" || doc["jq_root_path"] != "." {
|
||||
t.Fatalf("schema metadata = %#v", doc)
|
||||
}
|
||||
properties := doc["schema"].(map[string]any)["properties"].(map[string]any)
|
||||
if len(properties) != len(wantProperties) {
|
||||
t.Fatalf("schema.properties = %#v, want exactly %d flat fields", properties, len(wantProperties))
|
||||
}
|
||||
for _, field := range wantProperties {
|
||||
if _, ok := properties[field].(map[string]any); !ok {
|
||||
t.Fatalf("schema missing %q: %#v", field, properties)
|
||||
}
|
||||
}
|
||||
members := properties["members"].(map[string]any)
|
||||
items, ok := members["items"].(map[string]any)
|
||||
if !ok || members["type"] != "array" || items["type"] != "object" {
|
||||
t.Fatalf("members schema = %#v", members)
|
||||
}
|
||||
memberProperties, ok := items["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("members.items.properties = %#v", items["properties"])
|
||||
}
|
||||
for _, field := range []string{"nick", "open_dingtalk_id"} {
|
||||
if _, ok := memberProperties[field].(map[string]any); !ok {
|
||||
t.Fatalf("member schema missing %q: %#v", field, memberProperties)
|
||||
}
|
||||
}
|
||||
if _, ok := properties["payload"]; ok {
|
||||
t.Fatalf("group member schema exposed generic payload: %#v", properties)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -485,6 +592,64 @@ func TestPersonalEventFromUserIsPubliclyAvailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalNewIMEventsDryRunAndValidation(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
RefreshToken: "refresh-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
ClientID: "client-1",
|
||||
})
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
for _, test := range []struct {
|
||||
eventKey string
|
||||
args []string
|
||||
}{
|
||||
{eventKey: personal.EventAllSingleChat},
|
||||
{eventKey: personal.EventAllGroupChat},
|
||||
{eventKey: personal.EventGroupUpdated, args: []string{"--group", "cid-test-group"}},
|
||||
{eventKey: personal.EventGroupMemberAdded, args: []string{"--group", "cid-test-group"}},
|
||||
{eventKey: personal.EventGroupMemberExited, args: []string{"--group", "cid-test-group"}},
|
||||
{eventKey: personal.EventGroupDisbanded, args: []string{"--group", "cid-test-group"}},
|
||||
} {
|
||||
t.Run(test.eventKey, func(t *testing.T) {
|
||||
if err := ensurePublicPersonalEvent(test.eventKey); err != nil {
|
||||
t.Fatalf("ensurePublicPersonalEvent() error = %v", err)
|
||||
}
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs(append([]string{test.eventKey}, append(test.args, "--dry-run")...))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("dry-run Execute() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, eventKey := range []string{personal.EventAllSingleChat, personal.EventAllGroupChat} {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{eventKey, "--user", "test-user-001", "--dry-run"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+eventKey) {
|
||||
t.Fatalf("%s scoped user error = %v", eventKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, eventKey := range []string{personal.EventGroupUpdated, personal.EventGroupMemberAdded, personal.EventGroupMemberExited, personal.EventGroupDisbanded} {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{eventKey, "--dry-run"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "--group is required for "+eventKey) {
|
||||
t.Fatalf("%s missing group error = %v", eventKey, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
root.SilenceUsage = true
|
||||
@@ -550,6 +715,14 @@ func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
|
||||
t.Fatalf("schema constraint %s = %#v, missing %#v", field, groups, want)
|
||||
}
|
||||
assertJSONConstraintGroup("require_one_of", []string{"event_key", "subscribe-id"})
|
||||
positionals, ok := doc["positionals"].([]any)
|
||||
if !ok || len(positionals) != 1 {
|
||||
t.Fatalf("schema positionals = %#v", doc["positionals"])
|
||||
}
|
||||
eventKey, ok := positionals[0].(map[string]any)
|
||||
if !ok || eventKey["name"] != "event_key" || eventKey["variadic"] != true {
|
||||
t.Fatalf("event_key positional = %#v, want variadic", positionals[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) {
|
||||
|
||||
@@ -353,6 +353,13 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
return cmd.Help()
|
||||
},
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Validate the optional observation label before any edition hook
|
||||
// or command network activity can run. Header-only library callers
|
||||
// use the best-effort path in resolveIdentityHeaders instead.
|
||||
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile(flags.Profile)
|
||||
// Apply OAuth credential overrides from CLI flags (highest priority).
|
||||
if flags.ClientID != "" {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -41,6 +42,7 @@ func configureRootHelp(root *cobra.Command) {
|
||||
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd != root {
|
||||
defaultHelpFunc(cmd, args)
|
||||
cli.RenderSafetyAnnotation(cmd)
|
||||
return
|
||||
}
|
||||
renderRootHelp(root)
|
||||
|
||||
@@ -380,7 +380,7 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
||||
}
|
||||
|
||||
searchAdvanced := mustFindCommand(t, root, "chat", "message", "search-advanced")
|
||||
for _, flag := range []string{"sender", "senders", "sender-ids"} {
|
||||
for _, flag := range []string{"sender", "senders", "sender-ids", "message-type", "only-robot", "conversation-type"} {
|
||||
if searchAdvanced.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("chat message search-advanced missing --%s", flag)
|
||||
}
|
||||
|
||||
@@ -224,6 +224,24 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
return r.runSingle(ctx, invocation, true)
|
||||
}
|
||||
|
||||
// RunReadOnly executes one already-classified read lookup for a semantic
|
||||
// Shortcut that is building a dry-run plan. It clones the runtime flags and
|
||||
// clears DryRun only on that clone: the process-wide caller and every ordinary
|
||||
// ToolCaller invocation retain the global execution barrier.
|
||||
func (r *runtimeRunner) RunReadOnly(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
if r == nil {
|
||||
return executor.Result{}, fmt.Errorf("runtime runner is not configured")
|
||||
}
|
||||
clone := *r
|
||||
if r.globalFlags != nil {
|
||||
flags := *r.globalFlags
|
||||
flags.DryRun = false
|
||||
clone.globalFlags = &flags
|
||||
}
|
||||
invocation.DryRun = false
|
||||
return clone.Run(ctx, invocation)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
|
||||
if r.loader == nil || r.transport == nil {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
@@ -1038,6 +1056,14 @@ func resolveIdentityHeaders() map[string]string {
|
||||
headers["x-dws-channel"] = v
|
||||
}
|
||||
|
||||
// DWS_AGENT_HOST is a caller-provided observation label only. Root command
|
||||
// execution validates it strictly in PersistentPreRunE. Library callers
|
||||
// that bypass the root command keep this best-effort API contract: invalid
|
||||
// values are omitted rather than changing the public function signature.
|
||||
if agentHost, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err == nil && agentHost != "" {
|
||||
headers[headerDWSAgentHost] = agentHost
|
||||
}
|
||||
|
||||
if fn := edition.Get().MergeHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
// 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.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 265
|
||||
schemaPublishedShortcutCount = 210
|
||||
)
|
||||
|
||||
func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
tools := embeddedSchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
public := make([]shortcut.Shortcut, 0, publicShortcutCount)
|
||||
for _, candidate := range shortcut.All() {
|
||||
if candidate.UserDefined || !shortcut.InPublicCatalog(candidate.Service, candidate.Command) {
|
||||
continue
|
||||
}
|
||||
public = append(public, candidate)
|
||||
}
|
||||
if got := len(public); got != publicShortcutCount {
|
||||
t.Fatalf("public built-in shortcuts = %d, want %d", got, publicShortcutCount)
|
||||
}
|
||||
|
||||
deliveredShortcuts := 0
|
||||
for canonical := range tools {
|
||||
if strings.Contains(canonical, ".shortcut_") {
|
||||
deliveredShortcuts++
|
||||
}
|
||||
}
|
||||
if deliveredShortcuts != schemaPublishedShortcutCount {
|
||||
t.Fatalf("embedded schema --all shortcut tools = %d, want %d", deliveredShortcuts, schemaPublishedShortcutCount)
|
||||
}
|
||||
|
||||
exclusions, err := cli.EmbeddedRuntimeSchemaExclusions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
excludedPaths := make(map[string]bool, len(exclusions))
|
||||
for _, exclusion := range exclusions {
|
||||
if !exclusion.Reviewed || strings.TrimSpace(exclusion.Reason) == "" {
|
||||
t.Fatalf("unreviewed public command exclusion: %#v", exclusion)
|
||||
}
|
||||
excludedPaths[exclusion.CLIPath] = true
|
||||
}
|
||||
|
||||
excludedShortcuts := 0
|
||||
for _, declared := range public {
|
||||
declared := declared
|
||||
t.Run(declared.Service+"/"+strings.TrimPrefix(declared.Command, "+"), func(t *testing.T) {
|
||||
canonical := shortcutSchemaCanonical(declared)
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
cliPath := declared.Service + " " + declared.Command
|
||||
if !excludedPaths[cliPath] {
|
||||
t.Fatalf("embedded schema --all is missing %s (%s) without an exact reviewed exclusion", canonical, cliPath)
|
||||
}
|
||||
excludedShortcuts++
|
||||
return
|
||||
}
|
||||
assertEmbeddedShortcutIdentityAndSelection(t, tool, declared, canonical)
|
||||
assertEmbeddedShortcutSafetyAndInterface(t, tool, declared, canonical)
|
||||
assertEmbeddedShortcutParameters(t, tool, declared, canonical)
|
||||
assertEmbeddedShortcutConstraints(t, tool, declared, canonical)
|
||||
})
|
||||
}
|
||||
if got, want := excludedShortcuts, publicShortcutCount-schemaPublishedShortcutCount; got != want {
|
||||
t.Fatalf("exactly excluded public shortcuts = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "chat +messages-read-status")
|
||||
if got, want := schemaContractString(leaf["canonical_path"]), "chat.shortcut_messages_read_status"; got != want {
|
||||
t.Fatalf("shortcut leaf canonical_path = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := schemaContractString(leaf["confirmation"]), "not_required"; got != want {
|
||||
t.Fatalf("shortcut leaf confirmation = %q, want %q", got, want)
|
||||
}
|
||||
conversationID := schemaContractMap(leaf["parameters"])["conversation-id"]
|
||||
if required, _ := conversationID["required"].(bool); !required {
|
||||
t.Fatal("public --conversation-id must become required after hidden compatibility aliases are removed from Schema")
|
||||
}
|
||||
if got := leaf["constraints"]; got != nil {
|
||||
t.Fatalf("shortcut leaf constraints = %#v, want omitted after hidden compatibility aliases collapse", got)
|
||||
}
|
||||
|
||||
constrainedLeaf := executeShortcutSchemaQuery(t, "--cli-path", "calendar +freebusy")
|
||||
wantConstraints := map[string]any{
|
||||
"require_one_of": [][]string{{"users", "rooms"}},
|
||||
}
|
||||
if got := constrainedLeaf["constraints"]; !schemaContractJSONEqual(got, wantConstraints) {
|
||||
t.Fatalf("shortcut leaf constraints = %#v, want %#v", got, wantConstraints)
|
||||
}
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 120; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
shortcutCount := 0
|
||||
for _, summary := range summaries {
|
||||
if strings.HasPrefix(schemaContractString(summary["canonical_path"]), "chat.shortcut_") {
|
||||
shortcutCount++
|
||||
}
|
||||
}
|
||||
if shortcutCount != 42 {
|
||||
t.Fatalf("schema chat shortcut summaries = %d, want 42", shortcutCount)
|
||||
}
|
||||
}
|
||||
|
||||
func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any {
|
||||
t.Helper()
|
||||
root := NewRootCommand()
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs(append([]string{"schema"}, append(args, "--format", "json")...))
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute dws schema %q: %v; stderr=%s", strings.Join(args, " "), err, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode dws schema %q: %v", strings.Join(args, " "), err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func shortcutSchemaCanonical(declared shortcut.Shortcut) string {
|
||||
name := strings.ReplaceAll(strings.TrimPrefix(declared.Command, "+"), "-", "_")
|
||||
return declared.Service + ".shortcut_" + name
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutIdentityAndSelection(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
if got, want := schemaContractString(tool["canonical_path"]), canonical; got != want {
|
||||
t.Errorf("canonical_path = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := schemaContractString(tool["primary_cli_path"]), declared.Service+" "+declared.Command; got != want {
|
||||
t.Errorf("%s primary_cli_path = %q, want %q", canonical, got, want)
|
||||
}
|
||||
if got, want := schemaContractString(tool["agent_summary"]), declared.Description; got != want {
|
||||
t.Errorf("%s agent_summary = %q, want %q", canonical, got, want)
|
||||
}
|
||||
if got, want := schemaContractStringSlice(tool["use_when"]), []string{declared.Intent}; !schemaContractJSONEqual(got, want) {
|
||||
t.Errorf("%s use_when = %#v, want %#v", canonical, got, want)
|
||||
}
|
||||
if len(schemaContractStringSlice(tool["avoid_when"])) == 0 {
|
||||
t.Errorf("%s has no reviewed avoid_when", canonical)
|
||||
}
|
||||
examples := schemaContractStringSlice(tool["examples"])
|
||||
if len(examples) == 0 || len(examples) > 2 {
|
||||
t.Errorf("%s examples = %d, want 1..2", canonical, len(examples))
|
||||
}
|
||||
for _, example := range examples {
|
||||
if strings.Contains(example, "--yes") {
|
||||
t.Errorf("%s stores unsafe example %q", canonical, example)
|
||||
}
|
||||
if !strings.HasPrefix(example, "dws "+declared.Service+" "+declared.Command) {
|
||||
t.Errorf("%s example does not use its primary path: %q", canonical, example)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutSafetyAndInterface(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
risk := declared.Risk
|
||||
if risk == "" {
|
||||
risk = shortcut.RiskRead
|
||||
}
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent"
|
||||
switch risk {
|
||||
case shortcut.RiskWrite:
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown"
|
||||
case shortcut.RiskHighWrite:
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown"
|
||||
}
|
||||
for field, want := range map[string]string{
|
||||
"effect": wantEffect,
|
||||
"risk": wantRisk,
|
||||
"confirmation": wantConfirmation,
|
||||
"idempotency": wantIdempotency,
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
} {
|
||||
if got := schemaContractString(tool[field]); got != want {
|
||||
t.Errorf("%s %s = %q, want %q", canonical, field, got, want)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(schemaContractString(tool["interface_reason"])) == "" {
|
||||
t.Errorf("%s has no reviewed composite interface reason", canonical)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutParameters(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
parameters := schemaContractMap(tool["parameters"])
|
||||
publicFlags := make([]shortcut.Flag, 0, len(declared.Flags))
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Hidden {
|
||||
publicFlags = append(publicFlags, flag)
|
||||
}
|
||||
}
|
||||
if got, want := len(parameters), len(publicFlags); got != want {
|
||||
t.Errorf("%s parameters = %d, want %d", canonical, got, want)
|
||||
}
|
||||
for _, flag := range publicFlags {
|
||||
parameter := parameters[flag.Name]
|
||||
if parameter == nil {
|
||||
t.Errorf("%s is missing parameter --%s", canonical, flag.Name)
|
||||
continue
|
||||
}
|
||||
flagType := flag.Type
|
||||
if flagType == "" {
|
||||
flagType = shortcut.FlagString
|
||||
}
|
||||
wantType := map[shortcut.FlagType]string{
|
||||
shortcut.FlagString: "string",
|
||||
shortcut.FlagBool: "boolean",
|
||||
shortcut.FlagInt: "integer",
|
||||
shortcut.FlagStringSlice: "array",
|
||||
}[flagType]
|
||||
if got := schemaContractString(parameter["type"]); got != wantType {
|
||||
t.Errorf("%s --%s type = %q, want %q", canonical, flag.Name, got, wantType)
|
||||
}
|
||||
if got, _ := parameter["required"].(bool); got != shortcutSchemaRequired(declared, flag.Name) {
|
||||
t.Errorf("%s --%s required = %t, want %t", canonical, flag.Name, got, shortcutSchemaRequired(declared, flag.Name))
|
||||
}
|
||||
if got, want := schemaContractString(parameter["default"]), shortcutSchemaDefault(flag); got != want {
|
||||
t.Errorf("%s --%s default = %q, want %q", canonical, flag.Name, got, want)
|
||||
}
|
||||
gotEnum := schemaContractStringSlice(parameter["enum"])
|
||||
if len(flag.Enum) == 0 {
|
||||
if len(gotEnum) != 0 {
|
||||
t.Errorf("%s --%s enum = %#v, want empty", canonical, flag.Name, gotEnum)
|
||||
}
|
||||
} else if !schemaContractJSONEqual(gotEnum, flag.Enum) {
|
||||
t.Errorf("%s --%s enum = %#v, want %#v", canonical, flag.Name, gotEnum, flag.Enum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shortcutSchemaDefault(flag shortcut.Flag) string {
|
||||
value := strings.TrimSpace(flag.Default)
|
||||
switch flag.Type {
|
||||
case shortcut.FlagBool:
|
||||
if value != "true" {
|
||||
return ""
|
||||
}
|
||||
case shortcut.FlagInt:
|
||||
if value == "0" {
|
||||
return ""
|
||||
}
|
||||
case shortcut.FlagStringSlice:
|
||||
if value != "" {
|
||||
return "[" + value + "]"
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
|
||||
for _, flag := range declared.Flags {
|
||||
if flag.Name == flagName && flag.Required {
|
||||
return true
|
||||
}
|
||||
}
|
||||
public := make(map[string]bool, len(declared.Flags))
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Hidden {
|
||||
public[flag.Name] = true
|
||||
}
|
||||
}
|
||||
for _, constraint := range declared.Constraints {
|
||||
if constraint.Kind != shortcut.ConstraintAtLeastOne && constraint.Kind != shortcut.ConstraintExactlyOne {
|
||||
continue
|
||||
}
|
||||
visible := make([]string, 0, len(constraint.Flags))
|
||||
for _, constrained := range constraint.Flags {
|
||||
if public[constrained] {
|
||||
visible = append(visible, constrained)
|
||||
}
|
||||
}
|
||||
if len(visible) == 1 && visible[0] == flagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutConstraints(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
public := make(map[string]bool, len(declared.Flags))
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Hidden {
|
||||
public[flag.Name] = true
|
||||
}
|
||||
}
|
||||
want := map[string][][]string{}
|
||||
for _, constraint := range declared.Constraints {
|
||||
flags := make([]string, 0, len(constraint.Flags))
|
||||
for _, flagName := range constraint.Flags {
|
||||
if public[flagName] {
|
||||
flags = append(flags, flagName)
|
||||
}
|
||||
}
|
||||
switch constraint.Kind {
|
||||
case shortcut.ConstraintAtLeastOne:
|
||||
if len(flags) > 1 {
|
||||
want["require_one_of"] = append(want["require_one_of"], flags)
|
||||
}
|
||||
case shortcut.ConstraintExactlyOne:
|
||||
if len(flags) > 1 {
|
||||
want["require_one_of"] = append(want["require_one_of"], flags)
|
||||
want["mutually_exclusive"] = append(want["mutually_exclusive"], flags)
|
||||
}
|
||||
case shortcut.ConstraintMutuallyExclusive:
|
||||
if len(flags) > 1 {
|
||||
want["mutually_exclusive"] = append(want["mutually_exclusive"], flags)
|
||||
}
|
||||
case shortcut.ConstraintCustom:
|
||||
for _, flagName := range flags {
|
||||
description := schemaContractString(schemaContractMap(tool["parameters"])[flagName]["description"])
|
||||
for _, evidence := range shortcutCustomConstraintEvidence(constraint.Description) {
|
||||
if !strings.Contains(description, evidence) {
|
||||
t.Errorf("%s --%s description does not publish custom constraint evidence %q: %q", canonical, flagName, evidence, description)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s has unsupported declared shortcut constraint %q", canonical, constraint.Kind)
|
||||
}
|
||||
}
|
||||
if len(want) == 0 {
|
||||
if got := tool["constraints"]; got != nil {
|
||||
t.Errorf("%s constraints = %#v, want omitted", canonical, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := tool["constraints"]; !schemaContractJSONEqual(got, want) {
|
||||
t.Errorf("%s constraints = %s, want %s", canonical, mustShortcutJSON(got), mustShortcutJSON(want))
|
||||
}
|
||||
}
|
||||
|
||||
func shortcutCustomConstraintEvidence(description string) []string {
|
||||
// Custom constraints are prose rather than a typed wire contract. Require
|
||||
// their decision-relevant facts to survive in the delivered parameter
|
||||
// description while allowing the renderer to reorder connective wording.
|
||||
probes := []string{
|
||||
"原文=>替换",
|
||||
"不能为空",
|
||||
"不能重复",
|
||||
"大于 0",
|
||||
"工作目录",
|
||||
"相对路径",
|
||||
"绝对路径",
|
||||
"..",
|
||||
"最多 15 个字符",
|
||||
}
|
||||
evidence := make([]string, 0, len(probes))
|
||||
for _, probe := range probes {
|
||||
if strings.Contains(description, probe) {
|
||||
evidence = append(evidence, probe)
|
||||
}
|
||||
}
|
||||
if len(evidence) > 0 {
|
||||
return evidence
|
||||
}
|
||||
return []string{strings.TrimSpace(description)}
|
||||
}
|
||||
|
||||
func mustShortcutJSON(value any) string {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%#v", value)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
@@ -66,6 +66,33 @@ func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName st
|
||||
return convertResult(result), nil
|
||||
}
|
||||
|
||||
type dryRunReadRunner interface {
|
||||
RunReadOnly(context.Context, executor.Invocation) (executor.Result, error)
|
||||
}
|
||||
|
||||
// CallReadTool executes a Shortcut's explicitly classified read lookup while
|
||||
// the outer command is in dry-run mode. Ordinary CallTool remains protected by
|
||||
// the global execution barrier. The runner capability is optional and fails
|
||||
// closed so an injected runner cannot accidentally receive a real call.
|
||||
func (a *toolCallerAdapter) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
if a == nil || a.runner == nil {
|
||||
return nil, fmt.Errorf("ToolCaller runner is not configured")
|
||||
}
|
||||
if !a.DryRun() {
|
||||
return a.CallTool(ctx, productID, toolName, args)
|
||||
}
|
||||
readRunner, ok := a.runner.(dryRunReadRunner)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("ToolCaller runner does not support read-only dry-run lookups")
|
||||
}
|
||||
inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args)
|
||||
result, err := readRunner.RunReadOnly(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertResult(result), nil
|
||||
}
|
||||
|
||||
// CallToolWithToken invokes a helper with an in-memory token override. It is
|
||||
// used during login before the new token has been persisted to any profile
|
||||
// slot.
|
||||
|
||||
@@ -39,6 +39,19 @@ func (r recordingToolCaller) CallTool(ctx context.Context, product, tool string,
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r recordingToolCaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
inner, ok := r.inner.(edition.ReadToolCaller)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("ToolCaller read-only dry-run lookup is not configured")
|
||||
}
|
||||
recordedArgs := cloneToolArgs(args)
|
||||
res, err := inner.CallReadTool(ctx, product, tool, args)
|
||||
// This is a real read even though the outer command is a dry-run. Record it
|
||||
// as such so usage evidence does not misclassify the lookup as skipped.
|
||||
usage.Append(product, tool, recordedArgs, err == nil, false)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r recordingToolCaller) CallToolWithToken(
|
||||
ctx context.Context,
|
||||
token, product, tool string,
|
||||
|
||||
@@ -27,6 +27,7 @@ type crossPlatformCoverageCaller struct {
|
||||
args map[string]any
|
||||
token string
|
||||
dryRun bool
|
||||
reads int
|
||||
}
|
||||
|
||||
func (c *crossPlatformCoverageCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) {
|
||||
@@ -44,6 +45,16 @@ func (c *crossPlatformCoverageCaller) CallToolWithToken(
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
|
||||
func (c *crossPlatformCoverageCaller) CallReadTool(
|
||||
_ context.Context,
|
||||
_, _ string,
|
||||
args map[string]any,
|
||||
) (*edition.ToolResult, error) {
|
||||
c.reads++
|
||||
c.args = args
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
|
||||
func (*crossPlatformCoverageCaller) Format() string { return "json" }
|
||||
func (c *crossPlatformCoverageCaller) DryRun() bool { return c.dryRun }
|
||||
func (*crossPlatformCoverageCaller) Fields() string { return "id,name" }
|
||||
@@ -129,6 +140,51 @@ func TestCrossPlatformCoverageRecordingToolCaller(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordingReadToolCaller(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("DWS_USAGE_TRACKING", "1")
|
||||
|
||||
inner := &crossPlatformCoverageCaller{dryRun: true}
|
||||
caller := newRecordingToolCaller(inner)
|
||||
readCaller, ok := caller.(edition.ReadToolCaller)
|
||||
if !ok {
|
||||
t.Fatal("recording caller dropped read-only capability")
|
||||
}
|
||||
if _, err := readCaller.CallReadTool(
|
||||
context.Background(),
|
||||
"im",
|
||||
"search_groups",
|
||||
map[string]any{"open_conversation_id": "cid_x"},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inner.reads != 1 || inner.args["open_conversation_id"] != "cid_x" {
|
||||
t.Fatalf("read forwarding = reads %d args %#v", inner.reads, inner.args)
|
||||
}
|
||||
records, err := usage.Read()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 1 || !records[0].OK {
|
||||
t.Fatalf("real read must be recorded as successful: %#v", records)
|
||||
}
|
||||
|
||||
withoutRead := recordingToolCaller{inner: nonReadToolCaller{}}
|
||||
if _, err := withoutRead.CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
|
||||
t.Fatal("recording caller accepted an inner caller without read support")
|
||||
}
|
||||
}
|
||||
|
||||
type nonReadToolCaller struct{}
|
||||
|
||||
func (nonReadToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
func (nonReadToolCaller) Format() string { return "json" }
|
||||
func (nonReadToolCaller) DryRun() bool { return false }
|
||||
func (nonReadToolCaller) Fields() string { return "" }
|
||||
func (nonReadToolCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageRootPublishesShortcutCommands(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// 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.
|
||||
|
||||
// command_meta.go provides the unified metadata consumption API. All runtime
|
||||
// consumers (help, schema, agent selection, skill generation) call ResolveMeta
|
||||
// to get a CommandMeta struct — one function, one struct, no need to know which
|
||||
// of the 6 generation layers a field comes from.
|
||||
//
|
||||
// This is the "simple consumption" half of the generation/consumption split:
|
||||
// - Generation (gen.go + internal/generator/): 6 inputs → catalog snapshot.
|
||||
// - Consumption (this file): catalog snapshot → ResolveMeta → CommandMeta.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// CommandMeta is the complete runtime metadata view for a single command.
|
||||
// Consumers read this struct; they never touch the raw catalog maps.
|
||||
type CommandMeta struct {
|
||||
Identity CommandIdentity
|
||||
Safety CommandSafety
|
||||
Selection CommandSelection
|
||||
}
|
||||
|
||||
// CommandIdentity is the stable identity of a command.
|
||||
type CommandIdentity struct {
|
||||
CLIPath string // "dev app delete"
|
||||
Canonical string // "dev.delete_dev_app"
|
||||
Aliases []string // ["search", ...]
|
||||
ProductID string // "devapp"
|
||||
Title string // one-line description
|
||||
}
|
||||
|
||||
// CommandSelection is the agent-facing selection metadata.
|
||||
type CommandSelection struct {
|
||||
AgentSummary string
|
||||
UseWhen []string
|
||||
AvoidWhen []string
|
||||
Examples []string
|
||||
}
|
||||
|
||||
var (
|
||||
metaByCLIPathOnce sync.Once
|
||||
metaByCLIPath map[string]CommandMeta
|
||||
)
|
||||
|
||||
// initMetaByCLIPath builds the cli_path → CommandMeta lookup from the embedded
|
||||
// catalog. Runs once (sync.Once); the catalog is already decoded at package init.
|
||||
func initMetaByCLIPath() {
|
||||
metaByCLIPath = buildMetaByCLIPath(embeddedSchemaCatalog())
|
||||
}
|
||||
|
||||
// buildMetaByCLIPath constructs the lookup from a loaded catalog snapshot.
|
||||
// Split from initMetaByCLIPath so malformed-snapshot guards stay testable.
|
||||
func buildMetaByCLIPath(loaded loadedSchemaCatalog) map[string]CommandMeta {
|
||||
lookup := make(map[string]CommandMeta)
|
||||
if loaded.Snapshot.Tools == nil {
|
||||
return lookup
|
||||
}
|
||||
metas := make([]CommandMeta, 0, len(loaded.Snapshot.Tools))
|
||||
for _, tool := range loaded.Snapshot.Tools {
|
||||
cliPath := schemaString(tool["cli_path"])
|
||||
if cliPath == "" {
|
||||
continue
|
||||
}
|
||||
meta := CommandMeta{
|
||||
Identity: CommandIdentity{
|
||||
CLIPath: cliPath,
|
||||
Canonical: schemaString(tool["canonical_path"]),
|
||||
Aliases: schemaStringSlice(tool["aliases"]),
|
||||
ProductID: schemaString(tool["product_id"]),
|
||||
Title: schemaString(tool["title"]),
|
||||
},
|
||||
Safety: CommandSafety{
|
||||
Effect: schemaString(tool["effect"]),
|
||||
Risk: schemaString(tool["risk"]),
|
||||
Confirmation: schemaString(tool["confirmation"]),
|
||||
Idempotency: schemaString(tool["idempotency"]),
|
||||
},
|
||||
Selection: CommandSelection{
|
||||
AgentSummary: schemaString(tool["agent_summary"]),
|
||||
UseWhen: schemaStringSlice(tool["use_when"]),
|
||||
AvoidWhen: schemaStringSlice(tool["avoid_when"]),
|
||||
Examples: schemaStringSlice(tool["examples"]),
|
||||
},
|
||||
}
|
||||
lookup[cliPath] = meta
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
// Register compat alias paths (e.g. "report list") against the same
|
||||
// metadata in a second pass, sorted by primary cli_path: primary paths
|
||||
// always win (registered above, aliases only fill vacancies), and an
|
||||
// alias-vs-alias collision resolves deterministically to the owner with
|
||||
// the lexicographically smallest primary path — Snapshot.Tools is a map,
|
||||
// so relying on iteration order would make the winner vary per process.
|
||||
sort.Slice(metas, func(i, j int) bool {
|
||||
return metas[i].Identity.CLIPath < metas[j].Identity.CLIPath
|
||||
})
|
||||
for _, meta := range metas {
|
||||
for _, alias := range meta.Identity.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias == "" || alias == meta.Identity.CLIPath {
|
||||
continue
|
||||
}
|
||||
if _, exists := lookup[alias]; !exists {
|
||||
lookup[alias] = meta
|
||||
}
|
||||
}
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
// ResolveMeta returns the complete metadata for a command identified by its CLI
|
||||
// path (e.g. "dev app delete") or one of its compat aliases (e.g. "report list"
|
||||
// for "report inbox list"). Returns ok=false for commands not in the embedded
|
||||
// catalog (utility commands, hidden commands, shortcuts).
|
||||
func ResolveMeta(cliPath string) (CommandMeta, bool) {
|
||||
metaByCLIPathOnce.Do(initMetaByCLIPath)
|
||||
m, ok := metaByCLIPath[strings.TrimSpace(cliPath)]
|
||||
return m, ok
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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 cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// CommandSafety holds the safety metadata for a CLI command, resolved at
|
||||
// runtime from the embedded schema catalog. This is a read-only view over the
|
||||
// catalog — NOT a second safety source. The catalog remains the single
|
||||
// authoritative reviewed source; this struct merely provides typed access for
|
||||
// consumers (help rendering, skill generation).
|
||||
type CommandSafety struct {
|
||||
Effect string // read / write / destructive
|
||||
Risk string // low / medium / high
|
||||
Confirmation string // not_required / user_required
|
||||
Idempotency string // idempotent / non_idempotent
|
||||
}
|
||||
|
||||
// ShouldRender returns true when the safety metadata warrants a visible
|
||||
// annotation in --help. Only commands that need confirmation or carry
|
||||
// above-low risk are annotated; read-only low-risk commands stay clean.
|
||||
func (s CommandSafety) ShouldRender() bool {
|
||||
return s.Confirmation == "user_required" ||
|
||||
(s.Risk != "" && s.Risk != "low")
|
||||
}
|
||||
|
||||
// SafetyForCLIPath returns the safety metadata for a command identified by its
|
||||
// CLI path (e.g. "dev app delete"). Returns ok=false when the command is not
|
||||
// in the embedded catalog (utility commands, hidden commands, shortcuts).
|
||||
//
|
||||
// Deprecated: use ResolveMeta(cliPath).Safety for the complete metadata view.
|
||||
// Kept for backward compatibility with existing callers.
|
||||
func SafetyForCLIPath(cliPath string) (CommandSafety, bool) {
|
||||
meta, ok := ResolveMeta(cliPath)
|
||||
if !ok {
|
||||
return CommandSafety{}, false
|
||||
}
|
||||
return meta.Safety, true
|
||||
}
|
||||
|
||||
// RenderSafetyAnnotation writes a "Safety:" line to the command's stdout when
|
||||
// the command carries above-low risk or requires user confirmation. This is
|
||||
// the shared entry point for ALL help rendering paths (root HelpFunc, product
|
||||
// group custom HelpFuncs like calendar's). It avoids the timing issue where a
|
||||
// group captures origHelp before configureRootHelp sets the root's custom func.
|
||||
func RenderSafetyAnnotation(cmd *cobra.Command) {
|
||||
cliPath := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" "))
|
||||
safety, ok := SafetyForCLIPath(cliPath)
|
||||
if !ok || !safety.ShouldRender() {
|
||||
return
|
||||
}
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintf(w, "\nSafety: effect=%s risk=%s confirmation=%s", safety.Effect, safety.Risk, safety.Confirmation)
|
||||
if safety.Idempotency != "" {
|
||||
fmt.Fprintf(w, " idempotency=%s", safety.Idempotency)
|
||||
}
|
||||
if safety.Confirmation == "user_required" {
|
||||
fmt.Fprint(w, " (requires --yes)")
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafetyForCLIPathKnownCommand(t *testing.T) {
|
||||
// dev app delete 是 destructive + high risk + user_required。
|
||||
s, ok := SafetyForCLIPath("dev app delete")
|
||||
if !ok {
|
||||
t.Fatal("SafetyForCLIPath(\"dev app delete\") returned ok=false; want true")
|
||||
}
|
||||
if s.Effect != "destructive" {
|
||||
t.Errorf("effect = %q, want destructive", s.Effect)
|
||||
}
|
||||
if s.Risk != "high" {
|
||||
t.Errorf("risk = %q, want high", s.Risk)
|
||||
}
|
||||
if s.Confirmation != "user_required" {
|
||||
t.Errorf("confirmation = %q, want user_required", s.Confirmation)
|
||||
}
|
||||
if !s.ShouldRender() {
|
||||
t.Error("ShouldRender() = false for destructive/high/user_required; want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafetyForCLIPathUnknownSkips(t *testing.T) {
|
||||
// 不存在的命令 → ok=false。
|
||||
_, ok := SafetyForCLIPath("nonexistent fake command")
|
||||
if ok {
|
||||
t.Fatal("SafetyForCLIPath for nonexistent command returned ok=true; want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafetyForCLIPathReadOnlyLowRiskSkips(t *testing.T) {
|
||||
// 找一个 read/low 风险命令,ShouldRender 应 false。
|
||||
// dev app list 是 read,默认 low risk。
|
||||
s, ok := SafetyForCLIPath("dev app list")
|
||||
if !ok {
|
||||
t.Skip("dev app list not in catalog; skipping")
|
||||
}
|
||||
if s.ShouldRender() {
|
||||
t.Errorf("ShouldRender() = true for effect=%s risk=%s; want false (read/low)", s.Effect, s.Risk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafetyForCLIPathTrimsWhitespace(t *testing.T) {
|
||||
// 带首尾空格的 cli_path 也能查到。
|
||||
s1, _ := SafetyForCLIPath("dev app delete")
|
||||
s2, ok2 := SafetyForCLIPath(" dev app delete ")
|
||||
if !ok2 {
|
||||
t.Fatal("trimmed lookup failed; whitespace not trimmed")
|
||||
}
|
||||
if s1 != s2 {
|
||||
t.Errorf("whitespace-trimmed result differs: %+v vs %+v", s1, s2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMetaComplete(t *testing.T) {
|
||||
// dev app delete: destructive + high + user_required + 有 selection。
|
||||
m, ok := ResolveMeta("dev app delete")
|
||||
if !ok {
|
||||
t.Fatal("ResolveMeta(\"dev app delete\") returned ok=false")
|
||||
}
|
||||
if m.Identity.CLIPath != "dev app delete" {
|
||||
t.Errorf("CLIPath = %q", m.Identity.CLIPath)
|
||||
}
|
||||
if m.Safety.Effect != "destructive" {
|
||||
t.Errorf("Effect = %q, want destructive", m.Safety.Effect)
|
||||
}
|
||||
if len(m.Selection.UseWhen) == 0 && m.Selection.AgentSummary == "" {
|
||||
t.Error("Selection is empty; expected use_when or agent_summary")
|
||||
}
|
||||
t.Logf("dev app delete: canonical=%s safety=%+v selection(use_when=%d avoid_when=%d examples=%d)",
|
||||
m.Identity.Canonical, m.Safety, len(m.Selection.UseWhen), len(m.Selection.AvoidWhen), len(m.Selection.Examples))
|
||||
}
|
||||
|
||||
func TestResolveMetaUnknownSkips(t *testing.T) {
|
||||
_, ok := ResolveMeta("nonexistent fake command")
|
||||
if ok {
|
||||
t.Fatal("ResolveMeta for unknown command returned ok=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMetaCopiesAliases(t *testing.T) {
|
||||
// report inbox list 在 Catalog 中声明了兼容别名 "report list"。
|
||||
m, ok := ResolveMeta("report inbox list")
|
||||
if !ok {
|
||||
t.Fatal("ResolveMeta(\"report inbox list\") returned ok=false")
|
||||
}
|
||||
found := false
|
||||
for _, alias := range m.Identity.Aliases {
|
||||
if alias == "report list" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("Identity.Aliases = %v, want to contain \"report list\"", m.Identity.Aliases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMetaAliasLookup(t *testing.T) {
|
||||
// 兼容别名路径必须解析到与主 cli_path 同一份元数据。
|
||||
primary, ok := ResolveMeta("report inbox list")
|
||||
if !ok {
|
||||
t.Fatal("primary path lookup failed")
|
||||
}
|
||||
aliased, ok := ResolveMeta("report list")
|
||||
if !ok {
|
||||
t.Fatal("ResolveMeta(\"report list\") alias lookup returned ok=false")
|
||||
}
|
||||
if aliased.Identity.CLIPath != primary.Identity.CLIPath || aliased.Identity.Canonical != primary.Identity.Canonical {
|
||||
t.Fatalf("alias metadata = %+v, want same identity as primary %+v", aliased.Identity, primary.Identity)
|
||||
}
|
||||
if aliased.Safety != primary.Safety {
|
||||
t.Fatalf("alias safety = %+v, want %+v", aliased.Safety, primary.Safety)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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.
|
||||
|
||||
// gen.go is the single entry point for schema metadata generation. It isolates
|
||||
// all //go:generate pragmas from business code so that:
|
||||
// - schema_agent_metadata.go / schema_catalog.go contain only types + embed.
|
||||
// - Generation is a standalone process (make generate-schema triggers this).
|
||||
// - The 6-input → 1-output contract is documented in one place.
|
||||
//
|
||||
// Generation inputs (authored, reviewed):
|
||||
// 1. schema_command_registry/ identity (canonical/aliases/navigation)
|
||||
// 2. schema_hints/metadata/*.json safety (effect/risk/confirmation)
|
||||
// 3. schema_hints/selection/*.json selection (use_when/avoid_when)
|
||||
// 4. schema_mcp_metadata.json MCP server tool definitions
|
||||
// 5. schema_parameter_bindings.json parameter type/property mappings
|
||||
// 6. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
//
|
||||
// Generation outputs (embedded at build):
|
||||
// - schema_agent_metadata/*.json per-product agent metadata
|
||||
// - schema_catalog/ per-product catalog shards
|
||||
|
||||
package cli
|
||||
|
||||
//go:generate go run ../generator/cmd_schema_agent_metadata -root ../.. -registry internal/cli/schema_command_registry -output-dir schema_agent_metadata -audit-output schema_agent_metadata_audit.json
|
||||
// Rebuild all dependencies so the Catalog compiler cannot reuse the cli
|
||||
// package cached by the preceding metadata generator with the old embedded
|
||||
// JSON files.
|
||||
//go:generate go run -a ../generator/cmd_schema_catalog -root ../.. -output schema_catalog
|
||||
@@ -22,12 +22,6 @@ import (
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
//go:generate go run ../generator/cmd_schema_agent_metadata -root ../.. -registry internal/cli/schema_command_registry.json -output-dir schema_agent_metadata -audit-output schema_agent_metadata_audit.json
|
||||
// Rebuild all dependencies so the Catalog compiler cannot reuse the cli
|
||||
// package cached by the preceding metadata generator with the old embedded
|
||||
// JSON files.
|
||||
//go:generate go run -a ../generator/cmd_schema_catalog -root ../.. -output schema_catalog.json
|
||||
|
||||
//go:embed schema_agent_metadata/*.json
|
||||
var embeddedAgentMetadataFS embed.FS
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"product_id": "event",
|
||||
"tools": {
|
||||
"event consume": {
|
||||
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
"agent_summary": "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
"agent_summary_source": "dws-agent-selection/event",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
@@ -14,18 +14,18 @@
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
"value": "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
"value": "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
@@ -106,7 +106,7 @@
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -116,7 +116,7 @@
|
||||
{
|
||||
"value": [
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -231,8 +231,11 @@
|
||||
"use_when": {
|
||||
"value": [
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"用户明确要求监听当前身份的所有单聊或所有群消息",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
|
||||
"需要监听指定群的标题变更、成员进退群或群解散事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
|
||||
"同一目标、同一过滤条件需要同时监听多个兼容事件"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -242,8 +245,11 @@
|
||||
{
|
||||
"value": [
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"用户明确要求监听当前身份的所有单聊或所有群消息",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
|
||||
"需要监听指定群的标题变更、成员进退群或群解散事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
|
||||
"同一目标、同一过滤条件需要同时监听多个兼容事件"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -270,8 +276,11 @@
|
||||
],
|
||||
"use_when": [
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"用户明确要求监听当前身份的所有单聊或所有群消息",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
|
||||
"需要监听指定群的标题变更、成员进退群或群解散事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
|
||||
"同一目标、同一过滤条件需要同时监听多个兼容事件"
|
||||
]
|
||||
},
|
||||
"event list": {
|
||||
@@ -763,7 +772,7 @@
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
|
||||
"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -772,7 +781,7 @@
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
|
||||
"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -797,7 +806,7 @@
|
||||
"skills/mono/references/products/event.md"
|
||||
],
|
||||
"use_when": [
|
||||
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
|
||||
"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"
|
||||
]
|
||||
},
|
||||
"event status": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:be0a967cd36b8430c22dd27fbd9a01ec232d0ce07b8a5ca3f9d35f2554ecbbe4",
|
||||
"surface_hash": "sha256:2c84c53f5844050980fb5e1459c92e3afbd701357e125608f4c41a43abc9bdd5",
|
||||
"source_hash": "sha256:1fed2a8bc5328212fdf646c3e68d465a2a440b2d303c1ad120981e06c97da487",
|
||||
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
|
||||
"coverage": {
|
||||
"surface_products": 24,
|
||||
"products_with_metadata": 24,
|
||||
"surface_tools": 592,
|
||||
"tools_with_metadata": 592,
|
||||
"tools_with_agent_summary": 592,
|
||||
"tools_with_use_when": 592,
|
||||
"tools_with_avoid_when": 592,
|
||||
"tools_with_examples": 592,
|
||||
"tools_with_interface_mode": 592,
|
||||
"unmatched_skill_tools": 118,
|
||||
"unreviewed_skill_tools": 7
|
||||
"surface_products": 26,
|
||||
"products_with_metadata": 26,
|
||||
"surface_tools": 813,
|
||||
"tools_with_metadata": 813,
|
||||
"tools_with_agent_summary": 813,
|
||||
"tools_with_use_when": 813,
|
||||
"tools_with_avoid_when": 813,
|
||||
"tools_with_examples": 813,
|
||||
"tools_with_interface_mode": 813,
|
||||
"unmatched_skill_tools": 122,
|
||||
"unreviewed_skill_tools": 11
|
||||
},
|
||||
"products": {
|
||||
"aisearch": {
|
||||
@@ -585,6 +585,74 @@
|
||||
"创建/配置开放平台应用、机器人、权限、事件订阅或发布版本"
|
||||
]
|
||||
},
|
||||
"devapp": {
|
||||
"agent_summary": "管理钉钉开放平台企业内部应用、成员、权限、机器人、事件与版本",
|
||||
"agent_summary_source": "dws-agent-selection/devapp",
|
||||
"avoid_when": [
|
||||
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "管理钉钉开放平台企业内部应用、成员、权限、机器人、事件与版本",
|
||||
"source": "internal/cli/schema_hints/selection/devapp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "管理钉钉开放平台企业内部应用、成员、权限、机器人、事件与版本",
|
||||
"source": "internal/cli/schema_hints/selection/devapp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devapp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devapp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devapp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devapp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"source_refs": [
|
||||
"CommandRegistry:product=devapp",
|
||||
"internal/cli/schema_command_registry.json",
|
||||
"internal/cli/schema_hints/selection/devapp.json",
|
||||
"internal/shortcut/devapp"
|
||||
],
|
||||
"use_when": [
|
||||
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理"
|
||||
]
|
||||
},
|
||||
"devdoc": {
|
||||
"agent_summary": "搜索钉钉开放平台开发文档与错误排查资料",
|
||||
"agent_summary_source": "dws-agent-selection/devdoc",
|
||||
@@ -870,20 +938,20 @@
|
||||
]
|
||||
},
|
||||
"event": {
|
||||
"agent_summary": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
|
||||
"agent_summary": "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
|
||||
"agent_summary_source": "dws-agent-selection/event",
|
||||
"avoid_when": [
|
||||
"查历史聊天或主动发消息分别用 chat 查询/发送命令"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
|
||||
"value": "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
|
||||
"value": "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
@@ -910,7 +978,7 @@
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
|
||||
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -918,7 +986,7 @@
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
|
||||
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -938,7 +1006,79 @@
|
||||
"skills/mono/references/products/event.md"
|
||||
],
|
||||
"use_when": [
|
||||
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
|
||||
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期"
|
||||
]
|
||||
},
|
||||
"hrbrain": {
|
||||
"agent_summary": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
|
||||
"agent_summary_source": "dws-agent-selection/hrbrain",
|
||||
"avoid_when": [
|
||||
"要操作通讯录/组织架构基础信息时改用 contact 产品",
|
||||
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"要操作通讯录/组织架构基础信息时改用 contact 产品",
|
||||
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"要操作通讯录/组织架构基础信息时改用 contact 产品",
|
||||
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"source_refs": [
|
||||
"CommandRegistry:product=hrbrain",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md",
|
||||
"internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"skills/mono/SKILL.md"
|
||||
],
|
||||
"use_when": [
|
||||
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
|
||||
]
|
||||
},
|
||||
"live": {
|
||||
@@ -1734,11 +1874,13 @@
|
||||
"chat",
|
||||
"contact",
|
||||
"dev",
|
||||
"devapp",
|
||||
"devdoc",
|
||||
"ding",
|
||||
"doc",
|
||||
"drive",
|
||||
"event",
|
||||
"hrbrain",
|
||||
"live",
|
||||
"mail",
|
||||
"markdown",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,525 @@
|
||||
{
|
||||
"product_id": "report",
|
||||
"tools": {
|
||||
"report +inbox-list": {
|
||||
"agent_summary": "列出我收到的日报(按时间范围分页)",
|
||||
"agent_summary_source": "dws-agent-selection/report",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "列出我收到的日报(按时间范围分页)",
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "列出我收到的日报(按时间范围分页)",
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "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.",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "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.",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_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.",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"ShortcutRegistry:report +inbox-list",
|
||||
"cobra-help:dws report +inbox-list",
|
||||
"internal/cli/schema_command_registry.json#report.shortcut_inbox_list",
|
||||
"internal/cli/schema_hints/metadata/report.json",
|
||||
"internal/cli/schema_hints/selection/report.json"
|
||||
],
|
||||
"use_when": [
|
||||
"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"
|
||||
]
|
||||
},
|
||||
"report +outbox-list": {
|
||||
"agent_summary": "列出我发出的日报(可选时间/模版名过滤)",
|
||||
"agent_summary_source": "dws-agent-selection/report",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws report +outbox-list --cursor 0 --size 20",
|
||||
"dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\""
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "列出我发出的日报(可选时间/模版名过滤)",
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "列出我发出的日报(可选时间/模版名过滤)",
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws report +outbox-list --cursor 0 --size 20",
|
||||
"dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\""
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws report +outbox-list --cursor 0 --size 20",
|
||||
"dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\""
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "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.",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "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.",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_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.",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"ShortcutRegistry:report +outbox-list",
|
||||
"cobra-help:dws report +outbox-list",
|
||||
"internal/cli/schema_command_registry.json#report.shortcut_outbox_list",
|
||||
"internal/cli/schema_hints/metadata/report.json",
|
||||
"internal/cli/schema_hints/selection/report.json"
|
||||
],
|
||||
"use_when": [
|
||||
"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"
|
||||
]
|
||||
},
|
||||
"report entry get": {
|
||||
"agent_summary": "获取指定一篇日志的详情信息",
|
||||
"agent_summary_source": "dws-agent-selection/report",
|
||||
|
||||
@@ -1,6 +1,522 @@
|
||||
{
|
||||
"product_id": "sheet",
|
||||
"tools": {
|
||||
"sheet +list-sheets": {
|
||||
"agent_summary": "获取表格文档中全部工作表列表",
|
||||
"agent_summary_source": "dws-agent-selection/sheet",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws sheet +list-sheets --node NODE_ID"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "获取表格文档中全部工作表列表",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "获取表格文档中全部工作表列表",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws sheet +list-sheets --node NODE_ID"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws sheet +list-sheets --node NODE_ID"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "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.",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "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.",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_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.",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"ShortcutRegistry:sheet +list-sheets",
|
||||
"cobra-help:dws sheet +list-sheets",
|
||||
"internal/cli/schema_command_registry.json#sheet.shortcut_list_sheets",
|
||||
"internal/cli/schema_hints/metadata/sheet.json",
|
||||
"internal/cli/schema_hints/selection/sheet.json"
|
||||
],
|
||||
"use_when": [
|
||||
"当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。"
|
||||
]
|
||||
},
|
||||
"sheet +read": {
|
||||
"agent_summary": "读取工作表指定范围的结构化单元格数据",
|
||||
"agent_summary_source": "dws-agent-selection/sheet",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range \"A1:D10\""
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "读取工作表指定范围的结构化单元格数据",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "读取工作表指定范围的结构化单元格数据",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range \"A1:D10\""
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range \"A1:D10\""
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "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.",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "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.",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_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.",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"ShortcutRegistry:sheet +read",
|
||||
"cobra-help:dws sheet +read",
|
||||
"internal/cli/schema_command_registry.json#sheet.shortcut_read",
|
||||
"internal/cli/schema_hints/metadata/sheet.json",
|
||||
"internal/cli/schema_hints/selection/sheet.json"
|
||||
],
|
||||
"use_when": [
|
||||
"当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。"
|
||||
]
|
||||
},
|
||||
"sheet add-dimension": {
|
||||
"agent_summary": "在工作表末尾追加空行或空列。",
|
||||
"agent_summary_source": "dws-agent-selection/sheet",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,264 @@
|
||||
{
|
||||
"product_id": "wiki",
|
||||
"tools": {
|
||||
"wiki +space-search": {
|
||||
"agent_summary": "搜索知识库",
|
||||
"agent_summary_source": "dws-agent-selection/wiki",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws wiki +space-search --query \"产品文档\""
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "搜索知识库",
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "搜索知识库",
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws wiki +space-search --query \"产品文档\""
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws wiki +space-search --query \"产品文档\""
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "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.",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "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.",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_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.",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"ShortcutRegistry:wiki +space-search",
|
||||
"cobra-help:dws wiki +space-search",
|
||||
"internal/cli/schema_command_registry.json#wiki.shortcut_space_search",
|
||||
"internal/cli/schema_hints/metadata/wiki.json",
|
||||
"internal/cli/schema_hints/selection/wiki.json"
|
||||
],
|
||||
"use_when": [
|
||||
"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"
|
||||
]
|
||||
},
|
||||
"wiki member add": {
|
||||
"agent_summary": "为指定知识库添加一个或多个成员,并授予指定角色",
|
||||
"agent_summary_source": "dws-agent-selection/wiki",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,10 +15,11 @@ package cli
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -29,8 +30,20 @@ import (
|
||||
|
||||
const SchemaCatalogSnapshotVersion = 1
|
||||
|
||||
//go:embed schema_catalog.json
|
||||
var embeddedSchemaCatalogJSON []byte
|
||||
// The release Catalog is committed as a per-product split so concurrent
|
||||
// feature PRs only rewrite the shard for the product they touch:
|
||||
//
|
||||
// schema_catalog/catalog.json global envelope + Catalog map
|
||||
// schema_catalog/tools/<product>.json that product's leaf ToolSpecs
|
||||
//
|
||||
// The loader reassembles the exact same SchemaCatalogSnapshot, so the
|
||||
// source_hash integrity check is identical to the single-file layout.
|
||||
//
|
||||
//go:embed schema_catalog/catalog.json
|
||||
var embeddedSchemaCatalogEnvelopeJSON []byte
|
||||
|
||||
//go:embed schema_catalog/tools
|
||||
var embeddedSchemaCatalogTools embed.FS
|
||||
|
||||
// SchemaCatalogSnapshot is the release-stable Agent contract. Catalog holds
|
||||
// the progressive product/tool index; Tools holds full leaf parameter schemas.
|
||||
@@ -68,11 +81,80 @@ var runtimeEmbeddedSchemaCatalogLazyLoadCount atomic.Uint64
|
||||
func embeddedSchemaCatalog() loadedSchemaCatalog {
|
||||
runtimeEmbeddedSchemaCatalogOnce.Do(func() {
|
||||
runtimeEmbeddedSchemaCatalogLazyLoadCount.Add(1)
|
||||
runtimeEmbeddedSchemaCatalog, runtimeEmbeddedSchemaCatalogErr = decodeSchemaCatalogSnapshot(embeddedSchemaCatalogJSON)
|
||||
runtimeEmbeddedSchemaCatalog, runtimeEmbeddedSchemaCatalogErr = assembleEmbeddedSchemaCatalog()
|
||||
})
|
||||
return runtimeEmbeddedSchemaCatalog
|
||||
}
|
||||
|
||||
// schemaCatalogEnvelope is the global half of the split release Catalog. It
|
||||
// mirrors the generator's envelope struct; the Catalog map and release hashes
|
||||
// do not partition by product and stay in one file.
|
||||
type schemaCatalogEnvelope struct {
|
||||
Version int `json:"version"`
|
||||
SurfaceHash string `json:"surface_hash,omitempty"`
|
||||
SourceHash string `json:"source_hash"`
|
||||
Catalog map[string]any `json:"catalog"`
|
||||
}
|
||||
|
||||
// schemaCatalogToolShard mirrors the per-product shard written by the
|
||||
// generator. Only the product and its leaf ToolSpecs are stored here.
|
||||
type schemaCatalogToolShard struct {
|
||||
Product string `json:"product"`
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
|
||||
// assembleEmbeddedSchemaCatalog reassembles the split release Catalog shards
|
||||
// into the same SchemaCatalogSnapshot the single-file layout produced, then
|
||||
// validates it through the production loader. source_hash still covers the
|
||||
// whole payload: if any shard is missing, stale, or tampered, the content hash
|
||||
// check in loadSchemaCatalogSnapshot fails exactly as before.
|
||||
func assembleEmbeddedSchemaCatalog() (loadedSchemaCatalog, error) {
|
||||
snapshot, err := assembleSchemaCatalogSnapshot(embeddedSchemaCatalogEnvelopeJSON, embeddedSchemaCatalogTools, "schema_catalog/tools")
|
||||
if err != nil {
|
||||
return loadedSchemaCatalog{}, err
|
||||
}
|
||||
return loadSchemaCatalogSnapshot(snapshot)
|
||||
}
|
||||
|
||||
// assembleSchemaCatalogSnapshot merges an envelope document and a directory of
|
||||
// per-product tool shards back into the single-document snapshot shape. Split
|
||||
// from assembleEmbeddedSchemaCatalog so shard failure modes stay testable
|
||||
// against fake filesystems.
|
||||
func assembleSchemaCatalogSnapshot(envelopeJSON []byte, shards fs.FS, dir string) (SchemaCatalogSnapshot, error) {
|
||||
var envelope schemaCatalogEnvelope
|
||||
if err := decodeStrictSchemaJSON(envelopeJSON, &envelope); err != nil {
|
||||
return SchemaCatalogSnapshot{}, fmt.Errorf("decode embedded schema catalog.json: %w", err)
|
||||
}
|
||||
entries, err := fs.ReadDir(shards, dir)
|
||||
if err != nil {
|
||||
return SchemaCatalogSnapshot{}, fmt.Errorf("read embedded schema catalog tools directory: %w", err)
|
||||
}
|
||||
tools := make(map[string]map[string]any, len(entries)*8)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
data, readErr := fs.ReadFile(shards, dir+"/"+entry.Name())
|
||||
if readErr != nil {
|
||||
return SchemaCatalogSnapshot{}, fmt.Errorf("read embedded schema catalog shard %s: %w", entry.Name(), readErr)
|
||||
}
|
||||
var shard schemaCatalogToolShard
|
||||
if err := decodeStrictSchemaJSON(data, &shard); err != nil {
|
||||
return SchemaCatalogSnapshot{}, fmt.Errorf("decode embedded schema catalog shard %s: %w", entry.Name(), err)
|
||||
}
|
||||
for canonical, spec := range shard.Tools {
|
||||
tools[canonical] = spec
|
||||
}
|
||||
}
|
||||
return SchemaCatalogSnapshot{
|
||||
Version: envelope.Version,
|
||||
SurfaceHash: envelope.SurfaceHash,
|
||||
SourceHash: envelope.SourceHash,
|
||||
Catalog: envelope.Catalog,
|
||||
Tools: tools,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func embeddedSchemaCatalogError() error {
|
||||
_ = embeddedSchemaCatalog()
|
||||
return runtimeEmbeddedSchemaCatalogErr
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,762 @@
|
||||
{
|
||||
"product": "devdoc",
|
||||
"tools": {
|
||||
"devdoc.search_open_platform_docs_rag": {
|
||||
"agent_metadata_source": "embedded-skill-metadata",
|
||||
"agent_source_refs": [
|
||||
"CommandRegistry:canonical_path=devdoc.search_open_platform_docs_rag",
|
||||
"Skill:skills/mono/references/products/devdoc.md",
|
||||
"cobra-help:dws devdoc article search",
|
||||
"dws-wukong-envelope@4574f7022c32:dws-wukong-discovery.devdoc.prod.json#devdoc.search_open_platform_docs",
|
||||
"internal/cli/schema_command_registry.json#devdoc.search_open_platform_docs_rag",
|
||||
"internal/cli/schema_hints/imported/wukong.json",
|
||||
"internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"internal/cli/schema_hints/selection/devdoc.json",
|
||||
"internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag",
|
||||
"live-dws-schema:devdoc.search_open_platform_docs_rag",
|
||||
"skills/mono/references/products/devdoc.md",
|
||||
"skills/mono/references/products/simple.md",
|
||||
"structured-hint:internal/cli/schema_hints/imported/wukong.json#devdoc.search_open_platform_docs_rag",
|
||||
"structured-hint:internal/cli/schema_hints/selection-review.json#devdoc.search_open_platform_docs_rag"
|
||||
],
|
||||
"agent_summary": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)",
|
||||
"agent_summary_source": "dws-agent-selection/devdoc",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
|
||||
"要执行开放平台应用配置变更时用 dev"
|
||||
],
|
||||
"canonical_path": "devdoc.search_open_platform_docs_rag",
|
||||
"cli_name": "search",
|
||||
"cli_path": "devdoc article search",
|
||||
"confirmation": "not_required",
|
||||
"constraints": {
|
||||
"require_one_of": [
|
||||
[
|
||||
"query",
|
||||
"keyword"
|
||||
]
|
||||
]
|
||||
},
|
||||
"description": "按关键词搜索 open.dingtalk.com 文档,支持分页。默认表格输出,可用 -f json 获取完整响应。",
|
||||
"display": "开放平台文档搜索",
|
||||
"effect": "read",
|
||||
"effect_source": "command-verb",
|
||||
"examples": [
|
||||
"dws devdoc article search --query \"OAuth2 接入\" --format json",
|
||||
"dws devdoc article search --query \"errcode 40078\" --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"value": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"value": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)"
|
||||
},
|
||||
"availability": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"value": "available"
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false,
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag.interface_ref",
|
||||
"value": "available"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"value": "available"
|
||||
},
|
||||
"avoid_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"value": [
|
||||
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
|
||||
"要执行开放平台应用配置变更时用 dev"
|
||||
]
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"value": [
|
||||
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
|
||||
"要执行开放平台应用配置变更时用 dev"
|
||||
]
|
||||
},
|
||||
"canonical_path": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "command_registry",
|
||||
"selected": true,
|
||||
"source": "reviewed_command_registry",
|
||||
"source_ref": "devdoc article search",
|
||||
"value": "devdoc.search_open_platform_docs_rag"
|
||||
},
|
||||
{
|
||||
"precedence": "reviewed_manual",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"selected": false,
|
||||
"source": "reviewed_manual_hint",
|
||||
"source_ref": "devdoc article search",
|
||||
"value": "devdoc.search_open_platform_docs_rag"
|
||||
}
|
||||
],
|
||||
"precedence": "command_registry",
|
||||
"resolution": "registry_identity",
|
||||
"source": "reviewed_command_registry",
|
||||
"source_ref": "devdoc article search",
|
||||
"value": "devdoc.search_open_platform_docs_rag"
|
||||
},
|
||||
"confirmation": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"value": "not_required"
|
||||
},
|
||||
{
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false,
|
||||
"source": "risk-default",
|
||||
"value": "not_required"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"value": "not_required"
|
||||
},
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_help",
|
||||
"selected": true,
|
||||
"source": "cobra_help",
|
||||
"value": "按关键词搜索 open.dingtalk.com 文档,支持分页。默认表格输出,可用 -f json 获取完整响应。"
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_metadata",
|
||||
"selected": false,
|
||||
"source": "mcp_metadata",
|
||||
"value": "根据关键词搜索钉钉开放平台的开发文档,返回匹配的文档条目列表,包含标题、摘要、文档链接和相关标签。搜索结果按相关性排序。适用于开发者在集成或调试过程中快速查找 API 说明、接入指南、错误码解释等技术资料。"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_help",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_help",
|
||||
"value": "按关键词搜索 open.dingtalk.com 文档,支持分页。默认表格输出,可用 -f json 获取完整响应。"
|
||||
},
|
||||
"effect": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true,
|
||||
"source": "command-verb",
|
||||
"value": "read"
|
||||
}
|
||||
],
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "command-verb",
|
||||
"value": "read"
|
||||
},
|
||||
"examples": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"value": [
|
||||
"dws devdoc article search --query \"OAuth2 接入\" --format json",
|
||||
"dws devdoc article search --query \"errcode 40078\" --format json"
|
||||
]
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"value": [
|
||||
"dws devdoc article search --query \"OAuth2 接入\" --format json",
|
||||
"dws devdoc article search --query \"errcode 40078\" --format json"
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true,
|
||||
"source": "effect-default",
|
||||
"value": "idempotent"
|
||||
}
|
||||
],
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "effect-default",
|
||||
"value": "idempotent"
|
||||
},
|
||||
"interface_mode": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"value": "mcp"
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false,
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag.interface_ref",
|
||||
"value": "mcp"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"value": "mcp"
|
||||
},
|
||||
"interface_ref": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "imported",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/imported/wukong.json",
|
||||
"value": {
|
||||
"product_id": "devdoc",
|
||||
"rpc_name": "search_open_platform_docs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false,
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag.interface_ref",
|
||||
"value": "devdoc.search_open_platform_docs"
|
||||
}
|
||||
],
|
||||
"precedence": "imported",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "internal/cli/schema_hints/imported/wukong.json",
|
||||
"value": {
|
||||
"product_id": "devdoc",
|
||||
"rpc_name": "search_open_platform_docs"
|
||||
}
|
||||
},
|
||||
"metadata_source": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "derived_resolution",
|
||||
"selected": true,
|
||||
"source": "metadata_source_resolution",
|
||||
"value": "embedded-mcp-metadata"
|
||||
}
|
||||
],
|
||||
"precedence": "derived_resolution",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "metadata_source_resolution",
|
||||
"value": "embedded-mcp-metadata"
|
||||
},
|
||||
"reviewed": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"selected": false,
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"value": true
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"value": true
|
||||
},
|
||||
"risk": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true,
|
||||
"source": "effect-default",
|
||||
"value": "low"
|
||||
}
|
||||
],
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "effect-default",
|
||||
"value": "low"
|
||||
},
|
||||
"title": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_help",
|
||||
"selected": true,
|
||||
"source": "cobra_help",
|
||||
"value": "搜索开放平台文档"
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_metadata",
|
||||
"selected": false,
|
||||
"source": "mcp_metadata",
|
||||
"value": "search_open_platform_docs"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_help",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_help",
|
||||
"value": "搜索开放平台文档"
|
||||
},
|
||||
"use_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"value": [
|
||||
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
|
||||
]
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"value": [
|
||||
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
|
||||
]
|
||||
}
|
||||
},
|
||||
"group": "article",
|
||||
"has_parameters": true,
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "mcp",
|
||||
"interface_ref": {
|
||||
"product_id": "devdoc",
|
||||
"rpc_name": "search_open_platform_docs"
|
||||
},
|
||||
"is_alias": false,
|
||||
"metadata_source": "embedded-mcp-metadata",
|
||||
"name": "search_open_platform_docs_rag",
|
||||
"parameter_count": 3,
|
||||
"parameters": {
|
||||
"page": {
|
||||
"default": "1",
|
||||
"description": "页码,默认 1",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "页码,默认 1"
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_metadata",
|
||||
"selected": false,
|
||||
"source": "mcp_metadata",
|
||||
"value": "分页页码"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "页码,默认 1"
|
||||
},
|
||||
"interface_type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "mcp_metadata",
|
||||
"selected": true,
|
||||
"source": "mcp_metadata",
|
||||
"value": "number"
|
||||
},
|
||||
{
|
||||
"precedence": "fallback",
|
||||
"selected": false,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
],
|
||||
"precedence": "mcp_metadata",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "mcp_metadata",
|
||||
"value": "number"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": true,
|
||||
"source": "flag_name_inference",
|
||||
"value": "page"
|
||||
}
|
||||
],
|
||||
"precedence": "inference",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "flag_name_inference",
|
||||
"value": "page"
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
},
|
||||
"interface_description": "分页页码",
|
||||
"interface_type": "number",
|
||||
"property": "page",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
"query": {
|
||||
"description": "搜索关键词 (必填)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "搜索关键词 (必填)"
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_metadata",
|
||||
"selected": false,
|
||||
"source": "mcp_metadata",
|
||||
"value": "搜索关键词"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "搜索关键词 (必填)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "versioned_binding",
|
||||
"selected": true,
|
||||
"source": "versioned_parameter_binding",
|
||||
"value": "keyword"
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "flag_name_inference",
|
||||
"value": "query"
|
||||
}
|
||||
],
|
||||
"precedence": "versioned_binding",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "versioned_parameter_binding",
|
||||
"value": "keyword"
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_manual",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"selected": true,
|
||||
"source": "reviewed_manual_hint",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"precedence": "command_constraint",
|
||||
"selected": false,
|
||||
"source": "require_one_of_constraint",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_metadata",
|
||||
"selected": false,
|
||||
"source": "mcp_metadata",
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "usage_required_inference",
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_manual",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"source": "reviewed_manual_hint",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
},
|
||||
"interface_description": "搜索关键词",
|
||||
"property": "keyword",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"default": "10",
|
||||
"description": "每页数量,默认 10",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "每页数量,默认 10"
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_metadata",
|
||||
"selected": false,
|
||||
"source": "mcp_metadata",
|
||||
"value": "分页大小"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "每页数量,默认 10"
|
||||
},
|
||||
"interface_type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "mcp_metadata",
|
||||
"selected": true,
|
||||
"source": "mcp_metadata",
|
||||
"value": "number"
|
||||
},
|
||||
{
|
||||
"precedence": "fallback",
|
||||
"selected": false,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
],
|
||||
"precedence": "mcp_metadata",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "mcp_metadata",
|
||||
"value": "number"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": true,
|
||||
"source": "flag_name_inference",
|
||||
"value": "size"
|
||||
}
|
||||
],
|
||||
"precedence": "inference",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "flag_name_inference",
|
||||
"value": "size"
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
},
|
||||
"interface_description": "分页大小",
|
||||
"interface_type": "number",
|
||||
"property": "size",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"path": "devdoc.search_open_platform_docs_rag",
|
||||
"positionals": [
|
||||
{
|
||||
"description": "搜索关键词;也可通过 --query 传入",
|
||||
"index": 0,
|
||||
"name": "keyword",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"primary_cli_path": "devdoc article search",
|
||||
"product_id": "devdoc",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source": "reviewed_command_registry",
|
||||
"title": "搜索开放平台文档",
|
||||
"use_when": [
|
||||
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,360 @@
|
||||
{
|
||||
"product": "live",
|
||||
"tools": {
|
||||
"live.get_my_lives": {
|
||||
"agent_metadata_source": "embedded-skill-metadata",
|
||||
"agent_source_refs": [
|
||||
"CommandRegistry:canonical_path=live.get_my_lives",
|
||||
"Skill:skills/multi/dingtalk-live/SKILL.md",
|
||||
"cobra-help:dws live stream list",
|
||||
"dws-wukong-envelope@4574f7022c32:dws-wukong-discovery.live.prod.json#live.get_my_lives",
|
||||
"internal/cli/schema_command_registry.json#live.get_my_lives",
|
||||
"internal/cli/schema_hints/imported/wukong.json",
|
||||
"internal/cli/schema_hints/metadata/live.json",
|
||||
"internal/cli/schema_hints/selection/live.json",
|
||||
"internal/cli/schema_mcp_metadata.json#tools.live.get_my_lives",
|
||||
"live-dws-schema:live.get_my_lives",
|
||||
"skills/mono/references/products/live.md",
|
||||
"structured-hint:internal/cli/schema_hints/imported/wukong.json#live.get_my_lives",
|
||||
"structured-hint:internal/cli/schema_hints/selection-review.json#live.get_my_lives"
|
||||
],
|
||||
"agent_summary": "查看当前用户发起的直播列表与基础统计",
|
||||
"agent_summary_source": "dws-agent-selection/live",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"需要创建/开播/结束直播时不要使用;当前公开面仅列表查询"
|
||||
],
|
||||
"canonical_path": "live.get_my_lives",
|
||||
"cli_name": "list",
|
||||
"cli_path": "live stream list",
|
||||
"confirmation": "not_required",
|
||||
"description": "查看我的直播列表",
|
||||
"display": "直播列表 / 信息",
|
||||
"effect": "read",
|
||||
"effect_source": "command-verb",
|
||||
"examples": [
|
||||
"dws live stream list"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/live.json",
|
||||
"value": "查看当前用户发起的直播列表与基础统计"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/live.json",
|
||||
"value": "查看当前用户发起的直播列表与基础统计"
|
||||
},
|
||||
"availability": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/live.json",
|
||||
"value": "available"
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false,
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.live.get_my_lives.interface_ref",
|
||||
"value": "available"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
|
||||
"source": "internal/cli/schema_hints/metadata/live.json",
|
||||
"value": "available"
|
||||
},
|
||||
"avoid_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/live.json",
|
||||
"value": [
|
||||
"需要创建/开播/结束直播时不要使用;当前公开面仅列表查询"
|
||||
]
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/live.json",
|
||||
"value": [
|
||||
"需要创建/开播/结束直播时不要使用;当前公开面仅列表查询"
|
||||
]
|
||||
},
|
||||
"canonical_path": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "command_registry",
|
||||
"selected": true,
|
||||
"source": "reviewed_command_registry",
|
||||
"source_ref": "live stream list",
|
||||
"value": "live.get_my_lives"
|
||||
}
|
||||
],
|
||||
"precedence": "command_registry",
|
||||
"resolution": "registry_identity",
|
||||
"source": "reviewed_command_registry",
|
||||
"source_ref": "live stream list",
|
||||
"value": "live.get_my_lives"
|
||||
},
|
||||
"confirmation": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true,
|
||||
"source": "risk-default",
|
||||
"value": "not_required"
|
||||
}
|
||||
],
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "risk-default",
|
||||
"value": "not_required"
|
||||
},
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_help",
|
||||
"selected": true,
|
||||
"source": "cobra_help",
|
||||
"value": "查看我的直播列表"
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_metadata",
|
||||
"selected": false,
|
||||
"source": "mcp_metadata",
|
||||
"value": "获取当前用户发起的直播列表,包含每场直播的基础信息与统计数据。返回字段包括:直播标题、封面图 URL、直播 ID、主播昵称、开始/结束时间戳、播放时长(毫秒)、观看量(UV)及直播状态(如已结束)。数据按分页返回,支持判断是否还有更多内容。适用于个人直播管理、历史记录查看等场景,仅展示当前用户有权限访问的直播内容。"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_help",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_help",
|
||||
"value": "查看我的直播列表"
|
||||
},
|
||||
"effect": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true,
|
||||
"source": "command-verb",
|
||||
"value": "read"
|
||||
}
|
||||
],
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "command-verb",
|
||||
"value": "read"
|
||||
},
|
||||
"examples": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/live.json",
|
||||
"value": [
|
||||
"dws live stream list"
|
||||
]
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/live.json",
|
||||
"value": [
|
||||
"dws live stream list"
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true,
|
||||
"source": "effect-default",
|
||||
"value": "idempotent"
|
||||
}
|
||||
],
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "effect-default",
|
||||
"value": "idempotent"
|
||||
},
|
||||
"interface_mode": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/live.json",
|
||||
"value": "mcp"
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false,
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.live.get_my_lives.interface_ref",
|
||||
"value": "mcp"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
|
||||
"source": "internal/cli/schema_hints/metadata/live.json",
|
||||
"value": "mcp"
|
||||
},
|
||||
"interface_ref": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "imported",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/imported/wukong.json",
|
||||
"value": {
|
||||
"product_id": "live",
|
||||
"rpc_name": "get_my_lives"
|
||||
}
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false,
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.live.get_my_lives.interface_ref",
|
||||
"value": "live.get_my_lives"
|
||||
}
|
||||
],
|
||||
"precedence": "imported",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "internal/cli/schema_hints/imported/wukong.json",
|
||||
"value": {
|
||||
"product_id": "live",
|
||||
"rpc_name": "get_my_lives"
|
||||
}
|
||||
},
|
||||
"metadata_source": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "derived_resolution",
|
||||
"selected": true,
|
||||
"source": "metadata_source_resolution",
|
||||
"value": "embedded-mcp-metadata"
|
||||
}
|
||||
],
|
||||
"precedence": "derived_resolution",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "metadata_source_resolution",
|
||||
"value": "embedded-mcp-metadata"
|
||||
},
|
||||
"reviewed": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/live.json",
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"selected": false,
|
||||
"source": "internal/cli/schema_hints/selection/live.json",
|
||||
"value": true
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
|
||||
"source": "internal/cli/schema_hints/metadata/live.json",
|
||||
"value": true
|
||||
},
|
||||
"risk": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true,
|
||||
"source": "effect-default",
|
||||
"value": "low"
|
||||
}
|
||||
],
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "effect-default",
|
||||
"value": "low"
|
||||
},
|
||||
"title": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_help",
|
||||
"selected": true,
|
||||
"source": "cobra_help",
|
||||
"value": "查看我的直播列表"
|
||||
},
|
||||
{
|
||||
"precedence": "mcp_metadata",
|
||||
"selected": false,
|
||||
"source": "mcp_metadata",
|
||||
"value": "获取当前用户发起的直播列表"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_help",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_help",
|
||||
"value": "查看我的直播列表"
|
||||
},
|
||||
"use_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/live.json",
|
||||
"value": [
|
||||
"用户要看自己发起过的直播、状态或观看量等列表信息"
|
||||
]
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/live.json",
|
||||
"value": [
|
||||
"用户要看自己发起过的直播、状态或观看量等列表信息"
|
||||
]
|
||||
}
|
||||
},
|
||||
"group": "stream",
|
||||
"has_parameters": false,
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "mcp",
|
||||
"interface_ref": {
|
||||
"product_id": "live",
|
||||
"rpc_name": "get_my_lives"
|
||||
},
|
||||
"is_alias": false,
|
||||
"metadata_source": "embedded-mcp-metadata",
|
||||
"name": "get_my_lives",
|
||||
"parameter_count": 0,
|
||||
"parameters": {},
|
||||
"path": "live.get_my_lives",
|
||||
"primary_cli_path": "live stream list",
|
||||
"product_id": "live",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source": "reviewed_command_registry",
|
||||
"title": "查看我的直播列表",
|
||||
"use_when": [
|
||||
"用户要看自己发起过的直播、状态或观看量等列表信息"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
||||
{
|
||||
"product": "mcp",
|
||||
"tools": {
|
||||
"mcp.url_get": {
|
||||
"agent_metadata_source": "embedded-skill-metadata",
|
||||
"agent_source_refs": [
|
||||
"cobra-help:dws mcp url get --help",
|
||||
"internal/app/mcp_url_command.go",
|
||||
"internal/cli/schema_command_registry.json#mcp.url_get",
|
||||
"internal/cli/schema_hints/metadata/mcp.json",
|
||||
"internal/cli/schema_hints/selection/mcp.json",
|
||||
"pkg/edition/default.go#openSupplementServers"
|
||||
],
|
||||
"agent_summary": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
|
||||
"agent_summary_source": "dws-agent-selection/mcp",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"只是查询 DWS 已公开命令或参数时使用 dws schema",
|
||||
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
|
||||
],
|
||||
"canonical_path": "mcp.url_get",
|
||||
"cli_name": "get",
|
||||
"cli_path": "mcp url get",
|
||||
"confirmation": "not_required",
|
||||
"description": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。",
|
||||
"display": "管理 MCP 服务连接信息",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws mcp url get 10043 --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址"
|
||||
},
|
||||
"availability": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "available"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "available"
|
||||
},
|
||||
"avoid_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"value": [
|
||||
"只是查询 DWS 已公开命令或参数时使用 dws schema",
|
||||
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
|
||||
]
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"value": [
|
||||
"只是查询 DWS 已公开命令或参数时使用 dws schema",
|
||||
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
|
||||
]
|
||||
},
|
||||
"canonical_path": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "command_registry",
|
||||
"selected": true,
|
||||
"source": "reviewed_command_registry",
|
||||
"source_ref": "mcp url get",
|
||||
"value": "mcp.url_get"
|
||||
}
|
||||
],
|
||||
"precedence": "command_registry",
|
||||
"resolution": "registry_identity",
|
||||
"source": "reviewed_command_registry",
|
||||
"source_ref": "mcp url get",
|
||||
"value": "mcp.url_get"
|
||||
},
|
||||
"confirmation": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "not_required"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "not_required"
|
||||
},
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_help",
|
||||
"selected": true,
|
||||
"source": "cobra_help",
|
||||
"value": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_help",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_help",
|
||||
"value": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。"
|
||||
},
|
||||
"effect": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "read"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "read"
|
||||
},
|
||||
"examples": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"value": [
|
||||
"dws mcp url get 10043 --format json"
|
||||
]
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"value": [
|
||||
"dws mcp url get 10043 --format json"
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "idempotent"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "idempotent"
|
||||
},
|
||||
"interface_mode": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "composite"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "composite"
|
||||
},
|
||||
"interface_reason": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata."
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata."
|
||||
},
|
||||
"interface_ref": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": null
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": null
|
||||
},
|
||||
"reviewed": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
|
||||
"selected": false,
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"value": true
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": true
|
||||
},
|
||||
"risk": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "medium"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"value": "medium"
|
||||
},
|
||||
"title": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_help",
|
||||
"selected": true,
|
||||
"source": "cobra_help",
|
||||
"value": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_help",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_help",
|
||||
"value": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址"
|
||||
},
|
||||
"use_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"value": [
|
||||
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
|
||||
]
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"value": [
|
||||
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
|
||||
]
|
||||
}
|
||||
},
|
||||
"group": "url",
|
||||
"has_parameters": false,
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
|
||||
"is_alias": false,
|
||||
"name": "url_get",
|
||||
"parameter_count": 0,
|
||||
"parameters": {},
|
||||
"path": "mcp.url_get",
|
||||
"positionals": [
|
||||
{
|
||||
"description": "钉钉 MCP 市场中的 mcpId",
|
||||
"index": 0,
|
||||
"name": "mcp_id",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"primary_cli_path": "mcp url get",
|
||||
"product_id": "mcp",
|
||||
"reviewed": true,
|
||||
"risk": "medium",
|
||||
"source": "reviewed_command_registry",
|
||||
"title": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址",
|
||||
"use_when": [
|
||||
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
||||
// 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 cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// schema_catalog.json is generated, but every command entry it delivers to
|
||||
// Agents must follow one unified closed structure: a fixed required core plus
|
||||
// a whitelisted optional set. Any new field must be registered here first,
|
||||
// which keeps the delivered command data structure uniform across products.
|
||||
// Command identity (names/paths) is owned by schema_command_registry.json and
|
||||
// is deliberately out of scope for this validator.
|
||||
|
||||
const schemaCatalogStructureMaxViolations = 25
|
||||
|
||||
// schemaCatalogToolRequiredKeys is the required core present on every tool
|
||||
// entry. Keep sorted; ValidateCatalogStructure rejects entries missing any of
|
||||
// these.
|
||||
var schemaCatalogToolRequiredKeys = []string{
|
||||
"agent_metadata_source",
|
||||
"agent_source_refs",
|
||||
"agent_summary",
|
||||
"agent_summary_source",
|
||||
"availability",
|
||||
"avoid_when",
|
||||
"canonical_path",
|
||||
"cli_name",
|
||||
"cli_path",
|
||||
"confirmation",
|
||||
"description",
|
||||
"display",
|
||||
"effect",
|
||||
"effect_source",
|
||||
"examples",
|
||||
"field_provenance",
|
||||
"has_parameters",
|
||||
"idempotency",
|
||||
"interface_mode",
|
||||
"is_alias",
|
||||
"name",
|
||||
"parameter_count",
|
||||
"parameters",
|
||||
"path",
|
||||
"primary_cli_path",
|
||||
"product_id",
|
||||
"reviewed",
|
||||
"risk",
|
||||
"source",
|
||||
"title",
|
||||
"use_when",
|
||||
}
|
||||
|
||||
// schemaCatalogToolOptionalKeys is the optional whitelist. interface_ref and
|
||||
// interface_reason are mutually exclusive and gated by interface_mode.
|
||||
var schemaCatalogToolOptionalKeys = []string{
|
||||
"aliases",
|
||||
"constraints",
|
||||
"dry_run",
|
||||
"group",
|
||||
"interface_reason",
|
||||
"interface_ref",
|
||||
"metadata_source",
|
||||
"positionals",
|
||||
}
|
||||
|
||||
var schemaCatalogToolEnums = map[string][]string{
|
||||
"effect": {"read", "write", "destructive"},
|
||||
"risk": {"low", "medium", "high"},
|
||||
"confirmation": {"not_required", "user_required"},
|
||||
"interface_mode": {InterfaceModeMCP, InterfaceModeComposite, InterfaceModeLocal},
|
||||
"availability": {InterfaceAvailable, InterfaceUnavailable},
|
||||
}
|
||||
|
||||
// schemaCatalogParamRequiredKeys is the required core of every parameter.
|
||||
var schemaCatalogParamRequiredKeys = []string{
|
||||
"description",
|
||||
"field_provenance",
|
||||
"required",
|
||||
"type",
|
||||
}
|
||||
|
||||
// schemaCatalogParamOptionalKeys is the parameter optional whitelist.
|
||||
var schemaCatalogParamOptionalKeys = []string{
|
||||
"cli_required",
|
||||
"default",
|
||||
"enum",
|
||||
"example",
|
||||
"format",
|
||||
"interface_description",
|
||||
"interface_type",
|
||||
"property",
|
||||
"required_when",
|
||||
}
|
||||
|
||||
var schemaCatalogParamTypes = []string{"string", "integer", "number", "boolean", "array", "object"}
|
||||
|
||||
type schemaCatalogStructureViolation struct {
|
||||
tool string
|
||||
message string
|
||||
}
|
||||
|
||||
// ValidateCatalogStructure checks that every tool entry in a schema_catalog
|
||||
// snapshot conforms to the unified command data structure. It returns an
|
||||
// error aggregating up to schemaCatalogStructureMaxViolations violations.
|
||||
func ValidateCatalogStructure(data []byte) error {
|
||||
var snapshot struct {
|
||||
Version int `json:"version"`
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
return fmt.Errorf("decode schema catalog: %w", err)
|
||||
}
|
||||
if snapshot.Version != SchemaCatalogSnapshotVersion {
|
||||
return fmt.Errorf("schema catalog version = %d, want %d", snapshot.Version, SchemaCatalogSnapshotVersion)
|
||||
}
|
||||
if len(snapshot.Tools) == 0 {
|
||||
return fmt.Errorf("schema catalog has no tools")
|
||||
}
|
||||
|
||||
violations := []schemaCatalogStructureViolation{}
|
||||
for toolID, entry := range snapshot.Tools {
|
||||
validateCatalogToolEntry(toolID, entry, &violations)
|
||||
}
|
||||
if len(violations) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(violations, func(i, j int) bool {
|
||||
if violations[i].tool != violations[j].tool {
|
||||
return violations[i].tool < violations[j].tool
|
||||
}
|
||||
return violations[i].message < violations[j].message
|
||||
})
|
||||
var b strings.Builder
|
||||
shown := violations
|
||||
truncated := 0
|
||||
if len(shown) > schemaCatalogStructureMaxViolations {
|
||||
truncated = len(shown) - schemaCatalogStructureMaxViolations
|
||||
shown = shown[:schemaCatalogStructureMaxViolations]
|
||||
}
|
||||
for _, v := range shown {
|
||||
fmt.Fprintf(&b, "\n %s: %s", v.tool, v.message)
|
||||
}
|
||||
if truncated > 0 {
|
||||
fmt.Fprintf(&b, "\n ... and %d more violations", truncated)
|
||||
}
|
||||
return fmt.Errorf("schema catalog entries violate the unified command structure (%d total):%s", len(violations), b.String())
|
||||
}
|
||||
|
||||
func validateCatalogToolEntry(toolID string, entry map[string]any, violations *[]schemaCatalogStructureViolation) {
|
||||
report := func(format string, args ...any) {
|
||||
*violations = append(*violations, schemaCatalogStructureViolation{
|
||||
tool: toolID,
|
||||
message: fmt.Sprintf(format, args...),
|
||||
})
|
||||
}
|
||||
|
||||
allowed := make(map[string]bool, len(schemaCatalogToolRequiredKeys)+len(schemaCatalogToolOptionalKeys))
|
||||
for _, k := range schemaCatalogToolRequiredKeys {
|
||||
allowed[k] = true
|
||||
}
|
||||
for _, k := range schemaCatalogToolOptionalKeys {
|
||||
allowed[k] = true
|
||||
}
|
||||
for key := range entry {
|
||||
if !allowed[key] {
|
||||
report("unknown field %q (register it in schema_catalog_structure.go first)", key)
|
||||
}
|
||||
}
|
||||
|
||||
stringKeys := []string{
|
||||
"agent_metadata_source", "agent_summary", "agent_summary_source",
|
||||
"availability", "canonical_path", "cli_name", "cli_path", "confirmation",
|
||||
"description", "display", "effect", "effect_source", "idempotency",
|
||||
"interface_mode", "name", "path", "primary_cli_path", "product_id",
|
||||
"risk", "source", "title",
|
||||
}
|
||||
for _, key := range stringKeys {
|
||||
if !requireNonEmptyString(entry, key) {
|
||||
report("field %q must be a non-empty string", key)
|
||||
}
|
||||
}
|
||||
stringArrayKeys := []string{"agent_source_refs", "avoid_when", "examples", "use_when"}
|
||||
for _, key := range stringArrayKeys {
|
||||
if !requireStringArray(entry, key) {
|
||||
report("field %q must be an array of strings", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"has_parameters", "is_alias", "reviewed"} {
|
||||
if _, ok := entry[key].(bool); !ok {
|
||||
report("field %q must be a boolean", key)
|
||||
}
|
||||
}
|
||||
if _, ok := entry["field_provenance"].(map[string]any); !ok {
|
||||
report("field %q must be an object", "field_provenance")
|
||||
}
|
||||
for field, values := range schemaCatalogToolEnums {
|
||||
raw, ok := entry[field].(string)
|
||||
if !ok {
|
||||
continue // already reported as missing/typed above
|
||||
}
|
||||
if !stringSliceContains(values, raw) {
|
||||
report("field %q = %q, want one of {%s}", field, raw, strings.Join(values, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
parameters, paramsOK := entry["parameters"].(map[string]any)
|
||||
if !paramsOK {
|
||||
report("field %q must be an object", "parameters")
|
||||
parameters = nil
|
||||
}
|
||||
count, countOK := entry["parameter_count"].(float64)
|
||||
if !countOK {
|
||||
report("field %q must be a number", "parameter_count")
|
||||
} else if paramsOK && int(count) != len(parameters) {
|
||||
report("parameter_count = %d, want %d (len(parameters))", int(count), len(parameters))
|
||||
}
|
||||
if has, ok := entry["has_parameters"].(bool); ok && paramsOK && has != (len(parameters) > 0) {
|
||||
report("has_parameters = %v, want %v (len(parameters) > 0)", has, len(parameters) > 0)
|
||||
}
|
||||
|
||||
validateCatalogInterface(toolID, entry, violations)
|
||||
for paramName, raw := range parameters {
|
||||
param, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
report("parameter %q must be an object", paramName)
|
||||
continue
|
||||
}
|
||||
validateCatalogParam(toolID, paramName, param, violations)
|
||||
}
|
||||
}
|
||||
|
||||
func validateCatalogInterface(toolID string, entry map[string]any, violations *[]schemaCatalogStructureViolation) {
|
||||
report := func(format string, args ...any) {
|
||||
*violations = append(*violations, schemaCatalogStructureViolation{
|
||||
tool: toolID,
|
||||
message: fmt.Sprintf(format, args...),
|
||||
})
|
||||
}
|
||||
mode, _ := entry["interface_mode"].(string)
|
||||
ref, hasRef := entry["interface_ref"]
|
||||
reason, _ := entry["interface_reason"].(string)
|
||||
switch mode {
|
||||
case InterfaceModeMCP:
|
||||
if !hasRef {
|
||||
report("interface_mode=mcp requires interface_ref")
|
||||
return
|
||||
}
|
||||
refObj, ok := ref.(map[string]any)
|
||||
if !ok {
|
||||
report("interface_ref must be an object")
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"product_id", "rpc_name"} {
|
||||
if s, ok := refObj[key].(string); !ok || strings.TrimSpace(s) == "" {
|
||||
report("interface_ref.%s must be a non-empty string", key)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(reason) != "" {
|
||||
report("interface_mode=mcp must not set interface_reason")
|
||||
}
|
||||
case InterfaceModeComposite, InterfaceModeLocal:
|
||||
if hasRef {
|
||||
report("interface_mode=%s must not set interface_ref", mode)
|
||||
}
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
report("interface_mode=%s requires a non-empty interface_reason", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateCatalogParam(toolID, paramName string, param map[string]any, violations *[]schemaCatalogStructureViolation) {
|
||||
report := func(format string, args ...any) {
|
||||
*violations = append(*violations, schemaCatalogStructureViolation{
|
||||
tool: toolID,
|
||||
message: fmt.Sprintf(format, args...),
|
||||
})
|
||||
}
|
||||
|
||||
allowed := make(map[string]bool, len(schemaCatalogParamRequiredKeys)+len(schemaCatalogParamOptionalKeys))
|
||||
for _, k := range schemaCatalogParamRequiredKeys {
|
||||
allowed[k] = true
|
||||
}
|
||||
for _, k := range schemaCatalogParamOptionalKeys {
|
||||
allowed[k] = true
|
||||
}
|
||||
for key := range param {
|
||||
if !allowed[key] {
|
||||
report("parameter %q: unknown field %q", paramName, key)
|
||||
}
|
||||
}
|
||||
|
||||
typ, _ := param["type"].(string)
|
||||
if !stringSliceContains(schemaCatalogParamTypes, typ) {
|
||||
report("parameter %q: type = %q, want one of {%s}", paramName, typ, strings.Join(schemaCatalogParamTypes, ", "))
|
||||
}
|
||||
if s, ok := param["description"].(string); !ok || strings.TrimSpace(s) == "" {
|
||||
report("parameter %q: description must be a non-empty string", paramName)
|
||||
}
|
||||
if _, ok := param["required"].(bool); !ok {
|
||||
report("parameter %q: required must be a boolean", paramName)
|
||||
}
|
||||
if _, ok := param["field_provenance"].(map[string]any); !ok {
|
||||
report("parameter %q: field_provenance must be an object", paramName)
|
||||
}
|
||||
|
||||
for _, key := range []string{"property", "interface_description", "interface_type", "format", "default", "example", "required_when"} {
|
||||
if raw, present := param[key]; present {
|
||||
if _, ok := raw.(string); !ok {
|
||||
report("parameter %q: %s must be a string", paramName, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, present := param["cli_required"]; present {
|
||||
if _, ok := raw.(bool); !ok {
|
||||
report("parameter %q: cli_required must be a boolean", paramName)
|
||||
}
|
||||
}
|
||||
if raw, present := param["enum"]; present {
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
report("parameter %q: enum must be an array", paramName)
|
||||
} else {
|
||||
for i, item := range items {
|
||||
if _, ok := item.(string); !ok {
|
||||
report("parameter %q: enum[%d] must be a string", paramName, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requireNonEmptyString(entry map[string]any, key string) bool {
|
||||
s, ok := entry[key].(string)
|
||||
return ok && strings.TrimSpace(s) != ""
|
||||
}
|
||||
|
||||
func requireStringArray(entry map[string]any, key string) bool {
|
||||
raw, ok := entry[key].([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, item := range raw {
|
||||
if _, ok := item.(string); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, target string) bool {
|
||||
for _, v := range values {
|
||||
if v == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// 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 cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEmbeddedSchemaCatalogStructure gates the delivered catalog: every tool
|
||||
// entry must conform to the unified closed structure. If this fails after
|
||||
// regeneration, either fix the generator inputs or deliberately extend the
|
||||
// whitelist in schema_catalog_structure.go.
|
||||
func TestEmbeddedSchemaCatalogStructure(t *testing.T) {
|
||||
// After PR #656 the catalog is embedded as per-product shards, not a single
|
||||
// JSON file. The loaded snapshot is the reassembled result — serialize it
|
||||
// back to JSON and validate the same closed structure.
|
||||
loaded := embeddedSchemaCatalog()
|
||||
data, err := json.Marshal(loaded.Snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal embedded catalog snapshot: %v", err)
|
||||
}
|
||||
if err := ValidateCatalogStructure(data); err != nil {
|
||||
t.Fatalf("embedded schema catalog violates the unified command structure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validCatalogToolEntry() map[string]any {
|
||||
return map[string]any{
|
||||
"agent_metadata_source": "embedded-skill-metadata",
|
||||
"agent_source_refs": []any{"skills/mono/references/products/aitable.md"},
|
||||
"agent_summary": "创建多维表格记录",
|
||||
"agent_summary_source": "dws-agent-selection/aitable",
|
||||
"availability": "available",
|
||||
"avoid_when": []any{"批量更新时请用 record update"},
|
||||
"canonical_path": "aitable.record_create",
|
||||
"cli_name": "create",
|
||||
"cli_path": "aitable record create",
|
||||
"confirmation": "not_required",
|
||||
"description": "在数据表中创建一条或多条记录。",
|
||||
"display": "多维表格",
|
||||
"effect": "write",
|
||||
"effect_source": "command-verb",
|
||||
"examples": []any{"dws aitable record create --base-id x"},
|
||||
"field_provenance": map[string]any{"effect": map[string]any{"precedence": "reviewed_explicit"}},
|
||||
"has_parameters": true,
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "mcp",
|
||||
"interface_ref": map[string]any{"product_id": "aitable", "rpc_name": "create_records"},
|
||||
"is_alias": false,
|
||||
"name": "record create",
|
||||
"parameter_count": float64(1),
|
||||
"parameters": map[string]any{
|
||||
"base-id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "目标多维表格 ID。",
|
||||
"required": true,
|
||||
"field_provenance": map[string]any{"description": map[string]any{"precedence": "reviewed_explicit"}},
|
||||
},
|
||||
},
|
||||
"path": "aitable.record_create",
|
||||
"primary_cli_path": "aitable record create",
|
||||
"product_id": "aitable",
|
||||
"reviewed": true,
|
||||
"risk": "medium",
|
||||
"source": "cobra+registry",
|
||||
"title": "创建记录",
|
||||
"use_when": []any{"需要向多维表格写入新记录时"},
|
||||
}
|
||||
}
|
||||
|
||||
func catalogPayload(t *testing.T, entry map[string]any) []byte {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"version": SchemaCatalogSnapshotVersion,
|
||||
"tools": map[string]any{"aitable.record_create": entry},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestValidateCatalogStructureAcceptsValidEntry(t *testing.T) {
|
||||
if err := ValidateCatalogStructure(catalogPayload(t, validCatalogToolEntry())); err != nil {
|
||||
t.Fatalf("ValidateCatalogStructure() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogStructureRejectsViolations(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(entry map[string]any)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unknown tool field",
|
||||
mutate: func(e map[string]any) { e["surprise"] = "x" },
|
||||
want: `unknown field "surprise"`,
|
||||
},
|
||||
{
|
||||
name: "missing required core field",
|
||||
mutate: func(e map[string]any) { delete(e, "agent_summary") },
|
||||
want: `"agent_summary" must be a non-empty string`,
|
||||
},
|
||||
{
|
||||
name: "bad effect enum",
|
||||
mutate: func(e map[string]any) { e["effect"] = "mutate" },
|
||||
want: `"effect" = "mutate"`,
|
||||
},
|
||||
{
|
||||
// 非字符串枚举字段:类型错误由字符串校验报告,枚举循环跳过。
|
||||
name: "non-string enum field skips enum check",
|
||||
mutate: func(e map[string]any) { e["effect"] = float64(1) },
|
||||
want: `"effect" must be a non-empty string`,
|
||||
},
|
||||
{
|
||||
name: "mcp without interface_ref",
|
||||
mutate: func(e map[string]any) { delete(e, "interface_ref") },
|
||||
want: "interface_mode=mcp requires interface_ref",
|
||||
},
|
||||
{
|
||||
name: "composite without interface_reason",
|
||||
mutate: func(e map[string]any) {
|
||||
e["interface_mode"] = "composite"
|
||||
delete(e, "interface_ref")
|
||||
},
|
||||
want: "interface_mode=composite requires a non-empty interface_reason",
|
||||
},
|
||||
{
|
||||
name: "composite must not keep interface_ref",
|
||||
mutate: func(e map[string]any) {
|
||||
e["interface_mode"] = "composite"
|
||||
e["interface_reason"] = "本地组合多个 MCP 调用"
|
||||
},
|
||||
want: "interface_mode=composite must not set interface_ref",
|
||||
},
|
||||
{
|
||||
name: "parameter_count mismatch",
|
||||
mutate: func(e map[string]any) { e["parameter_count"] = float64(2) },
|
||||
want: "parameter_count = 2, want 1",
|
||||
},
|
||||
{
|
||||
name: "has_parameters mismatch",
|
||||
mutate: func(e map[string]any) { e["has_parameters"] = false },
|
||||
want: "has_parameters = false, want true",
|
||||
},
|
||||
{
|
||||
name: "param missing required",
|
||||
mutate: func(e map[string]any) {
|
||||
params := e["parameters"].(map[string]any)
|
||||
delete(params["base-id"].(map[string]any), "required")
|
||||
},
|
||||
want: `parameter "base-id": required must be a boolean`,
|
||||
},
|
||||
{
|
||||
name: "param unknown field",
|
||||
mutate: func(e map[string]any) {
|
||||
params := e["parameters"].(map[string]any)
|
||||
params["base-id"].(map[string]any)["mystery"] = true
|
||||
},
|
||||
want: `parameter "base-id": unknown field "mystery"`,
|
||||
},
|
||||
{
|
||||
name: "param bad type enum",
|
||||
mutate: func(e map[string]any) {
|
||||
params := e["parameters"].(map[string]any)
|
||||
params["base-id"].(map[string]any)["type"] = "text"
|
||||
},
|
||||
want: `parameter "base-id": type = "text"`,
|
||||
},
|
||||
{
|
||||
name: "param default must be string",
|
||||
mutate: func(e map[string]any) {
|
||||
params := e["parameters"].(map[string]any)
|
||||
params["base-id"].(map[string]any)["default"] = float64(3)
|
||||
},
|
||||
want: `parameter "base-id": default must be a string`,
|
||||
},
|
||||
{
|
||||
name: "reviewed must be bool",
|
||||
mutate: func(e map[string]any) { e["reviewed"] = "yes" },
|
||||
want: `"reviewed" must be a boolean`,
|
||||
},
|
||||
{
|
||||
name: "examples must be string array",
|
||||
mutate: func(e map[string]any) { e["examples"] = []any{"ok", float64(1)} },
|
||||
want: `"examples" must be an array of strings`,
|
||||
},
|
||||
{
|
||||
name: "use_when must be an array at all",
|
||||
mutate: func(e map[string]any) { e["use_when"] = "单个字符串" },
|
||||
want: `"use_when" must be an array of strings`,
|
||||
},
|
||||
{
|
||||
name: "field_provenance must be object",
|
||||
mutate: func(e map[string]any) { e["field_provenance"] = "not-an-object" },
|
||||
want: `"field_provenance" must be an object`,
|
||||
},
|
||||
{
|
||||
name: "parameters must be object",
|
||||
mutate: func(e map[string]any) { e["parameters"] = []any{"x"} },
|
||||
want: `"parameters" must be an object`,
|
||||
},
|
||||
{
|
||||
name: "parameter_count must be number",
|
||||
mutate: func(e map[string]any) { e["parameter_count"] = "1" },
|
||||
want: `"parameter_count" must be a number`,
|
||||
},
|
||||
{
|
||||
name: "parameter entry must be object",
|
||||
mutate: func(e map[string]any) {
|
||||
e["parameters"] = map[string]any{"base-id": "not-an-object"}
|
||||
e["parameter_count"] = float64(1)
|
||||
},
|
||||
want: `parameter "base-id" must be an object`,
|
||||
},
|
||||
{
|
||||
name: "interface_ref must be object",
|
||||
mutate: func(e map[string]any) { e["interface_ref"] = "aitable.create_records" },
|
||||
want: "interface_ref must be an object",
|
||||
},
|
||||
{
|
||||
name: "interface_ref keys must be non-empty",
|
||||
mutate: func(e map[string]any) {
|
||||
e["interface_ref"] = map[string]any{"product_id": " ", "rpc_name": float64(1)}
|
||||
},
|
||||
want: "interface_ref.product_id must be a non-empty string",
|
||||
},
|
||||
{
|
||||
name: "mcp must not set interface_reason",
|
||||
mutate: func(e map[string]any) { e["interface_reason"] = "多余理由" },
|
||||
want: "interface_mode=mcp must not set interface_reason",
|
||||
},
|
||||
{
|
||||
name: "param description must be non-empty",
|
||||
mutate: func(e map[string]any) {
|
||||
params := e["parameters"].(map[string]any)
|
||||
params["base-id"].(map[string]any)["description"] = " "
|
||||
},
|
||||
want: `parameter "base-id": description must be a non-empty string`,
|
||||
},
|
||||
{
|
||||
name: "param field_provenance must be object",
|
||||
mutate: func(e map[string]any) {
|
||||
params := e["parameters"].(map[string]any)
|
||||
params["base-id"].(map[string]any)["field_provenance"] = "x"
|
||||
},
|
||||
want: `parameter "base-id": field_provenance must be an object`,
|
||||
},
|
||||
{
|
||||
name: "param cli_required must be bool",
|
||||
mutate: func(e map[string]any) {
|
||||
params := e["parameters"].(map[string]any)
|
||||
params["base-id"].(map[string]any)["cli_required"] = "yes"
|
||||
},
|
||||
want: `parameter "base-id": cli_required must be a boolean`,
|
||||
},
|
||||
{
|
||||
name: "param enum must be array",
|
||||
mutate: func(e map[string]any) {
|
||||
params := e["parameters"].(map[string]any)
|
||||
params["base-id"].(map[string]any)["enum"] = "A,B"
|
||||
},
|
||||
want: `parameter "base-id": enum must be an array`,
|
||||
},
|
||||
{
|
||||
name: "param enum items must be strings",
|
||||
mutate: func(e map[string]any) {
|
||||
params := e["parameters"].(map[string]any)
|
||||
params["base-id"].(map[string]any)["enum"] = []any{"A", float64(2)}
|
||||
},
|
||||
want: `parameter "base-id": enum[1] must be a string`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entry := validCatalogToolEntry()
|
||||
tc.mutate(entry)
|
||||
err := ValidateCatalogStructure(catalogPayload(t, entry))
|
||||
if err == nil {
|
||||
t.Fatalf("ValidateCatalogStructure() = nil, want violation containing %q", tc.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("ValidateCatalogStructure() error = %v, want substring %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogStructureRejectsBadEnvelope(t *testing.T) {
|
||||
if err := ValidateCatalogStructure([]byte(`{"version":99,"tools":{}}`)); err == nil {
|
||||
t.Fatal("ValidateCatalogStructure() = nil, want version error")
|
||||
}
|
||||
if err := ValidateCatalogStructure([]byte(`{"version":1,"tools":{}}`)); err == nil {
|
||||
t.Fatal("ValidateCatalogStructure() = nil, want empty-tools error")
|
||||
}
|
||||
if err := ValidateCatalogStructure([]byte(`not json`)); err == nil {
|
||||
t.Fatal("ValidateCatalogStructure() = nil, want decode error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogStructureSortsViolationsAcrossTools(t *testing.T) {
|
||||
// 两个工具各制造两条违规,驱动排序比较器的 tool 与 message 两个分支。
|
||||
bad1 := validCatalogToolEntry()
|
||||
bad1["effect"] = "mutate"
|
||||
bad1["risk"] = "extreme"
|
||||
bad2 := validCatalogToolEntry()
|
||||
bad2["canonical_path"] = "zz.last_tool"
|
||||
bad2["effect"] = "mutate"
|
||||
bad2["risk"] = "extreme"
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"version": SchemaCatalogSnapshotVersion,
|
||||
"tools": map[string]any{"zz.last_tool": bad2, "aitable.record_create": bad1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verr := ValidateCatalogStructure(payload)
|
||||
if verr == nil {
|
||||
t.Fatal("ValidateCatalogStructure() = nil, want sorted violations")
|
||||
}
|
||||
msg := verr.Error()
|
||||
if !strings.Contains(msg, "(4 total)") {
|
||||
t.Fatalf("error = %v, want 4 total violations", verr)
|
||||
}
|
||||
if first, second := strings.Index(msg, "aitable.record_create"), strings.Index(msg, "zz.last_tool"); first < 0 || second < 0 || first > second {
|
||||
t.Fatalf("violations not sorted by tool: %v", verr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogStructureTruncatesViolationList(t *testing.T) {
|
||||
// 单工具制造超过 25 条违规,验证截断提示。
|
||||
entry := validCatalogToolEntry()
|
||||
for i := 0; i < 30; i++ {
|
||||
entry[fmt.Sprintf("unknown_field_%02d", i)] = true
|
||||
}
|
||||
err := ValidateCatalogStructure(catalogPayload(t, entry))
|
||||
if err == nil {
|
||||
t.Fatal("ValidateCatalogStructure() = nil, want truncated violation list")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "more violations") {
|
||||
t.Fatalf("error = %v, want truncation marker", err)
|
||||
}
|
||||
}
|
||||
@@ -849,7 +849,13 @@ func TestAssemblerUsesReviewedCommandVisibility(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCommandRegistrySourceValidationIsFailClosed(t *testing.T) {
|
||||
registry, err := ValidateCommandRegistrySource(embeddedSchemaCommandRegistryJSON)
|
||||
// After splitting registry into per-product shards, tests use the merged
|
||||
// JSON (reassembled from shards) for byte-level operations.
|
||||
merged, err := EmbeddedCommandRegistryMergedJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("EmbeddedCommandRegistryMergedJSON() error = %v", err)
|
||||
}
|
||||
registry, err := ValidateCommandRegistrySource(merged)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateCommandRegistrySource(embedded) error = %v", err)
|
||||
}
|
||||
@@ -861,8 +867,9 @@ func TestCommandRegistrySourceValidationIsFailClosed(t *testing.T) {
|
||||
t.Fatalf("embedded hash = %q, decoded hash = %q", hash, registry.SourceHash())
|
||||
}
|
||||
|
||||
drifted := strings.Replace(string(embeddedSchemaCommandRegistryJSON), `"cli_path": "aisearch person"`, `"cli_path": "aisearch people"`, 1)
|
||||
if drifted == string(embeddedSchemaCommandRegistryJSON) {
|
||||
// Match both pretty-printed ("cli_path": "...") and compact ("cli_path":"...") JSON.
|
||||
drifted := strings.Replace(string(merged), `"aisearch person"`, `"aisearch people"`, 1)
|
||||
if drifted == string(merged) {
|
||||
t.Fatal("test fixture did not mutate embedded registry")
|
||||
}
|
||||
_, err = ValidateCommandRegistrySource([]byte(drifted))
|
||||
@@ -870,7 +877,12 @@ func TestCommandRegistrySourceValidationIsFailClosed(t *testing.T) {
|
||||
t.Fatalf("drift error = %v", err)
|
||||
}
|
||||
|
||||
unknownField := strings.Replace(string(embeddedSchemaCommandRegistryJSON), `"version": 1`, `"version": 1, "unreviewed": true`, 1)
|
||||
// Test unknown-field rejection: inject an unrecognized top-level key.
|
||||
unknownField := strings.Replace(string(merged), `"version":1`, `"version":1,"unreviewed":true`, 1)
|
||||
if unknownField == string(merged) {
|
||||
// Try with space (pretty-printed format).
|
||||
unknownField = strings.Replace(string(merged), `"version": 1`, `"version": 1, "unreviewed": true`, 1)
|
||||
}
|
||||
_, err = ValidateCommandRegistrySource([]byte(unknownField))
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("unknown-field error = %v", err)
|
||||
|
||||
@@ -48,223 +48,6 @@
|
||||
"version"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "shortcut-catalog-surface",
|
||||
"reason": "Shortcut commands are high-level task entries discovered through dws shortcut list and product skills; they are intentionally separate from the runtime Schema catalog of base Agent tools.",
|
||||
"reviewed": true,
|
||||
"commands": [
|
||||
"aitable +base-get",
|
||||
"aitable +base-list",
|
||||
"aitable +base-search",
|
||||
"aitable +chart-get",
|
||||
"aitable +chart-widgets-example",
|
||||
"aitable +dashboard-config-example",
|
||||
"aitable +dashboard-get",
|
||||
"aitable +field-get",
|
||||
"aitable +find-record",
|
||||
"aitable +form-field-list",
|
||||
"aitable +form-list",
|
||||
"aitable +form-share-get",
|
||||
"aitable +list-tables",
|
||||
"aitable +record-history-list",
|
||||
"aitable +record-query",
|
||||
"aitable +record-query-empty",
|
||||
"aitable +record-share-links",
|
||||
"aitable +record-share-url",
|
||||
"aitable +resolve-base",
|
||||
"aitable +resolve-table",
|
||||
"aitable +role-list",
|
||||
"aitable +section-list-empty",
|
||||
"aitable +section-list-nodes",
|
||||
"aitable +table-get",
|
||||
"aitable +template-search",
|
||||
"aitable +view-get",
|
||||
"aitable +view-get-frozen-cols",
|
||||
"aitable +view-get-lock",
|
||||
"aitable +view-get-row-height",
|
||||
"attendance +check-record",
|
||||
"attendance +check-result",
|
||||
"attendance +get-adjustment-rule",
|
||||
"attendance +get-approve-template",
|
||||
"attendance +get-checkin-record",
|
||||
"attendance +get-leave-records",
|
||||
"attendance +get-overtime-rule",
|
||||
"attendance +get-schedule",
|
||||
"attendance +get-self-setting",
|
||||
"attendance +get-summary",
|
||||
"attendance +list-approve",
|
||||
"attendance +list-leave-types",
|
||||
"attendance +my-attendance",
|
||||
"attendance +query-report-data",
|
||||
"attendance +search-adjustment-rule",
|
||||
"attendance +search-class",
|
||||
"attendance +search-group",
|
||||
"attendance +search-overtime-rule",
|
||||
"attendance +this-month",
|
||||
"calendar +agenda",
|
||||
"calendar +attendee-list",
|
||||
"calendar +book",
|
||||
"calendar +book-list",
|
||||
"calendar +book-search",
|
||||
"calendar +cancel-event",
|
||||
"calendar +conflicts",
|
||||
"calendar +free",
|
||||
"calendar +free-slots",
|
||||
"calendar +freebusy",
|
||||
"calendar +invite",
|
||||
"calendar +my-free",
|
||||
"calendar +next-event",
|
||||
"calendar +reschedule",
|
||||
"calendar +room-groups",
|
||||
"calendar +room-search",
|
||||
"calendar +suggest-time",
|
||||
"calendar +today",
|
||||
"calendar +tomorrow",
|
||||
"calendar +week",
|
||||
"chat +at-me",
|
||||
"chat +bot-find",
|
||||
"chat +bot-search",
|
||||
"chat +broadcast",
|
||||
"chat +category-create",
|
||||
"chat +category-delete",
|
||||
"chat +category-list",
|
||||
"chat +category-rename",
|
||||
"chat +chat-bots",
|
||||
"chat +chat-dismiss",
|
||||
"chat +chat-invite-url",
|
||||
"chat +chat-list-all",
|
||||
"chat +chat-list-join-requests",
|
||||
"chat +chat-list-mine",
|
||||
"chat +chat-mute",
|
||||
"chat +chat-role-add",
|
||||
"chat +chat-role-list",
|
||||
"chat +chat-role-query-user",
|
||||
"chat +chat-role-set-user",
|
||||
"chat +chat-role-update",
|
||||
"chat +chat-search",
|
||||
"chat +chat-set-admin",
|
||||
"chat +chat-set-history",
|
||||
"chat +chat-update-alias",
|
||||
"chat +chat-update-nick",
|
||||
"chat +conversation-clear-all-red-point",
|
||||
"chat +conversation-info",
|
||||
"chat +conversation-list",
|
||||
"chat +conversation-list-top",
|
||||
"chat +dm",
|
||||
"chat +group-members",
|
||||
"chat +messages-list-direct",
|
||||
"chat +messages-list-pin",
|
||||
"chat +messages-list-unread-conversations",
|
||||
"chat +messages-mget",
|
||||
"chat +messages-query-send-status",
|
||||
"chat +messages-read-status",
|
||||
"chat +messages-send-by-webhook",
|
||||
"chat +messages-update-card",
|
||||
"chat +my-groups",
|
||||
"chat +send-to-group",
|
||||
"chat +unread-chats",
|
||||
"contact +by-mobile",
|
||||
"contact +dept-members",
|
||||
"contact +list-dept-members",
|
||||
"contact +list-followings",
|
||||
"contact +list-role-members",
|
||||
"contact +list-roles",
|
||||
"contact +list-sub-depts",
|
||||
"contact +lookup",
|
||||
"contact +me",
|
||||
"contact +org",
|
||||
"contact +resolve-dept",
|
||||
"contact +search-mobile",
|
||||
"contact +search-user",
|
||||
"contact +team",
|
||||
"devapp +create",
|
||||
"devapp +delete",
|
||||
"devapp +disable",
|
||||
"devapp +enable",
|
||||
"devapp +event-list",
|
||||
"devapp +get",
|
||||
"devapp +list",
|
||||
"devapp +member-add",
|
||||
"devapp +member-list",
|
||||
"devapp +member-remove",
|
||||
"devapp +permission-list",
|
||||
"devapp +robot-get",
|
||||
"devapp +update",
|
||||
"devapp +version-check-approval",
|
||||
"devapp +version-get",
|
||||
"devapp +version-list",
|
||||
"devapp +version-status",
|
||||
"devapp +webapp-config",
|
||||
"devapp +webapp-get",
|
||||
"ding +list",
|
||||
"ding +recall-personal",
|
||||
"ding +receiver-status",
|
||||
"ding +send-personal",
|
||||
"doc +comment-create",
|
||||
"doc +comment-list",
|
||||
"doc +comment-reply",
|
||||
"doc +copy",
|
||||
"doc +doc-append",
|
||||
"doc +export-get",
|
||||
"doc +export-submit",
|
||||
"doc +find-doc",
|
||||
"doc +list",
|
||||
"doc +move",
|
||||
"doc +search",
|
||||
"doc +share-doc",
|
||||
"doc +template-list",
|
||||
"doc +template-search",
|
||||
"doc +version-list",
|
||||
"doc +version-revert",
|
||||
"doc +version-save",
|
||||
"drive +copy",
|
||||
"drive +find-file",
|
||||
"drive +info",
|
||||
"drive +move",
|
||||
"drive +recent",
|
||||
"drive +search",
|
||||
"drive +search-docs",
|
||||
"mail +contact-list",
|
||||
"mail +find-mail-user",
|
||||
"mail +folder-list",
|
||||
"mail +recent-mail",
|
||||
"mail +search-mail",
|
||||
"mail +tag-list",
|
||||
"mail +template-list",
|
||||
"mail +thread-list",
|
||||
"mail +unread-mail",
|
||||
"mail +user-search",
|
||||
"minutes +detail",
|
||||
"minutes +list-all",
|
||||
"minutes +list-mine",
|
||||
"minutes +list-shared",
|
||||
"minutes +record-start",
|
||||
"minutes +replace-batch",
|
||||
"oa +list-cc",
|
||||
"oa +list-executed",
|
||||
"oa +list-forms",
|
||||
"oa +list-pending",
|
||||
"oa +list-submitted",
|
||||
"oa +my-initiated",
|
||||
"oa +search-forms",
|
||||
"report +inbox-list",
|
||||
"report +outbox-list",
|
||||
"sheet +list-sheets",
|
||||
"sheet +read",
|
||||
"todo +assign",
|
||||
"todo +assign-multi",
|
||||
"todo +created-todos",
|
||||
"todo +get",
|
||||
"todo +get-my-tasks",
|
||||
"todo +list-attachment",
|
||||
"todo +list-comment",
|
||||
"todo +list-sub",
|
||||
"todo +overdue",
|
||||
"todo +remind",
|
||||
"todo +todo-done",
|
||||
"wiki +space-search"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "agoal-out-of-surface",
|
||||
"reason": "The Agoal product remains executable for compatibility but is outside the currently reviewed open-source Agent command surface.",
|
||||
@@ -370,6 +153,68 @@
|
||||
"todo task list-sub",
|
||||
"todo task remove-attachment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat-shortcuts-pending-schema-curation",
|
||||
"reason": "These reviewed public Chat shortcuts remain executable and discoverable through the Shortcut catalog while their typed Schema selection and metadata records are curated; exact paths keep reverse completeness strict without hiding future shortcuts.",
|
||||
"reviewed": true,
|
||||
"commands": [
|
||||
"chat +category-add-conversation",
|
||||
"chat +category-list-conversations",
|
||||
"chat +category-remove-conversation",
|
||||
"chat +chat-add-bot",
|
||||
"chat +chat-audit-join",
|
||||
"chat +chat-create",
|
||||
"chat +chat-get-by-id",
|
||||
"chat +chat-members-get",
|
||||
"chat +chat-members-list",
|
||||
"chat +chat-messages",
|
||||
"chat +chat-mute-member",
|
||||
"chat +chat-quit",
|
||||
"chat +chat-remove-bot",
|
||||
"chat +chat-role-remove",
|
||||
"chat +chat-role-remove-user",
|
||||
"chat +chat-transfer-owner",
|
||||
"chat +chat-update",
|
||||
"chat +chat-update-icon",
|
||||
"chat +chat-update-settings",
|
||||
"chat +conversation-clear-messages",
|
||||
"chat +conversation-clear-red-point",
|
||||
"chat +conversation-hide",
|
||||
"chat +conversation-mark-read",
|
||||
"chat +conversation-mark-unread",
|
||||
"chat +conversation-mute",
|
||||
"chat +conversation-set-top",
|
||||
"chat +feed-group-query-item",
|
||||
"chat +flag-cancel",
|
||||
"chat +flag-create",
|
||||
"chat +flag-list",
|
||||
"chat +messages-add-emoji",
|
||||
"chat +messages-add-text-emotion",
|
||||
"chat +messages-batch-recall-by-bot",
|
||||
"chat +messages-batch-send-by-bot",
|
||||
"chat +messages-combine-forward",
|
||||
"chat +messages-create-text-emotion",
|
||||
"chat +messages-forward",
|
||||
"chat +messages-forward-topic",
|
||||
"chat +messages-list",
|
||||
"chat +messages-recall",
|
||||
"chat +messages-recall-by-bot",
|
||||
"chat +messages-remove-emoji",
|
||||
"chat +messages-remove-text-emotion",
|
||||
"chat +messages-reply",
|
||||
"chat +messages-resource-download",
|
||||
"chat +messages-resource-url",
|
||||
"chat +messages-send",
|
||||
"chat +messages-send-by-bot",
|
||||
"chat +messages-send-card",
|
||||
"chat +messages-set-pin",
|
||||
"chat +messages-set-top",
|
||||
"chat +messages-unset-pin",
|
||||
"chat +messages-unset-top",
|
||||
"chat +search-msg",
|
||||
"chat +thread-replies"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@ package cli
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -25,8 +26,11 @@ const commandRegistrySchemaRef = "./schema_command_registry.schema.json"
|
||||
// sole source of stable command identity and navigation. Catalog and generated
|
||||
// metadata are downstream views and must never be read back here.
|
||||
|
||||
//go:embed schema_command_registry.json
|
||||
var embeddedSchemaCommandRegistryJSON []byte
|
||||
//go:embed schema_command_registry/registry.json
|
||||
var embeddedSchemaCommandRegistryEnvelopeJSON []byte
|
||||
|
||||
//go:embed schema_command_registry/products
|
||||
var embeddedSchemaCommandRegistryProducts embed.FS
|
||||
|
||||
//go:embed schema_command_registry.schema.json
|
||||
var embeddedSchemaCommandRegistrySchemaJSON []byte
|
||||
@@ -34,7 +38,7 @@ var embeddedSchemaCommandRegistrySchemaJSON []byte
|
||||
var (
|
||||
commandRegistryProductIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
|
||||
commandRegistryCanonicalPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*\.[A-Za-z0-9][A-Za-z0-9_.-]*$`)
|
||||
commandRegistryCLIPathToken = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`)
|
||||
commandRegistryCLIPathToken = regexp.MustCompile(`^(?:[A-Za-z0-9][A-Za-z0-9._:-]*|\+[A-Za-z0-9][A-Za-z0-9._:-]*)$`)
|
||||
)
|
||||
|
||||
type schemaCommandRegistrySnapshot struct {
|
||||
@@ -96,11 +100,110 @@ var (
|
||||
|
||||
func loadEmbeddedCommandRegistry() (CommandRegistry, error) {
|
||||
embeddedSchemaCommandRegistryOnce.Do(func() {
|
||||
embeddedSchemaCommandRegistryData, embeddedSchemaCommandRegistryErr = decodeCommandRegistry(embeddedSchemaCommandRegistryJSON)
|
||||
embeddedSchemaCommandRegistryData, embeddedSchemaCommandRegistryErr = assembleCommandRegistry()
|
||||
})
|
||||
return cloneCommandRegistry(embeddedSchemaCommandRegistryData), embeddedSchemaCommandRegistryErr
|
||||
}
|
||||
|
||||
// assembleCommandRegistry reads the per-product shards embedded at build time
|
||||
// and reassembles them into a single CommandRegistry, identical to the previous
|
||||
// single-file layout. Mirrors assembleEmbeddedSchemaCatalog for catalog shards.
|
||||
func assembleCommandRegistry() (CommandRegistry, error) {
|
||||
return assembleCommandRegistryFrom(embeddedSchemaCommandRegistryEnvelopeJSON, embeddedSchemaCommandRegistryProducts, "schema_command_registry/products")
|
||||
}
|
||||
|
||||
// assembleCommandRegistryFrom reassembles an envelope plus product shard
|
||||
// directory. Split from assembleCommandRegistry so shard failure modes stay
|
||||
// testable against fake filesystems.
|
||||
func assembleCommandRegistryFrom(envelopeJSON []byte, shards fs.FS, dir string) (CommandRegistry, error) {
|
||||
var envelope struct {
|
||||
Schema string `json:"$schema,omitempty"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(envelopeJSON, &envelope); err != nil {
|
||||
return CommandRegistry{}, fmt.Errorf("decode embedded command registry envelope: %w", err)
|
||||
}
|
||||
|
||||
entries, err := fs.ReadDir(shards, dir)
|
||||
if err != nil {
|
||||
return CommandRegistry{}, fmt.Errorf("read embedded command registry products: %w", err)
|
||||
}
|
||||
|
||||
var snapshot schemaCommandRegistrySnapshot
|
||||
snapshot.Schema = envelope.Schema
|
||||
snapshot.Version = envelope.Version
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
data, readErr := fs.ReadFile(shards, dir+"/"+entry.Name())
|
||||
if readErr != nil {
|
||||
return CommandRegistry{}, fmt.Errorf("read embedded command registry shard %s: %w", entry.Name(), readErr)
|
||||
}
|
||||
var product schemaCommandRegistryProduct
|
||||
if err := json.Unmarshal(data, &product); err != nil {
|
||||
return CommandRegistry{}, fmt.Errorf("decode embedded command registry shard %s: %w", entry.Name(), err)
|
||||
}
|
||||
snapshot.Products = append(snapshot.Products, product)
|
||||
}
|
||||
|
||||
// Encode to bytes and decode via the existing pipeline so all validation
|
||||
// (canonical patterns, duplicate detection, visibility) runs identically.
|
||||
// Marshal of this plain struct cannot fail.
|
||||
data, _ := json.Marshal(snapshot)
|
||||
return decodeCommandRegistry(data)
|
||||
}
|
||||
|
||||
// EmbeddedCommandRegistryMergedJSON returns the per-product shards reassembled
|
||||
// into a single JSON document matching the pre-split layout. Used by tests that
|
||||
// need the full registry bytes.
|
||||
func EmbeddedCommandRegistryMergedJSON() ([]byte, error) {
|
||||
return mergedCommandRegistryJSON(embeddedSchemaCommandRegistryEnvelopeJSON, embeddedSchemaCommandRegistryProducts, "schema_command_registry/products")
|
||||
}
|
||||
|
||||
// mergedCommandRegistryJSON is the injectable core of
|
||||
// EmbeddedCommandRegistryMergedJSON.
|
||||
func mergedCommandRegistryJSON(envelopeJSON []byte, shards fs.FS, dir string) ([]byte, error) {
|
||||
var envelope struct {
|
||||
Schema string `json:"$schema,omitempty"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(envelopeJSON, &envelope); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := fs.ReadDir(shards, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
products := make([]json.RawMessage, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
data, err := fs.ReadFile(shards, dir+"/"+entry.Name())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
products = append(products, json.RawMessage(data))
|
||||
}
|
||||
result := struct {
|
||||
Schema string `json:"$schema,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Products json.RawMessage `json:"products"`
|
||||
}{
|
||||
Schema: envelope.Schema,
|
||||
Version: envelope.Version,
|
||||
}
|
||||
if result.Schema == "" {
|
||||
result.Schema = "./schema_command_registry.schema.json"
|
||||
}
|
||||
result.Products, err = json.Marshal(products)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(result)
|
||||
}
|
||||
|
||||
// ValidateCommandRegistrySource validates a compatibility --surface input and
|
||||
// requires it to be semantically identical to the embedded reviewed registry.
|
||||
// Generators may retain the flag for migration, but cannot use it to introduce
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,8 +36,8 @@
|
||||
},
|
||||
"cliPath": {
|
||||
"type": "string",
|
||||
"pattern": "^(?!dws(?:\\s|$))(?!.*(?:^|\\s)--)(?!.*[*?\\[\\]])[A-Za-z0-9][A-Za-z0-9._:-]*(?: [A-Za-z0-9][A-Za-z0-9._:-]*)*$",
|
||||
"description": "Exact Cobra command path without the leading 'dws', flags, wildcard characters, or surrounding/repeated whitespace."
|
||||
"pattern": "^(?!dws(?:\\s|$))(?!.*(?:^|\\s)--)(?!.*[*?\\[\\]])(?:[A-Za-z0-9][A-Za-z0-9._:-]*|\\+[A-Za-z0-9][A-Za-z0-9._:-]*)(?: (?:[A-Za-z0-9][A-Za-z0-9._:-]*|\\+[A-Za-z0-9][A-Za-z0-9._:-]*))*$",
|
||||
"description": "Exact Cobra command path without the leading 'dws', flags, wildcard characters, or surrounding/repeated whitespace. A token may use the reviewed '+' shortcut prefix."
|
||||
},
|
||||
"product": {
|
||||
"type": "object",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "aisearch",
|
||||
"tools": [
|
||||
{
|
||||
"canonical_path": "aisearch.search_enterprise_behavior",
|
||||
"cli_path": "aisearch behavior"
|
||||
},
|
||||
{
|
||||
"canonical_path": "aisearch.search_enterprise",
|
||||
"cli_path": "aisearch enterprise"
|
||||
},
|
||||
{
|
||||
"canonical_path": "aisearch.enterprise_person_search",
|
||||
"cli_path": "aisearch person"
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user