Compare commits

..
Author SHA1 Message Date
玉澜andCursor d5c8982c00 feat(upgrade): always refresh to multi-skill layout (no sticky)
When a release zip contains multi/, upgrade one-shot refreshes to the
multi-skill layout and migrates existing mono installs. Docs drop the
cancelled runtime switch / sticky design.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:33:37 +08:00
玉澜andCursor 402429ac2a feat(skill): default installs and upgrades to multi-skill layout
Flip the agent-skill default from mono (single dws/ dir) to multi
(per-product dingtalk-* + dws-shared) across all distribution faces,
and fix the upgrade path so it no longer re-installs mono alongside
multi (mono+multi co-existence bug).

- upgrade: LocateSkillsRoot prefers the zip multi/ tree; multi refresh
  removes mono leftovers and stale skills, refreshes the multi cache
- install.sh/ps1/install-skills.sh/npm install.js: multi real-install
  (was print-only), default flipped, mono stays opt-in via DWS_SKILL_MODE
- skill setup: non-interactive default multi; full installs now clean
  stale dingtalk-*/dws-shared with confirm-preview disclosure, filtered
  (-s/-x) installs stay additive
- mutual exclusion is symmetric and includes dws-shared (previously
  leaked through the dingtalk- prefix) on all faces
- install.js: guard empty/corrupt multi trees (fall back to mono),
  validate SKILL.md on the mono branch, guard cache refreshes
- docs: roadmap (8/30 back-schedule), migration plan, distribution
  mechanism, rollout capability, capability completion, architecture
  optimization, wukong comparison (archived; line retired)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:13:45 +08:00
816 changed files with 22236 additions and 125975 deletions
-61
View File
@@ -1,61 +0,0 @@
# /eval 自助触发允许名单
#
# 名单内的 GitHub 登录名可对【自己创建的 PR】触发 /eval 评测;
# 对任意 PR 触发仍需仓库 write/maintain/admin 权限(维护者背书)。
# 授权读取的始终是默认分支上的本文件,PR 无法修改自身授权。
#
# 变更本文件必须走 PR 评审。每行一个 GitHub login,# 开头为注释。
aftersss
notable-open
EdgarWang0925
ayunya
yutongshe
qingyang1014
caiTriumph
xlb1130
Anonymity-0
FuShu-Yang
guimingyue
AlwaysLee
TaoJikun
zengyoulingzyl-stack
liyuan333
huangyoo
lifeihong
nitonitori
cywan1998
gangwn
junlonghuo2
aqruan
Freda0909
ShawnWhite777
PeterGuy326
abucraft
pengzhihan47-star
rainyak8
gongrongyun
huangyuanzhuo-coder
ybcstudy
bigqy
liwang-ai
meng93
wxianfeng
Patrick-Star-CN
rossluo28-hz
dxy704330469
gtezg30062
Neige-Premaire
zhuoyu20
avicii-chen
typefield
Haofeng0705
Huwenjiao
liuzeyang
maoqxxmm
FloralTide
lingyun9833
dxb121
C0922
xiaoji121
H3java
-285
View File
@@ -1,285 +0,0 @@
'use strict';
// 评审归属是受保护分支上的声明式规则;未知路径不猜测,交给工作流负载均衡兜底。
const REVIEWER_POOL = ['wxianfeng', 'typefield', 'haofeng0705', 'hlzjsong'];
const PRODUCT_GROUPS = [
{
primary: 'wxianfeng',
backup: 'typefield',
products: ['chat', 'contact', 'ding', 'event', 'mail', 'live', 'conference', 'dev', 'devapp', 'mcp', 'aiapp'],
},
{
primary: 'typefield',
backup: 'wxianfeng',
products: ['doc', 'drive', 'wiki', 'markdown', 'docparse', 'aidesign', 'devdoc', 'blackboard', 'finance', 'law', 'credit'],
},
{
primary: 'haofeng0705',
backup: 'typefield',
products: ['minutes', 'sheet', 'aitable', 'calendar', 'todo', 'oa', 'attendance', 'report', 'agoal', 'aisearch', 'yida', 'hrbrain'],
},
];
const pathStartsWith = (prefixes) => (path) => prefixes.some((prefix) => path.startsWith(prefix));
const MODULES = [
{
id: 'security',
label: '登录、认证、权限、安全',
primary: 'hlzjsong',
backup: 'typefield',
requiresSecondary: true,
matches: pathStartsWith([
'internal/auth/',
'internal/keychain/',
'internal/audit/',
'internal/pat/',
'internal/security/',
'internal/safety/',
'pkg/edition/',
]),
},
{
id: 'delivery',
label: 'CI、测试、发布、安装',
primary: 'haofeng0705',
backup: 'wxianfeng',
requiresSecondary: true,
matches: (path) =>
path.startsWith('.github/') ||
path.startsWith('scripts/release/') ||
path.startsWith('scripts/policy/') ||
path.startsWith('scripts/dev/') ||
path.startsWith('scripts/install') ||
path.startsWith('Formula/') ||
path.startsWith('build/') ||
path.startsWith('internal/upgrade/') ||
path.startsWith('internal/app/upgrade') ||
path.startsWith('test/') ||
path.startsWith('verify/') ||
path.startsWith('.workflow/') ||
path === 'coverage.txt' ||
path === 'coverage-base.txt' ||
path === '.goreleaser.yaml' ||
path === 'package.json' ||
path === 'package-lock.json' ||
path === 'docs/releasing.md',
},
{
id: 'architecture',
label: 'DWS 架构、公共内核',
primary: 'wxianfeng',
backup: 'typefield',
requiresSecondary: true,
matches: pathStartsWith([
'cmd/',
'internal/apiclient/',
'internal/app/',
'internal/cli/',
'internal/cobracmd/',
'internal/corecmd/',
'internal/errors/',
'internal/executor/',
'internal/generator/',
'internal/i18n/',
'internal/interfacesnapshot/',
'internal/jsonutil/',
'internal/localio/',
'internal/logging/',
'internal/output/',
'internal/pipeline/',
'internal/plugin/',
'internal/profilectx/',
'internal/registry/',
'internal/syncdata/',
'internal/testseam/',
'internal/transport/',
'pkg/',
]),
},
{
id: 'compatibility',
label: '兼容性',
primary: 'wxianfeng',
backup: 'typefield',
requiresSecondary: true,
matches: (path) =>
/(?:^|[/_.-])compat(?:ibility)?(?=$|[/_.-])/.test(path) ||
path.includes('schema_compat'),
},
];
function productMatches(path, product) {
const aliases = product === 'blackboard' ? ['blackboard', 'whiteboard'] : [product];
return aliases.some((alias) => new RegExp(`(?:^|[/_.-])${alias}(?=$|[/_.-])`).test(path));
}
const PRODUCT_MODULES = PRODUCT_GROUPS.flatMap((group) =>
group.products.map((product) => ({
id: `product:${product}`,
label: `产品:${product}`,
primary: group.primary,
backup: group.backup,
requiresSecondary: false,
matches: (path) => productMatches(path, product),
})),
);
const ALL_MODULES = [MODULES[0], MODULES[1], ...PRODUCT_MODULES, MODULES[2], MODULES[3]];
function normalizedPaths(file) {
return [file?.filename, file?.previous_filename]
.filter((path) => typeof path === 'string' && path !== '')
.map((path) => path.toLowerCase());
}
function compareStats(left, right) {
return right.files - left.files || left.module.order - right.module.order || left.module.id.localeCompare(right.module.id);
}
function classifyFiles(files) {
const counts = new Map();
for (const file of files || []) {
const matchingModules = new Set();
for (const path of normalizedPaths(file)) {
const matches = ALL_MODULES.filter((module) => module.matches(path));
const securityOrDelivery = matches.filter(
(module) => module.id === 'security' || module.id === 'delivery',
);
const effectiveMatches = securityOrDelivery.length > 0
? [...securityOrDelivery, ...matches.filter((module) => module.id === 'compatibility')]
: matches;
for (const match of effectiveMatches) {
matchingModules.add(match.id);
}
if (
effectiveMatches.length === 0 &&
(path.startsWith('internal/helpers/') || path.startsWith('internal/shortcut/'))
) {
matchingModules.add('architecture');
}
}
for (const moduleID of matchingModules) {
counts.set(moduleID, (counts.get(moduleID) || 0) + 1);
}
}
return [...counts.entries()]
.map(([id, files]) => {
const index = ALL_MODULES.findIndex((module) => module.id === id);
return {module: {...ALL_MODULES[index], order: index}, files};
})
.sort(compareStats);
}
function chooseModuleReviewer(module, unavailable) {
return [module.primary, module.backup].find(
(reviewer) => REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer),
);
}
function addReviewer(reviewers, reviewer) {
if (reviewer && !reviewers.includes(reviewer)) {
reviewers.push(reviewer);
}
}
function reviewerCandidates({preferredReviewers, fallbackReviewers, eligibleReviewers}) {
const eligible = new Set(eligibleReviewers.map((reviewer) => reviewer.toLowerCase()));
const candidates = [];
for (const reviewer of [...preferredReviewers, ...fallbackReviewers]) {
if (
eligible.has(reviewer.toLowerCase()) &&
!candidates.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase())
) {
candidates.push(reviewer);
}
}
return candidates;
}
async function requestReviewersWithFallback({
candidates,
requiredReviewers,
satisfiedReviewers = [],
requestReviewer,
onFailure = () => {},
}) {
const alreadySatisfied = new Set(
satisfiedReviewers.map((reviewer) => reviewer.toLowerCase()),
);
const satisfied = new Set();
const requested = [];
for (const reviewer of candidates) {
if (satisfied.size >= requiredReviewers) {
break;
}
const normalizedReviewer = reviewer.toLowerCase();
if (alreadySatisfied.has(normalizedReviewer)) {
satisfied.add(normalizedReviewer);
continue;
}
try {
const shouldContinue = await requestReviewer(reviewer);
if (shouldContinue === false) {
return {requested, satisfiedReviewers: [...satisfied], aborted: true};
}
requested.push(reviewer);
satisfied.add(normalizedReviewer);
} catch (error) {
onFailure(reviewer, error);
}
}
return {requested, satisfiedReviewers: [...satisfied], aborted: false};
}
function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = REVIEWER_POOL}) {
const modules = classifyFiles(files);
const unavailable = new Set([author, latestPusher].filter(Boolean).map((login) => login.toLowerCase()));
const reviewers = [];
const primaryModule = modules[0];
if (!primaryModule) {
return {modules: [], reviewers, requiredReviewers: 1, reason: 'unknown_paths'};
}
addReviewer(reviewers, chooseModuleReviewer(primaryModule.module, unavailable));
const requiresSecondary =
modules.length > 1 || modules.some(({module}) => module.requiresSecondary);
const secondaryModule = modules.find(({module}) => module.id !== primaryModule.module.id) || primaryModule;
if (requiresSecondary) {
addReviewer(
reviewers,
chooseModuleReviewer(secondaryModule.module, new Set([...unavailable, ...reviewers])),
);
}
for (const reviewer of fallbackReviewers) {
if (reviewers.length >= (requiresSecondary ? 2 : 1)) {
break;
}
if (REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer)) {
addReviewer(reviewers, reviewer);
}
}
return {
modules: modules.map(({module, files}) => ({id: module.id, label: module.label, files})),
reviewers,
requiredReviewers: requiresSecondary ? 2 : 1,
reason: requiresSecondary ? 'cross_or_sensitive' : 'single_module',
};
}
module.exports = {
REVIEWER_POOL,
classifyFiles,
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
};
-148
View File
@@ -1,148 +0,0 @@
'use strict';
const assert = require('node:assert/strict');
const {
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
} = require('./reviewer-routing');
function route(files, author = 'author', latestPusher = author) {
return resolveReviewRouting({files: files.map((filename) => ({filename})), author, latestPusher});
}
{
const result = route(['internal/helpers/chat_toolbar.go']);
assert.deepEqual(result.reviewers, ['wxianfeng']);
assert.equal(result.requiredReviewers, 1);
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat']);
}
{
const result = route(['internal/helpers/chat_toolbar.go', 'internal/helpers/doc_style.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['.github/workflows/ci.yml']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
assert.equal(result.reason, 'cross_or_sensitive');
}
{
const result = route(['internal/auth/login.go'], 'hlzjsong');
assert.deepEqual(result.reviewers, ['typefield', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/upgrade/downloader.go']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/app/upgrade.go', 'scripts/dev/test-release.sh']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['pkg/edition/edition.go']);
assert.deepEqual(result.reviewers, ['hlzjsong', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/shortcut/chat/compatibility_coverage_test.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat', 'compatibility']);
}
{
const result = route(['internal/helpers/leaf_dispatch.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['docs/unknown-area.md']);
assert.deepEqual(result.reviewers, []);
assert.equal(result.reason, 'unknown_paths');
}
async function testSingleReviewerFallback() {
const candidates = reviewerCandidates({
preferredReviewers: ['wxianfeng'],
fallbackReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
eligibleReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
});
const attempts = [];
const result = await requestReviewersWithFallback({
candidates,
requiredReviewers: 1,
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
if (reviewer === 'wxianfeng') {
throw Object.assign(new Error('cannot request primary'), {status: 422});
}
return true;
},
});
assert.deepEqual(attempts, ['wxianfeng', 'typefield']);
assert.deepEqual(result.requested, ['typefield']);
assert.equal(result.satisfiedReviewers.length, 1);
}
async function testTwoReviewerFallback() {
const candidates = reviewerCandidates({
preferredReviewers: ['haofeng0705', 'wxianfeng'],
fallbackReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
eligibleReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
});
const attempts = [];
const result = await requestReviewersWithFallback({
candidates,
requiredReviewers: 2,
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
if (reviewer === 'wxianfeng') {
throw Object.assign(new Error('temporary failure'), {status: 503});
}
return true;
},
});
assert.deepEqual(attempts, ['haofeng0705', 'wxianfeng', 'typefield']);
assert.deepEqual(result.requested, ['haofeng0705', 'typefield']);
assert.equal(result.satisfiedReviewers.length, 2);
}
async function testLowerPriorityExistingRequestDoesNotReplaceOwner() {
const attempts = [];
const result = await requestReviewersWithFallback({
candidates: ['wxianfeng', 'typefield'],
requiredReviewers: 1,
satisfiedReviewers: ['typefield'],
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
return true;
},
});
assert.deepEqual(attempts, ['wxianfeng']);
assert.deepEqual(result.requested, ['wxianfeng']);
assert.deepEqual(result.satisfiedReviewers, ['wxianfeng']);
}
Promise.all([
testSingleReviewerFallback(),
testTwoReviewerFallback(),
testLowerPriorityExistingRequestDoesNotReplaceOwner(),
])
.then(() => console.log('reviewer routing policy tests passed'))
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
+19 -40
View File
@@ -111,7 +111,6 @@ jobs:
filename.startsWith('internal/app/') ||
filename.startsWith('internal/cli/') ||
filename.startsWith('internal/cobracmd/') ||
filename.startsWith('internal/corecmd/') ||
filename.startsWith('internal/helpers/') ||
filename.startsWith('internal/i18n/') ||
filename.startsWith('internal/interfacesnapshot/') ||
@@ -148,12 +147,12 @@ jobs:
filename === '.github/actionlint.yaml' ||
filename.startsWith('scripts/') ||
filename.startsWith('verify/') ||
filename.startsWith('internal/helpers/') ||
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/') ||
@@ -434,10 +433,6 @@ jobs:
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
- name: Test reviewer routing policy
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
run: node .github/reviewer-routing.test.js
test-focused:
name: Test (changed packages)
needs: lint
@@ -707,11 +702,14 @@ jobs:
with:
go-version-file: go.mod
- name: Test macOS auth and Keychain packages with Race Detection
run: go test -v -race -count=1 -timeout=6m ./internal/keychain ./internal/auth
# Full ./internal/app (including schema --all assembly) already runs in the
# race:app shard. Keep this job focused on native auth/keychain paths so
# heavy Schema completeness tests cannot exhaust the 10m budget.
- name: Test macOS auth and Keychain paths with Race Detection
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth
- name: Test macOS auth migration, Keychain diagnostics, and upgrade self-heal with Race Detection
run: go test -v -race -count=1 -timeout=5m ./internal/app -run '^(TestValidateNewBinary_RecoversFromUnsignedDarwin|Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch))'
- name: Test macOS auth migration and portable auth diagnostics
run: go test -v -race -count=1 -timeout=5m ./internal/app -run '^Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch|ExportRejectsWindowsDPAPIBackend|ImportRejectsWindowsDPAPIBackend)'
test-windows:
name: Test (Windows)
@@ -822,7 +820,6 @@ jobs:
run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt
- name: Upload Windows coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-windows
@@ -1181,7 +1178,7 @@ jobs:
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
COVERAGE_TARGET: "100"
COVERAGE_ENFORCE_OVERALL: "false"
COVERAGE_OVERALL_TOLERANCE: "0.1"
COVERAGE_OVERALL_TOLERANCE: "0"
run: |
policy_profile=coverage-policy.txt
if [ "$FULL_SUITE" != true ]; then
@@ -1371,47 +1368,29 @@ jobs:
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
candidate_ref="$(git rev-parse 'HEAD^{commit}')"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$candidate_ref" != "$PR_HEAD_SHA" ]; then
echo "Compatibility checkout $candidate_ref does not match PR head $PR_HEAD_SHA" >&2
exit 1
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
. ./scripts/release/release-lib.sh
stable_ref=""
for tag in $(git tag --merged "$base_ref" --list 'v*' --sort=-version:refname); do
release_is_stable_version "$tag" || continue
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
continue
fi
stable_ref="$tag"
break
done
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
if [ -z "$stable_ref" ]; then
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
exit 1
fi
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
printf '%s\n' \
"COMPATIBILITY_BASE_REF=$base_ref" \
"COMPATIBILITY_STABLE_REF=$stable_ref" \
"COMPATIBILITY_CANDIDATE_REF=$candidate_ref" >> "$GITHUB_ENV"
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
- name: Check historical commands, help, and complete CLI compatibility
- name: Check historical commands and help compatibility
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" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
BASE_REF="$COMPATIBILITY_BASE_REF"
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_STABLE_REF"
fi
- name: Check complete Schema compatibility
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" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
- name: Check skill command references
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') }}
-296
View File
@@ -1,296 +0,0 @@
name: PR Eval Dispatch
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 生成可验证的评测请求,报告由 bot 回贴。
# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码。
# 审核 SHA 规则:评测他人 PR 必须显式携带 sha=(审阅背书凭据,验证
# 其恰为当前 open head);评测自己创建的 PR 可省略,自动钉住派发时刻
# 的当前 head(作者自背书,无第三方偷换窗口);受控评测执行端另以
# FETCH_HEAD 校验兜底派发后的变更。
# 授权两级:仓库 write/maintain/admin 可派发任意 PR;默认分支
# .github/eval-allowlist.txt 名单内的用户仅可派发自己创建的 PR。
# 触发通道:workflow 先创建占位评论,再上传与本次 run/comment 绑定的
# 不可变 manifest artifact,最后把 artifact 指针写回同一评论。评论仅是
# 不可信通知;受控评测服务必须验证成功 run、artifact 与 manifest,并在
# 触发评测前原子占用 manifest.idempotency_key,重复占用只能 no-op。
on:
issue_comment:
types:
- created
permissions: {}
concurrency:
group: eval-dispatch-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
dispatch:
name: Dispatch internal evaluation
if: >-
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/eval')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
# 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。
pull-requests: write
steps:
- name: Check out default branch tooling
uses: actions/checkout@v4
- name: Verify commenter dispatch authorization
env:
GH_TOKEN: ${{ github.token }}
COMMENTER: ${{ github.event.comment.user.login }}
PR_AUTHOR: ${{ github.event.issue.user.login }}
EVAL_ALLOWLIST_PATH: .github/eval-allowlist.txt
run: |
# 不用 --fail:非协作者查权限返回 404 错误体,交由 guard 走名单分支;硬网络错误降级为空对象同样 fail-closed
permission_json="$(curl --silent --show-error \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/collaborators/${COMMENTER}/permission")" || permission_json='{}'
printf '%s' "$permission_json" | python3 scripts/ci/eval_dispatch_guard.py permission
- name: Parse /eval command
id: parse
continue-on-error: true
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: python3 scripts/ci/eval_comment_parse.py
- name: Reply usage on parse failure
if: steps.parse.outcome == 'failure'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
PARSE_ERROR: ${{ steps.parse.outputs.error }}
run: |
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$body" \
> /dev/null
exit 1
- name: Verify reviewed PR head
id: pr
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
EXPECTED_PR_NUMBER: ${{ github.event.issue.number }}
REVIEWED_SHA: ${{ steps.parse.outputs.reviewed_sha }}
COMMENTER: ${{ github.event.comment.user.login }}
run: |
pr_json="$(curl --fail --silent --show-error \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")"
printf '%s' "$pr_json" \
| python3 scripts/ci/eval_dispatch_guard.py head \
>> "$GITHUB_OUTPUT"
- name: Create dispatch placeholder
id: placeholder
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
set -euo pipefail
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
response="$(
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$placeholder_body"
)"
comment_id="$(
printf '%s' "$response" \
| jq -er \
--arg issue_url "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}" \
'select(.issue_url == $issue_url) | .id | tostring | select(test("^[1-9][0-9]*$"))'
)"
printf 'comment_id=%s\n' "$comment_id" >> "$GITHUB_OUTPUT"
- name: Build dispatch request manifest
env:
REPOSITORY_ID: '1187709537'
REPOSITORY: ${{ github.repository }}
WORKFLOW_ID: '331725458'
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
SOURCE_COMMENT_ID: ${{ github.event.comment.id }}
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
ACTOR_ID: ${{ github.event.comment.user.id }}
ACTOR_LOGIN: ${{ github.event.comment.user.login }}
PR_NUMBER: ${{ github.event.issue.number }}
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PRODUCTS: ${{ steps.parse.outputs.products }}
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
SOURCE_BODY: ${{ github.event.comment.body }}
MANIFEST_PATH: ${{ runner.temp }}/eval-dispatch-request.json
run: |
set -euo pipefail
if [ "$REPOSITORY" != "DingTalk-Real-AI/dingtalk-workspace-cli" ]; then
echo "unexpected repository: ${REPOSITORY}" >&2
exit 1
fi
for value in \
"$REPOSITORY_ID" \
"$WORKFLOW_ID" \
"$RUN_ID" \
"$RUN_ATTEMPT" \
"$SOURCE_COMMENT_ID" \
"$DISPATCH_COMMENT_ID" \
"$ACTOR_ID" \
"$PR_NUMBER"; do
if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then
echo "dispatch manifest contains a non-canonical identifier" >&2
exit 1
fi
done
if [[ ! "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "dispatch manifest contains an invalid PR head SHA" >&2
exit 1
fi
hash_output="$(printf '%s' "$SOURCE_BODY" | sha256sum)"
source_body_sha256="${hash_output%% *}"
if [[ ! "$source_body_sha256" =~ ^[0-9a-f]{64}$ ]]; then
echo "failed to hash source comment" >&2
exit 1
fi
idempotency_key="${REPOSITORY_ID}:${SOURCE_COMMENT_ID}"
umask 077
jq -n \
--arg repository_id "$REPOSITORY_ID" \
--arg repository "$REPOSITORY" \
--arg workflow_id "$WORKFLOW_ID" \
--arg workflow_path "$WORKFLOW_PATH" \
--arg run_id "$RUN_ID" \
--arg run_attempt "$RUN_ATTEMPT" \
--arg source_comment_id "$SOURCE_COMMENT_ID" \
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
--arg actor_id "$ACTOR_ID" \
--arg actor_login "$ACTOR_LOGIN" \
--arg pr_number "$PR_NUMBER" \
--arg pr_head_sha "$PR_HEAD_SHA" \
--arg products "$PRODUCTS" \
--arg cases_ref "$CASES_REF" \
--arg source_body_sha256 "$source_body_sha256" \
--arg idempotency_key "$idempotency_key" \
'{
schema_version: 1,
repository_id: $repository_id,
repository: $repository,
workflow_id: $workflow_id,
workflow_path: $workflow_path,
run_id: $run_id,
run_attempt: $run_attempt,
source_comment_id: $source_comment_id,
dispatch_comment_id: $dispatch_comment_id,
actor_id: $actor_id,
actor_login: $actor_login,
pr_number: $pr_number,
pr_head_sha: $pr_head_sha,
products: $products,
cases_ref: $cases_ref,
source_body_sha256: $source_body_sha256,
idempotency_key: $idempotency_key
}' > "$MANIFEST_PATH"
- name: Upload dispatch request manifest
id: artifact
uses: actions/upload-artifact@v4
with:
name: eval-dispatch-request-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.placeholder.outputs.comment_id }}
path: ${{ runner.temp }}/eval-dispatch-request.json
if-no-files-found: error
retention-days: 1
overwrite: false
- name: Finalize dispatch marker
env:
GH_TOKEN: ${{ github.token }}
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
REPOSITORY_ID: '1187709537'
WORKFLOW_ID: '331725458'
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}
ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PRODUCTS: ${{ steps.parse.outputs.products }}
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
run: |
set -euo pipefail
if [[ ! "$DISPATCH_COMMENT_ID" =~ ^[1-9][0-9]*$ ]] || \
[[ ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then
echo "artifact marker contains a non-canonical identifier" >&2
exit 1
fi
artifact_digest="${ARTIFACT_DIGEST,,}"
if [[ "$artifact_digest" != sha256:* ]]; then
artifact_digest="sha256:${artifact_digest}"
fi
if [[ ! "$artifact_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "artifact marker contains an invalid digest" >&2
exit 1
fi
marker_json="$(
jq -nc \
--arg repository_id "$REPOSITORY_ID" \
--arg workflow_id "$WORKFLOW_ID" \
--arg workflow_path "$WORKFLOW_PATH" \
--arg run_id "$RUN_ID" \
--arg run_attempt "$RUN_ATTEMPT" \
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
--arg artifact_id "$ARTIFACT_ID" \
--arg artifact_digest "$artifact_digest" \
'{
schema_version: 1,
repository_id: $repository_id,
workflow_id: $workflow_id,
workflow_path: $workflow_path,
run_id: $run_id,
run_attempt: $run_attempt,
dispatch_comment_id: $dispatch_comment_id,
artifact_id: $artifact_id,
artifact_digest: $artifact_digest
}'
)"
cases_note=""
if [ -n "$CASES_REF" ]; then
cases_note=",用例版本 \`${CASES_REF}\`"
fi
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
response="$(
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$body"
)"
printf '%s' "$response" \
| jq -e \
--arg comment_id "$DISPATCH_COMMENT_ID" \
--arg body "$body" \
'((.id | tostring) == $comment_id) and (.body == $body)' \
> /dev/null
- name: Mark dispatch preparation failure
if: ${{ failure() && steps.placeholder.outputs.comment_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
run: |
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$failure_body" \
> /dev/null \
|| true
+44 -87
View File
@@ -21,11 +21,6 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out trusted routing policy
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Route review and enable auto-merge
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
@@ -34,13 +29,12 @@ jobs:
const repo = context.repo.repo;
const pullNumber = context.payload.pull_request.number;
const eventHeadSha = context.payload.pull_request.head.sha;
const {
REVIEWER_POOL,
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
} = require('./.github/reviewer-routing.js');
const reviewerPool = REVIEWER_POOL;
const reviewerPool = [
'sczheng189',
'shangguanxuan633-lab',
'audanye-sudo',
'wxianfeng',
];
async function getReadyEventPull(phase) {
const {data: currentPull} = await github.rest.pulls.get({
@@ -73,21 +67,6 @@ jobs:
: author;
async function routeReview() {
let changedFiles;
try {
changedFiles = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pullNumber,
per_page: 100,
});
} catch (error) {
core.warning(
`Could not inspect changed files for PR #${pullNumber}; using load-balanced fallback (${error.status || 'unknown status'}).`,
);
changedFiles = [];
}
const eligible = reviewerPool.filter(
reviewer =>
reviewer.toLowerCase() !== author &&
@@ -98,9 +77,13 @@ jobs:
return;
}
const existingRequestedReviewers = new Set(
(pullRequest.requested_reviewers || []).map(({login}) => login.toLowerCase()),
);
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 {
@@ -133,18 +116,22 @@ jobs:
latestDecisionByLogin.set(login, review);
}
}
const currentHeadReviewers = new Set(
[...latestDecisionByLogin.values()]
.filter(
review =>
review.commit_id === headSha &&
eligible.some(
reviewer => reviewer.toLowerCase() === review.user.login.toLowerCase(),
) &&
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
)
.map(review => review.user.login.toLowerCase()),
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 {
@@ -191,42 +178,20 @@ jobs:
tieOrder.get(left) - tieOrder.get(right),
);
const routing = resolveReviewRouting({
files: changedFiles,
author,
latestPusher,
fallbackReviewers: ranked,
});
const candidates = reviewerCandidates({
preferredReviewers: routing.reviewers,
fallbackReviewers: ranked,
eligibleReviewers: eligible,
});
const desiredReviewers = candidates.slice(0, routing.requiredReviewers);
if (routing.reason === 'unknown_paths' && currentHeadReviewers.size > 0) {
core.info(
`PR #${pullNumber} has a current-head review for unknown paths; leaving manual ownership unchanged.`,
);
return;
}
core.info(
`PR #${pullNumber} routing: ${routing.reason}; modules=${routing.modules.map(module => module.id).join(',') || 'unknown'}; reviewers=${desiredReviewers.join(',') || 'load-balanced fallback'}.`,
);
const satisfiedReviewers = new Set([
...currentHeadReviewers,
...[...existingRequestedReviewers].filter((reviewer) =>
candidates.some((candidate) => candidate.toLowerCase() === reviewer),
),
]);
const requestResult = await requestReviewersWithFallback({
candidates,
requiredReviewers: routing.requiredReviewers,
satisfiedReviewers: [...satisfiedReviewers],
requestReviewer: async (reviewer) => {
for (const reviewer of ranked) {
try {
const currentPull = await getReadyEventPull('review request');
if (!currentPull) {
return false;
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,
@@ -237,23 +202,15 @@ jobs:
core.info(
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
);
return true;
},
onFailure: (reviewer, error) => {
return;
} catch (error) {
core.warning(
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
);
},
});
if (requestResult.aborted) {
return;
}
}
if (requestResult.satisfiedReviewers.length < routing.requiredReviewers) {
core.warning(
`Only ${requestResult.satisfiedReviewers.length} of ${routing.requiredReviewers} required reviewers could be satisfied for PR #${pullNumber}.`,
);
}
core.warning(`No reviewer request could be created for PR #${pullNumber}.`);
}
async function enableAutoMerge() {
+12 -144
View File
@@ -302,8 +302,9 @@ on the leaf:
```bash
dws auth status # token_valid should be true
dws schema <mcp-canonical> --jq '{canonical_path,interface_ref,parameters}' -f json
# or CLI path: dws schema --cli-path "drive copy" --jq '{canonical_path,interface_ref,parameters}' -f json
dws cache refresh # deprecated no-op: prints a retirement notice (discovery cache is gone; refreshes nothing)
dws schema <mcp-canonical> -f json
# or CLI path: dws schema --cli-path "drive copy" -f json
```
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
@@ -320,10 +321,8 @@ Skill (evidence only)**.
Split work by product groups. Each agent must:
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live
`dws schema <leaf> --compact` for its tools. Mapping/interface/provenance
audits may query the full leaf only through a narrow `--jq` / `--fields`
projection; do not load an entire full leaf into Agent context.
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
for its tools.
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
forbid wholesale JSON merges from review dumps.
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
@@ -464,134 +463,6 @@ Keep CLI confirmation behavior and Schema metadata consistent, and add a
semantic regression test through the final embedded loader/query delivery
path; a generator unit test or JSON count alone is insufficient.
## Unified result Schema and performance
The unified runtime envelope and the per-command Schema result declaration are
related but distinct contracts:
- Runtime owns the outer machine envelope (`ok`, `outcome`, `data`, `error`,
`meta`) and derives it through `internal/output`. Business commands return a
`CommandResult`; they must not hand-author the outer JSON shape.
- A leaf `Contract.Result` / `contract.ResultSpec` describes the reviewed
business value inside `data`. It may declare `outcomes`, `data_schema`, and
`sensitive_paths`. `Contract.Pagination` is a separate command capability
because pagination is emitted under envelope `meta`, not inside `data`.
- `outcomes` is the set of results a command may produce; it is not the outcome
of the current invocation. `data_schema` is a JSON Schema object for business
data and must not duplicate the framework envelope.
- Result declarations are delivered in the full leaf and in the reviewed
`--compact` Agent projection. Compact retains the normalized `result` object
verbatim but still omits provenance, interface bindings, and other audit-only
fields. Product/group summaries remain navigation views and need not repeat
every leaf Result. When an Agent needs return-shape facts, query the compact
leaf directly; do not load the whole full Catalog.
- A missing `result` means “no reviewed return-value declaration is published
for this leaf.” It does **not** prove that the runtime is legacy, and it must
not be filled by inference from examples, MCP samples, or previous command
output. Runtime rollout remains an internal per-command fact.
- The public contract has no `contract_version`, no `--output-contract`, and no
Agent-selectable protocol alias. Agents continue to request machine output
with `--format json`; migrated commands use the unified result directly and
unmigrated commands retain their current legacy output.
- Existing `dev` / `devapp` pilot coverage is gradual. Active reviewed
`devapp` shortcuts are gated on a non-empty Result declaration, while `dev`
currently has representative Result coverage. Do not describe that as
repository-wide coverage. Any newly activated Agent-visible command should
add and test its Result declaration; the remaining pilot gaps should shrink,
not expand.
The compact/full leaf `result` object has one stable shape:
```json
{
"result": {
"outcomes": ["success", "pending", "partial_failure", "failure"],
"data_schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Stable resource ID"},
"name": {"type": "string", "description": "Display name"}
}
}
}
}
},
"sensitive_paths": ["credential.secret"]
},
"pagination": {
"kind": "cursor",
"cursor_parameter": "cursor",
"meta_path": "meta.pagination",
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
"next_token_path": "meta.pagination.next_token"
}
}
```
Field rules:
| Field | Required | Contract |
|---|---|---|
| `outcomes` | yes | Non-empty unique subset of `success`, `pending`, `partial_failure`, `failure`; normalization publishes canonical order. |
| `data_schema` | yes | One recursive JSON Schema **object** describing only the runtime envelope's `data` value. Every named `properties` child must have a non-empty `description`. It must not duplicate `ok`, `outcome`, `error`, or `meta`. |
| `sensitive_paths` | no | Unique safe dot paths relative to `data`; renderers/redaction consumers must not treat them as shell/JQ expressions. |
Optional members are omitted, never emitted as `null`. A leaf without a
reviewed Result omits the entire `result` key. Compact must preserve the same
normalized Result value as the full leaf; it must not summarize, infer, rename,
or independently rebuild any Result field. Product/group summaries do not
aggregate child Result objects.
`pagination` is a sibling of `result`, not a child. It declares the canonical
CLI cursor parameter and the fixed framework paths under `meta.pagination`.
Product response fields used to derive that metadata remain mapper internals;
they are not part of `result.data_schema`. Do not execute a second request to
derive pagination metadata.
Invalid result declarations fail closed during normalization: unknown or
duplicate outcomes, a non-object/multiple `data_schema`, unsafe or duplicate
sensitive paths, unsupported pagination kinds, attempts to override framework
meta paths, and an invalid cursor parameter must be rejected rather than
silently removed.
Full-leaf wire round trips must
preserve the normalized Result exactly. Do not commit generated Schema JSON as
evidence; tests construct contracts in Go and runtime/CI assemble the Catalog
from declarations.
### Performance model and rules
- Catalog construction is declaration-driven and cached through the existing
lazy `sync.Once` delivery path. Do not reassemble or reopen annotations per
command invocation, per leaf lookup, or per renderer.
- Normalizing one Result declaration is linear in the size of that declaration.
Full `schema --all` is linear in tools + parameters + Result schema bytes and
is an audit/compatibility export, not the normal Agent discovery path.
Overview → compact product/group → compact leaf remains the normal route;
only the final leaf carries its Result declaration.
- Constructing a `CommandResult` defensively clones result data and validates
invariants; rendering is buffer-first and then writes once. Both CPU cost and
transient memory are O(payload size), with roughly one additional in-memory
rendered copy. This buys immutability and prevents partial JSON leakage, but
it is not free.
- Large list/search commands must use bounded pages and publish continuation
facts. The current emitter buffers one command result/page before publishing;
pagination is the memory bound. Continuous event streams are a separate,
command-specific protocol and are not described by `ResultSpec`.
- A `dual_validate` command must execute the business request exactly once,
validate a shadow unified result, and preserve legacy bytes. Never obtain
validation by issuing a second network or write request.
- Filters and alternate formats are render-time work over the same in-memory
result. They must not rerun the business operation or rebuild Schema.
- Performance changes must preserve the one-result, buffer-first, fail-closed,
and atomic `--output` guarantees. Do not trade correctness for a microbenchmark
improvement. For a material hot-path change, benchmark representative small
and page-sized payloads and report allocations/bytes as well as latency.
## Current Schema boundaries
- `schema list` remains a progressive overview. `schema --all` is the stable
@@ -600,16 +471,13 @@ from declarations.
`parameters` object for commands without flags. Keep it suitable for the #602
compatibility baseline and fail rather than silently emitting a partial
export.
- `schema --all` is not normal command discovery. Use overview -> compact
product/group -> compact leaf for routine Agent work. `--compact` is the
reviewed positive-field allowlist for Agent context: new full/audit fields
must not appear there until explicitly reviewed. A compact full export is not
a complete compatibility baseline.
- `schema --all` is not normal command discovery. Use overview -> product/group
-> leaf for routine Agent work. `--compact` is supported for context-saving
projections, but a compact full export is not a complete compatibility
baseline.
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
executable accepts. A compact leaf defines Agent selection, CLI parameters,
constraints, safety/confirmation semantics, and any reviewed `result`
contract. Full leaf fields such as `property`, `interface_ref`, and
provenance are audit facts. A conflict is contract drift, not permission to
guess.
executable accepts. A leaf Schema defines Agent selection, parameter mapping
and constraints, and safety/confirmation semantics. A conflict is contract
drift, not permission to guess.
- Schema and Help describe commands; neither returns DingTalk business data.
After discovery, execute the real read/search/list command to obtain data.
-261
View File
@@ -6,268 +6,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.58-beta.3] - 2026-08-11
### Added
- **Aitable workflow execution and history** — adds `dws aitable workflow run` for confirmed asynchronous execution of scheduled or record-triggered workflows, plus `dws aitable workflow history` for status-, time-, and page-filtered execution records. The commands map directly to `aitable/run_workflow` and `aitable/get_flow_record_list`, validate trigger-specific arguments locally, and document the `executionId` / `instanceId` correlation.
- **Streaming-card mentions** — `chat +messages-send-card` now accepts
`--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention
targets to the initial card-creation request and prepending its returned
`atTag` to the automatic streaming update.
- **Personal OA approval events** — personal event consumers now support task
creation, completion, redirection, instance start, termination, and
completion events, with typed output and matching usage documentation.
### Fixed
- **Machine-readable export and download receipts** — `dws doc export`,
`dws drive download`, and `dws drive download --version` now keep progress
logs on stderr under `--format json` and emit one JSON result on stdout after
a successful local write. The result includes the saved path and byte size;
document exports additionally report the node, requested format, job/task
ID, and final status.
- **IM search and card-write safety** — conversation-scoped search now fails
closed when the target cannot be verified, and streaming-card updates require
business evidence rather than a transport-only success response.
- **Document shortcut reliability** — document write, readback verification,
pagination, template/version discovery, export, media, and local-file
workflows now preserve compatibility while rejecting ambiguous write results.
- **Event runtime-token handoff** — personal `event consume`, `status`,
`stop`, and `+listen-im` honor the root `--token` without falling back to a
stale OAuth profile. Detached buses negotiate an owner-only, memory-only IPC
credential channel; tokens are never placed in child argv, environment,
profiles, logs, or run-state files.
### Changed
- **Minutes `permission apply --policy` type** — `--policy` is now declared as
an `int` flag and its required check uses `Flags().Changed`, matching the
numeric-parameter convention. `--help` reports `int` instead of `string`;
accepted values (2/3/4) and gateway behavior are unchanged.
- **Minutes skill references** — document `permission apply` in both Minutes
skill references: list it in the command trees, describe its policy values and
how it differs from `permission add`, and add its intent routing.
- **Chat paging guidance** — typed chat message commands now document
`--page-all`, aggregate result shapes, and cursor behavior in CLI Help and
Agent selection examples.
- **Calendar skill parity** — mono and multi Calendar references are aligned to
prevent documentation drift without changing CLI behavior.
- **Release engineering** — CI now shards helper-package changes through the
full race suite, widens a flaky stdio idempotency test budget, governs exact
reviewed CLI/Schema type migrations, and lets authorized maintainers trigger
internal MCP evaluation with a reviewed `/eval` PR comment.
## [1.0.58-beta.2] - 2026-08-10
### Added
- **`dws sheet create-with-data`(新命令)** — 建表并写入初始数据与样式:`--values`(二维数组写默认表)/ `--sheets`(typed table 多工作表,二者必须给一个)/ `--styles`(`cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`,顶层键对齐飞书 snake_case、列表项内字段兼容 camelCase)。所有结构、字段类型与枚举在创建文档之前校验,非法配置不会留下白建的空文档:`--sheets` 按 `table_put` 的输入契约逐字段校验(`columns` 必填且列名非空不重复、`data` 为二维且行宽与 `columns` 一致、单元格仅限字符串/数字/布尔/null、`dtypes`/`formats` 的键须是列名、`mode`/`header`/`allowOverwrite`/`startCell` 类型与取值、单表 30000 单元格上限),并拒绝未知键、snake_case 变体、`{"sheets":"bad"}` 这类畸形包装与 `sheetId`(服务端会静默丢弃写错的键,导致"只写了表头却报成功"的静默丢数据);`--values` 校验单元格为标量并受 30000 单元格 / 2000000 字符上限约束;`--styles` 的顶层键与列表项内字段同样拒绝未知键,避免样式只应用一半。写入后回读校验按 `startCell` / `header` / `mode` 推算的首个预期非空单元格,而非固定 A1。该命令是多步编排(建文档 → 探活 → 定位默认工作表 → 写数据 → 回读 → 可选样式),因此如实声明为独立叶子 `sheet.create_with_data` + `interface_mode: composite`(附评审 reason,按契约不带 `interface_ref`);**`dws sheet create` 保持原样不变**——仍是一次 `create_workspace_sheet` 直连(`interface_mode: mcp`),不新增 flag,避免让 Schema 消费者把编排步骤的参数误当成该 RPC 的入参。
- **`dws sheet export-csv`(新命令)** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样;父目录不存在按错误处理,不会自动创建),不传则把纯 CSV 打到 stdout(警告只走 stderr)。数据超出单次读取上限时默认报错、既不输出也不写文件,需 `--allow-truncated` 显式接受不完整结果。响应缺 `csv` 字段或类型不对一律报错,不会用 0 字节覆盖已有文件。该分支读的是 `get_range_as_csv`、与 xlsx 的异步导出任务毫无关系,因此独立成叶子 `sheet.export_csv` 并如实声明 `interface_mode: mcp` + `interface_ref: get_range_as_csv`;**`dws sheet export` 保持原样不变**——仍只导 xlsx(`interface_ref: submit_export_job`),flag 面仍是 `--node` / `--output`,csv 专属 flag 不会出现在它上面(此前挂在同一条命令上时,漏写 `--export-format csv` 会让 `--range` 被静默丢弃而导出整篇工作簿)。
- **`sheet update-dimension --size-type`** — `pixel` / `standard`(恢复默认行高列宽)/ `auto`(按内容自适应行高,仅 ROWS)。
- **`sheet replace --match-formula`** — 在公式文本中查找替换。
- **`sheet range set-style` 扩展样式维度** — 新增 `--font-style`(斜体)/ `--font-line`(下划线、删除线)/ `--font-family` / `--border-styles-json`(四边边框;每条边只接受 `style` / `color`,未知键与非字符串 `color` 直接报错,不再静默忽略而画出无颜色的边框,`set-style` / `batch-set-style` / `create-with-data --styles` 三条路径同源校验)。
- **`sheet range batch-set-style --ranges`** — 一组样式刷多个带工作表前缀的区域,组装为一次原子 `batch_update`。
### Changed
- **Chat message post-send ID handoff** (#897) — CLI Help and bundled Skills
now document the `send` → `query-send-status` → `edit`/`recall` workflow,
so callers can reuse returned task, message, and conversation IDs instead
of searching message history by content.
- **Sheet mono/multi Skill alignment** — replaces the oversized mono Sheet
reference with the progressive routing layout, aligns all 20 Sheet topic
references across the mono and multi bundles, and adds a content-policy guard
that prevents the paired topic trees from drifting again.
- **`sheet range set-style` 后端切换为 `set_cell_range`** — 样式统一走 cellStyles 路径(仅设样式、保留原值),这是斜体/下划线删除线/字体族/边框唯一可用的通道。`interface_ref` 由 `update_range` 变为 `set_cell_range`,12 个样式 flag 改为 reviewed mapping exclusion。CLI 用法向后兼容、无 flag 删除;schema-compatibility 经 reviewed 豁免判定为兼容(0 changed fields)。
- **`sheet range batch-set-style` 改为单次原子提交** — 由本地循环多次 `update_range` 改为一次 `batch_update`,任一项失败默认整批回滚;`--continue-on-error` 由本地控制改为透传服务端。新增批量上限:最多 100 个区域且累计不超过 200000 个单元格。
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
- **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`<product>/<tool id>` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。
### Fixed
- **Event runtime-token handoff** — personal `event consume`, `status`, `stop`, and `+listen-im` now honor the existing root `--token` instead of falling back to a stale local OAuth profile. Detached personal-event buses negotiate the credential only after an additive capability handshake, receive and rotate it through owner-only local IPC, and keep it in memory; the token is never forwarded through child argv, environment variables, profiles, logs, or run-state files. Existing OAuth and multi-profile behavior is unchanged when `--token` is absent. A new client refuses to send a runtime token to an older bus and leaves its existing consumers and subscriptions untouched; the recovery message asks users to inspect `event status --as user`, preview `event stop --as user --all --dry-run`, and explicitly confirm `event stop --as user --all --yes` before retrying.
## [1.0.58-beta.1] - 2026-08-07
### Added
- **Robot image and file messages** (#867) — `dws chat message send-by-bot`
now supports image URLs and local-file uploads through explicit message
types, while retaining Markdown as the default and preserving its existing
title and text requirements.
- **Conversation shortcut-bar management** (#877) — adds `dws chat toolbar`
commands to list, add, hide, sort, and manage custom conversation shortcuts,
with validation and confirmation for destructive removal.
- **Complete AI Table Shortcut surface** (#901) — makes all 92 supported
AI Table Shortcuts discoverable through Runtime Schema and adds reliable
Base, table, record, attachment, view, dashboard, and workflow operations
with explicit confirmation and result-verification semantics for writes.
### Changed
- **Doc import upload fallback** — `dws doc import` no longer fails on file
formats outside the conversion whitelist (html, pdf, zip, extensionless,
and any future format): it now hands the file to the document-space upload
chain (the same primitive as `dws drive upload --workspace`), stores the
original file at the requested `--folder`/`--workspace` target, and prints
an explicit stderr notice with the supported-format list and the
convert-to-md alternative. The fallback shares the import file checks
(20MB cap, empty-file guard), keeps `--format json` / `--dry-run` output as
a single JSON document, and marks the machine-readable result with
`fallback: "upload"` and `converted: false` so agents never mistake the
stored file for a converted online document. The fallback fails closed
unless the commit response parses as JSON and carries a file identity
(exposed as `dentry_id`); empty or unverifiable responses surface as
errors instead of fabricated success. Importable formats and
`dws sheet import` validation are unchanged.
- **IM natural-target and history alignment** — Chat shortcuts can resolve natural user/group targets before execution, and message-history workflows expose bounded time ranges, ordering, explicit all-page controls, continuation ledgers, safe local export, and thread-reply pagination without treating empty or incomplete reads as successful results. Bundled mono/multi Skills and intent routing now describe the same executable surface.
- **Sheet CSV formula writes** — `dws sheet csv-put` and batch `csv-put` now expose the service contract that CSV fields beginning with `=` are written as formulas. Prefix the field with an apostrophe to write literal text beginning with `=`; CSV content continues to pass through unchanged.
- **Release-equivalent PR compatibility gate** (#889) — pull-request
admission now runs command-surface compatibility checks against the current
release baseline before code reaches `main`.
- **Reviewer routing governance** (#903) — updates the Reviewer Router pool
used for new ready PRs while retaining the existing current-head review and
required-check gates.
### Fixed
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
- **Sheet formula verification** (#873) — `dws sheet formula-verify` now calls
the registered remote tool name `verify_formula`; the previous
`formula_verify` name failed at gateway dispatch.
- **CLI and parameter recovery boundaries** (#864) — command and parameter
recovery now fail closed when an Agent-provided path or flag cannot be
reconciled with the executable CLI surface, reducing unsafe hallucinated
retries.
## [1.0.57-beta.4] - 2026-08-06
### Added
- **Expanded open CLI workflows** (#887) — adds calendar event-instance
queries, Drive latest-file selection, Markdown diff, Mail calendar/export/
share-to-chat workflows, and Minutes hot-word, permission, and audio-memo
operations, with matching Schema and cross-platform coverage.
### Changed
- **Multi-skill framework alignment** (#887) — folds long-tail skills into
`dingtalk-misc`, renames the shared package to `dingtalk-shared`, removes
stale Preview guidance, and reorganizes shared recipes and routing for more
predictable Agent selection.
- **Bounded Agent Schema delivery** (#887) — keeps compact and wire projections
focused on executable contract facts, retires stale MCP metadata candidates,
and teaches Agents to prefer `dws schema --compact` for bounded context.
### Deprecated
- **Recovery and discovery-cache compatibility surfaces** (#887) — keeps
visible Deprecated `dws recovery` and `dws cache` compatibility stubs while
retiring their former recovery engine and dynamic discovery-cache behavior.
Recovery plan/execute/finalize now return an explicit “不再支持” notice, and
Skills no longer teach either retired workflow.
### Fixed
- **Mail share-to-chat confirmation** (#887) — requires explicit confirmation
before the first remote write, while preserving the confirmed sign-retry
flow and covering both direct-success and retry responses.
## [1.0.57] - 2026-08-06
This stable release promotes the fully delivered `v1.0.57-beta.4` baseline.
It includes the v1.0.57 beta-line command-contract, document, chat, OA, Wiki,
and compatibility improvements, plus the multi-skill framework alignment and
expanded calendar, Drive, Markdown, Mail, and Minutes workflows validated in
the final prerelease.
- **Promote v1.0.57-beta.4** — publishes the final validated prerelease
baseline as stable `v1.0.57` without adding post-beta product changes.
## [1.0.57-beta.3] - 2026-08-06
### Added
- **Reviewed document shortcuts** (#880) — adds public document shortcuts for
safe local downloads, content and history, review, media and style, and
document access/sharing workflows, while retaining reviewed compatibility
identities and confirmation safeguards for writes.
- **Mentions in chat replies** (#881) — `chat message reply` now supports
`--at-open-dingtalk-ids` and `--at-all`, forwarding reply mention fields and
adding any required mention placeholders without changing existing send
behavior.
## [1.0.57-beta.2] - 2026-08-05
### Fixed
- **Stable Chat command compatibility** (#876) — restores the hidden migration
entries for `chat send`, `chat history`, and their `im` aliases, preserving
the v1.0.56 command surface while directing callers to the supported
`chat message send/list` commands. Legacy flags now reach the same migration
hints instead of failing during flag parsing.
- **Drive download cancellation-test stability** (#876) — replaces a
timing-sensitive worker-cancellation coverage test with a deterministic seam,
reducing flaky CI without changing download behavior.
## [1.0.57-beta.1] - 2026-08-05
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
command-contract and runtime Schema architecture, complete Multi IM Chat
coverage, document whiteboard and OA approval workflows, Wiki activity feeds,
and compatibility and CI reliability fixes.
### Added
- **Contact personal-status updates** (#872) — adds `contact user update-ownness`
(alias `set-ownness`) for updating a user's personal status text. The write
operation maps reviewed `userId` and `ownnessText` parameters to the service
contract and requires confirmation unless `--yes` is explicitly supplied.
- **Document whiteboard workflows** (#861) — adds `doc whiteboard insert`,
`whiteboard query/update`, and `doc media upload`. These commands support
confirmed document-embedded whiteboard creation and updates, structured
OpenNodes reads, and preparation of node-bound Vector/SVG resources.
- **Complete Multi IM Chat coverage** (#860) — hardens deterministic group and
stable-ID resolution, sending, querying, downloading, pagination, and JSON
export. The remaining reviewed Chat Shortcuts enter Schema coverage, with
destructive delete and clear operations aligned to confirmation gates.
- **OA approval form workflows** (#853) — adds OA form-schema lookup,
process forecast, and confirmed approval-instance creation, supporting both
simple flags and complete `--request` payloads.
- **Wiki activity-feed queries** (#862) — adds `wiki feed list` to retrieve
workspace document activity, with cursor paging and optional file exclusion.
### Changed
- **Unified command and Schema contract framework** (#830) — Leaf commands and
Shortcuts now use the shared typed `corecmd` base for flags, constraints,
confirmation, Help, and runtime Schema projection. Schema delivery assembles
from leaf Contract declarations at runtime; the retired hint overlays,
pinned MCP metadata, and committed Catalog artifacts are no longer delivery
authorities.
- **Faster macOS CI without reducing native coverage** (#857) — narrows the
macOS race suite to Keychain, codesign, and Darwin-only tests while adding a
reachability contract that prevents native-only tests from being silently
excluded.
### Fixed
- **Chat media-download JSON compatibility** (#854) — restores parseable
`success`, `downloadUrl`, and `output` fields for
`chat message download-media --format json` after a successful download,
without progress output corrupting JSON stdout.
### Added
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
- **Robot image and file messages** — extends `chat message send-by-bot` with public image URL delivery through `--msg-type image --image-url` and local-file upload/send through `--msg-type file --file-path`, while preserving Markdown as the default message type.
### Changed
- **Chat reply mentions** — `dws chat message reply` can @ specified group members with `--at-open-dingtalk-ids` or @ everyone with `--at-all`, forwarding the existing `send_personal_message` mention fields and automatically adding missing current-user `<@id>` / `<@all>` placeholders.
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
- **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger.
- **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly.
@@ -276,7 +16,6 @@ and compatibility and CI reliability fixes.
### Fixed
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
- **Unified command safety and Shortcut runtime (H0)** — Shortcut leaves now execute through `corecmd.New`, sharing the same typed Safety confirmation gate as Leaf commands. EOF / closed stdin returns `confirmation_required`, and interactive `no` returns the existing non-zero cancellation validation error instead of reporting success for an operation that did not run. Pass `--yes` or `--dry-run` to skip the prompt.
- **Constraint "provided" for `at_least_one` / `exactly_one` (H0)** — a flag set to an empty string (`--flag ""`) no longer counts as provided; previously bare Cobra `Changed` satisfied the constraint. Pass a non-blank value for a member of the group.
- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout.
+3 -11
View File
@@ -68,17 +68,9 @@ coverage is additionally selected for platform-sensitive code.
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 resolves the exact merge-base, latest reachable
non-withdrawn stable GA tag, and committed candidate SHA, then enters the single compatibility
decision seam through
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
The Make target delegates to the authoritative wrapper; CI does not invoke a
second comparator or the legacy fixture checker. See
[CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
for the reviewed two-stage `pending` → `consumed` lifecycle.
Agent-visible flag migrations must also run
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
it consumes the same base-owned ledger rather than a second exception list.
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
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.58-beta.3"
version "1.0.56-beta.4"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-arm64.tar.gz"
sha256 "29b4fb9e081f36a699933c0919fe7530544f62de3e27c785d27626a575a2efc2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-arm64.tar.gz"
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-amd64.tar.gz"
sha256 "a6b9c4ef212c533e02b414bd9c3be0b8d1874d4af983a7896072825bdfec1033"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-arm64.tar.gz"
sha256 "ae3a9ebe151702fd05dee0c56d778a20789d98c390cf8c0a0b2ec695b57b5ec3"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-amd64.tar.gz"
sha256 "544b480701e9ec9ec5366467ad4c855fca06dea887e3d577ff50e4b3eeb13ac2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-skills.zip"
sha256 "322f1840442ff183ad4b6d4f2a2b38825ff9f96a3fcde06ba57b8f80647468ae"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
end
def install
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.57"
version "1.0.56"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz"
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-arm64.tar.gz"
sha256 "5c6003fe484aa36cc00820a574186652467b9d075f19c159cf807e57590256ba"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-amd64.tar.gz"
sha256 "969b005a10682c2a1a828fa112165b5b0cd8ceeed8d22110ef7f39402cc36804"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-arm64.tar.gz"
sha256 "530c5ea7ddc7de320d9c2471fbd33752a723d00c9665f49321c7580e8392c756"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-amd64.tar.gz"
sha256 "675fa42727ac9a549c6710b82e1980cd0f795363d71d5116a4e69771b7c5470e"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-skills.zip"
sha256 "3d57794e4660a089209ce3962571d16ca0d46141e973c9993257a301cce0e097"
end
def install
+10 -48
View File
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
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 skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-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 skill-context-budget cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema 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
@@ -24,16 +24,15 @@ help:
@printf " make format-check - Check all repository Go source files with gofmt\n"
@printf " make fmt - Format all repository Go source files\n"
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
@printf " make interface-integrity [BASE_REF=<ref>] [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check authoritative CLI history\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check Git-owned CLI history\n"
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
@printf " make update-interface-baseline - Update the non-authoritative CLI smoke fixture\n"
@printf " make reset-interface-baseline - DANGEROUS: replace the non-authoritative CLI smoke fixture\n"
@printf " make schema-compatibility BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check the authoritative Schema history\n"
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
@printf " make cli-smoke - Verify help for every public top-level command\n"
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
@@ -88,8 +87,6 @@ policy: test-auth-legacy-compat
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
@python3 scripts/run_chat_shortcut_live_audit_test.py
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
@@ -103,22 +100,10 @@ edition-test:
$(GO) test -v -count=1 ./pkg/editiontest/...
interface-integrity:
@base_ref="$(BASE_REF)"; \
candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$base_ref" ]; then base_ref="origin/main"; fi; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-interface-baselines.sh \
--base-ref "$$base_ref" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
@./scripts/policy/check-interface-baseline.sh
authoritative-interface-integrity:
@candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-interface-baselines.sh \
--base-ref "$(BASE_REF)" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
coverage-gate:
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
@@ -133,12 +118,7 @@ reset-interface-baseline:
@./scripts/policy/check-interface-baseline.sh --reset
schema-compatibility:
@candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-schema-compatibility.sh \
--base-ref "$(BASE_REF)" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
skill-command-integrity:
@./scripts/policy/check-skill-commands.sh
@@ -146,12 +126,6 @@ skill-command-integrity:
skill-context-budget:
@./scripts/policy/check-skill-context-budget.sh
multi-im-skill-chain-integrity:
@./scripts/policy/check-multi-im-skill-chain.sh
skill-mono-multi-content:
@./scripts/policy/check-mono-multi-skill-content.sh
cli-smoke:
@./scripts/policy/check-cli-smoke.sh
@@ -169,13 +143,9 @@ generate-schema:
@set -e; \
concepts_guard=$$(mktemp); \
concepts_schema_guard=$$(mktemp); \
command_fallbacks_guard=$$(mktemp); \
command_fallbacks_schema_guard=$$(mktemp); \
trap 'rm -f "$$concepts_guard" "$$concepts_schema_guard" "$$command_fallbacks_guard" "$$command_fallbacks_schema_guard"' EXIT HUP INT TERM; \
trap 'rm -rf "$$concepts_guard" "$$concepts_schema_guard"' EXIT HUP INT TERM; \
cp internal/cli/param_concepts.json "$$concepts_guard"; \
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
cp internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard"; \
cp internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard"; \
$(GO) generate ./internal/cli; \
rm -rf internal/cli/schema_agent_metadata internal/cli/schema_agent_metadata_audit.json; \
rm -f internal/cli/schema_meta_index.json; \
@@ -191,14 +161,6 @@ generate-schema:
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.schema.json' >&2; \
exit 1; \
}; \
if [ -e internal/cli/schema_hints ]; then \
printf '%s\n' 'retired schema_hints/ must not reappear after generation' >&2; \
exit 1; \
+37 -44
View File
@@ -70,15 +70,17 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **multi** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
How to pick:
- **Quick install** (one-liner above): non-interactive, installs `mono`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
- **Quick install** (one-liner above): non-interactive, installs `multi`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — re-run any time.
</details>
@@ -369,7 +371,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
Use Cobra help and Schema for different parts of the command contract:
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
- `dws schema "<path>" --compact` is the normative Agent view for command selection, CLI parameters and constraints, risk, and confirmation; use a full leaf with a narrow `--jq` projection for mapping or provenance audits.
- `dws schema "<path>"` is the Agent contract for command selection, parameter mappings and constraints, risk, and confirmation semantics.
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
@@ -378,32 +380,32 @@ Use Cobra help and Schema for different parts of the command contract:
dws aitable record query --help
# Discover within a product, then inspect the selected leaf contract
dws schema aitable --compact
dws schema "aitable record query" --compact
dws schema aitable
dws schema "aitable record query"
# Execute the real business query
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should query progressively with `--compact`; its positive field allowlist prevents new full/audit fields from silently expanding Agent context.
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should prefer product/group discovery followed by a leaf query to avoid loading the full Catalog into context.
### Agent Skills
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
```bash
# Install skills into current project (defaults to mono)
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
> `install.sh` installs under `$HOME/.agents/skills/` (global; multi layout is per-product siblings, mono is the `dws/` subdirectory); `install-skills.sh` installs under `./.agents/skills/` (current project).
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
@@ -441,6 +443,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
<details>
@@ -471,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 covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, 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`.
@@ -481,33 +484,28 @@ For an event-focused installation, use the official convenience installer:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
# Or install the standalone multi skill from an existing dws installation
dws skill setup --mode multi -s event
```
```bash
# Inspect the public personal event catalog and schema
dws event list
dws event schema user_im_message_receive_o2o --flatten
dws event list --category oa
dws event schema user_oa_approval_task_created --flatten
# Listen for messages that mention the current user
dws event +listen-im --kind at-me -f ndjson
dws event consume user_im_message_receive_at --flatten -f ndjson
# Listen for messages from a specified sender
dws event +listen-im --kind sender --user <userId> -f ndjson
# Listen for one-to-one messages with a specified user
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
# Listen for messages in a specified group
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
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 +listen-im --kind all-direct -f ndjson
dws event +listen-im --kind all-group -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
# 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
@@ -515,19 +513,14 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
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 messages, reads, and recalls from the same sender in one process
dws event +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# Listen for all six public OA approval events in one process
# Listen for multiple events for the same user in one process
dws event consume \
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
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
@@ -631,7 +624,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# Built-in jq expressions
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.parameters'
dws schema "dev app create" --jq '.tool.required'
# Return only specific fields
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -643,9 +636,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
```bash
dws schema aitable --compact # discover product commands
dws schema "aitable record query" --compact # view the selected Agent leaf contract
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # view required fields
dws schema aitable # discover product commands
dws schema "aitable record query" # view the selected leaf contract
dws schema "aitable record query" --jq '.tool.required' # view required fields
dws schema --all # full export for CI/audit/baselines
```
@@ -726,7 +719,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
<summary>Coming soon</summary>
- `conference` (video meetings)
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back
</details>
+37 -44
View File
@@ -70,15 +70,17 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
怎么选:
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),随时重跑都行。
</details>
@@ -363,7 +365,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
命令帮助和 Schema 分别负责命令契约的不同部分:
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
- `dws schema "<path>" --compact` 是 Agent 选命令、CLI 参数与约束、风险和确认语义的规范视图;映射或 provenance 审计使用 full leaf 配合 `--jq` 精确投影。
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
@@ -372,32 +374,32 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
dws aitable record query --help
# 先在产品内发现命令,再查看选中 leaf 的契约
dws schema aitable --compact
dws schema "aitable record query" --compact
dws schema aitable
dws schema "aitable record query"
# 执行真实业务查询
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应使用 `--compact` 渐进查询;该视图采用正向字段白名单,full 新增的审计字段不会自动进入 Agent 上下文。
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应优先按产品/分组发现后查询 leaf,避免把整个 Catalog 加载进上下文。
### Agent Skills
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
```bash
# 安装 skills 到当前项目(默认 mono)
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
> `install.sh` 安装到 `$HOME/.agents/skills/`(全局,multi 为按产品平铺,mono 为 `dws/` 子目录);`install-skills.sh` 安装到 `./.agents/skills/`(当前项目)。
>
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
@@ -435,6 +437,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
<details>
@@ -465,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。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
`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` 同时使用。
@@ -475,33 +478,28 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
# 或在已有 dws 环境中安装独立的 multi skill
dws skill setup --mode multi -s event
```
```bash
# 查看公开个人事件目录和 schema
dws event list
dws event schema user_im_message_receive_o2o --flatten
dws event list --category oa
dws event schema user_oa_approval_task_created --flatten
# 监听当前用户被 @ 的消息
dws event +listen-im --kind at-me -f ndjson
dws event consume user_im_message_receive_at --flatten -f ndjson
# 监听指定发送人的消息
dws event +listen-im --kind sender --user <userId> -f ndjson
# 监听与指定用户的单聊消息
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
# 监听指定群的消息
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
# 监听所有单聊或所有群消息
dws event +listen-im --kind all-direct -f ndjson
dws event +listen-im --kind all-group -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
@@ -509,19 +507,14 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
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 +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# 一个进程监听全部六个公开 OA 审批事件
# 一个进程监听同一用户的多个事件
dws event consume \
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
# 查看本地 consume,并取消指定订阅
dws event status
@@ -625,7 +618,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# 内置 jq 表达式
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.parameters'
dws schema "dev app create" --jq '.tool.required'
# 只返回指定字段
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -637,9 +630,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
```bash
dws schema aitable --compact # 发现产品命令
dws schema "aitable record query" --compact # 查看 Agent leaf 契约
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # 定向查看必填字段
dws schema aitable # 发现产品命令
dws schema "aitable record query" # 查看选中 leaf 契约
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
dws schema --all # CI/审计/基线的全量导出
```
@@ -715,7 +708,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
<summary>即将推出</summary>
- `conference`(视频会议)
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 可切回单 skill
</details>
+134 -8
View File
@@ -127,6 +127,15 @@ function installSkillsToHomes(skillRoot) {
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
// Mutual exclusion: remove multi leftovers before laying down mono.
// Directories only — a stray file named dingtalk-x.md must survive.
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory() && (entry.name.startsWith("dingtalk-") || entry.name === "dws-shared")) {
fs.rmSync(path.join(baseDir, entry.name), { recursive: true, force: true });
}
}
}
const destDir = path.join(baseDir, "dws");
fs.rmSync(destDir, { recursive: true, force: true });
copyChildren(skillRoot, destDir);
@@ -138,23 +147,122 @@ function installSkillsToHomes(skillRoot) {
}
}
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
// Test-MultiTreeHasSkills in scripts/install.ps1: true only when the multi
// bundle carries at least one product skill (a subdir with SKILL.md). An
// empty or corrupt multi/ tree must never select the multi branch nor refresh
// the multi cache — installing it would wipe existing skills and lay down
// nothing.
function multiTreeHasSkills(dir) {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
return false;
}
return fs
.readdirSync(dir, { withFileTypes: true })
.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")));
}
// installMultiSkillsToHomes mirrors installSkillsToHomes for the multi bundle:
// every product skill becomes a sibling directory of the agent home. Mutual
// exclusion: the mono leftover (dws/) and stale dingtalk-* skills not present
// in the new bundle are removed first.
function installMultiSkillsToHomes(multiRoot) {
const homeDir = os.homedir();
const skills = fs
.readdirSync(multiRoot, { withFileTypes: true })
.filter((e) => e.isDirectory() && fs.existsSync(path.join(multiRoot, e.name, "SKILL.md")))
.map((e) => e.name);
if (skills.length === 0) {
throw new Error(`no product skills found under ${multiRoot}`);
}
const skillSet = new Set(skills);
let installed = 0;
const installToBase = (baseDir) => {
fs.mkdirSync(baseDir, { recursive: true });
fs.rmSync(path.join(baseDir, "dws"), { recursive: true, force: true });
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (
entry.isDirectory() &&
(entry.name.startsWith("dingtalk-") || entry.name === "dws-shared") &&
!skillSet.has(entry.name)
) {
fs.rmSync(path.join(baseDir, entry.name), { recursive: true, force: true });
}
}
for (const name of skills) {
const destDir = path.join(baseDir, name);
fs.rmSync(destDir, { recursive: true, force: true });
copyChildren(path.join(multiRoot, name), destDir);
}
};
AGENT_DIRS.forEach((agentDir, index) => {
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
installToBase(baseDir);
installed += 1;
});
if (installed === 0) {
installToBase(path.join(homeDir, ".agents", "skills"));
}
}
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
// wins; multi is the default. The --skill-mode flag accepts both the space
// form (`--skill-mode mono`) and the equals form (`--skill-mode=mono`).
function resolveSkillMode() {
const raw = (process.env.DWS_SKILL_MODE || "").trim().toLowerCase();
if (raw === "mono" || raw === "multi") {
return raw;
}
if (raw !== "") {
throw new Error(`invalid DWS_SKILL_MODE='${process.env.DWS_SKILL_MODE}'. Use 'mono' or 'multi'.`);
}
let fromFlag;
const flagIndex = process.argv.indexOf("--skill-mode");
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
fromFlag = process.argv[flagIndex + 1];
} else {
const equalsArg = process.argv.find((arg) => arg.startsWith("--skill-mode="));
if (equalsArg) {
fromFlag = equalsArg.slice("--skill-mode=".length);
}
}
if (fromFlag !== undefined) {
const mode = fromFlag.trim().toLowerCase();
if (mode === "mono" || mode === "multi") {
return mode;
}
throw new Error(`invalid --skill-mode '${fromFlag}'. Use 'mono' or 'multi'.`);
}
return "multi";
}
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
// can fall back to a user-local cache when --source is not provided. mono is
// already installed into agent homes by installSkillsToHomes; the cache is
// purely a source-of-truth for the setup command.
// can fall back to a user-local cache when --source is not provided. A cache
// is only refreshed when the new bundle actually carries that tree — an
// empty/corrupt multi/ (or a missing mono tree) must never wipe a previously
// good cache.
function cacheUserSkills(extractedSkillsRoot) {
const cacheBase = path.join(os.homedir(), ".dws", "skills");
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
? path.join(extractedSkillsRoot, "mono")
: extractedSkillsRoot;
const monoCache = path.join(cacheBase, "mono");
fs.rmSync(monoCache, { recursive: true, force: true });
copyChildren(monoSource, monoCache);
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
const monoCache = path.join(cacheBase, "mono");
fs.rmSync(monoCache, { recursive: true, force: true });
copyChildren(monoSource, monoCache);
}
const multiSource = path.join(extractedSkillsRoot, "multi");
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
if (multiTreeHasSkills(multiSource)) {
const multiCache = path.join(cacheBase, "multi");
fs.rmSync(multiCache, { recursive: true, force: true });
copyChildren(multiSource, multiCache);
@@ -191,7 +299,25 @@ function main() {
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
? path.join(skillsStaging, "mono")
: skillsStaging;
installSkillsToHomes(monoRoot);
// A mono install requires an actual SKILL.md at the root of monoRoot. On a
// multi-only zip monoRoot would degrade to the staging root and copy the
// whole bundle (multi/ included) into a dws/ directory — skip instead.
const monoHasSkill = fs.existsSync(path.join(monoRoot, "SKILL.md"));
const multiRoot = path.join(skillsStaging, "multi");
const skillMode = resolveSkillMode();
if (skillMode === "multi" && multiTreeHasSkills(multiRoot)) {
console.log(`Skill mode: multi — installing per-product skills`);
installMultiSkillsToHomes(multiRoot);
} else {
if (skillMode === "multi") {
console.log("multi skill tree not found or empty in bundle; falling back to mono.");
}
if (monoHasSkill) {
installSkillsToHomes(monoRoot);
} else {
console.log("mono skill tree not found in bundle; skipping skill install.");
}
}
cacheUserSkills(skillsStaging);
}
+2 -91
View File
@@ -17,23 +17,18 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
"github.com/spf13/cobra"
)
var newRootCommand = func() *cobra.Command { return app.NewRootCommand() }
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
@@ -117,11 +112,7 @@ func runGenerate(args []string, stdout, stderr io.Writer) error {
defer i18n.SetLang(previousLang)
i18n.SetLang("en")
root := newRootCommand()
snapshot := interfacesnapshot.Capture(root)
if err := validateHelpRendering(root, snapshot); err != nil {
return err
}
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
if *output == "-" {
return interfacesnapshot.Write(stdout, snapshot)
}
@@ -147,16 +138,6 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
currentPath := flags.String("current", "", "candidate snapshot path")
basePath := flags.String("base", "", "target main/development baseline snapshot path")
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
approvedMigrationsPath := flags.String(
"approved-flag-migrations",
"",
"merge-base-owned approved flag migration manifest",
)
candidateMigrationsPath := flags.String(
"candidate-flag-migrations",
"",
"candidate flag migration manifest",
)
if err := flags.Parse(args); err != nil {
return false, err
}
@@ -169,14 +150,6 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
if *basePath == "" && *stablePath == "" {
return false, fmt.Errorf("compare requires --base, --stable, or both")
}
if (*approvedMigrationsPath == "") != (*candidateMigrationsPath == "") {
return false, fmt.Errorf(
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
)
}
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
}
current, err := readSnapshot(*currentPath)
if err != nil {
@@ -197,25 +170,6 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
}
report := interfacesnapshot.CompareAll(current, references)
if *approvedMigrationsPath != "" {
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
}
candidate, readErr := readFlagMigrationManifest(*candidateMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read candidate flag migrations: %w", readErr)
}
report, err = interfacesnapshot.CompareAllWithFlagMigrations(
current,
references,
approved,
candidate,
)
if err != nil {
return false, fmt.Errorf("validate flag migration lifecycle: %w", err)
}
}
encoder := json.NewEncoder(stdout)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
@@ -225,49 +179,6 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
return report.Compatible, nil
}
func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationManifest, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.FlagMigrationManifest{}, err
}
defer file.Close()
return interfacesnapshot.ReadFlagMigrationManifest(file)
}
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
for _, command := range snapshot.Commands {
path := strings.TrimPrefix(command.Path, "dws")
resolved, remaining, err := root.Find(strings.Fields(path))
if err != nil || len(remaining) != 0 || resolved == nil {
return fmt.Errorf("resolve %q before help rendering: remaining=%v error=%v", command.Path, remaining, err)
}
if err := renderCommandHelp(resolved); err != nil {
return fmt.Errorf("render %q help: %w", command.Path, err)
}
}
return nil
}
func renderCommandHelp(command *cobra.Command) (err error) {
var stdout, stderr bytes.Buffer
command.InitDefaultHelpFlag()
command.SetOut(&stdout)
command.SetErr(&stderr)
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("help renderer panicked: %v", recovered)
}
}()
command.HelpFunc()(command, []string{})
if stderr.Len() > 0 {
return fmt.Errorf("help renderer wrote an error: %s", strings.TrimSpace(stderr.String()))
}
if stdout.Len() == 0 {
return fmt.Errorf("help renderer produced empty output")
}
return nil
}
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
@@ -280,5 +191,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage:")
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
}
-447
View File
@@ -15,16 +15,11 @@ package main
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) {
@@ -61,24 +56,6 @@ func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T)
}
}
func TestCrossPlatformCoverageRunGenerateRejectsHelpRenderingFailure(t *testing.T) {
testseam.Swap(t, &newRootCommand, func() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
})
return root
})
var stdout, stderr bytes.Buffer
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "injected help failure") {
t.Fatalf("run(generate) stderr=%q", stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
current := commandSnapshot("dws")
mergeBase := commandSnapshot("dws")
@@ -106,368 +83,6 @@ func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *tes
}
}
func TestCrossPlatformCoverageRunCompareEnforcesBaseOwnedFlagMigrationLifecycle(t *testing.T) {
dir := t.TempDir()
before := flagMigrationSnapshot(false)
after := flagMigrationSnapshot(true)
currentPath := writeSnapshot(t, dir, "current.json", after)
basePath := writeSnapshot(t, dir, "base.json", before)
stablePath := writeSnapshot(t, dir, "stable.json", before)
approvedPath := writeManifest(t, dir, "approved.json", flagMigrationManifestJSON("pending"))
candidatePath := writeManifest(t, dir, "candidate.json", flagMigrationManifestJSON("consumed"))
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--stable", stablePath,
"--approved-flag-migrations", approvedPath,
"--candidate-flag-migrations", candidatePath,
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("exact base-owned migration exit=%d stderr=%s", exitCode, stderr.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"compatible": true`)) {
t.Fatalf("exact migration report is not compatible:\n%s", stdout.String())
}
stdout.Reset()
stderr.Reset()
emptyApproved := writeManifest(t, dir, "empty-approved.json", `{"version":1,"migrations":[]}`)
exitCode = run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--stable", stablePath,
"--approved-flag-migrations", emptyApproved,
"--candidate-flag-migrations", candidatePath,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "must start pending") {
t.Fatalf("candidate self-approval exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--approved-flag-migrations", approvedPath,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "must be provided together") {
t.Fatalf("one-sided migration input exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
stablePath := writeSnapshot(t, dir, "stable.json", commandSnapshot("dws"))
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
candidatePath := writeManifest(t, dir, "candidate.json", `{"version":1,"migrations":[]}`)
tests := []struct {
name string
args []string
}{
{
name: "missing stable",
args: []string{"--base", basePath},
},
{
name: "missing base",
args: []string{"--stable", stablePath},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
args := []string{"compare", "--current", currentPath}
args = append(args, test.args...)
args = append(args,
"--approved-flag-migrations", approvedPath,
"--candidate-flag-migrations", candidatePath,
)
var stdout, stderr bytes.Buffer
exitCode := run(args, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "requires both --base and --stable") {
t.Fatalf("one-reference migration compare exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunPrintsUsageForMissingAndUnknownCommands(t *testing.T) {
tests := []struct {
name string
args []string
wantStderr []string
}{
{
name: "missing command",
args: nil,
wantStderr: []string{"usage:", "interface-snapshot generate", "--approved-flag-migrations"},
},
{
name: "unknown command",
args: []string{"unknown"},
wantStderr: []string{`unknown command "unknown"`, "usage:", "interface-snapshot compare"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("run(%v) unexpectedly wrote stdout: %s", test.args, stdout.String())
}
for _, want := range test.wantStderr {
if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) stderr missing %q:\n%s", test.args, want, stderr.String())
}
}
})
}
}
func TestCrossPlatformCoverageRunRejectsInvalidSubcommandArguments(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "generate unknown flag", args: []string{"generate", "--unknown"}, want: "flag provided but not defined"},
{name: "generate positional", args: []string{"generate", "unexpected"}, want: "generate accepts no positional arguments"},
{name: "compare unknown flag", args: []string{"compare", "--unknown"}, want: "flag provided but not defined"},
{name: "compare positional", args: []string{"compare", "unexpected"}, want: "compare accepts no positional arguments"},
{name: "compare missing current", args: []string{"compare", "--base", "base.json"}, want: "compare requires --current"},
{name: "compare missing reference", args: []string{"compare", "--current", "current.json"}, want: "compare requires --base, --stable, or both"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("run(%v) stderr missing %q:\n%s", test.args, test.want, stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunGenerateRejectsUnsafeOutputPath(t *testing.T) {
outputDirectory := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := run([]string{"generate", "--output", outputDirectory}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "create snapshot") {
t.Fatalf("directory output exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunGenerateReportsTemporaryDirectoryFailure(t *testing.T) {
missingTempRoot := filepath.Join(t.TempDir(), "missing")
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
t.Setenv(name, missingTempRoot)
}
var stdout, stderr bytes.Buffer
exitCode := run([]string{"generate"}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "create isolated home") {
t.Fatalf("invalid temporary root exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareReportsSnapshotReadFailures(t *testing.T) {
dir := t.TempDir()
validPath := writeSnapshot(t, dir, "valid.json", commandSnapshot("dws"))
missingPath := filepath.Join(dir, "missing.json")
tests := []struct {
name string
args []string
want string
}{
{
name: "current",
args: []string{"compare", "--current", missingPath, "--base", validPath},
want: "read current snapshot",
},
{
name: "main",
args: []string{"compare", "--current", validPath, "--base", missingPath},
want: "read main/development baseline snapshot",
},
{
name: "stable",
args: []string{"compare", "--current", validPath, "--stable", missingPath},
want: "read stable snapshot",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(compare) exit=%d, want 2", exitCode)
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("stderr missing %q:\n%s", test.want, stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunCompareReportsEachManifestReadFailure(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
validManifest := writeManifest(t, dir, "valid-manifest.json", `{"version":1,"migrations":[]}`)
invalidManifest := writeManifest(t, dir, "invalid-manifest.json", `{`)
tests := []struct {
name string
approved string
candidate string
want string
}{
{
name: "approved manifest",
approved: invalidManifest,
candidate: validManifest,
want: "read approved flag migrations",
},
{
name: "candidate manifest",
approved: validManifest,
candidate: invalidManifest,
want: "read candidate flag migrations",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
"--stable", snapshotPath,
"--approved-flag-migrations", test.approved,
"--candidate-flag-migrations", test.candidate,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
t.Fatalf("%s exit=%d stdout=%s stderr=%s", test.name, exitCode, stdout.String(), stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunCompareReportsOutputFailure(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
var stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
}, failingWriter{}, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "write comparison report") {
t.Fatalf("comparison output failure exit=%d stderr=%s", exitCode, stderr.String())
}
}
func TestCrossPlatformCoverageReadHelpersRejectMissingAndInvalidInputs(t *testing.T) {
dir := t.TempDir()
missingPath := filepath.Join(dir, "missing.json")
invalidPath := filepath.Join(dir, "invalid.json")
if err := os.WriteFile(invalidPath, []byte(`{`), 0o600); err != nil {
t.Fatalf("write invalid fixture: %v", err)
}
if _, err := readSnapshot(missingPath); err == nil {
t.Fatal("readSnapshot(missing) unexpectedly succeeded")
}
if _, err := readSnapshot(invalidPath); err == nil {
t.Fatal("readSnapshot(invalid) unexpectedly succeeded")
}
if _, err := readFlagMigrationManifest(missingPath); err == nil {
t.Fatal("readFlagMigrationManifest(missing) unexpectedly succeeded")
}
if _, err := readFlagMigrationManifest(invalidPath); err == nil {
t.Fatal("readFlagMigrationManifest(invalid) unexpectedly succeeded")
}
}
func TestCrossPlatformCoverageValidateHelpRenderingReportsResolveError(t *testing.T) {
root := &cobra.Command{Use: "dws"}
err := validateHelpRendering(root, commandSnapshot("dws missing"))
if err == nil || !strings.Contains(err.Error(), `resolve "dws missing" before help rendering`) {
t.Fatalf("validateHelpRendering resolve error = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingReportsTemplateError(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpTemplate(`{{index .Commands 99}}`)
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
t.Fatalf("validateHelpRendering template error = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRecoversTemplatePanic(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpTemplate("{{")
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
t.Fatalf("validateHelpRendering template panic = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRejectsCustomHelpStderr(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
})
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), "injected help failure") {
t.Fatalf("validateHelpRendering custom stderr = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRejectsEmptyOutput(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(*cobra.Command, []string) {})
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Fatalf("validateHelpRendering empty output = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingAcceptsNormalOutput(t *testing.T) {
root := &cobra.Command{Use: "dws", Short: "root command"}
if err := validateHelpRendering(root, commandSnapshot("dws")); err != nil {
t.Fatalf("validateHelpRendering normal output: %v", err)
}
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) {
return 0, errors.New("injected write failure")
}
var _ io.Writer = failingWriter{}
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
commands := make([]interfacesnapshot.Command, 0, len(paths))
for _, path := range paths {
@@ -505,68 +120,6 @@ func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Sn
return path
}
func writeManifest(t *testing.T, dir, name, contents string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
func flagMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
legacy := interfacesnapshot.Flag{
Name: "legacy-id",
Shorthand: "l",
Type: "string",
Default: "",
NoOpt: "auto",
Required: true,
}
flags := []interfacesnapshot.Flag{legacy}
if after {
legacy.Required = false
legacy.Hidden = true
legacy.AliasOf = "message-id"
flags = []interfacesnapshot.Flag{
legacy,
{Name: "message-id", Type: "string", Default: "", Required: true},
}
}
return interfacesnapshot.Snapshot{
SchemaVersion: interfacesnapshot.SchemaVersion,
Rules: interfacesnapshot.Rules{
ExcludedCommandSubtrees: []string{},
ExcludedFlags: []string{},
},
Commands: []interfacesnapshot.Command{
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
{Path: "dws chat send", Runnable: true, Aliases: []string{}, LocalFlags: flags, InheritedFlags: []interfacesnapshot.Flag{}},
},
}
}
func flagMigrationManifestJSON(state string) string {
return strings.Replace(`{
"version": 1,
"migrations": [{
"command": "dws chat send",
"legacy": {
"name": "legacy-id",
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
},
"canonical": {
"name": "message-id",
"before": {"present": false},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "STATE",
"reason": "reviewed exact migration"
}]
}`, "STATE", state, 1)
}
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
for _, flag := range flags {
if flag.Name == name && flag.Type == flagType {
+1
View File
@@ -40,6 +40,7 @@
- `internal/output`: response formatting (json, table, raw, pretty)
- `internal/logging`: structured logging and argument sanitization
- `internal/tui`: terminal UI helpers
- `internal/recovery`: panic recovery and graceful degradation
- `pkg/configmeta`: environment variable registry and documentation
- `pkg/config`: configuration constants and paths
- `pkg/edition`: edition detection (oss vs enterprise)
+1 -1
View File
@@ -43,7 +43,7 @@ repository root while preserving repo-local guidance for automation.
- Error message or category issues: inspect `internal/errors`
- Audit log issues: inspect `internal/audit`
- Plugin loading or command surface: inspect `internal/plugin`
- Failure or degraded mode: inspect `internal/errors`
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
## Policy Checks
+8 -39
View File
@@ -157,33 +157,15 @@ expected to repeat every CI job locally:
```sh
make build
make policy
make interface-integrity BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
make interface-integrity
make authoritative-interface-integrity BASE_REF=<merge-base>
make schema-compatibility BASE_REF=<merge-base>
make skill-command-integrity
make cli-smoke
make mock-mcp-smoke
go test -v -count=1 ./pkg/editiontest/...
```
CI 先解析并核对精确的 merge-base、最近可达且未撤回的 stable GA tag 和已提交的 candidate
SHA,再调用 `make authoritative-interface-integrity`。本地 `make interface-integrity`
与该 CI target 都只委托给同一个 modern authoritative wrapper,不存在第二个比较
入口。省略 `BASE_REF` 时本地 target 默认比较 `origin/main`,省略 `STABLE_REF` 时自动
选择该 base 可达且未撤回的最近 stable GA tag,省略 `CANDIDATE_REF` 时比较已提交的 `HEAD`。
需要逐字复现某次 CI 时,应显式传入该次运行记录的 merge-base、stable tag 和
candidate SHA。
`make update-interface-baseline` / `make reset-interface-baseline` 只维护
`test/fixtures/cli-interface-baseline.txt` 这一份非权威 CLI Smoke fixture。底层旧
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
flag 迁移。
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
放宽其他 Schema 历史字段。
For an exact CHANGELOG-only branch:
```sh
@@ -204,24 +186,11 @@ 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.
CLI 兼容检查只使用 modern Interface Snapshot 这一处权威比较 seam,并从 PR
merge-base 和最近的可达 stable release 生成权威快照。本治理机制合入后,
merge-base 拥有生成器、比较器和已审批迁移清单,因此 candidate 不能通过修改
helper、fixture 或在同一 PR 新增 self-approval 记录来放行 breaking change。首次
bootstrap 仍由 merge-base 已有的 modern helper 做无豁免比较,并只接受 candidate
提交中的规范空清单;完整边界见下方治理文档。
精确的两阶段 flag 迁移生命周期见
[CLI flag 兼容迁移治理](cli-interface-flag-migrations.md)。治理 PR 只能在
surface 未变化时新增 `pending`;后续产品 PR 达到审批的精确 surface 后,才能
消费 base-owned 记录并改为 `consumed`。在 main 与 stable 都达到 after 状态前
必须保留该回执,之后再由单独 PR 清理。机制只放行记录中的 legacy
visible-to-hidden,以及 canonical required 新增或提升;删除、type、scope、
shorthand、no-opt 和任何无关漂移仍然阻塞。Schema 可以新增;历史 product、
tool、parameter、mapping、positional execution、constraint 与 safety 语义继续
受保护。`alias_of` 只是一项由 `FlagSpec.Aliases` 产生的框架关系证据,不是 payload
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
Compatibility checks derive authoritative Interface snapshots from the PR
merge-base and the latest reachable stable release. The candidate cannot bless
a breaking change by editing a fixture. Schema additions are allowed;
historical products, tools, parameters, mappings, positional execution fields,
constraints, and safety semantics remain protected.
## Required GitHub repository settings
-176
View File
@@ -1,176 +0,0 @@
# CLI flag 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
## 唯一比较入口与信任边界
PR 与本地兼容性审批的唯一权威比较入口是 modern Interface Snapshot:
- `cmd/interface-snapshot` 生成和比较快照;
- `internal/interfacesnapshot` 实现兼容规则和迁移生命周期;
- `scripts/policy/check-command-compatibility.sh` 组装 candidate、PR merge-base 和最近可达且未撤回的 stable GA 三份快照;
- `scripts/policy/check-authoritative-interface-baselines.sh` 只保留为 Makefile 的兼容包装,不再维护第二套判断逻辑。
紧随其后的 Schema compatibility 不是第二份审批清单。它从同一 merge-base-owned
ledger 和同一组三方 Interface Snapshot 取得已经完成 lifecycle 校验的
authorization。merge-base-owned checker 会分别规范化 merge-base 与 stable 的完整
Schema,并让 candidate 对两份历史 contract 独立执行检查;授权的 flag rename 只会
精确投影到当前被检查的历史副本。candidate 不能为 CLI 与 Schema 分别提供两套例外。
`make interface-integrity` 也调用上述 authoritative wrapper;默认 base 为
`origin/main`,stable 可由包装脚本自动解析,candidate 默认为已提交的 `HEAD`。旧
`scripts/policy/check-interface-baseline.sh` 只供
`make update-interface-baseline` / `make reset-interface-baseline` 维护非权威 CLI
Smoke fixture,不参与迁移审批。
直接调用 `interface-snapshot compare` 时,只要提供 migration manifest 参数,就必须
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
调用方因漏传历史参考而提前清理 consumed receipt。
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
已审批清单固定为:
```text
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
## 两阶段迁移与回执清理
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|---|---|---|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
```json
{
"version": 1,
"migrations": [
{
"command": "dws chat message recall",
"legacy": {
"name": "msg-id",
"before": {
"present": true,
"type": "string",
"required": true,
"scope": "local"
},
"after": {
"present": true,
"type": "string",
"hidden": true,
"scope": "local",
"alias_of": "message-id"
}
},
"canonical": {
"name": "message-id",
"before": { "present": false },
"after": {
"present": true,
"type": "string",
"required": true,
"scope": "local"
}
},
"state": "pending",
"reason": "保留旧 argv 兼容性,并将规范 flag 设为唯一可见入口"
}
]
}
```
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
## `alias_of` 是框架来源的受评审关系证据
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
- legacy 与 canonical 位于同一个可执行命令;
- canonical flag 确实存在;
- legacy 与 canonical 类型一致;
- legacy 不是指向自身,也不存在 alias chain;
- legacy 的 after 状态精确指向该记录中的 canonical flag。
通过命令框架声明 `FlagSpec.Aliases` 时,框架会自动注册隐藏的兼容 flag,并写入两项 relation annotation;仅手写 `alias_of`、伪造 origin、重复值或不精确值都会让快照生成失败。不要用 Schema overlay、迁移清单或手写 Cobra annotation 伪造关系。
这项关系证据只证明 legacy/canonical 经过受控框架路径建立关系,不证明最终 transport payload 等价,也不会替产品代码实现命令特有的值同步。当前框架还禁止把
`MarkRequired` 与 `FlagSpec.Aliases` 直接组合,因为 Cobra 的 hard-required
校验只识别 canonical spelling。若产品迁移同时需要 canonical 的 Cobra required
标记和 legacy spelling,产品 PR 必须提供明确的运行时方案,并通过 canonical / legacy
最终 payload 等价、同值输入一致、冲突输入在 transport 前失败、legacy 仍可调用但 Help 隐藏等测试;迁移清单和 relation evidence 都不能替代这些证明。
## 豁免边界
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
- 删除 legacy、canonical、命令或其他 flag;
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
- `alias_of` 缺失、指向变化或 alias chain;
- 命令路径及任何无关的阻塞性接口变化;
- 不精确、部分完成、超出记录范围的 surface 变化。
## Schema 投影边界
Agent-visible command 会把 visible Cobra flag 投影为 Schema parameter,因此合法的
legacy hidden 迁移会同时表现为历史 parameter 消失,constraint member 也可能从
legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interface Snapshot
判定为 authorized 的迁移,并按 tool 的精确 `primary_cli_path` 绑定:
- reference 仍处于 `before` 且该 flag 有 Schema surface 时,baseline legacy parameter
必须存在,candidate legacy parameter 必须消失,candidate canonical parameter 必须存在;
如果 baseline 只有 canonical、没有 legacy,则 adapter 不得借 CLI ledger 提升
`required` / `cli_required` 或重写 constraint;
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
`required_when` 必须完全一致;
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
fail closed。
adapter 先构造经过上述验证的历史 contract 副本,再调用原 Schema checker;它不会按
错误字符串删除 finding。这样既能处理纯 rename,也能阻止“旧 required 参数改名后意外
变为 optional”或 property 漂移等伪兼容。`consumed` 回执在 merge-base Schema 已经处于
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
验证 candidate 是否仍与该 canonical contract 兼容。
## 本地验证
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
```sh
make interface-integrity \
BASE_REF=<merge-base> \
STABLE_REF=<stable-tag> \
CANDIDATE_REF=<candidate-sha>
make schema-compatibility \
BASE_REF=<merge-base> \
STABLE_REF=<stable-tag> \
CANDIDATE_REF=<candidate-sha>
```
`STABLE_REF` 必须解析到从该 merge-base 可达的最高未撤回 stable GA tag;primary checker 会按 release contract 独立核对,不能用任意 after commit 或已撤回版本提前清理回执。包装脚本可以在省略时自动解析。`CANDIDATE_REF` 省略时固定为命令启动时的已提交 `HEAD`;评审和复现 CI 时应显式传入 candidate SHA,避免 surface 与清单来自不同 revision。
+3 -6
View File
@@ -40,7 +40,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
- [`dws minutes` — AI Minutes](#dws-minutes) · 19 commands
- [`dws oa` — OA Approval](#dws-oa) · 12 commands
- [`dws oa` — OA Approval](#dws-oa) · 9 commands
- [`dws report` — Reports](#dws-report) · 7 commands
- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands
@@ -277,17 +277,14 @@ _AI meeting notes: listing, summary, todos, transcription, recording control, mi
## `dws oa` — OA Approval
_OA approval workflows: inspect forms, forecast routes, create instances, approve, reject, revoke, and audit records._
_OA approval workflows: list, approve, reject, revoke, records._
**12 commands**
**9 commands**
| Command | Description | When to use |
|---|---|---|
| `dws oa approval approve` | Approve a pending approval process instance (task) as the current user. | When the agent acts on a pending approval the user has delegated it to handle. |
| `dws oa approval create-instance` | Create a real approval process instance from validated form values or a complete request payload. | After the agent has inspected the form Schema, forecast the route, resolved any selectable approvers, and obtained explicit user confirmation. |
| `dws oa approval detail` | Retrieve full details of an approval process instance, including form fields, attachments, and state. | When the agent needs to read the content of an approval ticket before deciding on it or summarizing it. |
| `dws oa approval form-schema` | Retrieve the form Schema for an approval template by processCode. | Before collecting or validating values for a new approval instance. |
| `dws oa approval forecast-process` | Forecast the approval route for a template and its proposed form values. | Before creating an instance, especially when the route contains user-selectable approver or notifier nodes. |
| `dws oa approval list-forms` | List approval process templates (forms) the current user is allowed to initiate. | When the agent needs to pick the right approval form before submitting a new request. |
| `dws oa approval list-initiated` | List approval process instances the current user has initiated. | When the agent reviews the status of approvals the user submitted. |
| `dws oa approval list-pending` | List approval process instances currently awaiting action from the current user. | When the agent surfaces "needs your approval" items in the user's inbox. |
+1 -1
View File
@@ -1,7 +1,7 @@
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
> 用途:评测 agent(加载 `dingtalk-misc` 的 `references/devapp.md` 后)能否正确处理开放平台 dev 任务。
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
+3 -3
View File
@@ -6,7 +6,7 @@
## 一键安装
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),**只需要 curl + tar,不需要 git / go / make**。
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
### macOS / Linux
@@ -24,7 +24,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
2. 安装 `dws` 到默认目录 `~/.local/bin`。
3. 从 Release 的 skills 包里安装 `dingtalk-misc` skill 到本机已检测到的 Agent 目录。
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
支持这些环境变量(全部可选):
@@ -33,7 +33,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-misc` skill 安装 |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
+1 -41
View File
@@ -4,7 +4,7 @@ Defines the stable `dws event consume` subprocess contract so an
orchestrator can determine when the consumer is ready, stop it cleanly,
and machine-read why it exited.
Scope of this branch: the six **contract** items below. Reconnect
Scope of this branch: the five **contract** items below. Reconnect
resilience (keeping the stream alive across a transient upstream drop) is
tracked separately and intentionally out of scope here.
@@ -159,46 +159,6 @@ marker; reconnecting an established Stream remains a separate mechanism.
`terminal_hold`, and identity-scoped cleanup; skill/docs tests pin the
operational recovery instructions.
### 6. Host runtime-token handoff
When the root command carries an explicit host-supplied `--token`, personal
event control requests and the foreground Stream use that token with higher
priority than local OAuth. A detached bus receives it only through the
owner-only local IPC transport:
1. The child starts in runtime-token mode with non-sensitive identity and
ticket metadata only; neither its argv nor environment contains the token.
2. The consumer sends `Hello` with `credential_mode=runtime_token`.
3. The bus advertises the additive `runtime_token_v1` capability and its
in-memory credential generation in `HelloAck`.
4. Only after that capability is confirmed does the consumer send a bounded
`credential_update` frame. The bus applies it with generation CAS, replies
with `credential_update_ack`, and registers the consumer only on success.
The bus blocks ticket acquisition until the first runtime credential arrives.
A later invocation may rotate Token A to Token B on a compatible existing bus;
the current WebSocket remains connected and the next ticket request or natural
reconnect uses B. If a 401 rejects the current runtime token, only an already
installed newer generation is retried; the runtime path never refreshes or
falls back to a local OAuth profile and never suggests `dws auth login`.
Clients do not send a token to a bus that lacks the capability, do not stop
other consumers automatically, and fail before printing the ready marker. With
no explicit `--token`, the original OAuth, refresh, profile, and old-client to
new-bus protocol behavior remains unchanged.
**Verification**
- T6a: a stale local Token A and root Token B produce control and ticket
requests authenticated only with B.
- T6b: compatible bus reuse supports A-to-B rotation and generation conflicts;
401 retries only an already-installed newer runtime token.
- T6c: an old bus receives no credential and remains running; the new consumer
exits before its ready marker.
- T6d: a canary credential is absent from child argv/environment, dry-run,
stdout/stderr, `bus.meta`, `bus.log`, run state, and returned errors.
- T6e: no-token OAuth, refresh, multi-profile, marker/cache, and bus-reuse tests
continue to pass.
## Out of scope (next branch)
**Reconnect resilience** — today `personal source` retries only
@@ -1,134 +0,0 @@
# 独立 `meta.pagination` Schema 方案
## 1. 目标结构
业务结果与分页控制信息分层:
```json
{
"ok": true,
"outcome": "success",
"data": {
"items": [{"id": "a"}]
},
"meta": {
"pagination": {
"endpoint_exhausted": false,
"next_token": "cursor-2"
}
}
}
```
对应 compact/full leaf Schema:
```json
{
"result": {
"outcomes": ["success", "failure"],
"data_schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"description": "当前页业务记录",
"items": {"type": "object"}
}
}
}
},
"pagination": {
"kind": "cursor",
"cursor_parameter": "cursor",
"meta_path": "meta.pagination",
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
"next_token_path": "meta.pagination.next_token"
}
}
```
`result` 只描述 `data`;`pagination` 是与 `result` 同级的命令能力声明。
## 2. 分页状态
| 状态 | `endpoint_exhausted` | `next_token` | Agent 行为 |
|---|---:|---|---|
| 可续跑 | `false` | 必须非空 | 将 token 传给 `--<cursor_parameter>` |
| 已耗尽 | `true` | 必须省略 | 停止翻页 |
`endpoint_exhausted:true` 只表示观察到 Endpoint 分页耗尽,不表示搜索索引
健康、数据全量覆盖或业务对象不存在。
## 3. 映射规则
产品 mapper 可以读取服务端原始 `hasMore/nextCursor`、`has_more/page_token`
等字段,但统一 CLI 输出只公布 `meta.pagination`:
- 服务端表示还有下一页且 cursor 非空 → `endpoint_exhausted:false` + token。
- 服务端表示没有下一页 → `endpoint_exhausted:true`,不带 token。
- 表示还有下一页但 cursor 缺失、类型错误或证据冲突 → typed
`pagination_inconsistent`,禁止伪装终页。
- mapper 使用同一份上游响应构造 `data` 与 `meta`,不得重新请求。
原始分页控制字段不进入新的 `result.data_schema`。未迁移命令保持 legacy;
已迁移命令按命令独立切换和回滚,不通过 Agent 参数选择协议。
## 4. Schema 规则
- `kind` 当前只允许 `cursor`。
- `cursor_parameter` 是真实 canonical CLI flag 名,不带 `--`,并必须存在于
同一 leaf 的 `parameters`。
- 三个 meta path 由框架固定生成,产品不能覆盖。
- compact/full leaf 同时包含相同的 `result` 和 `pagination`。
- product/group 导航摘要不复制分页对象;Agent 需要时查询具体 compact leaf。
- 没有 `pagination` 表示该命令尚未发布经评审的分页能力,Agent 不得猜测。
## 5. 渐进接入
1. **legacy_only**:保持原输出,不公布分页声明。
2. **dual_validate**:业务执行一次;影子构造并校验 `meta.pagination`,外部
legacy 字节不变。
3. **unified_active**:输出独立 `meta.pagination`,Schema 公布同级
`pagination` 声明。
4. **unified_stable**:Skill、示例和 Agent 审计均只读取 meta 分页。
不增加 `contract_version`、`--output-contract` 或分页协议别名。
## 6. 验收
每个分页命令至少验证:
1. 有下一页时 `endpoint_exhausted:false` 且 token 非空。
2. 终页和空终页为 `endpoint_exhausted:true` 且无 token。
3. 分页矛盾产生 typed failure,不 panic、不静默停止。
4. `cursor_parameter` 在 Help/Schema 中真实存在。
5. compact/full 的 `result`、`pagination` 分别 JSON 等价。
6. `data_schema` 不包含分页控制字段。
7. 运行时 `data` 不包含迁移后的分页控制字段。
8. dual validate 与 active 都只消费一次上游响应。
9. Agent 逐命令扫描结果进入评测台账;不提交生成 Schema JSON fixture。
### DevApp 首批落地
以下 8 个终结命令已发布独立 `pagination` Schema;运行时统一输出只在
`meta.pagination` 返回分页控制信息:
- `dev app list`
- `dev app permission list`
- `dev app event list`
- `dev app version list`
- `devapp +list`
- `devapp +permission-list`
- `devapp +event-list`
- `devapp +version-list`
两套既有命令前缀继续保留。原子命令的业务记录字段为 `data.items`;Shortcut
保留既有业务投影(例如 `data.apps`、`data.permissions`、`data.events`、
`data.versions` 以及 `data.count`),但两套入口都不再在业务数据中公布
`hasMore/nextCursor`。
## 7. 对齐依据
GWS 用请求参数和 response schema 描述分页事实;Lark 在统一输出层维护分页
元数据。DWS 采用更明确的分层:业务 `data` 保真承载记录,框架 `meta` 承载
续跑状态,Schema 用独立能力把 token 与下一次 CLI 参数连接起来。
File diff suppressed because it is too large Load Diff
+10 -11
View File
@@ -94,7 +94,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
dws schema -f pretty "calendar event create" --compact # Pretty Agent schema view / Agent Schema 彩色查看
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
```
## Dry Run / 试运行
@@ -119,28 +119,27 @@ Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树
```bash
dws schema # 当前公开产品面的紧凑概览
dws schema calendar --compact # Agent 产品视图
dws schema "calendar event" --compact # Agent 分组视图
dws schema "calendar event create" --compact # Agent leaf(CLI 空格路径)
dws schema calendar.create_calendar_event --compact # Agent leaf(canonical path)
dws schema --cli-path "calendar event create" --compact # Agent leaf(显式 CLI path)
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema calendar # 展开一个产品
dws schema "calendar event" # 展开一个命令分组
dws schema "calendar event create" # 按 CLI 空格路径查询工具
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
dws schema --cli-path "calendar event create" # 显式 CLI path
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
```
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
省略 `--compact` 的 full leaf、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;compact leaf 仅做字段白名单投影,不重新解析语义。概览、产品/分组和 Catalog summary 也来自同一 `ToolSpec`。通过 alias 查询时,只允许路径视图发生变化,参数、安全和接口契约不得变化。
Leaf 查询、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;概览、产品/分组和 Catalog summary 也由该 `ToolSpec` 的统一 summary 投影生成。通过 alias 查询时,只允许 `cli_path` 和 `is_alias` 发生视图变化,参数、安全和接口契约不得变化。
`--compact` 是 Schema 的稳定 Agent 字段白名单,也是普通 Agent 查询的规范选项。它保留 CLI 参数、组合约束、选择和安全语义,但有意省略 `interface_ref`、参数 `property/interface_type` 与 provenance。检查这些映射/审计字段时,使用 full leaf 并通过 `--jq` / `--fields` 精确投影。若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试;这只降低输出裁剪能力,不表示 leaf 缺失。
`--compact` 是 Schema 的展示选项。当前版本支持该 flag;若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试。这只降低输出裁剪能力,不表示 leaf 不存在,也不能改用 Schema 查询业务数据。
### Schema、Help 与业务数据的边界
| 问题 | 事实源 |
|------|--------|
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、CLI 参数与组合约束、risk/confirmation | 对应的 Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定与 provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| Agent 选哪个命令、参数映射与组合约束、risk/confirmation | 对应的 leaf `dws schema "<path>"` |
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
+1 -1
View File
@@ -6,7 +6,7 @@
## 第一步:安装 dws
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
### macOS / Linux
+6 -7
View File
@@ -231,8 +231,7 @@ Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/prov
| 问题 | 事实源 |
|---|---|
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、CLI 参数/required/约束、risk/confirmation | Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定、provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| Agent 选哪个命令、参数映射/required/约束、risk/confirmation | 对应 leaf `dws schema "<path>"` |
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 和 Help 冲突是契约漂移,不能静默猜测:
@@ -247,10 +246,10 @@ Schema 和 Help 冲突是契约漂移,不能静默猜测:
```bash
dws schema # 产品紧凑概览
dws schema calendar --compact # Agent 产品摘要
dws schema "calendar event" --compact # Agent 分组摘要
dws schema "calendar event create" --compact # Agent leaf
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema calendar # 产品摘要
dws schema "calendar event" # 分组摘要
dws schema "calendar event create" # 完整 leaf
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
dws schema --all # 所有工具的完整 leaf 导出
```
@@ -258,7 +257,7 @@ dws schema --all # 所有工具的完整 leaf 导
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
`--compact` 是普通 Agent 查询的规范视图:通过正向字段白名单保留选参、约束与安全语义,full 新增字段不会自动进入 Agent 上下文。省略它的 leaf 包含参数 property、接口绑定和 provenance,只用于定向审计;`schema --all --compact` 也可执行,但不能作为完整兼容性 baseline。
`--compact` 当前受支持,适合减少常规 leaf 查询上下文。`schema --all --compact` 也可执行,但会移除 provenance/debug 和接口映射字段,不能作为完整兼容性 baseline。
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
+187
View File
@@ -0,0 +1,187 @@
# lark-cli Shortcut 深度对齐矩阵
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
## 2026-07-13 最新源码复核
对比基线:
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
- lark-cli:`main@e96c4fa5`
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
### 当前命令面快照
| 指标 | 数量 | 说明 |
|---|---:|---|
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
| 产品映射 | DWS | lark | 同名 |
|---|---:|---:|---:|
| aitable ↔ base | 82 | 87 | 31 |
| calendar ↔ calendar | 23 | 10 | 3 |
| chat ↔ im | 89 | 21 | 2 |
| contact ↔ contact | 16 | 2 | 1 |
| devapp ↔ apps | 30 | 63 | 3 |
| doc ↔ doc | 19 | 14 | 1 |
| drive ↔ drive | 9 | 26 | 3 |
| mail ↔ mail | 10 | 21 | 0 |
| minutes ↔ minutes | 13 | 9 | 1 |
| sheet ↔ sheets | 2 | 42 | 0 |
| todo ↔ task | 13 | 17 | 2 |
| wiki ↔ wiki | 7 | 12 | 3 |
### 最新优先差距
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
### 不建议机械追平
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
## 汇总(361 条 lark shortcut)
| dws_status | 数量 | 含义 |
|---|:---:|---|
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
| **gap-buildable** | **41** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`/`+chat-list`、task `+related-tasks` |
| covered-smart | 49 | 已建智能 shortcut / 部分覆盖 |
## 🎯 gap-buildable 目标清单(原 49 条,已建 8 → 剩 41,按服务)
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
### im → chat(5)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` 当前页过滤 + `--exclude-muted` + page-size/page-token 别名 + openConversationId/name/conversationType 投影。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
| `+messages-search` | read | dws 有 search_messages_by_keyword/by_time_range/by_sender/at_me 多个原子 tool,但各自单点,缺统一多维filter编排+mget+chat上下文富化+跨字段Validate |
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:list_topic_replies + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
### task → todo(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
### calendar → calendar(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
### doc (docs) → doc(2)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
### drive → drive(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
### mail → mail(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
### wiki → wiki(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
### minutes → minutes(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
### base → aitable(10)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
### sheets → sheet(14)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
### apps → devapp(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
- **im**: +chat-members-list +chat-list +messages-send +threads-messages-list
- **task**: +complete +assign +get-my-tasks +get-related-tasks
- **contact**: +search-user
- **calendar**: +agenda +create +update +freebusy +suggestion
- **doc (docs)**: +history-revert
- **drive**: +upload +search +inspect
- **mail**: +triage
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
File diff suppressed because it is too large Load Diff
+272
View File
@@ -0,0 +1,272 @@
# Skill 分发/安装架构优化方案
> 目标:把 DWS Agent skill 的"装在哪、怎么装、装完记什么"从 7+ 个安装面的
> N 份漂移拷贝,收敛为 **Go 侧单一事实源 + 单一安装引擎 + 脚本侧 bootstrap**。
> 本文基于 feat/skill-mode-migration 工作区(multi 化落地后)的代码级盘点
> (2026-08-05),所有锚点均可跳转验证。
> 前置文档:[skill-multi-migration-plan.md](skill-multi-migration-plan.md)(下称《迁移计划》)、
> [skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
## 1. 问题量化
### 1.1 复制矩阵(能力 × 面)
multi 化之后,同一块逻辑在各安装面的拷贝数(✓ = 独立拷贝一份,数字 = 份数):
| 能力 \ 面 | install.sh | install.ps1 | npm install.js | install-skills.sh | install-event.sh | install-devapp.sh(+ps1) | Homebrew caveats | `dws skill setup` (Go) | `dws upgrade` (Go) |
|---|---|---|---|---|---|---|---|---|---|
| agent home 清单 | ✓×3 | ✓ | ✓ | ✓×2 | ✓ | ✓×2 | — | ✓ | ✓ |
| 互斥清理(mono↔multi) | ✓×2(其中 1 份死代码) | ✓ | ✓ | ✓ | — | — | — | ✓ | ✓ |
| mode 解析(env/flag/交互) | ✓ | ✓ | ✓ | — | — | — | — | ✓ | 布局嗅探 |
| stale `dingtalk-*` 清理 | ✓ | ✓ | ✓ | ✓ | — | — | — | ✗(additive,不清) | ✓ |
| `~/.dws/skills` 缓存写 | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | —(只读回退) | ✓(仅 multi) |
| 安装状态(state.json) | 无 | 无 | 无 | 无 | 无 | 无 | — | 无 | 无 |
清单锚点(agent home 清单,全仓共 **15 份**):
| # | 位置 | 备注 |
|---|---|---|
| 1 | `internal/app/skill_setup.go:20-37` | `skillSetupAgentHomes`,16 项,**无 opencode** |
| 2 | `internal/app/skill_command.go:119-141` | `agentSkillPaths`,17 项,含 opencode(`:129`) |
| 3 | `internal/upgrade/paths.go:35-52` | `knownSkillDirs`,16 项,**无 opencode** |
| 4 | `scripts/install.sh:370-386` | mono 安装循环内联清单 |
| 5 | `scripts/install.sh:439-455` | multi 清单(**死代码**,见 1.2) |
| 6 | `scripts/install.sh:516-532` | multi 清单(生效) |
| 7 | `scripts/install.ps1:44-61` | `$AgentDirs` |
| 8 | `build/npm/install.js:11-28` | `AGENT_DIRS` |
| 9 | `scripts/install-skills.sh:166-182` | multi 清单 |
| 10 | `scripts/install-skills.sh:247-261` | mono 清单 |
| 11 | `scripts/install-event.sh:103-107` | 17 项,**含 `.config/opencode/skills`**(`:107`) |
| 12 | `scripts/install-devapp.sh:87-91` | 含 opencode(`:91`) |
| 13 | `scripts/install-devapp.ps1:106` | 含 opencode |
| 14 | `test/scripts/package_script_test.go` | `expectedPackagedSkillTargets` |
| 15 | `scripts/release/verify-package-managers.sh:75-76` | `HOME_AGENT_PARENTS` / `HOME_SKILL_TARGETS` |
互斥清理逻辑共 **≥8 份**:`skill_setup.go:570-608`、`paths.go:306-327` +
`paths.go:163-179`(mono 分支内联)、`install.sh:394-399` + `install.sh:557-570`
+ 死代码 `install.sh:477-486`、`install.ps1:488-498` + `install.ps1:562-570`、
`install.js:130-137` + `install.js:165-178`、`install-skills.sh:207-220`。
mode 解析共 **4 份**:`skill_setup.go:343-376`、`install.sh:220-257`、
`install.ps1:293-332`、`install.js:197-214`(各自实现 env 优先级、非法值报错、
TTY 交互、非 TTY 默认值,措辞与行为细节已不完全一致)。
stale `dingtalk-*` 清理共 **5 份**:`paths.go:317-325`、`install.sh:561-567`、
`install.ps1:562-570`、`install.js:168-172`、`install-skills.sh:211-220`。
缓存写共 **6 份**:`install.sh:326-344`(multi)+ `install.sh:349-360`(mono)、
`install.ps1:446-471`、`install.js:221-237`、`install-event.sh:165-169`、
`install-devapp.sh:82-84`、`paths.go:290-296`(upgrade 只刷 multi)。
### 1.2 已发生的漂移实例(不是假设,是现状)
1. **opencode 清单漂移**:`dws skill install` 支持 opencode 目标
(`skill_command.go:129`),install-event / install-devapp 也往
`~/.config/opencode/skills` 装(`install-event.sh:107`、
`install-devapp.sh:91`),但 `dws skill setup --target all` 与
`dws upgrade` 的清单都不含它(`skill_setup.go:20-37`、
`paths.go:35-52`)。结果:opencode 用户能收到 event/dev skill,
却永远收不到内置 skill 的安装与升级。
2. **install.sh 死代码**:`install_multi_skills_to_homes` 定义了两次
(`install.sh:434` 与 `install.sh:511`),`_install_multi_to_base`
同样两次(`install.sh:473` 与 `install.sh:549`)。bash 后定义覆盖先定义,
第一对(434-505)整体是死代码——本次 multi 化自己引入的重复。
3. **"镜像"注释与实现已不符**:`install.sh:507-510` 注释声称 multi 安装
"mirroring `dws skill setup --mode multi`",但脚本会删除 bundle 里不存在
的 stale `dingtalk-*`(`install.sh:561-567`),而 setup 是 additive 语义、
只清 `dws/`(`skill_setup.go:587-593`)。注释说镜像,行为已分叉。
4. **互斥语义自相矛盾**:install-event.sh 把 multi 的 `dingtalk-event` 和
mono 的 `dws` **同时**装进同一批 agent home(`install-event.sh:171-172`),
与其他所有面"mono/multi 互斥"的语义直接冲突。
5. **缓存只写不读、版本不可见**:`dws skill setup` 默认走二进制 embed
(`skill_setup_embed.go:50-59`),`~/.dws/skills` 只是 legacy 回退候选
(`skill_setup.go:442-447`);6 个写入方没有任何版本戳,upgrade 只刷
multi 不刷 mono(`paths.go:292-296`),缓存与 embed 漂移不可见。
6. **保留前缀规则已双份**:`dingtalk-` / `dws-shared` 的保留约定在
`skill_setup.go:199`(`multiSkillPrefix`)、`skill_setup.go:205`
(`multiSharedSkill`)与 `paths.go:332-334`(`isMultiSkillDirName`)
各写一份,互斥清理依赖"市场 skill 不用该前缀"这一隐性约定。
### 1.3 维护成本论证(本次 multi 化实证)
本次"把 7 个面全部 multi 化"这一个语义变更,工作区改动为 **13 个文件、
+759/−166 行**(`git diff --stat`),横跨 Go / POSIX sh / PowerShell /
JavaScript 四种语言:
| 文件 | 改动量 | 改了什么 |
|---|---|---|
| `scripts/install.sh` | +256/−… | mode 解析、multi 安装×2(含死代码)、互斥清理、缓存写 |
| `scripts/install.ps1` | +169 | 同上,PowerShell 再写一遍 |
| `build/npm/install.js` | +87 | 同上,JavaScript 再写一遍 |
| `scripts/install-skills.sh` | +101 | multi 安装 + stale 清理 |
| `internal/upgrade/paths.go` | +206 | upgrade multi 刷新 + 互斥清理 + 缓存 |
| `internal/app/skill_setup.go` 等 Go 侧 | ~+50 | setup multi 语义 |
| 测试与文档 | 其余 | 4 个测试文件 + README×2 + SKILL.md |
即:**一个语义变更 = 4 种语言 × 7+ 处同步编辑**,且仍然漏了 opencode、
制造了死代码、留下了语义分叉(1.2)。新增一个 agent home 今天要改 15 份
清单中的至少 11 份(另 4 份是测试/校验)。这不是"以后会漂移",而是
**每一次改动都在当场制造漂移**。
## 2. 目标架构
### 2.1 分层总览
```text
┌──────────────────────────────────────────────────────────────────┐
│ L3 分发面(7 个,全部退化为 bootstrap) │
│ install.sh / install.ps1 / npm install.js / install-skills.sh /│
│ install-event.sh / install-devapp.sh / Homebrew caveats │
│ 职责:装二进制 → exec `dws skill setup --mode X --yes` │
│ └─ 失败 → 冻结的内嵌 fallback 拷贝(一档,不再演进) │
├──────────────────────────────────────────────────────────────────┤
│ L2 单一安装引擎(Go,唯二入口) │
│ dws skill setup ─┐ │
│ dws upgrade ─┴─► 共用同一 install 实现(含互斥清理/记账) │
├──────────────────────────────────────────────────────────────────┤
│ L1 单一事实源:internal/skillhome(新共享包) │
│ AgentHomes() 清单 + 布局规则(mono→<home>/dws,multi→平铺) │
│ + 互斥/ stale 清理规则 + 保留前缀常量 + state.json 读写 │
├──────────────────────────────────────────────────────────────────┤
│ L0 事实数据 │
│ //go:embed skills/{mono,multi}(skills_embed.go:28,默认源) │
│ ~/.dws/skills/state.json(权威安装状态) │
│ ~/.dws/skills/{mono,multi}(显式回退源,带版本戳,唯一写方=Go) │
└──────────────────────────────────────────────────────────────────┘
▲ 门禁:scripts/policy/check-agent-homes-sync.sh(进 make policy)
比对各脚本可解析清单块 ↔ skillhome 导出清单
```
### 2.2 单一事实源:`internal/skillhome` 共享包
新建 `internal/skillhome`,把今天散在 3 处 Go 代码里的规则合并导出,
setup / upgrade / 测试共用(对应《迁移计划》P0c-1):
| 导出物 | 收敛的现有拷贝 |
|---|---|
| `AgentHomes()`(含 opencode,带"首项必装/其余父目录门控"元数据) | `skill_setup.go:20-37`、`paths.go:35-52`,并与 `skill_command.go:119-141` 互相断言 |
| `HomeForMode(base, mode)` 布局规则 | `skill_setup.go:502-507`(`agentHomeForMode`) |
| `MutualExclusionVictims(home, mode)` / stale 判定 | `skill_setup.go:570-596`、`paths.go:306-327` |
| `ReservedSkillPrefixes` / `ReservedSkillNames` 常量 | `skill_setup.go:199/205`、`paths.go:332-334` |
| `State` 读写(state.json,见 2.4) | 新增(《迁移计划》P0b-1) |
脚本侧不生成代码(sh/ps1/js 三语言片段生成成本高、措辞差异大),改为
**可解析标记块 + policy 比对**:每个脚本的清单包在
`# DWS-AGENT-HOMES-BEGIN` / `# DWS-AGENT-HOMES-END` 注释块内,新增
`scripts/policy/check-agent-homes-sync.sh` 提取 7 个脚本块与
`skillhome` 导出清单(`go run ./internal/skillhome/cmd/dump` 或
`dws skill setup --print-agent-homes` 之类调试出口)逐一比对,挂进
`make policy`(Makefile:86-97 现有政策链)。对应《迁移计划》P0c-2。
### 2.3 安装逻辑单引擎化:脚本退化为 bootstrap
**终态形态**:每个安装脚本只做两件事——装二进制、执行
`dws skill setup --mode <resolved> --yes`(mode 解析仍允许脚本做,因为它
要处理自己的 env/flag;也可以更进一步把 `DWS_SKILL_MODE` 透传给 setup)。
执行失败(二进制跑不起来、setup 非 0 退出)时,降级到**冻结的内嵌
fallback 拷贝逻辑一档**——即今天的拷贝实现原样保留但标记"不再演进",
语义变更只改 Go 引擎。
各面降级可行性分析:
| 面 | 二进制可得性 | 可行性 | 风险与对策 |
|---|---|---|---|
| install.sh | `main` 先 `install_binary` 后 `install_skills`(`install.sh:830-831`),二进制就在 `$INSTALL_DIR` | ✅ 直接调 `"$INSTALL_DIR/dws" skill setup --mode "$SKILL_MODE" --yes` | `DWS_SKILLS_ONLY=1` 时不装二进制 → 先 `command -v dws`,无则走 fallback。curl 下载不带 quarantine 属性,macOS 可直接执行。风险低 |
| install.ps1 | 同序(`Install-Binary`:337 → `Install-Skills`:622,main 入口 `:700`) | ✅ 调 `& $installDir\dws.exe skill setup ...` | ExecutionPolicy 约束的是 .ps1 脚本本身,**子进程 dws.exe 不受其限制**;AppLocker/WDAC 环境可能拦未签名二进制——但那种环境 dws 本身也跑不了,fallback 拷贝仍必要。风险低-中 |
| npm install.js | postinstall 已把平台二进制解到 `vendor/`(`install.js:260`)再装 skill(`:271-280`) | ✅ `execFileSync(path.join(vendorDir,'dws'), ['skill','setup','--mode',m,'--yes'])` | `npm i --ignore-scripts` 时 postinstall 整体不跑(现状如此,非新增风险);部分 CI 以 root 跑生命周期脚本权限怪异;Windows 用 `dws.exe`。风险中,fallback 必须保留 |
| install-skills.sh | skills-only 面,不装二进制 | ✅ `command -v dws` 有则调引擎,无则 fallback | 本质是"刷新 skill"路径,有 dws 才谈得上刷新。风险低 |
| install-event.sh | 装二进制 + 单 skill(dingtalk-event + mono dws) | ⚠️ 有条件 | `EVENT_VERSION` 可与已装二进制版本不同(尤其 `DWS_SKILLS_ONLY=1`),embed 与目标 zip 可能错配;且当前同时装 mono+multi 的语义(`:171-172`)需先按 1.2-4 收敛。**最后迁移**,迁移前先把 dingtalk-event 纳入 embed 并改成 `-s event` 调引擎 |
| install-devapp.sh(+ps1) | 同上(dingtalk-dev) | ⚠️ 有条件 | 同 install-event。风险中-高,最后迁移 |
| Homebrew caveats | 只打印提示 | ✅ 已是终态 | `homebrew.rb.tmpl:33-36` 现状就是"Run `dws skill setup`",无需改,是其他面的样板 |
配套收益:《迁移计划》P2-2(sh/ps1 的 multi 分支真装,1.5d)与 P2-3
(install.js / install-skills.sh 加 mode 支持,1d)在单引擎方案下**大幅
缩水**——脚本只需把 mode 透传给引擎,不再在 sh/ps1/js 里写安装逻辑。
### 2.4 状态与缓存
- **state.json 权威化**(《迁移计划》P0b 原样采纳):
`~/.dws/skills/state.json` 记录 `schema_version / mode / cli_version /
installed / agent_homes / previous`,写方只有 `dws skill setup` 与
`dws upgrade`(脚本侧不再各自写状态——它们调引擎,引擎记账);缺失/损坏
时按磁盘形态反推(有 `dws/` → mono;有 `dingtalk-*` → multi;都有 →
报 drift 要求显式收敛)。
- **`~/.dws/skills` 缓存收敛**:二选一,本文建议后者——
1. ~~被 state 取代,删除缓存~~:激进;embed 之外的"无源码机回退源"场景
(`skill_setup.go:442-447` 注释描述的场景)会断。
2. **明确为 setup 回退源 + 版本戳**(采纳):缓存目录写入
`cli_version` 戳文件(或并入 state.json 的 `cache_version` 字段);
**写缓存收敛到 Go 侧唯一实现**(setup/upgrade 共用),install.sh /
ps1 / js / event / devapp 的 6 份缓存写逻辑(1.1 矩阵)随 P1/P3 删除;
upgrade 补齐 mono 缓存刷新(修 `paths.go:292-296` 只刷 multi 的不对称)。
mono 下线版本再评估整体废弃缓存。
### 2.5 市场 skill 边界正式化
把"`dingtalk-*` 前缀与 `dws-shared` / `dws` 名称为 DWS 内置保留"从隐性约定
变成共享常量 + 测试:
- `skillhome.ReservedSkillPrefixes = ["dingtalk-"]`、
`skillhome.ReservedSkillNames = ["dws-shared", "dws"]`,替换
`skill_setup.go:199/205` 与 `paths.go:332-334` 两份私有拷贝;所有互斥/
stale 清理只认这两个常量。
- 测试一:扫描 `skills/multi/` 实际目录名,断言全部命中保留规则(防新增
产品 skill 破坏前缀约定)。
- 测试二:市场 skill 安装路径(`skill_command.go:488-498` 的目标解析)若
产物名命中保留名则拒绝安装——把"市场无同名前缀"从祈祷变成门禁。
## 3. 迁移步骤(渐进、不破坏存量用户)
| 阶段 | 内容 | 风险 |
|---|---|---|
| **P0 清单 + policy** | 建 `internal/skillhome`:AgentHomes(**补 opencode**,修 1.2-1)、布局/互斥/保留名常量;setup/upgrade 切到共享包;脚本清单包标记块;新增 `check-agent-homes-sync.sh` 进 `make policy`;删 install.sh 死代码(1.2-2) | 低。纯收敛不改行为;policy 脚本初期可能误报 → 先 warn-only 跑一个版本再转 hard-fail |
| **P1 setup 单引擎 + 脚本降级** | setup/upgrade 安装实现合一(互斥清理、stale 语义统一为"引擎内一种",修 1.2-3);install.sh / install.ps1 / install.js / install-skills.sh 改为 bootstrap + 冻结 fallback;install-event/devapp 暂不动 | 中。bootstrap 首版要在三语言 CI 矩阵上验证"引擎失败→fallback"链路;fallback 标记冻结,防止继续演进出第 9 份拷贝 |
| **P2 state 权威 + 缓存收敛** | state.json 读写进 skillhome,setup/upgrade 记账;缓存加版本戳、写方收敛到 Go、upgrade 补刷 mono 缓存;install-event/devapp 语义收敛(mono+multi 双装改为引擎语义)后同样 bootstrap 化 | 中。存量机器无 state → 磁盘反推逻辑必须有故障注入测试;event/devapp 版本错配场景需保留 zip 直装逃生门 |
| **P3 删除遗留拷贝** | 删除:脚本侧 6 份缓存写、install.sh/ps1/js 内被引擎接管的安装函数(保留冻结 fallback 一档)、Go 侧 `mutualExclusionVictims` 等被 skillhome 吸收的私有拷贝;观察一个版本后评估 fallback 是否可再降档 | 低-中。每删一处先确认 policy 与测试不再引用;fallback 删除是独立决策,不与本阶段捆绑 |
与《迁移计划》的先后关系:本文 P0 即计划 P0c,应**最先做**;P1 与计划
P0a/P0b 并行不冲突(引擎合一会让 P0a 的 mode-aware upgrade 少写一份代码);
P2 吸收计划 P0b;P3 在计划 P2 切默认之后执行。
## 4. 明确不做
- **不重写成 symlink canonical**(canonical 一份 + 各 agent home 软链):
已随生态分发通道一并评估否决,决策与实测原因见
《迁移计划》[§7.3](skill-multi-migration-plan.md)(无版本固定、依赖
Node/GitHub 可达、mono 会被一起发现、悟空 bundled/离线预装覆盖不了;
其中悟空分发线已于 2026-08-05 下线,该条约束随之消失)。
本地 symlink 模式(`setup --link`)同样维持计划 §8.2"可选/后置"定位,
不在本方案内。
- **不动 `dws-skills.zip` 产物布局**:zip 根 mono 副本 + `mono/` + `multi/`
双树保持不变(`scripts/release/post-goreleaser.sh:220-248`)。
- **不动市场 skill**:`dws skill install` 的安装语义、目标解析
(`skill_command.go:488-498`)不变;2.5 只新增保留名拒绝门禁与测试,
不改变既有安装行为。
- **不删 fallback**:脚本侧内嵌拷贝逻辑降级为冻结档保留,不追求"脚本零
拷贝"的纯净化。
## 5. 收益/成本与任务映射
### 5.1 收益/成本表
| 项 | 现状 | 目标 | 量化收益 |
|---|---|---|---|
| agent home 清单 | 15 份拷贝,已漂移(opencode) | 1 份 Go 源 + policy 比对 | 新增 agent 从"改 11 处"变"改 1 处" |
| 安装/互斥/stale 逻辑 | ≥8 份,4 种语言,语义已分叉 | 1 个 Go 引擎 | 语义变更从 4 语言 × 7 面(本次实证 13 文件 +759 行)变 1 包 |
| mode 解析 | 4 份,行为细节不一 | 脚本只做透传,引擎一处实现 | 非法值/默认值行为天然一致 |
| 缓存写 | 6 份,无版本戳,只写不读 | Go 唯一写方 + 版本戳 | 缓存漂移可观测、可判废 |
| 安装状态 | 无 | state.json 权威 | upgrade 粘性、回滚、drift 检测的前提 |
| 市场边界 | 隐性约定,2 份前缀拷贝 | 共享常量 + 2 个测试 | 互斥清理误伤市场 skill 的风险归零 |
| 成本 | — | P0≈1.5d,P1≈3-4d,P2≈2d,P3≈1d | 合计 7.5-8.5 人日;其中 P0/P2 与《迁移计划》P0b/P0c 重叠,净新增约 4-5 人日 |
### 5.2 与《迁移计划》§8 任务 ID 映射
| 本文阶段 | 对应计划任务 | 关系 |
|---|---|---|
| P0 清单 + policy | P0c-1(清单下沉共享包+补 opencode)、P0c-2(check-agent-homes-sync.sh) | 原样采纳,包名定为 `internal/skillhome` |
| P1 引擎合一 | P0a-1(UpgradeSkillLocations mode-aware)的前置简化 | 引擎合一后 P0a-1 不复写互斥/清理逻辑 |
| P1 脚本 bootstrap | **取代** P2-2(sh/ps1 multi 真装,1.5d)、P2-3(install.js/install-skills.sh mode 支持,1d) | 脚本不再写安装逻辑,两个任务缩水为"透传 mode + 调引擎 + 冻结 fallback",合计从 2.5d 降至 ~1d |
| P2 state 权威 | P0b-1(state.json schema+setup 写入)、P0b-2(磁盘反推) | 原样采纳;缓存版本戳与写方收敛为本文新增 |
| P2 event/devapp 收敛 | 新增(修 1.2-4 语义矛盾) | 依赖 P1 引擎稳定 |
| P3 删除遗留 | 新增 | 在计划 P2 切默认之后执行 |
| (不在本文) | P0-3 备份式安装、P0-4 请求头、P1-1 `dws skill mode`、P1-2/P2-1 默认翻转、P2-4 文案 | 仍按计划执行,与本文正交;备份式安装落地时直接写进引擎,天然覆盖所有面 |
+231
View File
@@ -0,0 +1,231 @@
# Skill 能力缺口分析与补全计划
> 基于 `feat/skill-mode-migration` 工作区代码级盘点(2026-08-05)。本工作区已把
> install / upgrade / setup 的默认形态翻转为 multi;本文回答两个问题:
> **管理能力上还缺什么**(Part 1)、**multi 内容是否覆盖 mono**(Part 2),
> 并给出优先级排序的实施清单(Part 3)。
> 任务 ID 复用 [skill-multi-migration-plan.md](skill-multi-migration-plan.md) §8.2
> (P0a/P0b/P0c/P0-3/P0-4/P1-1/P1-2/P2-x/W4);新增项以 `P1-3`、`C1–C7` 编号。
> 机制背景见 [skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
## 0. 结论速览
- **管理面**:默认翻转已落地(setup/四个安装脚本/upgrade 识 multi),但生命周期
能力仍缺 6/8 项:无状态(state.json)、无备份回滚、无卸载、无故障恢复、
upgrade 不按已装清单做增量、市场/内置边界靠前缀约定。当前形态 = "能装上
multi,但装成什么样、出了事怎么退,全靠运气"。
- **内容面**:multi 对产品参考的覆盖**总体是 mono 超集**(chat/event/dev/sheet
均更详细),但 mono 有 **4 块全局能力在 multi 完全缺失**(recovery 闭环、
确认门禁协议、Schema 渐进查询教学、LICENSE/NOTICE),另有 1 个脚本
(`report_inbox_today.py`)未迁移;multi 自身还有 6 项内部不一致(死链、
orphan 脚本、漏改 EXPERIMENTAL 文案等)。
- **工作量**:管理面 P0–P2 剩余 ≈ 10.5–13 人日;内容补全 C1–C7 ≈ 3.5–4 人日;
合计 ≈ 14–17 人日,与 plan §8.1 的 13–17 人日口径一致(内容项是新增量)。
---
## Part 1 — 管理能力缺口(skill 生命周期管理)
### 1.1 现状盘点(本工作区实际状态)
**`dws skill setup`(内置 skill 安装)**
| 能力 | 状态 | 锚点 |
|---|---|---|
| mode 选择 | mono / multi;无 `--mode` 时 TTY 交互(multi 为默认项)、非 TTY 默认 multi | `internal/app/skill_setup.go:343-376` |
| 按产品挑选 | `-s/--skill`、`-x/--exclude` 互斥;`dws-shared` 强制包含;未点名的已有 `dingtalk-*` 保留(additive) | `skill_setup.go:101-102`、`254-318`、`209-226`、`640-670` |
| `--dry-run` | 有,预览 mode/来源/目标/子 skill,不写文件(root 持久 flag 注入,`internal/app/flags.go:43`) | `skill_setup.go:156-167` |
| 源 | `--source` / `DWS_SKILL_SOURCE` 显式覆盖(失败不回退)→ 默认 embed(与二进制同版) | `internal/app/skill_setup_embed.go:50-58`、`skills_embed.go:28` |
| 目标 | 16 个 agent home,父目录门控;`--target all` 不含 opencode,但命名 target 含(不对称) | `skill_setup.go:20-37`、`509-522` vs `internal/app/skill_command.go:129` |
| 互斥清理 | 装 mono 删 `dingtalk-*`+`dws-shared`,装 multi 删 `dws/`;**best-effort,失败仅 warning 继续装** | `skill_setup.go:570-608` |
| 备份/记账 | **无**。`RemoveAll` 直删,不写任何状态 | `skill_setup.go:616`、`655` |
**`dws skill search / get / install`(市场 skill)**
- 子命令全集仅 `get / install / search / setup`(+ 隐藏的 find/add 兼容提示),
**无 status / mode / remove / rollback**(`skill_command.go:202-209`)。
- `install` 解压到 agent skills **根目录**(非 `dws/` 子目录),无 mode 概念、
无记账、无覆盖保护(`skill_command.go:378-443`、`653-672`)。
**`dws upgrade`(skill 刷新)**
- `LocateSkillsRoot` **优先 zip 内 `multi/`**(`internal/upgrade/paths.go`,
接线于 `internal/app/upgrade.go`);`UpgradeSkillLocations` **包驱动**:有
multi 树则始终 multi 刷新,否则 legacy mono 回退(不做磁盘粘性)。
- multi 路径:平铺刷新 `<agent>/dingtalk-*`+`dws-shared`,清 `dws/` 残留与过期
`dingtalk-*`,best-effort 刷 `~/.dws/skills/multi`(sibling `mono/` 存在时
亦刷 mono 缓存)。
- **有 multi 包时存量 mono 一次性迁 multi**(2026-08-05 owner:升级不做粘性;
非运行时 mode-switch 产品,无确认/备份/state)。
- skill 安装发生在**二进制替换之后**,skill 失败时二进制已换 → 半升级态。
`dws upgrade --rollback` 只回滚二进制。
**安装脚本(7 面)**
| 面 | 本工作区状态 |
|---|---|
| `scripts/install.sh` | 已默认 multi 且**真装**(`install_multi_skills_to_homes`),mono 为 opt-in;`rm -rf` 直删无备份 |
| `scripts/install.ps1` | 同上(Windows) |
| `scripts/install-skills.sh` | 已加 `DWS_SKILL_MODE`(默认 multi)+ multi 安装 |
| `build/npm/install.js` | 已加 `installMultiSkillsToHomes`(互斥清理 + 平铺) |
| Homebrew formula | 不铺 agent home,caveats 引导 `dws skill setup` |
| `scripts/install-event.sh` / `install-devapp.sh` | 单 skill 专项语义,缓存 `multi/dingtalk-event` / `multi/dingtalk-dev` 子集 |
| 共同缺口 | **均不写 state.json、均无备份**;agent home 清单仍是 sh/ps1/js/Go 多份手写(`paths.go:23-52` 的 keep-in-sync 注释约定,无门禁) |
### 1.2 缺口表(对照完整生命周期)
严重度:🔴 高(可致数据丢失/双份派发/半装态)|🟡 中(能力缺失但有绕行)|🟢 低(体验项)
| # | 生命周期项 | 现状 | 严重度 | 补全设计(复用 plan §8 ID) |
|---|---|---|---|---|
| 1 | **status 查询** | **无**。无 state.json、无 `dws skill mode/status`;判断 mode 只能人工看磁盘形态(`dws/` vs `dingtalk-*`) | 🔴 | **P0b-1** 新增 `~/.dws/skills/state.json`(schema_version/mode/cli_version/installed/agent_homes/previous),setup 与安装脚本写入;**P0b-2** 缺失/损坏时磁盘形态反推,双形态并存 → drift 报错;**P1-1** `dws skill mode`(status 子命令展示 mode/版本/已装列表/上次切换/备份) |
| 2 | **切换** | 有(`setup --mode`),但**无状态、无记账**:切完不留 previous,互斥清理失败仅 warning 继续装(`skill_setup.go:600-608`)→ 可能 mono+multi 双份共存、Agent 双份派发 | 🔴 | **P0b** 记账(含 `previous`);**P0-3** 把「清理失败继续装」改为「失败整体回滚」;**P1-1** `mode set <mode>` 复用 setup 安装实现 + 备份 + 记账,成功后提示重启 AI 工具 |
| 3 | **回滚** | **无备份**。setup/upgrade/安装脚本全部 `RemoveAll`/`rm -rf` 直删(`skill_setup.go:616,655`、`paths.go:181,249,307`、`install.js` `fs.rmSync`);`upgrade --rollback` 只回二进制 | 🔴 | **P0-3** 备份式安装:`RemoveAll` → `mv` 到 `~/.dws/skills/backup/<ts>-<mode>/`,保留最近 2 份,任一 home 失败自动恢复并非 0 退出;**P1-1** `mode rollback` 一条命令回到 `state.previous` |
| 4 | **版本对齐** | embed 天然同版(setup 默认源,`skill_setup_embed.go:50-58`)✓;但 `~/.dws/skills` 缓存**只写不读**(仅 legacy 回退候选,`skill_setup.go:445-447`),upgrade 只刷 multi 缓存不刷 mono(`paths.go:290-296`),漂移不可见 | 🟡 | **P0b-1** state.json 记 `cli_version` 使漂移可见;**P0a-1** upgrade 按 mode 同步刷新对应缓存(或评估废弃缓存,plan §6 风险表末行) |
| 5 | **卸载** | **无 remove**。skill 子命令仅 get/install/search/setup(`skill_command.go:202-209`);用户只能手动删目录,且不知道该删哪些(16 个 home × N 个 skill) | 🟡 | **新增 P1-3** `dws skill remove`:按 state.json 的 `agent_homes`×`installed` 精确删除内置 skill(`dingtalk-*`+`dws-shared`+`dws/`),不动市场 skill;`--dry-run` 预览(依赖 P0b) |
| 6 | **局部更新** | setup 侧 additive 语义完整(`-s/-x`,未点名保留);但 **upgrade 增量语义未按 `state.installed`**:`UpgradeSkillLocations` 用 zip 内 bundle 全集刷新(`paths.go:135-137`、`339-358`),用户 `-x` 排除过的 skill 会被升级装回来 | 🔴 | plan §8.3-1 已定死语义(以 `state.installed` 为准增量刷新、未装不补装);**P0b-1** 先落 `installed` 列表,**P0a-1** `UpgradeSkillLocations(dir, mode)` 按其过滤 |
| 7 | **故障恢复** | **无**。setup 清理/拷贝失败仅 warning 或按 home 跳过,留半装态;upgrade 的 skill 失败发生在二进制替换之后(`upgrade.go:593-611`),半升级态无自动恢复、无修复命令 | 🔴 | **P0-3** 备份式安装 + 失败整体回滚(改变「warning 继续装」语义,需同步改 `skill_setup_full_coverage_test.go` 多处断言,plan §8.3-2);**P0b-2** drift 检测给显式收敛指令;**P1-1** `mode rollback` |
| 8 | **市场 vs 内置边界** | **隐性前缀约定**:互斥清理按 `dingtalk-`/`dws-shared` 名称扫描(`paths.go:332-334`、`skill_setup.go:581`),无 SKILL.md frontmatter 校验 —— 市场 skill 若同名前缀会被误删;反向地,市场 `install` 解压无保护,可覆盖内置 `dingtalk-*`(`skill_command.go:653-672`) | 🟡 | plan §6 风险行:清理前校验目录内 SKILL.md frontmatter 属 DWS 产品集并写成测试(落入 **P0-3** 的清理改造);长期由 state.json 的 `installed` 清单取代前缀扫描(P0b 后续) |
**附:本工作区已完成项**(不再列入缺口):setup 默认 multi(P2-1)、
install.sh/ps1 真装 multi(P2-2)、install-skills.sh/install.js mode 支持
(P2-3)、README×2 与 dingtalk-skill 文案(P2-4 部分)、upgrade 识别并刷新
multi 包(P0a 的布局识别一半)。**尚未做**:P0c 清单收敛、P0b state.json、
P0-3 备份、P0a 的 mode-aware 一半、P0-4 请求头、P1-1 mode 命令、P1-2 beta 轨。
---
## Part 2 — 内容能力对等(mono vs multi)
### 2.1 树对比总览
| 维度 | mono | multi |
|---|---|---|
| 规模 | 152 文件 / ≈2.5 MB | 244 文件 / ≈3.3 MB(19 个 `dingtalk-*` + `dws-shared`) |
| 入口 | 单 `SKILL.md`(332 行,含全局路由/危险表/Schema 教学) | 每产品一个 `SKILL.md` + `dws-shared` 全局契约(90 行) |
| 全局参考 | `references/` 10 项(intent-guide、global-reference、url-patterns、error-codes、capability-limits、channel-login、field-rules、recovery-guide、best_practices/、products/) | `dws-shared/references/` 9 项 + 各产品 skill 自带 |
| 脚本 | `scripts/` 37 个 | 10 个 skill 带 `scripts/` 共 55 个 |
| 最佳实践 | `best_practices/` 01–11 + lite + `_common` | 按产品分发(01→chat … 11→minutes),`_common` 与 lite 入 `dws-shared` |
### 2.2 产品参考覆盖核对
逐产品比对结论:**multi 覆盖 mono 全部产品参考,且多数为超集**。
| mono 产品参考 | multi 对应物 | 结论 |
|---|---|---|
| aitable.md + aitable/(20)+ aitable-record-ops | `dingtalk-aitable`(同 20 子章节 + field-rules + 06-data-analytics) | ✅ 超集 |
| chat.md + chat-emoji-list | `dingtalk-chat`(+ chat/ 5 个子章节) | ✅ 超集 |
| calendar / contact / doc+doc/ / drive / mail / minutes / todo / wiki / aisearch / hrbrain / pat / markdown | 同名 `dingtalk-*` skill | ✅ 覆盖(doc 侧 mono 的 doc-file-ops/doc-list/doc-permission/doc-search 四篇已迁移为 `dingtalk-doc/references/doc.md:202-207` 的 drive/wiki 迁移表,属刻意重构) |
| event.md(222 行) | `dingtalk-event`(event-im.md 399 行 + 完整订阅治理契约) | ✅ 超集 |
| dev.md(212 行) | `dingtalk-dev`(12 篇 reference 共 584 行) | ✅ 超集(结构重组) |
| oa / attendance / report / sheet / ding / devdoc / agoal | `dingtalk-misc` 对应 reference(sheet 多 3 篇:comment/formula/version) | ✅ 超集 |
| simple.md(devdoc+oa 合集 + 意图判断 + 上下文传递表) | 已拆入 `dingtalk-misc/references/oa.md:309,425` 与 `devdoc.md:15,19` | ✅ 覆盖 |
| 多组织/多账号(SKILL.md:64-70 一节) | `dingtalk-profile` 整个 skill | ✅ 超集 |
| 意图决策树(SKILL.md:101-123) | `dws-shared/references/intent-guide.md`(536 行 ≥ mono 488 行)+ `routing.md` | ✅ 覆盖 |
| best_practices 01–11 / lite / _common | 按产品分发 + `dws-shared/references/best_practices/_common/` | ✅ 覆盖 |
| url-patterns / capability-limits / channel-login / error-codes | `dws-shared/references/` 同名 | ✅ 覆盖 |
| field-rules.md(mono 全局位) | `dingtalk-aitable/references/field-rules.md` | ✅ 合理下沉(内容即 AI 表格字段规则) |
### 2.3 不对等项清单(mono 有、multi 无)
| # | 缺失项 | mono 锚点 | multi 现状证据 | 严重度 | 补齐方式(承载方) |
|---|---|---|---|---|---|
| M1 | **Recovery 闭环**(recovery-guide.md + global-reference §Recovery + 错误处理第 2 步) | `skills/mono/SKILL.md:296,311`、`references/recovery-guide.md`、`global-reference.md:62` | multi 全树 `RECOVERY_EVENT_ID` 零引用;`dws-shared/SKILL.md:83-89` 错误最短路径无 recovery;`dingtalk-dev/SKILL.md:124` 指向「root dws / dws-shared 的错误处理」→ **断链** | 🔴 | **dws-shared**:新增 `references/recovery-guide.md`(从 mono 移植)、SKILL.md 错误最短路径加 recovery 步、`global-reference.md` 补 Recovery 节(→ C1) |
| M2 | **确认门禁协议 + 全局危险操作表** | `skills/mono/SKILL.md:137-187`(危险操作表 + 确认流程 + `confirmation_required` 识别与重试协议) | multi 仅 aitable/doc/misc 三个 SKILL.md 有产品级危险表;`confirmation_required` / 「确认门禁」全树零命中;`dws-shared/SKILL.md:37-38` 只有一行泛化规则 | 🔴 | **dws-shared**:全局确认门禁协议(识别 `confirmation_required`、原始命令追加 `--yes` 重试、`--dry-run` 预览、禁止管道喂答案)+ 危险操作索引指向各产品表(→ C2) |
| M3 | **Schema 渐进查询教学** | `skills/mono/SKILL.md:198-292`(≈95 行:四层查询、`--compact`/`--all` 边界、字段速查、Schema/Help/业务数据边界表、漂移处理) | 各产品 SKILL.md 仅点状提及 leaf Schema(17 处);`dws-shared/references/global-reference.md:79-89`「命令自省」只有 `--help` | 🟡 | **dws-shared**:新增 Schema 渐进查询章节(改写为 multi 语境,链入渐进加载表)(→ C3) |
| M4 | **scripts/report_inbox_today.py** | `skills/mono/scripts/report_inbox_today.py` | multi 无(misc 仅有 `report_received_today.py`);mono 文档也未引用它 | 🟢 | **dingtalk-misc**:先验证脚本仍可用 → 迁入 `scripts/` 并在 `report.md` 引用;不可用则连同 mono 侧一起删(→ C5) |
| M5 | **LICENSE / NOTICE** | `skills/mono/LICENSE`、`NOTICE` | multi 20 个 skill 均无 | 🟡 | 每个 multi skill 根复制两份(或 release 打包期注入,`scripts/release/post-goreleaser.sh`)(→ C7) |
| M6 | **aiapp 意图路由** | `skills/mono/SKILL.md:76,101`(产品表 + 决策树有 aiapp 行,但目标 `aiapp.md` 不存在 —— mono 自身死链) | multi 全树无 aiapp 路由/文档;仅 orphan 脚本 `dingtalk-misc/scripts/aiapp_create_and_poll.py` | 🟡 | 决策:**dws-shared/routing.md** + **dingtalk-misc** 产品索引补 aiapp 行并新建 reference,或明确下线该能力并清掉 mono 死链与 orphan 脚本(→ C5) |
### 2.4 multi 自身不一致项(不阻塞对等结论,但阻塞「multi 可独当一面」)
| # | 问题 | 证据 | 处理 |
|---|---|---|---|
| X1 | 16 个 orphan 脚本无任何文档引用(yida×13、finance×2、aiapp×1);`dws-shared/references/routing.md` 把「宜搭」路由到 dingtalk-misc,但 misc 产品索引无 yida 行、无 yida reference | `skills/multi/dingtalk-misc/scripts/`;`dingtalk-misc/SKILL.md:20-32` | 补文档(misc 产品索引 + reference)或移出发布包(→ C5) |
| X2 | 死链:`dingtalk-chat/SKILL.md:131` 引用 `scripts/extract_media_id.py`,文件不存在(mono 也无) | 同上 | 补脚本或删引用(→ C4) |
| X3 | 死链:`dws-shared/references/routing.md:22` 指向 `dingtalk-misc/references/markdown.md`,实际在 `dingtalk-markdown` | 同上 | 改指 `../../dingtalk-markdown/SKILL.md`(→ C4) |
| X4 | `dingtalk-event/SKILL.md` 缺 `dws-shared` PREREQUISITE 与 `metadata:` 块(其余 19 个 skill 均有) | `skills/multi/dingtalk-event/SKILL.md:1-4` | 补齐(→ C4) |
| X5 | 4 个 skill 仍是 🧪 EXPERIMENTAL + 「生产优先 mono」文案,与本工作区已翻转的默认矛盾(dingtalk-skill 已改,这 4 个漏改) | `dingtalk-profile/SKILL.md:15`、`dingtalk-hrbrain:15`、`dingtalk-markdown:15`、`dingtalk-pat:15` | 统一下调文案(→ C4,即 plan P2-4 剩余量) |
| X6 | `<!-- SAFETY_PREAMBLE_INJECT -->` 标记存在于 5 个 SKILL.md,但仓库内无注入器 | `dingtalk-{pat,hrbrain,markdown,skill,profile}/SKILL.md` | 明确注入方(仓外流程则写注释)或移除标记(→ C4) |
### 2.5 测试与政策门覆盖
| 门面 | 覆盖 | 缺口 |
|---|---|---|
| `test/skill_tests.md` 覆盖表(40-54 行) | 13 产品 ≈256 用例 | 12/13 行引用 **mono 路径**(`references/products/...`),仅 dev 指 multi;`workbench` 行指向不存在的 `workbench.md`(54 行,mono/multi 均无);`devdoc` 行指 `simple.md`(47 行,multi 无此文件);未覆盖 doc/drive/mail/minutes/oa/sheet/wiki/aisearch/hrbrain/markdown/pat/profile/skill。multi 默认后需按 multi 路径重写并补产品(→ C6) |
| `scripts/policy/check-skill-context-budget.sh` | 锁 `dingtalk-chat/SKILL.md` ≤14000B + shortcut 区块不膨胀 + mono SKILL.md 不含「充分阅读产品参考文件」回归(9-38 行);`gen_skill_shortcut_sections.py --check` 同时写 mono 与 multi 的 shortcut 区块 | mono 下线判据(plan §5-4)要求先替代对 `skills/mono/SKILL.md` 的依赖 |
| `make skill-command-integrity`(`check-skill-commands.sh` → `test/skill_static/skill_static_test.go:119-133`) | 静态校验 mono+multi 两树文档中的命令与 flag | 覆盖 OK;X2/X3 类 reference 死链不在其校验面 |
**Part 2 结论**:multi **没有**覆盖 mono 的全部能力 —— 产品参考层面是超集,
但全局能力缺 M1–M3 三块(recovery / 确认门禁 / Schema 教学),加 M4–M6 三个
小项;另有 X1–X6 六项 multi 内部不一致。补齐全部落在 `dws-shared`(M1/M2/M3)、
`dingtalk-misc`(M4/M6/X1)、各产品 skill(X2/X4/X5)与打包流程(M5)。
---
## Part 3 — 优先级排序实施清单
> 估时以 1 名熟悉本仓库的工程师计(人日),口径同 plan §8。
> 「状态」列:✅ 本工作区已完成|🚧 部分完成|⬜ 未做。
### 3.1 P0 — 先堵会丢数据/双份派发的洞(≈6.5–8.5d)
| ID | 状态 | 任务 | 文件级改动点 | 估时 | 依赖 |
|---|---|---|---|---|---|
| P0c-1 | ⬜ | agent home 清单下沉共享包,补 opencode 不对称 | 新 `internal/skillhome`(或 `internal/upgrade` 导出):合并 `paths.go:35-52` `knownSkillDirs` + `skill_setup.go:20-37` `skillSetupAgentHomes` + `skill_command.go:119-141` `agentSkillPaths`;setup/upgrade/测试共用 | 0.5d | — |
| P0c-2 | ⬜ | 清单同步门禁 | 新 `scripts/policy/check-agent-homes-sync.sh`(进 `make policy`);install.sh:ps1:install.js:install-skills.sh 清单改可解析块 | 1d | P0c-1 |
| P0b-1 | ❌ CANCELLED | state.json schema + 写入 | 2026-08-05:无运行时切换,不写 state.json | — | — |
| P0b-2 | ❌ CANCELLED | 磁盘形态反推作 state 兜底 | 无 sticky / 无 state;upgrade 按包刷 multi | — | — |
| P0-3 | ❌ CANCELLED | 备份式安装 + 失败整体回滚 | 2026-08-05:随切换产品线取消 | — | — |
| P0a-1 | ✅ | upgrade 包驱动 multi | `UpgradeSkillLocations`:有 multi→始终 multi(含 mono 盘一次性迁移);legacy 无 multi→mono;不读 state | — | — |
| P0a-2 | ✅ | force-multi 集成 / E2E | `paths_multi_test.go`(`MonoDiskMigratesToMulti` 等)+ `upgrade_skill_multi_e2e_test.go` | — | — |
| P0-4 | ❌ CANCELLED | `x-dws-skill-mode` 请求头 | owner 决策移除 | — | — |
### 3.2 P1 — 生命周期命令 + 内容补全(≈7–7.5d,两条线可并行)
**管理命令线**
| ID | 状态 | 任务 | 文件级改动点 | 估时 | 依赖 |
|---|---|---|---|---|---|
| P1-1 | ❌ CANCELLED | `dws skill mode` status/set/rollback/--dry-run | 2026-08-05:无运行时模式切换产品 | — | — |
| P1-3 | ⬜ | `dws skill remove`(新增,缺口 #5) | 按磁盘/约定前缀精确删内置 skill(**不**依赖已取消的 state.json);`--dry-run` 预览 | 1d | — |
**内容补全线**(对应 Part 2 编号)
| ID | 任务 | 文件级改动点 | 估时 | 依赖 |
|---|---|---|---|---|
| C1 | recovery 闭环进 multi(M1) | 新 `skills/multi/dws-shared/references/recovery-guide.md`;`dws-shared/SKILL.md:83-89` 错误最短路径加 recovery 步;`dws-shared/references/global-reference.md` 补 Recovery 节;`dingtalk-dev/SKILL.md:124` 断链改指 | 0.5d | — |
| C2 | 确认门禁协议 + 危险操作索引(M2) | `dws-shared/SKILL.md` 增「确认门禁」节(移植 mono `SKILL.md:170-187` 协议)+ 危险操作索引表指向各产品 SKILL.md | 0.5d | — |
| C3 | Schema 渐进查询教学(M3) | `dws-shared/SKILL.md` 渐进加载表加一行 + 新 `references/schema-usage.md`(改写 mono `SKILL.md:198-292`) | 0.5d | — |
| C4 | multi 一致性修复(X2/X3/X4/X5/X6 = plan P2-4 剩余量) | `dws-shared/references/routing.md:22` 改指 dingtalk-markdown;`dingtalk-chat/SKILL.md:131` 死链处置;`dingtalk-event/SKILL.md` 补 PREREQUISITE+metadata;`dingtalk-{profile,hrbrain,markdown,pat}/SKILL.md:15` EXPERIMENTAL 文案下调;SAFETY_PREAMBLE_INJECT 标记处置 | 0.5d | — |
| C5 | orphan 脚本与缺失产品处置(X1/M4/M6) | `dingtalk-misc/SKILL.md:20-32` 产品索引补 yida/finance/aiapp 行 + 新 reference(或把 16 个脚本移出发布包);`report_inbox_today.py` 验证后迁入或删除;aiapp 路由决策落 `dws-shared/references/routing.md` | 0.5–1d | — |
| C6 | skill_tests.md multi 化 + 扩产品 | `test/skill_tests.md:40-54` 覆盖表改 multi 路径;修 workbench(54 行)/devdoc(47 行)死链;补 doc/drive/mail/minutes/oa/sheet/wiki 用例 | 1d | C1–C5(路径稳定后) |
| C7 | LICENSE/NOTICE 进 multi(M5) | 20 个 `skills/multi/*/LICENSE|NOTICE`(或 `scripts/release/post-goreleaser.sh` 打包期注入) | 0.25d | — |
### 3.3 P2 / W4 — 收尾(≈1d)
| ID | 状态 | 任务 | 估时 | 依赖 |
|---|---|---|---|---|
| P1-2 | ⬜ | beta 轨默认切 multi(版本门控;本工作区已全量切,可选择保留全量或回退为 beta 先行) | 0.5–1d | P1-1 |
| P2-1 ~ P2-3 | ✅ | setup / install.sh / install.ps1 / install-skills.sh / install.js 默认 multi | — | 已完成 |
| P2-4 | 🚧 | 文案翻转:README×2、dingtalk-skill 已改 ✅;4 个 EXPERIMENTAL 漏改 → 并入 C4 | — | — |
| W4 | ⬜ | mono deprecation 警告(不删代码)+ mono 下线判据第 4 条(替代 `check-skill-context-budget.sh:10,34-38` 对 mono 的依赖) | 0.5d | P2、C6 |
### 3.4 依赖图与排期建议
```text
P0c-1 ─► P0c-2
P0b-1 ─► P0b-2 ─┐
P0-3 ───────────┼─► P0a-1 ─► P0a-2 ─► P1-1 ─► P1-3
P0b-1 ──────────┴─► P0-4
C1…C5(互相独立,可与 P0 并行)─► C6
```
- **W1**:P0c-1 → P0b-1/P0b-2 → P0-3 → P0a-1/P0a-2(管理面止血);并行 C1–C4(内容高危项)。
- **W2**:P0-4、P1-1、P1-3;并行 C5、C7。
- **W3**:C6(内容面收口)+ P1-2 beta 决策;`make policy` 全绿。
- **W4**:mono deprecation + 观察(plan §4 原节奏不变)。
合计:P0 ≈ 6.5–8.5d + P1 管理 ≈ 3–3.5d + 内容 C1–C7 ≈ 3.5–4d + P2/W4 ≈ 1d
= **14–17 人日**。砍法同 plan §8.4:内容线可砍 C3/C7(教学与法律文件可后置),
管理线不可砍 P0b/P0-3/P0a(缺了就是现在这副「能装不能管」的样子)。
-105
View File
@@ -1,105 +0,0 @@
# DWS Skill 内容框架合同
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
## 1. 两棵内容树
| 树 | 路径 | 角色 |
|---|---|---|
| **mono**(单 skill) | `skills/mono/` | 单一 `SKILL.md` 入口 + `references/products/*` 产品面 + 全局协议 |
| **multi**(多 skill) | `skills/multi/` | 平铺 `dingtalk-*` 产品 skill + 必选 `dingtalk-shared` |
Agent / 安装面选哪棵树由**行为分支**决定;本文件只规定树内合同。
## 2. Multi 目录合同(如何新增一个产品 skill)
新建 `skills/multi/<name>/` 时必须满足:
1. **命名**
- 产品 skill:`dingtalk-<product>`(小写、连字符)
- 共享 skill:仅允许 `dingtalk-shared`
2. **根文件**
- 必有 `SKILL.md`(YAML frontmatter + 正文)
- `references/` 推荐;无 reference 的 skill(如极简 profile)须在质检 omit 表登记
- `scripts/` 可选;脚本须被本 skill 树内某 `.md` 引用,或进入 orphan allowlist
3. **Frontmatter 最小集**(产品 / shared)
- `name`:与目录名一致
- `description`:非空,含触发意图与边界
- `metadata.category`:`product` 或 `shared`(允许历史写法把 `cli_version` 放在 frontmatter 顶层)
- `metadata.requires.bins`:含 `dws`
4. **契约块**
- 产品 skill 推荐内嵌 `<!-- DWS_RUNTIME_CONTRACT_START -->…END -->` **或** 明确 PREREQUISITE 指向 `dingtalk-shared`
- `dingtalk-shared` 承载跨产品路由与全局协议落点
5. **与 mono 映射**
- 每个 mono `references/products/<stem>`(文件或目录)必须在
`skills/content-qa/mono-multi-coverage.yaml` 有 `coverage` 或 `omit_coverage` 行
### 2.1 推荐骨架
```text
skills/multi/dingtalk-example/
├── SKILL.md
├── references/
│ ├── example.md # 主产品面
│ └── … # 子章节 / 意图表
└── scripts/ # 可选;须被 md 引用
└── example_helper.py
```
## 3. Mono 目录合同(质检对照基准)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/ # 覆盖质检主源
│ ├── error-codes.md # 全局协议示例
│ ├── error-codes.md
│ └── …
└── scripts/
```
- `references/products/` 下每个顶层 stem(`.md` 去后缀或子目录名)计入覆盖索引。
- 同 stem 的 `.md` + 子目录视为同一产品面(如 `doc.md` + `doc/`)。
## 4. 共享内容(`dingtalk-shared`)
| 职责 | 落点 |
|---|---|
| 跨产品路由 / 工作流 | `references/routing.md`、`workflow-routing.md`、`intent-guide.md` |
| 运行时最小契约长文 | `references/runtime-contract.md`(受 context-budget 约束) |
| 全局协议(确认门禁 / Schema 教学等) | `references/`;见质检基线 |
| 与 mono 全局文同名迁移 | `error-codes`、`url-patterns`、`capability-limits`、`channel-login`、`global-reference`、`recipes/`(`conventions.md`、`meta.md`、`lite-catalog.md`) |
产品专属规则(如 AI 表格 `field-rules`)允许下沉到对应 `dingtalk-*`,须在覆盖表注明。
## 5. Zip 内容布局合同(形状,非安装默认)
发布物 `dws-skills.zip`(及 embed 同源)内容树形状:
| Zip 路径 | 含义 |
|---|---|
| `<root>/` | mono 内容副本(兼容旧面) |
| `<root>/mono/` | 与 `skills/mono/` 同构 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言源树形状;**不**断言安装器默认解压哪棵。
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|---|---|---|
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
## 7. 变更流程
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
+146
View File
@@ -0,0 +1,146 @@
# Skill 分发与消费机制调研(含 lark-cli 对标)
> 配套方案:[skill-multi-migration-plan.md](skill-multi-migration-plan.md)
> 调研日期:2026-08-04,基于 `feat/skill-mode-migration` 工作区代码级梳理 +
> lark-cli(larksuite/cli@main)公开仓库调研。
## 1. DWS 分发链路(源树 → 制品 → 渠道)
```
skills/mono/ ─┬─ go build ──► embed.FS(skills_embed.go:28)
skills/multi/ ┘ │ dws skill setup 默认源
│
└─ post-goreleaser.sh:220-248 ──► dws-skills.zip
布局:zip 根 = mono 副本(向后兼容)+ mono/ + multi/
│
┌───────┬───────┼────────┬─────────┬──────────┐
GitHub Gitee OSS npm Homebrew 专项脚本
Release (镜像) (只发 tarball (cellar install-event/
│ │ 不读) │ 不铺agent) install-devapp
│ │ │
install.sh/ps1 postinstall
install-skills.sh install.js
│ │
├─► 各 agent home ◄────┤ 永远 mono:<agent>/dws/
└─► ~/.dws/skills/{mono,multi} 缓存
```
渠道清单(7 个安装/分发面):
| 面 | skill 行为 | mode 概念 |
|---|---|---|
| `scripts/install.sh` | 装 mono 到 agent homes + 双缓存;选 multi **只打印提示、连缓存都跳过** | 有(半残) |
| `scripts/install.ps1` | 同 install.sh(Windows) | 有(半残) |
| `scripts/install-skills.sh` | 只装 mono,缓存 mono+multi | 无 |
| `build/npm/install.js` | 永远 mono;缓存 mono+multi | 无 |
| Homebrew formula | 整包 zip 进 cellar,不铺 agent、不写缓存,caveats 提示手动 setup | 无 |
| `scripts/install-event.sh` | 装 mono + 缓存 `multi/dingtalk-event`;多 `.config/opencode/skills` | 无 |
| `scripts/install-devapp.sh` | 缓存 `multi/dingtalk-dev` | 无 |
产物事实:`dws-skills.zip` 已含 mono/multi 双树,**切 multi 不需要改 release 产物**。
## 2. DWS 消费链路(源 → agent 目录)
### 2.1 `dws skill setup`(`internal/app/skill_setup.go`)
- 源优先级:`--source` / `DWS_SKILL_SOURCE`(失败不回退)→ **embed 默认**
(`skill_setup_embed.go:50-58`);legacy 候选(exe 旁/cwd/`~/.dws/skills`)
仅在绕过 wrapper 直连时才走。
- 目标:`skillSetupAgentHomes` 16 个 agent home,父目录门控(i=0 `.agents`
无条件,其余需 `~/.claude` 这类父目录存在)。`--target all` **不含
opencode**,但 `agentSkillPaths` 命名 target 含(不对称,测试只锁单向)。
- 布局:mono → `<agent-home>/dws/`;multi → `<agent-home>/` 平铺兄弟目录。
- 互斥清理:装 mono 删 `dingtalk-*`、装 multi 删 `dws/`;**best-effort,
失败仅 warning 继续装**(`cleanupMutualExclusion:611-620`),无备份。
- multi 过滤:`-s/--skill` 与 `-x/--exclude` 互斥;`dws-shared` 强制包含;
未点名的已有 `dingtalk-*` 保留(additive)。
### 2.2 `dws upgrade`(`internal/upgrade/paths.go`)
- `LocateSkillMD` 命中 zip 根 mono → `UpgradeSkillLocations` 只写
`<agent>/dws/`:**不识 multi、不清理 `dingtalk-*`、不更新 `~/.dws/skills`**。
- 后果:multi 用户升级后 **mono + multi 共存**,Agent 双份派发。
- `--rollback` 只回滚二进制,不回滚 skill。
### 2.3 市场 skill(`dws skill install`)
解压到 agent skills **根目录**(非 `dws/` 子目录),无 mode 概念;互斥清理按
`dingtalk-*` 前缀扫描,依赖"市场无同名前缀"隐性约定。
### 2.4 状态与缓存
- **无任何已安装模式状态**:`~/.dws/` 有 auth/backups/cache,无 install
manifest;判断 mode 只能看磁盘形态。
- `~/.dws/skills` 缓存事实**只写不读**(默认 setup 走 embed),upgrade 不更新,
版本漂移不可见。
## 3. lark-cli 分发机制(larksuite/cli@main)
npm 包 `@larksuite/cli` 是薄壳(`files` 仅 install.js / install-wizard.js /
run.js / checksums.txt):
- **二进制**:postinstall 按平台从 GitHub Releases 下载,SHA256 校验
(checksums.txt 随 npm 包发);镜像链 GitHub → 用户 registry 派生镜像 →
npmmirror 兜底;host allowlist + checksum 双保险;`run.js` 缺二进制自动补下,
Windows 有 `.old` 崩溃恢复。
- **Skills**:不进 npm 包、不进二进制。repo 根 `skills/` 即事实源,由生态
安装器 `npx skills add larksuite/cli -y -g`(vercel-labs/skills)安装;
wizard 首选 `https://open.feishu.cn` 直链,GitHub shorthand 兜底。
- **一键向导** `npx @larksuite/cli@latest install`:run.js 拦截 `install` →
install-wizard.js 串联 4 步(npm 全局装/升级 → skills → config init →
auth login),每步幂等(`skills ls -g` 检测 `lark-*` 已装则跳过);
非 TTY 降级为"装完打印后续命令"。
生态安装器 `skills` CLI 提供的能力:
- 76 个 agent 目录清单生态维护,自动探测;project/global 双 scope;
- **symlink canonical(推荐)或 copy**;symlink 下升级 = 更新一处;
- `list / find / update / remove` 全生命周期;**skill 升级由
`npx skills update` 承担,lark-cli 自己不写 skill 刷新逻辑**;
- 发现约定兼容 catalog 布局 `skills/<catalog>/<name>/SKILL.md`。
## 4. 对标:DWS vs lark-cli
| 维度 | lark-cli | DWS 现状 |
|---|---|---|
| 分发单元 | repo `skills/`(源码即事实源) | zip + embed + 5 处拷贝缓存 |
| 安装器 | 1 个(生态工具) | 7 个自维护脚本,已漂移 |
| 落盘 | symlink 单点更新 | 全量 copy,升级重写 16 home |
| skill 升级 | `npx skills update`(生态承担) | `dws upgrade` 自写,不识 multi |
| mono/multi | 不存在此问题(天生多 skill) | 互斥/切换/状态全自建 |
| npm 包 | 薄壳运行时下载 | 全平台 archive + zip 全打进 tarball |
| 大陆镜像 | registry 派生 + npmmirror | Gitee fallback + OSS 只发不读 |
## 5. "分发外包给生态,自己只维护源码目录"是什么
职责切分:skill 的分发/安装/升级/卸载交给生态标准化工具(`npx skills`,
"agent skill 界的 npm"),DWS 只保证 repo 里 `skills/` 符合 agentskills.io
规范。它消掉的正是 DWS 现在自维护的四块问题:
1. **N 份事实源**(zip 三拷贝 + embed + 缓存 + 16 home)→ 只剩 repo 目录一份;
2. **7 个自写安装器** → 零个,agent 清单别人维护(新 agent 自动支持);
3. **自建升级/切换/回滚**(UpgradeSkillLocations、互斥清理、方案中的
state.json/备份/`dws skill mode`)→ symlink 模式更新 canonical 一处;
4. **lark-cli 因此根本没有 mono/multi 之争**,也没有 P0 要修的那些 bug。
### 代价与前提(不是免费午餐)
| 风险 | 说明 | 对策 |
|---|---|---|
| 版本错配 | DWS skill 从 Cobra 树生成,与 CLI 版本强耦合;embed 天然同版,生态安装从主分支拉可能错配。**已实测**:`skills add`(v1.5.21)无 tag/ref 版本固定参数,`@` 后接的是 skill 名而非 git ref;唯一的可复现机制是 project 级 `skills-lock.json`(experimental_install) | 短期:发布说明引导"skill 随 CLI 升级(`skills update`)";中期:release 时推一个 `release/vX.Y.Z` 镜像分支或专用 skills 镜像仓供按版本安装;或接受错配(skill 内容为文档,错配成本=提到不存在的命令) |
| 网络可达 | 依赖 npx + GitHub;大陆/内网现靠 Gitee fallback | `skills` CLI 支持任意 git URL,Gitee 镜像仓兜底,链路需验证 |
| 离线/打包场景 | 悟空 bundled-skills、企业预装镜像生态通道覆盖不了(2026-08-05 注:悟空分发线已于当日下线,「悟空 bundled-skills」一项不再适用;企业预装镜像约束仍在) | 自维护打包保留一条 |
| 生态工具策略漂移 | 清单/发现约定/默认值被动跟随 | 作为增量通道而非唯一通道,保留 embed 兜底 |
## 6. 结论
- **生态分发通道已否决**(2026-08-04):无版本固定(`skills add` 不支持
tag/ref,实测 v1.5.21)、依赖 Node + GitHub 可达、mono 会被一起发现、
悟空/离线场景覆盖不了。分发维持全自维护。(2026-08-05 注:悟空分发线
已于当日下线,「悟空场景覆盖不了」一条随之失效;其余否决理由与结论
不变。)
- 一个月内:按方案 P0–P2 修自维护通道并切 multi 默认(zip + embed +
安装脚本,产物布局不变)。
- 终态即"自维护通道修好之后"的形态,不再向 lark-cli 的生态外包形态收敛。
- 调研保留备查:`npx -y skills add . --list` 实测可发现全部 21 个 skill
(若未来生态工具补齐版本固定能力,可重新评估本决策)。
-76
View File
@@ -1,76 +0,0 @@
# Mono↔Multi Skill 内容质检规格
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
## 1. 质检矩阵
| ID | 类型 | 输入 | 通过准则 |
|---|---|---|---|
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 内容一致(允许合同声明的布局链接替换);全局协议存在或 ∈ `omit_global` |
| **G5 链接** | 可达性 | 合同覆盖的 paired Markdown | 内联相对链接目标文件或目录存在且不逃出仓库;外链、纯锚点和锚点内容不在检查范围 |
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
## 2. 有意省略 / 延期登记格式
YAML(见 coverage 文件):
```yaml
omit_coverage:
- mono: simple
disposition: covered_by # covered_by | defer | wontfix
via: dingtalk-misc # optional
reason: "拆入 oa/devdoc…"
omit_global:
- id: field-rules-global
mono_path: references/field-rules.md
expected_multi: dingtalk-aitable/references/field-rules.md
disposition: covered_by
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
orphan_scripts_allowlist:
- path: dingtalk-misc/scripts/report_received_today.py
disposition: defer
reason: "pending report.md reference"
paired_files:
- mono: references/products/sheet.md
multi: dingtalk-misc/references/sheet.md
mode: link-normalized
link_substitutions:
- mono: "../url-patterns.md"
multi: "../../dingtalk-shared/references/url-patterns.md"
- mono: "../intent-guide.md"
multi: "sheet-intent-guide.md"
```
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
## 3. 缺口基线(相对 mono)
| ID | 项 | disposition | 说明 |
|---|---|---|---|
| M1 | recovery-guide / RECOVERY_EVENT_ID 闭环 | **removed** | 已从 mono/multi skill 文档删除;不做移植 |
| M2 | confirmation_required 全局协议 | **done** | `dingtalk-shared/references/confirmation.md` + SKILL 导航 |
| M3 | Schema 渐进查询教学 | **done** | `dingtalk-shared/references/schema-usage.md` |
| M4 | `report_inbox_today.py` | `defer` / orphan 侧 | 验证后迁 misc 或删 |
| M5 | multi LICENSE/NOTICE | `defer` | 内容或打包注入 |
| M6 | aiapp 路由 vs orphan 脚本 | **done(标明未产品化)** | mono 死链移除;`unsupported-scripts.md` |
| X1 | yida/finance/aiapp orphan scripts | **done(登记)** | 由 unsupported-scripts 具名引用 |
| X2 | chat 死链 `extract_media_id.py` | n/a | 现仅为反模式提及 |
| X3 | routing → markdown 错路径 | **done** | 已指 `dingtalk-misc/references/markdown.md`;drive 尾链已修 |
| X4 | event 缺 metadata | **done** | |
| X5 | multi skill 横幅 /「优先 mono」文案 | **done** | 横幅已全部移除 |
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
## 4. 与悟空
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
+307
View File
@@ -0,0 +1,307 @@
# Skill 多 skill(multi)切换方案
> 目标:一个月内把 DWS 的 Agent skill 默认安装形态从 mono(单 skill)切换为
> multi(按产品拆分),两套并行期后下掉 mono。
> 本文基于对分发/消费链路的代码级梳理(2026-08-04),所有锚点均可跳转验证。
> 机制调研与 lark-cli 对标细节:[skill-distribution-mechanism.md](skill-distribution-mechanism.md)
## 1. 目标与约束
| 项 | 内容 |
|---|---|
| 终态 | 新装/升级默认铺 multi(`<agent-home>/dingtalk-*/`、`dws-shared/`);mono 仅 opt-in;最终物理删除 mono |
| 并行期 | 约一个月,mono 保留可切换、可回退 |
| 硬约束 | DWS 无服务端灰度/远程配置;skill 是本地文件分发;安装面至少 7 个且已有漂移 |
| 前置原则 | 先修已存在的 upgrade×multi 双份 bug,再谈切默认 |
## 2. 现状关键事实(梳理结论)
分发侧:
- `dws-skills.zip` 布局:zip 根 = mono 副本(向后兼容)+ `mono/` + `multi/`
(`scripts/release/post-goreleaser.sh:220-248`)。**产物无需改动**。
- 二进制 `//go:embed all:skills/mono all:skills/multi`(`skills_embed.go:28`),
`dws skill setup` 默认源就是 embed,**与分发渠道无关**。
- 7 个安装/分发面:install.sh、install.ps1、install-skills.sh、npm install.js、
Homebrew formula、install-event.sh、install-devapp.sh。
- OSS 只发不读;大陆链路靠 Gitee fallback。
消费侧:
- `dws skill setup`:源优先级 `--source`/env → embed;目标 16 个 agent home
(父目录门控);互斥清理 best-effort、失败仅 warning、无备份无回滚
(`internal/app/skill_setup.go`)。
- `dws upgrade`:`LocateSkillMD` 命中 zip 根 mono → `UpgradeSkillLocations`
只写 `<agent>/dws/`、不清理 `dingtalk-*`、不更新 `~/.dws/skills`
(`internal/upgrade/paths.go:119-172`)。**multi 用户升级后 mono+multi 共存**。
- `dws upgrade --rollback` 只回滚二进制,不回滚 skill。
- **无任何已安装模式状态**:`~/.dws/` 无 install manifest。
- `~/.dws/skills` 缓存事实上只写不读(默认 setup 走 embed),且 upgrade 不更新,
版本漂移不可见。
- `skill setup --target all` 不含 opencode,但 `agentSkillPaths` 含
(`skill_command.go:119-141` vs `skill_setup.go:20-37`)。
- 市场 skill(`dws skill install`)解压到 agent skills 根,与内置 skill 无 mode
概念;互斥清理按 `dingtalk-*` 前缀扫描,依赖"市场无同名前缀"这一隐性约定。
## 3. 总体设计
### 3.1 单一事实源收敛(P0c)
agent home 清单目前在 5+ 处各写一份(注释约定 keep in sync,无门禁)。
- Go 侧:`skillSetupAgentHomes` 与 `knownSkillDirs` 合并为一个导出列表
(放在 `internal/upgrade` 或新 `internal/skillhome` 包),补 opencode,
setup/upgrade/测试共用。
- 脚本侧:install.sh / install.ps1 / install.js / install-skills.sh /
install-event.sh / install-devapp.sh 的清单由同一个 JSON 生成或政策脚本
比对(新增 `scripts/policy/check-agent-homes-sync.sh`,进 `make policy`)。
### 3.2 安装状态文件(P0b)— ❌ CANCELLED(2026-08-05)
原计划新增 `~/.dws/skills/state.json`(mode / cli_version / installed /
previous / backup)。**已取消**:owner 决策不做运行时模式切换产品;upgrade
有 multi 包时直接刷 multi,不再需要状态文件作为正确性或切换前提。
### 3.3 备份式安装与真回滚(P0a)— ❌ CANCELLED(2026-08-05)
原计划把 `RemoveAll` 改为 `mv` 到 `~/.dws/skills/backup/<ts>-<mode>/` 并
支持失败整体回滚 / `dws skill mode rollback`。**已取消**:无运行时切换则
不交付备份回滚产品面。安装/清理仍可为直接删除;mono retirement 版本若做
一次性迁移,届时再单独评估临时备份,不预建 switch UX。
### 3.4 upgrade 包驱动 multi 刷新(P0a,已落地)
> 取代「按 state.json mode 刷新」与「磁盘粘性」:有 multi 包时一次性刷成 multi。
- `UpgradeSkillLocations(extractedDir)`:**不**推断磁盘布局。
- 包内有 multi 技能树 → 始终 multi 刷新(清 `dws/`、刷产品 skill、刷
`~/.dws/skills/multi`);存量 mono 在日常 upgrade 上一并迁走。
- 无 multi 树的 legacy 包 → mono 刷新回退。
- 这是包驱动的一次性迁移,**不是**运行时 mode-switch 产品。
- 互斥清理在 multi 路径内执行,避免双布局长期共存。
### 3.5 模式切换命令(P1)— ❌ CANCELLED(2026-08-05)
原计划 `dws skill mode status|set|rollback|--dry-run`。**已取消**。
用户若需改布局:重新走安装入口(`dws skill setup --mode <mono|multi> --yes`
或安装脚本 `DWS_SKILL_MODE=`),不是 lifecycle 切换命令。
### 3.6 默认切换(P2)
改默认值="multi 默认、mono opt-in",七个面一起改 + 门禁锁一致性:
| 面 | 改动 |
|---|---|
| `dws skill setup` | 无 `--mode` 时默认 multi(交互选项顺序反转,mono 标 legacy) |
| install.sh / install.ps1 | multi 分支**真正安装**(现在是打印提示跳过);`DWS_SKILL_MODE=mono` opt-in;TTY 默认项改 multi |
| install-skills.sh / npm install.js | 从零加 mode 支持(env `DWS_SKILL_MODE` / `--skill-mode`),默认 multi |
| Homebrew formula | caveats 改提示 `dws skill setup`(默认即 multi),无需改资源 |
| install-event.sh / install-devapp.sh | 维持单 skill 语义,但改走共享 install 函数 |
文档同步:README/README_zh(`README_zh.md:81` "默认 1")、
`skills/multi/dingtalk-skill/SKILL.md` 的 🧪 EXPERIMENTAL 措辞下调、
install.sh 内 multi 警告文案、AGENTS.md"生产优先 mono"表述。
### 3.7 灰度与止血(无服务端能力下的替代)
- **L1 渠道灰度(先行)**:beta 轨(GitHub prerelease / npm `beta` dist-tag /
`dws upgrade --beta`)先切默认 multi,stable 保持 mono。零新增代码。
- **L2 确定性分桶(可选增强)**:随 release 发 `rollout.json`
(GitHub asset + OSS 同步),安装/升级时 `hash(machine-id) % 100 < pct` 决策
并粘入 `state.rollout`。规则:本地显式 env/flag 永远优先;拉取失败 fail-safe
mono(并行期)/ 保持现状(切默认后);存量机器不被 rollout 改模式。
- **Kill switch**:`rollout.json` pct=0(若上 L2,已砍)+ beta 撤回 +
公告引导重装 `dws skill setup --mode mono --yes`(**无** `skill mode`
命令;备份回滚产品已随 D5 取消)。
- **可观测**:原 `x-dws-skill-mode` 请求头方案 **CANCELLED**(2026-08-05
owner);灰度与下线判断改 issue 反馈 + 主动回访。
### 3.8 安装与升级的语义矩阵(两条路径都要支持 multi)
| 场景 | 时期 | 行为 |
|---|---|---|
| 新装 | 并行期(切默认前) | mono 默认,`DWS_SKILL_MODE=multi` / 交互可选 multi |
| 新装 | 切默认后 | **multi 默认**,`DWS_SKILL_MODE=mono` / `--mode mono` / TTY opt-in(**仅安装时**) |
| 升级(存量 mono) | 含 `multi/` 的包 | **一次性刷成 multi**(清 `dws/`);无 switch 提示命令 |
| 升级(存量 multi) | 含 `multi/` 的包 | multi 刷新,清过期 skill,刷 multi 缓存 |
| 升级(无安装) | 含 `multi/` 的包 | 安装 multi(与安装默认一致) |
| 升级(任意磁盘) | legacy 无 multi 树的包 | mono 刷新回退 |
| `dws upgrade --rollback` | 任意 | 只回滚二进制(现状);skill 无独立 rollback 命令 |
原则:**升级不做磁盘粘性**;有 multi 包时一律刷 multi(含存量 mono 一次性
迁移)。默认翻转与安装 opt-in 仍只影响新装/重装。无运行时 `dws skill mode`
产品。
## 4. 阶段与时间线(4 周)
| 周 | 内容 | 出口标准 |
|---|---|---|
| W1 | P0a upgrade mode-aware + P0b state.json + P0c 清单收敛与门禁 + 备份式安装 | upgrade×multi 集成测试(装 multi → upgrade → 无 `dws/` 残留);备份回滚测试(模拟中途失败);`make policy` 含 homes 同步检查 |
| W2 | P1 `dws skill mode`(status/set/rollback/dry-run);beta 轨默认切 multi(L1);`x-dws-skill-mode` 头 | 双向切换 + 中断恢复手工验收;beta 轨冒烟 |
| W3 | P2 stable 默认切 multi、mono 降为 opt-in;文案翻转;(可选 L2 分桶 5%→20%) | 七面默认行为一致(政策脚本);issue/请求头占比观察 |
| W4 | (L2 则 50%→100%);mono 打 deprecation 警告,**不删代码** | 连续 7 天无 multi 相关 P1 |
## 5. mono 下线判据(不满足则不删)
> 判据更新(2026-08-05):原判据 3「悟空 bundled skill 分发线(dws_res →
> bundled-skills,本仓库外)已切 multi」作废——悟空分发线已于当日决策
> 下线(见 [skill-multi-roadmap.md](skill-multi-roadmap.md)
> 「悟空线下线的影响(2026-08-05)」),无仓外 mono 依赖,mono 下线不再
> 有仓外节奏闸门。判据重新编号如下(原 4/5 顺延为 3/4)。
1. `x-dws-skill-mode=multi` 请求占比 ≥ 90%;
2. mono 主动 opt-in 率 ≤ 2%(install 脚本/命令埋点);
3. 连续两周无 multi P1;
4. 已有等价政策门替代 `scripts/policy/check-skill-context-budget.sh` 对
`skills/mono/SKILL.md` 的依赖;`skills_embed.go` 去掉 `all:skills/mono`;
存量 `<agent>/dws` 目录有"遇到即迁移清理"逻辑。
满足后单独一个版本窗口物理删除 `skills/mono/`,install/setup/upgrade 中 mono
分支改为报错并指向 `dws skill mode set multi`。
## 6. 风险与对策
| 风险 | 对策 |
|---|---|
| 半装状态(清理成功、拷贝失败) | 3.3 备份式安装,失败整体回滚 |
| multi 用户被 upgrade 塞回 mono | 3.4 mode-aware,P0a 先修 |
| Agent 目录清单继续漂移 | 3.1 单一事实源 + policy 门禁 |
| 互斥清理误伤市场 `dingtalk-*` skill | 清理前校验目录内 SKILL.md frontmatter 属 DWS 产品集,写成测试 |
| 无灰度全切翻车 | beta 轨先行 + 备份回滚 + kill switch 公告命令 |
| 用户不重启 AI 工具读到旧 skill | mode set / setup / install 输出统一提示重启 |
| `~/.dws/skills` 缓存与 embed 版本漂移 | upgrade 时同步刷新对应 mode 缓存;长期可评估废弃缓存 |
## 7. 分发机制对标 lark-cli(2026-08-04 调研)
### 7.1 lark-cli 的实际分发结构
npm 包 `@larksuite/cli` 是一个**薄壳**(package.json `files` 只有
`install.js` / `install-wizard.js` / `run.js` / `checksums.txt`):
- **二进制**:postinstall 时按平台从 GitHub Releases 下载,SHA256 校验
(checksums.txt 随 npm 包发布);镜像链 = GitHub → 用户 registry 派生镜像 →
npmmirror 兜底,host allowlist + checksum 双保险。`run.js` 在二进制缺失时
自动补下载;Windows 有 `.old` 崩溃恢复。
- **Skills**:**不进 npm 包、不进二进制**。repo 根 `skills/` 目录即事实源,
由生态通用安装器 `npx skills add larksuite/cli -y -g`(vercel-labs/skills)
安装;wizard 首选 `https://open.feishu.cn` 直链、GitHub shorthand 兜底。
- **一键向导** `npx @larksuite/cli@latest install`:run.js 拦截 `install`
子命令 → install-wizard.js 串联 4 步(npm 全局装/升级 → skills 安装 →
config init → auth login),每步幂等可跳过(`skills ls -g` 检测 `lark-*`
已装则跳过);非 TTY 自动降级为"装完打印后续命令"。
生态安装器 `skills` CLI 的能力(lark-cli 免费获得的):
- **76 个 agent 的目录清单由生态维护**,自动探测已装 agent;project/global
两种 scope;
- **symlink 到 canonical 副本(推荐)或 copy** —— symlink 模式下升级
= 更新 canonical 一份,所有 agent 即时生效;
- `list / find / update / remove` 全套生命周期命令,skill 升级由
`npx skills update` 承担,**lark-cli 自己不写 skill 刷新逻辑**;
- 发现约定兼容 catalog 布局 `skills/<catalog>/<name>/SKILL.md`。
### 7.2 与 DWS 的关键差异
| 维度 | lark-cli | DWS 现状 |
|---|---|---|
| 分发单元 | repo `skills/` 目录(源码即事实源) | zip + 二进制 embed + 5 处拷贝缓存 |
| 安装器数量 | **1 个**(生态工具,76 agent 清单别人维护) | **7 个**自维护脚本,清单已漂移 |
| 落盘方式 | symlink(canonical 单点更新) | 全量 copy,升级要重写 16 个 home |
| skill 升级 | `npx skills update`(生态承担) | `dws upgrade` 自写,且不识 multi |
| mono/multi 问题 | **不存在** —— 天生按目录多 skill | 互斥清理/模式切换/状态全是自建 |
| npm 包体积 | 薄壳(运行时下载单平台二进制) | 全平台 archive + skills.zip 全打进 tarball |
| 大陆镜像 | registry 派生 + npmmirror | Gitee fallback + OSS(只发不读) |
### 7.3 结论:生态分发通道**已否决**(2026-08-04)
~~借生态安装器做分发通道~~ 方向经评估后**放弃**。否决原因(均为实测):
1. **无版本固定**:`skills add`(v1.5.21)不支持 tag/ref 安装,而 DWS skill
从 Cobra 树生成、与 CLI 版本强耦合,错配不可接受;唯一可复现机制
`skills-lock.json` 仍是 experimental。
2. **依赖 Node + GitHub 可达**:curl|sh / Homebrew / 大陆 Gitee fallback 的
用户环境大量无 Node,生态通道在这些场景是断的。
3. **mono 会被一起发现**:发布即制造双份,须等 mono 下线才能发布,节奏不合。
4. **悟空 bundled / 离线预装**生态通道永远覆盖不了。(2026-08-05 注:
悟空分发线已下线,此条约束随之消失;前 3 条否决理由仍成立。)
**决策:分发维持全自维护通道**(zip + embed + 安装脚本),按 P0–P2 落地;
不从 lark-cli 借鉴分发架构。可保留的借鉴点只剩两个本地语义,与生态无关:
1. **wizard 式幂等安装**:`install.sh` 的 multi 分支从"打印提示跳过"改为
检测 state 可重入的真正安装(参考 install-wizard 的步骤化幂等)。
2. **npm 下载校验**:install.js 如后续薄壳化,可参考其 host allowlist +
checksum 双保险与镜像链(GitHub → registry 派生 → npmmirror)设计。
与本次 multi 迁移解耦,不单列任务。
## 8. 工作量评估与任务拆解
> 以 1 名熟悉本仓库的工程师计(人日)。规模基线:涉及生产代码约 5.0k 行
> (Go 3.0k + shell/ps1/js 2.0k),已有测试 1.2k 行可复用。
### 8.1 总体判断
**核心路径 13–17 人日,一人一个月可行但偏紧**。风险不在 Go 而在"shell /
ps1 / js 三语言 × 七面同步"和对应的测试矩阵。可选项(rollout 分桶、npm
薄壳化、symlink、生态通道发布)全部可砍可后置,砍后**最小可行集 8–10
人日**(见 8.4)。
### 8.2 任务拆解(带依赖)
```
P0c 清单收敛 ──┐
P0b state.json ─┼─► P0a upgrade mode-aware ─► P1 skill mode ─► P2 切默认
备份式安装 ────┘ │ │
└─► x-dws-skill-mode 头 └─► beta 轨先切(P2 预演)
```
| # | 任务 | 改动面 | 估时 | 依赖 |
|---|---|---|---|---|
| P0c-1 | agent home 清单下沉共享包(setup/upgrade 共用),补 opencode | `internal/upgrade/paths.go` 或新 `internal/skillhome`(~80 行新代码) | 0.5d | — |
| P0c-2 | `scripts/policy/check-agent-homes-sync.sh`:比对 sh/ps1/js/sh 专项清单与 Go 清单,进 `make policy` | 新脚本 ~120 行 + 各脚本清单改成可解析块 | 1d | P0c-1 |
| P0b-1 | state.json schema + setup 写入(含 installed 列表、agent_homes、previous) | `internal/app/skill_setup.go` +新文件 ~200 行 | 1d | — |
| P0b-2 | 磁盘形态反推(dws/ → mono;dingtalk-* → multi;都有 → drift 报错)+ 单测 | ~120 行 | 0.5–1d | P0b-1 schema |
| P0-3 | 备份式安装:RemoveAll→mv backup、失败自动回滚、保留最近 2 份、改 warning 语义 | setup install 两函数重写 ~150 行 + 测试 | 1.5–2d | — |
| P0a-1 | `UpgradeSkillLocations(dir, mode)`:mono 现状+清残留;multi 从 zip `multi/` 平铺刷新+清 `dws/`+刷缓存 | `internal/upgrade/paths.go` ~150 行 | 1.5d | P0c-1、P0b |
| P0a-2 | upgrade×multi 集成测试(装 multi→upgrade→无 dws/ 残留、dingtalk-* 已刷新) | 测试 ~200 行 | 1d | P0a-1 |
| P0-4 | `x-dws-skill-mode` 请求头(仿 `x-dws-channel`) | `internal/auth/oauth_helpers.go` 附近 ~30 行 | 0.5d | P0b |
| P1-1 | `dws skill mode` status/set/rollback/--dry-run | 新文件 ~350 行 + 测试 | 2–2.5d | P0b、P0-3 |
| P1-2 | beta 轨默认切 multi(版本门控的默认值翻转,stable 不变) | setup/install.sh 默认值逻辑 ~40 行 | 0.5–1d | P1-1 |
| P2-1 | setup 默认翻转 + 交互选项反转 + mono 标 legacy | ~30 行 + 测试更新 | 0.5d | P1 |
| P2-2 | install.sh / install.ps1 的 multi 分支**真装**(幂等、读 state 跳过) | 两个脚本各 ~80 行 | 1.5d | P0b(脚本侧写 state) |
| P2-3 | install.js / install-skills.sh 加 mode 支持(env + flag,默认 multi) | 各 ~60 行 | 1d | P2-2 同批 |
| P2-4 | 文案翻转:README×2、SKILL.md EXPERIMENTAL 下调、install 脚本提示、AGENTS.md | 纯文档 | 0.5d | — |
| W4 | mono deprecation 警告(不删代码)+ 观察 | ~20 行 | 0.5d | P2 |
**小计:核心 13–17 人日**(P0 ≈ 6.5–8.5,P1 ≈ 3–3.5,P2 ≈ 3.5,W4 0.5)。
可选/后置:rollout.json 分桶 2–3d;`setup --link` symlink 1–2d(需逐 agent
验证);npm 薄壳化 2–3d(独立立项)。
### 8.3 风险最高的两处(先动)
1. **P0a upgrade multi 刷新语义**:additive 安装 vs upgrade 全量刷新之间存在
一个真实设计题 —— 用户手动 `-x` 排除过的 skill,upgrade 要不要装回来?
答案:以 state.json 的 `installed` 为准做增量刷新,未装的不得补装
(否则违背 additive 语义)。这条必须在 P0a 开工前定死。
2. **P0-3 备份回滚改语义**:现有测试断言"清理失败继续装",改语义会动
`skill_setup_full_coverage_test.go` 多处;回滚恢复顺序(先恢复再报错)
要用故障注入测试覆盖。
### 8.4 一个月做不完时的砍法
按价值/成本比从后往前砍:rollout 分桶(L1 beta 轨已够)→ install.js /
install-skills.sh mode 支持(npm 渠道用户量小,可先只改 sh/ps1)。砍后**最小可行集 8–10 人日**:
> P0c-1 + P0b + P0-3 + P0a + P1-1 + P2-1 + P2-2(仅 sh/ps1)+ 文案
即:upgrade 不再制造双份、有状态可回滚、setup 与主流安装脚本默认 multi。
npm/install-skills.sh 维持 mono 显式行为并在输出中标注即将切换。
## 9. 明确不做
- 不做服务端远程配置/灰度平台(用 beta 轨 + rollout.json 替代)。
- 不动 `dws-skills.zip` 产物布局(已含 mono/multi 双树)。
- 不动市场 skill(`dws skill install`)的安装语义。
- 并行期内不删 mono 代码与产物,只降级为 opt-in。
+153
View File
@@ -0,0 +1,153 @@
# Skill multi 迁移:技术方案(as-implemented)与 Roadmap
> 本文是 multi 迁移的当前事实源:第一部分记录**已落地实现**(代码级锚点,
> 均可跳转验证),第二部分是带实时状态的 roadmap。
> 原始方案 [skill-multi-migration-plan.md](skill-multi-migration-plan.md) 的
> 若干"待做"描述已被后续决策取代(见决策记录 D1/D5);分发机制调研结论见
> [skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
> 代码快照:`feat/skill-mode-migration` @ `402429ac` + 工作区
> upgrade-force-multi 调整(2026-08-05)。
## 状态速览
| 项 | 内容 |
|---|---|
| 当前阶段 | **阶段 1 / 1.5 ✅**(安装/升级默认 multi 已落地);**阶段 2 运行时切换产品线 ❌ CANCELLED**(2026-08-05 owner 决策) |
| 硬 deadline | **2026-08-30**:安装/升级默认 multi(✅)+ upgrade **有 multi 包时一次性刷成 multi**(不做磁盘粘性)+ mono 仅安装时 opt-in;mono **物理下线**仍可在独立 retirement 版本推进,但**不再**依赖 `dws skill mode` / 备份回滚产品 |
| 已完成 | 五面默认 multi、`dws skill setup` 默认 multi、互斥清理对称、文案翻转;upgrade 有 `multi/` 时一律刷新 multi(存量 mono 一次性迁移) |
| 下一步 | beta/L1 版本门控与观察(靠 issue/回访,**无** `x-dws-skill-mode` 埋点);可选 agent-home 清单门禁;内容 C 线并行 |
| 终态 | mono 仅安装时 opt-in;日常 upgrade(含 multi 的包)刷 multi;mono 物理删除仍可在 dedicated retirement 版本收尾(非用户切换命令) |
### 简化设计(2026-08-05 起生效)
1. **安装时一次决定**:默认 multi;`DWS_SKILL_MODE=mono` / `--mode mono` / 安装器 TTY 选 mono 为唯一 opt-in。
2. **装完无运行时切换产品**:不做 `dws skill mode set/rollback`,不做备份式安装 / `state.json` 记账产品面。
3. **升级不做粘性**:release zip 含 `multi/` 时 **一律** 刷新 multi(清 `dws/`、刷产品 skill + 缓存);仅 legacy 无 multi 树的包回退 mono 路径。存量 mono 在日常 upgrade 上一次性迁到 multi。
4. **mono 下线**:安装侧仍可 opt-in;upgrade 已承担「有 multi 包即迁走」;物理删 mono 树可另议 retirement。
5. **可观测**:`x-dws-skill-mode` 请求头已按 owner 决策移除;灰度靠 issue + 回访。
---
# 第一部分:技术方案(as-implemented)
## 1. 升级:包驱动 multi 刷新(`dws upgrade`)
核心语义:**升级不做磁盘粘性**;产物有 `multi/` 时一次性刷成 multi。
无需 `state.json`,也无运行时切换命令。
- `LocateSkillsRoot` 优先返回 zip 内 `multi/`(`internal/upgrade/paths.go`)。
- `UpgradeSkillLocations`:包内有 multi 技能树 → **始终** `upgradeMultiSkillLocations`
(平铺 `dingtalk-*` + `dws-shared`,删 mono 残留 `dws/`,清过期 multi skill,
刷 `~/.dws/skills/multi`);无 multi 树的 legacy 包才走 mono 刷新。
- 这是 upgrade 上的一次性迁移,不是 `dws skill mode` 产品。
测试:`internal/upgrade/paths_multi_test.go`(含
`TestUpgradeSkillLocationsMonoDiskMigratesToMulti`)+
`internal/app/upgrade_skill_multi_e2e_test.go`。
## 2. 安装默认 multi(四个脚本面)
四个脚本安装面默认值全部为 multi,`DWS_SKILL_MODE=mono` 为统一 opt-in,
互斥清理双向对称。详见阶段 1 落地说明(`scripts/install.sh` /
`install.ps1` / `build/npm/install.js` / `scripts/install-skills.sh`)。
## 3. `dws skill setup` 默认 multi
- 非交互未指定 `--mode` 时默认 multi。
- 交互选项 multi 在前(默认)、mono 标 legacy。
- 仍可用 `dws skill setup --mode mono --yes` **重装**到 mono(这是安装入口,
不是 lifecycle 切换产品;无备份/state/rollback 命令)。
## 4. 文案翻转
- `README.md` / `README_zh.md`:multi 默认、mono legacy。
- `skills/multi/dingtalk-skill/SKILL.md`:去掉 EXPERIMENTAL。
## 5. 已验证
- `go test ./internal/upgrade ./internal/app ./test/scripts`(阶段 1 基线)+
upgrade-force-multi 单测 / fake-HOME E2E。
- 脚本面契约测试暴露 `DWS_SKILL_MODE` 与 mono/multi 选项。
## 6. 决策记录
- **D1 升级不依赖 state.json**(仍成立)。不读状态文件;布局由包内容驱动。
- **D2 无服务端灰度**。L1 beta 轨 + issue/回访;L2 `rollout.json` 已砍;
kill switch = beta 撤回 / 重装 `--mode mono`(无 `skill mode` 命令)。
- **D3 生态分发通道已否决**(`npx skills add` 等)。见
[skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
- **D4 互斥前缀约定**。`dingtalk-*` / `dws-shared` 属 DWS 产品 skill。
- **D5 无运行时模式切换(2026-08-05)**。取消阶段 2 的备份式安装、
`state.json`、`dws skill mode`(status/set/rollback)、`x-dws-skill-mode`
请求头。Mode 只在安装时决定。
- **D6 升级不做粘性(2026-08-05)**。有 multi 包时 upgrade 一次性刷成 multi
(含存量 mono);legacy 无 multi 树才回退 mono 路径。
---
# 第二部分:Roadmap
## ✅ 阶段 1 / 1.5(已完成,2026-08-05)
安装/升级默认 multi、五面互斥清理、文案翻转、1.5 review 修复与实机 9/9。
HEAD:`402429ac`。
## ❌ 阶段 2(原切换/状态/备份产品线)— CANCELLED(2026-08-05)
| 原任务 | 状态 | 说明 |
|---|---|---|
| 备份式安装(`~/.dws/skills/backup/...`) | ❌ CANCELLED | 不做运行时切换,无需备份回滚产品 |
| `~/.dws/skills/state.json` | ❌ CANCELLED | 无切换产品;upgrade 按包刷 multi,不需要状态文件 |
| `dws skill mode` status/set/rollback/--dry-run | ❌ CANCELLED | 无运行时切换 UX |
| `x-dws-skill-mode` 请求头 | ❌ CANCELLED | owner 决策移除;观测改 issue/回访 |
| agent home 清单门禁 | ⬜ 可选 | 与切换产品无关,仍可作工程质量项 |
| `upgrade --dry-run` multi 文案 | ⬜ 可选 | 可随 force-multi 语义轻量对齐 |
## 重构后的 8/30 目标
**底线**:新装默认 multi;含 `multi/` 的 release 上 `dws upgrade` **一次性刷成
multi**(含存量 mono);仅需保持 mono 的用户用安装入口 opt-in 后勿升级到
含 multi 的包,或 retirement 前用 setup 重装;mono 物理删除可另议。
### 建议关键路径(简化)
```text
阶段1默认multi ✅ → upgrade force-multi ✅ → beta/L1(可选)→ 观察(issue/回访)
→ stable 默认已是 multi → 可选 mono retirement(删 mono 树 / 安装入口报错)
```
### mono retirement(原阶段 4,重框)
- **不再**提供 `dws skill mode rollback` 作为用户出口。
- 日常 upgrade 已在有 multi 包时迁走存量 mono;retirement 版本可进一步移除
install/setup 的 mono 分支与 zip 内 mono 树。
- 下线判据改为:issue/回访无系统性 multi P1、mono opt-in 可接受、政策门
不再依赖 `skills/mono/SKILL.md`。(原请求头占比判据作废。)
## 悟空线下线的影响(2026-08-05)
悟空 bundled-skill 分发线已下线:mono 下线无仓外节奏闸门;`skills/multi/`
为唯一 multi 事实源。设计资产留档
[skill-wukong-comparison.md](skill-wukong-comparison.md)。
## 明确不做
- ❌ 运行时模式切换(`dws skill mode set/rollback`)。
- ❌ `state.json` / 备份式安装产品面(随 D5 取消)。
- ❌ `x-dws-skill-mode` 请求头。
- ❌ 服务端远程配置 / L2 `rollout.json`。
- ❌ 运行时「模式切换」产品(含 sticky 伪切换);upgrade 有 multi 时刷 multi
是包驱动的一次性迁移,不是 switch UX。
- 不动 `dws-skills.zip` 双树布局(仍可含 mono 副本供安装 opt-in);不动市场
skill(`dws skill install`)。
---
## 风险表
| 风险 | 现状 | 缓解 |
|---|---|---|
| 半装无备份 | 安装/清理仍 `RemoveAll` | 接受为非切换产品下的已知限制;重装可收敛 |
| mono/multi 漂移 | 无 state;upgrade 有 multi 即刷 multi | 升级后收敛为 multi;安装互斥清理 |
| 用户想换模式 | 无 switch 命令 | 文档引导:`dws skill setup --mode <mono\|multi> --yes` 重装 |
| 8/30 滑期 | 切换产品线已砍,关键路径缩短 | 聚焦 force-multi upgrade + 默认 multi 稳定 |
+290
View File
@@ -0,0 +1,290 @@
# DWS Skill mono→multi 灰度能力设计(rollout capability)
> 本文回答一个问题:**在没有服务端远程配置/灰度平台的前提下,mono→multi
> 默认翻转如何灰度发布、如何观测、如何止血**。关联文档:
> [skill-multi-roadmap.md](skill-multi-roadmap.md)(迁移事实源,本文展开其
> 阶段 3「灰度切流」)、[skill-multi-migration-plan.md](skill-multi-migration-plan.md)
> §3.7(灰度与止血的原始设计)。代码锚点快照:`feat/skill-mode-migration`
> 工作区未 commit 变更(2026-08-05)。
---
## TL;DR 推荐路线
| 步 | 动作 | 层级 | 前置 |
|---|---|---|---|
| 1 | 把「五面默认 multi」拆成**版本门控默认**(beta→multi / stable 观察),同一份代码发 beta 轨先吃 | L1 | 无(本文 §2.1) |
| 2 | ~~阶段 2 四件套(备份 / state.json / `dws skill mode` / 请求头)~~ | — | **❌ CANCELLED(2026-08-05)**:无运行时模式切换;upgrade 有 multi 时刷 multi(不做粘性) |
| 3 | beta 轨观察:issue 流入 + 主动回访(**无**请求头占比) | 人工 | 步 1 |
| 4 | stable:默认 multi 已在阶段 1 落地;L2 `rollout.json` 已砍 | — | — |
| 5 | kill switch:beta 撤回(已有)/ 公告重装 `dws skill setup --mode mono --yes` | — | 无备份回滚产品 |
---
## 1. 现状盘点:今天可用于灰度的全部旋钮
### 1.1 旋钮总表
| # | 旋钮 | 代码锚点 | 生效范围 | 盲区(谁够不着) |
|---|---|---|---|---|
| K1 | GitHub Release 双轨(stable / prerelease) | `internal/upgrade/github.go:100-106`(`ReleaseTrack`);`internal/app/upgrade.go:870-875`(`upgradeTrack`);release.yml 强制版本→轨映射(`release.yml:547` 含 `-beta.` → prerelease) | `dws upgrade --beta` / `--version vX.Y.Z-beta.N` 的二进制+skill 升级 | 不用 `dws upgrade` 的人;Gitee/OSS 镜像用户(见 K3/K4) |
| K2 | npm dist-tag 双轨(`latest` / `beta`) | `release.yml:1761-1762`(prerelease→`beta` tag);发布防倒退 `release.yml:1828-1835`;撤回脚本 `scripts/release/withdraw-release.sh:652-665`(dist-tag 回拨+deprecate) | `npm i dingtalk-workspace-cli@beta` 的新装/重装 | npm 默认安装(`@latest`)用户无感;npm 装完即走、不再 `npm i` 的存量 |
| K3 | Gitee 镜像(代码 + release 资产) | main 代码镜像 `.github/workflows/mirror-to-gitee.yml:42-82`;release 资产镜像 `release.yml:1957-1965`(`sync-to-gitee.sh`);安装脚本侧 `DWS_GITEE_REPO` 解析 `scripts/install.sh:18-19`、`scripts/install-skills.sh:21-24` | curl\|sh / install-skills.sh 的国内用户(显式 env 或 GitHub 不可达自动回退) | **`dws upgrade` 不到 Gitee**:upgrade client 只打 GitHub API(`internal/app/upgrade.go:48` → `internal/upgrade/github.go`,包内无任何 Gitee 引用);Gitee release 是否 prerelease 由镜像脚本原样搬运,无独立轨控 |
| K4 | OSS 镜像(ossutil 同步) | `scripts/release/sync-to-oss.sh:9-14`(`download/<version>/` + `latest.txt`/`beta.txt` 指针);`release.yml:1897-1908` | 今天:**只写不读**——脚本注释明示「repository installers currently resolve GitHub/Gitee and do not consume these OSS pointers directly」(`sync-to-oss.sh:5-7`) | 所有人(指针无人消费);但 `latest.txt`/`beta.txt` 是天然的**可变 channel 指针**(见 §2.2 设计复用) |
| K5 | 安装脚本 env / flag | `DWS_SKILL_MODE`:`scripts/install.sh:17`(解析 `install.sh:220-256`)、`scripts/install.ps1:293-332`、`scripts/install-skills.sh:29-33`;npm `--skill-mode` / env `build/npm/install.js:197-214`;`DWS_VERSION` 指定 beta 版 `install.sh:38` | 新装时的逐台显式控制(CI、内推灰度名单) | 只对**执行安装那一刻**生效;装完无持久化(无 state.json),事后无法得知当初怎么装的;`DWS_VERSION=latest` 在 GitHub 侧只解析 stable(`/releases/latest` 永不指向 prerelease,`install.sh:190-198`),beta 必须显式给版本号 |
| K6 | 版本门控默认值(构建期注入) | goreleaser ldflags 注入版本 `.goreleaser.yaml:22`(`internal/app.version=v{{.Version}}`);`prerelease: auto` `.goreleaser.yaml:68` | 同一 commit,beta build 与 stable build 可表现不同默认值(§2.1 切法 B 的机制) | 脚本面拿不到 Go 变量,需各自从「解析出的版本号」重推导(§2.1);homebrew formula 只搬 zip 根(mono 布局)到 pkgshare(`build/homebrew.rb.tmpl:26-31`),formula 本身无 mode 概念 |
| K7 | 本地遥测(opt-in) | `internal/shortcut/usage/recorder.go:82-88`(`DWS_USAGE_TRACKING=1`,默认关);只写本地 `~/.dws/usage.jsonl`(`recorder.go:91`) | 高频命令形状挖掘(shortcut P2) | **不上传任何服务端**:对灰度占比测量零贡献;默认关意味着即使上传也无统计意义 |
| K8 | 请求头通道(已有上行链路) | MCP 请求统一注入点 `internal/app/runner.go:953-1008`(`resolveIdentityHeaders`,接线于 `runner.go:140/240/597`、`internal/app/recovery_command.go:271/337`);登录权限检查 `internal/auth/oauth_helpers.go:1424-1428`;`x-dws-channel`(`DWS_CHANNEL`)先例 `runner.go:1006-1008`、`oauth_helpers.go:1425-1426` | 服务端(MCP 网关)已能按 header 聚合:`x-dws-agent-id`、`x-dingtalk-dws-agent-code`、`X-Cli-Version` 均在线 | 只有「已登录且发 MCP 请求」的用户可被观测;纯安装未使用、auth 失败前的用户在分子里缺席(占比偏高估,§2.3) |
### 1.2 结构性盲区(任何旋钮都够不着)
| 盲区 | 说明 | 出处 |
|---|---|---|
| ~~悟空 bundled skill 分发线~~(盲区已移除) | 悟空分发线(dws_res → Wukong.app bundled-skills)已于 2026-08-05 决策下线,本盲区随之移除;历史上该线在**本仓库外**、仅有 main CI 成功后的下游触发(`.github/workflows/notify-wukong.yml:13-38`),本仓库默认值翻转管不到它 | 原 roadmap 风险表「悟空线外挂」行(已解除)、阶段 4 原判据 3(已作废) |
| homebrew 用户 | formula 只把 zip 根(mono 副本)stage 进 `pkgshare/skills/dws`(`build/homebrew.rb.tmpl:26-31`),caveats 指向 `dws skill setup`(`:33-38`);multi 源不在包内,`setup --mode multi` 只能靠 `~/.dws/skills/multi` 缓存 | 同上,K6 |
| 永不升级的存量 mono 用户 | 所有旋钮都作用于「新装/升级/重装」三个时点;不动作的用户一切照旧(这正是灰度的天然保护层) | — |
| 安装后不再运行 `dws` 的用户 | K8 观测不到,占比分母缺失 | §2.3 |
---
## 2. 方案设计(分层)
### 2.1 L1 渠道灰度:beta 轨先吃 multi 默认
目标:**同一代码、不同 release 轨不同默认值**,stable 用户在观察期内完全无感。
#### 切法 A:纯流程(零新增代码,不推荐单独使用)
当前工作区五面已无条件翻转 multi;直接发 beta 即完成「beta 先吃」。
问题:main 上的默认值已是 multi,**下一个 stable 无处可躲**——stable 发布
窗口一到就必须全切,观察期长短不由人;且中途想给 stable 出补丁版(hotfix)
会被迫带上 multi 默认。仅适合「beta 观察期确定短、stable 窗口确定远」的情形。
#### 切法 B:版本门控默认值(推荐)
把五面的默认值从「无条件 multi」改为「beta 版本默认 multi,stable 版本默认
mono」。判据统一用版本号是否含 `-beta.`(release.yml 已强制版本→轨唯一映射,
`release.yml:547`、`.goreleaser.yaml:68`):
| 面 | 门控取值来源 | 落点 |
|---|---|---|
| `dws skill setup` / `dws upgrade`(Go) | ldflags 注入的 `internal/app.version`(`.goreleaser.yaml:22`),`strings.Contains(version, "-beta.")` | `internal/app/skill_setup.go:354-357`(非交互默认)与 `:364-368`(交互默认项排序) |
| install.sh / install.ps1 / install-skills.sh | 脚本自己解析出的 `$VERSION`(`install.sh:177-198`;Gitee 侧 `install.sh:180-188`)——`case "$VERSION" in *-beta.*)` | `install.sh:220-256`、`install.ps1:293-332`、`install-skills.sh:29` |
| npm install.js | 包内 `package.json` 的 `version`(staging 时由 `stage-npm-package.sh` 写入 release 版本) | `build/npm/install.js:197-214` |
**关键发现——upgrade 路径(2026-08-05 更新)。** skill 升级语义是「跟着 zip
产物布局走、不做磁盘粘性」:`LocateSkillsRoot` 恒优先 `multi/`,
`UpgradeSkillLocations` 在包内有 multi 时**始终**刷 multi(含存量 mono
一次性迁移,清 `dws/`)。若仍要做「stable 观察期不迁 mono」,门控必须落在
**是否发布含 multi 的 zip / 是否走 upgrade skill 刷新**,而不是磁盘粘性分支
(粘性方案已否决)。当前产品默认接受:含 multi 的 release 上 upgrade = 迁
multi。
切法 B 的安装默认门控(beta→multi / stable→mono)仍可独立存在;与 upgrade
force-multi 正交——安装 opt-in mono 的用户一旦升级含 multi 的包会被迁走。
#### 风险与回退
| 风险 | 说明 | 回退 |
|---|---|---|
| 门控不可见 | 默认值随版本号变化,review/测试容易漏 | 每面补契约测试(beta→multi / stable→mono),`test/scripts` 已有同构先例(`test/scripts/install_script_test.go:529-576`) |
| 升级迁走 mono | **接受为产品语义**:有 multi 包时 upgrade 一次性刷 multi;需 mono 则 `dws skill setup --mode mono --yes` 重装(装完后再 upgrade 仍可能被迁回) | S1 撤回含 multi 的坏包;S3 公告重装 |
| beta 轨整体有毒 | 二进制或 skill 包级事故 | 现有撤回链:`scripts/release/withdraw-release.sh`(GitHub release 撤回 + npm deprecate + dist-tag 回滚,`withdraw-release.sh:652-665`);stable 轨不受影响 |
| 版本字符串被仿造 | 本地 `go build` 无版本注入时 `version=""`,门控落 stable 分支(保守方向,正确) | — |
### 2.2 L2 确定性分桶:随 release 发 `rollout.json`
L1 的粒度是「轨」:beta 全吃、stable 全不吃。stable 切流若要 5%→100% 的
渐进,需要机器级分桶。无服务端,用**随版本分发的只读配置 + 客户端确定性
哈希**替代。
#### rollout.json schema 与发布链路
作为 release 资产随每个版本发出(进 `dist/`):
```json
{
"skill_mode": {
"pct": 20,
"salt": "skill-mode-2026h2",
"note": "mono->multi default rollout for stable track"
}
}
```
- `pct`:0–100,`bucket < pct` 的机器默认 multi。
- `salt`:分桶盐,换盐=重新洗牌(默认不换,保证跨版本粘性可比)。
- 发布链路改动:`scripts/release/post-goreleaser.sh` 生成进 `dist/`;
**资产命名空间是精确集合**(`scripts/release/verify-release-artifacts.sh:12-38`,
「public release assets must contain exactly the supported files」),必须把
`rollout.json` 加进 EXPECTED_ASSETS;stable 晋升门会比较 beta 资产集
(`release.yml:833-846`),所以引入该资产的那个 beta 起两轨必须同时带。
- checksums.txt 由 dist 自动生成,镜像脚本(Gitee `release.yml:1957-1965`、
OSS `sync-to-oss.sh:9-14`)整目录搬运,**rollout.json 自动随资产集流到
Gitee/OSS,无需额外接线**。
#### machine-id 来源:读现成,不新建
`internal/auth/identity.go` 已有稳定 per-install UUID v4 `machineId`
(`identity.go:19-20`、结构体 `:70-76`),持久化在 `~/.dws/identity.json`
(`identity.go:51` + `pkg/config/constants.go:177-188`),惰性生成
(`EnsureExists` `:115-133`),v1 文件透明迁移(`:98-113`)。分桶直接复用:
```
bucket = int(sha256(machineId + "|" + salt)[:8], 16) % 100
```
边界情况:`identity.json` 首建于首次 MCP 请求链路(`runner.go:954`)。灰度
决策发生在 setup/upgrade 时,可能早于任何 MCP 请求——此时按 `EnsureExists`
同款语义**就地惰性创建**(best-effort 持久化,失败则用进程内随机值且当次
不记账,下次重决)。不引入第二套 `~/.dws/install-id`,避免双事实源。
脚本面(sh/ps1)做 sha256 分桶要读 JSON + 哈希,复杂且易错;npm install.js
用 node crypto 是一行。**范围划定:L2 分桶只在 Go 面(`dws upgrade` /
`dws skill setup` / 未来 `dws skill mode`)与 npm install.js 实现**;sh/ps1
停在 L1 版本门控(curl 用户全是新装,渠道轨已够;百分比分桶的主战场是存量
升级,而升级必过 Go 二进制)。
#### state.json 的 rollout 字段
依赖阶段 2 的 `~/.dws/skills/state.json`(P0b,未实现,roadmap 阶段 2):
```json
{
"mode": "multi",
"rollout": {
"bucket": 37,
"pct": 20,
"salt": "skill-mode-2026h2",
"decision": "multi",
"decided_at": "2026-08-20T08:00:00Z",
"decided_by": "rollout.json@v1.4.2",
"explicit": false
}
}
```
决策顺序(每台机器只决策一次,粘性):
1. 本地显式(`DWS_SKILL_MODE` / `--mode` / `--skill-mode` / `dws skill mode set`)
→ 用之,`explicit=true`;
2. `state.json` 已有 `mode` 或磁盘形态可反推(roadmap 阶段 2 既定兜底)
→ 保持现状,不参与分桶;
3. `state.rollout.decision` 已存在 → 复用(pct 后续变化不翻案);
4. 拉取 `rollout.json`(Go 面:upgrade 已下载本版资产,同 release 再取一个
小文件;npm:包内自带)→ 算 bucket 决策并记账;
5. 任一步失败 → 当期默认(L1 版本门控结果)。
#### 三条硬规则
| 规则 | 内容 | 理由 |
|---|---|---|
| R1 本地显式优先 | env/flag/命令任何时候压过 rollout 决策;显式选择落 `explicit=true` 后 rollout 永不改它 | 灰度不能覆盖用户意志;也是 kill switch 的用户侧出口 |
| R2 拉取失败 fail-safe | 拉不到/解析失败/字段越界 → 保持现状(存量)或当期默认(新装),**绝不因拉取失败翻模式** | 无服务端下网络面即故障面,故障必须倒向保守侧 |
| R3 存量不被改模式 | 已有 mode(state 或磁盘可推)的机器不参与分桶;pct 只影响「未决策」机器 | pct 从 20 降到 0 不能把已进 multi 的 20% 弹回 mono(那需要 kill switch,不是分桶语义) |
#### 与 Gitee / immutable release 的兼容
- **Gitee**:`dws upgrade` 不读 Gitee(§1.1-K3),rollout.json 经
reconcile/sync 脚本随资产集镜像到 Gitee release;Gitee 侧安装脚本如需消费,
走与 `dws-skills.zip` 相同的 Gitee API 资产枚举(`install.sh:180-188` 同
模式)。一期不消费、只保证镜像不缺失。
- **immutable release 约束**:官方仓已开启不可变 release(`release.yml:802`
「Immutable releases must be enabled before publishing」),**资产发布后不可
替换**——调 pct = 发一个新补丁版(beta 线 release.yml 支持连续 beta:
`release_bump` 在连续 beta 线时被忽略,`release.yml:25-27`)。这决定了
L2 的调参时延 = 一次发版;追求更快止血见 §2.4。
- (可选远期)OSS `latest.txt`/`beta.txt` 是现成的**可变**指针
(`sync-to-oss.sh:13-14`),若未来 installer 学会读 OSS,可把
`rollout-current.json` 放 OSS 变指针后面实现「不发版调 pct」;今天无消费
方,不建。
### 2.3 L3 可观测性:`x-dws-skill-mode` 请求头 — ❌ CANCELLED
**2026-08-05 owner 决策:不实现该请求头**(与运行时模式切换 / state.json
一并取消)。原设计(注入 `resolveIdentityHeaders`、按 state/磁盘上报
`mono|multi|unknown`)仅作历史记录,不进入排期。
观察手段改为:**issue 反馈 + 主动回访**;不再有请求级 multi 占比判据。
### 2.4 Kill switch:四层止血
| 层 | 手段 | 时延 | 现状/依赖 |
|---|---|---|---|
| S1 beta 轨整体撤回 | `scripts/release/withdraw-release.sh`:GitHub release 撤回 + npm deprecate + `beta` dist-tag 回拨(`:652-665`) | 分钟级 | **今天可用** |
| S2 rollout.json `pct=0` | 发补丁版把 pct 打 0(immutable release 不允许原地改资产,§2.2) | 一次发版(小时级) | 依赖 L2 落地 |
| S3 公告命令 | 公告用户重装 `dws skill setup --mode mono --yes`(安装入口,非 switch 产品) | 用户触达时延 | **可用**(无 `dws skill mode`;阶段 2 切换命令已 CANCELLED) |
| S4 备份回滚 | ~~`dws skill mode rollback` + 备份式安装~~ | — | **❌ CANCELLED(2026-08-05)** 与运行时切换一并取消 |
二进制侧另有既有的 `dws upgrade --rollback`(`internal/upgrade/rollback.go`,
备份在 `~/.dws/data/backups`、保留 5 份 `:16/:54-63`),但只回滚二进制不回滚
skill 布局——skill 止血靠 S1 + S3(重装 mono),**无** S4。
**结论(2026-08-05):** kill switch = S1(beta 撤回)+ S3(公告重装
`--mode mono`)。S4 已取消;日常 upgrade 有 multi 包时**一次性刷 multi**
(不做粘性),故「装完 mono 再 upgrade」会迁走——止血靠撤回坏包或重装。
---
## 3. 对标(简要)
**npm dist-tag 双轨(lark-cli 类企业内部 CLI 的通行形态)。** 以 npm 仓
registry 为唯一分发面时,灰度即 dist-tag:`latest` 稳态、`beta`/`next` 先行
(`next@canary`、`typescript@beta` 同款模式),安装侧 `npm i pkg@beta` 或
CI 指定 tag 即完成分群;撤回即 `npm dist-tag add pkg@<prev> latest` +
`npm deprecate`。DWS 已完整具备此形态(§1.1-K2),lark-cli 等内部 CLI 在
集团内网 registry 上亦按同一范式运作——差别只在内部 registry 可附带按
员工/部门灰度的下发规则,那是「registry 有服务端」的红利,DWS 面向公网
npm 没有这一层,故需 L2 补齐。
**安装时下载器内版本选择(deno / rustup 模式)。** `curl | sh` 安装器不显式
给版本时,先拉一个**可变 channel 指针文件**(如 deno 的
`dl.deno.land/release-latest.txt`、rustup 的 channel manifest),再按指针下载
真实产物——指针一改全量新装即转向,**不发版即可调流**。DWS 的 OSS
`latest.txt`/`beta.txt`(`sync-to-oss.sh:13-14`)已是同构物,只差安装脚本消费
它;install.sh 今天直接打 GitHub `/releases/latest` 重定向(`install.sh:190-198`),
等价于把 GitHub 当不可调指针用。此模式是指针级灰度,做不到机器级百分比,
需与 L2 分桶叠加。
**双产物并行(VS Code Stable / Insiders 模式)。** 两个渠道各发各的包、用户
自选安装,灰度靠「渠道人口结构」自然形成,无需任何运行时门控。DWS 的
GitHub prerelease + npm `@beta` 已是它的轻量版(同包不同 tag 而非两个包名),
L1 切法 B 的版本门控默认正是把「渠道差异」从纯流程下沉为可测试的代码事实。
---
## 4. 推荐路线与改动点清单
### 4.1 路线(与 roadmap 阶段 2/3 对齐后的排序)
```
L1 版本门控拆分(本工作区之上叠加,先合入)
→ ~~阶段 2 四件套~~ ❌ CANCELLED(无运行时切换;upgrade force-multi)
→ beta 轨发版先吃(L1 自动生效)+ L3 观察 ≥2 周
→ stable 切流:小步直接 100%(删门控);若要求渐进再上 L2 分桶
→ mono retirement 判据(roadmap:issue/回访,**无**请求头占比)
```
### 4.2 改动点清单(文件级)
| 项 | 文件 | 改动 | 估时 |
|---|---|---|---|
| L1-a Go 门控函数 | `internal/app/skill_setup.go`(或新 `internal/upgrade/track.go` 下沉共享) | `defaultSkillModeForVersion(version)`:含 `-beta.`→multi 否则 mono;替换非交互默认与交互排序 | 0.5d |
| L1-b upgrade force-multi(已落地) | `internal/upgrade/paths.go` `UpgradeSkillLocations` | 有 multi→始终 multi(含 mono 盘迁移);legacy 无 multi→mono | ✅ |
| L1-c 脚本面门控 | `scripts/install.sh` / `install.ps1` / `install-skills.sh` / `build/npm/install.js` | 默认值解析加 `*-beta.*` 分支(若仍做 L1) | 0.5d |
| L1-d 契约测试 | `test/scripts` / `internal/upgrade` / `internal/app` | 每面断言 beta→multi;upgrade mono→multi E2E | 0.5–1d |
| L3 请求头 | — | **❌ CANCELLED** | — |
| L2-a/b/c rollout.json | — | **已砍**(roadmap) | — |
| S3/S4 | — | S3=重装 mono(可用);S4 备份/rollback **❌ CANCELLED** | — |
合计:L1 ≈ 2–2.5d;L3 ≈ 0.5d;L2 ≈ 2.5–3d(在 state.json 之后)。
L1+L3 是进入 beta 观察期的最小集;L2 只在 stable 需要渐进切流时才启动,
否则删门控一步到位即可。
### 4.3 明确不做(沿用 roadmap/D2,本文补充)
- 不建服务端远程配置/灰度平台;不为灰度单独引入可变配置下发通道(OSS 变
指针仅作远期可选,今天无消费方)。
- 不动 `dws-skills.zip` 产物布局(D1);不按轨发不同 zip——轨差异全部落在
版本门控的客户端行为上。
- 不用遥测做灰度测量(K7 本地 opt-in 无统计意义),观测只走 K8 请求头。
-272
View File
@@ -1,272 +0,0 @@
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
>
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
>
> 对照仓:
>
> | 仓 | 路径 | 基线 |
> |---|---|---|
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
---
## 0. TL;DR
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
### 0.1 IN SCOPE
| 类别 | 包含 |
|---|---|
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
### 0.2 OUT OF SCOPE
| 类别 | 去向 |
|---|---|
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
| 把质检做成「改安装默认值」的后门 | 拒绝 |
---
## 1. 内容现状盘点
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
│ └── best_practices/…
└── scripts/
```
### 1.2 DWS `skills/multi`(内容主体)
```text
skills/multi/
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
└── dingtalk-*/ # 19 产品 + 各 references、scripts
```
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
### 1.4 Zip 内容布局合同
| Zip 路径 | 内容含义 |
|---|---|
| `<root>/` | mono 副本(兼容) |
| `<root>/mono/` | 显式 mono 内容源 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
### 1.5 现有 DWS skill 内容质检资产(复用清单)
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|---|---|---|
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
### 1.6 悟空侧类比质检
| 悟空 | 说明 | 本分支 |
|---|---|---|
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
---
## 2. Diff(内容组织 + 质检视角)
### 2.1 已同构
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
### 2.2 分叉与已知内容风险(质检要盯的)
| 风险 ID | 现象(线索) | 质检类型 |
|---|---|---|
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
### 2.3 Reject
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
---
## 3. Goals / Non-goals
### 3.1 Goals
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
### 3.2 Non-goals
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
---
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
### Phase 0 — 方案冻结(本文)
| | |
|---|---|
| **范围** | 本文件;§7(含质检轨)勾选 |
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
| | |
|---|---|
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
| **触达** | `docs/skill-content-framework.md` |
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
| | |
|---|---|
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
| | |
|---|---|
| **范围** | G1–G4 自动门禁 |
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
| | |
|---|---|
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
### 延期登记(非本分支)
| 主题 | 载体 |
|---|---|
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
| skillhome / 安装面 bootstrap | 行为分支 |
---
## 5. Port / Adapt / Reject
| 项 | 决策 | 说明 |
|---|---|---|
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
| 行为 cherry-pick | **Defer** | 另分支 |
---
## 6. 与 `402429ac` / `d5c8982c`
| | |
|---|---|
| 本分支 cherry-pick? | **否** |
| 质检是否替代行为翻转? | **否** |
| 行为分支 | 另开;可与内容/质检并行 |
---
## 7. 批准清单(请重新勾选)
**范围**
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
- [x] 取消产品与悟空客户端链路仍拒绝
**内容框架 Phase**
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
**质检轨 Phase**
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
**可选**
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
**Follow-up 知悉**
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
---
## 8. 下一步
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
安装默认 multi 等行为仍走 **另一分支**。
---
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
+340
View File
@@ -0,0 +1,340 @@
# DWS multi-skill vs 悟空(dws-wukong)分发线对比
> ⚠️ **留档注记(2026-08-05)**:悟空(dws-wukong)bundled-skill 分发线
> 已于 2026-08-05 决策下线。本文自此仅作**历史调研留档**,不再作为任何
> 对齐依据——文中的「判据 #3」「对齐清单(§7.1)」「待确认问题(§7.2)」
> 等均随悟空线下线而 MOOT。其中 bundle 的自描述打包设计
> (`manifest.json` + `scripts/_install.sh`)与 symlink 提升消费方式
> 可作为未来打包方案参考保留。
>
> 撰写日期:2026-08-05。聚焦 **multi-skill** 主题:DWS 侧(本工作区
> `feat/skill-mode-migration`)已把 multi 翻转为全通道默认,而 mono 下线
> 原判据 #3 曾要求"悟空 bundled skill 分发线(dws_res → bundled-skills)
> 已切 multi"([skill-multi-roadmap.md](skill-multi-roadmap.md) 阶段 4、
> [skill-multi-migration-plan.md](skill-multi-migration-plan.md) §5;
> 该判据已于 2026-08-05 随悟空线下线作废)。
> 本文盘清悟空线现状、两边差异、切换影响与对齐清单。
>
> 配套阅读:[skill-multi-roadmap.md](skill-multi-roadmap.md)(DWS 侧
> as-implemented 事实源)、[skill-distribution-mechanism.md](skill-distribution-mechanism.md)
> (DWS 分发/消费链路调研)。
## 0. 摘要(TL;DR)
- **DWS 侧**:multi(`skills/multi/`,19 个 `dingtalk-*` 产品 skill +
`dws-shared`)已是安装(4 脚本面)、升级、`dws skill setup` 五面默认;
产物 `dws-skills.zip` 恒含"根 mono 副本 + `mono/` + `multi/`"三树,
二进制 embed 双树(`skills_embed.go:28`)。**产物零改动即完成默认翻转**。
- **悟空侧**:multi 打包能力**已合入 dws-wukong `develop`**(merge
`9eb801e5`,"DWS MultiSkill 与 Qwen Work Cloud 六平台打包"),但
**正式发版 target `make real-platform` 仍只打 mono**
`dingtalk-workspace.zip`;multi 以 `dingtalk-workspace-bundle.zip` 双包
形态存在(`bundle-platform` / `package-dual`),本地有 2026-07-03 的双包
实测产物,但**未随已发布版本出门**(`release/0.2.97`–`0.2.99` 均不含该
merge)。
- **关键缺口在客户端**:RewindDesktop(悟空桌面端)构建期
`download_binary.py` 只认 `dingtalk-workspace.zip` 单 zip;运行时
`dws_update.rs` 灰度更新也只 upsert 单个 `dingtalk-workspace` skill;
全仓 grep 无 `dingtalk-workspace-bundle` / T4b 处理。**端内尚无消费
multi bundle 的代码路径**。
- **悟空线的 multi 与 DWS 的 multi 是两套独立维护的树**(dws-wukong
`dingtalk-skills/` 12 产品 + `dws-shared`,由本仓 mono 机械派生;DWS
`skills/multi/` 19 产品 + `dws-shared`),内容靠 SOP 人工对齐,无自动
同源。判据 #3 的"切 multi"首先要解决的是**打包形态 + 端内加载**,
内容同源是紧随其后的问题。
- 结论(历史):判据 #3 远未满足。对齐需要 dws-wukong 仓(发版 target)、
RewindDesktop 仓(构建期 + 运行时两条加载路径)两侧改动,详见 §7 清单。
**(2026-08-05 MOOT:悟空线下线,判据 #3 已作废——见
[skill-multi-roadmap.md](skill-multi-roadmap.md) 阶段 4 判据更新;
上述对齐工作不再需要。)**
## 1. 证据源与版本快照
本地仓库(均为真实磁盘证据,非仅凭文档):
| 仓库 | 本地路径 | 核查时状态 |
|---|---|---|
| DWS(本仓) | `~/GolandProjects/open-source/dws-skill-mode-migration` | `feat/skill-mode-migration`,含未 commit 的阶段 1 变更 |
| dws-wukong 主仓 | `~/GolandProjects/open-source/dws-wukong` | checkout `codex/deploy-qwenwork-dev`(落后 develop 602 commits);本文 Makefile/脚本结论均以 `develop` 分支内容(`git show develop:...`)为准 |
| dws-wukong multiSkill 工作区 | `~/GolandProjects/open-source/dws-wukong-multiSkill` | detached @ `9eb801e5`(multiSkill merge 本体),`target/` 有 2026-07 实测产物 |
| RewindDesktop | `~/IdeaProjects/RewindDesktop` | checkout `dws/0.2.98`;`develop` 上 `DEFAULT_DWS_RES_URL` = pod `0.2.96` |
关键版本事实:
| 事实 | 证据 |
|---|---|
| multiSkill merge `9eb801e5` 已入 `develop` 与本地 `release/0.2.100` | `git branch --contains 9eb801e5` |
| `release/0.2.97` / `0.2.98` / `0.2.99` **不含** multiSkill merge;其 `real-platform` 不打 bundle | `git merge-base --is-ancestor` + `git show origin/release/0.2.99:Makefile` |
| 当前发给悟空的 pod 包为 mono:`dws_res_mac.zip` 内仅 `dingtalk-workspace.zip`(单 skill,`SKILL.md`+`references/products/*`)+ 双架构二进制 | 主仓 `target/dws_res_mac.zip`(2026-06-01)`unzip -l` 实测 |
| 双包形态已实测:`dws_res_mac/` 同时含 `dingtalk-workspace.zip`(924K)与 `dingtalk-workspace-bundle.zip`(1.27M) | multiSkill 工作区 `target/dws_res_mac.zip`(2026-07-03)实测 |
| multi bundle 内部布局:`manifest.json` + `scripts/_install.sh` + `skills/<name>/SKILL.md...` 平铺目录 | multiSkill 工作区 `target/dingtalk-workspace.zip`(2026-07-24)实测 |
**本地未能验证**(详见 §7.2 问题清单):pod 线上当前包内容(需内网
SSO);`/Applications/Wukong.app` 未安装在本机(bundled-skills 目录不存在,
无法核对在端真实 zip);RewindDesktop 端"T4b 二选一"灰度逻辑(全仓无匹配,
疑似未开发或在平台侧);Qwen Work Cloud 六平台打包的实际发布状态。
## 2. 分发链路对比
### 2.1 链路全景
DWS 侧(本仓,multi 已默认):
```text
skills/mono/ ─┬─ go:embed all:skills/mono all:skills/multi (skills_embed.go:28)
skills/multi/ ┘ │ `dws skill setup` 默认源(embed 优先)
│
└─ scripts/release/post-goreleaser.sh:220-248 ──► dws-skills.zip
(根 = mono 副本 + mono/ + multi/,三树恒含)
│
GitHub Release / Gitee / OSS / npm tarball / Homebrew / 专项脚本
│
install.sh · install.ps1 · install-skills.sh · npm install.js(四面默认 multi)
+ `dws skill setup`(第五面默认 multi)+ `dws upgrade`(布局探测→multi 刷新)
│
各 agent home 平铺 dingtalk-*/(互斥清理 dws/ 与过期 skill)
+ ~/.dws/skills/{mono,multi} 双缓存
```
悟空侧(dws-wukong + RewindDesktop,发版线仍 mono):
```text
上游 CLI 仓 ../dingtalk-workspace-cli(go.mod replace,sync-upstream 按
CLI_UPSTREAM_TAG 重建 release 分支)──► 只提供 Go 代码,不提供 skill 内容
dws-wukong 仓内 skill 内容(自维护):
dingtalk-workspace/(mono 源,含 overlays/real)
├─ build-workspace-zip ──► dingtalk-workspace.zip(mono,单 skill)
└─ scripts/sync-monolith-to-multiskill.py ──► dingtalk-skills/(multi 派生树)
└─ scripts/build-bundle.sh ──► dingtalk-workspace-bundle.zip
(manifest.json + scripts/_install.sh + skills/<name>/)
make real-platform(发版默认)──► dws_res_{mac,win}.zip
= dws 二进制 + dingtalk-workspace.zip(仅 mono)
make bundle-platform / package-dual(已合入 develop,未用于正式发版)
= dws 二进制 + dingtalk-workspace.zip(mono 兜底)+ dingtalk-workspace-bundle.zip
│
pod.alibaba-inc.com zipUpload(SSO 浏览器上传,版本号独立递增)
│
RewindDesktop scripts/download_binary.py(DEFAULT_DWS_RES_URL 手工对齐)
│
Wukong.app Contents/Resources/resources/
├─ dws/bin/dws(二进制)
└─ bundled-skills/dingtalk-workspace.zip(原样拷贝的单 zip)
│
运行时两条路:
a) 启动同步 initialize_bundled_skills_from_resources
→ 解 zip 到中央技能库 ~/.real/.skills/bundled/dingtalk-workspace
(及 ~/.real/users/*/.skills/bundled)
b) 灰度自更新 dws_update.rs:Gaea 开关 wukong/dws_auto_update_enabled_v2
→ LWP /r/Adaptor/DwsGrayI/getLatest 取 {version,url,sha256}
→ 下载 dws_res → 换 seed 二进制 + 换 bundled zip + upsert 单 skill
```
### 2.2 链路对照表
| 环节 | DWS(本仓) | 悟空线 | 锚点(悟空侧) |
|---|---|---|---|
| skill 事实源 | `skills/mono` + `skills/multi`(同仓双树,multi 19 产品 + dws-shared) | dws-wukong 仓 `dingtalk-workspace/`(mono 源)→ 派生 `dingtalk-skills/`(12 产品 + dws-shared);**与上游 skills/ 无自动同步** | `scripts/sync-monolith-to-multiskill.py` docstring |
| 二进制与 skill 的版本耦合 | embed 进二进制,天然同版 | 二进制来自上游 tag(`go.mod:58` replace + `sync-upstream` pin `CLI_UPSTREAM_TAG`);skill 在 dws-wukong 仓随 `VERSION`/`main.go` 双写发版 | dws-wukong `Makefile` `sync-upstream` |
| 打包产物 | `dws-skills.zip`:根 mono 副本 + `mono/` + `multi/`(`post-goreleaser.sh:220-248`);embed 双树 | `dws_res_{mac,win}.zip`:二进制 + `dingtalk-workspace.zip`(mono);双包 target 已存在但未上发版线 | dws-wukong `Makefile` `real-platform` / `bundle-platform` / `package-mac-dual` |
| 渠道 | GitHub/Gitee/OSS/npm/Homebrew/专项脚本,7 个安装面 | pod zipUpload(SSO)→ RewindDesktop `download_binary.py` → 客户端 bundle | release skill 文档 + `download_binary.py:72-84` |
| 端内安装 | 4 脚本 + `dws skill setup` 平铺到 16 个 agent home(父目录门控) | 构建期拷贝 zip 进 app 资源;启动时解到 `~/.real/.skills/bundled/` | `startup.rs:486-554` |
| 运行时更新 | `dws upgrade`(布局探测→multi 刷新 + 互斥清理 + 缓存刷新) | 客户端灰度自更新(Gaea + LWP),整包替换 seed 二进制 + skill zip | `dws_update.rs:107,27-28,440-545` |
| 灰度能力 | 无服务端:beta 轨(L1)+ 可选 `rollout.json` 分桶(L2)+ 公告 kill switch(决策 D2) | 有服务端:Gaea 开关 + LWP getLatest + pod 版本号 | 决策记录 D2 vs `dws_update.rs` |
## 3. skill 布局对比
### 3.1 三种形态
| 形态 | 布局 | 消费方 |
|---|---|---|
| DWS mono | 单 skill:`SKILL.md` + `references/` + `scripts/`,装进 `<agent-home>/dws/` | DWS legacy 安装面;悟空 `dingtalk-workspace.zip` 同构(多 `plugins/`、real overlay) |
| DWS multi | 平铺目录树:`multi/dingtalk-<product>/{SKILL.md,references,scripts}` + `multi/dws-shared/`,无 manifest、无安装器,拷贝即平铺到 agent home | DWS 五面默认;`dws upgrade` 探测 `multi/` 树(`internal/upgrade/paths.go:363-369`) |
| 悟空 multi bundle | zip 内 `manifest.json`(`{"version":...}`)+ `scripts/_install.sh` + `skills/<name>/` 平铺目录(含 `dws-shared`) | **Qwen Work Cloud 已消费**(`_install.sh` 把 `skills/*` 以 symlink 提升到一层 skills 根,供 Codex/OpenCode 扫描;`.dws-multiskill-current` + `.dws-multiskill-links` 记账);**Wukong.app 尚未消费** |
注意两种 multi 的"平铺"语义不同:DWS multi 是**裸目录树**,由安装面自己
拷贝到各 agent home;悟空 bundle 是**自描述包**(manifest + 安装脚本),
由消费方解包后提升。`dws-skills.zip` 的 `multi/` 树与
`dingtalk-workspace-bundle.zip` 的 `skills/` 树**布局同构但内容不同源**
(见 §3.3)。
### 3.2 悟空客户端加载约定(RewindDesktop 实测)
构建期(`scripts/download_binary.py`):
- `DWS_RES_WORKSPACE_ZIP = "dingtalk-workspace.zip"`(`:103`),
`resolve_dws_res_contents`(`:1366-1380`)强制 dws_res 内必须同时有
平台二进制和**这个文件名的 zip**——`dingtalk-workspace-bundle.zip` 会被
原样忽略(不报错,但也不使用)。
- `sync_dingtalk_workspace_bundle`(`:1422-1432`)把该 zip **原样拷贝**到
`tauri-app/src-tauri/resources/bundled-skills/dingtalk-workspace.zip`,不解包。
运行时(`tauri-app/src-tauri/src/skills/startup.rs`):
- `collect_bundled_skill_sources`(`:486-506`)扫描 `resources/bundled-skills/`:
**每个子目录或每个 `.zip` = 一个 skill**,`skill_id = 文件主干名`
(`dingtalk-workspace.zip` → skill id `dingtalk-workspace`)。
该扫描**天然支持多 skill 平铺**(放 `dingtalk-mail.zip`、`dingtalk-doc.zip`
就会被分别注册),但**不认识嵌套 bundle**:若把
`dingtalk-workspace-bundle.zip` 丢进去,只会被当成一个名叫
`dingtalk-workspace-bundle` 的单 skill 解开(内容是 manifest+skills/ 目录,
不会被提升)。
- `sync_bundled_skill_source`(`:533-554`)解 zip 拷贝进中央技能库;
`cleanup_removed_bundled_skills`(`:508-531`)会删除 bundled-skills 里
已不存在的 bundled skill(mono→multi 切换时可自动清掉旧
`dingtalk-workspace`,前提是 store 记录完好)。
- 灰度自更新 `dws_update.rs:440-545` 硬编码单 skill:
`upsert_bundled_skill_from_source(store, DWS_WORKSPACE_SKILL_ID, …)`
(`DWS_WORKSPACE_SKILL_ID = "dingtalk-workspace"`),换包 = 替换
`bundled-skills/dingtalk-workspace.zip` + 重 upsert 这一个 skill。
结论:端内**构建期与运行时两条路都按"单 zip 单 skill"接线**;要支持
multi,二选一:(a) 端内学会解 bundle(manifest + 提升子 skill),或
(b) 打包侧把每个子 skill 打成独立 zip 平铺进 dws_res,复用现有平铺扫描
(仅需把 `dws_update.rs` 的单 skill upsert 改为遍历)。详见 §6.2。
### 3.3 两套 multi 树的集合差异
| | DWS `skills/multi/` | dws-wukong `dingtalk-skills/`(develop) |
|---|---|---|
| 产品 skill 数 | 19 | 12 |
| 共有(12 个) | aisearch, aitable, calendar, chat, contact, doc, drive, mail, minutes, misc, todo, wiki | 同左 |
| 仅 DWS 有(7 个) | dev, event, hrbrain, markdown, pat, profile, skill | — |
| 共享层 | `dws-shared` | `dws-shared`(内容独立维护) |
| 内容来源 | 本仓直接维护 | 由本仓 mono `dingtalk-workspace/` 经 `sync-monolith-to-multiskill.py` 机械派生(链接改写 + misc 桶归并) |
| 场景 skill | 不涉及 | `dingtalk-products-skills/` 23 个 scenario skill **仅保留源码,不进统一发布包**(`build-bundle.sh` 头注释) |
含义:即使悟空线明天切到 multi 形态,其 multi **内容**与 DWS multi 也不
一致(少 7 个产品、各自演化)。判据 #3 只要求"分发线切 multi"(形态),
但长期看内容同源(或明确的子集契约)需要一并决策。
**(2026-08-05 MOOT:悟空线下线,"DWS skills/multi(19) vs 悟空
dingtalk-skills(12) 分歧"的长期统一问题随之作废,无需统一;DWS
`skills/multi/` 成为唯一 multi 事实源。)**
## 4. 版本对齐对比
| 维度 | DWS | 悟空线 |
|---|---|---|
| skill↔CLI 耦合 | embed 进二进制,`dws skill setup` 装的就是本二进制版本(`skills_embed.go` + `skill_setup_embed.go`) | 二进制版本 = 上游 tag(`sync-upstream` pin);skill 版本 = dws-wukong `Makefile VERSION` + `main.go version` 双写;**两者只通过"同一次发版动作"对齐,无结构性强约束** |
| 产物版本 | `dws-skills.zip` 随 goreleaser 与二进制同 tag 发布 | pod 版本号独立于 dws 版本(右most 段 +1 递增,mac/win 各自一条线,如 mac `0.2.28.0`、win `0.2.2`);RewindDesktop `DEFAULT_DWS_RES_URL` 手工改指 |
| 端内版本事实源 | — | 客户端以 `dws --version` 输出为准(`get_dws_version_sync`,失败回退 `versions.json`);skill zip 无独立版本概念(mono zip 内无 manifest) |
| multi 包版本 | 无 manifest;版本 = 所属 zip/二进制版本 | bundle 内 `manifest.json` 带 `version`(`build-bundle.sh` 由 `$(VERSION)` 写入)——**multi 形态反而第一次给 skill 包带来了显式版本号** |
| 升级时的布局兼容 | 新 zip 恒含 `multi/`,`LocateSkillsRoot` 优先 multi;老 zip 自然落回 mono(决策 D1,产物零改动) | dws_res 布局固定(二进制 + workspace zip);双包形态下 mono zip 保留作兜底(`package-mac-dual` 注释:"端内 validate 必含,bundle 缺失时兜底") |
## 5. 更新 / 回滚对比
| 维度 | DWS | 悟空线 |
|---|---|---|
| 更新触发 | 用户主动 `dws upgrade` / 重装脚本 | 两条:(a) 随客户端版本更新(app 内嵌资源替换 + 启动同步);(b) 运行时灰度自更新(Gaea `dws_auto_update_enabled_v2` + LWP getLatest → 下载整包 → 换 seed 二进制 + 换 bundled zip + upsert skill) |
| skill 刷新语义 | 布局探测(multi 优先)+ 按 home 互斥清理(删 `dws/`、过期 `dingtalk-*`/`dws-shared`),清理失败则该 home 不装,杜绝共存(`internal/upgrade/paths.go:217-299`) | 启动同步按 bundled-skills 现状全量对账(新增拷贝、缺失删除,`startup.rs:508-554`);自更新路径是单 zip 替换 + 单 skill upsert(`dws_update.rs:462-513`) |
| 回滚 | `dws upgrade --rollback` 只回二进制不回 skill;备份式安装 + `dws skill mode rollback` 是阶段 2 P0,**尚未落地** | 无显式回滚命令;事实回滚 = Gaea 开关关闭/改指旧 pod 版本重新下发,或客户端版本回退;`replace-wukong-skill.sh`(仅存在于 `codex/deploy-qwenwork-dev` 分支)提供人工替换 + 重启 |
| 半装保护 | 现状 `RemoveAll` 直删、失败仅 warning(风险表已列,阶段 2 改备份式) | zip 整体替换 + preflight 可写性检查,失败提示重启;粒度为整个 skill 包,无子 skill 级半装概念 |
| 离线/内网 | embed 兜底(无网可 setup) | app 内嵌 zip 兜底;灰度通道依赖内网 LWP/pod 可达 |
## 6. 切换影响分析
### 6.1 DWS 切 multi 默认 / 最终删 mono 对悟空线的影响点
| # | 影响点 | 评估 |
|---|---|---|
| 1 | DWS 删 `skills/mono` 后上游 embed 只剩 multi;悟空二进制由上游 tag 构建,`dws skill setup` 行为随之变 | **低**。悟空客户端不从 embed 装 skill(走 bundled zip);但悟空用户在端内手动跑 `dws skill setup` 时会得到 multi——行为变化需在悟空侧公告 |
| 2 | `dws-skills.zip` 布局(根 mono + mono/ + multi/) | **零影响**。悟空线不消费 `dws-skills.zip`;DWS 侧也已承诺不动该产物(决策 D1、"明确不做") |
| 3 | dws-wukong 的 mono 内容源 `dingtalk-workspace/` 与上游 `skills/mono` 本就各自维护 | **低(但需注意)**。上游删 mono 不会直接打破 dws-wukong 构建;但两边 mono 的"内容漂移对照基准"消失,dws-wukong mono 将彻底成为孤儿副本,加速与上游 CLI 能力的文档漂移 |
| 4 | mono 下线判据 #3 反向卡住 DWS 侧进度 | **高(流程性)**。悟空线一天不切 multi,DWS 就不能物理删 `skills/mono/`(roadmap 风险表"悟空线外挂"行)。**(已解除 2026-08-05:悟空线下线,判据 #3 作废,DWS 侧进度不再受仓外闸门约束)** |
### 6.2 悟空线"吃 multi"的改造选项
**选项 A:端内解 bundle(dws-wukong 现有 bundle 产物直接被消费)**
- dws-wukong 侧:`real-platform` 改打(或加打)`dingtalk-workspace-bundle.zip`
——`bundle-platform` / `package-dual` 已就绪,基本零新开发。
- RewindDesktop 侧(主要工作量):
- `download_binary.py`:`resolve_dws_res_contents` 接受/校验
`dingtalk-workspace-bundle.zip`,同步进 `resources/bundled-skills/`;
- `startup.rs`:识别 bundle(`manifest.json` + `skills/*`),把每个子
skill 注册为独立 bundled skill(逻辑等价于 `_install.sh` 的提升,
但落在中央技能库);
- `dws_update.rs`:灰度自更新从"单 skill upsert"改为"bundle 全量对账"
(可复用启动同步的对账逻辑)。
- 优点:与 Qwen Work Cloud 已消费的包形态一致,一份产物两个端;bundle 自
带 `manifest.json` 版本号。
- 缺点:端内要新增 bundle 解析/提升代码与测试;`skills/` 嵌套布局与现有
"一 zip 一 skill"约定不同,需谨慎处理迁移期(旧 mono skill 清理)。
**选项 B:打包侧拆 zip(端内零新格式)**
- dws-wukong 侧:新增 target 把 `dingtalk-skills/` 每个子 skill 打成独立
zip(`dingtalk-mail.zip` … `dws-shared.zip`)平铺进 dws_res。
- RewindDesktop 侧:`download_binary.py` 改为同步多个 zip;
`startup.rs` 现有平铺扫描**零改动**(自动注册每个 zip);
`dws_update.rs` 仍需从单 skill upsert 改为遍历。
- 优点:复用端内现有"一 zip 一 skill"约定,启动路径几乎不动。
- 缺点:与 Qwen Work 的 bundle 形态分叉(一份内容两种包);dws_res 内文件
数膨胀;`dws-shared` 作为独立 skill id 出现在用户可见列表里需要确认
端内展示策略。
**选项 C(过渡态,事实已在用)**:双包并存——mono zip 兜底 + bundle 灰度,
端内按灰度二选一。`package-dual` 的注释已写明此意图("端内 T4b 二选一,
monolith 端内 validate 必含,bundle 缺失时兜底"),但**端内 T4b/灰度选择
逻辑在 RewindDesktop 尚未找到实现**,当前双包发出去也只会用 mono。
### 6.3 mono 下线判据 #3 的建议验收方式
> **(2026-08-05 MOOT:判据 #3 已随悟空线下线作废,本节验收清单不再
> 适用,仅留档。)**
判据原文:"悟空 bundled skill 分发线(dws_res → bundled-skills,本仓库
外)已切 multi"(原 [skill-multi-migration-plan.md](skill-multi-migration-plan.md)
§5-3;该判据已于 2026-08-05 作废,plan §5 已重新编号)。建议按以下
可核查项验收(全绿才算满足):
1. **发版**:dws-wukong 正式 release 流程(release skill 文档中的
`make real-platform` 路径)产出的 dws_res 内含 multi 形态包(bundle 或
平铺 zip 集),且 pod 上当前版本即为该形态。
2. **构建期消费**:RewindDesktop `develop` 的 `download_binary.py` 把 multi
形态同步进 `bundled-skills/`(不再是只认 `dingtalk-workspace.zip`)。
3. **运行时消费**:悟空端启动同步后,`~/.real/.skills/bundled/` 下出现
`dingtalk-*` 多 skill(而非单个 `dingtalk-workspace`);灰度自更新路径
同样支持多 skill 对账。
4. **实机回归**:全新安装悟空 → 技能列表出现各 `dingtalk-*` skill 且
路由正常;从 mono 旧版升级 → 旧 `dingtalk-workspace` 被清理、无双份
派发;灰度通道下发一次 multi 包 → 更新后无残留。
5. **回退预案**:悟空侧保留 mono 兜底产物或快速重发能力,直至 DWS 删
mono 窗口关闭。
## 7. 结论
### 7.1 对齐清单(悟空侧待办,按优先级)
> **(2026-08-05 MOOT:悟空线下线,本清单整体不再需要执行,仅留档。)**
| 优先级 | 事项 | 仓库/位置 | 备注 |
|---|---|---|---|
| P0 | 决策端内 multi 消费方案(选项 A 解 bundle vs 选项 B 平铺 zip) | RewindDesktop + dws-wukong 联合 | 建议 A:与 Qwen Work 已消费形态一致,且 bundle 带版本 manifest |
| P0 | `download_binary.py` 支持 multi 形态同步进 bundled-skills | RewindDesktop `scripts/download_binary.py:103,1366-1432` | 选项 A 下识别 `dingtalk-workspace-bundle.zip` |
| P0 | 启动同步/技能库支持 bundle 解包与子 skill 注册 | RewindDesktop `tauri-app/src-tauri/src/skills/startup.rs:486-554` | 含旧 mono skill 的迁移清理(现有 `cleanup_removed_bundled_skills` 可复用语义) |
| P0 | 灰度自更新支持 multi(单 skill upsert → 多 skill 对账) | RewindDesktop `.../dws_update.rs:440-545` | 否则运行时更新会把 multi 打回 mono |
| P1 | 正式发版 target 切 multi(`real-platform` 改打/加打 bundle,或改用 `bundle-platform`/`package-dual`) | dws-wukong `Makefile` | 打包能力已在 develop,缺的是设为默认 + release skill 文档同步更新 |
| P1 | 端内灰度选择逻辑落地(双包二选一/T4b,或确认直接全量切) | RewindDesktop(未找到现有实现) | 若选选项 C 过渡则必须 |
| P1 | 两套 multi 树的内容同源策略:dws-wukong `dingtalk-skills/`(12 产品)vs DWS `skills/multi/`(19 产品) | dws-wukong + 本仓 | **MOOT(2026-08-05)**:悟空线下线,无需统一;原备注:至少明确"悟空子集"契约与同步 SOP 的归属;7 个缺失产品(dev/event/hrbrain/markdown/pat/profile/skill)是否需要进悟空 |
| P2 | `replace-wukong-skill.sh` 等运维脚本支持 bundle 形态 | dws-wukong `scripts/deploy/` | 当前只在特性分支且只处理单 zip |
| P2 | 悟空侧公告:端内 `dws skill setup` 行为随上游 embed 变化 | dws-wukong 发版流程 | 对应 §6.1 影响点 1 |
| P2 | 判据 #3 验收清单(§6.3)写入 DWS roadmap 并跟踪 | 本仓 `docs/skill-multi-roadmap.md` | 阶段 3 期间启动 |
### 7.2 待确认问题(本地无法闭环,需找人/仓库确认)
| # | 问题 | 建议确认方 |
|---|---|---|
| 1 | pod 线上当前 `dws_res_mac/win` 的版本与内部构成(是否已有人发过双包) | pod.alibaba-inc.com(需内网 SSO)/ 悟空发版 owner |
| 2 | "端内 T4b 二选一"灰度逻辑是否已存在(在哪个仓库/平台),还是仅写在 Makefile 注释里的规划 | RewindDesktop 团队 / 悟空端内灰度平台 owner |
| 3 | LWP `/r/Adaptor/DwsGrayI/getLatest` 服务端返回的下载 URL 指向何处(pod?另一制品库?),multi 包下发是否需要服务端配合改造 | Adaptor/DwsGrayI 服务端 owner |
| 4 | Qwen Work Cloud 六平台打包的发布状态与其对 bundle 的消费方式是否可作为悟空端改造的直接参照 | dws-wukong 仓 owner(merge `9eb801e5` 提交者) |
| 5 | 悟空端内是否允许 `dws-shared` 作为独立 bundled skill 暴露(名称/展示/路由策略),还是应内联进各产品 skill | RewindDesktop 技能库 owner |
| 6 | dws-wukong `dingtalk-skills/` 与上游 `skills/multi/` 的长期关系:保持派生自本仓 mono,还是改为从上游 multi 同步(**MOOT 2026-08-05**:悟空线下线,无需统一) | dws-wukong + DWS 双侧 owner 联合决策 |
| 7 | 悟空线切换的目标时间窗(决定 DWS 阶段 4 判据 #3 的最早可满足点)(**MOOT 2026-08-05**:判据 #3 已作废) | 悟空发版 owner |
---
*本文所有"已验证"结论均可按 §1 的仓库路径与文中锚点复查;未能本地验证
的项集中在 §7.2。*
-108
View File
@@ -1,108 +0,0 @@
# DWS 统一命令框架设计概要
> 状态:Framework core 已实现,dingtalk-dev/devapp 首批命令渐进接入中。本文定义框架能力、集成边界和首批 pilot 的发布纪律;其余产品命令迁移、Skill 更新和真实服务复验继续由后续 PR 独立完成。
## 1. 产品裁决
1. 不公开 `--output-contract`,也不增加任何等价别名。
2. Agent 继续只使用既有 `--format json`。
3. 每条 terminal command 在一个 release 中只有一个 active wire contract:已迁移命令直接使用统一结果,未迁移命令保持 legacy。
4. contract 不由用户参数、环境变量、会话能力协商或 Agent 选择。
5. 回滚是命令声明与发布行为,不改变消费者 argv。
6. 本 PR 只迁移完成命令级兼容审计的 dingtalk-dev/devapp pilot;其他命令路径、参数和输出保持不变。
## 2. 渐进迁移
内部状态机:
```text
legacy_only -> dual_validate -> unified_active -> unified_stable -> unified_only
```
- `legacy_only`:只构造、输出 legacy。
- `dual_validate`:业务只执行一次;外部仍逐字输出 legacy;同一内存结果 shadow-build 统一结果并严格校验。
- `unified_active`:`--format json` 直接返回统一结果信封,可按发布声明回退。
- `unified_stable`:完成真实 Agent 消费观察和兼容窗口。
- `unified_only`:清理仅服务 legacy 的产品 renderer。
状态是每条 terminal command 的内部发布元数据。Help、Skill、Agent Schema 不展示迁移状态,也不让消费者选择协议。
## 3. 统一结果
统一命令框架表达四类结果:
```text
success 请求完成且命令认为操作已完成
pending 请求被受理,但异步操作尚未终结
partial_failure 批量操作有成功项,也有失败或未知项
failure 请求或操作失败
```
JSON 基本形态:
```json
{
"ok": true,
"outcome": "success",
"data": {}
}
```
硬不变量:
```text
ok == (outcome in {success, pending})
process rc == 0 <=> ok == true
top-level error present <=> outcome == failure
one invocation emits exactly one primary result
```
框架负责 L1 request outcome 和 L2 operation outcome 的统一表达;L3 verification 必须由产品命令基于业务事实实现,框架不得自动推断 `changed/verified`。
## 4. 输出与错误纪律
- 统一 JSON primary result 写 stdout;stderr 只写诊断。普通命令不把
NDJSON 作为通用结果契约;持续事件流若需要逐事件输出,由 event 命令
自己声明专用流协议。
- 分页统一输出到信封 `meta.pagination`,并在命令 Schema 中作为与 `result`
同级的 `pagination` 能力声明;`result.data_schema` 只描述业务 data,不再
混入分页控制字段。
- 日志不得污染 stdout。
- `ok`、`retryable`、`dry_run` 等必须是 JSON boolean。
- 失败由框架根据 typed error 映射退出码;产品代码不能自报任意 rc。
- `partial_failure` 保留 `succeeded[]/failed[]/unknown[]`,使用非零 rc 7。
- `pending` 必须提供 operation id、state 和可执行的 `next_command`。
- `endpoint_exhausted` 只表示观察到当前 endpoint 分页耗尽;false 必须带 `next_token`,不得扩大成索引健康或业务数据完整。
- dry-run 是已经完成的无副作用预览,表达为 `success + dry_run:true`,不是 `pending`。
## 5. 重试与超时边界
- 框架只统一表达 `retryable`、`retry_after_seconds` 和 `execution_started`,不自动决定业务操作能否安全重放。
- 写调用的模糊失败、HTTP timeout 和异步等待预算属于 transport/产品集成范围,不在本 PR 改动。
- 产品迁移必须证明其重试声明与幂等性、安全等级一致。
## 6. 集成范围
- 产品命令通过 `corecmd.ResultInvoke` 构造 `CommandResult`,由 root 单一出口渲染。
- 首批 dingtalk-dev/devapp 命令用于验证原子命令与 shortcut 的接入缝;未进入 pilot 的 shortcut、长连接、批量写和异步任务各自需要独立集成 PR。框架 core 不替产品推断 success、pending、partial 或分页事实。
- 每条 terminal command 独立 rollout;不能整域一次切换,也不能通过 Agent 参数选择协议。
- 已有命令在进入 `unified_active` 前必须保留 legacy byte golden,并完成真实 Agent 语义扫描。
## 7. 对齐原则
- 对齐 Lark CLI:统一 envelope/emitter、typed error、partial、pending、分页窄语义和强类型结果。
- 对齐 GWS:机器结果稳定结构化、日志与数据分流、消费者不协商协议版本。
- DWS 保留差异:声明式 Agent Schema、安全门禁、静态命令与 shortcut 共存,以及四 outcome 模型。
## 8. 发布门禁
命令晋级 `unified_active` 前至少满足:
1. success/failure/dry-run golden;批量或异步命令另有 partial/pending golden。
2. 业务请求 exactly once;dual validation 不得二次调用服务端。
3. legacy 命令 stdout/stderr/rc 字节级回归不变。
4. Help、Schema 和全仓示例不存在协议选择参数。
5. `--format json` 输出单个合法统一结果文档,stdout 无日志污染。
6. typed error、进程 rc 与信封 `error.exit_code` 一致。
7. 安全声明、确认门禁与 dry-run 运行时行为同源。
8. Agent 语义扫描记录命令级迁移证据;发布回滚无需修改 Agent argv。
+9 -7
View File
@@ -335,6 +335,12 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }})
captureRuntimeFailure(executor.Invocation{}, nil, nil)
captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), nil)
oldArgs := os.Args
os.Args = []string{"dws", "doc", "download", "--node", "n"}
if got := runtimeCommandPath(executor.Invocation{}); len(got) != 2 {
t.Fatalf("runtime command path = %#v", got)
}
os.Args = oldArgs
t.Setenv(authpkg.AgentCodeEnv, "")
if hostControlProviderFromEnv() != "" {
@@ -354,10 +360,6 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
command.SetOut(io.Discard)
rootWrap := &cobra.Command{Use: "dws"}
rootWrap.PersistentFlags().String("format", "json", "")
rootWrap.AddCommand(command)
command.SetOut(io.Discard)
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
@@ -387,18 +389,18 @@ func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.
for _, format := range []string{"json", "pretty", "table"} {
_ = cacheRoot.PersistentFlags().Set("format", format)
cacheCmd.SetOut(io.Discard)
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); err != nil {
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
t.Fatal(err)
}
}
fail := errors.New("write")
cacheCmd.SetOut(appFailWriter{err: fail})
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
t.Fatalf("pretty write error = %v", err)
}
_ = cacheRoot.PersistentFlags().Set("format", "table")
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
t.Fatalf("table write error = %v", err)
}
}
+11 -22
View File
@@ -21,10 +21,6 @@ import (
"github.com/spf13/cobra"
)
const cacheUnsupportedMessage = "dws cache 不再支持:服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。"
const cacheReplacementHint = "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。"
type cacheCompatNotice struct {
Status string `json:"status"`
Command string `json:"command"`
@@ -32,32 +28,24 @@ type cacheCompatNotice struct {
Replacement string `json:"replacement,omitempty"`
}
// newCacheCommand keeps a visible Deprecated compatibility surface for
// historical argv (refresh/status/clean). Behavior is a successful no-op notice.
// Skills must not teach this path. Deprecated leaves are excluded from Schema
// via cobra.IsAvailableCommand() — do not add schema_command_exclusions entries.
func newCacheCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "不再支持:服务发现缓存兼容入口",
Long: "此命令组仅为历史 argv 兼容保留。静态端点模式下无需服务发现缓存;Skill / Agent 请勿引导此路径。",
Deprecated: "不再支持;" + cacheUnsupportedMessage,
Args: cobra.NoArgs,
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
Hidden: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printCacheCompatNotice(cmd, "dws cache")
return cmd.Help()
},
}
for _, name := range []string{"refresh", "status", "clean"} {
subName := name
sub := &cobra.Command{
Use: subName,
Short: "不再支持:静态端点模式无需服务发现缓存",
Deprecated: "不再支持;" + cacheUnsupportedMessage,
Use: name,
Short: "已弃用:静态端点模式无需服务发现缓存",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printCacheCompatNotice(cmd, "dws cache "+subName)
return printCacheCompatNotice(cmd, name)
},
}
cmd.AddCommand(sub)
@@ -68,9 +56,9 @@ func newCacheCommand() *cobra.Command {
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
notice := cacheCompatNotice{
Status: "deprecated",
Command: command,
Message: cacheUnsupportedMessage,
Replacement: cacheReplacementHint,
Command: "dws cache " + command,
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
switch strings.ToLower(strings.TrimSpace(format)) {
@@ -78,7 +66,8 @@ func printCacheCompatNotice(cmd *cobra.Command, command string) error {
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
case "pretty":
data, _ := json.MarshalIndent(notice, "", " ")
_, err := fmt.Fprintln(cmd.OutOrStdout(), string(data))
var err error
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
return err
default:
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
-111
View File
@@ -1,111 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageCacheDeprecatedCompatShim(t *testing.T) {
root := NewRootCommand()
group := mustFindCommand(t, root, "cache")
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
t.Fatalf("cache group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
}
if group.IsAvailableCommand() {
t.Fatal("deprecated cache group must not be IsAvailableCommand")
}
for _, leaf := range []string{"refresh", "status", "clean"} {
cmd := mustFindCommand(t, root, "cache", leaf)
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
t.Fatalf("cache %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
}
if cmd.IsAvailableCommand() {
t.Fatalf("deprecated cache %s must not be IsAvailableCommand", leaf)
}
}
var out bytes.Buffer
cmd := NewRootCommand()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
}
got := out.String()
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "不再支持", "服务发现已下线"} {
if !strings.Contains(got, want) {
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
}
}
for _, format := range []string{"", "json", "pretty", "table"} {
var buf bytes.Buffer
parent := &cobra.Command{Use: "dws"}
parent.PersistentFlags().String("format", format, "")
parent.SetOut(&buf)
sub := &cobra.Command{Use: "cache"}
parent.AddCommand(sub)
if err := printCacheCompatNotice(sub, "dws cache status"); err != nil {
t.Fatalf("format=%q: %v", format, err)
}
text := buf.String()
if !strings.Contains(text, "不再支持") && !strings.Contains(text, "服务发现已下线") {
t.Fatalf("format=%q missing notice:\n%s", format, text)
}
if format == "" || format == "json" || format == "pretty" {
if !strings.Contains(text, `"status":"deprecated"`) && !strings.Contains(text, `"status": "deprecated"`) {
t.Fatalf("format=%q missing deprecated JSON status:\n%s", format, text)
}
}
}
for _, format := range []string{"pretty", "table"} {
parent := &cobra.Command{Use: "dws"}
parent.PersistentFlags().String("format", format, "")
parent.SetOut(failWriter{})
sub := &cobra.Command{Use: "cache"}
parent.AddCommand(sub)
if err := printCacheCompatNotice(sub, "dws cache clean"); err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
}
}
parent := newCacheCommand()
var parentOut bytes.Buffer
rootWrap := &cobra.Command{Use: "dws"}
rootWrap.PersistentFlags().String("format", "json", "")
rootWrap.SetOut(&parentOut)
rootWrap.AddCommand(parent)
parent.SetOut(&parentOut)
if err := parent.RunE(parent, nil); err != nil {
t.Fatalf("cache parent RunE = %v, want nil success", err)
}
if !strings.Contains(parentOut.String(), `"command":"dws cache"`) {
t.Fatalf("cache parent notice missing command:\n%s", parentOut.String())
}
cache := newCacheCommand()
cache.SetOut(&bytes.Buffer{})
cache.SetArgs([]string{"status"})
if err := cache.Execute(); err != nil {
t.Fatal(err)
}
}
@@ -1,84 +0,0 @@
// 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 (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
func TestCrossPlatformCoverageChatMessageReceiptActionsBindToRunnableCommands(t *testing.T) {
testCases := []struct {
name string
payload map[string]any
}{
{
name: "send receipt awaiting status",
payload: chatmsg.ProjectMessageSendReceipt(map[string]any{
"openTaskId": "task-pending",
}),
},
{
name: "send receipt ready for message actions",
payload: chatmsg.ProjectMessageSendReceipt(map[string]any{
"openTaskId": "task-ready",
"openMessageId": "message-ready",
"openConversationId": "conversation-ready",
}),
},
{
name: "send status awaiting message reference",
payload: chatmsg.ProjectMessageSendStatus(map[string]any{
"status": "PENDING",
}, "task-pending"),
},
{
name: "send status ready for message actions",
payload: chatmsg.ProjectMessageSendStatus(map[string]any{
"openTaskId": "task-ready",
"openMessageId": "message-ready",
"openConversationId": "conversation-ready",
"status": "SUCCESS",
}, "task-ready"),
},
}
root := NewRootCommand()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
actions, ok := testCase.payload["nextActions"].([]map[string]any)
if !ok || len(actions) == 0 {
t.Fatalf("nextActions = %#v, want non-empty []map[string]any", testCase.payload["nextActions"])
}
for index, action := range actions {
cliPath, ok := action["cliPath"].(string)
if !ok || strings.TrimSpace(cliPath) == "" {
t.Fatalf("nextActions[%d].cliPath = %#v, want non-empty string", index, action["cliPath"])
}
command, remaining, err := root.Find(strings.Fields(cliPath))
if err != nil {
t.Fatalf("nextActions[%d].cliPath %q does not bind: %v", index, cliPath, err)
}
if command == nil || len(remaining) != 0 || !command.Runnable() {
t.Fatalf("nextActions[%d].cliPath %q resolved to command=%v remaining=%v runnable=%v", index, cliPath, command, remaining, command != nil && command.Runnable())
}
arguments, ok := action["arguments"].(map[string]any)
if !ok {
t.Fatalf("nextActions[%d].arguments = %#v, want map[string]any", index, action["arguments"])
}
for name := range arguments {
if command.Flags().Lookup(name) == nil && command.InheritedFlags().Lookup(name) == nil {
t.Errorf("nextActions[%d] argument %q is not a flag of runnable command %q", index, name, cliPath)
}
}
}
})
}
}
@@ -1,301 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
_ "embed"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
//go:embed testdata/shortcut_hallucination_badcases_20260728.json
var shortcutHallucinationBadcases20260728JSON []byte
type shortcutHallucinationReplayCorpus struct {
SourceReport string `json:"source_report"`
SourceFile string `json:"source_file"`
SourceDWSCommit string `json:"source_dws_commit"`
Model string `json:"model"`
ExpectedCount int `json:"expected_count"`
Badcases []shortcutHallucinationReplayBadcase `json:"badcases"`
}
type shortcutHallucinationReplayBadcase struct {
ID string `json:"id"`
CaseID string `json:"case_id"`
Run int `json:"run"`
CommandIndex int `json:"command_index"`
Turn int `json:"turn"`
SourcePath string `json:"source_path"`
ExpectedOutcome string `json:"expected_outcome"`
Raw string `json:"raw"`
OriginalExitCode int `json:"original_exit_code"`
IsHelp bool `json:"is_help"`
}
func TestCrossPlatformCoverageShortcutHallucinationBadcases20260728Replay(t *testing.T) {
var corpus shortcutHallucinationReplayCorpus
if err := json.Unmarshal(shortcutHallucinationBadcases20260728JSON, &corpus); err != nil {
t.Fatalf("decode historical shortcut badcase corpus: %v", err)
}
if corpus.SourceReport != "param_hallucination_20260728_120943" ||
corpus.SourceFile != "raw_dashscope_qwen3_7-max_20260728_120943.json" ||
corpus.SourceDWSCommit != "a8e83e5" || corpus.Model != "dashscope/qwen3.7-max" {
t.Fatalf("unexpected historical corpus provenance: %#v", corpus)
}
if corpus.ExpectedCount != 52 || len(corpus.Badcases) != corpus.ExpectedCount {
t.Fatalf("historical shortcut badcases = %d, expected_count=%d; want 52", len(corpus.Badcases), corpus.ExpectedCount)
}
wantOutcomeCounts := map[string]int{
"rewrite": 8,
"ambiguous": 44,
}
wantSourceCounts := map[string]int{
"chat +group-member-list": 1,
"chat +group-send-text": 1,
"chat +list-group-bots": 1,
"chat +list-robot": 1,
"chat +list-robots": 1,
"chat +members": 3,
"chat +message-list": 1,
"chat +read-single": 6,
"chat +rename-group": 1,
"chat +send": 4,
"chat +send-by-bot": 3,
"chat +send-dm": 2,
"chat +send-file": 15,
"chat +send-image": 3,
"chat +send-media": 2,
"chat +send-message": 3,
"chat +send-single": 1,
"chat +send-text": 2,
"chat +send-to": 1,
}
rewriteTargets := map[string]string{
"chat +members": "chat +group-members",
"chat +group-member-list": "chat +group-members",
"chat +list-group-bots": "chat +chat-bots",
"chat +list-robot": "chat +chat-bots",
"chat +list-robots": "chat +chat-bots",
"chat +rename-group": "chat +chat-update",
}
seenIDs := make(map[string]bool, len(corpus.Badcases))
gotOutcomeCounts := make(map[string]int)
gotSourceCounts := make(map[string]int)
for _, badcase := range corpus.Badcases {
badcase := badcase
t.Run(badcase.ID, func(t *testing.T) {
if badcase.ID == "" || seenIDs[badcase.ID] {
t.Fatalf("missing or duplicate historical badcase id %q", badcase.ID)
}
seenIDs[badcase.ID] = true
gotOutcomeCounts[badcase.ExpectedOutcome]++
gotSourceCounts[badcase.SourcePath]++
args := historicalShortcutBadcaseArgv(t, badcase.Raw)
if len(args) < 2 || strings.Join(args[:2], " ") != badcase.SourcePath {
t.Fatalf("raw argv source = %v, fixture source_path=%q", args, badcase.SourcePath)
}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
switch badcase.ExpectedOutcome {
case "rewrite":
assertHistoricalShortcutRewrite(t, badcase, rewriteTargets[badcase.SourcePath], ctx, err)
case "ambiguous":
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath)
if !ok || entry.Mode != cli.CommandPathFallbackAmbiguous || len(entry.Candidates) < 2 {
t.Fatalf("ambiguous fallback table entry = %#v, %v", entry, ok)
}
case "unknown_shortcut":
assertHistoricalShortcutReason(t, badcase, ctx, err, "unknown_shortcut")
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
t.Fatalf("contract-incomplete path unexpectedly entered fallback table: %#v", entry)
}
default:
t.Fatalf("unsupported expected_outcome %q", badcase.ExpectedOutcome)
}
})
}
if !reflect.DeepEqual(gotOutcomeCounts, wantOutcomeCounts) {
t.Errorf("historical outcome counts = %v, want %v", gotOutcomeCounts, wantOutcomeCounts)
}
if !reflect.DeepEqual(gotSourceCounts, wantSourceCounts) {
t.Errorf("historical source counts = %v, want %v", gotSourceCounts, wantSourceCounts)
}
}
func TestCrossPlatformCoverageShortcutHallucinationMerged20260720Replay(t *testing.T) {
tests := []struct {
id string
raw string
occurrences int
outcome string
target string
}{
{
id: "dws_im_v2_0013-search-group",
raw: `dws chat +search-group --name "dws测试群02" --format json`,
occurrences: 1,
outcome: "official_alias",
target: "chat +chat-search",
},
{
id: "dws_oa_0017-list-processes",
raw: `dws oa +list-processes --format json --limit 50`,
occurrences: 2,
outcome: "ambiguous",
},
}
totalOccurrences := 0
for _, test := range tests {
test := test
t.Run(test.id, func(t *testing.T) {
totalOccurrences += test.occurrences
args := historicalShortcutBadcaseArgv(t, test.raw)
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
badcase := shortcutHallucinationReplayBadcase{
ID: test.id,
SourcePath: strings.Join(args[:2], " "),
ExpectedOutcome: test.outcome,
Raw: test.raw,
}
switch test.outcome {
case "rewrite":
assertHistoricalShortcutRewrite(t, badcase, test.target, ctx, err)
case "official_alias":
assertHistoricalOfficialAlias(t, badcase, test.target, ctx, err)
case "ambiguous":
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
default:
t.Fatalf("unsupported merged expected outcome %q", test.outcome)
}
})
}
if totalOccurrences != 3 {
t.Fatalf("merged shortcut hallucination occurrences = %d, want 3", totalOccurrences)
}
}
func assertHistoricalOfficialAlias(
t *testing.T,
badcase shortcutHallucinationReplayBadcase,
wantTarget string,
ctx *pipeline.Context,
err error,
) {
t.Helper()
if err != nil {
t.Fatalf("historical official alias %q returned error: %v", badcase.SourcePath, err)
}
if wantTarget == "" || ctx == nil || ctx.Command != "dws "+wantTarget {
t.Fatalf("historical official alias context = %#v; want command dws %s", ctx, wantTarget)
}
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" {
t.Fatalf("historical official alias received fallback correction: %#v", ctx.Corrections)
}
}
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
t.Fatalf("official alias unexpectedly remains in fallback table: %#v", entry)
}
}
func historicalShortcutBadcaseArgv(t *testing.T, raw string) []string {
t.Helper()
command := strings.TrimSpace(raw)
for _, suffix := range []string{" 2>&1 | head -50", " 2>&1"} {
command = strings.TrimSuffix(command, suffix)
}
argv, err := cli.ParseAgentExampleArgv(command)
if err != nil {
t.Fatalf("parse historical raw command %q without a shell: %v", raw, err)
}
if len(argv) < 3 || argv[0] != "dws" {
t.Fatalf("historical raw command did not produce dws argv: %q => %v", raw, argv)
}
return append([]string(nil), argv[1:]...)
}
func assertHistoricalShortcutRewrite(
t *testing.T,
badcase shortcutHallucinationReplayBadcase,
wantTarget string,
ctx *pipeline.Context,
err error,
) {
t.Helper()
if wantTarget == "" {
t.Fatalf("historical rewrite %q has no reviewed target", badcase.SourcePath)
}
if ctx == nil || ctx.Command != "dws "+wantTarget {
t.Fatalf("historical rewrite context = %#v, error=%v; want command dws %s", ctx, err, wantTarget)
}
found := false
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" && correction.Original == badcase.SourcePath && correction.Corrected == wantTarget {
found = true
break
}
}
if !found {
t.Fatalf("historical rewrite corrections = %#v; want %q -> %q", ctx.Corrections, badcase.SourcePath, wantTarget)
}
wantPrefix := strings.Fields(wantTarget)
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
t.Fatalf("historical rewrite argv = %v, want prefix %v", ctx.Args, wantPrefix)
}
if reason := structuredShortcutReplayReason(err); reason == "unknown_shortcut" || reason == "ambiguous_command_fallback" {
t.Fatalf("historical rewrite returned command-resolution reason %q: %v", reason, err)
}
}
func assertHistoricalShortcutReason(
t *testing.T,
badcase shortcutHallucinationReplayBadcase,
ctx *pipeline.Context,
err error,
wantReason string,
) {
t.Helper()
if ctx == nil {
t.Fatalf("historical %s badcase returned nil context: %v", wantReason, err)
}
if got := structuredShortcutReplayReason(err); got != wantReason {
t.Fatalf("historical badcase %q reason = %q, error=%v; want %q (original exit=%d help=%v)", badcase.Raw, got, err, wantReason, badcase.OriginalExitCode, badcase.IsHelp)
}
if strings.Contains(strings.ToLower(fmt.Sprint(err)), "unknown flag") {
t.Fatalf("historical badcase %q regressed to unknown flag: %v", badcase.Raw, err)
}
}
func structuredShortcutReplayReason(err error) string {
if err == nil {
return ""
}
var structured *apperrors.Error
if stderrors.As(err, &structured) {
return structured.Reason
}
return ""
}
@@ -1,603 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"context"
stderrors "errors"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
const (
runtimeShortcutFallbackChildEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CHILD"
runtimeShortcutFallbackConfigEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CONFIG_DIR"
)
func TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback(t *testing.T) {
if os.Getenv(runtimeShortcutFallbackChildEnv) == "1" {
t.Setenv("DWS_CONFIG_DIR", os.Getenv(runtimeShortcutFallbackConfigEnv))
engine := newPipelineEngine()
root := NewRootCommandWithEngine(context.Background(), engine)
runtimeCommand := exactAppCommand(root, "chat +members")
if runtimeCommand == nil || !runtimeCommand.Runnable() || cmdutil.IsHintOnlyCommand(runtimeCommand) {
var userDefined []string
for _, candidate := range shortcut.All() {
if candidate.UserDefined {
userDefined = append(userDefined, candidate.Service+" "+candidate.Command)
}
}
t.Fatalf("runtime shortcut was not mounted as an executable command: command=%#v user_defined=%v config=%q", runtimeCommand, userDefined, os.Getenv("DWS_CONFIG_DIR"))
}
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"chat", "+members", "--sentinel", "fixture", "--mock", "--format", "json"}
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, engine, args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws chat +members" {
t.Fatalf("runtime shortcut context = %#v", ctx)
}
assertNoCommandPathFallbackCorrection(t, ctx)
if err := root.Execute(); err != nil {
t.Fatalf("runtime shortcut execute: %v", err)
}
return
}
configDir := t.TempDir()
shortcutDir := filepath.Join(configDir, "shortcuts")
if err := os.MkdirAll(shortcutDir, 0o700); err != nil {
t.Fatal(err)
}
definition := `version: 1
service: chat
command: "+members"
product: chat
description: runtime members fixture
execute:
tool: fixture_user_members
bind:
sentinel: "${sentinel}"
flags:
- name: sentinel
type: string
required: true
desc: fixture sentinel
`
if err := os.WriteFile(filepath.Join(shortcutDir, "chat.members.yaml"), []byte(definition), 0o600); err != nil {
t.Fatal(err)
}
command := exec.Command(os.Args[0], "-test.run=^TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback$", "-test.count=1")
command.Env = append(os.Environ(), runtimeShortcutFallbackConfigEnv+"="+configDir, runtimeShortcutFallbackChildEnv+"=1")
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("runtime shortcut child failed: %v\n%s", err, strings.TrimSpace(string(output)))
}
if !strings.Contains(string(output), `"_tool": "fixture_user_members"`) {
t.Fatalf("runtime shortcut child used the wrong command:\n%s", strings.TrimSpace(string(output)))
}
}
func TestCrossPlatformCoverageReviewedCommandFallbacksReachCanonicalDryRunPayload(t *testing.T) {
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "+chat-search", "--query", "project", "--dry-run",
)
if err != nil || len(canonicalQueryAttempts) != 0 {
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
}
tests := []struct {
name string
args []string
wantCommand string
wantPreview paramAliasDryRunPreview
}{
{
name: "group search query",
args: []string{"chat", "+group-search", "--query", "project", "--dry-run"},
wantCommand: "dws chat +chat-search",
wantPreview: canonicalQuery,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
if executeErr != nil {
t.Fatalf("fallback execute error = %v", executeErr)
}
if len(attempts) != 0 {
t.Fatalf("fallback crossed dry-run dispatch boundary: %#v", attempts)
}
if !reflect.DeepEqual(preview, test.wantPreview) {
t.Fatalf("fallback preview = %#v, want canonical %#v", preview, test.wantPreview)
}
if ctx == nil || ctx.Command != test.wantCommand || len(ctx.Corrections) == 0 {
t.Fatalf("fallback context = %#v, want command %q", ctx, test.wantCommand)
}
correction := ctx.Corrections[0]
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
t.Fatalf("fallback correction = %#v", correction)
}
})
}
}
func TestCrossPlatformCoverageOfficialCommandAliasesBypassFallbackAndReachEquivalentPayload(t *testing.T) {
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "+chat-search", "--query", "project", "--dry-run",
)
if err != nil || len(canonicalQueryAttempts) != 0 {
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
}
_, canonicalKeyword, canonicalKeywordAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "+chat-search", "--keyword", "project", "--dry-run",
)
if err != nil || len(canonicalKeywordAttempts) != 0 {
t.Fatalf("canonical keyword preview = %#v, attempts=%v, error=%v", canonicalKeyword, canonicalKeywordAttempts, err)
}
_, nativeCanonical, nativeCanonicalAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "search", "--query", "project", "--dry-run",
)
if err != nil || len(nativeCanonicalAttempts) != 0 {
t.Fatalf("native canonical preview = %#v, attempts=%v, error=%v", nativeCanonical, nativeCanonicalAttempts, err)
}
tests := []struct {
name string
args []string
wantCommand string
wantPreview paramAliasDryRunPreview
}{
{
name: "search group shortcut alias",
args: []string{"chat", "+search-group", "--keyword", "project", "--dry-run"},
wantCommand: "dws chat +chat-search",
wantPreview: canonicalKeyword,
},
{
name: "chat group search shortcut alias",
args: []string{"chat", "+chat-group-search", "--query", "project", "--dry-run"},
wantCommand: "dws chat +chat-search",
wantPreview: canonicalQuery,
},
{
name: "hidden native compatibility leaf",
args: []string{"chat", "group", "search", "--query", "project", "--dry-run"},
wantCommand: "dws chat group search",
wantPreview: nativeCanonical,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
if executeErr != nil {
t.Fatalf("official alias execute error = %v", executeErr)
}
if len(attempts) != 0 {
t.Fatalf("official alias crossed dry-run dispatch boundary: %#v", attempts)
}
if !reflect.DeepEqual(preview, test.wantPreview) {
t.Fatalf("official alias preview = %#v, want canonical %#v", preview, test.wantPreview)
}
if ctx == nil || ctx.Command != test.wantCommand {
t.Fatalf("official alias context = %#v, want command %q", ctx, test.wantCommand)
}
assertNoCommandPathFallbackCorrection(t, ctx)
})
}
}
func TestCrossPlatformCoverageReviewedReadFallbacksResolveCanonicalLeafBeforeParameterValidation(t *testing.T) {
tests := []struct {
name string
args []string
target string
}{
{
name: "members",
args: []string{"chat", "+members", "--group", "Fixture Group"},
target: "chat +group-members",
},
{
name: "group member list",
args: []string{"chat", "+group-member-list", "--group-name", "Fixture Group"},
target: "chat +group-members",
},
{
name: "list group bots",
args: []string{"chat", "+list-group-bots", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "list robot",
args: []string{"chat", "+list-robot", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "list robots",
args: []string{"chat", "+list-robots", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "bot list",
args: []string{"chat", "+bot-list", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "conversation detail",
args: []string{"chat", "+conversation-detail", "--group", "cid-fixture"},
target: "chat +conversation-info",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(test.args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws "+test.target || len(ctx.Corrections) == 0 {
t.Fatalf("read fallback context = %#v", ctx)
}
wantPrefix := strings.Fields(test.target)
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
t.Fatalf("read fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
}
})
}
}
func TestCrossPlatformCoverageOfficialGroupMembersAliasBypassesCommandFallback(t *testing.T) {
args := []string{"chat", "+chat-group-members", "--conversation-id", "cid-fixture", "--member-types", "user"}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws chat +chat-members-list" {
t.Fatalf("official group-members alias context = %#v", ctx)
}
assertNoCommandPathFallbackCorrection(t, ctx)
}
func TestCrossPlatformCoverageReviewedRenameFallbackResolvesCanonicalLeafBeforeParameterValidation(t *testing.T) {
args := []string{"chat", "+rename-group", "--id", "cid-fixture", "--name", "Fixture Group"}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws chat +chat-update" || len(ctx.Corrections) == 0 {
t.Fatalf("rename fallback context = %#v", ctx)
}
wantPrefix := []string{"chat", "+chat-update"}
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
t.Fatalf("rename fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
}
}
func TestCrossPlatformCoverageReviewedDocHistorySaveFallbacksPreserveArguments(t *testing.T) {
sources := []string{"+create-version", "+save-version", "+snapshot", "+version-create"}
for _, source := range sources {
t.Run(source, func(t *testing.T) {
args := []string{"doc", source, "--node", "node-fixture", "--mock", "--format", "json"}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws doc +history-save" || len(ctx.Corrections) == 0 {
t.Fatalf("history-save fallback context = %#v", ctx)
}
wantArgs := []string{"doc", "+history-save", "--node", "node-fixture", "--mock", "--format", "json"}
if !reflect.DeepEqual(ctx.Args, wantArgs) {
t.Fatalf("history-save fallback args = %v, want %v", ctx.Args, wantArgs)
}
correction := ctx.Corrections[0]
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
t.Fatalf("history-save correction = %#v", correction)
}
})
}
}
func TestCrossPlatformCoverageDocExportPDFRemainsUnknownShortcut(t *testing.T) {
if entry, ok := cli.LookupCommandPathFallback("doc +export-pdf"); ok {
t.Fatalf("+export-pdf must not receive a path-only fallback: %#v", entry)
}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"doc", "+export-pdf", "--node", "node-fixture", "--mock", "--format", "json"}
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if ctx == nil {
t.Fatal("+export-pdf returned nil context")
}
var structured *apperrors.Error
if !stderrors.As(err, &structured) || structured.Reason != "unknown_shortcut" {
t.Fatalf("+export-pdf preparse error = %T %#v", err, err)
}
if len(ctx.Corrections) != 0 {
t.Fatalf("+export-pdf unexpectedly received corrections: %#v", ctx.Corrections)
}
}
func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t *testing.T) {
tests := []struct {
path string
candidates []string
}{
{path: "chat +group-send-text", candidates: []string{"chat +send-to-group", "chat +messages-send"}},
{path: "chat +message-list", candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"}},
{path: "chat +read-single", candidates: []string{"chat +messages-list-direct", "chat +chat-messages"}},
{path: "chat +send", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-by-bot", candidates: []string{"chat +messages-send", "chat message send-by-bot"}},
{path: "chat +send-dm", candidates: []string{"chat +dm", "chat +messages-send"}},
{path: "chat +send-message", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-single", candidates: []string{"chat +dm", "chat +messages-send"}},
{path: "chat +send-text", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-to", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-file", candidates: []string{"chat +messages-send", "chat message send"}},
{path: "chat +send-image", candidates: []string{"chat +messages-send", "chat message send"}},
{path: "chat +send-media", candidates: []string{"chat +messages-send", "chat message send"}},
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
caller := &paramAliasCaptureCaller{}
args := append(strings.Fields(test.path), "--format", "json")
ctx, err := executeParamAliasE2E(t, caller, args...)
if ctx == nil {
t.Fatal("ambiguous command fallback returned nil context")
}
var structured *apperrors.Error
if !stderrors.As(err, &structured) || structured.Reason != "ambiguous_command_fallback" || structured.ExitCode() != 3 {
t.Fatalf("ambiguous error = %T %#v", err, err)
}
if len(structured.Actions) != len(test.candidates) || len(caller.calls) != 0 {
t.Fatalf("ambiguous actions=%v calls=%#v", structured.Actions, caller.calls)
}
for _, candidate := range test.candidates {
if !strings.Contains(structured.Hint, "dws "+candidate) {
t.Errorf("ambiguous hint %q missing candidate %q", structured.Hint, candidate)
}
}
})
}
}
func TestCrossPlatformCoverageCanonicalShortcutBadFlagDoesNotEnterCommandFallback(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"chat", "+chat-search", "--definitely-not-a-real-flag", "project"}
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatalf("preparse error = %v", err)
}
if ctx == nil || ctx.Command != "dws chat +chat-search" {
t.Fatalf("canonical context = %#v", ctx)
}
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" {
t.Fatalf("canonical command received fallback correction: %#v", ctx.Corrections)
}
}
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("canonical bad flag error = %v", err)
}
}
func TestCrossPlatformCoverageRewrittenShortcutStillUsesCanonicalParameterErrors(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{
name: "missing required query",
args: []string{"chat", "+group-search", "--dry-run"},
want: "请至少指定 --query、--keyword 之一",
},
{
name: "unknown canonical flag",
args: []string{"chat", "+group-search", "--definitely-not-a-real-flag", "project"},
want: "unknown flag",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(test.args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if err != nil {
t.Fatalf("preparse error = %v", err)
}
if ctx == nil || ctx.Command != "dws chat +chat-search" || len(ctx.Corrections) == 0 ||
ctx.Corrections[0].Handler != "command-path-fallback" {
t.Fatalf("rewritten context = %#v", ctx)
}
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("canonical parameter error = %v, want containing %q", err, test.want)
}
})
}
}
func TestCrossPlatformCoverageCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
invalidShortcuts := map[string]bool{
"+bot-list": true,
"+conversation-detail": true,
"+conversation-category-list": true,
"+conversation-group-list": true,
"+list-my-groups": true,
"+group-search": true,
"+members": true,
"+group-member-list": true,
"+list-group-bots": true,
"+list-robot": true,
"+list-robots": true,
"+message-list": true,
"+read-single": true,
"+rename-group": true,
"+send": true,
"+send-by-bot": true,
"+send-dm": true,
"+send-message": true,
"+send-single": true,
"+send-text": true,
"+send-to": true,
"+send-file": true,
"+send-image": true,
"+send-media": true,
"+group-send-text": true,
}
root := NewSchemaSourceRootCommand()
chat := exactAppCommand(root, "chat")
if chat == nil {
t.Fatal("chat command missing")
}
for _, child := range chat.Commands() {
if invalidShortcuts[child.Name()] {
t.Fatalf("fallback name %q became a real command", child.Name())
}
for _, alias := range child.Aliases {
if invalidShortcuts[alias] {
t.Fatalf("fallback name %q became a Cobra alias", alias)
}
}
}
for path := range invalidShortcuts {
if _, ok := cli.ResolveMeta("chat " + path); ok {
t.Fatalf("fallback path %q leaked into embedded Schema", path)
}
}
for _, declared := range shortcut.All() {
if declared.Service == "chat" && invalidShortcuts[declared.Command] {
t.Fatalf("fallback name %q leaked into shortcut catalog", declared.Command)
}
}
if _, ok := cli.ResolveMeta("oa +list-processes"); ok {
t.Fatal("fallback path oa +list-processes leaked into embedded Schema")
}
for _, declared := range shortcut.All() {
if declared.Service == "oa" && declared.Command == "+list-processes" {
t.Fatal("fallback name +list-processes leaked into shortcut catalog")
}
}
group := exactAppCommand(root, "chat group")
searchCompatibility := exactAppCommand(root, "chat group search")
if group == nil || searchCompatibility == nil || !searchCompatibility.Hidden || cmdutil.IsHintOnlyCommand(searchCompatibility) {
t.Fatalf("chat group search must be a hidden executable compatibility leaf: group=%v search=%v", group, searchCompatibility)
}
var help bytes.Buffer
group.SetOut(&help)
if err := group.Help(); err != nil {
t.Fatal(err)
}
if strings.Contains(help.String(), "\n search ") {
t.Fatalf("hidden fallback source leaked into group help:\n%s", help.String())
}
}
func TestCrossPlatformCoverageDocCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
invalidShortcuts := map[string]bool{
"+create-version": true,
"+save-version": true,
"+snapshot": true,
"+version-create": true,
"+export-pdf": true,
}
root := NewSchemaSourceRootCommand()
doc := exactAppCommand(root, "doc")
if doc == nil {
t.Fatal("doc command missing")
}
for _, child := range doc.Commands() {
if invalidShortcuts[child.Name()] {
t.Fatalf("invalid shortcut %q became a real command", child.Name())
}
for _, alias := range child.Aliases {
if invalidShortcuts[alias] {
t.Fatalf("invalid shortcut %q became a Cobra alias", alias)
}
}
}
for path := range invalidShortcuts {
if _, ok := cli.ResolveMeta("doc " + path); ok {
t.Fatalf("invalid path %q leaked into embedded Schema", path)
}
}
for _, declared := range shortcut.All() {
if declared.Service == "doc" && invalidShortcuts[declared.Command] {
t.Fatalf("invalid shortcut %q leaked into shortcut catalog", declared.Command)
}
}
}
func assertNoCommandPathFallbackCorrection(t *testing.T, ctx *pipeline.Context) {
t.Helper()
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" {
t.Fatalf("official command unexpectedly received fallback correction: %#v", ctx.Corrections)
}
}
}
func exactAppCommand(root *cobra.Command, path string) *cobra.Command {
current := root
parts := strings.Fields(path)
if len(parts) > 0 && parts[0] == root.Name() {
parts = parts[1:]
}
for _, part := range parts {
var next *cobra.Command
for _, child := range current.Commands() {
if child.Name() == part {
next = child
break
}
}
if next == nil {
return nil
}
current = next
}
return current
}
+182 -13
View File
@@ -32,6 +32,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
@@ -226,6 +227,115 @@ func TestCrossPlatformCoverageDocDownloadPureCoverage(t *testing.T) {
}
}
func TestCrossPlatformCoverageRecoveryPureCoverage(t *testing.T) {
if _, err := decodeRecoveryAttempts(nil, nil, "", ""); err != nil {
t.Fatal(err)
}
if _, err := decodeRecoveryAttempts(json.RawMessage("null"), nil, "", ""); err != nil {
t.Fatal(err)
}
if got, err := decodeRecoveryAttempts(json.RawMessage(`[{"command_summary":"one"}]`), nil, "", ""); err != nil || len(got) != 1 {
t.Fatalf("array attempts = %#v %v", got, err)
}
if _, err := decodeRecoveryAttempts(json.RawMessage("{"), nil, "", ""); err == nil {
t.Fatal("malformed attempts succeeded")
}
if got, err := decodeRecoveryAttempts(json.RawMessage("2"), []string{"a"}, "ok", ""); err != nil || len(got) != 2 {
t.Fatalf("legacy attempts = %#v %v", got, err)
}
if legacyRecoveryAttempts(0, nil, "", "") != nil || len(legacyRecoveryAttempts(1, nil, "", "")) != 1 {
t.Fatal("legacy attempts edge mismatch")
}
for _, args := range [][]string{
{"dws", "--debug", "doc", "get", "--node", "n"},
{"dws", "--format=json", "doc", "--", "ignored"},
{"dws", "--unknown", "value", "doc"},
} {
old := os.Args
os.Args = args
_ = currentCommandPath()
os.Args = old
}
for _, inv := range []executor.Invocation{
{LegacyPath: "legacy path"},
{CanonicalProduct: "doc", Tool: "get"},
{CanonicalProduct: "doc"},
{},
} {
old := os.Args
os.Args = []string{"dws"}
_ = runtimeCommandPath(inv)
os.Args = old
}
if cloneRecoveryArgs(nil) != nil {
t.Fatal("empty recovery args should clone to nil")
}
original := map[string]any{"x": 1}
clone := cloneRecoveryArgs(original)
clone["x"] = 2
if original["x"] != 1 {
t.Fatal("recovery args were not cloned")
}
if got, _ := (*recoveryRuntime)(nil).Search(context.Background(), "query", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
t.Fatalf("nil recovery search = %#v", got)
}
if got, _ := (&recoveryRuntime{}).Search(context.Background(), " ", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
t.Fatalf("blank recovery search = %#v", got)
}
if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil {
t.Fatal("nil recovery runtime call succeeded")
}
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
t.Fatalf("missing recovery endpoint error = %v", err)
} else {
var apiErr *apperrors.Error
if !errors.As(err, &apiErr) || apiErr.Category != apperrors.CategoryAPI || apiErr.Operation != "discovery.resolve" || apiErr.Reason != "endpoint_not_resolved" {
t.Fatalf("missing recovery endpoint classification = %#v", err)
}
}
t.Setenv("DINGTALK_OK_MCP_URL", " https://catalog.test ")
runtime := &recoveryRuntime{}
if got, err := runtime.resolveEndpoint(context.Background(), "ok", "tool"); err != nil || got != "https://catalog.test" {
t.Fatalf("recovery endpoint override = %q %v", got, err)
}
if recoveryRuntimeToken(nil) != "" || recoveryRuntimeToken(&GlobalFlags{Token: " token "}) != "token" {
t.Fatal("recovery token mismatch")
}
if toRecoveryToolResponse(nil) != nil {
t.Fatal("nil recovery response should stay nil")
}
response := toRecoveryToolResponse(&transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Type: "text", Text: "body"}}})
if response == nil || !response.IsError || len(response.Content) != 1 {
t.Fatalf("recovery response = %#v", response)
}
items := []any{map[string]any{"title": "A", "url": "u", "desc": "d"}, "skip", map[string]any{}}
for _, payload := range []map[string]any{
nil,
{"items": items},
{"data": map[string]any{"items": items}},
{"result": map[string]any{"items": items}},
} {
_ = parseDocSearchItemsFromMap(payload)
}
if toDocSearchItems("bad") != nil {
t.Fatal("non-list doc items accepted")
}
result := &transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "{"}, {Text: `{"items":[{"title":"B"}]}`}}}
if got := parseDocSearchItems(result); len(got) != 1 {
t.Fatalf("block doc items = %#v", got)
}
if parseDocSearchItems(nil) != nil {
t.Fatal("nil doc result should be nil")
}
searchItems := []recovery.DocSearchItem{{Title: "query", URL: "u"}, {Title: "other"}, {Title: "third"}, {Title: "fourth"}}
if len(rerankDocSearchHits("query", recovery.RecoveryContext{ToolName: "tool", CommandPath: []string{"doc"}}, searchItems)) != 3 || rerankDocSearchHits("", recovery.RecoveryContext{}, nil) != nil {
t.Fatal("doc search reranking mismatch")
}
}
func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
RegisterPluginAuth("coverage-registry", &PluginAuth{Token: "token"})
t.Cleanup(func() {
@@ -401,6 +511,59 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
_ = defaultPATMCPEndpoint()
}
func TestCrossPlatformCoverageRecoveryLoadExecutionCoverage(t *testing.T) {
if _, err := loadRecoveryExecution(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("missing recovery execution succeeded")
}
path := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadRecoveryExecution(path); err == nil {
t.Fatal("malformed recovery execution succeeded")
}
for name, body := range map[string]string{
"legacy": `{"action":" one ","attempt":2,"result":" ok ","error":" bad "}`,
"modern": `{"actions":["one"],"attempts":[{"command_summary":"one"}],"error_summary":"bad"}`,
} {
t.Run(name, func(t *testing.T) {
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if got, err := loadRecoveryExecution(path); err != nil || len(got.Actions) != 1 || len(got.Attempts) == 0 {
t.Fatalf("loaded execution = %#v %v", got, err)
}
})
}
}
func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
var result map[string]any = map[string]any{"content": []map[string]any{{"type": "text", "text": `{"items":[{"title":"query result","url":"u"}]}`}}}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int `json:"id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
}))
defer server.Close()
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
t.Cleanup(func() { SetDynamicServers(nil) })
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
t.Fatalf("recovery search = %#v %v", got, err)
}
result = map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "failed"}}}
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
t.Fatal("recovery MCP error succeeded")
}
server.Close()
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
t.Fatal("recovery network error succeeded")
}
}
func TestCrossPlatformCoverageEventCommandPureCoverage(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
@@ -597,7 +760,7 @@ func TestCrossPlatformCoverageVersionCacheCompletionCoverage(t *testing.T) {
child := &cobra.Command{Use: "child"}
root.AddCommand(child)
child.SetOut(io.Discard)
if err := printCacheCompatNotice(child, "dws cache status"); err != nil {
if err := printCacheCompatNotice(child, "status"); err != nil {
t.Fatalf("cache %s: %v", format, err)
}
root.RemoveCommand(child)
@@ -1584,6 +1747,9 @@ func TestCrossPlatformCoverageDoctorCommandCoverage(t *testing.T) {
}
for _, jsonOut := range []bool{false, true} {
if got := doctorCheckCache(io.Discard, jsonOut); got.Status != statusPass {
t.Fatal("cache check failed")
}
if got := doctorCheckPerf(io.Discard, jsonOut); got.Status != statusPass {
t.Fatalf("perf check = %#v", got)
}
@@ -2002,7 +2168,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
}
_ = os.Symlink(filepath.Join(mono, "SKILL.md"), filepath.Join(mono, "linked.md"))
multi := filepath.Join(t.TempDir(), "multi")
for _, name := range []string{"dingtalk-shared", "dingtalk-a", "dingtalk-b"} {
for _, name := range []string{"dws-shared", "dingtalk-a", "dingtalk-b"} {
dir := filepath.Join(multi, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
@@ -2030,7 +2196,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") {
t.Fatalf("multi setup = %q, %v", output, err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
t.Fatal(err)
}
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
@@ -2048,13 +2214,16 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
t.Fatalf("invalid setup %#v succeeded", args)
}
}
if _, _, err := run("--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
t.Fatalf("default mono setup: %v", err)
if _, _, err := run("--mode", "mono", "--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
t.Fatalf("mono setup: %v", err)
}
if output, _, err := run("--source", multi, "--target", "agents", "--yes", "--dry-run"); err != nil || !strings.Contains(output, "mode=multi") {
t.Fatalf("default mode should be multi: %q, %v", output, err)
}
}
func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
all := []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}
all := []string{"dingtalk-a", "dingtalk-b", "dws-shared"}
for _, tc := range []struct {
include []string
exclude []string
@@ -2072,7 +2241,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
t.Errorf("filter %#v/%#v = %v", tc.include, tc.exclude, err)
}
}
for _, selected := range [][]string{nil, {"dingtalk-shared"}, {"dingtalk-a"}} {
for _, selected := range [][]string{nil, {"dws-shared"}, {"dingtalk-a"}} {
_ = ensureMandatorySharedSkill(selected, all)
}
_ = ensureMandatorySharedSkill([]string{"dingtalk-a"}, []string{"dingtalk-a"})
@@ -2082,7 +2251,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
if _, err := listMultiSkillNames(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("missing multi source succeeded")
}
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMono {
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMulti {
t.Fatalf("default setup mode = %q, %v", mode, err)
}
if _, err := resolveSkillSetupMode("bad", true, io.Discard); err == nil {
@@ -2125,8 +2294,8 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
_ = agentHomeForMode("base", skillSetupModeMulti)
_ = detectExistingAgentHomes(t.TempDir(), skillSetupModeMono)
for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti, "bad"} {
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all)
_ = mutualExclusionVictims(root, mode)
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all, false)
_, _ = mutualExclusionVictims(root, mode)
}
if isCharDevice(nil) || isInteractiveTerminal() {
t.Fatal("test process unexpectedly interactive")
@@ -2134,17 +2303,17 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
_ = os.MkdirAll(filepath.Join(filepath.Dir(monoDest), "dingtalk-old"), 0o755)
_ = mutualExclusionVictims(monoDest, skillSetupModeMono)
_, _ = mutualExclusionVictims(monoDest, skillSetupModeMono)
multiDest := filepath.Join(t.TempDir(), "agent")
_ = os.MkdirAll(filepath.Join(multiDest, "dws"), 0o755)
_ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
_, _ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
cleanupMutualExclusion(monoDest, skillSetupModeMono, io.Discard, io.Discard)
cleanupMutualExclusion(multiDest, skillSetupModeMulti, io.Discard, io.Discard)
badParent := filepath.Join(t.TempDir(), "file")
_ = os.WriteFile(badParent, []byte("x"), 0o600)
_, _, _ = installSkillToHomes(root, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard, true)
if err := copyDir(filepath.Join(root, "missing"), t.TempDir()); err == nil {
t.Fatal("copy missing directory succeeded")
}
+1
View File
@@ -302,6 +302,7 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
_ = newConfigCommand()
_ = newCacheCommand()
_ = newVersionCommand()
_ = newRecoveryCommand(&GlobalFlags{})
_ = newAPICommand(&GlobalFlags{})
_ = NewRootCommand(context.Background())
}
+22 -1
View File
@@ -61,7 +61,7 @@ func newDoctorCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "doctor",
Short: "环境健康检查",
Long: "一键检查登录态、网络连通性和版本更新,快速定位常见问题。",
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: runDoctor,
@@ -92,6 +92,9 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
checks = append(checks, networkResult)
cacheResult := doctorCheckCache(w, jsonOut)
checks = append(checks, cacheResult)
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
checks = append(checks, versionResult)
@@ -294,6 +297,24 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
return r
}
// ── Cache check ─────────────────────────────────────────────────────────
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
}
r := checkResult{
Name: "cache",
Status: statusPass,
Message: "静态端点模式, 无需缓存",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
// ── Version check ───────────────────────────────────────────────────────
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
+25
View File
@@ -109,6 +109,31 @@ func TestPrintCheckResultNoHint(t *testing.T) {
}
}
func TestDoctorCheckCacheEmpty(t *testing.T) {
t.Setenv("DWS_CACHE_DIR", t.TempDir())
var buf bytes.Buffer
r := doctorCheckCache(&buf, false)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
}
func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
t.Setenv("DWS_CACHE_DIR", t.TempDir())
var buf bytes.Buffer
r := doctorCheckCache(&buf, true)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
if buf.Len() != 0 {
t.Error("expected no output in JSON mode")
}
}
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
+35 -95
View File
@@ -38,7 +38,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"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/registry"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
@@ -75,18 +74,18 @@ var (
// newEventCommand returns the `event` parent command and all its subcommands.
// Wired into root.go's utilityCommands list.
func newEventCommand(globalFlags ...*GlobalFlags) *cobra.Command {
func newEventCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/event.json
// products.event). Catalog assembly stamps provenance contract_final.
contract.RegisterProductDecl(contract.ProductDecl{
ID: "event",
Selection: contract.ProductSelectionDecl{
AgentSummary: "实时监听当前用户相关的个人 IM 与 OA 审批事件,并管理订阅生命周期",
AgentSummary: "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
UseWhen: []string{
"需要实时监听未来发生的个人消息、消息动作、群生命周期或 OA 审批任务/实例事件,或管理个人事件订阅生命周期",
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期",
},
AvoidWhen: []string{
"查历史聊天或主动发消息用 chat;查询或处理审批实例/任务用 oa;配置开放平台应用事件回调用 dev app event",
"查历史聊天或主动发消息分别用 chat 查询/发送命令",
},
},
})
@@ -100,12 +99,11 @@ func newEventCommand(globalFlags ...*GlobalFlags) *cobra.Command {
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
}
cmd.AddCommand(
newEventListenIMCommand(globalFlags...),
newEventConsumeCommand(globalFlags...),
newEventConsumeCommand(),
newEventListCommand(),
newEventSchemaCommand(),
newEventStatusCommandWithFlags(globalFlags...),
newEventStopCommandWithFlags(globalFlags...),
newEventStatusCommand(),
newEventStopCommand(),
newEventBusCommand(),
)
return cmd
@@ -115,7 +113,7 @@ func newEventCommand(globalFlags ...*GlobalFlags) *cobra.Command {
// event consume
// ─────────────────────────────────────────────────────────────────────
func newEventConsumeCommand(globalFlags ...*GlobalFlags) *cobra.Command {
func newEventConsumeCommand() *cobra.Command {
var (
eventTypes []string
filter string
@@ -171,8 +169,6 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
return err
}
if as == "user" {
personalOpts.ExplicitToken = eventExplicitToken(globalFlags)
personalOpts.ClientIDOverride = eventExplicitClientID(globalFlags)
personalOpts.EventKeys = dedupePersonalEventKeys(args)
personalOpts.EventKey = firstArg(personalOpts.EventKeys)
personalOpts.Flatten = flatten
@@ -336,7 +332,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
f.BoolVar(&force, "force", false,
"仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)")
f.BoolVar(&dryRun, "dry-run", false,
"仅打印解析后的配置;不创建订阅、不连接 bus;复用 --subscribe-id 时会只读查询控制面")
"仅打印解析后的配置,不连接 bus / 云端")
f.BoolVar(&foreground, "foreground", false,
"当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它")
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
@@ -401,21 +397,22 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
Reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
},
Selection: contract.SelectionSpec{
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
AgentSummary: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
UseWhen: []string{
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"用户明确要求监听当前身份的所有单聊或所有群消息",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"需要监听指定群的标题变更、成员进退群或群解散事件",
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
"同一目标、同一过滤条件需要同时监听多个兼容事件",
},
AvoidWhen: []string{
"普通 @我、指定发送人/群、全部单聊/群聊及 message/reaction/read/recall 监听优先使用 event +listen-im",
"只查历史聊天记录时用 chat 查询命令",
"查询、同意、拒绝、转交、撤销或发起审批时用 oa;配置应用事件回调时用 dev app event",
"只看事件目录/字段时用 event list / event schema",
},
Examples: []string{
"dws event consume user_oa_approval_task_created user_oa_approval_instance_finished --flatten --duration 10m --format ndjson",
"dws event consume user_im_group_member_added --group cid-example --flatten --max-events 1 --format ndjson",
"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_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson",
},
},
},
@@ -566,8 +563,6 @@ func newEventBusCommand() *cobra.Command {
clientIDOverride string
idleTimeout time.Duration
sourceKindRaw string
runtimeTokenMode bool
identityHashFlag string
streamOpts eventStreamTicketOptions
)
cmd := &cobra.Command{
@@ -604,45 +599,23 @@ func newEventBusCommand() *cobra.Command {
sourceKind = dwsevent.SourceKindAppStream
}
if sourceKind == dwsevent.SourceKindPersonalStream {
var (
identity personal.Identity
identityHash string
)
if runtimeTokenMode {
identityHash = strings.TrimSpace(identityHashFlag)
if !validPersonalIdentityHash(identityHash) {
return failEarly(errors.New("event _bus: --identity-hash must be a 16-character hexadecimal identity hash in runtime token mode"))
}
if strings.TrimSpace(clientIDOverride) == "" {
return failEarly(errors.New("event _bus: --client-id is required in runtime token mode"))
}
identity = personal.Identity{
ClientID: strings.TrimSpace(clientIDOverride),
SourceID: personalEventStreamSourceID(streamOpts.SourceID),
}
} else {
var err error
identity, err = eventResolvePersonal(ctx, configDir, streamOpts.SourceID)
if err != nil {
return failEarly(fmt.Errorf("event _bus: %w", err))
}
if clientIDOverride != "" {
identity.ClientID = clientIDOverride
}
identityHash = dwsevent.IdentityHash(identity.Key())
identity, err := eventResolvePersonal(ctx, configDir, streamOpts.SourceID)
if err != nil {
return failEarly(fmt.Errorf("event _bus: %w", err))
}
if clientIDOverride != "" {
identity.ClientID = clientIDOverride
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
credentialBroker := newPersonalCredentialBroker(configDir, runtimeTokenMode, runtimeTokenMode)
src, err := eventNewPersonalSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: streamOpts.Mode,
TicketURL: streamOpts.TicketURL,
ClientIDOverride: clientIDOverride,
CredentialBroker: credentialBroker,
RuntimeTokenMode: runtimeTokenMode,
})
if err != nil {
return failEarly(err)
@@ -655,18 +628,17 @@ func newEventBusCommand() *cobra.Command {
}
}
busCfg := bus.Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: src,
IdleTimeout: idleTimeout,
ReadyPipe: readyPipe,
Logger: slog.Default(),
CredentialBroker: credentialBroker,
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: src,
IdleTimeout: idleTimeout,
ReadyPipe: readyPipe,
Logger: slog.Default(),
}
bus.ApplyEnvTuning(&busCfg)
return eventBusRun(ctx, busCfg)
@@ -726,18 +698,12 @@ func newEventBusCommand() *cobra.Command {
"exit after this long with zero consumers (0 = disabled)")
cmd.Flags().StringVar(&sourceKindRaw, "source-kind", string(dwsevent.SourceKindAppStream),
"event source kind: app_stream|personal_stream")
cmd.Flags().BoolVar(&runtimeTokenMode, "runtime-token-mode", false,
"use an owner-injected in-memory runtime credential")
cmd.Flags().StringVar(&identityHashFlag, "identity-hash", "",
"pre-resolved non-sensitive personal identity hash")
cmd.Flags().StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")),
"用户 Stream 建联模式:空=SDK app credential;normal/custom=portal 取票")
cmd.Flags().StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"用户 Stream sourceId;personal_stream 开源版默认 open")
cmd.Flags().StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
"用户 Stream 取票 URL;personal_stream 默认由 MCP base URL 派生")
_ = cmd.Flags().MarkHidden("runtime-token-mode")
_ = cmd.Flags().MarkHidden("identity-hash")
return cmd
}
@@ -856,10 +822,6 @@ func newEventListCommand() *cobra.Command {
// ─────────────────────────────────────────────────────────────────────
func newEventStatusCommand() *cobra.Command {
return newEventStatusCommandWithFlags()
}
func newEventStatusCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
var (
all bool
allEditions bool
@@ -885,8 +847,6 @@ func newEventStatusCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command
return fmt.Errorf("event status: %w", err)
}
personalOpts.Format = formatRaw
personalOpts.ExplicitToken = eventExplicitToken(globalFlags)
personalOpts.ClientIDOverride = eventExplicitClientID(globalFlags)
return eventRunPersonalStatus(c, personalOpts)
}
if err := rejectChangedFlags(c, "user", "event", "status", "subscribe-id", "personal-event-base-url", "stream-source-id"); err != nil {
@@ -1178,10 +1138,6 @@ func renderStatusBlock(w io.Writer, qs busctl.EntryStatus) {
}
func newEventStopCommand() *cobra.Command {
return newEventStopCommandWithFlags()
}
func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
var asIdentity string
var opts personalStopOptions
cmd := &cobra.Command{
@@ -1202,8 +1158,6 @@ func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
}
if as == "user" {
opts.SubscribeID = firstArg(args)
opts.ExplicitToken = eventExplicitToken(globalFlags)
opts.ClientIDOverride = eventExplicitClientID(globalFlags)
if eventStopDryRun(c) {
return writeEventStopDryRun(c, as, opts)
}
@@ -1307,20 +1261,6 @@ func eventStopDryRun(cmd *cobra.Command) bool {
return value
}
func eventExplicitToken(globalFlags []*GlobalFlags) string {
if len(globalFlags) == 0 || globalFlags[0] == nil {
return ""
}
return strings.TrimSpace(globalFlags[0].Token)
}
func eventExplicitClientID(globalFlags []*GlobalFlags) string {
if len(globalFlags) == 0 || globalFlags[0] == nil {
return ""
}
return strings.TrimSpace(globalFlags[0].ClientID)
}
func writeEventStopDryRun(cmd *cobra.Command, identity string, opts personalStopOptions) error {
payload := map[string]any{
"dry_run": true,
-297
View File
@@ -1,297 +0,0 @@
// 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 (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/spf13/cobra"
)
type listenIMOptions struct {
Kind string
Events []string
UserID string
OpenDingTalkID string
UserQuery string
ChatID string
ChatQuery string
QueryCSV string
MaxEvents int
Duration time.Duration
DryRun bool
ControlBaseURL string
StreamTicketMode string
StreamTicketURL string
StreamSourceID string
}
type listenIMPlan struct {
EventKeys []string
UserID string
OpenDingTalkID string
GroupID string
ResolvedTargets []any
}
type eventTargetReader struct{}
func (eventTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
text, err := helpers.CallMCPReadToolTextOnServer(product, tool, params)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return data, nil
}
var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} }
func newEventListenIMCommand(globalFlags ...*GlobalFlags) *cobra.Command {
var opts listenIMOptions
cmd := &cobra.Command{
Use: "+listen-im",
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期;本命令只处理 IM,不接收 OA 审批事件。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
plan, err := compileListenIMPlan(eventListenIMReader(), opts)
if err != nil {
return fmt.Errorf("event +listen-im: %w", err)
}
consumeOpts := personalConsumeOptions{
EventKey: firstArg(plan.EventKeys),
EventKeys: plan.EventKeys,
Flatten: true,
UserID: plan.UserID,
OpenDingTalkID: plan.OpenDingTalkID,
GroupID: plan.GroupID,
QueryCSV: opts.QueryCSV,
ControlBaseURL: opts.ControlBaseURL,
StreamTicketMode: opts.StreamTicketMode,
StreamTicketURL: opts.StreamTicketURL,
StreamSourceID: opts.StreamSourceID,
ExplicitToken: eventExplicitToken(globalFlags),
ClientIDOverride: eventExplicitClientID(globalFlags),
Common: commonConsumeOptions{
FormatRaw: "ndjson",
MaxEvents: opts.MaxEvents,
Duration: opts.Duration,
DryRun: opts.DryRun,
},
}
return eventRunPersonalConsume(c, consumeOpts)
},
}
f := cmd.Flags()
f.StringVar(&opts.Kind, "kind", "at-me", "监听意图: at-me|sender|group|all-direct|all-group")
f.StringSliceVar(&opts.Events, "events", []string{"message"}, "事件种类: message,reaction,read,recall")
f.StringVar(&opts.UserID, "user", "", "指定发送人/单聊对端 userId")
f.StringVar(&opts.OpenDingTalkID, "open-dingtalk-id", "", "指定发送人/单聊对端 openDingTalkId")
f.StringVar(&opts.UserQuery, "user-query", "", "按姓名/花名唯一解析指定发送人")
f.StringVar(&opts.ChatID, "chat-id", "", "指定群 openConversationId")
f.StringVar(&opts.ChatQuery, "chat-query", "", "按群名唯一解析指定群")
f.StringVar(&opts.QueryCSV, "query", "", "消息文本关键词过滤,逗号分隔;仅 message 事件")
f.IntVar(&opts.MaxEvents, "max-events", 0, "收到 N 条后退出 (0 = 不限)")
f.DurationVar(&opts.Duration, "duration", 0, "运行时长上限 (Go duration,如 30s/5m;0 = 不限)")
f.BoolVar(&opts.DryRun, "dry-run", false, "解析目标并打印订阅计划,不创建订阅或连接 bus")
f.StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
f.StringVar(&opts.StreamTicketMode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")), "个人 Stream 建联模式;默认 normal")
f.StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), "个人 Stream sourceId;开源版默认 open")
f.StringVar(&opts.StreamTicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")), "个人 Stream 取票 URL")
hideEventInternalFlags(cmd, "personal-event-base-url", "stream-ticket-mode", "stream-source-id", "stream-ticket-url")
cli.AnnotateRuntimeFlagEnum(cmd, "kind", "at-me", "sender", "group", "all-direct", "all-group")
cli.AnnotateRuntimeFlagEnum(cmd, "events", "message", "reaction", "read", "recall")
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id", "user-query", "chat-id", "chat-query"}},
})
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: helpers.LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "event",
Name: "listen_im",
CanonicalPath: "event.listen_im",
CLIPath: "event +listen-im",
PrimaryCLIPath: "event +listen-im",
},
Description: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey,自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按 @我、发送人、群或全量范围监听普通 IM message/reaction/read/recall 事件",
UseWhen: []string{
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
},
AvoidWhen: []string{
"OA 审批事件、群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层控制使用 event consume;只查历史消息使用 chat 查询入口",
},
Examples: []string{
"dws event +listen-im --kind at-me --max-events 1",
"dws event +listen-im --kind group --events message,reaction --chat-id <openConversationId> --duration 10m",
},
},
Parameters: []contract.ParamDecl{
{Name: "chat-id", Property: "chatId"},
{Name: "chat-query", Property: "chatQuery"},
{Name: "dry-run", Property: "dryRun"},
{Name: "duration", Property: "duration"},
{Name: "events", Property: "events"},
{Name: "kind", Property: "kind"},
{Name: "max-events", Property: "maxEvents"},
{Name: "open-dingtalk-id", Property: "openDingtalkId"},
{Name: "query", Property: "query"},
{Name: "user", Property: "user"},
{Name: "user-query", Property: "userQuery"},
},
},
})
return cmd
}
func compileListenIMPlan(reader targetresolver.Reader, opts listenIMOptions) (listenIMPlan, error) {
kind := strings.ToLower(strings.TrimSpace(opts.Kind))
if kind == "" {
kind = "at-me"
}
events := uniqueListenIMValues(opts.Events)
if len(events) == 0 {
return listenIMPlan{}, apperrors.NewValidation("--events 至少包含一个事件种类")
}
if strings.TrimSpace(opts.QueryCSV) != "" {
for _, eventName := range events {
if eventName != "message" {
return listenIMPlan{}, apperrors.NewValidation("--query 只支持 message 事件")
}
}
}
plan := listenIMPlan{}
var err error
switch kind {
case "at-me", "all-direct", "all-group":
if listenIMTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation(fmt.Sprintf("--kind %s 不接受用户或群目标", kind))
}
case "sender":
if listenIMUserTargetCount(opts) != 1 || listenIMChatTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation("--kind sender 必须且只能指定 --user、--open-dingtalk-id 或 --user-query 之一")
}
plan.UserID = strings.TrimSpace(opts.UserID)
plan.OpenDingTalkID = strings.TrimSpace(opts.OpenDingTalkID)
if query := strings.TrimSpace(opts.UserQuery); query != "" {
resolved, resolveErr := targetresolver.ResolveUser(reader, query, targetresolver.IdentityAny)
if resolveErr != nil {
return listenIMPlan{}, resolveErr
}
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
plan.UserID = resolved.Selected.UserID
if plan.UserID == "" {
plan.OpenDingTalkID = resolved.Selected.OpenDingTalkID
}
}
case "group":
if listenIMChatTargetCount(opts) != 1 || listenIMUserTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation("--kind group 必须且只能指定 --chat-id 或 --chat-query 之一")
}
plan.GroupID = strings.TrimSpace(opts.ChatID)
if query := strings.TrimSpace(opts.ChatQuery); query != "" {
resolved, resolveErr := targetresolver.ResolveChat(reader, query)
if resolveErr != nil {
return listenIMPlan{}, resolveErr
}
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
plan.GroupID = resolved.Selected.OpenConversationID
}
default:
return listenIMPlan{}, apperrors.NewValidation("--kind 必须是 at-me、sender、group、all-direct 或 all-group")
}
plan.EventKeys, err = listenIMEventKeys(kind, events)
if err != nil {
return listenIMPlan{}, err
}
return plan, nil
}
func listenIMEventKeys(kind string, events []string) ([]string, error) {
mapping := map[string]map[string]string{
"at-me": {"message": personal.EventMention},
"sender": {"message": personal.EventFromUser, "reaction": personal.EventReactionO2O, "read": personal.EventReadO2O, "recall": personal.EventRecallO2O},
"group": {"message": personal.EventInChat, "reaction": personal.EventReactionGroup, "read": personal.EventReadGroup, "recall": personal.EventRecallGroup},
"all-direct": {"message": personal.EventAllSingleChat},
"all-group": {"message": personal.EventAllGroupChat},
}
byEvent := mapping[kind]
keys := make([]string, 0, len(events))
for _, eventName := range events {
key := byEvent[eventName]
if key == "" {
return nil, apperrors.NewValidation(fmt.Sprintf("--kind %s 不支持 event %s", kind, eventName))
}
keys = append(keys, key)
}
return keys, nil
}
func listenIMUserTargetCount(opts listenIMOptions) int {
return nonEmptyListenIMCount(opts.UserID, opts.OpenDingTalkID, opts.UserQuery)
}
func listenIMChatTargetCount(opts listenIMOptions) int {
return nonEmptyListenIMCount(opts.ChatID, opts.ChatQuery)
}
func listenIMTargetCount(opts listenIMOptions) int {
return listenIMUserTargetCount(opts) + listenIMChatTargetCount(opts)
}
func nonEmptyListenIMCount(values ...string) int {
count := 0
for _, value := range values {
if strings.TrimSpace(value) != "" {
count++
}
}
return count
}
func uniqueListenIMValues(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
-360
View File
@@ -1,360 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"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/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type listenIMFakeReader struct {
responses map[string]map[string]any
calls []string
}
type listenIMErrorReader struct{ err error }
func (r listenIMErrorReader) CallMCPData(string, string, map[string]any) (map[string]any, error) {
return nil, r.err
}
type listenIMHelperCaller struct {
text string
err error
}
func (c listenIMHelperCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result()
}
func (c listenIMHelperCaller) CallReadTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result()
}
func (c listenIMHelperCaller) result() (*edition.ToolResult, error) {
if c.err != nil {
return nil, c.err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil
}
func (listenIMHelperCaller) Format() string { return "json" }
func (listenIMHelperCaller) DryRun() bool { return false }
func (listenIMHelperCaller) Fields() string { return "" }
func (listenIMHelperCaller) JQ() string { return "" }
func (f *listenIMFakeReader) CallMCPData(product, tool string, _ map[string]any) (map[string]any, error) {
key := product + "/" + tool
f.calls = append(f.calls, key)
if response, ok := f.responses[key]; ok {
return response, nil
}
return map[string]any{}, nil
}
func TestCrossPlatformCoverageCompileListenIMPlanResolvesGroupAndMapsMultipleEvents(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
plan, err := compileListenIMPlan(reader, listenIMOptions{
Kind: "group",
Events: []string{"message", "reaction", "recall"},
ChatQuery: "项目群",
})
if err != nil {
t.Fatal(err)
}
wantKeys := []string{personal.EventInChat, personal.EventReactionGroup, personal.EventRecallGroup}
if !reflect.DeepEqual(plan.EventKeys, wantKeys) || plan.GroupID != "cid-1" {
t.Fatalf("plan = %#v, want keys=%v group=cid-1", plan, wantKeys)
}
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
t.Fatalf("resolver calls = %#v", reader.calls)
}
}
func TestCrossPlatformCoverageCompileListenIMPlanReturnsStructuredAmbiguityBeforeSubscription(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"contact/search_contact_by_key_word": {
"result": []any{
map[string]any{"name": "张三", "userId": "u1"},
map[string]any{"name": "张三", "userId": "u2"},
},
},
}}
_, err := compileListenIMPlan(reader, listenIMOptions{
Kind: "sender",
Events: []string{"message"},
UserQuery: "张三",
})
if err == nil {
t.Fatal("ambiguous sender unexpectedly compiled")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
t.Fatalf("ambiguity error = %#v", err)
}
}
func TestCrossPlatformCoverageEventListenIMCommandDelegatesOneCompiledConsumeLifecycle(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
oldReader := eventListenIMReader
oldRun := eventRunPersonalConsume
t.Cleanup(func() {
eventListenIMReader = oldReader
eventRunPersonalConsume = oldRun
})
eventListenIMReader = func() targetresolver.Reader { return reader }
var captured personalConsumeOptions
var calls int
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
calls++
captured = opts
return nil
}
cmd := newEventListenIMCommand()
cmd.SetArgs([]string{
"--kind", "group",
"--events", "message,reaction",
"--chat-query", "项目群",
"--max-events", "2",
"--duration", "30s",
"--dry-run",
})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("consume lifecycle calls = %d, want 1", calls)
}
if !reflect.DeepEqual(captured.EventKeys, []string{personal.EventInChat, personal.EventReactionGroup}) ||
captured.GroupID != "cid-1" || !captured.Flatten || !captured.Common.DryRun ||
captured.Common.MaxEvents != 2 || captured.Common.Duration.String() != "30s" {
t.Fatalf("captured options = %#v", captured)
}
}
func TestCrossPlatformCoverageCompileListenIMPlanRejectsIncompatibleKindAndTargets(t *testing.T) {
reader := &listenIMFakeReader{}
cases := []listenIMOptions{
{Kind: "at-me", Events: []string{"reaction"}},
{Kind: "all-group", Events: []string{"message"}, ChatID: "cid"},
{Kind: "sender", Events: []string{"message"}},
{Kind: "group", Events: []string{"message"}, ChatID: "cid", ChatQuery: "群"},
{Kind: "group", Events: []string{"message", "reaction"}, ChatID: "cid", QueryCSV: "关键词"},
}
for _, opts := range cases {
if _, err := compileListenIMPlan(reader, opts); err == nil {
t.Errorf("options unexpectedly accepted: %#v", opts)
}
}
}
func TestCrossPlatformCoverageListenIMCompletionBranches(t *testing.T) {
for _, tc := range []struct {
name string
text string
err error
ok bool
}{
{name: "transport", err: errors.New("transport")},
{name: "empty", text: " ", ok: true},
{name: "invalid json", text: "{invalid"},
{name: "valid", text: `{"result":{"ok":true}}`, ok: true},
} {
t.Run("reader "+tc.name, func(t *testing.T) {
helpers.InitDeps(listenIMHelperCaller{text: tc.text, err: tc.err})
data, err := (eventTargetReader{}).CallMCPData("im", "search_groups", nil)
if (err == nil) != tc.ok {
t.Fatalf("data=%#v error=%v ok=%v", data, err, tc.ok)
}
})
}
if plan, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Events: []string{" MESSAGE ", "message"}}); err != nil || len(plan.EventKeys) != 1 {
t.Fatalf("default/deduplicated plan = %#v, %v", plan, err)
}
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "at-me"}); err == nil {
t.Fatal("empty event set unexpectedly accepted")
}
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "unknown", Events: []string{"message"}}); err == nil {
t.Fatal("unknown kind unexpectedly accepted")
}
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"contact/search_contact_by_key_word": {
"result": []any{map[string]any{"name": "甲", "openDingTalkId": "D-user"}},
},
}}
plan, err := compileListenIMPlan(reader, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"})
if err != nil || plan.UserID != "" || plan.OpenDingTalkID != "D-user" {
t.Fatalf("open-id sender plan = %#v, %v", plan, err)
}
wantErr := errors.New("resolution failed")
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"}); !errors.Is(err, wantErr) {
t.Fatalf("sender resolution error = %v", err)
}
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "group", Events: []string{"message"}, ChatQuery: "群"}); !errors.Is(err, wantErr) {
t.Fatalf("group resolution error = %v", err)
}
cmd := newEventListenIMCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--kind", "sender"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "event +listen-im") {
t.Fatalf("command compile error = %v", err)
}
}
func TestCrossPlatformCoverageEventListenIME2ELifecycleCleansAndRollsBack(t *testing.T) {
newReader := func() *listenIMFakeReader {
return &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
}
installFacade := func(t *testing.T, reader *listenIMFakeReader) {
t.Helper()
oldReader := eventListenIMReader
oldRun := eventRunPersonalConsume
t.Cleanup(func() {
eventListenIMReader = oldReader
eventRunPersonalConsume = oldRun
})
eventListenIMReader = func() targetresolver.Reader { return reader }
eventRunPersonalConsume = runPersonalEventConsume
}
installLifecycle := func(t *testing.T) {
t.Helper()
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token", CorpID: "corp", UserID: "user",
ClientID: "client", SourceID: "open",
}, nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
}
t.Run("ready then clean every created subscription", func(t *testing.T) {
reader := newReader()
installFacade(t, reader)
installLifecycle(t)
var created, deleted, removed []string
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
created = append(created, opts.EventKey)
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "group", nil
}
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
if len(specs) != 2 || !cfg.Flatten {
t.Fatalf("consume specs/config = %#v / %#v", specs, cfg)
}
fmt.Fprintf(cfg.Stderr, "[event] ready event_count=%d bus_pid=123\n", len(specs))
return nil
}
cmd := newEventListenIMCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SetArgs([]string{
"--kind", "group", "--events", "message,reaction",
"--chat-query", "项目群", "--max-events", "1",
})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
wantEvents := []string{personal.EventInChat, personal.EventReactionGroup}
if !reflect.DeepEqual(created, wantEvents) {
t.Fatalf("created = %#v, want %#v", created, wantEvents)
}
wantDeleted := []string{"sub-" + personal.EventReactionGroup, "sub-" + personal.EventInChat}
if !reflect.DeepEqual(deleted, wantDeleted) || !reflect.DeepEqual(removed, wantDeleted) {
t.Fatalf("deleted=%#v removed=%#v want=%#v", deleted, removed, wantDeleted)
}
if !strings.Contains(stderr.String(), "[event] ready event_count=2") {
t.Fatalf("missing ready marker: %s", stderr.String())
}
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
t.Fatalf("resolver calls = %#v", reader.calls)
}
})
t.Run("second create failure rolls back first without starting consumer", func(t *testing.T) {
reader := newReader()
installFacade(t, reader)
installLifecycle(t)
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, "group", nil
}
var deleted, removed []string
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
t.Fatal("consumer started after partial subscription failure")
return nil
}
cmd := newEventListenIMCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SilenceUsage = true
cmd.SetArgs([]string{
"--kind", "group", "--events", "message,reaction",
"--chat-query", "项目群",
})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), wantErr.Error()) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
}
})
}
-20
View File
@@ -28,7 +28,6 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
@@ -185,25 +184,6 @@ func (r *personalSubscriptionAttemptReservation) completeSuccess() error {
return nil
}
// releaseRuntimeTokenFailure releases the in-flight claim without recording a
// cross-invocation hold. A host may supply a fresh token on the very next
// command, which must be allowed to retry immediately.
func (r *personalSubscriptionAttemptReservation) releaseRuntimeTokenFailure() error {
if r == nil {
return runtimecred.ErrRuntimeTokenRejected
}
if r.store == nil || r.claim == nil {
return personalSubscriptionGuardError(errors.Join(
runtimecred.ErrRuntimeTokenRejected,
errors.New("personal event: subscription attempt reservation is incomplete"),
))
}
if err := r.store.Release(r.claim); err != nil {
return personalSubscriptionGuardError(errors.Join(runtimecred.ErrRuntimeTokenRejected, err))
}
return runtimecred.ErrRuntimeTokenRejected
}
func (r *personalSubscriptionAttemptReservation) completeFailure(
ctx context.Context,
failedIndex int,
+2 -2
View File
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
}
}
if publicCount != 22 {
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
if publicCount != 16 {
t.Fatalf("public personal events = %d, want 16", publicCount)
}
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
if !ruleTypes[ruleType] {
+72 -528
View File
@@ -14,7 +14,6 @@
package app
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -27,7 +26,6 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"text/tabwriter"
"time"
@@ -41,7 +39,6 @@ import (
"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/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
@@ -84,8 +81,6 @@ type personalConsumeOptions struct {
StreamTicketMode string
StreamTicketURL string
StreamSourceID string
ExplicitToken string
ClientIDOverride string
}
type personalListOptions struct {
@@ -96,23 +91,19 @@ type personalListOptions struct {
}
type personalStatusOptions struct {
EventKey string
Status string
SubscribeID string
Format string
ControlBaseURL string
StreamSourceID string
ExplicitToken string
ClientIDOverride string
EventKey string
Status string
SubscribeID string
Format string
ControlBaseURL string
StreamSourceID string
}
type personalStopOptions struct {
SubscribeID string
All bool
ControlBaseURL string
StreamSourceID string
ExplicitToken string
ClientIDOverride string
SubscribeID string
All bool
ControlBaseURL string
StreamSourceID string
}
type personalStreamSourceOptions struct {
@@ -121,8 +112,6 @@ type personalStreamSourceOptions struct {
TicketMode string
TicketURL string
ClientIDOverride string
CredentialBroker *runtimecred.Broker
RuntimeTokenMode bool
}
var (
@@ -152,19 +141,10 @@ var (
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
personalForceRefreshRejectedToken = forceRefreshRejectedAccessToken
personalLoadTokenData = authpkg.LoadTokenData
personalLoadProfiles = authpkg.LoadProfiles
personalClientID = authpkg.ClientID
personalRuntimeEventClientID = runtimePersonalEventClientID
personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict
)
func runtimePersonalEventClientID() string {
if clientID := strings.TrimSpace(edition.Get().AuthClientID); clientID != "" {
return clientID
}
return strings.TrimSpace(os.Getenv("DWS_CLIENT_ID"))
}
func newEventSchemaCommand() *cobra.Command {
var asIdentity string
var formatRaw string
@@ -221,15 +201,12 @@ func newEventSchemaCommand() *cobra.Command {
},
Selection: contract.SelectionSpec{
AgentSummary: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
UseWhen: []string{"已知任一公开个人 IM 或 OA event_key,消费前需要理解 --flatten 输出字段或 payload 契约"},
UseWhen: []string{"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"},
AvoidWhen: []string{
"查询 CLI 命令参数契约时用顶层 dws schema",
"要实际收事件时用 event consume",
},
Examples: []string{
"dws event schema user_im_message_receive_at --flatten --format json",
"dws event schema user_oa_approval_task_created --flatten --format json",
},
Examples: []string{"dws event schema user_im_message_receive_at --flatten --format json"},
},
},
})
@@ -285,9 +262,6 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return personalSubscriptionValidationError(err)
}
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
@@ -302,7 +276,7 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
@@ -310,41 +284,23 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
spawnProfileSelector := ""
if strings.TrimSpace(opts.ExplicitToken) == "" {
spawnProfileSelector = personalBusProfileSelector(configDir, identity)
}
spawnArgs := personalBusSpawnArgsForToken(
identity,
identityHash,
opts.StreamTicketMode,
opts.StreamTicketURL,
spawnProfileSelector,
opts.ExplicitToken,
)
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
} else {
_, eventKey, _, err := personalEnsureSubscription(ctx, client, identity, opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
opts.EventKey = eventKey
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgsForToken(identity, identityHash, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector, opts.ExplicitToken),
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
@@ -371,8 +327,7 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: spawnArgs,
RuntimeToken: strings.TrimSpace(opts.ExplicitToken),
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
@@ -401,31 +356,20 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
}
}
var (
foregroundSource *source.PersonalSource
foregroundBroker *runtimecred.Broker
)
var foregroundSource *source.PersonalSource
if opts.Common.Foreground {
explicitToken := strings.TrimSpace(opts.ExplicitToken)
foregroundBroker = newPersonalCredentialBroker(configDir, explicitToken != "", false)
if explicitToken != "" {
if _, err := foregroundBroker.Update(0, explicitToken); err != nil {
return personalSubscriptionValidationError(err)
}
}
foregroundSource, err = personalNewStreamSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
CredentialBroker: foregroundBroker,
RuntimeTokenMode: explicitToken != "",
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
})
if err != nil {
return personalSubscriptionValidationError(err)
}
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
var attempt *personalSubscriptionAttemptReservation
if strings.TrimSpace(opts.SubscribeID) == "" {
attempt, err = reservePersonalSubscriptionAttempts(
@@ -441,10 +385,6 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
}
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
if err != nil {
if strings.TrimSpace(opts.ExplicitToken) != "" && personalRuntimeTokenControlRejection(err) {
err = attempt.releaseRuntimeTokenFailure()
return fmt.Errorf("event consume --as user: %w", err)
}
err = attempt.completeFailure(ctx, 0, 0, err, nil)
return fmt.Errorf("event consume --as user: %w", err)
}
@@ -468,17 +408,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
)
return fmt.Errorf("event consume --as user: %w", err)
}
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
ownsSubscription := selfCreated || opts.Ephemeral
var cleanupOnce sync.Once
cleanupOwnedSubscription := func(cleanupCtx context.Context) {
if !ownsSubscription {
return
}
cleanupOnce.Do(func() {
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
})
cleanup := func(cleanupCtx context.Context) {
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
}
if err := personalUpsertRunState(workDir, personal.RunState{
SubscribeID: sub.SubscribeID,
@@ -489,19 +421,19 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
IdentityHash: identityHash,
}); err != nil {
wrapped := fmt.Errorf("save run state: %w", err)
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, wrapped) {
cleanupCtx = ctx
}
if attempt != nil {
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, wrapped) {
cleanupCtx = ctx
}
classification := personalSubscriptionLocalFailure()
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
cleanup(cleanupCtx)
}
cleanupOwnedSubscription(cleanupCtx)
return fmt.Errorf("event consume --as user: %w", wrapped)
}
if err := attempt.completeSuccess(); err != nil {
cleanupOwnedSubscription(context.Background())
cleanup(context.Background())
return fmt.Errorf("event consume --as user: %w", err)
}
// Ownership-based cleanup: a subscription this run CREATED is
@@ -510,8 +442,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
// leaks server-side. A subscription REUSED via --subscribe-id is left
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
// either way.
if ownsSubscription {
defer cleanupOwnedSubscription(context.Background())
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
if opts.Ephemeral || selfCreated {
defer cleanup(context.Background())
}
cfg.EventKey = eventKey
@@ -523,20 +456,27 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
}
if opts.Common.Foreground {
busCfg := bus.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: foregroundSource,
CredentialBroker: foregroundBroker,
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: foregroundSource,
}
bus.ApplyEnvTuning(&busCfg)
return personalBusRun(ctx, busCfg)
err = personalBusRun(ctx, busCfg)
if err != nil && !opts.Ephemeral {
cleanup(context.Background())
}
return err
}
return personalConsumeRun(ctx, cfg)
err = personalConsumeRun(ctx, cfg)
if err != nil && !opts.Ephemeral {
cleanup(context.Background())
}
return err
}
type personalMultiSubscription struct {
@@ -565,7 +505,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
ctx := c.Context()
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
@@ -573,10 +513,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
spawnProfileSelector := ""
if strings.TrimSpace(opts.ExplicitToken) == "" {
spawnProfileSelector = personalBusProfileSelector(configDir, identity)
}
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
@@ -585,7 +522,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgsForToken(identity, identityHash, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector, opts.ExplicitToken),
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
@@ -611,9 +548,8 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
printPersonalMultiDryRun(c.ErrOrStderr(), baseCfg, plans)
return nil
}
baseCfg.RuntimeToken = strings.TrimSpace(opts.ExplicitToken)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
attempt, err := reservePersonalSubscriptionAttempts(
workDir,
client,
@@ -650,10 +586,6 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
if personalSubscriptionCanceled(ctx, cause) {
cleanupCtx = ctx
}
if strings.TrimSpace(opts.ExplicitToken) != "" && personalRuntimeTokenControlRejection(cause) {
cleanup(cleanupCtx)
return attempt.releaseRuntimeTokenFailure()
}
completed := attempt.completeFailure(ctx, failedIndex, succeededCount, cause, override)
// Persist the hold (or release a canceled claim) before any potentially
// slow remote rollback. Otherwise the attempt lease can expire while
@@ -756,9 +688,6 @@ func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsume
if !def.Public {
return nil, personal.PublicAvailabilityError(eventKey)
}
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
return nil, err
}
switch def.RuleType {
case "singleChat", "sender":
hasUserScope = true
@@ -885,9 +814,6 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
}
func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
return err
}
if _, _, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
@@ -900,37 +826,6 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
return err
}
func validatePersonalOAOptions(eventKey string, opts personalConsumeOptions) error {
changed := personalOAOptionNames(opts)
if len(changed) == 0 {
return nil
}
def, ok := personalLookupDefinition(strings.TrimSpace(eventKey))
if !ok || def.Category != "oa" {
return nil
}
return fmt.Errorf("%s not supported for OA event %s", strings.Join(changed, ", "), eventKey)
}
func personalOAOptionNames(opts personalConsumeOptions) []string {
var changed []string
for _, item := range []struct {
name string
value string
}{
{name: "--user", value: opts.UserID},
{name: "--open-dingtalk-id", value: opts.OpenDingTalkID},
{name: "--group", value: opts.GroupID},
{name: "--query", value: opts.QueryCSV},
{name: "--filter-json", value: opts.FilterJSON},
} {
if strings.TrimSpace(item.value) != "" {
changed = append(changed, item.name)
}
}
return changed
}
type personalPreparedSubscription struct {
EventKey string
RuleType string
@@ -944,9 +839,6 @@ func preparePersonalSubscription(identity personal.Identity, opts personalConsum
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return personalPreparedSubscription{}, err
}
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
return personalPreparedSubscription{}, err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
@@ -993,32 +885,13 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
if err != nil {
return nil, "", "", err
}
if sub == nil {
return nil, "", "", errors.New("personal event: server returned an empty subscription")
}
requestedEventKey := strings.TrimSpace(opts.EventKey)
actualEventKey := strings.TrimSpace(sub.EventKey)
if requestedEventKey != "" && actualEventKey != "" && requestedEventKey != actualEventKey {
return nil, "", "", fmt.Errorf(
"event_key %q does not match reused subscription %q event_key %q",
requestedEventKey,
strings.TrimSpace(opts.SubscribeID),
actualEventKey,
)
}
eventKey := actualEventKey
if eventKey == "" {
eventKey = requestedEventKey
}
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
if eventKey == "" {
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
}
if err := ensurePublicPersonalEvent(eventKey); err != nil {
return nil, "", "", err
}
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
return nil, "", "", err
}
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
if ruleType == "" {
if def, ok := personal.Lookup(eventKey); ok {
@@ -1041,7 +914,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
return err
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event status --as user: %w", err)
}
@@ -1073,7 +946,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
if status == "" || status == "all" {
status = ""
}
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken), ctx, personal.ListOptions{
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
Status: status,
EventKey: opts.EventKey,
SubscribeID: opts.SubscribeID,
@@ -1094,15 +967,6 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
return nil
}
func personalRuntimeTokenControlRejection(err error) bool {
var apiErr *personal.APIError
if !errors.As(err, &apiErr) || apiErr == nil {
return false
}
return apiErr.HTTPStatus == http.StatusUnauthorized ||
strings.EqualFold(strings.TrimSpace(apiErr.Code), "RUNTIME_TOKEN_REJECTED")
}
func ensurePublicPersonalEvent(eventKey string) error {
eventKey = strings.TrimSpace(eventKey)
if eventKey == "" {
@@ -1185,7 +1049,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
@@ -1197,7 +1061,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
for _, id := range subscribeIDs {
if err := personalDeleteSubscription(client, ctx, id); err != nil {
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
@@ -1313,138 +1177,6 @@ func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, bu
fmt.Fprintf(w, "cancelled %d personal subscription(s); %s\n", len(subscribeIDs), busState)
}
func resolvePersonalEventIdentityForToken(ctx context.Context, configDir, sourceIDOverride, explicitToken string, clientIDOverrides ...string) (personal.Identity, error) {
explicitToken = strings.TrimSpace(explicitToken)
if explicitToken == "" {
return personalResolveEventIdentity(ctx, configDir, sourceIDOverride)
}
clientIDOverride := ""
if len(clientIDOverrides) > 0 {
clientIDOverride = strings.TrimSpace(clientIDOverrides[0])
}
return resolvePersonalEventIdentityWithToken(ctx, configDir, sourceIDOverride, explicitToken, clientIDOverride)
}
// resolvePersonalEventIdentityWithToken resolves only non-sensitive identity
// metadata around a caller-supplied bearer token. It intentionally does not
// call LoadTokenData or any refresh-capable token resolver: an explicit root
// --token must never be replaced with, persisted into, or used to refresh a
// local OAuth profile.
func resolvePersonalEventIdentityWithToken(ctx context.Context, configDir, sourceIDOverride, explicitToken string, clientIDOverrides ...string) (personal.Identity, error) {
explicitToken = strings.TrimSpace(explicitToken)
if explicitToken == "" {
return resolvePersonalEventIdentity(ctx, configDir, sourceIDOverride)
}
if strings.Contains(strings.TrimSpace(authpkg.RuntimeProfile()), ",") {
return personal.Identity{}, fmt.Errorf("personal events require exactly one --profile")
}
corpID := resolveRuntimeDefault(ctx, "$corpId")
userID := resolveRuntimeDefault(ctx, "$currentUserId")
clientID := ""
if len(clientIDOverrides) > 0 {
clientID = strings.TrimSpace(clientIDOverrides[0])
}
if clientID == "" {
// An edition hook or explicit environment value is runtime identity,
// not persisted app state. Resolve it before profiles.json so a complete
// host context never depends on local OAuth metadata health.
clientID = strings.TrimSpace(personalRuntimeEventClientID())
}
explicitProfile := strings.TrimSpace(authpkg.RuntimeProfile()) != ""
if explicitProfile || corpID == "" || userID == "" || clientID == "" {
profile, err := personalEventProfileMetadata(configDir)
if err != nil {
// A user-selected --profile remains a strict contract. Without an
// explicit selector, profiles.json is optional metadata for a
// host-managed bearer: malformed or stale persisted state must not
// override complete runtime defaults or prevent the later global
// client-id fallback.
if explicitProfile {
return personal.Identity{}, fmt.Errorf("load OAuth identity metadata: %w", err)
}
profile = nil
}
if profile != nil {
if corpID == "" {
corpID = strings.TrimSpace(profile.CorpID)
}
if userID == "" {
userID = strings.TrimSpace(profile.UserID)
}
if clientID == "" {
clientID = strings.TrimSpace(profile.ClientID)
}
}
}
if clientID == "" {
// Persisted/global app credentials are only a fallback after the
// selected profile, so an old app config cannot override profile.ClientID.
clientID = strings.TrimSpace(personalClientID())
}
if clientID == "" {
if id, _, _, _, resolveErr := personalResolveAppCredentialsStrict(configDir); resolveErr == nil {
clientID = strings.TrimSpace(id)
}
}
if clientID == "" {
return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events")
}
sourceID := strings.TrimSpace(sourceIDOverride)
if sourceID == "" {
sourceID = personalEventStreamSourceID("")
}
localSubject := ""
if corpID == "" || userID == "" {
localSubject = personalTokenSubject("access", explicitToken)
}
return personal.Identity{
LocalSubject: localSubject,
CorpID: corpID,
UserID: userID,
ClientID: clientID,
SourceID: sourceID,
}, nil
}
func personalEventProfileMetadata(configDir string) (*authpkg.Profile, error) {
cfg, err := personalLoadProfiles(configDir)
if err != nil {
return nil, err
}
selector := strings.TrimSpace(authpkg.RuntimeProfile())
explicitSelector := selector != ""
if strings.Contains(selector, ",") {
return nil, fmt.Errorf("personal events require exactly one --profile")
}
if cfg == nil || len(cfg.Profiles) == 0 {
if explicitSelector {
return nil, fmt.Errorf("profile %q not found", selector)
}
return nil, nil
}
if selector == "" {
selector = strings.TrimSpace(cfg.CurrentProfile)
}
if selector == "" {
return nil, nil
}
profile, err := selectPersonalEventProfileMetadata(cfg, selector, make(map[string]struct{}))
if err != nil && !explicitSelector {
// A stale persisted CurrentProfile must not make a host-provided bearer
// unusable. Runtime defaults and the one-way local subject are sufficient
// to isolate the event bus without consulting local OAuth credentials.
return nil, nil
}
return profile, err
}
func selectPersonalEventProfileMetadata(cfg *authpkg.ProfilesConfig, selector string, visited map[string]struct{}) (*authpkg.Profile, error) {
_ = visited // retained for the focused compatibility seam used by app tests.
return authpkg.ResolveProfileMetadata(cfg, strings.TrimSpace(selector))
}
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
accessToken, err := personalResolveAuxiliaryAccessToken(ctx, configDir, "")
if err != nil {
@@ -1499,11 +1231,7 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
}, nil
}
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity, explicitTokens ...string) *personal.Client {
explicitToken := ""
if len(explicitTokens) > 0 {
explicitToken = strings.TrimSpace(explicitTokens[0])
}
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
identity.AccessToken = ""
client := personal.NewClient(baseURL, identity)
version := strings.TrimSpace(RawVersion())
@@ -1512,146 +1240,12 @@ func newPersonalEventControlClient(configDir, baseURL string, identity personal.
}
client.ClientVersion = version
client.UserAgent = "dws-cli/" + version
if explicitToken != "" {
client.AccessTokenProvider = func(context.Context) (string, error) { return explicitToken, nil }
client.HTTPClient.Transport = runtimeTokenControlTransport{base: http.DefaultTransport, token: explicitToken}
client.HTTPClient.CheckRedirect = runtimeTokenRedirectPolicy
} else {
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
}
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
}
return client
}
// runtimeTokenRedirectPolicy prevents Go's redirect machinery from copying
// DWS's custom x-user-access-token header to another authority. Returning
// ErrUseLastResponse keeps the 3xx response available to the caller without a
// url.Error that could echo an attacker-controlled Location value.
func runtimeTokenRedirectPolicy(req *http.Request, via []*http.Request) error {
if len(via) == 0 || req == nil || req.URL == nil || via[0] == nil || via[0].URL == nil {
return http.ErrUseLastResponse
}
origin := via[0].URL
if !strings.EqualFold(strings.TrimSpace(req.URL.Host), strings.TrimSpace(origin.Host)) {
return http.ErrUseLastResponse
}
if strings.EqualFold(origin.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") {
return http.ErrUseLastResponse
}
return nil
}
const runtimeTokenControlErrorBody = `{"code":"RUNTIME_TOKEN_REJECTED","message":"event runtime token was rejected; retry with a fresh host credential"}`
// runtimeTokenControlTransport scrubs an explicit bearer from every response
// body and diagnostic header before the control client decodes or logs it. A
// 401 is replaced with a fixed rejection envelope so untrusted response text
// can never escape through stderr or debug logs.
type runtimeTokenControlTransport struct {
base http.RoundTripper
token string
}
func (t runtimeTokenControlTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.base
if base == nil {
base = http.DefaultTransport
}
resp, err := base.RoundTrip(req)
if err != nil {
if token := strings.TrimSpace(t.token); token != "" && strings.Contains(err.Error(), token) {
return nil, errors.New("personal event: runtime-token control request failed")
}
return nil, err
}
if resp == nil {
return resp, err
}
token := strings.TrimSpace(t.token)
for key, values := range resp.Header {
for i := range values {
if token != "" {
values[i] = strings.ReplaceAll(values[i], token, "<redacted-runtime-token>")
}
}
resp.Header[key] = values
}
var responseBody []byte
if resp.Body != nil {
responseBody, err = io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
_ = resp.Body.Close()
if err != nil {
return nil, errors.New("personal event: read runtime-token control response")
}
}
if resp.StatusCode == http.StatusUnauthorized {
responseBody = []byte(runtimeTokenControlErrorBody)
} else if token != "" {
responseBody = redactRuntimeTokenResponseBody(responseBody, token)
}
resp.Body = io.NopCloser(bytes.NewReader(responseBody))
resp.ContentLength = int64(len(responseBody))
if resp.Header == nil {
resp.Header = make(http.Header)
}
resp.Header.Set("Content-Type", "application/json")
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(responseBody)))
return resp, nil
}
func redactRuntimeTokenResponseBody(data []byte, token string) []byte {
token = strings.TrimSpace(token)
if len(data) == 0 || token == "" {
return data
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var decoded any
if err := decoder.Decode(&decoded); err == nil {
var trailing any
if trailingErr := decoder.Decode(&trailing); errors.Is(trailingErr, io.EOF) {
if redacted, changed := redactRuntimeTokenJSONValue(decoded, token); changed {
if encoded, marshalErr := json.Marshal(redacted); marshalErr == nil {
return encoded
}
}
}
}
return bytes.ReplaceAll(data, []byte(token), []byte("<redacted-runtime-token>"))
}
func redactRuntimeTokenJSONValue(value any, token string) (any, bool) {
switch typed := value.(type) {
case string:
redacted := strings.ReplaceAll(typed, token, "<redacted-runtime-token>")
return redacted, redacted != typed
case []any:
changed := false
for i := range typed {
var itemChanged bool
typed[i], itemChanged = redactRuntimeTokenJSONValue(typed[i], token)
changed = changed || itemChanged
}
return typed, changed
case map[string]any:
changed := false
redactedMap := make(map[string]any, len(typed))
for key, item := range typed {
redactedKey := strings.ReplaceAll(key, token, "<redacted-runtime-token>")
redacted, itemChanged := redactRuntimeTokenJSONValue(item, token)
redactedMap[redactedKey] = redacted
changed = changed || itemChanged || redactedKey != key
}
if !changed {
return typed, false
}
return redactedMap, true
default:
return value, false
}
}
func personalTokenSubject(kind, token string) string {
token = strings.TrimSpace(token)
if token == "" {
@@ -1661,15 +1255,6 @@ func personalTokenSubject(kind, token string) string {
return strings.TrimSpace(kind) + ":" + hex.EncodeToString(sum[:])
}
func validPersonalIdentityHash(value string) bool {
value = strings.TrimSpace(value)
if len(value) != 16 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func resolveRuntimeDefault(ctx context.Context, key string) string {
if fnMap := edition.Get().RuntimeDefaults; fnMap != nil {
if fn := fnMap()[key]; fn != nil {
@@ -1707,42 +1292,20 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
}
clientSecret = secret
}
credentialBroker := opts.CredentialBroker
if credentialBroker == nil {
credentialBroker = newPersonalCredentialBroker(opts.ConfigDir, false, false)
}
httpClient := &http.Client{Timeout: 30 * time.Second}
if opts.RuntimeTokenMode {
httpClient.CheckRedirect = runtimeTokenRedirectPolicy
}
_ = ctx
return source.NewPersonal(source.PersonalConfig{
AccessTokenProvider: func(ctx context.Context) (string, error) {
return credentialBroker.Resolve(ctx)
return personalResolveAuxiliaryAccessToken(ctx, opts.ConfigDir, "")
},
ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) {
return credentialBroker.RefreshRejected(ctx, rejectedToken)
},
ClassifyRetryReject: credentialBroker.ClassifyRejectedAfterRetry,
ClientID: clientID,
ClientSecret: clientSecret,
SourceID: opts.Identity.SourceID,
TicketURL: ticketURL,
TicketMode: mode,
HTTPClient: httpClient,
})
}
func newPersonalCredentialBroker(configDir string, requireSeed, requireActivation bool) *runtimecred.Broker {
return runtimecred.New(runtimecred.Config{
RequireSeed: requireSeed,
RequireActivation: requireActivation,
LocalResolve: func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
},
LocalRefresh: func(ctx context.Context, rejectedToken string) (string, error) {
return personalForceRefreshRejectedToken(ctx, configDir, rejectedToken)
return personalForceRefreshRejectedToken(ctx, opts.ConfigDir, rejectedToken)
},
ClientID: clientID,
ClientSecret: clientSecret,
SourceID: opts.Identity.SourceID,
TicketURL: ticketURL,
TicketMode: mode,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
})
}
@@ -1807,25 +1370,6 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri
return args
}
func personalBusSpawnArgsForToken(identity personal.Identity, identityHash, ticketMode, ticketURL, profileSelector, explicitToken string) []string {
if strings.TrimSpace(explicitToken) == "" {
return personalBusSpawnArgs(identity, ticketMode, ticketURL, profileSelector)
}
args := []string{
"--source-kind", string(dwsevent.SourceKindPersonalStream),
"--runtime-token-mode",
"--identity-hash", strings.TrimSpace(identityHash),
"--stream-source-id", strings.TrimSpace(identity.SourceID),
}
if strings.TrimSpace(ticketMode) != "" {
args = append(args, "--stream-ticket-mode", strings.TrimSpace(ticketMode))
}
if strings.TrimSpace(ticketURL) != "" {
args = append(args, "--stream-ticket-url", strings.TrimSpace(ticketURL))
}
return args
}
func personalEventTypes(eventKey string, explicit []string) []string {
if len(explicit) > 0 {
return explicit
@@ -18,7 +18,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -37,20 +36,18 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
}
}
testseam.Protect(t, &personalGetSubscription)
testseam.Protect(t, &personalCreateSubscription)
oldGet := personalGetSubscription
oldCreate := personalCreateSubscription
t.Cleanup(func() {
personalGetSubscription = oldGet
personalCreateSubscription = oldCreate
})
client := personal.NewClient("https://example.test", personal.Identity{})
wantErr := errors.New("subscription")
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { return nil, wantErr }
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
t.Fatalf("get subscription error = %v", err)
}
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return nil, nil
}
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil || !strings.Contains(err.Error(), "empty subscription") {
t.Fatalf("nil subscription = %v", err)
}
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{}, nil
}
-627
View File
@@ -1,627 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"context"
"encoding/json"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/spf13/cobra"
)
func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
list := newEventListCommand()
list.SilenceUsage = true
list.SilenceErrors = true
var listOut bytes.Buffer
list.SetOut(&listOut)
list.SetArgs([]string{"--category", "oa"})
if err := list.Execute(); err != nil {
t.Fatalf("event list --category oa error = %v", err)
}
tests := []struct {
eventKey string
properties []string
}{
{
eventKey: personal.EventOAApprovalTaskCreated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "create_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalTaskFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalTaskRedirected,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceStarted,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceTerminated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "finish_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "result", "create_time", "finish_time",
"event_time",
},
},
}
for _, tt := range tests {
eventKey := tt.eventKey
if !strings.Contains(listOut.String(), eventKey) {
t.Fatalf("OA event list missing %s:\n%s", eventKey, listOut.String())
}
schema := newEventSchemaCommand()
schema.SilenceUsage = true
schema.SilenceErrors = true
var schemaOut bytes.Buffer
schema.SetOut(&schemaOut)
schema.SetArgs([]string{eventKey, "--flatten"})
if err := schema.Execute(); err != nil {
t.Fatalf("event schema %s --flatten error = %v", eventKey, err)
}
var doc map[string]any
if err := json.Unmarshal(schemaOut.Bytes(), &doc); err != nil {
t.Fatalf("decode schema for %s: %v\n%s", eventKey, err, schemaOut.String())
}
if doc["event_key"] != eventKey || doc["rule_type"] != "all" || doc["jq_root_path"] != "." {
t.Fatalf("schema document for %s = %#v", eventKey, doc)
}
schemaBody, ok := doc["schema"].(map[string]any)
if !ok {
t.Fatalf("schema body for %s = %#v", eventKey, doc["schema"])
}
properties, ok := schemaBody["properties"].(map[string]any)
if !ok || len(properties) != len(tt.properties) {
t.Fatalf("schema properties for %s = %#v, want %d fields", eventKey, schemaBody["properties"], len(tt.properties))
}
for _, name := range tt.properties {
if _, ok := properties[name].(map[string]any); !ok {
t.Fatalf("schema property %s for %s = %#v", name, eventKey, properties[name])
}
}
if _, ok := properties["payload"]; ok {
t.Fatalf("schema for %s exposed generic payload: %#v", eventKey, properties)
}
}
if strings.Contains(listOut.String(), personal.EventMention) {
t.Fatalf("OA category list leaked IM event:\n%s", listOut.String())
}
}
func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
oldIdentity := personalResolveEventIdentity
oldGet := personalGetSubscription
t.Cleanup(func() {
personalResolveEventIdentity = oldIdentity
personalGetSubscription = oldGet
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
LocalSubject: "subject",
ClientID: "client",
SourceID: "open",
}, nil
}
personalGetSubscription = func(_ *personal.Client, _ context.Context, subscribeID string) (*personal.Subscription, error) {
switch subscribeID {
case "oa-sub-task":
return &personal.Subscription{
SubscribeID: subscribeID,
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
case "im-sub-at":
return &personal.Subscription{
SubscribeID: subscribeID,
EventKey: personal.EventMention,
RuleType: "at",
}, nil
default:
t.Fatalf("unexpected subscription lookup %q", subscribeID)
return nil, nil
}
}
oaEvents := []string{
personal.EventOAApprovalTaskCreated,
personal.EventOAApprovalTaskFinished,
personal.EventOAApprovalTaskRedirected,
personal.EventOAApprovalInstanceStarted,
personal.EventOAApprovalInstanceTerminated,
personal.EventOAApprovalInstanceFinished,
}
for _, eventKey := range oaEvents {
t.Run(eventKey+"/dry-run", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var stderr bytes.Buffer
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{eventKey, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("OA dry-run error = %v", err)
}
if !strings.Contains(stderr.String(), "event_types : "+eventKey) {
t.Fatalf("OA dry-run does not select %s:\n%s", eventKey, stderr.String())
}
})
for _, args := range [][]string{
{"--user", "user-1"},
{"--open-dingtalk-id", "open-user-1"},
{"--group", "cid-1"},
{"--query", "urgent"},
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
} {
name := strings.TrimPrefix(args[0], "--")
t.Run(eventKey+"/reject-"+name, func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(append([]string{eventKey}, append(args, "--dry-run")...))
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "not supported") {
t.Fatalf("OA consume %s error = %v, want unsupported option", args[0], err)
}
})
}
}
t.Run("multi-dry-run", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var stderr bytes.Buffer
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
cmd.SetArgs(append(append([]string(nil), oaEvents...), "--dry-run"))
if err := cmd.Execute(); err != nil {
t.Fatalf("multi OA dry-run error = %v", err)
}
for _, eventKey := range oaEvents {
want := "event_key=" + eventKey + " rule_type=all rule_param={}"
if !strings.Contains(stderr.String(), want) {
t.Fatalf("multi OA dry-run missing %q:\n%s", want, stderr.String())
}
}
})
reuseOverrides := [][]string{
{"--user", "user-1"},
{"--open-dingtalk-id", "open-user-1"},
{"--group", "cid-1"},
{"--query", "urgent"},
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
}
t.Run("reuse-dry-run/implicit-event-key/resolves-oa-event", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var stderr bytes.Buffer
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("implicit reused OA dry-run error = %v", err)
}
if !strings.Contains(stderr.String(), "event_types : "+personal.EventOAApprovalTaskCreated) {
t.Fatalf("implicit reused OA dry-run did not resolve event key:\n%s", stderr.String())
}
})
for _, explicitEventKey := range []bool{true, false} {
mode := "implicit-event-key"
if explicitEventKey {
mode = "explicit-event-key"
}
for _, override := range reuseOverrides {
flag := override[0]
t.Run("reuse-dry-run/"+mode+"/"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
args := make([]string, 0, 6)
if explicitEventKey {
args = append(args, personal.EventOAApprovalTaskCreated)
}
args = append(args, "--subscribe-id", "oa-sub-task", flag, override[1], "--dry-run")
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), flag+" not supported for OA event") {
t.Fatalf("%s reused OA dry-run %s error = %v", mode, flag, err)
}
})
}
}
t.Run("reuse-dry-run/implicit-im-remains-supported", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--subscribe-id", "im-sub-at", "--query", "urgent", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("implicit reused IM dry-run error = %v", err)
}
})
for _, override := range reuseOverrides {
flag, value := override[0], override[1]
t.Run("multi-reject-"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
args := append([]string(nil), oaEvents...)
args = append(args, flag, value, "--dry-run")
cmd.SetArgs(args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "not supported for OA event") {
t.Fatalf("multi OA consume %s error = %v", flag, err)
}
})
}
for _, test := range []struct {
name string
args []string
}{
{
name: "message query remains supported",
args: []string{personal.EventMention, "--query", "urgent", "--dry-run"},
},
{
name: "single group lifecycle filter remains supported",
args: []string{
personal.EventGroupUpdated,
"--group", "cid-1",
"--filter-json", `{"field":"future","op":"eq","value":"value"}`,
"--dry-run",
},
},
} {
t.Run(test.name, func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(test.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("existing IM consume behavior changed: %v", err)
}
})
}
}
func TestCrossPlatformCoveragePersonalOAValidationBranches(t *testing.T) {
invalid := personalConsumeOptions{
EventKey: personal.EventOAApprovalTaskCreated,
UserID: "user-1",
}
if err := validatePersonalSubscriptionOptions(invalid); err == nil ||
!strings.Contains(err.Error(), "--user not supported for OA event") {
t.Fatalf("validatePersonalSubscriptionOptions() error = %v", err)
}
if _, err := preparePersonalSubscription(personal.Identity{}, invalid); err == nil ||
!strings.Contains(err.Error(), "--user not supported for OA event") {
t.Fatalf("preparePersonalSubscription() error = %v", err)
}
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "oa-sub-without-event-key",
RuleType: "all",
}, nil
}
_, _, _, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
personalConsumeOptions{
SubscribeID: "oa-sub-without-event-key",
EventKey: personal.EventOAApprovalTaskCreated,
UserID: "user-1",
},
)
if err == nil || !strings.Contains(err.Error(), "--user not supported for OA event") {
t.Fatalf("ensurePersonalSubscription() error = %v", err)
}
}
func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *testing.T) {
restoreMany := installPersonalManySeams(t)
defer restoreMany()
oldCreate := personalCreateSubscription
defer func() { personalCreateSubscription = oldCreate }()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
identity := personal.Identity{
AccessToken: "token",
LocalSubject: "subject",
ClientID: "client",
SourceID: "open",
}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return identity, nil
}
var requests []personal.CreateSubscriptionRequest
personalCreateSubscription = func(_ *personal.Client, _ context.Context, req personal.CreateSubscriptionRequest) (*personal.Subscription, error) {
requests = append(requests, req)
return &personal.Subscription{SubscribeID: "sub-" + req.EventKey}, nil
}
personalEnsureSubscription = ensurePersonalSubscription
var states []personal.RunState
personalUpsertRunState = func(_ string, state personal.RunState) error {
states = append(states, state)
return nil
}
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
runManyCalls := 0
var gotSpecs []consume.ConsumerSpec
personalConsumeRunMany = func(_ context.Context, _ consume.Config, specs []consume.ConsumerSpec) error {
runManyCalls++
gotSpecs = append([]consume.ConsumerSpec(nil), specs...)
return nil
}
eventKeys := []string{
personal.EventOAApprovalTaskCreated,
personal.EventOAApprovalTaskFinished,
personal.EventOAApprovalTaskRedirected,
personal.EventOAApprovalInstanceStarted,
personal.EventOAApprovalInstanceTerminated,
personal.EventOAApprovalInstanceFinished,
}
if err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: eventKeys,
Flatten: true,
}); err != nil {
t.Fatalf("multi OA consume error = %v", err)
}
if runManyCalls != 1 {
t.Fatalf("RunMany calls = %d, want one shared-bus consume call", runManyCalls)
}
if len(requests) != len(eventKeys) || len(states) != len(eventKeys) || len(gotSpecs) != len(eventKeys) {
t.Fatalf("requests=%d states=%d specs=%d, want %d each", len(requests), len(states), len(gotSpecs), len(eventKeys))
}
for i, eventKey := range eventKeys {
req := requests[i]
if req.EventKey != eventKey || req.RuleType != "all" || req.RuleParam == nil || len(req.RuleParam) != 0 || req.Filter != nil {
t.Fatalf("subscription request[%d] = %#v, want %s all/{}", i, req, eventKey)
}
if states[i].EventKey != eventKey || states[i].RuleType != "all" {
t.Fatalf("run state[%d] = %#v", i, states[i])
}
wantSpec := consume.ConsumerSpec{
EventKey: eventKey,
EventTypes: []string{eventKey},
SubscribeID: "sub-" + eventKey,
ReadySubscribeID: "sub-" + eventKey,
}
if !reflect.DeepEqual(gotSpecs[i], wantSpec) {
t.Fatalf("consumer spec[%d] = %#v, want %#v", i, gotSpecs[i], wantSpec)
}
}
}
func TestPersonalOAReusedSubscriptionRejectsDefinitionOverridesAtRuntime(t *testing.T) {
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
getCalls := 0
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
getCalls++
return &personal.Subscription{
SubscribeID: "oa-sub-task",
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
}
tests := []struct {
name string
set func(*personalConsumeOptions)
}{
{name: "user", set: func(opts *personalConsumeOptions) { opts.UserID = "user-1" }},
{name: "open-dingtalk-id", set: func(opts *personalConsumeOptions) { opts.OpenDingTalkID = "open-user-1" }},
{name: "group", set: func(opts *personalConsumeOptions) { opts.GroupID = "cid-1" }},
{name: "query", set: func(opts *personalConsumeOptions) { opts.QueryCSV = "urgent" }},
{name: "filter-json", set: func(opts *personalConsumeOptions) { opts.FilterJSON = `{"field":"content","op":"eq","value":"urgent"}` }},
}
for _, explicitEventKey := range []bool{true, false} {
mode := "implicit-event-key"
if explicitEventKey {
mode = "explicit-event-key"
}
for _, test := range tests {
t.Run(mode+"/"+test.name, func(t *testing.T) {
opts := personalConsumeOptions{SubscribeID: "oa-sub-task"}
if explicitEventKey {
opts.EventKey = personal.EventOAApprovalTaskCreated
}
test.set(&opts)
before := getCalls
_, _, _, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
opts,
)
if err == nil || !strings.Contains(err.Error(), "--"+test.name+" not supported for OA event") {
t.Fatalf("reused OA subscription %s error = %v", test.name, err)
}
if getCalls != before+1 {
t.Fatalf("subscription lookup calls = %d, want %d", getCalls, before+1)
}
})
}
}
}
func TestPersonalOAImplicitReuseRuntimeLooksUpEventBeforeValidation(t *testing.T) {
oldIdentity := personalResolveEventIdentity
oldGet := personalGetSubscription
t.Cleanup(func() {
personalResolveEventIdentity = oldIdentity
personalGetSubscription = oldGet
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
LocalSubject: "subject",
ClientID: "client",
SourceID: "open",
}, nil
}
getCalls := 0
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
getCalls++
return &personal.Subscription{
SubscribeID: "oa-sub-task",
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
}
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--group", "cid-1"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--group not supported for OA event "+personal.EventOAApprovalTaskCreated) {
t.Fatalf("implicit reused OA runtime error = %v", err)
}
if getCalls != 1 {
t.Fatalf("subscription lookup calls = %d, want 1", getCalls)
}
}
func TestPersonalIMReusedSubscriptionWithExistingOverridesRemainsSupported(t *testing.T) {
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "im-sub",
EventKey: personal.EventSingleChat,
RuleType: "singleChat",
}, nil
}
sub, eventKey, ruleType, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
personalConsumeOptions{
SubscribeID: "im-sub",
EventKey: personal.EventSingleChat,
UserID: "user-1",
QueryCSV: "urgent",
FilterJSON: `{"field":"content","op":"eq","value":"urgent"}`,
},
)
if err != nil {
t.Fatalf("reused IM subscription error = %v", err)
}
if sub.SubscribeID != "im-sub" || eventKey != personal.EventSingleChat || ruleType != "singleChat" {
t.Fatalf("reused IM subscription = %#v, event=%q rule=%q", sub, eventKey, ruleType)
}
}
func TestPersonalOAStatusAndStopCommandWiring(t *testing.T) {
oldStatus := eventRunPersonalStatus
oldStop := eventRunPersonalStop
t.Cleanup(func() {
eventRunPersonalStatus = oldStatus
eventRunPersonalStop = oldStop
})
var statusOpts personalStatusOptions
eventRunPersonalStatus = func(_ *cobra.Command, opts personalStatusOptions) error {
statusOpts = opts
return nil
}
status := newEventStatusCommand()
status.SilenceUsage = true
status.SilenceErrors = true
status.SetOut(io.Discard)
status.SetErr(io.Discard)
status.SetArgs([]string{
"--event", personal.EventOAApprovalTaskCreated,
"--subscribe-id", "oa-sub-task",
"--status", "all",
})
if err := status.Execute(); err != nil {
t.Fatalf("OA event status error = %v", err)
}
if statusOpts.EventKey != personal.EventOAApprovalTaskCreated ||
statusOpts.SubscribeID != "oa-sub-task" ||
statusOpts.Status != "all" {
t.Fatalf("OA status options = %#v", statusOpts)
}
var stopOpts personalStopOptions
eventRunPersonalStop = func(_ *cobra.Command, opts personalStopOptions) error {
stopOpts = opts
return nil
}
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.PersistentFlags().Bool("yes", false, "")
event := &cobra.Command{Use: "event"}
event.AddCommand(newEventStopCommand())
root.AddCommand(event)
root.SetArgs([]string{"event", "stop", "oa-sub-task", "--yes"})
if err := root.Execute(); err != nil {
t.Fatalf("OA event stop error = %v", err)
}
if stopOpts.SubscribeID != "oa-sub-task" || stopOpts.All {
t.Fatalf("OA stop options = %#v", stopOpts)
}
}
@@ -1,427 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"errors"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"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/source"
)
func TestPersonalConsumeCleanupOwnershipRuntimeMatrix(t *testing.T) {
runErr := errors.New("runtime failed")
for _, foreground := range []bool{false, true} {
for _, selfCreated := range []bool{false, true} {
for _, ephemeral := range []bool{false, true} {
for _, failRuntime := range []bool{false, true} {
name := strings.Join([]string{
map[bool]string{false: "background", true: "foreground"}[foreground],
map[bool]string{false: "reused", true: "self-created"}[selfCreated],
map[bool]string{false: "persistent", true: "ephemeral"}[ephemeral],
map[bool]string{false: "success", true: "error"}[failRuntime],
}, "/")
t.Run(name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldNewSource := personalNewStreamSource
oldBusRun := personalBusRun
oldConsumeRun := personalConsumeRun
t.Cleanup(func() {
personalNewStreamSource = oldNewSource
personalBusRun = oldBusRun
personalConsumeRun = oldConsumeRun
})
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) {
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
personalUpsertRunState = func(string, personal.RunState) error { return nil }
deleteCalls := 0
removeCalls := 0
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
deleteCalls++
return nil
}
personalRemoveRunStates = func(string, []string) error {
removeCalls++
return nil
}
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
return nil, nil
}
personalBusRun = func(context.Context, bus.Config) error {
if failRuntime {
return runErr
}
return nil
}
personalConsumeRun = func(context.Context, consume.Config) error {
if failRuntime {
return runErr
}
return nil
}
opts := personalConsumeOptions{
EventKey: personal.EventMention,
Ephemeral: ephemeral,
ControlBaseURL: "https://mcp.example.test/dws",
Common: commonConsumeOptions{
Foreground: foreground,
},
}
if !selfCreated {
opts.SubscribeID = "sub-one"
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts)
if failRuntime {
if !errors.Is(err, runErr) {
t.Fatalf("runtime error = %v, want %v", err, runErr)
}
} else if err != nil {
t.Fatalf("consume error = %v", err)
}
wantCleanup := 0
if selfCreated || ephemeral {
wantCleanup = 1
}
if deleteCalls != wantCleanup || removeCalls != wantCleanup {
t.Fatalf(
"cleanup delete/remove = %d/%d, want %d/%d",
deleteCalls,
removeCalls,
wantCleanup,
wantCleanup,
)
}
})
}
}
}
}
}
func TestPersonalConsumeCleanupOwnershipOnRunStateFailure(t *testing.T) {
stateErr := errors.New("save state failed")
for _, test := range []struct {
name string
selfCreated bool
ephemeral bool
wantCleanup int
}{
{name: "self-created", selfCreated: true, wantCleanup: 1},
{name: "reused persistent", wantCleanup: 0},
{name: "reused ephemeral", ephemeral: true, wantCleanup: 1},
} {
t.Run(test.name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(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"}, nil
}
personalEnsureSubscription = func(
context.Context,
*personal.Client,
personal.Identity,
personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalUpsertRunState = func(string, personal.RunState) error { return stateErr }
deleteCalls := 0
removeCalls := 0
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
deleteCalls++
return nil
}
personalRemoveRunStates = func(string, []string) error {
removeCalls++
return nil
}
opts := personalConsumeOptions{
EventKey: personal.EventMention,
Ephemeral: test.ephemeral,
ControlBaseURL: "https://mcp.example.test/dws",
}
if !test.selfCreated {
opts.SubscribeID = "sub-one"
}
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts); !errors.Is(err, stateErr) {
t.Fatalf("state error = %v, want %v", err, stateErr)
}
if deleteCalls != test.wantCleanup || removeCalls != test.wantCleanup {
t.Fatalf(
"cleanup delete/remove = %d/%d, want %d/%d",
deleteCalls,
removeCalls,
test.wantCleanup,
test.wantCleanup,
)
}
})
}
}
func TestPersonalReusedSubscriptionEventKeyResolution(t *testing.T) {
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
for _, test := range []struct {
name string
requested string
actual string
wantKey string
wantErr bool
}{
{
name: "matching key uses actual",
requested: personal.EventMention,
actual: personal.EventMention,
wantKey: personal.EventMention,
},
{
name: "implicit key uses actual",
actual: personal.EventOAApprovalTaskCreated,
wantKey: personal.EventOAApprovalTaskCreated,
},
{
name: "missing actual falls back to requested",
requested: personal.EventOAApprovalTaskCreated,
wantKey: personal.EventOAApprovalTaskCreated,
},
{
name: "requested IM mismatches actual OA",
requested: personal.EventMention,
actual: personal.EventOAApprovalTaskCreated,
wantErr: true,
},
{
name: "requested OA mismatches actual IM",
requested: personal.EventOAApprovalTaskCreated,
actual: personal.EventMention,
wantErr: true,
},
} {
t.Run(test.name, func(t *testing.T) {
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "sub-one",
EventKey: test.actual,
}, nil
}
_, eventKey, _, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
personalConsumeOptions{SubscribeID: "sub-one", EventKey: test.requested},
)
if test.wantErr {
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
t.Fatalf("mismatch error = %v", err)
}
if !strings.Contains(err.Error(), test.requested) || !strings.Contains(err.Error(), test.actual) {
t.Fatalf("mismatch error does not identify both keys: %v", err)
}
return
}
if err != nil {
t.Fatalf("resolve reused subscription: %v", err)
}
if eventKey != test.wantKey {
t.Fatalf("resolved event key = %q, want %q", eventKey, test.wantKey)
}
})
}
}
func TestPersonalReusedSubscriptionMismatchStopsDryRunAndRuntime(t *testing.T) {
for _, mode := range []struct {
name string
dryRun bool
foreground bool
}{
{name: "dry-run", dryRun: true},
{name: "background"},
{name: "foreground", foreground: true},
} {
t.Run(mode.name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldGet := personalGetSubscription
oldNewSource := personalNewStreamSource
oldBusRun := personalBusRun
oldConsumeRun := personalConsumeRun
t.Cleanup(func() {
personalGetSubscription = oldGet
personalNewStreamSource = oldNewSource
personalBusRun = oldBusRun
personalConsumeRun = oldConsumeRun
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
}
personalEnsureSubscription = ensurePersonalSubscription
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "sub-one",
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
upsertCalls := 0
consumeCalls := 0
busCalls := 0
personalUpsertRunState = func(string, personal.RunState) error {
upsertCalls++
return nil
}
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
return nil, nil
}
personalBusRun = func(context.Context, bus.Config) error {
busCalls++
return nil
}
personalConsumeRun = func(context.Context, consume.Config) error {
consumeCalls++
return nil
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
EventKey: personal.EventMention,
SubscribeID: "sub-one",
ControlBaseURL: "https://mcp.example.test/dws",
Common: commonConsumeOptions{
DryRun: mode.dryRun,
Foreground: mode.foreground,
},
})
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
t.Fatalf("mismatch error = %v", err)
}
if upsertCalls != 0 || consumeCalls != 0 || busCalls != 0 {
t.Fatalf(
"mismatch reached upsert/consumer/bus = %d/%d/%d",
upsertCalls,
consumeCalls,
busCalls,
)
}
})
}
}
func TestPersonalReusedSubscriptionUsesActualKeyInDryRunAndRuntime(t *testing.T) {
for _, dryRun := range []bool{true, false} {
name := map[bool]string{false: "runtime", true: "dry-run"}[dryRun]
t.Run(name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldGet := personalGetSubscription
oldConsumeRun := personalConsumeRun
t.Cleanup(func() {
personalGetSubscription = oldGet
personalConsumeRun = oldConsumeRun
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
}
personalEnsureSubscription = ensurePersonalSubscription
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "sub-oa",
EventKey: personal.EventOAApprovalInstanceFinished,
RuleType: "all",
}, nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
personalUpsertRunState = func(_ string, state personal.RunState) error {
if state.EventKey != personal.EventOAApprovalInstanceFinished {
t.Fatalf("run state event key = %q", state.EventKey)
}
return nil
}
var got consume.Config
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
got = cfg
return nil
}
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
SubscribeID: "sub-oa",
ControlBaseURL: "https://mcp.example.test/dws",
Common: commonConsumeOptions{
DryRun: dryRun,
},
}); err != nil {
t.Fatalf("reuse subscription: %v", err)
}
if got.EventKey != personal.EventOAApprovalInstanceFinished ||
len(got.EventTypes) != 1 || got.EventTypes[0] != personal.EventOAApprovalInstanceFinished ||
got.SubscribeID != "sub-oa" {
t.Fatalf("consume config = %#v", got)
}
})
}
}
func TestEventConsumeDryRunHelpDescribesReuseLookup(t *testing.T) {
usage := newEventConsumeCommand().Flags().Lookup("dry-run").Usage
for _, want := range []string{"不创建订阅", "不连接 bus", "复用 --subscribe-id", "只读查询控制面"} {
if !strings.Contains(usage, want) {
t.Fatalf("dry-run help %q missing %q", usage, want)
}
}
}
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
root := &cobra.Command{Use: "dws"}
event := newEventCommand()
markdown := &cobra.Command{Use: "markdown"}
@@ -37,7 +37,7 @@ func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *
leaves = append(leaves, command.Name())
}
sort.Strings(leaves)
want := []string{"+listen-im", "consume", "list", "schema", "status", "stop"}
want := []string{"consume", "list", "schema", "status", "stop"}
if len(leaves) != len(want) {
t.Fatalf("public event leaves = %v, want %v", leaves, want)
}
@@ -48,7 +48,7 @@ func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *
}
}
func TestCrossPlatformCoveragePluginCannotReplaceBuiltInEventCommand(t *testing.T) {
func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) {
root := &cobra.Command{Use: "dws"}
builtIn := newEventCommand()
root.AddCommand(builtIn)
@@ -1,396 +0,0 @@
// 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"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/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/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
const (
runtimeTokenDetachedChildEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_CHILD"
runtimeTokenDetachedWorkDirEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_WORKDIR"
runtimeTokenDetachedEndpointEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_ENDPOINT"
runtimeTokenDetachedEvidenceEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_EVIDENCE"
runtimeTokenDetachedCanaryA = "dws-runtime-e2e-A-9f34c8d10b7e"
runtimeTokenDetachedCanaryB = "dws-runtime-e2e-B-2ad761e5c490"
runtimeTokenDetachedClientID = "runtime-e2e-client"
runtimeTokenDetachedIdentityHash = "90abcdef12345678"
runtimeTokenDetachedSourceID = "runtime-e2e-source"
)
// runRuntimeTokenDetachedE2EChild is called at the very start of TestMain.
// busctl.Spawn executes this test binary with production-style `event _bus`
// arguments; the env marker lets the child run a real bus daemon before the Go
// test runner attempts to parse those CLI arguments.
func runRuntimeTokenDetachedE2EChild() (int, bool) {
if os.Getenv(runtimeTokenDetachedChildEnv) != "1" {
return 0, false
}
workDir := strings.TrimSpace(os.Getenv(runtimeTokenDetachedWorkDirEnv))
endpoint := strings.TrimSpace(os.Getenv(runtimeTokenDetachedEndpointEnv))
evidence := strings.TrimSpace(os.Getenv(runtimeTokenDetachedEvidenceEnv))
if workDir == "" || endpoint == "" || evidence == "" {
return 91, true
}
argvClean := !runtimeTokenDetachedContainsCanary(strings.Join(os.Args, "\x00"))
envClean := !runtimeTokenDetachedContainsCanary(strings.Join(os.Environ(), "\x00"))
if err := appendRuntimeTokenDetachedEvidence(evidence,
fmt.Sprintf("child_start argv_clean=%t env_clean=%t", argvClean, envClean)); err != nil {
return 92, true
}
if !argvClean || !envClean {
return 93, true
}
logFile, err := os.OpenFile(filepath.Join(workDir, "bus.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return 94, true
}
defer logFile.Close()
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
err = bus.Run(ctx, bus.Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: runtimeTokenDetachedClientID,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: runtimeTokenDetachedIdentityHash,
SourceID: runtimeTokenDetachedSourceID,
Edition: "open",
SDKVersion: "runtime-e2e",
Source: &runtimeTokenDetachedSource{broker: broker, evidence: evidence},
CredentialBroker: broker,
ReadyPipe: busctl.ReadyFDFromEnv(),
Logger: slog.New(slog.NewTextHandler(logFile, nil)),
})
if err != nil && !errors.Is(err, context.Canceled) {
_ = appendRuntimeTokenDetachedEvidence(evidence, "bus_exit clean=false")
return 95, true
}
_ = appendRuntimeTokenDetachedEvidence(evidence, "bus_exit clean=true")
return 0, true
}
type runtimeTokenDetachedSource struct {
broker *runtimecred.Broker
evidence string
}
// Start models the credential-sensitive part of a reconnecting Stream source
// without network access. It resolves A for the first connection, waits until a
// second consumer rotates the broker to B, then exercises the exact 401 path:
// RefreshRejected(A) must return B and must not fall back to local OAuth.
func (s *runtimeTokenDetachedSource) Start(ctx context.Context, _ dwsevent.EmitFn) error {
first, err := s.broker.Resolve(ctx)
if err != nil {
return errors.New("runtime e2e: initial credential unavailable")
}
if first != runtimeTokenDetachedCanaryA || s.broker.Generation() != 1 {
return errors.New("runtime e2e: initial credential mismatch")
}
if err := appendRuntimeTokenDetachedEvidence(s.evidence, "resolved_a=true generation=1"); err != nil {
return errors.New("runtime e2e: record initial connection")
}
ticker := time.NewTicker(5 * time.Millisecond)
defer ticker.Stop()
for s.broker.Generation() < 2 {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
rotated, err := s.broker.RefreshRejected(ctx, first)
if err != nil || rotated != runtimeTokenDetachedCanaryB {
return errors.New("runtime e2e: rotated credential unavailable")
}
if err := appendRuntimeTokenDetachedEvidence(s.evidence, "rejected_a=true resolved_b=true reconnect=true generation=2"); err != nil {
return errors.New("runtime e2e: record reconnect")
}
<-ctx.Done()
return ctx.Err()
}
func appendRuntimeTokenDetachedEvidence(path, line string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintln(f, line)
return err
}
func runtimeTokenDetachedContainsCanary(value string) bool {
return strings.Contains(value, runtimeTokenDetachedCanaryA) ||
strings.Contains(value, runtimeTokenDetachedCanaryB)
}
func TestCrossPlatformCoverageUnixDetachedRuntimeTokenLifecycleAndCanaryLeakScan(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("real detached-process lifecycle is Unix-only; Windows named-pipe code is cross-compiled separately")
}
root, err := os.MkdirTemp("/tmp", "dws-runtime-token-e2e-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(root) })
workDir := filepath.Join(root, "events", "open", string(dwsevent.SourceKindPersonalStream), runtimeTokenDetachedIdentityHash)
if err := os.MkdirAll(workDir, 0o700); err != nil {
t.Fatal(err)
}
endpoint := dwsevent.IPCEndpoint(workDir, "open", dwsevent.SourceKindPersonalStream, runtimeTokenDetachedIdentityHash)
evidencePath := filepath.Join(workDir, "runtime-e2e.evidence")
identity := personal.Identity{
ClientID: runtimeTokenDetachedClientID,
SourceID: runtimeTokenDetachedSourceID,
CorpID: "runtime-e2e-corp",
UserID: "runtime-e2e-user",
}
spawnArgs := personalBusSpawnArgsForToken(identity, runtimeTokenDetachedIdentityHash, "", "", "corp:user", runtimeTokenDetachedCanaryA)
assertRuntimeTokenDetachedClean(t, "spawn argv", []byte(strings.Join(spawnArgs, "\x00")))
childEnv := append([]string{}, os.Environ()...)
childEnv = append(childEnv,
runtimeTokenDetachedChildEnv+"=1",
runtimeTokenDetachedWorkDirEnv+"="+workDir,
runtimeTokenDetachedEndpointEnv+"="+endpoint,
runtimeTokenDetachedEvidenceEnv+"="+evidencePath,
)
assertRuntimeTokenDetachedClean(t, "spawn environment", []byte(strings.Join(childEnv, "\x00")))
pid, err := busctl.Spawn(busctl.SpawnConfig{
ExecPath: os.Args[0],
ClientID: runtimeTokenDetachedClientID,
ExtraArgs: spawnArgs,
Env: childEnv,
})
if err != nil {
failRuntimeTokenDetachedError(t, "spawn detached runtime bus", err)
}
stopped := false
t.Cleanup(func() {
if !stopped {
_ = busctl.Stop(busctl.StopConfig{WorkDir: workDir, Timeout: 2 * time.Second})
if proc, findErr := os.FindProcess(pid); findErr == nil {
_ = proc.Kill()
}
}
})
waitRuntimeTokenDetachedFile(t, evidencePath, "child_start argv_clean=true env_clean=true", 3*time.Second)
var stdoutA, stderrA bytes.Buffer
err = consume.Run(context.Background(), runtimeTokenDetachedConsumeConfig(
workDir, endpoint, "sub-runtime-a", runtimeTokenDetachedCanaryA, 500*time.Millisecond, &stdoutA, &stderrA,
))
if err != nil {
failRuntimeTokenDetachedError(t, "consume token A", err)
}
waitRuntimeTokenDetachedFile(t, evidencePath, "resolved_a=true generation=1", 3*time.Second)
if err := personal.UpsertRunState(workDir, personal.RunState{
SubscribeID: "sub-runtime-b",
EventKey: personal.EventMention,
ClientID: runtimeTokenDetachedClientID,
SourceID: runtimeTokenDetachedSourceID,
IdentityHash: runtimeTokenDetachedIdentityHash,
}); err != nil {
failRuntimeTokenDetachedError(t, "persist non-sensitive run state", err)
}
var stdoutB, stderrB bytes.Buffer
consumeDone := make(chan error, 1)
go func() {
consumeDone <- consume.Run(context.Background(), runtimeTokenDetachedConsumeConfig(
workDir, endpoint, "sub-runtime-b", runtimeTokenDetachedCanaryB, 5*time.Second, &stdoutB, &stderrB,
))
}()
status := waitRuntimeTokenDetachedStatus(t, endpoint, "sub-runtime-b", 3*time.Second)
if status.Bus.PID != pid || status.Bus.IdentityHash != runtimeTokenDetachedIdentityHash {
t.Fatalf("status bus identity = %#v, want pid=%d identity=%s", status.Bus, pid, runtimeTokenDetachedIdentityHash)
}
waitRuntimeTokenDetachedFile(t, evidencePath, "rejected_a=true resolved_b=true reconnect=true generation=2", 3*time.Second)
stopResp, err := busctl.StopConsumers(endpoint, []string{"sub-runtime-b"})
if err != nil {
failRuntimeTokenDetachedError(t, "targeted consumer stop", err)
}
if len(stopResp.Stopped) != 1 || stopResp.Stopped[0] != "sub-runtime-b" {
t.Fatalf("targeted stop response = %#v", stopResp)
}
select {
case err := <-consumeDone:
if err != nil {
failRuntimeTokenDetachedError(t, "consume token B after targeted stop", err)
}
case <-time.After(3 * time.Second):
t.Fatal("token B consumer did not exit after targeted stop")
}
status = waitRuntimeTokenDetachedStatus(t, endpoint, "", 3*time.Second)
if len(status.Consumers) != 0 {
t.Fatalf("status consumers after stop = %#v", status.Consumers)
}
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir, Timeout: 4 * time.Second}); err != nil {
failRuntimeTokenDetachedError(t, "stop detached bus", err)
}
stopped = true
waitRuntimeTokenDetachedFile(t, evidencePath, "bus_exit clean=true", 3*time.Second)
statusJSON, err := json.Marshal(status)
if err != nil {
t.Fatal(err)
}
stopJSON, err := json.Marshal(stopResp)
if err != nil {
t.Fatal(err)
}
for name, artifact := range map[string][]byte{
"consume A stdout": stdoutA.Bytes(),
"consume A stderr": stderrA.Bytes(),
"consume B stdout": stdoutB.Bytes(),
"consume B stderr": stderrB.Bytes(),
"status response": statusJSON,
"stop response": stopJSON,
} {
assertRuntimeTokenDetachedClean(t, name, artifact)
}
assertRuntimeTokenDetachedTreeClean(t, root)
for _, required := range []string{
filepath.Join(workDir, bus.MetaFileName),
filepath.Join(workDir, "bus.log"),
filepath.Join(workDir, personal.StateFileName),
evidencePath,
} {
if info, statErr := os.Stat(required); statErr != nil || !info.Mode().IsRegular() {
t.Fatalf("expected runtime artifact %s: info=%v err=%v", required, info, statErr)
}
}
}
func runtimeTokenDetachedConsumeConfig(workDir, endpoint, subscribeID, token string, duration time.Duration, stdout, stderr *bytes.Buffer) consume.Config {
return consume.Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: runtimeTokenDetachedClientID,
RuntimeToken: token,
EventTypes: []string{personal.EventMention},
EventKey: personal.EventMention,
SubscribeID: subscribeID,
ReadySubscribeID: subscribeID,
Duration: duration,
Format: consume.FormatNDJSON,
Stdout: stdout,
Stderr: stderr,
}
}
func waitRuntimeTokenDetachedFile(t *testing.T, path, want string, timeout time.Duration) string {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
data, err := os.ReadFile(path)
if err == nil && strings.Contains(string(data), want) {
return string(data)
}
time.Sleep(10 * time.Millisecond)
}
data, err := os.ReadFile(path)
if runtimeTokenDetachedContainsCanary(string(data)) {
t.Fatalf("runtime credential leaked into child evidence while waiting for %q", want)
}
t.Fatalf("evidence %s missing %q: data=%q err=%v", path, want, data, err)
return ""
}
func waitRuntimeTokenDetachedStatus(t *testing.T, endpoint, subscribeID string, timeout time.Duration) *transport.StatusResp {
t.Helper()
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
status, err := busctl.QueryStatus(endpoint)
if err == nil {
if subscribeID == "" && len(status.Consumers) == 0 {
return status
}
for _, consumer := range status.Consumers {
if consumer.SubscribeID == subscribeID {
return status
}
}
}
lastErr = err
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("status never reached subscribe_id=%q: %v", subscribeID, lastErr)
return nil
}
func assertRuntimeTokenDetachedTreeClean(t *testing.T, root string) {
t.Helper()
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || !entry.Type().IsRegular() {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
assertRuntimeTokenDetachedClean(t, path, data)
return nil
})
if err != nil {
t.Fatalf("scan runtime artifacts: %v", err)
}
}
func assertRuntimeTokenDetachedClean(t *testing.T, name string, artifact []byte) {
t.Helper()
if runtimeTokenDetachedContainsCanary(string(artifact)) {
t.Fatalf("runtime credential leaked into %s", name)
}
}
func failRuntimeTokenDetachedError(t *testing.T, step string, err error) {
t.Helper()
if err != nil && runtimeTokenDetachedContainsCanary(err.Error()) {
t.Fatalf("%s failed and exposed a runtime credential", step)
}
t.Fatalf("%s: %v", step, err)
}
@@ -1,335 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"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/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCrossPlatformCoverageRuntimeTokenBusRejectsIncompleteIdentity(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
args []string
want string
}{
{
name: "invalid identity hash",
args: []string{"--source-kind", "personal_stream", "--runtime-token-mode", "--identity-hash", "not-a-hash", "--client-id", "client"},
want: "16-character hexadecimal identity hash",
},
{
name: "missing client id",
args: []string{"--source-kind", "personal_stream", "--runtime-token-mode", "--identity-hash", "0123456789abcdef"},
want: "--client-id is required",
},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := newEventBusCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(tc.args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute() error = %v, want %q", err, tc.want)
}
})
}
}
type eventRuntimeTokenReleaseErrorStore struct {
err error
}
func (*eventRuntimeTokenReleaseErrorStore) Claim([]personal.AttemptSpec, time.Duration) (*personal.AttemptClaim, error) {
return nil, nil
}
func (*eventRuntimeTokenReleaseErrorStore) CompleteSuccess(*personal.AttemptClaim) error {
return nil
}
func (*eventRuntimeTokenReleaseErrorStore) CompleteFailure(*personal.AttemptClaim, []string, personal.AttemptFailure) (personal.AttemptHold, error) {
return personal.AttemptHold{}, nil
}
func (s *eventRuntimeTokenReleaseErrorStore) Release(*personal.AttemptClaim) error {
return s.err
}
func TestCrossPlatformCoverageRuntimeTokenAttemptReleaseGuardEdges(t *testing.T) {
var nilReservation *personalSubscriptionAttemptReservation
if err := nilReservation.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("nil reservation error = %v", err)
}
incomplete := &personalSubscriptionAttemptReservation{}
if err := incomplete.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) ||
!strings.Contains(err.Error(), "reservation is incomplete") {
t.Fatalf("incomplete reservation error = %v", err)
}
wantErr := errors.New("release failed")
reservation := &personalSubscriptionAttemptReservation{
store: &eventRuntimeTokenReleaseErrorStore{err: wantErr},
claim: &personal.AttemptClaim{AttemptID: "attempt"},
}
if err := reservation.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) ||
!errors.Is(err, wantErr) {
t.Fatalf("release failure error = %v", err)
}
}
func TestCrossPlatformCoverageRuntimeTokenConsumeRejectionAndOversizeEdges(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
oldProfile := authpkg.RuntimeProfile()
oldLoadProfiles := personalLoadProfiles
oldValidate := personalValidateConsumeConfig
oldConflict := personalValidateNoOutputConflict
oldAttemptStore := personalNewSubscriptionAttemptStore
oldEnsure := personalEnsureSubscription
t.Cleanup(func() {
edition.Override(oldEdition)
authpkg.SetRuntimeProfile(oldProfile)
personalLoadProfiles = oldLoadProfiles
personalValidateConsumeConfig = oldValidate
personalValidateNoOutputConflict = oldConflict
personalNewSubscriptionAttemptStore = oldAttemptStore
personalEnsureSubscription = oldEnsure
})
edition.Override(&edition.Hooks{})
authpkg.SetRuntimeProfile("")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
oversized := strings.Repeat("x", runtimecred.DefaultMaxTokenBytes+1)
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
EventKey: personal.EventMention,
ExplicitToken: oversized,
ClientIDOverride: "runtime-client",
Common: commonConsumeOptions{Foreground: true},
})
if !errors.Is(err, runtimecred.ErrTokenTooLarge) {
t.Fatalf("oversized foreground token error = %v", err)
}
rejection := &personal.APIError{
Code: "RUNTIME_TOKEN_REJECTED",
HTTPStatus: http.StatusUnauthorized,
}
if personalRuntimeTokenControlRejection(errors.New("ordinary failure")) {
t.Fatal("ordinary error classified as runtime-token rejection")
}
singleStore := &personalRecordingAttemptStore{}
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore { return singleStore }
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
return nil, "", "", rejection
}
err = runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
EventKey: personal.EventMention,
ExplicitToken: "runtime-token-single",
ClientIDOverride: "runtime-client",
ControlBaseURL: "https://control.example.test",
})
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) || singleStore.releaseCalls != 1 || singleStore.failureCalls != 0 {
t.Fatalf("single rejection = %v, release=%d failure=%d", err, singleStore.releaseCalls, singleStore.failureCalls)
}
manyStore := &personalRecordingAttemptStore{}
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore { return manyStore }
err = runPersonalEventConsumeMany(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
ExplicitToken: "runtime-token-many",
ClientIDOverride: "runtime-client",
ControlBaseURL: "https://control.example.test",
})
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) || manyStore.releaseCalls != 1 || manyStore.failureCalls != 0 {
t.Fatalf("multi rejection = %v, release=%d failure=%d", err, manyStore.releaseCalls, manyStore.failureCalls)
}
}
func TestCrossPlatformCoverageRuntimeTokenIdentityFallbackEdges(t *testing.T) {
configDir := t.TempDir()
oldEdition := edition.Get()
oldProfile := authpkg.RuntimeProfile()
oldResolveIdentity := personalResolveEventIdentity
oldResolveAuxiliary := personalResolveAuxiliaryAccessToken
oldLoadTokenData := personalLoadTokenData
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
oldClientID := personalClientID
oldResolveCredentials := personalResolveAppCredentialsStrict
t.Cleanup(func() {
edition.Override(oldEdition)
authpkg.SetRuntimeProfile(oldProfile)
personalResolveEventIdentity = oldResolveIdentity
personalResolveAuxiliaryAccessToken = oldResolveAuxiliary
personalLoadTokenData = oldLoadTokenData
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
personalClientID = oldClientID
personalResolveAppCredentialsStrict = oldResolveCredentials
})
legacy := personal.Identity{ClientID: "legacy-client", SourceID: "legacy-source"}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return legacy, nil }
identity, err := resolvePersonalEventIdentityForToken(context.Background(), configDir, "", " ")
if err != nil || identity.ClientID != legacy.ClientID {
t.Fatalf("wrapper empty-token fallback = %#v, %v", identity, err)
}
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) {
return "legacy-access", nil
}
personalLoadTokenData = func(string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{
CorpID: "legacy-corp", UserID: "legacy-user", ClientID: "direct-client",
}, nil
}
identity, err = resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", " ")
if err != nil || identity.ClientID != "direct-client" {
t.Fatalf("direct empty-token fallback = %#v, %v", identity, err)
}
edition.Override(&edition.Hooks{})
personalRuntimeEventClientID = func() string { return "" }
personalClientID = func() string { return "" }
wantMetadataErr := errors.New("profiles unreadable")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, wantMetadataErr }
authpkg.SetRuntimeProfile("corp:user")
if _, err := resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "token", "runtime-client"); !errors.Is(err, wantMetadataErr) {
t.Fatalf("explicit profile metadata error = %v", err)
}
authpkg.SetRuntimeProfile("")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "app-client", "", "", "", nil
}
identity, err = resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "runtime-token")
if err != nil || identity.ClientID != "app-client" || !strings.HasPrefix(identity.LocalSubject, "access:") {
t.Fatalf("app-credential fallback identity = %#v, %v", identity, err)
}
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "", "", "", "", errors.New("missing app credentials")
}
if _, err := resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "runtime-token"); err == nil || !strings.Contains(err.Error(), "cannot resolve OAuth client_id") {
t.Fatalf("missing client ID error = %v", err)
}
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{
CurrentProfile: "stale",
Profiles: []authpkg.Profile{{Name: "other", CorpID: "corp", UserID: "user"}},
}, nil
}
profile, err := personalEventProfileMetadata(configDir)
if err != nil || profile != nil {
t.Fatalf("stale implicit current profile = %#v, %v", profile, err)
}
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{Name: "other"}}}, nil
}
profile, err = personalEventProfileMetadata(configDir)
if err != nil || profile != nil {
t.Fatalf("empty implicit selector = %#v, %v", profile, err)
}
authpkg.SetRuntimeProfile("corp-a:user-a,corp-b:user-b")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
if _, err := personalEventProfileMetadata(configDir); err == nil || !strings.Contains(err.Error(), "exactly one --profile") {
t.Fatalf("multi-profile metadata error = %v", err)
}
}
type eventRuntimeTokenReadErrorBody struct{}
func (eventRuntimeTokenReadErrorBody) Read([]byte) (int, error) {
return 0, errors.New("body read failed")
}
func (eventRuntimeTokenReadErrorBody) Close() error { return nil }
func TestCrossPlatformCoverageRuntimeTokenControlTransportErrorEdges(t *testing.T) {
const token = "runtime-control-edge-canary"
unsupported, err := http.NewRequest(http.MethodGet, "unsupported://control.example.test/path", nil)
if err != nil {
t.Fatal(err)
}
if _, err := (runtimeTokenControlTransport{}).RoundTrip(unsupported); err == nil {
t.Fatal("nil base unexpectedly accepted an unsupported protocol")
}
wantTransportErr := errors.New("ordinary transport failure")
ordinary := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, wantTransportErr
})}
if _, err := ordinary.RoundTrip(unsupported); !errors.Is(err, wantTransportErr) {
t.Fatalf("ordinary transport error = %v", err)
}
leaking := runtimeTokenControlTransport{
token: token,
base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("reflected " + token)
}),
}
if _, err := leaking.RoundTrip(unsupported); err == nil || strings.Contains(err.Error(), token) ||
err.Error() != "personal event: runtime-token control request failed" {
t.Fatalf("redacted transport error = %v", err)
}
nilResponse := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, nil
})}
if resp, err := nilResponse.RoundTrip(unsupported); resp != nil || err != nil {
t.Fatalf("nil response = %#v, %v", resp, err)
}
readFailure := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Header: make(http.Header),
Body: eventRuntimeTokenReadErrorBody{},
Request: req,
}, nil
})}
if _, err := readFailure.RoundTrip(unsupported); err == nil || !strings.Contains(err.Error(), "read runtime-token control response") {
t.Fatalf("body read error = %v", err)
}
nilHeader := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Request: req}, nil
})}
resp, err := nilHeader.RoundTrip(unsupported)
if err != nil || resp == nil || resp.Header == nil || resp.Header.Get("Content-Type") != "application/json" {
t.Fatalf("nil-header response = %#v, %v", resp, err)
}
if got := redactRuntimeTokenResponseBody(nil, token); len(got) != 0 {
t.Fatalf("empty response redaction = %q", got)
}
value, changed := redactRuntimeTokenJSONValue([]any{"plain", "prefix-" + token}, token)
items, ok := value.([]any)
if !ok || !changed || len(items) != 2 || strings.Contains(items[1].(string), token) {
t.Fatalf("array redaction = %#v changed=%t", value, changed)
}
}
@@ -1,917 +0,0 @@
// 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"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageEventCommandsWireTrimmedRootRuntimeToken(t *testing.T) {
oldConsume := eventRunPersonalConsume
oldStatus := eventRunPersonalStatus
oldStop := eventRunPersonalStop
t.Cleanup(func() {
eventRunPersonalConsume = oldConsume
eventRunPersonalStatus = oldStatus
eventRunPersonalStop = oldStop
})
flags := &GlobalFlags{Token: " runtime-canary ", ClientID: " root-client "}
assertIdentity := func(token, clientID string) {
t.Helper()
if token != "runtime-canary" || clientID != "root-client" {
t.Fatalf("runtime identity = token %q client %q", token, clientID)
}
}
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
return nil
}
consumeCmd := newEventConsumeCommand(flags)
if err := consumeCmd.RunE(consumeCmd, []string{personal.EventMention}); err != nil {
t.Fatalf("consume RunE() error = %v", err)
}
eventRunPersonalStatus = func(_ *cobra.Command, opts personalStatusOptions) error {
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
return nil
}
statusCmd := newEventStatusCommandWithFlags(flags)
if err := statusCmd.RunE(statusCmd, nil); err != nil {
t.Fatalf("status RunE() error = %v", err)
}
eventRunPersonalStop = func(_ *cobra.Command, opts personalStopOptions) error {
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
return nil
}
stopCmd := newEventStopCommandWithFlags(flags)
stopRoot := &cobra.Command{Use: "dws"}
stopRoot.PersistentFlags().Bool("yes", true, "")
stopRoot.AddCommand(stopCmd)
if err := stopCmd.RunE(stopCmd, []string{"sub-runtime"}); err != nil {
t.Fatalf("stop RunE() error = %v", err)
}
listenCmd := newEventListenIMCommand(flags)
if err := listenCmd.RunE(listenCmd, nil); err != nil {
t.Fatalf("listen-im RunE() error = %v", err)
}
}
func TestCrossPlatformCoverageEventConsumeParsesRootRuntimeTokenBeforeAndAfterSubcommand(t *testing.T) {
oldConsume := eventRunPersonalConsume
t.Cleanup(func() { eventRunPersonalConsume = oldConsume })
for _, tc := range []struct {
name string
args []string
}{
{name: "before", args: []string{"--token", "runtime-before", "event", "consume", personal.EventMention}},
{name: "after", args: []string{"event", "consume", personal.EventMention, "--token", "runtime-after"}},
} {
t.Run(tc.name, func(t *testing.T) {
flags := &GlobalFlags{}
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
bindPersistentFlags(root, flags)
root.AddCommand(newEventCommand(flags))
var got string
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
got = opts.ExplicitToken
return nil
}
root.SetArgs(tc.args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
want := "runtime-" + tc.name
if got != want {
t.Fatalf("ExplicitToken = %q, want %q", got, want)
}
})
}
}
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithTokenUsesMetadataOnly(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldLoadToken := personalLoadTokenData
oldAux := personalResolveAuxiliaryAccessToken
oldClientID := personalClientID
oldCredentials := personalResolveAppCredentialsStrict
previousProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalLoadTokenData = oldLoadToken
personalResolveAuxiliaryAccessToken = oldAux
personalClientID = oldClientID
personalResolveAppCredentialsStrict = oldCredentials
authpkg.SetRuntimeProfile(previousProfile)
})
personalLoadTokenData = func(string) (*authpkg.TokenData, error) {
t.Fatal("explicit token identity read sensitive TokenData")
return nil, nil
}
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) {
t.Fatal("explicit token identity resolved local OAuth")
return "", nil
}
personalClientID = func() string {
t.Fatal("explicit root client ID was not preferred")
return ""
}
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
t.Fatal("explicit root client ID unexpectedly fell back to app credentials")
return "", "", "", "", nil
}
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{
Version: 2,
Profiles: []authpkg.Profile{{
Name: "Runtime profile",
CorpID: "profile-corp",
CorpName: "Runtime Org",
UserID: "profile-user",
UserName: "Runtime User",
ClientID: "profile-client",
}},
}, nil
}
authpkg.SetRuntimeProfile("Runtime Org:Runtime User")
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "", false },
}
}})
identity, err := resolvePersonalEventIdentityWithToken(
context.Background(), "unused", "runtime-source", " runtime-canary ", "root-client",
)
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.CorpID != "runtime-corp" || identity.UserID != "profile-user" || identity.ClientID != "root-client" {
t.Fatalf("identity metadata = %#v", identity)
}
if identity.AccessToken != "" {
t.Fatalf("identity retained raw runtime token: %q", identity.AccessToken)
}
if identity.LocalSubject != "" {
t.Fatalf("complete identity LocalSubject = %q, want empty", identity.LocalSubject)
}
}
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithCompleteRuntimeMetadataSkipsProfiles(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
})
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
t.Fatal("complete host metadata unexpectedly read profiles.json")
return nil, nil
}
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
personalRuntimeEventClientID = func() string { return "edition-client" }
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary", "root-client")
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "root-client" {
t.Fatalf("identity = %#v", identity)
}
}
func TestCrossPlatformCoverageRuntimeEventClientIDPrefersEditionBeforeEnvironment(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
t.Setenv("DWS_CLIENT_ID", "environment-client")
edition.Override(&edition.Hooks{AuthClientID: "edition-client"})
if got := runtimePersonalEventClientID(); got != "edition-client" {
t.Fatalf("runtime client ID = %q, want edition hook", got)
}
edition.Override(&edition.Hooks{})
if got := runtimePersonalEventClientID(); got != "environment-client" {
t.Fatalf("runtime client ID = %q, want environment fallback", got)
}
}
func TestCrossPlatformCoverageCompleteRuntimeIdentityUsesEditionClientBeforeProfiles(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
})
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
t.Fatal("complete host metadata unexpectedly read profiles.json")
return nil, errors.New("unreachable")
}
personalRuntimeEventClientID = func() string { return "edition-client" }
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "edition-client" {
t.Fatalf("identity = %#v", identity)
}
}
func TestCrossPlatformCoverageSelectedProfileClientPrecedesPersistedGlobalClient(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
oldClientID := personalClientID
previousProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
personalClientID = oldClientID
authpkg.SetRuntimeProfile(previousProfile)
})
edition.Override(&edition.Hooks{})
personalRuntimeEventClientID = func() string { return "" }
personalClientID = func() string { return "stale-global-client" }
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{
Version: 3,
CurrentProfile: "corp:user",
Profiles: []authpkg.Profile{{
Name: "Selected", CorpID: "corp", UserID: "user", ClientID: "profile-client",
}},
}, nil
}
authpkg.SetRuntimeProfile("corp:user")
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.ClientID != "profile-client" {
t.Fatalf("ClientID = %q, want selected profile client", identity.ClientID)
}
}
func TestCrossPlatformCoverageMalformedPersistedProfilesDoNotBlockRuntimeDefaultsAndGlobalClient(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
oldClientID := personalClientID
previousProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
personalClientID = oldClientID
authpkg.SetRuntimeProfile(previousProfile)
})
authpkg.SetRuntimeProfile("")
personalRuntimeEventClientID = func() string { return "" }
personalClientID = func() string { return "global-client" }
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return nil, errors.New("malformed persisted profiles")
}
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "global-client" {
t.Fatalf("identity = %#v", identity)
}
}
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithTokenRejectsMultipleProfilesBeforeMetadata(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
previousProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
authpkg.SetRuntimeProfile(previousProfile)
})
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
t.Fatal("multiple runtime profiles unexpectedly reached metadata loading")
return nil, nil
}
authpkg.SetRuntimeProfile("corp-a:user-a,corp-b:user-b")
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
_, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary", "root-client")
if err == nil || !strings.Contains(err.Error(), "exactly one --profile") {
t.Fatalf("multiple-profile error = %v", err)
}
}
func TestCrossPlatformCoverageExplicitProfileRequiresMetadataRegistry(t *testing.T) {
oldLoadProfiles := personalLoadProfiles
defer func() { personalLoadProfiles = oldLoadProfiles }()
oldProfile := authpkg.RuntimeProfile()
authpkg.SetRuntimeProfile("missing-profile")
defer authpkg.SetRuntimeProfile(oldProfile)
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{}, nil
}
_, err := personalEventProfileMetadata(t.TempDir())
if err == nil || !strings.Contains(err.Error(), `profile "missing-profile" not found`) {
t.Fatalf("personalEventProfileMetadata() error = %v", err)
}
}
func TestCrossPlatformCoverageCompleteRuntimeIdentityStillValidatesExplicitProfile(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
oldProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
authpkg.SetRuntimeProfile(oldProfile)
})
authpkg.SetRuntimeProfile("missing-profile")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{}, nil
}
personalRuntimeEventClientID = func() string { return "runtime-client" }
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
_, err := resolvePersonalEventIdentityWithToken(context.Background(), t.TempDir(), "source", "canary")
if err == nil || !strings.Contains(err.Error(), `profile "missing-profile" not found`) {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
}
func TestCrossPlatformCoveragePersonalProfileMetadataOrganizationCurrentBeatsUnresolved(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
Version: 3,
Profiles: []authpkg.Profile{
{Name: "Historical", CorpID: "corp-1"},
{Name: "Exact", CorpID: "corp-1", UserID: "user-1"},
},
OrgCurrentProfiles: map[string]string{"corp-1": "corp-1:user-1"},
}
profile, err := selectPersonalEventProfileMetadata(cfg, "corp-1", make(map[string]struct{}))
if err != nil {
t.Fatalf("selectPersonalEventProfileMetadata() error = %v", err)
}
if profile == nil || profile.UserID != "user-1" {
t.Fatalf("selected profile = %#v, want organization current account", profile)
}
}
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsReflected401(t *testing.T) {
const token = "runtime-control-canary"
oldLogger := slog.Default()
var logs bytes.Buffer
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(oldLogger) })
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
ClientID: "client", SourceID: "source",
}, token)
wrapped, ok := client.HTTPClient.Transport.(runtimeTokenControlTransport)
if !ok {
t.Fatalf("control transport = %T, want runtimeTokenControlTransport", client.HTTPClient.Transport)
}
var authorization string
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
authorization = req.Header.Get("Authorization")
body := `{"code":"UNAUTHORIZED","message":"rejected ` + token + `"}`
header := make(http.Header)
header.Set("X-Request-Id", "request-"+token)
header.Set("X-Trace-Id", "trace-"+token)
return &http.Response{
StatusCode: http.StatusUnauthorized,
Header: header,
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})
client.HTTPClient.Transport = wrapped
_, err := client.ListSubscriptions(context.Background(), personal.ListOptions{})
if err == nil {
t.Fatal("ListSubscriptions() unexpectedly succeeded")
}
if authorization != "Bearer "+token {
t.Fatalf("Authorization = %q", authorization)
}
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
t.Fatalf("runtime token leaked: error=%q logs=%q", err, logs.String())
}
if !strings.Contains(err.Error(), "RUNTIME_TOKEN_REJECTED") {
t.Fatalf("error = %q, want fixed runtime token rejection", err)
}
}
func TestCrossPlatformCoverageRuntimeTokenRedirectGuardDoesNotForwardCustomHeader(t *testing.T) {
const token = "runtime-redirect-canary"
var controlTargetHits, ticketTargetHits atomic.Int32
controlTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
controlTargetHits.Add(1)
if r.Header.Get("x-user-access-token") == token {
t.Error("control redirect forwarded runtime token")
}
w.WriteHeader(http.StatusNoContent)
}))
defer controlTarget.Close()
controlOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-user-access-token") != token {
t.Error("control origin did not receive runtime token")
}
http.Redirect(w, r, controlTarget.URL, http.StatusFound)
}))
defer controlOrigin.Close()
client := newPersonalEventControlClient("unused", controlOrigin.URL, personal.Identity{
ClientID: "client", SourceID: "source",
}, token)
_, controlErr := client.ListSubscriptions(context.Background(), personal.ListOptions{})
if controlErr == nil {
t.Fatal("cross-host control redirect unexpectedly succeeded")
}
if controlTargetHits.Load() != 0 || strings.Contains(controlErr.Error(), token) {
t.Fatalf("control redirect hits=%d error=%q", controlTargetHits.Load(), controlErr)
}
ticketTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ticketTargetHits.Add(1)
if r.Header.Get("x-user-access-token") == token {
t.Error("ticket redirect forwarded runtime token")
}
w.WriteHeader(http.StatusNoContent)
}))
defer ticketTarget.Close()
ticketOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-user-access-token") != token {
t.Error("ticket origin did not receive runtime token")
}
http.Redirect(w, r, ticketTarget.URL, http.StatusFound)
}))
defer ticketOrigin.Close()
broker := runtimecred.New(runtimecred.Config{RequireSeed: true})
if _, err := broker.Update(0, token); err != nil {
t.Fatal(err)
}
src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{
ConfigDir: "unused",
Identity: personal.Identity{ClientID: "client", SourceID: "source"},
TicketURL: ticketOrigin.URL,
CredentialBroker: broker,
RuntimeTokenMode: true,
})
if err != nil {
t.Fatal(err)
}
err = src.Start(context.Background(), func(*dwsevent.RawEvent) {})
if err == nil {
t.Fatal("cross-host ticket redirect unexpectedly succeeded")
}
if ticketTargetHits.Load() != 0 || strings.Contains(err.Error(), token) {
t.Fatalf("ticket redirect hits=%d error=%q", ticketTargetHits.Load(), err)
}
}
func TestCrossPlatformCoverageRuntimeTokenRedirectPolicyBranches(t *testing.T) {
origin, _ := http.NewRequest(http.MethodGet, "https://control.example/start", nil)
sameHost, _ := http.NewRequest(http.MethodGet, "https://control.example/next", nil)
if err := runtimeTokenRedirectPolicy(sameHost, []*http.Request{origin}); err != nil {
t.Fatalf("same-host HTTPS redirect rejected: %v", err)
}
for name, request := range map[string]*http.Request{
"cross-host": func() *http.Request {
r, _ := http.NewRequest(http.MethodGet, "https://other.example/next", nil)
return r
}(),
"downgrade": func() *http.Request {
r, _ := http.NewRequest(http.MethodGet, "http://control.example/next", nil)
return r
}(),
} {
if err := runtimeTokenRedirectPolicy(request, []*http.Request{origin}); !errors.Is(err, http.ErrUseLastResponse) {
t.Fatalf("%s redirect policy error = %v", name, err)
}
}
if err := runtimeTokenRedirectPolicy(nil, nil); !errors.Is(err, http.ErrUseLastResponse) {
t.Fatalf("empty redirect chain error = %v", err)
}
}
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsEveryErrorEnvelope(t *testing.T) {
const token = "runtime-control-all-status-canary"
tests := []struct {
name string
status int
body string
}{
{name: "bad-request", status: http.StatusBadRequest, body: `{"code":"BAD_REQUEST","message":"` + token + `"}`},
{name: "server-error", status: http.StatusInternalServerError, body: `{"code":"INTERNAL","message":"` + token + `"}`},
{name: "success-false", status: http.StatusOK, body: `{"success":false,"errorCode":"DENIED","errorMsg":"` + token + `"}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
oldLogger := slog.Default()
var logs bytes.Buffer
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(oldLogger) })
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
ClientID: "client", SourceID: "source",
}, token)
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
header := make(http.Header)
header.Set("X-Request-Id", "request-"+token)
header.Set("X-Trace-Id", "trace-"+token)
return &http.Response{
StatusCode: tc.status,
Header: header,
Body: io.NopCloser(strings.NewReader(tc.body)),
Request: req,
}, nil
})
client.HTTPClient.Transport = wrapped
_, err := client.ListSubscriptions(context.Background(), personal.ListOptions{})
if err == nil {
t.Fatal("ListSubscriptions() unexpectedly succeeded")
}
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
t.Fatalf("runtime token leaked: error=%q logs=%q", err, logs.String())
}
})
}
}
func TestCrossPlatformCoverageExplicitTokenControlTransportPreservesSuccessfulResponse(t *testing.T) {
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
ClientID: "client", SourceID: "source",
}, "runtime-success-canary")
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"success":true,"result":{"items":[],"total":0}}`)),
Request: req,
}, nil
})
client.HTTPClient.Transport = wrapped
if _, err := client.ListSubscriptions(context.Background(), personal.ListOptions{}); err != nil {
t.Fatalf("ListSubscriptions() successful response error = %v", err)
}
}
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsJSONEscapedToken(t *testing.T) {
const token = "runtime<escaped>&canary"
body, err := json.Marshal(map[string]any{"code": "BAD_REQUEST", "message": "rejected " + token})
if err != nil {
t.Fatal(err)
}
if bytes.Contains(body, []byte(token)) {
t.Fatalf("fixture was not JSON-escaped: %s", body)
}
oldLogger := slog.Default()
var logs bytes.Buffer
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(oldLogger) })
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
ClientID: "client", SourceID: "source",
}, token)
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(body)),
Request: req,
}, nil
})
client.HTTPClient.Transport = wrapped
_, err = client.ListSubscriptions(context.Background(), personal.ListOptions{})
if err == nil {
t.Fatal("ListSubscriptions() unexpectedly succeeded")
}
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
t.Fatalf("escaped runtime token leaked: error=%q logs=%q", err, logs.String())
}
}
func TestCrossPlatformCoverageRuntimeTokenBusModeSkipsLocalOAuthIdentity(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldResolve := eventResolvePersonal
oldSource := eventNewPersonalSource
oldRun := eventBusRun
t.Cleanup(func() {
eventResolvePersonal = oldResolve
eventNewPersonalSource = oldSource
eventBusRun = oldRun
})
resolvedLocal := false
eventResolvePersonal = func(context.Context, string, string) (personal.Identity, error) {
resolvedLocal = true
return personal.Identity{}, nil
}
var sourceOpts personalStreamSourceOptions
eventNewPersonalSource = func(_ context.Context, opts personalStreamSourceOptions) (*source.PersonalSource, error) {
sourceOpts = opts
return nil, nil
}
var busCfg bus.Config
eventBusRun = func(_ context.Context, cfg bus.Config) error {
busCfg = cfg
return nil
}
cmd := newEventBusCommand()
cmd.SetArgs([]string{
"--source-kind", "personal_stream",
"--runtime-token-mode",
"--identity-hash", "0123456789abcdef",
"--client-id", "runtime-client",
"--stream-source-id", "runtime-source",
"--idle-timeout", "0",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("event _bus runtime mode error = %v", err)
}
if resolvedLocal {
t.Fatal("runtime token bus resolved local OAuth identity")
}
if sourceOpts.CredentialBroker == nil || busCfg.CredentialBroker != sourceOpts.CredentialBroker {
t.Fatal("personal source and bus did not share one credential broker")
}
if busCfg.IdentityHash != "0123456789abcdef" || busCfg.ClientID != "runtime-client" || busCfg.SourceID != "runtime-source" {
t.Fatalf("bus identity = %#v", busCfg)
}
generation, err := sourceOpts.CredentialBroker.Update(0, "detached-activation-canary")
if err != nil {
t.Fatalf("seed detached broker: %v", err)
}
waitCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if _, err := sourceOpts.CredentialBroker.Resolve(waitCtx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("detached broker resolved before consumer activation: %v", err)
}
if _, err := sourceOpts.CredentialBroker.Activate(generation); err != nil {
t.Fatalf("activate detached broker: %v", err)
}
if resolved, err := sourceOpts.CredentialBroker.Resolve(context.Background()); err != nil || resolved == "" {
t.Fatalf("detached broker did not resolve after activation: %v", err)
}
}
func TestCrossPlatformCoverageForegroundRuntimeBrokerDoesNotRequireActivation(t *testing.T) {
broker := newPersonalCredentialBroker(t.TempDir(), true, false)
if _, err := broker.Update(0, "foreground-activation-canary"); err != nil {
t.Fatalf("seed foreground broker: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if resolved, err := broker.Resolve(ctx); err != nil || resolved == "" {
t.Fatalf("foreground broker unexpectedly waited for activation: %v", err)
}
}
func TestCrossPlatformCoveragePersonalRuntimeBusSpawnArgsContainNoSecretOrProfile(t *testing.T) {
const token = "runtime-spawn-canary"
args := personalBusSpawnArgsForToken(personal.Identity{
ClientID: "client", SourceID: "source", CorpID: "corp", UserID: "user",
}, "identity-hash", "normal", "https://ticket.invalid", "corp:user", token)
joined := strings.Join(args, " ")
for _, forbidden := range []string{token, "--profile", "corp:user"} {
if strings.Contains(joined, forbidden) {
t.Fatalf("spawn args leaked %q: %q", forbidden, joined)
}
}
for _, required := range []string{"--runtime-token-mode", "--identity-hash", "identity-hash", "--stream-source-id", "source"} {
if !strings.Contains(joined, required) {
t.Fatalf("spawn args %q missing %q", joined, required)
}
}
}
func TestCrossPlatformCoverageUnsupportedOldBusDoesNotDeleteReusedSubscription(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
oldEnsure := personalEnsureSubscription
oldUpsert := personalUpsertRunState
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
oldConsume := personalConsumeRun
oldValidate := personalValidateConsumeConfig
oldConflict := personalValidateNoOutputConflict
t.Cleanup(func() {
edition.Override(oldEdition)
personalEnsureSubscription = oldEnsure
personalUpsertRunState = oldUpsert
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
personalConsumeRun = oldConsume
personalValidateConsumeConfig = oldValidate
personalValidateNoOutputConflict = oldConflict
})
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-existing"}, personal.EventMention, "at", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
deleteCalls := 0
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
deleteCalls++
return nil
}
var removed []string
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
if strings.TrimSpace(cfg.RuntimeToken) == "" {
t.Fatal("runtime token was not wired to consume")
}
return &consume.RuntimeTokenUnsupportedError{BusPID: 72}
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
SubscribeID: "sub-existing",
ExplicitToken: "old-bus-cleanup-canary",
ClientIDOverride: "runtime-client",
})
if !errors.Is(err, consume.ErrRuntimeTokenUnsupported) {
t.Fatalf("consume error = %v", err)
}
if deleteCalls != 0 {
t.Fatalf("reused remote subscription was deleted %d time(s)", deleteCalls)
}
if len(removed) != 0 {
t.Fatalf("reused local run-state was removed: %#v", removed)
}
}
func TestCrossPlatformCoverageRuntimeTokenReusedDryRunUsesExplicitControlCredential(t *testing.T) {
const token = "runtime-dry-run-control-canary"
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
oldEnsure := personalEnsureSubscription
oldUpsert := personalUpsertRunState
oldConsume := personalConsumeRun
oldBusRun := personalBusRun
t.Cleanup(func() {
edition.Override(oldEdition)
personalEnsureSubscription = oldEnsure
personalUpsertRunState = oldUpsert
personalConsumeRun = oldConsume
personalBusRun = oldBusRun
})
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
personalEnsureSubscription = func(ctx context.Context, client *personal.Client, _ personal.Identity, _ personalConsumeOptions) (*personal.Subscription, string, string, error) {
if _, ok := client.HTTPClient.Transport.(runtimeTokenControlTransport); !ok {
t.Fatalf("control transport = %T, want runtimeTokenControlTransport", client.HTTPClient.Transport)
}
got, err := client.AccessTokenProvider(ctx)
if err != nil || got != token {
t.Fatalf("control token = %q, %v", got, err)
}
return &personal.Subscription{SubscribeID: "sub-existing"}, personal.EventMention, "at", nil
}
personalUpsertRunState = func(string, personal.RunState) error {
t.Fatal("dry-run unexpectedly persisted run state")
return nil
}
consumeCalls := 0
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
consumeCalls++
if !cfg.DryRun {
t.Fatal("consume config is not dry-run")
}
if strings.Contains(strings.Join(cfg.SpawnExtraArgs, " "), token) {
t.Fatal("dry-run spawn args leaked runtime token")
}
return nil
}
personalBusRun = func(context.Context, bus.Config) error {
t.Fatal("dry-run unexpectedly started a bus")
return nil
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
SubscribeID: "sub-existing",
ExplicitToken: token,
ClientIDOverride: "runtime-client",
Common: commonConsumeOptions{DryRun: true},
})
if err != nil {
t.Fatalf("dry-run consume error = %v", err)
}
if consumeCalls != 1 {
t.Fatalf("dry-run consume calls = %d, want 1", consumeCalls)
}
}
func TestCrossPlatformCoverageRuntimeTokenControlRejectionReleasesSubscriptionClaim(t *testing.T) {
store := &personalRecordingAttemptStore{}
reservation := &personalSubscriptionAttemptReservation{
store: store,
claim: &personal.AttemptClaim{AttemptID: "runtime-token-attempt"},
items: []personalSubscriptionAttemptItem{{eventKey: personal.EventMention, fingerprint: strings.Repeat("a", 64)}},
}
cause := &personal.APIError{
Code: "RUNTIME_TOKEN_REJECTED",
Message: "event runtime token was rejected; retry with a fresh host credential",
HTTPStatus: http.StatusUnauthorized,
}
if !personalRuntimeTokenControlRejection(cause) {
t.Fatal("runtime token control rejection was not classified")
}
err := reservation.releaseRuntimeTokenFailure()
if err == nil || !strings.Contains(err.Error(), "runtime token was rejected") {
t.Fatalf("releaseRuntimeTokenFailure() error = %v", err)
}
if store.releaseCalls != 1 || store.failureCalls != 0 {
t.Fatalf("attempt store release=%d failure=%d, want release only", store.releaseCalls, store.failureCalls)
}
}
type eventRuntimeRoundTripFunc func(*http.Request) (*http.Response, error)
func (f eventRuntimeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
@@ -1,74 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func TestCrossPlatformCoverageEventAgentSelectionBoundaries(t *testing.T) {
_ = NewRootCommand()
eventProduct, ok := contract.LookupProductDecl("event")
if !ok {
t.Fatal("event ProductDecl is not registered")
}
assertSelectionContains(t, "event product", eventProduct.Selection.AgentSummary,
[]string{"IM", "OA"})
assertSelectionContains(t, "event product use_when", strings.Join(eventProduct.Selection.UseWhen, "\n"),
[]string{"消息", "群生命周期", "OA"})
assertSelectionContains(t, "event product avoid_when", strings.Join(eventProduct.Selection.AvoidWhen, "\n"),
[]string{"chat", "oa", "dev app event"})
listenMeta, ok := cli.ResolveMeta("event +listen-im")
if !ok {
t.Fatal("event +listen-im metadata is not registered")
}
assertSelectionContains(t, "event.listen_im use_when", strings.Join(listenMeta.Selection.UseWhen, "\n"),
[]string{"@我", "message/reaction/read/recall"})
assertSelectionContains(t, "event.listen_im avoid_when", strings.Join(listenMeta.Selection.AvoidWhen, "\n"),
[]string{"OA 审批事件", "群标题", "Filter DSL", "event consume", "历史消息"})
consumeMeta, ok := cli.ResolveMeta("event consume")
if !ok {
t.Fatal("event consume metadata is not registered")
}
consumeUse := strings.Join(consumeMeta.Selection.UseWhen, "\n")
assertSelectionContains(t, "event.consume use_when", consumeUse,
[]string{"OA", "群", "EventKey", "Filter DSL", "subscribe_id", "transport envelope", "高级多事件"})
consumeAvoid := strings.Join(consumeMeta.Selection.AvoidWhen, "\n")
assertSelectionContains(t, "event.consume avoid_when", consumeAvoid,
[]string{"event +listen-im", "历史聊天", "oa", "dev app event"})
schemaMeta, ok := cli.ResolveMeta("event schema")
if !ok {
t.Fatal("event schema metadata is not registered")
}
assertSelectionContains(t, "event.schema use_when", strings.Join(schemaMeta.Selection.UseWhen, "\n"),
[]string{"IM", "OA", "--flatten"})
for productID, want := range map[string]string{
"chat": "event +listen-im",
"oa": "event consume",
} {
decl, found := contract.LookupProductDecl(productID)
if !found {
t.Fatalf("%s ProductDecl is not registered", productID)
}
assertSelectionContains(t, productID+" avoid_when", strings.Join(decl.Selection.AvoidWhen, "\n"), []string{want})
}
}
func assertSelectionContains(t *testing.T, label, text string, fragments []string) {
t.Helper()
for _, fragment := range fragments {
if !strings.Contains(text, fragment) {
t.Errorf("%s = %q, want fragment %q", label, text, fragment)
}
}
}
-525
View File
@@ -1,525 +0,0 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestFrameworkErrorProjectionPreservesRecoveryMetadata(t *testing.T) {
next := time.Date(2026, 8, 10, 1, 2, 3, 0, time.FixedZone("test", 8*60*60))
retry := int64(4)
started := true
leaf := &helpers.CLIError{Code: "UPSTREAM_CODE", Suggestion: "retry with id", Operation: "create"}
call := &transport.CallError{Stage: transport.CallStage("decode"), HTTPStatus: 503, RPCCode: 91, TraceID: "call-trace", Cause: leaf}
typed := &apperrors.Error{
Category: apperrors.CategoryAPI, Message: "failed", Reason: "upstream_failed", Hint: "use status",
Actions: []string{"dws status"}, Retryable: true, RetryableSet: true, RetryAfterSeconds: &retry,
RPCCode: 92, RPCData: json.RawMessage(`{"task":"x"}`), Operation: "publish", ServerKey: "server",
Origin: "gateway", FailureStage: "response", ExecutionStarted: &started, NextRetryAt: &next,
AvailableFlags: []string{"--id"}, Snapshot: "/tmp/snapshot", Details: map[string]any{"id": "x"},
ServerDiag: apperrors.ServerDiagnostics{TraceID: "typed-trace", ServerErrorCode: "SERVER_CODE", TechnicalDetail: "detail", FriendlyHint: "friendly", ActionURL: "https://example.test"},
Cause: call,
}
info := errorInfoFromExecutionError(typed)
if info.Type != "api" || info.Subtype != "upstream_failed" || info.HTTPStatus != 503 || info.RPCCode != 92 || info.RequestID != "call-trace" || info.TraceID != "typed-trace" {
t.Fatalf("projection=%+v", info)
}
if info.UpstreamCode != "SERVER_CODE" || info.Operation != "publish" || info.NextRetryAt == "" || info.Cause == "" || info.RPCData == nil || info.ExecutionStarted == nil || !*info.ExecutionStarted {
t.Fatalf("recovery metadata=%+v", info)
}
innerOperation := &helpers.CLIError{Operation: "create"}
outerWithoutOperation := &apperrors.Error{
Category: apperrors.CategoryAPI,
Message: "failed",
Cause: innerOperation,
}
preserved := errorInfoFromExecutionError(outerWithoutOperation)
if preserved.Operation != "create" {
t.Fatalf("operation=%q, want inner operation preserved", preserved.Operation)
}
requestCall := &transport.CallError{Stage: transport.CallStage("request"), HTTPStatus: 429, RequestID: "request-id"}
requestInfo := errorInfoFromExecutionError(requestCall)
if requestInfo.RequestID != "request-id" || requestInfo.HTTPStatus != 429 {
t.Fatalf("request projection=%+v", requestInfo)
}
partial := errorInfoFromExecutionError(&apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"})
if partial.Type != "internal" {
t.Fatalf("partial error type=%s", partial.Type)
}
for code, want := range map[int]string{1: "api", 2: "auth", 3: "validation", 4: "permission", 6: "discovery", 99: "internal"} {
if got := errorTypeForExitCode(code); got != want {
t.Fatalf("errorTypeForExitCode(%d)=%q", code, got)
}
}
}
func TestFrameworkExecutePreparseUnifiedErrorAndEmissionFallback(t *testing.T) {
for _, failWriter := range []bool{false, true} {
t.Run(map[bool]string{false: "unified", true: "fallback"}[failWriter], func(t *testing.T) {
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "leaf"}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("bad preparse") })
var stdout bytes.Buffer
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.SetContext(ctx)
leaf := &cobra.Command{Use: "leaf"}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
if failWriter {
leaf.SetOut(frameworkFailWriter{})
} else {
leaf.SetOut(&stdout)
}
leaf.SetErr(&bytes.Buffer{})
root.AddCommand(leaf)
return root
})
if code := Execute(); code != 3 {
t.Fatalf("Execute code=%d", code)
}
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
t.Fatalf("stdout=%q", stdout.String())
}
})
}
}
func TestFrameworkPublicRootRequiresResultFromActiveCommand(t *testing.T) {
root := NewRootCommand(context.Background())
leaf := &cobra.Command{Use: "active-no-result", RunE: func(*cobra.Command, []string) error { return nil }}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"active-no-result"})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "without a CommandResult") {
t.Fatalf("ExecuteC error=%v", err)
}
}
func TestFrameworkAbortOutputSinkRemoveFailure(t *testing.T) {
originalRemove := rootRemoveFile
t.Cleanup(func() { rootRemoveFile = originalRemove })
file, err := os.CreateTemp(t.TempDir(), "abort-*")
if err != nil {
t.Fatal(err)
}
rootRemoveFile = func(string) error { return errors.New("remove failed") }
cmd := &cobra.Command{Use: "abort"}
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file, tempPath: file.Name()}))
if err := abortOutputSink(cmd); err == nil || !strings.Contains(err.Error(), "remove temporary") {
t.Fatalf("abort error=%v", err)
}
}
func TestFrameworkOutputSinkHookWrappingAndCleanupEdges(t *testing.T) {
installOutputSinkRunBoundary(nil)
plain := &cobra.Command{Use: "plain"}
plain.SetContext(context.Background())
installOutputSinkRunBoundary(plain)
// newBoundaryChild builds a leaf whose --output lives on the root's
// persistent flag set, matching production wiring (a local --output flag
// belongs to the leaf's own business contract and skips the sink).
newBoundaryChild := func(outputPath string) *cobra.Command {
root := &cobra.Command{Use: "root"}
root.PersistentFlags().String("output", outputPath, "")
cmd := &cobra.Command{Use: "leaf"}
root.AddCommand(cmd)
cmd.SetContext(context.Background())
return cmd
}
var calls int
cmd := newBoundaryChild("")
cmd.RunE = func(*cobra.Command, []string) error { calls++; return nil }
cmd.PostRunE = func(*cobra.Command, []string) error { calls++; return nil }
installOutputSinkRunBoundary(cmd)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
if err := cmd.PostRunE(cmd, nil); err != nil {
t.Fatal(err)
}
runOnly := newBoundaryChild("")
runOnly.Run = func(*cobra.Command, []string) { calls++ }
runOnly.PostRun = func(*cobra.Command, []string) { calls++ }
installOutputSinkRunBoundary(runOnly)
if runOnly.Run != nil || runOnly.RunE == nil {
t.Fatal("Run-only leaf must be converted to RunE so sink setup errors surface")
}
if err := runOnly.RunE(runOnly, nil); err != nil {
t.Fatal(err)
}
runOnly.PostRun(runOnly, nil)
if calls != 4 {
t.Fatalf("hook calls=%d", calls)
}
// A sink setup failure at Run entry returns before the business hook runs.
testseam.Swap(t, &rootCreateTemp, func(string, string) (*os.File, error) { return nil, errors.New("create failed") })
failCmd := newBoundaryChild(filepath.Join(t.TempDir(), "out.txt"))
businessRan := false
failCmd.RunE = func(*cobra.Command, []string) error { businessRan = true; return nil }
installOutputSinkRunBoundary(failCmd)
if err := failCmd.RunE(failCmd, nil); err == nil || !strings.Contains(err.Error(), "create failed") {
t.Fatalf("Run entry sink setup error=%v", err)
}
if businessRan {
t.Fatal("business hook ran after sink setup failure")
}
testseam.Swap(t, &rootCreateTemp, os.CreateTemp)
// A Run entry business error aborts the open sink: the temporary file is
// removed and the final target is never created.
abortTarget := filepath.Join(t.TempDir(), "result.txt")
abortCmd := newBoundaryChild(abortTarget)
abortCmd.RunE = func(*cobra.Command, []string) error { return errors.New("boom") }
installOutputSinkRunBoundary(abortCmd)
if err := abortCmd.RunE(abortCmd, nil); err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("Run entry business error=%v", err)
}
if _, err := os.Stat(abortTarget); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("target exists after aborted run: %v", err)
}
assertNoOutputTemps(t, abortTarget)
// A second configureOutputSink call on an already-open sink (a reused
// command tree stacks one Run wrapper per ExecuteC) must not replace the
// live sink with a second temporary file.
repeatTarget := filepath.Join(t.TempDir(), "result.txt")
repeatCmd := newBoundaryChild(repeatTarget)
if err := configureOutputSink(repeatCmd); err != nil {
t.Fatal(err)
}
first := outputSinkForCommand(repeatCmd)
if first == nil {
t.Fatal("first configureOutputSink did not open a sink")
}
if err := configureOutputSink(repeatCmd); err != nil {
t.Fatal(err)
}
if second := outputSinkForCommand(repeatCmd); second != first {
t.Fatal("configureOutputSink replaced an open sink")
}
if err := abortOutputSink(repeatCmd); err != nil {
t.Fatal(err)
}
assertNoOutputTemps(t, repeatTarget)
file2, err := os.CreateTemp(t.TempDir(), "sink-error-*")
if err != nil {
t.Fatal(err)
}
errorCmd := &cobra.Command{Use: "error"}
errorCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file2, tempPath: file2.Name(), target: "unused"}))
if err := runWithOutputSinkErrorCleanup(errorCmd, func() error { return errors.New("boom") }); err == nil {
t.Fatal("run error swallowed")
}
file3, err := os.CreateTemp(t.TempDir(), "sink-panic-*")
if err != nil {
t.Fatal(err)
}
panicCmd := &cobra.Command{Use: "panic"}
panicCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file3, tempPath: file3.Name(), target: "unused"}))
func() {
defer func() {
if recover() == nil {
t.Fatal("panic swallowed")
}
}()
_ = runWithOutputSinkErrorCleanup(panicCmd, func() error { panic("boom") })
}()
if closeOutputSink(nil) != nil || abortOutputSink(nil) != nil || outputSinkForCommand(nil) != nil {
t.Fatal("nil sink guards failed")
}
finished := &outputSinkState{finished: true, file: file3}
finishedCmd := &cobra.Command{Use: "finished"}
finishedCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, finished))
if closeOutputSink(finishedCmd) != nil || abortOutputSink(finishedCmd) != nil {
t.Fatal("finished sink was processed twice")
}
}
type frameworkFailWriter struct{}
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
for _, failWriter := range []bool{false, true} {
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
testseam.Protect(t, &os.Args)
os.Args = []string{"dws"}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
var stdout bytes.Buffer
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws"}
cmd.SetContext(ctx)
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
if failWriter {
cmd.SetOut(frameworkFailWriter{})
} else {
cmd.SetOut(&stdout)
}
cmd.SetErr(&bytes.Buffer{})
return cmd
})
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) { panic("before emission") })
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
t.Fatalf("stdout=%q", stdout.String())
}
})
}
}
func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T) {
t.Run("preparse interrupted", func(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootRunPreParse, func(cmd *cobra.Command, _ *pipeline.Engine) error {
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return errors.New("preparse failed")
})
if code := Execute(); code != 130 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("nil executed after emission attempt", func(t *testing.T) {
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
cmd.SetOut(frameworkFailWriter{})
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
_, _, _ = output.EmitStoredResult(cmd)
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return nil, cmd.Context().Err()
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("publication failure after emission", func(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
return cmd, newOutputPublicationError("publish", errors.New("rename failed"))
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("publication failure envelope writer also fails", func(t *testing.T) {
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
file, err := os.CreateTemp(t.TempDir(), "finished-output-*")
if err != nil {
t.Fatal(err)
}
defer file.Close()
state := &outputSinkState{file: file, original: frameworkFailWriter{}, finished: true}
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, state))
return cmd, newOutputPublicationError("publish", errors.New("rename failed"))
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("failure envelope cannot be written", func(t *testing.T) {
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
cmd.SetOut(frameworkFailWriter{})
return cmd, errors.New("business failed")
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("late output publication warning", func(t *testing.T) {
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
file, err := os.CreateTemp(t.TempDir(), "late-output-*")
if err != nil {
t.Fatal(err)
}
state := &outputSinkState{file: file, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json")}
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, state))
return cmd, nil
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
for _, tc := range []struct {
name string
unified bool
original io.Writer
wantOutput bool
}{
{name: "unified late publication failure", unified: true, original: &bytes.Buffer{}, wantOutput: true},
{name: "legacy late publication failure", original: io.Discard},
{name: "late publication failure writer fails", unified: true, original: frameworkFailWriter{}},
} {
t.Run(tc.name, func(t *testing.T) {
installSignalExecuteSeams(t, tc.unified, io.Discard, io.Discard)
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
var original io.Writer = tc.original
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
file, err := os.CreateTemp(t.TempDir(), "panic-output-*")
if err != nil {
t.Fatal(err)
}
cmd.SetOut(file)
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
file: file, original: original, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json"),
}))
panic("after sink open")
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
if tc.wantOutput && !strings.Contains(tc.original.(*bytes.Buffer).String(), `"outcome": "failure"`) {
t.Fatalf("stdout=%q", tc.original.(*bytes.Buffer).String())
}
})
}
t.Run("abort failure is diagnostic", func(t *testing.T) {
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
file, err := os.CreateTemp(t.TempDir(), "abort-output-*")
if err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
file: file, original: io.Discard, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json"),
}))
return cmd, errors.New("business failed")
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("publication helper requires observable finished transaction", func(t *testing.T) {
cmd := &cobra.Command{Use: "unified"}
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
file, err := os.CreateTemp(t.TempDir(), "unfinished-output-*")
if err != nil {
t.Fatal(err)
}
defer file.Close()
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
file: file, finished: true,
}))
if _, handled, emitErr := emitOutputPublicationFailure(cmd, newOutputPublicationError("publish", errors.New("rename failed"))); handled || emitErr != nil {
t.Fatalf("handled=%v err=%v", handled, emitErr)
}
})
}
type frameworkPanicWriter struct{}
func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") }
func TestCrossPlatformCoverageFrameworkRootHookErrors(t *testing.T) {
t.Run("flag group validation", func(t *testing.T) {
root := NewRootCommand(context.Background())
leaf := &cobra.Command{Use: "exclusive", RunE: func(*cobra.Command, []string) error { return nil }}
leaf.Flags().Bool("left", false, "")
leaf.Flags().Bool("right", false, "")
leaf.MarkFlagsMutuallyExclusive("left", "right")
root.AddCommand(leaf)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"exclusive", "--left", "--right"})
if err := root.Execute(); err == nil {
t.Fatal("expected mutually-exclusive flag error")
}
})
t.Run("edition pre-run error", func(t *testing.T) {
old := edition.Get()
t.Cleanup(func() { edition.Override(old) })
edition.Override(&edition.Hooks{AfterPersistentPreRun: func(*cobra.Command, []string) error {
return errors.New("edition hook failed")
}})
root := NewRootCommand(context.Background())
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "edition hook failed") {
t.Fatalf("Execute error=%v", err)
}
})
t.Run("post-run emission panic", func(t *testing.T) {
root := NewRootCommand(context.Background())
cmd := &cobra.Command{Use: "panic-output"}
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.SetOut(frameworkPanicWriter{})
if err := output.StoreResult(ctx, output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
defer func() {
if recover() == nil {
t.Fatal("expected post-run panic")
}
}()
_ = root.PersistentPostRunE(cmd, nil)
})
}
+3 -13
View File
@@ -27,11 +27,9 @@ import (
"github.com/spf13/cobra"
)
// mountLegacyPublicCommands builds the product + shortcut command tree without
// mutating process-global MCP deps or dynamic server endpoints. Used by the
// Schema source root (declaration-only) path so assembly cannot clobber a live
// runtime's InitDeps caller or plugin endpoints.
func mountLegacyPublicCommands(runner executor.Runner, loadUserShortcuts bool) []*cobra.Command {
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
commands := helpers.NewPublicCommands(runner)
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
// command tree, so distilled high-frequency operations mount alongside the
@@ -53,14 +51,6 @@ func mountLegacyPublicCommands(runner executor.Runner, loadUserShortcuts bool) [
return mergeTopLevelCommands(commands)
}
// newLegacyPublicCommands is the executable CLI path: inject static MCP
// endpoints, InitDeps, then mount the public command tree.
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
return mountLegacyPublicCommands(runner, loadUserShortcuts)
}
func injectStaticServers() {
hooks := edition.Get()
var servers []edition.ServerInfo
@@ -10,7 +10,7 @@ import (
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
// contract asserts the exact phrase below inside the installed
// dingtalk-shared/SKILL.md, so removing it from the embedded skill source must
// dws-shared/SKILL.md, so removing it from the embedded skill source must
// fail at PR time instead of at release time.
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
@@ -19,12 +19,12 @@ func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
}
t.Cleanup(cleanup)
data, err := os.ReadFile(filepath.Join(dir, "dingtalk-shared", "SKILL.md"))
data, err := os.ReadFile(filepath.Join(dir, "dws-shared", "SKILL.md"))
if err != nil {
t.Fatalf("read embedded dingtalk-shared/SKILL.md: %v", err)
t.Fatalf("read embedded dws-shared/SKILL.md: %v", err)
}
const rule = "禁止选择第一项、最近登录或最近使用账号"
if !strings.Contains(string(data), rule) {
t.Fatalf("embedded dingtalk-shared/SKILL.md lost the mandatory account safety rule %q", rule)
t.Fatalf("embedded dws-shared/SKILL.md lost the mandatory account safety rule %q", rule)
}
}
+15 -15
View File
@@ -33,43 +33,43 @@ func contains(ss []string, want string) bool {
return false
}
// dingtalk-shared must ship even when --skill narrows the set to a single product.
// dws-shared must ship even when --skill narrows the set to a single product.
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
src := writeMultiSkillSrc(t, "dingtalk-shared", "dingtalk-aitable", "dingtalk-calendar")
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
all, err := listMultiSkillNames(src)
if err != nil {
t.Fatal(err)
}
if !contains(all, "dingtalk-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dingtalk-shared: %v", all)
if !contains(all, "dws-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
}
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
if err != nil {
t.Fatal(err)
}
if contains(filtered, "dingtalk-shared") {
t.Fatalf("precondition: filter should drop dingtalk-shared for -s aitable: %v", filtered)
if contains(filtered, "dws-shared") {
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
}
final := ensureMandatorySharedSkill(filtered, all)
if !contains(final, "dingtalk-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dingtalk-shared: %v", final)
if !contains(final, "dws-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
}
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
// Actually install with the filtered+mandatory set and assert dws-shared landed.
dest := t.TempDir()
var out, errOut bytes.Buffer
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut, true); err != nil {
t.Fatalf("install: %v (%s)", err, errOut.String())
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-shared not installed with -s aitable: %v", err)
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-aitable not installed: %v", err)
}
}
// When the source has no dingtalk-shared (older layout), nothing is forced.
// When the source has no dws-shared (older layout), nothing is forced.
func TestP1SharedNoopWhenAbsent(t *testing.T) {
src := writeMultiSkillSrc(t, "dingtalk-aitable")
all, err := listMultiSkillNames(src)
@@ -77,7 +77,7 @@ func TestP1SharedNoopWhenAbsent(t *testing.T) {
t.Fatal(err)
}
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
if contains(final, "dingtalk-shared") {
t.Fatalf("must not invent dingtalk-shared when source lacks it: %v", final)
if contains(final, "dws-shared") {
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
}
}
+14 -53
View File
@@ -39,7 +39,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
copyArgs[key] = value
}
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
text := c.paramAliasResponseForTool(tool)
text := paramAliasResponseForTool(tool)
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
@@ -48,7 +48,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
// print the transport result and need an empty object; smart shortcuts that
// inspect a read response receive the smallest shape that lets their full RunE
// complete without falling back to a validation error.
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
func paramAliasResponseForTool(tool string) string {
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
@@ -58,27 +58,6 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
return `{"deptList":[{"deptId":1,"name":"Fixture Dept"}]}`
case "search_groups":
return `{"result":{"items":[{"openConversationId":"fixture-conversation","title":"Fixture Group"}]}}`
case "search_contact_by_key_word":
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
case "list_doc_versions":
return `{"result":{"items":[{"version":3}]}}`
case "revert_doc_version":
return `{"version":3}`
case "search_doc_templates":
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "create_document":
return `{"nodeId":"fixture-node"}`
case "get_document_content":
for index := len(c.calls) - 2; index >= 0; index-- {
call := c.calls[index]
for _, key := range []string{"jsonml", "markdown"} {
if content, ok := call.args[key].(string); ok {
encoded, _ := json.Marshal(map[string]any{"revision": 1, key: content})
return string(encoded)
}
}
}
return `{"revision":1}`
default:
return `{}`
}
@@ -186,24 +165,6 @@ func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context
return ctx, preview, append([]executor.Invocation(nil), rejectRunner.attempts...), executeErr
}
func TestCrossPlatformCoverageFlagListDryRunStopsBeforeReadDispatch(t *testing.T) {
_, preview, attempts, err := executeParamAliasDryRunE2E(t,
"chat", "+flag-list", "--page-size", "20", "--cursor", "0", "--dry-run",
)
if err != nil {
t.Fatalf("flag-list dry-run error = %v", err)
}
if len(attempts) != 0 {
t.Fatalf("flag-list dry-run crossed dispatch boundary: %#v", attempts)
}
if !preview.DryRun || preview.Executed || preview.Tool != "list_message_favorites" {
t.Fatalf("flag-list dry-run preview = %#v", preview)
}
if preview.Arguments["cursor"] != float64(0) || preview.Arguments["size"] != "20" {
t.Fatalf("flag-list dry-run arguments = %#v", preview.Arguments)
}
}
func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
t.Helper()
originalArgs := os.Args
@@ -230,7 +191,7 @@ func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ..
return ctx, root.Execute()
}
func TestCrossPlatformCoverageBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
tests := []struct {
name string
confirmation []string
@@ -283,7 +244,7 @@ func TestCrossPlatformCoverageBooleanStickyCannotBypassDestructiveConfirmation(t
}
}
func TestCrossPlatformCoverageParamAliasReadCommandFinalPayload(t *testing.T) {
func TestParamAliasReadCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
start := "2026-03-10T14:00:00+08:00"
end := "2026-03-10T18:00:00+08:00"
@@ -318,7 +279,7 @@ func TestCrossPlatformCoverageParamAliasReadCommandFinalPayload(t *testing.T) {
}
}
func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, caller,
"chat", "message", "send",
@@ -350,7 +311,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
tests := []struct {
name string
command []string
@@ -429,7 +390,7 @@ func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPaylo
}
}
func TestCrossPlatformCoverageAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
root := NewRootCommand()
engine := newPipelineEngine()
entries, err := cli.ReduceParamAliases(root)
@@ -517,7 +478,7 @@ func TestCrossPlatformCoverageAllGeneratedChatParamAliasesReachRuntimeCobraContr
t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
}
func TestCrossPlatformCoverageIMUserIDHallucinationRoutes(t *testing.T) {
func TestIMUserIDHallucinationRoutes(t *testing.T) {
tests := []struct {
command string
want string
@@ -570,7 +531,7 @@ func TestCrossPlatformCoverageIMUserIDHallucinationRoutes(t *testing.T) {
}
}
func TestCrossPlatformCoverageHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
const command = "chat message list-direct"
if _, ok := cli.LookupParamAlias(command); ok {
t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command)
@@ -595,7 +556,7 @@ func TestCrossPlatformCoverageHiddenIMListDirectRemainsOutsideCentralAliasTable(
}
}
func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
tests := []struct {
name string
tool string
@@ -692,7 +653,7 @@ func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryR
}
}
func TestCrossPlatformCoverageParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
caller := &paramAliasCaptureCaller{}
for _, args := range [][]string{
{"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"},
@@ -717,7 +678,7 @@ func TestCrossPlatformCoverageParamAliasCanonicalConflictFailsBeforeRunE(t *test
}
}
func TestCrossPlatformCoverageAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -809,7 +770,7 @@ func TestCrossPlatformCoverageAllReviewedParamAliasGuardsReachRuntimeContract(t
}
}
func TestCrossPlatformCoverageRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
for _, test := range []struct {
path string
emitted string
@@ -856,7 +817,7 @@ func TestCrossPlatformCoverageRepresentativeParamAliasGuardsReachFinalErrorsWith
}
}
func TestCrossPlatformCoverageFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
func TestFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
err := (&pipeline.FlagConflictError{Command: "dws demo", Canonical: "start", Spellings: []string{"start", "date"}}).Error()
want := `conflicting parameter spellings for --start on "dws demo": --date, --start; pass exactly one spelling`
if err != want {
+5 -11
View File
@@ -44,7 +44,7 @@ import (
// - expect=<realFlag> : emitted must reduce to that canonical flag.
// - expect=did-you-mean:blocked : block guard hit; never auto-rewritten.
// - expect=did-you-mean:ambiguous: co-occurrence guard hit; never rewritten.
func TestCrossPlatformCoverageParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -113,19 +113,13 @@ func TestCrossPlatformCoverageParamAliasFixtureThroughEmbeddedDeliveryPath(t *te
t.Fatalf("reviewed canonical --%s on %q is not a real Cobra flag", c.Expect, c.Command)
}
flagArgs := ctx.Args[len(strings.Fields(c.Command)):]
if len(flagArgs) == 0 {
t.Fatalf("%q on %q lost its flag and value: args=%v", c.Emitted, c.Command, ctx.Args)
}
got := flagArgs[0]
gotBare, inlineValue, hasInlineValue := strings.Cut(strings.TrimPrefix(got, "--"), "=")
switch {
case hasInlineValue && inlineValue != fixtureValue:
t.Fatalf("%q on %q changed its inline value: got %q, want %q (args=%v)", c.Emitted, c.Command, inlineValue, fixtureValue, ctx.Args)
case !hasInlineValue && (len(flagArgs) < 2 || flagArgs[1] != fixtureValue):
if len(flagArgs) < 2 || flagArgs[1] != fixtureValue {
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
}
got := flagArgs[0]
gotBare := strings.SplitN(strings.TrimPrefix(got, "--"), "=", 2)[0]
switch {
case gotBare == c.Expect:
case got == "--"+c.Expect:
// (1) rewritten; the embedded table must agree.
if !hasEntry {
t.Fatalf("%q on %q was rewritten without an embedded alias entry", c.Emitted, c.Command)
@@ -29,37 +29,14 @@ var paramAliasCompleteCommands = map[string][]string{
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
"chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"},
"chat +conversation-set-top": {"chat", "+conversation-set-top", "--conversation-id", "fixture-conversation", "--yes"},
"chat +feed-group-query-item": {"chat", "+feed-group-query-item", "--category-id", "7", "--conversation-ids", "fixture-conversation"},
"chat +flag-cancel": {"chat", "+flag-cancel", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
"chat +flag-create": {"chat", "+flag-create", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--page-size", "7"},
"chat +messages-combine-forward": {"chat", "+messages-combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat +messages-forward": {"chat", "+messages-forward", "--src-conversation-id", "fixture-source", "--msg-id", "message-1", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat +messages-forward-topic": {"chat", "+messages-forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
@@ -102,36 +79,9 @@ var paramAliasCompleteCommands = map[string][]string{
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
"doc +access-grant": {"doc", "+access-grant", "--node", "node-1", "--to", "Fixture User", "--role", "READER", "--workspace", "workspace-1", "--yes"},
"doc +copy": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
"doc +create": {"doc", "+create", "--name", "Fixture Document", "--content", "fixture body", "--doc-format", "markdown"},
"doc +create-from-template": {"doc", "+create-from-template", "--query", "fixture template", "--name", "Fixture From Template", "--folder", "folder-1", "--workspace", "workspace-1"},
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--text", "fixture appendix", "--yes"},
"doc +export-submit": {"doc", "+export-submit", "--node", "node-1", "--export-format", "docx"},
"doc +fetch": {"doc", "+fetch", "--node", "node-1", "--scope", "section", "--start-block-id", "block-1"},
"doc +find-doc": {"doc", "+find-doc", "--query", "fixture", "--limit", "7"},
"doc +history-revert": {"doc", "+history-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +inspect": {"doc", "+inspect", "--node", "node-1", "--include-history"},
"doc +list": {"doc", "+list", "--folder", "folder-1", "--cursor", "cursor-1"},
"doc +move": {"doc", "+move", "--node", "node-1", "--folder", "folder-1", "--yes"},
"doc +search": {"doc", "+search", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"doc +template-list": {"doc", "+template-list", "--source", "MY", "--limit", "7", "--cursor", "cursor-1"},
"doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"},
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
"doc comment create-inline": {"doc", "comment", "create-inline", "--node", "node-1", "--block-id", "block-1", "--start", "0", "--end", "7", "--content", "fixture comment", "--yes"},
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
"doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"},
"doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"},
"doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"},
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
@@ -149,13 +99,6 @@ var paramAliasCompleteCommands = map[string][]string{
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"doc +copy": {
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
},
"doc block insert": {
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
},
"chat message list": {
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
},
@@ -166,9 +109,6 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
},
"chat +conversation-set-top": {
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
},
}
// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM
@@ -179,7 +119,6 @@ var paramAliasNewIMCases = []struct {
emitted string
canonical string
}{
{command: "chat +chat-messages", emitted: "chat", canonical: "group"},
{command: "chat +bot-find", emitted: "name", canonical: "query"},
{command: "chat bot find", emitted: "name", canonical: "query"},
{command: "chat +bot-search", emitted: "query", canonical: "name"},
@@ -190,7 +129,6 @@ var paramAliasNewIMCases = []struct {
{command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +search-msg", emitted: "chat", canonical: "group"},
{command: "chat +unread-chats", emitted: "limit", canonical: "count"},
{command: "chat +unread-chats", emitted: "size", canonical: "count"},
{command: "chat bot search", emitted: "query", canonical: "name"},
@@ -207,38 +145,6 @@ var paramAliasNewIMCases = []struct {
{command: "chat message send", emitted: "file", canonical: "file-path"},
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +chat-update", emitted: "chat-id", canonical: "group"},
{command: "chat +chat-update", emitted: "conversation-id", canonical: "group"},
{command: "chat +chat-update", emitted: "open-conversation-id", canonical: "group"},
{command: "chat +chat-update", emitted: "title", canonical: "name"},
{command: "chat +chat-update", emitted: "new-title", canonical: "name"},
{command: "chat +flag-list", emitted: "limit", canonical: "page-size"},
{command: "chat +chat-members-list", emitted: "chat-id", canonical: "conversation-id"},
{command: "chat +chat-members-list", emitted: "id", canonical: "conversation-id"},
{command: "chat +conversation-set-top", emitted: "open-conversation-id", canonical: "conversation-id"},
{command: "chat +conversation-set-top", emitted: "chat-ids", canonical: "conversation-ids"},
{command: "chat +chat-members-get", emitted: "conversation-id", canonical: "id"},
{command: "chat +chat-members-get", emitted: "open-dingtalk-ids", canonical: "users"},
{command: "chat +chat-members-get", emitted: "chat", canonical: "id"},
{command: "chat +messages-list", emitted: "start", canonical: "time"},
{command: "chat +messages-reply", emitted: "msg-id", canonical: "ref-msg-id"},
{command: "chat +messages-reply", emitted: "chat", canonical: "conversation-id"},
{command: "chat +flag-cancel", emitted: "group", canonical: "conversation-id"},
{command: "chat +flag-cancel", emitted: "chat", canonical: "conversation-id"},
{command: "chat +flag-create", emitted: "group", canonical: "conversation-id"},
{command: "chat +chat-add-bot", emitted: "conversation-id", canonical: "id"},
{command: "chat +chat-add-bot", emitted: "robot", canonical: "robot-code"},
{command: "chat +chat-audit-join", emitted: "applicant-user-id", canonical: "applicant"},
{command: "chat +chat-mute-member", emitted: "user-ids", canonical: "users"},
{command: "chat +chat-remove-bot", emitted: "open-bot-id", canonical: "bot-id"},
{command: "chat +chat-role-remove-user", emitted: "open-dingtalk-id", canonical: "user"},
{command: "chat +chat-transfer-owner", emitted: "user-id", canonical: "new-owner"},
{command: "chat +feed-group-query-item", emitted: "chat-ids", canonical: "conversation-ids"},
{command: "chat +messages-combine-forward", emitted: "src-open-cid", canonical: "src-conversation-id"},
{command: "chat +messages-forward", emitted: "source-message-id", canonical: "msg-id"},
{command: "chat +messages-forward-topic", emitted: "src-open-message-id", canonical: "src-msg-id"},
{command: "chat +messages-resource-download", emitted: "conversation-id", canonical: "open-conversation-id"},
{command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
@@ -253,36 +159,17 @@ var paramAliasNewIMCases = []struct {
// That duplicated command construction was enough to push the pre-existing
// macOS app suite beyond its package-level 10-minute timeout.
var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias
paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut
paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum
paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow
paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier
paramAliasPayloadCaseKey("doc +create-from-template", "parent-folder-id"): true, // template target folder identifier
paramAliasPayloadCaseKey("doc +export-submit", "doc-id"): true, // Doc node identifier alias
paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact
paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation
paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value
paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut
paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
paramAliasPayloadCaseKey("doc +access-grant", "doc-id"): true, // permission write keeps document identity
paramAliasPayloadCaseKey("doc +version-revert", "version-number"): true, // high-write version role with canonical confirmation
paramAliasPayloadCaseKey("doc block insert", "content"): true, // block write content alias
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -333,7 +220,6 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller)
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
@@ -366,7 +252,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
}
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewIMCases {
test := test
@@ -382,9 +268,8 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
canonicalCaller := &paramAliasCaptureCaller{}
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
@@ -400,17 +285,13 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
activeAliases++
aliasCaller := &paramAliasCaptureCaller{}
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if err != nil {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) {
t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr)
}
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
@@ -421,33 +302,6 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
}
// Resource download deliberately continues from the transport call into a
// local HTTPS download. The generic capture caller returns an empty object, so
// this command's stable post-transport validation error is the expected test
// boundary; canonical and alias calls must still produce the same request and
// the same error.
func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
return command == "chat +messages-resource-download" && err != nil &&
strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
}
// +chat-messages supplies the current wall-clock time when callers omit
// --time. Alias equivalence concerns the resolved target and transport shape;
// a suite crossing a second boundary must not make that default appear
// alias-dependent.
func normalizeParamAliasVolatileDefaults(command string, callers ...*paramAliasCaptureCaller) {
if command != "chat +chat-messages" {
return
}
for _, caller := range callers {
for i := range caller.calls {
if caller.calls[i].tool == "list_conversation_message_v2" || caller.calls[i].tool == "list_individual_chat_message" {
delete(caller.calls[i].args, "time")
}
}
}
}
func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
complete, ok := paramAliasCompleteCommands[command]
if variants := paramAliasCompleteCommandVariants[command]; variants != nil {
+2 -64
View File
@@ -24,7 +24,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
tests := []struct {
name string
args []string
@@ -55,7 +55,7 @@ func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t
}
}
func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
root := NewSchemaSourceRootCommand()
args := []string{
"chat", "message", "send",
@@ -98,65 +98,3 @@ func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *te
t.Fatalf("--debug details missing from early error:\n%s", rendered)
}
}
func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTree(t *testing.T) {
tests := []struct {
name string
args []string
wantReason string
wantCommand string
}{
{
name: "unknown shortcut",
args: []string{"chat", "+chat-mesages", "--keyword", "x", "--format", "json"},
wantReason: "unknown_shortcut",
wantCommand: "dws chat",
},
{
name: "unknown dev app subcommand",
args: []string{"dev", "app", "search", "--keyword", "x", "--format", "json"},
wantReason: "unknown_subcommand",
wantCommand: "dws dev app",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if ctx == nil || ctx.Command != test.wantCommand {
t.Fatalf("Context = %#v, want command %q", ctx, test.wantCommand)
}
err = newPreParseValidationError(err)
var structured *apperrors.Error
if !stderrors.As(err, &structured) {
t.Fatalf("error = %T %v", err, err)
}
if structured.Reason != test.wantReason || structured.ExitCode() != 3 {
t.Fatalf("structured error = %#v", structured)
}
if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") {
t.Fatalf("command error leaked flag classification: %#v", structured)
}
})
}
}
func TestCrossPlatformCoverageResolvedShortcutStillUsesExactLeafFlagError(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"chat", "+chat-messages", "--keyword", "x", "--format", "json"}
if ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args); err != nil {
t.Fatalf("valid shortcut path failed PreParse: %#v, %v", ctx, err)
}
root.SetArgs(args)
err := root.Execute()
var structured *apperrors.Error
if !stderrors.As(err, &structured) {
t.Fatalf("error = %T %v", err, err)
}
if structured.Reason != "unknown_flag" || !strings.Contains(structured.Message, "dws chat +chat-messages --help") {
t.Fatalf("resolved shortcut flag error = %#v", structured)
}
}
+441 -63
View File
@@ -1,118 +1,496 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/spf13/cobra"
)
const recoveryUnsupportedMessage = "dws recovery 不再支持:失败快照恢复计划/执行/闭环已下线,请改用 doctor / schema / 对应业务命令排查。"
var (
recoverySavePlan = (*recovery.Store).SavePlan
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
)
type recoveryCompatNotice struct {
Status string `json:"status"`
Command string `json:"command"`
Message string `json:"message"`
}
func newRecoveryCommand(flags *GlobalFlags) *cobra.Command {
var (
planUseLast bool
planEventID string
executeUseLast bool
executeEventID string
finalEventID string
finalOutcome string
executionFile string
)
runtime := newRecoveryRuntime(flags)
// newRecoveryCommand keeps a visible Deprecated compatibility surface for
// historical argv and Interface Integrity. Behavior is unchanged: every leaf
// returns an explicit unsupported notice. Skills must not teach this path.
func newRecoveryCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "recovery",
Short: "不再支持:错误恢复辅助命令(兼容入口)",
Long: "此命令组仅为历史 argv 兼容保留,不再读取失败快照或生成恢复计划。Skill / Agent 请勿引导此路径。",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Short: "错误恢复辅助命令",
Long: "读取失败快照,生成恢复分析,并回写恢复结果。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printRecoveryUnsupported(cmd, "dws recovery")
return cmd.Help()
},
}
planCmd := &cobra.Command{
Use: "plan",
Short: "不再支持:基于失败快照生成恢复计划",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Short: "基于失败快照生成恢复计划",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printRecoveryUnsupported(cmd, "dws recovery plan")
store := recovery.NewStore(defaultConfigDir())
last, err := loadRecoverySnapshot(store, planUseLast, planEventID)
if err != nil {
return err
}
planner := recovery.NewPlanner(runtime)
plan := planner.PlanWithOptions(cmd.Context(), last.Context, recovery.PlanOptions{
EventID: last.EventID,
EnableDocSearch: true,
})
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
if err := recoverySavePlan(store, last.EventID, plan); err != nil {
return fmt.Errorf("保存恢复计划失败: %w", err)
}
payload := map[string]any{
"event_id": last.EventID,
"context": last.Context,
"plan": plan,
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
},
}
planCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
planCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
planCmd.Flags().BoolVar(&planUseLast, "last", false, "读取最近一次失败快照")
planCmd.Flags().StringVar(&planEventID, "event-id", "", "按 event_id 读取失败快照")
executeCmd := &cobra.Command{
Use: "execute",
Short: "不再支持:生成面向 Agent 的恢复分析包",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Short: "生成面向 Agent 的恢复分析包",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printRecoveryUnsupported(cmd, "dws recovery execute")
store := recovery.NewStore(defaultConfigDir())
last, err := loadRecoverySnapshot(store, executeUseLast, executeEventID)
if err != nil {
return err
}
planner := recovery.NewPlanner(runtime)
executor := recovery.NewExecutor(planner, runtime)
bundle := executor.Execute(cmd.Context(), *last)
if err := recoverySaveAnalysis(store, last.EventID, bundle.Plan, bundle); err != nil {
return fmt.Errorf("保存恢复分析失败: %w", err)
}
return output.WriteCommandPayload(cmd, bundle, output.FormatJSON)
},
}
executeCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
executeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
executeCmd.Flags().BoolVar(&executeUseLast, "last", false, "读取最近一次失败快照")
executeCmd.Flags().StringVar(&executeEventID, "event-id", "", "按 event_id 读取失败快照")
finalizeCmd := &cobra.Command{
Use: "finalize",
Short: "不再支持:回写恢复闭环结果",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Short: "回写恢复闭环结果",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printRecoveryUnsupported(cmd, "dws recovery finalize")
if strings.TrimSpace(finalEventID) == "" {
return fmt.Errorf("必须提供 --event-id")
}
if strings.TrimSpace(finalOutcome) == "" {
return fmt.Errorf("必须提供 --outcome")
}
switch finalOutcome {
case "recovered", "failed", "handoff":
default:
return fmt.Errorf("--outcome 仅支持 recovered|failed|handoff")
}
store := recovery.NewStore(defaultConfigDir())
var execution *recovery.RecoveryExecution
if strings.TrimSpace(executionFile) != "" {
loaded, err := loadRecoveryExecution(executionFile)
if err != nil {
return err
}
execution = &loaded
}
if err := store.Finalize(finalEventID, finalOutcome, execution); err != nil {
return fmt.Errorf("回写恢复结果失败: %w", err)
}
payload := map[string]any{
"event_id": finalEventID,
"outcome": finalOutcome,
"success": true,
}
if execution != nil {
payload["execution_recorded"] = true
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
},
}
finalizeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd.Flags().String("outcome", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd.Flags().String("execution-file", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd.Flags().StringVar(&finalEventID, "event-id", "", "恢复事件 ID")
finalizeCmd.Flags().StringVar(&finalOutcome, "outcome", "", "恢复结果: recovered|failed|handoff")
finalizeCmd.Flags().StringVar(&executionFile, "execution-file", "", "Agent 执行详情 JSON 文件")
cmd.AddCommand(planCmd, executeCmd, finalizeCmd)
return cmd
}
func printRecoveryUnsupported(cmd *cobra.Command, command string) error {
notice := recoveryCompatNotice{
Status: "unsupported",
Command: command,
Message: recoveryUnsupportedMessage,
func loadRecoverySnapshot(store *recovery.Store, useLast bool, eventID string) (*recovery.LastError, error) {
if useLast && strings.TrimSpace(eventID) != "" {
return nil, fmt.Errorf("--last 和 --event-id 不能同时使用")
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
switch strings.ToLower(strings.TrimSpace(format)) {
case "", "json":
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(notice); err != nil {
return err
switch {
case useLast:
last, err := store.LoadLastError()
if err != nil {
return nil, fmt.Errorf("读取失败快照失败: %w", err)
}
return apperrors.NewValidation(recoveryUnsupportedMessage)
case "pretty":
data, _ := json.MarshalIndent(notice, "", " ")
if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
return err
return last, nil
case strings.TrimSpace(eventID) != "":
last, err := store.LoadErrorByEvent(strings.TrimSpace(eventID))
if err != nil {
return nil, fmt.Errorf("读取失败快照失败: %w", err)
}
return apperrors.NewValidation(recoveryUnsupportedMessage)
return last, nil
default:
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", notice.Command, notice.Message); err != nil {
return err
}
return apperrors.NewValidation(recoveryUnsupportedMessage)
return nil, fmt.Errorf("必须通过 --last 或 --event-id 指定失败快照")
}
}
func loadRecoveryExecution(path string) (recovery.RecoveryExecution, error) {
var execution recovery.RecoveryExecution
data, err := os.ReadFile(path)
if err != nil {
return execution, fmt.Errorf("读取恢复执行详情失败: %w", err)
}
var payload recoveryExecutionPayload
if err := json.Unmarshal(data, &payload); err != nil {
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
}
execution.Actions = append([]string(nil), payload.Actions...)
if len(execution.Actions) == 0 && strings.TrimSpace(payload.Action) != "" {
execution.Actions = []string{strings.TrimSpace(payload.Action)}
}
execution.Result = strings.TrimSpace(payload.Result)
execution.ErrorSummary = strings.TrimSpace(payload.ErrorSummary)
if execution.ErrorSummary == "" {
execution.ErrorSummary = strings.TrimSpace(payload.Error)
}
attempts, err := decodeRecoveryAttempts(payload.Attempts, execution.Actions, execution.Result, execution.ErrorSummary)
if err != nil {
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
}
if len(attempts) == 0 && payload.Attempt > 0 {
attempts = legacyRecoveryAttempts(payload.Attempt, execution.Actions, execution.Result, execution.ErrorSummary)
}
execution.Attempts = attempts
return execution, nil
}
type recoveryExecutionPayload struct {
Action string `json:"action,omitempty"`
Actions []string `json:"actions,omitempty"`
Attempt int `json:"attempt,omitempty"`
Attempts json.RawMessage `json:"attempts,omitempty"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ErrorSummary string `json:"error_summary,omitempty"`
}
func decodeRecoveryAttempts(raw json.RawMessage, actions []string, result, errorSummary string) ([]recovery.RecoveryAttempt, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == "null" {
return nil, nil
}
if strings.HasPrefix(trimmed, "[") {
var attempts []recovery.RecoveryAttempt
if err := json.Unmarshal(raw, &attempts); err != nil {
return nil, err
}
return attempts, nil
}
var count int
if err := json.Unmarshal(raw, &count); err != nil {
return nil, err
}
return legacyRecoveryAttempts(count, actions, result, errorSummary), nil
}
func legacyRecoveryAttempts(count int, actions []string, result, errorSummary string) []recovery.RecoveryAttempt {
if count <= 0 {
return nil
}
summary := strings.TrimSpace(strings.Join(actions, ", "))
if summary == "" {
summary = "legacy execution attempt"
}
attempts := make([]recovery.RecoveryAttempt, 0, count)
for i := 0; i < count; i++ {
attempts = append(attempts, recovery.RecoveryAttempt{
CommandSummary: summary,
Result: result,
ErrorSummary: errorSummary,
Source: "legacy_execution_file",
})
}
return attempts
}
type recoveryRuntime struct {
transport *transport.Client
flags *GlobalFlags
}
func newRecoveryRuntime(flags *GlobalFlags) *recoveryRuntime {
var httpClient *http.Client
if flags != nil && flags.Timeout > 0 {
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
}
client := transport.NewClient(httpClient)
client.ExtraHeaders = resolveIdentityHeaders()
return &recoveryRuntime{
transport: client,
flags: flags,
}
}
func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.RecoveryContext) (recovery.KnowledgeRetrieval, error) {
const (
searchPage = 1
searchSize = 5
)
requestArgs := map[string]any{
"keyword": query,
"page": searchPage,
"size": searchSize,
}
retrieval := recovery.KnowledgeRetrieval{
DocSearch: recovery.DocSearch{
Provider: "open_platform_docs",
Query: query,
Page: searchPage,
Size: searchSize,
Status: "empty",
Request: &recovery.ToolCallRecord{
ServerID: "devdoc",
ToolName: "search_open_platform_docs_rag",
Arguments: cloneRecoveryArgs(requestArgs),
},
},
}
if r == nil || strings.TrimSpace(query) == "" {
retrieval.DocSearch.Status = "skipped"
return retrieval, nil
}
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
if result != nil {
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
}
if err != nil {
retrieval.DocSearch.Status = "error"
retrieval.DocSearch.Error = err.Error()
return retrieval, err
}
retrieval.DocSearch.Items = parseDocSearchItems(result)
if len(retrieval.DocSearch.Items) > 0 {
retrieval.DocSearch.Status = "success"
retrieval.KBHits = rerankDocSearchHits(query, rc, retrieval.DocSearch.Items)
}
return retrieval, nil
}
func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error) {
if r == nil || r.transport == nil {
return nil, fmt.Errorf("recovery runtime not initialized")
}
endpoint, err := r.resolveEndpoint(ctx, serverID, toolName)
if err != nil {
return nil, err
}
authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags))
if err != nil {
return nil, tokenResolutionError(err)
}
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
result, err := tc.CallTool(ctx, endpoint, toolName, args)
if err != nil {
return nil, err
}
if result.IsError {
return &result, apperrors.NewAPI(
extractMCPErrorMessage(result),
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(serverID),
)
}
return &result, nil
}
func (r *recoveryRuntime) resolveEndpoint(_ context.Context, productID, toolName string) (string, error) {
if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok {
return endpoint, nil
}
return "", endpointNotResolvedError(productID, toolName, "no dynamic endpoint registered for product or tool")
}
func recoveryRuntimeToken(flags *GlobalFlags) string {
if flags == nil {
return ""
}
return strings.TrimSpace(flags.Token)
}
func toRecoveryToolResponse(result *transport.ToolCallResult) *recovery.ToolResponse {
if result == nil {
return nil
}
response := &recovery.ToolResponse{IsError: result.IsError}
if len(result.Blocks) > 0 {
response.Content = make([]recovery.ToolResponseBlock, 0, len(result.Blocks))
for _, block := range result.Blocks {
response.Content = append(response.Content, recovery.ToolResponseBlock{
Type: block.Type,
Text: block.Text,
})
}
}
return response
}
func parseDocSearchItems(result *transport.ToolCallResult) []recovery.DocSearchItem {
if result == nil {
return nil
}
if items := parseDocSearchItemsFromMap(result.Content); len(items) > 0 {
return items
}
for _, block := range result.Blocks {
var payload map[string]any
if err := json.Unmarshal([]byte(block.Text), &payload); err == nil {
if items := parseDocSearchItemsFromMap(payload); len(items) > 0 {
return items
}
}
}
return nil
}
func parseDocSearchItemsFromMap(payload map[string]any) []recovery.DocSearchItem {
if len(payload) == 0 {
return nil
}
if items := toDocSearchItems(payload["items"]); len(items) > 0 {
return items
}
if data, ok := payload["data"].(map[string]any); ok {
if items := toDocSearchItems(data["items"]); len(items) > 0 {
return items
}
}
if result, ok := payload["result"].(map[string]any); ok {
if items := toDocSearchItems(result["items"]); len(items) > 0 {
return items
}
}
return nil
}
func toDocSearchItems(raw any) []recovery.DocSearchItem {
list, ok := raw.([]any)
if !ok {
return nil
}
items := make([]recovery.DocSearchItem, 0, len(list))
for _, entry := range list {
object, ok := entry.(map[string]any)
if !ok {
continue
}
item := recovery.DocSearchItem{}
if title, ok := object["title"].(string); ok {
item.Title = title
}
if url, ok := object["url"].(string); ok {
item.URL = url
}
if desc, ok := object["desc"].(string); ok {
item.Desc = desc
}
if item.Title != "" || item.URL != "" || item.Desc != "" {
items = append(items, item)
}
}
return items
}
func rerankDocSearchHits(query string, rc recovery.RecoveryContext, items []recovery.DocSearchItem) []recovery.KBHit {
if len(items) == 0 {
return nil
}
keywords := strings.Fields(strings.ToLower(strings.TrimSpace(query)))
type scoredHit struct {
hit recovery.KBHit
score float64
}
scored := make([]scoredHit, 0, len(items))
for _, item := range items {
text := strings.ToLower(strings.Join(append([]string{
item.Title,
item.URL,
item.Desc,
rc.ToolName,
}, rc.CommandPath...), " "))
score := 0.0
for _, keyword := range keywords {
if strings.Contains(text, keyword) {
score += 1
}
}
scored = append(scored, scoredHit{
hit: recovery.KBHit{
Source: "open_platform_docs",
Title: item.Title,
URL: item.URL,
Snippet: item.Desc,
Score: score,
},
score: score,
})
}
sort.SliceStable(scored, func(i, j int) bool {
return scored[i].score > scored[j].score
})
limit := len(scored)
if limit > 3 {
limit = 3
}
hits := make([]recovery.KBHit, 0, limit)
for _, item := range scored[:limit] {
hits = append(hits, item.hit)
}
return hits
}
@@ -1,110 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRecoveryDeprecatedUnsupportedShim(t *testing.T) {
root := NewRootCommand()
group := mustFindCommand(t, root, "recovery")
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
t.Fatalf("recovery group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
}
for _, leaf := range []string{"plan", "execute", "finalize"} {
cmd := mustFindCommand(t, root, "recovery", leaf)
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
t.Fatalf("recovery %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
}
wantFlags := []string{"event-id"}
switch leaf {
case "plan", "execute":
wantFlags = append(wantFlags, "last")
case "finalize":
wantFlags = append(wantFlags, "outcome", "execution-file")
}
for _, flag := range wantFlags {
if cmd.Flags().Lookup(flag) == nil {
t.Fatalf("recovery %s missing --%s", leaf, flag)
}
}
for _, child := range newRecoveryCommand().Commands() {
if child.Name() != leaf {
continue
}
if err := child.RunE(child, nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
t.Fatalf("recovery %s RunE = %v, want 不再支持", leaf, err)
}
}
}
for _, format := range []string{"", "json", "pretty", "table"} {
var out bytes.Buffer
cmd := &cobra.Command{Use: "dws"}
cmd.PersistentFlags().String("format", format, "")
cmd.SetOut(&out)
sub := &cobra.Command{Use: "recovery"}
cmd.AddCommand(sub)
err := printRecoveryUnsupported(sub, "dws recovery plan")
if err == nil {
t.Fatalf("format=%q returned nil error", format)
}
typed, ok := err.(*apperrors.Error)
if !ok || typed.Category != apperrors.CategoryValidation {
t.Fatalf("format=%q error = %T/%v, want validation Error", format, err, err)
}
got := out.String() + err.Error()
if !strings.Contains(got, "不再支持") {
t.Fatalf("format=%q missing 不再支持:\n%s", format, got)
}
if format == "" || format == "json" || format == "pretty" {
if !strings.Contains(got, `"status":"unsupported"`) && !strings.Contains(got, `"status": "unsupported"`) {
t.Fatalf("format=%q missing unsupported JSON status:\n%s", format, got)
}
}
}
for _, format := range []string{"json", "pretty", "table"} {
cmd := &cobra.Command{Use: "dws"}
cmd.PersistentFlags().String("format", format, "")
cmd.SetOut(failWriter{})
sub := &cobra.Command{Use: "recovery"}
cmd.AddCommand(sub)
if err := printRecoveryUnsupported(sub, "dws recovery plan"); err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
}
}
if err := newRecoveryCommand().RunE(newRecoveryCommand(), nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
t.Fatalf("recovery parent RunE = %v, want 不再支持", err)
}
captureRuntimeFailure(executor.Invocation{}, nil, nil)
}
type failWriter struct{}
func (failWriter) Write([]byte) (int, error) {
return 0, errWriteFailed
}
var errWriteFailed = errors.New("write failed")
@@ -0,0 +1,151 @@
package app
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
cmd := newRecoveryCommand(&GlobalFlags{})
out := &strings.Builder{}
cmd.SetOut(out)
cmd.SetErr(io.Discard)
cmd.SetArgs(cmdArgs)
err := cmd.Execute()
return out.String(), err
}
func TestCrossPlatformCoverageRecoveryCommandRemainingCoverage(t *testing.T) {
oldSavePlan, oldSaveAnalysis := recoverySavePlan, recoverySaveAnalysis
t.Cleanup(func() {
recoverySavePlan, recoverySaveAnalysis = oldSavePlan, oldSaveAnalysis
})
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
store := recovery.NewStore(configDir)
last, err := store.Capture(recovery.RecoveryContext{ServerID: "doc", ToolName: "get"})
if err != nil {
t.Fatal(err)
}
recoverySavePlan = func(*recovery.Store, string, recovery.RecoveryPlan) error { return errors.New("save plan") }
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
t.Fatal("injected plan save failure succeeded")
}
recoverySavePlan = oldSavePlan
recoverySaveAnalysis = func(*recovery.Store, string, recovery.RecoveryPlan, recovery.RecoveryBundle) error {
return errors.New("save analysis")
}
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
t.Fatal("injected analysis save failure succeeded")
}
recoverySaveAnalysis = oldSaveAnalysis
parent := newRecoveryCommand(nil)
parent.SetOut(io.Discard)
if err := parent.RunE(parent, nil); err != nil {
t.Fatal(err)
}
if out, err := recoveryCoverageRun("plan", "--last"); err != nil || !strings.Contains(out, last.EventID) {
t.Fatalf("recovery plan = %q, %v", out, err)
}
if out, err := recoveryCoverageRun("execute", "--event-id", last.EventID); err != nil || out == "" {
t.Fatalf("recovery execute = %q, %v", out, err)
}
for _, args := range [][]string{
{"finalize"},
{"finalize", "--event-id", last.EventID},
{"finalize", "--event-id", last.EventID, "--outcome", "unknown"},
{"finalize", "--event-id", last.EventID, "--outcome", "recovered", "--execution-file", "missing"},
} {
if _, err := recoveryCoverageRun(args...); err == nil {
t.Fatalf("recovery finalize %#v should fail", args)
}
}
executionPath := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(executionPath, []byte(`{"action":"retry","attempt":1,"result":"ok"}`), 0o600); err != nil {
t.Fatal(err)
}
if out, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "handoff", "--execution-file", executionPath); err != nil || !strings.Contains(out, "execution_recorded") {
t.Fatalf("recovery finalize = %q, %v", out, err)
}
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "failed"); err != nil {
t.Fatal(err)
}
if _, err := loadRecoverySnapshot(store, true, last.EventID); err == nil {
t.Fatal("conflicting snapshot selectors should fail")
}
if _, err := loadRecoverySnapshot(store, false, "missing"); err == nil {
t.Fatal("missing event snapshot should fail")
}
if _, err := loadRecoverySnapshot(store, false, ""); err == nil {
t.Fatal("empty snapshot selector should fail")
}
missingStore := recovery.NewStore(t.TempDir())
if _, err := loadRecoverySnapshot(missingStore, true, ""); err == nil {
t.Fatal("missing latest snapshot should fail")
}
eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl")
if err := os.Remove(eventsPath); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(eventsPath, 0o700); err != nil {
t.Fatal(err)
}
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
t.Fatal("recovery plan save should fail")
}
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
t.Fatal("recovery analysis save should fail")
}
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "recovered"); err == nil {
t.Fatal("recovery finalization save should fail")
}
}
func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *testing.T) {
t.Setenv("DINGTALK_DEVDOC_MCP_URL", "http://127.0.0.1:1")
path := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(path, []byte(`{"attempts":{}}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadRecoveryExecution(path); err == nil {
t.Fatal("invalid attempts should fail")
}
if _, err := decodeRecoveryAttempts([]byte(`[{}`), nil, "", ""); err == nil {
t.Fatal("invalid attempt array should fail")
}
SetDynamicServers(nil)
runtime := &recoveryRuntime{
transport: transport.NewClient(nil),
flags: &GlobalFlags{Token: "token"},
}
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
t.Fatalf("direct resolution error = %v", err)
}
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
t.Fatalf("search error = %#v, %v", got, err)
}
if got := parseDocSearchItems(&transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "not-json"}}}); got != nil {
t.Fatalf("empty doc search items = %#v", got)
}
for _, payload := range []map[string]any{
{"data": map[string]any{}},
{"result": map[string]any{}},
} {
if got := parseDocSearchItemsFromMap(payload); got != nil {
t.Fatalf("empty nested doc search items = %#v", got)
}
}
}
+88 -4
View File
@@ -1,10 +1,94 @@
package app
import (
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
)
// captureRuntimeFailure previously persisted a recovery snapshot for
// `dws recovery`. The recovery package is gone; keep a no-op seam so runner
// failure paths stay stable while the visible Deprecated shim remains.
func captureRuntimeFailure(_ executor.Invocation, _, _ error) {}
func captureRuntimeFailure(invocation executor.Invocation, rawErr, wrappedErr error) {
if rawErr == nil && wrappedErr == nil {
return
}
store := recovery.NewStore(defaultConfigDir())
if store == nil || !store.Enabled() {
return
}
input := recovery.CaptureInput{
CommandPath: runtimeCommandPath(invocation),
ServerID: strings.TrimSpace(invocation.CanonicalProduct),
ToolName: strings.TrimSpace(invocation.Tool),
Args: cloneRecoveryArgs(invocation.Params),
Argv: append([]string(nil), os.Args[1:]...),
RawErr: rawErr,
WrappedErr: wrappedErr,
}
_, _ = store.Capture(recovery.BuildContext(input), recovery.BuildReplay(input))
}
func runtimeCommandPath(invocation executor.Invocation) []string {
if path := currentCommandPath(); len(path) > 0 {
return path
}
if legacy := strings.Fields(strings.TrimSpace(invocation.LegacyPath)); len(legacy) > 0 {
return legacy
}
if product := strings.TrimSpace(invocation.CanonicalProduct); product != "" {
if tool := strings.TrimSpace(invocation.Tool); tool != "" {
return []string{product, tool}
}
return []string{product}
}
return nil
}
func currentCommandPath() []string {
boolFlags := map[string]struct{}{
"--verbose": {},
"-v": {},
"--debug": {},
"--mock": {},
"--dry-run": {},
"--yes": {},
"-y": {},
"--help": {},
"-h": {},
"--json": {},
}
path := make([]string, 0, len(os.Args))
skipNext := false
for _, arg := range os.Args[1:] {
if skipNext {
skipNext = false
continue
}
if arg == "--" {
break
}
if strings.HasPrefix(arg, "-") {
if strings.Contains(arg, "=") {
continue
}
if _, ok := boolFlags[arg]; ok {
continue
}
skipNext = true
continue
}
path = append(path, arg)
}
return path
}
func cloneRecoveryArgs(args map[string]any) map[string]any {
if len(args) == 0 {
return nil
}
out := make(map[string]any, len(args))
for key, value := range args {
out[key] = value
}
return out
}
+43 -530
View File
@@ -15,32 +15,32 @@ package app
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -51,22 +51,20 @@ import (
type outputFileContextKey struct{}
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
var (
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
rootExecuteCommand = (*cobra.Command).ExecuteC
rootNewRootCommandWithEngine = NewRootCommandWithEngine
rootRunPreParse = pipeline.RunPreParse
rootLatestRecoveryCapture = recovery.LatestCapture
rootResetRecoveryState = recovery.ResetRuntimeState
rootStopAllStdioClients = StopAllStdioClients
rootLoadPlugins = loadPlugins
rootMkdirAll = os.MkdirAll
rootCreateTemp = os.CreateTemp
rootSyncFile = (*os.File).Sync
rootCreateFile = os.Create
rootCloseFile = (*os.File).Close
// os.Rename replaces an existing non-directory target on every supported
// Go host; the Windows implementation uses MOVEFILE_REPLACE_EXISTING. Keep
// the temporary file beside the target so publication stays on one volume.
rootRenameFile = os.Rename
rootRemoveFile = os.Remove
rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv
rootPluginLoadUser = (*plugin.Loader).LoadUser
rootPluginLoadDev = (*plugin.Loader).LoadDev
@@ -83,53 +81,10 @@ var (
// Execute runs the root command and returns the process exit code.
func Execute() (exitCode int) {
var (
root *cobra.Command
executed *cobra.Command
resultStore *output.ResultStore
)
defer func() {
if r := recover(); r != nil {
target := executed
if target == nil && root != nil {
if found, _, err := root.Find(os.Args[1:]); err == nil {
target = found
}
}
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
exitCode = code
if target != nil {
fmt.Fprintf(target.ErrOrStderr(), "Warning: command panicked after result emission attempt: %v\n", r)
}
} else if target != nil && output.UsesUnifiedResult(target) {
info := &output.ErrorInfo{Type: "internal", ExitCode: 5, Message: fmt.Sprintf("internal panic: %v", r)}
if code, err := output.EmitResult(target, output.Failure(info)); err == nil {
exitCode = code
} else {
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
exitCode = 5
}
} else {
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
exitCode = 5
}
if executed == nil {
executed = target
}
}
CloseFileLogger()
if executed != nil {
if err := closeOutputSink(executed); err != nil {
if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil {
exitCode = code
} else {
exitCode = apperrors.ExitCode(err)
fmt.Fprintf(os.Stderr, "Warning: close output sink: %v\n", err)
if emitErr != nil {
fmt.Fprintf(os.Stderr, "Warning: emit output publication failure: %v\n", emitErr)
}
}
}
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
exitCode = 5
}
}()
@@ -145,17 +100,16 @@ func Execute() (exitCode int) {
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
}()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Attach timing collector to context for use by child components
ctx := WithTimingCollector(context.Background(), timing)
ctx, resultStore = output.WithResultStore(ctx)
var signalState *processSignalState
var stopSignals func()
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
defer stopSignals()
ctx = WithTimingCollector(ctx, timing)
initStart := time.Now()
rootResetRecoveryState()
engine := newPipelineEngine()
root = rootNewRootCommandWithEngine(ctx, engine)
root := rootNewRootCommandWithEngine(ctx, engine)
timing.Record("cmd_init", time.Since(initStart))
// Run PreParse handlers on raw argv before Cobra parses flags.
@@ -163,218 +117,33 @@ func Execute() (exitCode int) {
// and --limit100 → --limit 100.
if err := rootRunPreParse(root, engine); err != nil {
err = newPreParseValidationError(err)
if interrupted, _ := signalState.outcome(); interrupted != nil {
err = interrupted
}
if target, _, findErr := root.Find(os.Args[1:]); findErr == nil && target != nil && output.UsesUnifiedResult(target) {
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
code, emitErr := output.EmitResult(target, result)
if emitErr == nil {
return code
}
}
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
return apperrors.ExitCode(err)
}
var err error
executed, err = rootExecuteCommand(root)
// PersistentPostRunE normally commits or aborts the transactional output
// sink. Finalize once more at the process boundary so custom execution
// seams, embedding callers, or future hook changes cannot leave publication
// errors to a defer that runs after the process exit code is fixed.
if executed != nil {
if err == nil {
if closeErr := closeOutputSink(executed); closeErr != nil {
err = closeErr
}
} else if abortErr := abortOutputSink(executed); abortErr != nil {
fmt.Fprintf(executed.ErrOrStderr(), "Warning: abort output sink after command failure: %v\n", abortErr)
}
}
interrupted, primaryCompletedBeforeSignal := signalState.outcome()
if interrupted != nil && !primaryCompletedBeforeSignal {
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
var publicationErr *outputPublicationError
if err != nil && stderrors.As(err, &publicationErr) {
// The successful result was written only to a transaction that did
// not publish. Let the error path replace it with one observable
// failure envelope on the restored original stream.
} else {
if executed == nil {
executed = root
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: process interrupted after result emission attempt: %v\n", interrupted)
// Once publication starts, its stored exit code is authoritative. A
// signal recorded just before or during publication must not turn a
// successfully emitted result into a contradictory 130/143 process
// status; likewise, a failed publication must retain its internal
// error code instead of being relabelled as cancellation.
return code
}
}
var publicationErr *outputPublicationError
if err == nil || !stderrors.As(err, &publicationErr) {
err = interrupted
}
}
executed, err := rootExecuteCommand(root)
if err != nil {
if executed == nil {
executed = root
}
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
var publicationErr *outputPublicationError
if stderrors.As(err, &publicationErr) {
if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled {
if emitErr == nil {
return failureCode
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
}
return apperrors.ExitCode(publicationErr)
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
return code
}
err = rewordRequiredFlagError(err)
var raw apperrors.RawStderrError
if output.UsesUnifiedResult(executed) && !stderrors.As(err, &raw) {
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
code, emitErr := output.EmitResult(executed, result)
if emitErr == nil {
return code
}
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
}
if isUnknownCommandError(err) {
executed.SetOut(os.Stderr)
_ = executed.Help()
_, _ = fmt.Fprintln(os.Stderr)
}
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
if last := rootLatestRecoveryCapture(); last != nil && last.EventID != "" {
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
}
return apperrors.ExitCode(err)
}
if code, emitted := output.StoredExitCode(resultStore); emitted {
return code
}
return 0
}
// errorInfoFromExecutionError projects the repository error model into the unified
// failure body. Exit code and category are derived from the same error value,
// preventing the wire and process status from drifting apart.
func errorInfoFromExecutionError(err error) *output.ErrorInfo {
exitCode := apperrors.ExitCode(err)
info := &output.ErrorInfo{
Type: errorTypeForExitCode(exitCode),
ExitCode: exitCode,
Message: err.Error(),
}
var interrupted *processInterruption
if stderrors.As(err, &interrupted) && interrupted != nil {
info.Type = "internal"
info.Subtype = interrupted.Subtype()
return info
}
if stderrors.Is(err, context.DeadlineExceeded) {
info.Subtype = "deadline_exceeded"
}
var cliErr *helpers.CLIError
if stderrors.As(err, &cliErr) && cliErr != nil {
info.UpstreamCode = cliErr.Code
info.Hint = cliErr.Suggestion
info.Operation = cliErr.Operation
}
var callErr *transport.CallError
if stderrors.As(err, &callErr) && callErr != nil {
info.HTTPStatus = callErr.HTTPStatus
info.RPCCode = callErr.RPCCode
info.Stage = string(callErr.Stage)
if callErr.RequestID != "" {
info.RequestID = callErr.RequestID
} else if callErr.TraceID != "" {
info.RequestID = callErr.TraceID
}
}
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed == nil {
return info
}
if typed.Category == apperrors.CategoryPartial {
// An error lacks the item-level data required by partial_failure.
// Callers must use output.Partial; fail closed consistently otherwise.
info.Type = string(apperrors.CategoryInternal)
} else {
info.Type = string(typed.Category)
}
info.Subtype = typed.Reason
if typed.Hint != "" {
info.Hint = typed.Hint
}
info.Actions = append([]string(nil), typed.Actions...)
info.Retryable = typed.RetryableSet && typed.Retryable
info.RetryAfterSeconds = typed.RetryAfterSeconds
if typed.RPCCode != 0 {
info.RPCCode = typed.RPCCode
}
if typed.ServerDiag.TraceID != "" {
info.TraceID = typed.ServerDiag.TraceID
}
if typed.Operation != "" {
info.Operation = typed.Operation
}
info.ServerKey = typed.ServerKey
info.Origin = typed.Origin
if typed.FailureStage != "" {
info.Stage = typed.FailureStage
}
info.ExecutionStarted = typed.ExecutionStarted
if typed.NextRetryAt != nil {
info.NextRetryAt = typed.NextRetryAt.UTC().Format(time.RFC3339)
}
info.AvailableFlags = append([]string(nil), typed.AvailableFlags...)
info.SnapshotPath = typed.Snapshot
info.Details = typed.Details
if len(typed.RPCData) > 0 {
var rpcData any
if json.Unmarshal(typed.RPCData, &rpcData) == nil {
info.RPCData = rpcData
}
}
info.TechnicalDetail = typed.ServerDiag.TechnicalDetail
info.FriendlyHint, info.ActionURL = apperrors.ServerGuidance(typed.ServerDiag)
if typed.Cause != nil {
info.Cause = typed.Cause.Error()
}
if typed.ServerDiag.ServerErrorCode != "" {
info.UpstreamCode = typed.ServerDiag.ServerErrorCode
}
return info
}
func errorTypeForExitCode(code int) string {
switch code {
case 1:
return "api"
case 2:
return "auth"
case 3:
return "validation"
case 4:
return "permission"
case 6:
return "discovery"
default:
return "internal"
}
}
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
if structured, ok := err.(*apperrors.Error); ok {
return structured
}
userErr := err
var handlerErr *pipeline.HandlerError
if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil {
@@ -605,22 +374,19 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
rootCtx, _ = output.WithResultStore(rootCtx)
return newRootCommandWithEngine(rootCtx, nil, true, false)
return newRootCommandWithEngine(rootCtx, nil, true)
}
// NewSchemaSourceRootCommand constructs the distribution-owned command tree
// used as the Schema assembly source root (RegisterSchemaSourceRoot →
// ResolveSchemaBuild) and by command-surface policy. Installed plugins and
// user-defined shortcuts must not change the reviewed Schema surface.
// declarationOnly skips injectStaticServers / helpers.InitDeps so Schema
// assembly cannot clobber a live process's ToolCaller or plugin endpoints.
func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
var rootCtx context.Context
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return newRootCommandWithEngine(rootCtx, nil, false, true)
return newRootCommandWithEngine(rootCtx, nil, false)
}
// NewRootCommandWithEngine constructs the root CLI command with an
@@ -628,11 +394,10 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
// no pipeline processing is applied.
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
registerSchemaRuntimeDelivery()
rootCtx, _ = output.WithResultStore(rootCtx)
return newRootCommandWithEngine(rootCtx, engine, true, false)
return newRootCommandWithEngine(rootCtx, engine, true)
}
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool) *cobra.Command {
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool) *cobra.Command {
if rootCtx == nil {
rootCtx = context.Background()
}
@@ -653,25 +418,6 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// A public root may be reused by embedding callers through multiple
// ExecuteC invocations. Begin each invocation with an empty result
// lifecycle while retaining the store pointer observed by Execute's
// signal and exit-code handling. Declaration-only command trees do not
// install a store at construction time, so add one lazily when those
// trees are executed for compatibility and policy tests.
executionCtx, _ := output.WithResultStore(cmd.Context())
cmd.SetContext(executionCtx)
// WithResultStore above guarantees the reset precondition.
_ = output.ResetResultStore(executionCtx)
// Do not run Cobra's ValidateRequiredFlags/ValidateFlagGroups here:
// Cobra executes them between the leaf's PreRunE and RunE, and leaves
// rely on that order to normalize alias flags into required canonical
// flags (for example chat message download-media copies --msg-id into
// the required --message-id in PreRunE). Running them early fails the
// alias path before the leaf can normalize it. The transactional
// --output sink instead opens at Run entry (after Cobra's own
// validation), so validation failures still cannot strand a
// temporary file.
// Validate caller-provided identity labels before any edition hook
// or command network activity can run. Header-only library callers
// use the best-effort path in resolveIdentityHeaders instead.
@@ -694,37 +440,19 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
// Configure global slog level based on --debug / --verbose flags.
configureLogLevel(flags)
installOutputSinkRunBoundary(cmd)
if err := configureOutputSink(cmd); err != nil {
return err
}
if fn := edition.Get().AfterPersistentPreRun; fn != nil {
if err := fn(cmd, args); err != nil {
return err
}
return fn(cmd, args)
}
return nil
},
PersistentPostRunE: func(cmd *cobra.Command, args []string) (err error) {
defer func() {
if r := recover(); r != nil {
warnAbortOutputSink(cmd)
panic(r)
}
if err != nil {
warnAbortOutputSink(cmd)
}
}()
_, emitted, emitErr := output.EmitStoredResult(cmd)
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
StopAllStdioClients()
CloseAuditSink()
if emitErr != nil {
return apperrors.NewInternal("emit command result: "+emitErr.Error(), apperrors.WithCause(emitErr))
}
if output.UsesUnifiedResult(cmd) && !emitted {
return apperrors.NewInternal("framework 2.0 command returned without a CommandResult")
}
if closeErr := closeOutputSink(cmd); closeErr != nil {
return closeErr
}
return nil
CloseFileLogger()
return closeOutputSink(cmd)
},
}
@@ -747,10 +475,10 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
newCatalogCommand(),
newConfigCommand(),
newDoctorCommand(),
newRecoveryCommand(),
newEventCommand(flags),
newEventCommand(),
newAuditCommand(),
newCompletionCommand(root),
newRecoveryCommand(flags),
newUpgradeCommand(),
newVersionCommand(),
newPluginCommand(),
@@ -760,14 +488,7 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
}
root.AddCommand(utilityCommands...)
if declarationOnly {
// Schema / surface assembly: mount the reviewed tree only. Do not
// injectStaticServers or InitDeps — those mutate process globals and
// would clobber a live runtime's caller and plugin endpoints.
root.AddCommand(mountLegacyPublicCommands(runner, loadRuntimeExtensions)...)
} else {
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
}
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
// PAT authorization commands (open-source core)
pat.RegisterCommands(root, patCaller)
@@ -994,8 +715,8 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
var builtinCommandNames = map[string]bool{
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
"doctor": true, "event": true, "completion": true, "skill": true,
"plugin": true, "profile": true, "recovery": true, "version": true, "help": true,
"schema": true, "mcp": true, "upgrade": true,
"plugin": true, "profile": true, "version": true, "help": true,
"recovery": true, "schema": true, "mcp": true, "upgrade": true,
}
// commandNameSet returns a new set containing every name in base plus extras.
@@ -1124,54 +845,6 @@ func deduplicateCommands(root *cobra.Command) {
}
}
type outputSinkState struct {
mu sync.Mutex
file *os.File
original io.Writer
tempPath string
target string
finished bool
}
type outputPublicationError struct {
cause error
}
func (e *outputPublicationError) Error() string { return e.cause.Error() }
func (e *outputPublicationError) Unwrap() error { return e.cause }
func (e *outputPublicationError) ExitCode() int { return 5 }
func newOutputPublicationError(message string, cause error) error {
return &outputPublicationError{cause: fmt.Errorf("%s: %w", message, cause)}
}
// emitOutputPublicationFailure replaces a result that was rendered only into a
// rolled-back transactional file with one observable failure envelope on the
// original output stream. This is not a second public result: closeOutputSink
// has removed the temporary file and restored cmd.OutOrStdout before returning
// the publication error.
func emitOutputPublicationFailure(cmd *cobra.Command, err error) (code int, handled bool, emitErr error) {
var publicationErr *outputPublicationError
if cmd == nil || !stderrors.As(err, &publicationErr) || !output.UsesUnifiedResult(cmd) {
return 0, false, nil
}
state := outputSinkForCommand(cmd)
if state == nil {
return 0, false, nil
}
state.mu.Lock()
original := state.original
finished := state.finished
state.mu.Unlock()
if original == nil || !finished {
return 0, false, nil
}
cmd.SetOut(original)
result := output.FailureWithExitCode(errorInfoFromExecutionError(publicationErr), apperrors.ExitCode(publicationErr))
code, emitErr = output.EmitResult(cmd, result)
return code, true, emitErr
}
func configureOutputSink(cmd *cobra.Command) error {
if local := cmd.LocalFlags().Lookup("output"); local != nil {
return nil
@@ -1184,180 +857,32 @@ func configureOutputSink(cmd *cobra.Command) error {
if outputPath == "" {
return nil
}
// A public root may be reused across ExecuteC calls, accumulating one Run
// wrapper per execution. When the sink for this invocation is already open,
// an inner wrapper must not replace it with a second temporary file.
if state := outputSinkForCommand(cmd); state != nil {
state.mu.Lock()
finished := state.finished
state.mu.Unlock()
if !finished {
return nil
}
}
if err := validateOptionalPath("--output", outputPath); err != nil {
return err
}
if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
}
tempPattern := "." + filepath.Base(outputPath) + ".tmp-*"
file, err := rootCreateTemp(filepath.Dir(outputPath), tempPattern)
file, err := rootCreateFile(outputPath)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create temporary output file: %v", err))
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err))
}
originalOut := cmd.OutOrStdout()
cmd.SetOut(file)
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
file: file,
original: originalOut,
tempPath: file.Name(),
target: outputPath,
}))
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, file))
return nil
}
// installOutputSinkRunBoundary defers opening the transactional --output sink
// to the executed command's Run entry. Cobra runs ValidateRequiredFlags and
// ValidateFlagGroups after the leaf's PreRunE and immediately before RunE, so
// opening the sink there keeps two invariants at once: leaf PreRunE hooks can
// still normalize alias flags into required canonical flags, and a validation
// failure can never strand a temporary output file. Run-only leaves are
// converted to RunE so a sink setup failure remains a returned error. Post-run
// hooks keep the error cleanup wrapping so a post-run failure still aborts the
// transaction; pre-run hooks need no wrapping because the sink cannot exist
// before Run entry.
func installOutputSinkRunBoundary(cmd *cobra.Command) {
if cmd == nil {
return
}
openSinkAndRun := func(run func(*cobra.Command, []string) error) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
if err := configureOutputSink(cmd); err != nil {
return err
}
return runWithOutputSinkErrorCleanup(cmd, func() error { return run(cmd, args) })
}
}
if cmd.RunE != nil {
cmd.RunE = openSinkAndRun(cmd.RunE)
} else if cmd.Run != nil {
original := cmd.Run
cmd.Run = nil
cmd.RunE = openSinkAndRun(func(cmd *cobra.Command, args []string) error {
original(cmd, args)
return nil
})
}
if cmd.PostRunE != nil {
original := cmd.PostRunE
cmd.PostRunE = func(cmd *cobra.Command, args []string) error {
return runWithOutputSinkErrorCleanup(cmd, func() error { return original(cmd, args) })
}
}
if cmd.PostRun != nil {
original := cmd.PostRun
cmd.PostRun = func(cmd *cobra.Command, args []string) {
_ = runWithOutputSinkErrorCleanup(cmd, func() error {
original(cmd, args)
return nil
})
}
}
}
func runWithOutputSinkErrorCleanup(cmd *cobra.Command, run func() error) (err error) {
defer func() {
if r := recover(); r != nil {
warnAbortOutputSink(cmd)
panic(r)
}
if err != nil {
warnAbortOutputSink(cmd)
}
}()
return run()
}
func warnAbortOutputSink(cmd *cobra.Command) {
if closeErr := abortOutputSink(cmd); closeErr != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: close output sink: %v\n", closeErr)
}
}
func closeOutputSink(cmd *cobra.Command) error {
state := outputSinkForCommand(cmd)
if state == nil {
file, ok := cmd.Context().Value(outputFileContextKey{}).(*os.File)
if !ok || file == nil {
return nil
}
state.mu.Lock()
defer state.mu.Unlock()
// A reusable Cobra tree must never retain the transactional file as its
// stdout after this execution. Restore the caller's writer on every terminal
// path, including sync/close/rename failures and repeated cleanup calls.
if state.original != nil {
cmd.SetOut(state.original)
}
if state.finished {
return nil
}
state.finished = true
if err := rootSyncFile(state.file); err != nil {
_ = rootCloseFile(state.file)
_ = rootRemoveFile(state.tempPath)
return newOutputPublicationError("failed to sync output file", err)
}
if err := rootCloseFile(state.file); err != nil {
_ = rootRemoveFile(state.tempPath)
return newOutputPublicationError("failed to close output file", err)
}
if err := rootRenameFile(state.tempPath, state.target); err != nil {
_ = rootRemoveFile(state.tempPath)
return newOutputPublicationError("failed to publish output file", err)
if err := rootCloseFile(file); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err))
}
return nil
}
func abortOutputSink(cmd *cobra.Command) error {
state := outputSinkForCommand(cmd)
if state == nil {
return nil
}
state.mu.Lock()
defer state.mu.Unlock()
if state.finished {
return nil
}
state.finished = true
// A business error still needs the root execution boundary to publish one
// typed failure envelope. Restore the pre-transaction writer before closing
// and unlinking the temporary file so that failure emission cannot target a
// closed descriptor. The final --output target remains untouched.
if state.original != nil {
cmd.SetOut(state.original)
}
closeErr := rootCloseFile(state.file)
removeErr := rootRemoveFile(state.tempPath)
if closeErr != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", closeErr))
}
if removeErr != nil && !stderrors.Is(removeErr, os.ErrNotExist) {
return apperrors.NewInternal(fmt.Sprintf("failed to remove temporary output file: %v", removeErr))
}
return nil
}
func outputSinkForCommand(cmd *cobra.Command) *outputSinkState {
if cmd == nil || cmd.Context() == nil {
return nil
}
state, _ := cmd.Context().Value(outputFileContextKey{}).(*outputSinkState)
if state == nil || state.file == nil {
return nil
}
return state
}
func validateOptionalPath(flagName, path string) error {
path = strings.TrimSpace(path)
if path == "" {
@@ -1799,18 +1324,6 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
// - PostResponse: after transport returns, before stdout (canonical RunE)
func newPipelineEngine() *pipeline.Engine {
engine := pipeline.NewEngine()
engine.SetCommandPathFallbackLookup(func(path string) (pipeline.CommandPathFallback, bool) {
entry, ok := cli.LookupCommandPathFallback(path)
if !ok {
return pipeline.CommandPathFallback{}, false
}
return pipeline.CommandPathFallback{
From: entry.From,
Mode: string(entry.Mode),
To: entry.To,
Candidates: append([]string(nil), entry.Candidates...),
}, true
})
engine.RegisterAll(
// Register handler runs during command tree building.
handlers.RegisterHandler{},
+14 -10
View File
@@ -12,6 +12,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -23,6 +24,8 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldLatest := rootLatestRecoveryCapture
oldReset := rootResetRecoveryState
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
@@ -30,16 +33,20 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootLatestRecoveryCapture = oldLatest
rootResetRecoveryState = oldReset
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootResetRecoveryState = func() {}
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
rootLatestRecoveryCapture = func() *recovery.LastError { return nil }
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
if code := Execute(); code != 0 {
t.Fatalf("successful Execute code = %d", code)
@@ -52,6 +59,7 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
wantErr := errors.New("unknown command missing")
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
if code := Execute(); code == 0 {
t.Fatal("failed Execute returned zero")
@@ -157,11 +165,11 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
})
oldMkdir := rootMkdirAll
oldCreate := rootCreateTemp
oldCreate := rootCreateFile
oldClose := rootCloseFile
t.Cleanup(func() {
rootMkdirAll = oldMkdir
rootCreateTemp = oldCreate
rootCreateFile = oldCreate
rootCloseFile = oldClose
})
wantErr := errors.New("filesystem")
@@ -193,19 +201,17 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
t.Fatal("mkdir failure succeeded")
}
rootMkdirAll = func(string, os.FileMode) error { return nil }
rootCreateTemp = func(string, string) (*os.File, error) { return nil, wantErr }
rootCreateFile = func(string) (*os.File, error) { return nil, wantErr }
if err := configureOutputSink(newOutputCommand(filepath.Join("create-failure", "out"))); err == nil {
t.Fatal("create failure succeeded")
}
rootCreateTemp = oldCreate
rootCreateFile = oldCreate
file, err := os.CreateTemp(t.TempDir(), "close")
if err != nil {
t.Fatal(err)
}
cmd := &cobra.Command{Use: "close"}
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-target"),
}))
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
rootCloseFile = func(*os.File) error { return wantErr }
if err := closeOutputSink(cmd); err == nil {
t.Fatal("close failure succeeded")
@@ -218,9 +224,7 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
if err != nil {
t.Fatal(err)
}
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-success-target"),
}))
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
if err := closeOutputSink(cmd); err != nil {
t.Fatalf("close success = %v", err)
}
+3 -17
View File
@@ -163,16 +163,9 @@ func commandShort(cmd *cobra.Command) string {
// resolveVisibleProducts returns the set of top-level product IDs that should
// be treated as visible. It unions the edition's VisibleProducts hook (when
// set), StaticServers product IDs, and DirectRuntimeProductIDs(), so
// dynamically-registered products — including plugins loaded via
// AppendDynamicServer — are never silently hidden by a static VisibleProducts
// list.
//
// StaticServers are consulted directly (without injectStaticServers) so the
// declaration-only Schema source root can keep reviewed products visible
// without mutating the process-global dynamic endpoint registry.
// SupplementServers stay out of this set: they are helper-only endpoints and
// must not synthesize top-level product visibility.
// set) with DirectRuntimeProductIDs(), so dynamically-registered products —
// including plugins loaded via AppendDynamicServer — are never silently hidden
// by a static VisibleProducts list.
func resolveVisibleProducts() map[string]bool {
allowed := map[string]bool{}
if fn := edition.Get().VisibleProducts; fn != nil {
@@ -180,13 +173,6 @@ func resolveVisibleProducts() map[string]bool {
allowed[p] = true
}
}
if fn := edition.Get().StaticServers; fn != nil {
for _, server := range fn() {
if id := strings.TrimSpace(server.ID); id != "" {
allowed[id] = true
}
}
}
for id := range DirectRuntimeProductIDs() {
allowed[id] = true
}
+13 -40
View File
@@ -72,39 +72,6 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
for _, path := range []string{
"chat send",
"chat history",
"im send",
"im history",
} {
command, remaining, err := root.Find(strings.Fields(path))
if err != nil {
t.Fatalf("find %s: %v", path, err)
}
if len(remaining) != 0 || !command.Hidden || !command.Runnable() {
t.Fatalf("%s compatibility contract: remaining=%v hidden=%v runnable=%v", path, remaining, command.Hidden, command.Runnable())
}
}
for _, tc := range []struct {
args []string
hint string
}{
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
command := NewRootCommand()
command.SilenceErrors = true
command.SilenceUsage = true
command.SetArgs(tc.args)
err := command.Execute()
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
}
}
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
if listDirect.Flags().Lookup(flag) == nil {
@@ -419,14 +386,20 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
}
}
func TestCacheCommandDeprecatedCompatStub(t *testing.T) {
root := NewRootCommand()
cmd, _, err := root.Find([]string{"cache", "refresh"})
if err != nil || cmd == nil || cmd == root {
t.Fatalf("dws cache refresh compatibility stub missing: %v", err)
func TestCacheRefreshCompatibilityStub(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
}
if cmd.Hidden || cmd.Deprecated == "" {
t.Fatalf("cache refresh must be visible Deprecated: hidden=%v deprecated=%q", cmd.Hidden, cmd.Deprecated)
got := out.String()
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "服务发现已下线"} {
if !strings.Contains(got, want) {
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
}
}
}
@@ -1,240 +0,0 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/spf13/cobra"
)
func TestPublicRootDirectExecuteResetsUnifiedResultLifecycle(t *testing.T) {
root := NewRootCommand(context.Background())
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&bytes.Buffer{})
run := 0
leaf := &cobra.Command{
Use: "lifecycle-repeat",
RunE: func(cmd *cobra.Command, _ []string) error {
run++
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"run": run}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
for want := 1; want <= 2; want++ {
stdout.Reset()
root.SetArgs([]string{"lifecycle-repeat", "--format", "json"})
executed, err := root.ExecuteC()
if err != nil {
t.Fatalf("ExecuteC run %d: %v", want, err)
}
if executed != leaf {
t.Fatalf("ExecuteC run %d executed %v, want lifecycle leaf", want, executed)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
Run int `json:"run"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("ExecuteC run %d output %q: %v", want, stdout.String(), err)
}
if !envelope.OK || envelope.Data.Run != want {
t.Fatalf("ExecuteC run %d envelope=%+v", want, envelope)
}
}
missing := &cobra.Command{Use: "lifecycle-missing", RunE: func(*cobra.Command, []string) error { return nil }}
output.SetCommandRollout(missing, output.RolloutUnifiedActive)
root.AddCommand(missing)
stdout.Reset()
root.SetArgs([]string{"lifecycle-missing", "--format", "json"})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "without a CommandResult") {
t.Fatalf("missing-result ExecuteC error=%v, want fresh lifecycle failure", err)
}
if stdout.Len() != 0 {
t.Fatalf("missing-result ExecuteC replayed stale output %q", stdout.String())
}
}
func TestPublicRootRestoresStdoutAfterSuccessfulOutputPublication(t *testing.T) {
root := NewRootCommand(context.Background())
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&bytes.Buffer{})
run := 0
leaf := &cobra.Command{
Use: "lifecycle-output-repeat",
RunE: func(cmd *cobra.Command, _ []string) error {
run++
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"run": run}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
target := filepath.Join(t.TempDir(), "result.json")
root.SetArgs([]string{"lifecycle-output-repeat", "--output", target, "--format", "json"})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("first ExecuteC: %v", err)
}
first, err := os.ReadFile(target)
if err != nil || !bytes.Contains(first, []byte(`"run": 1`)) {
t.Fatalf("published output=%q err=%v", first, err)
}
if err := root.PersistentFlags().Set("output", ""); err != nil {
t.Fatal(err)
}
stdout.Reset()
root.SetArgs([]string{"lifecycle-output-repeat", "--format", "json"})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("second ExecuteC: %v", err)
}
if !strings.Contains(stdout.String(), `"run": 2`) {
t.Fatalf("second stdout=%q", stdout.String())
}
}
func TestPublicRootDirectExecuteFailsWhenUnifiedSinkCannotPublish(t *testing.T) {
oldClose := rootCloseFile
t.Cleanup(func() { rootCloseFile = oldClose })
closeCalls := 0
rootCloseFile = func(file *os.File) error {
closeCalls++
if err := file.Close(); err != nil {
return err
}
return errors.New("late close diagnostic")
}
root := NewRootCommand(context.Background())
leaf := &cobra.Command{
Use: "lifecycle-unified",
RunE: func(cmd *cobra.Command, _ []string) error {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"lifecycle-unified", "--output", filepath.Join(t.TempDir(), "result.json")})
executed, err := root.ExecuteC()
if err == nil || apperrors.ExitCode(err) != 5 {
t.Fatalf("direct ExecuteC error=%v, want publication failure with exit 5", err)
}
if executed != leaf {
t.Fatalf("executed=%v, want lifecycle leaf", executed)
}
if closeCalls != 1 {
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
}
}
func TestPublicRootDirectExecutePreservesLegacyCloseError(t *testing.T) {
oldClose := rootCloseFile
t.Cleanup(func() { rootCloseFile = oldClose })
rootCloseFile = func(file *os.File) error {
_ = file.Close()
return errors.New("legacy close failed")
}
root := NewRootCommandWithEngine(context.Background(), nil)
root.AddCommand(&cobra.Command{Use: "lifecycle-legacy", RunE: func(*cobra.Command, []string) error { return nil }})
root.SetArgs([]string{"lifecycle-legacy", "--output", filepath.Join(t.TempDir(), "result.txt")})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "legacy close failed") {
t.Fatalf("legacy direct ExecuteC error=%v, want close failure", err)
}
}
func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
oldClose := rootCloseFile
t.Cleanup(func() { rootCloseFile = oldClose })
closeCalls := 0
rootCloseFile = func(file *os.File) error {
closeCalls++
return file.Close()
}
root := NewRootCommand(context.Background())
root.AddCommand(&cobra.Command{Use: "lifecycle-error", RunE: func(*cobra.Command, []string) error {
return errors.New("handler failed")
}})
root.SetArgs([]string{"lifecycle-error", "--output", filepath.Join(t.TempDir(), "result.txt")})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "handler failed") {
t.Fatalf("direct ExecuteC error=%v, want handler failure", err)
}
if closeCalls != 1 {
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
}
}
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootStopAllStdioClients = func() {}
var stdout, stderr bytes.Buffer
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetContext(ctx)
return cmd
}
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) {
result := output.Failure(&output.ErrorInfo{Type: "validation", Message: "bad input"})
if err := output.StoreResult(cmd.Context(), result); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
panic("after emission")
}
if code := Execute(); code != 3 {
t.Fatalf("Execute code=%d, want emitted validation code 3", code)
}
if got := strings.Count(stdout.String(), `"outcome": "failure"`); got != 1 {
t.Fatalf("stdout contains %d envelopes, want one: %s", got, stdout.String())
}
if !strings.Contains(stderr.String(), "panicked after result emission attempt") {
t.Fatalf("panic diagnostic missing: %q", stderr.String())
}
}
func TestErrorInfoProjectionKeepsTraceIDDistinctFromRequestID(t *testing.T) {
err := apperrors.NewAPI("failed", apperrors.WithTraceID("trace-1"))
info := errorInfoFromExecutionError(err)
if info.TraceID != "trace-1" || info.RequestID != "" {
t.Fatalf("projection trace_id=%q request_id=%q", info.TraceID, info.RequestID)
}
}
-377
View File
@@ -1,377 +0,0 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestOutputSinkAtomicallyReplacesExistingTargetWithMode0600(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
var tempMode os.FileMode
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
info, err := cmd.OutOrStdout().(*os.File).Stat()
if err != nil {
return err
}
tempMode = info.Mode().Perm()
_, err = fmt.Fprint(cmd.OutOrStdout(), "replacement")
return err
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("ExecuteC: %v", err)
}
assertOutputFile(t, target, "replacement", 0o600)
if tempMode != 0o600 {
t.Fatalf("temporary output mode=%#o, want 0600", tempMode)
}
assertNoOutputTemps(t, target)
}
func TestOutputSinkHandlerFailurePreservesTarget(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
return errors.New("handler failed")
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded")
}
assertOutputFile(t, target, "original", 0o640)
assertNoOutputTemps(t, target)
}
func TestExecuteUnifiedRunEFailureWithOutputRestoresStdoutAndPreservesTarget(t *testing.T) {
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "atomic-output-unified-failure", "--output", filepath.Join(t.TempDir(), "result.json"), "--format", "json"}
target := os.Args[3]
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
var stdout, stderr bytes.Buffer
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
root := NewRootCommandWithEngine(ctx, engine)
root.SetOut(&stdout)
root.SetErr(&stderr)
leaf := &cobra.Command{
Use: "atomic-output-unified-failure",
RunE: func(*cobra.Command, []string) error {
return apperrors.NewValidation("business validation failed")
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
return root
})
if code := Execute(); code != 3 {
t.Fatalf("Execute exit code=%d, want validation code 3; stderr=%q", code, stderr.String())
}
var envelope struct {
OK bool `json:"ok"`
Outcome string `json:"outcome"`
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
}
if envelope.OK || envelope.Outcome != "failure" || envelope.Error.Type != "validation" || envelope.Error.Message != "business validation failed" {
t.Fatalf("failure envelope=%+v", envelope)
}
assertOutputFile(t, target, "original", 0o640)
assertNoOutputTemps(t, target)
}
func TestOutputSinkPanicCleansTempAndPreservesTarget(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
panic("boom")
})
root.SetArgs([]string{"atomic-output", "--output", target})
if recovered := executeAndRecover(root); recovered == nil {
t.Fatal("ExecuteC did not panic")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func TestOutputSinkRenameFailurePreservesTarget(t *testing.T) {
testseam.Swap(t, &rootRenameFile, func(string, string) error {
return errors.New("rename failed")
})
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
return err
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err == nil || err.Error() == "" {
t.Fatalf("ExecuteC error=%v, want publication failure", err)
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func TestOutputSinkSyncAndCloseFailuresPreserveTarget(t *testing.T) {
tests := []struct {
name string
seam func(*testing.T)
}{
{
name: "sync",
seam: func(t *testing.T) {
testseam.Swap(t, &rootSyncFile, func(*os.File) error { return errors.New("sync failed") })
},
},
{
name: "close",
seam: func(t *testing.T) {
testseam.Swap(t, &rootCloseFile, func(file *os.File) error {
_ = file.Close()
return errors.New("close failed")
})
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.seam(t)
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
return err
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
})
}
}
func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing.T) {
testseam.Swap(t, &rootRenameFile, func(string, string) error {
return errors.New("rename failed")
})
dir := t.TempDir()
target := filepath.Join(dir, "result.json")
root := NewRootCommand()
leaf := &cobra.Command{
Use: "atomic-output-unified",
RunE: func(cmd *cobra.Command, _ []string) error {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"atomic-output-unified", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("unified ExecuteC succeeded without publishing its output")
} else if code := apperrors.ExitCode(err); code != 5 {
t.Fatalf("publication exit code=%d, want 5: %v", code, err)
}
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("final output exists after publication failure: %v", err)
}
assertNoOutputTemps(t, target)
}
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
testseam.Protect(t, &os.Args)
dir := t.TempDir()
target := filepath.Join(dir, "result.json")
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
t.Fatal(err)
}
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
var stdout, stderr bytes.Buffer
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
root := NewRootCommandWithEngine(ctx, engine)
root.SetOut(&stdout)
root.SetErr(&stderr)
leaf := &cobra.Command{
Use: "atomic-output-unified-publication",
RunE: func(cmd *cobra.Command, _ []string) error {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
return root
})
if code := Execute(); code != 5 {
t.Fatalf("Execute exit code=%d, want publication failure code 5; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
var envelope output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("publication failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
}
if envelope.OK || envelope.Outcome != output.OutcomeFailure || envelope.Error == nil || envelope.Error.Type != "internal" || envelope.Error.ExitCode != 5 {
t.Fatalf("publication failure envelope=%+v", envelope)
}
if !strings.Contains(envelope.Error.Message, "failed to publish output file") {
t.Fatalf("publication failure message=%q", envelope.Error.Message)
}
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)); got != 1 {
t.Fatalf("stdout contains %d failure envelopes, want one: %s", got, stdout.String())
}
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
}
assertOutputFile(t, target, "original", 0o640)
assertNoOutputTemps(t, target)
}
func TestOutputSinkEmissionFailurePreservesTarget(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.json")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := NewRootCommand()
leaf := &cobra.Command{
Use: "atomic-emission-failure",
RunE: func(cmd *cobra.Command, _ []string) error {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"})); err != nil {
return err
}
return cmd.OutOrStdout().(*os.File).Close()
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"atomic-emission-failure", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded after emission failure")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func TestOutputSinkValidationFailureDoesNotCreateTemp(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := NewRootCommand()
leaf := &cobra.Command{Use: "atomic-validation", RunE: func(*cobra.Command, []string) error { return nil }}
leaf.Flags().String("required", "", "")
_ = leaf.MarkFlagRequired("required")
root.AddCommand(leaf)
root.SetArgs([]string{"atomic-validation", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded without required flag")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func newAtomicOutputTestRoot(run func(*cobra.Command) error) *cobra.Command {
root := NewRootCommand()
root.AddCommand(&cobra.Command{
Use: "atomic-output",
RunE: func(cmd *cobra.Command, _ []string) error {
return run(cmd)
},
})
return root
}
func executeAndRecover(cmd *cobra.Command) (recovered any) {
defer func() { recovered = recover() }()
_, _ = cmd.ExecuteC()
return nil
}
func assertOutputFile(t *testing.T, path, want string, wantMode os.FileMode) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read output: %v", err)
}
if string(data) != want {
t.Fatalf("output=%q, want %q", data, want)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat output: %v", err)
}
if mode := info.Mode().Perm(); mode != wantMode {
t.Fatalf("output mode=%#o, want %#o", mode, wantMode)
}
}
func assertNoOutputTemps(t *testing.T, target string) {
t.Helper()
matches, err := filepath.Glob(filepath.Join(filepath.Dir(target), "."+filepath.Base(target)+".tmp-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("temporary output files remain: %v", matches)
}
}
@@ -1,38 +0,0 @@
package app
import (
"bytes"
"context"
"path/filepath"
"testing"
)
// TestChatDownloadMediaAliasPreRunNormalizesRequiredFlag is the root-level
// regression for the alias normalization order: root's persistent pre-run must
// not run Cobra's required-flag validation ahead of the leaf PreRunE.
// chat message download-media copies --msg-id / --open-message-id into the
// required --message-id flag in its PreRunE; validating early failed that
// documented alias path with "missing required flag(s): --message-id".
func TestChatDownloadMediaAliasPreRunNormalizesRequiredFlag(t *testing.T) {
for _, alias := range []string{"msg-id", "open-message-id"} {
t.Run(alias, func(t *testing.T) {
root := NewRootCommand(context.Background())
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
target := filepath.Join(t.TempDir(), "download.bin")
root.SetArgs([]string{
"chat", "message", "download-media",
"--type", "mediaId",
"--resource-id", "media-1",
"--" + alias, "msg-1",
"--open-conversation-id", "cid-1",
"--output", target,
"--dry-run", "--format", "json",
})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("ExecuteC with alias --%s: %v\nstdout: %s\nstderr: %s", alias, err, stdout.String(), stderr.String())
}
})
}
}
-229
View File
@@ -1,229 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"context"
"errors"
"os"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/spf13/cobra"
)
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
cmd.SetContext(ctx)
return cmd
}
var stdout, stderr bytes.Buffer
executed := &cobra.Command{Use: "leaf"}
output.SetCommandRollout(executed, output.RolloutUnifiedActive)
executed.SetOut(&stdout)
executed.SetErr(&stderr)
rootExecuteCommand = func(root *cobra.Command) (*cobra.Command, error) {
executed.SetContext(root.Context())
if err := output.StoreResult(executed.Context(), output.Success(map[string]any{"id": "a"})); err != nil {
return executed, err
}
if _, _, err := output.EmitStoredResult(executed); err != nil {
return executed, err
}
return executed, nil
}
if code := Execute(); code != 0 {
t.Fatalf("Execute code=%d, want 0", code)
}
if stderr.Len() != 0 {
t.Fatalf("stderr=%q, want diagnostics only/empty", stderr.String())
}
if !strings.Contains(stdout.String(), `"outcome": "success"`) || strings.Contains(stdout.String(), `"contract_version"`) {
t.Fatalf("stdout does not match the unified envelope: %s", stdout.String())
}
}
// TestRootExecutionErrorToStderrOnly 是 B184 的回归断言:失败信封(JSON 错误
// 输出)恒走 stderr,stdout 严格为空(契约 §5.1:失败时 stdout 必须为空)。
// printExecutionError 把 PrintJSON/PrintHuman 都写 stderr writer,stdout
// writer 不得收到任何字节。
func TestRootExecutionErrorToStderrOnly(t *testing.T) {
t.Parallel()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "json", "")
_ = root.PersistentFlags().Set("format", "json")
var stdout, stderr bytes.Buffer
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewAuth("token expired")); err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
}
want := "{\n \"error\": {\n \"category\": \"auth\",\n \"code\": 2,\n \"message\": \"token expired\"\n }\n}\n"
if got := stderr.String(); got != want {
t.Fatalf("legacy root error wire changed\n got: %q\nwant: %q", got, want)
}
}
// TestRootHumanErrorToStderrOnly 是 B184 的人类可读分支断言:非 JSON 模式下,
// 失败走 stderr(PrintHuman),stdout 为空。
func TestRootHumanErrorToStderrOnly(t *testing.T) {
t.Parallel()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "table", "")
_ = root.PersistentFlags().Set("format", "table")
var stdout, stderr bytes.Buffer
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewInternal("boom")); err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "Error:") {
t.Fatalf("expected human error on stderr, got %q", stderr.String())
}
}
// TestRootExecuteOutcomeToExitCode 是 B185 的 Execute 出口断言:Execute 把
// 命令返回的 error 类别映射为进程退出码(apperrors.ExitCode)。ok→0、
// confirmation/validation→3、panic 与 unrepresentable partial error→5。
func TestRootExecuteOutcomeToExitCode(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
// ok / pending(信封 success/pending 语义)→ 0
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, nil }
if code := Execute(); code != 0 {
t.Fatalf("success Execute code = %d, want 0", code)
}
// An error cannot carry partial succeeded/failed data and fails closed.
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, &apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"}
}
if code := Execute(); code != 5 {
t.Fatalf("partial error Execute code = %d, want 5", code)
}
// confirmation_required(validation 子类)→ 3
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, apperrors.NewValidation("blocked", apperrors.WithReason("confirmation_required"))
}
if code := Execute(); code != 3 {
t.Fatalf("confirmation Execute code = %d, want 3", code)
}
// plain internal → 5
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, errors.New("plain")
}
if code := Execute(); code != 5 {
t.Fatalf("plain Execute code = %d, want 5", code)
}
}
// TestRootSilenceErrorsAndDeferTeardown 是 B186 的断言:根命令 SilenceErrors/
// SilenceUsage 打开(Cobra 不自行打印),且 Execute 出口 defer 收尾路径
// (StopAllStdioClients)在错误路径也被调用。
func TestRootSilenceErrorsAndDeferTeardown(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
stopped := false
rootStopAllStdioClients = func() { stopped = true }
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
// 错误路径:Execute 返回非零,且 defer 收尾(StopAllStdioClients)被调用。
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, apperrors.NewInternal("fail")
}
_ = Execute()
if !stopped {
t.Fatal("defer teardown (StopAllStdioClients) not called on error path")
}
}
// TestRootSilenceErrorsFlag 断言根命令的 SilenceErrors/SilenceUsage 为真,
// 保证 Cobra 不自行在错误时打印 usage/错误(错误渲染统一走 printExecutionError)。
func TestRootSilenceErrorsFlag(t *testing.T) {
t.Parallel()
root := NewRootCommand()
if !root.SilenceErrors || !root.SilenceUsage {
t.Fatalf("root must set SilenceErrors=%v SilenceUsage=%v", root.SilenceErrors, root.SilenceUsage)
}
}
+14 -41
View File
@@ -413,33 +413,6 @@ func multiProfileErrorPayload(err error) map[string]any {
if typed.Operation != "" {
payload["operation"] = typed.Operation
}
if typed.Origin != "" {
payload["origin"] = typed.Origin
}
if typed.FailureStage != "" {
payload["stage"] = typed.FailureStage
}
if typed.ExecutionStarted != nil {
payload["execution_started"] = *typed.ExecutionStarted
}
if typed.RetryableSet {
payload["retryable"] = typed.Retryable
}
if typed.Hint != "" {
payload["hint"] = typed.Hint
}
if len(typed.Actions) > 0 {
payload["actions"] = append([]string(nil), typed.Actions...)
}
if len(typed.Details) > 0 {
payload["details"] = typed.Details
}
if typed.ServerDiag.TraceID != "" {
payload["trace_id"] = typed.ServerDiag.TraceID
}
if typed.ServerDiag.ServerErrorCode != "" {
payload["server_error_code"] = typed.ServerDiag.ServerErrorCode
}
if code := typed.ExitCode(); code != 0 {
payload["exitCode"] = code
}
@@ -705,6 +678,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if callResult.IsError {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
// ClassifyToolResult hook: let the overlay intercept known error
// patterns (PAT permission, gateway-auth) before generic handling.
@@ -721,14 +695,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
}
mcpErr := newServerFailureAPIError(
mcpErr := apperrors.NewAPI(
extractMCPErrorMessage(callResult),
"mcp_tool_error",
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
invocation.CanonicalProduct,
diag,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
apperrors.WithServerDiag(diag),
)
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
// PAT scope error in business response: offer human-readable output and retry
if isPatScopeError(mcpErr) {
scopeErr := extractPatScopeError(mcpErr)
@@ -746,15 +720,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
classifiedErr := newServerFailureAPIError(
bizErr,
"business_error",
"The API returned a business-level error. Check required parameters and values.",
invocation.CanonicalProduct,
diag,
logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag)
return executor.Result{}, apperrors.NewAPI(bizErr,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("business_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
apperrors.WithServerDiag(diag),
)
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
return executor.Result{}, classifiedErr
}
invocation.Implemented = true
@@ -296,14 +296,7 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
t.Fatalf("write dry-run fixture %s: %v", path, err)
}
}
return agentExampleFiles{
root: root,
markdown: "./" + filepath.Base(markdown),
json: "./" + filepath.Base(jsonFile),
batch: "./" + filepath.Base(batch),
binary: "./" + filepath.Base(binary),
image: "./" + filepath.Base(image),
}
return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
}
func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string {
-56
View File
@@ -25,32 +25,11 @@ func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
if !containsSchemaPath(report.Covered, "chat category create-smart") {
t.Fatal("chat category create-smart is not covered by runtime Schema")
}
for _, path := range missingChatCatalogCoveragePaths() {
if !containsSchemaPath(report.Covered, path) {
t.Fatalf("%s is not covered by runtime Schema", path)
}
}
if !containsSchemaPath(report.Excluded, "agoal strategy list") {
t.Fatal("agoal strategy list is not recorded as a reviewed exclusion")
}
}
func TestRuntimeSchemaCompletenessDoesNotExcludeMissingChatCatalogPaths(t *testing.T) {
exclusions, err := cli.ReviewedRuntimeSchemaExclusions()
if err != nil {
t.Fatal(err)
}
excluded := map[string]bool{}
for _, exclusion := range exclusions {
excluded[exclusion.CLIPath] = true
}
for _, path := range missingChatCatalogCoveragePaths() {
if excluded[path] {
t.Fatalf("%s must not remain in runtime Schema exclusions", path)
}
}
}
func containsSchemaPath(paths []string, want string) bool {
for _, path := range paths {
if path == want {
@@ -59,38 +38,3 @@ func containsSchemaPath(paths []string, want string) bool {
}
return false
}
func missingChatCatalogCoveragePaths() []string {
return []string{
"chat category add-conv",
"chat category create",
"chat category delete",
"chat category remove-conv",
"chat category rename",
"chat chmod",
"chat clear-all-red-point",
"chat clear-messages",
"chat clear-red-point",
"chat data-auth cross-org",
"chat group audit-join-validation",
"chat group list-all",
"chat group list-join-validations",
"chat group members list-by-ids",
"chat group notice create",
"chat group notice edit",
"chat group notice get",
"chat group notice list",
"chat group share-invite",
"chat group update-alias",
"chat hide",
"chat list-all-conversations",
"chat mark-read",
"chat mark-unread",
"chat message list-emotion-replies",
"chat message set-top-msg",
"chat message unset-top-msg",
"chat mute-at-all",
"chat mute-red-envelope",
"chat text translate",
}
}
@@ -34,12 +34,7 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
{
canonical: "sheet.range_batch_set_style",
mode: "composite",
reason: "The CLI assembles style cell matrices locally from --ranges or a local batch file and submits them as one sheet/batch_update operations array; no single direct MCP interface represents the wrapper input shape.",
},
{
canonical: "sheet.create_with_data",
mode: "composite",
reason: "Reviewed composite workflow: the command calls sheet/create_workspace_sheet, waits for the new document to become writable, resolves the default worksheet, writes the initial data through sheet/set_range_from_csv or sheet/table_put, reads it back with sheet/get_range_as_csv, and optionally applies sheet/set_cell_range, sheet/update_dimension and sheet/merge_cells; no single pinned RPC represents the workflow.",
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
},
{
canonical: "sheet.range_read",
@@ -1,76 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"strings"
"testing"
)
func TestOAApprovalDualModeConstraintsReachEmbeddedSchema(t *testing.T) {
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
tests := []struct {
canonical string
optional []string
requireTogether []string
mutuallyExclusive []string
}{
{
canonical: "oa.forecast_process",
optional: []string{"request", "process-code", "dept-id", "form-values"},
requireTogether: []string{"process-code", "dept-id", "form-values"},
mutuallyExclusive: []string{"process-code", "dept-id", "form-values"},
},
{
canonical: "oa.start_process_instance",
optional: []string{"request", "process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"},
requireTogether: []string{"process-code", "form-values"},
mutuallyExclusive: []string{"process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"},
},
}
for _, test := range tests {
t.Run(test.canonical, func(t *testing.T) {
tool := tools[test.canonical]
parameters := schemaContractMap(tool["parameters"])
for _, name := range test.optional {
if got := parameters[name]["required"]; got != false {
t.Errorf("--%s required = %#v, want false for dual-mode command", name, got)
}
}
assertSchemaContractConstraintGroup(t, tool, "require_one_of", []string{"request", "process-code"})
assertSchemaContractConstraintGroup(t, tool, "require_together", test.requireTogether)
for _, name := range test.mutuallyExclusive {
assertSchemaContractConstraintGroup(t, tool, "mutually_exclusive", []string{"request", name})
}
constraints, _ := tool["constraints"].(map[string]any)
groups, _ := constraints["mutually_exclusive"].([]any)
if len(groups) != len(test.mutuallyExclusive) {
t.Errorf("mutually_exclusive group count = %d, want %d: %#v", len(groups), len(test.mutuallyExclusive), groups)
}
for _, rawGroup := range groups {
group, _ := rawGroup.([]any)
if len(group) != 2 {
t.Errorf("mutually_exclusive contains an over-broad group: %#v", group)
}
}
hasRequestOnlyExample := false
for _, example := range schemaContractStringSlice(tool["examples"]) {
if strings.Contains(example, " --request ") && !strings.Contains(example, " --process-code ") && !strings.Contains(example, " --form-values ") {
hasRequestOnlyExample = true
}
}
if !hasRequestOnlyExample {
t.Errorf("examples do not contain a request-only invocation: %#v", tool["examples"])
}
})
}
create := tools["oa.start_process_instance"]
if got := schemaContractString(create["confirmation"]); got != "user_required" {
t.Errorf("create-instance confirmation = %q, want user_required", got)
}
}
@@ -26,14 +26,6 @@ func TestReviewedMutationSafetyReachesFinalSchema(t *testing.T) {
wants := []finalSchemaSafetyWant{
{canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent", provenance: declared},
{canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
// Card update intentionally layers confirmation: the atomic typed command
// preserves its original contract, while the Agent-facing shortcut owns
// the outer confirmation boundary.
{canonical: "chat.update_streaming_card", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_send", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_send_by_webhook", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_send_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_update_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
+18 -226
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 378
publicShortcutCount = 266
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 379
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 216
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 378
publiclyDeliveredShortcutCount = 215
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,218 +114,18 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 217; got != want {
if got, want := int(product["count"].(float64)), 129; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
shortcutCount := 0
summaryByCLIPath := make(map[string]map[string]any, len(summaries))
for _, summary := range summaries {
summaryByCLIPath[schemaContractString(summary["cli_path"])] = summary
if strings.HasPrefix(schemaContractString(summary["canonical_path"]), "chat.shortcut_") {
shortcutCount++
}
}
if shortcutCount != 98 {
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
}
for _, cliPath := range missingChatCatalogCoveragePaths() {
if summaryByCLIPath[cliPath] == nil {
t.Fatalf("schema chat missing expected catalog tool %q", cliPath)
}
}
assertSchemaSummarySafety(t, summaryByCLIPath, "chat clear-messages", "destructive", "high", "user_required")
assertSchemaSummarySafety(t, summaryByCLIPath, "chat data-auth cross-org", "write", "high", "user_required")
assertSchemaSummarySafety(t, summaryByCLIPath, "chat group share-invite", "write", "medium", "user_required")
assertChatCatalogCompleteLeafContracts(t)
}
func assertSchemaSummarySafety(
t testing.TB,
summaries map[string]map[string]any,
cliPath string,
effect string,
risk string,
confirmation string,
) {
t.Helper()
summary := summaries[cliPath]
if summary == nil {
t.Fatalf("schema chat missing expected catalog tool %q", cliPath)
}
if got := schemaContractString(summary["effect"]); got != effect {
t.Fatalf("%s effect = %q, want %q", cliPath, got, effect)
}
if got := schemaContractString(summary["risk"]); got != risk {
t.Fatalf("%s risk = %q, want %q", cliPath, got, risk)
}
if got := schemaContractString(summary["confirmation"]); got != confirmation {
t.Fatalf("%s confirmation = %q, want %q", cliPath, got, confirmation)
}
}
func assertChatCatalogCompleteLeafContracts(t testing.TB) {
t.Helper()
for _, cliPath := range []string{
"chat clear-messages",
"chat clear-red-point",
"chat hide",
"chat mark-read",
"chat mark-unread",
"chat mute-at-all",
"chat mute-red-envelope",
} {
leaf := executeShortcutSchemaQuery(t, "--cli-path", cliPath)
assertSchemaLeafParameterRequired(t, leaf, cliPath, "conversation-id", false)
assertSchemaLeafConstraints(t, leaf, cliPath, map[string]any{
"require_one_of": [][]string{{"conversation-id", "id", "chat"}},
"mutually_exclusive": [][]string{{"conversation-id", "id", "chat"}},
})
}
markRead := executeShortcutSchemaQuery(t, "--cli-path", "chat mark-read")
assertSchemaLeafParameterRequired(t, markRead, "chat mark-read", "message-id", true)
chmod := executeShortcutSchemaQuery(t, "--cli-path", "chat chmod")
assertSchemaLeafConstraints(t, chmod, "chat chmod", map[string]any{
"require_one_of": [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
"mutually_exclusive": [][]string{{"conversation-id", "open-dingtalk-id", "user"}},
})
assertChatGrantParameterFacts(t, chmod, "chat chmod")
crossOrg := executeShortcutSchemaQuery(t, "--cli-path", "chat data-auth cross-org")
assertSchemaLeafConstraints(t, crossOrg, "chat data-auth cross-org", map[string]any{
"require_one_of": [][]string{{"target-org-id", "all"}},
"mutually_exclusive": [][]string{{"target-org-id", "all"}},
})
assertChatGrantParameterFacts(t, crossOrg, "chat data-auth cross-org")
shareInvite := executeShortcutSchemaQuery(t, "--cli-path", "chat group share-invite")
assertSchemaLeafConstraints(t, shareInvite, "chat group share-invite", map[string]any{
"require_one_of": [][]string{{"target", "receiver"}},
"mutually_exclusive": [][]string{{"target", "receiver"}},
})
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
}
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
t.Helper()
parameters := schemaContractMap(leaf["parameters"])
parameter := parameters[name]
if parameter == nil {
t.Fatalf("%s missing --%s parameter: %#v", cliPath, name, parameters)
}
if got, _ := parameter["required"].(bool); got != want {
t.Fatalf("%s --%s required = %#v, want %v", cliPath, name, parameter["required"], want)
}
}
func assertSchemaLeafParameterEnum(t testing.TB, leaf map[string]any, cliPath, name string, want []string) {
t.Helper()
parameters := schemaContractMap(leaf["parameters"])
parameter := parameters[name]
if parameter == nil {
t.Fatalf("%s missing --%s parameter: %#v", cliPath, name, parameters)
}
if got := schemaContractStringSlice(parameter["enum"]); !schemaContractJSONEqual(got, want) {
t.Fatalf("%s --%s enum = %#v, want %#v", cliPath, name, got, want)
}
}
func assertSchemaLeafConstraints(t testing.TB, leaf map[string]any, cliPath string, want map[string]any) {
t.Helper()
if got := leaf["constraints"]; !schemaContractJSONEqual(got, want) {
t.Fatalf("%s constraints = %#v, want %#v", cliPath, got, want)
}
}
func assertChatGrantParameterFacts(t testing.TB, leaf map[string]any, cliPath string) {
t.Helper()
parameters := schemaContractMap(leaf["parameters"])
grantType := parameters["grant-type"]
if grantType == nil {
t.Fatalf("%s missing --grant-type parameter: %#v", cliPath, parameters)
}
wantEnum := []string{"once", "session", "timed", "permanent"}
if got := schemaContractStringSlice(grantType["enum"]); !schemaContractJSONEqual(got, wantEnum) {
t.Fatalf("%s --grant-type enum = %#v, want %#v", cliPath, got, wantEnum)
}
if got := schemaContractString(parameters["session-id"]["required_when"]); got != "grant-type is session" {
t.Fatalf("%s --session-id required_when = %q, want grant-type is session", cliPath, got)
}
if got := schemaContractString(parameters["ttl"]["required_when"]); got != "grant-type is timed" {
t.Fatalf("%s --ttl required_when = %q, want grant-type is timed", cliPath, got)
}
}
func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +update")
if got, want := schemaContractString(leaf["confirmation"]), "user_required"; got != want {
t.Fatalf("confirmation = %q, want %q", got, want)
}
parameters := schemaContractMap(leaf["parameters"])
if got, want := len(parameters), 11; got != want {
t.Fatalf("parameter count = %d, want %d: %#v", got, want, parameters)
}
if required, _ := parameters["node"]["required"].(bool); !required {
t.Errorf("--node required = %#v, want true", parameters["node"]["required"])
}
if required, _ := parameters["command"]["required"].(bool); required {
t.Errorf("--command required = true, want runtime custom validation")
}
wantProperties := map[string]string{
"node": "node", "doc": "node", "command": "command", "content": "content", "text": "content", "doc-format": "docFormat",
"block-id": "blockId", "after-block-id": "afterBlockId", "old": "old", "new": "new",
"expected-revision": "expectedRevision",
}
for name, want := range wantProperties {
if got := schemaContractString(parameters[name]["property"]); got != want {
t.Errorf("--%s property = %q, want %q", name, got, want)
}
}
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
parameter := parameters[name]
if required, _ := parameter["required"].(bool); required {
t.Errorf("--%s required = true, want runtime custom validation", name)
}
if got := schemaContractString(parameter["required_when"]); got != "" {
t.Errorf("--%s required_when = %q, want compatibility-safe custom validation", name, got)
}
}
if constraints, exists := leaf["constraints"]; exists && constraints != nil {
t.Fatalf("enum-discriminated requirements must not be mispublished as relationship constraints: %#v", constraints)
}
}
func TestDeliveryDocCommentExportImportContractsAreCanonical(t *testing.T) {
comment := executeShortcutSchemaQuery(t, "--cli-path", "doc +comment-create")
commentParameters := schemaContractMap(comment["parameters"])
for _, name := range []string{"node", "content", "selection", "block-id", "start", "end", "selected-text", "mention"} {
if _, ok := commentParameters[name]; !ok {
t.Errorf("comment-create missing --%s: %#v", name, commentParameters)
}
}
for name, want := range map[string]string{"node": "node", "mention": "mention"} {
if got := schemaContractString(commentParameters[name]["property"]); got != want {
t.Errorf("comment-create --%s property = %q, want %q", name, got, want)
}
}
export := executeShortcutSchemaQuery(t, "--cli-path", "doc +export")
exportFormat := schemaContractMap(export["parameters"])["export-format"]
if required, _ := exportFormat["required"].(bool); required {
t.Fatalf("export --export-format required = true, want compatibility default")
}
if defaultValue := schemaContractString(exportFormat["default"]); defaultValue != "docx" {
t.Fatalf("export --export-format default = %q, want docx", defaultValue)
}
importLeaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +import")
constraints, _ := importLeaf["constraints"].(map[string]any)
if requireOneOf, ok := constraints["require_one_of"]; ok && !schemaContractJSONEqual(requireOneOf, [][]string{}) {
t.Fatalf("import unexpectedly requires a target: %#v", constraints)
if shortcutCount != 47 {
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
}
}
@@ -394,9 +194,17 @@ func assertDeliveryShortcutSafetyAndInterface(
canonical string,
) {
t.Helper()
safety := shortcut.EffectiveSafety(declared)
wantEffect, wantRisk := safety.Effect, safety.Risk
wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency
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,
@@ -426,15 +234,6 @@ func assertDeliveryShortcutParameters(
for _, flag := range declared.Flags {
if !flag.Hidden {
publicFlags = append(publicFlags, flag)
if flag.AliasesVisible {
for _, alias := range flag.Aliases {
aliasFlag := flag
aliasFlag.Name = alias
aliasFlag.Default = ""
aliasFlag.Aliases = nil
publicFlags = append(publicFlags, aliasFlag)
}
}
}
}
if got, want := len(parameters), len(publicFlags); got != want {
@@ -500,13 +299,6 @@ func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
if flag.Name == flagName && flag.Required {
return true
}
if flag.Required && flag.AliasesVisible {
for _, alias := range flag.Aliases {
if alias == flagName {
return true
}
}
}
}
public := make(map[string]bool, len(declared.Flags))
for _, flag := range declared.Flags {
@@ -1,163 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
// schemaSourceRootMarkerCaller is a recognizable ToolCaller used to detect
// InitDeps clobbering when NewSchemaSourceRootCommand builds the tree.
type schemaSourceRootMarkerCaller struct{}
func (schemaSourceRootMarkerCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (schemaSourceRootMarkerCaller) Format() string { return "json" }
func (schemaSourceRootMarkerCaller) DryRun() bool { return false }
func (schemaSourceRootMarkerCaller) Fields() string { return "" }
func (schemaSourceRootMarkerCaller) JQ() string { return "" }
// TestSchemaSourceRootPreservesRuntimeDepsAndPluginEndpoints ensures Schema
// assembly (NewSchemaSourceRootCommand / ResolveMeta delivery) does not call
// helpers.InitDeps or SetDynamicServers, which would wipe a live process's
// caller and plugin endpoints.
func TestSchemaSourceRootPreservesRuntimeDepsAndPluginEndpoints(t *testing.T) {
const (
pluginID = "schema-source-root-plugin-marker"
pluginEndpoint = "https://schema-source-root-plugin.example/mcp"
)
dynamicMu.RLock()
previousEndpoints := dynamicEndpoints
previousProducts := dynamicProducts
previousAliases := dynamicAliases
previousToolEndpoints := dynamicToolEndpoints
dynamicMu.RUnlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = previousEndpoints
dynamicProducts = previousProducts
dynamicAliases = previousAliases
dynamicToolEndpoints = previousToolEndpoints
dynamicMu.Unlock()
})
marker := schemaSourceRootMarkerCaller{}
helpers.InitDepsForTest(t, marker)
SetDynamicServers(nil)
AppendDynamicServer(mcptypes.ServerDescriptor{
Key: pluginID,
Endpoint: pluginEndpoint,
CLI: mcptypes.CLIOverlay{
ID: pluginID,
Command: pluginID,
},
})
root := NewSchemaSourceRootCommand()
if root == nil {
t.Fatal("NewSchemaSourceRootCommand returned nil")
}
if got := helpers.GetCaller(); got != marker {
t.Fatalf("helpers.GetCaller() after NewSchemaSourceRootCommand = %T (%p), want marker caller preserved", got, got)
}
gotEndpoint, ok := directRuntimeEndpoint(pluginID, "")
if !ok || gotEndpoint != pluginEndpoint {
t.Fatalf("plugin endpoint after NewSchemaSourceRootCommand = %q ok=%v, want %q preserved", gotEndpoint, ok, pluginEndpoint)
}
resolved, err := cli.ResolveSchemaBuild(root)
if err != nil {
t.Fatalf("ResolveSchemaBuild after declaration-only root: %v", err)
}
if resolved.CommandCount() == 0 {
t.Fatal("ResolveSchemaBuild command count is 0")
}
// Delivery path also builds NewSchemaSourceRootCommand via the factory.
registerSchemaRuntimeDelivery()
meta, ok := cli.ResolveMeta("dev app delete")
if !ok || meta.Identity.Canonical == "" {
t.Fatalf("ResolveMeta after registerSchemaRuntimeDelivery = %#v ok=%v", meta, ok)
}
if got := helpers.GetCaller(); got != marker {
t.Fatalf("helpers.GetCaller() after ResolveMeta delivery = %T, want marker caller preserved", got)
}
gotEndpoint, ok = directRuntimeEndpoint(pluginID, "")
if !ok || gotEndpoint != pluginEndpoint {
t.Fatalf("plugin endpoint after ResolveMeta delivery = %q ok=%v, want %q preserved", gotEndpoint, ok, pluginEndpoint)
}
}
// TestCrossPlatformCoverageSchemaSourceRootStaticServerVisibilityWithoutInject
// proves the declaration-only Schema source root does not need
// injectStaticServers to keep StaticServers products visible. VisibleProducts
// may be unset; StaticServers alone must still prevent
// hideNonDirectRuntimeCommands from marking those products Hidden — without
// mutating dynamic endpoints.
func TestCrossPlatformCoverageSchemaSourceRootStaticServerVisibilityWithoutInject(t *testing.T) {
previous := edition.Get()
t.Cleanup(func() { edition.Override(previous) })
dynamicMu.RLock()
previousEndpoints := dynamicEndpoints
previousProducts := dynamicProducts
previousAliases := dynamicAliases
previousToolEndpoints := dynamicToolEndpoints
dynamicMu.RUnlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = previousEndpoints
dynamicProducts = previousProducts
dynamicAliases = previousAliases
dynamicToolEndpoints = previousToolEndpoints
dynamicMu.Unlock()
})
edition.Override(&edition.Hooks{
Name: "schema-source-root-static-visibility",
StaticServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "", Name: "ignored-empty", Endpoint: "https://schema-source-root-static.example/empty"},
{ID: " ", Name: "ignored-blank", Endpoint: "https://schema-source-root-static.example/blank"},
{ID: "doc", Name: "Doc", Endpoint: "https://schema-source-root-static.example/doc"},
}
},
// VisibleProducts intentionally nil: declaration-only visibility must
// still derive from StaticServers without SetDynamicServers.
})
SetDynamicServers(nil)
root := NewSchemaSourceRootCommand()
var doc *cobra.Command
for _, cmd := range root.Commands() {
if cmd.Name() == "doc" {
doc = cmd
break
}
}
if doc == nil {
t.Fatal("doc command missing from Schema source root")
}
if doc.Hidden {
t.Fatal("declaration-only Schema source root hid doc despite StaticServers")
}
// Endpoint resolution may still read StaticServers directly; the invariant
// is that declaration-only construction must not mutate the dynamic registry.
dynamicMu.RLock()
_, injected := dynamicProducts["doc"]
dynamicMu.RUnlock()
if injected {
t.Fatal("declaration-only Schema source root must not inject StaticServers into dynamicProducts")
}
}
-106
View File
@@ -1,106 +0,0 @@
// 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 (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type serverFailureClass struct {
message string
reason string
origin string
stage string
hint string
actions []string
}
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
text := strings.ToLower(strings.TrimSpace(message))
if code == "NETWORK_ERROR" ||
strings.Contains(detail, "statuscode.unavailable") ||
strings.Contains(detail, "connection refused") {
classified := serverFailureClass{
message: "MCP 后端依赖暂时不可用",
reason: "backend_dependency_unavailable",
origin: "mcp_gateway",
stage: "backend_dependency",
hint: "请求参数无需修改;请使用相同参数稍后重试。持续失败时请提供 Trace ID 排查 MCP 服务。",
actions: []string{
"使用相同参数重试一次",
"持续失败时保留 Trace ID 并排查 MCP 后端依赖",
},
}
if strings.Contains(detail, "querytoolmeta") {
classified.message = "MCP 后端元数据服务暂时不可用"
classified.stage = "tool_metadata_lookup"
}
return classified, true
}
if code == "PARAM_ERROR" ||
strings.Contains(text, "opencid or cid is required") ||
strings.Contains(text, "openconversationid") && strings.Contains(text, "required") {
return serverFailureClass{
message: message,
reason: "invalid_request",
origin: "dingtalk_api",
stage: "tool_validation",
hint: "请求未通过后端参数校验;请核对当前 leaf Help/Schema 和稳定 ID 类型后重试。",
}, true
}
return serverFailureClass{}, false
}
func newServerFailureAPIError(
message string,
fallbackReason string,
fallbackHint string,
serverKey string,
diag apperrors.ServerDiagnostics,
) error {
opts := []apperrors.Option{
apperrors.WithOperation("tools/call"),
apperrors.WithReason(fallbackReason),
apperrors.WithServerKey(serverKey),
apperrors.WithHint(fallbackHint),
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
apperrors.WithServerDiag(diag),
}
if classified, ok := classifyServerFailure(message, diag); ok {
message = classified.message
opts = append(opts,
apperrors.WithReason(classified.reason),
apperrors.WithOrigin(classified.origin),
apperrors.WithFailureStage(classified.stage),
apperrors.WithHint(classified.hint),
apperrors.WithActions(classified.actions...),
)
}
return apperrors.NewAPI(message, opts...)
}
func serverFailureReason(err error, fallback string) string {
typed, ok := err.(*apperrors.Error)
if ok && strings.TrimSpace(typed.Reason) != "" {
return typed.Reason
}
return fallback
}
@@ -1,226 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(t *testing.T) {
retryable := true
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{
TraceID: "trace-local",
ServerErrorCode: "NETWORK_ERROR",
TechnicalDetail: "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
ServerRetryable: &retryable,
},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
}
if !typed.RetryableSet || !typed.Retryable {
t.Fatalf("retryability = (%v, %v), want explicit true", typed.RetryableSet, typed.Retryable)
}
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || strings.Contains(typed.Hint, "认证") {
t.Fatalf("misleading hint = %q", typed.Hint)
}
}
func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *testing.T) {
err := newServerFailureAPIError(
"openCid or cid is required",
"business_error",
"check parameters",
"chat",
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "invalid_request" || typed.FailureStage != "tool_validation" {
t.Fatalf("classification = reason %q stage %q", typed.Reason, typed.FailureStage)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
}
}
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil {
t.Fatalf("unexpected fallback classification: %#v", typed)
}
if len(typed.Actions) == 0 || !strings.Contains(typed.Actions[0], "dws doctor") {
t.Fatalf("fallback error has no stable troubleshooting entry: %#v", typed.Actions)
}
}
func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
)
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
t.Fatalf("reason = %q", got)
}
if got := serverFailureReason(errors.New("plain"), "fallback"); got != "fallback" {
t.Fatalf("fallback reason = %q", got)
}
}
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(t *testing.T) {
retryable := true
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{
TraceID: "trace-multi",
ServerErrorCode: "NETWORK_ERROR",
TechnicalDetail: "McpService.queryToolMeta: StatusCode.UNAVAILABLE",
ServerRetryable: &retryable,
},
)
payload := multiProfileErrorPayload(err)
for key, want := range map[string]any{
"reason": "backend_dependency_unavailable",
"origin": "mcp_gateway",
"stage": "tool_metadata_lookup",
"retryable": true,
"trace_id": "trace-multi",
"server_error_code": "NETWORK_ERROR",
} {
if got := payload[key]; got != want {
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
}
}
if _, ok := payload["execution_started"]; ok {
t.Fatalf("payload must not invent execution_started: %#v", payload)
}
}
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesResolutionDetails(t *testing.T) {
err := apperrors.NewValidation(
"群目标不唯一",
apperrors.WithReason("resolution_ambiguous"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithHint("请选择候选"),
apperrors.WithActions("使用稳定 ID 重试"),
apperrors.WithDetails(map[string]any{
"type": "resolution",
"candidates": []string{"cid-1", "cid-2"},
}),
)
payload := multiProfileErrorPayload(err)
details, ok := payload["details"].(map[string]any)
if !ok || details["type"] != "resolution" {
t.Fatalf("details = %#v", payload["details"])
}
if payload["execution_started"] != false || payload["origin"] != "client" || payload["stage"] != "target_resolution" {
t.Fatalf("payload = %#v", payload)
}
if actions, ok := payload["actions"].([]string); !ok || len(actions) != 1 {
t.Fatalf("actions = %#v", payload["actions"])
}
}
func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]any{
"structuredContent": map[string]any{
"success": false,
"code": "NETWORK_ERROR",
"trace_id": "trace-replay",
"technical_detail": "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
"retryable": true,
},
},
})
}))
defer server.Close()
client := transport.NewClient(server.Client())
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
runner := &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "local-test-token"},
}
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "im",
Tool: "list_conversations",
Params: map[string]any{"pageSize": 100},
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
}
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.ServerDiag.TraceID != "trace-replay" || !typed.RetryableSet || !typed.Retryable {
t.Fatalf("diagnostics = %#v retryable=(%v,%v)", typed.ServerDiag, typed.RetryableSet, typed.Retryable)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
}
}
-137
View File
@@ -1,137 +0,0 @@
package app
import (
"context"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
var rootEscalateSignal = func(sig os.Signal) {
signal.Reset(sig)
redeliverProcessSignal(sig)
}
var (
rootFindProcess = os.FindProcess
rootExitProcess = os.Exit
)
// redeliverProcessSignal asks the current process to handle the second signal
// with the platform's default semantics. Platforms that cannot deliver the
// requested signal through os.Process.Signal fall back to the conventional
// CLI exit status instead of leaving the process running after escalation.
func redeliverProcessSignal(sig os.Signal) {
process, err := rootFindProcess(os.Getpid())
if err == nil {
err = process.Signal(sig)
}
if err != nil {
rootExitProcess(interruptionExitCode(sig))
}
}
func interruptionExitCode(sig os.Signal) int {
if sig == syscall.SIGTERM {
return 143
}
return 130
}
type processInterruption struct {
signal os.Signal
}
func (e *processInterruption) Error() string {
return fmt.Sprintf("process interrupted by %s", e.signal)
}
func (e *processInterruption) Unwrap() error { return context.Canceled }
func (e *processInterruption) ExitCode() int {
return interruptionExitCode(e.signal)
}
func (e *processInterruption) Subtype() string {
if e.signal == syscall.SIGTERM {
return "terminated"
}
return "cancelled_by_user"
}
type processSignalState struct {
mu sync.Mutex
interruption *processInterruption
primaryCompletedAtSignal bool
}
func (s *processSignalState) record(sig os.Signal, store *output.ResultStore) (first bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.interruption != nil {
return false
}
_, _, s.primaryCompletedAtSignal, _ = output.StoredEmissionState(store)
s.interruption = &processInterruption{signal: sig}
return true
}
func (s *processSignalState) outcome() (*processInterruption, bool) {
s.mu.Lock()
defer s.mu.Unlock()
return s.interruption, s.primaryCompletedAtSignal
}
func installProcessSignalContext(parent context.Context, store *output.ResultStore) (context.Context, *processSignalState, func()) {
signals := make(chan os.Signal, 2)
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
return manageProcessSignals(parent, store, signals, func() { signal.Stop(signals) }, rootEscalateSignal)
}
func manageProcessSignals(
parent context.Context,
store *output.ResultStore,
signals <-chan os.Signal,
stopNotify func(),
escalate func(os.Signal),
) (context.Context, *processSignalState, func()) {
ctx, cancel := context.WithCancelCause(parent)
state := &processSignalState{}
done := make(chan struct{})
stopped := make(chan struct{})
var stopOnce sync.Once
go func() {
defer close(stopped)
for {
select {
case sig := <-signals:
if sig == nil {
continue
}
if state.record(sig, store) {
cancel(state.interruption)
continue
}
escalate(sig)
return
case <-done:
return
}
}
}()
stop := func() {
stopOnce.Do(func() {
stopNotify()
close(done)
<-stopped
cancel(context.Canceled)
})
}
return ctx, state, stop
}
-336
View File
@@ -1,336 +0,0 @@
package app
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func signalSelf(t *testing.T, sig syscall.Signal) {
t.Helper()
process, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatalf("find current process: %v", err)
}
if err := process.Signal(sig); err != nil {
t.Skipf("current platform does not support process signal delivery: %v", err)
}
}
func TestFrameworkSignalRedeliveryFallbackAndInterruptionMethods(t *testing.T) {
originalFind, originalExit := rootFindProcess, rootExitProcess
t.Cleanup(func() { rootFindProcess, rootExitProcess = originalFind, originalExit })
rootFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find failed") }
exitCode := 0
rootExitProcess = func(code int) { exitCode = code }
rootEscalateSignal(syscall.SIGTERM)
if exitCode != 143 {
t.Fatalf("escalation exit=%d", exitCode)
}
exitCode = 0
redeliverProcessSignal(syscall.SIGTERM)
if exitCode != 143 {
t.Fatalf("fallback exit=%d", exitCode)
}
rootFindProcess = func(int) (*os.Process, error) { return os.FindProcess(99999999) }
exitCode = 0
redeliverProcessSignal(syscall.SIGINT)
if exitCode != 130 {
t.Fatalf("signal fallback exit=%d", exitCode)
}
interrupted := &processInterruption{signal: syscall.SIGINT}
if !errors.Is(interrupted, context.Canceled) || interrupted.ExitCode() != 130 || interrupted.Subtype() != "cancelled_by_user" || !strings.Contains(interrupted.Error(), "interrupt") {
t.Fatalf("interruption=%v", interrupted)
}
terminated := &processInterruption{signal: syscall.SIGTERM}
if terminated.ExitCode() != 143 || terminated.Subtype() != "terminated" {
t.Fatalf("termination=%v", terminated)
}
state := &processSignalState{}
if !state.record(syscall.SIGINT, nil) || state.record(syscall.SIGTERM, nil) {
t.Fatal("signal state did not reject a second interruption")
}
}
func TestFrameworkManageProcessSignalsNilAndEscalation(t *testing.T) {
signals := make(chan os.Signal, 3)
stopped, escalated := false, make(chan os.Signal, 1)
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() { stopped = true }, func(sig os.Signal) { escalated <- sig })
signals <- nil
signals <- syscall.SIGINT
<-ctx.Done()
signals <- syscall.SIGTERM
if got := <-escalated; got != syscall.SIGTERM {
t.Fatalf("escalated=%v", got)
}
stop()
stop()
if !stopped {
t.Fatal("signal notification was not stopped")
}
}
func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Writer) {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = []string{"dws"}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
if unified {
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
}
cmd.SetContext(ctx)
cmd.SetOut(stdout)
cmd.SetErr(stderr)
return cmd
})
}
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
for _, tc := range []struct {
name string
signal syscall.Signal
code int
subtype string
}{
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
} {
t.Run(tc.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, &stderr)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
signalSelf(t, tc.signal)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != tc.code {
t.Fatalf("Execute code=%d, want %d", code, tc.code)
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
}
if env.Error == nil || env.Error.Type != "internal" || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
t.Fatalf("error=%+v, want internal/%s exit %d", env.Error, tc.subtype, tc.code)
}
if bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)) != 1 {
t.Fatalf("stdout must contain one failure envelope: %s", stdout.String())
}
})
}
}
func TestExecuteSignalLegacyExitCodes(t *testing.T) {
for _, tc := range []struct {
signal syscall.Signal
code int
}{{syscall.SIGINT, 130}, {syscall.SIGTERM, 143}} {
t.Run(tc.signal.String(), func(t *testing.T) {
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
signalSelf(t, tc.signal)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != tc.code {
t.Fatalf("Execute code=%d, want %d", code, tc.code)
}
})
}
}
func TestExecuteDeadlineIsNotSignalCancellation(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
return cmd, context.DeadlineExceeded
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d, want internal deadline code 5", code)
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env.Error == nil || env.Error.Subtype != "deadline_exceeded" || env.Error.ExitCode == 130 || env.Error.ExitCode == 143 {
t.Fatalf("deadline error=%+v", env.Error)
}
}
func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
cmd.SetOut(failingWriter{})
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
_, _, _ = output.EmitStoredResult(cmd)
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d, want publication failure code 5", code)
}
if stdout.Len() != 0 {
t.Fatalf("second envelope emitted: %q", stdout.String())
}
}
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
// Record cancellation before publication begins, then simulate a command
// hook that has already committed its result and completes publication.
// The wire result must remain authoritative over the earlier signal.
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
return cmd, cmd.Context().Err()
})
if code := Execute(); code != 0 {
t.Fatalf("Execute code=%d, want published success code 0", code)
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
}
if !env.OK || env.Outcome != output.OutcomeSuccess {
t.Fatalf("published envelope=%+v, want successful outcome", env)
}
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
}
}
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != 0 {
t.Fatalf("Execute code=%d, want established success code 0", code)
}
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
}
}
func TestExecuteSignalSubprocessExitStatus(t *testing.T) {
if os.Getenv("DWS_SIGNAL_HELPER") == "1" {
installSignalExecuteSeams(t, true, os.Stdout, os.Stderr)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
_, _ = fmt.Fprintln(os.Stderr, "READY")
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
os.Exit(Execute())
}
for _, tc := range []struct {
name string
signal syscall.Signal
code int
subtype string
}{
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestExecuteSignalSubprocessExitStatus$")
cmd.Env = append(os.Environ(), "DWS_SIGNAL_HELPER=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
if scanner := bufio.NewScanner(stderr); !scanner.Scan() || scanner.Text() != "READY" {
t.Fatalf("helper readiness failed: %q, err=%v", scanner.Text(), scanner.Err())
}
if err := cmd.Process.Signal(tc.signal); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Skipf("current platform does not support subprocess signal delivery: %v", err)
}
payload, readErr := io.ReadAll(stdout)
if readErr != nil {
t.Fatal(readErr)
}
waitErr := cmd.Wait()
var exitErr *exec.ExitError
if !errors.As(waitErr, &exitErr) || exitErr.ExitCode() != tc.code {
t.Fatalf("wait error=%v, want exit %d", waitErr, tc.code)
}
var env output.Envelope
if err := json.Unmarshal(payload, &env); err != nil {
t.Fatalf("decode helper output: %v; output=%q", err, payload)
}
if env.Error == nil || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
t.Fatalf("helper error=%+v", env.Error)
}
})
}
}
func TestSecondSignalUsesEscalationSeam(t *testing.T) {
signals := make(chan os.Signal, 2)
escalated := make(chan os.Signal, 1)
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() {}, func(sig os.Signal) {
escalated <- sig
})
signals <- syscall.SIGINT
<-ctx.Done()
if !errors.Is(context.Cause(ctx), context.Canceled) {
t.Fatalf("cause=%v, want cancellation", context.Cause(ctx))
}
signals <- syscall.SIGTERM
if got := <-escalated; got != syscall.SIGTERM {
t.Fatalf("escalated %v, want SIGTERM", got)
}
stop()
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
+130 -607
View File
@@ -51,8 +51,6 @@ var (
skillSetupInstallMono = installSkillToHomes
skillSetupInstallMulti = installMultiSkillToHomes
skillSetupCopyDir = copyDir
skillSetupMkdirTemp = os.MkdirTemp
skillSetupRename = os.Rename
skillSetupRunForm = (*huh.Form).Run
skillSetupInteractive = isInteractiveTerminal
skillSetupReadDir = os.ReadDir
@@ -77,13 +75,14 @@ func newSkillSetupCommand() *cobra.Command {
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
支持两种模式:
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
multi 多 skill—— 按产品拆 N 个独立 skill
multi 多 skill(默认)—— 按产品拆 N 个独立 skill(dingtalk-*)
mono 单 skill(legacy)—— 总入口 SKILL.md + references/products/
multi 模式支持按产品挑选:
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
-x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥)
未列出的已有 dingtalk-* skill 会保留(additive 叠加语义)
用 -s/-x 挑选时未列出的已有 dingtalk-* skill 会保留(additive 叠加语义);
不带过滤条件的全量安装会清理不在 bundle 内的过期 dingtalk-* / dws-shared。
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
@@ -138,9 +137,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
var multiSkillNames []string
var foldedEventMiscTargets []string
var migrateEventMiscTargets []string
var installsEventMiscCompanion bool
if mode == skillSetupModeMulti {
allMultiSkillNames, listErr := skillSetupListMulti(skillSrc)
if listErr != nil {
@@ -153,33 +149,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
if filterErr != nil {
return filterErr
}
// dingtalk-shared carries the global rules every product skill declares as a
// dws-shared carries the global rules every product skill declares as a
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
foldedEventMiscTargets = findFoldedEventMiscTargets(dests)
if len(foldedEventMiscTargets) > 0 {
hasEvent := containsSkillName(multiSkillNames, multiEventSkill)
hasMisc := containsSkillName(multiSkillNames, multiMiscSkill)
switch {
case normalizedSkillListContains(excludeRaw, multiEventSkill):
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能显式 --exclude event,请先完成 dingtalk-event 迁移")
case hasMisc && !hasEvent:
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能只覆盖 dingtalk-misc,必须同时迁移 dingtalk-event")
case hasEvent:
if normalizedSkillListContains(excludeRaw, multiMiscSkill) {
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;本次安装 dingtalk-event 必须同时迁移 dingtalk-misc,不能显式 --exclude misc")
}
if !containsSkillName(allMultiSkillNames, multiMiscSkill) {
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由,但当前 multi 源缺少迁移所需的 %s", multiMiscSkill)
}
if err := validateEventMiscMigrationSource(skillSrc); err != nil {
return err
}
migrateEventMiscTargets = append(migrateEventMiscTargets, foldedEventMiscTargets...)
installsEventMiscCompanion = !hasMisc
}
}
}
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
@@ -191,16 +163,17 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
}
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
}
return nil
}
// filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude
// 时保持 additive(不动未列出的 sibling);全量安装与 install.sh /
// install.js 对齐,清掉不在 bundle 内的过期 dingtalk-* / dws-shared。
filtered := len(includeRaw) > 0 || len(excludeRaw) > 0
if !autoYes {
if mode == skillSetupModeMulti {
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
}
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames, filtered)
if err != nil {
return err
}
@@ -215,14 +188,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
case skillSetupModeMono:
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
case skillSetupModeMulti:
installed, skipped, err = installMultiSkillsWithEventMigration(
skillSrc,
multiSkillNames,
dests,
migrateEventMiscTargets,
out,
errOut,
)
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut, filtered)
default:
return fmt.Errorf("内部错误:未知 mode %q", mode)
}
@@ -231,7 +197,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
}
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。")
return nil
}
@@ -242,228 +207,8 @@ const multiSkillPrefix = "dingtalk-"
// multiSharedSkill is the shared, non-product skill that every per-product
// skill declares as a PREREQUISITE. It must always be installed in multi mode
// regardless of --skill / --exclude, otherwise the product skills reference a
// dingtalk-shared that was never installed.
const multiSharedSkill = "dingtalk-shared"
// legacyMultiSharedSkill is the retired name shipped by older multi-skill
// bundles. Once the replacement has been installed successfully, remove this
// exact directory so Agent discovery cannot load both routing contracts.
const legacyMultiSharedSkill = "dws-shared"
const (
multiEventSkill = "dingtalk-event"
multiMiscSkill = "dingtalk-misc"
)
var eventMigrationRequiredReferences = []string{
"event-im.md",
"event-im-keys.md",
"event-im-lifecycle.md",
"event-im-operations.md",
"event-im-output.md",
"event-oa.md",
}
func containsSkillName(names []string, want string) bool {
for _, name := range names {
if name == want {
return true
}
}
return false
}
func normalizedSkillListContains(raw []string, want string) bool {
for _, name := range raw {
if normalizeMultiSkillName(name) == want {
return true
}
}
return false
}
// findFoldedEventMiscTargets identifies the short-lived multi-skill layout in
// which personal Event routing lived inside dingtalk-misc. Both markers are
// required so an unrelated misc install is never treated as a migration target.
func findFoldedEventMiscTargets(dests []string) []string {
var targets []string
for _, dest := range dests {
miscRoot := filepath.Join(dest, multiMiscSkill)
skillBody, err := os.ReadFile(filepath.Join(miscRoot, "SKILL.md"))
if err != nil || !containsPersonalEventRoute(skillBody) {
continue
}
eventRef, err := skillSetupStat(filepath.Join(miscRoot, "references", "event.md"))
if err != nil || eventRef.IsDir() {
continue
}
targets = append(targets, dest)
}
sort.Strings(targets)
return targets
}
func containsPersonalEventRoute(skillBody []byte) bool {
body := strings.ToLower(string(skillBody))
for _, marker := range []string{
"dws event",
"个人 event",
"个人 im 事件",
"个人 im/oa",
"personal event",
} {
if strings.Contains(body, marker) {
return true
}
}
return false
}
func printEventMiscMigrationPreview(out io.Writer, targets []string, installsCompanion bool) {
if len(targets) == 0 {
return
}
action := "将原子切换 dingtalk-event 与本次已选择的干净 dingtalk-misc"
if installsCompanion {
action = "将原子切换 dingtalk-event,并额外安装干净的 dingtalk-misc 作为迁移伴侣(仅限以下目标)"
}
fmt.Fprintf(out, "Event Skill 迁移:%s:\n", action)
for _, target := range targets {
fmt.Fprintf(out, " - %s\n", target)
}
}
func validateEventMiscMigrationSource(src string) error {
if err := validateEventMigrationSkillRoot(filepath.Join(src, multiEventSkill)); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
if err := validateMigrationSkillRoot(filepath.Join(src, multiMiscSkill), multiMiscSkill, nil); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
if err := validateCleanEventMiscRoot(filepath.Join(src, multiMiscSkill)); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
return nil
}
func validateEventMigrationSkillRoot(root string) error {
required := make([]string, 0, len(eventMigrationRequiredReferences))
for _, name := range eventMigrationRequiredReferences {
required = append(required, filepath.Join("references", name))
}
return validateMigrationSkillRoot(root, multiEventSkill, required)
}
func validateMigrationSkillRoot(root, expectedName string, requiredFiles []string) error {
skillPath := filepath.Join(root, "SKILL.md")
skillBody, err := os.ReadFile(skillPath)
if err != nil {
return fmt.Errorf("无法读取 %s: %w", skillPath, err)
}
name, err := parseMigrationSkillFrontmatter(skillBody)
if err != nil {
return fmt.Errorf("%s 无效: %w", skillPath, err)
}
if name != expectedName {
return fmt.Errorf("%s 的 name=%q,期望 %q", skillPath, name, expectedName)
}
for _, rel := range requiredFiles {
path := filepath.Join(root, rel)
info, statErr := skillSetupStat(path)
if statErr != nil || info.IsDir() {
if statErr == nil {
statErr = errors.New("is a directory")
}
return fmt.Errorf("缺少有效文件 %s: %w", path, statErr)
}
body, readErr := os.ReadFile(path)
if readErr != nil {
return fmt.Errorf("无法读取 %s: %w", path, readErr)
}
if strings.TrimSpace(string(body)) == "" {
return fmt.Errorf("文件为空 %s", path)
}
}
return nil
}
func parseMigrationSkillFrontmatter(body []byte) (string, error) {
normalized := strings.ReplaceAll(string(body), "\r\n", "\n")
lines := strings.Split(normalized, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return "", errors.New("缺少 YAML frontmatter")
}
name := ""
description := ""
closingLine := -1
for i := 1; i < len(lines); i++ {
rawLine := lines[i]
line := strings.TrimSpace(rawLine)
if line == "---" {
closingLine = i
break
}
// Only inspect top-level frontmatter keys. Nested metadata may legally
// contain its own `name` without changing the Skill identity.
if strings.TrimLeft(rawLine, " \t") != rawLine {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
value = strings.Trim(strings.TrimSpace(value), "\"'")
switch strings.TrimSpace(key) {
case "name":
if name != "" {
return "", errors.New("frontmatter 含重复 name")
}
name = value
case "description":
description = value
}
}
if closingLine < 0 {
return "", errors.New("YAML frontmatter 未闭合")
}
if name == "" {
return "", errors.New("frontmatter 缺少 name")
}
if description == "" {
return "", errors.New("frontmatter 缺少 description")
}
if strings.TrimSpace(strings.Join(lines[closingLine+1:], "\n")) == "" {
return "", errors.New("SKILL.md 正文为空")
}
return name, nil
}
func validateCleanEventMiscRoot(miscRoot string) error {
miscSkillPath := filepath.Join(miscRoot, "SKILL.md")
miscBody, err := os.ReadFile(miscSkillPath)
if err != nil {
return fmt.Errorf("无法读取 %s: %w", miscSkillPath, err)
}
if containsPersonalEventRoute(miscBody) {
return fmt.Errorf("%s 仍包含个人 Event 路由", miscSkillPath)
}
refsRoot := filepath.Join(miscRoot, "references")
entries, err := skillSetupReadDir(refsRoot)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("无法检查 %s: %w", refsRoot, err)
}
for _, entry := range entries {
name := strings.ToLower(entry.Name())
if !entry.IsDir() && strings.HasPrefix(name, "event") && strings.HasSuffix(name, ".md") {
return fmt.Errorf("%s 仍存在折叠 Event 参考页", filepath.Join(refsRoot, entry.Name()))
}
}
return nil
}
// dws-shared that was never installed.
const multiSharedSkill = "dws-shared"
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
@@ -508,10 +253,10 @@ func normalizeMultiSkillName(name string) string {
// - both lists empty → return `all` (install everything)
// - exclude that drops every name → error (avoid silent no-op install)
//
// The caller is responsible for additive installation: install only the
// returned names, leaving any other already-installed dingtalk-* siblings
// untouched (handled by installMultiSkillToHomes which does not enumerate
// the destination).
// The caller threads whether a filter was used into installMultiSkillToHomes:
// filtered installs stay additive (already-installed dingtalk-* siblings are
// left untouched); a full unfiltered install also removes stale dingtalk-* /
// dws-shared directories that are no longer part of the bundle.
func filterMultiSkillNames(all, include, exclude []string) ([]string, error) {
if len(include) > 0 && len(exclude) > 0 {
return nil, fmt.Errorf("--skill 与 --exclude 不能同时使用")
@@ -613,8 +358,8 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
}
if autoYes || !skillSetupInteractive() {
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono")
return skillSetupModeMono, nil
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 multi")
return skillSetupModeMulti, nil
}
var choice string
@@ -622,10 +367,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
huh.NewGroup(
huh.NewSelect[string]().
Title("选择 dws skill 安装模式").
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分的独立 skill").
Description("multi = 按产品拆分(默认)\nmono = 单 skill 入口(legacy)").
Options(
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
huh.NewOption("multi — 多 skill(按产品拆分)", skillSetupModeMulti),
huh.NewOption("multi — 多 skill(默认)", skillSetupModeMulti),
huh.NewOption("mono — 单 skill(legacy)", skillSetupModeMono),
).
Value(&choice),
),
@@ -637,8 +382,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
}
// resolveSkillSetupSource finds the local skill source directory for the
// given mode. PR 1 supports only mono; multi is reserved for a later PR
// and currently returns an error before reaching this function.
// given mode ("mono" or "multi"). Explicit overrides (--source flag or
// DWS_SKILL_SOURCE) win and never fall back to another source; without an
// override the ordered candidate list (binary-adjacent, working directory,
// ~/.dws/skills user cache) is probed for a valid skill root of that mode.
func resolveSkillSetupSource(explicit, mode string) (string, error) {
subdir := mode // "mono" or "multi"
@@ -782,7 +529,7 @@ func detectExistingAgentHomes(home, mode string) []string {
return out
}
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string, filtered bool) (bool, error) {
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
if mode == skillSetupModeMulti {
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
@@ -797,10 +544,21 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
// 列出互斥清理:装 mode 前要把对面 mode 的残留删掉
fmt.Fprintln(out, " 互斥清理(确认后才执行):")
for _, d := range dests {
for _, victim := range mutualExclusionVictims(d, mode) {
victims, _ := mutualExclusionVictims(d, mode) // 预览只读,扫描失败不阻塞确认
for _, victim := range victims {
fmt.Fprintf(out, " × 将删除 %s\n", victim)
}
}
// 全量 multi 安装还会清掉不在 bundle 内的过期 dingtalk-* / dws-shared
// (removeStaleMultiSkills);这些删除同样必须先进入确认预览。带
// -s/-x 的 filtered 安装是 additive 语义,不会动未列出的 sibling。
if mode == skillSetupModeMulti && !filtered {
for _, d := range dests {
for _, victim := range staleMultiSkillVictims(d, multiSkillNames) {
fmt.Fprintf(out, " × 将删除过期 skill %s\n", victim)
}
}
}
if !skillSetupInteractive() {
return true, nil
@@ -828,38 +586,49 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
//
// - mono dest is <agent-home>/dws → multi 残留是 <agent-home>/dingtalk-*
// - multi dest is <agent-home> → mono 残留是 <agent-home>/dws
func mutualExclusionVictims(dest, mode string) []string {
//
// A scan failure (e.g. unreadable agent home) is returned as a non-nil error
// so callers can surface a warning instead of silently skipping cleanup.
func mutualExclusionVictims(dest, mode string) ([]string, error) {
switch mode {
case skillSetupModeMono:
// dest = <agent-home>/dws → agent-home = parent
agentHome := filepath.Dir(dest)
entries, err := skillSetupReadDir(agentHome)
if err != nil {
return nil
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("扫描 multi 残留失败 %s: %w", agentHome, err)
}
var victims []string
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), "dingtalk-") {
if e.IsDir() && (strings.HasPrefix(e.Name(), multiSkillPrefix) || e.Name() == multiSharedSkill) {
victims = append(victims, filepath.Join(agentHome, e.Name()))
}
}
sort.Strings(victims)
return victims
return victims, nil
case skillSetupModeMulti:
// dest = <agent-home> → mono 残留是 dest/dws
monoPath := filepath.Join(dest, "dws")
if _, err := skillSetupStat(monoPath); err == nil {
return []string{monoPath}
return []string{monoPath}, nil
}
return nil
return nil, nil
}
return nil
return nil, nil
}
// cleanupMutualExclusion best-effort removes the opposite-mode leftovers.
// Failures emit a warning to errOut but never abort the install.
// Failures — including a failed victim scan — emit a warning to errOut but
// never abort the install.
func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
for _, victim := range mutualExclusionVictims(dest, mode) {
victims, scanErr := mutualExclusionVictims(dest, mode)
if scanErr != nil {
fmt.Fprintf(errOut, " ⚠️ 互斥清理扫描失败(继续安装) %s: %v\n", dest, scanErr)
}
for _, victim := range victims {
if err := skillSetupRemoveAll(victim); err != nil {
fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err)
continue
@@ -868,21 +637,6 @@ func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
}
}
func cleanupLegacyMultiSharedSkill(dest string, out, errOut io.Writer) {
legacyPath := filepath.Join(dest, legacyMultiSharedSkill)
if _, err := skillSetupStat(legacyPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(errOut, " ⚠️ 无法检查已退役 Skill 残留 %s: %v\n", legacyPath, err)
}
return
}
if err := skillSetupRemoveAll(legacyPath); err != nil {
fmt.Fprintf(errOut, " ⚠️ 已退役 Skill 清理失败(已安装 %s) %s: %v\n", multiSharedSkill, legacyPath, err)
return
}
fmt.Fprintf(out, " × 已清理已退役 Skill 残留 %s\n", legacyPath)
}
func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
sort.Strings(dests)
for _, dest := range dests {
@@ -910,303 +664,16 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
return installed, skipped, nil
}
func installMultiSkillsWithEventMigration(
src string,
skillNames []string,
dests []string,
migrationTargets []string,
out, errOut io.Writer,
) (installed, skipped int, err error) {
if len(migrationTargets) == 0 {
return skillSetupInstallMulti(src, skillNames, dests, out, errOut)
}
migrationSet := make(map[string]struct{}, len(migrationTargets))
for _, dest := range migrationTargets {
migrationSet[dest] = struct{}{}
}
var ordinaryTargets []string
for _, dest := range dests {
if _, migrates := migrationSet[dest]; !migrates {
ordinaryTargets = append(ordinaryTargets, dest)
}
}
if len(ordinaryTargets) > 0 {
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut)
installed += n
skipped += nSkipped
if err != nil {
return installed, skipped, err
}
if nSkipped > 0 {
return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
}
}
// The folded pair is excluded from the ordinary best-effort installer. All
// other selected skills (especially dingtalk-shared) must succeed before the
// old Event route is touched.
for _, dest := range migrationTargets {
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
}
var prerequisiteNames []string
for _, name := range skillNames {
if name != multiEventSkill && name != multiMiscSkill {
prerequisiteNames = append(prerequisiteNames, name)
}
}
if len(prerequisiteNames) > 0 {
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut)
installed += n
skipped += nSkipped
if err != nil {
return installed, skipped, err
}
if nSkipped > 0 {
return installed, skipped, fmt.Errorf("event Skill 迁移前置安装不完整(skipped=%d);已保留折叠版 Event/misc", nSkipped)
}
}
migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut)
installed += migrated
if migrationErr != nil {
return installed, skipped, migrationErr
}
return installed, skipped, nil
}
type eventMiscMigration struct {
dest string
stageRoot string
stagedEvent string
stagedMisc string
backupEvent string
backupMisc string
eventPath string
miscPath string
eventBackedUp bool
miscBackedUp bool
newEventEnabled bool
newMiscEnabled bool
}
func prepareEventMiscMigration(src, dest string) (*eventMiscMigration, error) {
stageRoot, err := skillSetupMkdirTemp(dest, ".dws-event-migration-")
if err != nil {
return nil, fmt.Errorf("无法在目标文件系统创建 Event Skill 迁移 staging %s: %w", dest, err)
}
migration := &eventMiscMigration{
dest: dest,
stageRoot: stageRoot,
stagedEvent: filepath.Join(stageRoot, "new-event"),
stagedMisc: filepath.Join(stageRoot, "new-misc"),
backupEvent: filepath.Join(stageRoot, "old-event"),
backupMisc: filepath.Join(stageRoot, "old-misc"),
eventPath: filepath.Join(dest, multiEventSkill),
miscPath: filepath.Join(dest, multiMiscSkill),
}
cleanupOnError := func(cause error) (*eventMiscMigration, error) {
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
cause = errors.Join(cause, fmt.Errorf("清理 staging %s 失败: %w", stageRoot, cleanupErr))
}
return nil, cause
}
if err := skillSetupCopyDir(filepath.Join(src, multiEventSkill), migration.stagedEvent); err != nil {
return cleanupOnError(fmt.Errorf("预备 dingtalk-event 失败 %s: %w", dest, err))
}
if err := skillSetupCopyDir(filepath.Join(src, multiMiscSkill), migration.stagedMisc); err != nil {
return cleanupOnError(fmt.Errorf("预备 dingtalk-misc 失败 %s: %w", dest, err))
}
if err := validateEventMigrationSkillRoot(migration.stagedEvent); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedEvent, err))
}
if err := validateMigrationSkillRoot(migration.stagedMisc, multiMiscSkill, nil); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
}
if err := validateCleanEventMiscRoot(migration.stagedMisc); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
}
return migration, nil
}
func migrateEventMiscAtomically(src string, dests []string, out, errOut io.Writer) (int, error) {
sortedDests := append([]string(nil), dests...)
sort.Strings(sortedDests)
migrations := make([]*eventMiscMigration, 0, len(sortedDests))
// Stage every target before switching any target. This prevents a source or
// copy failure on a later Agent home from leaving earlier homes upgraded.
for _, dest := range sortedDests {
migration, err := prepareEventMiscMigration(src, dest)
if err != nil {
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
return 0, err
}
migrations = append(migrations, migration)
}
committed := make([]*eventMiscMigration, 0, len(migrations))
for _, migration := range migrations {
if err := commitEventMiscMigration(migration); err != nil {
rollbackErr := rollbackEventMiscMigrations(committed)
if rollbackErr != nil {
err = errors.Join(err, fmt.Errorf("已切换目标回滚失败: %w", rollbackErr))
}
var recoveryRoots []string
for _, candidate := range migrations {
if eventMiscMigrationNeedsRecovery(candidate) {
recoveryRoots = append(recoveryRoots, candidate.stageRoot)
}
}
if len(recoveryRoots) > 0 {
err = errors.Join(err, fmt.Errorf("回滚不完整,已保留恢复目录(请勿删除): %s", strings.Join(recoveryRoots, ", ")))
}
if cleanupErr := cleanupEventMiscStages(migrations, true, errOut); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
return 0, err
}
committed = append(committed, migration)
}
for _, migration := range migrations {
fmt.Fprintf(out, " ✓ %s\n", migration.eventPath)
fmt.Fprintf(out, " ✓ %s(Event 原子迁移)\n", migration.miscPath)
}
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
fmt.Fprintf(errOut, " ⚠️ Event Skill 迁移已完成,但 staging 清理不完整: %v\n", cleanupErr)
}
return len(migrations) * 2, nil
}
func cleanupEventMiscStages(migrations []*eventMiscMigration, preserveRecovery bool, errOut io.Writer) error {
var cleanupErr error
for _, migration := range migrations {
if preserveRecovery && eventMiscMigrationNeedsRecovery(migration) {
fmt.Fprintf(errOut, " ⚠️ 已保留 Event Skill 恢复目录 %s\n", migration.stageRoot)
continue
}
if err := skillSetupRemoveAll(migration.stageRoot); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("清理 Event Skill staging %s 失败: %w", migration.stageRoot, err))
}
}
return cleanupErr
}
func eventMiscMigrationNeedsRecovery(migration *eventMiscMigration) bool {
return migration.eventBackedUp || migration.miscBackedUp || migration.newEventEnabled || migration.newMiscEnabled
}
func commitEventMiscMigration(migration *eventMiscMigration) error {
eventExists, err := skillSetupPathExists(migration.eventPath)
if err != nil {
return fmt.Errorf("无法检查旧 dingtalk-event %s: %w", migration.dest, err)
}
miscExists, err := skillSetupPathExists(migration.miscPath)
if err != nil {
return fmt.Errorf("无法检查旧 dingtalk-misc %s: %w", migration.dest, err)
}
if !miscExists {
return fmt.Errorf("event Skill 迁移中止:折叠版 dingtalk-misc 已不存在 %s", migration.dest)
}
rollbackFailure := func(cause error) error {
if rollbackErr := rollbackEventMiscMigration(migration); rollbackErr != nil {
return errors.Join(cause, fmt.Errorf("回滚 Event/misc 失败 %s: %w", migration.dest, rollbackErr))
}
return cause
}
if eventExists {
if err := skillSetupRename(migration.eventPath, migration.backupEvent); err != nil {
return fmt.Errorf("备份旧 dingtalk-event 失败 %s: %w", migration.dest, err)
}
migration.eventBackedUp = true
}
if err := skillSetupRename(migration.stagedEvent, migration.eventPath); err != nil {
return rollbackFailure(fmt.Errorf("切换 dingtalk-event 失败 %s: %w", migration.dest, err))
}
migration.newEventEnabled = true
if err := skillSetupRename(migration.miscPath, migration.backupMisc); err != nil {
return rollbackFailure(fmt.Errorf("备份旧 dingtalk-misc 失败 %s: %w", migration.dest, err))
}
migration.miscBackedUp = true
if err := skillSetupRename(migration.stagedMisc, migration.miscPath); err != nil {
return rollbackFailure(fmt.Errorf("切换 dingtalk-misc 失败 %s: %w", migration.dest, err))
}
migration.newMiscEnabled = true
return nil
}
func rollbackEventMiscMigrations(migrations []*eventMiscMigration) error {
var rollbackErr error
for i := len(migrations) - 1; i >= 0; i-- {
if err := rollbackEventMiscMigration(migrations[i]); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
return rollbackErr
}
func rollbackEventMiscMigration(migration *eventMiscMigration) error {
move := func(enabled *bool, from, to, label string) error {
if !*enabled {
return nil
}
if err := skillSetupRename(from, to); err != nil {
return fmt.Errorf("%s: %w", label, err)
}
*enabled = false
return nil
}
// Stop at the first rollback failure. In particular, do not remove the
// already-working standalone Event while the folded misc route has not been
// restored: even an incomplete rollback must leave at least one Event entry
// point live and preserve the remaining assets in staging for recovery.
steps := []struct {
enabled *bool
from string
to string
label string
}{
{&migration.newMiscEnabled, migration.miscPath, migration.stagedMisc, "移出新 dingtalk-misc"},
{&migration.miscBackedUp, migration.backupMisc, migration.miscPath, "恢复旧 dingtalk-misc"},
{&migration.newEventEnabled, migration.eventPath, migration.stagedEvent, "移出新 dingtalk-event"},
{&migration.eventBackedUp, migration.backupEvent, migration.eventPath, "恢复旧 dingtalk-event"},
}
for _, step := range steps {
if err := move(step.enabled, step.from, step.to, step.label); err != nil {
return err
}
}
return nil
}
func skillSetupPathExists(path string) (bool, error) {
_, err := skillSetupStat(path)
switch {
case err == nil:
return true, nil
case errors.Is(err, os.ErrNotExist):
return false, nil
default:
return false, err
}
}
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
// dest as a sibling skill directory. installed/skipped is counted per
// (agent-home × sub-skill) pair so the user sees granular progress.
func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
//
// filtered mirrors whether runSkillSetup saw -s/--skill or -x/--exclude:
// a filtered install stays additive and never touches siblings outside the
// requested set; a full (unfiltered) install additionally removes stale
// dingtalk-* / dws-shared directories that are no longer in the bundle,
// matching install.sh / install.ps1 / install.js / upgrade paths.
func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer, filtered bool) (installed, skipped int, err error) {
sort.Strings(dests)
for _, dest := range dests {
// 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留)
@@ -1218,7 +685,10 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
continue
}
sharedInstalled := false
if !filtered {
removeStaleMultiSkills(dest, skillNames, out, errOut)
}
for _, name := range skillNames {
subSrc := filepath.Join(src, name)
subDest := filepath.Join(dest, name)
@@ -1234,17 +704,70 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
}
fmt.Fprintf(out, " ✓ %s\n", subDest)
installed++
if name == multiSharedSkill {
sharedInstalled = true
}
}
if sharedInstalled {
cleanupLegacyMultiSharedSkill(dest, out, errOut)
}
}
return installed, skipped, nil
}
// staleMultiSkillVictims lists the dingtalk-* / dws-shared directories under
// dest that a full (unfiltered) multi install would delete because they are
// not part of the bundle. It is the read-only preview companion of
// removeStaleMultiSkills; scan failures degrade to a nil list so the
// confirmation prompt is never blocked by an unreadable agent home.
func staleMultiSkillVictims(dest string, keep []string) []string {
entries, err := skillSetupReadDir(dest)
if err != nil {
return nil
}
keepSet := make(map[string]bool, len(keep))
for _, n := range keep {
keepSet[n] = true
}
var victims []string
for _, e := range entries {
if !e.IsDir() || keepSet[e.Name()] {
continue
}
if !strings.HasPrefix(e.Name(), multiSkillPrefix) && e.Name() != multiSharedSkill {
continue
}
victims = append(victims, filepath.Join(dest, e.Name()))
}
sort.Strings(victims)
return victims
}
// removeStaleMultiSkills deletes dingtalk-* / dws-shared directories under
// dest that are not part of the current bundle. Best-effort: scan/removal
// failures warn on errOut and never abort the install.
func removeStaleMultiSkills(dest string, keep []string, out, errOut io.Writer) {
entries, err := skillSetupReadDir(dest)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(errOut, " ⚠️ 过期 skill 扫描失败(继续安装) %s: %v\n", dest, err)
}
return
}
keepSet := make(map[string]bool, len(keep))
for _, n := range keep {
keepSet[n] = true
}
for _, e := range entries {
if !e.IsDir() || keepSet[e.Name()] {
continue
}
if !strings.HasPrefix(e.Name(), multiSkillPrefix) && e.Name() != multiSharedSkill {
continue
}
stale := filepath.Join(dest, e.Name())
if err := skillSetupRemoveAll(stale); err != nil {
fmt.Fprintf(errOut, " ⚠️ 过期 skill 清理失败(继续安装) %s: %v\n", stale, err)
continue
}
fmt.Fprintf(out, " × 已清理过期 skill %s\n", stale)
}
}
func copyDir(src, dst string) error {
return skillSetupWalk(src, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {

Some files were not shown because too many files have changed in this diff Show More