Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5c8982c00 | ||
|
|
402429ac2a |
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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') }}
|
||||
|
||||
@@ -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
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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; \
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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。
|
||||
@@ -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,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`。
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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
@@ -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` 本身不会读取或搜索业务内容。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 第一步:安装 dws
|
||||
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
|
||||
@@ -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 查询业务数据。
|
||||
|
||||
|
||||
@@ -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
|
||||
+115
-1401
File diff suppressed because it is too large
Load Diff
@@ -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 文案 | 仍按计划执行,与本文正交;备份式安装落地时直接写进引擎,天然覆盖所有面 |
|
||||
@@ -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(缺了就是现在这副「能装不能管」的样子)。
|
||||
@@ -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 + 原因),**禁止**用安装默认值绕过
|
||||
@@ -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
|
||||
(若未来生态工具补齐版本固定能力,可重新评估本决策)。
|
||||
@@ -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。
|
||||
@@ -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。
|
||||
@@ -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 稳定 |
|
||||
@@ -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 请求头。
|
||||
@@ -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)。*
|
||||
@@ -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。*
|
||||
@@ -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。
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 := ¶mAliasCaptureCaller{}
|
||||
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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -302,6 +302,7 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
|
||||
_ = newConfigCommand()
|
||||
_ = newCacheCommand()
|
||||
_ = newVersionCommand()
|
||||
_ = newRecoveryCommand(&GlobalFlags{})
|
||||
_ = newAPICommand(&GlobalFlags{})
|
||||
_ = NewRootCommand(context.Background())
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func newDoctorCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "环境健康检查",
|
||||
Long: "一键检查登录态、网络连通性和版本更新,快速定位常见问题。",
|
||||
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runDoctor,
|
||||
@@ -92,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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 := ¶mAliasCaptureCaller{}
|
||||
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 := ¶mAliasCaptureCaller{}
|
||||
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 := ¶mAliasCaptureCaller{}
|
||||
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 {
|
||||
|
||||
@@ -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 := ¶mAliasCaptureCaller{}
|
||||
_, 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 := ¶mAliasCaptureCaller{}
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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{},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user