Files
dingtalk-workspace-cli/docs/rfc-command-framework-convergence.md
T
玉澜 ceca98c573 refactor(cmdcore): layer dispatch into Invoke/Orchestrate behind a shared Ctx
The single Dispatch hook assumed every command is one MCP call, so the
Shortcut projection could only ever describe a command, never run it. Split
dispatch into Invoke (assembled toolArgs) and Orchestrate (multi-step), keep
RunE as the escape hatch, and reject specs that declare anything other than
exactly one at construction time. Ctx gives both hooks the same typed flag
accessors so orchestration no longer reaches for framework-specific plumbing.

Catalog output stays byte-identical.
2026-07-30 20:32:04 +08:00

423 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# RFC: Converging the DWS command frameworks onto one typed base
**Status**: draft, seeking review
**Scope**: `internal/cmdcore`, `internal/helpers` (LeafSpec), `internal/shortcut`
**Related PR**: #830 (landed the shared base; supersedes #827 and #829)
This document is self-contained: a reviewer without repository access should be
able to judge the design from it.
---
## 1. Context
`dws` (DingTalk Workspace CLI) is a Go/Cobra CLI whose commands are thin wrappers
that dispatch a JSON-RPC `tools/call` to DingTalk MCP servers. It targets both
humans and AI agents, so every command also publishes an Agent-visible "Runtime
Schema" (parameters, constraints, safety/confirmation semantics).
### 1.1 Three command-definition systems (measured)
| System | Count | Shape | Notes |
|---|---:|---|---|
| Handwritten `cobra.Command` | **1195** | `dws <product> <cmd>` | 96% of commands; `internal/helpers/*.go`, 7.3万 lines |
| Shortcut | **376** | `dws <product> +<cmd>` | Declarative + **multi-step orchestration** |
| LeafSpec | **27** | `dws <product> <cmd>` | Declarative, single-step; only `devapp` |
Duplication measured inside the handwritten commands: `mustGetFlag` ×500,
`callMCPTool*` ×469, `flagOrFallback` ×306, required validation ×262,
`AttachRuntimeSchema`/`AnnotateRuntimeConstraints` ×165 each.
A static AST census classified the 1195 handwritten commands by how they could
migrate:
| Class | Count | Criterion | Disposition |
|---|---:|---|---|
| GROUP | 594 | no MCP call, no loop/IO (pure parent) | not migrated |
| **A** pure CRUD | **141** | exactly 1 MCP call, no loop/IO/confirm | 1:1 migratable |
| **B** confirm/fallback | **92** | 1 MCP call + confirm/dry-run/alias fallback | migratable |
| **C** multi-step / special IO | **368** | 0 or ≥2 MCP calls, loops, upload/poll | needs multi-step support |
So 233/601 (38.8%) of commands with a body are migratable **today**; the 368
C-class ones require the base to model multi-step orchestration.
### 1.2 What already landed (PR #830)
1. `LeafSpec` gained a declarative contract: `at_least_one` / `exactly_one` /
`mutually_exclusive` constraints, `bool` + `string-slice` flag kinds, and
`Risk`-driven write confirmation honoring `--dry-run` / `--yes` (including
root-injected globals, via a 3-level flag lookup).
2. `internal/cmdcore` — a dispatch-agnostic shared base extracted **verbatim**
from `leaf.go`: flag registration, the `explicit → alias → env → default`
value chain, required validation, constraint declaration checks + runtime
enforcement, Risk confirmation, `toolArgs` assembly, Runtime Schema
projection, help rendering.
3. `cmdcore.CommandSpec` + `cmdcore.NewCommand` — one typed definition and one
orchestration path; dispatch became a spec field.
4. `helpers.FromLeafSpec`; `NewLeafCommand` is now a one-line mapping. All
LeafSpec commands (incl. all 27 devapp leaves) flow through the unified spec.
5. `shortcut.FromShortcut` — a typed seam projecting a Shortcut's shared base
into a `CommandSpec`, deliberately **not** wired into the live `mount()`.
### 1.3 Verification discipline (already enforced, non-negotiable)
- **`check-generated-drift`**: regenerate the Agent metadata + command catalog
and byte-compare against the committed artifacts. For a refactor, a
byte-identical catalog (840 tools, identical hashes) is the equivalence proof
for the **build-time projection** — identity, flags, help, annotations.
- **Runtime semantics are NOT covered by drift.** Required validation, args
assembly, confirmation and dispatch order are evidenced only by unit tests.
(An earlier version of this work overstated drift as proof of runtime
behavior; that claim was corrected.)
- **100% changed-code coverage** gate. Important subtlety: CI runs
`go test -coverprofile` **without `-coverpkg`**, so each package is measured
only by its *own* tests. A new package whose logic is exercised only from a
consumer package will fail the gate (this actually happened: `cmdcore`
self-coverage was 31.5%, CI changed-code coverage 40.5%, plus an overall
regression — fixed by giving `cmdcore` its own exhaustive tests).
- **Interface compatibility**: the public Cobra command surface is diffed
against both the PR merge-base and the latest stable GA tag.
---
## 2. Design review findings
An adversarial review found **no BLOCKERs**: the Phase-1 extraction was
mechanically verified as a verbatim move, and the unified `BoolFlag` was proven
equivalent to the two readers it replaced. The following are the structural
issues that motivate this plan. (Items marked ✅ were already fixed in #830.)
### Structural
**F1. `Dispatch` is single-step biased; the type permits an unrunnable value.**
```go
Dispatch func(cmd *cobra.Command, args []string, toolArgs map[string]any) error
```
This presumes "assemble `toolArgs` → make one call". A Shortcut's
`Execute(rt *RuntimeContext)` is arbitrary multi-step orchestration and **cannot
be expressed**. Consequently `FromShortcut` returned a spec that could not run.
✅ Partially mitigated in #830 by making `NewCommand` reject such a spec — but
that is a patch, not a model fix. **A type that can be constructed into a
permanently unusable state means the model is missing a dimension.**
**F2. The base leaks the MCP payload shape.**
`BuildArgs → map[string]any` is the MCP `tools/call` argument shape, yet it lives
in a package documented as dispatch-agnostic. `devapp` dispatches through
`executor.Runner` and is still handed a `toolArgs` map.
**F3. The same field name carries two semantics.**
- `Required`: cmdcore accepts *any effective value* (including registration
default and env fallback); Shortcut requires the flag to be `Changed`.
- Decline behavior: LeafSpec/cmdcore return a typed validation error (**exit ≠
0**); Shortcut's `mount()` returns `nil` (**exit 0**).
These are semantic splits inside a "shared" base — documentation cannot fix them.
**F4. The constraint model is incomplete and asymmetric with the Schema.**
| Capability | Runtime Schema | `cmdcore.Constraint` |
|---|---|---|
| `mutually_exclusive` | ✅ | ✅ |
| `require_one_of` | ✅ | ✅ (`at_least_one` / `exactly_one`) |
| **`require_together`** | ✅ | ❌ |
| `enum` | ✅ (`AnnotateRuntimeFlagEnum`) | ❌ |
Anything unrepresentable falls back to the imperative `Validate` hook, which
**loses the Schema projection and help rendering** — destroying the main value of
the design ("declare once, take effect in three places").
**F5. Too many hooks; `PostMount` is a back door.**
Extension points: `Validate` + `PostMount` + `RunE` + dispatch. `PostMount(cmd)`
can rewrite anything on the built Cobra command (args, annotations, flags), so
any declarative guarantee can be bypassed — which is precisely why drift became
the only safety net. Measured real usage is narrow: `devAppLeafMeta` ×8 (attach
schema identity) and `registerDevAppCursorFlags` ×4 (pagination flags).
### Secondary
**F6. `LeafSpec` is now vestigial** — 179 lines that are mostly comments and type
aliases; the only substantive own fields are the three dispatch fields
(`Server`/`Tool`/`Call`). It is a pure forwarding layer kept for 27 call sites.
**F7. `FlagSpec` has 14 fields with three "two ways to do one thing" pairs**:
`Default` vs `ArgDefault`, `OmitEmpty` vs `Transform`-returning-nil, `Required`
vs `MarkRequired`. This accreted from per-command special cases rather than a
clean model.
**F8. "Single registry" currently covers ~2%** of commands. The 368 C-class
handwritten commands are exactly the ones the current `CommandSpec` cannot
express, so F1 is a prerequisite for coverage, not an optional polish.
**F9. The Schema still has two sources.** `AnnotateConstraints` projects only two
constraint kinds; `required` / `enum` / `require_together` still come from the
separately reviewed registry + hint files. The declarative spec is not yet the
single source of parameter truth.
### What is worth preserving
- **Declare once → runtime validation + Schema projection + `--help`** (the core
value).
- **Drift-as-equivalence-proof** — makes a 400+ command migration verifiable.
- **Type aliases for zero-churn migration** (`type LeafFlag = cmdcore.FlagSpec`).
- **Dispatch as a spec property** rather than a framework identity.
---
## 3. Plan
### Dependency graph
```
S1 dispatch layering + Ctx ──┬─→ S3 constraints/enum ──┐
│ ├─→ S8 Shortcut live-wire → S9 Schema single source
S2 semantics unification ────┘ │
(needs a product decision) │
S4 MCP shape push-down → S5 FlagSpec orthogonalization → S6 narrow PostMount → S7 delete LeafSpec
```
S1/S2/S3 are **hard prerequisites** for S8. S4→S7 is an independent cleanup
chain that can proceed in parallel.
---
### S1 — Dispatch layering + unified `Ctx` (biggest lever)
Fixes **F1**; unblocks the 368 C-class commands and S8.
```go
// cmdcore: framework-neutral execution context (knows nothing about MCP)
type Ctx struct { /* cmd, args, declared flags */ }
func (c *Ctx) Command() *cobra.Command
func (c *Ctx) Args() []string
func (c *Ctx) Str(name string) string // reuses alias → env → default chain
func (c *Ctx) Int(name string) int
func (c *Ctx) Bool(name string) bool
func (c *Ctx) StrSlice(name string) []string
func (c *Ctx) Changed(name string) bool
func (c *Ctx) DryRun() bool
func (c *Ctx) Yes() bool
// CommandSpec: exactly one of the three, validated at CONSTRUCTION time
RunE func(cmd *cobra.Command, args []string) error // escape hatch
Invoke func(c *Ctx, toolArgs map[string]any) error // single-step
Orchestrate func(c *Ctx) error // multi-step
```
Notes:
- `Ctx` accessors honor the declared fallback chain, so a body reads exactly the
value the required/constraint checks saw. This is **stronger** than Shortcut's
`RuntimeContext`, which reads flags bare.
- **Layering rule**: MCP capability stays out of `cmdcore`.
`shortcut.RuntimeContext` keeps `CallMCPData`/`CallMCPWriteData`/`Output`; the
`FromShortcut` adapter builds it from `Ctx.Command()` inside `Orchestrate`.
(Embedding `*cmdcore.Ctx` into `RuntimeContext` is deferred to S8 to avoid
changing 376 shortcuts' flag-reading behavior in this step.)
- Construction-time `panic` replaces the run-time error added in #830,
consistent with the existing `ValidateConstraintDecls` philosophy: a spec with
no runnable body (or two competing ones) is a programming error that every
test and startup path trips immediately.
**Gates**: drift zero; leaf suites green; three construction-panic cases;
`Orchestrate` confirmation/decline cases; `Ctx` accessor cases.
**Risk**: low (existing dispatch shifts to `Invoke`).
**Effort**: 1 PR (medium). *Status: in progress on the branch.*
---
### S2 — Semantics unification (contains a **decision that needs sign-off**)
Fixes **F3**.
**S2a. `Required`** — make the divergence explicit instead of implicit:
```go
type RequiredMode int
const (
RequiredAnyEffectiveValue RequiredMode = iota // = today's LeafSpec behavior (default)
RequiredMustBePassed // = today's Shortcut behavior (Changed)
)
```
During migration both modes coexist; at S8 each shortcut flag maps to
`MustBePassed`, so behavior is unchanged.
**S2b. Decline behavior — ⚠️ user-visible contract change.**
LeafSpec returns an error (exit ≠ 0); Shortcut returns `nil` (exit 0). Options:
1. Converge on **error** (declining a destructive operation should exit non-zero).
Changes the exit code of 376 shortcuts → needs product sign-off + CHANGELOG.
2. Carry a `DeclineBehavior` field through migration and converge later.
3. Converge on `nil` (weakens the atomic-command contract; not recommended).
**S8 must not start before this decision is made.**
**Gates**: cases for both modes; Shortcut's existing suites unchanged.
**Effort**: 1 PR (small) + 1 decision.
---
### S3 — Complete the constraint model and add `enum`
Fixes **F4**; restores "declare once → three effects".
| Addition | Runtime validation | Schema projection | `--help` |
|---|---|---|---|
| `RequireTogether` constraint | ✅ | `RuntimeSchemaConstraints.RequireTogether` (exists) | ✅ |
| `FlagSpec.Enum` | ✅ | `cli.AnnotateRuntimeFlagEnum` (exists) | `(可选值: a, b)` |
| `Required` projection | — | `cli.AnnotateRuntimeRequiredFlags` (exists) | `(必填)` |
All three annotation APIs already exist in `internal/cli`, so this is wiring, not
new Schema surface.
**By-product**: the base can derive the `必填 / 可选值` help decoration itself, so
`FromShortcut` no longer needs to borrow Shortcut's `flagHelp` (removing a
coupling that caused a real bug — see §4).
**Gates**: drift zero (**new annotations must appear only on commands that
declare them; all existing commands unchanged**) + runtime/help/schema cases for
each addition.
**Risk**: medium (touches Schema annotations; drift is the guard).
**Effort**: 1 PR (medium).
---
### S4 — Push the MCP shape out of the base
Fixes **F2**. Do it while only 27 call sites exist.
```go
// base: CLI-facing contract
FlagSpec{ Name, Usage, Kind, Default, Aliases, EnvVar, Required, RequiredMode, Enum, Trim }
// MCP binding layer: payload shaping
ArgBinding{ Flag, Bind string; Transform func(string)(any,error); OmitEmpty bool; ArgDefault string }
```
`BuildArgs` moves to the MCP binding layer (`helpers` or `cmdcore/mcpbind`).
**Gates**: drift zero + per-key comparison of devapp's 27 commands' `toolArgs`.
**Effort**: 1 PR (medium-large; rewrites 27 call sites).
---
### S5 — Orthogonalize `FlagSpec`
Fixes **F7**.
| Redundancy | Resolution |
|---|---|
| `Default` vs `ArgDefault` | S4 moves `ArgDefault` into `ArgBinding` — resolved naturally |
| `OmitEmpty` vs `Transform`-nil | keep `OmitEmpty` (declarative wins); document `Transform`-nil as the general escape |
| `Required` vs `MarkRequired` | merge into `Required` + `EnforceBy{Framework,Cobra}`; `Cobra` preserves the catalog `required` hard floor |
**Gates**: drift zero (the `MarkRequired` → catalog `required` projection must be
byte-identical).
**Effort**: 1 PR (small-medium).
---
### S6 — Narrow `PostMount`
Fixes **F5**. Measured usage is only two kinds:
- `devAppLeafMeta(cmd, tool)` ×8 → named field `SchemaIdentity{Product, Tool, Source}`
- `registerDevAppCursorFlags(cmd)` ×4 → named `ExtraFlags []FlagSpec` / `Pagination`
`PostMount` survives as an explicitly labelled escape hatch with a call-site
inventory (migration-era asset).
**Gates**: drift zero. **Effort**: 1 PR (small).
---
### S7 — Delete `LeafSpec`
Fixes **F6**. Convert devapp's 27 sites to `CommandSpec` + an MCP dispatch
helper; delete `LeafSpec`, the type aliases, `FromLeafSpec`, `NewLeafCommand`.
**Gates**: drift zero + interface-compatibility dual baseline (the command
surface must not change).
**Effort**: 1 PR (medium).
---
### S8 — Live-wire Shortcut (prerequisites: S1 + S2 + S3)
Migrate per service package (20 packages, 1 PR each), ordered
`usage` (smallest) → `todo`/`contact` → … → `chat`/`aitable` (largest).
**Per-PR gates**
1. **Generalize the existing mount-equivalence test into a table over
`allShortcuts`**: for every registered shortcut, assert
`cmdcore.NewCommand(FromShortcut(s))` and `mount(s)` agree byte-for-byte on
flag set / types / usage / `Long` / annotations. ← the most important guard.
2. `shortcut list` output byte-identical (265 entries).
3. `dws schema --all` byte-identical.
4. drift zero + 100% changed-code coverage.
**Still unmodeled before S8** (must be represented or explicitly rejected):
`Flag.Hidden` (no `CommandSpec` field), `Shortcut.Tips → Example`,
`ConstraintCustom`.
**Effort**: ~20 PRs.
---
### S9 — Schema single source (endgame)
Fixes **F9**. Make the declarative spec the only source of parameter facts
(`required` / `enum` / `require_together` / constraints); `schema_hints` retains
only Agent selection prose (`agent_summary` / `use_when` / `avoid_when`).
**Risk**: high (touches the Schema generation pipeline's division of
responsibility with the reviewed registry). **Effort**: separate project.
---
## 4. Bugs this design review already caught (evidence the gates matter)
Fixed in #830 after review:
- A spec with neither `RunE` nor `Dispatch` ran the whole pipeline — **including
the write-confirmation prompt** — then silently exited 0 having done nothing.
- `FromShortcut` **double-rendered** the `参数约束` help section (`shortcutLongHelp`
already appends it; `NewCommand` appended `ConstraintHelp` again) and **dropped**
the `必填 / 可选值` usage decoration. Both would have silently changed help and
catalog output at S8.
- A new **mount-equivalence test** (projected flag set/types/usage + rendered
`Long` vs live `mount(s)`) now catches this whole class; it is the seed of the
S8 gate.
---
## 5. Handwritten commands (1195)
- After **S1**, the 368 C-class commands become expressible for the first time
(via `Orchestrate`).
- The 233 A/B-class commands are migratable today, but should be scheduled
**after S4/S5**, otherwise they get migrated onto the old `FlagSpec` shape and
then rewritten again.
- The 594 GROUP nodes need no migration.
---
## 6. Global discipline (per PR, non-negotiable)
1. `check-generated-drift` zero drift = build-time projection equivalence proof.
2. Runtime semantics can only be proven by unit tests — drift cannot see them.
3. 100% changed-code coverage, and **a new package must have its own direct
tests** (never rely on cross-package `coverpkg`; this was a real CI failure).
4. Anything touching a user-visible contract (exit codes, flag names, help text)
requires a CHANGELOG entry and an explicit human decision.
---
## 7. Questions for the reviewer
1. **S2b**: should declining a risky operation exit non-zero (unify on error), or
should Shortcut's exit-0 behavior be preserved? This is the only user-visible
contract change in the plan.
2. **S1**: is `Invoke` / `Orchestrate` / `RunE` with a construction-time
"exactly one" rule the right factoring, or should dispatch be an interface
(e.g. `Dispatcher` with two implementations) instead of three nullable
function fields?
3. **S4**: is splitting `FlagSpec` (CLI-facing) from `ArgBinding` (payload-facing)
worth the churn on 27 call sites now, versus keeping one struct and accepting
the leak?
4. **S8 ordering**: is per-service migration with a byte-identity gate the right
risk posture for 376 shipped shortcuts, or is a parallel "shadow mode"
(build both, compare at run time) warranted?
5. Is **S9** worth attempting at all, given the reviewed-registry design
intentionally separates identity/safety review from code?