478 lines
15 KiB
Go
478 lines
15 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
package output
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// CommandResult is the immutable framework result consumed by CLI and MCP
|
|
// adapters. The unexported envelope method prevents product packages from
|
|
// implementing ad-hoc result shapes.
|
|
type CommandResult interface {
|
|
Outcome() Outcome
|
|
ExitCode() int
|
|
envelope() *Envelope
|
|
}
|
|
|
|
type commandResult struct {
|
|
env Envelope
|
|
exitCode int
|
|
exitCodeOverride bool
|
|
}
|
|
|
|
func (r *commandResult) Outcome() Outcome { return r.env.Outcome }
|
|
func (r *commandResult) ExitCode() int { return r.exitCode }
|
|
func (r *commandResult) envelope() *Envelope {
|
|
copy := cloneEnvelope(r.env)
|
|
return ©
|
|
}
|
|
|
|
// ResultOption enriches a result without exposing mutable framework fields.
|
|
type ResultOption struct{ apply func(*Envelope) }
|
|
|
|
func WithIdentity(identity string) ResultOption {
|
|
return ResultOption{apply: func(env *Envelope) { env.Identity = identity }}
|
|
}
|
|
|
|
func WithDryRun() ResultOption {
|
|
return ResultOption{apply: func(env *Envelope) { env.DryRun = true }}
|
|
}
|
|
|
|
func WithMeta(meta *Meta) ResultOption {
|
|
return ResultOption{apply: func(env *Envelope) { env.Meta = cloneMeta(meta) }}
|
|
}
|
|
|
|
// Success constructs an immutable success result.
|
|
func Success(data any, opts ...ResultOption) CommandResult {
|
|
return newCommandResult(OutcomeSuccess, data, nil, opts...)
|
|
}
|
|
|
|
// Pending constructs an immutable accepted-but-not-terminal result.
|
|
func Pending(data any, operation *OperationInfo, opts ...ResultOption) CommandResult {
|
|
opts = append(opts, ResultOption{apply: func(env *Envelope) {
|
|
if env.Meta == nil {
|
|
env.Meta = &Meta{}
|
|
}
|
|
env.Meta.Operation = operation
|
|
}})
|
|
return newCommandResult(OutcomePending, data, nil, opts...)
|
|
}
|
|
|
|
// Partial constructs an immutable multi-status result with exit code 7.
|
|
func Partial(data *PartialData, opts ...ResultOption) CommandResult {
|
|
return newCommandResult(OutcomePartialFailure, data, nil, opts...)
|
|
}
|
|
|
|
// Failure constructs an immutable typed failure result.
|
|
func Failure(info *ErrorInfo, opts ...ResultOption) CommandResult {
|
|
if info != nil {
|
|
info = cloneErrorInfo(info)
|
|
// Product code does not own process status. The framework derives it
|
|
// from type/subtype so wire and process status cannot drift.
|
|
info.ExitCode = 0
|
|
}
|
|
return newCommandResult(OutcomeFailure, nil, info, opts...)
|
|
}
|
|
|
|
// FailureWithExitCode adapts an existing repository error whose historical
|
|
// process status is part of a compatibility contract (for example SIGINT=130).
|
|
// Product handlers must use Failure; only the root legacy-error adapter should
|
|
// call this function.
|
|
func FailureWithExitCode(info *ErrorInfo, exitCode int, opts ...ResultOption) CommandResult {
|
|
if info != nil {
|
|
info = cloneErrorInfo(info)
|
|
info.ExitCode = 0
|
|
}
|
|
return newCommandResultWithExitCode(OutcomeFailure, nil, info, exitCode, true, opts...)
|
|
}
|
|
|
|
func newCommandResult(outcome Outcome, data any, info *ErrorInfo, opts ...ResultOption) CommandResult {
|
|
return newCommandResultWithExitCode(outcome, data, info, 0, false, opts...)
|
|
}
|
|
|
|
func newCommandResultWithExitCode(outcome Outcome, data any, info *ErrorInfo, override int, hasOverride bool, opts ...ResultOption) CommandResult {
|
|
env := Envelope{Outcome: outcome, Data: data, Error: info}
|
|
env.OK = outcome == OutcomeSuccess || outcome == OutcomePending
|
|
for _, opt := range opts {
|
|
if opt.apply != nil {
|
|
opt.apply(&env)
|
|
}
|
|
}
|
|
exitCode := ExitCodeForEnvelope(&env)
|
|
if hasOverride && override > 0 {
|
|
exitCode = override
|
|
}
|
|
if env.Error != nil {
|
|
env.Error.ExitCode = exitCode
|
|
}
|
|
env = cloneEnvelope(env)
|
|
return &commandResult{env: env, exitCode: exitCode, exitCodeOverride: hasOverride}
|
|
}
|
|
|
|
func cloneEnvelope(source Envelope) Envelope {
|
|
out := source
|
|
out.Meta = cloneMeta(source.Meta)
|
|
out.Error = cloneErrorInfo(source.Error)
|
|
out.Data = cloneResultData(source.Data)
|
|
out.Notice = cloneResultData(source.Notice)
|
|
if partial, ok := source.Data.(*PartialData); ok && partial != nil {
|
|
copyPartial := &PartialData{
|
|
Total: partial.Total,
|
|
Succeeded: make([]any, len(partial.Succeeded)),
|
|
Failed: make([]PartialFailedEntry, len(partial.Failed)),
|
|
Unknown: make([]PartialUnknownEntry, len(partial.Unknown)),
|
|
}
|
|
copy(copyPartial.Failed, partial.Failed)
|
|
copy(copyPartial.Unknown, partial.Unknown)
|
|
for i := range partial.Succeeded {
|
|
copyPartial.Succeeded[i] = cloneResultData(partial.Succeeded[i])
|
|
}
|
|
for i := range copyPartial.Failed {
|
|
copyPartial.Failed[i].Error = cloneErrorInfo(copyPartial.Failed[i].Error)
|
|
}
|
|
out.Data = copyPartial
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneMeta(source *Meta) *Meta {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
meta := *source
|
|
if source.Count != nil {
|
|
count := *source.Count
|
|
meta.Count = &count
|
|
}
|
|
if source.Operation != nil {
|
|
op := *source.Operation
|
|
meta.Operation = &op
|
|
}
|
|
if source.Pagination != nil {
|
|
pagination := *source.Pagination
|
|
meta.Pagination = &pagination
|
|
}
|
|
return &meta
|
|
}
|
|
|
|
func cloneResultData(value any) any {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return cloneReflectValue(reflect.ValueOf(value), make(map[cloneVisit]reflect.Value)).Interface()
|
|
}
|
|
|
|
type cloneVisit struct {
|
|
typ reflect.Type
|
|
ptr uintptr
|
|
len int
|
|
cap int
|
|
}
|
|
|
|
func cloneReflectValue(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value {
|
|
if !value.IsValid() {
|
|
return value
|
|
}
|
|
switch value.Kind() {
|
|
case reflect.Interface:
|
|
if value.IsNil() {
|
|
return reflect.Zero(value.Type())
|
|
}
|
|
cloned := cloneReflectValue(value.Elem(), seen)
|
|
out := reflect.New(value.Type()).Elem()
|
|
out.Set(cloned)
|
|
return out
|
|
case reflect.Pointer:
|
|
if value.IsNil() {
|
|
return reflect.Zero(value.Type())
|
|
}
|
|
visit := cloneVisit{typ: value.Type(), ptr: value.Pointer()}
|
|
if cloned, ok := seen[visit]; ok {
|
|
return cloned
|
|
}
|
|
out := reflect.New(value.Type().Elem())
|
|
seen[visit] = out
|
|
out.Elem().Set(cloneReflectValue(value.Elem(), seen))
|
|
return out
|
|
case reflect.Map:
|
|
if value.IsNil() {
|
|
return reflect.Zero(value.Type())
|
|
}
|
|
visit := cloneVisit{typ: value.Type(), ptr: value.Pointer()}
|
|
if cloned, ok := seen[visit]; ok {
|
|
return cloned
|
|
}
|
|
out := reflect.MakeMapWithSize(value.Type(), value.Len())
|
|
seen[visit] = out
|
|
iter := value.MapRange()
|
|
for iter.Next() {
|
|
out.SetMapIndex(cloneReflectValue(iter.Key(), seen), cloneReflectValue(iter.Value(), seen))
|
|
}
|
|
return out
|
|
case reflect.Slice:
|
|
if value.IsNil() {
|
|
return reflect.Zero(value.Type())
|
|
}
|
|
visit := cloneVisit{typ: value.Type(), ptr: value.Pointer(), len: value.Len(), cap: value.Cap()}
|
|
if cloned, ok := seen[visit]; ok {
|
|
return cloned
|
|
}
|
|
out := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
|
|
seen[visit] = out
|
|
for i := 0; i < value.Len(); i++ {
|
|
out.Index(i).Set(cloneReflectValue(value.Index(i), seen))
|
|
}
|
|
return out
|
|
case reflect.Array:
|
|
out := reflect.New(value.Type()).Elem()
|
|
for i := 0; i < value.Len(); i++ {
|
|
out.Index(i).Set(cloneReflectValue(value.Index(i), seen))
|
|
}
|
|
return out
|
|
case reflect.Struct:
|
|
out := reflect.New(value.Type()).Elem()
|
|
out.Set(value)
|
|
for i := 0; i < value.NumField(); i++ {
|
|
if value.Type().Field(i).PkgPath == "" && out.Field(i).CanSet() {
|
|
out.Field(i).Set(cloneReflectValue(value.Field(i), seen))
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func cloneErrorInfo(source *ErrorInfo) *ErrorInfo {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
out := *source
|
|
out.UpstreamCode = cloneResultData(source.UpstreamCode)
|
|
out.Params = append([]string(nil), source.Params...)
|
|
out.Actions = append([]string(nil), source.Actions...)
|
|
out.AvailableFlags = append([]string(nil), source.AvailableFlags...)
|
|
if source.ExecutionStarted != nil {
|
|
started := *source.ExecutionStarted
|
|
out.ExecutionStarted = &started
|
|
}
|
|
if source.Details != nil {
|
|
out.Details = cloneResultData(source.Details).(map[string]any)
|
|
}
|
|
out.RPCData = cloneResultData(source.RPCData)
|
|
if source.RetryAfterSeconds != nil {
|
|
seconds := *source.RetryAfterSeconds
|
|
out.RetryAfterSeconds = &seconds
|
|
}
|
|
return &out
|
|
}
|
|
|
|
// ValidateResult is the mandatory pre-emission policy boundary.
|
|
func ValidateResult(result CommandResult) error {
|
|
if result == nil {
|
|
return fmt.Errorf("output: nil command result")
|
|
}
|
|
env := result.envelope()
|
|
if err := env.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if env.Outcome == OutcomePending {
|
|
if env.Meta == nil || env.Meta.Operation == nil {
|
|
return fmt.Errorf("output: pending result requires meta.operation")
|
|
}
|
|
op := env.Meta.Operation
|
|
if strings.TrimSpace(op.ID) == "" {
|
|
return fmt.Errorf("output: pending result requires operation.id")
|
|
}
|
|
if strings.TrimSpace(op.State) == "" {
|
|
return fmt.Errorf("output: pending result requires operation.state")
|
|
}
|
|
if strings.TrimSpace(op.NextCommand) == "" {
|
|
return fmt.Errorf("output: pending result requires operation.next_command")
|
|
}
|
|
}
|
|
if env.Outcome == OutcomePartialFailure {
|
|
if partial, ok := env.Data.(*PartialData); !ok || partial == nil {
|
|
return fmt.Errorf("output: partial_failure result requires typed *PartialData")
|
|
}
|
|
}
|
|
concrete, _ := result.(*commandResult)
|
|
want := ExitCodeForEnvelope(env)
|
|
if concrete == nil || !concrete.exitCodeOverride {
|
|
if got := result.ExitCode(); got != want {
|
|
return fmt.Errorf("output: result exit code=%d disagrees with outcome-derived code=%d", got, want)
|
|
}
|
|
} else if result.ExitCode() <= 0 {
|
|
return fmt.Errorf("output: explicit compatibility exit code must be positive")
|
|
}
|
|
if env.Error != nil && env.Error.ExitCode != result.ExitCode() {
|
|
return fmt.Errorf("output: error.exit_code=%d disagrees with process exit code=%d", env.Error.ExitCode, result.ExitCode())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResultStore transfers a successful in-memory result through Cobra without
|
|
// pretending it is an error. The root PersistentPostRunE emits the result
|
|
// before closing output sinks, so normal cleanup always runs.
|
|
type ResultStore struct {
|
|
mu sync.Mutex
|
|
result CommandResult
|
|
emitAttempted bool
|
|
bytesRisk bool
|
|
emitted bool
|
|
exitCode int
|
|
}
|
|
|
|
type resultStoreContextKey struct{}
|
|
|
|
func WithResultStore(ctx context.Context) (context.Context, *ResultStore) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if store, ok := resultStoreFromContext(ctx); ok {
|
|
return ctx, store
|
|
}
|
|
store := &ResultStore{}
|
|
return context.WithValue(ctx, resultStoreContextKey{}, store), store
|
|
}
|
|
|
|
func resultStoreFromContext(ctx context.Context) (*ResultStore, bool) {
|
|
if ctx == nil {
|
|
return nil, false
|
|
}
|
|
store, ok := ctx.Value(resultStoreContextKey{}).(*ResultStore)
|
|
return store, ok && store != nil
|
|
}
|
|
|
|
// ResetResultStore starts a new command execution on a reusable Cobra tree.
|
|
//
|
|
// NewRootCommand installs one store in the root context so process-level
|
|
// signal and exit-code tracking can retain a stable pointer. Library callers,
|
|
// however, may call ExecuteC more than once on that same root. Resetting the
|
|
// execution fields before PersistentPreRunE prevents a previous result or
|
|
// emission attempt from leaking into the next invocation while preserving the
|
|
// store identity used by the process boundary.
|
|
func ResetResultStore(ctx context.Context) error {
|
|
store, ok := resultStoreFromContext(ctx)
|
|
if !ok {
|
|
return fmt.Errorf("output: command context has no result store; install output.WithResultStore at the root execution boundary before resetting it")
|
|
}
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
store.result = nil
|
|
store.emitAttempted = false
|
|
store.bytesRisk = false
|
|
store.emitted = false
|
|
store.exitCode = 0
|
|
return nil
|
|
}
|
|
|
|
// StoreResult records exactly one terminal framework result for this command.
|
|
func StoreResult(ctx context.Context, result CommandResult) error {
|
|
if err := ValidateResult(result); err != nil {
|
|
return err
|
|
}
|
|
store, ok := resultStoreFromContext(ctx)
|
|
if !ok {
|
|
return fmt.Errorf("output: command context has no result store; install output.WithResultStore at the root execution boundary before calling StoreResult")
|
|
}
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
if store.result != nil {
|
|
return fmt.Errorf("output: command produced more than one framework result")
|
|
}
|
|
store.result = result
|
|
return nil
|
|
}
|
|
|
|
// EmitStoredResult is called from PersistentPostRunE before output cleanup.
|
|
func EmitStoredResult(cmd *cobra.Command) (int, bool, error) {
|
|
if cmd == nil {
|
|
return 0, false, nil
|
|
}
|
|
store, ok := resultStoreFromContext(cmd.Context())
|
|
if !ok {
|
|
return 0, false, nil
|
|
}
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
if store.result == nil || store.emitAttempted {
|
|
return store.exitCode, store.emitted, nil
|
|
}
|
|
if !UsesUnifiedResult(cmd) {
|
|
return 0, false, fmt.Errorf("output: legacy command %q produced a unified framework result", cmd.CommandPath())
|
|
}
|
|
store.emitAttempted = true
|
|
store.exitCode = store.result.ExitCode()
|
|
code, bytesRisk, err := emitResult(cmd, store.result)
|
|
store.bytesRisk = bytesRisk
|
|
if err != nil {
|
|
store.exitCode = exitCodeInternal
|
|
if !bytesRisk {
|
|
// emitResult renders into memory before touching stdout. A rendering
|
|
// failure therefore has no externally visible bytes and can safely be
|
|
// replaced by one minimal typed failure. Write failures keep
|
|
// bytesRisk=true and must never attempt a second primary result.
|
|
fallback := Failure(&ErrorInfo{
|
|
Type: "internal",
|
|
Subtype: "result_encoding_failed",
|
|
Message: "failed to encode command result",
|
|
TechnicalDetail: err.Error(),
|
|
})
|
|
fallbackCode, fallbackBytesRisk, fallbackErr := emitResult(cmd, fallback)
|
|
store.bytesRisk = fallbackBytesRisk
|
|
if fallbackErr == nil {
|
|
store.exitCode = fallbackCode
|
|
store.emitted = true
|
|
return fallbackCode, true, nil
|
|
}
|
|
return store.exitCode, false, fmt.Errorf("output: render command result: %v; emit fallback: %w", err, fallbackErr)
|
|
}
|
|
// The writer may have consumed bytes even when it returned an error.
|
|
// Preserve the single-primary-result invariant and surface the write
|
|
// failure only as a diagnostic plus the stored internal exit code.
|
|
return store.exitCode, false, err
|
|
}
|
|
store.exitCode = code
|
|
store.emitted = true
|
|
return code, true, nil
|
|
}
|
|
|
|
// StoredEmissionState reports whether result emission was attempted. Once an
|
|
// attempt starts, callers must not try another envelope because a writer may
|
|
// have accepted bytes even when it returned an error.
|
|
func StoredEmissionState(store *ResultStore) (exitCode int, attempted, emitted, bytesRisk bool) {
|
|
if store == nil {
|
|
return 0, false, false, false
|
|
}
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
return store.exitCode, store.emitAttempted, store.emitted, store.bytesRisk
|
|
}
|
|
|
|
// StoredExitCode returns the code produced by PersistentPostRunE.
|
|
func StoredExitCode(store *ResultStore) (int, bool) {
|
|
if store == nil {
|
|
return 0, false
|
|
}
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
return store.exitCode, store.emitted
|
|
}
|
|
|
|
// EnvelopeFromResult returns a detached envelope copy for adapters/tests.
|
|
func EnvelopeFromResult(result CommandResult) (*Envelope, error) {
|
|
if err := ValidateResult(result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result.envelope(), nil
|
|
}
|