mirror of
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
synced 2026-09-28 21:25:22 +08:00
741 lines
23 KiB
Go
741 lines
23 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 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()
|
||
}
|