Compare commits
225
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d27313f5e | ||
|
|
cc86d1e958 | ||
|
|
5619cb150e | ||
|
|
5aaf2efb59 | ||
|
|
d583247935 | ||
|
|
dfc3b028d7 | ||
|
|
95da8214a1 | ||
|
|
d8a3d5d6fd | ||
|
|
5ed7c3adce | ||
|
|
d1f1ab724b | ||
|
|
67505c6c83 | ||
|
|
61c39efb85 | ||
|
|
5b412ac196 | ||
|
|
c0e579fe6b | ||
|
|
c2ff4ab242 | ||
|
|
3fa85d19c9 | ||
|
|
33b76400bf | ||
|
|
2b417e2f2a | ||
|
|
c0e1ec576a | ||
|
|
2b3f482fdc | ||
|
|
f79c066806 | ||
|
|
95645e4f2c | ||
|
|
575303a5b0 | ||
|
|
d9b728f8e5 | ||
|
|
6240584ae2 | ||
|
|
cb46823280 | ||
|
|
fdcaa61587 | ||
|
|
a0495c169b | ||
|
|
3e481d296c | ||
|
|
22f87296cd | ||
|
|
c198d8577d | ||
|
|
33828b7858 | ||
|
|
0c80aa79e5 | ||
|
|
da62d11b35 | ||
|
|
a5d7fd05f1 | ||
|
|
cf13026f48 | ||
|
|
20c901d7ae | ||
|
|
7a858b9732 | ||
|
|
00c337c438 | ||
|
|
27aca3ccc3 | ||
|
|
84036678dd | ||
|
|
d5031a89f0 | ||
|
|
e51ecc04f1 | ||
|
|
ed1ebe5d03 | ||
|
|
0d22a4a1bd | ||
|
|
586ad0a5df | ||
|
|
83f3b4f385 | ||
|
|
6d88c9968e | ||
|
|
36c61fd8ac | ||
|
|
272b6b8a70 | ||
|
|
c3328411c9 | ||
|
|
83cfd10416 | ||
|
|
9d43a12e08 | ||
|
|
7900e27946 | ||
|
|
42f54832b0 | ||
|
|
e217901a6b | ||
|
|
5f6ca90821 | ||
|
|
cad437aabd | ||
|
|
47d71375f6 | ||
|
|
69540c3372 | ||
|
|
3a2b738c06 | ||
|
|
725e60f07c | ||
|
|
8b4453adf9 | ||
|
|
63854705fd | ||
|
|
109a2891de | ||
|
|
4e106cd5ad | ||
|
|
9858844158 | ||
|
|
00ca448aa2 | ||
|
|
afe01d4b70 | ||
|
|
4c6db326f5 | ||
|
|
843edd700d | ||
|
|
58ff0248b6 | ||
|
|
ab0d1d2ad6 | ||
|
|
6c895c23ed | ||
|
|
358e1ab065 | ||
|
|
510b120630 | ||
|
|
f253f865c7 | ||
|
|
8e34134dbc | ||
|
|
65885dd262 | ||
|
|
b354b371c9 | ||
|
|
15d289d3f3 | ||
|
|
57b5845eb3 | ||
|
|
fab84af434 | ||
|
|
2dd724f1e1 | ||
|
|
3ac9b83565 | ||
|
|
234253e75f | ||
|
|
6a4803d85a | ||
|
|
d11e69fbdb | ||
|
|
a3566f39c4 | ||
|
|
a192e988c4 | ||
|
|
91d2e29925 | ||
|
|
26638cbd98 | ||
|
|
2f05649277 | ||
|
|
6c8e7e082b | ||
|
|
63a6de7b49 | ||
|
|
46b641f227 | ||
|
|
71da2dfded | ||
|
|
21395ed12d | ||
|
|
5f2344d16d | ||
|
|
e79efc70af | ||
|
|
7fa4ee7bac | ||
|
|
5b9234d8fe | ||
|
|
619319517a | ||
|
|
74513bae2f | ||
|
|
fcbddb0904 | ||
|
|
e84743615f | ||
|
|
a102447eb5 | ||
|
|
2b131a1031 | ||
|
|
5fdaea5f36 | ||
|
|
e8a320a06b | ||
|
|
59268a42a6 | ||
|
|
06661af43f | ||
|
|
951dd27f0c | ||
|
|
bc5e5db7ef | ||
|
|
a0accff258 | ||
|
|
f7a3e606f7 | ||
|
|
1d1aca5fd1 | ||
|
|
b199fd29cb | ||
|
|
93703113cb | ||
|
|
e5ed9e6e39 | ||
|
|
7924e84fb6 | ||
|
|
b4129c467d | ||
|
|
a12abdfb54 | ||
|
|
d9283b9a82 | ||
|
|
f1d40e26e1 | ||
|
|
0db91cfc44 | ||
|
|
f88be7ae21 | ||
|
|
e3313095ba | ||
|
|
c7882d7f72 | ||
|
|
92196738d3 | ||
|
|
0a4da58d8f | ||
|
|
f14332f143 | ||
|
|
62883b7940 | ||
|
|
6e20bc765f | ||
|
|
ecaefb416f | ||
|
|
f16feed896 | ||
|
|
d0a9dad079 | ||
|
|
e6c54cf777 | ||
|
|
7a97354d93 | ||
|
|
a46958c788 | ||
|
|
b664ace2f2 | ||
|
|
a789a2eea7 | ||
|
|
e4a1feccf0 | ||
|
|
3f39a9ddb1 | ||
|
|
b080b6e7c5 | ||
|
|
dc180f6d07 | ||
|
|
7b64576ea1 | ||
|
|
437dd234b2 | ||
|
|
04f78bcb15 | ||
|
|
9abcdb4deb | ||
|
|
c0da89e674 | ||
|
|
09fc5d993d | ||
|
|
8f3a9e9d4a | ||
|
|
567ea5d77c | ||
|
|
fc18f8fd04 | ||
|
|
a39ad4e6af | ||
|
|
301429e3aa | ||
|
|
0af5751d75 | ||
|
|
79f7ee80e0 | ||
|
|
44d640bbae | ||
|
|
06b4f3ba31 | ||
|
|
9f983be1ac | ||
|
|
80bca147e0 | ||
|
|
2cea069f55 | ||
|
|
208a6c0273 | ||
|
|
4e8469a175 | ||
|
|
b7a07abcb1 | ||
|
|
2292a49c6a | ||
|
|
cf43cf1b47 | ||
|
|
344104268a | ||
|
|
89027aa2e2 | ||
|
|
e45608bb13 | ||
|
|
ff6e2347f6 | ||
|
|
2d29f6601b | ||
|
|
fa887ccd26 | ||
|
|
30202e2b81 | ||
|
|
1203409185 | ||
|
|
0ba55350d8 | ||
|
|
14c2569cfb | ||
|
|
ae1565c0ff | ||
|
|
7581955892 | ||
|
|
24bbdda423 | ||
|
|
276658590b | ||
|
|
16d20b8178 | ||
|
|
dd89c67f4d | ||
|
|
75bf44b7be | ||
|
|
1bae872341 | ||
|
|
d1ecdcd551 | ||
|
|
a47740ca1c | ||
|
|
4ad321557f | ||
|
|
34dee96833 | ||
|
|
fc455f800c | ||
|
|
3556d28fdd | ||
|
|
618eb842a2 | ||
|
|
465acf1406 | ||
|
|
ce73a5b452 | ||
|
|
175b51cec0 | ||
|
|
53a71b08c7 | ||
|
|
3ef735bb3c | ||
|
|
44e18a4062 | ||
|
|
ccbacf84b3 | ||
|
|
7cfaa1ca74 | ||
|
|
d8f8f29062 | ||
|
|
f8af8dc1dc | ||
|
|
bb6fd2f256 | ||
|
|
580c4d201b | ||
|
|
4f754133a5 | ||
|
|
37cd629335 | ||
|
|
b447aac84b | ||
|
|
9703a21a2d | ||
|
|
0c0e2b3ce1 | ||
|
|
7d16c9693f | ||
|
|
1dee02d900 | ||
|
|
7664f04fda | ||
|
|
8177a06296 | ||
|
|
c674366aea | ||
|
|
ce5815a606 | ||
|
|
d211b79a80 | ||
|
|
de8df0fa6f | ||
|
|
9ff31cdd55 | ||
|
|
fde37f7896 | ||
|
|
31902a987e | ||
|
|
5ef52503ae | ||
|
|
8ee9fc3f48 | ||
|
|
19e19bcd2b |
@@ -0,0 +1,14 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with
|
||||
strict response, identity, pagination, and real-data verification while
|
||||
retaining historical CLI discovery and argument compatibility for commands
|
||||
that remain unavailable to agents. Mailbox auto-resolution now accepts both
|
||||
reviewed string and object response shapes, and Attendance date ranges cover
|
||||
the complete requested end date without dropping cross-midnight punches whose
|
||||
actual check time is inside the requested range. The schedule query remains
|
||||
CLI-compatible but is withheld from the Agent catalog because its downstream
|
||||
service returns a successful process exit with a null body for both populated
|
||||
and empty ranges.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors.
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -512,6 +512,12 @@ 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
@@ -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 flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
|
||||
[CLI Help / Schema compatibility migration governance](docs/cli-interface-flag-migrations.md)
|
||||
for the reviewed two-stage `pending` → `consumed` lifecycle.
|
||||
Agent-visible flag migrations must also run
|
||||
Agent-visible flag or command-path 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
|
||||
|
||||
@@ -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 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.
|
||||
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.
|
||||
|
||||
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 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.
|
||||
> 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.
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
|
||||
+1214
-126
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,6 @@
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
"node": ">=16.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,16 @@ 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
|
||||
}
|
||||
@@ -174,8 +184,13 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
|
||||
)
|
||||
}
|
||||
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
|
||||
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
|
||||
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")
|
||||
}
|
||||
|
||||
current, err := readSnapshot(*currentPath)
|
||||
@@ -197,7 +212,39 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
}
|
||||
|
||||
report := interfacesnapshot.CompareAll(current, references)
|
||||
if *approvedMigrationsPath != "" {
|
||||
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 != "" {
|
||||
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
|
||||
@@ -234,6 +281,15 @@ 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")
|
||||
@@ -280,5 +336,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage:")
|
||||
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE] [--approved-command-migrations FILE --candidate-command-migrations FILE]")
|
||||
}
|
||||
|
||||
@@ -166,6 +166,102 @@ 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"))
|
||||
@@ -567,6 +663,33 @@ 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
@@ -184,11 +184,11 @@ candidate SHA。
|
||||
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
|
||||
flag 迁移。
|
||||
|
||||
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
|
||||
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
|
||||
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
|
||||
放宽其他 Schema 历史字段。
|
||||
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 历史字段。
|
||||
|
||||
For a release-seal branch that archives rendered fragments:
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# CLI flag 兼容迁移治理
|
||||
# CLI Help / Schema 兼容迁移治理
|
||||
|
||||
本文定义一种受控迁移:保留旧 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。一项迁移不得跨两种机制组合授权。
|
||||
@@ -31,7 +33,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 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。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 引入的接口变化。首次引入 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。
|
||||
|
||||
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
|
||||
|
||||
@@ -39,10 +41,55 @@ 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,7 +360,6 @@ 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 一致 |
|
||||
|
||||
@@ -54,8 +54,8 @@ DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约
|
||||
|
||||
| 模式 | Agent 目录布局 | 选择方式 |
|
||||
|---|---|---|
|
||||
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
|
||||
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in |
|
||||
| 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 |
|
||||
|
||||
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
|
||||
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
|
||||
@@ -140,10 +140,26 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
|
||||
|
||||
## 8. Upgrade 与恢复语义
|
||||
|
||||
升级器对每个 Agent 目标执行:
|
||||
升级器始终先发布 `~/.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 home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback;
|
||||
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill;
|
||||
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 模式的发布目标。
|
||||
|
||||
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
|
||||
2. 在目标文件系统的 staging 中复制完整新集合;
|
||||
@@ -151,6 +167,14 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
|
||||
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
|
||||
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
|
||||
|
||||
旧集合可能位于外部卷或自定义 Agent 根,而备份固定写入
|
||||
`~/.dws/skill-backups`。因此备份与反向恢复统一采用 rename-first:同卷直接原子
|
||||
rename;遇到跨文件系统错误时,在目标所在文件系统创建临时 staging,词法复制并
|
||||
保留目录/文件权限、普通文件、符号链接及 dangling symlink,校验路径类型、目录项、
|
||||
文件大小与 SHA256、链接目标后,再将 staging 原子 rename 为正式目标。正式目标
|
||||
再次校验成功后才删除源路径。复制、校验或发布失败时保留源并清理 staging;源删除
|
||||
失败时允许源与正式目标同时存在,但必须返回明确错误,不能报告成功。
|
||||
|
||||
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
|
||||
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
|
||||
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
|
||||
@@ -159,11 +183,31 @@ Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功
|
||||
## 9. 备份合同
|
||||
|
||||
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
|
||||
- 主要操作:同一文件系统内使用 rename 移动;
|
||||
- 主要操作:同一文件系统内使用 rename 移动;跨文件系统使用目标卷 staging 的
|
||||
copy → verify → publish → remove 回退;
|
||||
- 失败语义:备份失败时原目录保持不变,目标安装失败;
|
||||
- 恢复语义:反向恢复使用相同回退;若删除备份源失败,原路径和备份可同时存在,
|
||||
但恢复必须失败并明确提示两份均被保留;
|
||||
- 可见性:计划和执行日志显示原路径与备份路径;
|
||||
- 保留策略:自动修剪,仅保留最近 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`。
|
||||
|
||||
@@ -192,6 +236,7 @@ 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 执行相同流程。
|
||||
|
||||
@@ -209,6 +254,12 @@ 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. 后续演进
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
# Attendance Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:Lark CLI 1.0.87
|
||||
> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。
|
||||
- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。
|
||||
- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。
|
||||
- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。
|
||||
- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` |
|
||||
| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` |
|
||||
| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 |
|
||||
| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut |
|
||||
| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut |
|
||||
| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` |
|
||||
| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` |
|
||||
| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` |
|
||||
| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable |
|
||||
| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable |
|
||||
| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 |
|
||||
| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable |
|
||||
| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog |
|
||||
| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable |
|
||||
| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 |
|
||||
| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog |
|
||||
| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。
|
||||
- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。
|
||||
- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。
|
||||
- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。
|
||||
- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。
|
||||
- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。
|
||||
- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。
|
||||
- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。
|
||||
- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。
|
||||
- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。
|
||||
- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。
|
||||
2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。
|
||||
3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。
|
||||
4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。
|
||||
|
||||
### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。
|
||||
- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。
|
||||
- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。
|
||||
- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。
|
||||
- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。
|
||||
- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。
|
||||
- 安全证据句柄:`ATT-REPORT-NULL-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。
|
||||
- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。
|
||||
- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。
|
||||
- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。
|
||||
- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。
|
||||
- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。
|
||||
2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。
|
||||
3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。
|
||||
4. 假期报表至少覆盖已知非空、合法空和未知假期类型。
|
||||
5. 分页/partial 分支和远端零残留通过。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。
|
||||
|
||||
### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。
|
||||
- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。
|
||||
- 安全证据句柄:`ATT-CHECK-PAGE-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。
|
||||
- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。
|
||||
- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。
|
||||
- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。
|
||||
- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。
|
||||
|
||||
### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。
|
||||
- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。
|
||||
- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。
|
||||
- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。
|
||||
- 安全证据句柄:`ATT-FIXTURE-GAP-01`。
|
||||
|
||||
#### B. 需要的测试基础设施与合同
|
||||
|
||||
- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。
|
||||
- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。
|
||||
- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。
|
||||
- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。
|
||||
- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。
|
||||
2. create→get→update→restore/delete 覆盖班次、考勤组与排班。
|
||||
3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。
|
||||
4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。
|
||||
5. 测试结束远端和本地均零残留。
|
||||
|
||||
在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。
|
||||
|
||||
### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。
|
||||
- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。
|
||||
- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。
|
||||
- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。
|
||||
- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。
|
||||
- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。
|
||||
- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。
|
||||
- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。
|
||||
2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。
|
||||
3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。
|
||||
|
||||
在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。
|
||||
|
||||
### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。
|
||||
- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。
|
||||
- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。
|
||||
- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。
|
||||
- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。
|
||||
- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。
|
||||
- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。
|
||||
- 改动需 additive/versioned;现有详情业务字段保持兼容。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。
|
||||
2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。
|
||||
3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。
|
||||
|
||||
下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。
|
||||
|
||||
### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。
|
||||
- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。
|
||||
- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。
|
||||
- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。
|
||||
- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。
|
||||
- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。
|
||||
- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。
|
||||
2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。
|
||||
3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。
|
||||
4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。
|
||||
|
||||
能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。
|
||||
|
||||
### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。
|
||||
- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。
|
||||
- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。
|
||||
- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。
|
||||
- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。
|
||||
- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。
|
||||
- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。
|
||||
- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。
|
||||
2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。
|
||||
3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。
|
||||
4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。
|
||||
|
||||
下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|---|
|
||||
| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 |
|
||||
|
||||
无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|---|
|
||||
| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 |
|
||||
| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 |
|
||||
| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 |
|
||||
| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 |
|
||||
| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 |
|
||||
| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 |
|
||||
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 |
|
||||
| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 |
|
||||
| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 |
|
||||
| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS |
|
||||
| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS |
|
||||
|
||||
### 6.1 clean-HEAD live 发布门状态
|
||||
|
||||
| 叶子 | clean executable HEAD 双层证据 | 发布状态 |
|
||||
|---|---|---|
|
||||
| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 |
|
||||
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS |
|
||||
| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS |
|
||||
|
||||
pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。
|
||||
|
||||
上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。
|
||||
- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。
|
||||
- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。
|
||||
- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
@@ -0,0 +1,198 @@
|
||||
# Mail Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:lark-cli 1.0.87
|
||||
> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。
|
||||
|
||||
上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。
|
||||
|
||||
仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` |
|
||||
| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` |
|
||||
| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` |
|
||||
| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` |
|
||||
| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` |
|
||||
| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` |
|
||||
| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` |
|
||||
| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut |
|
||||
| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut |
|
||||
| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 |
|
||||
| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable |
|
||||
| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 |
|
||||
| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable |
|
||||
| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable |
|
||||
| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 |
|
||||
| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 |
|
||||
| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 |
|
||||
| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 |
|
||||
| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 |
|
||||
| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut |
|
||||
| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 |
|
||||
| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut |
|
||||
| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-Mail-001` — 可验证的发送生命周期
|
||||
|
||||
- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。
|
||||
- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。
|
||||
- 所需接口合同:
|
||||
- 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。
|
||||
- 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。
|
||||
- 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。
|
||||
- 支持幂等键,或明确 unknown commit 不可自动重试。
|
||||
- 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。
|
||||
- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。
|
||||
|
||||
### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真
|
||||
|
||||
- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。
|
||||
- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。
|
||||
- 所需接口合同:
|
||||
- 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。
|
||||
- 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。
|
||||
- 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。
|
||||
- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。
|
||||
|
||||
### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态
|
||||
|
||||
- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。
|
||||
- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。
|
||||
- 所需接口合同:
|
||||
- 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。
|
||||
- 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。
|
||||
- 软删除和永久删除使用不同操作,危险级别与确认要求可声明。
|
||||
- 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。
|
||||
- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。
|
||||
|
||||
### `DS-Mail-004` — 已读回执与签名
|
||||
|
||||
- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。
|
||||
- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。
|
||||
- 所需接口合同:
|
||||
- 消息详情公开稳定回执请求状态和请求者身份类型。
|
||||
- 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。
|
||||
- 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。
|
||||
- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。
|
||||
|
||||
### `DS-Mail-005` — 新邮件监听的订阅生命周期
|
||||
|
||||
- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。
|
||||
- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。
|
||||
- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。
|
||||
- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。
|
||||
|
||||
### `DS-Mail-006` — 模板附件与内联图片事务
|
||||
|
||||
- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。
|
||||
- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。
|
||||
- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。
|
||||
- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。
|
||||
|
||||
### `DS-Mail-007` — 联系人写操作的稳定身份
|
||||
|
||||
- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。
|
||||
- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。
|
||||
- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。
|
||||
- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。
|
||||
|
||||
### `DS-Mail-008` — 统一成功、空结果与分页协议
|
||||
|
||||
- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。
|
||||
- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。
|
||||
- 所需接口合同:
|
||||
- success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。
|
||||
- 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。
|
||||
- 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。
|
||||
- 保留兼容期,但提供 capability/version 让上游安全切换。
|
||||
- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。
|
||||
|
||||
### `DS-Mail-009` — 安全租户与真实 E2E fixture
|
||||
|
||||
- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。
|
||||
- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。
|
||||
- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。
|
||||
- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。
|
||||
|
||||
### `DS-Mail-010` — 草稿/模板可证明的清理终态
|
||||
|
||||
- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。
|
||||
- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。
|
||||
- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。
|
||||
- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。
|
||||
- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|
|
||||
| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 |
|
||||
| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable |
|
||||
| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable |
|
||||
| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut |
|
||||
| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut |
|
||||
| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut |
|
||||
| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable |
|
||||
| `+signature` | no | 缺签名读取资源 | platform unavailable |
|
||||
| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked |
|
||||
| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw |
|
||||
| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed |
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|
|
||||
| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 |
|
||||
| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 |
|
||||
| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 |
|
||||
| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 |
|
||||
| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 |
|
||||
| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 |
|
||||
| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 |
|
||||
| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 |
|
||||
| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` |
|
||||
| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS |
|
||||
| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 |
|
||||
| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED |
|
||||
|
||||
### 6.1 clean executable HEAD 双层证据
|
||||
|
||||
| 公开入口 | exact Shortcut + owning raw 证据 | 状态 |
|
||||
|---|---|---|
|
||||
| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 |
|
||||
| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
|
||||
8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。
|
||||
- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。
|
||||
- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。
|
||||
- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。
|
||||
- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。
|
||||
- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-18T17:38:50.904696",
|
||||
"count": 436,
|
||||
"generated_at": "2026-08-19T10:35:58.304269",
|
||||
"count": 423,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -933,137 +933,84 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+check-record",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+check-result",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-adjustment-rule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+get-approve-template",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-checkin-record",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-leave-records",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+get-overtime-rule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-schedule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-self-setting",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-summary",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+list-approve",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+list-leave-types",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+my-attendance",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+query-report-data",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+search-adjustment-rule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+search-class",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+search-group",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+search-overtime-rule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+this-month",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3305,74 +3252,84 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "mail",
|
||||
"command": "+contact-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+find-mail-user",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+folder-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+recent-mail",
|
||||
"command": "+message",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+messages",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+search-mail",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+tag-list",
|
||||
"command": "+thread",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+template-list",
|
||||
"command": "+triage",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "mail",
|
||||
"command": "+thread-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "mail",
|
||||
"command": "+unread-mail",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+user-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
|
||||
@@ -274,6 +274,7 @@ type agentExampleFiles struct {
|
||||
markdown string
|
||||
json string
|
||||
batch string
|
||||
job string
|
||||
binary string
|
||||
image string
|
||||
}
|
||||
@@ -283,12 +284,14 @@ 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'},
|
||||
} {
|
||||
@@ -301,6 +304,7 @@ 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),
|
||||
}
|
||||
@@ -351,6 +355,10 @@ 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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// 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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 435
|
||||
publicShortcutCount = 422
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 438
|
||||
schemaPublishedShortcutCount = 447
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 435
|
||||
publiclyDeliveredShortcutCount = 422
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
|
||||
+128
-26
@@ -117,38 +117,114 @@ type CliSkillDTO struct {
|
||||
// (skill_setup.go) MUST have a matching path value here — enforced by
|
||||
// TestAgentSkillPathsCoversSetupHomes.
|
||||
var agentSkillPaths = map[string]string{
|
||||
// `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",
|
||||
// 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",
|
||||
"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",
|
||||
"gemini": ".gemini/skills",
|
||||
"github": ".copilot/skills",
|
||||
"hermes": ".hermes/skills",
|
||||
"kiro": ".kiro/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",
|
||||
}
|
||||
|
||||
// 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)+1)
|
||||
targets := make([]string, 0, len(agentSkillPaths)+len(unsupportedGlobalAgentTargets)+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, ", ")
|
||||
@@ -182,11 +258,28 @@ func formatAgentSkillPathsForHelp() string {
|
||||
sort.Strings(names)
|
||||
var b strings.Builder
|
||||
for _, n := range names {
|
||||
fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n])
|
||||
installPath := agentSkillPaths[n]
|
||||
if isUniversalSkillInstallTarget(n) {
|
||||
installPath = ".agents/skills"
|
||||
}
|
||||
fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, installPath)
|
||||
}
|
||||
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",
|
||||
@@ -485,8 +578,13 @@ func resolveSkillTargetPath(target string) (string, error) {
|
||||
return os.Getwd()
|
||||
}
|
||||
|
||||
// Look up predefined agent paths
|
||||
relPath, ok := agentSkillPaths[strings.ToLower(target)]
|
||||
target = strings.ToLower(target)
|
||||
if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported {
|
||||
return "", errors.New(reason)
|
||||
}
|
||||
|
||||
// Look up predefined agent paths.
|
||||
_, ok := agentSkillPaths[target]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unsupported target")
|
||||
}
|
||||
@@ -496,7 +594,11 @@ func resolveSkillTargetPath(target string) (string, error) {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Join(homeDir, relPath), nil
|
||||
destination := resolveSkillSetupBase(homeDir, target)
|
||||
if isUniversalSkillInstallTarget(target) {
|
||||
destination = filepath.Join(homeDir, ".agents", "skills")
|
||||
}
|
||||
return destination, nil
|
||||
}
|
||||
|
||||
// fetchSkillDownloadInfo calls the download API to get the skill download URL.
|
||||
|
||||
@@ -28,6 +28,7 @@ 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) {
|
||||
@@ -57,19 +58,19 @@ func TestResolveSkillTargetPath(t *testing.T) {
|
||||
{
|
||||
name: "cursor target",
|
||||
target: "cursor",
|
||||
wantSuffix: filepath.Join(".cursor", "skills"),
|
||||
wantSuffix: filepath.Join(".agents", "skills"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "codex target",
|
||||
target: "codex",
|
||||
wantSuffix: filepath.Join(".codex", "skills"),
|
||||
wantSuffix: filepath.Join(".agents", "skills"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "opencode target",
|
||||
target: "opencode",
|
||||
wantSuffix: filepath.Join(".config", "opencode", "skills"),
|
||||
wantSuffix: filepath.Join(".agents", "skills"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
@@ -118,6 +119,37 @@ 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()
|
||||
@@ -477,8 +509,12 @@ 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] {
|
||||
if !paths[home] && !legacyCleanupOnly[home] {
|
||||
t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+
|
||||
"add it to agentSkillPaths so users can address it via --target <name>", home)
|
||||
}
|
||||
|
||||
+459
-125
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -23,22 +24,77 @@ 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",
|
||||
".cursor/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",
|
||||
".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 (
|
||||
@@ -65,15 +121,20 @@ 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
|
||||
@@ -82,7 +143,10 @@ 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 {
|
||||
@@ -91,9 +155,11 @@ type skillSetupBackup struct {
|
||||
}
|
||||
|
||||
type skillSetupTargetPlan struct {
|
||||
Destination string
|
||||
Backups []skillSetupBackup
|
||||
CleanupOnly bool
|
||||
Destination string
|
||||
CanonicalBase string
|
||||
Backups []skillSetupBackup
|
||||
CleanupOnly bool
|
||||
LinkCanonical bool
|
||||
}
|
||||
|
||||
type skillSetupPlan struct {
|
||||
@@ -149,8 +215,9 @@ multi 模式支持按产品挑选:
|
||||
备份失败时保留原目录并跳过该目标,绝不静默删除。
|
||||
· 所有将被移除的目录都会在确认前逐条列出。
|
||||
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录;
|
||||
未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 Agent 扫描两份 Skill。
|
||||
不带 --mode 时进入交互式询问;Skill 统一安装到 ~/.agents/skills。
|
||||
DWS 会自动适配本机上检测到的 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`,
|
||||
@@ -243,7 +310,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude
|
||||
// 时保持 additive(不动未列出的 sibling);全量安装与 install.sh /
|
||||
// install.js 对齐,清掉不在 bundle 内且有明确 DWS 所有权记录的过期 Skill。
|
||||
@@ -316,6 +382,17 @@ 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",
|
||||
@@ -350,7 +427,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
|
||||
fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。")
|
||||
fmt.Fprintln(out, " 统一安装位置:~/.agents/skills")
|
||||
fmt.Fprintln(out, " 已自动适配本机上检测到的 Agent")
|
||||
fmt.Fprintln(out, "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效。")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -910,8 +989,9 @@ func isSkillSourceRoot(path, mode string) bool {
|
||||
}
|
||||
|
||||
// resolveSkillSetupTargets returns the list of absolute Agent home destinations.
|
||||
// If target == "all", returns every agent home whose parent directory exists.
|
||||
// Otherwise returns the single matching home (whether or not it currently exists).
|
||||
// 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.
|
||||
//
|
||||
// 末段约定:
|
||||
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
|
||||
@@ -923,15 +1003,88 @@ 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
|
||||
}
|
||||
|
||||
rel, ok := agentSkillPaths[target]
|
||||
if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported {
|
||||
return nil, errors.New(reason)
|
||||
}
|
||||
_, ok := agentSkillPaths[target]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets())
|
||||
}
|
||||
return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil
|
||||
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])
|
||||
}
|
||||
|
||||
// agentHomeForMode appends the mode-specific tail segment to an agent home base.
|
||||
@@ -943,79 +1096,141 @@ func agentHomeForMode(base, mode string) string {
|
||||
}
|
||||
|
||||
func detectExistingAgentHomes(home, mode string) []string {
|
||||
var specific []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)
|
||||
}
|
||||
}
|
||||
for i, rel := range skillSetupAgentHomes {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
base := filepath.Join(home, rel)
|
||||
parent := filepath.Dir(base)
|
||||
if info, err := skillSetupStat(parent); err != nil || !info.IsDir() {
|
||||
continue
|
||||
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")
|
||||
}
|
||||
specific = append(specific, agentHomeForMode(base, mode))
|
||||
addDetected(rel, base)
|
||||
}
|
||||
if len(specific) > 0 {
|
||||
return specific
|
||||
for _, target := range []string{"github-copilot", "windsurf"} {
|
||||
addDetected(agentSkillPaths[target], resolveSkillSetupBase(home, target))
|
||||
}
|
||||
return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)}
|
||||
return dests
|
||||
}
|
||||
|
||||
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 := 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
|
||||
}
|
||||
func skillSetupBaseForMode(dest, mode string) string {
|
||||
if mode == skillSetupModeMono {
|
||||
return filepath.Dir(dest)
|
||||
}
|
||||
if home == "" {
|
||||
return nil, nil
|
||||
}
|
||||
genericBase := filepath.Join(home, ".agents", "skills")
|
||||
return dest
|
||||
}
|
||||
|
||||
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})
|
||||
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
|
||||
}
|
||||
}
|
||||
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})
|
||||
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
|
||||
}
|
||||
}
|
||||
if len(target.Backups) == 0 {
|
||||
return nil, nil
|
||||
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)
|
||||
}
|
||||
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
|
||||
return target, nil
|
||||
return clean
|
||||
}
|
||||
|
||||
func canonicalSkillSetupBase(dests []string, mode string) string {
|
||||
for _, dest := range dests {
|
||||
base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode)))
|
||||
if strings.HasSuffix(base, "/.agents/skills") {
|
||||
return skillSetupBaseForMode(dest, mode)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func samePhysicalSkillSetupPath(left, right string) bool {
|
||||
leftReal, leftErr := skillSetupEvalSymlinks(left)
|
||||
rightReal, rightErr := skillSetupEvalSymlinks(right)
|
||||
return leftErr == nil && rightErr == nil && sameSkillSetupPath(leftReal, rightReal)
|
||||
}
|
||||
|
||||
func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filtered bool) (*skillSetupPlan, error) {
|
||||
@@ -1030,10 +1245,26 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
|
||||
}
|
||||
sort.Strings(plan.MultiSkillNames)
|
||||
sortedDests := append([]string(nil), dests...)
|
||||
sort.Strings(sortedDests)
|
||||
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]
|
||||
})
|
||||
managedNames := currentManagedSkillNames()
|
||||
canonicalBase := canonicalSkillSetupBase(sortedDests, mode)
|
||||
for _, dest := range sortedDests {
|
||||
target := skillSetupTargetPlan{Destination: dest}
|
||||
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
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
add := func(path, reason string) {
|
||||
if seen[path] {
|
||||
@@ -1077,26 +1308,22 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
|
||||
}
|
||||
}
|
||||
for _, path := range replacements {
|
||||
info, statErr := skillSetupStat(path)
|
||||
if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) {
|
||||
continue
|
||||
}
|
||||
_, statErr := skillSetupLstat(path)
|
||||
if statErr != nil {
|
||||
if errors.Is(statErr, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, statErr)
|
||||
}
|
||||
if info.IsDir() {
|
||||
add(path, skillSetupBackupReplace)
|
||||
}
|
||||
add(path, skillSetupBackupReplace)
|
||||
}
|
||||
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
|
||||
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)
|
||||
if !target.CleanupOnly || len(target.Backups) > 0 {
|
||||
plan.Targets = append(plan.Targets, target)
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
@@ -1140,6 +1367,33 @@ 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 {
|
||||
@@ -1148,12 +1402,14 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
|
||||
fmt.Fprintf(out, " · %s\n", name)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(out, " destinations:")
|
||||
fmt.Fprintln(out, " 安装与适配位置:")
|
||||
for _, target := range plan.Targets {
|
||||
if target.CleanupOnly {
|
||||
fmt.Fprintf(out, " - %s (仅迁移旧的通用 DWS 副本)\n", target.Destination)
|
||||
fmt.Fprintf(out, " - %s(移除该 Agent 中的旧版 DWS Skills,改用统一安装位置)\n", target.Destination)
|
||||
} else if target.LinkCanonical {
|
||||
fmt.Fprintf(out, " - %s(自动配置此 Agent 使用统一安装位置)\n", target.Destination)
|
||||
} else {
|
||||
fmt.Fprintf(out, " - %s\n", target.Destination)
|
||||
fmt.Fprintf(out, " - %s(统一安装位置)\n", target.Destination)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):")
|
||||
@@ -1324,8 +1580,12 @@ 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 {
|
||||
@@ -1334,23 +1594,47 @@ func installMultiSkillsWithEventMigration(
|
||||
}
|
||||
}
|
||||
|
||||
if len(ordinaryTargets) > 0 {
|
||||
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
|
||||
}
|
||||
var n, nSkipped int
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut, filtered)
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, names, targets, out, errOut, filtered)
|
||||
installed += n
|
||||
skipped += nSkipped
|
||||
if err != nil {
|
||||
return installed, skipped, err
|
||||
return err
|
||||
}
|
||||
if nSkipped > 0 {
|
||||
return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
|
||||
return 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 migrationTargets {
|
||||
for _, dest := range physicalMigrationTargets {
|
||||
if cleanupErr := cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut); cleanupErr != nil {
|
||||
return installed, skipped + len(skillNames), cleanupErr
|
||||
}
|
||||
@@ -1361,9 +1645,9 @@ func installMultiSkillsWithEventMigration(
|
||||
prerequisiteNames = append(prerequisiteNames, name)
|
||||
}
|
||||
}
|
||||
if len(prerequisiteNames) > 0 {
|
||||
if len(prerequisiteNames) > 0 && len(physicalMigrationTargets) > 0 {
|
||||
var n, nSkipped int
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut, true)
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, physicalMigrationTargets, out, errOut, true)
|
||||
installed += n
|
||||
skipped += nSkipped
|
||||
if err != nil {
|
||||
@@ -1374,7 +1658,7 @@ func installMultiSkillsWithEventMigration(
|
||||
}
|
||||
}
|
||||
|
||||
migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut)
|
||||
migrated, migrationErr := migrateEventMiscAtomically(src, physicalMigrationTargets, out, errOut)
|
||||
installed += migrated
|
||||
if migrationErr != nil {
|
||||
return installed, skipped, migrationErr
|
||||
@@ -1646,9 +1930,32 @@ 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)
|
||||
}
|
||||
@@ -1675,16 +1982,11 @@ 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 []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))
|
||||
}
|
||||
}
|
||||
func restoreSkillSetupTarget(published []upgrade.SkillPathPublication, backups []skillSetupBackedUpDir) error {
|
||||
restoreErr := skillSetupRollbackPaths(published)
|
||||
for i := len(backups) - 1; i >= 0; i-- {
|
||||
item := backups[i]
|
||||
if _, err := skillSetupStat(item.original); err == nil {
|
||||
if _, err := skillSetupLstat(item.original); err == nil {
|
||||
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复目标仍存在 %s;备份保留于 %s", item.original, item.backup))
|
||||
continue
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
@@ -1695,7 +1997,7 @@ func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir
|
||||
restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录失败 %s: %w;备份保留于 %s", filepath.Dir(item.original), err, item.backup))
|
||||
continue
|
||||
}
|
||||
if err := skillSetupPublishRename(item.backup, item.original); err != nil {
|
||||
if err := skillSetupRestoreBackup(item.backup, item.original); err != nil {
|
||||
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w;备份保留于 %s", item.original, err, item.backup))
|
||||
}
|
||||
}
|
||||
@@ -1728,45 +2030,53 @@ func backupSkillSetupTarget(home string, planned []skillSetupBackup, out io.Writ
|
||||
}
|
||||
|
||||
func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupBackedUpDir) error {
|
||||
published := make([]string, 0, len(staged))
|
||||
published := make([]upgrade.SkillPathPublication, 0, len(staged))
|
||||
for _, item := range staged {
|
||||
// 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 {
|
||||
publication, err := skillSetupPublishPath(item.staged, item.dest)
|
||||
if 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()
|
||||
perTarget := 1
|
||||
if plan.Mode == skillSetupModeMulti {
|
||||
perTarget = len(plan.MultiSkillNames)
|
||||
hasCanonicalDependents := false
|
||||
for _, candidate := range plan.Targets {
|
||||
if candidate.CanonicalBase != "" && !sameSkillSetupPath(skillSetupBaseForMode(candidate.Destination, plan.Mode), candidate.CanonicalBase) {
|
||||
hasCanonicalDependents = true
|
||||
break
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if skipped > 0 {
|
||||
continue
|
||||
}
|
||||
// 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 homeErr != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr)
|
||||
skipped++
|
||||
fmt.Fprintf(errOut, " ⚠️ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr)
|
||||
continue
|
||||
}
|
||||
if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr)
|
||||
skipped++
|
||||
fmt.Fprintf(errOut, " ⚠️ universal Agent 旧副本迁移失败,已回滚,可手动删除 %s: %v\n", target.Destination, cleanupErr)
|
||||
}
|
||||
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 {
|
||||
@@ -1777,7 +2087,16 @@ 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
|
||||
@@ -1787,6 +2106,9 @@ 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
|
||||
@@ -1797,7 +2119,19 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
|
||||
if cleanupErr != nil {
|
||||
publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
|
||||
}
|
||||
fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr)
|
||||
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)
|
||||
}
|
||||
skipped += perTarget
|
||||
continue
|
||||
}
|
||||
@@ -1836,7 +2170,7 @@ func staleMultiSkillVictimsWithError(dest string, keep []string, managed ...map[
|
||||
}
|
||||
var victims []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || keepSet[e.Name()] {
|
||||
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || keepSet[e.Name()] {
|
||||
continue
|
||||
}
|
||||
if !isManagedDWSMultiSkillDir(filepath.Join(dest, e.Name()), managed...) {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
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,6 +15,7 @@ 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) {
|
||||
@@ -336,12 +337,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *test
|
||||
return originalBackup(homeDir, dir)
|
||||
})
|
||||
} else {
|
||||
originalRename := skillSetupPublishRename
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
|
||||
originalPublish := skillSetupPublishPath
|
||||
testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) {
|
||||
if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
|
||||
return failure
|
||||
return upgrade.SkillPathPublication{}, failure
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
return originalPublish(oldPath, newPath)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -432,8 +433,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, &skillSetupRemoveAll, func(string) error { return failure })
|
||||
if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) {
|
||||
testseam.Swap(t, &skillSetupRollbackPaths, func([]upgrade.SkillPathPublication) error { return failure })
|
||||
if err := restoreSkillSetupTarget([]upgrade.SkillPathPublication{{Destination: "published"}}, nil); !errors.Is(err, failure) {
|
||||
t.Fatalf("remove published error = %v", err)
|
||||
}
|
||||
})
|
||||
@@ -445,14 +446,14 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
}
|
||||
})
|
||||
t.Run("stat", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
|
||||
testseam.Swap(t, &skillSetupLstat, 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, &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, &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 恢复目录失败") {
|
||||
@@ -460,9 +461,9 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
}
|
||||
})
|
||||
t.Run("rename", func(t *testing.T) {
|
||||
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, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
|
||||
testseam.Swap(t, &skillSetupRestoreBackup, 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)
|
||||
@@ -479,10 +480,10 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
}
|
||||
return "", failure
|
||||
})
|
||||
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, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
|
||||
restoreErr := errors.New("restore failure")
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr })
|
||||
testseam.Swap(t, &skillSetupRestoreBackup, 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)
|
||||
@@ -490,8 +491,11 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("publish rollback failure", func(t *testing.T) {
|
||||
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, &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, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
|
||||
err := publishSkillSetupTarget(
|
||||
[]skillSetupStagedDir{{staged: "staged", dest: "dest"}},
|
||||
@@ -530,12 +534,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
|
||||
t.Run("after publish failure", func(t *testing.T) {
|
||||
plan := newPlan(t)
|
||||
originalRename := skillSetupPublishRename
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
|
||||
originalPublish := skillSetupPublishPath
|
||||
testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) {
|
||||
if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
|
||||
return failure
|
||||
return upgrade.SkillPathPublication{}, failure
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
return originalPublish(oldPath, newPath)
|
||||
})
|
||||
originalRemoveAll := skillSetupRemoveAll
|
||||
cleanupErr := errors.New("cleanup after publish failure")
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -523,15 +523,45 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
|
||||
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 })
|
||||
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(), "未执行迁移") {
|
||||
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")
|
||||
ordinary := filepath.Join(t.TempDir(), "ordinary")
|
||||
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 _, _, 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)
|
||||
}
|
||||
|
||||
@@ -48,16 +48,13 @@ 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)
|
||||
@@ -119,6 +116,9 @@ 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,55 +149,38 @@ func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testin
|
||||
func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".codex", "skills")
|
||||
genericMono := filepath.Join(home, ".agents", "skills", "dws")
|
||||
if err := os.MkdirAll(genericMono, 0o755); err != nil {
|
||||
canonical := filepath.Join(home, ".agents", "skills")
|
||||
oldCodex := filepath.Join(dest, "dingtalk-chat")
|
||||
if err := os.MkdirAll(oldCodex, 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{dest}, []string{"dingtalk-chat"}, true)
|
||||
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{canonical, dest}, []string{"dingtalk-chat"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != dest {
|
||||
t.Fatalf("universal cleanup target = %#v", plan.Targets)
|
||||
}
|
||||
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono {
|
||||
t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups)
|
||||
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != oldCodex {
|
||||
t.Fatalf("universal cleanup backups = %#v", plan.Targets[1].Backups)
|
||||
}
|
||||
var preview bytes.Buffer
|
||||
renderSkillSetupPlan(&preview, plan)
|
||||
if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") {
|
||||
t.Fatalf("generic cleanup preview missing: %s", preview.String())
|
||||
if !strings.Contains(preview.String(), "改用统一安装位置") {
|
||||
t.Fatalf("universal 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("prior skip suppresses cleanup", func(t *testing.T) {
|
||||
t.Run("cleanup runs even after an install skip", 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)
|
||||
@@ -206,11 +189,14 @@ 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 != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") {
|
||||
if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") {
|
||||
t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
@@ -220,7 +206,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 != 1 || !strings.Contains(stderr.String(), "迁移失败") {
|
||||
if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "迁移失败") {
|
||||
t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
@@ -247,11 +233,13 @@ 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)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -297,12 +297,12 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 dest, got %d", len(got))
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected canonical + Claude, got %d", len(got))
|
||||
}
|
||||
want := filepath.Join(home, ".claude", "skills", "dws")
|
||||
if filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("expected %s, got %s", want, got[0])
|
||||
if filepath.Clean(got[1]) != filepath.Clean(want) {
|
||||
t.Fatalf("expected %s, got %s", want, got[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,12 +321,12 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 dest, got %d", len(got))
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected canonical + Claude, got %d", len(got))
|
||||
}
|
||||
want := filepath.Join(home, ".claude", "skills")
|
||||
if filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("expected %s, got %s", want, got[0])
|
||||
if filepath.Clean(got[1]) != filepath.Clean(want) {
|
||||
t.Fatalf("expected %s, got %s", want, got[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,8 +343,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".codex", "skills")
|
||||
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("targets = %v, want [%s]", got, want)
|
||||
if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) {
|
||||
t.Fatalf("targets = %v, want canonical + %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,8 +360,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".zcode", "skills")
|
||||
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("targets = %v, want [%s]", got, want)
|
||||
if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) {
|
||||
t.Fatalf("targets = %v, want canonical + %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) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) {
|
||||
t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono)
|
||||
if len(explicit) != 2 || filepath.Clean(explicit[1]) != filepath.Clean(wantMono) {
|
||||
t.Fatalf("explicit zcode targets = %v, want canonical + %s", explicit, wantMono)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,12 +1055,11 @@ 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, "重新加载 Skills") {
|
||||
if !strings.Contains(stdout, "请重启已打开的 Agent") {
|
||||
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
|
||||
}
|
||||
|
||||
|
||||
@@ -620,6 +620,9 @@ 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("✓"))
|
||||
}
|
||||
|
||||
@@ -292,6 +292,12 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -300,7 +306,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",
|
||||
"success", "success-no-skills", "install-retire-warning",
|
||||
} {
|
||||
t.Run(stage, func(t *testing.T) {
|
||||
configure(stage)
|
||||
@@ -330,14 +336,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"
|
||||
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" && stage != "install-retire-warning"
|
||||
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") && !rb.cleaned {
|
||||
if (stage == "success" || stage == "success-no-skills" || stage == "install-retire-warning") && !rb.cleaned {
|
||||
t.Fatal("successful upgrade did not clean backups")
|
||||
}
|
||||
if stage == "success" {
|
||||
|
||||
@@ -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", "role-id", "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", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role update",
|
||||
|
||||
@@ -155,7 +155,6 @@
|
||||
"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},
|
||||
@@ -438,7 +437,6 @@
|
||||
{"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"},
|
||||
|
||||
@@ -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, "wait": true,
|
||||
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
|
||||
"result": true, "pagination": true,
|
||||
"examples": true, "use_when": true, "avoid_when": true,
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ var schemaCatalogToolOptionalKeys = []string{
|
||||
"pagination",
|
||||
"positionals",
|
||||
"result",
|
||||
"wait",
|
||||
}
|
||||
|
||||
var schemaCatalogToolEnums = map[string][]string{
|
||||
|
||||
@@ -60,7 +60,6 @@ type ToolSpec struct {
|
||||
Constraints RuntimeSchemaConstraints
|
||||
Positionals []contract.RuntimeSchemaPositional
|
||||
DryRun *contract.DryRunSpec
|
||||
Wait *contract.WaitSpec
|
||||
Result *contract.ResultSpec
|
||||
Pagination *contract.PaginationSpec
|
||||
Safety contract.SafetySpec
|
||||
@@ -136,7 +135,6 @@ type RuntimeToolSpecInput struct {
|
||||
Constraints RuntimeSchemaConstraints
|
||||
Positionals []contract.RuntimeSchemaPositional
|
||||
DryRun *contract.DryRunSpec
|
||||
Wait *contract.WaitSpec
|
||||
Result *contract.ResultSpec
|
||||
Pagination *contract.PaginationSpec
|
||||
Safety contract.SafetySpec
|
||||
@@ -544,11 +542,6 @@ 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
|
||||
@@ -751,16 +744,6 @@ 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 {
|
||||
@@ -999,10 +982,6 @@ 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,57 +974,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,7 +354,6 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
||||
Constraints: constraints,
|
||||
Positionals: positionals,
|
||||
DryRun: final.DryRun,
|
||||
Wait: final.Wait,
|
||||
Result: result,
|
||||
Pagination: pagination,
|
||||
Safety: safety,
|
||||
|
||||
@@ -65,7 +65,6 @@ 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"`
|
||||
@@ -270,7 +269,6 @@ 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{
|
||||
|
||||
@@ -30,7 +30,6 @@ type ContractFinalPayload struct {
|
||||
Parameters []ParamDecl
|
||||
Safety *SafetySpec
|
||||
DryRun *DryRunSpec
|
||||
Wait *WaitSpec
|
||||
Result *ResultSpec
|
||||
Pagination *PaginationSpec
|
||||
Interface *InterfaceSpec
|
||||
|
||||
@@ -62,155 +62,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,227 +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 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)
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,6 @@ type ContractDecl struct {
|
||||
Positionals []contract.RuntimeSchemaPositional
|
||||
Parameters []contract.ParamDecl
|
||||
DryRun *contract.DryRunSpec
|
||||
Wait *contract.WaitSpec
|
||||
Result *contract.ResultSpec
|
||||
Pagination *contract.PaginationSpec
|
||||
Interface *contract.InterfaceSpec
|
||||
@@ -147,9 +146,6 @@ 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,13 +200,6 @@ 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"},
|
||||
@@ -225,8 +218,6 @@ 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
|
||||
@@ -235,10 +226,6 @@ 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) })
|
||||
|
||||
@@ -74,15 +74,6 @@ 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)
|
||||
|
||||
+35
-334
@@ -49,16 +49,12 @@ package corecmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -68,7 +64,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -235,9 +230,10 @@ const (
|
||||
// - Validate / PostMount — orchestration only; must not register business flags
|
||||
// or assemble business params that belong in Flags/ConstParams.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
type Spec struct {
|
||||
Use string
|
||||
Short string
|
||||
@@ -264,7 +260,8 @@ 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, and never satisfy Required.
|
||||
// They are payload declaration, not user flags, never satisfy Required, and
|
||||
// require an Invoke or ResultInvoke dispatcher that consumes assembled args.
|
||||
ConstParams map[string]any
|
||||
// Contract is the authoring-time leaf contract declaration (selection /
|
||||
// interface / parameters / dry-run / identity). When non-empty, embed
|
||||
@@ -287,21 +284,6 @@ 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.
|
||||
@@ -375,15 +357,6 @@ 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") }
|
||||
|
||||
@@ -400,11 +373,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)
|
||||
@@ -421,7 +394,6 @@ 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)
|
||||
@@ -431,6 +403,7 @@ 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)
|
||||
}
|
||||
@@ -487,10 +460,6 @@ 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)
|
||||
@@ -498,6 +467,17 @@ 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
|
||||
@@ -542,293 +522,6 @@ 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
|
||||
@@ -860,6 +553,21 @@ 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.
|
||||
@@ -1731,13 +1439,6 @@ 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 {
|
||||
|
||||
@@ -1416,17 +1416,23 @@ func TestCrossPlatformCoverageBuildArgsIntArgDefaultFloor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommandMergesConstParams(t *testing.T) {
|
||||
func TestCrossPlatformCoverageNewCommandFreezesAndMergesConstParams(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: map[string]any{"precheckOnly": false},
|
||||
ConstParams: declared,
|
||||
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)
|
||||
@@ -1437,6 +1443,95 @@ func TestNewCommandMergesConstParams(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) {
|
||||
@@ -2121,111 +2216,3 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,902 +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 (
|
||||
"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())
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,6 @@ 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
|
||||
|
||||
@@ -27,6 +27,38 @@ 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
|
||||
@@ -7753,17 +7785,23 @@ 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-ids roleId1,roleId2
|
||||
Example: ` dws chat group-role set-user --conversation-id <openConversationId> --user <userId> --role-id <openRoleId>
|
||||
# 查询人员: 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", "role-ids"); err != nil {
|
||||
if err := validateRequiredFlags(cmd, "conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlagWithAliases(cmd, "user", "userId"); err != nil {
|
||||
return err
|
||||
}
|
||||
roleIDs := parseCSVValues(mustGetFlag(cmd, "role-ids"))
|
||||
roleIDs, err := resolveChatGroupRoleSetUserRoleIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := flagOrFallback(cmd, "user", "userId")
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"),
|
||||
@@ -7798,13 +7836,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-ids roleId1,roleId2"},
|
||||
Examples: []string{"dws chat group-role set-user --conversation-id <openConversationId> --user <userId> --role-id <openRoleId>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId"},
|
||||
{Name: "role-ids", Property: "openRoleIds"},
|
||||
{Name: "role-id", Property: "openRoleIds"},
|
||||
{Name: "user", Property: "openDingTalkId"},
|
||||
},
|
||||
},
|
||||
@@ -7814,8 +7852,11 @@ 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-ids", "", "群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份")
|
||||
_ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-ids")
|
||||
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")
|
||||
|
||||
chatGroupRoleRemoveUserCmd := &cobra.Command{
|
||||
Use: "remove-user",
|
||||
|
||||
@@ -243,6 +243,155 @@ 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"}
|
||||
@@ -293,7 +442,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-ids=r1"},
|
||||
{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=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"},
|
||||
@@ -523,7 +672,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-ids": "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 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,6 +58,12 @@ 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")
|
||||
@@ -73,6 +79,16 @@ 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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -305,7 +306,14 @@ func CallMCPToolDataOnServer(ctx context.Context, serverID, toolName string, arg
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var data any
|
||||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||||
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 值")
|
||||
}
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err))
|
||||
}
|
||||
return data, nil
|
||||
|
||||
+237
-1
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
@@ -322,6 +323,95 @@ func newOAAttachmentCommand() *cobra.Command {
|
||||
return attachmentCmd
|
||||
}
|
||||
|
||||
// oaAdminQueryMaxPageSize is the pageSize upper bound of
|
||||
// get_process_instances_by_admin.
|
||||
const oaAdminQueryMaxPageSize = float64(20)
|
||||
|
||||
// validateOARequestProcessCode checks the processCode field of a decoded
|
||||
// --request payload: the tool requires it, and the backend answers a bad
|
||||
// processCode with success:true and an empty list, so reject it client-side.
|
||||
func validateOARequestProcessCode(request map[string]any) error {
|
||||
v, ok := request["processCode"]
|
||||
if !ok {
|
||||
return fmt.Errorf("--request 缺少必填字段 processCode")
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || s == "" {
|
||||
return fmt.Errorf("--request processCode 必须为非空字符串")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOARequestPageSize checks the pageSize field of a decoded
|
||||
// --request payload (json.Number values from decodeOARequest).
|
||||
func validateOARequestPageSize(request map[string]any) error {
|
||||
v, ok := request["pageSize"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
n, ok := v.(json.Number)
|
||||
if !ok {
|
||||
return fmt.Errorf("pageSize 必须为数字")
|
||||
}
|
||||
f, err := n.Float64()
|
||||
if err != nil || f < 1 || f > oaAdminQueryMaxPageSize {
|
||||
return fmt.Errorf("pageSize 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), n.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// oaAdminTimeLayout is the startTime/endTime wire format of
|
||||
// get_process_instances_by_admin since the 2026-08 MCP contract update
|
||||
// (both fields changed from epoch millis to strings).
|
||||
const oaAdminTimeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
// oaAdminTimeLayoutHint is the user-facing spelling of the layout used in
|
||||
// error messages.
|
||||
const oaAdminTimeLayoutHint = "yyyy-MM-dd HH:mm:ss"
|
||||
|
||||
var oaAdminTimeZone = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
|
||||
// formatOAAdminQueryTime renders a millisecond timestamp into the
|
||||
// yyyy-MM-dd HH:mm:ss string required by get_process_instances_by_admin.
|
||||
func formatOAAdminQueryTime(ms int64) string {
|
||||
return time.UnixMilli(ms).In(oaAdminTimeZone).Format(oaAdminTimeLayout)
|
||||
}
|
||||
|
||||
// validateOARequestTimeRange checks startTime/endTime of a decoded
|
||||
// --request payload: startTime is required, both must be
|
||||
// yyyy-MM-dd HH:mm:ss strings, and endTime must be strictly after
|
||||
// startTime (matching simple mode's ValidateTimeRange).
|
||||
func validateOARequestTimeRange(request map[string]any) error {
|
||||
startRaw, ok := request["startTime"]
|
||||
if !ok {
|
||||
return fmt.Errorf("--request 缺少必填字段 startTime")
|
||||
}
|
||||
startStr, ok := startRaw.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("startTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint)
|
||||
}
|
||||
start, err := time.ParseInLocation(oaAdminTimeLayout, startStr, oaAdminTimeZone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("startTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, startStr)
|
||||
}
|
||||
endRaw, ok := request["endTime"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
endStr, ok := endRaw.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("endTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint)
|
||||
}
|
||||
end, err := time.ParseInLocation(oaAdminTimeLayout, endStr, oaAdminTimeZone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("endTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, endStr)
|
||||
}
|
||||
if !end.After(start) {
|
||||
return fmt.Errorf("--request endTime 必须晚于 startTime")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws oa — OA 审批
|
||||
// MCP tools(tools/list): list_pending_approvals, get_processInstance_detail,
|
||||
@@ -329,7 +419,8 @@ func newOAAttachmentCommand() *cobra.Command {
|
||||
// get_processInstance_records, list_initiated_instances, list_pending_tasks,
|
||||
// list_user_visible_process, append_task, search_form, oa_ding_user, revert_task,
|
||||
// get_inst_revert_activities, get_process_schema, forecast_process,
|
||||
// start_process_instance, get_attachment_download_url, auth_download_file,
|
||||
// start_process_instance, get_process_instances_by_admin,
|
||||
// get_attachment_download_url, auth_download_file,
|
||||
// auth_preview_attachment
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1432,6 +1523,129 @@ func newOaCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
// 以管理员身份查询审批实例列表
|
||||
listByAdminSimpleFlags := []string{"process-code", "start", "end", "cursor", "limit", "user-ids", "statuses"}
|
||||
approvalListByAdminCmd := &cobra.Command{
|
||||
Use: "list-by-admin", Short: "以管理员身份查询审批模板的实例列表",
|
||||
Example: ` dws oa approval list-by-admin --process-code <code> --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20
|
||||
dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20,"statuses":["RUNNING"]}'`,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Cobra 的 flag group 校验(英文报错)在 PreRunE 之后执行,
|
||||
// 这里先校验同一组约束,保证用户看到的是中文错误。
|
||||
request, _ := cmd.Flags().GetString("request")
|
||||
processCode, _ := cmd.Flags().GetString("process-code")
|
||||
if request == "" && processCode == "" {
|
||||
return fmt.Errorf("--request、--process-code 至少指定一个")
|
||||
}
|
||||
if request != "" {
|
||||
for _, name := range listByAdminSimpleFlags {
|
||||
if cmd.Flags().Changed(name) {
|
||||
return fmt.Errorf("--request 与 --%s 不能同时指定", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if processCode != "" && !cmd.Flags().Changed("start") {
|
||||
return fmt.Errorf("--process-code、--start 必须同时指定(缺少 --start)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if raw, _ := cmd.Flags().GetString("request"); raw != "" {
|
||||
request, err := decodeOARequest(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--request JSON 解析失败: %w", err)
|
||||
}
|
||||
if err := validateOARequestProcessCode(request); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOARequestPageSize(request); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOARequestTimeRange(request); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request})
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "process-code", "start"); err != nil {
|
||||
return err
|
||||
}
|
||||
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cursor, err := strconv.ParseFloat(mustGetFlag(cmd, "cursor"), 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--cursor 必须为数字: %w", err)
|
||||
}
|
||||
pageSize, err := strconv.ParseFloat(mustGetFlag(cmd, "limit"), 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--limit 必须为数字: %w", err)
|
||||
}
|
||||
if pageSize < 1 || pageSize > oaAdminQueryMaxPageSize {
|
||||
return fmt.Errorf("--limit 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), mustGetFlag(cmd, "limit"))
|
||||
}
|
||||
request := map[string]any{
|
||||
"processCode": mustGetFlag(cmd, "process-code"),
|
||||
"startTime": formatOAAdminQueryTime(startMs),
|
||||
"cursor": cursor,
|
||||
"pageSize": pageSize,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("end"); v != "" {
|
||||
endMs, err := parseISOTimeToMillis("end", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTimeRange(startMs, endMs); err != nil {
|
||||
return err
|
||||
}
|
||||
request["endTime"] = formatOAAdminQueryTime(endMs)
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("user-ids"); v != "" {
|
||||
request["userIds"] = strings.Split(v, ",")
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("statuses"); v != "" {
|
||||
request["statuses"] = strings.Split(v, ",")
|
||||
}
|
||||
return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(approvalListByAdminCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "oa",
|
||||
Name: "get_process_instances_by_admin",
|
||||
CanonicalPath: "oa.get_process_instances_by_admin",
|
||||
CLIPath: "oa approval list-by-admin",
|
||||
PrimaryCLIPath: "oa approval list-by-admin",
|
||||
},
|
||||
Description: "以管理员身份获取审批单列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_process_instances_by_admin"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "以管理员身份按模板、时间范围、状态与用户查询企业内审批实例列表",
|
||||
UseWhen: []string{"具备 OA 审批管理员权限,需要跨用户统计或检索某模板下的审批实例时"},
|
||||
AvoidWhen: []string{
|
||||
"只查自己的待办/已办/已发起/抄送时改用 list-pending / list-executed / list-initiated / list-cc",
|
||||
"无 OA 管理员权限时不要使用,该命令查不到数据",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws oa approval list-by-admin --process-code <code> --start \"2026-03-10T00:00:00+08:00\" --cursor 0 --limit 20",
|
||||
"dws oa approval list-by-admin --process-code <code> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --statuses RUNNING,COMPLETED --user-ids \"userId1,userId2\"",
|
||||
},
|
||||
},
|
||||
// Simple-mode flags are mapping exclusions (encoded inside ProcessInstanceListQueryRequest).
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "request", Property: "ProcessInstanceListQueryRequest", InterfaceType: "object"},
|
||||
},
|
||||
},
|
||||
})
|
||||
approvalCreateCmd := &cobra.Command{
|
||||
Use: "create-instance", Short: "发起审批实例(需要 --yes 确认)",
|
||||
Example: "dws oa approval create-instance --process-code <processCode> --form-values '{\"事由\":\"测试\"}' --yes",
|
||||
@@ -1610,6 +1824,27 @@ func newOaCommand() *cobra.Command {
|
||||
RequireTogether: [][]string{{"process-code", "dept-id", "form-values"}},
|
||||
})
|
||||
|
||||
approvalListByAdminCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
|
||||
approvalListByAdminCmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式使用;与 --request 互斥)")
|
||||
approvalListByAdminCmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选)")
|
||||
approvalListByAdminCmd.Flags().String("cursor", "0", "分页游标,首次传 0")
|
||||
approvalListByAdminCmd.Flags().String("limit", "20", "每页大小,最大 20")
|
||||
approvalListByAdminCmd.Flags().String("user-ids", "", "按发起人 userId 过滤,多个用逗号分隔(可选)")
|
||||
approvalListByAdminCmd.Flags().String("statuses", "", "按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED)")
|
||||
approvalListByAdminCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
|
||||
approvalListByAdminCmd.MarkFlagsOneRequired("request", "process-code")
|
||||
approvalListByAdminCmd.MarkFlagsRequiredTogether("process-code", "start")
|
||||
listByAdminMutuallyExclusive := make([][]string, 0, len(listByAdminSimpleFlags))
|
||||
for _, name := range listByAdminSimpleFlags {
|
||||
approvalListByAdminCmd.MarkFlagsMutuallyExclusive("request", name)
|
||||
listByAdminMutuallyExclusive = append(listByAdminMutuallyExclusive, []string{"request", name})
|
||||
}
|
||||
cli.AnnotateRuntimeConstraints(approvalListByAdminCmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: listByAdminMutuallyExclusive,
|
||||
RequireOneOf: [][]string{{"request", "process-code"}},
|
||||
RequireTogether: [][]string{{"process-code", "start"}},
|
||||
})
|
||||
|
||||
approvalCreateCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
|
||||
approvalCreateCmd.Flags().String("dept-id", "-1", "发起人部门 ID")
|
||||
approvalCreateCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
|
||||
@@ -1656,6 +1891,7 @@ func newOaCommand() *cobra.Command {
|
||||
approvalRevertTaskCmd,
|
||||
approvalFormSchemaCmd,
|
||||
approvalForecastCmd,
|
||||
approvalListByAdminCmd,
|
||||
approvalCreateCmd,
|
||||
)
|
||||
approvalCmd.AddCommand(newOAAttachmentCommand())
|
||||
|
||||
@@ -131,6 +131,51 @@ func TestCrossPlatformCoverageOAApprovalCreateInstanceRequiresExplicitYes(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAApprovalListByAdminMapsSimpleOptions(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeOACommand(t, caller,
|
||||
"approval", "list-by-admin",
|
||||
"--process-code", "PROC",
|
||||
"--start", "2030-01-01T09:00:00+08:00",
|
||||
"--end", "2030-01-01T10:00:00+08:00",
|
||||
"--cursor", "5",
|
||||
"--limit", "20",
|
||||
"--user-ids", "user-1,user-2",
|
||||
"--statuses", "RUNNING,COMPLETED",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("list by admin: %v", err)
|
||||
}
|
||||
if caller.server != "oa" || caller.tool != "get_process_instances_by_admin" {
|
||||
t.Fatalf("called %s/%s, want oa/get_process_instances_by_admin", caller.server, caller.tool)
|
||||
}
|
||||
request, ok := caller.args["ProcessInstanceListQueryRequest"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("request payload = %#v", caller.args)
|
||||
}
|
||||
if got := request["processCode"]; got != "PROC" {
|
||||
t.Fatalf("processCode = %#v", got)
|
||||
}
|
||||
if got := request["cursor"]; got != float64(5) {
|
||||
t.Fatalf("cursor = %#v", got)
|
||||
}
|
||||
if got := request["pageSize"]; got != float64(20) {
|
||||
t.Fatalf("pageSize = %#v", got)
|
||||
}
|
||||
if got := request["startTime"]; got != "2030-01-01 09:00:00" {
|
||||
t.Fatalf("startTime = %#v", got)
|
||||
}
|
||||
if got := request["endTime"]; got != "2030-01-01 10:00:00" {
|
||||
t.Fatalf("endTime = %#v", got)
|
||||
}
|
||||
if got := request["userIds"]; len(got.([]string)) != 2 || got.([]string)[0] != "user-1" || got.([]string)[1] != "user-2" {
|
||||
t.Fatalf("userIds = %#v", got)
|
||||
}
|
||||
if got := request["statuses"]; len(got.([]string)) != 2 || got.([]string)[0] != "RUNNING" || got.([]string)[1] != "COMPLETED" {
|
||||
t.Fatalf("statuses = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *testing.T) {
|
||||
validCases := []struct {
|
||||
name string
|
||||
@@ -157,6 +202,26 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t
|
||||
args: []string{"approval", "create-instance", "--request", `{"processCode":"PROC"}`, "--yes"},
|
||||
tool: "start_process_instance",
|
||||
},
|
||||
{
|
||||
name: "list-by-admin simple mode",
|
||||
args: []string{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00"},
|
||||
tool: "get_process_instances_by_admin",
|
||||
},
|
||||
{
|
||||
name: "list-by-admin request mode",
|
||||
args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
|
||||
tool: "get_process_instances_by_admin",
|
||||
},
|
||||
{
|
||||
name: "list-by-admin request mode without pageSize",
|
||||
args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0}`},
|
||||
tool: "get_process_instances_by_admin",
|
||||
},
|
||||
{
|
||||
name: "list-by-admin request mode with endTime",
|
||||
args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 23:59:59","cursor":0,"pageSize":20}`},
|
||||
tool: "get_process_instances_by_admin",
|
||||
},
|
||||
}
|
||||
for _, tc := range validCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -188,6 +253,31 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t
|
||||
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--dept-id", "bad", "--yes"},
|
||||
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--approvers", "u", "--approvers-action-type", "bad", "--yes"},
|
||||
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--cc-list", "u", "--cc-position", "bad", "--yes"},
|
||||
{"approval", "list-by-admin"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "bad"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "bad"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "2030-01-01T09:00:00+08:00"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--cursor", "bad"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "bad"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "21"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "0"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", ""},
|
||||
{"approval", "list-by-admin", "--request", "{"},
|
||||
{"approval", "list-by-admin", "--request", "null"},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC"}`, "--process-code", "PROC"},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":21}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":"20"}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":1893459600000,"cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 10:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":1893463200000,"cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","endTime":"NOT-A-TIME","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":123,"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
|
||||
}
|
||||
for _, args := range invalidCases {
|
||||
caller := &scriptedToolCaller{}
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
// 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
@@ -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", "report", "sheet", "todo", "wiki", "whiteboard",
|
||||
"markdown", "minutes", "oa", "recruit", "report", "sheet", "todo", "wiki", "whiteboard",
|
||||
} {
|
||||
if !seenProducts[want] {
|
||||
t.Errorf("public product %q was not registered", want)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// 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}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
// 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
|
||||
}
|
||||
@@ -71,6 +71,9 @@ 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
|
||||
@@ -153,6 +156,14 @@ 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,6 +135,63 @@ 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"),
|
||||
|
||||
@@ -91,22 +91,32 @@ 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 flag migration manifest value at %s: %w", path, err)
|
||||
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()
|
||||
}
|
||||
|
||||
switch schema.Kind() {
|
||||
case reflect.Struct:
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
return migrationJSONTypeError(path, schema, token, label)
|
||||
}
|
||||
fields := migrationJSONFields(schema)
|
||||
seen := make(map[string]bool, len(fields))
|
||||
for decoder.More() {
|
||||
keyToken, keyErr := decoder.Token()
|
||||
if keyErr != nil {
|
||||
return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr)
|
||||
return fmt.Errorf("read %s migration manifest field at %s: %w", label, path, keyErr)
|
||||
}
|
||||
// encoding/json guarantees object member names are string tokens.
|
||||
key := keyToken.(string)
|
||||
@@ -115,57 +125,58 @@ func validateMigrationJSONValue(decoder *json.Decoder, path string, schema refle
|
||||
for canonical := range fields {
|
||||
if strings.EqualFold(key, canonical) {
|
||||
return fmt.Errorf(
|
||||
"flag migration manifest contains non-canonical field %q at %s (want %q)",
|
||||
"%s migration manifest contains non-canonical field %q at %s (want %q)",
|
||||
label,
|
||||
key,
|
||||
path,
|
||||
canonical,
|
||||
)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path)
|
||||
return fmt.Errorf("%s migration manifest contains unknown field %q at %s", label, key, path)
|
||||
}
|
||||
if seen[key] {
|
||||
return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path)
|
||||
return fmt.Errorf("%s migration manifest contains duplicate field %q at %s", label, key, path)
|
||||
}
|
||||
seen[key] = true
|
||||
if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil {
|
||||
if err := validateLabeledMigrationJSONValue(decoder, path+"."+key, fieldSchema, label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, closeErr := decoder.Token(); closeErr != nil {
|
||||
return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr)
|
||||
return fmt.Errorf("close %s migration manifest object at %s: %w", label, path, closeErr)
|
||||
}
|
||||
return nil
|
||||
case reflect.Slice:
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
return migrationJSONTypeError(path, schema, token, label)
|
||||
}
|
||||
for index := 0; decoder.More(); index++ {
|
||||
if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil {
|
||||
if err := validateLabeledMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem(), label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, closeErr := decoder.Token(); closeErr != nil {
|
||||
return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr)
|
||||
return fmt.Errorf("close %s migration manifest array at %s: %w", label, path, closeErr)
|
||||
}
|
||||
return nil
|
||||
case reflect.String:
|
||||
if _, ok := token.(string); !ok {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
return migrationJSONTypeError(path, schema, token, label)
|
||||
}
|
||||
return nil
|
||||
case reflect.Int:
|
||||
if _, ok := token.(json.Number); !ok {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
return migrationJSONTypeError(path, schema, token, label)
|
||||
}
|
||||
return nil
|
||||
case reflect.Bool:
|
||||
if _, ok := token.(bool); !ok {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
return migrationJSONTypeError(path, schema, token, label)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema)
|
||||
return fmt.Errorf("%s migration manifest value at %s has unsupported Go schema type %s", label, path, schema)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,9 +199,10 @@ func migrationJSONFields(schema reflect.Type) map[string]reflect.Type {
|
||||
return fields
|
||||
}
|
||||
|
||||
func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error {
|
||||
func migrationJSONTypeError(path string, want reflect.Type, token json.Token, label string) error {
|
||||
return fmt.Errorf(
|
||||
"flag migration manifest value at %s must be %s, got %s",
|
||||
"%s migration manifest value at %s must be %s, got %s",
|
||||
label,
|
||||
path,
|
||||
migrationJSONKindDescription(want),
|
||||
migrationJSONTokenDescription(token),
|
||||
|
||||
@@ -22,12 +22,13 @@ 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 = 2
|
||||
const SchemaVersion = 3
|
||||
|
||||
// FlagAliasOfAnnotation records a framework-originated reviewed relationship
|
||||
// between a retained compatibility flag and an exact canonical Cobra flag.
|
||||
@@ -66,13 +67,14 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// Flag contains the stable pflag contract visible at a command node.
|
||||
@@ -126,14 +128,16 @@ 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()),
|
||||
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,
|
||||
})
|
||||
|
||||
children := append([]*cobra.Command(nil), cmd.Commands()...)
|
||||
@@ -198,6 +202,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
@@ -98,16 +99,105 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin
|
||||
|
||||
func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
|
||||
input := bytes.NewBufferString(`{
|
||||
"schema_version": 2,
|
||||
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
|
||||
"commands": [],
|
||||
"future_field": true
|
||||
"schema_version": 3,
|
||||
"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(
|
||||
|
||||
@@ -435,7 +435,6 @@ const (
|
||||
exitCodeInternal = 5
|
||||
exitCodeDiscovery = 6
|
||||
exitCodePartial = 7 // partial_failure 专用(契约 §4;规划 WS2 第4项;B142 将在 errors 侧补同源常量)
|
||||
exitCodeWait = 8 // wait 终态失败专用(--wait 观察到失败终态;与 partial 同为"仅新增专用码")
|
||||
)
|
||||
|
||||
// subtypeConfirmationRequired 是门禁拦截的 failure 子类标记(契约规范 §2.4),
|
||||
@@ -492,8 +491,6 @@ func exitCodeForErrorInfo(info *ErrorInfo) int {
|
||||
return exitCodePermission
|
||||
case "discovery":
|
||||
return exitCodeDiscovery
|
||||
case "wait":
|
||||
return exitCodeWait
|
||||
default:
|
||||
return exitCodeInternal
|
||||
}
|
||||
|
||||
@@ -283,11 +283,9 @@ 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); "wait" is the wait
|
||||
// phase's terminal-failure projection (rc=8, e.g. an approval observed
|
||||
// REJECTED after --wait).
|
||||
// compatibility projection for PAT failures (rc=4).
|
||||
switch errorType {
|
||||
case "api", "auth", "validation", "permission", "discovery", "internal", "wait":
|
||||
case "api", "auth", "validation", "permission", "discovery", "internal":
|
||||
default:
|
||||
return fmt.Errorf("output: unsupported failure error.type %q", e.Type)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ 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 © }
|
||||
|
||||
type cloneNode struct {
|
||||
|
||||
@@ -19,10 +19,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -34,7 +30,6 @@ 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 ©
|
||||
@@ -76,91 +71,6 @@ 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 {
|
||||
|
||||
@@ -1,165 +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 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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package attendance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *testing.T) {
|
||||
shortcuts := []struct {
|
||||
name string
|
||||
rollout output.RolloutState
|
||||
hasResult bool
|
||||
resultSchema string
|
||||
identity string
|
||||
cursor string
|
||||
}{
|
||||
{CheckResult.Command, CheckResult.OutputRollout, CheckResult.Contract.Result != nil, string(CheckResult.Contract.Result.DataSchema), `"id"`, "offset"},
|
||||
{CheckRecord.Command, CheckRecord.OutputRollout, CheckRecord.Contract.Result != nil, string(CheckRecord.Contract.Result.DataSchema), `"id"`, ""},
|
||||
{ListApprove.Command, ListApprove.OutputRollout, ListApprove.Contract.Result != nil, string(ListApprove.Contract.Result.DataSchema), `"id"`, ""},
|
||||
{GetApproveTemplate.Command, GetApproveTemplate.OutputRollout, GetApproveTemplate.Contract.Result != nil, string(GetApproveTemplate.Contract.Result.DataSchema), `"processCode"`, ""},
|
||||
{SearchClass.Command, SearchClass.OutputRollout, SearchClass.Contract.Result != nil, string(SearchClass.Contract.Result.DataSchema), `"classId"`, "page"},
|
||||
{SearchAdjustmentRule.Command, SearchAdjustmentRule.OutputRollout, SearchAdjustmentRule.Contract.Result != nil, string(SearchAdjustmentRule.Contract.Result.DataSchema), `"ruleId"`, "page"},
|
||||
{GetOvertimeRule.Command, GetOvertimeRule.OutputRollout, GetOvertimeRule.Contract.Result != nil, string(GetOvertimeRule.Contract.Result.DataSchema), `"id"`, ""},
|
||||
{SearchOvertimeRule.Command, SearchOvertimeRule.OutputRollout, SearchOvertimeRule.Contract.Result != nil, string(SearchOvertimeRule.Contract.Result.DataSchema), `"ruleId"`, "page"},
|
||||
}
|
||||
for _, item := range shortcuts {
|
||||
if item.rollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s rollout = %q, want unified_active", item.name, item.rollout)
|
||||
}
|
||||
if !item.hasResult || strings.TrimSpace(item.resultSchema) == "" {
|
||||
t.Errorf("%s must publish a non-empty Result", item.name)
|
||||
}
|
||||
if !json.Valid([]byte(item.resultSchema)) || !strings.Contains(item.resultSchema, item.identity) {
|
||||
t.Errorf("%s Result must declare stable identity %s: %s", item.name, item.identity, item.resultSchema)
|
||||
}
|
||||
if len(attendanceDeclarationByName(item.name).Contract.Result.SensitivePaths) == 0 {
|
||||
t.Errorf("%s must publish reviewed sensitive_paths", item.name)
|
||||
}
|
||||
if strings.Contains(item.resultSchema, `"complete"`) || strings.Contains(item.resultSchema, `"nextPage"`) || strings.Contains(item.resultSchema, `"nextOffset"`) {
|
||||
t.Errorf("%s Result must not place pagination controls in business data", item.name)
|
||||
}
|
||||
if item.cursor == "" {
|
||||
if itemPagination := attendanceDeclarationByName(item.name).Contract.Pagination; itemPagination != nil {
|
||||
t.Errorf("%s unexpectedly publishes Pagination", item.name)
|
||||
}
|
||||
} else if pagination := attendanceDeclarationByName(item.name).Contract.Pagination; pagination == nil || pagination.CursorParameter != item.cursor {
|
||||
t.Errorf("%s Pagination = %#v, want cursor_parameter=%s", item.name, pagination, item.cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceDeclarationByName(name string) *shortcut.Shortcut {
|
||||
for _, declaration := range []*shortcut.Shortcut{&CheckResult, &CheckRecord, &ListApprove, &GetApproveTemplate, &SearchClass, &SearchAdjustmentRule, &GetOvertimeRule, &SearchOvertimeRule} {
|
||||
if declaration.Command == name {
|
||||
return declaration
|
||||
}
|
||||
}
|
||||
return &shortcut.Shortcut{}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) {
|
||||
for _, declaration := range []*shortcut.Shortcut{&GetSummary, &ListLeaveTypes, &GetLeaveRecords, &GetCheckinRecord, &GetSchedule, &GetSelfSetting} {
|
||||
if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil {
|
||||
t.Errorf("%s unavailable contract = rollout %q result=%v pagination=%v", declaration.Command, declaration.OutputRollout, declaration.Contract.Result, declaration.Contract.Pagination)
|
||||
}
|
||||
if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != attendanceCompatibilityInterfaceReason {
|
||||
t.Errorf("%s must preserve its Schema-compatible non-public Interface", declaration.Command)
|
||||
}
|
||||
}
|
||||
if GetClass.OutputRollout != output.RolloutLegacyOnly || GetClass.Contract.Result != nil || GetClass.Contract.Pagination != nil || GetClass.Contract.Interface == nil || GetClass.Contract.Interface.Availability != contract.InterfaceUnavailable || strings.TrimSpace(GetClass.Contract.Interface.Reason) == "" {
|
||||
t.Error("get-class must publish a precise unavailable Interface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttendanceProjectorsDistinguishEmptyFromBroken(t *testing.T) {
|
||||
explicitEmpty := map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"items": []any{},
|
||||
"adjustmentList": []any{},
|
||||
"atRuleList": []any{},
|
||||
},
|
||||
}
|
||||
if got, err := searchClassProject(explicitEmpty); err != nil || len(got) != 0 {
|
||||
t.Fatalf("explicit empty class list must succeed: got=%v err=%v", got, err)
|
||||
}
|
||||
if got, err := searchRuleProject(explicitEmpty, "attendance-wukong/test", "result.adjustmentList"); err != nil || len(got) != 0 {
|
||||
t.Fatalf("explicit empty rule list must succeed: got=%v err=%v", got, err)
|
||||
}
|
||||
if got, err := searchGroupProject(explicitEmpty); err != nil || len(got) != 0 {
|
||||
t.Fatalf("explicit empty group list must succeed: got=%v err=%v", got, err)
|
||||
}
|
||||
|
||||
broken := []map[string]any{
|
||||
{},
|
||||
{"success": false, "result": []any{}},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": map[string]any{"items": "not-an-array"}},
|
||||
{"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing-id"}}}},
|
||||
}
|
||||
for index, data := range broken {
|
||||
if got, err := searchClassProject(data); err == nil {
|
||||
t.Errorf("broken class response %d returned success: %v", index, got)
|
||||
}
|
||||
if got, err := searchGroupProject(data); err == nil {
|
||||
t.Errorf("broken group response %d returned success: %v", index, got)
|
||||
}
|
||||
}
|
||||
|
||||
ruleBroken := map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"adjustmentList": []any{map[string]any{"entityVO": map[string]any{"name": "missing-id"}}},
|
||||
},
|
||||
}
|
||||
if got, err := searchRuleProject(ruleBroken, "attendance-wukong/test", "result.adjustmentList"); err == nil {
|
||||
t.Fatalf("rule row without stable ID returned success: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttendancePaginationRequiresCompletionEvidence(t *testing.T) {
|
||||
withoutEvidence := map[string]any{"success": true, "result": map[string]any{"items": []any{}}}
|
||||
if _, _, err := attendancePageEvidence(withoutEvidence, "attendance-wukong/test", 1, 20, 0); err == nil {
|
||||
t.Fatal("missing totalCount/totalPage must not be reported as a complete page")
|
||||
}
|
||||
firstPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(21)}}
|
||||
if _, _, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 0); err == nil {
|
||||
t.Fatal("nonzero total with empty page must fail closed")
|
||||
}
|
||||
complete, extra, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("valid page evidence: %v", err)
|
||||
}
|
||||
if complete || extra["nextPage"] != 2 {
|
||||
t.Fatalf("first page should be incomplete with nextPage=2: complete=%v extra=%v", complete, extra)
|
||||
}
|
||||
lastPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(2)}}
|
||||
complete, extra, err = attendancePageEvidence(lastPage, "attendance-wukong/test", 2, 20, 1)
|
||||
if err != nil || !complete {
|
||||
t.Fatalf("last page should be complete: complete=%v extra=%v err=%v", complete, extra, err)
|
||||
}
|
||||
conflicting := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(1), "totalCount": float64(21)}}
|
||||
if _, _, err := attendancePageEvidence(conflicting, "attendance-wukong/test", 1, 20, 20); err == nil {
|
||||
t.Fatal("conflicting totalPage/totalCount evidence must fail closed")
|
||||
}
|
||||
mismatchedPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "totalCount": float64(0)}}
|
||||
if _, _, err := attendancePageEvidence(mismatchedPage, "attendance-wukong/test", 1, 20, 0); err == nil {
|
||||
t.Fatal("mismatched currentPage must fail closed")
|
||||
}
|
||||
negativeTotal := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(-1)}}
|
||||
if _, _, err := attendancePageEvidence(negativeTotal, "attendance-wukong/test", 1, 20, 0); err == nil {
|
||||
t.Fatal("negative pagination evidence must fail closed")
|
||||
}
|
||||
wrongType := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": "0"}}
|
||||
if _, _, err := attendancePageEvidence(wrongType, "attendance-wukong/test", 1, 20, 0); err == nil {
|
||||
t.Fatal("wrong pagination type must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttendanceStableIdentityRejectsZeroBlankAndDuplicates(t *testing.T) {
|
||||
for _, items := range [][]map[string]any{{{"id": 0}}, {{"id": ""}}, {{"id": 1}, {"id": 1}}} {
|
||||
if err := attendanceValidatePositiveIntegerIDs(items, "attendance/test", "id"); err == nil {
|
||||
t.Fatalf("invalid integer identity accepted: %#v", items)
|
||||
}
|
||||
}
|
||||
if err := attendanceValidatePositiveIntegerIDs([]map[string]any{{"id": float64(1)}, {"id": int64(2)}}, "attendance/test", "id"); err != nil {
|
||||
t.Fatalf("valid integer identities rejected: %v", err)
|
||||
}
|
||||
if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": ""}}, "attendance/test", "approveType", "LEAVE"); err == nil {
|
||||
t.Fatal("blank string identity accepted")
|
||||
}
|
||||
if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": "LEAVE"}, {"approveType": "LEAVE"}}, "attendance/test", "approveType", "LEAVE"); err == nil {
|
||||
t.Fatal("duplicate string identity accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttendanceApproveTemplateIdentityAndBinding(t *testing.T) {
|
||||
valid := []map[string]any{
|
||||
{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"},
|
||||
{"processCode": "process-2", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"},
|
||||
}
|
||||
if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil {
|
||||
t.Fatalf("same approveType with unique processCode rejected: %v", err)
|
||||
}
|
||||
valid[0]["processCode"] = " process-1 "
|
||||
if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil || valid[0]["processCode"] != "process-1" {
|
||||
t.Fatalf("processCode was not normalized: value=%#v err=%v", valid[0]["processCode"], err)
|
||||
}
|
||||
valid[0]["approveType"] = "OUT"
|
||||
valid[1]["approveType"] = "OUT"
|
||||
if err := attendanceValidateApproveTemplates(valid, "attendance/test", "OUT"); err != nil {
|
||||
t.Fatalf("OUT templates with unique processCode rejected: %v", err)
|
||||
}
|
||||
|
||||
invalid := []struct {
|
||||
name string
|
||||
items []map[string]any
|
||||
}{
|
||||
{"missing processCode", []map[string]any{{"approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}},
|
||||
{"wrong processCode type", []map[string]any{{"processCode": 1, "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}},
|
||||
{"blank processCode", []map[string]any{{"processCode": " ", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}},
|
||||
{"duplicate processCode", []map[string]any{{"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}, {"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"}}},
|
||||
{"missing approveType", []map[string]any{{"processCode": "process-1", "submitUrl": "https://example.invalid/1"}}},
|
||||
{"null approveType", []map[string]any{{"processCode": "process-1", "approveType": nil, "submitUrl": "https://example.invalid/1"}}},
|
||||
{"wrong approveType type", []map[string]any{{"processCode": "process-1", "approveType": 1, "submitUrl": "https://example.invalid/1"}}},
|
||||
{"wrong approveType", []map[string]any{{"processCode": "process-1", "approveType": "OUT", "submitUrl": "https://example.invalid/1"}}},
|
||||
{"missing submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL"}}},
|
||||
{"null submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": nil}}},
|
||||
{"wrong submitUrl type", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": 1}}},
|
||||
{"blank submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": " "}}},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := attendanceValidateApproveTemplates(tc.items, "attendance/test", "TRAVEL"); err == nil {
|
||||
t.Fatalf("invalid templates accepted: %#v", tc.items)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(GetApproveTemplate.Contract.Result.DataSchema, &schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := schema["properties"].(map[string]any)["templates"].(map[string]any)["items"].(map[string]any)
|
||||
required := items["required"].([]any)
|
||||
for _, name := range []string{"processCode", "approveType", "submitUrl"} {
|
||||
found := false
|
||||
for _, value := range required {
|
||||
if value == name {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("template schema required=%#v missing %s", required, name)
|
||||
}
|
||||
}
|
||||
if got, want := strings.Join(GetApproveTemplate.Contract.Result.SensitivePaths, ","), "templates.formName,templates.processCode,templates.submitUrl"; got != want {
|
||||
t.Fatalf("template sensitive paths = %q, want %q", got, want)
|
||||
}
|
||||
properties := items["properties"].(map[string]any)
|
||||
for _, name := range []string{"processCode", "submitUrl"} {
|
||||
property := properties[name].(map[string]any)
|
||||
if property["type"] != "string" || property["minLength"] != float64(1) {
|
||||
t.Errorf("template schema %s = %#v, want nonempty string", name, property)
|
||||
}
|
||||
}
|
||||
approveType := properties["approveType"].(map[string]any)
|
||||
if approveType["type"] != "string" {
|
||||
t.Errorf("template schema approveType = %#v, want string", approveType)
|
||||
}
|
||||
enum := approveType["enum"].([]any)
|
||||
values := make([]string, 0, len(enum))
|
||||
for _, value := range enum {
|
||||
values = append(values, value.(string))
|
||||
}
|
||||
if got, want := strings.Join(values, ","), "REPAIR_CHECK,LEAVE,OVERTIME,TRAVEL,OUT"; got != want {
|
||||
t.Errorf("template approveType enum = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package attendance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
|
||||
)
|
||||
|
||||
const attendanceCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or downstream proof gap is closed."
|
||||
|
||||
func attendanceIntegerObjectResult(description, identity string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验后的考勤业务结果","properties":{%q:{"type":"integer","minimum":1,"description":"与请求精确绑定的稳定业务 ID"}},"required":[%q],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`,
|
||||
description, identity, identity,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceApproveTemplateResult() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"严格校验且绑定请求类型的考勤审批模板","properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效审批模板数量"},"templates":{"type":"array","description":"可发起的考勤审批模板","items":{"type":"object","description":"具备稳定流程身份与提交入口的考勤审批模板","properties":{"processCode":{"type":"string","minLength":1,"description":"模板的稳定唯一流程编码"},"approveType":{"type":"string","enum":["REPAIR_CHECK","LEAVE","OVERTIME","TRAVEL","OUT"],"description":"与请求精确绑定的审批类型"},"submitUrl":{"type":"string","minLength":1,"description":"非空审批提交入口"},"formName":{"type":"string","description":"审批模板展示名称"}},"required":["processCode","approveType","submitUrl"],"additionalProperties":true}}},"required":["count","templates"],"additionalProperties":false}`),
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceCollectionResult(collection, description, identity, identityType string) *contract.ResultSpec {
|
||||
identityConstraint := `"minLength":1`
|
||||
if identityType == "integer" {
|
||||
identityConstraint = `"minimum":1`
|
||||
}
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效业务记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具备稳定身份的考勤业务记录","properties":{%q:{"type":%q,%s,"description":"记录的稳定业务身份"}},"required":[%q],"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`,
|
||||
description, collection, description, identity, identityType, identityConstraint, identity, collection,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceCursorPagination(cursorParameter string) *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor,
|
||||
CursorParameter: cursorParameter,
|
||||
MetaPath: contract.PaginationMetaPath,
|
||||
EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
||||
NextTokenPath: contract.PaginationNextTokenPath,
|
||||
}
|
||||
}
|
||||
|
||||
func hardenPublicAttendanceContracts() {
|
||||
// Composite Shortcut properties are the stable published workflow inputs.
|
||||
// Execute owns the explicit adapters to the differently named MCP fields;
|
||||
// redirecting an existing non-empty Schema property requires a versioned
|
||||
// migration and cannot be folded into this hardening change.
|
||||
CheckResult.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, {Name: "offset", Property: "offset"}, {Name: "limit", Property: "limit"}}
|
||||
CheckRecord.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}}
|
||||
ListApprove.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "types", Property: "types"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}}
|
||||
GetApproveTemplate.Contract.Parameters = []contract.ParamDecl{{Name: "type", Property: "type"}}
|
||||
GetSchedule.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}}
|
||||
SearchClass.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "filter-type", Property: "filterType"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}
|
||||
GetClass.Contract.Parameters = []contract.ParamDecl{{Name: "class-id", Property: "classId"}}
|
||||
SearchAdjustmentRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}
|
||||
GetOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "overtime-id", Property: "overtimeId"}}
|
||||
SearchOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}
|
||||
GetSelfSetting.Contract.Parameters = []contract.ParamDecl{{Name: "setting-scene", Property: "settingScene"}, {Name: "user", Property: "user"}}
|
||||
|
||||
collections := []struct {
|
||||
declaration *shortcut.Shortcut
|
||||
collection string
|
||||
description string
|
||||
identity string
|
||||
identityType string
|
||||
cursor string
|
||||
}{
|
||||
{&CheckResult, "records", "严格校验的员工打卡结果", "id", "integer", "offset"},
|
||||
{&CheckRecord, "records", "严格校验的员工打卡流水", "id", "integer", ""},
|
||||
{&ListApprove, "approvals", "严格校验的考勤审批记录", "id", "integer", ""},
|
||||
{&SearchClass, "classes", "严格校验的班次搜索结果", "classId", "integer", "page"},
|
||||
{&SearchAdjustmentRule, "rules", "严格校验的补卡规则搜索结果", "ruleId", "integer", "page"},
|
||||
{&SearchOvertimeRule, "rules", "严格校验的加班规则搜索结果", "ruleId", "integer", "page"},
|
||||
}
|
||||
for _, item := range collections {
|
||||
item.declaration.OutputRollout = output.RolloutUnifiedActive
|
||||
item.declaration.Contract.Result = attendanceCollectionResult(item.collection, item.description, item.identity, item.identityType)
|
||||
if item.cursor != "" {
|
||||
item.declaration.Contract.Pagination = attendanceCursorPagination(item.cursor)
|
||||
}
|
||||
}
|
||||
CheckResult.Contract.Result.SensitivePaths = []string{"records.corpId", "records.record", "records.userId"}
|
||||
CheckRecord.Contract.Result.SensitivePaths = []string{"records.baseAddress", "records.baseLatitude", "records.baseLongitude", "records.corpId", "records.deviceId", "records.features", "records.userAddress", "records.userId", "records.userLatitude", "records.userLongitude"}
|
||||
ListApprove.Contract.Result.SensitivePaths = []string{"approvals.corpId", "approvals.originId", "approvals.subType", "approvals.tagName", "approvals.userId"}
|
||||
GetApproveTemplate.OutputRollout = output.RolloutUnifiedActive
|
||||
GetApproveTemplate.Contract.Result = attendanceApproveTemplateResult()
|
||||
GetApproveTemplate.Contract.Result.SensitivePaths = []string{"templates.formName", "templates.processCode", "templates.submitUrl"}
|
||||
SearchClass.Contract.Result.SensitivePaths = []string{"classes.name", "classes.ownerName"}
|
||||
SearchAdjustmentRule.Contract.Result.SensitivePaths = []string{"rules.name"}
|
||||
SearchOvertimeRule.Contract.Result.SensitivePaths = []string{"rules.name"}
|
||||
GetOvertimeRule.OutputRollout = output.RolloutUnifiedActive
|
||||
GetOvertimeRule.Contract.Result = attendanceIntegerObjectResult("严格校验的加班规则详情", "id")
|
||||
GetOvertimeRule.Contract.Result.SensitivePaths = []string{"value.content", "value.groupIdAndNames", "value.name", "value.owner", "value.ownerList", "value.scopes"}
|
||||
markAttendanceCompatibilityOnly(&GetSummary)
|
||||
markAttendanceCompatibilityOnly(&ListLeaveTypes)
|
||||
markAttendanceCompatibilityOnly(&GetLeaveRecords)
|
||||
markAttendanceCompatibilityOnly(&GetCheckinRecord)
|
||||
markAttendanceCompatibilityOnly(&GetSchedule)
|
||||
markAttendanceUnavailable(&GetClass, "下游 get_class_detail 对搜索得到的真实 classId 返回非空详情,但 shiftVO 不回显 id/classId,Shortcut 无法诚实地把详情精确绑定到请求 ID;需要下游补充稳定 ID 回显。")
|
||||
markAttendanceCompatibilityOnly(&GetSelfSetting)
|
||||
}
|
||||
|
||||
func markAttendanceCompatibilityOnly(declaration *shortcut.Shortcut) {
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
declaration.Contract.Result = nil
|
||||
declaration.Contract.Pagination = nil
|
||||
declaration.Contract.Interface = &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: attendanceCompatibilityInterfaceReason,
|
||||
}
|
||||
}
|
||||
|
||||
func markAttendanceUnavailable(declaration *shortcut.Shortcut, reason string) {
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
declaration.Contract.Result = nil
|
||||
declaration.Contract.Pagination = nil
|
||||
declaration.Contract.Interface = &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceUnavailable,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceCallCollection(
|
||||
rt *shortcut.RuntimeContext,
|
||||
product, tool string,
|
||||
params map[string]any,
|
||||
collection string,
|
||||
complete bool,
|
||||
extra map[string]any,
|
||||
validate func([]map[string]any) error,
|
||||
paths ...string,
|
||||
) error {
|
||||
data, err := rt.CallMCPData(product, tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := responsecheck.RequireObjectCollection(data, product+"/"+tool, paths...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if validate != nil {
|
||||
if err := validate(items); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return attendanceOutputCollection(rt, collection, items, complete, extra, false, "")
|
||||
}
|
||||
|
||||
func attendanceCallValue(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error {
|
||||
data, err := rt.CallMCPData(product, tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := responsecheck.RequireResult(data, product+"/"+tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"value": value})
|
||||
}
|
||||
|
||||
func attendanceCallObject(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error {
|
||||
data, err := rt.CallMCPData(product, tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := responsecheck.RequireSingleObjectResult(data, product+"/"+tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"value": value})
|
||||
}
|
||||
|
||||
func attendanceCollectionPayload(collection string, items []map[string]any, complete bool, extra map[string]any) map[string]any {
|
||||
payload := map[string]any{
|
||||
"count": len(items),
|
||||
collection: items,
|
||||
"complete": complete,
|
||||
}
|
||||
for key, value := range extra {
|
||||
payload[key] = value
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func attendanceOutputCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, legacyExtra map[string]any, paginated bool, nextToken string) error {
|
||||
payload := attendanceCollectionPayload(collection, items, complete, legacyExtra)
|
||||
if !output.UsesUnifiedResult(rt.Command()) {
|
||||
return rt.Output(payload)
|
||||
}
|
||||
business := map[string]any{"count": len(items), collection: items}
|
||||
meta := &output.Meta{Count: output.NewCount(len(items))}
|
||||
if paginated {
|
||||
pagination, err := output.NewPagination(complete, nextToken)
|
||||
if err != nil {
|
||||
return responsecheck.Error("attendance/pagination", "invalid_pagination", err.Error())
|
||||
}
|
||||
meta.Pagination = pagination
|
||||
}
|
||||
return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta)))
|
||||
}
|
||||
|
||||
func attendanceValidatePositiveIntegerIDs(items []map[string]any, operation string, path ...string) error {
|
||||
seen := make(map[int64]struct{}, len(items))
|
||||
for index, item := range items {
|
||||
value, ok := attendanceNestedValue(item, path...)
|
||||
if !ok {
|
||||
return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 ID", index))
|
||||
}
|
||||
identity, ok := attendancePositiveInteger(value)
|
||||
if !ok {
|
||||
return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项稳定 ID 必须是大于 0 的整数", index))
|
||||
}
|
||||
if _, duplicate := seen[identity]; duplicate {
|
||||
return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项稳定 ID 重复", index))
|
||||
}
|
||||
seen[identity] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceValidateExpectedStrings(items []map[string]any, operation, field, expected string) error {
|
||||
expected = strings.TrimSpace(expected)
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for index, item := range items {
|
||||
value, ok := item[field].(string)
|
||||
value = strings.TrimSpace(value)
|
||||
if !ok || value == "" {
|
||||
return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 %s 必须是非空字符串", index, field))
|
||||
}
|
||||
if expected != "" && value != expected {
|
||||
return responsecheck.Error(operation, "item_identity_mismatch", fmt.Sprintf("第 %d 项 %s 与请求不一致", index, field))
|
||||
}
|
||||
if _, duplicate := seen[value]; duplicate {
|
||||
return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 %s 重复", index, field))
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceValidateApproveTemplates(items []map[string]any, operation, expectedType string) error {
|
||||
expectedType = strings.TrimSpace(expectedType)
|
||||
seenProcessCodes := make(map[string]struct{}, len(items))
|
||||
for index, item := range items {
|
||||
processCodeValue, present := item["processCode"]
|
||||
if !present {
|
||||
return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 processCode", index))
|
||||
}
|
||||
processCode, ok := processCodeValue.(string)
|
||||
processCode = strings.TrimSpace(processCode)
|
||||
if !ok || processCode == "" {
|
||||
return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 processCode 必须是非空字符串", index))
|
||||
}
|
||||
item["processCode"] = processCode
|
||||
if _, duplicate := seenProcessCodes[processCode]; duplicate {
|
||||
return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 processCode 重复", index))
|
||||
}
|
||||
seenProcessCodes[processCode] = struct{}{}
|
||||
|
||||
approveType, ok := item["approveType"].(string)
|
||||
if !ok || approveType != expectedType {
|
||||
return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项 approveType 与请求不一致", index))
|
||||
}
|
||||
submitURL, ok := item["submitUrl"].(string)
|
||||
if !ok || strings.TrimSpace(submitURL) == "" {
|
||||
return responsecheck.Error(operation, "malformed_item", fmt.Sprintf("第 %d 项缺少非空 submitUrl", index))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceNestedValue(item map[string]any, path ...string) (any, bool) {
|
||||
var current any = item
|
||||
for _, key := range path {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
current, ok = object[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func attendancePositiveInteger(value any) (int64, bool) {
|
||||
switch number := value.(type) {
|
||||
case int:
|
||||
return int64(number), number > 0
|
||||
case int32:
|
||||
return int64(number), number > 0
|
||||
case int64:
|
||||
return number, number > 0
|
||||
case float64:
|
||||
return int64(number), number > 0 && number == float64(int64(number))
|
||||
case json.Number:
|
||||
parsed, err := strconv.ParseInt(string(number), 10, 64)
|
||||
return parsed, err == nil && parsed > 0
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceValidateUserIDs(values []string, maximum int) error {
|
||||
if len(values) == 0 {
|
||||
return fmt.Errorf("用户 ID 列表不能为空")
|
||||
}
|
||||
if maximum > 0 && len(values) > maximum {
|
||||
return fmt.Errorf("用户 ID 数量不能超过 %d", maximum)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("用户 ID 不能为空字符串")
|
||||
}
|
||||
if value != trimmed {
|
||||
return fmt.Errorf("用户 ID 不能包含首尾空白")
|
||||
}
|
||||
if _, duplicate := seen[trimmed]; duplicate {
|
||||
return fmt.Errorf("用户 ID 不能重复")
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceValidateUserAndTimeBinding(items []map[string]any, operation string, requestedUsers []string, userField, timeField string, startMillis, endMillis int64) error {
|
||||
users := make(map[string]struct{}, len(requestedUsers))
|
||||
for _, user := range requestedUsers {
|
||||
users[user] = struct{}{}
|
||||
}
|
||||
for index, item := range items {
|
||||
rawUser, present := item[userField]
|
||||
if !present {
|
||||
return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index))
|
||||
}
|
||||
user, ok := rawUser.(string)
|
||||
user = strings.TrimSpace(user)
|
||||
if !ok || user == "" {
|
||||
return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项用户身份回显必须是非空字符串", index))
|
||||
}
|
||||
if _, requested := users[user]; !requested {
|
||||
return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index))
|
||||
}
|
||||
rawTimestamp, present := item[timeField]
|
||||
if !present {
|
||||
return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少时间回显", index))
|
||||
}
|
||||
timestamp, ok := attendancePositiveInteger(rawTimestamp)
|
||||
if !ok {
|
||||
return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项时间回显必须是大于 0 的整数毫秒时间戳", index))
|
||||
}
|
||||
if timestamp < startMillis || timestamp > endMillis {
|
||||
return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项时间不在请求范围内", index))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceValidateApprovalBinding(items []map[string]any, operation string, requestedUsers []string, requestedTypes map[int]struct{}, startMillis, endMillis int64) error {
|
||||
users := make(map[string]struct{}, len(requestedUsers))
|
||||
for _, user := range requestedUsers {
|
||||
users[user] = struct{}{}
|
||||
}
|
||||
for index, item := range items {
|
||||
user, ok := item["userId"].(string)
|
||||
user = strings.TrimSpace(user)
|
||||
if !ok || user == "" {
|
||||
return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index))
|
||||
}
|
||||
if _, requested := users[user]; !requested {
|
||||
return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index))
|
||||
}
|
||||
bizType, ok := attendancePositiveInteger(item["bizType"])
|
||||
if !ok {
|
||||
return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少审批类型回显", index))
|
||||
}
|
||||
if _, requested := requestedTypes[int(bizType)]; !requested {
|
||||
return responsecheck.Error(operation, "request_type_mismatch", fmt.Sprintf("第 %d 项审批类型不在请求集合中", index))
|
||||
}
|
||||
begin, beginOK := attendancePositiveInteger(item["beginTime"])
|
||||
end, endOK := attendancePositiveInteger(item["endTime"])
|
||||
if !beginOK || !endOK || end < begin || end < startMillis || begin > endMillis {
|
||||
return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项审批时间不与请求范围相交", index))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceValidateMonthRange(startText, endText string) error {
|
||||
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(startText), time.Local)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--start 日期格式错误,应为 YYYY-MM-DD: %w", err)
|
||||
}
|
||||
end, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(endText), time.Local)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--end 日期格式错误,应为 YYYY-MM-DD: %w", err)
|
||||
}
|
||||
if end.Before(start) {
|
||||
return fmt.Errorf("--end 不能早于 --start")
|
||||
}
|
||||
if end.After(start.AddDate(0, 1, 0)) {
|
||||
return fmt.Errorf("--start 到 --end 的跨度不能超过 1 个月")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceValidatePageRequest(rt *shortcut.RuntimeContext) error {
|
||||
_, _, err := attendancePageInput(rt)
|
||||
return err
|
||||
}
|
||||
|
||||
func attendancePageEvidence(data map[string]any, operation string, page, limit, itemCount int) (bool, map[string]any, error) {
|
||||
if page < 1 || limit < 1 || itemCount < 0 || itemCount > limit {
|
||||
return false, nil, responsecheck.Error(operation, "invalid_pagination_request", "页码、页大小或当前项数无效")
|
||||
}
|
||||
result, err := responsecheck.RequireObjectResult(data, operation)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
extra := map[string]any{"page": page, "limit": limit}
|
||||
if raw, present := result["currentPage"]; present {
|
||||
currentPage, ok := attendanceInt(raw)
|
||||
if !ok {
|
||||
return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 currentPage 必须是整数")
|
||||
}
|
||||
if currentPage != page {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_page_mismatch", "服务端 currentPage 与请求页码不一致")
|
||||
}
|
||||
}
|
||||
var evidence []bool
|
||||
var totalPageValue, totalCountValue *int
|
||||
if raw, present := result["totalPage"]; present {
|
||||
totalPage, ok := attendanceInt(raw)
|
||||
if !ok {
|
||||
return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 必须是整数")
|
||||
}
|
||||
if totalPage < 0 {
|
||||
return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 不能为负数")
|
||||
}
|
||||
extra["totalPage"] = totalPage
|
||||
totalPageValue = &totalPage
|
||||
if totalPage == 0 {
|
||||
if page != 1 || itemCount != 0 {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "totalPage=0 只允许第一页为空")
|
||||
}
|
||||
evidence = append(evidence, true)
|
||||
} else {
|
||||
if page > totalPage {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_page_out_of_range", "请求页码超过服务端 totalPage")
|
||||
}
|
||||
evidence = append(evidence, page == totalPage)
|
||||
}
|
||||
}
|
||||
if raw, present := result["totalCount"]; present {
|
||||
totalCount, ok := attendanceInt(raw)
|
||||
if !ok {
|
||||
return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 必须是整数")
|
||||
}
|
||||
if totalCount < 0 {
|
||||
return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 不能为负数")
|
||||
}
|
||||
extra["totalCount"] = totalCount
|
||||
totalCountValue = &totalCount
|
||||
if totalCount > 0 && itemCount == 0 {
|
||||
return false, nil, responsecheck.Error(operation, "empty_page_with_nonzero_total", "totalCount>0 但当前集合为空,不能证明前进或终止")
|
||||
}
|
||||
pageStart := (page - 1) * limit
|
||||
if pageStart > totalCount || pageStart+itemCount > totalCount {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "当前页项数与 totalCount/page/limit 矛盾")
|
||||
}
|
||||
evidence = append(evidence, page*limit >= totalCount)
|
||||
}
|
||||
if len(evidence) == 0 {
|
||||
return false, nil, responsecheck.Error(operation, "missing_pagination_evidence", "分页响应缺少 totalCount/totalPage,无法证明当前页是否完整")
|
||||
}
|
||||
complete := evidence[0]
|
||||
for _, candidate := range evidence[1:] {
|
||||
if candidate != complete {
|
||||
return false, nil, responsecheck.Error(operation, "conflicting_pagination_evidence", "服务端 totalCount 与 totalPage 对当前页是否完成给出矛盾证据")
|
||||
}
|
||||
}
|
||||
if totalPageValue != nil && totalCountValue != nil {
|
||||
expectedPages := 0
|
||||
if *totalCountValue > 0 {
|
||||
expectedPages = (*totalCountValue + limit - 1) / limit
|
||||
}
|
||||
if *totalPageValue != expectedPages {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_total_mismatch", "totalPage 与 totalCount/limit 矛盾")
|
||||
}
|
||||
}
|
||||
if !complete && itemCount == 0 {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_no_progress", "未完成页没有返回任何稳定项,无法安全前进")
|
||||
}
|
||||
if !complete && itemCount != limit {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_short_page", "未完成页返回项数小于 limit,与继续分页证据矛盾")
|
||||
}
|
||||
if complete && totalCountValue != nil {
|
||||
pageStart := (page - 1) * limit
|
||||
if pageStart+itemCount != *totalCountValue {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "终止页项数未精确覆盖 totalCount")
|
||||
}
|
||||
}
|
||||
if !complete {
|
||||
extra["nextPage"] = page + 1
|
||||
}
|
||||
return complete, extra, nil
|
||||
}
|
||||
|
||||
func attendancePageInput(rt *shortcut.RuntimeContext) (int, int, error) {
|
||||
page, limit := rt.Int("page"), rt.Int("limit")
|
||||
if page < 1 {
|
||||
return 0, 0, fmt.Errorf("--page 必须大于等于 1")
|
||||
}
|
||||
if limit < 1 || limit > 200 {
|
||||
return 0, 0, fmt.Errorf("--limit 必须在 1 到 200 之间")
|
||||
}
|
||||
return page, limit, nil
|
||||
}
|
||||
|
||||
func attendanceInt(value any) (int, bool) {
|
||||
switch number := value.(type) {
|
||||
case int:
|
||||
return number, true
|
||||
case int32:
|
||||
return int(number), true
|
||||
case int64:
|
||||
return int(number), true
|
||||
case float64:
|
||||
if number != float64(int(number)) {
|
||||
return 0, false
|
||||
}
|
||||
return int(number), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,12 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSearchClassProjectShiftVOShape guards against projection-data-loss:
|
||||
// TestCrossPlatformCoverageSearchClassProjectShiftVOShape guards against projection-data-loss:
|
||||
// get_class_list nests the list under result.items and wraps each shift's
|
||||
// identity under shiftVO. The projection must unwrap shiftVO or +search-class
|
||||
// silently returns empty despite the backend returning shifts.
|
||||
func TestSearchClassProjectShiftVOShape(t *testing.T) {
|
||||
const raw = `{"result":{"items":[
|
||||
func TestCrossPlatformCoverageSearchClassProjectShiftVOShape(t *testing.T) {
|
||||
const raw = `{"success":true,"result":{"items":[
|
||||
{"shiftVO":{"id":957395083,"name":"default shift"}},
|
||||
{"shiftVO":{"id":957395084,"name":"morning shift"}}
|
||||
]}}`
|
||||
@@ -31,7 +31,10 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
got := searchClassProject(data)
|
||||
got, err := searchClassProject(data)
|
||||
if err != nil {
|
||||
t.Fatalf("project class response: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: 2 shifts in backend, projection returned %d (%v)", len(got), got)
|
||||
}
|
||||
@@ -40,18 +43,18 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList)
|
||||
// TestCrossPlatformCoverageSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList)
|
||||
// and get_adjustment_rule (result.adjustmentList), both wrapping the rule under
|
||||
// entityVO. The resolver must probe those keys and unwrap entityVO or
|
||||
// +search-overtime-rule / +search-adjustment-rule silently return empty.
|
||||
func TestSearchRuleProjectEntityVOShape(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSearchRuleProjectEntityVOShape(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, key string
|
||||
}{
|
||||
{"overtime", "atRuleList"},
|
||||
{"adjustment", "adjustmentList"},
|
||||
} {
|
||||
raw := `{"result":{"` + tc.key + `":[
|
||||
raw := `{"success":true,"result":{"` + tc.key + `":[
|
||||
{"entityVO":{"id":11,"name":"weekday overtime"},"permissionVO":{}},
|
||||
{"entityVO":{"id":12,"name":"holiday overtime"},"permissionVO":{}}
|
||||
]}}`
|
||||
@@ -59,7 +62,10 @@ func TestSearchRuleProjectEntityVOShape(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("[%s] unmarshal fixture: %v", tc.name, err)
|
||||
}
|
||||
got := searchRuleProject(data)
|
||||
got, err := searchRuleProject(data, "attendance-wukong/test", "result."+tc.key)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] project rule response: %v", tc.name, err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("[%s] lower/upper mismatch: result.%s has 2 entries, projection returned %d", tc.name, tc.key, len(got))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_attendance.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "attendance" {
|
||||
t.Fatalf("service = %q", source.Service)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service != "attendance" {
|
||||
continue
|
||||
}
|
||||
if _, exists := registered[item.Command]; exists {
|
||||
t.Fatalf("duplicate %s", item.Command)
|
||||
}
|
||||
registered[item.Command] = item
|
||||
}
|
||||
if len(registered) != 35 || len(source.Shortcuts) != 35 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 35/35", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
public := 0
|
||||
compatibilityVisible := 0
|
||||
wantCompatibilityVisible := map[string]bool{
|
||||
"+get-adjustment-rule": true,
|
||||
"+get-checkin-record": true,
|
||||
"+get-leave-records": true,
|
||||
"+get-self-setting": true,
|
||||
"+get-schedule": true,
|
||||
"+get-summary": true,
|
||||
"+list-leave-types": true,
|
||||
"+my-attendance": true,
|
||||
"+query-report-data": true,
|
||||
"+search-group": true,
|
||||
"+this-month": true,
|
||||
}
|
||||
var missing, stale []string
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
availability := record.Availability
|
||||
if availability == "" {
|
||||
availability = source.Availability
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Errorf("%s: semantic review facts drifted", command)
|
||||
}
|
||||
if item.Risk != record.Risk {
|
||||
t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk)
|
||||
}
|
||||
if record.Public {
|
||||
public++
|
||||
if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable {
|
||||
t.Errorf("%s: public available shortcut is not executable/visible", command)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command)
|
||||
}
|
||||
} else if availability == shortcut.AvailabilityAvailable || item.Hidden == record.CompatibilityVisible {
|
||||
t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command)
|
||||
}
|
||||
if availability == shortcut.AvailabilityUnavailable && item.Contract.Interface != nil {
|
||||
wantInterfaceAvailability := "unavailable"
|
||||
if record.CompatibilityVisible {
|
||||
wantInterfaceAvailability = "available"
|
||||
}
|
||||
if item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" {
|
||||
t.Errorf("%s: runtime compatibility interface is not explicit", command)
|
||||
}
|
||||
}
|
||||
if record.CompatibilityVisible {
|
||||
compatibilityVisible++
|
||||
}
|
||||
if record.CompatibilityVisible != wantCompatibilityVisible[command] {
|
||||
t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
|
||||
}
|
||||
}
|
||||
for command := range source.Shortcuts {
|
||||
if _, ok := registered[command]; !ok {
|
||||
stale = append(stale, command)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||
}
|
||||
if public != 8 || compatibilityVisible != 11 {
|
||||
t.Fatalf("public/compatibility-visible attendance shortcuts = %d/%d, want 8/11", public, compatibilityVisible)
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,14 @@ type chatSemanticCatalogFixture struct {
|
||||
Service string `json:"service"`
|
||||
Availability shortcut.Availability `json:"default_availability"`
|
||||
Shortcuts map[string]struct {
|
||||
Disposition shortcut.SemanticDisposition `json:"disposition"`
|
||||
SemanticDelta string `json:"semantic_delta"`
|
||||
Risk shortcut.Risk `json:"risk"`
|
||||
Availability shortcut.Availability `json:"availability"`
|
||||
Primary string `json:"primary"`
|
||||
Public bool `json:"public"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
Disposition shortcut.SemanticDisposition `json:"disposition"`
|
||||
SemanticDelta string `json:"semantic_delta"`
|
||||
Risk shortcut.Risk `json:"risk"`
|
||||
Availability shortcut.Availability `json:"availability"`
|
||||
Primary string `json:"primary"`
|
||||
Public bool `json:"public"`
|
||||
CompatibilityVisible bool `json:"compatibility_visible"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
} `json:"shortcuts"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_mail.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "mail" {
|
||||
t.Fatalf("service = %q", source.Service)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "mail" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if len(registered) != 18 || len(source.Shortcuts) != 18 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 18/18", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
var missing, stale []string
|
||||
public := 0
|
||||
unavailable := 0
|
||||
compatibilityVisible := 0
|
||||
wantCompatibilityVisible := map[string]bool{
|
||||
"+contact-list": true,
|
||||
"+recent-mail": true,
|
||||
"+tag-list": true,
|
||||
"+template-list": true,
|
||||
"+thread-list": true,
|
||||
"+unread-mail": true,
|
||||
}
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
availability := record.Availability
|
||||
if availability == "" {
|
||||
availability = source.Availability
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Errorf("%s: semantic review facts drifted", command)
|
||||
}
|
||||
if item.Risk != record.Risk {
|
||||
t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk)
|
||||
}
|
||||
if record.Public {
|
||||
public++
|
||||
if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable {
|
||||
t.Errorf("%s: public available shortcut is not executable/visible", command)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command)
|
||||
}
|
||||
}
|
||||
if availability == shortcut.AvailabilityUnavailable {
|
||||
unavailable++
|
||||
if item.Hidden == record.CompatibilityVisible || item.Availability != shortcut.AvailabilityUnavailable {
|
||||
t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command)
|
||||
}
|
||||
wantInterfaceAvailability := "unavailable"
|
||||
if record.CompatibilityVisible {
|
||||
wantInterfaceAvailability = "available"
|
||||
}
|
||||
if item.Contract.Interface == nil || item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" {
|
||||
t.Errorf("%s: runtime compatibility interface is not explicit", command)
|
||||
}
|
||||
if item.Contract.Result != nil || item.Contract.Pagination != nil || item.OutputRollout != output.RolloutLegacyOnly {
|
||||
t.Errorf("%s: unavailable runtime still publishes result/pagination/unified rollout", command)
|
||||
}
|
||||
}
|
||||
if record.CompatibilityVisible {
|
||||
compatibilityVisible++
|
||||
}
|
||||
if record.CompatibilityVisible != wantCompatibilityVisible[command] {
|
||||
t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
|
||||
}
|
||||
}
|
||||
for command := range source.Shortcuts {
|
||||
if _, ok := registered[command]; !ok {
|
||||
stale = append(stale, command)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||
}
|
||||
if public != 8 || unavailable != 10 || compatibilityVisible != 6 {
|
||||
t.Fatalf("mail public/unavailable/compatibility-visible shortcuts = %d/%d/%d, want 8/10/6", public, unavailable, compatibilityVisible)
|
||||
}
|
||||
}
|
||||
@@ -437,7 +437,7 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
|
||||
}{
|
||||
{
|
||||
name: "user group mentions",
|
||||
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
|
||||
args: []string{"--identity", "user", "--group", "cid", "--text", "@" + fixtureCurrentDOpenID + " <@" + fixtureCurrentDOpenID2 + "> x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
|
||||
want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true},
|
||||
},
|
||||
{
|
||||
@@ -505,6 +505,69 @@ 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 {
|
||||
|
||||
@@ -585,6 +585,119 @@ 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,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
@@ -337,8 +338,15 @@ 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(call.args[tt.bodyKey].(string)), &content); err != nil {
|
||||
if err := json.Unmarshal([]byte(rawContent), &content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content["text"] != tt.body {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
@@ -18,6 +19,7 @@ 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"
|
||||
@@ -181,6 +183,12 @@ 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")
|
||||
@@ -234,6 +242,89 @@ 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)
|
||||
@@ -499,7 +590,7 @@ func resolvedUserMarkdownParams(
|
||||
if target.GroupID != "" {
|
||||
body = helpers.NormalizeMessageMentions(body, atOpenIDs, atAll, true)
|
||||
}
|
||||
content, _ := json.Marshal(map[string]string{"title": title, "text": body})
|
||||
content, _ := jsonutil.Marshal(map[string]string{"title": title, "text": body})
|
||||
params := rt.AddAIMessageTag(map[string]any{
|
||||
"msgType": "markdown",
|
||||
"content": string(content),
|
||||
@@ -573,8 +664,10 @@ func executeMessagesSendUserFile(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetArgs := map[string]any{}
|
||||
addMessagesSendUserTarget(targetArgs, group, openID)
|
||||
uploadTargetArgs := map[string]any{}
|
||||
addMessagesSendUserUploadTarget(uploadTargetArgs, group, openID)
|
||||
sendTargetArgs := map[string]any{}
|
||||
addMessagesSendUserTarget(sendTargetArgs, group, openID)
|
||||
idempotencyKey := messagesSendIdempotencyKey(rt)
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
@@ -587,6 +680,7 @@ func executeMessagesSendUserFile(
|
||||
{
|
||||
"identity": "user",
|
||||
"tool": "init/commit_conversation_file_upload",
|
||||
"target": uploadTargetArgs,
|
||||
"file": map[string]any{
|
||||
"path": rawPath,
|
||||
"name": meta.FileName,
|
||||
@@ -598,7 +692,7 @@ func executeMessagesSendUserFile(
|
||||
"tool": "send_personal_message",
|
||||
"requestedMessageType": requestedType,
|
||||
"effectiveMessageType": "file",
|
||||
"target": targetArgs,
|
||||
"target": sendTargetArgs,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -607,7 +701,7 @@ func executeMessagesSendUserFile(
|
||||
rt.Command().Context(), messagesSendFileUploadTimeout)
|
||||
defer cancelUpload()
|
||||
commitText, err := helpers.UploadConversationLocalFile(
|
||||
uploadContext, targetArgs, meta, idempotencyKey)
|
||||
uploadContext, uploadTargetArgs, meta, idempotencyKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -653,6 +747,14 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const mailCompositeReason = "Reviewed Mail Shortcut composite: the executable CLI owns strict response validation, pagination evidence, stable-identity checks, output projection, and confirmation; no single MCP interface represents the complete command contract."
|
||||
|
||||
const mailCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or cleanup proof gap is closed."
|
||||
|
||||
func mailCollectionResult(collection, description string) *contract.ResultSpec {
|
||||
identity := mailCollectionIdentitySchema(collection)
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
SensitivePaths: mailCollectionSensitivePaths(collection),
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件业务记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`,
|
||||
description, collection, description, identity, collection,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
func mailCollectionSensitivePaths(collection string) []string {
|
||||
switch collection {
|
||||
case "threads":
|
||||
return []string{"threads.subject", "threads.lastUpdated"}
|
||||
case "folders":
|
||||
return []string{"folders.name"}
|
||||
case "tags":
|
||||
return []string{"tags.name"}
|
||||
case "users":
|
||||
return []string{"users.name", "users.email", "users.employeeNo"}
|
||||
case "templates":
|
||||
return []string{"templates.name", "templates.subject"}
|
||||
case "contacts":
|
||||
return []string{"contacts.contactEmail", "contacts.displayName"}
|
||||
case "messages":
|
||||
return []string{"messages.subject", "messages.body", "messages.markdownBody", "messages.from", "messages.toRecipients", "messages.ccRecipients", "messages.bccRecipients", "messages.attachments", "messages.receivedDateTime", "messages.sentDateTime"}
|
||||
default:
|
||||
panic("unsupported Mail collection sensitive paths: " + collection)
|
||||
}
|
||||
}
|
||||
|
||||
func mailCollectionIdentitySchema(collection string) string {
|
||||
switch collection {
|
||||
case "threads":
|
||||
return `"properties":{"conversationId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["conversationId"]`
|
||||
case "folders", "tags", "templates", "contacts", "messages":
|
||||
return `"properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"]`
|
||||
case "users":
|
||||
return `"properties":{"userId":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["userId"]},{"required":["email"]}]`
|
||||
default:
|
||||
panic("unsupported Mail collection Result identity: " + collection)
|
||||
}
|
||||
}
|
||||
|
||||
func mailCursorPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor,
|
||||
CursorParameter: "cursor",
|
||||
MetaPath: contract.PaginationMetaPath,
|
||||
EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
||||
NextTokenPath: contract.PaginationNextTokenPath,
|
||||
}
|
||||
}
|
||||
|
||||
func mailObjectResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验且身份匹配的邮件业务对象","properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`,
|
||||
description,
|
||||
)),
|
||||
SensitivePaths: []string{"value.body", "value.markdownBody", "value.subject", "value.summary", "value.name", "value.email", "value.contactEmail", "value.displayName", "value.from", "value.toRecipients", "value.ccRecipients", "value.bccRecipients", "value.senders", "value.attachments", "value.receivedDateTime", "value.sentDateTime"},
|
||||
}
|
||||
}
|
||||
|
||||
func mailReadSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
||||
}
|
||||
|
||||
func mailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error {
|
||||
if !required && !rt.Changed(flag) {
|
||||
return nil
|
||||
}
|
||||
value := rt.Int(flag)
|
||||
if value < 1 || value > 100 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) (string, error) {
|
||||
if !required && !rt.Changed(flag) {
|
||||
return "", nil
|
||||
}
|
||||
value, err := strconv.Atoi(rt.Str(flag))
|
||||
if err != nil || value < 1 || value > 100 {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag))
|
||||
}
|
||||
return strconv.Itoa(value), nil
|
||||
}
|
||||
|
||||
func mailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) error {
|
||||
_, err := mailStringPageSize(rt, flag, required)
|
||||
return err
|
||||
}
|
||||
|
||||
func mailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error {
|
||||
if strings.TrimSpace(rt.Str(flag)) == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailValidateThreadList(rt *shortcut.RuntimeContext) error {
|
||||
if err := mailValidatePageSize(rt, "limit", true); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed := make(map[string]time.Time, 2)
|
||||
for _, flag := range []string{"start", "end"} {
|
||||
if !rt.Changed(flag) {
|
||||
continue
|
||||
}
|
||||
value, err := time.Parse(time.RFC3339, strings.TrimSpace(rt.Str(flag)))
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 必须是 RFC3339 时间", flag))
|
||||
}
|
||||
_, offset := value.Zone()
|
||||
if offset != 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 必须使用 UTC 时区", flag))
|
||||
}
|
||||
parsed[flag] = value
|
||||
}
|
||||
start, hasStart := parsed["start"]
|
||||
end, hasEnd := parsed["end"]
|
||||
if hasStart && hasEnd && end.Before(start) {
|
||||
return apperrors.NewValidation("--end 不能早于 --start")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailWriteSafety(idempotency string) contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: idempotency}
|
||||
}
|
||||
|
||||
func mailReadContract(command, description, intent string, result *contract.ResultSpec, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
cliPath := "mail " + command
|
||||
return corecmd.ContractDecl{
|
||||
Description: description,
|
||||
Result: result,
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail", Name: name, CanonicalPath: "mail." + name,
|
||||
CLIPath: cliPath, PrimaryCLIPath: cliPath,
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: description,
|
||||
UseWhen: []string{intent},
|
||||
AvoidWhen: []string{"只需摘要列表时使用 mail +triage 或 +recent-mail;需要原始响应时改用对应原子命令"},
|
||||
Examples: examples,
|
||||
},
|
||||
Parameters: flags,
|
||||
}
|
||||
}
|
||||
|
||||
func mailWriteContract(command, description, intent string, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
|
||||
contractDecl := mailReadContract(command, description, intent, mailObjectResult(description), flags, examples...)
|
||||
contractDecl.Selection.AvoidWhen = []string{"用户尚未确认写入内容或目标时不要执行;只需读取时使用对应只读 shortcut"}
|
||||
return contractDecl
|
||||
}
|
||||
|
||||
func mailResponseError(operation, reason, message string) error {
|
||||
return apperrors.NewAPI(message,
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("response_validation"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithReason(reason),
|
||||
)
|
||||
}
|
||||
|
||||
// mailRequireSuccess accepts only the two success encodings observed from the
|
||||
// Mail backend: boolean true and the exact string "true". Missing, false, or
|
||||
// malformed status values fail closed.
|
||||
func mailRequireSuccess(data map[string]any, operation string) error {
|
||||
if len(data) == 0 {
|
||||
return mailResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空")
|
||||
}
|
||||
switch value := data["success"].(type) {
|
||||
case bool:
|
||||
if value {
|
||||
return nil
|
||||
}
|
||||
case string:
|
||||
if value == "true" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if _, present := data["success"]; !present {
|
||||
return mailResponseError(operation, "missing_success", "响应缺少 success 业务状态")
|
||||
}
|
||||
message := mailFirstString(data, "errorMessage", "errorMsg", "message", "error")
|
||||
if message == "" {
|
||||
message = "服务未明确返回成功状态"
|
||||
}
|
||||
return mailResponseError(operation, "remote_failure", message)
|
||||
}
|
||||
|
||||
func mailRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) {
|
||||
if err := mailRequireSuccess(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, present := mailLookup(data, path)
|
||||
if !present {
|
||||
return nil, mailResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path))
|
||||
}
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value))
|
||||
}
|
||||
items := make([]map[string]any, 0, len(raw))
|
||||
for index, item := range raw {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index))
|
||||
}
|
||||
items = append(items, object)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func mailRequireObject(data map[string]any, operation, path string) (map[string]any, error) {
|
||||
if err := mailRequireSuccess(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, present := mailLookup(data, path)
|
||||
if !present || value == nil {
|
||||
return nil, mailResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path))
|
||||
}
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return nil, mailResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value))
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
|
||||
func mailRequireIdentity(object map[string]any, operation, expected string, keys ...string) error {
|
||||
actual := mailFirstString(object, keys...)
|
||||
if actual == "" {
|
||||
return mailResponseError(operation, "missing_stable_id", "业务对象缺少稳定 ID")
|
||||
}
|
||||
if expected != "" && actual != expected {
|
||||
return mailResponseError(operation, "identity_mismatch", "业务对象 ID 与请求目标不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailValidateRows(items []map[string]any, operation string, identityKeys ...string) error {
|
||||
allowNumericID := false
|
||||
for _, key := range identityKeys {
|
||||
if key == "email" {
|
||||
allowNumericID = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for index, item := range items {
|
||||
valid := mailFirstString(item, identityKeys...) != ""
|
||||
if !valid && allowNumericID {
|
||||
valid = mailNumericIdentity(item["id"])
|
||||
}
|
||||
if !valid {
|
||||
return mailResponseError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailNumericIdentity(value any) bool {
|
||||
switch value.(type) {
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, json.Number:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func mailProjectValue(operation, outputName string, value any) (any, bool, error) {
|
||||
if value == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
switch outputName {
|
||||
case "email":
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 email 必须是字符串")
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
return text, text != "", nil
|
||||
case "userId":
|
||||
if text, ok := value.(string); ok {
|
||||
text = strings.TrimSpace(text)
|
||||
return text, text != "", nil
|
||||
}
|
||||
if !mailNumericIdentity(value) {
|
||||
return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 userId 必须是字符串或数字")
|
||||
}
|
||||
return value, true, nil
|
||||
default:
|
||||
if text, ok := value.(string); ok {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
return text, true, nil
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func mailProjectCollection(data map[string]any, operation, path string, identityKeys []string, fields map[string][]string) ([]map[string]any, error) {
|
||||
items, err := mailRequireCollection(data, operation, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mailValidateRows(items, operation, identityKeys...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projected := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
row := make(map[string]any, len(fields))
|
||||
for outputName, candidates := range fields {
|
||||
for _, candidate := range candidates {
|
||||
if value, present := item[candidate]; present && value != nil {
|
||||
projectedValue, include, err := mailProjectValue(operation, outputName, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !include {
|
||||
continue
|
||||
}
|
||||
row[outputName] = projectedValue
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
projected = append(projected, row)
|
||||
}
|
||||
return projected, nil
|
||||
}
|
||||
|
||||
func mailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) {
|
||||
container := data
|
||||
if prefix != "" {
|
||||
value, present := mailLookup(data, prefix)
|
||||
if !present {
|
||||
return false, "", mailResponseError(operation, "missing_pagination", "分页响应缺少已审核的分页对象")
|
||||
}
|
||||
var ok bool
|
||||
container, ok = value.(map[string]any)
|
||||
if !ok {
|
||||
return false, "", mailResponseError(operation, "malformed_pagination", "分页容器不是对象")
|
||||
}
|
||||
}
|
||||
nextValue, nextPresent := container["nextCursor"]
|
||||
next, nextOK := nextValue.(string)
|
||||
if !nextPresent || !nextOK {
|
||||
return false, "", mailResponseError(operation, "missing_pagination", "响应缺少字符串 nextCursor,无法证明结果是否完整")
|
||||
}
|
||||
if raw, present := container["hasMore"]; present {
|
||||
hasMore, ok := mailBool(raw)
|
||||
if !ok {
|
||||
return false, "", mailResponseError(operation, "malformed_pagination", "hasMore 应为布尔值")
|
||||
}
|
||||
terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$"
|
||||
if hasMore && terminal {
|
||||
return false, "", mailResponseError(operation, "missing_next_cursor", "hasMore=true 但 nextCursor 为空")
|
||||
}
|
||||
if !hasMore && !terminal {
|
||||
return false, "", mailResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空")
|
||||
}
|
||||
if !hasMore {
|
||||
return true, "", nil
|
||||
}
|
||||
if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
|
||||
return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
|
||||
}
|
||||
return false, next, nil
|
||||
}
|
||||
if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" {
|
||||
return true, "", nil
|
||||
}
|
||||
if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
|
||||
return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
|
||||
}
|
||||
return false, next, nil
|
||||
}
|
||||
|
||||
func mailBool(value any) (bool, bool) {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed, true
|
||||
case string:
|
||||
if typed == "true" {
|
||||
return true, true
|
||||
}
|
||||
if typed == "false" {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func mailLookup(object map[string]any, path string) (any, bool) {
|
||||
var current any = object
|
||||
for _, segment := range strings.Split(path, ".") {
|
||||
mapped, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
current, ok = mapped[segment]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func mailFirstString(object map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mailCollectionPayload(collection string, items []map[string]any, complete bool, next string) map[string]any {
|
||||
payload := map[string]any{"count": len(items), collection: items, "complete": complete}
|
||||
if !complete {
|
||||
payload["nextCursor"] = next
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func mailBusinessCollectionPayload(collection string, items []map[string]any) map[string]any {
|
||||
return map[string]any{"count": len(items), collection: items}
|
||||
}
|
||||
|
||||
// mailOutputPage preserves legacy payload compatibility while keeping cursor
|
||||
// controls in the unified envelope's meta.pagination contract.
|
||||
func mailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error {
|
||||
payload := mailCollectionPayload(collection, items, complete, next)
|
||||
if !output.UsesUnifiedResult(rt.Command()) {
|
||||
return rt.Output(payload)
|
||||
}
|
||||
pagination, err := output.NewPagination(complete, next)
|
||||
if err != nil {
|
||||
return mailResponseError("mail/pagination", "invalid_pagination", err.Error())
|
||||
}
|
||||
business := map[string]any{"count": len(items), collection: items}
|
||||
meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))}
|
||||
return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta)))
|
||||
}
|
||||
|
||||
func hardenPublicMailContracts() {
|
||||
collections := []struct {
|
||||
declaration *shortcut.Shortcut
|
||||
collection string
|
||||
description string
|
||||
paginated bool
|
||||
}{
|
||||
{&ThreadList, "threads", "严格校验的邮件会话列表", true},
|
||||
{&FolderList, "folders", "严格校验的邮箱文件夹列表", false},
|
||||
{&TagList, "tags", "严格校验的邮件标签列表", false},
|
||||
{&UserSearch, "users", "严格校验的企业邮箱用户搜索结果", true},
|
||||
{&TemplateList, "templates", "严格校验的邮件模板列表", true},
|
||||
{&ContactList, "contacts", "严格校验的邮件联系人列表", true},
|
||||
}
|
||||
for _, item := range collections {
|
||||
item.declaration.OutputRollout = output.RolloutUnifiedActive
|
||||
item.declaration.Contract.Result = mailCollectionResult(item.collection, item.description)
|
||||
if item.paginated {
|
||||
item.declaration.Contract.Pagination = mailCursorPagination()
|
||||
}
|
||||
item.declaration.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason}
|
||||
}
|
||||
markMailCompatibilityOnly(&ThreadList)
|
||||
markMailCompatibilityOnly(&TagList)
|
||||
markMailCompatibilityOnly(&TemplateList)
|
||||
markMailCompatibilityOnly(&ContactList)
|
||||
}
|
||||
|
||||
func markMailCompatibilityOnly(declaration *shortcut.Shortcut) {
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
declaration.Contract.Result = nil
|
||||
declaration.Contract.Pagination = nil
|
||||
declaration.Contract.Interface = &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: mailCompatibilityInterfaceReason,
|
||||
}
|
||||
}
|
||||
|
||||
func markMailUnavailable(declaration *shortcut.Shortcut, reason string) {
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
declaration.Contract.Result = nil
|
||||
declaration.Contract.Pagination = nil
|
||||
declaration.Contract.Interface = &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceUnavailable,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
+132
-399
@@ -18,8 +18,11 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
@@ -64,6 +67,13 @@ var ThreadList = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "mail +thread-list",
|
||||
},
|
||||
Description: "列出指定邮箱文件夹下的邮件会话(thread)",
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "folder", Property: "folder"},
|
||||
{Name: "limit", Property: "limit"},
|
||||
{Name: "start", Property: "start"},
|
||||
{Name: "end", Property: "end"},
|
||||
{Name: "ascending", Property: "ascending"},
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -85,6 +95,13 @@ var ThreadList = shortcut.Shortcut{
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "结束 UTC 时间,如 2024-12-31T23:59:59Z"},
|
||||
{Name: "ascending", Type: shortcut.FlagBool, Desc: "是否按时间升序"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 UTC RFC3339 时间,且 end 不能早于 start"},
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateThreadList(rt)
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +thread-list --email user@company.com --folder 104 --limit 20`,
|
||||
},
|
||||
@@ -110,76 +127,20 @@ var ThreadList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
threads := threadListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(threads), "threads": threads})
|
||||
threads, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{
|
||||
"conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
complete, next, err := mailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return mailOutputPage(rt, "threads", threads, complete, next)
|
||||
},
|
||||
}
|
||||
|
||||
// threadListProject reshapes the raw list_mailbox_threads response into a clean
|
||||
// {conversationId, subject, lastUpdated, isRead} thread list —
|
||||
// clean output projection. Both the list container and per-item
|
||||
// field names are probed defensively across candidate keys, so an empty/unknown
|
||||
// shape yields an empty list rather than a crash or fabricated data.
|
||||
func threadListProject(data map[string]any) []map[string]any {
|
||||
raw := threadListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := threadListFirst(m, "conversationId", "conversation_id", "id", "threadId"); ok {
|
||||
row["conversationId"] = v
|
||||
}
|
||||
if v, ok := threadListFirst(m, "subject", "title", "topic"); ok {
|
||||
row["subject"] = v
|
||||
}
|
||||
if v, ok := threadListFirst(m, "lastUpdated", "last_updated", "lastModifiedDateTime", "updateTime", "modifiedTime", "sentDateTime"); ok {
|
||||
row["lastUpdated"] = v
|
||||
}
|
||||
if v, ok := threadListFirst(m, "isRead", "is_read", "read", "unread"); ok {
|
||||
row["isRead"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// threadListResolveList locates the list payload inside the response, tolerating
|
||||
// a bare top-level array container or nesting one level deeper.
|
||||
func threadListResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "threads", "conversations"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "threads", "conversations", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// threadListFirst returns the first present candidate key's value.
|
||||
func threadListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Thread 根据会话 ID 获取会话详情。
|
||||
// ThreadUpdate 修改单个邮件会话的状态或标签。
|
||||
// ThreadBatchUpdate 批量修改邮件会话的状态或标签。
|
||||
@@ -208,6 +169,7 @@ var FolderList = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "mail +folder-list",
|
||||
},
|
||||
Description: "列出顶层文件夹或指定父文件夹下的子文件夹",
|
||||
Parameters: []contract.ParamDecl{{Name: "folder", Property: "folder"}},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -221,9 +183,13 @@ var FolderList = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址", Required: true},
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址,不能为空", Required: true},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID,不传则返回顶层文件夹"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"email"}, Description: "不能为空"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateRequiredText(rt, "email")
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +folder-list --email user@company.com`,
|
||||
},
|
||||
@@ -238,73 +204,16 @@ var FolderList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folders := folderListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(folders), "folders": folders})
|
||||
folders, err := mailProjectCollection(data, "mail/list_folders", "folders", []string{"id"}, map[string][]string{
|
||||
"id": {"id"}, "name": {"displayName"}, "parentId": {"parentFolderId"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(mailBusinessCollectionPayload("folders", folders))
|
||||
},
|
||||
}
|
||||
|
||||
// folderListProject reshapes the raw list_folders response into a clean
|
||||
// {id, name, parentId} folder list — clean output projection. Both
|
||||
// the list container and per-item field names are probed defensively across
|
||||
// candidate keys, so an empty/unknown shape yields an empty list rather than a
|
||||
// crash or fabricated data.
|
||||
func folderListProject(data map[string]any) []map[string]any {
|
||||
raw := folderListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := folderListFirst(m, "id", "folderId", "folder_id"); ok {
|
||||
row["id"] = v
|
||||
}
|
||||
if v, ok := folderListFirst(m, "name", "folderName", "folder_name", "displayName"); ok {
|
||||
row["name"] = v
|
||||
}
|
||||
if v, ok := folderListFirst(m, "parentId", "parent_id", "parentFolderId"); ok {
|
||||
row["parentId"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// folderListResolveList locates the list payload inside the response, tolerating
|
||||
// a bare top-level array container or nesting one level deeper.
|
||||
func folderListResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "folders"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "folders", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// folderListFirst returns the first present candidate key's value.
|
||||
func folderListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// FolderCreate 创建邮件文件夹。
|
||||
// FolderUpdate 更新邮件文件夹名称。
|
||||
// FolderDelete 删除邮件文件夹。
|
||||
@@ -356,73 +265,16 @@ var TagList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tags := tagListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(tags), "tags": tags})
|
||||
tags, err := mailProjectCollection(data, "mail/list_tags", "tags", []string{"id"}, map[string][]string{
|
||||
"id": {"id"}, "name": {"name"}, "parentId": {"parentId"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(mailBusinessCollectionPayload("tags", tags))
|
||||
},
|
||||
}
|
||||
|
||||
// tagListProject reshapes the raw list_tags response into a clean
|
||||
// {id, name, parentId} tag list — clean output projection. Both the
|
||||
// list container and per-item field names are probed defensively across
|
||||
// candidate keys, so an empty/unknown shape yields an empty list rather than a
|
||||
// crash or fabricated data.
|
||||
func tagListProject(data map[string]any) []map[string]any {
|
||||
raw := tagListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := tagListFirst(m, "id", "tagId", "tag_id"); ok {
|
||||
row["id"] = v
|
||||
}
|
||||
if v, ok := tagListFirst(m, "name", "tagName", "tag_name", "displayName"); ok {
|
||||
row["name"] = v
|
||||
}
|
||||
if v, ok := tagListFirst(m, "parentId", "parent_id"); ok {
|
||||
row["parentId"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tagListResolveList locates the list payload inside the response, tolerating a
|
||||
// bare top-level array container or nesting one level deeper.
|
||||
func tagListResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "tags"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "tags", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// tagListFirst returns the first present candidate key's value.
|
||||
func tagListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// TagCreate 创建邮件标签。
|
||||
// TagUpdate 更新邮件标签名称。
|
||||
// TagDelete 删除邮件标签。
|
||||
@@ -449,6 +301,10 @@ var UserSearch = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "mail +user-search",
|
||||
},
|
||||
Description: "按关键词或工号搜索邮箱用户(仅企业邮箱)",
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "employee-no", Property: "employeeNo"},
|
||||
{Name: "limit", Property: "limit"},
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -465,20 +321,40 @@ var UserSearch = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词(未提供 --employee-no 时为必填)"},
|
||||
{Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索"},
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词;显式提供时不能为空(未提供 --employee-no 时为必填)"},
|
||||
{Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索;显式提供时不能为空"},
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "搜索目标邮箱地址"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量"},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"keyword", "employee-no"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"keyword", "employee-no"}, Description: "不能为空"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"},
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := mailValidateStringPageSize(rt, "limit", false); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range []string{"keyword", "employee-no"} {
|
||||
if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) == "" {
|
||||
return apperrors.NewValidation("--" + name + " 显式提供时不能为空")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(rt.Str("keyword")) == "" && strings.TrimSpace(rt.Str("employee-no")) == "" {
|
||||
return apperrors.NewValidation("--keyword 和 --employee-no 至少需要一个非空值")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +user-search --keyword "张三"`,
|
||||
`dws mail +user-search --email user@company.com --employee-no "E123456"`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
size, err := mailStringPageSize(rt, "limit", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{}
|
||||
if rt.Str("keyword") != "" {
|
||||
params["keyword"] = rt.Str("keyword")
|
||||
@@ -493,82 +369,26 @@ var UserSearch = shortcut.Shortcut{
|
||||
params["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["size"] = rt.Str("limit")
|
||||
params["size"] = size
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "search_mail_users", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users := userSearchProject(data)
|
||||
return rt.Output(map[string]any{"count": len(users), "users": users})
|
||||
users, err := mailProjectCollection(data, "mail/search_mail_users", "users", []string{"id", "email"}, map[string][]string{
|
||||
"name": {"name"}, "email": {"email"}, "employeeNo": {"employeeNo"}, "userId": {"id"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
complete, next, err := mailPage(data, "mail/search_mail_users", "", rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return mailOutputPage(rt, "users", users, complete, next)
|
||||
},
|
||||
}
|
||||
|
||||
// userSearchProject reshapes the raw search_mail_users response into a clean
|
||||
// {name, email, employeeNo, userId} user list — clean output projection.
|
||||
// Both the list container and per-item field names are probed defensively
|
||||
// across candidate keys, so an empty/unknown shape yields an empty list rather
|
||||
// than a crash or fabricated data.
|
||||
func userSearchProject(data map[string]any) []map[string]any {
|
||||
raw := userSearchResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := userSearchFirst(m, "name", "userName", "displayName", "nickName"); ok {
|
||||
row["name"] = v
|
||||
}
|
||||
if v, ok := userSearchFirst(m, "email", "mail", "emailAddress"); ok {
|
||||
row["email"] = v
|
||||
}
|
||||
if v, ok := userSearchFirst(m, "employeeNo", "employee_no", "employeeNumber", "jobNumber"); ok {
|
||||
row["employeeNo"] = v
|
||||
}
|
||||
if v, ok := userSearchFirst(m, "userId", "user_id", "id"); ok {
|
||||
row["userId"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// userSearchResolveList locates the list payload inside the response, tolerating
|
||||
// a bare top-level array container or nesting one level deeper.
|
||||
func userSearchResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "users"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "users", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// userSearchFirst returns the first present candidate key's value.
|
||||
func userSearchFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ── attachment ─────────────────────────────────────────────
|
||||
|
||||
// AttachmentList 列举邮件附件。
|
||||
@@ -596,6 +416,7 @@ var TemplateList = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "mail +template-list",
|
||||
},
|
||||
Description: "列出指定邮箱的所有邮件模板",
|
||||
Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -610,16 +431,24 @@ var TemplateList = shortcut.Shortcut{
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateStringPageSize(rt, "limit", true)
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +template-list --email user@company.com --limit 20`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
size, err := mailStringPageSize(rt, "limit", true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"email": rt.Str("email"),
|
||||
"size": rt.Str("limit"),
|
||||
"size": size,
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["cursor"] = rt.Str("cursor")
|
||||
@@ -628,73 +457,20 @@ var TemplateList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
templates := templateListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(templates), "templates": templates})
|
||||
templates, err := mailProjectCollection(data, "mail/list_user_message_templates", "templates", []string{"id"}, map[string][]string{
|
||||
"id": {"id"}, "name": {"name"}, "subject": {"subject"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
complete, next, err := mailPage(data, "mail/list_user_message_templates", "", rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return mailOutputPage(rt, "templates", templates, complete, next)
|
||||
},
|
||||
}
|
||||
|
||||
// templateListProject reshapes the raw list_user_message_templates response into
|
||||
// a clean {id, name, subject} template list — clean output projection.
|
||||
// Both the list container and per-item field names are probed defensively
|
||||
// across candidate keys, so an empty/unknown shape yields an empty list rather
|
||||
// than a crash or fabricated data.
|
||||
func templateListProject(data map[string]any) []map[string]any {
|
||||
raw := templateListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := templateListFirst(m, "id", "templateId", "template_id"); ok {
|
||||
row["id"] = v
|
||||
}
|
||||
if v, ok := templateListFirst(m, "name", "templateName", "template_name", "displayName"); ok {
|
||||
row["name"] = v
|
||||
}
|
||||
if v, ok := templateListFirst(m, "subject", "title"); ok {
|
||||
row["subject"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// templateListResolveList locates the list payload inside the response,
|
||||
// tolerating a bare top-level array container or nesting one level deeper.
|
||||
func templateListResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "templates"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "templates", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// templateListFirst returns the first present candidate key's value.
|
||||
func templateListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// TemplateGet 获取邮件模板详情。
|
||||
// TemplateUpdate 更新邮件模板。
|
||||
// TemplateDelete 删除邮件模板。
|
||||
@@ -722,6 +498,7 @@ var ContactList = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "mail +contact-list",
|
||||
},
|
||||
Description: "列出指定邮箱的所有邮件联系人",
|
||||
Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -736,16 +513,24 @@ var ContactList = shortcut.Shortcut{
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateStringPageSize(rt, "limit", true)
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +contact-list --email user@company.com --limit 20`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
size, err := mailStringPageSize(rt, "limit", true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"email": rt.Str("email"),
|
||||
"size": rt.Str("limit"),
|
||||
"size": size,
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["cursor"] = rt.Str("cursor")
|
||||
@@ -754,73 +539,20 @@ var ContactList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contacts := contactListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(contacts), "contacts": contacts})
|
||||
contacts, err := mailProjectCollection(data, "mail/list_user_mail_contacts", "contacts", []string{"id"}, map[string][]string{
|
||||
"id": {"id"}, "contactEmail": {"contactEmail", "email"}, "displayName": {"displayName", "name"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
complete, next, err := mailPage(data, "mail/list_user_mail_contacts", "", rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return mailOutputPage(rt, "contacts", contacts, complete, next)
|
||||
},
|
||||
}
|
||||
|
||||
// contactListProject reshapes the raw list_user_mail_contacts response into a
|
||||
// clean {id, contactEmail, displayName} contact list — output-projection
|
||||
// clean output projection. Both the list container and per-item field names are probed
|
||||
// defensively across candidate keys, so an empty/unknown shape yields an empty
|
||||
// list rather than a crash or fabricated data.
|
||||
func contactListProject(data map[string]any) []map[string]any {
|
||||
raw := contactListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := contactListFirst(m, "id", "contactId", "contact_id"); ok {
|
||||
row["id"] = v
|
||||
}
|
||||
if v, ok := contactListFirst(m, "contactEmail", "contact_email", "email"); ok {
|
||||
row["contactEmail"] = v
|
||||
}
|
||||
if v, ok := contactListFirst(m, "displayName", "display_name", "name"); ok {
|
||||
row["displayName"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// contactListResolveList locates the list payload inside the response,
|
||||
// tolerating a bare top-level array container or nesting one level deeper.
|
||||
func contactListResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "contacts"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "contacts", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// contactListFirst returns the first present candidate key's value.
|
||||
func contactListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ContactUpdate 更新邮件联系人。
|
||||
// ContactBatchDelete 批量删除邮件联系人。
|
||||
// ── auto-reply ─────────────────────────────────────────────
|
||||
@@ -834,6 +566,7 @@ func contactListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
// RuleDelete 删除个人收信规则。
|
||||
// RuleAdjust 调整收信规则排序。
|
||||
func init() {
|
||||
hardenPublicMailContracts()
|
||||
shortcut.Register(
|
||||
ThreadList,
|
||||
FolderList,
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func runMailDeclarationForCoverage(t *testing.T, declaration shortcut.Shortcut, caller *mailWriteContractCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func mailRuntimeForCoverage(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return shortcut.RuntimeContextForTest(cmd, declaration)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailReadLeavesExerciseExactIdentityAndMailboxBranches(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
responses map[string][]string
|
||||
errors map[string]error
|
||||
wantErr bool
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "message explicit mailbox", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 1},
|
||||
{name: "message auto mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"email":"mail@example.invalid"}]}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2},
|
||||
{name: "message auto nested string mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":["mail@example.invalid"]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2},
|
||||
{name: "mailbox transport", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1},
|
||||
{name: "mailbox missing collection", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true}`}}, wantErr: true, wantCalls: 1},
|
||||
{name: "mailbox empty", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[]}`}}, wantErr: true, wantCalls: 1},
|
||||
{name: "mailbox missing identity", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"name":"mailbox"}]}`}}, wantErr: true, wantCalls: 1},
|
||||
{name: "message transport", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("message transport")}, wantErr: true, wantCalls: 1},
|
||||
{name: "message malformed", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":[]}`}}, wantErr: true, wantCalls: 1},
|
||||
{name: "message wrong identity", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 1},
|
||||
{name: "messages ordered", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"message-2"}}`}}, wantCalls: 2},
|
||||
{name: "messages auto data mailbox", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"data":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2},
|
||||
{name: "messages second fails", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 2},
|
||||
{name: "messages mailbox resolution fails", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1},
|
||||
{name: "thread exact", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 1},
|
||||
{name: "thread auto result mailbox", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 2},
|
||||
{name: "thread transport", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"get_thread": fmt.Errorf("thread transport")}, wantErr: true, wantCalls: 1},
|
||||
{name: "thread mailbox resolution fails", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1},
|
||||
{name: "thread missing result", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true}`}}, wantErr: true, wantCalls: 1},
|
||||
{name: "thread wrong identity", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"other"}}`}}, wantErr: true, wantCalls: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: tc.responses, errors: tc.errors}
|
||||
if caller.responses == nil {
|
||||
caller.responses = map[string][]string{}
|
||||
}
|
||||
if caller.errors == nil {
|
||||
caller.errors = map[string]error{}
|
||||
}
|
||||
err := runConfirmedMailWriteContract(t, caller, tc.args...)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("invalid read unexpectedly succeeded")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("valid read failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err := mailValidateMessageIDs(nil); err == nil {
|
||||
t.Fatal("empty ids unexpectedly valid")
|
||||
}
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := mailRuntimeForCoverage(t, Messages, map[string]string{"email": "mail@example.invalid", "ids": " "})
|
||||
if err := Messages.Execute(rt); err == nil {
|
||||
t.Fatal("defensive empty-id guard unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("defensive empty-id guard made %d calls", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailMailboxResolverSupportsReviewedShapesAndFailsClosed(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{
|
||||
name: "top string",
|
||||
data: map[string]any{"success": true, "emailAccounts": []any{" mail@example.invalid "}},
|
||||
},
|
||||
{
|
||||
name: "top object",
|
||||
data: map[string]any{"success": true, "emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}},
|
||||
},
|
||||
{
|
||||
name: "result string",
|
||||
data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}},
|
||||
},
|
||||
{
|
||||
name: "result object",
|
||||
data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}},
|
||||
},
|
||||
{
|
||||
name: "data string",
|
||||
data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}},
|
||||
},
|
||||
{
|
||||
name: "data object",
|
||||
data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := mailMailboxAddresses(tc.data)
|
||||
if err != nil || len(got) != 1 || got[0] != "mail@example.invalid" {
|
||||
t.Fatalf("addresses=%#v err=%v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for name, data := range map[string]map[string]any{
|
||||
"remote failure": {"success": false},
|
||||
"missing collection": {"success": true},
|
||||
"malformed collection": {"success": true, "emailAccounts": map[string]any{}},
|
||||
"blank string": {"success": true, "emailAccounts": []any{" "}},
|
||||
"missing object email": {"success": true, "emailAccounts": []any{map[string]any{"name": "mailbox"}}},
|
||||
"wrong object email": {"success": true, "emailAccounts": []any{map[string]any{"email": 7}}},
|
||||
"bad later item": {"success": true, "emailAccounts": []any{"mail@example.invalid", 7}},
|
||||
"multiple paths": {"success": true, "emailAccounts": []any{"one@example.invalid"}, "result": map[string]any{"emailAccounts": []any{"two@example.invalid"}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := mailMailboxAddresses(data); err == nil {
|
||||
t.Fatal("invalid mailbox response unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailListLeavesFailAtOwningBoundary(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
tool string
|
||||
response string
|
||||
remoteErr bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "folder happy", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid", "--folder", "parent-1"}, tool: "list_folders", response: `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`},
|
||||
{name: "folder remote", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", remoteErr: true, wantErr: true},
|
||||
{name: "folder malformed", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", response: `{"success":true,"folders":{}}`, wantErr: true},
|
||||
{name: "tag happy", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":[{"id":"tag-1"}]}`},
|
||||
{name: "tag remote", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", remoteErr: true, wantErr: true},
|
||||
{name: "tag malformed", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":{}}`, wantErr: true},
|
||||
{name: "thread malformed rows", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":{},"nextCursor":""}}`, wantErr: true},
|
||||
{name: "thread missing page", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}]}}`, wantErr: true},
|
||||
{name: "user malformed rows", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":{}}`, wantErr: true},
|
||||
{name: "user missing page", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1"}]}`, wantErr: true},
|
||||
{name: "template malformed rows", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":{}}`, wantErr: true},
|
||||
{name: "template missing page", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}]}`, wantErr: true},
|
||||
{name: "contact malformed rows", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":{}}`, wantErr: true},
|
||||
{name: "contact missing page", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}]}`, wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}}
|
||||
if tc.remoteErr {
|
||||
caller.errors[tc.tool] = fmt.Errorf("remote failure")
|
||||
}
|
||||
err := runConfirmedMailWriteContract(t, caller, tc.args...)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("invalid list response unexpectedly succeeded")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("valid list response failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailStrictHelpersCoverMalformedWireAndPagination(t *testing.T) {
|
||||
assertPanic := func(name string, fn func()) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
deferred := false
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unsupported declaration did not panic")
|
||||
}
|
||||
deferred = true
|
||||
}()
|
||||
fn()
|
||||
_ = deferred
|
||||
})
|
||||
}
|
||||
assertPanic("sensitive collection", func() { mailCollectionSensitivePaths("unknown") })
|
||||
assertPanic("identity collection", func() { mailCollectionIdentitySchema("unknown") })
|
||||
|
||||
if _, err := mailRequireObject(map[string]any{}, "mail/get", "message"); err == nil {
|
||||
t.Fatal("object accepted missing success")
|
||||
}
|
||||
if object, err := mailRequireObject(map[string]any{"success": true, "message": map[string]any{"id": "message-1"}}, "mail/get", "message"); err != nil || object["id"] != "message-1" {
|
||||
t.Fatalf("valid object rejected: %#v %v", object, err)
|
||||
}
|
||||
if _, _, err := mailProjectValue("mail/users", "email", 7); err == nil {
|
||||
t.Fatal("numeric email unexpectedly projected")
|
||||
}
|
||||
if _, include, err := mailProjectValue("mail/users", "userId", " "); err != nil || include {
|
||||
t.Fatalf("blank user id include=%v err=%v", include, err)
|
||||
}
|
||||
if _, include, err := mailProjectValue("mail/test", "name", nil); err != nil || include {
|
||||
t.Fatalf("nil projection include=%v err=%v", include, err)
|
||||
}
|
||||
if _, include, err := mailProjectValue("mail/test", "name", " "); err != nil || include {
|
||||
t.Fatalf("blank projection include=%v err=%v", include, err)
|
||||
}
|
||||
if value, include, err := mailProjectValue("mail/test", "rank", 7); err != nil || !include || value != 7 {
|
||||
t.Fatalf("numeric projection value=%v include=%v err=%v", value, include, err)
|
||||
}
|
||||
|
||||
for name, data := range map[string]map[string]any{
|
||||
"missing prefix": {},
|
||||
"malformed prefix": {"result": []any{}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := mailPage(data, "mail/page", "result", ""); err == nil {
|
||||
t.Fatal("invalid prefixed page unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, data := range map[string]map[string]any{
|
||||
"missing cursor": {},
|
||||
"malformed cursor": {"nextCursor": 7},
|
||||
"malformed hasMore": {"nextCursor": "cursor-2", "hasMore": []any{}},
|
||||
"conflicting terminal": {"nextCursor": "cursor-2", "hasMore": false},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := mailPage(data, "mail/page", "", ""); err == nil {
|
||||
t.Fatal("invalid page unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
if complete, next, err := mailPage(map[string]any{"nextCursor": "cursor-2"}, "mail/page", "", ""); err != nil || complete || next != "cursor-2" {
|
||||
t.Fatalf("continuing cursor rejected: %v %q %v", complete, next, err)
|
||||
}
|
||||
if _, _, err := mailPage(map[string]any{"nextCursor": "cursor-1"}, "mail/page", "", "cursor-1"); err == nil {
|
||||
t.Fatal("unstated hasMore repeated cursor unexpectedly succeeded")
|
||||
}
|
||||
if value, ok := mailBool("true"); !ok || !value {
|
||||
t.Fatal("string true not normalized")
|
||||
}
|
||||
if _, ok := mailBool(7); ok {
|
||||
t.Fatal("invalid boolean unexpectedly normalized")
|
||||
}
|
||||
if _, ok := mailLookup(map[string]any{"result": "bad"}, "result.items"); ok {
|
||||
t.Fatal("non-object intermediate lookup unexpectedly succeeded")
|
||||
}
|
||||
declaration := UserSearch
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(io.Discard)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, declaration)
|
||||
if err := mailOutputPage(rt, "users", nil, true, "unexpected-next"); err == nil {
|
||||
t.Fatal("invalid terminal pagination unexpectedly stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailWriteVerificationHelpersAndDryRun(t *testing.T) {
|
||||
for name, message := range map[string]map[string]any{
|
||||
"missing addresses": {},
|
||||
"wrong address collection": {"toRecipients": "bad"},
|
||||
"wrong address item": {"toRecipients": []any{7}},
|
||||
"blank address item": {"toRecipients": []any{map[string]any{"email": " "}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := mailReadAddresses(message, "mail/verify", "toRecipients"); err == nil {
|
||||
t.Fatal("malformed recipients unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
addresses, err := mailReadAddresses(map[string]any{"toRecipients": []any{" A@example.invalid ", map[string]any{"address": "b@example.invalid"}}}, "mail/verify", "toRecipients")
|
||||
if err != nil || len(addresses) != 2 {
|
||||
t.Fatalf("valid recipients rejected: %v %v", addresses, err)
|
||||
}
|
||||
if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"a@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid", "b@example.invalid"}); err == nil {
|
||||
t.Fatal("recipient length mismatch accepted")
|
||||
}
|
||||
if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"b@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil {
|
||||
t.Fatal("recipient value mismatch accepted")
|
||||
}
|
||||
if err := mailVerifyAddresses(map[string]any{}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil {
|
||||
t.Fatal("recipient parser error was not propagated")
|
||||
}
|
||||
if _, err := mailDraftMessage(map[string]any{"success": false}, "mail/create"); err == nil {
|
||||
t.Fatal("failed draft receipt unexpectedly succeeded")
|
||||
}
|
||||
for name, message := range map[string]map[string]any{
|
||||
"missing sender": {},
|
||||
"wrong sender": {"from": []any{}},
|
||||
"blank sender": {"from": " "},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := mailVerifySender(message, "mail/verify", "sender@example.invalid"); err == nil {
|
||||
t.Fatal("malformed sender unexpectedly accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := mailVerifySender(map[string]any{"from": "sender@example.invalid"}, "mail/verify", "SENDER@example.invalid"); err != nil {
|
||||
t.Fatalf("case-insensitive sender rejected: %v", err)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "subject", "--dry-run"},
|
||||
{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject", "--dry-run"},
|
||||
{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--dry-run"},
|
||||
{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject", "--dry-run"},
|
||||
} {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, args...); err != nil {
|
||||
t.Fatalf("dry-run failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run made %d remote calls", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
for _, declaration := range []shortcut.Shortcut{DraftEdit, TemplateUpdate} {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
values := map[string]string{"email": "sender@example.invalid", "id": "template-1"}
|
||||
if declaration.Command == "+draft-edit" {
|
||||
values = map[string]string{"from": "sender@example.invalid", "id": "draft-1"}
|
||||
}
|
||||
rt := mailRuntimeForCoverage(t, declaration, values)
|
||||
if err := declaration.Execute(rt); err == nil {
|
||||
t.Fatal("execute-level no-change guard unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("no-change guard made %d calls", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
readback string
|
||||
}{
|
||||
{name: "body mismatch", readback: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")},
|
||||
{name: "sender mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid")},
|
||||
{name: "cc mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{
|
||||
"create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`},
|
||||
"get_email_by_message_id": {tc.readback},
|
||||
}, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"); err == nil {
|
||||
t.Fatal("draft verification mismatch unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{
|
||||
"create_user_message_template": {`{"success":true,"id":"template-1"}`},
|
||||
"get_user_message_template": {`{"success":"false"}`},
|
||||
}, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"); err == nil {
|
||||
t.Fatal("failed template readback unexpectedly succeeded")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
declaration shortcut.Shortcut
|
||||
values map[string]string
|
||||
}{
|
||||
{declaration: Message, values: map[string]string{"id": " "}},
|
||||
{declaration: FolderList, values: map[string]string{"email": " "}},
|
||||
} {
|
||||
rt := mailRuntimeForCoverage(t, tc.declaration, tc.values)
|
||||
if err := tc.declaration.Validate(rt); err == nil {
|
||||
t.Fatal("direct required-text validator accepted whitespace")
|
||||
}
|
||||
}
|
||||
rt := mailRuntimeForCoverage(t, UserSearch, map[string]string{})
|
||||
if err := UserSearch.Validate(rt); err == nil {
|
||||
t.Fatal("direct user-search validator accepted missing identities")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailTemplateReadbackRejectsBusinessFailure(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{
|
||||
"get_user_message_template": {`{"id":"template-1"}`},
|
||||
}, errors: map[string]error{}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := mailRuntimeForCoverage(t, TemplateCreate, map[string]string{})
|
||||
if _, err := mailReadTemplate(rt, "mail@example.invalid", "template-1"); err == nil {
|
||||
t.Fatal("failed template detail response unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls=%d want=1", len(caller.calls))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type mailConfirmationCaller struct{ calls int }
|
||||
|
||||
func (caller *mailConfirmationCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls++
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true}`}}}, nil
|
||||
}
|
||||
|
||||
func (*mailConfirmationCaller) Format() string { return "json" }
|
||||
func (*mailConfirmationCaller) DryRun() bool { return false }
|
||||
func (*mailConfirmationCaller) Fields() string { return "" }
|
||||
func (*mailConfirmationCaller) JQ() string { return "" }
|
||||
|
||||
func runUnconfirmedMailWrite(t *testing.T, caller *mailConfirmationCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDeps(caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailConfirmationPrecedesRemoteCall(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "fixture"},
|
||||
{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-id", "--subject", "fixture"},
|
||||
{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "fixture", "--subject", "fixture", "--body", "fixture"},
|
||||
{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-id", "--subject", "fixture"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
caller := &mailConfirmationCaller{}
|
||||
if err := runUnconfirmedMailWrite(t, caller, args...); err == nil {
|
||||
t.Fatalf("unconfirmed %v unexpectedly succeeded", args)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("unconfirmed %v reached MCP %d times", args, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMailContractsAreUnifiedAndTyped(t *testing.T) {
|
||||
declarations := []*struct {
|
||||
rollout output.RolloutState
|
||||
result bool
|
||||
}{
|
||||
{FolderList.OutputRollout, FolderList.Contract.Result != nil},
|
||||
{UserSearch.OutputRollout, UserSearch.Contract.Result != nil},
|
||||
{Message.OutputRollout, Message.Contract.Result != nil},
|
||||
{Messages.OutputRollout, Messages.Contract.Result != nil},
|
||||
{Thread.OutputRollout, Thread.Contract.Result != nil},
|
||||
}
|
||||
for index, declaration := range declarations {
|
||||
if declaration.rollout != output.RolloutUnifiedActive || !declaration.result {
|
||||
t.Fatalf("mail declaration %d is not unified with Result", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailUnavailableWritesMatchRuntimeBoundary(t *testing.T) {
|
||||
for _, declaration := range []*shortcut.Shortcut{&DraftCreate, &DraftEdit, &TemplateCreate, &TemplateUpdate} {
|
||||
if declaration.OutputRollout != output.RolloutLegacyOnly {
|
||||
t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout)
|
||||
}
|
||||
if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil {
|
||||
t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command)
|
||||
}
|
||||
if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" || declaration.Contract.Interface.Reason == "" {
|
||||
t.Errorf("%s missing precise unavailable interface", declaration.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailCompatibilityReadsMatchRuntimeBoundary(t *testing.T) {
|
||||
for _, declaration := range []*shortcut.Shortcut{&ThreadList, &TagList, &TemplateList, &ContactList} {
|
||||
if declaration.OutputRollout != output.RolloutLegacyOnly {
|
||||
t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout)
|
||||
}
|
||||
if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil {
|
||||
t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command)
|
||||
}
|
||||
if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != mailCompatibilityInterfaceReason {
|
||||
t.Errorf("%s missing Schema-compatible non-public interface", declaration.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailMutationConstraintsAreDeclared(t *testing.T) {
|
||||
want := map[string]bool{"to": false, "cc": false, "subject": false, "body": false}
|
||||
for _, constraint := range DraftEdit.Constraints {
|
||||
if constraint.Kind != shortcut.ConstraintAtLeastOne {
|
||||
continue
|
||||
}
|
||||
for _, flag := range constraint.Flags {
|
||||
if _, ok := want[flag]; ok {
|
||||
want[flag] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for flag, found := range want {
|
||||
if !found {
|
||||
t.Fatalf("draft edit missing at-least-one declaration for %s", flag)
|
||||
}
|
||||
}
|
||||
if len(Messages.Constraints) != 1 || Messages.Constraints[0].Kind != shortcut.ConstraintCustom || Messages.Validate == nil {
|
||||
t.Fatal("messages must declare and execute the 1-100 nonempty ID contract")
|
||||
}
|
||||
userSearchNonempty := false
|
||||
for _, constraint := range UserSearch.Constraints {
|
||||
if constraint.Kind == shortcut.ConstraintCustom && len(constraint.Flags) == 2 && constraint.Flags[0] == "keyword" && constraint.Flags[1] == "employee-no" && constraint.Description != "" {
|
||||
userSearchNonempty = true
|
||||
}
|
||||
}
|
||||
if !userSearchNonempty {
|
||||
t.Fatal("user search must declare its explicit nonempty string contract")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailPaginationDeclarationsMatchLeaves(t *testing.T) {
|
||||
for _, declaration := range []*shortcut.Shortcut{&UserSearch} {
|
||||
if declaration.Contract.Pagination == nil || declaration.Contract.Pagination.CursorParameter != "cursor" {
|
||||
t.Fatalf("%s missing cursor pagination declaration", declaration.Command)
|
||||
}
|
||||
}
|
||||
for _, declaration := range []*shortcut.Shortcut{&ThreadList, &FolderList, &TagList, &TemplateList, &ContactList, &Messages} {
|
||||
if declaration.Contract.Pagination != nil {
|
||||
t.Fatalf("%s must not declare pagination", declaration.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailUnifiedPaginationLivesOnlyInMeta(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
complete bool
|
||||
next string
|
||||
exhausted bool
|
||||
}{
|
||||
{name: "continuing", complete: false, next: "cursor-2", exhausted: false},
|
||||
{name: "terminal", complete: true, next: "", exhausted: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
declaration := UserSearch
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, declaration)
|
||||
if err := mailOutputPage(rt, "users", []map[string]any{{"email": "fixture@example.invalid"}}, tc.complete, tc.next); err != nil {
|
||||
t.Fatalf("store pagination: %v", err)
|
||||
}
|
||||
if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 {
|
||||
t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err)
|
||||
}
|
||||
var envelope struct {
|
||||
Data map[string]any `json:"data"`
|
||||
Meta struct {
|
||||
Pagination *struct {
|
||||
EndpointExhausted bool `json:"endpoint_exhausted"`
|
||||
NextToken string `json:"next_token"`
|
||||
} `json:"pagination"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v", err)
|
||||
}
|
||||
for _, field := range []string{"complete", "hasMore", "nextCursor"} {
|
||||
if _, exists := envelope.Data[field]; exists {
|
||||
t.Fatalf("pagination field %s leaked into data", field)
|
||||
}
|
||||
}
|
||||
if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted != tc.exhausted || envelope.Meta.Pagination.NextToken != tc.next {
|
||||
t.Fatalf("pagination=%+v", envelope.Meta.Pagination)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailResultSchemasRequireStableIdentity(t *testing.T) {
|
||||
collections := []struct {
|
||||
declaration *shortcut.Shortcut
|
||||
collection string
|
||||
identity string
|
||||
}{
|
||||
{&FolderList, "folders", "id"},
|
||||
{&UserSearch, "users", "email"},
|
||||
{&Messages, "messages", "id"},
|
||||
}
|
||||
for _, tc := range collections {
|
||||
if len(tc.declaration.Contract.Result.SensitivePaths) == 0 {
|
||||
t.Fatalf("%s missing sensitive_paths", tc.declaration.Command)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(tc.declaration.Contract.Result.DataSchema, &schema); err != nil {
|
||||
t.Fatalf("%s schema: %v", tc.declaration.Command, err)
|
||||
}
|
||||
properties := schema["properties"].(map[string]any)
|
||||
count := properties["count"].(map[string]any)
|
||||
if count["minimum"] != float64(0) {
|
||||
t.Fatalf("%s count minimum=%v, want 0", tc.declaration.Command, count["minimum"])
|
||||
}
|
||||
items := properties[tc.collection].(map[string]any)["items"].(map[string]any)
|
||||
identity, ok := items["properties"].(map[string]any)[tc.identity].(map[string]any)
|
||||
if !ok || identity["description"] == "" {
|
||||
t.Fatalf("%s missing identity %s", tc.declaration.Command, tc.identity)
|
||||
}
|
||||
}
|
||||
for _, declaration := range []*shortcut.Shortcut{&Message, &Thread} {
|
||||
if len(declaration.Contract.Result.SensitivePaths) == 0 {
|
||||
t.Fatalf("%s missing sensitive_paths", declaration.Command)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(declaration.Contract.Result.DataSchema, &schema); err != nil {
|
||||
t.Fatalf("%s schema: %v", declaration.Command, err)
|
||||
}
|
||||
value := schema["properties"].(map[string]any)["value"].(map[string]any)
|
||||
required := value["required"].([]any)
|
||||
if len(required) != 1 || required[0] != "id" {
|
||||
t.Fatalf("%s value does not require id", declaration.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailWritesRequireConfirmation(t *testing.T) {
|
||||
for _, declaration := range []struct {
|
||||
name string
|
||||
confirmation string
|
||||
}{
|
||||
{DraftCreate.Command, DraftCreate.Safety.Confirmation},
|
||||
{DraftEdit.Command, DraftEdit.Safety.Confirmation},
|
||||
{TemplateCreate.Command, TemplateCreate.Safety.Confirmation},
|
||||
{TemplateUpdate.Command, TemplateUpdate.Safety.Confirmation},
|
||||
} {
|
||||
if declaration.confirmation != "user_required" {
|
||||
t.Errorf("%s confirmation=%q, want user_required", declaration.name, declaration.confirmation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailStrictResponseMatrix(t *testing.T) {
|
||||
validEmpty := map[string]any{"success": "true", "folders": []any{}}
|
||||
items, err := mailRequireCollection(validEmpty, "mail/list_folders", "folders")
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("explicit empty collection must succeed: items=%v err=%v", items, err)
|
||||
}
|
||||
for name, fixture := range map[string]map[string]any{
|
||||
"missing success": {"folders": []any{}},
|
||||
"missing collection": {"success": "true"},
|
||||
"wrong collection": {"success": true, "folders": map[string]any{}},
|
||||
"bad item": {"success": "true", "folders": []any{"bad"}},
|
||||
} {
|
||||
if _, err := mailRequireCollection(fixture, "mail/list_folders", "folders"); err == nil {
|
||||
t.Fatalf("%s must fail closed", name)
|
||||
}
|
||||
}
|
||||
if err := mailRequireSuccess(map[string]any{"success": "false"}, "mail/test"); err == nil {
|
||||
t.Fatal("string false must not be success")
|
||||
}
|
||||
if err := mailRequireSuccess(map[string]any{"success": true}, "mail/test"); err != nil {
|
||||
t.Fatalf("boolean true must be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailPaginationAndIdentity(t *testing.T) {
|
||||
complete, next, err := mailPage(map[string]any{"nextCursor": "$"}, "mail/search", "", "")
|
||||
if err != nil || !complete || next != "" {
|
||||
t.Fatalf("terminal dollar cursor mismatch: complete=%v next=%q err=%v", complete, next, err)
|
||||
}
|
||||
complete, next, err = mailPage(map[string]any{"hasMore": "false", "nextCursor": ""}, "mail/list", "", "")
|
||||
if err != nil || !complete || next != "" {
|
||||
t.Fatalf("string false pagination mismatch: complete=%v next=%q err=%v", complete, next, err)
|
||||
}
|
||||
if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": ""}, "mail/list", "", ""); err == nil {
|
||||
t.Fatal("hasMore without a progressing cursor must fail")
|
||||
}
|
||||
if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": "cursor-1"}, "mail/list", "", "cursor-1"); err == nil {
|
||||
t.Fatal("repeated cursor must fail closed")
|
||||
}
|
||||
if err := mailRequireIdentity(map[string]any{"id": "actual"}, "mail/get", "expected", "id"); err == nil {
|
||||
t.Fatal("identity mismatch must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailUserProjectionOmitsEmptyOptionalIdentity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
user map[string]any
|
||||
wantKey string
|
||||
absentKey string
|
||||
}{
|
||||
{name: "user id only", user: map[string]any{"id": "user-1", "email": ""}, wantKey: "userId", absentKey: "email"},
|
||||
{name: "email only", user: map[string]any{"id": "", "email": "user@example.invalid"}, wantKey: "email", absentKey: "userId"},
|
||||
{name: "numeric user id only", user: map[string]any{"id": float64(7), "email": " "}, wantKey: "userId", absentKey: "email"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rows, err := mailProjectCollection(
|
||||
map[string]any{"success": true, "users": []any{tc.user}},
|
||||
"mail/search_mail_users", "users", []string{"id", "email"},
|
||||
map[string][]string{"userId": {"id"}, "email": {"email"}},
|
||||
)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("rows=%v err=%v", rows, err)
|
||||
}
|
||||
if _, ok := rows[0][tc.wantKey]; !ok {
|
||||
t.Fatalf("projection missing %s: %#v", tc.wantKey, rows[0])
|
||||
}
|
||||
if _, ok := rows[0][tc.absentKey]; ok {
|
||||
t.Fatalf("projection retained empty %s: %#v", tc.absentKey, rows[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := mailProjectCollection(
|
||||
map[string]any{"success": true, "users": []any{map[string]any{"id": "", "email": ""}}},
|
||||
"mail/search_mail_users", "users", []string{"id", "email"},
|
||||
map[string][]string{"userId": {"id"}, "email": {"email"}},
|
||||
); err == nil {
|
||||
t.Fatal("user without a stable identity unexpectedly projected")
|
||||
}
|
||||
for name, user := range map[string]map[string]any{
|
||||
"malformed email with valid id": {"id": "user-1", "email": 7},
|
||||
"malformed id with valid email": {"id": map[string]any{"value": "user-1"}, "email": "user@example.invalid"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := mailProjectCollection(
|
||||
map[string]any{"success": true, "users": []any{user}},
|
||||
"mail/search_mail_users", "users", []string{"id", "email"},
|
||||
map[string][]string{"userId": {"id"}, "email": {"email"}},
|
||||
); err == nil {
|
||||
t.Fatal("malformed optional identity unexpectedly passed projection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type mailWriteCall struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type mailWriteContractCaller struct {
|
||||
responses map[string][]string
|
||||
errors map[string]error
|
||||
calls []mailWriteCall
|
||||
}
|
||||
|
||||
func (caller *mailWriteContractCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls = append(caller.calls, mailWriteCall{tool: tool, args: maps.Clone(args)})
|
||||
if err := caller.errors[tool]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
queue := caller.responses[tool]
|
||||
if len(queue) == 0 {
|
||||
return nil, fmt.Errorf("unexpected Mail tool call: %s", tool)
|
||||
}
|
||||
text := queue[0]
|
||||
caller.responses[tool] = queue[1:]
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (*mailWriteContractCaller) Format() string { return "json" }
|
||||
func (*mailWriteContractCaller) DryRun() bool { return false }
|
||||
func (*mailWriteContractCaller) Fields() string { return "" }
|
||||
func (*mailWriteContractCaller) JQ() string { return "" }
|
||||
|
||||
func runConfirmedMailWriteContract(t *testing.T, caller *mailWriteContractCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetArgs(append(args, "--yes", "--format", "json"))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func mailDraftReadback(id, subject, body, sender, to, cc string) string {
|
||||
return fmt.Sprintf(`{"success":true,"message":{"id":%q,"subject":%q,"markdownBody":%q,"from":{"email":%q},"toRecipients":[{"email":%q}],"ccRecipients":[{"email":%q}]}}`, id, subject, body, sender, to, cc)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailWriteHappyPathsUseReceiptThenExactReadback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
responses map[string][]string
|
||||
wantTools []string
|
||||
}{
|
||||
{
|
||||
name: "draft create",
|
||||
args: []string{"mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"},
|
||||
responses: map[string][]string{
|
||||
"create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`},
|
||||
"get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")},
|
||||
},
|
||||
wantTools: []string{"create_draft", "get_email_by_message_id"},
|
||||
},
|
||||
{
|
||||
name: "draft edit",
|
||||
args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"},
|
||||
responses: map[string][]string{
|
||||
"update_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`},
|
||||
"get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")},
|
||||
},
|
||||
wantTools: []string{"update_draft", "get_email_by_message_id"},
|
||||
},
|
||||
{
|
||||
name: "template create core fields",
|
||||
args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"},
|
||||
responses: map[string][]string{
|
||||
"create_user_message_template": {`{"success":true,"id":"template-1"}`},
|
||||
"get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`},
|
||||
},
|
||||
wantTools: []string{"create_user_message_template", "get_user_message_template"},
|
||||
},
|
||||
{
|
||||
name: "template update success plus requested id readback",
|
||||
args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body"},
|
||||
responses: map[string][]string{
|
||||
"update_user_message_template": {`{"success":true}`},
|
||||
"get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`},
|
||||
},
|
||||
wantTools: []string{"update_user_message_template", "get_user_message_template"},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil {
|
||||
t.Fatalf("write execution failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != len(tc.wantTools) {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), len(tc.wantTools))
|
||||
}
|
||||
for index, tool := range tc.wantTools {
|
||||
if caller.calls[index].tool != tool {
|
||||
t.Fatalf("call[%d]=%s want=%s", index, caller.calls[index].tool, tool)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailDraftWritesFailClosedOnReceiptAndReadback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
writeBody string
|
||||
readBody string
|
||||
writeErr error
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "empty write body", writeBody: "", wantCalls: 1},
|
||||
{name: "missing receipt", writeBody: `{"success":true}`, wantCalls: 1},
|
||||
{name: "malformed receipt", writeBody: `{"success":true,"result":{"message":"bad"}}`, wantCalls: 1},
|
||||
{name: "missing receipt id", writeBody: `{"success":true,"result":{"message":{"subject":"x"}}}`, wantCalls: 1},
|
||||
{name: "missing readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true}`, wantCalls: 2},
|
||||
{name: "malformed readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true,"message":"bad"}`, wantCalls: 2},
|
||||
{name: "wrong readback id", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "request field mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "recipient mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "write failure", writeErr: fmt.Errorf("write failed"), wantCalls: 1},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{
|
||||
responses: map[string][]string{"create_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}},
|
||||
errors: map[string]error{"create_draft": tc.writeErr},
|
||||
}
|
||||
err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body")
|
||||
if err == nil {
|
||||
t.Fatal("invalid receipt/readback unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailEditAndTemplateNegativeCallHistory(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
responses map[string][]string
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "draft edit no change", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1"}, responses: map[string][]string{}, wantCalls: 0},
|
||||
{name: "draft edit wrong receipt id", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject"}, responses: map[string][]string{"update_draft": {`{"success":true,"result":{"message":{"id":"other"}}}`}}, wantCalls: 1},
|
||||
{name: "template update no change", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1"}, responses: map[string][]string{}, wantCalls: 0},
|
||||
{name: "template update missing success", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{}`}}, wantCalls: 1},
|
||||
{name: "template update wrong readback id", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2},
|
||||
{name: "template update field mismatch", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`}}, wantCalls: 2},
|
||||
{name: "template create missing id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true}`}}, wantCalls: 1},
|
||||
{name: "template create wrong readback id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true,"id":"template-1"}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2},
|
||||
{name: "template create unverifiable from", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--from", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{}, wantCalls: 0},
|
||||
{name: "template create unverifiable draft", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--draft"}, responses: map[string][]string{}, wantCalls: 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil {
|
||||
t.Fatal("invalid write unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailCursorPassthroughAndStall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
tool string
|
||||
response string
|
||||
}{
|
||||
{name: "thread list", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}],"hasMore":true,"nextCursor":"cursor-2"}}`},
|
||||
{name: "user search", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`},
|
||||
{name: "template list", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}],"hasMore":true,"nextCursor":"cursor-2"}`},
|
||||
{name: "contact list", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}],"hasMore":true,"nextCursor":"cursor-2"}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil {
|
||||
t.Fatalf("execution failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].args["cursor"] != "cursor-1" {
|
||||
t.Fatalf("cursor was not passed through: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
caller := &mailWriteContractCaller{
|
||||
responses: map[string][]string{"search_mail_users": {`{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-1"}`}},
|
||||
errors: map[string]error{},
|
||||
}
|
||||
if err := runConfirmedMailWriteContract(t, caller, "mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"); err == nil {
|
||||
t.Fatal("repeated cursor unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailValidationRejectsBeforeRemoteCall(t *testing.T) {
|
||||
tooManyIDs := make([]string, 101)
|
||||
for index := range tooManyIDs {
|
||||
tooManyIDs[index] = fmt.Sprintf("message-%d", index)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "thread zero limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "0"}},
|
||||
{name: "thread high limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "101"}},
|
||||
{name: "thread bad start", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "bad"}},
|
||||
{name: "thread non utc", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00+08:00"}},
|
||||
{name: "thread reversed", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00Z", "--end", "2026-08-18T09:00:00Z"}},
|
||||
{name: "user zero limit", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "0"}},
|
||||
{name: "template nonnumeric limit", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "bad"}},
|
||||
{name: "contact high limit", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "101"}},
|
||||
{name: "messages empty item", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1", "--ids", " "}},
|
||||
{name: "messages too many", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", strings.Join(tooManyIDs, ",")}},
|
||||
{name: "user empty keyword", args: []string{"mail", "+user-search", "--keyword", " "}},
|
||||
{name: "user empty employee number", args: []string{"mail", "+user-search", "--keyword", "fixture", "--employee-no", " "}},
|
||||
{name: "message empty id", args: []string{"mail", "+message", "--id", " "}},
|
||||
{name: "thread empty id", args: []string{"mail", "+thread", "--id", " "}},
|
||||
{name: "folder empty email", args: []string{"mail", "+folder-list", "--email", " "}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil {
|
||||
t.Fatal("invalid input unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid input reached remote calls: %d", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailLegacyStringLimitsStayCompatibleAndStrict(t *testing.T) {
|
||||
if err := mailValidatePageSize(mailRuntimeForCoverage(t, ThreadList, nil), "limit", false); err != nil {
|
||||
t.Fatalf("omitted optional integer limit should remain valid: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
declaration *shortcut.Shortcut
|
||||
args []string
|
||||
tool string
|
||||
response string
|
||||
wantSize string
|
||||
wantPresent bool
|
||||
}{
|
||||
{name: "user limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "1"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`, wantSize: "1", wantPresent: true},
|
||||
{name: "user omitted limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`},
|
||||
{name: "template limit", declaration: &TemplateList, args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "100"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[],"hasMore":false,"nextCursor":""}`, wantSize: "100", wantPresent: true},
|
||||
{name: "contact limit", declaration: &ContactList, args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[],"hasMore":false,"nextCursor":""}`, wantSize: "20", wantPresent: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
found := false
|
||||
for _, flag := range tc.declaration.Flags {
|
||||
if flag.Name != "limit" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if flag.Type != shortcut.FlagString || flag.Default != "" {
|
||||
t.Fatalf("declaration --limit type/default = %q/%q, want string/empty", flag.Type, flag.Default)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("declaration missing --limit")
|
||||
}
|
||||
cmd := corecmd.New(shortcut.FromShortcut(*tc.declaration))
|
||||
if flag := cmd.Flags().Lookup("limit"); flag == nil || flag.Value.Type() != "string" || flag.DefValue != "" {
|
||||
t.Fatalf("Cobra --limit = %#v, want historical string with empty default", flag)
|
||||
}
|
||||
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil {
|
||||
t.Fatalf("compatible execution failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls=%d, want 1", len(caller.calls))
|
||||
}
|
||||
gotSize, present := caller.calls[0].args["size"]
|
||||
if present != tc.wantPresent || (present && gotSize != tc.wantSize) {
|
||||
t.Fatalf("size=%#v present=%v, want %q present=%v", gotSize, present, tc.wantSize, tc.wantPresent)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
values map[string]string
|
||||
}{
|
||||
{name: "user execute guard", declaration: UserSearch, values: map[string]string{"keyword": "fixture", "limit": "bad"}},
|
||||
{name: "template execute guard", declaration: TemplateList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}},
|
||||
{name: "contact execute guard", declaration: ContactList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := mailRuntimeForCoverage(t, tc.declaration, tc.values)
|
||||
if err := tc.declaration.Execute(rt); err == nil {
|
||||
t.Fatal("direct Execute accepted malformed string limit")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("direct Execute reached %d remote calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailLegacyStringLimitsRejectEveryInvalidClassWithZeroCalls(t *testing.T) {
|
||||
for _, leaf := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "user", args: []string{"mail", "+user-search", "--keyword", "fixture"}},
|
||||
{name: "template", args: []string{"mail", "+template-list", "--email", "mail@example.invalid"}},
|
||||
{name: "contact", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid"}},
|
||||
} {
|
||||
for _, invalid := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "blank", value: " "},
|
||||
{name: "nonnumeric", value: "bad"},
|
||||
{name: "zero", value: "0"},
|
||||
{name: "negative", value: "-1"},
|
||||
{name: "too large", value: "101"},
|
||||
} {
|
||||
t.Run(leaf.name+"/"+invalid.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
|
||||
args := append(append([]string{}, leaf.args...), "--limit", invalid.value)
|
||||
if err := runConfirmedMailWriteContract(t, caller, args...); err == nil {
|
||||
t.Fatal("invalid legacy string limit unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid legacy string limit reached %d remote calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailDraftEditOwnNegativeBranches(t *testing.T) {
|
||||
goodReceipt := `{"success":true,"result":{"message":{"id":"draft-1"}}}`
|
||||
goodReadback := mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
writeBody string
|
||||
readBody string
|
||||
errors map[string]error
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "receipt remote failure", writeBody: `{"success":false}`, wantCalls: 1},
|
||||
{name: "receipt missing", writeBody: `{"success":true}`, wantCalls: 1},
|
||||
{name: "receipt malformed", writeBody: `{"success":true,"result":{"message":[]}}`, wantCalls: 1},
|
||||
{name: "receipt wrong id", writeBody: `{"success":true,"result":{"message":{"id":"other"}}}`, wantCalls: 1},
|
||||
{name: "readback transport", writeBody: goodReceipt, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("read failed")}, wantCalls: 2},
|
||||
{name: "readback missing", writeBody: goodReceipt, readBody: `{"success":true}`, wantCalls: 2},
|
||||
{name: "readback malformed", writeBody: goodReceipt, readBody: `{"success":true,"message":[]}`, wantCalls: 2},
|
||||
{name: "readback wrong id", writeBody: goodReceipt, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "subject mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "body mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "from mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "to mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "cc mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid"), wantCalls: 2},
|
||||
{name: "happy control", writeBody: goodReceipt, readBody: goodReadback, wantCalls: 2},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{
|
||||
responses: map[string][]string{"update_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}},
|
||||
errors: tc.errors,
|
||||
}
|
||||
if caller.errors == nil {
|
||||
caller.errors = map[string]error{}
|
||||
}
|
||||
err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body")
|
||||
if tc.name == "happy control" {
|
||||
if err != nil {
|
||||
t.Fatalf("happy control: %v", err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("negative branch unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailTemplateCreateOwnNegativeBranches(t *testing.T) {
|
||||
goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
createBody string
|
||||
readBody string
|
||||
errors map[string]error
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "missing success", createBody: `{"id":"template-1"}`, wantCalls: 1},
|
||||
{name: "remote failure", createBody: `{"success":false,"id":"template-1"}`, wantCalls: 1},
|
||||
{name: "missing id", createBody: `{"success":true}`, wantCalls: 1},
|
||||
{name: "readback transport", createBody: `{"success":true,"id":"template-1"}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2},
|
||||
{name: "readback missing shape", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true}`, wantCalls: 2},
|
||||
{name: "readback wrong id", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2},
|
||||
{name: "name mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2},
|
||||
{name: "subject mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2},
|
||||
{name: "body mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2},
|
||||
{name: "happy control", createBody: `{"success":true,"id":"template-1"}`, readBody: goodReadback, wantCalls: 2},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{"create_user_message_template": {tc.createBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors}
|
||||
if caller.errors == nil {
|
||||
caller.errors = map[string]error{}
|
||||
}
|
||||
err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body")
|
||||
if tc.name == "happy control" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("negative branch unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailTemplateUpdateOwnNegativeBranches(t *testing.T) {
|
||||
goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
updateBody string
|
||||
readBody string
|
||||
errors map[string]error
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "missing success", updateBody: `{}`, wantCalls: 1},
|
||||
{name: "remote failure", updateBody: `{"success":false}`, wantCalls: 1},
|
||||
{name: "readback transport", updateBody: `{"success":true}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2},
|
||||
{name: "readback missing shape", updateBody: `{"success":true}`, readBody: `{"success":true}`, wantCalls: 2},
|
||||
{name: "readback wrong id", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2},
|
||||
{name: "name mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2},
|
||||
{name: "subject mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2},
|
||||
{name: "body mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2},
|
||||
{name: "happy control", updateBody: `{"success":true}`, readBody: goodReadback, wantCalls: 2},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{"update_user_message_template": {tc.updateBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors}
|
||||
if caller.errors == nil {
|
||||
caller.errors = map[string]error{}
|
||||
}
|
||||
err := runConfirmedMailWriteContract(t, caller, "mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body")
|
||||
if tc.name == "happy control" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("negative branch unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func mailResolveMailbox(rt *shortcut.RuntimeContext) (string, error) {
|
||||
if email := strings.TrimSpace(rt.Str("email")); email != "" {
|
||||
return email, nil
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "list_user_mailboxes", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mailboxes, err := mailMailboxAddresses(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(mailboxes) == 0 {
|
||||
return "", apperrors.NewValidation("当前身份没有可用邮箱,请用 --email 指定")
|
||||
}
|
||||
return mailboxes[0], nil
|
||||
}
|
||||
|
||||
// mailMailboxAddresses accepts the reviewed mailbox collection paths and item
|
||||
// shapes used by the Mail gateway. The complete selected array is validated
|
||||
// before the first address is returned so a malformed later row cannot be
|
||||
// hidden behind an otherwise usable first mailbox.
|
||||
func mailMailboxAddresses(data map[string]any) ([]string, error) {
|
||||
const operation = "mail/list_user_mailboxes"
|
||||
if err := mailRequireSuccess(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"}
|
||||
var value any
|
||||
selectedPath := ""
|
||||
for _, path := range paths {
|
||||
candidate, present := mailLookup(data, path)
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if selectedPath != "" {
|
||||
return nil, mailResponseError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path))
|
||||
}
|
||||
value = candidate
|
||||
selectedPath = path
|
||||
}
|
||||
if selectedPath == "" {
|
||||
return nil, mailResponseError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果")
|
||||
}
|
||||
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", selectedPath, value))
|
||||
}
|
||||
addresses := make([]string, 0, len(raw))
|
||||
for index, item := range raw {
|
||||
var address string
|
||||
switch typed := item.(type) {
|
||||
case string:
|
||||
address = strings.TrimSpace(typed)
|
||||
case map[string]any:
|
||||
rawEmail, present := typed["email"]
|
||||
if present && rawEmail != nil {
|
||||
var valid bool
|
||||
address, valid = rawEmail.(string)
|
||||
if !valid {
|
||||
return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d].email 必须是字符串", selectedPath, index))
|
||||
}
|
||||
address = strings.TrimSpace(address)
|
||||
}
|
||||
default:
|
||||
return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 必须是邮箱字符串或含 email 的对象", selectedPath, index))
|
||||
}
|
||||
if address == "" {
|
||||
return nil, mailResponseError(operation, "missing_item_identity", fmt.Sprintf("响应 %s[%d] 缺少非空邮箱地址", selectedPath, index))
|
||||
}
|
||||
addresses = append(addresses, address)
|
||||
}
|
||||
return addresses, nil
|
||||
}
|
||||
|
||||
func mailReadMessage(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) {
|
||||
data, err := rt.CallMCPData("mail", "get_email_by_message_id", map[string]any{"email": email, "messageId": id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message, err := mailRequireObject(data, "mail/get_email_by_message_id", "message")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mailRequireIdentity(message, "mail/get_email_by_message_id", id, "id"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
var Message = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "mail", Command: "+message", Product: "mail",
|
||||
Description: "读取一封邮件的完整正文与附件元数据", Intent: "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。",
|
||||
Risk: shortcut.RiskRead, Safety: mailReadSafety(),
|
||||
Contract: mailReadContract("+message", "读取一封邮件的完整正文与附件元数据", "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。", mailObjectResult("身份匹配的单封邮件详情"), []contract.ParamDecl{{Name: "id", Property: "messageId"}}, `dws mail +message --id <messageId> --format json`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件 messageId,不能为空"},
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateRequiredText(rt, "id")
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
email, err := mailResolveMailbox(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := mailReadMessage(rt, email, rt.Str("id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"value": message})
|
||||
},
|
||||
}
|
||||
|
||||
var Messages = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "mail", Command: "+messages", Product: "mail",
|
||||
Description: "按请求顺序读取多封邮件并逐封验证身份", Intent: "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。",
|
||||
Risk: shortcut.RiskRead, Safety: mailReadSafety(),
|
||||
Contract: mailReadContract("+messages", "按请求顺序读取多封邮件并逐封验证身份", "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。", mailCollectionResult("messages", "身份匹配且保持请求顺序的邮件详情"), []contract.ParamDecl{{Name: "ids", Property: "messageIds"}}, `dws mail +messages --ids <id1>,<id2> --format json`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "邮件 messageId 列表,1-100 个且每项不能为空"},
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{
|
||||
Kind: shortcut.ConstraintCustom, Flags: []string{"ids"},
|
||||
Description: "1-100 个且不能为空",
|
||||
}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateMessageIDs(rt.StrSlice("ids"))
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
ids := rt.StrSlice("ids")
|
||||
email, err := mailResolveMailbox(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages := make([]map[string]any, 0, len(ids))
|
||||
for index, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index))
|
||||
}
|
||||
message, err := mailReadMessage(rt, email, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages = append(messages, message)
|
||||
}
|
||||
return rt.Output(mailBusinessCollectionPayload("messages", messages))
|
||||
},
|
||||
}
|
||||
|
||||
func mailValidateMessageIDs(ids []string) error {
|
||||
if len(ids) == 0 || len(ids) > 100 {
|
||||
return apperrors.NewValidation("--ids 需要 1 到 100 个邮件 ID")
|
||||
}
|
||||
for index, id := range ids {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var Thread = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "mail", Command: "+thread", Product: "mail",
|
||||
Description: "读取完整邮件会话并精确验证 conversationId", Intent: "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。",
|
||||
Risk: shortcut.RiskRead, Safety: mailReadSafety(),
|
||||
Contract: mailReadContract("+thread", "读取完整邮件会话并精确验证 conversationId", "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。", mailObjectResult("身份匹配的邮件会话详情"), []contract.ParamDecl{{Name: "id", Property: "conversationId"}}, `dws mail +thread --id <conversationId> --format json`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件会话 conversationId,不能为空"},
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateRequiredText(rt, "id")
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
email, err := mailResolveMailbox(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "get_thread", map[string]any{"email": email, "conversationId": rt.Str("id")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conversation, err := mailRequireObject(data, "mail/get_thread", "conversation")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mailRequireIdentity(conversation, "mail/get_thread", rt.Str("id"), "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"value": conversation})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(Message, Messages, Thread)
|
||||
}
|
||||
@@ -21,18 +21,23 @@ import (
|
||||
// TestThreadListProjectConversationsShape guards the exact
|
||||
// list_mailbox_threads response contract, including lastModifiedDateTime.
|
||||
func TestThreadListProjectConversationsShape(t *testing.T) {
|
||||
const raw = `{"result":{"conversations":[{
|
||||
const raw = `{"success":true,"result":{"conversations":[{
|
||||
"id":"thread-1",
|
||||
"subject":"Projection contract",
|
||||
"lastModifiedDateTime":"2026-07-26T10:00:00Z",
|
||||
"isRead":false
|
||||
}]}}`
|
||||
}],"hasMore":false,"nextCursor":""}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
|
||||
got := threadListProject(data)
|
||||
got, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{
|
||||
"conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("strict projection: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("lower/upper mismatch: result.conversations has 1 entry, projection returned %d (%v)", len(got), got)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user