Compare commits

..
Author SHA1 Message Date
玉澜 8802dcf4dc fix(corecmd): support strongly-typed DTOs in event/auto wait modes
waitResource previously asserted result.Data() to map[string]any, which
caused event mode and auto mode to fail with 'data is not an object'
when commands returned strongly-typed DTOs (struct or struct pointer).

Now normalizes via JSON round-trip for non-map types, preserving the
fast path for map[string]any. Added regression tests covering struct
values, struct pointers, nested dotted queries, and error cases.
2026-08-19 15:14:22 +08:00
玉澜 28bb377e66 fix: remove stale allowlist entry for drive_tree_list.py
The file only exists in skills/mono/scripts/, not in skills/multi/,
so the allowlist entry was causing TestMonoMultiSkillContentG4Drift
to fail. The script is already referenced in both mono and multi
documentation, so no allowlist entry is needed.
2026-08-19 15:02:54 +08:00
玉澜 a0fe8d70c2 Merge remote-tracking branch 'upstream/main' into feat/wait-framework 2026-08-19 14:29:57 +08:00
玉澜 4770e5a8e6 Merge remote-tracking branch 'upstream/main' into feat/wait-framework 2026-08-19 14:22:29 +08:00
玉澜 0bcc2f27c6 fix(corecmd): sync operation.state on terminal wait close and canonicalize wait statuses
Terminal success/failure closes kept the acceptance-phase operation state,
emitting self-contradicting envelopes (outcome=success with state=processing).
WithOperationTerminalState now closes the envelope at the observed terminal
status and clears timed_out.

WaitSpec.Validate trimmed status values only for checking and never wrote them
back, so padded declarations passed validation, published a padded Schema, and
then fail-closed at runtime as unknown statuses. NormalizeWaitSpec is now the
single canonical form shared by declaration (corecmd.New / AttachContract),
ToolSpec, and validation: trimmed values, duplicate/conflict rejection,
defensive copy.

Also covers the waitTimeoutDuration secs<=0 branch flagged by the coverage
gate.
2026-08-19 14:17:11 +08:00
john 0b3abfad4b Merge branch 'main' into feat/wait-framework 2026-08-19 11:21:16 +08:00
玉澜 e625da4c27 fix(corecmd): reject overflowing --wait-timeout seconds
int(secs)*time.Second can wrap a pflag-legal MaxInt64 into a non-positive
duration, which skipped the wait deadline and waited forever. Convert with
an overflow check and return a validation error instead.
2026-08-18 16:59:28 +08:00
玉澜 c68603fea0 fix(corecmd): wait only on pending and honor wait-timeout on leaf I/O
Only a pending ResultInvoke envelope enters the wait phase, so success,
failure, and partial results are returned unchanged. WaitPoll/WaitEvents
now receive the --wait-timeout deadline (and Command().Context() is bound
to the same loop context) so a blocked poll or subscribe cannot hang past
the declared timeout.

Also allowlist the leftover multi drive_tree_list.py orphan that broke CI
after merging main.
2026-08-18 16:32:08 +08:00
john f118a369b0 Merge branch 'main' into feat/wait-framework 2026-08-18 15:34:12 +08:00
玉澜 b7aa6bddf5 feat(corecmd): implement event and auto wait modes
Completes the wait capability per review guidance ("add corresponding
execution hooks and fallback tests for the modes"):

- contract.WaitSpec restores event/auto modes with event_key,
  match_field, and a new resource_query (dotted path into the accepted
  result data yielding the identifier events correlate against);
  per-mode validation of required fields
- internal/wait adds EventStream (leaf-owned transport) and RunEvent:
  correlated-event filtering, the same terminal/pending/unknown mapping
  as polling, timed-out pending on deadline during consumption, and an
  ErrEventStreamEnded sentinel distinguishing stream termination from
  fail-closed status errors
- Spec.WaitEvents hook; validateWaitDecl pairs mode with hooks
  (poll<->WaitPoll, event<->WaitEvents, auto<->both; surplus hooks
  rejected too)
- the wait phase runs event-first in auto mode and falls back to polling
  when the stream ends or the subscription fails, under one deadline
  spanning both phases; strict event mode surfaces stream errors
- output.CommandResult gains Data() (deep copy) so the framework can
  resolve the resource identifier without exposing mutable state

Changed-code coverage re-verified at 100% (CI cross-package recipe).
2026-08-15 19:29:22 +08:00
玉澜 64ad5f22b0 test(cli): cover Wait capability projection (validate/normalize/payload) 2026-08-15 18:53:48 +08:00
玉澜 c68207ad4b fix(corecmd): CR feedback — poll-only wait, deadline-safe loop, ResultInvoke pairing
Addresses the three P1 findings from review 4942891040:

1. event/auto modes were declared but always executed polls. WaitSpec now
   accepts poll only (event/auto fail validation with a not-implemented
   message); event_key/match_field dead fields removed. Event waiting will
   land with its own execution path and mode constant.
2. a deadline reached during the between-poll sleep re-polled with a
   cancelled context, so a context-aware poller surfaced its error as a poll
   failure instead of the contracted timed-out pending. The wait between
   polls now uses a timer + select on ctx.Done(), the deadline is checked
   before each poll, and a poll error on a cancelled context closes as
   timed-out pending with the last observed status.
3. legacy Invoke/Orchestrate/RunE commands declaring Wait observed a failure
   terminal while still exiting 0. validateWaitDecl now requires the
   ResultInvoke dispatcher (the only path whose unified envelope can be
   closed); the wait phase no longer wraps legacy paths.

Also: dropped the unreachable nonPendingTerminal branch, simplified
waitTimeoutSecs to the flag value (registration always seeds the reviewed
default), and raised changed-code coverage to 100% (new wait-engine edge
tests, output With* unit tests, contractfinal deep-copy coverage,
AttachContract invalid-Wait panic path).
2026-08-15 18:16:24 +08:00
玉澜 4f57967c56 Merge remote-tracking branch 'upstream/main' into feat/wait-framework
# Conflicts:
#	internal/corecmd/corecmd.go
2026-08-15 17:59:10 +08:00
玉澜 b49bc0ed14 feat(corecmd): add declarative Wait capability (Contract.Wait + wait phase)
Framework-only: adds the reviewed wait contract mirroring the DryRunSpec
pattern (types declaration -> ContractDecl -> ContractFinal -> ToolSpec ->
Schema wait key). No business command declares it yet.

- contract.WaitSpec (mode poll/event/auto, poll_command, status_query,
  terminal status->success/failure map, pending_values, event_key,
  match_field, default_timeout_secs) with closed-set Validate
- Spec.WaitPoll hook pairs with the declaration at construction time
  (declared without hook / hook without declaration both panic)
- declared leaves register --wait / --wait-timeout natively (never
  FlagSpec, so they cannot enter MCP toolArgs); undeclared leaves reject
  the flags as unknown instead of ignoring them
- internal/wait engine: immediate-first-poll, x1.5 backoff capped 30s,
  dotted status extraction, fail-closed on unknown status, timeout ->
  pending
- ResultInvoke path closes the unified envelope: success terminal ->
  success, failure terminal -> failure with new wire-stable
  error.type "wait" (exit code 8, additive like partial=7), timeout ->
  pending + meta.operation.timed_out with last observed state (exit 0)
- output.WithOutcome / WithErrorInfo / WithOperationTimedOut preserve
  envelope invariants (I2/I3, pending requires meta.operation)

Verified: go test ./... green; check-generated-drift.sh ok (schema
assembly deterministic, wire unchanged); check-schema-catalog.sh ok
(27 products, 1121 tools).
2026-08-15 12:41:46 +08:00
136 changed files with 4344 additions and 23182 deletions
@@ -1,5 +0,0 @@
---
category: Changed
---
- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility.
-5
View File
@@ -1,5 +0,0 @@
---
category: Added
---
- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。
File diff suppressed because one or more lines are too long
-6
View File
@@ -1,6 +0,0 @@
---
category: Fixed
---
- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending.
- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery.
@@ -1,5 +0,0 @@
---
category: Changed
---
- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional `NoOpt=true` boolean flags from Help and Schema when their activated capability moves to a dedicated command, with legacy-leaf, complete parameter/constant mapping, durable runtime constant evidence, protected framework bridges, dry-run preservation, parameter-collision, and fail-closed required-parameter checks.
+26
View File
@@ -0,0 +1,26 @@
---
category: Added
---
- **Wait framework capability** — adds the reviewed `Contract.Wait`
declaration (`contract.WaitSpec`) with three execution modes: `poll`
(cadence-poll the leaf's `WaitPoll` hook), `event` (consume the leaf's
`WaitEvents` push stream, correlate events to the accepted resource via
`match_field`/`resource_query`, apply the same terminal map), and `auto`
(event first, fall back to polling when the stream ends or the
subscription fails — one deadline spans both phases). Declared commands
must use the `ResultInvoke` dispatcher; mode and hooks are paired at
construction (poll↔WaitPoll, event↔WaitEvents, auto↔both; surplus hooks
are rejected too). Declared commands register `--wait` /
`--wait-timeout` (framework-owned flags that never enter MCP toolArgs);
undeclared commands reject the flags as unknown. The wait phase closes
the unified envelope exactly once: terminal success → `success`,
terminal failure → `failure` with new wire-stable `error.type: "wait"`
(exit code 8), timeout → `pending` with `meta.operation.timed_out: true`
and the last observed state (exit 0). Deadline exhaustion during a poll,
during event consumption, or between polls always closes as timed-out
pending, never as a poll/stream failure; a correlated event with an
unknown status fails closed exactly like a poll. The capability is
projected into the Schema catalog (`wait` key) alongside `dry_run`. No
business command declares it yet; approval/export/batch adoption lands
separately.
-6
View File
@@ -512,12 +512,6 @@ jobs:
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
run: node .github/reviewer-routing.test.js
- name: Test npm installer smoke (prune, backup, publish)
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
env:
XDG_CONFIG_HOME: ""
run: node test/scripts/install_js_smoke.mjs
test-focused:
name: "Test (focused: ${{ matrix.shard }})"
needs: lint
+2 -2
View File
@@ -74,9 +74,9 @@ coverage is additionally selected for platform-sensitive code.
`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 Help / Schema compatibility migration governance](docs/cli-interface-flag-migrations.md)
[CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
for the reviewed two-stage `pending` → `consumed` lifecycle.
Agent-visible flag or command-path migrations must also run
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.
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
+2 -2
View File
@@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
The upgrade process follows a two-phase atomic flow to ensure consistency:
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents classified by the pinned compatibility registry as supporting the universal root read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice.
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
@@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> Installers use `$HOME/.agents/skills/` as the canonical global store, following the universal `.agents/skills` convention. Agents classified by the pinned compatibility registry as universal read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
+126 -1214
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,6 +32,6 @@
"README.md"
],
"engines": {
"node": ">=16.7.0"
"node": ">=16"
}
}
+4 -60
View File
@@ -157,16 +157,6 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
"",
"candidate flag migration manifest",
)
approvedCommandMigrationsPath := flags.String(
"approved-command-migrations",
"",
"merge-base-owned approved command migration manifest",
)
candidateCommandMigrationsPath := flags.String(
"candidate-command-migrations",
"",
"candidate command migration manifest",
)
if err := flags.Parse(args); err != nil {
return false, err
}
@@ -184,13 +174,8 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
)
}
if (*approvedCommandMigrationsPath == "") != (*candidateCommandMigrationsPath == "") {
return false, fmt.Errorf(
"--approved-command-migrations and --candidate-command-migrations must be provided together",
)
}
if (*approvedMigrationsPath != "" || *approvedCommandMigrationsPath != "") && (*basePath == "" || *stablePath == "") {
return false, fmt.Errorf("migration compare requires both --base and --stable")
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
}
current, err := readSnapshot(*currentPath)
@@ -212,39 +197,7 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
}
report := interfacesnapshot.CompareAll(current, references)
if *approvedCommandMigrationsPath != "" {
flagApproved := interfacesnapshot.FlagMigrationManifest{Version: interfacesnapshot.FlagMigrationManifestVersion, Migrations: []interfacesnapshot.FlagMigration{}}
flagCandidate := flagApproved
if *approvedMigrationsPath != "" {
flagApproved, err = readFlagMigrationManifest(*approvedMigrationsPath)
if err != nil {
return false, fmt.Errorf("read approved flag migrations: %w", err)
}
flagCandidate, err = readFlagMigrationManifest(*candidateMigrationsPath)
if err != nil {
return false, fmt.Errorf("read candidate flag migrations: %w", err)
}
}
commandApproved, readErr := readCommandMigrationManifest(*approvedCommandMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read approved command migrations: %w", readErr)
}
commandCandidate, readErr := readCommandMigrationManifest(*candidateCommandMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read candidate command migrations: %w", readErr)
}
report, err = interfacesnapshot.CompareAllWithInterfaceMigrations(
current,
references,
flagApproved,
flagCandidate,
commandApproved,
commandCandidate,
)
if err != nil {
return false, fmt.Errorf("validate interface migration lifecycle: %w", err)
}
} else if *approvedMigrationsPath != "" {
if *approvedMigrationsPath != "" {
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
@@ -281,15 +234,6 @@ func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationMani
return interfacesnapshot.ReadFlagMigrationManifest(file)
}
func readCommandMigrationManifest(path string) (interfacesnapshot.CommandMigrationManifest, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.CommandMigrationManifest{}, err
}
defer file.Close()
return interfacesnapshot.ReadCommandMigrationManifest(file)
}
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
for _, command := range snapshot.Commands {
path := strings.TrimPrefix(command.Path, "dws")
@@ -336,5 +280,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] [--approved-command-migrations FILE --candidate-command-migrations FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
}
-123
View File
@@ -166,102 +166,6 @@ func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testi
}
}
func TestCrossPlatformCoverageRunCompareCommandMigrationInputs(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
emptyFlag := writeManifest(t, dir, "empty-flags.json", `{"version":1,"migrations":[]}`)
emptyCommand := writeManifest(t, dir, "empty-commands.json", `{"version":1,"migrations":[]}`)
invalid := writeManifest(t, dir, "invalid-commands.json", `{`)
var stdout, stderr bytes.Buffer
args := []string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
"--stable", snapshotPath,
"--approved-flag-migrations", emptyFlag,
"--candidate-flag-migrations", emptyFlag,
"--approved-command-migrations", emptyCommand,
"--candidate-command-migrations", emptyCommand,
}
if exitCode := run(args, &stdout, &stderr); exitCode != 0 {
t.Fatalf("combined migration compare exit=%d stderr=%s", exitCode, stderr.String())
}
for _, test := range []struct {
name string
approved string
candidate string
want string
}{
{"approved flag", invalid, emptyFlag, "read approved flag migrations"},
{"candidate flag", emptyFlag, invalid, "read candidate flag migrations"},
} {
t.Run(test.name, func(t *testing.T) {
stdout.Reset()
stderr.Reset()
testArgs := []string{
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
"--approved-flag-migrations", test.approved,
"--candidate-flag-migrations", test.candidate,
"--approved-command-migrations", emptyCommand,
"--candidate-command-migrations", emptyCommand,
}
if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
t.Fatalf("combined flag error exit=%d stderr=%s", exitCode, stderr.String())
}
})
}
for _, test := range []struct {
name string
approved string
candidate string
want string
}{
{"approved", invalid, emptyCommand, "read approved command migrations"},
{"candidate", emptyCommand, invalid, "read candidate command migrations"},
} {
t.Run(test.name, func(t *testing.T) {
stdout.Reset()
stderr.Reset()
testArgs := []string{
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
"--approved-command-migrations", test.approved,
"--candidate-command-migrations", test.candidate,
}
if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
t.Fatalf("command manifest error exit=%d stderr=%s", exitCode, stderr.String())
}
})
}
stderr.Reset()
if exitCode := run([]string{
"compare", "--current", snapshotPath, "--base", snapshotPath,
"--approved-command-migrations", emptyCommand,
}, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "provided together") {
t.Fatalf("one-sided command manifest exit=%d stderr=%s", exitCode, stderr.String())
}
if _, err := readCommandMigrationManifest(filepath.Join(dir, "missing.json")); err == nil {
t.Fatal("missing command migration manifest unexpectedly read")
}
if _, err := readCommandMigrationManifest(invalid); err == nil {
t.Fatal("invalid command migration manifest unexpectedly read")
}
pending := writeManifest(t, dir, "pending-command.json", commandMigrationManifestJSON("pending"))
consumed := writeManifest(t, dir, "consumed-command.json", commandMigrationManifestJSON("consumed"))
stderr.Reset()
if exitCode := run([]string{
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
"--approved-command-migrations", pending,
"--candidate-command-migrations", consumed,
}, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "validate interface migration lifecycle") {
t.Fatalf("command lifecycle error exit=%d stderr=%s", exitCode, stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
@@ -663,33 +567,6 @@ func flagMigrationManifestJSON(state string) string {
}`, "STATE", state, 1)
}
func commandMigrationManifestJSON(state string) string {
return strings.Replace(`{
"version": 1,
"migrations": [{
"kind": "command_move",
"legacy": {
"command": "dws chat message old",
"before": {"present": true, "runnable": true},
"after": {"present": true, "runnable": true, "hidden": true}
},
"replacement": {
"command": "dws chat topic new",
"before": {"present": false},
"after": {"present": true, "runnable": true}
},
"schema": {
"product_id": "chat",
"source_tool_id": "chat.move",
"replacement_tool_id": "chat.move",
"parameters": []
},
"state": "STATE",
"reason": "reviewed command migration"
}]
}`, "STATE", state, 1)
}
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
for _, flag := range flags {
if flag.Name == name && flag.Type == flagType {
+5 -5
View File
@@ -184,11 +184,11 @@ candidate SHA。
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
flag 迁移。
Schema compatibility 使用同一组 base、stable、candidate refs,以及 base-owned flag
与 command migration ledgers。merge-base-owned checker 分别规范化 merge-base 与
stable 的完整 Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过
Interface lifecycle 的 exact rename、command move 或 flag extraction 规范化到当前历史
副本,不会维护第二份 allowlist,也不会放宽其他 Schema 历史字段。
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 a release-seal branch that archives rendered fragments:
+2 -49
View File
@@ -1,9 +1,7 @@
# CLI Help / Schema 兼容迁移治理
# CLI flag 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
@@ -33,7 +31,7 @@ Smoke fixture,不参与迁移审批。
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
调用方因漏传历史参考而提前清理 consumed receipt。
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入 flag 机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空 flag 清单。后续引入 command migration 扩展时,base 已拥有 flag comparator;bootstrap 仍只执行 base-owned 普通比较,不向旧 helper 传入新的 command ledger,因此允许随治理 PR 提交仍处于 before 的 pending 计划,也不会授予任何迁移豁免。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
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 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
@@ -41,55 +39,10 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁
```text
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
scripts/policy/interface-migrations/approved-command-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
## 跨命令迁移原语
`approved-command-migrations-v1.json` 只接受两种 `kind`:
| kind | CLI after 状态 | Schema 允许的精确投影 |
|---|---|---|
| `command_move` | legacy 命令仍 runnable、由 visible 变 hidden;replacement 由 absent 变 visible runnable | 同一 stable tool identity 的 `primary_cli_path` 改到 replacement;只允许清单列出的参数改名,参数类型、property、requiredness、default 等必须等价 |
| `flag_extraction` | legacy 命令保持 visible runnable;指定 legacy flag 仍可执行但由 visible 变 hidden;replacement 由 absent 变 visible runnable | source tool 只删除指定参数;replacement tool 必须位于精确的新路径,并保持 source 的 interface 与 safety identity;清单必须完整列出每个 source 参数到 replacement 参数或常量 property 的承接关系 |
`command_move` 只能隐藏没有子命令的 legacy leaf,且 legacy 与 replacement
不得互为祖先路径;整棵命令树的迁移需要单独设计逐叶治理,不能复用这一原语。
稳定 Schema tool 可以继续接受普通的 optional 参数新增,但不得借路径迁移引入清单未登记的
`required`、`cli_required` 或 `required_when` 参数;参数改名的目标也不得与历史
Schema 中已有的其他参数重名,避免把两个历史参数静默合并。`flag_extraction` 只接受
optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。它必须对 source tool
的全部历史参数逐项声明:普通参数使用精确 `from` → `to`(同名也必须显式写出),且恰好
一个与 legacy flag 同名的 `from` 使用 `replacement_constant`,不得同时声明 `to`;所有
`from` 与 replacement 参数/property 目标必须唯一。legacy bool flag 的 `no_opt` 必须等于
常量布尔值的字符串形式。v1 只治理 optional bool flag 的 `NoOpt=true` 激活分支,因此
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
必须通过 `NewLeafCommand` 的 `ConstParams` 声明并实际注入
`convThreadEnabled=true`;手写 `RunE` 固定值、Cobra annotation 或只改清单都不能提供这份
同源证据,Snapshot 只读取 `corecmd` 包内私有注册表公开的只读副本。第一次向旧快照增加
bool 常量证据属于 bootstrap;一旦任一历史快照已记录该
证据,普通 Interface Compare 会持续要求 property/value 集合完全一致,因此 ledger 清理后
删除、翻转或增加常量仍会阻塞。若 candidate 改动 command ledger,则
`internal/corecmd/corecmd.go`、`internal/corecmd/interface_const_params.go` 与
`internal/helpers/leaf.go` 三份执行/证据桥必须保持 base Git blob 不变;框架演进必须先用
独立 PR 合入,不能和产品消费混在一起。
replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` 非空时不得删除或改值;
历史未声明时允许 replacement 新增 dry-run。这与普通 Schema 兼容规则保持同一单调边界。
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。
## 两阶段迁移与回执清理
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
@@ -360,6 +360,7 @@ Definition(仅声明;不可编译)
| | `idempotency` | 评审源(或未来 Contract) | reviewed metadata | 今日非框架声明;不得推断 |
| | `effect_source` / provenance | 组装派生物 | resolver 写入 `FieldProvenance` | 派生,不手写 |
| **DryRun** | `preview_kind`, `remote_reads` | 评审源 | `schema_dry_run_capabilities`(正能力声明) | 否;无条目 ≠ 推断「不支持」之外的假能力 |
| **Wait** | `mode`(`poll`/`event`/`auto`), `poll_command`, `status_query`, `terminal`(状态→success/failure), `pending_values`, `event_key`/`match_field`/`resource_query`(event/auto), `default_timeout_secs` | **声明**(`ContractDecl.Wait` 正能力声明,且必须搭配 ResultInvoke dispatcher + 按模式的 hook:poll↔`WaitPoll`、event↔`WaitEvents`、auto↔两者,构造期配对校验,多余 hook 同样拒绝) | 声明后注册 `--wait`/`--wait-timeout`(框架 flag,不进 toolArgs);Schema 投影 `wait` 键;auto = 事件优先、流终止/订阅失败回退轮询,一个 deadline 覆盖两阶段并传入 `WaitPoll`/`WaitEvents`(及 `Command().Context()`);仅 pending 初始结果进入等待,success/failure/partial 原样返回 | 否;未声明命令传 `--wait` = unknown flag。终态失败经统一信封 `error.type: "wait"`(rc=8),超时保持 pending + `meta.operation.timed_out`(rc=0);轮询间/轮询中/事件消费中超时一律按 pending 关闭 |
| **Interface** | `interface_mode`, `interface_ref`, `availability`, `reason` | 评审源 | MCP meta + agent metadata 解析 | 否;与 CLI Identity 分离 |
| **Selection** | `agent_summary`, `use_when`, `avoid_when`, `examples`, `prerequisites`, `tips`, `workflow_refs`, … | 声明(`ContractDecl.Selection` / `ProductDecl`) | `ContractDecl` / `ProductDecl`(`schema_hints/` 已退役) | 可声明;声明载荷**不得携带** `Reviewed`(旧路径专用),携带即组装报错 |
| **FieldProvenance** | 各字段 winner / candidates | 组装派生物 | Schema 组装器 | 派生;须与 delivered value 一致 |
+6 -57
View File
@@ -54,8 +54,8 @@ DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约
| 模式 | Agent 目录布局 | 选择方式 |
|---|---|---|
| multi(默认) | canonical `~/.agents/skills/dingtalk-*/`;非 universal Agent 使用链接或复制兼容层 | 默认;`dws skill setup --mode multi` |
| mono(兼容) | canonical `~/.agents/skills/dws/`;非 universal Agent 使用链接或复制兼容层 | `dws skill setup --mode mono` 或安装器的 mono opt-in |
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in |
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
@@ -140,26 +140,10 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
## 8. Upgrade 与恢复语义
升级器始终先发布 `~/.agents/skills` canonical 集合。固定兼容注册表中被分类为
universal 的 Agent 不再保留 Agent 私有副本;检测到的
非 universal Agent(如 Claude、OpenClaw、Hermes、Windsurf)使用指向 canonical
的目录链接:npm 与 PowerShell 安装器在 Windows 上创建 junction,`dws upgrade` /
`dws skill setup` 创建符号链接(`os.Symlink`)。链接不可用时回退为内容完整的
直接复制,包括未开启开发者模式、因而无法创建符号链接的 Windows。
自定义 `CODEX_HOME`、`CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`AUTOHAND_HOME`、
`GROK_HOME`、`VIBE_HOME`、`XDG_CONFIG_HOME` 与 OpenClaw 历史目录 `.clawdbot`、
`.moltbot` 必须按 Agent 实际优先级解析。
升级器对每个 Agent 目标执行:
Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts` 与 `installer.ts`(基准提交
`c6f69c6`)为契约:76 个 ID 必须完整登记,其中 19 个 universal、57 个
non-universal。`eve`、`promptscript` 没有全局目录,因此全局安装时跳过;多个 Agent
解析到同一个 XDG 目录时按最终绝对路径去重(Windows 大小写不敏感)。DWS 额外支持
Qoderwork(按 non-universal Agent 建立兼容链接);旧版使用的 `.github/skills`、
`.amp/skills`、`.cline/skills` 与
`.windsurf/skills` 仅作为可恢复迁移清理目标,不计入上游 Agent 枚举。
对 universal Agent,上游 installer 的 global 模式明确选择 canonical 并跳过
Agent 私有 global 目录;注册表中的 `globalSkillsDir` 仍用于识别和退役历史 native
路径,不作为 universal symlink 模式的发布目标。
- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback;
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill;
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
2. 在目标文件系统的 staging 中复制完整新集合;
@@ -167,14 +151,6 @@ Agent 私有 global 目录;注册表中的 `globalSkillsDir` 仍用于识别
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
旧集合可能位于外部卷或自定义 Agent 根,而备份固定写入
`~/.dws/skill-backups`。因此备份与反向恢复统一采用 rename-first:同卷直接原子
rename;遇到跨文件系统错误时,在目标所在文件系统创建临时 staging,词法复制并
保留目录/文件权限、普通文件、符号链接及 dangling symlink,校验路径类型、目录项、
文件大小与 SHA256、链接目标后,再将 staging 原子 rename 为正式目标。正式目标
再次校验成功后才删除源路径。复制、校验或发布失败时保留源并清理 staging;源删除
失败时允许源与正式目标同时存在,但必须返回明确错误,不能报告成功。
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
@@ -183,31 +159,11 @@ Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功
## 9. 备份合同
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
- 主要操作:同一文件系统内使用 rename 移动;跨文件系统使用目标卷 staging 的
copy → verify → publish → remove 回退;
- 主要操作:同一文件系统内使用 rename 移动;
- 失败语义:备份失败时原目录保持不变,目标安装失败;
- 恢复语义:反向恢复使用相同回退;若删除备份源失败,原路径和备份可同时存在,
但恢复必须失败并明确提示两份均被保留;
- 可见性:计划和执行日志显示原路径与备份路径;
- 保留策略:自动修剪,仅保留最近 5 批。
跨卷回退只有 staging → 正式目标的发布 rename 是原子的,整次迁移不是跨文件系统
原子事务;该边界由“发布前不删源、发布后再次校验、删除失败保留两份”补偿。Shell
入口继续使用系统 `mv` 的跨文件系统复制/删除能力;Go、npm 与 PowerShell 显式实现
上述验证和失败合同。
原子 no-replace 发布(Linux `RENAME_NOREPLACE`、Darwin `RENAME_EXCL`)依赖底层文件
系统支持:`rename(2)` 只列出 ext4、btrfs、tmpfs 与 cifs,因此 NFS、FUSE 与
overlayfs 家目录会以 `EINVAL` 拒绝该 flag。这些文件系统不得让安装整体失败,而是降级
为原子占位发布:目录目标用 `mkdir` 认领(已占用即 `EEXIST`,认领期间目标始终被本事务
持有,源子项逐个移入认领目录,最终以 rename 覆盖仅属于本事务的空认领或直接移入);
普通文件目标用硬链接占位(同样以 `EEXIST` 拒绝已占用路径)后删除源。任何一步失败都会
回迁已移动的子项并只撤销本事务的占位,被并发创建的对象(文件、符号链接或目录)既不会
被覆盖,也不会被链接进内部。逐子项移动路径不是全量原子可见(降级文件系统上的可接受
边界),但不覆盖契约在所有平台保持不变。Windows `MoveFile` 本身即拒绝已存在的目标,
无需降级。npm 与 Shell 安装面遵循同一占位模型:目录用 `mkdir`/子项移动,链接直接在
目标路径创建(symlink(2) 原子拒绝已占用路径)。
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
`dws skill setup --mode mono`。
@@ -236,7 +192,6 @@ setup 在未显式指定 `--source` 时的本地回退缓存。
| `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 |
| `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 |
| npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 |
| `scripts/install-event.sh` / `install-devapp.*` | 产品 multi 子集 | 同样使用 canonical 与 Agent 兼容层 |
Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。
@@ -254,12 +209,6 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行
- 复制失败不留下 Agent 可见的残缺官方目录;
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
- Windows、macOS、Linux 的路径和覆盖率门禁;
- symlinked parent、npm/PowerShell 的 Windows junction、`dws upgrade` /
`dws skill setup` 的符号链接、链接失败复制回退与 broken link 修复;
- Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级;
- `CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`XDG_CONFIG_HOME` 等自定义根跨文件系统时的
正向备份、反向恢复、普通链接及 dangling symlink 词法保留;
- copy、verify、publish、remove 各阶段故障,以及非跨设备权限错误不得进入复制回退;
- npm、Shell、PowerShell 与包管理器安装冒烟。
## 13. 后续演进
@@ -274,7 +274,6 @@ type agentExampleFiles struct {
markdown string
json string
batch string
job string
binary string
image string
}
@@ -284,14 +283,12 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
markdown := filepath.Join(root, "content.md")
jsonFile := filepath.Join(root, "report.json")
batch := filepath.Join(root, "styles.json")
job := filepath.Join(root, "job.json")
binary := filepath.Join(root, "report.pdf")
image := filepath.Join(root, "chart.png")
for path, content := range map[string][]byte{
markdown: []byte("# Agent dry-run fixture\n\nNo business call is allowed.\n"),
jsonFile: []byte(`[{"content":"Agent dry-run fixture","sort":"0","key":"fixture","contentType":"markdown","type":"1"}]`),
batch: []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`),
job: []byte(`{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"minSalary":20000,"maxSalary":35000,"extData":{"headCount":1,"fullTimeExtData":{"salaryMonth":12}},"creatorUserId":"creator-user-id","ownerUserIds":["owner-user-id"]}`),
binary: []byte("%PDF-1.4\n%%EOF\n"),
image: {0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'},
} {
@@ -304,7 +301,6 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
markdown: "./" + filepath.Base(markdown),
json: "./" + filepath.Base(jsonFile),
batch: "./" + filepath.Base(batch),
job: "./" + filepath.Base(job),
binary: "./" + filepath.Base(binary),
image: "./" + filepath.Base(image),
}
@@ -355,10 +351,6 @@ func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []strin
replacement = files.markdown
case "contents-file":
replacement = files.json
case "from":
if strings.HasSuffix(strings.ToLower(value), "job.json") {
replacement = files.job
}
case "batch":
if strings.HasSuffix(strings.ToLower(value), "styles.json") {
replacement = files.batch
@@ -1,74 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"encoding/json"
"reflect"
"testing"
)
func recruitSchemaLeaf(t *testing.T, canonical string, compact bool) map[string]any {
t.Helper()
root := NewRootCommand()
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
args := []string{"schema", canonical, "--format", "json"}
if compact {
args = append(args, "--compact")
}
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("execute schema %s compact=%v: %v; stderr=%s", canonical, compact, err, stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode schema %s compact=%v: %v", canonical, compact, err)
}
return payload
}
func TestRecruitDeliveredSchemaPublishesResultAndPagination(t *testing.T) {
for _, canonical := range []string{"recruit.list_jobs", "recruit.get_job_detail", "recruit.create_job"} {
t.Run(canonical, func(t *testing.T) {
full := recruitSchemaLeaf(t, canonical, false)
compact := recruitSchemaLeaf(t, canonical, true)
if full["result"] == nil || compact["result"] == nil {
t.Fatalf("result missing: full=%#v compact=%#v", full["result"], compact["result"])
}
if !reflect.DeepEqual(full["result"], compact["result"]) {
t.Fatalf("full/compact result mismatch\nfull=%#v\ncompact=%#v", full["result"], compact["result"])
}
if canonical == "recruit.list_jobs" {
if full["pagination"] == nil || compact["pagination"] == nil {
t.Fatalf("list pagination missing: full=%#v compact=%#v", full["pagination"], compact["pagination"])
}
if !reflect.DeepEqual(full["pagination"], compact["pagination"]) {
t.Fatalf("full/compact pagination mismatch\nfull=%#v\ncompact=%#v", full["pagination"], compact["pagination"])
}
parameters, _ := full["parameters"].(map[string]any)
cursor, _ := parameters["cursor"].(map[string]any)
if cursor["type"] != "string" || cursor["interface_type"] != "number" {
t.Fatalf("cursor contract = %#v, want CLI string converted to MCP number", cursor)
}
size, _ := parameters["size"].(map[string]any)
if required, _ := size["required"].(bool); required {
t.Fatalf("size required = true, want false: %#v", size)
}
if size["default"] != "20" {
t.Fatalf("size default = %#v, want 20", size["default"])
}
compactParameters, _ := compact["parameters"].(map[string]any)
compactSize, _ := compactParameters["size"].(map[string]any)
if compactRequired, _ := compactSize["required"].(bool); compactRequired || compactSize["default"] != "20" {
t.Fatalf("compact size contract = %#v, want required=false default=20", compactSize)
}
} else if full["pagination"] != nil || compact["pagination"] != nil {
t.Fatalf("non-list pagination must be absent: full=%#v compact=%#v", full["pagination"], compact["pagination"])
}
})
}
}
+26 -128
View File
@@ -117,114 +117,38 @@ type CliSkillDTO struct {
// (skill_setup.go) MUST have a matching path value here — enforced by
// TestAgentSkillPathsCoversSetupHomes.
var agentSkillPaths = map[string]string{
// Universal agents. Those without an independent global directory map
// directly to the canonical ~/.agents/skills store.
"agents": ".agents/skills",
"amp": filepath.Join(".config", "agents", "skills"),
"antigravity": ".gemini/antigravity/skills",
"antigravity-cli": ".gemini/antigravity-cli/skills",
"codex": ".codex/skills",
"cursor": ".cursor/skills",
"deepagents": ".deepagents/agent/skills",
"firebender": ".firebender/skills",
"gemini-cli": ".gemini/skills",
"github-copilot": ".copilot/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
"replit": filepath.Join(".config", "agents", "skills"),
"universal": filepath.Join(".config", "agents", "skills"),
"cline": ".agents/skills",
"dexto": ".agents/skills",
"kimi-code-cli": ".agents/skills",
"loaf": ".agents/skills",
"warp": ".agents/skills",
"zed": ".agents/skills",
// Non-universal agents with global Skill directories.
"aider-desk": ".aider-desk/skills",
"astrbot": ".astrbot/data/skills",
"autohand-code": ".autohand/skills",
"augment": ".augment/skills",
"bob": ".bob/skills",
"claude-code": ".claude/skills",
"openclaw": ".openclaw/skills",
"codearts-agent": ".codeartsdoer/skills",
"codebuddy": ".codebuddy/skills",
"codemaker": ".codemaker/skills",
"codestudio": ".codestudio/skills",
"command-code": ".commandcode/skills",
"continue": ".continue/skills",
"cortex": ".snowflake/cortex/skills",
"crush": filepath.Join(".config", "crush", "skills"),
"devin": filepath.Join(".config", "devin", "skills"),
"droid": ".factory/skills",
"forgecode": ".forge/skills",
"goose": filepath.Join(".config", "goose", "skills"),
"grok": ".grok/skills",
"hermes-agent": ".hermes/skills",
"inference-sh": ".inferencesh/skills",
"jazz": ".jazz/skills",
"junie": ".junie/skills",
"iflow-cli": ".iflow/skills",
"kilo": ".kilocode/skills",
"kimchi": filepath.Join(".config", "kimchi", "harness", "skills"),
"kiro-cli": ".kiro/skills",
"kode": ".kode/skills",
"lingma": ".lingma/skills",
"mcpjam": ".mcpjam/skills",
"minimax-code": ".minimax/skills",
"mistral-vibe": ".vibe/skills",
"moxby": ".moxby/skills",
"mux": ".mux/skills",
"openhands": ".openhands/skills",
"ona": ".ona/skills",
"pi": ".pi/agent/skills",
"qoder": ".qoder/skills",
"qoder-cn": ".qoder-cn/skills",
"qwen-code": ".qwen/skills",
"reasonix": ".reasonix/skills",
"rovodev": ".rovodev/skills",
"roo": ".roo/skills",
"tabnine-cli": ".tabnine/agent/skills",
"terramind": ".terramind/skills",
"tinycloud": ".tinycloud/skills",
"trae": ".trae/skills",
"trae-cn": ".trae-cn/skills",
"windsurf": ".codeium/windsurf/skills",
"zcode": ".zcode/skills",
"zencoder": ".zencoder/skills",
"zenflow": ".zencoder/skills",
"neovate": ".neovate/skills",
"pochi": ".pochi/skills",
"adal": ".adal/skills",
// DWS compatibility aliases and DWS-only integrations.
"claude": ".claude/skills",
"gemini": ".gemini/skills",
"github": ".copilot/skills",
"hermes": ".hermes/skills",
"kiro": ".kiro/skills",
// `agents` is the generic-agent sentinel: install scripts and `setup`
// special-case ~/.agents/skills as a no-checks-required fallback so a
// fresh machine without any IDE/agent registry still gets skills.
"agents": ".agents/skills",
"qoder": ".qoder/skills",
"qoderwork": ".qoderwork/skills",
}
// Eve has project-scoped Skill directories but no upstream globalSkillsDir.
// Keep it in the advertised enumeration while failing explicitly instead of
// pretending that a global install configured Eve.
var unsupportedGlobalAgentTargets = map[string]string{
"eve": "Eve 不支持全局 Skill 安装,请在 Eve 项目内配置 agent/skills",
"promptscript": "PromptScript 不支持全局 Skill 安装,请在项目内使用 .agents/skills",
"claude": ".claude/skills",
"cursor": ".cursor/skills",
"codex": ".codex/skills",
"zcode": ".zcode/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
// IDE / agent registries also probed by `dws skill setup --target all`.
"gemini": ".gemini/skills",
"github": ".github/skills",
"windsurf": ".windsurf/skills",
"augment": ".augment/skills",
"cline": ".cline/skills",
"amp": ".amp/skills",
"kiro": ".kiro/skills",
"trae": ".trae/skills",
"openclaw": ".openclaw/skills",
"hermes": ".hermes/skills",
}
// supportedTargets returns a sorted, comma-separated list of supported
// targets. Sorted so help text and error messages stay stable across runs
// (Go map iteration order is intentionally randomized).
func supportedTargets() string {
targets := make([]string, 0, len(agentSkillPaths)+len(unsupportedGlobalAgentTargets)+1)
targets := make([]string, 0, len(agentSkillPaths)+1)
for target := range agentSkillPaths {
targets = append(targets, target)
}
for target := range unsupportedGlobalAgentTargets {
targets = append(targets, target)
}
sort.Strings(targets)
targets = append(targets, ".")
return strings.Join(targets, ", ")
@@ -258,28 +182,11 @@ func formatAgentSkillPathsForHelp() string {
sort.Strings(names)
var b strings.Builder
for _, n := range names {
installPath := agentSkillPaths[n]
if isUniversalSkillInstallTarget(n) {
installPath = ".agents/skills"
}
fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, installPath)
fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n])
}
return b.String()
}
// Universal Agents discover the shared ~/.agents/skills store directly. A
// marketplace install addressed to one of those Agent IDs must therefore
// publish to canonical instead of recreating an Agent-private duplicate.
func isUniversalSkillInstallTarget(target string) bool {
rel, ok := agentSkillPaths[target]
if !ok {
return false
}
base := filepath.Join("__home__", rel)
canonical := filepath.Join("__home__", ".agents", "skills")
return sameSkillSetupPath(base, canonical) || isUniversalSkillSetupBase(base)
}
func buildSkillCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "skill",
@@ -578,13 +485,8 @@ func resolveSkillTargetPath(target string) (string, error) {
return os.Getwd()
}
target = strings.ToLower(target)
if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported {
return "", errors.New(reason)
}
// Look up predefined agent paths.
_, ok := agentSkillPaths[target]
// Look up predefined agent paths
relPath, ok := agentSkillPaths[strings.ToLower(target)]
if !ok {
return "", fmt.Errorf("unsupported target")
}
@@ -594,11 +496,7 @@ func resolveSkillTargetPath(target string) (string, error) {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
destination := resolveSkillSetupBase(homeDir, target)
if isUniversalSkillInstallTarget(target) {
destination = filepath.Join(homeDir, ".agents", "skills")
}
return destination, nil
return filepath.Join(homeDir, relPath), nil
}
// fetchSkillDownloadInfo calls the download API to get the skill download URL.
+4 -40
View File
@@ -28,7 +28,6 @@ import (
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestResolveSkillTargetPath(t *testing.T) {
@@ -58,19 +57,19 @@ func TestResolveSkillTargetPath(t *testing.T) {
{
name: "cursor target",
target: "cursor",
wantSuffix: filepath.Join(".agents", "skills"),
wantSuffix: filepath.Join(".cursor", "skills"),
wantErr: false,
},
{
name: "codex target",
target: "codex",
wantSuffix: filepath.Join(".agents", "skills"),
wantSuffix: filepath.Join(".codex", "skills"),
wantErr: false,
},
{
name: "opencode target",
target: "opencode",
wantSuffix: filepath.Join(".agents", "skills"),
wantSuffix: filepath.Join(".config", "opencode", "skills"),
wantErr: false,
},
{
@@ -119,37 +118,6 @@ func TestResolveSkillTargetPath(t *testing.T) {
}
}
func TestCrossPlatformCoverageUniversalSkillInstallTargetsUseCanonical(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil })
if isUniversalSkillInstallTarget("missing-agent") {
t.Fatal("unknown Agent target classified as universal")
}
for _, target := range []string{
"amp", "antigravity", "antigravity-cli", "cline", "codex", "cursor",
"deepagents", "dexto", "firebender", "gemini", "gemini-cli", "github",
"github-copilot", "kimi-code-cli", "loaf", "opencode", "replit",
"universal", "warp", "zed",
} {
got, err := resolveSkillTargetPath(target)
if err != nil {
t.Fatalf("resolve %s: %v", target, err)
}
if want := filepath.Join(home, ".agents", "skills"); got != want {
t.Errorf("resolve %s = %s, want canonical %s", target, got, want)
}
}
for target, want := range map[string]string{
"claude": filepath.Join(home, ".claude", "skills"),
"qoder": filepath.Join(home, ".qoder", "skills"),
"zcode": filepath.Join(home, ".zcode", "skills"),
} {
if got, err := resolveSkillTargetPath(target); err != nil || got != want {
t.Errorf("resolve non-universal %s = %s, %v; want %s", target, got, err, want)
}
}
}
func TestResolveSkillTargetPathCurrentDir(t *testing.T) {
// Test "." target returns current working directory
cwd, err := os.Getwd()
@@ -509,12 +477,8 @@ func TestAgentSkillPathsCoversSetupHomes(t *testing.T) {
for _, p := range agentSkillPaths {
paths[p] = true
}
legacyCleanupOnly := map[string]bool{
".github/skills": true, ".windsurf/skills": true,
".cline/skills": true, ".amp/skills": true,
}
for _, home := range skillSetupAgentHomes {
if !paths[home] && !legacyCleanupOnly[home] {
if !paths[home] {
t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+
"add it to agentSkillPaths so users can address it via --target <name>", home)
}
+129 -463
View File
@@ -6,7 +6,6 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
@@ -24,77 +23,22 @@ import (
// agree on the install footprint.
var skillSetupAgentHomes = []string{
".agents/skills",
".config/agents/skills",
".gemini/antigravity/skills",
".gemini/antigravity-cli/skills",
".deepagents/agent/skills",
".firebender/skills",
".copilot/skills",
".config/opencode/skills",
".aider-desk/skills",
".astrbot/data/skills",
".autohand/skills",
".augment/skills",
".bob/skills",
".claude/skills",
".openclaw/skills",
".codeartsdoer/skills",
".codebuddy/skills",
".codemaker/skills",
".codestudio/skills",
".commandcode/skills",
".continue/skills",
".snowflake/cortex/skills",
".config/crush/skills",
".config/devin/skills",
".factory/skills",
".forge/skills",
".config/goose/skills",
".grok/skills",
".hermes/skills",
".inferencesh/skills",
".jazz/skills",
".junie/skills",
".iflow/skills",
".kilocode/skills",
".config/kimchi/harness/skills",
".kiro/skills",
".kode/skills",
".lingma/skills",
".mcpjam/skills",
".minimax/skills",
".vibe/skills",
".moxby/skills",
".mux/skills",
".openhands/skills",
".ona/skills",
".pi/agent/skills",
".qoder/skills",
".qoder-cn/skills",
".qwen/skills",
".reasonix/skills",
".rovodev/skills",
".roo/skills",
".tabnine/agent/skills",
".terramind/skills",
".tinycloud/skills",
".trae/skills",
".trae-cn/skills",
".codeium/windsurf/skills",
".zcode/skills",
".zencoder/skills",
".neovate/skills",
".pochi/skills",
".adal/skills",
".qoderwork/skills",
// beta.6 compatibility roots: cleanup only.
".cursor/skills",
".qoder/skills",
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".zcode/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
".cline/skills",
".amp/skills",
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
}
const (
@@ -121,20 +65,15 @@ var (
skillSetupInteractive = isInteractiveTerminal
skillSetupReadDir = os.ReadDir
skillSetupStat = os.Stat
skillSetupLstat = os.Lstat
skillSetupGetenv = os.Getenv
skillSetupSymlink = os.Symlink
skillSetupExecutable = os.Executable
skillSetupGetwd = os.Getwd
skillSetupUserHomeDir = os.UserHomeDir
skillSetupRemoveAll = os.RemoveAll
skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir
skillSetupRestoreBackup = upgrade.RestoreSkillPath
skillSetupMkdirAll = os.MkdirAll
skillSetupWalk = filepath.Walk
skillSetupRel = filepath.Rel
skillSetupReadlink = os.Readlink
skillSetupEvalSymlinks = filepath.EvalSymlinks
skillSetupOpen = os.Open
skillSetupOpenFile = os.OpenFile
skillSetupWriteFile = os.WriteFile
@@ -143,10 +82,7 @@ var (
skillSetupReadState = skillstate.Read
skillSetupWriteState = skillstate.Write
skillSetupRemoveState = skillstate.Remove
skillSetupPublishPath = upgrade.PublishSkillPathNoReplace
skillSetupRollbackPaths = upgrade.RollbackSkillPathPublications
skillSetupNow = time.Now
skillSetupFoldPathCase = runtime.GOOS == "windows"
)
type skillSetupBackup struct {
@@ -155,11 +91,9 @@ type skillSetupBackup struct {
}
type skillSetupTargetPlan struct {
Destination string
CanonicalBase string
Backups []skillSetupBackup
CleanupOnly bool
LinkCanonical bool
Destination string
Backups []skillSetupBackup
CleanupOnly bool
}
type skillSetupPlan struct {
@@ -215,9 +149,8 @@ multi 模式支持按产品挑选:
备份失败时保留原目录并跳过该目标,绝不静默删除。
· 所有将被移除的目录都会在确认前逐条列出。
不带 --mode 时进入交互式询问;Skill 统一安装到 ~/.agents/skills。
DWS 会自动适配本机上检测到的 Agent;共享安装方式不可用时会自动改用兼容安装,
无需用户手动处理,也不会让同一个 Skill 重复出现。
不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录;
未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 Agent 扫描两份 Skill。
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
Example: ` dws skill setup --mode multi --target claude --dry-run
dws skill setup --mode multi --target claude`,
@@ -310,6 +243,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
}
}
}
// filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude
// 时保持 additive(不动未列出的 sibling);全量安装与 install.sh /
// install.js 对齐,清掉不在 bundle 内且有明确 DWS 所有权记录的过期 Skill。
@@ -382,17 +316,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
if err != nil {
return err
}
if mode == skillSetupModeMulti && len(migrateEventMiscTargets) > 0 {
retiredNames := append([]string(nil), multiSkillNames...)
if installsEventMiscCompanion && !containsSkillName(retiredNames, multiMiscSkill) {
retiredNames = append(retiredNames, multiMiscSkill)
}
if retireErr := retireMigratedUniversalSkills(migrateEventMiscTargets, retiredNames, out); retireErr != nil {
// Retiring an obsolete universal copy installs nothing; report it and
// keep the successful installation rather than failing the run.
fmt.Fprintf(errOut, " ⚠️ %v\n", retireErr)
}
}
if skipped > 0 {
return fmt.Errorf(
"Skill 安装不完整(mode=%s, installed=%d, skipped=%d);修复失败原因后请重试 setup,或运行普通 upgrade 全量刷新预制 Skill",
@@ -427,9 +350,7 @@ 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, " 统一安装位置:~/.agents/skills")
fmt.Fprintln(out, " 已自动适配本机上检测到的 Agent")
fmt.Fprintln(out, "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效。")
fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。")
return nil
}
@@ -989,9 +910,8 @@ func isSkillSourceRoot(path, mode string) bool {
}
// resolveSkillSetupTargets returns the list of absolute Agent home destinations.
// The canonical ~/.agents/skills destination is always first. If target ==
// "all", detected concrete Agent roots follow it. A specific target follows
// canonical as well so unknown/future Agents retain the universal copy.
// If target == "all", returns every agent home whose parent directory exists.
// Otherwise returns the single matching home (whether or not it currently exists).
//
// 末段约定:
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
@@ -1003,88 +923,15 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) {
}
target = strings.ToLower(strings.TrimSpace(target))
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
if target == "" || target == "all" {
return detectExistingAgentHomes(home, mode), nil
}
if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported {
return nil, errors.New(reason)
}
_, ok := agentSkillPaths[target]
rel, ok := agentSkillPaths[target]
if !ok {
return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets())
}
dest := agentHomeForMode(resolveSkillSetupBase(home, target), mode)
if sameSkillSetupPath(dest, canonical) {
return []string{canonical}, nil
}
return []string{canonical, dest}, nil
}
func resolveOpenClawSetupBase(home string) string {
for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} {
base := filepath.Join(home, name)
if info, err := skillSetupStat(base); err == nil && info.IsDir() {
return filepath.Join(base, "skills")
}
}
return filepath.Join(home, ".openclaw", "skills")
}
func resolveSkillSetupBase(home, target string) string {
switch target {
case "claude", "claude-code":
if custom := strings.TrimSpace(skillSetupGetenv("CLAUDE_CONFIG_DIR")); custom != "" {
return filepath.Join(custom, "skills")
}
case "codex":
if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "hermes", "hermes-agent":
if custom := strings.TrimSpace(skillSetupGetenv("HERMES_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "autohand-code":
if custom := strings.TrimSpace(skillSetupGetenv("AUTOHAND_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "grok":
if custom := strings.TrimSpace(skillSetupGetenv("GROK_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "mistral-vibe":
if custom := strings.TrimSpace(skillSetupGetenv("VIBE_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "openclaw":
return resolveOpenClawSetupBase(home)
case "opencode", "amp", "replit", "universal", "crush", "devin", "goose", "kimchi":
configHome := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME"))
if configHome == "" {
configHome = filepath.Join(home, ".config")
}
switch target {
case "opencode":
return filepath.Join(configHome, "opencode", "skills")
case "amp", "replit", "universal":
return filepath.Join(configHome, "agents", "skills")
case "crush":
return filepath.Join(configHome, "crush", "skills")
case "devin":
return filepath.Join(configHome, "devin", "skills")
case "goose":
return filepath.Join(configHome, "goose", "skills")
case "kimchi":
return filepath.Join(configHome, "kimchi", "harness", "skills")
}
case "github", "github-copilot":
return filepath.Join(home, ".copilot", "skills")
case "windsurf":
return filepath.Join(home, ".codeium", "windsurf", "skills")
}
return filepath.Join(home, agentSkillPaths[target])
return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil
}
// agentHomeForMode appends the mode-specific tail segment to an agent home base.
@@ -1096,141 +943,79 @@ func agentHomeForMode(base, mode string) string {
}
func detectExistingAgentHomes(home, mode string) []string {
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
dests := []string{canonical}
canonicalKey := skillSetupPathKey(canonical)
seen := map[string]bool{canonicalKey: true}
addDetected := func(rel, base string) {
detectedDir := filepath.Dir(base)
switch filepath.ToSlash(filepath.Clean(rel)) {
case ".config/kimchi/harness/skills", ".tabnine/agent/skills":
detectedDir = filepath.Dir(filepath.Dir(base))
case ".zcode/skills":
// Application bundles are machine-scoped detection signals. Keep this
// independent of HOME so setup matches npm, Shell, and PowerShell.
if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() {
detectedDir = ""
}
case ".minimax/skills":
if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() {
detectedDir = ""
}
}
if detectedDir != "" {
if info, err := skillSetupStat(detectedDir); err != nil || !info.IsDir() {
return
}
}
dest := agentHomeForMode(base, mode)
key := skillSetupPathKey(dest)
if !seen[key] {
seen[key] = true
dests = append(dests, dest)
}
}
var specific []string
for i, rel := range skillSetupAgentHomes {
if i == 0 {
continue
}
base := filepath.Join(home, rel)
switch filepath.ToSlash(filepath.Clean(rel)) {
case ".claude/skills":
base = resolveSkillSetupBase(home, "claude-code")
case ".codex/skills":
base = resolveSkillSetupBase(home, "codex")
case ".hermes/skills":
base = resolveSkillSetupBase(home, "hermes-agent")
case ".autohand/skills":
base = resolveSkillSetupBase(home, "autohand-code")
case ".grok/skills":
base = resolveSkillSetupBase(home, "grok")
case ".vibe/skills":
base = resolveSkillSetupBase(home, "mistral-vibe")
case ".openclaw/skills":
base = resolveSkillSetupBase(home, "openclaw")
case ".config/opencode/skills":
base = resolveSkillSetupBase(home, "opencode")
case ".config/agents/skills":
base = resolveSkillSetupBase(home, "amp")
case ".config/crush/skills":
base = resolveSkillSetupBase(home, "crush")
case ".config/devin/skills":
base = resolveSkillSetupBase(home, "devin")
case ".config/goose/skills":
base = resolveSkillSetupBase(home, "goose")
case ".config/kimchi/harness/skills":
base = resolveSkillSetupBase(home, "kimchi")
parent := filepath.Dir(base)
if info, err := skillSetupStat(parent); err != nil || !info.IsDir() {
continue
}
addDetected(rel, base)
specific = append(specific, agentHomeForMode(base, mode))
}
for _, target := range []string{"github-copilot", "windsurf"} {
addDetected(agentSkillPaths[target], resolveSkillSetupBase(home, target))
if len(specific) > 0 {
return specific
}
return dests
return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)}
}
func skillSetupBaseForMode(dest, mode string) string {
if mode == skillSetupModeMono {
return filepath.Dir(dest)
}
return dest
}
func isUniversalSkillSetupBase(base string) bool {
cleanBase := filepath.Clean(base)
if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" && sameSkillSetupPath(cleanBase, filepath.Join(custom, "skills")) {
return true
}
if custom := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME")); custom != "" {
if sameSkillSetupPath(cleanBase, filepath.Join(custom, "agents", "skills")) ||
sameSkillSetupPath(cleanBase, filepath.Join(custom, "opencode", "skills")) {
return true
}
}
base = filepath.ToSlash(cleanBase)
for rel := range map[string]bool{
".config/agents/skills": true, ".gemini/antigravity/skills": true,
".gemini/antigravity-cli/skills": true, ".codex/skills": true,
".cursor/skills": true, ".deepagents/agent/skills": true,
".firebender/skills": true, ".gemini/skills": true,
".copilot/skills": true, ".config/opencode/skills": true,
// beta.6 compatibility roots are cleanup-only.
".github/skills": true, ".windsurf/skills": true,
".cline/skills": true, ".amp/skills": true,
} {
if strings.HasSuffix(base, "/"+rel) {
return true
}
}
return false
}
func sameSkillSetupPath(left, right string) bool {
return skillSetupPathKey(left) == skillSetupPathKey(right)
}
func skillSetupPathKey(path string) string {
clean := filepath.Clean(path)
if skillSetupFoldPathCase {
clean = strings.ToLower(clean)
}
return clean
}
func canonicalSkillSetupBase(dests []string, mode string) string {
func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) {
// Derive HOME from a concrete Agent destination instead of resolving it a
// second time. The destinations were already resolved from HOME by the
// caller, and a later/transient UserHomeDir failure must not turn an
// otherwise valid setup plan into an error. Direct/custom destinations that
// do not match a known concrete Agent root have no generic-root migration.
home := ""
for _, dest := range dests {
base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode)))
if strings.HasSuffix(base, "/.agents/skills") {
return skillSetupBaseForMode(dest, mode)
base := dest
if filepath.Base(dest) == "dws" {
base = filepath.Dir(dest)
}
base = filepath.Clean(base)
for i, rel := range skillSetupAgentHomes {
if i == 0 {
continue
}
suffix := filepath.Clean(filepath.FromSlash(rel))
needle := string(filepath.Separator) + suffix
if strings.HasSuffix(base, needle) {
home = strings.TrimSuffix(base, needle)
break
}
}
if home != "" {
break
}
}
return ""
}
if home == "" {
return nil, nil
}
genericBase := filepath.Join(home, ".agents", "skills")
func samePhysicalSkillSetupPath(left, right string) bool {
leftReal, leftErr := skillSetupEvalSymlinks(left)
rightReal, rightErr := skillSetupEvalSymlinks(right)
return leftErr == nil && rightErr == nil && sameSkillSetupPath(leftReal, rightReal)
target := &skillSetupTargetPlan{Destination: genericBase, CleanupOnly: true}
add := func(path, reason string) {
if info, statErr := skillSetupStat(path); statErr == nil && info.IsDir() {
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: reason})
}
}
add(filepath.Join(genericBase, "dws"), skillSetupBackupMutual)
entries, readErr := skillSetupReadDir(genericBase)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return nil, fmt.Errorf("扫描通用 Skill 根目录失败 %s: %w", genericBase, readErr)
}
for _, entry := range entries {
path := filepath.Join(genericBase, entry.Name())
if entry.IsDir() && isManagedDWSMultiSkillDir(path, managed) {
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupStale})
}
}
if len(target.Backups) == 0 {
return nil, nil
}
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
return target, nil
}
func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filtered bool) (*skillSetupPlan, error) {
@@ -1245,26 +1030,10 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
}
sort.Strings(plan.MultiSkillNames)
sortedDests := append([]string(nil), dests...)
sort.Slice(sortedDests, func(i, j int) bool {
leftCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[i], mode))), "/.agents/skills")
rightCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[j], mode))), "/.agents/skills")
if leftCanonical != rightCanonical {
return leftCanonical
}
return sortedDests[i] < sortedDests[j]
})
sort.Strings(sortedDests)
managedNames := currentManagedSkillNames()
canonicalBase := canonicalSkillSetupBase(sortedDests, mode)
for _, dest := range sortedDests {
base := skillSetupBaseForMode(dest, mode)
target := skillSetupTargetPlan{Destination: dest, CanonicalBase: canonicalBase}
if canonicalBase != "" && filepath.Clean(base) != filepath.Clean(canonicalBase) {
if isUniversalSkillSetupBase(base) {
target.CleanupOnly = true
} else {
target.LinkCanonical = true
}
}
target := skillSetupTargetPlan{Destination: dest}
seen := map[string]bool{}
add := func(path, reason string) {
if seen[path] {
@@ -1308,22 +1077,26 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
}
}
for _, path := range replacements {
if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) {
continue
}
_, statErr := skillSetupLstat(path)
info, statErr := skillSetupStat(path)
if statErr != nil {
if errors.Is(statErr, os.ErrNotExist) {
continue
}
return nil, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, statErr)
}
add(path, skillSetupBackupReplace)
if info.IsDir() {
add(path, skillSetupBackupReplace)
}
}
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
if !target.CleanupOnly || len(target.Backups) > 0 {
plan.Targets = append(plan.Targets, target)
}
plan.Targets = append(plan.Targets, target)
}
cleanupTarget, cleanupErr := genericSkillCleanupTarget(sortedDests, managedNames)
if cleanupErr != nil {
return nil, cleanupErr
}
if cleanupTarget != nil {
plan.Targets = append(plan.Targets, *cleanupTarget)
}
return plan, nil
}
@@ -1367,33 +1140,6 @@ func configureEventMiscMigrationPlan(plan *skillSetupPlan, targets []string, ins
}
}
func retireMigratedUniversalSkills(targets, names []string, out io.Writer) error {
home, err := skillSetupUserHomeDir()
if err != nil {
return fmt.Errorf("无法解析 HOME 以退役 universal Agent 旧副本: %w", err)
}
seen := map[string]bool{}
var victims []skillSetupBackup
for _, target := range targets {
if !isUniversalSkillSetupBase(target) {
continue
}
for _, name := range names {
path := filepath.Join(target, name)
if seen[path] {
continue
}
seen[path] = true
victims = append(victims, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace})
}
}
sort.Slice(victims, func(i, j int) bool { return victims[i].Path < victims[j].Path })
if _, err := backupSkillSetupTarget(home, victims, out); err != nil {
return fmt.Errorf("退役 universal Agent Event/misc 旧副本失败,已回滚: %w", err)
}
return nil
}
func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
fmt.Fprintf(out, "📦 将安装 skill:\n mode: %s\n source: %s\n", plan.Mode, plan.Source)
if plan.Mode == skillSetupModeMulti {
@@ -1402,14 +1148,12 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
fmt.Fprintf(out, " · %s\n", name)
}
}
fmt.Fprintln(out, " 安装与适配位置:")
fmt.Fprintln(out, " destinations:")
for _, target := range plan.Targets {
if target.CleanupOnly {
fmt.Fprintf(out, " - %s(移除该 Agent 中的旧版 DWS Skills,改用统一安装位置)\n", target.Destination)
} else if target.LinkCanonical {
fmt.Fprintf(out, " - %s(自动配置此 Agent 使用统一安装位置)\n", target.Destination)
fmt.Fprintf(out, " - %s (仅迁移旧的通用 DWS 副本)\n", target.Destination)
} else {
fmt.Fprintf(out, " - %s(统一安装位置)\n", target.Destination)
fmt.Fprintf(out, " - %s\n", target.Destination)
}
}
fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):")
@@ -1580,12 +1324,8 @@ func installMultiSkillsWithEventMigration(
}
migrationSet := make(map[string]struct{}, len(migrationTargets))
physicalMigrationTargets := make([]string, 0, len(migrationTargets))
for _, dest := range migrationTargets {
migrationSet[dest] = struct{}{}
if !isUniversalSkillSetupBase(dest) {
physicalMigrationTargets = append(physicalMigrationTargets, dest)
}
}
var ordinaryTargets []string
for _, dest := range dests {
@@ -1594,47 +1334,23 @@ func installMultiSkillsWithEventMigration(
}
}
var canonicalTargets, otherOrdinaryTargets []string
for _, dest := range ordinaryTargets {
base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, skillSetupModeMulti)))
if strings.HasSuffix(base, "/.agents/skills") {
canonicalTargets = append(canonicalTargets, dest)
} else {
otherOrdinaryTargets = append(otherOrdinaryTargets, dest)
}
}
installOrdinary := func(names, targets []string) error {
if len(targets) == 0 {
return nil
}
if len(ordinaryTargets) > 0 {
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, names, targets, out, errOut, filtered)
n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut, filtered)
installed += n
skipped += nSkipped
if err != nil {
return err
return installed, skipped, err
}
if nSkipped > 0 {
return fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
}
return nil
}
canonicalNames := append([]string(nil), skillNames...)
if !containsSkillName(canonicalNames, multiMiscSkill) {
canonicalNames = append(canonicalNames, multiMiscSkill)
sort.Strings(canonicalNames)
}
if err := installOrdinary(canonicalNames, canonicalTargets); err != nil {
return installed, skipped, err
}
if err := installOrdinary(skillNames, otherOrdinaryTargets); err != nil {
return installed, skipped, err
}
// 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 physicalMigrationTargets {
for _, dest := range migrationTargets {
if cleanupErr := cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut); cleanupErr != nil {
return installed, skipped + len(skillNames), cleanupErr
}
@@ -1645,9 +1361,9 @@ func installMultiSkillsWithEventMigration(
prerequisiteNames = append(prerequisiteNames, name)
}
}
if len(prerequisiteNames) > 0 && len(physicalMigrationTargets) > 0 {
if len(prerequisiteNames) > 0 {
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, physicalMigrationTargets, out, errOut, true)
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut, true)
installed += n
skipped += nSkipped
if err != nil {
@@ -1658,7 +1374,7 @@ func installMultiSkillsWithEventMigration(
}
}
migrated, migrationErr := migrateEventMiscAtomically(src, physicalMigrationTargets, out, errOut)
migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut)
installed += migrated
if migrationErr != nil {
return installed, skipped, migrationErr
@@ -1930,32 +1646,9 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s
err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
}()
realStageParent, realParentErr := skillSetupEvalSymlinks(stageParent)
if realParentErr != nil {
return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", stageParent, realParentErr)
}
stageOne := func(src, dest string) error {
stagedDir := filepath.Join(stageRoot, filepath.Base(dest))
if target.LinkCanonical {
canonicalTarget := filepath.Join(target.CanonicalBase, filepath.Base(dest))
if samePhysicalSkillSetupPath(dest, canonicalTarget) {
return nil
}
realCanonicalTarget, realTargetErr := skillSetupEvalSymlinks(canonicalTarget)
if realTargetErr != nil {
return fmt.Errorf("解析 canonical Skill 失败 %s: %w", canonicalTarget, realTargetErr)
}
relTarget, relErr := skillSetupRel(realStageParent, realCanonicalTarget)
if relErr != nil {
return fmt.Errorf("计算 Skill 相对链接失败 %s: %w", canonicalTarget, relErr)
}
if linkErr := skillSetupSymlink(relTarget, stagedDir); linkErr != nil {
return fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedDir, relTarget, linkErr)
}
staged = append(staged, skillSetupStagedDir{staged: stagedDir, dest: dest})
return nil
}
if err := skillSetupMkdirAll(stagedDir, 0o755); err != nil {
return fmt.Errorf("创建 Skill staging 目录失败 %s: %w", stagedDir, err)
}
@@ -1982,11 +1675,16 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s
// restoreSkillSetupTarget removes a partially published replacement and
// restores every original directory from its exact backup path.
func restoreSkillSetupTarget(published []upgrade.SkillPathPublication, backups []skillSetupBackedUpDir) error {
restoreErr := skillSetupRollbackPaths(published)
func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir) error {
var restoreErr error
for i := len(published) - 1; i >= 0; i-- {
if err := skillSetupRemoveAll(published[i]); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err))
}
}
for i := len(backups) - 1; i >= 0; i-- {
item := backups[i]
if _, err := skillSetupLstat(item.original); err == nil {
if _, err := skillSetupStat(item.original); err == nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复目标仍存在 %s;备份保留于 %s", item.original, item.backup))
continue
} else if !errors.Is(err, os.ErrNotExist) {
@@ -1997,7 +1695,7 @@ func restoreSkillSetupTarget(published []upgrade.SkillPathPublication, backups [
restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录失败 %s: %w;备份保留于 %s", filepath.Dir(item.original), err, item.backup))
continue
}
if err := skillSetupRestoreBackup(item.backup, item.original); err != nil {
if err := skillSetupPublishRename(item.backup, item.original); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w;备份保留于 %s", item.original, err, item.backup))
}
}
@@ -2030,53 +1728,45 @@ func backupSkillSetupTarget(home string, planned []skillSetupBackup, out io.Writ
}
func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupBackedUpDir) error {
published := make([]upgrade.SkillPathPublication, 0, len(staged))
published := make([]string, 0, len(staged))
for _, item := range staged {
publication, err := skillSetupPublishPath(item.staged, item.dest)
if err != nil {
// Record before rename so rollback also removes a destination created by
// a platform-specific partial failure.
published = append(published, item.dest)
if err := skillSetupPublishRename(item.staged, item.dest); err != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", item.dest, err)
if restoreErr := restoreSkillSetupTarget(published, backups); restoreErr != nil {
return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr))
}
return publishErr
}
published = append(published, publication)
}
return nil
}
func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (installed, skipped int, err error) {
home, homeErr := skillSetupUserHomeDir()
hasCanonicalDependents := false
for _, candidate := range plan.Targets {
if candidate.CanonicalBase != "" && !sameSkillSetupPath(skillSetupBaseForMode(candidate.Destination, plan.Mode), candidate.CanonicalBase) {
hasCanonicalDependents = true
break
}
perTarget := 1
if plan.Mode == skillSetupModeMulti {
perTarget = len(plan.MultiSkillNames)
}
for _, target := range plan.Targets {
perTarget := 1
if plan.Mode == skillSetupModeMulti {
perTarget = len(plan.MultiSkillNames)
}
isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase)
if target.CleanupOnly {
// Nothing is installed below a universal root, so a stale copy that
// resists retirement must not count as a skipped install: any skipped
// count fails the whole setup, even when every real target succeeded.
if skipped > 0 {
continue
}
if homeErr != nil {
fmt.Fprintf(errOut, " ⚠️ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr)
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr)
skipped++
continue
}
if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil {
fmt.Fprintf(errOut, " ⚠️ universal Agent 旧副本迁移失败,已回滚,可手动删除 %s: %v\n", target.Destination, cleanupErr)
fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr)
skipped++
}
continue
}
if len(target.Backups) > 0 && homeErr != nil {
if isCanonical && hasCanonicalDependents {
return installed, skipped + perTarget, fmt.Errorf("无法解析 HOME,canonical Skill 刷新中止 %s: %w", target.Destination, homeErr)
}
if plan.Mode == skillSetupModeMono {
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr)
} else {
@@ -2087,16 +1777,7 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
}
stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target)
if stageErr != nil && target.LinkCanonical {
fmt.Fprintf(errOut, " ℹ️ %s 无法使用共享安装方式,正在自动改用兼容安装\n", target.Destination)
fallback := target
fallback.LinkCanonical = false
stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback)
}
if stageErr != nil {
if isCanonical && hasCanonicalDependents {
return installed, skipped + perTarget, fmt.Errorf("canonical Skill staging 失败 %s: %w", target.Destination, stageErr)
}
fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr)
skipped += perTarget
continue
@@ -2106,9 +1787,6 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
if isCanonical && hasCanonicalDependents {
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 备份失败,已执行回滚 %s: %w", target.Destination, backupErr)
}
fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, backupErr)
skipped += perTarget
continue
@@ -2119,19 +1797,7 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
if cleanupErr != nil {
publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
if isCanonical && hasCanonicalDependents {
if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) {
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %w", target.Destination, publishErr)
}
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, publishErr)
}
if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) {
// The destination may belong to a concurrent writer and was
// deliberately retained; the rollback refuses to displace it.
fmt.Fprintf(errOut, " ✗ Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %v\n", target.Destination, publishErr)
} else {
fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr)
}
fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr)
skipped += perTarget
continue
}
@@ -2170,7 +1836,7 @@ func staleMultiSkillVictimsWithError(dest string, keep []string, managed ...map[
}
var victims []string
for _, e := range entries {
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || keepSet[e.Name()] {
if !e.IsDir() || keepSet[e.Name()] {
continue
}
if !isManagedDWSMultiSkillDir(filepath.Join(dest, e.Name()), managed...) {
@@ -1,160 +0,0 @@
package app
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
func canonicalFailurePlan(t *testing.T) (string, *skillSetupPlan) {
t.Helper()
home := t.TempDir()
src := t.TempDir()
skill := filepath.Join(src, "dingtalk-chat")
if err := os.MkdirAll(skill, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skill, "SKILL.md"), []byte("chat"), 0o644); err != nil {
t.Fatal(err)
}
canonical := filepath.Join(home, ".agents", "skills")
dependent := filepath.Join(home, ".claude", "skills")
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, dependent}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
return home, plan
}
func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t *testing.T) {
t.Run("home", func(t *testing.T) {
_, plan := canonicalFailurePlan(t)
canonical := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
if err := os.MkdirAll(canonical, 0o755); err != nil {
t.Fatal(err)
}
plan.Targets[0].Backups = []skillSetupBackup{{Path: canonical, Reason: skillSetupBackupReplace}}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home denied") })
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 刷新中止") {
t.Fatalf("home failure = %v", err)
}
})
t.Run("backup", func(t *testing.T) {
home, plan := canonicalFailurePlan(t)
victim := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
plan.Targets[0].Backups = []skillSetupBackup{{Path: victim, Reason: skillSetupBackupReplace}}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup denied") })
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 备份失败") {
t.Fatalf("backup failure = %v", err)
}
})
t.Run("publish", func(t *testing.T) {
home, plan := canonicalFailurePlan(t)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
return upgrade.SkillPathPublication{}, errors.New("publish denied")
})
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 发布失败") {
t.Fatalf("publish failure = %v", err)
}
})
t.Run("uncertain-publish", func(t *testing.T) {
home, plan := canonicalFailurePlan(t)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain)
})
errOut := &bytes.Buffer{}
_, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut)
if err == nil || !strings.Contains(err.Error(), "canonical Skill 发布状态不确定") {
t.Fatalf("uncertain publish = %v", err)
}
// The destination was deliberately retained, so the canonical error
// must not claim a rollback happened.
if strings.Contains(err.Error(), "回滚") {
t.Fatalf("uncertain error must not claim a rollback: %v", err)
}
})
t.Run("uncertain dependent target is retained and reported", func(t *testing.T) {
home, plan := canonicalFailurePlan(t)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
originalPublish := skillSetupPublishPath
testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) {
if strings.HasPrefix(destination, filepath.Join(home, ".claude")) {
return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain)
}
return originalPublish(staged, destination)
})
errOut := &bytes.Buffer{}
_, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut)
if err != nil {
t.Fatalf("dependent uncertain publish = %v", err)
}
if skipped != 1 {
t.Fatalf("skipped = %d, want 1", skipped)
}
if !strings.Contains(errOut.String(), "发布状态不确定") || strings.Contains(errOut.String(), "已执行回滚") {
t.Fatalf("errOut = %q, want retained-destination notice", errOut.String())
}
})
}
func TestCrossPlatformCoverageSkillSetupLinkResolutionFailures(t *testing.T) {
home, plan := canonicalFailurePlan(t)
canonicalTarget := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
if err := os.MkdirAll(canonicalTarget, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(canonicalTarget, "SKILL.md"), []byte("chat"), 0o644); err != nil {
t.Fatal(err)
}
linked := plan.Targets[1]
t.Run("physical-parent", func(t *testing.T) {
testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) {
if path == linked.Destination {
return "", errors.New("parent denied")
}
return filepath.EvalSymlinks(path)
})
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "物理目录") {
t.Fatalf("physical parent error = %v", err)
}
})
t.Run("canonical-target", func(t *testing.T) {
testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) {
if path == canonicalTarget {
return "", errors.New("canonical denied")
}
return filepath.EvalSymlinks(path)
})
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "解析 canonical") {
t.Fatalf("canonical target error = %v", err)
}
})
t.Run("relative-path", func(t *testing.T) {
testseam.Swap(t, &skillSetupRel, func(string, string) (string, error) { return "", errors.New("relative denied") })
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "相对链接") {
t.Fatalf("relative path error = %v", err)
}
})
_ = home
}
-395
View File
@@ -1,395 +0,0 @@
package app
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{
".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills",
})
for _, parent := range []string{".codex", ".claude", ".openclaw"} {
if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil {
t.Fatal(err)
}
}
dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
if err != nil {
t.Fatal(err)
}
canonical := filepath.Join(home, ".agents", "skills")
if len(dests) != 4 || dests[0] != canonical {
t.Fatalf("targets = %v", dests)
}
src := t.TempDir()
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
dir := filepath.Join(src, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
t.Fatal(err)
}
claudeSkills := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(claudeSkills, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(claudeSkills, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("missing-target", filepath.Join(claudeSkills, "dingtalk-shared")); err != nil {
t.Fatal(err)
}
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false)
if err != nil {
t.Fatal(err)
}
installed, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{})
if err != nil || skipped != 0 || installed != 6 { // canonical + two linked Agents, two Skills each
t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err)
}
if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) {
t.Fatalf("Codex duplicate remains: %v", err)
}
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
t.Fatalf("canonical %s missing: %v", name, err)
}
for _, agent := range []string{".claude", ".openclaw"} {
link := filepath.Join(home, agent, "skills", name)
info, err := os.Lstat(link)
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("link %s = %#v, %v", link, info, err)
}
}
}
// Re-running setup must recognize the existing links as already correct;
// canonical refreshes in place without turning links into copied trees.
plan, err = buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false)
if err != nil {
t.Fatal(err)
}
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
for _, agent := range []string{".claude", ".openclaw"} {
info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat"))
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("idempotent setup replaced %s link: %#v, %v", agent, info, err)
}
}
}
func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *testing.T) {
// Keep the destination HOME synthetic: app-bundle detection is deliberately
// machine-scoped and must not depend on the selected installation HOME.
home := t.TempDir()
testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" })
for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
sentinel := filepath.Join(home, "app-sentinel")
if err := os.MkdirAll(sentinel, 0o755); err != nil {
t.Fatal(err)
}
appInfo, err := os.Stat(sentinel)
if err != nil {
t.Fatal(err)
}
zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")
minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")
originalStat := skillSetupStat
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == zcodeApp || path == minimaxApp {
return appInfo, nil
}
return originalStat(path)
})
dests := detectExistingAgentHomes(home, skillSetupModeMulti)
for _, target := range []string{
filepath.Join(home, ".config", "kimchi", "harness", "skills"),
filepath.Join(home, ".tabnine", "agent", "skills"),
filepath.Join(home, ".zcode", "skills"),
filepath.Join(home, ".minimax", "skills"),
} {
if !containsSkillName(dests, target) {
t.Errorf("detected targets %v missing %s", dests, target)
}
}
}
func TestCrossPlatformCoverageSkillSetupCustomRootsAliasesAndUniversalTargets(t *testing.T) {
home := t.TempDir()
customClaude := filepath.Join(t.TempDir(), "claude")
customCodex := filepath.Join(t.TempDir(), "codex")
customHermes := filepath.Join(t.TempDir(), "hermes")
for _, root := range []string{customClaude, customCodex, customHermes, filepath.Join(home, ".moltbot"), filepath.Join(home, ".copilot"), filepath.Join(home, ".config", "opencode"), filepath.Join(home, ".config", "agents"), filepath.Join(home, ".codeium", "windsurf")} {
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupGetenv, func(name string) string {
switch name {
case "CLAUDE_CONFIG_DIR":
return customClaude
case "CODEX_HOME":
return customCodex
case "HERMES_HOME":
return customHermes
default:
return ""
}
})
dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
filepath.Join(home, ".agents", "skills"),
filepath.Join(customClaude, "skills"),
filepath.Join(customCodex, "skills"),
filepath.Join(customHermes, "skills"),
filepath.Join(home, ".moltbot", "skills"),
filepath.Join(home, ".copilot", "skills"),
filepath.Join(home, ".config", "opencode", "skills"),
filepath.Join(home, ".config", "agents", "skills"),
filepath.Join(home, ".codeium", "windsurf", "skills"),
} {
found := false
for _, got := range dests {
if sameSkillSetupPath(got, want) {
found = true
break
}
}
if !found {
t.Fatalf("resolved targets %v missing %s", dests, want)
}
}
if !isUniversalSkillSetupBase(filepath.Join(customCodex, "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "opencode", "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "agents", "skills")) {
t.Fatal("custom Codex, OpenCode, and Amp must be universal cleanup-only targets")
}
}
func TestCrossPlatformCoverageSkillSetupCanonicalFailureStopsDependentTargets(t *testing.T) {
home := t.TempDir()
canonical := filepath.Join(home, ".agents", "skills")
claude := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(claude, 0o755); err != nil {
t.Fatal(err)
}
oldClaude := filepath.Join(claude, "dingtalk-chat")
if err := os.MkdirAll(oldClaude, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(oldClaude, "SKILL.md"), []byte("old remains"), 0o644); err != nil {
t.Fatal(err)
}
src := t.TempDir()
if err := os.MkdirAll(filepath.Join(src, "dingtalk-chat"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "dingtalk-chat", "SKILL.md"), []byte("new"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return errors.New("canonical copy denied") })
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical") {
t.Fatalf("canonical failure = %v", err)
}
body, readErr := os.ReadFile(filepath.Join(oldClaude, "SKILL.md"))
if readErr != nil || string(body) != "old remains" {
t.Fatalf("dependent Claude target changed: %q, %v", body, readErr)
}
}
func TestCrossPlatformCoverageSkillSetupCanonicalCopyFallbackMessage(t *testing.T) {
home := t.TempDir()
canonical := filepath.Join(home, ".agents", "skills")
claude := filepath.Join(home, ".claude", "skills")
src := t.TempDir()
skillSrc := filepath.Join(src, "dingtalk-chat")
if err := os.MkdirAll(skillSrc, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupSymlink, func(string, string) error { return errors.New("links unavailable") })
plan, err := buildSkillSetupPlan(
skillSetupModeMulti,
src,
[]string{canonical, claude},
[]string{"dingtalk-chat"},
false,
)
if err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut)
if err != nil || installed != 2 || skipped != 0 {
t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err)
}
if !strings.Contains(errOut.String(), "自动改用兼容安装") {
t.Fatalf("human-readable fallback message missing: %s", errOut.String())
}
info, err := os.Lstat(filepath.Join(claude, "dingtalk-chat"))
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("copy fallback = %#v, %v", info, err)
}
}
func TestCrossPlatformCoverageUpstreamAgentEnumerationAndEffectiveRoots(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" })
expected := map[string]string{
"aider-desk": ".aider-desk/skills", "amp": ".config/agents/skills",
"antigravity": ".gemini/antigravity/skills", "antigravity-cli": ".gemini/antigravity-cli/skills",
"astrbot": ".astrbot/data/skills", "autohand-code": ".autohand/skills",
"augment": ".augment/skills", "bob": ".bob/skills", "claude-code": ".claude/skills",
"openclaw": ".openclaw/skills", "cline": ".agents/skills", "codearts-agent": ".codeartsdoer/skills",
"codebuddy": ".codebuddy/skills", "codemaker": ".codemaker/skills", "codestudio": ".codestudio/skills",
"codex": ".codex/skills", "command-code": ".commandcode/skills", "continue": ".continue/skills",
"cortex": ".snowflake/cortex/skills", "crush": ".config/crush/skills", "cursor": ".cursor/skills",
"deepagents": ".deepagents/agent/skills", "devin": ".config/devin/skills", "dexto": ".agents/skills",
"droid": ".factory/skills", "firebender": ".firebender/skills", "forgecode": ".forge/skills",
"gemini-cli": ".gemini/skills", "github-copilot": ".copilot/skills", "goose": ".config/goose/skills",
"grok": ".grok/skills", "hermes-agent": ".hermes/skills", "inference-sh": ".inferencesh/skills",
"jazz": ".jazz/skills", "junie": ".junie/skills", "iflow-cli": ".iflow/skills",
"kilo": ".kilocode/skills", "kimchi": ".config/kimchi/harness/skills", "kimi-code-cli": ".agents/skills",
"kiro-cli": ".kiro/skills", "kode": ".kode/skills", "lingma": ".lingma/skills", "loaf": ".agents/skills",
"mcpjam": ".mcpjam/skills", "minimax-code": ".minimax/skills", "mistral-vibe": ".vibe/skills",
"moxby": ".moxby/skills", "mux": ".mux/skills", "opencode": ".config/opencode/skills",
"openhands": ".openhands/skills", "ona": ".ona/skills", "pi": ".pi/agent/skills",
"qoder": ".qoder/skills", "qoder-cn": ".qoder-cn/skills", "qwen-code": ".qwen/skills",
"replit": ".config/agents/skills", "reasonix": ".reasonix/skills", "rovodev": ".rovodev/skills",
"roo": ".roo/skills", "tabnine-cli": ".tabnine/agent/skills", "terramind": ".terramind/skills",
"tinycloud": ".tinycloud/skills", "trae": ".trae/skills", "trae-cn": ".trae-cn/skills",
"universal": ".config/agents/skills", "warp": ".agents/skills", "windsurf": ".codeium/windsurf/skills",
"zed": ".agents/skills", "zcode": ".zcode/skills", "zencoder": ".zencoder/skills",
"zenflow": ".zencoder/skills", "neovate": ".neovate/skills", "pochi": ".pochi/skills", "adal": ".adal/skills",
}
if got := len(expected) + len(unsupportedGlobalAgentTargets); got != 76 {
t.Fatalf("upstream agent enumeration = %d, want 76", got)
}
for target, rel := range expected {
mapped, ok := agentSkillPaths[target]
if !ok || filepath.Clean(mapped) != filepath.Clean(rel) {
t.Errorf("agent %s map = %q, want %q", target, mapped, rel)
}
if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, filepath.Join(home, filepath.FromSlash(rel))) {
t.Errorf("agent %s effective root = %q, want %q", target, got, filepath.Join(home, rel))
}
}
for _, target := range []string{"eve", "promptscript"} {
if _, err := resolveSkillSetupTargets(target, skillSetupModeMulti); err == nil {
t.Errorf("%s unexpectedly resolved a global setup root", target)
}
if _, err := resolveSkillTargetPath(target); err == nil {
t.Errorf("%s unexpectedly resolved a marketplace install root", target)
}
}
if got := supportedTargets(); !strings.Contains(got, "eve") || !strings.Contains(got, "promptscript") {
t.Fatalf("supported targets omit no-global upstream agents: %s", got)
}
custom := map[string]string{
"AUTOHAND_HOME": filepath.Join(home, "autohand-home"), "CLAUDE_CONFIG_DIR": filepath.Join(home, "claude-home"),
"CODEX_HOME": filepath.Join(home, "codex-home"), "GROK_HOME": filepath.Join(home, "grok-home"),
"HERMES_HOME": filepath.Join(home, "hermes-home"), "VIBE_HOME": filepath.Join(home, "vibe-home"),
"XDG_CONFIG_HOME": filepath.Join(home, "xdg"),
}
testseam.Swap(t, &skillSetupGetenv, func(name string) string { return custom[name] })
customCases := map[string]string{
"autohand-code": filepath.Join(custom["AUTOHAND_HOME"], "skills"),
"claude-code": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"),
"codex": filepath.Join(custom["CODEX_HOME"], "skills"),
"grok": filepath.Join(custom["GROK_HOME"], "skills"),
"hermes-agent": filepath.Join(custom["HERMES_HOME"], "skills"),
"mistral-vibe": filepath.Join(custom["VIBE_HOME"], "skills"),
"amp": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
"replit": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
"universal": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
"crush": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"),
"devin": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"),
"goose": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"),
"kimchi": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"),
"opencode": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"),
}
for target, want := range customCases {
if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, want) {
t.Errorf("custom %s root = %q, want %q", target, got, want)
}
}
for _, target := range []string{"codex", "amp", "opencode"} {
if !isUniversalSkillSetupBase(resolveSkillSetupBase(home, target)) {
t.Errorf("custom %s root not classified universal", target)
}
}
}
func TestCrossPlatformCoverageOpenClawAliasPriority(t *testing.T) {
for _, tc := range []struct {
name string
dirs []string
want string
}{
{name: "default", want: ".openclaw"},
{name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"},
{name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"},
{name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"},
} {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
for _, dir := range tc.dirs {
if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil {
t.Fatal(err)
}
}
if got := resolveOpenClawSetupBase(home); got != filepath.Join(home, tc.want, "skills") {
t.Fatalf("OpenClaw root = %q", got)
}
})
}
}
func TestCrossPlatformCoverageSkillSetupWindowsPathNormalization(t *testing.T) {
testseam.Swap(t, &skillSetupFoldPathCase, true)
if !sameSkillSetupPath(filepath.Join("Root", "Skills"), filepath.Join("root", "skills")) {
t.Fatal("case-insensitive platform path normalization failed")
}
}
@@ -15,7 +15,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
func useManagedSkillNames(t *testing.T, names ...string) {
@@ -337,12 +336,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *test
return originalBackup(homeDir, dir)
})
} else {
originalPublish := skillSetupPublishPath
testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) {
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return upgrade.SkillPathPublication{}, failure
return failure
}
return originalPublish(oldPath, newPath)
return originalRename(oldPath, newPath)
})
}
@@ -433,8 +432,8 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
t.Run("restore failure aggregation", func(t *testing.T) {
t.Run("remove published", func(t *testing.T) {
testseam.Swap(t, &skillSetupRollbackPaths, func([]upgrade.SkillPathPublication) error { return failure })
if err := restoreSkillSetupTarget([]upgrade.SkillPathPublication{{Destination: "published"}}, nil); !errors.Is(err, failure) {
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure })
if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) {
t.Fatalf("remove published error = %v", err)
}
})
@@ -446,14 +445,14 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
}
})
t.Run("stat", func(t *testing.T) {
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure })
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") {
t.Fatalf("restore stat error = %v", err)
}
})
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") {
@@ -461,9 +460,9 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
}
})
t.Run("rename", func(t *testing.T) {
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") {
t.Fatalf("restore rename error = %v", err)
@@ -480,10 +479,10 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
}
return "", failure
})
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
restoreErr := errors.New("restore failure")
testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return restoreErr })
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr })
_, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard)
if !errors.Is(err, failure) || !errors.Is(err, restoreErr) {
t.Fatalf("backup rollback error = %v", err)
@@ -491,11 +490,8 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
})
t.Run("publish rollback failure", func(t *testing.T) {
testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
return upgrade.SkillPathPublication{}, failure
})
testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
err := publishSkillSetupTarget(
[]skillSetupStagedDir{{staged: "staged", dest: "dest"}},
@@ -534,12 +530,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
t.Run("after publish failure", func(t *testing.T) {
plan := newPlan(t)
originalPublish := skillSetupPublishPath
testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) {
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return upgrade.SkillPathPublication{}, failure
return failure
}
return originalPublish(oldPath, newPath)
return originalRename(oldPath, newPath)
})
originalRemoveAll := skillSetupRemoveAll
cleanupErr := errors.New("cleanup after publish failure")
@@ -1,139 +0,0 @@
package app
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies
// pins the P0 fix: when a beta.6 folded dingtalk-misc (carrying the personal
// Event route) exists only in a UNIVERSAL agent home (~/.codex/skills), the
// Event/misc migration must not leave physical duplicates there. Universal
// homes read ~/.agents/skills directly, so their old folded copies are retired
// (backed up) and the split standalone event + clean misc land in canonical.
func TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
codexSkills := filepath.Join(home, ".codex", "skills")
writeFoldedEventMisc(t, codexSkills)
// beta.6 physical copies alongside the folded misc.
for _, name := range []string{multiEventSkill, multiSharedSkill} {
if err := os.MkdirAll(filepath.Join(codexSkills, name), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(codexSkills, name, "SKILL.md"), []byte("beta6 "+name+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
canonical := filepath.Join(home, ".agents", "skills")
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
}
// P0: the universal home must not retain any physical dingtalk-* copy.
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} {
if _, err := os.Stat(filepath.Join(codexSkills, name)); !os.IsNotExist(err) {
t.Fatalf("universal .codex/skills still has physical %s (stat err=%v)", name, err)
}
}
// canonical owns the new standalone event + clean misc (+ shared).
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} {
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
t.Fatalf("canonical missing %s: %v", name, err)
}
}
if err := validateCleanEventMiscRoot(filepath.Join(canonical, multiMiscSkill)); err != nil {
t.Fatalf("canonical misc still contains folded Event content: %v", err)
}
if _, _, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes"); err != nil {
t.Fatalf("idempotent rerun failed: %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupUnrelatedSelectionPreservesUniversalFoldedPair(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
canonical := filepath.Join(home, ".agents", "skills")
codexSkills := filepath.Join(home, ".codex", "skills")
writeFoldedEventMisc(t, codexSkills)
writeOldStandaloneEvent(t, codexSkills)
chat := filepath.Join(codexSkills, "dingtalk-chat")
if err := os.MkdirAll(chat, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(chat, "SKILL.md"), []byte("keep chat\n"), 0o644); err != nil {
t.Fatal(err)
}
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc"})
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "doc", "--yes")
if err != nil {
t.Fatalf("selective doc install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
}
if strings.Contains(stdout, "Event 原子迁移") {
t.Fatalf("unrelated selection migrated Event/misc: %s", stdout)
}
assertOldEventMiscPair(t, codexSkills)
if body, err := os.ReadFile(filepath.Join(chat, "SKILL.md")); err != nil || string(body) != "keep chat\n" {
t.Fatalf("unselected chat changed: body=%q err=%v", body, err)
}
}
func TestCrossPlatformCoverageSkillSetupUniversalRetirementFailures(t *testing.T) {
failure := errors.New("retirement denied")
t.Run("home", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
codex := filepath.Join(t.TempDir(), ".codex", "skills")
if err := retireMigratedUniversalSkills([]string{codex}, []string{multiEventSkill}, io.Discard); !errors.Is(err, failure) {
t.Fatalf("home failure = %v, want %v", err, failure)
}
})
t.Run("deduplicate", func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
codex := filepath.Join(home, ".codex", "skills")
if err := retireMigratedUniversalSkills(
[]string{codex, codex},
[]string{multiEventSkill, multiEventSkill},
io.Discard,
); err != nil {
t.Fatalf("deduplicated retirement failed: %v", err)
}
})
// Retiring an obsolete universal copy installs nothing, so its failure is
// surfaced as a warning and the successful installation is kept.
t.Run("backup failure warns without failing the command", func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
codex := filepath.Join(home, ".codex", "skills")
writeFoldedEventMisc(t, codex)
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
return "", failure
})
_, stderr, err := executeMultiSkillSetupTest(
t,
src,
[]string{filepath.Join(home, ".agents", "skills"), codex},
"--skill", "event", "--yes",
)
if err != nil {
t.Fatalf("retirement backup failure must not fail the command: %v", err)
}
if !strings.Contains(stderr, "退役 universal Agent") {
t.Fatalf("retirement backup failure must be reported, stderr = %q", stderr)
}
})
}
+5 -35
View File
@@ -523,45 +523,15 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing
}
})
t.Run("ordinary install error", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return fail })
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
return 0, 0, fail
})
migration := filepath.Join(t.TempDir(), "migration")
ordinary := filepath.Join(t.TempDir(), "ordinary")
if _, _, err := installMultiSkillsWithEventMigration(src, []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); err == nil || !strings.Contains(err.Error(), "未执行迁移") {
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("ordinary install failure = %v", err)
}
if copyCalls == 0 {
t.Fatal("ordinary staging failure seam was not exercised")
}
})
t.Run("canonical ordinary install error", func(t *testing.T) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
return 0, 0, fail
})
home := t.TempDir()
canonical := filepath.Join(home, ".agents", "skills")
universalMigration := filepath.Join(home, ".codex", "skills")
if _, _, err := installMultiSkillsWithEventMigration(
"src",
[]string{multiEventSkill},
[]string{canonical, universalMigration},
[]string{universalMigration},
true,
io.Discard,
io.Discard,
); !errors.Is(err, fail) {
t.Fatalf("canonical ordinary install failure = %v, want %v", err, fail)
}
})
t.Run("prerequisite install error", func(t *testing.T) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
return 0, 0, fail
})
migration := filepath.Join(t.TempDir(), "migration")
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("prerequisite install failure = %v", err)
}
+36 -24
View File
@@ -48,13 +48,16 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t
backupCalls, copyCalls := []string{}, 0
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) {
backupCalls = append(backupCalls, path)
if err := os.RemoveAll(path); err != nil {
return "", err
}
return "backup", nil
})
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error {
if err := os.RemoveAll(dest); err != nil {
return err
}
return os.Rename(src, dest)
})
dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var dryRunOut bytes.Buffer
dryRunCmd.SetOut(&dryRunOut)
@@ -116,9 +119,6 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t
// A filtered multi plan replaces only selected same-name skills and leaves
// unselected siblings out of the backup set.
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
t.Fatal(err)
}
filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true)
if err != nil {
t.Fatal(err)
@@ -149,38 +149,55 @@ func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testin
func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".codex", "skills")
canonical := filepath.Join(home, ".agents", "skills")
oldCodex := filepath.Join(dest, "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
genericMono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(genericMono, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
return "", errors.New("transient HOME failure")
})
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{canonical, dest}, []string{"dingtalk-chat"}, true)
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != dest {
t.Fatalf("universal cleanup target = %#v", plan.Targets)
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) {
t.Fatalf("generic cleanup target = %#v", plan.Targets)
}
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != oldCodex {
t.Fatalf("universal cleanup backups = %#v", plan.Targets[1].Backups)
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono {
t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups)
}
var preview bytes.Buffer
renderSkillSetupPlan(&preview, plan)
if !strings.Contains(preview.String(), "改用统一安装位置") {
t.Fatalf("universal cleanup preview missing: %s", preview.String())
if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") {
t.Fatalf("generic cleanup preview missing: %s", preview.String())
}
t.Run("managed multi and scan failure", func(t *testing.T) {
managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true})
if targetErr != nil || target == nil || len(target.Backups) != 2 {
t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr)
}
failure := errors.New("generic scan failure")
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) {
t.Fatalf("generic scan error = %v", targetErr)
}
if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) {
t.Fatalf("generic cleanup plan error = %v", planErr)
}
})
}
func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) {
failure := errors.New("cleanup failure")
cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}}
t.Run("cleanup runs even after an install skip", func(t *testing.T) {
t.Run("prior skip suppresses cleanup", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}}
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard)
@@ -189,14 +206,11 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.
}
})
// A cleanup-only target installs nothing, so its failure is a warning and
// must never increment skipped — runSkillSetup turns any skipped count into
// a hard error and would fail an otherwise complete installation.
t.Run("home failure keeps generic copy", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") {
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") {
t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
@@ -206,7 +220,7 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "迁移失败") {
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") {
t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
@@ -233,13 +247,11 @@ func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testin
t.Fatal(err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") {
t.Fatalf("replacement stat error = %v", err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") {
t.Fatalf("stale scan error = %v", err)
@@ -1,85 +0,0 @@
package app
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
func TestCrossPlatformCoverageSkillSetupRollbackRetainsConcurrentReplacement(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".agents", "skills")
first := filepath.Join(base, "dingtalk-first")
second := filepath.Join(base, "dingtalk-second")
writeSkillSetupFile(t, first, "old first")
writeSkillSetupFile(t, second, "old second")
backups, err := backupSkillSetupTarget(home, []skillSetupBackup{{Path: first}, {Path: second}}, io.Discard)
if err != nil {
t.Fatal(err)
}
stageRoot := filepath.Join(base, ".stage")
stagedFirst := filepath.Join(stageRoot, "dingtalk-first")
stagedSecond := filepath.Join(stageRoot, "dingtalk-second")
writeSkillSetupFile(t, stagedFirst, "new first")
writeSkillSetupFile(t, stagedSecond, "new second")
failure := errors.New("injected second setup publication failure")
originalPublish := skillSetupPublishPath
calls := 0
testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) {
calls++
if calls == 2 {
if err := os.RemoveAll(first); err != nil {
t.Fatal(err)
}
writeSkillSetupFile(t, first, "concurrent")
return upgrade.SkillPathPublication{}, failure
}
return originalPublish(staged, destination)
})
err = publishSkillSetupTarget([]skillSetupStagedDir{
{staged: stagedFirst, dest: first},
{staged: stagedSecond, dest: second},
}, backups)
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("setup transaction error = %v", err)
}
assertSkillSetupFile(t, first, "concurrent")
assertSkillSetupFile(t, second, "old second")
var firstBackup string
for _, item := range backups {
if item.original == first {
firstBackup = item.backup
break
}
}
if firstBackup == "" {
t.Fatal("first backup was not recorded")
}
assertSkillSetupFile(t, firstBackup, "old first")
}
func writeSkillSetupFile(t *testing.T, dir, content string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func assertSkillSetupFile(t *testing.T, dir, want string) {
t.Helper()
content, err := os.ReadFile(filepath.Join(dir, "SKILL.md"))
if err != nil || string(content) != want {
t.Fatalf("Skill content at %s = %q, %v; want %q", dir, content, err, want)
}
}
+16 -15
View File
@@ -297,12 +297,12 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected canonical + Claude, got %d", len(got))
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
}
want := filepath.Join(home, ".claude", "skills", "dws")
if filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[1])
if filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[0])
}
}
@@ -321,12 +321,12 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected canonical + Claude, got %d", len(got))
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
}
want := filepath.Join(home, ".claude", "skills")
if filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[1])
if filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[0])
}
}
@@ -343,8 +343,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t
t.Fatal(err)
}
want := filepath.Join(home, ".codex", "skills")
if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want canonical + %s", got, want)
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
}
}
@@ -360,8 +360,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T)
t.Fatal(err)
}
want := filepath.Join(home, ".zcode", "skills")
if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want canonical + %s", got, want)
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
}
explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono)
@@ -369,8 +369,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T)
t.Fatal(err)
}
wantMono := filepath.Join(want, "dws")
if len(explicit) != 2 || filepath.Clean(explicit[1]) != filepath.Clean(wantMono) {
t.Fatalf("explicit zcode targets = %v, want canonical + %s", explicit, wantMono)
if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) {
t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono)
}
}
@@ -1055,11 +1055,12 @@ func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets(
if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
}
if !strings.Contains(stdout, "请重启已打开的 Agent") {
if !strings.Contains(stdout, "重新加载 Skills") {
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
}
-3
View File
@@ -620,9 +620,6 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
for _, d := range succeeded {
fmt.Printf(" %s %s\n", ugDim("→"), ugCyan(shortenHome(d.Dir)))
}
for _, d := range result.RetireWarnings() {
fmt.Printf(" %s %s %s\n", ugYellow("⚠"), shortenHome(d.Dir), ugDim("旧副本未能迁移,可手动删除: "+d.Err.Error()))
}
} else {
fmt.Printf(" %s\n", ugGreen("✓"))
}
+3 -9
View File
@@ -292,12 +292,6 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
if stage == "install-failed-dir" {
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil
}
if stage == "install-retire-warning" {
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{
{Dir: "/ok", Status: upgradepkg.SkillDirOK},
{Dir: "/stale", Status: upgradepkg.SkillDirRetireWarning, Err: errors.New("retirement refused")},
}}, nil
}
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil
}
}
@@ -306,7 +300,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
"ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both",
"backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills",
"extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir",
"success", "success-no-skills", "install-retire-warning",
"success", "success-no-skills",
} {
t.Run(stage, func(t *testing.T) {
configure(stage)
@@ -336,14 +330,14 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
opts.skipSkills = true
}
err := runUpgrade(context.Background(), opts)
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" && stage != "install-retire-warning"
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills"
if wantError && err == nil {
t.Fatalf("stage %s succeeded", stage)
}
if !wantError && err != nil {
t.Fatalf("stage %s failed: %v", stage, err)
}
if (stage == "success" || stage == "success-no-skills" || stage == "install-retire-warning") && !rb.cleaned {
if (stage == "success" || stage == "success-no-skills") && !rb.cleaned {
t.Fatal("successful upgrade did not clean backups")
}
if stage == "success" {
+1 -1
View File
@@ -3422,7 +3422,7 @@ var generatedParamAliases = []ParamAliasEntry{
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group-role update",
+2
View File
@@ -155,6 +155,7 @@
"chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
"chat +chat-role-set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
"chat group-role remove-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
"chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
@@ -437,6 +438,7 @@
{"command": "chat message send", "emitted": "file-path", "expect": "file", "via": "native:reviewed-compatibility-alias"},
{"command": "chat category add-conv", "emitted": "category-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
{"command": "chat category rename", "emitted": "category-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
{"command": "chat group-role set-user", "emitted": "role-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
{"command": "chat group-role update", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
{"command": "chat message send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"},
{"command": "chat message send-by-bot", "emitted": "at-users", "expect": "at-user-ids", "via": "override:scoped-role"},
+1 -1
View File
@@ -1058,7 +1058,7 @@ var schemaCompactPayloadKeys = map[string]bool{
"agent_summary": true, "description": true,
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
"interface_mode": true, "availability": true, "interface_reason": true,
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
"parameters": true, "constraints": true, "positionals": true, "dry_run": true, "wait": true,
"result": true, "pagination": true,
"examples": true, "use_when": true, "avoid_when": true,
}
+1
View File
@@ -81,6 +81,7 @@ var schemaCatalogToolOptionalKeys = []string{
"pagination",
"positionals",
"result",
"wait",
}
var schemaCatalogToolEnums = map[string][]string{
+21
View File
@@ -60,6 +60,7 @@ type ToolSpec struct {
Constraints RuntimeSchemaConstraints
Positionals []contract.RuntimeSchemaPositional
DryRun *contract.DryRunSpec
Wait *contract.WaitSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
Safety contract.SafetySpec
@@ -135,6 +136,7 @@ type RuntimeToolSpecInput struct {
Constraints RuntimeSchemaConstraints
Positionals []contract.RuntimeSchemaPositional
DryRun *contract.DryRunSpec
Wait *contract.WaitSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
Safety contract.SafetySpec
@@ -542,6 +544,11 @@ func (t ToolSpec) Validate() error {
return err
}
}
if t.Wait != nil {
if err := t.Wait.Validate(id.CanonicalPath); err != nil {
return err
}
}
if t.Result != nil {
if _, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath); err != nil {
return err
@@ -744,6 +751,16 @@ func (t ToolSpec) normalized() ToolSpec {
dryRun.PreviewKind = strings.TrimSpace(dryRun.PreviewKind)
out.DryRun = &dryRun
}
if t.Wait != nil {
// NormalizeWaitSpec is the single canonical form shared with the
// declaration path: trimmed status values, duplicate/conflict
// rejection, defensive copy. Invalid declarations are rejected by
// ToolSpec.Validate below, which runs the same normalization
// through WaitSpec.Validate.
if wait, err := contract.NormalizeWaitSpec(t.Wait, id.CanonicalPath); err == nil {
out.Wait = wait
}
}
if t.Result != nil {
result, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath)
if err == nil {
@@ -982,6 +999,10 @@ func (t ToolSpec) ToPayload() (map[string]any, error) {
value, _ := typedJSONValue(t.DryRun)
payload["dry_run"] = value
}
if t.Wait != nil {
value, _ := typedJSONValue(t.Wait)
payload["wait"] = value
}
if t.Result != nil {
value, _ := typedJSONValue(t.Result)
payload["result"] = value
@@ -974,3 +974,57 @@ func TestFinalProvenanceCoverageDoesNotInventOptionalInterfaceReason(t *testing.
t.Fatalf("optional local interface_reason should not require invented provenance: %v", err)
}
}
func TestToolSpecWaitCapabilityIsPositiveOnly(t *testing.T) {
base := RuntimeToolSpecInput{Identity: contract.ToolIdentitySpec{
ProductID: "sample",
Name: "waitrun",
CLIName: "waitrun",
CLIPath: "sample waitrun",
}}
withoutCapability, err := ToolSpecFromRuntime(base)
if err != nil {
t.Fatalf("ToolSpecFromRuntime() error = %v", err)
}
payload, err := withoutCapability.ToPayload()
if err != nil {
t.Fatalf("ToPayload() error = %v", err)
}
if _, ok := payload["wait"]; ok {
t.Fatalf("nil capability unexpectedly emitted wait: %#v", payload["wait"])
}
base.Wait = &contract.WaitSpec{Mode: "webhook"}
if _, err := ToolSpecFromRuntime(base); err == nil || !strings.Contains(err.Error(), "unknown mode") {
t.Fatalf("invalid mode error = %v", err)
}
base.Wait = &contract.WaitSpec{Mode: contract.WaitModeEvent, StatusQuery: "status", Terminal: map[string]contract.ResultOutcome{"DONE": contract.ResultOutcomeSuccess}}
if _, err := ToolSpecFromRuntime(base); err == nil || !strings.Contains(err.Error(), "requires event_key") {
t.Fatalf("event mode body error = %v", err)
}
base.Wait = &contract.WaitSpec{
Mode: contract.WaitModePoll,
PollCommand: "sample status get",
StatusQuery: "result.status",
Terminal: map[string]contract.ResultOutcome{"COMPLETED": contract.ResultOutcomeSuccess},
PendingValues: []string{"NEW"},
DefaultTimeoutSecs: 120,
}
withCapability, err := ToolSpecFromRuntime(base)
if err != nil {
t.Fatalf("ToolSpecFromRuntime(valid wait) error = %v", err)
}
if withCapability.Wait == nil || withCapability.Wait.Mode != contract.WaitModePoll {
t.Fatalf("wait capability lost through normalization: %#v", withCapability.Wait)
}
payload, err = withCapability.ToPayload()
if err != nil {
t.Fatalf("ToPayload(valid wait) error = %v", err)
}
wait := payload["wait"].(map[string]any)
if wait["mode"] != contract.WaitModePoll || wait["poll_command"] != "sample status get" {
t.Fatalf("wait payload=%#v", wait)
}
}
+1
View File
@@ -354,6 +354,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
Constraints: constraints,
Positionals: positionals,
DryRun: final.DryRun,
Wait: final.Wait,
Result: result,
Pagination: pagination,
Safety: safety,
+2
View File
@@ -65,6 +65,7 @@ type schemaToolWire struct {
Constraints RuntimeSchemaConstraints `json:"constraints"`
Positionals []contract.RuntimeSchemaPositional `json:"positionals"`
DryRun *contract.DryRunSpec `json:"dry_run"`
Wait *contract.WaitSpec `json:"wait"`
Result *contract.ResultSpec `json:"result"`
Pagination *contract.PaginationSpec `json:"pagination"`
Effect string `json:"effect"`
@@ -269,6 +270,7 @@ func schemaToolSpecFromWire(wire schemaToolWire) (ToolSpec, error) {
Constraints: wire.Constraints,
Positionals: wire.Positionals,
DryRun: wire.DryRun,
Wait: wire.Wait,
Result: wire.Result,
Pagination: wire.Pagination,
Safety: contract.SafetySpec{
+1
View File
@@ -30,6 +30,7 @@ type ContractFinalPayload struct {
Parameters []ParamDecl
Safety *SafetySpec
DryRun *DryRunSpec
Wait *WaitSpec
Result *ResultSpec
Pagination *PaginationSpec
Interface *InterfaceSpec
+149
View File
@@ -62,6 +62,155 @@ type DryRunSpec struct {
RemoteReads bool `json:"remote_reads,omitempty"`
}
// Wait modes. Poll executes the leaf's WaitPoll hook on a cadence. Event
// consumes the leaf's WaitEvents push stream and correlates events to the
// accepted resource. Auto prefers the event stream and falls back to polling
// when the stream ends before a terminal status.
const (
WaitModePoll = "poll"
WaitModeEvent = "event"
WaitModeAuto = "auto"
)
// WaitSpec is a positive capability declaration for terminal-state waiting
// (approval flows, async exports, batch jobs). A nil ToolSpec.Wait means the
// command has not declared reviewed --wait support; the flag is not
// registered and the Schema does not publish the capability.
//
// Like DryRunSpec, the object is one atomic contract field: Schema only
// projects the reviewed capability; runtime execution stays owned by the
// command runner through the leaf's WaitPoll / WaitEvents hooks. PollCommand
// names the read command that observes status — it is a declared,
// catalog-visible fact (the same command an agent would poll manually), not
// a framework-owned invocation: how one poll or event subscription executes
// is decided by the leaf.
type WaitSpec struct {
Mode string `json:"mode"`
PollCommand string `json:"poll_command,omitempty"`
StatusQuery string `json:"status_query"`
Terminal map[string]ResultOutcome `json:"terminal"`
PendingValues []string `json:"pending_values,omitempty"`
// EventKey is the push channel key the WaitEvents hook subscribes to
// (event/auto modes). Declared for the catalog; the transport stays
// leaf-owned.
EventKey string `json:"event_key,omitempty"`
// MatchField is the event-document path holding the resource identifier
// (event/auto modes); its value must equal the ResourceQuery resolution
// of the accepted result.
MatchField string `json:"match_field,omitempty"`
// ResourceQuery is the dotted path into the accepted result data that
// yields the resource identifier correlated against MatchField
// (event/auto modes).
ResourceQuery string `json:"resource_query,omitempty"`
// DefaultTimeoutSecs is the reviewed default for --wait-timeout. Zero
// means the framework default (300s); the user flag always wins.
DefaultTimeoutSecs int `json:"default_timeout_secs"`
}
// Validate checks mode requirements and the terminal/pending status maps.
// Unknown terminal outcomes, unknown modes, and mode/body mismatches fail at
// declaration so a malformed wait capability cannot reach the wire.
// Validation delegates to NormalizeWaitSpec so the acceptance rules can never
// drift from the normalization the wire and the runtime wait engine share.
func (w WaitSpec) Validate(canonical string) error {
_, err := NormalizeWaitSpec(&w, canonical)
return err
}
// NormalizeWaitSpec returns a validated, canonical, defensively copied wait
// contract. It is shared by declaration (corecmd.New / AttachContract),
// ToolSpec, and snapshot paths, mirroring NormalizeResultSpec. Status values
// are trimmed into their wire form: the wait engine compares backend
// statuses verbatim against these tables, so a padded declaration
// (" processing ") would publish a Schema that its own runtime treats as an
// unknown status. Values collapsing onto one value after trimming (duplicate
// pending values, duplicate terminal keys, terminal/pending conflicts) are
// rejected instead of silently merged.
func NormalizeWaitSpec(in *WaitSpec, canonical string) (*WaitSpec, error) {
if in == nil {
return nil, nil
}
canonical = defaultString(strings.TrimSpace(canonical), "<unknown>")
out := &WaitSpec{
Mode: strings.TrimSpace(in.Mode),
PollCommand: strings.TrimSpace(in.PollCommand),
StatusQuery: strings.TrimSpace(in.StatusQuery),
EventKey: strings.TrimSpace(in.EventKey),
MatchField: strings.TrimSpace(in.MatchField),
ResourceQuery: strings.TrimSpace(in.ResourceQuery),
DefaultTimeoutSecs: in.DefaultTimeoutSecs,
}
if out.Mode == "" {
return nil, fmt.Errorf("schema tool %s wait has no mode", canonical)
}
switch out.Mode {
case WaitModePoll, WaitModeEvent, WaitModeAuto:
default:
return nil, fmt.Errorf("schema tool %s wait has unknown mode %q", canonical, out.Mode)
}
needsPoll := out.Mode == WaitModePoll || out.Mode == WaitModeAuto
if needsPoll && out.PollCommand == "" {
return nil, fmt.Errorf("schema tool %s wait mode %s requires poll_command", canonical, out.Mode)
}
needsEvent := out.Mode == WaitModeEvent || out.Mode == WaitModeAuto
if needsEvent {
if out.EventKey == "" {
return nil, fmt.Errorf("schema tool %s wait mode %s requires event_key", canonical, out.Mode)
}
if out.MatchField == "" {
return nil, fmt.Errorf("schema tool %s wait mode %s requires match_field", canonical, out.Mode)
}
if out.ResourceQuery == "" {
return nil, fmt.Errorf("schema tool %s wait mode %s requires resource_query", canonical, out.Mode)
}
}
if out.StatusQuery == "" {
return nil, fmt.Errorf("schema tool %s wait mode %s requires status_query", canonical, out.Mode)
}
if len(in.Terminal) == 0 {
return nil, fmt.Errorf("schema tool %s wait has no terminal states", canonical)
}
out.Terminal = make(map[string]ResultOutcome, len(in.Terminal))
for status, outcome := range in.Terminal {
status = strings.TrimSpace(status)
if status == "" {
return nil, fmt.Errorf("schema tool %s wait has a blank terminal status", canonical)
}
if _, dup := out.Terminal[status]; dup {
return nil, fmt.Errorf("schema tool %s wait has duplicate terminal status %q", canonical, status)
}
// Terminal states must close into success or failure. Pending and
// partial are not wait outcomes: pending is expressed through
// timeout, and partial requires the typed multi-status payload only
// the leaf can construct.
if outcome != ResultOutcomeSuccess && outcome != ResultOutcomeFailure {
return nil, fmt.Errorf(
"schema tool %s wait terminal status %q must map to success or failure, got %q",
canonical, status, outcome)
}
out.Terminal[status] = outcome
}
seenPending := make(map[string]bool, len(in.PendingValues))
for _, value := range in.PendingValues {
value = strings.TrimSpace(value)
if value == "" {
return nil, fmt.Errorf("schema tool %s wait has a blank pending value", canonical)
}
if _, conflict := out.Terminal[value]; conflict {
return nil, fmt.Errorf("schema tool %s wait status %q is both terminal and pending", canonical, value)
}
if seenPending[value] {
return nil, fmt.Errorf("schema tool %s wait has duplicate pending value %q", canonical, value)
}
seenPending[value] = true
out.PendingValues = append(out.PendingValues, value)
}
if in.DefaultTimeoutSecs < 0 {
return nil, fmt.Errorf("schema tool %s wait default_timeout_secs must be >= 0", canonical)
}
return out, nil
}
// ResultOutcome is one closed unified-output envelope outcome.
type ResultOutcome string
+227
View File
@@ -0,0 +1,227 @@
// 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 contract
import "testing"
func TestWaitSpecValidateAcceptsReviewedShapes(t *testing.T) {
cases := []WaitSpec{
{
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "result.status",
Terminal: map[string]ResultOutcome{
"COMPLETED": ResultOutcomeSuccess,
"REJECTED": ResultOutcomeFailure,
},
PendingValues: []string{"NEW", "RUNNING"},
DefaultTimeoutSecs: 600,
},
}
for i, spec := range cases {
if err := spec.Validate("sample.tool"); err != nil {
t.Fatalf("case %d: unexpected error: %v", i, err)
}
}
}
func TestWaitSpecValidateRejectsMalformedShapes(t *testing.T) {
terminal := map[string]ResultOutcome{"COMPLETED": ResultOutcomeSuccess}
cases := map[string]WaitSpec{
"no mode": {
Terminal: terminal,
},
"unknown mode": {
Mode: "webhook",
Terminal: terminal,
},
"poll without poll_command": {
Mode: WaitModePoll,
StatusQuery: "status",
Terminal: terminal,
},
"poll without status_query": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
Terminal: terminal,
},
"event without event_key": {
Mode: WaitModeEvent,
MatchField: "process_instance_id",
ResourceQuery: "id",
StatusQuery: "result.status",
Terminal: terminal,
},
"event without match_field": {
Mode: WaitModeEvent,
EventKey: "bpms_instance_change",
ResourceQuery: "id",
StatusQuery: "result.status",
Terminal: terminal,
},
"event without resource_query": {
Mode: WaitModeEvent,
EventKey: "bpms_instance_change",
MatchField: "process_instance_id",
StatusQuery: "result.status",
Terminal: terminal,
},
"auto missing poll_command": {
Mode: WaitModeAuto,
EventKey: "export_finished",
MatchField: "job_id",
ResourceQuery: "job_id",
StatusQuery: "status",
Terminal: terminal,
},
"no terminal states": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
},
"blank terminal status": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: map[string]ResultOutcome{" ": ResultOutcomeSuccess},
},
"terminal outcome pending": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: map[string]ResultOutcome{"COMPLETED": ResultOutcomePending, "REJECTED": ResultOutcomeFailure},
},
"terminal outcome partial": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: map[string]ResultOutcome{"COMPLETED": ResultOutcomePartialFailure, "REJECTED": ResultOutcomeFailure},
},
"terminal outcome outside closed set": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: map[string]ResultOutcome{"COMPLETED": ResultOutcome("explosion")},
},
"only pending terminal outcome": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: map[string]ResultOutcome{"NEW": ResultOutcomePending},
},
"status both terminal and pending": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: terminal,
PendingValues: []string{"COMPLETED"},
},
"blank pending value": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: terminal,
PendingValues: []string{" "},
},
"negative timeout default": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: terminal,
DefaultTimeoutSecs: -1,
},
}
for name, spec := range cases {
if err := spec.Validate("sample.tool"); err == nil {
t.Fatalf("%s: expected error, got nil", name)
}
}
}
func TestNormalizeWaitSpecTrimsStatusValuesIntoWireForm(t *testing.T) {
in := &WaitSpec{
Mode: " poll ",
PollCommand: " oa approval-instance get ",
StatusQuery: " result.status ",
Terminal: map[string]ResultOutcome{" COMPLETED ": ResultOutcomeSuccess, "REJECTED": ResultOutcomeFailure},
PendingValues: []string{" NEW ", "RUNNING"},
DefaultTimeoutSecs: 60,
}
out, err := NormalizeWaitSpec(in, "sample.tool")
if err != nil {
t.Fatalf("NormalizeWaitSpec() error = %v", err)
}
if out.Mode != WaitModePoll || out.PollCommand != "oa approval-instance get" || out.StatusQuery != "result.status" {
t.Fatalf("normalized scalars: %#v", out)
}
if len(out.Terminal) != 2 {
t.Fatalf("terminal=%#v, want two trimmed keys", out.Terminal)
}
if got := out.Terminal["COMPLETED"]; got != ResultOutcomeSuccess {
t.Fatalf("terminal[COMPLETED]=%q, want success (key must be trimmed)", got)
}
if _, padded := out.Terminal[" COMPLETED "]; padded {
t.Fatal("padded terminal key survived normalization")
}
for i, want := range []string{"NEW", "RUNNING"} {
if out.PendingValues[i] != want {
t.Fatalf("pending[%d]=%q, want %q", i, out.PendingValues[i], want)
}
}
// The input declaration must stay untouched (defensive copy).
if _, padded := in.Terminal[" COMPLETED "]; !padded {
t.Fatal("NormalizeWaitSpec mutated its input terminal map")
}
if in.PendingValues[0] != " NEW " {
t.Fatal("NormalizeWaitSpec mutated its input pending values")
}
}
func TestNormalizeWaitSpecRejectsDuplicatesAndConflictsAfterTrim(t *testing.T) {
terminal := map[string]ResultOutcome{"COMPLETED": ResultOutcomeSuccess}
cases := map[string]*WaitSpec{
"terminal keys collapsing after trim": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: map[string]ResultOutcome{"COMPLETED": ResultOutcomeSuccess, " COMPLETED ": ResultOutcomeFailure},
},
"pending values collapsing after trim": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: terminal,
PendingValues: []string{"NEW", " NEW "},
},
"terminal/pending conflict hidden by padding": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: terminal,
PendingValues: []string{" COMPLETED "},
},
}
for name, spec := range cases {
if _, err := NormalizeWaitSpec(spec, "sample.tool"); err == nil {
t.Fatalf("%s: expected error, got nil", name)
}
}
}
func TestNormalizeWaitSpecNilReturnsNil(t *testing.T) {
out, err := NormalizeWaitSpec(nil, "sample.tool")
if err != nil || out != nil {
t.Fatalf("NormalizeWaitSpec(nil) = %#v, %v", out, err)
}
}
+4
View File
@@ -42,6 +42,7 @@ type ContractDecl struct {
Positionals []contract.RuntimeSchemaPositional
Parameters []contract.ParamDecl
DryRun *contract.DryRunSpec
Wait *contract.WaitSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
Interface *contract.InterfaceSpec
@@ -146,6 +147,9 @@ func (s ContractDecl) empty() bool {
if s.DryRun != nil && strings.TrimSpace(s.DryRun.PreviewKind) != "" {
return false
}
if s.Wait != nil && strings.TrimSpace(s.Wait.Mode) != "" {
return false
}
if s.Result != nil {
return false
}
@@ -200,6 +200,13 @@ func TestFrameworkContractFinalDeepCopyAndSafetyConflicts(t *testing.T) {
Parameters: []contract.ParamDecl{{Name: "mode", Enum: []string{"a"}, Required: boolPointer(true)}},
Safety: &contract.SafetySpec{Effect: " read ", EffectSource: " source ", Risk: " low ", Confirmation: " not_required ", Idempotency: " idempotent "},
DryRun: &contract.DryRunSpec{PreviewKind: "plan"},
Wait: &contract.WaitSpec{
Mode: contract.WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "result.status",
Terminal: map[string]contract.ResultOutcome{"COMPLETED": contract.ResultOutcomeSuccess, "REJECTED": contract.ResultOutcomeFailure},
PendingValues: []string{"NEW"},
},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: []byte(`{"type":"object"}`), SensitivePaths: []string{"token"},
@@ -218,6 +225,8 @@ func TestFrameworkContractFinalDeepCopyAndSafetyConflicts(t *testing.T) {
if !ok || got.Result == payload.Result || got.Pagination == payload.Pagination || got.Interface == payload.Interface || got.Selection == payload.Selection || got.Identity == payload.Identity {
t.Fatalf("payload not deeply cloned: %#v", got)
}
payload.Wait.Terminal["COMPLETED"] = contract.ResultOutcomeFailure
payload.Wait.PendingValues[0] = "mutated"
payload.Parameters[0].Enum[0] = "changed"
*payload.Parameters[0].Required = false
*payload.Selection.ExampleDispositions[0].Index = 9
@@ -226,6 +235,10 @@ func TestFrameworkContractFinalDeepCopyAndSafetyConflicts(t *testing.T) {
if again.Parameters[0].Enum[0] != "a" || !*again.Parameters[0].Required || *again.Selection.ExampleDispositions[0].Index != 1 || !*again.Selection.Reviewed {
t.Fatalf("stored payload aliased input: %#v", again)
}
if again.Wait == payload.Wait || again.Wait.Terminal["COMPLETED"] != contract.ResultOutcomeSuccess || again.Wait.PendingValues[0] != "NEW" {
t.Fatalf("wait spec aliased input: %#v", again.Wait)
t.Fatalf("stored payload aliased input: %#v", again)
}
matching := &cobra.Command{Use: "matching"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(matching) })
+9
View File
@@ -74,6 +74,15 @@ func cloneContractFinalPayload(in contract.ContractFinalPayload) contract.Contra
value := *in.DryRun
out.DryRun = &value
}
if in.Wait != nil {
value := *in.Wait
value.Terminal = make(map[string]contract.ResultOutcome, len(in.Wait.Terminal))
for status, outcome := range in.Wait.Terminal {
value.Terminal[status] = outcome
}
value.PendingValues = cloneSlice(in.Wait.PendingValues)
out.Wait = &value
}
if in.Result != nil {
value := *in.Result
value.Outcomes = cloneSlice(in.Result.Outcomes)
+334 -35
View File
@@ -49,12 +49,16 @@ package corecmd
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"os"
"strconv"
"strings"
"time"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
@@ -64,6 +68,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
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/wait"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
@@ -230,10 +235,9 @@ const (
// - Validate / PostMount — orchestration only; must not register business flags
// or assemble business params that belong in Flags/ConstParams.
//
// Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New
// validates this at construction time. corecmd stays dispatch-agnostic and
// never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the
// body.
// Exactly one of RunE / Invoke / Orchestrate must be set; New validates this at
// construction time. corecmd stays dispatch-agnostic and never calls a backend:
// the adapters (FromLeafSpec / FromShortcut) supply the body.
type Spec struct {
Use string
Short string
@@ -260,8 +264,7 @@ type Spec struct {
// backend call).
ConfirmFirst bool
// ConstParams are fixed toolArgs merged after flag assembly (e.g. precheckOnly).
// They are payload declaration, not user flags, never satisfy Required, and
// require an Invoke or ResultInvoke dispatcher that consumes assembled args.
// They are payload declaration, not user flags, and never satisfy Required.
ConstParams map[string]any
// Contract is the authoring-time leaf contract declaration (selection /
// interface / parameters / dry-run / identity). When non-empty, embed
@@ -284,6 +287,21 @@ type Spec struct {
// Orchestrate executes a multi-step command; it assembles whatever payloads
// it needs from the Ctx.
Orchestrate func(c *Ctx) error
// WaitPoll executes one poll of the declared Contract.Wait capability.
// Exactly one poll is one call; cadence, status extraction, and outcome
// mapping belong to the framework wait phase. Required for poll/auto
// declarations — a declared capability without a runtime implementation
// can never honor --wait, so New rejects the pairing at construction.
// ctx is the wait-phase deadline (--wait-timeout); leaf I/O must honor
// it so a blocked poll cannot outlive the declared timeout.
WaitPoll func(ctx context.Context, c *Ctx) (wait.PollDoc, error)
// WaitEvents opens the push subscription of the declared Contract.Wait
// capability (event/auto modes). The framework owns correlation and
// status mapping; the leaf owns the transport. Auto mode falls back to
// WaitPoll when the stream ends before a terminal status. ctx is the
// same wait-phase deadline as WaitPoll; subscription setup must honor
// it so --wait-timeout can cancel a blocked subscribe.
WaitEvents func(ctx context.Context, c *Ctx) (wait.EventStream, error)
}
// Ctx is the framework-neutral execution context handed to Invoke/Orchestrate.
@@ -357,6 +375,15 @@ func (c *Ctx) Changed(name string) bool { return c.cmd.Flags().Changed(name) }
// DryRun reports the effective global --dry-run.
func (c *Ctx) DryRun() bool { return BoolFlag(c.cmd, "dry-run") }
// Wait reports the effective --wait flag. It is false on commands that did
// not declare the capability: the flag is not registered there, so passing it
// is an unknown-flag error rather than a silently ignored value.
func (c *Ctx) Wait() bool { return BoolFlag(c.cmd, waitFlagName) }
// WaitTimeoutSecs reports the effective --wait-timeout in seconds (flag
// value, then the declared default, then the framework default).
func (c *Ctx) WaitTimeoutSecs() int { return waitTimeoutSecs(c.cmd) }
// Yes reports the effective global --yes.
func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") }
@@ -373,11 +400,11 @@ func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") }
// malformed spec can never run the pipeline — write-confirmation prompt
// included — and then silently exit 0 having done nothing.
func New(spec Spec) *cobra.Command {
spec.ConstParams = cloneConstParams(spec.ConstParams)
validateDispatchDecl(spec)
validateConstParamsDecl(spec)
validateSafetySpec(spec)
validateContractDecl(spec)
normalizeWaitDecl(&spec)
validateWaitDecl(spec)
validateInputSpecs(spec.Use, spec.Flags)
// Help prose inherits the declaration when not authored separately:
// Selection.Examples (already contract-validated against the real flags)
@@ -394,6 +421,7 @@ func New(spec Spec) *cobra.Command {
Hidden: spec.Hidden,
}
RegisterFlags(cmd, spec.Flags)
registerWaitFlags(cmd, spec)
ValidateConstraintDecls(spec.Use, spec.Flags, spec.Constraints)
embedContractIntoSchema(cmd, spec)
AnnotateConstraints(cmd, spec.Constraints)
@@ -403,7 +431,6 @@ func New(spec Spec) *cobra.Command {
if spec.PostMount != nil {
spec.PostMount(cmd)
}
attachInterfaceBoolConstParams(cmd, spec.ConstParams)
if spec.OutputRollout != "" {
output.SetCommandRollout(cmd, spec.OutputRollout)
}
@@ -460,6 +487,10 @@ func New(spec Spec) *cobra.Command {
if err != nil {
return err
}
result, err = runDeclaredWaitPhase(cmd, args, spec, result)
if err != nil {
return err
}
return output.StoreResult(cmd.Context(), result)
}
return spec.Invoke(ctx, toolArgs)
@@ -467,17 +498,6 @@ func New(spec Spec) *cobra.Command {
return cmd
}
func cloneConstParams(params map[string]any) map[string]any {
if params == nil {
return nil
}
frozen := make(map[string]any, len(params))
for key, value := range params {
frozen[key] = value
}
return frozen
}
// ConfirmFirstAnnotation marks commands whose Spec declared ConfirmFirst. The
// delivery gate reads it to tell a declared guard-first command apart from an
// accidental confirm-before-validate inversion: guard-first is only legitimate
@@ -522,6 +542,293 @@ func runDeclaredPreflight(cmd *cobra.Command, args []string, spec Spec) error {
return nil
}
// Wait-phase framework flags. They are registered natively on the leaf (never
// as FlagSpec) so they cannot leak into toolArgs / MCP payloads: --wait is a
// client-side execution modifier, not a backend parameter. On commands that
// did not declare Contract.Wait the flags do not exist, so passing --wait
// fails as an unknown flag instead of being silently ignored.
const (
waitFlagName = "wait"
waitTimeoutFlagName = "wait-timeout"
defaultWaitTimeoutS = 300
)
// DefaultWaitTimeoutSecs is the framework default for --wait-timeout when the
// declaration carries no reviewed default.
const DefaultWaitTimeoutSecs = defaultWaitTimeoutS
// normalizeWaitDecl rewrites the spec's declared Wait in place with its
// canonical form (contract.NormalizeWaitSpec): trimmed status values,
// duplicate/conflict rejection, defensive copy. The runtime wait phase and
// AttachContract both read spec.Contract.Wait, so normalizing once at
// construction guarantees the wait engine, the Schema wire, and the
// registered ContractFinal payload all see identical status tables — a
// padded declaration can no longer publish a Schema its own runtime treats
// as unknown statuses. An invalid declaration panics here, next to the
// authoring mistake, with the same message Validate reports.
func normalizeWaitDecl(spec *Spec) {
decl := spec.Contract.Wait
if decl == nil || strings.TrimSpace(decl.Mode) == "" {
return
}
normalized, err := contract.NormalizeWaitSpec(decl, spec.Contract.Identity.CanonicalPath)
if err != nil {
panic(fmt.Sprintf("command %q has invalid Contract.Wait: %v", spec.Use, err))
}
spec.Contract.Wait = normalized
}
// validateWaitDecl enforces the declaration ⇄ implementation pairing at build
// time: a declared Contract.Wait without a WaitPoll hook is a capability the
// command can never honor, and a WaitPoll hook without the declaration has no
// flags or Schema capability to serve. The declaration also requires the
// ResultInvoke dispatcher: only the unified-result envelope can be closed
// into the terminal outcome (error.type "wait", exit code 8) and the timed-out
// pending form — legacy Invoke/Orchestrate/RunE paths emit their own output
// and would observe a failure terminal while still exiting 0. All three
// mismatches are programming errors.
func validateWaitDecl(spec Spec) {
decl := spec.Contract.Wait
declared := decl != nil && strings.TrimSpace(decl.Mode) != ""
if !declared {
if spec.WaitPoll != nil || spec.WaitEvents != nil {
panic(fmt.Sprintf(
"command %q sets a wait hook without declaring Contract.Wait: the wait flags and Schema capability come from the declaration",
spec.Use))
}
return
}
if spec.ResultInvoke == nil {
panic(fmt.Sprintf(
"command %q declares Contract.Wait without ResultInvoke: wait closes the unified-result envelope, which legacy Invoke/Orchestrate/RunE paths cannot rewrite",
spec.Use))
}
mode := strings.TrimSpace(decl.Mode)
needsPoll := mode == contract.WaitModePoll || mode == contract.WaitModeAuto
needsEvent := mode == contract.WaitModeEvent || mode == contract.WaitModeAuto
if needsPoll && spec.WaitPoll == nil {
panic(fmt.Sprintf(
"command %q declares wait mode %s but sets no WaitPoll: a declared wait capability must carry its runtime poll implementation",
spec.Use, mode))
}
if needsEvent && spec.WaitEvents == nil {
panic(fmt.Sprintf(
"command %q declares wait mode %s but sets no WaitEvents: a declared event wait must carry its runtime subscription",
spec.Use, mode))
}
if !needsPoll && spec.WaitPoll != nil {
panic(fmt.Sprintf(
"command %q declares wait mode %s but sets WaitPoll: the declaration decides which hooks run",
spec.Use, mode))
}
if !needsEvent && spec.WaitEvents != nil {
panic(fmt.Sprintf(
"command %q declares wait mode %s but sets WaitEvents: the declaration decides which hooks run",
spec.Use, mode))
}
}
// registerWaitFlags adds --wait / --wait-timeout to a leaf that declared
// Contract.Wait. The timeout default is the reviewed declaration, falling
// back to DefaultWaitTimeoutSecs.
func registerWaitFlags(cmd *cobra.Command, spec Spec) {
decl := spec.Contract.Wait
if decl == nil || strings.TrimSpace(decl.Mode) == "" {
return
}
cmd.Flags().Bool(waitFlagName, false,
"等待到达命令声明的终态(如审批完成、导出结束)后再返回;未声明该能力的命令不接受此 flag")
timeoutDefault := decl.DefaultTimeoutSecs
if timeoutDefault <= 0 {
timeoutDefault = DefaultWaitTimeoutSecs
}
cmd.Flags().Int(waitTimeoutFlagName, timeoutDefault,
"等待超时秒数;超时以 pending 结束(异步受理不是失败)")
}
// runDeclaredWaitPhase runs the declared wait loop after a successful
// ResultInvoke dispatch and closes the accepted unified envelope into the
// wait outcome (validateWaitDecl guarantees the ResultInvoke pairing).
// Only a pending accepted result is waitable: success / failure / partial
// are already terminal and must be returned unchanged. Waiting on a
// business failure would let WithOutcome(..., success) overwrite it into
// an illegal success-with-error envelope.
func runDeclaredWaitPhase(cmd *cobra.Command, args []string, spec Spec, result output.CommandResult) (output.CommandResult, error) {
if !BoolFlag(cmd, waitFlagName) {
return result, nil
}
if result == nil || result.Outcome() != output.OutcomePending {
return result, nil
}
decl := spec.Contract.Wait
timeout, err := waitTimeoutDuration(int64(waitTimeoutSecs(cmd)))
if err != nil {
return result, err
}
ctx := newCtx(cmd, args, spec.Flags)
outcome, err := runWaitLoop(cmd.Context(), decl, timeout, spec, ctx, result)
if err != nil {
return result, err
}
if outcome.TimedOut {
cmd.PrintErrf("等待超时(%s):当前状态 %q,未到达终态,以 pending 结束\n", timeout, outcome.Status)
return output.WithOutcome(result, output.OutcomePending,
output.WithOperationTimedOut(outcome.Status)), nil
}
if outcome.Outcome == contract.ResultOutcomeFailure {
return output.WithOutcome(result, output.OutcomeFailure,
output.WithOperationTerminalState(outcome.Status),
output.WithErrorInfo(&output.ErrorInfo{
Type: "wait",
Subtype: "terminal_failure",
Message: fmt.Sprintf("等待到达失败终态:%s", outcome.Status),
})), nil
}
return output.WithOutcome(result, output.OutcomeSuccess,
output.WithOperationTerminalState(outcome.Status)), nil
}
// runWaitLoop executes the declared wait mode. One deadline spans the event
// phase and an auto-mode poll fallback (the inner loops run without their
// own timeouts and inherit this context's deadline). The deadline is
// forwarded to WaitPoll / WaitEvents and bound onto the cobra command so
// leaf I/O that reads either the hook ctx or Command().Context() is
// cancelled when --wait-timeout expires.
func runWaitLoop(parent context.Context, decl *contract.WaitSpec, timeout time.Duration, spec Spec, ctx *Ctx, result output.CommandResult) (wait.Outcome, error) {
loopCtx := parent
if timeout > 0 {
var cancel context.CancelFunc
loopCtx, cancel = context.WithTimeout(parent, timeout)
defer cancel()
}
if ctx != nil && ctx.cmd != nil {
prev := ctx.cmd.Context()
ctx.cmd.SetContext(loopCtx)
defer ctx.cmd.SetContext(prev)
}
mode := strings.TrimSpace(decl.Mode)
if mode == contract.WaitModePoll {
return wait.Run(loopCtx, wait.LoopSpec{
StatusQuery: decl.StatusQuery,
Terminal: decl.Terminal,
Pending: decl.PendingValues,
}, func(pollCtx context.Context) (wait.PollDoc, error) {
return spec.WaitPoll(pollCtx, ctx)
})
}
resource, err := waitResource(decl, result)
if err != nil {
return wait.Outcome{}, err
}
stream, err := spec.WaitEvents(loopCtx, ctx)
if err != nil {
if loopCtx.Err() != nil {
// Subscribe blocked until the wait deadline: same contract as a
// cancelled poll — close as timed-out pending, do not surface
// ctx.Err() as a subscription failure (and do not poll-fallback
// in auto mode; the shared deadline is already exhausted).
return wait.Outcome{Outcome: contract.ResultOutcomePending, TimedOut: true}, nil
}
if mode == contract.WaitModeAuto {
// Subscription failed before any event: fall back to polling.
return pollWithSpec(loopCtx, decl, spec, ctx)
}
return wait.Outcome{}, fmt.Errorf("wait: event subscription failed: %w", err)
}
eventSpec := wait.EventLoopSpec{
StatusQuery: decl.StatusQuery,
MatchField: decl.MatchField,
Terminal: decl.Terminal,
Pending: decl.PendingValues,
}
outcome, err := wait.RunEvent(loopCtx, eventSpec, resource, stream)
if err == nil {
return outcome, nil
}
if mode == contract.WaitModeAuto && errors.Is(err, wait.ErrEventStreamEnded) {
// Stream ended before a terminal status: fall back to polling under
// the same deadline.
return pollWithSpec(loopCtx, decl, spec, ctx)
}
return outcome, err
}
// pollWithSpec runs the poll loop for an auto-mode fallback.
func pollWithSpec(loopCtx context.Context, decl *contract.WaitSpec, spec Spec, ctx *Ctx) (wait.Outcome, error) {
return wait.Run(loopCtx, wait.LoopSpec{
StatusQuery: decl.StatusQuery,
Terminal: decl.Terminal,
Pending: decl.PendingValues,
}, func(pollCtx context.Context) (wait.PollDoc, error) {
return spec.WaitPoll(pollCtx, ctx)
})
}
// waitResource resolves the resource identifier an event stream correlates
// against, from the accepted result data via the declared ResourceQuery.
// result.Data() returns any deep-copied business payload, which may be a
// map[string]any, struct, or struct pointer. We normalize via JSON round-trip
// to support all valid result types uniformly.
func waitResource(decl *contract.WaitSpec, result output.CommandResult) (string, error) {
raw := result.Data()
if raw == nil {
return "", fmt.Errorf("wait: accepted result data is nil; cannot resolve resource %q", decl.ResourceQuery)
}
// Fast path: already a map.
if data, ok := raw.(map[string]any); ok {
resource, ok := wait.ExtractStatus(wait.PollDoc(data), decl.ResourceQuery)
if !ok || strings.TrimSpace(resource) == "" {
return "", fmt.Errorf("wait: resource query %q not found in accepted result data", decl.ResourceQuery)
}
return resource, nil
}
// Slow path: struct or struct pointer. Normalize via JSON round-trip.
jsonBytes, err := json.Marshal(raw)
if err != nil {
return "", fmt.Errorf("wait: accepted result data cannot be serialized to JSON: %w", err)
}
var data map[string]any
if err := json.Unmarshal(jsonBytes, &data); err != nil {
return "", fmt.Errorf("wait: accepted result data is not an object; cannot resolve resource %q", decl.ResourceQuery)
}
resource, ok := wait.ExtractStatus(wait.PollDoc(data), decl.ResourceQuery)
if !ok || strings.TrimSpace(resource) == "" {
return "", fmt.Errorf("wait: resource query %q not found in accepted result data", decl.ResourceQuery)
}
return resource, nil
}
// waitTimeoutSecs resolves the effective timeout. The flag is registered
// with the reviewed declaration default (or the framework default), so the
// flag value is authoritative; a non-positive explicit value falls back to
// the framework default.
func waitTimeoutSecs(cmd *cobra.Command) int {
if value, err := cmd.Flags().GetInt(waitTimeoutFlagName); err == nil && value > 0 {
return value
}
return DefaultWaitTimeoutSecs
}
// maxWaitTimeoutSecs is the largest second count that still fits in a
// time.Duration. Multiplying a larger int by time.Second overflows to a
// non-positive duration, which would skip the deadline and wait forever.
const maxWaitTimeoutSecs = math.MaxInt64 / int64(time.Second)
// waitTimeoutDuration converts a resolved second count into the wait-phase
// deadline. Values that cannot be represented as a positive time.Duration
// are rejected as validation errors instead of silently disabling timeout.
func waitTimeoutDuration(secs int64) (time.Duration, error) {
if secs <= 0 {
secs = DefaultWaitTimeoutSecs
}
if secs > maxWaitTimeoutSecs {
return 0, apperrors.NewValidation(fmt.Sprintf(
"参数 --%s 取值 %d 超出可表示范围(最大 %d 秒)",
waitTimeoutFlagName, secs, maxWaitTimeoutSecs))
}
return time.Duration(secs) * time.Second, nil
}
// validateDispatchDecl enforces "exactly one dispatcher" at build time. Like
// ValidateConstraintDecls this panics: a spec with no runnable body (or with two
// competing ones) is a programming error that every test and startup path should
@@ -553,21 +860,6 @@ func validateDispatchDecl(spec Spec) {
}
}
func validateConstParamsDecl(spec Spec) {
if len(spec.ConstParams) == 0 {
return
}
if spec.Invoke == nil && spec.ResultInvoke == nil {
panic(fmt.Sprintf("command %q ConstParams require Invoke or ResultInvoke", spec.Use))
}
for _, flag := range spec.Flags {
key := bindKey(flag)
if _, conflicts := spec.ConstParams[key]; conflicts {
panic(fmt.Sprintf("command %q ConstParams key %q conflicts with flag --%s", spec.Use, key, flag.Name))
}
}
}
// validateSafetySpec rejects partial safety declarations. A zero value remains
// the historical read-only default, but once any field is authored all four
// independent Schema dimensions must be explicit.
@@ -1439,6 +1731,13 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
d.PreviewKind = strings.TrimSpace(d.PreviewKind)
payload.DryRun = &d
}
if decl.Wait != nil && strings.TrimSpace(decl.Wait.Mode) != "" {
waitSpec, err := contract.NormalizeWaitSpec(decl.Wait, decl.Identity.CanonicalPath)
if err != nil {
panic(fmt.Sprintf("command %q has invalid Contract.Wait: %v", cmd.Name(), err))
}
payload.Wait = waitSpec
}
if decl.Result != nil {
result, err := contract.NormalizeResultSpec(decl.Result, decl.Identity.CanonicalPath)
if err != nil {
+110 -97
View File
@@ -1416,23 +1416,17 @@ func TestCrossPlatformCoverageBuildArgsIntArgDefaultFloor(t *testing.T) {
}
}
func TestCrossPlatformCoverageNewCommandFreezesAndMergesConstParams(t *testing.T) {
func TestNewCommandMergesConstParams(t *testing.T) {
var got map[string]any
declared := map[string]any{
"precheckOnly": false,
"convThreadEnabled": true,
}
cmd := New(Spec{
Use: "pub",
Flags: []FlagSpec{{Name: "id", Usage: "ID", Bind: "versionId", Trim: true}},
ConstParams: declared,
ConstParams: map[string]any{"precheckOnly": false},
Invoke: func(_ *Ctx, toolArgs map[string]any) error {
got = toolArgs
return nil
},
})
declared["precheckOnly"] = true
declared["forgedAfterNew"] = true
_ = cmd.Flags().Set("id", "V1")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
@@ -1443,95 +1437,6 @@ func TestCrossPlatformCoverageNewCommandFreezesAndMergesConstParams(t *testing.T
if got["precheckOnly"] != false {
t.Fatalf("precheckOnly = %#v, want false", got["precheckOnly"])
}
if got["convThreadEnabled"] != true {
t.Fatalf("convThreadEnabled = %#v, want true", got["convThreadEnabled"])
}
if _, exists := got["forgedAfterNew"]; exists {
t.Fatalf("caller mutation leaked into dispatch args: %#v", got)
}
evidence := InterfaceBoolConstParams(cmd)
if !reflect.DeepEqual(evidence, map[string]bool{"convThreadEnabled": true, "precheckOnly": false}) {
t.Fatalf("bool ConstParams evidence = %#v", evidence)
}
if got["precheckOnly"] != evidence["precheckOnly"] {
t.Fatalf("dispatch/evidence drift: args=%#v evidence=%#v", got, evidence)
}
}
func TestCrossPlatformCoverageNewCommandDoesNotProjectMixedConstParamsEvidence(t *testing.T) {
var got map[string]any
cmd := New(Spec{
Use: "mixed",
ConstParams: map[string]any{
"convThreadEnabled": true,
"retryLimit": 3,
},
Invoke: func(_ *Ctx, toolArgs map[string]any) error {
got = toolArgs
return nil
},
})
if evidence := InterfaceBoolConstParams(cmd); evidence != nil {
t.Fatalf("mixed ConstParams evidence = %#v; want missing evidence", evidence)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
if got["convThreadEnabled"] != true || got["retryLimit"] != 3 {
t.Fatalf("mixed ConstParams dispatch args = %#v", got)
}
}
func TestCrossPlatformCoverageNewCommandRejectsInvalidConstParamsDispatch(t *testing.T) {
mustPanic := func(name string, spec Spec, needle string) {
t.Helper()
defer func() {
r := recover()
if r == nil {
t.Fatalf("%s: expected panic", name)
}
if msg, _ := r.(string); !strings.Contains(msg, needle) {
t.Fatalf("%s: panic=%v, want %q", name, r, needle)
}
}()
New(spec)
}
mustPanic("RunE", Spec{
Use: "run-e",
ConstParams: map[string]any{"fixed": true},
RunE: func(*cobra.Command, []string) error { return nil },
}, "ConstParams require Invoke or ResultInvoke")
mustPanic("Orchestrate", Spec{
Use: "orchestrate",
ConstParams: map[string]any{"fixed": true},
Orchestrate: func(*Ctx) error { return nil },
}, "ConstParams require Invoke or ResultInvoke")
mustPanic("explicit bind conflict", Spec{
Use: "bind-conflict",
Flags: []FlagSpec{{Name: "thread", Usage: "T", Bind: "convThreadEnabled"}},
ConstParams: map[string]any{"convThreadEnabled": true},
Invoke: func(*Ctx, map[string]any) error { return nil },
}, "conflicts with flag --thread")
mustPanic("default bind conflict", Spec{
Use: "default-bind-conflict",
Flags: []FlagSpec{{Name: "fixed-value", Usage: "F"}},
ConstParams: map[string]any{"fixedValue": true},
Invoke: func(*Ctx, map[string]any) error { return nil },
}, "conflicts with flag --fixed-value")
result := New(Spec{
Use: "result",
OutputRollout: output.RolloutUnifiedActive,
ConstParams: map[string]any{"fixed": true},
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Success(nil), nil
},
})
if evidence := InterfaceBoolConstParams(result); !evidence["fixed"] {
t.Fatalf("ResultInvoke ConstParams evidence = %#v", evidence)
}
}
func TestCrossPlatformCoverageNewCommandConfirmFirstAnnotationAndOrder(t *testing.T) {
@@ -2216,3 +2121,111 @@ func TestCrossPlatformCoverageEmbedContractSkipsBlankAndHiddenFlags(t *testing.T
}
}
}
// TestWaitResourceStronglyTypedDTOs verifies waitResource handles struct and
// struct pointer results via JSON normalization (P1 fix for auto-CR).
func TestWaitResourceStronglyTypedDTOs(t *testing.T) {
type TaskDTO struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
}
type NestedDTO struct {
Meta struct {
ResourceID string `json:"resource_id"`
} `json:"meta"`
}
tests := []struct {
name string
data any
query string
wantResource string
wantErrSubstr string
}{
{
name: "map[string]any fast path",
data: map[string]any{"task_id": "abc123"},
query: "task_id",
wantResource: "abc123",
},
{
name: "struct value",
data: TaskDTO{TaskID: "struct-456", Status: "running"},
query: "task_id",
wantResource: "struct-456",
},
{
name: "struct pointer",
data: &TaskDTO{TaskID: "ptr-789", Status: "pending"},
query: "task_id",
wantResource: "ptr-789",
},
{
name: "nested struct dotted query",
data: NestedDTO{},
query: "meta.resource_id",
wantResource: "",
wantErrSubstr: "not found",
},
{
name: "nested struct with value",
data: func() NestedDTO {
var d NestedDTO
d.Meta.ResourceID = "nested-xyz"
return d
}(),
query: "meta.resource_id",
wantResource: "nested-xyz",
},
{
name: "nil data",
data: nil,
query: "task_id",
wantErrSubstr: "nil",
},
{
name: "non-object data (string)",
data: "not-an-object",
query: "task_id",
wantErrSubstr: "not an object",
},
{
name: "non-object data (slice)",
data: []string{"a", "b"},
query: "task_id",
wantErrSubstr: "not an object",
},
{
name: "missing query field in struct",
data: TaskDTO{TaskID: "abc"},
query: "nonexistent",
wantErrSubstr: "not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
decl := &contract.WaitSpec{
ResourceQuery: tt.query,
}
result := output.Success(tt.data)
resource, err := waitResource(decl, result)
if tt.wantErrSubstr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr)
}
if !strings.Contains(err.Error(), tt.wantErrSubstr) {
t.Errorf("error = %q; want substring %q", err.Error(), tt.wantErrSubstr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resource != tt.wantResource {
t.Errorf("resource = %q; want %q", resource, tt.wantResource)
}
})
}
}
@@ -1,63 +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 corecmd
import (
"sync"
"github.com/spf13/cobra"
)
var interfaceBoolConstParamsRegistry sync.Map
// attachInterfaceBoolConstParams records framework-owned ConstParams evidence.
// Only a non-empty, entirely boolean declaration is representable in v1;
// empty or mixed declarations remove any evidence for the command.
func attachInterfaceBoolConstParams(cmd *cobra.Command, params map[string]any) {
if cmd == nil {
return
}
if len(params) == 0 {
interfaceBoolConstParamsRegistry.Delete(cmd)
return
}
bools := make(map[string]bool, len(params))
for key, value := range params {
boolValue, ok := value.(bool)
if !ok {
interfaceBoolConstParamsRegistry.Delete(cmd)
return
}
bools[key] = boolValue
}
interfaceBoolConstParamsRegistry.Store(cmd, bools)
}
// InterfaceBoolConstParams returns a clone of framework-owned boolean
// ConstParams evidence. Callers cannot mutate the private registry.
func InterfaceBoolConstParams(cmd *cobra.Command) map[string]bool {
if cmd == nil {
return nil
}
stored, ok := interfaceBoolConstParamsRegistry.Load(cmd)
if !ok {
return nil
}
params := stored.(map[string]bool)
clone := make(map[string]bool, len(params))
for key, value := range params {
clone[key] = value
}
return clone
}
@@ -1,44 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package corecmd
import (
"reflect"
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageInterfaceBoolConstParamsRegistryClonesAndDeletes(t *testing.T) {
attachInterfaceBoolConstParams(nil, map[string]any{"ignored": true})
if got := InterfaceBoolConstParams(nil); got != nil {
t.Fatalf("nil command evidence = %#v, want nil", got)
}
cmd := &cobra.Command{Use: "send"}
declared := map[string]any{"convThreadEnabled": true, "precheckOnly": false}
attachInterfaceBoolConstParams(cmd, declared)
declared["convThreadEnabled"] = false
want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false}
got := InterfaceBoolConstParams(cmd)
if !reflect.DeepEqual(got, want) {
t.Fatalf("registered bool ConstParams = %#v, want %#v", got, want)
}
got["convThreadEnabled"] = false
if again := InterfaceBoolConstParams(cmd); !reflect.DeepEqual(again, want) {
t.Fatalf("reader leaked mutable registry state: %#v", again)
}
attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true, "retryLimit": 3})
if got := InterfaceBoolConstParams(cmd); got != nil {
t.Fatalf("mixed ConstParams retained evidence: %#v", got)
}
attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true})
attachInterfaceBoolConstParams(cmd, nil)
if got := InterfaceBoolConstParams(cmd); got != nil {
t.Fatalf("empty ConstParams retained evidence: %#v", got)
}
}
+902
View File
@@ -0,0 +1,902 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package corecmd
import (
"bytes"
"context"
"errors"
"io"
"math"
"strconv"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/wait"
)
func waitTestDecl() ContractDecl {
return ContractDecl{
Title: "Wait Title",
Description: "Wait Desc",
Wait: &contract.WaitSpec{
Mode: contract.WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "result.status",
Terminal: map[string]contract.ResultOutcome{"COMPLETED": contract.ResultOutcomeSuccess, "REJECTED": contract.ResultOutcomeFailure},
PendingValues: []string{"NEW", "RUNNING"},
DefaultTimeoutSecs: 60,
},
Interface: &contract.InterfaceSpec{Mode: "local", Availability: "available"},
Selection: contract.SelectionSpec{
AgentSummary: "summary",
UseWhen: []string{"when wait"},
AvoidWhen: []string{"when nowait"},
Examples: []string{"dws wait-sample --wait"},
},
Identity: contract.ToolIdentitySpec{ProductID: "sample", Name: "waitsample", CanonicalPath: "sample.waitsample", CLIPath: "wait-sample", PrimaryCLIPath: "wait-sample"},
}
}
func baseWaitSpec(decl ContractDecl, poll func(context.Context, *Ctx) (wait.PollDoc, error)) Spec {
return Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: decl,
WaitPoll: poll,
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Pending(map[string]any{"id": "job-1"}, &output.OperationInfo{
ID: "job-1",
State: "NEW",
NextCommand: "dws wait-sample --id job-1",
}), nil
},
}
}
func TestWaitFlagsOnlyRegisteredWhenDeclared(t *testing.T) {
declared := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}))
if flag := declared.Flags().Lookup(waitFlagName); flag == nil {
t.Fatal("declared command missing --wait flag")
}
if flag := declared.Flags().Lookup(waitTimeoutFlagName); flag == nil {
t.Fatal("declared command missing --wait-timeout flag")
}
undeclared := New(Spec{
Use: "nowait",
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Invoke: func(*Ctx, map[string]any) error { return nil },
})
if flag := undeclared.Flags().Lookup(waitFlagName); flag != nil {
t.Fatal("undeclared command registered --wait")
}
undeclared.SetArgs([]string{"--wait"})
if err := undeclared.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("err=%v want unknown-flag", err)
}
}
func TestValidateWaitDeclPairsDeclarationWithImplementation(t *testing.T) {
decl := waitTestDecl()
spec := baseWaitSpec(decl, nil)
expectPanic(t, func() { New(spec) }, "WaitPoll")
spec.WaitPoll = func(context.Context, *Ctx) (wait.PollDoc, error) { return nil, nil }
expectPanic(t, func() {
New(Spec{
Use: "hook-only",
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Invoke: func(*Ctx, map[string]any) error { return nil },
WaitPoll: func(context.Context, *Ctx) (wait.PollDoc, error) { return nil, nil },
})
}, "Contract.Wait")
}
func expectPanic(t *testing.T, fn func(), want string) {
t.Helper()
defer func() {
recovered := recover()
if recovered == nil {
t.Fatalf("expected panic containing %q", want)
}
if message, ok := recovered.(string); !ok || !strings.Contains(message, want) {
t.Fatalf("panic=%v want containing %q", recovered, want)
}
}()
fn()
}
func TestWaitTimeoutFlagDefaultsComeFromDeclaration(t *testing.T) {
stub := func(context.Context, *Ctx) (wait.PollDoc, error) {
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}
declared := New(baseWaitSpec(waitTestDecl(), stub))
if value, err := declared.Flags().GetInt(waitTimeoutFlagName); err != nil || value != 60 {
t.Fatalf("declared default=%d/%v, want reviewed 60", value, err)
}
decl := waitTestDecl()
decl.Wait.DefaultTimeoutSecs = 0
fallback := New(baseWaitSpec(decl, stub))
if value, err := fallback.Flags().GetInt(waitTimeoutFlagName); err != nil || value != DefaultWaitTimeoutSecs {
t.Fatalf("fallback default=%d/%v, want framework %d", value, err, DefaultWaitTimeoutSecs)
}
// A non-positive explicit value falls back to the framework default.
fallback.SetArgs([]string{"--wait-timeout", "0", "--wait"})
if err := fallback.Flags().Set(waitTimeoutFlagName, "0"); err != nil {
t.Fatal(err)
}
if got := waitTimeoutSecs(fallback); got != DefaultWaitTimeoutSecs {
t.Fatalf("waitTimeoutSecs=%d, want %d", got, DefaultWaitTimeoutSecs)
}
}
func TestWaitTimeoutDurationRejectsOverflowingSeconds(t *testing.T) {
// math.MaxInt64 (9223372036854775807) is a legal pflag int on 64-bit
// platforms and overflows time.Duration(secs)*time.Second to a negative
// value, which would disable the wait deadline.
if _, err := waitTimeoutDuration(math.MaxInt64); err == nil || !strings.Contains(err.Error(), "超出可表示范围") {
t.Fatalf("err=%v, want overflow validation", err)
}
d, err := waitTimeoutDuration(maxWaitTimeoutSecs)
if err != nil {
t.Fatal(err)
}
if d <= 0 || d != time.Duration(maxWaitTimeoutSecs)*time.Second {
t.Fatalf("duration=%d, want the largest representable timeout", d)
}
// Non-positive second counts fall back to the framework default instead
// of disabling the deadline (waitTimeoutSecs already maps a zero/negative
// flag to the default; this keeps the conversion itself fail-safe).
if got, err := waitTimeoutDuration(0); err != nil || got != time.Duration(DefaultWaitTimeoutSecs)*time.Second {
t.Fatalf("duration/err=%d/%v, want framework default", got, err)
}
if got, err := waitTimeoutDuration(-5); err != nil || got != time.Duration(DefaultWaitTimeoutSecs)*time.Second {
t.Fatalf("duration/err=%d/%v, want framework default", got, err)
}
}
func TestResultInvokeWaitTimeoutOverflowIsValidationError(t *testing.T) {
if int64(math.MaxInt) <= maxWaitTimeoutSecs {
t.Skip("platform int cannot overflow time.Duration")
}
polled := false
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
polled = true
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}))
cmd.SetArgs([]string{"--wait", "--wait-timeout", strconv.Itoa(math.MaxInt)})
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "超出可表示范围") {
t.Fatalf("err=%v, want overflow validation", err)
}
if polled {
t.Fatal("overflowing --wait-timeout must not start the wait loop")
}
}
func TestResultInvokeWaitPollErrorFailsTheCommand(t *testing.T) {
boom := errors.New("rpc down")
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
return nil, boom
}))
cmd.SetArgs([]string{"--wait"})
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "rpc down") {
t.Fatalf("err=%v, want poll error surfaced", err)
}
}
func TestResultInvokeWaitUnknownStatusFailsClosed(t *testing.T) {
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
return wait.PollDoc{"result": map[string]any{"status": "Mystery"}}, nil
}))
cmd.SetArgs([]string{"--wait"})
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
err := cmd.Execute()
if err == nil || !wait.IsUnknownStatus(err) {
t.Fatalf("err=%v, want unknown-status", err)
}
}
func TestWaitCtxAccessorsExposeDeclaredCapability(t *testing.T) {
var gotWait bool
var gotTimeout int
cmd := New(baseWaitSpec(waitTestDecl(), func(_ context.Context, c *Ctx) (wait.PollDoc, error) {
gotWait = c.Wait()
gotTimeout = c.WaitTimeoutSecs()
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}))
cmd.SetArgs([]string{"--wait", "--wait-timeout", "90"})
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if !gotWait || gotTimeout != 90 {
t.Fatalf("ctx accessors=%v/%d", gotWait, gotTimeout)
}
}
func eventTestDecl(mode string) ContractDecl {
decl := waitTestDecl()
decl.Wait.Mode = mode
decl.Wait.EventKey = "bpms_instance_change"
decl.Wait.MatchField = "process_instance_id"
decl.Wait.ResourceQuery = "id"
return decl
}
type scriptedStream struct {
events []wait.PollDoc
err error
}
func (s *scriptedStream) Recv(context.Context) (wait.PollDoc, error) {
if len(s.events) > 0 {
doc := s.events[0]
s.events = s.events[1:]
return doc, nil
}
if s.err != nil {
return nil, s.err
}
return nil, io.EOF
}
func TestValidateWaitDeclPairsModeWithHooks(t *testing.T) {
poll := func(context.Context, *Ctx) (wait.PollDoc, error) { return nil, nil }
events := func(context.Context, *Ctx) (wait.EventStream, error) { return nil, nil }
cases := []struct {
name string
mode string
waitPoll bool
waitEvents bool
want string
}{
{"event without WaitEvents", contract.WaitModeEvent, false, false, "WaitEvents"},
{"auto without WaitPoll", contract.WaitModeAuto, false, true, "WaitPoll"},
{"poll with WaitEvents", contract.WaitModePoll, true, true, "WaitEvents"},
{"event with WaitPoll", contract.WaitModeEvent, true, true, "WaitPoll"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
expectPanic(t, func() {
New(Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: eventTestDecl(tc.mode),
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Pending(map[string]any{}, nil), nil
},
WaitPoll: hookOrNil(tc.waitPoll, poll),
WaitEvents: eventHookOrNil(tc.waitEvents, events),
})
}, tc.want)
})
}
}
func hookOrNil(set bool, hook func(context.Context, *Ctx) (wait.PollDoc, error)) func(context.Context, *Ctx) (wait.PollDoc, error) {
if !set {
return nil
}
return hook
}
func eventHookOrNil(set bool, hook func(context.Context, *Ctx) (wait.EventStream, error)) func(context.Context, *Ctx) (wait.EventStream, error) {
if !set {
return nil
}
return hook
}
func runWaitModeCommand(t *testing.T, decl ContractDecl, poll func(context.Context, *Ctx) (wait.PollDoc, error), events func(context.Context, *Ctx) (wait.EventStream, error), args ...string) (string, error) {
t.Helper()
cmd := New(Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: decl,
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Pending(map[string]any{"id": "job-1"}, &output.OperationInfo{
ID: "job-1", State: "NEW", NextCommand: "dws wait-sample --id job-1",
}), nil
},
WaitPoll: poll,
WaitEvents: events,
})
cmd.SetArgs(append([]string{"--wait"}, args...))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
err := cmd.Execute()
return stdout.String(), err
}
func TestEventModeClosesEnvelopeFromCorrelatedEvent(t *testing.T) {
stream := &scriptedStream{events: []wait.PollDoc{
{"process_instance_id": "other", "result": map[string]any{"status": "COMPLETED"}},
{"process_instance_id": "job-1", "result": map[string]any{"status": "REJECTED"}},
}}
stdout, err := runWaitModeCommand(t, eventTestDecl(contract.WaitModeEvent), nil, func(context.Context, *Ctx) (wait.EventStream, error) {
return stream, nil
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(stdout, `"outcome": "failure"`) || !strings.Contains(stdout, `"type": "wait"`) {
t.Fatalf("stdout=%s", stdout)
}
}
func TestEventModeSurfacesStreamEndAsError(t *testing.T) {
_, err := runWaitModeCommand(t, eventTestDecl(contract.WaitModeEvent), nil, func(context.Context, *Ctx) (wait.EventStream, error) {
return &scriptedStream{}, nil
})
if err == nil || !errors.Is(err, wait.ErrEventStreamEnded) {
t.Fatalf("err=%v, want stream-ended", err)
}
}
func TestEventModeRejectsUnresolvableResource(t *testing.T) {
decl := eventTestDecl(contract.WaitModeEvent)
decl.Wait.ResourceQuery = "missing"
_, err := runWaitModeCommand(t, decl, nil, func(context.Context, *Ctx) (wait.EventStream, error) {
return &scriptedStream{}, nil
})
if err == nil || !strings.Contains(err.Error(), "resource query") {
t.Fatalf("err=%v", err)
}
}
func TestAutoModeFallsBackToPollOnStreamEnd(t *testing.T) {
polled := false
stdout, err := runWaitModeCommand(t, eventTestDecl(contract.WaitModeAuto),
func(context.Context, *Ctx) (wait.PollDoc, error) {
polled = true
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
},
func(context.Context, *Ctx) (wait.EventStream, error) {
return &scriptedStream{}, nil // ends immediately
})
if err != nil {
t.Fatal(err)
}
if !polled {
t.Fatal("auto mode did not fall back to polling")
}
if !strings.Contains(stdout, `"outcome": "success"`) {
t.Fatalf("stdout=%s", stdout)
}
}
func TestAutoModeFallsBackToPollOnSubscriptionFailure(t *testing.T) {
polled := false
_, err := runWaitModeCommand(t, eventTestDecl(contract.WaitModeAuto),
func(context.Context, *Ctx) (wait.PollDoc, error) {
polled = true
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
},
func(context.Context, *Ctx) (wait.EventStream, error) {
return nil, errors.New("no subscriber credential")
})
if err != nil {
t.Fatal(err)
}
if !polled {
t.Fatal("auto mode did not fall back to polling on subscription failure")
}
}
func TestResultInvokeWaitClosesEnvelopeOutcome(t *testing.T) {
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
return wait.PollDoc{"result": map[string]any{"status": "REJECTED"}}, nil
}))
cmd.SetArgs([]string{"--wait"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 8 {
t.Fatalf("stored code/emitted=%d/%v, want dedicated wait-terminal-failure code 8", code, emitted)
}
if !strings.Contains(stdout.String(), `"type": "wait"`) {
t.Fatalf("stdout=%s, want error.type wait", stdout.String())
}
if !strings.Contains(stdout.String(), `"outcome": "failure"`) {
t.Fatalf("stdout=%s", stdout.String())
}
// The final emitted envelope must carry the observed terminal status in
// meta.operation.state — the acceptance-phase state ("NEW") must not
// survive the close (P1 regression guard).
if !strings.Contains(stdout.String(), `"state": "REJECTED"`) {
t.Fatalf("stdout=%s, want operation.state synced to the terminal status", stdout.String())
}
if strings.Contains(stdout.String(), `"state": "NEW"`) {
t.Fatalf("stdout=%s, acceptance-phase operation.state leaked into the terminal envelope", stdout.String())
}
if strings.Contains(stdout.String(), `"timed_out": true`) {
t.Fatalf("stdout=%s, terminal close must not claim timed_out", stdout.String())
}
}
func TestResultInvokeWaitSuccessCloseSyncsOperationState(t *testing.T) {
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}))
cmd.SetArgs([]string{"--wait"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v, want success exit 0", code, emitted)
}
if !strings.Contains(stdout.String(), `"outcome": "success"`) {
t.Fatalf("stdout=%s", stdout.String())
}
// Success close must publish the terminal status, never the stale
// acceptance-phase state (no outcome=success with state=processing/NEW).
if !strings.Contains(stdout.String(), `"state": "COMPLETED"`) {
t.Fatalf("stdout=%s, want operation.state synced to the terminal status", stdout.String())
}
if strings.Contains(stdout.String(), `"state": "NEW"`) {
t.Fatalf("stdout=%s, acceptance-phase operation.state leaked into the success envelope", stdout.String())
}
// Operation identity (id / next_command) survives the terminal close.
if !strings.Contains(stdout.String(), `"id": "job-1"`) || !strings.Contains(stdout.String(), `"next_command"`) {
t.Fatalf("stdout=%s, want operation id/next_command preserved", stdout.String())
}
}
func TestResultInvokeWaitTimeoutKeepsPending(t *testing.T) {
polls := 0
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
polls++
return wait.PollDoc{"result": map[string]any{"status": "RUNNING"}}, nil
}))
cmd.SetArgs([]string{"--wait", "--wait-timeout", "1"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
if err := cmd.Execute(); err != nil {
t.Fatalf("timeout wait must exit 0 (pending is not failure): %v", err)
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
}
if !strings.Contains(stdout.String(), `"outcome": "pending"`) {
t.Fatalf("stdout=%s", stdout.String())
}
if polls == 0 {
t.Fatal("wait phase never polled")
}
}
func TestWaitDeclRequiresResultInvokeDispatcher(t *testing.T) {
// A declared wait on the legacy Invoke path would observe a failure
// terminal while still exiting 0 — construction must reject it.
expectPanic(t, func() {
New(Spec{
Use: "wait-sample",
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: waitTestDecl(),
WaitPoll: func(context.Context, *Ctx) (wait.PollDoc, error) { return nil, nil },
Invoke: func(*Ctx, map[string]any) error { return nil },
})
}, "ResultInvoke")
}
func TestResultInvokeWithoutWaitFlagSkipsPhase(t *testing.T) {
polled := false
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
polled = true
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}))
cmd.SetArgs(nil)
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if polled {
t.Fatal("wait phase ran without --wait")
}
}
func TestAttachContractPanicsOnInvalidWaitDeclaration(t *testing.T) {
decl := waitTestDecl()
decl.Wait.Mode = "event" // not implemented
defer func() {
recovered := recover()
if recovered == nil {
t.Fatal("expected panic on invalid Contract.Wait")
}
if message, ok := recovered.(string); !ok || !strings.Contains(message, "Contract.Wait") {
t.Fatalf("panic=%v", recovered)
}
}()
AttachContract(&cobra.Command{Use: "wait-sample"}, contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
}, decl, "", "")
}
func TestWaitDeclPaddedStatusValuesAreNormalized(t *testing.T) {
// A declaration whose status values carry surrounding whitespace must be
// canonicalized at construction so the runtime wait engine and the
// published Schema agree: the backend returns "COMPLETED" verbatim, and
// a padded terminal key would fail closed as an unknown status.
decl := waitTestDecl()
decl.Wait.Terminal = map[string]contract.ResultOutcome{
" COMPLETED ": contract.ResultOutcomeSuccess,
"\tREJECTED": contract.ResultOutcomeFailure,
}
decl.Wait.PendingValues = []string{" NEW ", "RUNNING "}
cmd := New(baseWaitSpec(decl, func(context.Context, *Ctx) (wait.PollDoc, error) {
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}))
cmd.SetArgs([]string{"--wait"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
if err := cmd.Execute(); err != nil {
t.Fatalf("padded declaration must still reach the terminal status: %v", err)
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v, want success", code, emitted)
}
final, ok := contractfinal.RuntimeContractFinal(cmd)
if !ok || final.Wait == nil {
t.Fatal("registered ContractFinal lost the wait capability")
}
if _, ok := final.Wait.Terminal["COMPLETED"]; !ok {
t.Fatalf("registered terminal table not trimmed: %#v", final.Wait.Terminal)
}
for _, value := range final.Wait.PendingValues {
if strings.TrimSpace(value) != value {
t.Fatalf("registered pending value %q not trimmed", value)
}
}
}
func TestNewPanicsOnDuplicateOrConflictingWaitStatusesAfterTrim(t *testing.T) {
// Values that collapse onto one status after trimming are programming
// errors: silently merging them would pick one outcome for two authored
// declarations.
dupTerminal := waitTestDecl()
dupTerminal.Wait.Terminal = map[string]contract.ResultOutcome{
"COMPLETED": contract.ResultOutcomeSuccess,
" COMPLETED": contract.ResultOutcomeFailure,
}
expectPanic(t, func() { New(baseWaitSpec(dupTerminal, nil)) }, "Contract.Wait")
conflict := waitTestDecl()
conflict.Wait.PendingValues = []string{" COMPLETED "}
expectPanic(t, func() { New(baseWaitSpec(conflict, nil)) }, "Contract.Wait")
}
func TestContractDeclEmptyTreatsWaitAsAuthored(t *testing.T) {
// Only Wait is authored: empty() must report non-empty through the Wait
// branch (before validateContractDecl then fails on the missing prose).
decl := ContractDecl{Wait: &contract.WaitSpec{
Mode: contract.WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "result.status",
Terminal: map[string]contract.ResultOutcome{"COMPLETED": contract.ResultOutcomeSuccess},
}}
if decl.Empty() {
t.Fatal("Wait-only declaration must count as authored")
}
defer func() {
if recover() == nil {
t.Fatal("expected validateContractDecl to reject the missing prose")
}
}()
validateContractDecl(Spec{Use: "wait-only", Contract: decl})
}
func TestEventModeRejectsNonObjectResultData(t *testing.T) {
cmd := New(Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: eventTestDecl(contract.WaitModeEvent),
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Pending([]any{"not", "an", "object"}, &output.OperationInfo{
ID: "job-1", State: "NEW", NextCommand: "dws wait-sample",
}), nil
},
WaitEvents: func(context.Context, *Ctx) (wait.EventStream, error) {
return &scriptedStream{}, nil
},
})
cmd.SetArgs([]string{"--wait"})
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "not an object") {
t.Fatalf("err=%v, want non-object data rejection", err)
}
}
func TestEventModeSubscriptionFailureSurfacesInStrictMode(t *testing.T) {
decl := eventTestDecl(contract.WaitModeEvent)
_, err := runWaitModeCommand(t, decl, nil, func(context.Context, *Ctx) (wait.EventStream, error) {
return nil, errors.New("no subscriber credential")
})
if err == nil || !strings.Contains(err.Error(), "subscription failed") {
t.Fatalf("err=%v, want subscription failure surfaced", err)
}
}
func TestResultInvokeNonPendingSkipsWaitPhase(t *testing.T) {
partial, err := output.NewPartialData(2,
[]any{map[string]any{"id": "ok"}},
[]output.PartialFailedEntry{{ID: "bad", Error: &output.ErrorInfo{Type: "api", Message: "item failed"}}},
nil)
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
result output.CommandResult
want string
}{
{"failure", output.Failure(&output.ErrorInfo{Type: "api", Message: "business failed"}), "failure"},
{"success", output.Success(map[string]any{"id": "job-1"}), "success"},
{"partial", output.Partial(partial), "partial_failure"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
polled := false
subscribed := false
cmd := New(Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: eventTestDecl(contract.WaitModeAuto),
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return tc.result, nil
},
WaitPoll: func(context.Context, *Ctx) (wait.PollDoc, error) {
polled = true
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
},
WaitEvents: func(context.Context, *Ctx) (wait.EventStream, error) {
subscribed = true
return &scriptedStream{}, nil
},
})
cmd.SetArgs([]string{"--wait"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if polled || subscribed {
t.Fatal("wait phase must not call WaitPoll/WaitEvents for a non-pending initial result")
}
if _, emitted := output.StoredExitCode(store); !emitted {
t.Fatal("initial result was not stored")
}
if !strings.Contains(stdout.String(), `"outcome": "`+tc.want+`"`) {
t.Fatalf("stdout=%s, want outcome %s preserved", stdout.String(), tc.want)
}
if strings.Contains(stdout.String(), `"type": "wait"`) {
t.Fatalf("stdout=%s, wait phase overwrote the original envelope", stdout.String())
}
})
}
}
func TestWaitTimeoutCancelsBlockingPoll(t *testing.T) {
started := make(chan struct{})
cmd := New(baseWaitSpec(waitTestDecl(), func(ctx context.Context, c *Ctx) (wait.PollDoc, error) {
close(started)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-c.Command().Context().Done():
return nil, c.Command().Context().Err()
}
}))
cmd.SetArgs([]string{"--wait", "--wait-timeout", "1"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
done := make(chan error, 1)
go func() { done <- cmd.Execute() }()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("blocking poll never started")
}
select {
case err := <-done:
if err != nil {
t.Fatalf("timeout wait must exit 0 (pending is not failure): %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("blocked poll was not cancelled by --wait-timeout")
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
}
if !strings.Contains(stdout.String(), `"outcome": "pending"`) {
t.Fatalf("stdout=%s", stdout.String())
}
}
func TestWaitTimeoutCancelsBlockingSubscribe(t *testing.T) {
started := make(chan struct{})
cmd := New(Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: eventTestDecl(contract.WaitModeEvent),
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Pending(map[string]any{"id": "job-1"}, &output.OperationInfo{
ID: "job-1", State: "NEW", NextCommand: "dws wait-sample --id job-1",
}), nil
},
WaitEvents: func(ctx context.Context, c *Ctx) (wait.EventStream, error) {
close(started)
// Leaf subscribe may wait on either the hook ctx or the cobra
// command context; both must carry the wait-timeout deadline.
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-c.Command().Context().Done():
return nil, c.Command().Context().Err()
}
},
})
cmd.SetArgs([]string{"--wait", "--wait-timeout", "1"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
done := make(chan error, 1)
go func() { done <- cmd.Execute() }()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("blocking subscribe never started")
}
select {
case err := <-done:
if err != nil {
t.Fatalf("timeout wait must exit 0 (pending is not failure): %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("blocked subscribe was not cancelled by --wait-timeout")
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
}
if !strings.Contains(stdout.String(), `"outcome": "pending"`) {
t.Fatalf("stdout=%s", stdout.String())
}
}
func TestWaitTimeoutCancelsBlockingPollAfterAutoFallback(t *testing.T) {
started := make(chan struct{})
cmd := New(Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: eventTestDecl(contract.WaitModeAuto),
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Pending(map[string]any{"id": "job-1"}, &output.OperationInfo{
ID: "job-1", State: "NEW", NextCommand: "dws wait-sample --id job-1",
}), nil
},
WaitEvents: func(context.Context, *Ctx) (wait.EventStream, error) {
return &scriptedStream{}, nil // ends immediately → poll fallback
},
WaitPoll: func(ctx context.Context, _ *Ctx) (wait.PollDoc, error) {
close(started)
<-ctx.Done()
return nil, ctx.Err()
},
})
cmd.SetArgs([]string{"--wait", "--wait-timeout", "1"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
done := make(chan error, 1)
go func() { done <- cmd.Execute() }()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("auto-fallback blocking poll never started")
}
select {
case err := <-done:
if err != nil {
t.Fatalf("timeout wait must exit 0 (pending is not failure): %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("blocked auto-fallback poll was not cancelled by --wait-timeout")
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
}
if !strings.Contains(stdout.String(), `"outcome": "pending"`) {
t.Fatalf("stdout=%s", stdout.String())
}
}
+2
View File
@@ -58,6 +58,8 @@ const (
// 5 internal (CategoryInternal 与兜底:非结构化错误、panic 收敛均归 5)
// 6 discovery (CategoryDiscovery)
// 7 partial_failure(部分成功专用码,见 ExitCodePartial)
// 8 wait (--wait 观察到失败终态的专用码,见 internal/output
// 的 exitCodeWait;不设 Category,仅经统一信封产出)
//
// ExitCodePartial is the partial-result exit code shared with internal/output.
// It is not returned for CategoryPartial errors because they lack the typed
+8 -49
View File
@@ -27,38 +27,6 @@ import (
"github.com/spf13/cobra"
)
func resolveChatGroupRoleSetUserRoleIDs(cmd *cobra.Command) ([]string, error) {
roleIDChanged := cmd.Flags().Changed("role-id")
roleIDsChanged := cmd.Flags().Changed("role-ids")
switch {
case roleIDChanged && roleIDsChanged:
// PreRunE rejects callers that explicitly pass both flags. Reaching this
// branch means the hidden legacy flag was promoted to satisfy Cobra's
// required marker on the public canonical flag.
return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil
case roleIDChanged:
roleIDs := parseCSVValues(mustGetFlag(cmd, "role-id"))
if len(roleIDs) == 0 {
return nil, apperrors.NewValidation("--role-id 不能为空")
}
if len(roleIDs) > 1 {
return nil, apperrors.NewValidation("--role-id 只允许指定一个群身份")
}
return roleIDs, nil
case roleIDsChanged:
return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil
default:
return nil, apperrors.NewValidation("缺少必填参数 --role-id")
}
}
func prepareChatGroupRoleSetUserRoleID(cmd *cobra.Command) error {
if cmd.Flags().Changed("role-id") && cmd.Flags().Changed("role-ids") {
return apperrors.NewValidation("--role-id 与 --role-ids 不能同时指定")
}
return promoteLegacyChatString(cmd, "role-id", "role-ids")
}
// promoteLegacyChatString copies an explicitly supplied legacy flag into the
// new canonical flag. Cobra validates MarkFlagRequired after PreRunE, and the
// overwrite also preserves the migration rule that a legacy value wins when
@@ -7785,23 +7753,17 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
chatGroupRoleSetUserCmd := &cobra.Command{
Use: "set-user",
Short: "设置用户的群身份(覆盖该用户的全部群身份)",
Example: ` dws chat group-role set-user --conversation-id <openConversationId> --user <userId> --role-id <openRoleId>
Example: ` dws chat group-role set-user --conversation-id <openConversationId> --user <userId> --role-ids roleId1,roleId2
# 查询人员: dws contact user search --keyword "姓名" --format json
# 查询 role-id: dws chat group-role list --conversation-id <openConversationId>`,
PreRunE: func(cmd *cobra.Command, args []string) error {
return prepareChatGroupRoleSetUserRoleID(cmd)
},
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "conversation-id"); err != nil {
if err := validateRequiredFlags(cmd, "conversation-id", "role-ids"); err != nil {
return err
}
if err := validateRequiredFlagWithAliases(cmd, "user", "userId"); err != nil {
return err
}
roleIDs, err := resolveChatGroupRoleSetUserRoleIDs(cmd)
if err != nil {
return err
}
roleIDs := parseCSVValues(mustGetFlag(cmd, "role-ids"))
user := flagOrFallback(cmd, "user", "userId")
toolArgs := map[string]any{
"openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"),
@@ -7836,13 +7798,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
},
Selection: contract.SelectionSpec{
AgentSummary: "为指定群成员设置自定义角色",
UseWhen: []string{"需要把一个已有角色分配给成员时"},
UseWhen: []string{"需要把一个或多个已有角色分配给成员时"},
AvoidWhen: []string{"创建新角色定义时使用 chat group-role add"},
Examples: []string{"dws chat group-role set-user --conversation-id <openConversationId> --user <userId> --role-id <openRoleId>"},
Examples: []string{"dws chat group-role set-user --conversation-id <openConversationId> --user <userId> --role-ids roleId1,roleId2"},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId"},
{Name: "role-id", Property: "openRoleIds"},
{Name: "role-ids", Property: "openRoleIds"},
{Name: "user", Property: "openDingTalkId"},
},
},
@@ -7852,11 +7814,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
chatGroupRoleSetUserCmd.Flags().String("user", "", "用户 userId(必填)")
chatGroupRoleSetUserCmd.Flags().String("userId", "", "--user 的别名")
_ = chatGroupRoleSetUserCmd.Flags().MarkHidden("userId")
chatGroupRoleSetUserCmd.Flags().String("role-id", "", "群身份 openRoleId,由 group-role list 返回 (必填)")
chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "已隐藏的兼容参数:群身份 openRoleId 列表,逗号分隔")
_ = chatGroupRoleSetUserCmd.Flags().MarkHidden("role-ids")
corecmd.AnnotateFlagAlias(chatGroupRoleSetUserCmd, "role-ids", "role-id")
_ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-id")
chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份")
_ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-ids")
chatGroupRoleRemoveUserCmd := &cobra.Command{
Use: "remove-user",
+2 -151
View File
@@ -243,155 +243,6 @@ func TestCrossPlatformCoverageChatGroupUpdateIconRejectsBlankMediaID(t *testing.
}
}
func TestChatGroupRoleSetUserAcceptsSingleRoleIDAndLegacyRoleIDs(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
tests := []struct {
name string
args []string
want []string
}{
{
name: "public single role id",
args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1"},
want: []string{"r1"},
},
{
name: "hidden legacy role ids",
args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1,r2"},
want: []string{"r1", "r2"},
},
{
name: "hidden legacy empty role ids",
args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids="},
want: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := runChatCoverageCommand(t, caller, tc.args...); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("tool calls = %d, want 1", caller.calls)
}
if got := caller.args["openRoleIds"]; !reflect.DeepEqual(got, tc.want) {
t.Fatalf("openRoleIds = %#v, want %#v", got, tc.want)
}
})
}
}
func TestChatGroupRoleSetUserRejectsConflictingRoleFlags(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller,
"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1", "--role-ids=r2")
if err == nil {
t.Fatal("set-user accepted --role-id with --role-ids, want validation error")
}
if !strings.Contains(err.Error(), "--role-id 与 --role-ids 不能同时指定") {
t.Fatalf("error = %v", err)
}
if caller.calls != 0 {
t.Fatalf("tool calls = %d, want 0", caller.calls)
}
}
func TestChatGroupRoleSetUserRejectsMultiplePublicRoleIDs(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller,
"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1,r2")
if err == nil {
t.Fatal("set-user accepted multiple --role-id values, want validation error")
}
if !strings.Contains(err.Error(), "--role-id 只允许指定一个群身份") {
t.Fatalf("error = %v", err)
}
if caller.calls != 0 {
t.Fatalf("tool calls = %d, want 0", caller.calls)
}
}
func TestChatGroupRoleSetUserRejectsMissingOrEmptyPublicRoleID(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
tests := []struct {
name string
args []string
want string
}{
{
name: "missing public role id",
args: []string{"group-role", "set-user", "--group=cid", "--user=D1"},
want: "role-id",
},
{
name: "empty public role id",
args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id="},
want: "--role-id 不能为空",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller, tc.args...)
if err == nil {
t.Fatal("set-user accepted a missing or empty --role-id, want validation error")
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want substring %q", err, tc.want)
}
if caller.calls != 0 {
t.Fatalf("tool calls = %d, want 0", caller.calls)
}
})
}
}
func TestChatGroupRoleSetUserRoleIDResolverDefensiveBranches(t *testing.T) {
newCommand := func(t *testing.T) *cobra.Command {
t.Helper()
cmd := &cobra.Command{}
cmd.Flags().String("role-id", "", "")
cmd.Flags().String("role-ids", "", "")
return cmd
}
t.Run("legacy flag without pre-run promotion", func(t *testing.T) {
cmd := newCommand(t)
if err := cmd.Flags().Set("role-ids", "r1,r2"); err != nil {
t.Fatal(err)
}
got, err := resolveChatGroupRoleSetUserRoleIDs(cmd)
if err != nil {
t.Fatal(err)
}
if want := []string{"r1", "r2"}; !reflect.DeepEqual(got, want) {
t.Fatalf("role IDs = %#v, want %#v", got, want)
}
})
t.Run("no role flag", func(t *testing.T) {
_, err := resolveChatGroupRoleSetUserRoleIDs(newCommand(t))
if err == nil || !strings.Contains(err.Error(), "缺少必填参数 --role-id") {
t.Fatalf("error = %v, want missing --role-id validation", err)
}
})
}
func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
@@ -442,7 +293,7 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
{"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
{"mark-read", "--conversation-id=cid", "--message-id=mid"},
{"text", "translate", "--query=hello", "--to=zh_CN"},
{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1"},
{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"},
{"group-role", "remove-user", "--group=cid", "--user=D1", "--role-ids=r1"},
{"group-role", "query-user", "--group=cid", "--user=D1"},
{"group", "set-admin", "--group=cid", "--users=u1,D1"},
@@ -672,7 +523,7 @@ func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T)
{name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"},
{name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"},
{name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"},
{name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-id": "r1"}, want: "conversation-id"},
{name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
{name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
{name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"},
{name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"},
@@ -58,12 +58,6 @@ func TestCrossPlatformCoverageFramework2MCPDataEdges(t *testing.T) {
t.Fatalf("decoded data=%#v err=%v", data, err)
}
InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"count":7}`}}}})
data, err = CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil)
if err != nil || data.(map[string]any)["count"] != float64(7) {
t.Fatalf("legacy float64 number data=%#v err=%v", data, err)
}
InitDeps(&coverageErrorCaller{err: errors.New("transport failed")})
if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil {
t.Fatal("expected transport error")
@@ -79,16 +73,6 @@ func TestCrossPlatformCoverageFramework2MCPDataEdges(t *testing.T) {
if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil {
t.Fatal("expected invalid JSON error")
}
InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{} {}`}}}})
if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil || !strings.Contains(err.Error(), "存在多个 JSON 值") {
t.Fatalf("multiple JSON values error = %v", err)
}
InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{} {`}}}})
if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil || !strings.Contains(err.Error(), "解析 get_thing 返回失败") {
t.Fatalf("trailing invalid JSON error = %v", err)
}
}
func TestCrossPlatformCoverageFramework2LegacyTextAdapter(t *testing.T) {
+1 -9
View File
@@ -6,7 +6,6 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
@@ -306,14 +305,7 @@ func CallMCPToolDataOnServer(ctx context.Context, serverID, toolName string, arg
return map[string]any{}, nil
}
var data any
decoder := json.NewDecoder(strings.NewReader(text))
if err := decoder.Decode(&data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err))
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
err = fmt.Errorf("存在多个 JSON 值")
}
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err))
}
return data, nil
-678
View File
@@ -1,678 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
const (
recruitServerID = "recruit"
recruitCreateJobTool = "create_job"
recruitGetJobTool = "get_job_detail"
recruitListJobsTool = "list_jobs"
)
var recruitDryRun = &contract.DryRunSpec{
PreviewKind: contract.DryRunPreviewRequest,
RemoteReads: false,
}
var (
recruitListResult = &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"当前页招聘职位查询结果;续页信息位于 meta.pagination","properties":{"jobs":{"type":"array","description":"当前页职位记录","items":{"type":"object","description":"招聘职位摘要","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}}},"additionalProperties":true}`),
}
recruitJobDetailResult = &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位详情","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"description":{"type":"string","description":"职位描述"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}`),
}
recruitCreateJobResult = &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位创建结果","properties":{"jobId":{"type":"string","description":"新创建的职位 ID"}},"required":["jobId"],"additionalProperties":true}`),
}
)
func recruitCursorPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"}
}
func newRecruitCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "recruit",
Selection: contract.ProductSelectionDecl{
AgentSummary: "查询和创建钉钉招聘职位",
UseWhen: []string{"需要查询招聘职位列表、查看职位详情或创建职位时"},
AvoidWhen: []string{"查询企业员工与组织架构使用 contact;查询人才池、职业历程或绩效使用 hrbrain"},
},
})
root := &cobra.Command{
Use: "recruit",
Short: "钉钉招聘",
Long: "查询和创建钉钉招聘中的职位信息。",
RunE: groupRunE,
}
job := &cobra.Command{
Use: "job",
Short: "招聘职位管理",
RunE: groupRunE,
}
job.AddCommand(
newRecruitJobListCommand(),
newRecruitJobGetCommand(),
newRecruitJobCreateCommand(),
)
root.AddCommand(job)
return root
}
func newRecruitJobListCommand() *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "list",
Short: "查询招聘职位列表",
Long: "按职位 ID、关键词、状态、创建人、职位性质等条件分页查询招聘职位。",
Example: " dws recruit job list --keyword Java --status open --size 20 --format json\n dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json",
Server: recruitServerID,
Tool: recruitListJobsTool,
Safety: recruitSafetyRead(),
OutputRollout: output.RolloutUnifiedActive,
Flags: []LeafFlag{
{Name: "job-ids", Usage: "职位 ID,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "jobIds"},
{Name: "required-edu", Usage: "学历要求枚举值", Kind: LeafInt, Bind: "requiredEdu"},
{Name: "status", Usage: "职位状态:draft/open/invalid/closed,多个值用逗号分隔", Bind: "statusList", Trim: true, OmitEmpty: true, Transform: transformRecruitStatuses},
{Name: "job-nature", Usage: "职位性质", Bind: "jobNature", Trim: true, OmitEmpty: true},
{Name: "campus", Usage: "是否为校园招聘", Kind: LeafBool, Bind: "campus"},
{Name: "start-modified-time", Usage: "修改时间范围起点", Bind: "startModifiedTime", Trim: true, OmitEmpty: true},
{Name: "end-modified-time", Usage: "修改时间范围终点", Bind: "endModifiedTime", Trim: true, OmitEmpty: true},
{Name: "creator-user-ids", Usage: "创建人 userId,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "creatorUserIds"},
{Name: "keyword", Usage: "职位搜索关键词", Bind: "keyword", Trim: true, OmitEmpty: true},
{Name: "category", Usage: "职位分类", Bind: "category", Trim: true, OmitEmpty: true},
{Name: "cursor", Usage: "分页游标;首次查询不传,翻页时原样回填返回的 nextCursor", Bind: "cursor", Trim: true, OmitEmpty: true, Transform: transformRecruitCursor},
{Name: "size", Usage: "分页大小,默认 20", Kind: LeafInt, Bind: "size", Default: "20", ArgDefault: "20"},
},
Validate: validateRecruitList,
ResultCall: recruitResultCall,
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitListJobsTool, CanonicalPath: "recruit.list_jobs", CLIPath: "recruit job list", PrimaryCLIPath: "recruit job list"},
Description: "按条件分页查询招聘职位",
DryRun: recruitDryRun,
Result: recruitListResult,
Pagination: recruitCursorPagination(),
Interface: recruitMCPInterface(recruitListJobsTool),
Selection: contract.SelectionSpec{
AgentSummary: "按关键词、状态、创建人等条件分页查询招聘职位",
UseWhen: []string{"需要查找职位、筛选在招职位或取得 jobId 时"},
AvoidWhen: []string{"已经持有明确 jobId 且需要完整详情时使用 recruit job get"},
Examples: []string{`dws recruit job list --keyword "Java" --status open --size 20 --format json`},
},
Parameters: recruitListParamDecls(),
},
})
}
func recruitListToolArgs(args map[string]any) map[string]any {
params := map[string]any{"param": map[string]any{}, "size": args["size"]}
query := params["param"].(map[string]any)
for key, value := range args {
switch key {
case "cursor":
params[key] = value
case "size":
default:
query[key] = value
}
}
return params
}
func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
toolArgs := args
if tool == recruitListJobsTool {
toolArgs = recruitListToolArgs(args)
}
if deps.Caller.DryRun() {
return output.Success(map[string]any{
"tool": tool, "arguments": toolArgs, "executed": false,
}, output.WithDryRun()), nil
}
data, err := callRecruitMCPToolData(cmd.Context(), tool, toolArgs)
if err != nil {
return nil, err
}
clean, err := recruitBusinessResultData(data, tool)
if err != nil {
return recruitResponseFailure(err), nil
}
switch tool {
case recruitListJobsTool:
listData, meta, err := recruitListResultData(clean)
if err != nil {
return recruitInvalidResponse(err), nil
}
return output.Success(listData, output.WithMeta(meta)), nil
case recruitGetJobTool, recruitCreateJobTool:
if err := validateRecruitJobResult(clean, tool, args); err != nil {
return recruitInvalidResponse(err), nil
}
return output.Success(clean), nil
default:
return recruitInvalidResponse(fmt.Errorf("不支持校验 %s 的业务结果", tool)), nil
}
}
func validateRecruitJobResult(data map[string]any, tool string, args map[string]any) error {
jobID, ok := data["jobId"].(string)
if !ok || strings.TrimSpace(jobID) == "" {
return fmt.Errorf("%s 返回值缺少非空字符串字段 jobId", tool)
}
if tool != recruitGetJobTool {
return nil
}
requestedJobID, ok := args["jobId"].(string)
if !ok || strings.TrimSpace(requestedJobID) == "" {
return fmt.Errorf("%s 请求缺少非空字符串字段 jobId", tool)
}
if strings.TrimSpace(jobID) != strings.TrimSpace(requestedJobID) {
return fmt.Errorf("%s 返回的 jobId %q 与请求的 jobId %q 不一致", tool, jobID, requestedJobID)
}
return nil
}
func callRecruitMCPToolData(ctx context.Context, tool string, args map[string]any) (any, error) {
text, err := callMCPToolReturnTextOnServer(ctx, recruitServerID, tool, args)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var data any
decoder := json.NewDecoder(strings.NewReader(text))
decoder.UseNumber()
if err := decoder.Decode(&data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
err = fmt.Errorf("存在多个 JSON 值")
}
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return data, nil
}
func recruitResponseFailure(err error) output.CommandResult {
var businessFailure *recruitBusinessFailure
if errors.As(err, &businessFailure) {
return output.Failure(&output.ErrorInfo{
Type: "api", Message: businessFailure.Error(),
})
}
return recruitInvalidResponse(err)
}
type recruitBusinessFailure struct {
message string
}
func (e *recruitBusinessFailure) Error() string {
return e.message
}
func recruitBusinessResultData(data any, tool string) (map[string]any, error) {
object, ok := data.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s 返回值必须是 JSON 对象", tool)
}
successValue, hasSuccess := object["success"]
resultValue, hasResult := object["result"]
if !hasSuccess && !hasResult {
return object, nil
}
if !hasSuccess || !hasResult {
return nil, fmt.Errorf("%s 返回的 Connector 信封必须同时包含 success 和 result", tool)
}
success, ok := successValue.(bool)
if !ok {
return nil, fmt.Errorf("%s 返回的 Connector 信封字段 success 必须是布尔值", tool)
}
if !success {
message, _ := object["message"].(string)
if strings.TrimSpace(message) == "" {
message = "Connector 返回 success=false"
}
return nil, &recruitBusinessFailure{message: fmt.Sprintf("%s 调用失败: %s", tool, message)}
}
result, ok := resultValue.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s 返回值的 result 必须是 JSON 对象", tool)
}
return result, nil
}
func recruitInvalidResponse(err error) output.CommandResult {
return output.Failure(&output.ErrorInfo{
Type: "api", Subtype: "invalid_response", Message: err.Error(),
Hint: "保留原始响应并停止后续操作;不要依据不完整结果继续处理。",
})
}
func recruitListResultData(data any) (any, *output.Meta, error) {
object, ok := data.(map[string]any)
if !ok {
return nil, nil, fmt.Errorf("%s 返回值必须是 JSON 对象", recruitListJobsTool)
}
// The Connector envelope has already been removed by recruitResultCall.
// Only normalize the list business payload here so fields named success or
// result inside that payload cannot be mistaken for another envelope.
hasMore, ok := object["hasMore"].(bool)
if !ok {
return nil, nil, fmt.Errorf("list_jobs 返回值缺少布尔字段 hasMore")
}
nextToken := ""
if hasMore {
if raw, exists := object["nextCursor"]; exists && raw != nil {
var err error
nextToken, err = normalizeRecruitNextCursor(raw)
if err != nil {
return nil, nil, err
}
}
if nextToken == "" {
return nil, nil, fmt.Errorf("list_jobs 返回 hasMore=true 但缺少 nextCursor")
}
}
clean := make(map[string]any, len(object))
for key, value := range object {
if key != "hasMore" && key != "nextCursor" {
clean[key] = value
}
}
if jobs, exists := clean["list"]; exists {
if _, alreadyNormalized := clean["jobs"]; !alreadyNormalized {
clean["jobs"] = jobs
}
delete(clean, "list")
}
pagination := &output.Pagination{EndpointExhausted: !hasMore, NextToken: nextToken, Pages: 1}
meta := &output.Meta{Pagination: pagination}
if jobs, exists := clean["jobs"].([]any); exists {
meta.Count = output.NewCount(len(jobs))
pagination.Items = len(jobs)
}
return clean, meta, nil
}
func normalizeRecruitNextCursor(raw any) (string, error) {
var value string
switch cursor := raw.(type) {
case string:
value = strings.TrimSpace(cursor)
case json.Number:
value = string(cursor)
case float64:
value = strconv.FormatFloat(cursor, 'f', -1, 64)
default:
return "", fmt.Errorf("list_jobs 的 nextCursor 必须是字符串或数字")
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return "", fmt.Errorf("list_jobs 的 nextCursor 必须是可回填的非负十进制 int64 游标")
}
return strconv.FormatInt(parsed, 10), nil
}
func newRecruitJobGetCommand() *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "get",
Short: "查询招聘职位详情",
Long: "根据职位 ID 查询招聘职位详情。",
Example: " dws recruit job get --job-id JOB_ID --format json",
Server: recruitServerID,
Tool: recruitGetJobTool,
Safety: recruitSafetyRead(),
OutputRollout: output.RolloutUnifiedActive,
ResultCall: recruitResultCall,
Flags: []LeafFlag{{
Name: "job-id", Usage: "职位 ID(必填)", Bind: "jobId", Trim: true,
Required: true, RequiredHint: "--job-id 为必填",
}},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitGetJobTool, CanonicalPath: "recruit.get_job_detail", CLIPath: "recruit job get", PrimaryCLIPath: "recruit job get"},
Description: "根据职位 ID 查询招聘职位详情",
DryRun: recruitDryRun,
Result: recruitJobDetailResult,
Interface: recruitMCPInterface(recruitGetJobTool),
Selection: contract.SelectionSpec{
AgentSummary: "获取指定招聘职位的完整信息",
UseWhen: []string{"已经持有明确 jobId,需要查看职位详情时"},
AvoidWhen: []string{"不知道 jobId 时先使用 recruit job list 查询"},
Examples: []string{"dws recruit job get --job-id <JOB_ID> --format json"},
},
Parameters: []contract.ParamDecl{{Name: "job-id", Property: "jobId", Required: boolPtr(true), InterfaceType: "string"}},
},
})
}
func newRecruitJobCreateCommand() *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "create",
Short: "创建招聘职位",
Long: "从 JSON 文件读取职位信息并创建招聘职位。该操作会写入远端招聘系统,执行前必须确认。",
Example: " dws recruit job create --from ./job.json --dry-run --format json\n dws recruit job create --from ./job.json --format json",
Server: recruitServerID,
Tool: recruitCreateJobTool,
Safety: recruitSafetyCreate(),
OutputRollout: output.RolloutUnifiedActive,
ResultCall: recruitResultCall,
Flags: []LeafFlag{{
Name: "from", Usage: "职位 JSON 文件路径(必填)", Bind: "atsAddJobParam", Trim: true,
Required: true, RequiredHint: "--from 为必填", Transform: loadRecruitJobFile,
}},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitCreateJobTool, CanonicalPath: "recruit.create_job", CLIPath: "recruit job create", PrimaryCLIPath: "recruit job create"},
Description: "从 JSON 文件创建招聘职位",
DryRun: recruitDryRun,
Result: recruitCreateJobResult,
Interface: recruitMCPInterface(recruitCreateJobTool),
Selection: contract.SelectionSpec{
AgentSummary: "使用结构化 JSON 创建招聘职位",
UseWhen: []string{"用户明确要求新建职位,并已准备或同意生成职位 JSON 时;文件必须包含 name、description、jobNature、requiredEdu、extData、creatorUserId;jobNature 固定为 FULL-TIME;creatorUserId 必须使用真实创建人 userId;ownerUserIds 可选"},
AvoidWhen: []string{"仅查询职位时使用 recruit job list 或 recruit job get"},
Examples: []string{"dws recruit job create --from ./job.json --dry-run --format json"},
},
Parameters: []contract.ParamDecl{{Name: "from", Property: "atsAddJobParam", Required: boolPtr(true), InterfaceType: "object", Description: "职位 JSON 文件;CLI 校验后原样作为 atsAddJobParam 对象发送;creatorUserId 为必填的创建人 userId,ownerUserIds 为可选的负责人 userId 字符串数组"}},
},
})
}
func recruitListParamDecls() []contract.ParamDecl {
return []contract.ParamDecl{
{Name: "job-ids", Property: "param.jobIds", InterfaceType: "array"},
{Name: "required-edu", Property: "param.requiredEdu", InterfaceType: "number"},
{Name: "status", Property: "param.statusList", InterfaceType: "array", Enum: []string{"draft", "open", "invalid", "closed"}},
{Name: "job-nature", Property: "param.jobNature", InterfaceType: "string"},
{Name: "campus", Property: "param.campus", InterfaceType: "boolean"},
{Name: "start-modified-time", Property: "param.startModifiedTime", InterfaceType: "string"},
{Name: "end-modified-time", Property: "param.endModifiedTime", InterfaceType: "string"},
{Name: "creator-user-ids", Property: "param.creatorUserIds", InterfaceType: "array"},
{Name: "keyword", Property: "param.keyword", InterfaceType: "string"},
{Name: "category", Property: "param.category", InterfaceType: "string"},
{Name: "cursor", Property: "cursor", InterfaceType: "number"},
{Name: "size", Property: "size", InterfaceType: "number"},
}
}
func recruitMCPInterface(tool string) *contract.InterfaceSpec {
return &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: recruitServerID, RPCName: tool},
}
}
func recruitSafetyRead() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func recruitSafetyCreate() contract.SafetySpec {
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent"}
}
func transformRecruitStatuses(raw string) (any, error) {
values := strings.Split(raw, ",")
statuses := make([]int, 0, len(values))
seen := make(map[int]bool, len(values))
lookup := map[string]int{"draft": 0, "open": 1, "invalid": 2, "closed": 3}
for _, value := range values {
name := strings.ToLower(strings.TrimSpace(value))
if name == "" {
continue
}
status, ok := lookup[name]
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("--status 不支持 %q,可选 draft/open/invalid/closed", value))
}
if !seen[status] {
statuses = append(statuses, status)
seen[status] = true
}
}
return statuses, nil
}
func transformRecruitCursor(raw string) (any, error) {
value := strings.TrimSpace(raw)
cursor, err := strconv.ParseInt(value, 10, 64)
if err != nil || cursor < 0 {
return nil, apperrors.NewValidation("--cursor 必须是大于或等于 0 的整数")
}
return cursor, nil
}
func validateRecruitList(cmd *cobra.Command, _ []string) error {
size, _ := cmd.Flags().GetInt("size")
if cmd.Flags().Changed("size") && (size < 1 || size > 100) {
return apperrors.NewValidation("--size 必须在 1 到 100 之间")
}
requiredEdu, _ := cmd.Flags().GetInt("required-edu")
if cmd.Flags().Changed("required-edu") && (requiredEdu < 1 || requiredEdu > 9) {
return apperrors.NewValidation("--required-edu 必须在 1 到 9 之间")
}
return nil
}
func loadRecruitJobFile(path string) (any, error) {
data, err := os.ReadFile(strings.TrimSpace(path))
if err != nil {
return nil, fmt.Errorf("读取职位 JSON 失败: %w", err)
}
var job map[string]any
if err := json.Unmarshal(data, &job); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("职位文件不是有效的 JSON 对象: %v", err))
}
if job == nil {
return nil, apperrors.NewValidation("职位 JSON 顶层必须是对象")
}
if err := validateRecruitJob(job); err != nil {
return nil, err
}
return job, nil
}
func validateRecruitJob(job map[string]any) error {
for _, name := range []string{"name", "description", "jobNature", "requiredEdu", "extData", "creatorUserId"} {
value, ok := job[name]
if !ok || value == nil || (isString(value) && strings.TrimSpace(value.(string)) == "") {
return apperrors.NewValidation(fmt.Sprintf("职位 JSON 缺少必填字段 %s", name))
}
}
for _, name := range []string{"name", "description", "jobNature"} {
if !isString(job[name]) {
return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name))
}
}
if job["jobNature"].(string) != "FULL-TIME" {
return apperrors.NewValidation("职位 JSON 字段 jobNature 当前仅支持 FULL-TIME")
}
requiredEdu, ok := job["requiredEdu"].(float64)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 requiredEdu 必须是数字")
}
if requiredEdu < 1 || requiredEdu > 9 || requiredEdu != float64(int(requiredEdu)) {
return apperrors.NewValidation("职位 JSON 字段 requiredEdu 必须是 1 到 9 的整数")
}
minSalary, hasMinSalary, err := optionalRecruitNumber(job, "minSalary")
if err != nil {
return err
}
maxSalary, hasMaxSalary, err := optionalRecruitNumber(job, "maxSalary")
if err != nil {
return err
}
if hasMinSalary && hasMaxSalary && minSalary > maxSalary {
return apperrors.NewValidation("职位 JSON 中 minSalary 不能大于 maxSalary")
}
for _, name := range []string{"province", "city", "district", "category"} {
if value, exists := job[name]; exists && value != nil && !isString(value) {
return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name))
}
}
if value, exists := job["campus"]; exists && value != nil {
if _, ok := value.(bool); !ok {
return apperrors.NewValidation("职位 JSON 字段 campus 必须是布尔值")
}
}
if err := validateRecruitIdentityFields(job); err != nil {
return err
}
if err := validateRecruitAddress(job); err != nil {
return err
}
extData, ok := job["extData"].(map[string]any)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 extData 必须是对象")
}
if err := validateRecruitExtData(extData); err != nil {
return err
}
return nil
}
func optionalRecruitNumber(values map[string]any, name string) (float64, bool, error) {
value, exists := values[name]
if !exists || value == nil {
return 0, false, nil
}
number, ok := value.(float64)
if !ok {
return 0, false, apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是数字", name))
}
return number, true, nil
}
func validateRecruitIdentityFields(job map[string]any) error {
if _, ok := job["creatorUserId"].(string); !ok {
return apperrors.NewValidation("职位 JSON 字段 creatorUserId 必须是非空字符串")
}
if value, exists := job["ownerUserIds"]; exists && value != nil {
ownerUserIDs, ok := value.([]any)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 ownerUserIds 必须是字符串数组")
}
for _, owner := range ownerUserIDs {
ownerUserID, ok := owner.(string)
if !ok || strings.TrimSpace(ownerUserID) == "" {
return apperrors.NewValidation("职位 JSON 字段 ownerUserIds 只能包含非空字符串")
}
}
}
return nil
}
func validateRecruitAddress(job map[string]any) error {
value, exists := job["address"]
if !exists || value == nil {
return nil
}
address, ok := value.(map[string]any)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 address 必须是对象")
}
for _, name := range []string{"name", "detail", "longitude", "latitude"} {
field, exists := address[name]
text, ok := field.(string)
if !exists || !ok || strings.TrimSpace(text) == "" {
return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 address.%s 必须是非空字符串", name))
}
}
return nil
}
func validateRecruitExtData(extData map[string]any) error {
if value, exists := extData["headCount"]; exists && value != nil {
headCount, ok := value.(float64)
if !ok || headCount < 1 || headCount > 999 || headCount != float64(int(headCount)) {
return apperrors.NewValidation("职位 JSON 字段 extData.headCount 必须是 1 到 999 的整数")
}
}
if value, exists := extData["source"]; exists && value != nil && !isString(value) {
return apperrors.NewValidation("职位 JSON 字段 extData.source 必须是字符串")
}
if value, exists := extData["fullTimeExtData"]; exists && value != nil {
fullTime, ok := value.(map[string]any)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData 必须是对象")
}
if err := validateRecruitFullTimeExtData(fullTime); err != nil {
return err
}
}
if value, exists := extData["tags"]; exists && value != nil {
tags, ok := value.([]any)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 extData.tags 必须是数组")
}
for _, value := range tags {
tag, ok := value.(map[string]any)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 extData.tags 只能包含对象")
}
name, ok := tag["name"].(string)
if !ok || strings.TrimSpace(name) == "" {
return apperrors.NewValidation("职位 JSON 字段 extData.tags[].name 必须是非空字符串")
}
}
}
return nil
}
func validateRecruitFullTimeExtData(fullTime map[string]any) error {
salaryMonth, hasSalaryMonth, err := optionalRecruitNumber(fullTime, "salaryMonth")
if err != nil {
return err
}
if hasSalaryMonth && (salaryMonth < 12 || salaryMonth > 24 || salaryMonth != float64(int(salaryMonth))) {
return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.salaryMonth 必须是 12 到 24 的整数")
}
minExperience, hasMinExperience, err := optionalRecruitNumber(fullTime, "minJobExperience")
if err != nil {
return err
}
maxExperience, hasMaxExperience, err := optionalRecruitNumber(fullTime, "maxJobExperience")
if err != nil {
return err
}
if hasMinExperience && minExperience < 0 {
return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.minJobExperience 不能小于 0")
}
if hasMaxExperience && maxExperience < 0 {
return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.maxJobExperience 不能小于 0")
}
if hasMinExperience && hasMaxExperience && minExperience > maxExperience {
return apperrors.NewValidation("职位 JSON 中 minJobExperience 不能大于 maxJobExperience")
}
return nil
}
func isString(value any) bool {
_, ok := value.(string)
return ok
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t
for _, want := range []string{
"agoal", "aisearch", "aitable", "attendance", "calendar", "chat",
"contact", "devdoc", "ding", "doc", "drive", "live", "mail",
"markdown", "minutes", "oa", "recruit", "report", "sheet", "todo", "wiki", "whiteboard",
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard",
} {
if !seenProducts[want] {
t.Errorf("public product %q was not registered", want)
-11
View File
@@ -1,11 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
// 招聘是开源库显式维护的公开命令,不依赖生成式产品注册表。
func init() {
RegisterPublic(func() Handler {
return wukongHandler{name: "recruit", buildFn: newRecruitCommand}
})
}
@@ -1,639 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package interfacesnapshot
import (
"bytes"
"encoding/json"
"fmt"
"io"
"reflect"
"strconv"
"strings"
)
const CommandMigrationManifestVersion = 1
const (
CommandMigrationPending = "pending"
CommandMigrationConsumed = "consumed"
CommandMigrationMove = "command_move"
CommandMigrationFlagExtraction = "flag_extraction"
)
// CommandMigrationManifest governs compatibility-preserving surface moves that
// cannot be represented as an in-command flag rename. The merge-base owns the
// authorization; the candidate copy is only a lifecycle receipt.
type CommandMigrationManifest struct {
Version int `json:"version"`
Migrations []CommandMigration `json:"migrations"`
}
type CommandMigration struct {
Kind string `json:"kind"`
Legacy CommandMigrationSide `json:"legacy"`
Replacement CommandMigrationSide `json:"replacement"`
LegacyFlag CommandMigrationFlag `json:"legacy_flag"`
Schema CommandMigrationSchema `json:"schema"`
State string `json:"state"`
Reason string `json:"reason"`
}
type CommandMigrationSide struct {
Command string `json:"command"`
Before CommandMigrationState `json:"before"`
After CommandMigrationState `json:"after"`
}
type CommandMigrationState struct {
Present bool `json:"present"`
Runnable bool `json:"runnable,omitempty"`
Hidden bool `json:"hidden,omitempty"`
}
type CommandMigrationFlag struct {
Name string `json:"name,omitempty"`
Before FlagMigrationState `json:"before"`
After FlagMigrationState `json:"after"`
}
type CommandMigrationSchema struct {
ProductID string `json:"product_id"`
SourceToolID string `json:"source_tool_id"`
ReplacementToolID string `json:"replacement_tool_id"`
Parameters []CommandParameterMigration `json:"parameters"`
}
type CommandParameterMigration struct {
From string `json:"from"`
To string `json:"to,omitempty"`
ReplacementConstant *CommandReplacementConstant `json:"replacement_constant,omitempty"`
}
type CommandReplacementConstant struct {
Property string `json:"property"`
Value bool `json:"value"`
}
func ReadCommandMigrationManifest(r io.Reader) (CommandMigrationManifest, error) {
data, err := io.ReadAll(r)
if err != nil {
return CommandMigrationManifest{}, fmt.Errorf("read command migration manifest: %w", err)
}
var manifest CommandMigrationManifest
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&manifest); err != nil {
return CommandMigrationManifest{}, err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return CommandMigrationManifest{}, fmt.Errorf("command migration manifest contains trailing multiple JSON values")
}
return CommandMigrationManifest{}, fmt.Errorf("read trailing command migration manifest data: %w", err)
}
strict := json.NewDecoder(bytes.NewReader(data))
strict.UseNumber()
if err := validateLabeledMigrationJSONValue(strict, "$", reflect.TypeOf(CommandMigrationManifest{}), "command"); err != nil {
return CommandMigrationManifest{}, err
}
if err := manifest.Validate(); err != nil {
return CommandMigrationManifest{}, err
}
return manifest, nil
}
func (m CommandMigrationManifest) Validate() error {
if m.Version != CommandMigrationManifestVersion {
return fmt.Errorf("unsupported command migration manifest version %d (want %d)", m.Version, CommandMigrationManifestVersion)
}
if m.Migrations == nil {
return fmt.Errorf("command migration manifest migrations must be an array")
}
seen := make(map[string]bool, len(m.Migrations))
for index, migration := range m.Migrations {
if err := migration.validate(); err != nil {
return fmt.Errorf("command migration %d: %w", index, err)
}
if seen[migration.key()] {
return fmt.Errorf("command migration %d duplicates %s", index, migration.displayKey())
}
seen[migration.key()] = true
}
return nil
}
func (m CommandMigration) validate() error {
if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction {
return fmt.Errorf("invalid kind %q", m.Kind)
}
if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) {
return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws")
}
if m.Legacy.Command == m.Replacement.Command {
return fmt.Errorf("legacy and replacement command paths must differ")
}
if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) {
return fmt.Errorf("migration must include a non-empty trimmed reason")
}
if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed {
return fmt.Errorf("invalid state %q", m.State)
}
for label, state := range map[string]CommandMigrationState{
"legacy before": m.Legacy.Before,
"legacy after": m.Legacy.After,
"replacement before": m.Replacement.Before,
"replacement after": m.Replacement.After,
} {
if err := state.validate(label); err != nil {
return err
}
}
if !m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden ||
!m.Legacy.After.Present || !m.Legacy.After.Runnable {
return fmt.Errorf("legacy command must remain runnable and start visible")
}
if m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden {
return fmt.Errorf("replacement command must migrate exactly from absent to visible runnable")
}
if err := m.Schema.validate(m.Kind); err != nil {
return err
}
switch m.Kind {
case CommandMigrationMove:
if strings.HasPrefix(m.Replacement.Command, m.Legacy.Command+" ") ||
strings.HasPrefix(m.Legacy.Command, m.Replacement.Command+" ") {
return fmt.Errorf("command_move legacy and replacement command paths must not have an ancestor relationship")
}
if !m.Legacy.After.Hidden {
return fmt.Errorf("command_move legacy command must migrate exactly from visible to hidden")
}
if m.LegacyFlag != (CommandMigrationFlag{}) {
return fmt.Errorf("command_move must not declare legacy_flag")
}
if m.Schema.SourceToolID != m.Schema.ReplacementToolID {
return fmt.Errorf("command_move must retain one stable Schema tool identity")
}
case CommandMigrationFlagExtraction:
if m.Legacy.After.Hidden || m.Legacy.Before != m.Legacy.After {
return fmt.Errorf("flag_extraction legacy command must remain visible and unchanged")
}
if err := m.LegacyFlag.validate(); err != nil {
return err
}
if m.Schema.SourceToolID == m.Schema.ReplacementToolID {
return fmt.Errorf("flag_extraction requires a distinct replacement Schema tool")
}
if m.LegacyFlag.Before.Type != "bool" || m.LegacyFlag.After.Type != "bool" {
return fmt.Errorf("flag_extraction legacy_flag must be an optional bool")
}
var extracted *CommandParameterMigration
constantCount := 0
for index := range m.Schema.Parameters {
parameter := &m.Schema.Parameters[index]
if parameter.ReplacementConstant == nil {
continue
}
constantCount++
if parameter.From == m.LegacyFlag.Name {
extracted = parameter
}
}
if constantCount != 1 {
return fmt.Errorf("flag_extraction must declare exactly one replacement constant")
}
if extracted == nil {
return fmt.Errorf("flag_extraction must map the exact extracted flag to replacement_constant")
}
if !extracted.ReplacementConstant.Value {
return fmt.Errorf("flag_extraction v1 replacement_constant must be true")
}
wantNoOpt := strconv.FormatBool(extracted.ReplacementConstant.Value)
if m.LegacyFlag.Before.NoOpt != wantNoOpt || m.LegacyFlag.After.NoOpt != wantNoOpt {
return fmt.Errorf("flag_extraction legacy_flag no_opt must equal replacement constant %q", wantNoOpt)
}
}
return nil
}
func (s CommandMigrationState) validate(label string) error {
if !s.Present {
if s.Runnable || s.Hidden {
return fmt.Errorf("%s absent state must not declare command attributes", label)
}
return nil
}
if !s.Runnable {
return fmt.Errorf("%s present state must be runnable", label)
}
return nil
}
func (f CommandMigrationFlag) validate() error {
if !isExactFlagName(f.Name) {
return fmt.Errorf("legacy_flag name must be an exact flag")
}
if err := f.Before.validate("legacy_flag before"); err != nil {
return err
}
if err := f.After.validate("legacy_flag after"); err != nil {
return err
}
if !f.Before.Present || !f.After.Present || f.Before.Hidden || !f.After.Hidden {
return fmt.Errorf("legacy_flag must migrate exactly from visible to hidden while remaining present")
}
if f.Before.Required || f.After.Required {
return fmt.Errorf("legacy_flag must remain optional before and after extraction")
}
before := f.Before
after := f.After
before.Hidden = false
after.Hidden = false
if before != after {
return fmt.Errorf("legacy_flag may change only hidden visibility")
}
return nil
}
func (s CommandMigrationSchema) validate(kind string) error {
for label, value := range map[string]string{
"product_id": s.ProductID,
"source_tool_id": s.SourceToolID,
"replacement_tool_id": s.ReplacementToolID,
} {
if !isExactSchemaIdentifier(value) {
return fmt.Errorf("schema %s must be an exact identifier", label)
}
}
if s.Parameters == nil {
return fmt.Errorf("schema parameters must be an array")
}
seenFrom := map[string]bool{}
seenTo := map[string]bool{}
seenConstantProperty := map[string]bool{}
for index, parameter := range s.Parameters {
if !isExactFlagName(parameter.From) {
return fmt.Errorf("schema parameter %d from must be an exact parameter name", index)
}
if seenFrom[parameter.From] {
return fmt.Errorf("schema parameter %d duplicates from %q", index, parameter.From)
}
seenFrom[parameter.From] = true
if kind == CommandMigrationMove {
if parameter.ReplacementConstant != nil {
return fmt.Errorf("command_move schema parameter %d must not declare replacement_constant", index)
}
if !isExactFlagName(parameter.To) || parameter.To == parameter.From {
return fmt.Errorf("command_move schema parameter %d requires a distinct exact to name", index)
}
if seenTo[parameter.To] {
return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To)
}
seenTo[parameter.To] = true
continue
}
if kind != CommandMigrationFlagExtraction {
return fmt.Errorf("invalid command migration kind %q", kind)
}
hasTo := parameter.To != ""
hasConstant := parameter.ReplacementConstant != nil
if !hasTo && !hasConstant {
return fmt.Errorf("flag_extraction schema parameter %d requires an exact to or replacement_constant", index)
}
if hasTo && hasConstant {
return fmt.Errorf("flag_extraction schema parameter %d must declare exactly one target", index)
}
if hasTo {
if !isExactFlagName(parameter.To) {
return fmt.Errorf("flag_extraction schema parameter %d to must be an exact parameter name", index)
}
if seenTo[parameter.To] {
return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To)
}
if seenConstantProperty[parameter.To] {
return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, parameter.To)
}
seenTo[parameter.To] = true
continue
}
property := parameter.ReplacementConstant.Property
if !isExactSchemaIdentifier(property) {
return fmt.Errorf("flag_extraction schema parameter %d replacement_constant requires an exact property", index)
}
if seenConstantProperty[property] {
return fmt.Errorf("schema parameter %d duplicates replacement_constant property %q", index, property)
}
if seenTo[property] {
return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, property)
}
seenConstantProperty[property] = true
}
return nil
}
func isExactSchemaIdentifier(value string) bool {
return value != "" && value == strings.TrimSpace(value) &&
!strings.ContainsAny(value, "*?[]{} /\\\t\r\n")
}
func (m CommandMigration) key() string {
return strings.Join([]string{m.Kind, m.Legacy.Command, m.Replacement.Command, m.Schema.ProductID, m.Schema.SourceToolID, m.Schema.ReplacementToolID}, "\x00")
}
func (m CommandMigration) displayKey() string {
return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command)
}
type commandMigrationPhase string
const (
commandMigrationBefore commandMigrationPhase = "before"
commandMigrationAfter commandMigrationPhase = "after"
commandMigrationPartial commandMigrationPhase = "partial"
)
func AuthorizeCommandMigrations(
current Snapshot,
references map[string]Snapshot,
authority CommandMigrationManifest,
candidate CommandMigrationManifest,
) ([]CommandMigration, error) {
if err := current.Validate(); err != nil {
return nil, fmt.Errorf("validate current interface snapshot: %w", err)
}
for label, snapshot := range references {
if err := snapshot.Validate(); err != nil {
return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err)
}
}
if err := authority.Validate(); err != nil {
return nil, fmt.Errorf("validate approved command migrations: %w", err)
}
if err := candidate.Validate(); err != nil {
return nil, fmt.Errorf("validate candidate command migrations: %w", err)
}
type commandMoveTopologyCheck struct {
label string
snapshot Snapshot
manifest CommandMigrationManifest
}
topologyChecks := []commandMoveTopologyCheck{
{label: "approved", snapshot: current, manifest: authority},
{label: "candidate", snapshot: current, manifest: candidate},
}
if mergeBase, _, ok := flagMigrationAuthoritySnapshot(references); ok {
topologyChecks = append(topologyChecks,
commandMoveTopologyCheck{label: "approved base", snapshot: mergeBase, manifest: authority},
commandMoveTopologyCheck{label: "candidate base", snapshot: mergeBase, manifest: candidate},
)
}
for _, check := range topologyChecks {
if err := validateCommandMoveLegacyLeaves(check.snapshot, check.manifest); err != nil {
return nil, fmt.Errorf("validate %s command migration topology: %w", check.label, err)
}
}
return evaluateCommandMigrationLifecycle(current, references, authority, candidate)
}
func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigrationManifest) error {
commands := commandIndex(snapshot)
for _, migration := range manifest.Migrations {
if migration.Kind != CommandMigrationMove {
continue
}
if _, present := commands[migration.Legacy.Command]; !present {
continue
}
for _, command := range snapshot.Commands {
if strings.HasPrefix(command.Path, migration.Legacy.Command+" ") {
return fmt.Errorf("command_move legacy command %q must be a leaf", migration.Legacy.Command)
}
}
}
return nil
}
func evaluateCommandMigrationLifecycle(
current Snapshot,
references map[string]Snapshot,
authority CommandMigrationManifest,
candidate CommandMigrationManifest,
) ([]CommandMigration, error) {
if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 {
return nil, nil
}
if _, ok := references["stable"]; !ok {
return nil, fmt.Errorf("command migration lifecycle requires a stable reference")
}
mergeBase, label, ok := flagMigrationAuthoritySnapshot(references)
if !ok {
return nil, fmt.Errorf("command migration lifecycle requires a main or merge-base reference")
}
authorityByKey := commandMigrationIndex(authority)
candidateByKey := commandMigrationIndex(candidate)
authorizations := make([]CommandMigration, 0, len(authority.Migrations))
for _, approved := range authority.Migrations {
basePhase := matchCommandMigrationPhase(mergeBase, approved)
wantBase := commandMigrationBefore
if approved.State == CommandMigrationConsumed {
wantBase = commandMigrationAfter
}
if basePhase != wantBase {
return nil, fmt.Errorf("approved command migration %s is %s in %s, want exact %s state for %s", approved.displayKey(), basePhase, label, wantBase, approved.State)
}
proposed, exists := candidateByKey[approved.key()]
if exists && !sameCommandMigrationApproval(approved, proposed) {
return nil, fmt.Errorf("candidate modified base-owned command migration %s", approved.displayKey())
}
currentPhase := matchCommandMigrationPhase(current, approved)
switch approved.State {
case CommandMigrationPending:
if !exists {
return nil, fmt.Errorf("candidate removed pending command migration %s", approved.displayKey())
}
switch currentPhase {
case commandMigrationBefore:
if proposed.State != CommandMigrationPending {
return nil, fmt.Errorf("candidate falsely consumed unchanged command migration %s", approved.displayKey())
}
case commandMigrationAfter:
if proposed.State != CommandMigrationConsumed {
return nil, fmt.Errorf("candidate completed command migration %s without marking it consumed", approved.displayKey())
}
authorizations = append(authorizations, approved)
default:
return nil, fmt.Errorf("candidate partially applied command migration %s", approved.displayKey())
}
case CommandMigrationConsumed:
if currentPhase != commandMigrationAfter {
return nil, fmt.Errorf("candidate drifted from consumed command migration %s", approved.displayKey())
}
allAfter := true
for _, reference := range references {
if matchCommandMigrationPhase(reference, approved) != commandMigrationAfter {
allAfter = false
break
}
}
if allAfter {
if exists {
return nil, fmt.Errorf("consumed command migration %s is stale after all references reached the after state", approved.displayKey())
}
continue
}
if !exists || proposed.State != CommandMigrationConsumed {
return nil, fmt.Errorf("candidate must retain consumed command migration %s until every reference reaches the after state", approved.displayKey())
}
authorizations = append(authorizations, approved)
}
}
for _, proposed := range candidate.Migrations {
if _, exists := authorityByKey[proposed.key()]; exists {
continue
}
if proposed.State != CommandMigrationPending {
return nil, fmt.Errorf("candidate-added command migration %s must start pending", proposed.displayKey())
}
if matchCommandMigrationPhase(mergeBase, proposed) != commandMigrationBefore {
return nil, fmt.Errorf("candidate-added command migration %s does not match the merge-base before state", proposed.displayKey())
}
if matchCommandMigrationPhase(current, proposed) != commandMigrationBefore {
return nil, fmt.Errorf("candidate-added command migration %s cannot authorize its own interface change", proposed.displayKey())
}
}
return authorizations, nil
}
func commandMigrationIndex(manifest CommandMigrationManifest) map[string]CommandMigration {
index := make(map[string]CommandMigration, len(manifest.Migrations))
for _, migration := range manifest.Migrations {
index[migration.key()] = migration
}
return index
}
func sameCommandMigrationApproval(left, right CommandMigration) bool {
left.State = ""
right.State = ""
return reflect.DeepEqual(left, right)
}
func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase {
commands := commandIndex(snapshot)
legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command)
replacement := commandMigrationStateForCommand(commands, migration.Replacement.Command)
before := legacy == migration.Legacy.Before && replacement == migration.Replacement.Before
after := legacy == migration.Legacy.After && replacement == migration.Replacement.After
if migration.Kind == CommandMigrationFlagExtraction {
command, exists := commands[migration.Legacy.Command]
flagState := FlagMigrationState{}
if exists {
flagState = flagMigrationStateForCommand(command, migration.LegacyFlag.Name)
}
before = before && flagState == migration.LegacyFlag.Before
after = after && flagState == migration.LegacyFlag.After
if replacement, replacementExists := commands[migration.Replacement.Command]; replacementExists {
after = after && commandMigrationReplacementConstantsMatch(replacement, migration)
} else {
after = false
}
}
if before {
return commandMigrationBefore
}
if after {
return commandMigrationAfter
}
return commandMigrationPartial
}
func commandMigrationReplacementConstantsMatch(command Command, migration CommandMigration) bool {
expected := make(map[string]bool)
for _, parameter := range migration.Schema.Parameters {
if parameter.ReplacementConstant == nil {
continue
}
expected[parameter.ReplacementConstant.Property] = parameter.ReplacementConstant.Value
}
return reflect.DeepEqual(command.BoolConstParams, expected)
}
func commandMigrationStateForCommand(commands map[string]Command, path string) CommandMigrationState {
command, exists := commands[path]
if !exists {
return CommandMigrationState{}
}
return CommandMigrationState{Present: true, Runnable: command.Runnable, Hidden: command.Hidden}
}
// CompareAllWithInterfaceMigrations applies both migration families to one
// ordinary report, so the two ledgers cannot mask each other's unrelated
// findings.
func CompareAllWithInterfaceMigrations(
current Snapshot,
references map[string]Snapshot,
flagAuthority FlagMigrationManifest,
flagCandidate FlagMigrationManifest,
commandAuthority CommandMigrationManifest,
commandCandidate CommandMigrationManifest,
) (Report, error) {
flagAuthorizations, err := AuthorizeFlagMigrations(current, references, flagAuthority, flagCandidate)
if err != nil {
return Report{}, err
}
commandAuthorizations, err := AuthorizeCommandMigrations(current, references, commandAuthority, commandCandidate)
if err != nil {
return Report{}, err
}
report := CompareAll(current, references)
for index := range report.Comparisons {
comparison := &report.Comparisons[index]
reference := references[comparison.Reference]
filtered := comparison.Blocking[:0]
for _, change := range comparison.Blocking {
if flagMigrationAuthorizesChange(current, reference, change, flagAuthorizations) ||
commandMigrationAuthorizesChange(current, reference, change, commandAuthorizations) {
continue
}
filtered = append(filtered, change)
}
comparison.Blocking = filtered
comparison.Compatible = len(filtered) == 0
}
report.Compatible = true
for _, comparison := range report.Comparisons {
if !comparison.Compatible {
report.Compatible = false
break
}
}
return report, nil
}
func commandMigrationAuthorizesChange(current, reference Snapshot, change Change, authorizations []CommandMigration) bool {
canonicalPath := acceptedPathIndex(reference)[change.Path]
if canonicalPath == "" {
canonicalPath = change.Path
}
for _, migration := range authorizations {
if canonicalPath != migration.Legacy.Command ||
matchCommandMigrationPhase(reference, migration) != commandMigrationBefore ||
matchCommandMigrationPhase(current, migration) != commandMigrationAfter {
continue
}
switch migration.Kind {
case CommandMigrationMove:
if change.Kind == "command_became_hidden" && change.Flag == "" {
return true
}
case CommandMigrationFlagExtraction:
if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name {
return true
}
}
}
return false
}
@@ -1,744 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package interfacesnapshot
import (
"errors"
"os"
"strings"
"testing"
)
type commandMigrationErrorReader struct{}
func (commandMigrationErrorReader) Read([]byte) (int, error) {
return 0, errors.New("command migration read failure")
}
func TestApprovedCommandMigrationManifestRemainsValid(t *testing.T) {
manifest, err := os.Open("../../scripts/policy/interface-migrations/approved-command-migrations-v1.json")
if err != nil {
t.Fatalf("open approved command migration manifest: %v", err)
}
defer manifest.Close()
if _, err := ReadCommandMigrationManifest(manifest); err != nil {
t.Fatalf("approved command migration manifest is invalid: %v", err)
}
}
func TestCrossPlatformCoverageReadCommandMigrationManifestFailsClosed(t *testing.T) {
valid := commandMigrationManifestJSON()
if _, err := ReadCommandMigrationManifest(strings.NewReader(valid)); err != nil {
t.Fatalf("valid command migration manifest: %v", err)
}
validExtraction := flagExtractionCommandMigrationManifestJSON()
if _, err := ReadCommandMigrationManifest(strings.NewReader(validExtraction)); err != nil {
t.Fatalf("valid flag extraction command migration manifest: %v", err)
}
for _, test := range []struct {
input string
want string
}{
{strings.Replace(valid, `"state": "pending"`, `"state": "pending", "allow": true`, 1), "unknown field"},
{strings.Replace(valid, `"state": "pending"`, `"state": "pending", "state": "consumed"`, 1), "duplicate field"},
{strings.Replace(valid, `"version": 1`, `"version": null`, 1), "must be"},
{strings.Replace(valid, "dws chat message old", "dws chat *", 1), "exact command paths"},
{strings.Replace(valid, `"kind": "command_move"`, `"kind": "anything"`, 1), "invalid kind"},
{`{"version":1,"migrations":null}`, "must be an array"},
{strings.Replace(validExtraction, `"replacement_constant":{"property":"convThreadEnabled","value":true}`, `"replacement_constant":null`, 1), "must be an object"},
{strings.Replace(validExtraction, `,"value":true`, ``, 1), `must be true`},
{strings.Replace(validExtraction, `"value":true`, `"value":"true"`, 1), "bool"},
} {
if _, err := ReadCommandMigrationManifest(strings.NewReader(test.input)); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("ReadCommandMigrationManifest() error=%v, want %q", err, test.want)
}
}
if _, err := ReadCommandMigrationManifest(commandMigrationErrorReader{}); err == nil || !strings.Contains(err.Error(), "read failure") {
t.Fatalf("reader error=%v", err)
}
for _, input := range []string{valid + ` {}`, valid + ` {`} {
if _, err := ReadCommandMigrationManifest(strings.NewReader(input)); err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("trailing input error=%v", err)
}
}
}
func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) {
validMove := commandMigrationManifest(CommandMigrationPending).Migrations[0]
validExtraction := commandMigrationManifest(CommandMigrationPending).Migrations[1]
duplicate := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{validMove, validMove}}
if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") {
t.Fatalf("duplicate error=%v", err)
}
if err := (CommandMigrationManifest{Version: CommandMigrationManifestVersion}).Validate(); err == nil || !strings.Contains(err.Error(), "must be an array") {
t.Fatalf("nil migrations error=%v", err)
}
for _, test := range []struct {
name string
mutate func(*CommandMigration)
want string
}{
{"same command", func(m *CommandMigration) { m.Replacement.Command = m.Legacy.Command }, "must differ"},
{"empty reason", func(m *CommandMigration) { m.Reason = " " }, "non-empty trimmed"},
{"invalid state", func(m *CommandMigration) { m.State = "approved" }, "invalid state"},
{"invalid command state", func(m *CommandMigration) { m.Replacement.Before.Runnable = true }, "absent state"},
{"legacy contract", func(m *CommandMigration) { m.Legacy.Before.Hidden = true }, "remain runnable"},
{"replacement contract", func(m *CommandMigration) { m.Replacement.After.Hidden = true }, "absent to visible"},
{"schema contract", func(m *CommandMigration) { m.Schema.ProductID = "bad/id" }, "exact identifier"},
{"move not hidden", func(m *CommandMigration) { m.Legacy.After.Hidden = false }, "must migrate exactly"},
{"move flag", func(m *CommandMigration) { m.LegacyFlag.Name = "thread" }, "must not declare legacy_flag"},
{"move tool identity", func(m *CommandMigration) { m.Schema.ReplacementToolID = "chat.new" }, "stable Schema tool"},
} {
t.Run(test.name, func(t *testing.T) {
migration := validMove
test.mutate(&migration)
if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("validate error=%v, want %q", err, test.want)
}
})
}
for _, test := range []struct {
name string
mutate func(*CommandMigration)
want string
}{
{"legacy changed", func(m *CommandMigration) { m.Legacy.After.Hidden = true }, "remain visible"},
{"invalid legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "bad name" }, "exact flag"},
{"same tool", func(m *CommandMigration) { m.Schema.ReplacementToolID = m.Schema.SourceToolID }, "distinct replacement"},
{"wrong parameter", func(m *CommandMigration) { m.Schema.Parameters[3].From = "other" }, "exact extracted flag"},
{"wrong type", func(m *CommandMigration) {
m.LegacyFlag.Before.Type = "string"
m.LegacyFlag.After.Type = "string"
}, "optional bool"},
{"wrong no opt", func(m *CommandMigration) {
m.LegacyFlag.Before.NoOpt = "false"
m.LegacyFlag.After.NoOpt = "false"
}, "no_opt"},
{"missing extracted mapping", func(m *CommandMigration) {
m.Schema.Parameters = m.Schema.Parameters[:3]
}, "exactly one replacement constant"},
{"duplicate extracted mapping", func(m *CommandMigration) {
m.Schema.Parameters = append(m.Schema.Parameters, m.Schema.Parameters[3])
}, "duplicates from"},
{"multiple constants", func(m *CommandMigration) {
m.Schema.Parameters[0] = CommandParameterMigration{
From: "name",
ReplacementConstant: &CommandReplacementConstant{
Property: "name",
Value: true,
},
}
}, "exactly one replacement constant"},
} {
t.Run(test.name, func(t *testing.T) {
migration := cloneCommandMigration(validExtraction)
test.mutate(&migration)
if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("validate error=%v, want %q", err, test.want)
}
})
}
falseConstant := cloneCommandMigration(validExtraction)
falseConstant.Schema.Parameters[3].ReplacementConstant.Value = false
falseConstant.LegacyFlag.Before.NoOpt = "false"
falseConstant.LegacyFlag.After.NoOpt = "false"
if err := falseConstant.validate(); err == nil || !strings.Contains(err.Error(), "must be true") {
t.Fatalf("false replacement constant validation error=%v, want v1 true-only rejection", err)
}
for _, state := range []CommandMigrationState{
{Runnable: true},
{Present: true},
} {
if err := state.validate("state"); err == nil {
t.Fatalf("invalid command state accepted: %#v", state)
}
}
for _, test := range []struct {
name string
mutate func(*CommandMigrationFlag)
want string
}{
{"name", func(f *CommandMigrationFlag) { f.Name = "bad name" }, "exact flag"},
{"before state", func(f *CommandMigrationFlag) { f.Before = FlagMigrationState{Present: true} }, "requires type"},
{"after state", func(f *CommandMigrationFlag) { f.After = FlagMigrationState{Present: true} }, "requires type"},
{"visibility", func(f *CommandMigrationFlag) { f.After.Hidden = false }, "visible to hidden"},
{"attribute", func(f *CommandMigrationFlag) { f.After.Type = "string" }, "only hidden visibility"},
} {
t.Run("flag "+test.name, func(t *testing.T) {
flag := validExtraction.LegacyFlag
test.mutate(&flag)
if err := flag.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("flag validate error=%v, want %q", err, test.want)
}
})
}
validSchema := validMove.Schema
for _, test := range []struct {
name string
kind string
mutate func(*CommandMigrationSchema)
want string
}{
{"identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ProductID = "bad/id" }, "exact identifier"},
{"nil parameters", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = nil }, "must be an array"},
{"bad from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].From = "bad name" }, "exact parameter"},
{"duplicate from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, s.Parameters[0]) }, "duplicates from"},
{"bad to", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].To = s.Parameters[0].From }, "distinct exact"},
{"duplicate to", CommandMigrationMove, func(s *CommandMigrationSchema) {
s.Parameters = append(s.Parameters, CommandParameterMigration{From: "other", To: s.Parameters[0].To})
}, "duplicates to"},
{"move constant", CommandMigrationMove, func(s *CommandMigrationSchema) {
s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "property", Value: true}
}, "must not declare replacement_constant"},
{"invalid kind", "anything", func(*CommandMigrationSchema) {}, "invalid command migration kind"},
} {
t.Run("schema "+test.name, func(t *testing.T) {
schema := validSchema
schema.Parameters = append([]CommandParameterMigration(nil), validSchema.Parameters...)
test.mutate(&schema)
if err := schema.validate(test.kind); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("schema validate error=%v, want %q", err, test.want)
}
})
}
for _, test := range []struct {
name string
mutate func(*CommandMigrationSchema)
want string
}{
{"missing target", func(s *CommandMigrationSchema) { s.Parameters[0].To = "" }, "requires an exact to or replacement_constant"},
{"both targets", func(s *CommandMigrationSchema) {
s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "name", Value: true}
}, "exactly one target"},
{"bad to", func(s *CommandMigrationSchema) { s.Parameters[0].To = "bad name" }, "to must be an exact parameter name"},
{"bad constant property", func(s *CommandMigrationSchema) { s.Parameters[3].ReplacementConstant.Property = "bad/property" }, "exact property"},
{"duplicate parameter target", func(s *CommandMigrationSchema) { s.Parameters[1].To = s.Parameters[0].To }, "duplicates to"},
{"duplicate constant target", func(s *CommandMigrationSchema) {
s.Parameters[0] = CommandParameterMigration{From: "name", ReplacementConstant: &CommandReplacementConstant{Property: "convThreadEnabled", Value: true}}
}, "duplicates replacement_constant property"},
{"constant before parameter target", func(s *CommandMigrationSchema) {
s.Parameters[3].ReplacementConstant.Property = "name"
s.Parameters[0], s.Parameters[3] = s.Parameters[3], s.Parameters[0]
}, "duplicates parameter target"},
{"parameter target before constant", func(s *CommandMigrationSchema) {
s.Parameters[3].ReplacementConstant.Property = "name"
}, "duplicates parameter target"},
} {
t.Run("extraction schema "+test.name, func(t *testing.T) {
schema := cloneCommandMigration(validExtraction).Schema
test.mutate(&schema)
if err := schema.validate(CommandMigrationFlagExtraction); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("schema validate error=%v, want %q", err, test.want)
}
})
}
}
func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) {
for _, test := range []struct {
name string
mutate func(*CommandMigrationFlag)
}{
{"before required", func(flag *CommandMigrationFlag) { flag.Before.Required = true }},
{"after required", func(flag *CommandMigrationFlag) { flag.After.Required = true }},
} {
t.Run(test.name, func(t *testing.T) {
migration := commandMigrationManifest(CommandMigrationPending).Migrations[1]
test.mutate(&migration.LegacyFlag)
if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "optional") {
t.Fatalf("required legacy flag validation error=%v, want optional-only rejection", err)
}
})
}
}
func TestCrossPlatformCoverageCommandMoveRejectsRunnableParent(t *testing.T) {
migration := commandMigrationManifest(CommandMigrationPending).Migrations[0]
migration.Legacy.Command = "dws agoal"
migration.Replacement.Command = "dws goal"
migration.Schema = CommandMigrationSchema{
ProductID: "agoal",
SourceToolID: "agoal.root",
ReplacementToolID: "agoal.root",
Parameters: []CommandParameterMigration{},
}
authority := CommandMigrationManifest{
Version: CommandMigrationManifestVersion,
Migrations: []CommandMigration{migration},
}
consumed := authority
consumed.Migrations = append([]CommandMigration(nil), authority.Migrations...)
consumed.Migrations[0].State = CommandMigrationConsumed
before := testSnapshot(
testCommand("dws"),
testCommand("dws agoal"),
testCommand("dws agoal strategy"),
testCommand("dws agoal strategy list"),
)
after := testSnapshot(
testCommand("dws"),
Command{Path: "dws agoal", Runnable: true, Hidden: true},
testCommand("dws goal"),
)
_, err := AuthorizeCommandMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
authority,
consumed,
)
if err == nil || !strings.Contains(err.Error(), "must be a leaf") {
t.Fatalf("runnable parent command_move error=%v, want leaf-only rejection", err)
}
}
func TestCrossPlatformCoverageCommandMoveAllowsReplacementParent(t *testing.T) {
before := commandMigrationSnapshot(false, false)
after := commandMigrationSnapshot(true, false)
after.Commands = append(after.Commands, testCommand("dws chat topic new detail"))
pending := singleCommandMigrationManifest(CommandMigrationPending)
consumed := singleCommandMigrationManifest(CommandMigrationConsumed)
if err := validateCommandMoveLegacyLeaves(testSnapshot(testCommand("dws")), pending); err != nil {
t.Fatalf("absent legacy topology should remain a lifecycle concern: %v", err)
}
got, err := AuthorizeCommandMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil {
t.Fatalf("replacement parent command_move was rejected: %v", err)
}
if len(got) != 1 {
t.Fatalf("replacement parent command_move authorizations=%d, want 1", len(got))
}
}
func TestCrossPlatformCoverageCommandMoveRejectsAncestorOverlap(t *testing.T) {
for _, test := range []struct {
name string
legacy string
replacement string
}{
{
name: "legacy ancestor",
legacy: "dws chat message",
replacement: "dws chat message list",
},
{
name: "replacement ancestor",
legacy: "dws chat message list",
replacement: "dws chat message",
},
} {
t.Run(test.name, func(t *testing.T) {
migration := commandMigrationManifest(CommandMigrationPending).Migrations[0]
migration.Legacy.Command = test.legacy
migration.Replacement.Command = test.replacement
if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "must not have an ancestor relationship") {
t.Fatalf("overlapping command_move error=%v, want ancestor-overlap rejection", err)
}
})
}
}
func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
before := commandMigrationSnapshot(false, false)
after := commandMigrationSnapshot(true, false)
partial := commandMigrationSnapshot(false, false)
partial.Commands = append(partial.Commands, testCommand("dws chat topic new"))
pending := singleCommandMigrationManifest(CommandMigrationPending)
consumed := singleCommandMigrationManifest(CommandMigrationConsumed)
empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{}, empty, empty); err != nil || len(got) != 0 {
t.Fatalf("empty lifecycle=%#v, %v", got, err)
}
for _, test := range []struct {
name string
current Snapshot
references map[string]Snapshot
authority CommandMigrationManifest
candidate CommandMigrationManifest
want string
}{
{"missing stable", before, map[string]Snapshot{"main": before}, pending, pending, "stable reference"},
{"missing base", before, map[string]Snapshot{"stable": before}, pending, pending, "main or merge-base"},
{"pending base after", after, map[string]Snapshot{"main": after, "stable": before}, pending, pending, "want exact before"},
{"modified approval", before, map[string]Snapshot{"main": before, "stable": before}, pending, modifiedCommandManifest(pending), "modified base-owned"},
{"pending removed", before, map[string]Snapshot{"main": before, "stable": before}, pending, empty, "removed pending"},
{"false consumed", before, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "falsely consumed"},
{"after pending receipt", after, map[string]Snapshot{"main": before, "stable": before}, pending, pending, "without marking it consumed"},
{"partial", partial, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "partially applied"},
{"consumed drift", before, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed, "drifted from consumed"},
{"stale receipt", after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed, "stale after all references"},
{"early cleanup", after, map[string]Snapshot{"main": after, "stable": before}, consumed, empty, "must retain consumed"},
{"consumed back to pending", after, map[string]Snapshot{"main": after, "stable": before}, consumed, pending, "must retain consumed"},
{"candidate added consumed", after, map[string]Snapshot{"main": before, "stable": before}, empty, consumed, "must start pending"},
{"candidate base mismatch", before, map[string]Snapshot{"main": after, "stable": before}, empty, pending, "does not match"},
} {
t.Run(test.name, func(t *testing.T) {
_, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("lifecycle error=%v, want %q", err, test.want)
}
})
}
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed); err != nil || len(got) != 1 {
t.Fatalf("retained consumed receipt=%#v, %v", got, err)
}
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, empty); err != nil || len(got) != 0 {
t.Fatalf("cleaned stale receipt=%#v, %v", got, err)
}
if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{"main": before, "stable": before}, empty, pending); err != nil || len(got) != 0 {
t.Fatalf("candidate pending plan=%#v, %v", got, err)
}
invalidSnapshot := before
invalidSnapshot.SchemaVersion = 0
for _, test := range []struct {
name string
current Snapshot
references map[string]Snapshot
authority CommandMigrationManifest
candidate CommandMigrationManifest
}{
{"current", invalidSnapshot, map[string]Snapshot{"main": before}, empty, empty},
{"reference", before, map[string]Snapshot{"main": invalidSnapshot}, empty, empty},
{"authority", before, map[string]Snapshot{"main": before}, CommandMigrationManifest{}, empty},
{"candidate", before, map[string]Snapshot{"main": before}, empty, CommandMigrationManifest{}},
} {
t.Run("invalid "+test.name, func(t *testing.T) {
if _, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate); err == nil {
t.Fatal("invalid authorization input accepted")
}
})
}
invalidFlags := FlagMigrationManifest{}
validFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
validCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, invalidFlags, validFlags, validCommands, validCommands); err == nil {
t.Fatal("combined compare accepted invalid flag lifecycle")
}
if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, validFlags, validFlags, CommandMigrationManifest{}, validCommands); err == nil {
t.Fatal("combined compare accepted invalid command lifecycle")
}
if commandMigrationAuthorizesChange(before, before, Change{Kind: "command_removed", Path: "dws unrelated"}, pending.Migrations) {
t.Fatal("unrelated command change was authorized")
}
}
func TestCrossPlatformCoverageCommandMigrationLifecycleAndExactFiltering(t *testing.T) {
before := commandMigrationSnapshot(false, false)
after := commandMigrationSnapshot(true, false)
pending := commandMigrationManifest(CommandMigrationPending)
consumed := commandMigrationManifest(CommandMigrationConsumed)
emptyFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
report, err := CompareAllWithInterfaceMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
emptyFlags,
emptyFlags,
pending,
consumed,
)
if err != nil {
t.Fatalf("governed command migration: %v", err)
}
if !report.Compatible {
t.Fatalf("exact command migration remained blocking: %#v", report.Comparisons)
}
unrelated := commandMigrationSnapshot(true, true)
report, err = CompareAllWithInterfaceMigrations(
unrelated,
map[string]Snapshot{"merge-base": before, "stable": before},
emptyFlags,
emptyFlags,
pending,
consumed,
)
if err != nil {
t.Fatal(err)
}
if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "flag_removed") {
t.Fatalf("unrelated flag removal was hidden: %#v", report.Comparisons)
}
emptyCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
if _, err := AuthorizeCommandMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
emptyCommands,
pending,
); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
t.Fatalf("candidate self-authorization error=%v", err)
}
partial := commandMigrationSnapshot(true, false)
partial.Commands = partial.Commands[:len(partial.Commands)-1]
if _, err := AuthorizeCommandMigrations(
partial,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
); err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("partial command migration error=%v", err)
}
}
func TestCrossPlatformCoverageFlagExtractionRequiresReplacementConstantEvidence(t *testing.T) {
before := commandMigrationSnapshot(false, false)
after := commandMigrationSnapshot(true, false)
pending := flagExtractionCommandMigrationManifest(CommandMigrationPending)
consumed := flagExtractionCommandMigrationManifest(CommandMigrationConsumed)
empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
migration := pending.Migrations[0]
if phase := matchCommandMigrationPhase(before, migration); phase != commandMigrationBefore {
t.Fatalf("before phase=%s, want %s", phase, commandMigrationBefore)
}
if phase := matchCommandMigrationPhase(after, migration); phase != commandMigrationAfter {
t.Fatalf("after phase=%s, want %s", phase, commandMigrationAfter)
}
for _, test := range []struct {
name string
values map[string]bool
}{
{name: "missing", values: nil},
{name: "wrong property", values: map[string]bool{"otherProperty": true}},
{name: "wrong value", values: map[string]bool{"convThreadEnabled": false}},
{name: "extra", values: map[string]bool{"convThreadEnabled": true, "otherProperty": true}},
} {
t.Run(test.name, func(t *testing.T) {
current := withCommandBoolConstParams(after, "dws chat topic create", test.values)
if phase := matchCommandMigrationPhase(current, migration); phase != commandMigrationPartial {
t.Fatalf("phase=%s, want %s", phase, commandMigrationPartial)
}
if _, err := AuthorizeCommandMigrations(
current,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
); err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("constant evidence lifecycle error=%v, want partial rejection", err)
}
if _, err := AuthorizeCommandMigrations(
current,
map[string]Snapshot{"merge-base": before, "stable": before},
empty,
pending,
); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
t.Fatalf("constant evidence self-authorization error=%v", err)
}
})
}
}
func commandMigrationManifestJSON() string {
return `{
"version": 1,
"migrations": [{
"kind": "command_move",
"legacy": {
"command": "dws chat message old",
"before": {"present": true, "runnable": true},
"after": {"present": true, "runnable": true, "hidden": true}
},
"replacement": {
"command": "dws chat topic new",
"before": {"present": false},
"after": {"present": true, "runnable": true}
},
"schema": {
"product_id": "chat",
"source_tool_id": "chat.old",
"replacement_tool_id": "chat.old",
"parameters": [{"from": "old-id", "to": "new-id"}]
},
"state": "pending",
"reason": "Reviewed command move."
}]
}`
}
func flagExtractionCommandMigrationManifestJSON() string {
return `{
"version": 1,
"migrations": [{
"kind": "flag_extraction",
"legacy": {
"command": "dws chat group create",
"before": {"present": true, "runnable": true},
"after": {"present": true, "runnable": true}
},
"replacement": {
"command": "dws chat topic create",
"before": {"present": false},
"after": {"present": true, "runnable": true}
},
"legacy_flag": {
"name": "thread",
"before": {"present": true, "type": "bool", "no_opt": "true", "scope": "local"},
"after": {"present": true, "type": "bool", "hidden": true, "no_opt": "true", "scope": "local"}
},
"schema": {
"product_id": "chat",
"source_tool_id": "chat.create_group",
"replacement_tool_id": "chat.create_topic",
"parameters": [
{"from":"name","to":"name"},
{"from":"type","to":"type"},
{"from":"users","to":"users"},
{"from":"thread","replacement_constant":{"property":"convThreadEnabled","value":true}}
]
},
"state": "pending",
"reason": "Reviewed flag extraction."
}]
}`
}
func commandMigrationManifest(state string) CommandMigrationManifest {
move := CommandMigration{
Kind: CommandMigrationMove,
Legacy: CommandMigrationSide{
Command: "dws chat message old",
Before: CommandMigrationState{Present: true, Runnable: true},
After: CommandMigrationState{Present: true, Runnable: true, Hidden: true},
},
Replacement: CommandMigrationSide{
Command: "dws chat topic new",
Before: CommandMigrationState{},
After: CommandMigrationState{Present: true, Runnable: true},
},
Schema: CommandMigrationSchema{
ProductID: "chat",
SourceToolID: "chat.old",
ReplacementToolID: "chat.old",
Parameters: []CommandParameterMigration{{From: "old-id", To: "new-id"}},
},
State: state,
Reason: "Reviewed command move.",
}
extraction := CommandMigration{
Kind: CommandMigrationFlagExtraction,
Legacy: CommandMigrationSide{
Command: "dws chat group create",
Before: CommandMigrationState{Present: true, Runnable: true},
After: CommandMigrationState{Present: true, Runnable: true},
},
Replacement: CommandMigrationSide{
Command: "dws chat topic create",
Before: CommandMigrationState{},
After: CommandMigrationState{Present: true, Runnable: true},
},
LegacyFlag: CommandMigrationFlag{
Name: "thread",
Before: FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"},
After: FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"},
},
Schema: CommandMigrationSchema{
ProductID: "chat",
SourceToolID: "chat.create_group",
ReplacementToolID: "chat.create_topic",
Parameters: []CommandParameterMigration{
{From: "name", To: "name"},
{From: "type", To: "type"},
{From: "users", To: "users"},
{
From: "thread",
ReplacementConstant: &CommandReplacementConstant{
Property: "convThreadEnabled",
Value: true,
},
},
},
},
State: state,
Reason: "Reviewed flag extraction.",
}
return CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{move, extraction}}
}
func flagExtractionCommandMigrationManifest(state string) CommandMigrationManifest {
manifest := commandMigrationManifest(state)
manifest.Migrations = manifest.Migrations[1:]
return manifest
}
func cloneCommandMigration(source CommandMigration) CommandMigration {
cloned := source
cloned.Schema.Parameters = append([]CommandParameterMigration(nil), source.Schema.Parameters...)
for index, parameter := range source.Schema.Parameters {
if parameter.ReplacementConstant == nil {
continue
}
constant := *parameter.ReplacementConstant
cloned.Schema.Parameters[index].ReplacementConstant = &constant
}
return cloned
}
func singleCommandMigrationManifest(state string) CommandMigrationManifest {
manifest := commandMigrationManifest(state)
manifest.Migrations = manifest.Migrations[:1]
return manifest
}
func modifiedCommandManifest(source CommandMigrationManifest) CommandMigrationManifest {
modified := source
modified.Migrations = append([]CommandMigration(nil), source.Migrations...)
modified.Migrations[0].Reason = "Modified reason."
return modified
}
func commandMigrationSnapshot(after, removeUnrelated bool) Snapshot {
oldFlags := []Flag{{Name: "old-id", Type: "string", Required: true}, {Name: "keep", Type: "string"}}
groupFlags := []Flag{{Name: "thread", Type: "bool", NoOpt: "true"}}
commands := []Command{
testCommand("dws"),
testCommand("dws chat group create", groupFlags...),
testCommand("dws chat message old", oldFlags...),
}
if after {
commands[1].LocalFlags[0].Hidden = true
commands[2].Hidden = true
if removeUnrelated {
commands[2].LocalFlags = commands[2].LocalFlags[:1]
}
topicCreate := testCommand("dws chat topic create")
topicCreate.BoolConstParams = map[string]bool{"convThreadEnabled": true}
commands = append(commands,
topicCreate,
testCommand("dws chat topic new", Flag{Name: "new-id", Type: "string", Required: true}),
)
}
return testSnapshot(commands...)
}
func withCommandBoolConstParams(snapshot Snapshot, path string, values map[string]bool) Snapshot {
cloned := snapshot
cloned.Commands = append([]Command(nil), snapshot.Commands...)
for index := range cloned.Commands {
if cloned.Commands[index].Path == path {
cloned.Commands[index].BoolConstParams = values
break
}
}
return cloned
}
-11
View File
@@ -71,9 +71,6 @@ func CompareAll(current Snapshot, references map[string]Snapshot) Report {
// alias),
// - flags accepted at each command path may not disappear, change type, or
// become required; an existing path may not gain a new required flag,
// - once bool ConstParams evidence exists, its exact property/value map is a
// durable contract; adding the first evidence to an older snapshot remains
// a silent bootstrap,
// - new commands and flags are allowed.
//
// The single exception to the type rule is an individually reviewed migration
@@ -156,14 +153,6 @@ func Compare(current, baseline Snapshot, reference string) Comparison {
}
func compareCommandContract(result *Comparison, acceptedPath string, oldCommand, newCommand Command) {
if len(oldCommand.BoolConstParams) > 0 && !reflect.DeepEqual(oldCommand.BoolConstParams, newCommand.BoolConstParams) {
result.Blocking = append(result.Blocking, Change{
Kind: "bool_const_params_changed",
Path: acceptedPath,
Before: fmt.Sprintf("%v", oldCommand.BoolConstParams),
After: fmt.Sprintf("%v", newCommand.BoolConstParams),
})
}
if oldCommand.Runnable && !newCommand.Runnable {
result.Blocking = append(result.Blocking, Change{
Kind: "command_became_non_runnable",
@@ -135,63 +135,6 @@ func TestCrossPlatformCoverageCompareAllowsRenameWhenOldPathIsAlias(t *testing.T
}
}
func TestCrossPlatformCoverageCompareAllowsBoolConstParamsBootstrapAddition(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommand("dws send"),
)
current := testSnapshot(
testCommand("dws"),
testCommand("dws send"),
)
current.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true}
comparison := Compare(current, base, "base")
if !comparison.Compatible || len(comparison.Blocking) != 0 || len(comparison.Additions) != 0 {
t.Fatalf("ordinary Compare treated bootstrapped bool ConstParams evidence as a compatibility change: %#v", comparison)
}
}
func TestCrossPlatformCoverageCompareBlocksBoolConstParamsDriftAfterBootstrap(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommand("dws send"),
)
base.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true}
for _, test := range []struct {
name string
current map[string]bool
}{
{name: "removed", current: nil},
{name: "value flipped", current: map[string]bool{"convThreadEnabled": false}},
{name: "extra key", current: map[string]bool{"convThreadEnabled": true, "other": false}},
} {
t.Run(test.name, func(t *testing.T) {
current := testSnapshot(
testCommand("dws"),
testCommand("dws send"),
)
current.Commands[1].BoolConstParams = test.current
comparison := Compare(current, base, "base")
if comparison.Compatible {
t.Fatalf("historical bool ConstParams drift was accepted: %#v", comparison)
}
if len(comparison.Blocking) != 1 {
t.Fatalf("blocking=%#v, want exactly one durable contract change", comparison.Blocking)
}
change := comparison.Blocking[0]
if change.Kind != "bool_const_params_changed" || change.Path != "dws send" {
t.Fatalf("blocking=%#v, want bool_const_params_changed at dws send", comparison.Blocking)
}
if len(comparison.Additions) != 0 {
t.Fatalf("bool ConstParams drift also produced additions: %#v", comparison.Additions)
}
})
}
}
func TestCrossPlatformCoverageCompareBlocksRemovedAlias(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
+17 -29
View File
@@ -91,32 +91,22 @@ func validateFlagMigrationJSONSchema(data []byte) error {
}
func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error {
return validateLabeledMigrationJSONValue(decoder, path, schema, "flag")
}
func validateLabeledMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type, label string) error {
token, err := decoder.Token()
if err != nil {
return fmt.Errorf("read %s migration manifest value at %s: %w", label, path, err)
}
for schema.Kind() == reflect.Pointer {
if token == nil {
return migrationJSONTypeError(path, schema.Elem(), token, label)
}
schema = schema.Elem()
return fmt.Errorf("read flag migration manifest value at %s: %w", path, err)
}
switch schema.Kind() {
case reflect.Struct:
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
return migrationJSONTypeError(path, schema, token, label)
return migrationJSONTypeError(path, schema, token)
}
fields := migrationJSONFields(schema)
seen := make(map[string]bool, len(fields))
for decoder.More() {
keyToken, keyErr := decoder.Token()
if keyErr != nil {
return fmt.Errorf("read %s migration manifest field at %s: %w", label, path, keyErr)
return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr)
}
// encoding/json guarantees object member names are string tokens.
key := keyToken.(string)
@@ -125,58 +115,57 @@ func validateLabeledMigrationJSONValue(decoder *json.Decoder, path string, schem
for canonical := range fields {
if strings.EqualFold(key, canonical) {
return fmt.Errorf(
"%s migration manifest contains non-canonical field %q at %s (want %q)",
label,
"flag migration manifest contains non-canonical field %q at %s (want %q)",
key,
path,
canonical,
)
}
}
return fmt.Errorf("%s migration manifest contains unknown field %q at %s", label, key, path)
return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path)
}
if seen[key] {
return fmt.Errorf("%s migration manifest contains duplicate field %q at %s", label, key, path)
return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path)
}
seen[key] = true
if err := validateLabeledMigrationJSONValue(decoder, path+"."+key, fieldSchema, label); err != nil {
if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil {
return err
}
}
if _, closeErr := decoder.Token(); closeErr != nil {
return fmt.Errorf("close %s migration manifest object at %s: %w", label, path, closeErr)
return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr)
}
return nil
case reflect.Slice:
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
return migrationJSONTypeError(path, schema, token, label)
return migrationJSONTypeError(path, schema, token)
}
for index := 0; decoder.More(); index++ {
if err := validateLabeledMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem(), label); err != nil {
if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil {
return err
}
}
if _, closeErr := decoder.Token(); closeErr != nil {
return fmt.Errorf("close %s migration manifest array at %s: %w", label, path, closeErr)
return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr)
}
return nil
case reflect.String:
if _, ok := token.(string); !ok {
return migrationJSONTypeError(path, schema, token, label)
return migrationJSONTypeError(path, schema, token)
}
return nil
case reflect.Int:
if _, ok := token.(json.Number); !ok {
return migrationJSONTypeError(path, schema, token, label)
return migrationJSONTypeError(path, schema, token)
}
return nil
case reflect.Bool:
if _, ok := token.(bool); !ok {
return migrationJSONTypeError(path, schema, token, label)
return migrationJSONTypeError(path, schema, token)
}
return nil
default:
return fmt.Errorf("%s migration manifest value at %s has unsupported Go schema type %s", label, path, schema)
return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema)
}
}
@@ -199,10 +188,9 @@ func migrationJSONFields(schema reflect.Type) map[string]reflect.Type {
return fields
}
func migrationJSONTypeError(path string, want reflect.Type, token json.Token, label string) error {
func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error {
return fmt.Errorf(
"%s migration manifest value at %s must be %s, got %s",
label,
"flag migration manifest value at %s must be %s, got %s",
path,
migrationJSONKindDescription(want),
migrationJSONTokenDescription(token),
+15 -22
View File
@@ -22,13 +22,12 @@ import (
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const SchemaVersion = 3
const SchemaVersion = 2
// FlagAliasOfAnnotation records a framework-originated reviewed relationship
// between a retained compatibility flag and an exact canonical Cobra flag.
@@ -67,14 +66,13 @@ type Snapshot struct {
// Command contains compatibility-relevant command metadata. Path always
// includes the root command name (for example, "dws chat message send").
type Command struct {
Path string `json:"path"`
Runnable bool `json:"runnable"`
Hidden bool `json:"hidden"`
Deprecated string `json:"deprecated,omitempty"`
Aliases []string `json:"aliases"`
LocalFlags []Flag `json:"local_flags"`
InheritedFlags []Flag `json:"inherited_flags"`
BoolConstParams map[string]bool `json:"bool_const_params,omitempty"`
Path string `json:"path"`
Runnable bool `json:"runnable"`
Hidden bool `json:"hidden"`
Deprecated string `json:"deprecated,omitempty"`
Aliases []string `json:"aliases"`
LocalFlags []Flag `json:"local_flags"`
InheritedFlags []Flag `json:"inherited_flags"`
}
// Flag contains the stable pflag contract visible at a command node.
@@ -128,16 +126,14 @@ func Capture(root *cobra.Command) Snapshot {
}
aliases = compactSorted(aliases)
boolConstParams := corecmd.InterfaceBoolConstParams(cmd)
snapshot.Commands = append(snapshot.Commands, Command{
Path: path,
Runnable: cmd.Runnable(),
Hidden: cmd.Hidden,
Deprecated: strings.TrimSpace(cmd.Deprecated),
Aliases: aliases,
LocalFlags: captureFlags(cmd.LocalFlags()),
InheritedFlags: captureFlags(cmd.InheritedFlags()),
BoolConstParams: boolConstParams,
Path: path,
Runnable: cmd.Runnable(),
Hidden: cmd.Hidden,
Deprecated: strings.TrimSpace(cmd.Deprecated),
Aliases: aliases,
LocalFlags: captureFlags(cmd.LocalFlags()),
InheritedFlags: captureFlags(cmd.InheritedFlags()),
})
children := append([]*cobra.Command(nil), cmd.Commands()...)
@@ -202,9 +198,6 @@ func (s Snapshot) Validate() error {
return fmt.Errorf("interface snapshot contains duplicate command path %q", command.Path)
}
seenCommands[command.Path] = true
if command.BoolConstParams != nil && len(command.BoolConstParams) == 0 {
return fmt.Errorf("command %q must omit empty bool_const_params", command.Path)
}
if err := validateFlags(command.Path, "local", command.LocalFlags); err != nil {
return err
}
+4 -94
View File
@@ -17,7 +17,6 @@ import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -99,105 +98,16 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin
func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
input := bytes.NewBufferString(`{
"schema_version": 3,
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
"commands": [],
"future_field": true
"schema_version": 2,
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
"commands": [],
"future_field": true
}`)
if _, err := Read(input); err == nil {
t.Fatal("Read accepted an unknown field")
}
}
func TestCrossPlatformCoverageCaptureRoundTripsFrameworkBoolConstParams(t *testing.T) {
root := &cobra.Command{Use: "dws"}
leaf := corecmd.New(corecmd.Spec{
Use: "send",
ConstParams: map[string]any{"convThreadEnabled": true, "precheckOnly": false},
Invoke: func(*corecmd.Ctx, map[string]any) error { return nil },
})
root.AddCommand(leaf)
snapshot := Capture(root)
got := commandIndex(snapshot)["dws send"].BoolConstParams
want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false}
if !reflect.DeepEqual(got, want) {
t.Fatalf("captured bool ConstParams = %#v, want %#v", got, want)
}
var encoded bytes.Buffer
if err := Write(&encoded, snapshot); err != nil {
t.Fatalf("Write: %v", err)
}
decoded, err := Read(bytes.NewReader(encoded.Bytes()))
if err != nil {
t.Fatalf("Read: %v", err)
}
if got := commandIndex(decoded)["dws send"].BoolConstParams; !reflect.DeepEqual(got, want) {
t.Fatalf("round-tripped bool ConstParams = %#v, want %#v", got, want)
}
}
func TestCrossPlatformCoverageCaptureIgnoresHandwrittenLegacyConstParamsAnnotations(t *testing.T) {
root := &cobra.Command{Use: "dws"}
direct := &cobra.Command{
Use: "direct",
Run: func(*cobra.Command, []string) {},
Annotations: map[string]string{
"dws.compat.bool_const_params": `{"forged":true}`,
"dws.compat.const_params_origin": "corecmd.const_params.v1",
},
}
businessConstructor := func() *cobra.Command {
payloadKey := strings.Join([]string{"dws.compat.bool_", "const_params"}, "")
originKey := strings.Join([]string{"dws.compat.const_", "params_origin"}, "")
origin := strings.Join([]string{"corecmd.const_", "params.v1"}, "")
return &cobra.Command{
Use: "business",
Run: func(*cobra.Command, []string) {},
Annotations: map[string]string{
payloadKey: `{"forged":true}`,
originKey: origin,
},
}
}
root.AddCommand(direct, businessConstructor())
snapshot := Capture(root)
if err := snapshot.Validate(); err != nil {
t.Fatalf("handwritten legacy annotations affected snapshot validity: %v", err)
}
for _, path := range []string{"dws direct", "dws business"} {
if got := commandIndex(snapshot)[path].BoolConstParams; got != nil {
t.Fatalf("%s forged bool ConstParams evidence = %#v", path, got)
}
}
var encoded bytes.Buffer
if err := Write(&encoded, snapshot); err != nil {
t.Fatalf("Write snapshot without forged evidence: %v", err)
}
}
func TestCrossPlatformCoverageReadRejectsMalformedBoolConstParams(t *testing.T) {
for _, payload := range []string{`{"fixed":"true"}`, `{}`} {
input := bytes.NewBufferString(`{
"schema_version": 3,
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
"commands": [{
"path": "dws send",
"runnable": true,
"aliases": [],
"local_flags": [],
"inherited_flags": [],
"bool_const_params": ` + payload + `
}]
}`)
if _, err := Read(input); err == nil {
t.Fatalf("Read accepted malformed bool_const_params %s", payload)
}
}
}
func TestCrossPlatformCoverageCaptureRoundTripsFrameworkFlagAlias(t *testing.T) {
if FlagAliasOfAnnotation != runtimeannotate.AnnotationFlagAliasOf {
t.Fatalf(
+3
View File
@@ -435,6 +435,7 @@ const (
exitCodeInternal = 5
exitCodeDiscovery = 6
exitCodePartial = 7 // partial_failure 专用(契约 §4;规划 WS2 第4项;B142 将在 errors 侧补同源常量)
exitCodeWait = 8 // wait 终态失败专用(--wait 观察到失败终态;与 partial 同为"仅新增专用码")
)
// subtypeConfirmationRequired 是门禁拦截的 failure 子类标记(契约规范 §2.4),
@@ -491,6 +492,8 @@ func exitCodeForErrorInfo(info *ErrorInfo) int {
return exitCodePermission
case "discovery":
return exitCodeDiscovery
case "wait":
return exitCodeWait
default:
return exitCodeInternal
}
+4 -2
View File
@@ -283,9 +283,11 @@ func (e *ErrorInfo) Validate() error {
}
// error.type is a wire-stable Agent branch key, not an open-ended label.
// Keep this set aligned with exitCodeForErrorInfo. "permission" is the
// compatibility projection for PAT failures (rc=4).
// compatibility projection for PAT failures (rc=4); "wait" is the wait
// phase's terminal-failure projection (rc=8, e.g. an approval observed
// REJECTED after --wait).
switch errorType {
case "api", "auth", "validation", "permission", "discovery", "internal":
case "api", "auth", "validation", "permission", "discovery", "internal", "wait":
default:
return fmt.Errorf("output: unsupported failure error.type %q", e.Type)
}
@@ -19,6 +19,7 @@ type forgedResult struct {
func (r forgedResult) Outcome() Outcome { return r.env.Outcome }
func (r forgedResult) ExitCode() int { return r.exit }
func (r forgedResult) Data() any { return r.env.Data }
func (r forgedResult) envelope() *Envelope { copy := r.env; return &copy }
type cloneNode struct {
+90
View File
@@ -19,6 +19,10 @@ import (
type CommandResult interface {
Outcome() Outcome
ExitCode() int
// Data returns the accepted payload (already deep-copied). The wait
// phase reads it to resolve the resource identifier an event stream
// correlates against.
Data() any
envelope() *Envelope
}
@@ -30,6 +34,7 @@ type commandResult struct {
func (r *commandResult) Outcome() Outcome { return r.env.Outcome }
func (r *commandResult) ExitCode() int { return r.exitCode }
func (r *commandResult) Data() any { return cloneResultData(r.env.Data) }
func (r *commandResult) envelope() *Envelope {
copy := cloneEnvelope(r.env)
return &copy
@@ -71,6 +76,91 @@ func Partial(data *PartialData, opts ...ResultOption) CommandResult {
return newCommandResult(OutcomePartialFailure, data, nil, opts...)
}
// WithMeta(meta *Meta) ResultOption was declared above; the two options below
// exist for the wait phase.
// WithErrorInfo replaces the error info of the envelope. Used when a wait
// phase closes an accepted result into failure: envelope invariant I3
// requires an error iff the outcome is failure.
func WithErrorInfo(info *ErrorInfo) ResultOption {
return ResultOption{apply: func(env *Envelope) {
if info != nil {
info = cloneErrorInfo(info)
}
env.Error = info
}}
}
// WithOperationTimedOut marks the envelope's async operation as timed out at
// the last observed state, preserving the declared id / next_command resume
// facts (契约规范 §2.2: 超时必须保持 State 真实值并置 TimedOut:true). A result
// without operation info keeps nil — the pending envelope invariant then fails
// at emission, surfacing the leaf bug instead of synthesizing fake resume
// facts.
func WithOperationTimedOut(state string) ResultOption {
return ResultOption{apply: func(env *Envelope) {
if env.Meta == nil || env.Meta.Operation == nil {
return
}
operation := *env.Meta.Operation
// A subscribe/poll that never observed a status still times out
// against the accepted pending result: keep the original state
// rather than wiping it to empty (pending requires operation.state).
if strings.TrimSpace(state) != "" {
operation.State = state
}
operation.TimedOut = true
env.Meta.Operation = &operation
}}
}
// WithOperationTerminalState closes the envelope's async operation at the observed
// terminal status (契约规范 §2.2: 终态封装必须同步 operation.state — a success or
// failure close that kept the acceptance-phase state would emit a
// self-contradicting envelope such as outcome=success with
// operation.state=processing). The declared id / next_command facts are kept
// as the operation identity, and timed_out is cleared: the §2.2 anti-spoof
// rule forbids a timed-out claim on an operation that reached a terminal
// state. A result without operation info is left untouched.
func WithOperationTerminalState(state string) ResultOption {
return ResultOption{apply: func(env *Envelope) {
if env.Meta == nil || env.Meta.Operation == nil {
return
}
operation := *env.Meta.Operation
if strings.TrimSpace(state) != "" {
operation.State = state
}
operation.TimedOut = false
env.Meta.Operation = &operation
}}
}
// WithOutcome rewraps an existing result with a new outcome, preserving data,
// meta, identity, and any error info (subject to the opts). The corecmd wait
// phase uses it to close an accepted result into its terminal (or timed-out
// pending) outcome; the exit code is re-derived from the new envelope.
func WithOutcome(result CommandResult, outcome Outcome, opts ...ResultOption) CommandResult {
env := *result.envelope()
for _, opt := range opts {
if opt.apply != nil {
opt.apply(&env)
}
}
env.Outcome = outcome
env.OK = outcome == OutcomeSuccess || outcome == OutcomePending
if outcome == OutcomeFailure {
// Invariant I3: data and error are mutually exclusive. Closing into
// failure replaces the accepted data with the failure error.
env.Data = nil
}
exitCode := ExitCodeForEnvelope(&env)
if env.Error != nil {
env.Error.ExitCode = exitCode
}
return &commandResult{env: env, exitCode: exitCode}
}
// Failure constructs an immutable typed failure result.
func Failure(info *ErrorInfo, opts ...ResultOption) CommandResult {
if info != nil {
+165
View File
@@ -0,0 +1,165 @@
// 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 output
import (
"strings"
"testing"
)
func pendingAcceptedResult() CommandResult {
return Pending(map[string]any{"id": "job-1"}, &OperationInfo{
ID: "job-1",
State: "NEW",
NextCommand: "dws wait-sample --id job-1",
})
}
func TestWithOutcomeClosesSuccessPreservingDataAndMeta(t *testing.T) {
result := WithOutcome(pendingAcceptedResult(), OutcomeSuccess)
if result.Outcome() != OutcomeSuccess || result.ExitCode() != 0 {
t.Fatalf("outcome=%s exit=%d", result.Outcome(), result.ExitCode())
}
env := result.envelope()
if env.Meta == nil || env.Meta.Operation == nil {
t.Fatal("operation info lost on success close")
}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
}
func TestWithOutcomeFailureDropsDataAndCarriesError(t *testing.T) {
result := WithOutcome(pendingAcceptedResult(), OutcomeFailure, WithErrorInfo(&ErrorInfo{
Type: "wait",
Subtype: "terminal_failure",
Message: "等待到达失败终态:REJECTED",
}))
if result.Outcome() != OutcomeFailure || result.ExitCode() != 8 {
t.Fatalf("outcome=%s exit=%d, want failure/8", result.Outcome(), result.ExitCode())
}
env := result.envelope()
if env.Data != nil {
t.Fatal("failure close must drop data (I3)")
}
if env.Error == nil || env.Error.Type != "wait" {
t.Fatal("failure close must carry error info (I3)")
}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
}
func TestWithOperationTimedOutMarksStateAndKeepsResumeFacts(t *testing.T) {
result := WithOutcome(pendingAcceptedResult(), OutcomePending, WithOperationTimedOut("RUNNING"))
env := result.envelope()
op := env.Meta.Operation
if op.State != "RUNNING" || !op.TimedOut || op.ID != "job-1" || op.NextCommand != "dws wait-sample --id job-1" {
t.Fatalf("operation=%+v", op)
}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
}
func TestWithOperationTimedOutEmptyStatePreservesExisting(t *testing.T) {
result := WithOutcome(pendingAcceptedResult(), OutcomePending, WithOperationTimedOut(""))
op := result.envelope().Meta.Operation
if op.State != "NEW" || !op.TimedOut {
t.Fatalf("operation=%+v, want original state kept and timed_out set", op)
}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
}
func TestWithOperationTimedOutWithoutOperationInfoLeavesEnvelopeUntouched(t *testing.T) {
// A result without operation info keeps nil — ValidateResult must then
// reject the pending envelope instead of the option synthesizing fake
// resume facts.
result := WithOutcome(Success(map[string]any{"ok": true}), OutcomePending, WithOperationTimedOut("RUNNING"))
env := result.envelope()
if env.Meta != nil && env.Meta.Operation != nil {
t.Fatalf("operation=%+v, want untouched", env.Meta.Operation)
}
err := ValidateResult(result)
if err == nil || !strings.Contains(err.Error(), "meta.operation") {
t.Fatalf("err=%v, want pending-requires-operation rejection", err)
}
}
func TestDataAccessorReturnsDeepCopy(t *testing.T) {
result := pendingAcceptedResult()
data, ok := result.Data().(map[string]any)
if !ok {
t.Fatalf("data=%#v", result.Data())
}
data["id"] = "mutated"
again := result.Data().(map[string]any)
if again["id"] != "job-1" {
t.Fatalf("Data() aliased internal state: %#v", again)
}
}
func TestWithOperationTerminalStateSyncsStateAndClearsTimedOut(t *testing.T) {
for _, tc := range []struct {
name string
outcome Outcome
}{
{"success", OutcomeSuccess},
{"failure", OutcomeFailure},
} {
t.Run(tc.name, func(t *testing.T) {
opts := []ResultOption{WithOperationTerminalState("COMPLETED")}
if tc.outcome == OutcomeFailure {
opts = append(opts, WithErrorInfo(&ErrorInfo{
Type: "wait", Subtype: "terminal_failure", Message: "等待到达失败终态:COMPLETED",
}))
}
result := WithOutcome(pendingAcceptedResult(), tc.outcome, opts...)
op := result.envelope().Meta.Operation
if op.State != "COMPLETED" || op.TimedOut {
t.Fatalf("operation=%+v, want terminal state synced and timed_out cleared", op)
}
if op.ID != "job-1" || op.NextCommand != "dws wait-sample --id job-1" {
t.Fatalf("operation=%+v, want resume identity facts preserved", op)
}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
})
}
}
func TestWithOperationTerminalStateBlankKeepsObservedState(t *testing.T) {
// A terminal observation that carries no status must keep the last known
// state rather than wipe it: operation.state may never be emptied by a
// close transition.
result := WithOutcome(pendingAcceptedResult(), OutcomeSuccess, WithOperationTerminalState(" "))
op := result.envelope().Meta.Operation
if op.State != "NEW" || op.TimedOut {
t.Fatalf("operation=%+v, want original state kept and timed_out cleared", op)
}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
}
func TestWithOperationTerminalStateWithoutOperationInfoLeavesEnvelopeUntouched(t *testing.T) {
result := WithOutcome(Success(map[string]any{"ok": true}), OutcomeSuccess, WithOperationTerminalState("COMPLETED"))
env := result.envelope()
if env.Meta != nil && env.Meta.Operation != nil {
t.Fatalf("operation=%+v, want untouched", env.Meta.Operation)
}
}
@@ -437,7 +437,7 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
}{
{
name: "user group mentions",
args: []string{"--identity", "user", "--group", "cid", "--text", "@" + fixtureCurrentDOpenID + " <@" + fixtureCurrentDOpenID2 + "> x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true},
},
{
@@ -505,69 +505,6 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
}
}
func TestCrossPlatformCoverageMessagesSendRejectsMismatchedCurrentUserMentions(t *testing.T) {
tests := []struct {
name string
body string
atIDs string
atAll bool
wantErr string
}{
{
name: "declared member missing from body",
body: "请查收",
atIDs: fixtureCurrentDOpenID,
wantErr: "必须在正文中使用对应",
},
{
name: "declared member only appears as a longer token prefix",
body: "@" + fixtureCurrentDOpenID + "_suffix 请查收",
atIDs: fixtureCurrentDOpenID,
wantErr: "必须在正文中使用对应",
},
{
name: "body member missing from declaration",
body: "<@" + fixtureCurrentDOpenID + "> 请查收",
wantErr: "必须同时通过 --at-open-dingtalk-ids 声明",
},
{
name: "body at-all missing from declaration",
body: "<@all> 请查收",
wantErr: "必须同时指定 --at-all",
},
{
name: "bare body member missing from declaration",
body: "@" + fixtureCurrentDOpenID + " 请查收",
wantErr: "必须同时通过 --at-open-dingtalk-ids 声明",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
args := []string{
"chat", "+messages-send", "--identity", "user",
"--group", "cid", "--markdown", tt.body, "--yes",
}
if tt.atIDs != "" {
args = append(args, "--at-open-dingtalk-ids", tt.atIDs)
}
if tt.atAll {
args = append(args, "--at-all")
}
root.SetArgs(args)
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %v, want %q", err, tt.wantErr)
}
if len(fake.calls) != 0 {
t.Fatalf("invalid mention contract reached lower service: %#v", fake.calls)
}
})
}
}
func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T) {
flags := append([]shortcut.Flag(nil), MessagesSend.Flags...)
for i := range flags {
-113
View File
@@ -585,119 +585,6 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileFlow(t *testing.T)
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserDirectLocalFileUsesTransportSpecificTargets(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("direct.bin", []byte("direct-file"), 0o600); err != nil {
t.Fatal(err)
}
uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Errorf("upload method = %s", r.Method)
}
if _, err := io.Copy(io.Discard, r.Body); err != nil {
t.Error(err)
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(uploadServer.Close)
tests := []struct {
name string
targetArgs []string
requestedType string
wantOpenID string
wantUserResolve bool
}{
{
name: "explicit open id file",
targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID},
requestedType: "file",
wantOpenID: fixtureCurrentDOpenID,
},
{
name: "resolved user audio",
targetArgs: []string{"--user", "user-id"},
requestedType: "audio",
wantOpenID: "D-resolved",
wantUserResolve: true,
},
{
name: "explicit open id video",
targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID2},
requestedType: "video",
wantOpenID: fixtureCurrentDOpenID2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"upload-key"}`,
"im/commit_conversation_file_upload": `{"result":{"dentryId":31,"spaceId":41}}`,
"chat/send_personal_message": `{"result":{"openMessageId":"sent-direct-file"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
args := []string{
"chat", "+messages-send", "--identity", "user",
"--msg-type", tt.requestedType,
"--file", "./direct.bin",
"--idempotency-key", "direct-" + tt.requestedType,
"--yes",
}
args = append(args, tt.targetArgs...)
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
callOffset := 0
if tt.wantUserResolve {
if len(fake.calls) == 0 || fake.calls[0].product != "contact" ||
fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("resolution call = %#v", fake.calls)
}
callOffset = 1
}
if len(fake.calls) != callOffset+3 {
t.Fatalf("calls = %#v, want resolution plus init, commit, send", fake.calls)
}
initCall := fake.calls[callOffset]
commitCall := fake.calls[callOffset+1]
sendCall := fake.calls[callOffset+2]
for _, call := range []larkAlignmentCall{initCall, commitCall} {
if call.product != "im" || call.args["openDingTalkId"] != tt.wantOpenID {
t.Fatalf("upload call = %#v, want openDingTalkId %q", call, tt.wantOpenID)
}
if _, exists := call.args["receiverOpenDingTalkId"]; exists {
t.Fatalf("send-only receiver field leaked into upload call: %#v", call.args)
}
}
if initCall.tool != "init_conversation_file_upload" ||
commitCall.tool != "commit_conversation_file_upload" {
t.Fatalf("upload calls = %#v, %#v", initCall, commitCall)
}
if sendCall.product != "chat" || sendCall.tool != "send_personal_message" ||
sendCall.args["receiverOpenDingTalkId"] != tt.wantOpenID {
t.Fatalf("send call = %#v, want receiverOpenDingTalkId %q", sendCall, tt.wantOpenID)
}
if _, exists := sendCall.args["openDingTalkId"]; exists {
t.Fatalf("upload-only target field leaked into send call: %#v", sendCall.args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["requestedMessageType"] != tt.requestedType ||
payload["effectiveMessageType"] != "file" {
t.Fatalf("output = %#v", payload)
}
})
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("fixture.bin", []byte("x"), 0o600); err != nil {
@@ -10,7 +10,6 @@ import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
@@ -338,15 +337,8 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te
}
}
if tt.bodyKey == "content" {
rawContent := call.args[tt.bodyKey].(string)
if strings.Contains(rawContent, `\u003c`) || strings.Contains(rawContent, `\u003e`) {
t.Errorf("content = %q; current-user mention tokens must remain literal", rawContent)
}
if !strings.Contains(rawContent, "<@"+fixtureCurrentDOpenID+">") {
t.Errorf("content = %q; missing literal current-user mention token", rawContent)
}
var content map[string]string
if err := json.Unmarshal([]byte(rawContent), &content); err != nil {
if err := json.Unmarshal([]byte(call.args[tt.bodyKey].(string)), &content); err != nil {
t.Fatal(err)
}
if content["text"] != tt.body {
+5 -107
View File
@@ -11,7 +11,6 @@ import (
"os"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -19,7 +18,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
@@ -183,12 +181,6 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数")
}
if (group != "" || chatQuery != "") && (contentType == "text" || contentType == "markdown") {
if err := validateCurrentUserMentionConsistency(
messagesSendBody(rt), atOpenIDs, rt.Bool("at-all")); err != nil {
return err
}
}
case "bot":
if chatQuery != "" || userQuery != "" {
return apperrors.NewValidation("--identity bot 当前不接受 --chat-query 或 --user-query;请传真实群 ID 或批量用户 ID")
@@ -242,89 +234,6 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
return nil
}
func validateCurrentUserMentionConsistency(body string, atOpenIDs []string, atAll bool) error {
declared := make(map[string]struct{}, len(atOpenIDs))
for _, id := range atOpenIDs {
declared[id] = struct{}{}
}
missingPlaceholders := make([]string, 0)
for _, id := range atOpenIDs {
if !containsCurrentUserMentionToken(body, id) {
missingPlaceholders = append(missingPlaceholders, id)
}
}
if len(missingPlaceholders) > 0 {
return apperrors.NewValidation(fmt.Sprintf(
"--at-open-dingtalk-ids 中的成员必须在正文中使用对应 <@openDingTalkId> 占位符;缺少: %s",
strings.Join(missingPlaceholders, ","),
))
}
undeclared := make([]string, 0)
for _, id := range currentUserMentionBodyIDs(body) {
if _, ok := declared[id]; !ok {
undeclared = append(undeclared, id)
}
}
if len(undeclared) > 0 {
return apperrors.NewValidation(fmt.Sprintf(
"正文中的成员 <@openDingTalkId> 占位符必须同时通过 --at-open-dingtalk-ids 声明;未声明: %s",
strings.Join(undeclared, ","),
))
}
if !atAll && containsCurrentUserMentionToken(body, "all") {
return apperrors.NewValidation("正文中的 <@all> 占位符必须同时指定 --at-all")
}
return nil
}
func containsCurrentUserMentionToken(body, id string) bool {
placeholder := "@" + id
for searchFrom := 0; ; {
offset := strings.Index(body[searchFrom:], placeholder)
if offset < 0 {
return false
}
end := searchFrom + offset + len(placeholder)
if end == len(body) {
return true
}
next, _ := utf8.DecodeRuneInString(body[end:])
if !unicode.IsLetter(next) && !unicode.IsDigit(next) && next != '_' && next != '-' {
return true
}
searchFrom = end
}
}
func currentUserMentionBodyIDs(body string) []string {
ids := make([]string, 0)
for searchFrom := 0; ; {
offset := strings.IndexByte(body[searchFrom:], '@')
if offset < 0 {
break
}
start := searchFrom + offset + 1
end := start
for end < len(body) {
current := body[end]
if (current < 'a' || current > 'z') &&
(current < 'A' || current > 'Z') &&
(current < '0' || current > '9') {
break
}
end++
}
id := body[start:end]
if targetresolver.LooksLikeCurrentDOpenDingTalkID(id) {
ids = appendUniqueShortcutString(ids, id)
}
searchFrom = end
}
return ids
}
func executeMessagesSend(rt *shortcut.RuntimeContext) error {
identity := messagesSendIdentity(rt)
body := messagesSendBody(rt)
@@ -590,7 +499,7 @@ func resolvedUserMarkdownParams(
if target.GroupID != "" {
body = helpers.NormalizeMessageMentions(body, atOpenIDs, atAll, true)
}
content, _ := jsonutil.Marshal(map[string]string{"title": title, "text": body})
content, _ := json.Marshal(map[string]string{"title": title, "text": body})
params := rt.AddAIMessageTag(map[string]any{
"msgType": "markdown",
"content": string(content),
@@ -664,10 +573,8 @@ func executeMessagesSendUserFile(
if err != nil {
return err
}
uploadTargetArgs := map[string]any{}
addMessagesSendUserUploadTarget(uploadTargetArgs, group, openID)
sendTargetArgs := map[string]any{}
addMessagesSendUserTarget(sendTargetArgs, group, openID)
targetArgs := map[string]any{}
addMessagesSendUserTarget(targetArgs, group, openID)
idempotencyKey := messagesSendIdempotencyKey(rt)
if rt.DryRun() {
return rt.Output(map[string]any{
@@ -680,7 +587,6 @@ func executeMessagesSendUserFile(
{
"identity": "user",
"tool": "init/commit_conversation_file_upload",
"target": uploadTargetArgs,
"file": map[string]any{
"path": rawPath,
"name": meta.FileName,
@@ -692,7 +598,7 @@ func executeMessagesSendUserFile(
"tool": "send_personal_message",
"requestedMessageType": requestedType,
"effectiveMessageType": "file",
"target": sendTargetArgs,
"target": targetArgs,
},
},
})
@@ -701,7 +607,7 @@ func executeMessagesSendUserFile(
rt.Command().Context(), messagesSendFileUploadTimeout)
defer cancelUpload()
commitText, err := helpers.UploadConversationLocalFile(
uploadContext, uploadTargetArgs, meta, idempotencyKey)
uploadContext, targetArgs, meta, idempotencyKey)
if err != nil {
return err
}
@@ -747,14 +653,6 @@ func addMessagesSendUserTarget(params map[string]any, group, openID string) {
params["receiverOpenDingTalkId"] = openID
}
func addMessagesSendUserUploadTarget(params map[string]any, group, openID string) {
if group != "" {
params["openConversationId"] = group
return
}
params["openDingTalkId"] = openID
}
func nonEmptyStringCount(values ...string) int {
count := 0
for _, value := range values {
-521
View File
@@ -1,521 +0,0 @@
package upgrade
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{
".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills",
})
for _, parent := range []string{".codex", ".claude", ".openclaw"} {
if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil {
t.Fatal(err)
}
}
// beta.6 wrote a physical copy into Codex. The canonical migration must
// remove it without treating a matching user/market name as DWS-owned.
oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
t.Fatal(err)
}
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared")
result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"})
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("upgrade = %#v, %v", result, err)
}
canonical := filepath.Join(home, ".agents", "skills")
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
t.Fatalf("canonical %s missing: %v", name, err)
}
if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", name)); !os.IsNotExist(err) {
t.Fatalf("universal Codex duplicate remains for %s: %v", name, err)
}
for _, agent := range []string{".claude", ".openclaw"} {
link := filepath.Join(home, agent, "skills", name)
info, err := os.Lstat(link)
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("%s link = %#v, %v", link, info, err)
}
if _, err := os.Stat(filepath.Join(link, "SKILL.md")); err != nil {
t.Fatalf("%s does not resolve canonical content: %v", link, err)
}
}
}
result, err = UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"})
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("idempotent upgrade = %#v, %v", result, err)
}
for _, agent := range []string{".claude", ".openclaw"} {
info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat"))
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("idempotent upgrade replaced %s link: %#v, %v", agent, info, err)
}
}
}
func TestCrossPlatformCoverageCanonicalCustomRootsSymlinkedParentsAndLexicalVictims(t *testing.T) {
home := withFakeHome(t)
customCodex := filepath.Join(t.TempDir(), "codex")
customHermes := filepath.Join(t.TempDir(), "hermes")
externalClaude := filepath.Join(t.TempDir(), "claude")
for _, root := range []string{customCodex, customHermes, externalClaude, filepath.Join(home, ".clawdbot"), filepath.Join(home, ".codeium", "windsurf")} {
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink(externalClaude, filepath.Join(home, ".claude")); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &upgradeGetenv, func(name string) string {
switch name {
case "CODEX_HOME":
return customCodex
case "HERMES_HOME":
return customHermes
default:
return ""
}
})
oldCodex := filepath.Join(customCodex, "skills", "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
t.Fatal(err)
}
claudeBase := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(claudeBase, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(claudeBase, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("missing-target", filepath.Join(claudeBase, "dingtalk-shared")); err != nil {
t.Fatal(err)
}
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared")
result, err := UpgradeSkillLocations(multiRoot)
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("upgrade = %#v, %v", result, err)
}
if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) {
t.Fatalf("custom CODEX_HOME duplicate remains: %v", err)
}
for _, base := range []string{
claudeBase,
filepath.Join(customHermes, "skills"),
filepath.Join(home, ".clawdbot", "skills"),
filepath.Join(home, ".codeium", "windsurf", "skills"),
} {
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
path := filepath.Join(base, name)
info, statErr := os.Lstat(path)
if statErr != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("expected canonical link %s: %#v, %v", path, info, statErr)
}
body, readErr := os.ReadFile(filepath.Join(path, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), name) {
t.Fatalf("link %s does not resolve: %q, %v", path, body, readErr)
}
}
}
}
func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("links unavailable") })
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat")
result, err := UpgradeSkillLocations(multiRoot)
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("upgrade = %#v, %v", result, err)
}
dest := filepath.Join(home, ".claude", "skills", "dingtalk-chat")
info, err := os.Lstat(dest)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("copy fallback = %#v, %v", info, err)
}
if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil {
t.Fatalf("copy fallback content missing: %v", err)
}
}
func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) {
// Keep the destination HOME synthetic: app-bundle detection is deliberately
// machine-scoped and must not depend on the selected installation HOME.
home := t.TempDir()
testseam.Swap(t, &upgradeGetenv, func(string) string { return "" })
for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
sentinel := filepath.Join(home, "app-sentinel")
if err := os.MkdirAll(sentinel, 0o755); err != nil {
t.Fatal(err)
}
appInfo, err := os.Stat(sentinel)
if err != nil {
t.Fatal(err)
}
zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")
minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")
originalStat := upgradeStat
testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
if path == zcodeApp || path == minimaxApp {
return appInfo, nil
}
return originalStat(path)
})
want := map[string]bool{
".config/kimchi/harness/skills": false,
".tabnine/agent/skills": false,
".zcode/skills": false,
".minimax/skills": false,
}
for _, root := range configuredSkillRoots(home) {
label := filepath.ToSlash(root.label)
if _, ok := want[label]; ok {
want[label] = skillRootDetectedBase(root)
}
}
for label, detected := range want {
if !detected {
t.Errorf("root %s was not detected", label)
}
}
}
func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t *testing.T) {
home := t.TempDir()
custom := map[string]string{
"AUTOHAND_HOME": filepath.Join(home, "custom-autohand"),
"CLAUDE_CONFIG_DIR": filepath.Join(home, "custom-claude"),
"CODEX_HOME": filepath.Join(home, "custom-codex"),
"GROK_HOME": filepath.Join(home, "custom-grok"),
"HERMES_HOME": filepath.Join(home, "custom-hermes"),
"VIBE_HOME": filepath.Join(home, "custom-vibe"),
"XDG_CONFIG_HOME": filepath.Join(home, "custom-xdg"),
}
testseam.Swap(t, &upgradeGetenv, func(name string) string { return custom[name] })
roots := configuredSkillRoots(home)
if len(roots) != len(knownSkillDirs) {
t.Fatalf("configured unique roots = %d, want %d", len(roots), len(knownSkillDirs))
}
byLabel := make(map[string]resolvedSkillRoot, len(roots))
for _, root := range roots {
if _, duplicate := byLabel[root.label]; duplicate {
t.Fatalf("duplicate configured label %s", root.label)
}
byLabel[root.label] = root
}
wants := map[string]string{
".autohand/skills": filepath.Join(custom["AUTOHAND_HOME"], "skills"),
".claude/skills": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"),
".codex/skills": filepath.Join(custom["CODEX_HOME"], "skills"),
".grok/skills": filepath.Join(custom["GROK_HOME"], "skills"),
".hermes/skills": filepath.Join(custom["HERMES_HOME"], "skills"),
".vibe/skills": filepath.Join(custom["VIBE_HOME"], "skills"),
".config/agents/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
".config/crush/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"),
".config/devin/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"),
".config/goose/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"),
".config/kimchi/harness/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"),
".config/opencode/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"),
}
for label, want := range wants {
if got := byLabel[label].base; filepath.Clean(got) != filepath.Clean(want) {
t.Errorf("configured %s = %q, want %q", label, got, want)
}
}
for _, label := range []string{
".config/agents/skills", ".gemini/antigravity/skills", ".gemini/antigravity-cli/skills",
".codex/skills", ".cursor/skills", ".deepagents/agent/skills", ".firebender/skills",
".gemini/skills", ".copilot/skills", ".config/opencode/skills",
} {
if !byLabel[label].universal {
t.Errorf("%s not classified universal", label)
}
}
if !byLabel[".agents/skills"].canonical || byLabel[".claude/skills"].universal {
t.Fatal("canonical/non-universal classification drift")
}
}
func TestCrossPlatformCoverageUpgradeCanonicalFailureFailsFastUnconditionally(t *testing.T) {
for _, mode := range []string{"mono", "multi"} {
t.Run(mode, func(t *testing.T) {
withFakeHome(t)
// Canonical-only configuration (no non-universal dependents): a failed
// canonical publish must still fail the whole upgrade. Universal agents
// read canonical directly, so silently succeeding here would leave the
// machine without any usable Skill while reporting success.
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"})
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("canonical denied") })
src := t.TempDir()
if mode == "mono" {
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
} else {
src = writeMultiBundle(t, src, "dingtalk-chat")
}
result, err := UpgradeSkillLocations(src)
if err == nil || !strings.Contains(err.Error(), "canonical Skill 安装失败") {
t.Fatalf("canonical publish failure must propagate an error, got (%v)", err)
}
if result == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("result = %#v", result)
}
})
}
}
func TestCrossPlatformCoverageUpgradeOpenClawAliasPriority(t *testing.T) {
for _, tc := range []struct {
name string
dirs []string
want string
}{
{name: "default", want: ".openclaw"},
{name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"},
{name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"},
{name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"},
} {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
for _, dir := range tc.dirs {
if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil {
t.Fatal(err)
}
}
if got := resolveOpenClawSkillBase(home); got != filepath.Join(home, tc.want, "skills") {
t.Fatalf("OpenClaw root = %q", got)
}
})
}
}
func TestCrossPlatformCoverageUpgradeLinkStagingFailureEdges(t *testing.T) {
home := t.TempDir()
canonical := filepath.Join(home, ".agents", "skills")
dest := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(filepath.Join(canonical, "dingtalk-chat"), 0o755); err != nil {
t.Fatal(err)
}
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") })
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "创建 Agent Skill 目录") {
t.Fatalf("mkdir failure = %v", err)
}
})
t.Run("temp", func(t *testing.T) {
testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", errors.New("temp denied") })
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "链接 staging") {
t.Fatalf("temp failure = %v", err)
}
})
t.Run("physical-parent", func(t *testing.T) {
testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) {
if path == dest {
return "", errors.New("physical denied")
}
return filepath.EvalSymlinks(path)
})
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "物理目录") {
t.Fatalf("physical failure = %v", err)
}
})
t.Run("canonical-target", func(t *testing.T) {
target := filepath.Join(canonical, "dingtalk-chat")
testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) {
if path == target {
return "", errors.New("canonical denied")
}
return filepath.EvalSymlinks(path)
})
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "解析 canonical") {
t.Fatalf("canonical failure = %v", err)
}
})
t.Run("relative-path", func(t *testing.T) {
testseam.Swap(t, &upgradeRel, func(string, string) (string, error) { return "", errors.New("relative denied") })
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "相对链接") {
t.Fatalf("relative failure = %v", err)
}
})
}
func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) {
for _, mode := range []string{"mono", "multi"} {
t.Run(mode, func(t *testing.T) {
makeSource := func(t *testing.T) string {
t.Helper()
src := t.TempDir()
if mode == "mono" {
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
return src
}
return writeMultiBundle(t, src, "dingtalk-chat")
}
t.Run("undetected-and-physical-alias", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".qoder/skills"})
if err := os.MkdirAll(filepath.Join(home, ".qoder"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(home, ".agents", "skills"), filepath.Join(home, ".qoder", "skills")); err != nil {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil {
t.Fatal(err)
}
if len(result.Results) < 3 || result.Results[1].Status != SkillDirSkipped || result.Results[2].Status != SkillDirSkipped {
t.Fatalf("skip branches = %#v", result.Results)
}
})
t.Run("universal-retire-failure", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"})
victimBase := filepath.Join(home, ".cursor", "skills")
victim := filepath.Join(victimBase, "dws")
if mode == "multi" {
victim = filepath.Join(victimBase, "dingtalk-chat")
}
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
if mode == "multi" {
useUpgradeManagedNames(t, "dingtalk-chat")
}
origRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
if src == victim {
return "", errors.New("retire denied")
}
return origRename(src, dst)
})
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil {
t.Fatalf("retire failure must not fail the upgrade: %v", err)
}
if len(result.Failed()) != 0 {
t.Fatalf("retire failure must not count as an install failure: %#v", result.Failed())
}
warnings := result.RetireWarnings()
if len(warnings) != 1 || warnings[0].Err == nil {
t.Fatalf("retire warnings = %#v", warnings)
}
if len(result.Succeeded()) == 0 {
t.Fatalf("canonical must still be published: %#v", result)
}
if _, statErr := os.Lstat(victim); statErr != nil {
t.Fatalf("unretired copy must be preserved: %v", statErr)
}
})
t.Run("universal-retire-success", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"})
victimBase := filepath.Join(home, ".cursor", "skills")
victim := filepath.Join(victimBase, "dws")
if mode == "multi" {
victim = filepath.Join(victimBase, "dingtalk-chat")
useUpgradeManagedNames(t, "dingtalk-chat")
}
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("retire success = %#v, %v", result, err)
}
if _, err := os.Lstat(victim); !os.IsNotExist(err) {
t.Fatalf("universal victim remains: %v", err)
}
})
t.Run("victim-scan-and-fallback-copy-failures", func(t *testing.T) {
t.Run("victim-scan", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
base := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatal(err)
}
origReadDir := upgradeReadDir
testseam.Swap(t, &upgradeReadDir, func(path string) ([]os.DirEntry, error) {
if path == base {
return nil, errors.New("scan denied")
}
return origReadDir(path)
})
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil || len(result.Failed()) != 1 {
t.Fatalf("victim scan = %#v, %v", result, err)
}
})
t.Run("fallback-copy", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
base := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("link denied") })
origCopy := upgradeCopyDir
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
if strings.HasPrefix(filepath.Clean(dst), filepath.Clean(base)+string(filepath.Separator)) {
return errors.New("copy denied")
}
return origCopy(src, dst)
})
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil || len(result.Failed()) != 1 {
t.Fatalf("fallback copy = %#v, %v", result, err)
}
})
})
})
}
}
func TestCrossPlatformCoverageUpgradeWindowsPathNormalizationAndRootDedup(t *testing.T) {
testseam.Swap(t, &upgradeFoldPathCase, true)
if got := skillRootPathKey(filepath.Join("Root", "Skills")); got != strings.ToLower(got) {
t.Fatalf("case-folded path key = %q", got)
}
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".claude/skills"})
if roots := configuredSkillRoots(t.TempDir()); len(roots) != 2 {
t.Fatalf("deduplicated roots = %#v", roots)
}
}
+4 -7
View File
@@ -264,7 +264,7 @@ func TestCrossPlatformCoverageUpgradePathsAndSkillsEdges(t *testing.T) {
}
knownSkillDirs = []string{".agents/skills", ".real/skills", ".missing/skills", ".present/skills"}
result, err := UpgradeSkillLocations(source)
if err != nil || len(result.Succeeded()) != 2 || len(result.Failed()) != 0 {
if err != nil || len(result.Succeeded()) != 1 || len(result.Failed()) != 0 {
t.Fatalf("skill upgrade = %#v, %v", result, err)
}
knownSkillDirs = []string{".real/skills"}
@@ -579,10 +579,7 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) {
})
home := t.TempDir()
upgradeUserHomeDir = func() (string, error) { return home, nil }
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
knownSkillDirs = []string{".agents/skills"}
calls := 0
upgradeCopyDir = func(src, dst string) error {
calls++
@@ -596,8 +593,8 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(source)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical failure = %#v, %v", result, err)
if err != nil || len(result.Succeeded()) != 1 || len(result.Results) != 1 {
t.Fatalf("replaced primary fallback = %#v, %v", result, err)
}
failure := errors.New("ensure failed")
upgradeEnsureDir = func(string, os.FileMode) error { return failure }
+210 -523
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
@@ -36,133 +35,46 @@ const (
// - test/scripts/package_script_test.go expectedPackagedSkillTargets
// - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS
//
// The first entry (.agents/skills) is the canonical global Skill directory,
// following the universal .agents/skills convention.
// Agents that understand the universal .agents convention read it directly;
// detected non-universal Agents receive relative links to this canonical
// copy (with a direct-copy fallback when links are unavailable).
// The first entry (.agents/skills) is a generic fallback. It is used only
// when no concrete Agent home is detected; otherwise publishing there would
// duplicate every Skill for Agents (including Codex) that scan both roots.
// Subsequent entries are updated when their parent directory already exists.
var knownSkillDirs = []string{
".agents/skills",
".config/agents/skills",
".gemini/antigravity/skills",
".gemini/antigravity-cli/skills",
".deepagents/agent/skills",
".firebender/skills",
".copilot/skills",
".config/opencode/skills",
".aider-desk/skills",
".astrbot/data/skills",
".autohand/skills",
".augment/skills",
".bob/skills",
".claude/skills",
".openclaw/skills",
".codeartsdoer/skills",
".codebuddy/skills",
".codemaker/skills",
".codestudio/skills",
".commandcode/skills",
".continue/skills",
".snowflake/cortex/skills",
".config/crush/skills",
".config/devin/skills",
".factory/skills",
".forge/skills",
".config/goose/skills",
".grok/skills",
".hermes/skills",
".inferencesh/skills",
".jazz/skills",
".junie/skills",
".iflow/skills",
".kilocode/skills",
".config/kimchi/harness/skills",
".kiro/skills",
".kode/skills",
".lingma/skills",
".mcpjam/skills",
".minimax/skills",
".vibe/skills",
".moxby/skills",
".mux/skills",
".openhands/skills",
".ona/skills",
".pi/agent/skills",
".qoder/skills",
".qoder-cn/skills",
".qwen/skills",
".reasonix/skills",
".rovodev/skills",
".roo/skills",
".tabnine/agent/skills",
".terramind/skills",
".tinycloud/skills",
".trae/skills",
".trae-cn/skills",
".codeium/windsurf/skills",
".zcode/skills",
".zencoder/skills",
".neovate/skills",
".pochi/skills",
".adal/skills",
// DWS-only target retained for product compatibility.
".qoderwork/skills",
// beta.6 compatibility roots: cleanup only, never publish new copies.
".cursor/skills",
".qoder/skills",
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".zcode/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
".cline/skills",
".amp/skills",
}
// universalSkillDirs are concrete Agent directories whose current clients
// natively scan ~/.agents/skills. Publishing another copy or link below these
// roots makes the same Skill appear twice. Keep this classification aligned
// with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally
// non-universal because it is a DWS-specific integration absent upstream.
var universalSkillDirs = map[string]bool{
".config/agents/skills": true,
".gemini/antigravity/skills": true,
".gemini/antigravity-cli/skills": true,
".codex/skills": true,
".cursor/skills": true,
".deepagents/agent/skills": true,
".firebender/skills": true,
".gemini/skills": true,
".copilot/skills": true,
".config/opencode/skills": true,
// beta.6 compatibility roots are cleanup-only as well.
".github/skills": true,
".windsurf/skills": true,
".cline/skills": true,
".amp/skills": true,
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
}
var (
upgradeUserHomeDir = os.UserHomeDir
upgradeExecutable = os.Executable
upgradeEvalSymlinks = filepath.EvalSymlinks
upgradeCopyDir = copyDir
upgradeEnsureDir = ensureDir
upgradeRemoveAll = os.RemoveAll
upgradeMkdirAll = os.MkdirAll
upgradeMkdirTemp = os.MkdirTemp
upgradeReadDir = os.ReadDir
upgradeStat = os.Stat
upgradeLstat = os.Lstat
upgradeSymlink = os.Symlink
upgradeRel = filepath.Rel
upgradeGetenv = os.Getenv
upgradeBuildProvenance = skillprovenance.Build
upgradeReadSkillState = skillstate.Read
upgradePublishSkillPath = PublishSkillPathNoReplace
upgradeRollbackPublishedSkillPaths = RollbackSkillPathPublications
upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") }
upgradeWriteSkillState = skillstate.Write
upgradeNow = time.Now
upgradeFoldPathCase = runtime.GOOS == "windows"
upgradeUserHomeDir = os.UserHomeDir
upgradeExecutable = os.Executable
upgradeEvalSymlinks = filepath.EvalSymlinks
upgradeCopyDir = copyDir
upgradeEnsureDir = ensureDir
upgradeRemoveAll = os.RemoveAll
upgradeMkdirAll = os.MkdirAll
upgradeMkdirTemp = os.MkdirTemp
upgradeReadDir = os.ReadDir
upgradeStat = os.Stat
upgradeBuildProvenance = skillprovenance.Build
upgradeReadSkillState = skillstate.Read
upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") }
upgradeWriteSkillState = skillstate.Write
upgradeNow = time.Now
)
// skillBackupSubdir is the user-level directory where skill directories are
@@ -173,64 +85,43 @@ const skillBackupSubdir = ".dws/skill-backups"
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
// <stamp>/<rel> instead of destroying it, and returns the backup path. It is
// fail-safe: a path that cannot be backed up is NOT removed and the
// fail-safe: a directory that cannot be backed up is NOT removed and the
// error is returned so the caller can surface it (and never install the
// opposite layout next to it silently). Missing paths are no-ops. Directories,
// links (including dangling links), and ordinary files are all preserved:
// an unexpected file at a managed destination must never be overwritten.
// opposite layout next to it silently). Missing paths and regular files are
// no-ops ("", nil).
func backupAndRemoveSkillDir(homeDir, dir string) (string, error) {
_, err := upgradeLstat(dir)
info, err := upgradeStat(dir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err)
}
if !info.IsDir() {
return "", nil
}
rel, relErr := filepath.Rel(homeDir, dir)
if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") {
rel = filepath.Base(dir)
}
name := strings.NewReplacer(string(filepath.Separator), "-", "/", "-").Replace(rel)
stamp := upgradeBackupStamp()
if err := upgradeMkdirAll(filepath.Join(homeDir, skillBackupSubdir), dirPermShared); err != nil {
return "", fmt.Errorf("创建备份根目录失败 %s: %w", filepath.Join(homeDir, skillBackupSubdir), err)
}
var backupRoot, target string
for i := 0; ; i++ {
candidate := stamp
if i > 0 {
candidate = fmt.Sprintf("%s-%d", stamp, i)
}
if i > 1000 {
return "", fmt.Errorf("备份目录冲突无法解决: %s", filepath.Join(homeDir, skillBackupSubdir, candidate))
}
backupRoot = filepath.Join(homeDir, skillBackupSubdir, candidate)
target = filepath.Join(backupRoot, name)
if _, err := upgradeLstat(target); err == nil {
continue // payload name already taken in this stamp root
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("检查备份目标失败 %s: %w", target, err)
}
if err := skillPathMkdir(backupRoot, dirPermShared); err == nil {
// We created the root: stamp ownership before any payload moves
// in, so an interrupted backup never leaves an unmarked
// (never-prunable) stamp behind.
if err := skillBackupWriteMarker(backupRoot); err != nil {
// Bounded cleanup: the root is empty and ours alone.
_ = skillPathRemove(backupRoot)
return "", fmt.Errorf("写入备份所有权标记失败 %s: %w", backupRoot, err)
}
backupRoot := filepath.Join(homeDir, skillBackupSubdir, stamp)
target := filepath.Join(backupRoot, name)
for i := 1; ; i++ {
if _, err := os.Stat(target); os.IsNotExist(err) {
break
} else if !os.IsExist(err) {
return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err)
} else if skillBackupMarkerValid(backupRoot) {
break // proven ours: same-stamp payloads from this run reuse it
}
// A stamp-shaped directory we cannot prove ours must never be
// adopted — writing the marker or the payload into it would turn
// its foreign contents into prunable DWS backups.
backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i))
target = filepath.Join(backupRoot, name)
if i > 1000 {
return "", fmt.Errorf("备份目录冲突无法解决: %s", target)
}
}
if err := moveSkillPathRecoverably(dir, target); err != nil {
if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil {
return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err)
}
if err := upgradeRename(dir, target); err != nil {
return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err)
}
// Keep the backup directory bounded; a prune failure must not fail the
@@ -260,17 +151,13 @@ const (
SkillDirSkipped // agent not detected, directory skipped
SkillDirBlacklisted // blacklisted, never touched
SkillDirFailed // installation attempted but failed
// SkillDirRetireWarning marks a universal Agent whose obsolete private copy
// could not be retired. Nothing is installed below such a root, so the
// leftover is reported without counting as an install failure.
SkillDirRetireWarning
)
// SkillDirResult holds the per-directory install result.
type SkillDirResult struct {
Dir string // destination directory (e.g. ~/.claude/skills/dws)
Status SkillDirStatus // outcome
Err error // non-nil when Status == SkillDirFailed or SkillDirRetireWarning
Err error // non-nil when Status == SkillDirFailed
}
// SkillUpgradeResult aggregates the outcome of an UpgradeSkillLocations call.
@@ -300,18 +187,6 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
return out
}
// RetireWarnings returns universal Agent roots that still hold an obsolete
// private copy because retiring it failed.
func (r *SkillUpgradeResult) RetireWarnings() []SkillDirResult {
var out []SkillDirResult
for _, d := range r.Results {
if d.Status == SkillDirRetireWarning {
out = append(out, d)
}
}
return out
}
// UpgradeSkillLocations refreshes skills from extractedDir into agent homes.
// extractedDir may be a multi-skill bundle root (subdirectories each containing
// SKILL.md) or a legacy mono root (SKILL.md at its top level). Callers that
@@ -329,13 +204,11 @@ func (r *SkillUpgradeResult) RetireWarnings() []SkillDirResult {
// `dws skill setup --mode mono` flow.
//
// Strategy (matches npm install.js installSkillsToHomes):
// - ~/.agents/skills/ is always the canonical global store
// - universal Agents (Codex, Cursor, Gemini, Cline, Amp, Copilot) read the
// canonical store directly, so old agent-specific copies are retired
// - detected non-universal Agents receive relative links to canonical;
// filesystems that reject links receive a direct-copy fallback
// - Concrete agent dirs (claude, cursor, codex, ...) are updated when their
// parent directory exists (e.g. ~/.codex/ exists => user has Codex)
// - ~/.agents/skills/ is used only when no concrete Agent is detected
// - ~/.real/ and other blacklisted paths are NEVER touched
// - canonical publication is mandatory and fails the upgrade loudly
// - If no location was updated at all, fall back to ~/.agents/skills/
//
// Opposite-mode leftovers are backed up to ~/.dws/skill-backups/ and then
// removed so mono and multi never co-exist after an upgrade; a leftover that
@@ -404,54 +277,9 @@ func buildUpgradeProvenanceRecords(root string, names []string, version string)
// backups are kept.
const skillBackupKeep = 5
// The ownership marker inside a stamp root. Every DWS surface writes the
// exact same bytes (install.sh, install-skills.sh, install-event.sh,
// install-devapp.sh, the PowerShell installers, and build/npm/install.js):
// a stamp-shaped directory name alone is not ownership proof, because users
// and unrelated tools can create 20260819-120000-shaped names. Counting or
// deleting a backup root requires the marker to verify, and adopting an
// existing root for a new backup requires it first.
const (
skillBackupMarkerName = ".dws-skill-backup"
skillBackupMarkerContent = "dws skill backup v1\n"
)
var (
skillBackupReadMarker = func(root string) (string, error) {
data, err := os.ReadFile(filepath.Join(root, skillBackupMarkerName))
return string(data), err
}
skillBackupWriteMarker = func(root string) error {
return os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644)
}
)
// skillBackupMarkerValid reports whether root carries the ownership marker
// with exactly the expected bytes; a missing, unreadable, or differently
// worded marker means the directory is foreign data.
func skillBackupMarkerValid(root string) bool {
body, err := skillBackupReadMarker(root)
return err == nil && body == skillBackupMarkerContent
}
// skillBackupStampRegexp matches only directory names DWS itself writes:
// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry
// in the backup root is foreign (user data, unrelated tooling) and must be
// left untouched, so pruning is restricted to names it can prove DWS created.
var skillBackupStampRegexp = regexp.MustCompile(`^[0-9]{8}-[0-9]{6}(-[0-9]+)?$`)
// isSkillBackupStamp reports whether name is a DWS-created backup stamp.
func isSkillBackupStamp(name string) bool {
return skillBackupStampRegexp.MatchString(name)
}
// pruneSkillBackups removes the oldest backup directories when more than
// skillBackupKeep remain. Only directories whose names match the DWS backup
// stamp format AND whose ownership marker verifies are candidates; unknown
// or unmarked directories — including stamp roots created before the
// ownership marker existed — are foreign data and preserved. Best-effort:
// a removal failure never aborts, but pruning failures are reported so
// callers can warn the user.
// skillBackupKeep remain. Best-effort: a removal failure never aborts, but
// pruning failures are reported so callers can warn the user.
func pruneSkillBackups(homeDir string) error {
root := filepath.Join(homeDir, skillBackupSubdir)
entries, err := upgradeReadDir(root)
@@ -463,12 +291,7 @@ func pruneSkillBackups(homeDir string) error {
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() || !isSkillBackupStamp(e.Name()) {
continue
}
// A stamp-shaped name alone is not ownership proof: only roots
// whose marker verifies are counted or deleted.
if skillBackupMarkerValid(filepath.Join(root, e.Name())) {
if e.IsDir() {
names = append(names, e.Name())
}
}
@@ -581,15 +404,20 @@ func uniqueSkillDirs(paths []string) []string {
// restoreSkillSet removes any newly published directories, then restores all
// original directories in reverse backup order.
func restoreSkillSet(published []SkillPathPublication, backups []backedUpSkillDir) error {
restoreErr := upgradeRollbackPublishedSkillPaths(published)
func restoreSkillSet(published []string, backups []backedUpSkillDir) error {
var restoreErr error
for i := len(published) - 1; i >= 0; i-- {
if err := upgradeRemoveAll(published[i]); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err))
}
}
for i := len(backups) - 1; i >= 0; i-- {
backup := backups[i]
if err := upgradeMkdirAll(filepath.Dir(backup.original), dirPermShared); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录 %s: %w", filepath.Dir(backup.original), err))
continue
}
if err := moveSkillPathRecoverably(backup.backup, backup.original); err != nil {
if err := upgradeRename(backup.backup, backup.original); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w", backup.original, err))
}
}
@@ -622,17 +450,16 @@ func publishStagedSkillSet(homeDir string, staged []stagedSkillDir, victims []st
if err != nil {
return err
}
published := make([]SkillPathPublication, 0, len(staged))
published := make([]string, 0, len(staged))
for _, skill := range staged {
publication, publishErr := upgradePublishSkillPath(skill.staged, skill.dest)
if publishErr != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, publishErr)
if err := upgradeRename(skill.staged, skill.dest); err != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, err)
if restoreErr := restoreSkillSet(published, backups); restoreErr != nil {
return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr))
}
return publishErr
}
published = append(published, publication)
published = append(published, skill.dest)
}
return nil
}
@@ -690,274 +517,100 @@ func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []str
return publishStagedSkillSet(homeDir, staged, victims)
}
func hasDetectedSpecificSkillRoot(homeDir string) bool {
for _, agentDir := range knownSkillDirs {
if isGenericSkillRoot(agentDir) || isBlacklisted(agentDir) {
continue
}
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
if info, err := upgradeStat(parentGate); err == nil && info.IsDir() {
return true
}
}
return false
}
func isGenericSkillRoot(agentDir string) bool {
return filepath.Clean(agentDir) == filepath.Clean(".agents/skills")
}
func isUniversalSkillRoot(agentDir string) bool {
return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))]
}
type resolvedSkillRoot struct {
base string
label string
universal bool
canonical bool
}
func resolveOpenClawSkillBase(homeDir string) string {
for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} {
base := filepath.Join(homeDir, name)
if info, err := upgradeStat(base); err == nil && info.IsDir() {
return filepath.Join(base, "skills")
}
}
return filepath.Join(homeDir, ".openclaw", "skills")
}
func configuredSkillRoots(homeDir string) []resolvedSkillRoot {
var roots []resolvedSkillRoot
seen := map[string]bool{}
configHome := strings.TrimSpace(upgradeGetenv("XDG_CONFIG_HOME"))
if configHome == "" {
configHome = filepath.Join(homeDir, ".config")
}
add := func(base, label string, universal, canonical bool) {
key := skillRootPathKey(base)
if seen[key] {
return
}
seen[key] = true
roots = append(roots, resolvedSkillRoot{base: base, label: label, universal: universal, canonical: canonical})
}
for _, agentDir := range knownSkillDirs {
base := filepath.Join(homeDir, agentDir)
switch filepath.ToSlash(filepath.Clean(agentDir)) {
case ".claude/skills":
if custom := strings.TrimSpace(upgradeGetenv("CLAUDE_CONFIG_DIR")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".codex/skills":
if custom := strings.TrimSpace(upgradeGetenv("CODEX_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".hermes/skills":
if custom := strings.TrimSpace(upgradeGetenv("HERMES_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".autohand/skills":
if custom := strings.TrimSpace(upgradeGetenv("AUTOHAND_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".grok/skills":
if custom := strings.TrimSpace(upgradeGetenv("GROK_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".vibe/skills":
if custom := strings.TrimSpace(upgradeGetenv("VIBE_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".openclaw/skills":
base = resolveOpenClawSkillBase(homeDir)
case ".config/agents/skills":
base = filepath.Join(configHome, "agents", "skills")
case ".config/opencode/skills":
base = filepath.Join(configHome, "opencode", "skills")
case ".config/crush/skills":
base = filepath.Join(configHome, "crush", "skills")
case ".config/devin/skills":
base = filepath.Join(configHome, "devin", "skills")
case ".config/goose/skills":
base = filepath.Join(configHome, "goose", "skills")
case ".config/kimchi/harness/skills":
base = filepath.Join(configHome, "kimchi", "harness", "skills")
}
add(base, agentDir, isUniversalSkillRoot(agentDir), isGenericSkillRoot(agentDir))
}
return roots
}
func skillRootDetectedBase(root resolvedSkillRoot) bool {
detectedDir := filepath.Dir(root.base)
switch filepath.ToSlash(filepath.Clean(root.label)) {
case ".config/kimchi/harness/skills":
detectedDir = filepath.Dir(filepath.Dir(root.base))
case ".tabnine/agent/skills":
detectedDir = filepath.Dir(filepath.Dir(root.base))
case ".zcode/skills":
// Application bundles are machine-scoped detection signals. Keep this
// independent of HOME so upgrade matches npm, Shell, and PowerShell.
if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() {
return true
}
case ".minimax/skills":
if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() {
return true
}
}
info, err := upgradeStat(detectedDir)
return err == nil && info.IsDir()
}
func samePhysicalSkillRoot(left, right string) bool {
leftReal, leftErr := upgradeEvalSymlinks(left)
rightReal, rightErr := upgradeEvalSymlinks(right)
if leftErr != nil || rightErr != nil {
return false
}
return skillRootPathKey(leftReal) == skillRootPathKey(rightReal)
}
func skillRootPathKey(path string) string {
clean := filepath.Clean(path)
if upgradeFoldPathCase {
clean = strings.ToLower(clean)
}
return clean
}
func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error {
func retireGenericSkillRoot(homeDir string, managed map[string]bool) error {
base := filepath.Join(homeDir, ".agents", "skills")
victims, err := managedMultiSkillVictims(base, managed)
if err != nil {
return err
}
victims = append(victims, filepath.Join(base, "dws"))
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("迁移 Agent Skill 旧副本失败: %w", err)
return fmt.Errorf("迁移通用 Skill 根目录失败: %w", err)
}
return nil
}
func stageLinkedSkillSet(destBase, canonicalBase, prefix string, names []string) (stageRoot string, staged []stagedSkillDir, err error) {
if err := upgradeMkdirAll(destBase, dirPermShared); err != nil {
return "", nil, fmt.Errorf("创建 Agent Skill 目录失败 %s: %w", destBase, err)
}
stageRoot, err = upgradeMkdirTemp(destBase, prefix)
if err != nil {
return "", nil, fmt.Errorf("创建 Skill 链接 staging 失败 %s: %w", destBase, err)
}
defer func() {
if err != nil {
_ = upgradeRemoveAll(stageRoot)
}
}()
realDestBase, realErr := upgradeEvalSymlinks(destBase)
if realErr != nil {
return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", destBase, realErr)
}
for _, name := range names {
target := filepath.Join(canonicalBase, name)
realTarget, targetErr := upgradeEvalSymlinks(target)
if targetErr != nil {
return stageRoot, nil, fmt.Errorf("解析 canonical Skill 失败 %s: %w", target, targetErr)
}
relTarget, relErr := upgradeRel(realDestBase, realTarget)
if relErr != nil {
return stageRoot, nil, fmt.Errorf("计算 Skill 相对链接失败 %s: %w", target, relErr)
}
stagedPath := filepath.Join(stageRoot, name)
if linkErr := upgradeSymlink(relTarget, stagedPath); linkErr != nil {
return stageRoot, nil, fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedPath, relTarget, linkErr)
}
staged = append(staged, stagedSkillDir{staged: stagedPath, dest: filepath.Join(destBase, name)})
}
return stageRoot, staged, nil
}
func publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase string, names []string, victims []string) error {
needed := make([]string, 0, len(names))
correct := make(map[string]bool, len(names))
for _, name := range names {
dest := filepath.Join(destBase, name)
canonical := filepath.Join(canonicalBase, name)
if samePhysicalSkillRoot(dest, canonical) {
correct[filepath.Clean(dest)] = true
continue
}
needed = append(needed, name)
}
filteredVictims := make([]string, 0, len(victims))
for _, victim := range victims {
if !correct[filepath.Clean(victim)] {
filteredVictims = append(filteredVictims, victim)
}
}
stageRoot, staged, err := stageLinkedSkillSet(destBase, canonicalBase, ".dws-link-set-", needed)
if err != nil {
return err
}
defer func() { _ = upgradeRemoveAll(stageRoot) }()
return publishStagedSkillSet(homeDir, staged, filteredVictims)
}
// upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory
// per agent home.
func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) {
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
canonicalBase := filepath.Join(homeDir, ".agents", "skills")
canonicalDest := filepath.Join(canonicalBase, "dws")
if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err})
// Canonical is the mandatory store: universal agents read it directly, so a
// failed canonical publish means nobody received the upgrade. Always fail
// loud — including universal-only machines — instead of reporting success
// with nothing installed.
return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
}
result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK})
hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir)
for _, root := range configuredSkillRoots(homeDir) {
if root.canonical {
continue
}
destBase := root.base
destDir := filepath.Join(destBase, "dws")
if isBlacklisted(root.label) {
for _, agentDir := range knownSkillDirs {
destDir := filepath.Join(homeDir, agentDir, "dws")
if isBlacklisted(agentDir) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted})
continue
}
if !skillRootDetectedBase(root) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
if isGenericSkillRoot(agentDir) && hasSpecificRoot {
continue
}
if samePhysicalSkillRoot(destBase, canonicalBase) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue
}
if root.universal {
if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
// Nothing is installed below a universal root, so a stale copy that
// resists retirement is a warning, not an install failure.
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirRetireWarning, Err: err})
} else {
if !isGenericSkillRoot(agentDir) {
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
if _, err := upgradeStat(parentGate); os.IsNotExist(err) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
}
continue
}
victims, victimErr := monoUpgradeVictims(destBase, destDir, managedNames)
if victimErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: victimErr})
continue
}
if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, []string{"dws"}, victims); err != nil {
// An uncertain destination may belong to a concurrent writer.
// The copy fallback below would back that object aside and
// replace it — exactly what the sentinel forbids — so surface
// the uncertain state instead of retrying over it.
if errors.Is(err, ErrSkillPathPublicationUncertain) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
continue
}
// Match the upstream installer: platforms/filesystems that reject
// links receive a direct copy rather than losing Agent support.
if copyErr := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); copyErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: errors.Join(err, copyErr)})
continue
}
}
if err := publishMonoUpgradeTarget(homeDir, filepath.Join(homeDir, agentDir), skillSrc, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
continue
}
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK})
}
if hasSpecificRoot && len(result.Succeeded()) > 0 {
genericBase := filepath.Join(homeDir, ".agents", "skills")
if err := retireGenericSkillRoot(homeDir, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped})
}
}
// Fallback: if nothing succeeded, force the primary location. The multi
// leftovers under the primary base are the usual reason the primary
// install failed, so clean them first — failing loud like the multi
// fallback — instead of letting mono and multi co-exist marked OK.
if len(result.Succeeded()) == 0 && !hasSpecificRoot {
destBase := filepath.Join(homeDir, ".agents", "skills")
dest := filepath.Join(destBase, "dws")
if err := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); err != nil {
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
}
// Replace the earlier failed entry for this dir (if any) or append a new one
replaced := false
for idx, r := range result.Results {
if r.Dir == dest {
result.Results[idx] = SkillDirResult{Dir: dest, Status: SkillDirOK}
replaced = true
break
}
}
if !replaced {
result.Results = append(result.Results, SkillDirResult{Dir: dest, Status: SkillDirOK})
}
}
// Best-effort: refresh the user-level mono cache so that
// `dws skill setup --mode mono` fallbacks stay on the upgraded version
@@ -980,62 +633,61 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
canonicalBase := filepath.Join(homeDir, ".agents", "skills")
if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err})
// Canonical is the mandatory store (see the mono branch): fail loud even
// on universal-only machines instead of reporting success with nothing
// installed.
return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
}
result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK})
hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir)
for _, root := range configuredSkillRoots(homeDir) {
if root.canonical {
continue
}
destBase := root.base
if isBlacklisted(root.label) {
for _, agentDir := range knownSkillDirs {
destBase := filepath.Join(homeDir, agentDir)
if isBlacklisted(agentDir) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted})
continue
}
if !skillRootDetectedBase(root) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
if isGenericSkillRoot(agentDir) && hasSpecificRoot {
continue
}
if samePhysicalSkillRoot(destBase, canonicalBase) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue
}
if root.universal {
if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
// Nothing is installed below a universal root, so a stale copy that
// resists retirement is a warning, not an install failure.
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirRetireWarning, Err: err})
} else {
if !isGenericSkillRoot(agentDir) {
parentGate := filepath.Dir(destBase)
if _, err := upgradeStat(parentGate); os.IsNotExist(err) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
}
continue
}
victims, victimErr := multiUpgradeVictims(destBase, skillSet, skills, managedNames)
if victimErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: victimErr})
continue
}
if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, skills, victims); err != nil {
// An uncertain destination may belong to a concurrent writer;
// the copy fallback must not displace it (see the mono branch).
if errors.Is(err, ErrSkillPathPublicationUncertain) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
continue
}
if copyErr := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); copyErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: errors.Join(err, copyErr)})
continue
}
}
if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
continue
}
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
if hasSpecificRoot && len(result.Succeeded()) > 0 {
genericBase := filepath.Join(homeDir, ".agents", "skills")
if err := retireGenericSkillRoot(homeDir, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped})
}
}
// Fallback: if nothing succeeded, force the primary location
if len(result.Succeeded()) == 0 && !hasSpecificRoot {
destBase := filepath.Join(homeDir, ".agents", "skills")
if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
}
// Replace the earlier failed entry for this dir (if any) or append a new one
replaced := false
for idx, r := range result.Results {
if r.Dir == destBase {
result.Results[idx] = SkillDirResult{Dir: destBase, Status: SkillDirOK}
replaced = true
break
}
}
if !replaced {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
}
// Best-effort: refresh the user-level caches so that `dws skill setup`
// fallbacks stay on the upgraded version. The release zip ships both
@@ -1110,6 +762,23 @@ func skillTreeHasRoot(dir string) bool {
return err == nil && !info.IsDir()
}
// cleanupMultiLeftovers backs up + removes every proven DWS-managed
// multi-mode skill directory inside one agent home before mono is installed.
// A missing base directory simply means no leftovers; any other read failure
// is reported so mono never silently co-exists with multi. Removal is
// reversible: each leftover is preserved under ~/.dws/skill-backups/ and a
// backup failure aborts the removal for that home.
func cleanupMultiLeftovers(homeDir, baseDir string) error {
victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir))
if err != nil {
return err
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理 multi 残留失败: %w", err)
}
return nil
}
func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]string, error) {
entries, err := upgradeReadDir(baseDir)
if err != nil {
@@ -1120,7 +789,7 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str
}
victims := make([]string, 0)
for _, e := range entries {
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
if !e.IsDir() || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(baseDir, e.Name()))
@@ -1128,6 +797,24 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str
return victims, nil
}
// cleanupOppositeModeLeftovers backs up + removes, inside one agent home, the
// legacy mono directory (dws/) and every proven DWS-managed multi skill
// directory that is not part of the new bundle. A dingtalk-* prefix alone is
// not proof of ownership because market/user skills may use the same prefix.
// Removal is reversible: each
// directory is preserved under ~/.dws/skill-backups/ and a backup failure
// aborts the removal for that home.
func cleanupOppositeModeLeftovers(homeDir, destBase string, skillSet map[string]bool) error {
victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir))
if err != nil {
return err
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理对面模式残留失败: %w", err)
}
return nil
}
func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed ...map[string]bool) ([]string, error) {
victims := []string{filepath.Join(destBase, "dws")}
entries, err := upgradeReadDir(destBase)
@@ -1138,7 +825,7 @@ func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed
return nil, fmt.Errorf("读取技能目录失败 %s: %w", destBase, err)
}
for _, e := range entries {
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) {
if !e.IsDir() || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(destBase, e.Name()))
+238 -231
View File
@@ -9,7 +9,6 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
@@ -23,16 +22,6 @@ func withFakeHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home, nil })
// Host-installed macOS apps must not make a fake HOME appear to have extra
// detected Agents. Tests that exercise application-based detection can
// override this seam explicitly.
testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
applications := filepath.Join(string(filepath.Separator), "Applications")
if path == filepath.Join(applications, "ZCode.app") || path == filepath.Join(applications, "MiniMax Code.app") {
return nil, os.ErrNotExist
}
return os.Stat(path)
})
return home
}
@@ -135,7 +124,8 @@ func TestCrossPlatformCoverageBundleSkillNamesLayouts(t *testing.T) {
func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
home := withFakeHome(t)
// .agents is canonical; detected Claude receives links and Cursor is skipped.
// A concrete Agent root wins over the generic .agents fallback; .claude
// installs and .cursor is skipped.
agentsBase := filepath.Join(home, ".agents", "skills")
claudeBase := filepath.Join(home, ".claude", "skills")
for _, base := range []string{agentsBase, claudeBase} {
@@ -203,19 +193,17 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
// Succeeded entries report the agent home base in multi mode.
succeeded := result.Succeeded()
if len(succeeded) != 2 {
t.Fatalf("Succeeded() len = %d, want 2 (%v)", len(succeeded), result.Results)
if len(succeeded) != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (%v)", len(succeeded), result.Results)
}
wantDirs := map[string]bool{agentsBase: true, claudeBase: true}
wantDirs := map[string]bool{claudeBase: true}
for _, d := range succeeded {
if !wantDirs[d.Dir] {
t.Errorf("unexpected succeeded dir %q", d.Dir)
}
}
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Fatalf("canonical Skill missing: %v", err)
}
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
t.Fatalf("generic mono duplicate still visible: %v", err)
}
// Multi cache refreshed under the fake home.
@@ -228,7 +216,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
}
}
func TestCrossPlatformCoverageUpgradeUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) {
func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicate(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".codex/skills"})
@@ -253,12 +241,12 @@ func TestCrossPlatformCoverageUpgradeUsesCanonicalRootWithoutCodexDuplicate(t *t
t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err)
}
want := filepath.Join(genericBase, "dingtalk-chat", "SKILL.md")
want := filepath.Join(codexBase, "dingtalk-chat", "SKILL.md")
if _, err := os.Stat(want); err != nil {
t.Fatalf("canonical Skill missing at %s: %v", want, err)
t.Fatalf("canonical Codex Skill missing at %s: %v", want, err)
}
for _, duplicate := range []string{
filepath.Join(codexBase, "dingtalk-chat"),
filepath.Join(genericBase, "dingtalk-chat", "SKILL.md"),
filepath.Join(genericBase, "dws", "multi", "dingtalk-chat", "SKILL.md"),
} {
if _, err := os.Stat(duplicate); !os.IsNotExist(err) {
@@ -514,9 +502,6 @@ func TestUpgradeMonoCleansPreStateOfficialAndPreservesCustom(t *testing.T) {
// mono-only package path).
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(t *testing.T) {
home := withFakeHome(t)
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
agentsBase := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
t.Fatal(err)
@@ -541,15 +526,21 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(
}
result, err := UpgradeSkillLocations(mono)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical copy failure = %#v, %v", result, err)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("fallback should replace the failed entry with OK, got failed=%v", failed)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (%v)", got, result.Results)
}
data, err := os.ReadFile(filepath.Join(agentsBase, "dws", "SKILL.md"))
if err != nil {
t.Fatalf("mono fallback install missing: %v", err)
}
if string(data) != "old" {
t.Errorf("mono content = %q, want original preserved", data)
if string(data) != "# mono" {
t.Errorf("mono content = %q, want refreshed package", data)
}
}
@@ -583,22 +574,22 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t *
}
result, err := UpgradeSkillLocations(mono)
if err == nil {
t.Fatal("canonical read failure must fail the upgrade")
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v (fallback should not run: .claude succeeded)", err)
}
failed := result.Failed()
if len(failed) != 1 {
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
}
wantDir := filepath.Join(agentsBase, "dws")
wantDir := agentsBase
if failed[0].Dir != wantDir || failed[0].Err == nil {
t.Fatalf("failed entry = %#v, want dir %q with non-nil err", failed[0], wantDir)
}
if !strings.Contains(failed[0].Err.Error(), "读取技能目录失败") {
t.Fatalf("failed err should mention the read failure, got %v", failed[0].Err)
}
if got := len(result.Succeeded()); got != 0 {
t.Fatalf("Succeeded() len = %d, want 0", got)
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got)
}
// Mono must NOT have been laid down next to the unreadable multi state.
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
@@ -612,9 +603,6 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t *
// installing multi next to the stale skills.
func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t *testing.T) {
home := withFakeHome(t)
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
agentsBase := filepath.Join(home, ".agents", "skills")
staleDir := filepath.Join(agentsBase, "dingtalk-stale")
if err := os.MkdirAll(staleDir, 0o755); err != nil {
@@ -625,10 +613,10 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
}
useUpgradeManagedNames(t, filepath.Base(staleDir))
origRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
origRename := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
if strings.Contains(src, "dingtalk-stale") {
return "", errors.New("injected backup failure")
return errors.New("injected backup failure")
}
return origRename(src, dst)
})
@@ -640,8 +628,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
if err == nil {
t.Fatal("multi fallback cleanup failure must return an error")
}
if !strings.Contains(err.Error(), "canonical Skill 安装失败") {
t.Fatalf("error should mention the canonical cleanup failure, got %v", err)
if !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("error should mention the fallback cleanup failure, got %v", err)
}
if failed := result.Failed(); len(failed) != 1 {
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
@@ -655,17 +643,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
}
}
// seedSkillBackupMarker stamps a backup root as DWS-owned, the way every
// install surface does before any payload moves in.
func seedSkillBackupMarker(t *testing.T, root string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644); err != nil {
t.Fatal(err)
}
}
// TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges pins the fail-safe
// contract of the backup helper: all lexical paths are preserved, a colliding backup
// contract of the backup helper: non-dir paths are no-ops, a colliding backup
// target gets a numbered stamp, and any failure (mkdir / rename / unresolvable
// collision) leaves the original directory untouched with an error.
func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
@@ -675,8 +654,8 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
// backup. Inject it through the path seam so this branch is portable to
// Windows, where chmod-based permission failures are not reliable.
statFailurePath := filepath.Join(home, "stat-failure")
origStat := upgradeLstat
testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) {
origStat := upgradeStat
testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
if path == statFailurePath {
return nil, errors.New("injected stat failure")
}
@@ -686,16 +665,16 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
t.Fatalf("stat failure = (%q, %v), want wrapped error", got, err)
}
// Regular file: preserve it instead of allowing a later rename to overwrite it.
// Regular file: no-op, no backup.
filePath := filepath.Join(home, "not-a-dir")
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if got, err := backupAndRemoveSkillDir(home, filePath); got == "" || err != nil {
t.Fatalf("regular file = (%q, %v), want backup", got, err)
if got, err := backupAndRemoveSkillDir(home, filePath); got != "" || err != nil {
t.Fatalf("regular file = (%q, %v), want no-op", got, err)
}
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
t.Fatalf("regular file must move into backup: %v", err)
if _, err := os.Stat(filePath); err != nil {
t.Fatalf("regular file must survive: %v", err)
}
// A skill directory outside home falls back to its basename instead of
@@ -743,46 +722,6 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
t.Fatalf("victim must be gone after backup, stat err=%v", err)
}
// A dangling link at the first backup target is still an occupied lexical
// path and must select a numbered target rather than being overwritten.
danglingVictim := filepath.Join(home, ".agents", "skills", "dangling-collision")
if err := os.MkdirAll(danglingVictim, 0o755); err != nil {
t.Fatal(err)
}
danglingTaken := filepath.Join(home, skillBackupSubdir, "20260810-000000", ".agents-skills-dangling-collision")
if err := os.Symlink("missing-backup", danglingTaken); err == nil {
got, err = backupAndRemoveSkillDir(home, danglingVictim)
if err != nil {
t.Fatalf("backup with dangling collision error = %v", err)
}
want = filepath.Join(home, skillBackupSubdir, "20260810-000000-1", ".agents-skills-dangling-collision")
if got != want {
t.Fatalf("dangling collision backup path = %q, want %q", got, want)
}
} else if runtime.GOOS != "windows" {
t.Fatal(err)
}
// An unreadable target probe is not equivalent to a free path.
probeVictim := filepath.Join(home, "probe-victim")
if err := os.MkdirAll(probeVictim, 0o755); err != nil {
t.Fatal(err)
}
probeTarget := filepath.Join(home, skillBackupSubdir, "20260810-000000", "probe-victim")
testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) {
if path == probeTarget {
return nil, errors.New("target probe denied")
}
return os.Lstat(path)
})
if _, err := backupAndRemoveSkillDir(home, probeVictim); err == nil || !strings.Contains(err.Error(), "检查备份目标失败") {
t.Fatalf("target probe error = %v", err)
}
if _, err := os.Stat(probeVictim); err != nil {
t.Fatalf("probe victim must survive target check failure: %v", err)
}
testseam.Swap(t, &upgradeLstat, os.Lstat)
// Unresolvable collision (>1000 numbered stamps taken) fails and keeps dir.
victim2 := filepath.Join(home, "victim2")
if err := os.MkdirAll(victim2, 0o755); err != nil {
@@ -810,7 +749,7 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
t.Fatal(err)
}
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") })
if _, err := backupAndRemoveSkillDir(home, victim3); err == nil || !strings.Contains(err.Error(), "创建备份根目录失败") {
if _, err := backupAndRemoveSkillDir(home, victim3); err == nil || !strings.Contains(err.Error(), "创建备份目录失败") {
t.Fatalf("mkdir error = %v", err)
}
if _, err := os.Stat(victim3); err != nil {
@@ -823,14 +762,14 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
if err := os.MkdirAll(victim4, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("rename denied") })
testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("rename denied") })
if _, err := backupAndRemoveSkillDir(home, victim4); err == nil || !strings.Contains(err.Error(), "备份技能目录失败") {
t.Fatalf("rename error = %v", err)
}
if _, err := os.Stat(victim4); err != nil {
t.Fatalf("victim4 must survive rename failure: %v", err)
}
testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
testseam.Swap(t, &upgradeRename, os.Rename)
}
// TestCrossPlatformCoveragePruneSkillBackupsEdges pins the backup retention:
@@ -840,11 +779,9 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) {
home := t.TempDir()
root := filepath.Join(home, skillBackupSubdir)
for i := 0; i < skillBackupKeep+2; i++ {
stampDir := filepath.Join(root, fmt.Sprintf("20260810-00000%d", i))
if err := os.MkdirAll(stampDir, 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260810-00000%d", i)), 0o755); err != nil {
t.Fatal(err)
}
seedSkillBackupMarker(t, stampDir)
}
if err := pruneSkillBackups(home); err != nil {
t.Fatalf("pruneSkillBackups() error = %v", err)
@@ -877,11 +814,9 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) {
// Removal failure is reported as the first error. Seed more than
// skillBackupKeep dirs again so the prune loop actually runs.
for i := 0; i < skillBackupKeep+2; i++ {
stampDir := filepath.Join(root, fmt.Sprintf("20260811-00000%d", i))
if err := os.MkdirAll(stampDir, 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260811-00000%d", i)), 0o755); err != nil {
t.Fatal(err)
}
seedSkillBackupMarker(t, stampDir)
}
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return errors.New("remove denied") })
if err := pruneSkillBackups(home); err == nil {
@@ -890,86 +825,6 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) {
testseam.Swap(t, &upgradeRemoveAll, os.RemoveAll)
}
// TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown pins that
// pruneSkillBackups only retires directories whose names match the DWS backup
// stamp format. Any foreign directory in the backup root must survive even when
// more than skillBackupKeep stamped backups exist.
func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) {
home := t.TempDir()
root := filepath.Join(home, skillBackupSubdir)
for i := 0; i < skillBackupKeep+2; i++ {
stampDir := filepath.Join(root, fmt.Sprintf("20260910-00000%d", i))
if err := os.MkdirAll(stampDir, 0o755); err != nil {
t.Fatal(err)
}
seedSkillBackupMarker(t, stampDir)
}
// A collision-suffixed stamp is still a valid DWS backup and is eligible.
suffixed := filepath.Join(root, "20260910-000000-7")
if err := os.MkdirAll(suffixed, 0o755); err != nil {
t.Fatal(err)
}
seedSkillBackupMarker(t, suffixed)
// Stamp-shaped names without a verifying marker are foreign data too:
// the name format alone proves nothing.
unmarked := filepath.Join(root, "20260901-000000")
if err := os.MkdirAll(unmarked, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(unmarked, "user-data.txt"), []byte("must survive"), 0o644); err != nil {
t.Fatal(err)
}
wrongMarker := filepath.Join(root, "20260902-000000")
if err := os.MkdirAll(wrongMarker, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(wrongMarker, skillBackupMarkerName), []byte("not dws"), 0o644); err != nil {
t.Fatal(err)
}
unknown := []string{
"user-personal-backup",
"20260910-000000-abc", // stamp-shaped but non-numeric suffix
"20260910-00000", // too short
"notes",
"20260901-000000", // stamp-shaped but unmarked
"20260902-000000", // stamp-shaped but wrong marker content
}
for _, name := range unknown {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatal(err)
}
}
if err := pruneSkillBackups(home); err != nil {
t.Fatalf("pruneSkillBackups() error = %v", err)
}
for _, name := range unknown {
if _, err := os.Stat(filepath.Join(root, name)); err != nil {
t.Errorf("unknown backup entry %s must be preserved, stat err=%v", name, err)
}
}
// Only marker-proven backups beyond skillBackupKeep are pruned; foreign
// entries (including unmarked stamp-shaped ones) are excluded from the
// count, so the proven total stays at skillBackupKeep.
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
proven := 0
for _, e := range entries {
if isSkillBackupStamp(e.Name()) && skillBackupMarkerValid(filepath.Join(root, e.Name())) {
proven++
}
}
if proven != skillBackupKeep {
t.Errorf("marker-proven backups after prune = %d, want %d", proven, skillBackupKeep)
}
if data, err := os.ReadFile(filepath.Join(unmarked, "user-data.txt")); err != nil || string(data) != "must survive" {
t.Errorf("unmarked stamp-shaped data must survive, got %q, %v", data, err)
}
}
// TestCrossPlatformCoverageResolveSkillSrcLayouts pins every fallback branch of
// the mono-source resolver and the multi-bundle child lookup.
func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) {
@@ -1033,27 +888,88 @@ func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) {
// TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges pins the mono
// path's fail-loud backup semantics and every primary-fallback outcome.
func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
originalDirs := append([]string(nil), knownSkillDirs...)
t.Cleanup(func() { knownSkillDirs = originalDirs })
mono := t.TempDir()
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
// Per-home backup failure marks that home failed; another home still wins.
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755)
os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644)
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
if strings.Contains(src, ".agents") {
return "", errors.New("backup denied")
return errors.New("backup denied")
}
return renameSkillPathNoReplace(src, dst)
return os.Rename(src, dst)
})
result, err := UpgradeSkillLocations(mono)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical backup failure = %#v, %v", result, err)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
t.Fatalf("failed = %v, want exactly 1 backup-failed home", failed)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
t.Fatalf("failed canonical home must keep its original dws/: %v", err)
t.Fatalf("failed home must keep its original dws/: %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got)
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Fallback cleanup failure: only a blacklisted home in the main loop, then
// the primary cleanup hits a stale dir whose backup fails.
home2 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
knownSkillDirs = []string{".real/skills"}
stale := filepath.Join(home2, ".agents", "skills", "dingtalk-stale")
os.MkdirAll(stale, 0o755)
useUpgradeManagedNames(t, filepath.Base(stale))
testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") })
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback cleanup error = %v", err)
}
if _, err := os.Stat(stale); err != nil {
t.Fatalf("stale dir must survive failed cleanup: %v", err)
}
// Fallback backup failure of the primary dws/ itself.
home3 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil })
os.MkdirAll(filepath.Join(home3, ".agents", "skills", "dws"), 0o755)
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback backup error = %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Fallback copy failure: everything fails loud.
home4 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil })
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") })
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback copy error = %v", err)
}
testseam.Swap(t, &upgradeCopyDir, copyDir)
// Fallback append (no prior entry for the primary dir): a blacklisted-only
// main loop leaves no primary entry, so the fallback appends a fresh OK.
home5 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil })
result, err = UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("fallback append error = %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 appended primary", got)
}
if _, err := os.Stat(filepath.Join(home5, ".agents", "skills", "dws", "SKILL.md")); err != nil {
t.Fatalf("fallback append install missing: %v", err)
}
}
@@ -1061,10 +977,15 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
// path's blacklisted branch, per-skill backup/copy failures, and the primary
// fallback outcomes (cleanup OK + backup/copy failure, fresh append).
func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
originalDirs := append([]string(nil), knownSkillDirs...)
t.Cleanup(func() { knownSkillDirs = originalDirs })
extract := t.TempDir()
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat")
// Blacklisted entry is reported, non-blacklisted installs.
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".real/skills", ".agents/skills"})
knownSkillDirs = []string{".real/skills", ".agents/skills"}
os.MkdirAll(filepath.Join(home, ".agents"), 0o755)
result, err := UpgradeSkillLocations(multiRoot)
if err != nil {
@@ -1082,33 +1003,119 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, ".real")); !os.IsNotExist(err) {
t.Fatalf("blacklisted home must never be touched, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("canonical install missing: %v", err)
}
}
// The production wrappers that used to bundle these two steps were removed as
// dead code; the test keeps exercising the live building blocks they composed.
func cleanupMultiLeftoversForTest(homeDir, baseDir string) error {
victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir))
// A detected concrete Agent root wins over .agents. A failure while retiring
// the old generic copy is surfaced after the concrete root succeeds.
home2 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
os.MkdirAll(filepath.Join(home2, ".agents", "skills", "dingtalk-chat"), 0o755)
os.MkdirAll(filepath.Join(home2, ".claude"), 0o755)
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
if strings.Contains(src, ".agents") {
return errors.New("backup denied")
}
return os.Rename(src, dst)
})
result, err = UpgradeSkillLocations(multiRoot)
if err != nil {
return err
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理 multi 残留失败: %w", err)
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
t.Fatalf("failed = %v, want 1 backup-failed home", failed)
}
return nil
}
if _, err := os.Stat(filepath.Join(home2, ".agents", "skills", "dingtalk-chat")); err != nil {
t.Fatalf("failed home must keep its original skill: %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
func cleanupOppositeModeLeftoversForTest(homeDir, destBase string, skillSet map[string]bool) error {
victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir))
// Per-skill copy failure on the concrete home fails that home; with no
// successful concrete target, the generic copy is left untouched.
home3 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil })
os.MkdirAll(filepath.Join(home3, ".claude"), 0o755)
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
if strings.Contains(dst, ".claude") {
return errors.New("copy denied")
}
return copyDir(src, dst)
})
result, err = UpgradeSkillLocations(multiRoot)
if err != nil {
return err
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理对面模式残留失败: %w", err)
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
t.Fatalf("failed = %v, want 1 copy-failed home", failed)
}
testseam.Swap(t, &upgradeCopyDir, copyDir)
// Fallback backup failure: the same-name bundle skill refresh is what
// fails (cleanup succeeds, the per-skill backup does not).
home4 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil })
knownSkillDirs = []string{".real/skills"}
os.MkdirAll(filepath.Join(home4, ".agents", "skills", "dingtalk-chat"), 0o755)
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
if strings.Contains(src, "dingtalk-chat") {
return errors.New("backup denied")
}
return os.Rename(src, dst)
})
if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback backup error = %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Fallback copy failure.
home5 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil })
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") })
if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback copy error = %v", err)
}
testseam.Swap(t, &upgradeCopyDir, copyDir)
// Fallback append: blacklisted-only main loop → fresh OK entry appended.
home6 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home6, nil })
result, err = UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("fallback append error = %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 appended primary (%v)", got, result.Results)
}
if _, err := os.Stat(filepath.Join(home6, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("fallback append install missing: %v", err)
}
// Fallback replace: the main loop fails the primary home on a transient
// copy error, the fallback retry succeeds and replaces the failed entry.
home7 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home7, nil })
knownSkillDirs = []string{".agents/skills"}
origCopy := upgradeCopyDir
attempts := 0
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
attempts++
if attempts == 1 {
return errors.New("transient copy failure")
}
return origCopy(src, dst)
})
result, err = UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("fallback replace error = %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 replaced primary (%v)", got, result.Results)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("failed entry must be replaced by OK, got %v", failed)
}
if _, err := os.Stat(filepath.Join(home7, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("fallback replace install missing: %v", err)
}
return nil
}
// TestCrossPlatformCoverageCleanupLeftoversEdges pins the cleanup helpers:
@@ -1120,40 +1127,40 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
// Read failure (non-ENOENT) surfaces from both cleanups.
testseam.Swap(t, &upgradeReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("read denied") })
if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupMultiLeftoversForTest read error = %v", err)
if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupMultiLeftovers read error = %v", err)
}
if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupOppositeModeLeftoversForTest read error = %v", err)
if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupOppositeModeLeftovers read error = %v", err)
}
testseam.Swap(t, &upgradeReadDir, os.ReadDir)
// Backup failure of a multi leftover aborts cleanupMultiLeftovers.
os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755)
useUpgradeManagedNames(t, "dingtalk-stale")
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("backup denied") })
if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") {
testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") })
if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") {
t.Fatalf("cleanupMultiLeftovers backup error = %v", err)
}
// Backup failure of the mono leftover aborts the opposite-mode cleanup.
os.MkdirAll(filepath.Join(base, "dws"), 0o755)
if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
t.Fatalf("opposite cleanup mono backup error = %v", err)
}
testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
testseam.Swap(t, &upgradeRename, os.Rename)
// Backup failure of a stale (non-mono) skill aborts with its own message.
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
if strings.Contains(src, "dingtalk-stale") {
return "", errors.New("backup denied")
return errors.New("backup denied")
}
return renameSkillPathNoReplace(src, dst)
return os.Rename(src, dst)
})
if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
t.Fatalf("opposite cleanup stale backup error = %v", err)
}
testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
testseam.Swap(t, &upgradeRename, os.Rename)
// Success matrix: mono leftover + stale skill removed into backups, bundle
// skill and regular file preserved.
@@ -1161,7 +1168,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
skillSet := map[string]bool{"dingtalk-keep": true}
os.MkdirAll(filepath.Join(base, "dingtalk-keep"), 0o755)
os.WriteFile(filepath.Join(base, "regular-file"), []byte("x"), 0o644)
if err := cleanupOppositeModeLeftoversForTest(home, base, skillSet); err != nil {
if err := cleanupOppositeModeLeftovers(home, base, skillSet); err != nil {
t.Fatalf("cleanupOppositeModeLeftovers() error = %v", err)
}
for _, gone := range []string{"dws", "dingtalk-stale"} {
@@ -1181,7 +1188,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
}
// cleanupMultiLeftovers on a missing base is a no-op.
if err := cleanupMultiLeftoversForTest(home, filepath.Join(home, "missing")); err != nil {
if err := cleanupMultiLeftovers(home, filepath.Join(home, "missing")); err != nil {
t.Fatalf("missing base must be a no-op, got %v", err)
}
}
@@ -1,113 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// TestCrossPlatformCoverageSkillBackupOwnership pins the backup ownership
// contract: a stamp-shaped directory name alone proves nothing, so a backup
// root this transaction writes into is either created fresh (mkdir claim,
// marker stamped before any payload moves in) or an existing root whose
// marker already verifies. A same-stamp foreign directory is never adopted —
// writing the marker or the payload into it would turn its contents into
// prunable DWS backups.
func TestCrossPlatformCoverageSkillBackupOwnership(t *testing.T) {
t.Run("unproven stamp root is never adopted", func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260101-120000" })
foreign := filepath.Join(home, skillBackupSubdir, "20260101-120000")
if err := os.MkdirAll(foreign, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(foreign, "user-data.txt"), []byte("must survive"), 0o644); err != nil {
t.Fatal(err)
}
victim := filepath.Join(home, "skills", "dws")
seedUpgradeSkill(t, victim, "payload", false)
backup, err := backupAndRemoveSkillDir(home, victim)
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(home, skillBackupSubdir, "20260101-120000-1", "skills-dws"); backup != want {
t.Fatalf("backup = %s, want suffixed %s", backup, want)
}
assertUpgradeSkillContent(t, backup, "payload")
// The foreign root keeps its data and never receives our marker.
if data, readErr := os.ReadFile(filepath.Join(foreign, "user-data.txt")); readErr != nil || string(data) != "must survive" {
t.Fatalf("foreign data = %q, %v", data, readErr)
}
if _, statErr := os.Lstat(filepath.Join(foreign, skillBackupMarkerName)); !os.IsNotExist(statErr) {
t.Fatalf("foreign root must not receive the ownership marker, stat err=%v", statErr)
}
if !skillBackupMarkerValid(filepath.Dir(backup)) {
t.Fatal("the freshly claimed root must carry the ownership marker")
}
})
t.Run("same-stamp payloads reuse the proven root", func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260101-120000" })
first := filepath.Join(home, "skills", "dws")
second := filepath.Join(home, "agents", "claude", "dws")
seedUpgradeSkill(t, first, "first", false)
seedUpgradeSkill(t, second, "second", false)
backupA, err := backupAndRemoveSkillDir(home, first)
if err != nil {
t.Fatal(err)
}
backupB, err := backupAndRemoveSkillDir(home, second)
if err != nil {
t.Fatal(err)
}
if filepath.Dir(backupA) != filepath.Dir(backupB) {
t.Fatalf("same-stamp backups split roots: %s vs %s", backupA, backupB)
}
root := filepath.Dir(backupA)
if !skillBackupMarkerValid(root) {
t.Fatal("reused root must keep a verifying marker")
}
assertUpgradeSkillContent(t, backupA, "first")
assertUpgradeSkillContent(t, backupB, "second")
})
t.Run("marker write failure refuses the backup and cleans the empty root", func(t *testing.T) {
home := t.TempDir()
victim := filepath.Join(home, "skills", "dws")
seedUpgradeSkill(t, victim, "payload", false)
testseam.Swap(t, &skillBackupWriteMarker, func(string) error { return errors.New("marker denied") })
_, err := backupAndRemoveSkillDir(home, victim)
if err == nil || !strings.Contains(err.Error(), "备份所有权标记失败") {
t.Fatalf("marker write failure = %v", err)
}
assertUpgradeSkillContent(t, victim, "payload")
entries, readErr := os.ReadDir(filepath.Join(home, skillBackupSubdir))
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 0 {
t.Fatalf("empty unowned stamp root must be cleaned, left %v", entries)
}
})
t.Run("backup root creation failure surfaces", func(t *testing.T) {
home := t.TempDir()
victim := filepath.Join(home, "skills", "dws")
seedUpgradeSkill(t, victim, "payload", false)
testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission })
_, err := backupAndRemoveSkillDir(home, victim)
if !errors.Is(err, os.ErrPermission) {
t.Fatalf("root creation failure = %v", err)
}
assertUpgradeSkillContent(t, victim, "payload")
})
}
-363
View File
@@ -1,363 +0,0 @@
package upgrade
import (
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
)
var (
skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) }
skillPathRenameNoReplace = renameSkillPathNoReplace
skillPathCopy = copySkillPathLexically
skillPathVerify = verifySkillPathCopy
skillPathRemoveAll = os.RemoveAll
skillPathMkdirAll = os.MkdirAll
skillPathMkdirTemp = os.MkdirTemp
skillPathChmod = os.Chmod
skillPathLstat = os.Lstat
skillPathMkdir = os.Mkdir
skillPathLink = os.Link
skillPathRemove = os.Remove
skillPathReadDir = os.ReadDir
skillPathFileIdentity = skillPathFileIdentityImpl
skillPathFileIncarnation = skillPathFileIncarnationImpl
skillPathSameFileIdentity = skillPathSameFileIdentityImpl
skillPathReadlink = os.Readlink
skillPathSymlink = os.Symlink
skillPathOpen = os.Open
skillPathOpenFile = os.OpenFile
skillPathCopyBytes = io.Copy
skillPathSync = func(file *os.File) error { return file.Sync() }
skillPathWalkDir = filepath.WalkDir
)
// moveSkillPathRecoverably moves one managed Skill path without weakening the
// backup contract when source and destination are on different filesystems.
// The cross-device path publishes a fully copied and verified sibling staging
// path before removing the source. Any failure before source removal leaves the
// source intact; a removal failure deliberately leaves both verified copies.
func moveSkillPathRecoverably(src, dst string) (err error) {
if _, statErr := skillPathLstat(dst); statErr == nil {
return fmt.Errorf("目标已存在: %s", dst)
} else if !os.IsNotExist(statErr) {
return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr)
}
if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil {
return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err)
}
// moveSkillPathRecoverably is not a publication path; ignore any
// claim identity returned by the rename — no ownership check follows
// on this destination.
if _, renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil {
return nil
} else if !isCrossDeviceError(renameErr) {
return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr)
}
stageRoot, err := skillPathMkdirTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".cross-device-")
if err != nil {
return fmt.Errorf("创建跨设备 Skill staging 失败 %s: %w", dst, err)
}
stage := filepath.Join(stageRoot, "payload")
stageCleaned := false
defer func() {
if stageCleaned {
return
}
makeSkillPathTreeWritable(stageRoot)
if cleanupErr := skillPathRemoveAll(stageRoot); cleanupErr != nil {
err = errors.Join(err, fmt.Errorf("清理跨设备 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
}()
if err := skillPathCopy(src, stage); err != nil {
return fmt.Errorf("复制跨设备 Skill staging 失败 %s: %w", stage, err)
}
if err := skillPathVerify(src, stage); err != nil {
return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err)
}
stageInfo, err := skillPathLstat(stage)
if err != nil {
return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err)
}
if stageInfo.IsDir() {
// Darwin refuses to rename a read-only directory even when both parent
// directories are writable. The exact source mode was verified above;
// temporarily add owner access for publication, then restore it before
// the final verification and before the source is removed.
if err := skillPathChmod(stage, stageInfo.Mode().Perm()|0o700); err != nil {
return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err)
}
}
// Cross-device staging publishes via a self-owned staging directory,
// so the claim identity is not consulted; discard it.
if _, err := skillPathRenameNoReplace(stage, dst); err != nil {
return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err)
}
if stageInfo.IsDir() {
if err := skillPathChmod(dst, stageInfo.Mode().Perm()); err != nil {
return fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", dst, err)
}
}
if err := skillPathVerify(src, dst); err != nil {
return fmt.Errorf("校验已发布 Skill 备份失败 %s: %w", dst, err)
}
if err := skillPathRemoveAll(stageRoot); err != nil {
return fmt.Errorf("清理已发布 Skill staging 失败 %s: %w", stageRoot, err)
}
stageCleaned = true
if err := removePublishedSkillSource(src); err != nil {
return fmt.Errorf("Skill 目标已发布但源路径删除失败(源 %s 与目标 %s 均保留): %w", src, dst, err)
}
return nil
}
type skillPathDirMode struct {
path string
mode os.FileMode
}
func removePublishedSkillSource(src string) error {
dirModes, err := prepareSkillPathTreeRemoval(src)
if err != nil {
return err
}
removeErr := skillPathRemoveAll(src)
if removeErr == nil {
if _, statErr := skillPathLstat(src); os.IsNotExist(statErr) {
return nil
} else if statErr == nil {
removeErr = errors.New("源路径仍存在")
} else {
removeErr = fmt.Errorf("无法确认源路径已删除: %w", statErr)
}
}
return errors.Join(removeErr, restoreSkillPathDirModes(dirModes))
}
func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) {
var modes []skillPathDirMode
err := skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
mode := info.Mode().Perm()
if mode&0o700 == 0o700 {
return nil
}
if err := skillPathChmod(path, mode|0o700); err != nil {
return err
}
modes = append(modes, skillPathDirMode{path: path, mode: mode})
return nil
})
if err != nil {
return nil, errors.Join(err, restoreSkillPathDirModes(modes))
}
return modes, nil
}
func restoreSkillPathDirModes(modes []skillPathDirMode) error {
var restoreErr error
for i := len(modes) - 1; i >= 0; i-- {
item := modes[i]
if _, err := skillPathLstat(item.path); os.IsNotExist(err) {
continue
} else if err != nil {
restoreErr = errors.Join(restoreErr, err)
continue
}
if err := skillPathChmod(item.path, item.mode); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复源目录权限失败 %s: %w", item.path, err))
}
}
return restoreErr
}
func makeSkillPathTreeWritable(root string) {
// Best effort only: RemoveAll below remains the source of truth and its
// error is returned. This preparation lets it traverse read-only staging
// directories left by a copy or verification failure.
_ = skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr == nil && entry.IsDir() {
_ = skillPathChmod(path, 0o700)
}
return nil
})
}
// RestoreSkillPath restores a previously recorded backup using the same
// rename-first, verified cross-device fallback as backup publication.
func RestoreSkillPath(backup, original string) error {
return moveSkillPathRecoverably(backup, original)
}
func copySkillPathLexically(src, dst string) error {
info, err := skillPathLstat(src)
if err != nil {
return err
}
switch {
case info.IsDir():
// Staging must remain writable while children are copied. Restore the
// source mode only after the directory is complete so read-only Skill
// directories (for example 0555) can still be migrated lexically.
if err := skillPathMkdir(dst, 0o700); err != nil {
return err
}
entries, err := skillPathReadDir(src)
if err != nil {
return err
}
for _, entry := range entries {
if err := copySkillPathLexically(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil {
return err
}
}
return os.Chmod(dst, info.Mode().Perm())
case info.Mode()&os.ModeSymlink != 0:
target, err := skillPathReadlink(src)
if err != nil {
return err
}
return skillPathSymlink(target, dst)
case info.Mode().IsRegular():
return copyRegularSkillFile(src, dst, info.Mode().Perm())
default:
return fmt.Errorf("不支持复制特殊 Skill 路径 %s(mode=%s)", src, info.Mode())
}
}
func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) {
in, err := skillPathOpen(src)
if err != nil {
return err
}
defer func() { err = errors.Join(err, in.Close()) }()
out, err := skillPathOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
defer func() { err = errors.Join(err, out.Close()) }()
if _, err := skillPathCopyBytes(out, in); err != nil {
return err
}
if err := skillPathSync(out); err != nil {
return err
}
return os.Chmod(dst, mode)
}
func verifySkillPathCopy(src, dst string) error {
srcInfo, err := skillPathLstat(src)
if err != nil {
return err
}
dstInfo, err := skillPathLstat(dst)
if err != nil {
return err
}
if srcInfo.Mode()&os.ModeType != dstInfo.Mode()&os.ModeType {
return fmt.Errorf("路径类型不一致: %s (%s) != %s (%s)", src, srcInfo.Mode(), dst, dstInfo.Mode())
}
switch {
case srcInfo.IsDir():
if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() {
return fmt.Errorf("目录权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm())
}
srcNames, err := skillPathEntryNames(src)
if err != nil {
return err
}
dstNames, err := skillPathEntryNames(dst)
if err != nil {
return err
}
if len(srcNames) != len(dstNames) {
return fmt.Errorf("目录项数量不一致: %s (%d) != %s (%d)", src, len(srcNames), dst, len(dstNames))
}
for i, name := range srcNames {
if name != dstNames[i] {
return fmt.Errorf("目录项不一致: %s != %s", name, dstNames[i])
}
if err := verifySkillPathCopy(filepath.Join(src, name), filepath.Join(dst, name)); err != nil {
return err
}
}
return nil
case srcInfo.Mode()&os.ModeSymlink != 0:
srcTarget, err := skillPathReadlink(src)
if err != nil {
return err
}
dstTarget, err := skillPathReadlink(dst)
if err != nil {
return err
}
if srcTarget != dstTarget {
return fmt.Errorf("符号链接目标不一致: %q != %q", srcTarget, dstTarget)
}
return nil
case srcInfo.Mode().IsRegular():
if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() {
return fmt.Errorf("文件权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm())
}
if srcInfo.Size() != dstInfo.Size() {
return fmt.Errorf("文件大小不一致: %s (%d) != %s (%d)", src, srcInfo.Size(), dst, dstInfo.Size())
}
srcDigest, err := skillPathFileDigest(src)
if err != nil {
return err
}
dstDigest, err := skillPathFileDigest(dst)
if err != nil {
return err
}
if srcDigest != dstDigest {
return fmt.Errorf("文件内容摘要不一致: %s != %s", src, dst)
}
return nil
default:
return fmt.Errorf("不支持校验特殊 Skill 路径 %s(mode=%s)", src, srcInfo.Mode())
}
}
func skillPathEntryNames(dir string) ([]string, error) {
entries, err := skillPathReadDir(dir)
if err != nil {
return nil, err
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
sort.Strings(names)
return names, nil
}
func skillPathFileDigest(path string) ([sha256.Size]byte, error) {
f, err := skillPathOpen(path)
if err != nil {
return [sha256.Size]byte{}, err
}
defer f.Close()
hash := sha256.New()
if _, err := skillPathCopyBytes(hash, f); err != nil {
return [sha256.Size]byte{}, err
}
var digest [sha256.Size]byte
copy(digest[:], hash.Sum(nil))
return digest, nil
}
@@ -1,529 +0,0 @@
package upgrade
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) {
t.Run("destination probe", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
original := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == dst {
return nil, os.ErrPermission
}
return original(path)
})
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("destination parent", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
testseam.Swap(t, &skillPathMkdirAll, func(string, os.FileMode) error { return os.ErrPermission })
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("staging creation", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", os.ErrPermission })
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("failed staging cleanup", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
testseam.Swap(t, &skillPathCopy, func(string, string) error { return errors.New("copy failed") })
testseam.Swap(t, &skillPathRemoveAll, func(string) error { return errors.New("cleanup failed") })
err := moveSkillPathRecoverably(src, dst)
if err == nil || !strings.Contains(err.Error(), "copy failed") || !strings.Contains(err.Error(), "cleanup failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("missing copied staging", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
testseam.Swap(t, &skillPathCopy, func(string, string) error { return nil })
testseam.Swap(t, &skillPathVerify, func(string, string) error { return nil })
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "检查跨设备") {
t.Fatalf("error = %v", err)
}
})
for _, tc := range []struct {
name string
path func(src, dst string) string
want string
}{
{name: "prepare staging mode", path: func(_, dst string) string { return filepath.Join(filepath.Dir(dst), "payload") }, want: "准备跨设备"},
{name: "restore published mode", path: func(_, dst string) string { return dst }, want: "恢复已发布"},
} {
t.Run(tc.name, func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
original := skillPathChmod
testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error {
if tc.name == "prepare staging mode" && strings.HasSuffix(path, string(filepath.Separator)+"payload") {
return errors.New("chmod failed")
}
if tc.name == "restore published mode" && path == dst {
return errors.New("chmod failed")
}
return original(path, mode)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v", err)
}
})
}
t.Run("published staging cleanup", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
original := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if strings.Contains(filepath.Base(path), ".cross-device-") {
return errors.New("cleanup failed")
}
return original(path)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "清理已发布") {
t.Fatalf("error = %v", err)
}
})
t.Run("source deletion cannot be confirmed", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
removalAttempted := false
originalLstat := skillPathLstat
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if path == src {
removalAttempted = true
return nil
}
return originalRemove(path)
})
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == src && removalAttempted {
return nil, os.ErrPermission
}
return originalLstat(path)
})
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) {
t.Run("source preparation error", func(t *testing.T) {
testseam.Swap(t, &skillPathWalkDir, func(string, fs.WalkDirFunc) error {
return errors.New("prepare failed")
})
if err := removePublishedSkillSource("ignored"); err == nil || !strings.Contains(err.Error(), "prepare failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("walk error", func(t *testing.T) {
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
return fn(root, nil, errors.New("walk failed"))
})
if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "walk failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("writable mode preparation", func(t *testing.T) {
entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500}
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
return fn(root, entry, nil)
})
var chmodMode os.FileMode
testseam.Swap(t, &skillPathChmod, func(_ string, mode os.FileMode) error {
chmodMode = mode
return nil
})
modes, err := prepareSkillPathTreeRemoval("source")
if err != nil {
t.Fatal(err)
}
if chmodMode != 0o700 || len(modes) != 1 || modes[0].path != "source" || modes[0].mode != 0o500 {
t.Fatalf("prepared mode = %v, records = %#v", chmodMode, modes)
}
})
t.Run("writable mode preparation chmod error", func(t *testing.T) {
entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500}
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
return fn(root, entry, nil)
})
testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error {
return errors.New("chmod failed")
})
if _, err := prepareSkillPathTreeRemoval("source"); err == nil || !strings.Contains(err.Error(), "chmod failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("entry info error", func(t *testing.T) {
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
return fn(root, skillPathErrorDirEntry{}, nil)
})
if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "info failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("missing restored directory", func(t *testing.T) {
if err := restoreSkillPathDirModes([]skillPathDirMode{{path: filepath.Join(t.TempDir(), "missing"), mode: 0o555}}); err != nil {
t.Fatal(err)
}
})
t.Run("restore probe error", func(t *testing.T) {
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission })
if err := restoreSkillPathDirModes([]skillPathDirMode{{path: "blocked", mode: 0o555}}); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("restore chmod error", func(t *testing.T) {
path := t.TempDir()
testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission })
if err := restoreSkillPathDirModes([]skillPathDirMode{{path: path, mode: 0o555}}); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
func TestCrossPlatformCoverageSkillPathLexicalCopyErrors(t *testing.T) {
t.Run("source lstat", func(t *testing.T) {
if err := copySkillPathLexically(filepath.Join(t.TempDir(), "missing"), "unused"); !os.IsNotExist(err) {
t.Fatalf("error = %v", err)
}
})
t.Run("destination mkdir", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.Mkdir(src, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil {
t.Fatal(err)
}
if err := copySkillPathLexically(src, dst); err == nil {
t.Fatal("expected mkdir failure")
}
})
t.Run("source readdir", func(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "src")
if err := os.Mkdir(src, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrPermission })
if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("nested copy", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.Mkdir(src, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
original := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if filepath.Base(path) == "child" {
return nil, os.ErrPermission
}
return original(path)
})
if err := copySkillPathLexically(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("readlink", func(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "link")
if err := os.Symlink("target", src); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission })
if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("special path", func(t *testing.T) {
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) {
return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil
})
if err := copySkillPathLexically("special", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") {
t.Fatalf("error = %v", err)
}
})
t.Run("regular open", func(t *testing.T) {
if err := copyRegularSkillFile(filepath.Join(t.TempDir(), "missing"), "dst", 0o600); !os.IsNotExist(err) {
t.Fatalf("error = %v", err)
}
})
t.Run("regular destination open", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil {
t.Fatal(err)
}
if err := copyRegularSkillFile(src, dst, 0o600); err == nil {
t.Fatal("expected exclusive create failure")
}
})
for _, tc := range []struct {
name string
swap func(*testing.T)
}{
{name: "copy", swap: func(t *testing.T) {
testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy failed") })
}},
{name: "sync", swap: func(t *testing.T) {
testseam.Swap(t, &skillPathSync, func(*os.File) error { return errors.New("sync failed") })
}},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
tc.swap(t)
if err := copyRegularSkillFile(src, dst, 0o600); err == nil || !strings.Contains(err.Error(), tc.name) {
t.Fatalf("error = %v", err)
}
})
}
}
func TestCrossPlatformCoverageSkillPathVerificationErrors(t *testing.T) {
root := t.TempDir()
t.Run("source and destination lstat", func(t *testing.T) {
if err := verifySkillPathCopy(filepath.Join(root, "missing-src"), "dst"); !os.IsNotExist(err) {
t.Fatalf("source error = %v", err)
}
src := filepath.Join(root, "source")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, filepath.Join(root, "missing-dst")); !os.IsNotExist(err) {
t.Fatalf("destination error = %v", err)
}
})
t.Run("directory and file modes", func(t *testing.T) {
for _, tc := range []struct {
name string
src os.FileMode
dst os.FileMode
}{
{name: "directory", src: os.ModeDir | 0o700, dst: os.ModeDir | 0o755},
{name: "file", src: 0o600, dst: 0o644},
} {
t.Run(tc.name, func(t *testing.T) {
src, dst := "src", "dst"
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == src {
return skillPathFakeInfo{mode: tc.src}, nil
}
return skillPathFakeInfo{mode: tc.dst}, nil
})
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "权限") {
t.Fatalf("error = %v", err)
}
})
}
})
t.Run("directory read errors", func(t *testing.T) {
for _, failDest := range []bool{false, true} {
t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
base := t.TempDir()
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
if err := os.Mkdir(src, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dst, 0o700); err != nil {
t.Fatal(err)
}
original := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if (path == dst) == failDest {
return nil, os.ErrPermission
}
return original(path)
})
if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
})
t.Run("nested mismatch", func(t *testing.T) {
base := t.TempDir()
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
if err := os.Mkdir(src, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dst, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dst, "child"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") {
t.Fatalf("error = %v", err)
}
})
t.Run("symlink reads", func(t *testing.T) {
base := t.TempDir()
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
if err := os.Symlink("one", src); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if err := os.Symlink("one", dst); err != nil {
t.Fatal(err)
}
for _, failDest := range []bool{false, true} {
t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
original := skillPathReadlink
testseam.Swap(t, &skillPathReadlink, func(path string) (string, error) {
if (path == dst) == failDest {
return "", os.ErrPermission
}
return original(path)
})
if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
})
t.Run("file digest opens", func(t *testing.T) {
base := t.TempDir()
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
for _, failDest := range []bool{false, true} {
t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
original := skillPathOpen
testseam.Swap(t, &skillPathOpen, func(path string) (*os.File, error) {
if (path == dst) == failDest {
return nil, os.ErrPermission
}
return original(path)
})
if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
})
t.Run("special path", func(t *testing.T) {
fake := skillPathFakeInfo{mode: os.ModeNamedPipe}
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return fake, nil })
if err := verifySkillPathCopy("src", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") {
t.Fatalf("error = %v", err)
}
})
t.Run("entry names", func(t *testing.T) {
if _, err := skillPathEntryNames(filepath.Join(root, "missing-dir")); !os.IsNotExist(err) {
t.Fatalf("error = %v", err)
}
})
t.Run("digest open and read", func(t *testing.T) {
if _, err := skillPathFileDigest(filepath.Join(root, "missing-file")); !os.IsNotExist(err) {
t.Fatalf("open error = %v", err)
}
file := filepath.Join(root, "digest-file")
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("read failed") })
if _, err := skillPathFileDigest(file); err == nil || !strings.Contains(err.Error(), "read failed") {
t.Fatalf("read error = %v", err)
}
})
}
type skillPathFakeInfo struct {
mode os.FileMode
}
func (skillPathFakeInfo) Name() string { return "fake" }
func (skillPathFakeInfo) Size() int64 { return 0 }
func (info skillPathFakeInfo) Mode() os.FileMode { return info.mode }
func (skillPathFakeInfo) ModTime() time.Time { return time.Time{} }
func (info skillPathFakeInfo) IsDir() bool { return info.mode.IsDir() }
func (skillPathFakeInfo) Sys() any { return nil }
type skillPathErrorDirEntry struct{}
func (skillPathErrorDirEntry) Name() string { return "broken" }
func (skillPathErrorDirEntry) IsDir() bool { return true }
func (skillPathErrorDirEntry) Type() os.FileMode { return os.ModeDir }
func (skillPathErrorDirEntry) Info() (os.FileInfo, error) { return nil, errors.New("info failed") }
type skillPathModeDirEntry struct {
mode os.FileMode
}
func (skillPathModeDirEntry) Name() string { return "directory" }
func (skillPathModeDirEntry) IsDir() bool { return true }
func (entry skillPathModeDirEntry) Type() os.FileMode { return entry.mode.Type() }
func (entry skillPathModeDirEntry) Info() (os.FileInfo, error) {
return skillPathFakeInfo{mode: entry.mode}, nil
}
-451
View File
@@ -1,451 +0,0 @@
package upgrade
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillPathCrossDeviceMove(t *testing.T) {
t.Run("directory file and lexical links", func(t *testing.T) {
root := t.TempDir()
t.Cleanup(func() { makeSkillPathTreeWritable(root) })
src := filepath.Join(root, "external", "dingtalk-chat")
dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat")
if err := os.MkdirAll(filepath.Join(src, "references"), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("chat\n"), 0o640); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "references", "guide.md"), []byte("guide\n"), 0o600); err != nil {
t.Fatal(err)
}
linksCreated := true
if err := os.Symlink("SKILL.md", filepath.Join(src, "skill-link")); err != nil {
if runtime.GOOS != "windows" {
t.Fatal(err)
}
linksCreated = false
}
if linksCreated {
if err := os.Symlink("missing.md", filepath.Join(src, "dangling-link")); err != nil {
t.Fatal(err)
}
}
forceCrossDeviceRename(t, src, dst)
if err := moveSkillPathRecoverably(src, dst); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("source must be removed: %v", err)
}
if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "chat\n" {
t.Fatalf("copied file = %q, %v", got, err)
}
if info, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil {
t.Fatalf("copied file stat = %v, %v", info, err)
} else if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 {
t.Fatalf("copied file mode = %v; want 0640", info.Mode().Perm())
}
if linksCreated {
for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} {
info, err := os.Lstat(filepath.Join(dst, name))
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("%s must remain a symlink: %v, %v", name, info, err)
}
if got, err := os.Readlink(filepath.Join(dst, name)); err != nil || got != want {
t.Fatalf("%s target = %q, %v; want %q", name, got, err, want)
}
}
}
assertNoCrossDeviceStage(t, dst)
})
t.Run("regular file root", func(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source-file")
dst := filepath.Join(root, "backup", "source-file")
if err := os.WriteFile(src, []byte("state"), 0o600); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, src, dst)
if err := moveSkillPathRecoverably(src, dst); err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(dst); err != nil || string(got) != "state" {
t.Fatalf("backup file = %q, %v", got, err)
}
})
t.Run("read-only directory mode is restored after copying children", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX directory permission semantics are unavailable")
}
root := t.TempDir()
t.Cleanup(func() { makeSkillPathTreeWritable(root) })
src := filepath.Join(root, "external", "dingtalk-chat")
dst := filepath.Join(root, "backup", "dingtalk-chat")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("read only\n"), 0o444); err != nil {
t.Fatal(err)
}
if err := os.Chmod(src, 0o555); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, src, dst)
if err := moveSkillPathRecoverably(src, dst); err != nil {
t.Fatal(err)
}
if info, err := os.Stat(dst); err != nil || info.Mode().Perm() != 0o555 {
t.Fatalf("copied directory mode = %v, %v", info, err)
}
if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "read only\n" {
t.Fatalf("copied read-only file = %q, %v", got, err)
}
})
t.Run("rollback uses the same cross-device contract", func(t *testing.T) {
root := t.TempDir()
backup := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat")
original := filepath.Join(root, "external", "claude", "skills", "dingtalk-chat")
if err := os.MkdirAll(backup, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(backup, "SKILL.md"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, backup, original)
if err := RestoreSkillPath(backup, original); err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(filepath.Join(original, "SKILL.md")); err != nil || string(got) != "old" {
t.Fatalf("restored content = %q, %v", got, err)
}
if _, err := os.Lstat(backup); !os.IsNotExist(err) {
t.Fatalf("backup must be consumed after verified restore: %v", err)
}
})
}
func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) {
t.Run("non cross-device rename never copies", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
copied := false
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", os.ErrPermission })
testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil })
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
if copied {
t.Fatal("permission failure must not enter cross-device copy fallback")
}
assertSkillSourcePreserved(t, src)
})
for _, tc := range []struct {
name string
copy func(string, string) error
verify func(string, string) error
}{
{name: "copy failure", copy: func(string, string) error { return errors.New("copy failed") }},
{name: "verification failure", verify: func(string, string) error { return errors.New("verify failed") }},
} {
t.Run(tc.name, func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
if tc.copy != nil {
testseam.Swap(t, &skillPathCopy, tc.copy)
}
if tc.verify != nil {
testseam.Swap(t, &skillPathVerify, tc.verify)
}
if err := moveSkillPathRecoverably(src, dst); err == nil {
t.Fatal("expected failure")
}
assertSkillSourcePreserved(t, src)
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
t.Fatalf("formal backup must not publish: %v", err)
}
assertNoCrossDeviceStage(t, dst)
})
}
t.Run("staging publish failure", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) (string, error) {
if from == src && to == dst {
return "", testCrossDeviceError()
}
if to == dst {
return "", errors.New("publish failed")
}
return originalRename(from, to)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "publish failed") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
assertNoCrossDeviceStage(t, dst)
})
t.Run("published verification failure preserves both", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
calls := 0
testseam.Swap(t, &skillPathVerify, func(string, string) error {
calls++
if calls == 2 {
return errors.New("published verify failed")
}
return nil
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "published verify failed") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
if _, err := os.Lstat(dst); err != nil {
t.Fatalf("verified backup candidate must remain: %v", err)
}
assertNoCrossDeviceStage(t, dst)
})
t.Run("source removal failure preserves both", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if path == src {
return errors.New("remove failed")
}
return originalRemove(path)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
if _, err := os.Lstat(dst); err != nil {
t.Fatalf("formal backup must remain: %v", err)
}
})
t.Run("read-only source mode is restored after removal failure", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX directory permission semantics are unavailable")
}
src, dst := makeSkillMoveFixture(t)
if err := os.Chmod(src, 0o555); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, src, dst)
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if path == src {
return errors.New("remove failed")
}
return originalRemove(path)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") {
t.Fatalf("error = %v", err)
}
if info, err := os.Stat(src); err != nil || info.Mode().Perm() != 0o555 {
t.Fatalf("source directory mode after failed removal = %v, %v", info, err)
}
if err := os.Chmod(src, 0o755); err != nil {
t.Fatal(err)
}
})
t.Run("source permission preparation failure preserves both", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX directory permission semantics are unavailable")
}
src, dst := makeSkillMoveFixture(t)
if err := os.Chmod(src, 0o555); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, src, dst)
originalChmod := skillPathChmod
testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error {
if path == src {
return errors.New("chmod failed")
}
return originalChmod(path, mode)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "chmod failed") {
t.Fatalf("error = %v", err)
}
if err := originalChmod(src, 0o755); err != nil {
t.Fatal(err)
}
})
t.Run("lying remover is detected", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if path == src {
return nil
}
return originalRemove(path)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "仍存在") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
})
t.Run("same-filesystem fast path", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
if err := moveSkillPathRecoverably(src, dst); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("source still exists: %v", err)
}
})
t.Run("existing destination is never overwritten", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "目标已存在") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
})
}
func TestCrossPlatformCoverageVerifySkillPathCopyMismatches(t *testing.T) {
t.Run("type", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dst, 0o700); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") {
t.Fatalf("error = %v", err)
}
})
t.Run("directory entries", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.Mkdir(src, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dst, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "one"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "数量") {
t.Fatalf("error = %v", err)
}
if err := os.WriteFile(filepath.Join(dst, "two"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "目录项") {
t.Fatalf("error = %v", err)
}
})
t.Run("file size and digest", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.WriteFile(src, []byte("same"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("longer"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "大小") {
t.Fatalf("error = %v", err)
}
if err := os.WriteFile(dst, []byte("diff"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "摘要") {
t.Fatalf("error = %v", err)
}
})
t.Run("symlink target", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.Symlink("one", src); err != nil {
if runtime.GOOS == "windows" {
t.Skip("symlink creation unavailable")
}
t.Fatal(err)
}
if err := os.Symlink("two", dst); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "链接目标") {
t.Fatalf("error = %v", err)
}
})
}
func forceCrossDeviceRename(t *testing.T, src, dst string) {
t.Helper()
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) (string, error) {
if from == src && to == dst {
return "", testCrossDeviceError()
}
return originalRename(from, to)
})
}
func makeSkillMoveFixture(t *testing.T) (string, string) {
t.Helper()
root := t.TempDir()
t.Cleanup(func() { makeSkillPathTreeWritable(root) })
src := filepath.Join(root, "external", "dingtalk-chat")
dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
return src, dst
}
func assertSkillSourcePreserved(t *testing.T, src string) {
t.Helper()
if got, err := os.ReadFile(filepath.Join(src, "SKILL.md")); err != nil || string(got) != "old" {
t.Fatalf("source content = %q, %v", got, err)
}
}
func assertNoCrossDeviceStage(t *testing.T, dst string) {
t.Helper()
entries, err := os.ReadDir(filepath.Dir(dst))
if err != nil {
if os.IsNotExist(err) {
return
}
t.Fatal(err)
}
prefix := "." + filepath.Base(dst) + ".cross-device-"
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), prefix) {
t.Fatalf("cross-device staging leaked: %s", entry.Name())
}
}
}
-12
View File
@@ -1,12 +0,0 @@
//go:build !windows
package upgrade
import (
"errors"
"syscall"
)
func isCrossDeviceError(err error) bool {
return errors.Is(err, syscall.EXDEV)
}
@@ -1,16 +0,0 @@
//go:build !windows
package upgrade
import (
"os"
"syscall"
)
func testCrossDeviceError() error {
return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: syscall.EXDEV}
}
func testNoReplaceUnsupportedErrors() []error {
return []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported}
}
@@ -1,13 +0,0 @@
//go:build windows
package upgrade
import (
"errors"
"golang.org/x/sys/windows"
)
func isCrossDeviceError(err error) bool {
return errors.Is(err, windows.ERROR_NOT_SAME_DEVICE)
}
@@ -1,24 +0,0 @@
//go:build windows
package upgrade
import (
"os"
"testing"
"golang.org/x/sys/windows"
)
func testCrossDeviceError() error {
return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: windows.ERROR_NOT_SAME_DEVICE}
}
func testNoReplaceUnsupportedErrors() []error {
return []error{errNoReplaceRenameUnsupported}
}
func TestCrossPlatformCoverageWindowsCrossDeviceError(t *testing.T) {
if !isCrossDeviceError(testCrossDeviceError()) {
t.Fatal("ERROR_NOT_SAME_DEVICE must enter the cross-device fallback")
}
}
-241
View File
@@ -1,241 +0,0 @@
package upgrade
import (
"crypto/sha256"
"errors"
"fmt"
"hash"
"os"
"path/filepath"
"sort"
)
// ErrSkillPathPublicationUncertain marks a publication whose transactional
// outcome cannot be determined safely: this transaction did occupy the
// destination at some point, but a stronger witness — the fresh mkdir-claim
// identity captured by the child-move fallback — now indicates that the
// object currently at the destination may belong to a concurrent writer.
// Callers must not automatically retract, replace, or retry-copy over the
// destination; instead they should surface the state so the user or a
// higher layer can inspect it. Errors wrapping this sentinel are safe to
// test with errors.Is.
var ErrSkillPathPublicationUncertain = errors.New("Skill 发布状态不确定")
// SkillPathPublication records enough immutable identity to prove that a
// destination still belongs to the transaction that published it. The
// fingerprint is intentionally private so callers cannot forge records.
type SkillPathPublication struct {
Destination string
fingerprint [sha256.Size]byte
identity os.FileInfo
incarnation string
fileID string
}
// PublishSkillPathNoReplace atomically publishes a staged path without ever
// replacing a destination created after the backup phase.
//
// The rename returns a mkdir-claim identity when (and only when) the
// child-move fallback published dest. That identity is the tamperproof
// witness that dest is still the mkdir claim this transaction created
// (unlike the staged shell, whose presence alone is compatible with a
// wholesale replacement of dest by a concurrent writer). When the claim
// identity no longer matches dest, publication reports uncertain state
// (ErrSkillPathPublicationUncertain) and keeps dest — auto-retracting or
// overwriting would delete the concurrent writer's data.
func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication, error) {
identity, err := skillPathLstat(staged)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("读取待发布 Skill 身份失败 %s: %w", staged, err)
}
fingerprint, err := fingerprintSkillPath(staged)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err)
}
stagedFileID := skillPathFileIdentity(staged)
claimIdentity, err := skillPathRenameNoReplace(staged, destination)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err)
}
// If the child-move fallback published dest, the claim identity must
// still describe the object at destination. A wholesale replacement of
// dest between the mkdir claim and this check produces a different
// identity — dest is a foreign object and must not be treated as this
// transaction's publication. An unreadable live identity (dest already
// vanished, or the platform cannot report one) defers to the standard
// confirmation checks below instead of guessing.
if claimIdentity != "" {
if liveIdentity := skillPathFileIdentity(destination); liveIdentity != "" && liveIdentity != claimIdentity {
return SkillPathPublication{}, fmt.Errorf("%w:child-move 发布后目标 %s 已被替换;目标保留", ErrSkillPathPublicationUncertain, destination)
}
}
publishedIdentity, err := skillPathLstat(destination)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err)
}
publishedFingerprint, err := fingerprintSkillPath(destination)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err)
}
publishedFileID := skillPathFileIdentity(destination)
// When claim identity is available (child-move path), it is the
// authoritative witness of "dest is our object"; the staged inode
// necessarily differs from the mkdir-claim inode. Only enforce the
// staged-inode identity proof when there is no claim identity — that
// is, when the atomic primitive or the fast-path rename consumed the
// staged inode into dest.
if claimIdentity == "" && !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) {
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination)
}
if publishedFingerprint != fingerprint {
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): staging 内容已变化", destination)
}
return SkillPathPublication{
Destination: destination,
fingerprint: fingerprint,
identity: publishedIdentity,
incarnation: skillPathFileIncarnation(publishedIdentity),
fileID: publishedFileID,
}, nil
}
// RollbackSkillPathPublications removes only objects that can still be proven
// to have been published by this transaction. Each live path is first claimed
// into a private sibling quarantine. A concurrent replacement is restored when
// possible, otherwise retained in quarantine and reported explicitly.
func RollbackSkillPathPublications(publications []SkillPathPublication) error {
var rollbackErr error
for i := len(publications) - 1; i >= 0; i-- {
if err := rollbackSkillPathPublication(publications[i]); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
return rollbackErr
}
func rollbackSkillPathPublication(publication SkillPathPublication) (err error) {
destination := publication.Destination
quarantineRoot, err := skillPathMkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".rollback-")
if err != nil {
return fmt.Errorf("创建 Skill 回滚隔离目录失败 %s: %w", destination, err)
}
quarantine := filepath.Join(quarantineRoot, "payload")
cleanupRoot := func() error {
if cleanupErr := skillPathRemoveAll(quarantineRoot); cleanupErr != nil {
return fmt.Errorf("清理 Skill 回滚隔离目录失败 %s: %w", quarantineRoot, cleanupErr)
}
return nil
}
liveIdentity, liveIdentityErr := skillPathLstat(destination)
if os.IsNotExist(liveIdentityErr) {
return cleanupRoot()
}
liveFingerprint, liveFingerprintErr := fingerprintSkillPath(destination)
liveVerificationErr := errors.Join(liveIdentityErr, liveFingerprintErr)
if liveVerificationErr != nil {
return errors.Join(
fmt.Errorf("拒绝删除无法验证的 Skill %s: %w", destination, liveVerificationErr),
cleanupRoot(),
)
}
if publication.identity == nil ||
!skillPathIdentityProven(publication.identity, liveIdentity, publication.fileID, skillPathFileIdentity(destination)) ||
publication.incarnation != skillPathFileIncarnation(liveIdentity) ||
liveFingerprint != publication.fingerprint {
return errors.Join(
fmt.Errorf("拒绝删除非本事务 Skill %s: 发布对象身份已变化", destination),
cleanupRoot(),
)
}
if err := skillPathRename(destination, quarantine); err != nil {
cleanupErr := cleanupRoot()
if os.IsNotExist(err) {
return cleanupErr
}
return errors.Join(fmt.Errorf("隔离待回滚 Skill 失败 %s: %w", destination, err), cleanupErr)
}
actualIdentity, identityErr := skillPathLstat(quarantine)
actual, fingerprintErr := fingerprintSkillPath(quarantine)
if identityErr == nil && fingerprintErr == nil &&
skillPathIdentityProven(publication.identity, actualIdentity, publication.fileID, skillPathFileIdentity(quarantine)) &&
actual == publication.fingerprint {
if removeErr := removePublishedSkillSource(quarantine); removeErr != nil {
return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr)
}
return cleanupRoot()
}
verificationErr := errors.Join(identityErr, fingerprintErr)
if verificationErr == nil {
verificationErr = errors.New("发布对象身份已变化")
}
// Rollback restore is not a publication path; ignore any claim
// identity returned by the rename — no ownership check follows.
if _, restoreErr := skillPathRenameNoReplace(quarantine, destination); restoreErr != nil {
return fmt.Errorf("拒绝删除非本事务 Skill %s: %v;并发对象保留于 %s,恢复原路径失败: %w", destination, verificationErr, quarantine, restoreErr)
}
return errors.Join(
fmt.Errorf("拒绝删除非本事务 Skill %s: %w", destination, verificationErr),
cleanupRoot(),
)
}
func fingerprintSkillPath(path string) ([sha256.Size]byte, error) {
h := sha256.New()
if err := fingerprintSkillPathInto(h, path); err != nil {
return [sha256.Size]byte{}, err
}
var result [sha256.Size]byte
copy(result[:], h.Sum(nil))
return result, nil
}
func fingerprintSkillPathInto(h hash.Hash, path string) error {
info, err := skillPathLstat(path)
if err != nil {
return err
}
writeFingerprintField(h, info.Mode().Type().String())
writeFingerprintField(h, info.Mode().Perm().String())
switch {
case info.IsDir():
entries, err := skillPathReadDir(path)
if err != nil {
return err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
writeFingerprintField(h, fmt.Sprintf("%d", len(entries)))
for _, entry := range entries {
writeFingerprintField(h, entry.Name())
if err := fingerprintSkillPathInto(h, filepath.Join(path, entry.Name())); err != nil {
return err
}
}
return nil
case info.Mode()&os.ModeSymlink != 0:
target, err := skillPathReadlink(path)
if err != nil {
return err
}
writeFingerprintField(h, target)
return nil
case info.Mode().IsRegular():
digest, err := skillPathFileDigest(path)
if err != nil {
return err
}
writeFingerprintField(h, fmt.Sprintf("%d", info.Size()))
_, err = h.Write(digest[:])
return err
default:
return fmt.Errorf("不支持识别特殊 Skill 路径 %s(mode=%s)", path, info.Mode())
}
}
func writeFingerprintField(h hash.Hash, value string) {
_, _ = fmt.Fprintf(h, "%d:", len(value))
_, _ = h.Write([]byte(value))
}
@@ -1,50 +0,0 @@
//go:build darwin
package upgrade
import (
"fmt"
"os"
"syscall"
)
func skillPathFileIncarnationImpl(info os.FileInfo) string {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Sprintf("unknown:%T", info.Sys())
}
return fmt.Sprintf("%d:%d:%d:%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Gen,
stat.Ctimespec.Sec, stat.Ctimespec.Nsec, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec)
}
func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool {
return os.SameFile(left, right)
}
// skillPathFileIdentityImpl reports the device/inode pair from lstat. POSIX
// has no open-by-ID handle, but dev:ino identifies the object for its
// lifetime and is the same witness os.SameFile compares. It anchors the
// mkdir-claim identity of the child-move fallback: a wholesale replacement
// of the claimed destination reports a different identity, except in the
// classic inode-reuse race on recycled-inode filesystems, which the
// publication fingerprint backstop still catches. An unreadable or
// unrecognizable path reports "" and callers treat that as "no witness".
func skillPathFileIdentityImpl(path string) string {
info, err := skillPathLstat(path)
if err != nil {
return ""
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return ""
}
return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino)
}
// skillPathIdentityProven stays SameFile-based on POSIX: the file ID above is
// the same dev:ino witness, so preferring it would add no strength while
// making the proof refuse publishes whose staged lstat (and therefore staged
// identity) could not be read.
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
return skillPathSameFileIdentity(staged, published)
}
@@ -1,49 +0,0 @@
//go:build linux
package upgrade
import (
"fmt"
"os"
"syscall"
)
func skillPathFileIncarnationImpl(info os.FileInfo) string {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Sprintf("unknown:%T", info.Sys())
}
return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Ctim.Sec, stat.Ctim.Nsec)
}
func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool {
return os.SameFile(left, right)
}
// skillPathFileIdentityImpl reports the device/inode pair from lstat. POSIX
// has no open-by-ID handle, but dev:ino identifies the object for its
// lifetime and is the same witness os.SameFile compares. It anchors the
// mkdir-claim identity of the child-move fallback: a wholesale replacement
// of the claimed destination reports a different identity, except in the
// classic inode-reuse race on recycled-inode filesystems (notably tmpfs),
// which the publication fingerprint backstop still catches. An unreadable
// or unrecognizable path reports "" and callers treat that as "no witness".
func skillPathFileIdentityImpl(path string) string {
info, err := skillPathLstat(path)
if err != nil {
return ""
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return ""
}
return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino)
}
// skillPathIdentityProven stays SameFile-based on POSIX: the file ID above is
// the same dev:ino witness, so preferring it would add no strength while
// making the proof refuse publishes whose staged lstat (and therefore staged
// identity) could not be read.
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
return skillPathSameFileIdentity(staged, published)
}
@@ -1,27 +0,0 @@
//go:build !linux && !darwin && !windows
package upgrade
import (
"fmt"
"os"
)
func skillPathFileIncarnationImpl(info os.FileInfo) string {
return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode())
}
func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool {
return os.SameFile(left, right)
}
// skillPathFileIdentityImpl reports no identity on platforms whose stat
// layout we do not decode; the child-move claim witness degrades gracefully
// to the publication fingerprint backstop.
func skillPathFileIdentityImpl(_ string) string {
return ""
}
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
return skillPathSameFileIdentity(staged, published)
}
@@ -1,64 +0,0 @@
//go:build windows
package upgrade
import (
"fmt"
"os"
"syscall"
"golang.org/x/sys/windows"
)
func skillPathFileIncarnationImpl(info os.FileInfo) string {
stat, ok := info.Sys().(*syscall.Win32FileAttributeData)
if !ok {
return fmt.Sprintf("unknown:%T", info.Sys())
}
return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime)
}
// skillPathSameFileIdentity returns false because os.FileInfo on Windows does
// not expose the stable file ID (VolumeSerialNumber / FileIndex from
// GetFileInformationByHandle), which is the only primitive that distinguishes
// a recreated file from the original after NTFS file tunneling restores the
// creation time. Callers must use skillPathIdentityProven, which compares file
// IDs obtained via GetFileInformationByHandle.
func skillPathSameFileIdentityImpl(_, _ os.FileInfo) bool {
return false
}
// skillPathFileIdentityImpl queries the stable volume serial number and file
// index via GetFileInformationByHandle. Unlike creation time (which NTFS file
// tunneling can restore for a recreated same-named object), the file index
// uniquely identifies the file on the volume for its lifetime.
//
// FILE_FLAG_OPEN_REPARSE_POINT opens symlinks themselves rather than following
// them to the target. This matters at publish time because a staged symlink
// carries a relative target computed for the final destination, which may not
// resolve from the staging directory. Opening the reparse point yields a file
// ID that is stable across the rename and is the correct identity to compare.
func skillPathFileIdentityImpl(path string) (id string) {
if p, err := windows.UTF16PtrFromString(path); err == nil {
if handle, err := windows.CreateFile(
p, windows.FILE_READ_ATTRIBUTES,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil, windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0,
); err == nil {
defer windows.CloseHandle(handle)
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err == nil {
id = fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow)
}
}
}
return
}
// skillPathIdentityProven is the real identity proof on Windows. An empty
// expected value means the file ID could not be obtained at publish time, so
// identity cannot be proven and the caller must refuse the auto-delete.
func skillPathIdentityProven(_, _ os.FileInfo, expected, actual string) bool {
return expected != "" && expected == actual
}
-532
View File
@@ -1,532 +0,0 @@
package upgrade
import (
"crypto/sha256"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testing.T) {
t.Run("existing destination is never replaced", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
seedUpgradeSkill(t, destination, "concurrent", false)
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil {
t.Fatal("expected no-replace publication to fail")
}
assertUpgradeSkillContent(t, destination, "concurrent")
assertUpgradeSkillContent(t, staged, "new")
})
t.Run("owned publication is removed", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(destination); !os.IsNotExist(err) {
t.Fatalf("owned publication remains: %v", err)
}
})
t.Run("concurrent replacement is quarantined instead of deleted", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(destination); err != nil {
t.Fatal(err)
}
// Even a byte-for-byte identical replacement is not owned by this
// transaction: inode identity, not content alone, is authoritative.
seedUpgradeSkill(t, destination, "new", false)
err = RollbackSkillPathPublications([]SkillPathPublication{publication})
if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("replacement rollback error = %v", err)
}
assertUpgradeSkillContent(t, destination, "new")
})
t.Run("tunneled replacement with same creation time is still refused", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
// NTFS file tunneling can restore the original creation time for a
// same-named recreation, defeating the incarnation check. The file ID
// (or inode on Unix) must still differ and block the rollback.
testseam.Swap(t, &skillPathFileIncarnation, func(os.FileInfo) string { return "tunneled" })
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
// Simulate the identity change that a concurrent replacement causes.
// On Windows, skillPathIdentityProven compares the recorded file ID
// from GetFileInformationByHandle with the live one and ignores the
// SameFile seam; NTFS can reuse the freed MFT record for the
// recreation, so the live probe is perturbed through the file-ID
// seam as well. On Unix, where the file ID is advisory, this swap
// replaces the os.SameFile check (which can return true for a
// recreated file on tmpfs due to inode reuse) so the test is
// deterministic across filesystems.
testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false })
originalFileIdentity := skillPathFileIdentity
testseam.Swap(t, &skillPathFileIdentity, func(path string) string {
return originalFileIdentity(path) + ":replaced"
})
if err := os.RemoveAll(destination); err != nil {
t.Fatal(err)
}
seedUpgradeSkill(t, destination, "new", false)
err = RollbackSkillPathPublications([]SkillPathPublication{publication})
if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("tunneled replacement rollback error = %v", err)
}
assertUpgradeSkillContent(t, destination, "new")
})
}
func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) {
failure := errors.New("injected publication failure")
t.Run("platform no-replace path encoding", func(t *testing.T) {
if _, err := renameSkillPathNoReplace("invalid\x00source", filepath.Join(t.TempDir(), "dest")); err == nil {
t.Fatal("expected invalid source path failure")
}
source := filepath.Join(t.TempDir(), "source")
if err := os.WriteFile(source, []byte("value"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := renameSkillPathNoReplace(source, "invalid\x00destination"); err == nil {
t.Fatal("expected invalid destination path failure")
}
})
t.Run("publish identity", func(t *testing.T) {
if _, err := PublishSkillPathNoReplace(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dest")); err == nil {
t.Fatal("expected identity failure")
}
})
t.Run("publish fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
seedUpgradeSkill(t, staged, "value", false)
originalLstat := skillPathLstat
calls := 0
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
calls++
if calls == 2 {
return nil, failure
}
return originalLstat(path)
})
if _, err := PublishSkillPathNoReplace(staged, filepath.Join(root, "dest")); !errors.Is(err, failure) {
t.Fatal("expected fingerprint failure")
}
})
t.Run("publish confirmation missing", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) {
if _, err := originalRename(source, target); err != nil {
return "", err
}
return "", os.RemoveAll(target)
})
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "对象保留") {
t.Fatalf("publish confirmation error = %v", err)
}
})
t.Run("publish confirmation identity changed", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
// A real rename consumed the staged path (fast path), but the object
// at the destination must be proven to be the staged one. Whether a
// same-content swap is observable through inode/file-ID comparison
// depends on the filesystem — CI runners' ext4/overlayfs recycle
// inodes eagerly and NTFS can immediately reuse the freed MFT record,
// so reproducing the swap physically is not portable. Force the
// failure through both underlying primitives the platform-specific
// proof consults: os.SameFile (Unix) and the stable file ID
// (Windows), pinning the confirmation's rejection on every platform.
testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false })
originalFileIdentity := skillPathFileIdentity
stagedID := ""
testseam.Swap(t, &skillPathFileIdentity, func(path string) string {
id := originalFileIdentity(path)
if stagedID == "" {
stagedID = id
return id
}
// The published probe must report a different object than the
// staged one so the proof fails on both platforms.
return id + ":swapped"
})
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") {
t.Fatalf("publish identity error = %v", err)
}
assertUpgradeSkillContent(t, destination, "value")
})
t.Run("publish confirmation fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
originalRename := skillPathRenameNoReplace
published := false
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) {
if _, err := originalRename(source, target); err != nil {
return "", err
}
published = true
return "", nil
})
originalReadDir := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if published && path == destination {
return nil, failure
}
return originalReadDir(path)
})
if _, err := PublishSkillPathNoReplace(staged, destination); !errors.Is(err, failure) || !strings.Contains(err.Error(), "内容失败") {
t.Fatalf("publish fingerprint confirmation error = %v", err)
}
})
t.Run("rollback temp", func(t *testing.T) {
testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) {
t.Fatalf("temp error = %v", err)
}
})
t.Run("missing destination cleanup", func(t *testing.T) {
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); err != nil {
t.Fatal(err)
}
})
t.Run("missing destination cleanup failure", func(t *testing.T) {
testseam.Swap(t, &skillPathRemoveAll, func(string) error { return failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); !errors.Is(err, failure) {
t.Fatalf("cleanup error = %v", err)
}
})
t.Run("quarantine rename", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRename, func(string, string) error { return failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("quarantine error = %v", err)
}
})
t.Run("live verification", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalReadDir := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == destination {
return nil, failure
}
return originalReadDir(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("live verification error = %v", err)
}
})
t.Run("destination disappears before quarantine", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRename, func(source, target string) error {
if err := os.RemoveAll(source); err != nil {
return err
}
return &os.PathError{Op: "rename", Path: source, Err: os.ErrNotExist}
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil {
t.Fatal(err)
}
})
t.Run("owned remove", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if filepath.Base(path) == "payload" {
return failure
}
return originalRemove(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("remove error = %v", err)
}
})
t.Run("quarantined fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if filepath.Base(path) == "payload" {
return nil, failure
}
return originalLstat(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("fingerprint error = %v", err)
}
})
t.Run("mismatch restore and cleanup", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "published", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalRename := skillPathRename
testseam.Swap(t, &skillPathRename, func(source, target string) error {
if source == destination {
if err := os.RemoveAll(source); err != nil {
return err
}
seedUpgradeSkill(t, source, "replacement", false)
}
return originalRename(source, target)
})
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if strings.Contains(filepath.Base(path), ".destination.rollback-") {
return failure
}
return originalRemove(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("mismatch cleanup error = %v", err)
}
assertUpgradeSkillContent(t, destination, "replacement")
})
t.Run("mismatch restore failure", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "published", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalRename := skillPathRename
testseam.Swap(t, &skillPathRename, func(source, target string) error {
if source == destination {
if err := os.RemoveAll(source); err != nil {
return err
}
seedUpgradeSkill(t, source, "replacement", false)
}
return originalRename(source, target)
})
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) || !strings.Contains(err.Error(), "并发对象保留") {
t.Fatalf("mismatch restore error = %v", err)
}
})
t.Run("fingerprint read failures and lexical types", func(t *testing.T) {
t.Run("directory ordering", func(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"b", "a"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
if _, err := fingerprintSkillPath(dir); err != nil {
t.Fatal(err)
}
})
t.Run("directory read", func(t *testing.T) {
dir := t.TempDir()
testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
t.Fatalf("read dir error = %v", err)
}
})
t.Run("recursive child", func(t *testing.T) {
dir := t.TempDir()
child := filepath.Join(dir, "child")
if err := os.WriteFile(child, []byte("value"), 0o644); err != nil {
t.Fatal(err)
}
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == child {
return nil, failure
}
return originalLstat(path)
})
if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
t.Fatalf("child error = %v", err)
}
})
t.Run("symlink read", func(t *testing.T) {
link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink("missing", link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", failure })
if _, err := fingerprintSkillPath(link); !errors.Is(err, failure) {
t.Fatalf("readlink error = %v", err)
}
})
t.Run("symlink success", func(t *testing.T) {
link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink("missing", link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := fingerprintSkillPath(link); err != nil {
t.Fatal(err)
}
})
t.Run("file digest", func(t *testing.T) {
file := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(file, []byte("value"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathOpen, func(string) (*os.File, error) { return nil, failure })
if _, err := fingerprintSkillPath(file); !errors.Is(err, failure) {
t.Fatalf("digest error = %v", err)
}
})
t.Run("special", func(t *testing.T) {
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) {
return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil
})
if err := fingerprintSkillPathInto(sha256.New(), "special"); err == nil {
t.Fatal("expected special path error")
}
})
t.Run("unknown platform identity shape", func(t *testing.T) {
if got := skillPathFileIncarnation(skillPathFakeInfo{}); got == "" {
t.Fatal("empty incarnation")
}
})
t.Run("synthetic info does not match same-file identity", func(t *testing.T) {
fake := skillPathFakeInfo{}
if skillPathSameFileIdentity(fake, fake) {
t.Fatal("synthetic info should not match")
}
})
})
}
func TestCrossPlatformCoverageUpgradeRollbackRetainsConcurrentReplacement(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".agents", "skills")
first := filepath.Join(base, "dingtalk-first")
second := filepath.Join(base, "dingtalk-second")
seedUpgradeSkill(t, first, "old first", false)
seedUpgradeSkill(t, second, "old second", false)
stageRoot := filepath.Join(base, ".stage")
stagedFirst := filepath.Join(stageRoot, "dingtalk-first")
stagedSecond := filepath.Join(stageRoot, "dingtalk-second")
seedUpgradeSkill(t, stagedFirst, "new first", false)
seedUpgradeSkill(t, stagedSecond, "new second", false)
failure := errors.New("injected second publication failure")
originalPublish := upgradePublishSkillPath
calls := 0
testseam.Swap(t, &upgradePublishSkillPath, func(staged, destination string) (SkillPathPublication, error) {
calls++
if calls == 2 {
if err := os.RemoveAll(first); err != nil {
t.Fatal(err)
}
seedUpgradeSkill(t, first, "concurrent", false)
return SkillPathPublication{}, failure
}
return originalPublish(staged, destination)
})
err := publishStagedSkillSet(home, []stagedSkillDir{
{staged: stagedFirst, dest: first},
{staged: stagedSecond, dest: second},
}, []string{first, second})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("transaction error = %v", err)
}
assertUpgradeSkillContent(t, first, "concurrent")
assertUpgradeSkillContent(t, second, "old second")
matches, globErr := filepath.Glob(filepath.Join(home, ".dws", "skill-backups", "*", ".agents-skills-dingtalk-first", "SKILL.md"))
if globErr != nil || len(matches) != 1 {
t.Fatalf("retained old backup = %v, %v", matches, globErr)
}
content, readErr := os.ReadFile(matches[0])
if readErr != nil || string(content) != "old first" {
t.Fatalf("retained backup content = %q, %v", content, readErr)
}
}
@@ -1,451 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// childMoveDirEntry fakes one directory entry for seam-driven claim scans.
type childMoveDirEntry struct {
name string
isDir bool
}
func (entry childMoveDirEntry) Name() string { return entry.name }
func (entry childMoveDirEntry) IsDir() bool { return entry.isDir }
func (entry childMoveDirEntry) Type() os.FileMode { return entry.info().Mode().Type() }
func (entry childMoveDirEntry) Info() (os.FileInfo, error) { return entry.info(), nil }
func (entry childMoveDirEntry) info() os.FileInfo {
if entry.isDir {
return skillPathFakeInfo{mode: os.ModeDir | 0o755}
}
return skillPathFakeInfo{mode: 0o644}
}
// forceChildMove routes every directory publication through the child-move
// slow path: the atomic primitive reports the flag unsupported and the
// fast-path rename fails the way platforms that refuse renaming over any
// existing directory do.
func forceChildMove(t *testing.T) {
t.Helper()
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist })
}
// TestCrossPlatformCoverageChildMoveEdges pins the error and dispatch
// branches of the child-move directory fallback on every platform: the
// platform coverage gates only execute TestCrossPlatformCoverage-named
// tests, so these branches must be driven through seams rather than the
// platform-specific filesystem behaviors that cover them incidentally
// elsewhere.
func TestCrossPlatformCoverageChildMoveEdges(t *testing.T) {
t.Run("directory source stat failure surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
original := skillPathLstat
calls := 0
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == source {
calls++
if calls > 1 {
return nil, os.ErrPermission
}
}
return original(path)
})
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
t.Fatalf("directory stat failure = %v", err)
}
})
t.Run("claim read failure surfaces and reports the cleanup check", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == destination {
return nil, os.ErrPermission
}
return os.ReadDir(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || !strings.Contains(err.Error(), "检查降级发布目标") {
t.Fatalf("claim read failure = %v", err)
}
})
t.Run("claim vanished during cleanup is tolerated", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
destinationReads := 0
original := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == destination {
destinationReads++
if destinationReads == 1 {
return nil, os.ErrPermission
}
return nil, os.ErrNotExist
}
return original(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || strings.Contains(err.Error(), "检查降级发布目标") {
t.Fatalf("vanished claim cleanup = %v", err)
}
})
t.Run("file child link failure surfaces and the empty claim cleanup reports", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission })
testseam.Swap(t, &skillPathRemove, func(path string) error {
if path == destination {
return os.ErrPermission
}
return os.Remove(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "发布 Skill 文件失败") || !strings.Contains(err.Error(), "清理降级发布目标") {
t.Fatalf("link failure = %v", err)
}
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("file child collision reports ErrExist", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrExist })
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) {
t.Fatalf("file child collision = %v, want ErrExist", err)
}
})
t.Run("later child failure rolls earlier children back", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
if err := os.WriteFile(filepath.Join(source, "second"), []byte("second"), 0o644); err != nil {
t.Fatal(err)
}
forceChildMove(t)
// The second child's link fails; the forced rename failure then also
// breaks the rollback rename of the already relocated first child.
testseam.Swap(t, &skillPathLink, func(source, _ string) error {
if filepath.Base(source) == "second" {
return os.ErrPermission
}
return os.Link(source, filepath.Join(destination, filepath.Base(source)))
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "迁移 Skill 目录项失败") {
t.Fatalf("child failure rollback = %v", err)
}
// The forced rename failure also breaks the rollback restore, so the
// relocated child stays at the destination — retained, never deleted.
assertUpgradeSkillContent(t, destination, "payload")
})
t.Run("live claim read failure aborts with rollback", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
destinationReads := 0
original := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == destination {
destinationReads++
if destinationReads == 2 {
return nil, os.ErrPermission
}
}
return original(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "确认降级发布目标") {
t.Fatalf("live claim read failure = %v", err)
}
})
t.Run("foreign claim entry aborts the move", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
destinationReads := 0
original := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == destination {
destinationReads++
if destinationReads == 2 {
return []os.DirEntry{
childMoveDirEntry{name: "SKILL.md"},
childMoveDirEntry{name: "foreign"},
}, nil
}
}
return original(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "并发目录项") {
t.Fatalf("foreign claim entry = %v", err)
}
})
t.Run("claim mode restore failure aborts with rollback", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission })
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "恢复已发布 Skill 目录权限") {
t.Fatalf("mode restore failure = %v", err)
}
})
t.Run("source shell removal failure surfaces with the destination retained", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
testseam.Swap(t, &skillPathRemove, func(path string) error {
if path == source {
return os.ErrPermission
}
return os.Remove(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "清理已迁移 Skill 源目录") {
t.Fatalf("shell removal failure = %v", err)
}
assertUpgradeSkillContent(t, destination, "payload")
})
t.Run("nested directories and mixed children publish", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
if err := os.MkdirAll(filepath.Join(source, "nested"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "nested", "inner.txt"), []byte("inner"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "link"), []byte("stub"), 0o644); err != nil {
t.Fatal(err)
}
forceChildMove(t)
// The "link" child is presented to the dispatcher as a symlink; its
// recreation is simulated through seams so the test needs no
// symlink privileges (Windows runners refuse unprivileged symlinks).
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(source, "link") {
return skillPathFakeInfo{mode: os.ModeSymlink}, nil
}
return originalLstat(path)
})
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil })
testseam.Swap(t, &skillPathSymlink, func(string, string) error { return nil })
claim, err := renameSkillPathNoReplace(source, destination)
if err != nil {
t.Fatal(err)
}
if claim == "" {
t.Fatal("child-move must return the claim identity")
}
assertUpgradeSkillContent(t, destination, "payload")
if data, readErr := os.ReadFile(filepath.Join(destination, "nested", "inner.txt")); readErr != nil || string(data) != "inner" {
t.Fatalf("nested child = %q, %v", data, readErr)
}
if _, statErr := os.Lstat(filepath.Join(source, "link")); !os.IsNotExist(statErr) {
t.Fatalf("simulated symlink child must have its source consumed, stat err=%v", statErr)
}
if _, statErr := os.Lstat(source); !os.IsNotExist(statErr) {
t.Fatalf("source must be gone, stat err=%v", statErr)
}
})
t.Run("special-type child is refused", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(source, "SKILL.md") {
return mockSpecialInfo{}, nil
}
return originalLstat(path)
})
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, errNoReplaceRenameUnsupported) {
t.Fatalf("special child = %v, want unsupported", err)
}
})
t.Run("symlink child readlink failure surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(source, "SKILL.md") {
return skillPathFakeInfo{mode: os.ModeSymlink}, nil
}
return originalLstat(path)
})
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission })
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
t.Fatalf("symlink readlink failure = %v", err)
}
})
t.Run("symlink child collision reports ErrExist", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(source, "SKILL.md") {
return skillPathFakeInfo{mode: os.ModeSymlink}, nil
}
return originalLstat(path)
})
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil })
testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrExist })
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) {
t.Fatalf("symlink collision = %v, want ErrExist", err)
}
})
t.Run("symlink child publish failure surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(source, "SKILL.md") {
return skillPathFakeInfo{mode: os.ModeSymlink}, nil
}
return originalLstat(path)
})
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil })
testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrPermission })
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
t.Fatalf("symlink publish failure = %v", err)
}
})
t.Run("child stat failure surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(source, "SKILL.md") {
return nil, os.ErrPermission
}
return originalLstat(path)
})
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
t.Fatalf("child stat failure = %v", err)
}
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("source read failure aborts with the empty claim cleaned", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
original := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == source {
return nil, os.ErrPermission
}
return original(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "读取源 Skill 目录失败") {
t.Fatalf("source read failure = %v", err)
}
if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) {
t.Fatalf("empty claim must be cleaned, stat err=%v", statErr)
}
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("file identity degrades on unreadable and unrecognizable objects", func(t *testing.T) {
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission })
if id := skillPathFileIdentityImpl("unreadable"); id != "" {
t.Fatalf("unreadable identity = %q, want empty", id)
}
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return skillPathFakeInfo{mode: 0o644}, nil })
if id := skillPathFileIdentityImpl("synthetic"); id != "" {
t.Fatalf("unrecognizable identity = %q, want empty", id)
}
})
t.Run("published content drift after the fast-path rename surfaces", func(t *testing.T) {
dir := t.TempDir()
staged := filepath.Join(dir, "staged")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, staged, "value", false)
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) {
if _, err := originalRename(source, target); err != nil {
return "", err
}
// The published bytes drift after the rename while the object
// keeps its identity, so only the fingerprint check can refuse.
if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("drifted"), 0o644); err != nil {
return "", err
}
return "", nil
})
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 内容已变化") {
t.Fatalf("content drift = %v", err)
}
})
}
-290
View File
@@ -1,290 +0,0 @@
package upgrade
import (
"errors"
"fmt"
"os"
"path/filepath"
)
// errNoReplaceRenameUnsupported marks a platform that has no atomic no-replace
// rename primitive at all.
var errNoReplaceRenameUnsupported = errors.New("平台不支持原子 no-replace rename")
var skillPathRenameNoReplaceAtomic = renameSkillPathNoReplaceAtomic
// renameSkillPathNoReplace moves source onto destination and never replaces an
// object that already occupies destination. On success it also returns the
// fresh mkdir-claim identity captured by the child-move directory fallback
// (empty when the atomic primitive or the empty-target fast path published
// dest, or when the platform cannot report a stable file identity). The
// publication path uses that identity as a tamperproof witness that dest is
// still the mkdir claim this transaction created; every other caller ignores
// it.
//
// The atomic primitives (Linux RENAME_NOREPLACE, Darwin RENAME_EXCL) require
// support from the underlying filesystem and report EINVAL/ENOTSUP when it is
// missing — rename(2) lists only ext4, btrfs, tmpfs and cifs, so NFS, FUSE and
// overlayfs homes reject the flag. Failing there would abort the whole install
// on those filesystems, so the fallback uses truly atomic no-clobber primitives
// that work on every POSIX filesystem: os.Mkdir for directories (fails with
// EEXIST if the path is occupied), os.Link for regular files (fails with
// EEXIST if the path is occupied), and os.Symlink for symbolic links (same
// EEXIST guarantee). Neither primitive has a TOCTOU window.
func renameSkillPathNoReplace(source, destination string) (string, error) {
err := skillPathRenameNoReplaceAtomic(source, destination)
if err == nil {
return "", nil
}
if !errors.Is(err, errNoReplaceRenameUnsupported) && !isNoReplaceRenameUnsupported(err) {
return "", err
}
return renameSkillPathNoReplaceFallback(source, destination)
}
// renameSkillPathNoReplaceFallback is used when the kernel no-replace rename
// flag is unavailable. It dispatches on the source type to pick an atomic
// no-clobber primitive that does not require filesystem support. When the
// directory slow path publishes via child-move, the returned claim identity
// is the destination file identity captured immediately after mkdir; every
// other outcome returns an empty claim identity.
func renameSkillPathNoReplaceFallback(source, destination string) (string, error) {
info, err := skillPathLstat(source)
if err != nil {
return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err)
}
switch {
case info.IsDir():
return renameSkillDirNoReplaceFallback(source, destination)
case info.Mode().IsRegular():
return "", renameSkillFileNoReplaceFallback(source, destination)
default:
return "", &os.LinkError{
Op: "rename", Old: source, New: destination,
Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported),
}
}
}
// renameSkillDirNoReplaceFallback claims the destination once with mkdir and
// never unlinks it for the remainder of the transaction. The fast path
// renames the source over the claim; on platforms that permit replacing an
// empty directory (Linux) that rename only ever touches the claim we own.
// Platforms that refuse to rename over any directory (macOS returns EEXIST
// even for an empty target) take the slow path instead — but only after
// proving the claim is still empty: a rename that failed because a
// concurrent writer landed inside the claim must abort with the destination
// retained, never fall through to child moves whose per-entry primitives
// would collide with (and rollback would delete) the foreign data. The slow
// path moves the source children into the claim one by one, each through an
// atomic no-clobber primitive (mkdir claim for directories, os.Link for
// files, os.Symlink for links), so a same-name concurrent entry aborts the
// move and a foreign entry of any name — detected by re-reading the claim —
// retains the destination instead of letting the rollback delete it. The
// destination claim is removed only while still empty, so a foreign entry
// always survives a failed publication. On success the source is fully
// consumed either way: the fast-path rename moves it onto the claim, and
// the child-move slow path removes the emptied source shell after the last
// child relocates.
//
// The returned claim identity is non-empty only when the child-move slow
// path published dest and the platform can report a stable file identity;
// the fast-path rename consumes the mkdir claim (dest inherits the source
// inode) and returns "", as does an unsuccessful publish. Callers that need
// a tamperproof ownership witness (see PublishSkillPathNoReplace) compare
// the returned identity against skillPathFileIdentity(destination) at the
// moment of the ownership check — a wholesale replacement of dest after
// the claim produces a different identity.
//
// The old fallback (mkdir → rename → remove → rename) had a TOCTOU window
// between the second remove and the second rename during which a concurrent
// writer could create a file or empty directory at dest that the second
// os.Rename would silently overwrite. This implementation removes that
// window: the claim is never unlinked between the fast-path rename attempt
// and publication, and child-move relies exclusively on atomic no-clobber
// primitives that fail with EEXIST rather than replace.
func renameSkillDirNoReplaceFallback(source, destination string) (string, error) {
sourceInfo, err := skillPathLstat(source)
if err != nil {
return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err)
}
if err := skillPathMkdir(destination, 0o700); err != nil {
if os.IsExist(err) {
return "", &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
}
return "", fmt.Errorf("声明 Skill 发布目标失败 %s: %w", destination, err)
}
// Capture the claim identity immediately after mkdir but before any
// children move in. A concurrent replacement of destination (unlink +
// recreate) after this point produces a different identity.
claimID := skillPathFileIdentity(destination)
if err := skillPathRename(source, destination); err == nil {
// Fast path consumed the source: destination inode is now the
// staged inode, not the mkdir claim. Return no claim identity —
// the caller falls back to the standard staged-identity proof.
return "", nil
}
claimEntries, claimErr := skillPathReadDir(destination)
if claimErr != nil {
return "", errors.Join(
fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, claimErr),
removeClaimIfEmpty(destination),
)
}
if len(claimEntries) > 0 {
// A concurrent writer populated the empty claim before we could
// take the slow path. Falling through to child-move would let
// its per-entry no-clobber primitives collide with the foreign
// data; abort with the destination retained instead.
return "", fmt.Errorf("降级发布中止,目标在声明后被并发写入 %s: %w", destination, os.ErrExist)
}
if err := moveSkillDirChildrenIntoClaim(source, destination, sourceInfo.Mode().Perm()); err != nil {
return "", fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, err)
}
// Child-move consumed every source entry, so the emptied source shell is
// ours by construction. os.Remove refuses a non-empty directory, so a
// concurrent writer inside the staging tree fails the publication loudly
// instead of being silently discarded with the shell.
if err := skillPathRemove(source); err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("清理已迁移 Skill 源目录失败 %s: %w", source, err)
}
// Child-move succeeded: dest is still the mkdir claim we created.
return claimID, nil
}
// moveSkillDirChildNoReplace moves one child of a staged directory into the
// claimed destination through an atomic no-clobber primitive: directories
// recursively claim with mkdir, regular files publish via os.Link, and
// symlinks are recreated with os.Symlink. Every primitive fails with EEXIST
// when the destination entry was taken concurrently, so no step can replace
// a foreign object.
func moveSkillDirChildNoReplace(source, destination string) error {
info, err := skillPathLstat(source)
if err != nil {
return fmt.Errorf("读取源 Skill 目录项失败 %s: %w", source, err)
}
switch {
case info.IsDir():
// The nested fallback consumes the nested source on success — the
// fast-path rename moves it away and the child-move slow path
// removes the emptied shell. The nested claim identity is not a
// witness for this transaction (only the top-level destination's
// identity is), so it is discarded here.
_, err := renameSkillDirNoReplaceFallback(source, destination)
return err
case info.Mode().IsRegular():
return renameSkillFileNoReplaceFallback(source, destination)
case info.Mode()&os.ModeSymlink != 0:
return renameSkillSymlinkNoReplaceFallback(source, destination)
default:
return &os.LinkError{
Op: "rename", Old: source, New: destination,
Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported),
}
}
}
// removeClaimIfEmpty deletes the claimed destination only while it still
// holds no entries. A foreign entry that appeared after the claim must never
// be removed: the rollback has no ownership proof for it.
func removeClaimIfEmpty(destination string) error {
entries, err := skillPathReadDir(destination)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("检查降级发布目标失败 %s: %w", destination, err)
}
if len(entries) > 0 {
return nil
}
if err := skillPathRemove(destination); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("清理降级发布目标失败 %s: %w", destination, err)
}
return nil
}
// moveSkillDirChildrenIntoClaim moves every top-level child of source into
// the claimed destination — each through moveSkillDirChildNoReplace, so a
// concurrently created same-name entry fails atomically — and restores the
// source mode on the claim. The claim is re-read after the moves: an entry
// count above the source's means a different-named foreign object landed
// mid-move, and the publication aborts with the destination retained. Any
// failure moves the already relocated children back and removes the claim
// only while it is empty, leaving the source intact and foreign entries in
// place.
func moveSkillDirChildrenIntoClaim(source, destination string, sourceMode os.FileMode) error {
entries, err := skillPathReadDir(source)
if err != nil {
return errors.Join(
fmt.Errorf("读取源 Skill 目录失败 %s: %w", source, err),
removeClaimIfEmpty(destination),
)
}
moved := make([]string, 0, len(entries))
rollback := func() error {
var rollbackErr error
for i := len(moved) - 1; i >= 0; i-- {
if err := skillPathRename(filepath.Join(destination, moved[i]), filepath.Join(source, moved[i])); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
rollbackErr = errors.Join(rollbackErr, removeClaimIfEmpty(destination))
return rollbackErr
}
for _, entry := range entries {
name := entry.Name()
if err := moveSkillDirChildNoReplace(filepath.Join(source, name), filepath.Join(destination, name)); err != nil {
return errors.Join(fmt.Errorf("迁移 Skill 目录项失败 %s: %w", name, err), rollback())
}
moved = append(moved, name)
}
live, liveErr := skillPathReadDir(destination)
if liveErr != nil {
return errors.Join(
fmt.Errorf("确认降级发布目标失败 %s: %w", destination, liveErr),
rollback(),
)
}
if len(live) > len(entries) {
return errors.Join(
fmt.Errorf("降级发布中止,目标出现并发目录项 %s: %w", destination, os.ErrExist),
rollback(),
)
}
if err := skillPathChmod(destination, sourceMode); err != nil {
return errors.Join(
fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", destination, err),
rollback(),
)
}
return nil
}
// renameSkillFileNoReplaceFallback uses os.Link, which atomically fails if the
// destination exists, then removes the source to complete the move.
func renameSkillFileNoReplaceFallback(source, destination string) error {
if err := skillPathLink(source, destination); err != nil {
if os.IsExist(err) {
return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
}
return fmt.Errorf("发布 Skill 文件失败 %s: %w", destination, err)
}
return skillPathRemove(source)
}
// renameSkillSymlinkNoReplaceFallback recreates the link at the destination
// with os.Symlink — atomic, EEXIST when taken — and then removes the source.
func renameSkillSymlinkNoReplaceFallback(source, destination string) error {
target, err := skillPathReadlink(source)
if err != nil {
return fmt.Errorf("读取源 Skill 链接失败 %s: %w", source, err)
}
if err := skillPathSymlink(target, destination); err != nil {
if os.IsExist(err) {
return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
}
return fmt.Errorf("发布 Skill 链接失败 %s: %w", destination, err)
}
return skillPathRemove(source)
}
@@ -1,22 +0,0 @@
//go:build darwin
package upgrade
import (
"errors"
"syscall"
"golang.org/x/sys/unix"
)
func renameSkillPathNoReplaceAtomic(source, destination string) error {
return unix.RenamexNp(source, destination, unix.RENAME_EXCL)
}
// Volumes whose filesystem does not implement renamex_np (several network and
// FUSE mounts) reject RENAME_EXCL instead of performing the rename.
func isNoReplaceRenameUnsupported(err error) bool {
return errors.Is(err, syscall.ENOTSUP) ||
errors.Is(err, syscall.EINVAL) ||
errors.Is(err, syscall.ENOSYS)
}
@@ -1,23 +0,0 @@
//go:build linux
package upgrade
import (
"errors"
"syscall"
"golang.org/x/sys/unix"
)
func renameSkillPathNoReplaceAtomic(source, destination string) error {
return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_NOREPLACE)
}
// rename(2) documents EINVAL as "the filesystem does not support one of the
// flags", which is what NFS, FUSE and overlayfs return; ENOSYS covers kernels
// without renameat2 at all.
func isNoReplaceRenameUnsupported(err error) bool {
return errors.Is(err, syscall.EINVAL) ||
errors.Is(err, syscall.ENOSYS) ||
errors.Is(err, syscall.EOPNOTSUPP)
}
@@ -1,9 +0,0 @@
//go:build !linux && !darwin && !windows
package upgrade
func renameSkillPathNoReplaceAtomic(_, _ string) error {
return errNoReplaceRenameUnsupported
}
func isNoReplaceRenameUnsupported(error) bool { return false }
@@ -1,300 +0,0 @@
package upgrade
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
type mockSpecialInfo struct{}
func (mockSpecialInfo) Name() string { return "source" }
func (mockSpecialInfo) Size() int64 { return 0 }
func (mockSpecialInfo) Mode() os.FileMode { return os.ModeNamedPipe }
func (mockSpecialInfo) ModTime() time.Time { return time.Time{} }
func (mockSpecialInfo) IsDir() bool { return false }
func (mockSpecialInfo) Sys() interface{} { return nil }
// TestCrossPlatformCoverageNoReplaceRenameFallback pins the degradation path for
// filesystems that reject RENAME_NOREPLACE / RENAME_EXCL (NFS, FUSE, overlayfs).
// Those installs must still publish, and must still refuse to clobber an
// occupied destination. The fallback uses atomic no-clobber primitives
// (os.Mkdir for directories, os.Link for files) instead of a TOCTOU-prone
// check-then-rename.
func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) {
unsupported := testNoReplaceUnsupportedErrors()
t.Run("publishes a file when the filesystem rejects the flag", func(t *testing.T) {
for _, unsupportedErr := range unsupported {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr })
if _, err := renameSkillPathNoReplace(source, destination); err != nil {
t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err)
}
if data, err := os.ReadFile(destination); err != nil || string(data) != "payload" {
t.Fatalf("destination = %q, %v", data, err)
}
if _, err := os.Lstat(source); !os.IsNotExist(err) {
t.Fatalf("source must be gone, stat err=%v", err)
}
}
})
t.Run("publishes a directory when the filesystem rejects the flag", func(t *testing.T) {
for _, unsupportedErr := range unsupported {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr })
if _, err := renameSkillPathNoReplace(source, destination); err != nil {
t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err)
}
assertUpgradeSkillContent(t, destination, "payload")
if _, err := os.Lstat(source); !os.IsNotExist(err) {
t.Fatalf("source must be gone, stat err=%v", err)
}
}
})
t.Run("still refuses an occupied destination for files", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
for _, path := range []string{source, destination} {
if err := os.WriteFile(path, []byte(filepath.Base(path)), 0o644); err != nil {
t.Fatal(err)
}
}
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrExist) {
t.Fatalf("occupied destination must report ErrExist, got %v", err)
}
if data, readErr := os.ReadFile(destination); readErr != nil || string(data) != "destination" {
t.Fatalf("destination must be untouched, got %q, %v", data, readErr)
}
if _, statErr := os.Lstat(source); statErr != nil {
t.Fatalf("source must be preserved: %v", statErr)
}
})
t.Run("file link failure with non-EEXIST error surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrPermission) {
t.Fatalf("link error must surface, got %v", err)
}
if _, statErr := os.Lstat(source); statErr != nil {
t.Fatalf("source must be preserved: %v", statErr)
}
})
t.Run("refuses an occupied directory destination via mkdir EEXIST", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
seedUpgradeSkill(t, destination, "existing", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrExist) {
t.Fatalf("occupied destination must report ErrExist, got %v", err)
}
assertUpgradeSkillContent(t, destination, "existing")
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("concurrent entry in the claimed directory aborts with the destination retained", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathRename, func(_, dst string) error {
// A concurrent writer lands inside the empty claim before the
// slow path can take it; the fast-path rename then fails the
// way it would against a populated target.
if err := os.WriteFile(filepath.Join(dst, "foreign"), []byte("foreign"), 0o644); err != nil {
return err
}
return os.ErrExist
})
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "并发写入") {
t.Fatalf("concurrent claim entry must abort with ErrExist, got %v", err)
}
if data, readErr := os.ReadFile(filepath.Join(destination, "foreign")); readErr != nil || string(data) != "foreign" {
t.Fatalf("foreign entry must be retained, got %q, %v", data, readErr)
}
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("a genuine error is not retried", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
calls := 0
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error {
calls++
return os.ErrPermission
})
if _, err := renameSkillPathNoReplace(source, filepath.Join(dir, "destination")); !errors.Is(err, os.ErrPermission) {
t.Fatalf("permission error must surface, got %v", err)
}
if calls != 1 {
t.Fatalf("atomic attempts = %d, want 1", calls)
}
})
t.Run("source stat failure surfaces without rename", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
destination := filepath.Join(dir, "destination")
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrPermission) {
t.Fatalf("stat error must surface, got %v", err)
}
if _, statErr := os.Lstat(source); statErr != nil {
t.Fatalf("source must be preserved: %v", statErr)
}
})
t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) {
crossDevice := testCrossDeviceError()
if isNoReplaceRenameUnsupported(crossDevice) {
t.Fatal("cross-device error must not be treated as an unsupported flag")
}
dir := t.TempDir()
source := filepath.Join(dir, "source")
if err := os.MkdirAll(source, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
destination := filepath.Join(dir, "backup", "destination")
original := skillPathRenameNoReplaceAtomic
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(from, to string) error {
if from == source {
return crossDevice
}
return original(from, to)
})
if err := moveSkillPathRecoverably(source, destination); err != nil {
t.Fatalf("cross-device move = %v", err)
}
if data, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(data) != "payload" {
t.Fatalf("cross-device destination = %q, %v", data, err)
}
})
t.Run("mkdir failure with non-EEXIST error surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrPermission) {
t.Fatalf("mkdir error must surface, got %v", err)
}
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("remove failure after rename failure surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathRename, func(string, string) error { return errors.New("rename failed") })
removeErr := errors.New("simulated remove failure")
testseam.Swap(t, &skillPathRemove, func(string) error { return removeErr })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, removeErr) {
t.Fatalf("remove error must surface, got %v", err)
}
if _, statErr := os.Lstat(destination); statErr != nil {
t.Fatalf("destination must still exist after remove failure: %v", statErr)
}
assertUpgradeSkillContent(t, source, "payload")
_ = os.RemoveAll(destination)
})
t.Run("fast-path rename failure alone publishes through child-move", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
// macOS refuses to rename over any directory, empty or not. The
// claim's verified emptiness — not the rename error — decides
// whether the child-move slow path may proceed.
testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist })
if _, err := renameSkillPathNoReplace(source, destination); err != nil {
t.Fatalf("child-move must publish despite rename failure, got %v", err)
}
assertUpgradeSkillContent(t, destination, "payload")
if _, err := os.Lstat(source); !os.IsNotExist(err) {
t.Fatalf("source must be gone, stat err=%v", err)
}
})
t.Run("directory publish when first rename replaces empty dir", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
originalRename := skillPathRename
testseam.Swap(t, &skillPathRename, func(src, dst string) error {
_ = os.Remove(dst)
return originalRename(src, dst)
})
if _, err := renameSkillPathNoReplace(source, destination); err != nil {
t.Fatalf("publish must succeed when rename replaces empty dir, got %v", err)
}
assertUpgradeSkillContent(t, destination, "payload")
})
t.Run("non-regular non-directory source fails safely", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return mockSpecialInfo{}, nil })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, errNoReplaceRenameUnsupported) {
t.Fatalf("unsupported error must surface for special files, got %v", err)
}
})
}

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