Framework-only: adds the reviewed wait contract mirroring the DryRunSpec pattern (types declaration -> ContractDecl -> ContractFinal -> ToolSpec -> Schema wait key). No business command declares it yet. - contract.WaitSpec (mode poll/event/auto, poll_command, status_query, terminal status->success/failure map, pending_values, event_key, match_field, default_timeout_secs) with closed-set Validate - Spec.WaitPoll hook pairs with the declaration at construction time (declared without hook / hook without declaration both panic) - declared leaves register --wait / --wait-timeout natively (never FlagSpec, so they cannot enter MCP toolArgs); undeclared leaves reject the flags as unknown instead of ignoring them - internal/wait engine: immediate-first-poll, x1.5 backoff capped 30s, dotted status extraction, fail-closed on unknown status, timeout -> pending - ResultInvoke path closes the unified envelope: success terminal -> success, failure terminal -> failure with new wire-stable error.type "wait" (exit code 8, additive like partial=7), timeout -> pending + meta.operation.timed_out with last observed state (exit 0) - output.WithOutcome / WithErrorInfo / WithOperationTimedOut preserve envelope invariants (I2/I3, pending requires meta.operation) Verified: go test ./... green; check-generated-drift.sh ok (schema assembly deterministic, wire unchanged); check-schema-catalog.sh ok (27 products, 1121 tools).
182 lines
7.1 KiB
Go
182 lines
7.1 KiB
Go
// 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 (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
|
)
|
|
|
|
// ContractDecl is the authoring-time leaf contract declaration.
|
|
//
|
|
// Naming: this is not a Catalog / ToolSpec "Schema" object. Authors declare
|
|
// leaf Contract facts (selection / interface / parameters / dry-run /
|
|
// identity prose). AttachContract converts once into
|
|
// contract.ContractFinalPayload; Catalog assembly pass-throughs that payload.
|
|
// Nested fields reuse contract.* types directly so authoring cannot drift from
|
|
// the registry model.
|
|
//
|
|
// Description declare vs delivery (not dual authority, not "declare = wire"):
|
|
// - Construction requires Description (declaration evidence / fail-closed).
|
|
// - Catalog delivery: Cobra Long wins when present → provenance cobra_help;
|
|
// without Long, declared Description is delivered → contract_final.
|
|
// - Title: declared Title/ContractFinal first, then Cobra Short, then MCP.
|
|
//
|
|
// The payload stores the declared text; assembly stamps the real winner.
|
|
type ContractDecl struct {
|
|
Title string
|
|
Description string // required at construction; Catalog may prefer Cobra Long
|
|
Positionals []contract.RuntimeSchemaPositional
|
|
Parameters []contract.ParamDecl
|
|
DryRun *contract.DryRunSpec
|
|
Wait *contract.WaitSpec
|
|
Result *contract.ResultSpec
|
|
Pagination *contract.PaginationSpec
|
|
Interface *contract.InterfaceSpec
|
|
Selection contract.SelectionSpec
|
|
Identity contract.ToolIdentitySpec
|
|
}
|
|
|
|
// validateContractDecl enforces authoring-time homology for declared commands.
|
|
// A declared Contract is the sole final source for its fields: downstream
|
|
// catalog/Agent gates hard-require description and the selection prose for
|
|
// every effective tool — so a declaration missing any of these fields could
|
|
// only fail later, opaquely, in generated artifacts. Failing at construction
|
|
// keeps the error next to the authoring mistake and prevents silent drift
|
|
// between cobra prose (Short/Long/Example) and the published Contract values.
|
|
//
|
|
// Identity is required on every declared Contract as the leaf's
|
|
// self-description: identity is collected from ContractFinal on the live
|
|
// leaves, so an incomplete or inconsistent declared Identity fails collection,
|
|
// binding, and policy downstream.
|
|
func validateContractDecl(spec Spec) {
|
|
if spec.Contract.empty() {
|
|
return
|
|
}
|
|
missing := make([]string, 0, 8)
|
|
if strings.TrimSpace(spec.Contract.Description) == "" {
|
|
missing = append(missing, "Contract.Description")
|
|
}
|
|
if strings.TrimSpace(spec.Contract.Selection.AgentSummary) == "" {
|
|
missing = append(missing, "Contract.Selection.AgentSummary")
|
|
}
|
|
if len(spec.Contract.Selection.UseWhen) == 0 {
|
|
missing = append(missing, "Contract.Selection.UseWhen")
|
|
}
|
|
if len(spec.Contract.Selection.AvoidWhen) == 0 {
|
|
missing = append(missing, "Contract.Selection.AvoidWhen")
|
|
}
|
|
if len(spec.Contract.Selection.Examples) == 0 {
|
|
missing = append(missing, "Contract.Selection.Examples")
|
|
}
|
|
// Interface is an unconditional catalog required key for declared tools
|
|
// (no hints fallback). Spec.Safety is validated separately and is
|
|
// the single source for both runtime confirmation and the Schema block.
|
|
if iface := spec.Contract.Interface; iface == nil ||
|
|
strings.TrimSpace(iface.Mode) == "" || strings.TrimSpace(iface.Availability) == "" {
|
|
missing = append(missing, "Contract.Interface (mode/availability)")
|
|
} else if (iface.Mode == contract.InterfaceModeComposite || iface.Availability == contract.InterfaceUnavailable) &&
|
|
strings.TrimSpace(iface.Reason) == "" {
|
|
missing = append(missing, "Contract.Interface.Reason")
|
|
}
|
|
id := spec.Contract.Identity
|
|
productID := strings.TrimSpace(id.ProductID)
|
|
name := strings.TrimSpace(id.Name)
|
|
canonical := strings.TrimSpace(id.CanonicalPath)
|
|
cliPath := strings.TrimSpace(id.CLIPath)
|
|
primary := strings.TrimSpace(id.PrimaryCLIPath)
|
|
if productID == "" {
|
|
missing = append(missing, "Contract.Identity.ProductID")
|
|
}
|
|
if name == "" {
|
|
missing = append(missing, "Contract.Identity.Name")
|
|
}
|
|
if canonical == "" {
|
|
missing = append(missing, "Contract.Identity.CanonicalPath")
|
|
}
|
|
if cliPath == "" && primary == "" {
|
|
missing = append(missing, "Contract.Identity.CLIPath")
|
|
}
|
|
if len(missing) > 0 {
|
|
panic(fmt.Sprintf(
|
|
"command %q declares Contract but is missing %s: a declared Contract is the final source and must carry the full reviewed prose (no hints fallback)",
|
|
spec.Use, strings.Join(missing, ", ")))
|
|
}
|
|
wantCanonical := productID + "." + name
|
|
if canonical != wantCanonical {
|
|
panic(fmt.Sprintf(
|
|
"command %q Contract.Identity.CanonicalPath %q must equal ProductID.Name %q",
|
|
spec.Use, canonical, wantCanonical))
|
|
}
|
|
if cliPath != "" && primary != "" && cliPath != primary {
|
|
panic(fmt.Sprintf(
|
|
"command %q Contract.Identity.CLIPath %q and PrimaryCLIPath %q must agree on the primary leaf path",
|
|
spec.Use, cliPath, primary))
|
|
}
|
|
}
|
|
|
|
// Empty reports whether no ContractDecl field was authored.
|
|
func (s ContractDecl) Empty() bool {
|
|
return s.empty()
|
|
}
|
|
|
|
// empty reports whether no ContractDecl field was authored.
|
|
func (s ContractDecl) empty() bool {
|
|
if strings.TrimSpace(s.Title) != "" || strings.TrimSpace(s.Description) != "" {
|
|
return false
|
|
}
|
|
if len(s.Positionals) > 0 {
|
|
return false
|
|
}
|
|
if len(s.Parameters) > 0 {
|
|
return false
|
|
}
|
|
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
|
|
}
|
|
if s.Pagination != nil {
|
|
return false
|
|
}
|
|
if s.Interface != nil {
|
|
iface := s.Interface
|
|
if strings.TrimSpace(iface.Mode) != "" || strings.TrimSpace(iface.Availability) != "" ||
|
|
strings.TrimSpace(iface.Reason) != "" || iface.Ref != nil {
|
|
return false
|
|
}
|
|
}
|
|
if strings.TrimSpace(s.Selection.AgentSummary) != "" || len(s.Selection.UseWhen) > 0 ||
|
|
len(s.Selection.AvoidWhen) > 0 || len(s.Selection.Examples) > 0 ||
|
|
len(s.Selection.Prerequisites) > 0 || len(s.Selection.Tips) > 0 ||
|
|
len(s.Selection.WorkflowRefs) > 0 {
|
|
return false
|
|
}
|
|
if strings.TrimSpace(s.Identity.ProductID) != "" || strings.TrimSpace(s.Identity.SourceProductID) != "" ||
|
|
strings.TrimSpace(s.Identity.Name) != "" || strings.TrimSpace(s.Identity.CLIName) != "" ||
|
|
strings.TrimSpace(s.Identity.CanonicalPath) != "" || strings.TrimSpace(s.Identity.Path) != "" ||
|
|
strings.TrimSpace(s.Identity.CLIPath) != "" || strings.TrimSpace(s.Identity.PrimaryCLIPath) != "" ||
|
|
strings.TrimSpace(s.Identity.Group) != "" || strings.TrimSpace(s.Identity.Source) != "" ||
|
|
len(s.Identity.Aliases) > 0 || s.Identity.IsAlias {
|
|
return false
|
|
}
|
|
return true
|
|
}
|