Files

741 lines
23 KiB
Go
Raw Permalink 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.
// 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 errors
import (
"bytes"
"context"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"net/url"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
var marshalErrorJSON = jsonutil.MarshalIndent
// Category represents a stable error class with a documented exit code.
type Category string
const (
CategoryAPI Category = "api"
CategoryAuth Category = "auth"
CategoryValidation Category = "validation"
CategoryDiscovery Category = "discovery"
CategoryInternal Category = "internal"
// CategoryPartial is retained for source compatibility, but an error cannot
// reconstruct the per-item data required by a partial result. It therefore
// fails closed as internal; callers must use output.Partial for exit code 7.
CategoryPartial Category = "partial_failure"
)
// 退出码表(规划 v1.2 OQ-1 定案;契约规范 §4;轮10裁决⑬——保留现行码表,
// 仅新增 partial_failure 专用码,不做 wire 破坏性重排):
//
// 0 success / pending(异步受理不是失败)
// 1 api (CategoryAPI)
// 2 auth (CategoryAuth)
// 3 validation (CategoryValidation;confirmation_required 子类共享此码,
// 以 reason/subtype 区分,AC-13)
// 4 PAT (PATError 专属,见 pat.go ExitCodePermission;Category 不占用)
// 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
// succeeded/failed/unknown payload required for an honest partial result.
const ExitCodePartial = 7
// 类别专属退出码常量(B171/B172,权威 = 规划 v1.2 OQ-1 定案,契约规范 §4)。
// ExitCode() 的 switch 用内联字面量,本组常量由 exitcodes.go 的
// exitCodeByCategory 映射表引用,值与内联字面量一一对应(同源不双轨)。
// 修改任一值必须先同步 ExitCode() 的 switch 分支与 internal/output 侧码表。
const (
ExitCodeAPI = 1
ExitCodeAuth = 2
ExitCodeValidation = 3
ExitCodeDiscovery = 6
ExitCodeInternal = 5
)
// Error is the structured repository-local error model for the Go rewrite.
type Error struct {
Category Category
Message string
Operation string
ServerKey string
Origin string
FailureStage string
ExecutionStarted *bool
Retryable bool
RetryableSet bool
RetryAfterSeconds *int64
NextRetryAt *time.Time
Reason string
Hint string
Actions []string
AvailableFlags []string
Snapshot string
Details map[string]any
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
}
func (e *Error) Error() string {
return e.Message
}
// Unwrap returns the underlying cause, enabling errors.Is and errors.As chains.
func (e *Error) Unwrap() error {
return e.Cause
}
// Option mutates a structured error before it is returned.
type Option func(*Error)
// ExitCode returns the documented process exit code for the error category.
// exit=4 is reserved exclusively for PATError (see internal/errors/pat.go
// ExitCodePermission and the exit-code table in docs/reference.md);
// Discovery therefore uses 6 so hosts can tell "catalog lookup broke"
// apart from "PAT permission insufficient".
//
// confirmation_required 是 validation 的子类而非独立类别(B171,AC-13,
// 规划 v1.2 OQ-1 定案):门禁拦截错误挂 CategoryValidation 并以
// reason=confirmation_required 区分,与 validation 共享 rc=3。信封侧
// internal/output exitCodeForErrorInfo 的「subtype 优先于 type、
// confirmation_required 恒 3」规则与本表同源(轮10裁决⑬;远期独立码
// 保留于规划 OQ-9,落地前不得双轨)。
func (e *Error) ExitCode() int {
switch e.Category {
case CategoryAPI:
return 1
case CategoryAuth:
return 2
case CategoryValidation:
return 3
case CategoryDiscovery:
return 6
case CategoryPartial:
// An error has no per-item succeeded/failed/unknown data and therefore
// cannot truthfully represent partial_failure. Fail closed as internal.
return ExitCodeInternal
default:
return 5
}
}
// WithOperation records the operation that failed.
func WithOperation(operation string) Option {
return func(err *Error) {
err.Operation = operation
}
}
// IsConfirmationRequired reports whether err (or any wrapped cause) is a
// typed framework confirmation-gate failure carrying reason
// confirmation_required. Downstream classifiers must pass such errors through
// verbatim: the "re-run with --yes" semantics can only be carried by the
// machine-readable reason, while message-text classification actively
// misroutes them (a command path containing "permission" would be reported as
// an auth failure, and any other wording degrades to an unclassified error).
func IsConfirmationRequired(err error) bool {
if err == nil {
return false
}
var typed *Error
if stderrors.As(err, &typed) {
return strings.TrimSpace(typed.Reason) == "confirmation_required"
}
return false
}
// WithServerKey records the server identifier associated with the failure.
func WithServerKey(serverKey string) Option {
return func(err *Error) {
err.ServerKey = serverKey
}
}
// WithOrigin records the component that produced the failure, such as the
// client, MCP gateway, or DingTalk API. It is independent from Category,
// which remains the stable exit-code contract.
func WithOrigin(origin string) Option {
return func(err *Error) {
err.Origin = strings.TrimSpace(origin)
}
}
// WithFailureStage records the execution stage at which the failure occurred.
func WithFailureStage(stage string) Option {
return func(err *Error) {
err.FailureStage = strings.TrimSpace(stage)
}
}
// WithExecutionStarted records whether the downstream business operation was
// known to have started. Unknown state must be represented by omitting this
// option, which is important for safe retry decisions on write operations.
func WithExecutionStarted(started bool) Option {
return func(err *Error) {
value := started
err.ExecutionStarted = &value
}
}
// WithRetryable marks whether the error can be retried safely.
func WithRetryable(retryable bool) Option {
return func(err *Error) {
err.Retryable = retryable
err.RetryableSet = true
}
}
// WithRetryAfterSeconds records the server-recommended delay before a retry.
// A zero delay is meaningful and is therefore preserved; negative values are
// ignored as invalid server guidance.
//
// 本通道只存原值、不钳制(B195/B199,AC-24):服务端给多少存多少,wire 上
// retry_after_seconds 原样透传。transport 侧的 RetryMaxDelay 钳制只作用于
// 重试延迟选择(retryDelayForAttempt),不得回写或截断本字段(B196 草案:
// 钳制上限可配置化后仍须保持「钳制延迟、不钳制透传」双通道分离)。
func WithRetryAfterSeconds(seconds int64) Option {
return func(err *Error) {
if seconds < 0 {
return
}
value := seconds
err.RetryAfterSeconds = &value
}
}
// WithNextRetryAt records the absolute time at which a retry may be attempted.
func WithNextRetryAt(next time.Time) Option {
return func(err *Error) {
if next.IsZero() {
return
}
value := next.UTC()
err.NextRetryAt = &value
}
}
// WithReason records a stable machine-readable failure reason.
func WithReason(reason string) Option {
return func(err *Error) {
err.Reason = reason
}
}
// WithHint records a short recovery hint for humans and agents.
func WithHint(hint string) Option {
return func(err *Error) {
err.Hint = hint
}
}
// WithActions records suggested next actions for recovery.
func WithActions(actions ...string) Option {
return func(err *Error) {
out := make([]string, 0, len(actions))
for _, action := range actions {
if action == "" {
continue
}
out = append(out, action)
}
if len(out) > 0 {
err.Actions = out
}
}
}
// WithAvailableFlags records visible local flag names for agent recovery.
func WithAvailableFlags(names ...string) Option {
return func(err *Error) {
if len(names) == 0 {
return
}
err.AvailableFlags = append([]string{}, names...)
}
}
// WithSnapshot records the recovery snapshot path associated with the failure.
func WithSnapshot(path string) Option {
return func(err *Error) {
err.Snapshot = path
}
}
// WithDetails records an additive machine-readable payload for errors whose
// recovery needs typed context, such as ambiguous target-resolution
// candidates. Callers must keep credentials and other secrets out of details.
func WithDetails(details map[string]any) Option {
return func(err *Error) {
if len(details) == 0 {
return
}
err.Details = make(map[string]any, len(details))
for key, value := range details {
err.Details[key] = value
}
}
}
// WithRPCCode records the original JSON-RPC error code.
func WithRPCCode(code int) Option {
return func(err *Error) {
err.RPCCode = code
}
}
// WithRPCData records the original JSON-RPC error data payload.
func WithRPCData(data json.RawMessage) Option {
if len(bytes.TrimSpace(data)) == 0 {
return func(*Error) {} // no-op, consistent with other Options
}
return func(err *Error) {
err.RPCData = data
}
}
// WithCause wraps the original error so it can be retrieved via errors.Unwrap.
func WithCause(err error) Option {
return func(e *Error) {
e.Cause = err
}
}
func newError(category Category, message string, opts ...Option) error {
err := &Error{
Category: category,
Message: message,
}
for _, opt := range opts {
if opt != nil {
opt(err)
}
}
return err
}
// NewAPI returns an API-category error.
func NewAPI(message string, opts ...Option) error {
return newError(CategoryAPI, message, opts...)
}
// NewAuth returns an auth-category error.
func NewAuth(message string, opts ...Option) error {
return newError(CategoryAuth, message, opts...)
}
// NewValidation returns a validation-category error.
func NewValidation(message string, opts ...Option) error {
return newError(CategoryValidation, message, opts...)
}
// DeclaresClassification reports whether err already carries an explicit error
// contract of its own: a repository *Error with a stable category, reason and
// actions, or an ExitCoder that states its own exit code. It follows wrapped
// errors; nil is false.
//
// Sentinel cancellation and deadline errors do not qualify. They declare no
// category, so a classification boundary that owns them must still classify
// them; only a validation boundary needs their identity preserved, which is
// what PreserveClassification adds.
func DeclaresClassification(err error) bool {
if err == nil {
return false
}
var typed *Error
var exitCoder ExitCoder
return stderrors.As(err, &typed) || stderrors.As(err, &exitCoder)
}
// PreserveClassification reports whether adapting err must preserve its original
// identity, category and exit code. It follows wrapped errors; nil is false.
//
// Beyond DeclaresClassification this also covers cancellation and deadline
// errors, whose identity a validation boundary must not rewrite into a
// parameter failure. Business classification boundaries should use
// DeclaresClassification instead: a bare context.DeadlineExceeded carries no
// category of its own, and passing it through there forfeits the caller's own
// network-timeout classification.
func PreserveClassification(err error) bool {
if err == nil {
return false
}
return DeclaresClassification(err) ||
stderrors.Is(err, context.Canceled) || stderrors.Is(err, context.DeadlineExceeded)
}
// NormalizeValidation converts an untyped error produced by an authoritative
// parameter-validation phase into the repository's validation error contract.
// Errors that already carry an explicit category or exit code pass through
// unchanged, as do cancellation and deadline errors. Callers must use this at
// the validation boundary rather than infer categories from error messages.
func NormalizeValidation(err error, opts ...Option) error {
if err == nil || PreserveClassification(err) {
return err
}
options := make([]Option, 0, len(opts)+1)
options = append(options, WithCause(err))
options = append(options, opts...)
return NewValidation(err.Error(), options...)
}
// NewDiscovery returns a discovery-category error.
func NewDiscovery(message string, opts ...Option) error {
return newError(CategoryDiscovery, message, opts...)
}
// NewInternal returns an internal-category error.
func NewInternal(message string, opts ...Option) error {
return newError(CategoryInternal, message, opts...)
}
// ExitCoder is implemented by errors that provide their own exit code.
// Edition-specific error types (e.g. PATError, CLIError) implement this
// so the framework can resolve exit codes without importing edition packages.
type ExitCoder interface {
ExitCode() int
}
// RawStderrError is implemented by errors that must output raw content
// directly to stderr, bypassing all CLI formatting (e.g. "Error:" prefix).
// PAT authorization errors use this to pass JSON through to the desktop runtime.
type RawStderrError interface {
error
RawStderr() string
}
// ExitCode maps any error to a stable exit code.
func ExitCode(err error) int {
var typed *Error
if stderrors.As(err, &typed) {
return typed.ExitCode()
}
var ec ExitCoder
if stderrors.As(err, &ec) {
return ec.ExitCode()
}
return 5
}
// PrintJSON writes the legacy machine-readable JSON error object.
//
// This wire predates the unified result framework and is intentionally kept
// byte-compatible for commands whose rollout is legacy_only or dual_validate.
// Unified commands publish outcome/type/subtype through internal/output only.
func PrintJSON(w io.Writer, err error) error {
errorPayload := map[string]any{
"code": ExitCode(err),
"category": category(err),
"message": err.Error(),
}
var typed *Error
if stderrors.As(err, &typed) {
if typed.Reason != "" {
errorPayload["reason"] = typed.Reason
}
if typed.Operation != "" {
errorPayload["operation"] = typed.Operation
}
if typed.ServerKey != "" {
errorPayload["server_key"] = typed.ServerKey
}
if typed.Origin != "" {
errorPayload["origin"] = typed.Origin
}
if typed.FailureStage != "" {
errorPayload["stage"] = typed.FailureStage
}
if typed.ExecutionStarted != nil {
errorPayload["execution_started"] = *typed.ExecutionStarted
}
if typed.RetryableSet {
errorPayload["retryable"] = typed.Retryable
}
if typed.RetryAfterSeconds != nil {
errorPayload["retry_after_seconds"] = *typed.RetryAfterSeconds
}
if typed.NextRetryAt != nil {
errorPayload["next_retry_at"] = typed.NextRetryAt.UTC().Format(time.RFC3339)
}
if typed.Hint != "" {
errorPayload["hint"] = typed.Hint
}
if actions := RecoveryActions(err); len(actions) > 0 {
errorPayload["actions"] = actions
}
if len(typed.AvailableFlags) > 0 {
errorPayload["available_flags"] = typed.AvailableFlags
}
if typed.Snapshot != "" {
errorPayload["snapshot_path"] = typed.Snapshot
}
if len(typed.Details) > 0 {
errorPayload["details"] = typed.Details
}
if typed.RPCCode != 0 {
errorPayload["rpc_code"] = typed.RPCCode
}
if len(typed.RPCData) > 0 {
var parsed any
if json.Unmarshal(typed.RPCData, &parsed) == nil {
errorPayload["rpc_data"] = parsed
}
}
if !typed.ServerDiag.IsEmpty() {
if typed.ServerDiag.TraceID != "" {
errorPayload["trace_id"] = typed.ServerDiag.TraceID
}
if typed.ServerDiag.ServerErrorCode != "" {
errorPayload["server_error_code"] = typed.ServerDiag.ServerErrorCode
}
if typed.ServerDiag.TechnicalDetail != "" {
errorPayload["technical_detail"] = typed.ServerDiag.TechnicalDetail
}
friendlyHint, actionURL := serverGuidance(typed.ServerDiag)
if friendlyHint != "" {
errorPayload["friendly_hint"] = friendlyHint
}
if actionURL != "" {
errorPayload["action_url"] = actionURL
}
}
if typed.Cause != nil {
errorPayload["cause"] = typed.Cause.Error()
}
}
payload := map[string]any{"error": errorPayload}
data, marshalErr := marshalErrorJSON(payload, "", " ")
if marshalErr != nil {
_, writeErr := fmt.Fprintln(w, `{"error":{"code":5,"category":"internal","message":"failed to encode error output"}}`)
return writeErr
}
_, writeErr := fmt.Fprintln(w, string(data))
return writeErr
}
// Verbosity controls how much detail PrintHuman includes.
type Verbosity int
const (
// VerbosityNormal shows essential info: error, hint, actions, trace_id, server_code.
VerbosityNormal Verbosity = 0
// VerbosityVerbose adds technical_detail, snapshot, execution context.
VerbosityVerbose Verbosity = 1
// VerbosityDebug adds all internal diagnostics (category, operation, reason, rpc_code).
VerbosityDebug Verbosity = 2
)
// PrintHuman writes a concise human-readable error rendering at normal verbosity.
func PrintHuman(w io.Writer, err error) error {
return PrintHumanAt(w, err, VerbosityNormal)
}
// PrintHumanAt writes a human-readable error rendering at the given verbosity level.
func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
if err == nil {
return nil
}
var typed *Error
if !stderrors.As(err, &typed) {
_, writeErr := fmt.Fprintf(w, "%s %s\n", tui.StateMark("error"), tui.Danger("Error: "+err.Error()))
return writeErr
}
// Line 1: Error summary
lines := []string{
fmt.Sprintf("%s %s", tui.StateMark("error"), tui.Danger(fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message))),
}
// Always shown: hint, actions, retryable
if typed.Hint != "" {
lines = append(lines, tui.Cyan(fmt.Sprintf("Hint: %s", typed.Hint)))
}
if friendlyHint, actionURL := serverGuidance(typed.ServerDiag); friendlyHint != "" || actionURL != "" {
if friendlyHint != "" {
lines = append(lines, tui.Cyan("Hint: "+friendlyHint))
}
if actionURL != "" {
lines = append(lines, tui.White("Action: 处理入口: "+actionURL))
}
}
if actions := HumanRecoveryActions(err); len(actions) > 0 {
for _, action := range actions {
if strings.TrimSpace(action) == "" {
continue
}
lines = append(lines, tui.White(fmt.Sprintf("Action: %s", action)))
}
}
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
lines = append(lines, tui.Dim(line))
}
if typed.RetryableSet {
lines = append(lines, tui.Warning(fmt.Sprintf("Retryable: %t", typed.Retryable)))
}
if typed.RetryAfterSeconds != nil {
lines = append(lines, tui.Warning(fmt.Sprintf("Retry After: %ds", *typed.RetryAfterSeconds)))
}
if typed.NextRetryAt != nil {
lines = append(lines, tui.Warning("Next Retry At: "+typed.NextRetryAt.UTC().Format(time.RFC3339)))
}
// Always shown when present: Trace ID, Server Code
if typed.ServerDiag.TraceID != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID)))
}
if typed.ServerDiag.ServerErrorCode != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode)))
}
// Verbose+: technical detail, snapshot, reason, server key
if v >= VerbosityVerbose {
if typed.ServerDiag.TechnicalDetail != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail)))
}
if typed.Reason != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Reason: %s", typed.Reason)))
}
if typed.ServerKey != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey)))
}
if typed.Origin != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Origin: %s", typed.Origin)))
}
if typed.FailureStage != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Stage: %s", typed.FailureStage)))
}
if typed.ExecutionStarted != nil {
lines = append(lines, tui.Dim(fmt.Sprintf("Execution Started: %t", *typed.ExecutionStarted)))
}
if typed.Snapshot != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot)))
}
if typed.Cause != nil {
lines = append(lines, tui.Dim(fmt.Sprintf("Cause: %s", typed.Cause.Error())))
}
}
// Debug: all internal diagnostics
if v >= VerbosityDebug {
if typed.Operation != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Operation: %s", typed.Operation)))
}
if typed.RPCCode != 0 {
lines = append(lines, tui.Dim(fmt.Sprintf("RPC Code: %d", typed.RPCCode)))
}
if len(typed.RPCData) > 0 {
lines = append(lines, tui.Dim(fmt.Sprintf("RPC Data: %s", string(typed.RPCData))))
}
}
_, writeErr := fmt.Fprintln(w, strings.Join(lines, "\n"))
return writeErr
}
func serverGuidance(diag ServerDiagnostics) (string, string) {
friendlyHint := strings.TrimSpace(diag.FriendlyHint)
actionURL := safeServerActionURL(diag.ActionURL)
if friendlyHint == "" || actionURL == "" {
switch diag.ServerErrorCode {
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
if friendlyHint == "" {
friendlyHint = "该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。"
}
if actionURL == "" {
actionURL = config.GetDeveloperSettingsURL()
}
}
}
return friendlyHint, actionURL
}
// ServerGuidance exposes the same recovery projection to repository-local
// adapters so legacy JSON and unified-result errors stay semantically aligned.
func ServerGuidance(diag ServerDiagnostics) (string, string) {
return serverGuidance(diag)
}
func safeServerActionURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || !strings.EqualFold(parsed.Scheme, "https") ||
parsed.Hostname() == "" || parsed.User != nil {
return ""
}
return parsed.String()
}
func category(err error) string {
var typed *Error
if stderrors.As(err, &typed) {
if typed.Category == CategoryPartial {
return string(CategoryInternal)
}
return string(typed.Category)
}
return string(CategoryInternal)
}
const availableFlagsHumanMaxRunes = 200
func formatAvailableFlagsHumanLine(flags []string) string {
if len(flags) == 0 {
return ""
}
b := strings.Builder{}
b.WriteString("Flags: ")
written := 0
for i, name := range flags {
if i > 0 {
if written+2 > availableFlagsHumanMaxRunes {
b.WriteString("...")
return b.String()
}
b.WriteString(", ")
written += 2
}
if written+len(name) > availableFlagsHumanMaxRunes {
b.WriteString("...")
return b.String()
}
b.WriteString(name)
written += len(name)
}
return b.String()
}