feat: add unified command result framework core
This commit is contained in:
1 parent
cf3bcb380f
commit
2dd067562e
40 files changed
+9463
-74
No files matched your search
@@ -0,0 +1,103 @@
|
|||||||
|
# DWS Unified Command Framework 2.0 设计概要
|
||||||
|
|
||||||
|
> 状态:Framework core 提案。本文只定义框架能力与集成边界;产品命令迁移、Skill 更新和真实服务复验由后续 PR 独立完成。
|
||||||
|
|
||||||
|
## 1. 产品裁决
|
||||||
|
|
||||||
|
1. 不公开 `--output-contract`,也不增加任何等价别名。
|
||||||
|
2. Agent 继续只使用既有 `--format json`。
|
||||||
|
3. 每条 terminal command 在一个 release 中只有一个 active wire contract:已迁移命令直接使用统一结果,未迁移命令保持 legacy。
|
||||||
|
4. contract 不由用户参数、环境变量、会话能力协商或 Agent 选择。
|
||||||
|
5. 回滚是命令声明与发布行为,不改变消费者 argv。
|
||||||
|
6. 本 PR 不迁移任何产品命令;已有命令路径、参数和输出保持不变。
|
||||||
|
|
||||||
|
## 2. 渐进迁移
|
||||||
|
|
||||||
|
内部状态机:
|
||||||
|
|
||||||
|
```text
|
||||||
|
legacy_only -> dual_validate -> unified_active -> unified_stable -> unified_only
|
||||||
|
```
|
||||||
|
|
||||||
|
- `legacy_only`:只构造、输出 legacy。
|
||||||
|
- `dual_validate`:业务只执行一次;外部仍逐字输出 legacy;同一内存结果 shadow-build 统一结果并严格校验。
|
||||||
|
- `unified_active`:`--format json` 直接返回统一结果信封,可按发布声明回退。
|
||||||
|
- `unified_stable`:完成真实 Agent 消费观察和兼容窗口。
|
||||||
|
- `unified_only`:清理仅服务 legacy 的产品 renderer。
|
||||||
|
|
||||||
|
状态是每条 terminal command 的内部发布元数据。Help、Skill、Agent Schema 不展示迁移状态,也不让消费者选择协议。
|
||||||
|
|
||||||
|
## 3. 统一结果
|
||||||
|
|
||||||
|
Framework 2.0 统一表达四类结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
success 请求完成且命令认为操作已完成
|
||||||
|
pending 请求被受理,但异步操作尚未终结
|
||||||
|
partial_failure 批量操作有成功项,也有失败或未知项
|
||||||
|
failure 请求或操作失败
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON 基本形态:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"outcome": "success",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
硬不变量:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ok == (outcome in {success, pending})
|
||||||
|
process rc == 0 <=> ok == true
|
||||||
|
top-level error present <=> outcome == failure
|
||||||
|
one invocation emits exactly one primary result
|
||||||
|
```
|
||||||
|
|
||||||
|
框架负责 L1 request outcome 和 L2 operation outcome 的统一表达;L3 verification 必须由产品命令基于业务事实实现,框架不得自动推断 `changed/verified`。
|
||||||
|
|
||||||
|
## 4. 输出与错误纪律
|
||||||
|
|
||||||
|
- 统一 JSON/NDJSON 的 primary result 写 stdout;stderr 只写诊断。
|
||||||
|
- 日志不得污染 stdout。
|
||||||
|
- `ok`、`retryable`、`dry_run` 等必须是 JSON boolean。
|
||||||
|
- 失败由框架根据 typed error 映射退出码;产品代码不能自报任意 rc。
|
||||||
|
- `partial_failure` 保留 `succeeded[]/failed[]/unknown[]`,使用非零 rc 7。
|
||||||
|
- `pending` 必须提供 operation id、state 和可执行的 `next_command`。
|
||||||
|
- `endpoint_exhausted` 只表示观察到当前 endpoint 分页耗尽;false 必须带 `next_token`,不得扩大成索引健康或业务数据完整。
|
||||||
|
- dry-run 是已经完成的无副作用预览,表达为 `success + dry_run:true`,不是 `pending`。
|
||||||
|
|
||||||
|
## 5. 重试与超时边界
|
||||||
|
|
||||||
|
- 框架只统一表达 `retryable`、`retry_after_seconds` 和 `execution_started`,不自动决定业务操作能否安全重放。
|
||||||
|
- 写调用的模糊失败、HTTP timeout 和异步等待预算属于 transport/产品集成范围,不在本 PR 改动。
|
||||||
|
- 产品迁移必须证明其重试声明与幂等性、安全等级一致。
|
||||||
|
|
||||||
|
## 6. 后续集成范围
|
||||||
|
|
||||||
|
- 产品命令通过 `corecmd.ResultInvoke` 构造 `CommandResult`,由 root 单一出口渲染。
|
||||||
|
- shortcut、长连接、批量写和异步任务各自需要独立集成 PR;框架 core 不替产品推断 success、pending、partial 或分页事实。
|
||||||
|
- 每条 terminal command 独立 rollout;不能整域一次切换,也不能通过 Agent 参数选择协议。
|
||||||
|
- 已有命令在进入 `unified_active` 前必须保留 legacy byte golden,并完成真实 Agent 语义扫描。
|
||||||
|
|
||||||
|
## 7. 对齐原则
|
||||||
|
|
||||||
|
- 对齐 Lark CLI:统一 envelope/emitter、typed error、partial、pending、分页窄语义和强类型结果。
|
||||||
|
- 对齐 GWS:机器结果稳定结构化、日志与数据分流、消费者不协商协议版本。
|
||||||
|
- DWS 保留差异:声明式 Agent Schema、安全门禁、静态命令与 shortcut 共存,以及四 outcome 模型。
|
||||||
|
|
||||||
|
## 8. 发布门禁
|
||||||
|
|
||||||
|
命令晋级 `unified_active` 前至少满足:
|
||||||
|
|
||||||
|
1. success/failure/dry-run golden;批量或异步命令另有 partial/pending golden。
|
||||||
|
2. 业务请求 exactly once;dual validation 不得二次调用服务端。
|
||||||
|
3. legacy 命令 stdout/stderr/rc 字节级回归不变。
|
||||||
|
4. Help、Schema 和全仓示例不存在协议选择参数。
|
||||||
|
5. `--format json` 输出单个合法统一结果文档,stdout 无日志污染。
|
||||||
|
6. typed error、进程 rc 与信封 `error.exit_code` 一致。
|
||||||
|
7. 安全声明、确认门禁与 dry-run 运行时行为同源。
|
||||||
|
8. Agent 语义扫描记录命令级迁移证据;发布回滚无需修改 Agent argv。
|
||||||
+398
-22
@@ -15,24 +15,24 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
stderrors "errors"
|
stderrors "errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||||
@@ -40,6 +40,7 @@ import (
|
|||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
@@ -58,8 +59,11 @@ var (
|
|||||||
rootStopAllStdioClients = StopAllStdioClients
|
rootStopAllStdioClients = StopAllStdioClients
|
||||||
rootLoadPlugins = loadPlugins
|
rootLoadPlugins = loadPlugins
|
||||||
rootMkdirAll = os.MkdirAll
|
rootMkdirAll = os.MkdirAll
|
||||||
rootCreateFile = os.Create
|
rootCreateTemp = os.CreateTemp
|
||||||
|
rootSyncFile = (*os.File).Sync
|
||||||
rootCloseFile = (*os.File).Close
|
rootCloseFile = (*os.File).Close
|
||||||
|
rootRenameFile = os.Rename
|
||||||
|
rootRemoveFile = os.Remove
|
||||||
rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv
|
rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv
|
||||||
rootPluginLoadUser = (*plugin.Loader).LoadUser
|
rootPluginLoadUser = (*plugin.Loader).LoadUser
|
||||||
rootPluginLoadDev = (*plugin.Loader).LoadDev
|
rootPluginLoadDev = (*plugin.Loader).LoadDev
|
||||||
@@ -76,10 +80,49 @@ var (
|
|||||||
|
|
||||||
// Execute runs the root command and returns the process exit code.
|
// Execute runs the root command and returns the process exit code.
|
||||||
func Execute() (exitCode int) {
|
func Execute() (exitCode int) {
|
||||||
|
var (
|
||||||
|
root *cobra.Command
|
||||||
|
executed *cobra.Command
|
||||||
|
resultStore *output.ResultStore
|
||||||
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
target := executed
|
||||||
exitCode = 5
|
if target == nil && root != nil {
|
||||||
|
if found, _, err := root.Find(os.Args[1:]); err == nil {
|
||||||
|
target = found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||||
|
exitCode = code
|
||||||
|
if target != nil {
|
||||||
|
fmt.Fprintf(target.ErrOrStderr(), "Warning: command panicked after result emission attempt: %v\n", r)
|
||||||
|
}
|
||||||
|
} else if target != nil && output.UsesUnifiedResult(target) {
|
||||||
|
info := &output.ErrorInfo{Type: "internal", ExitCode: 5, Message: fmt.Sprintf("internal panic: %v", r)}
|
||||||
|
if code, err := output.EmitResult(target, output.Failure(info)); err == nil {
|
||||||
|
exitCode = code
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
||||||
|
exitCode = 5
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
||||||
|
exitCode = 5
|
||||||
|
}
|
||||||
|
if executed == nil {
|
||||||
|
executed = target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CloseFileLogger()
|
||||||
|
if executed != nil {
|
||||||
|
// The result envelope has already established the command outcome.
|
||||||
|
// A late sink-close failure is diagnostic only: emitting a second
|
||||||
|
// envelope or changing the exit code would violate the one-result
|
||||||
|
// contract and make stdout impossible for Agents to branch on.
|
||||||
|
if err := closeOutputSink(executed); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: close output sink: %v\n", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -95,15 +138,17 @@ func Execute() (exitCode int) {
|
|||||||
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
|
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Attach timing collector to context for use by child components
|
// Attach timing collector to context for use by child components
|
||||||
ctx = WithTimingCollector(ctx, timing)
|
ctx := WithTimingCollector(context.Background(), timing)
|
||||||
|
ctx, resultStore = output.WithResultStore(ctx)
|
||||||
|
var signalState *processSignalState
|
||||||
|
var stopSignals func()
|
||||||
|
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
|
||||||
|
defer stopSignals()
|
||||||
|
|
||||||
initStart := time.Now()
|
initStart := time.Now()
|
||||||
engine := newPipelineEngine()
|
engine := newPipelineEngine()
|
||||||
root := rootNewRootCommandWithEngine(ctx, engine)
|
root = rootNewRootCommandWithEngine(ctx, engine)
|
||||||
timing.Record("cmd_init", time.Since(initStart))
|
timing.Record("cmd_init", time.Since(initStart))
|
||||||
|
|
||||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||||
@@ -111,16 +156,55 @@ func Execute() (exitCode int) {
|
|||||||
// and --limit100 → --limit 100.
|
// and --limit100 → --limit 100.
|
||||||
if err := rootRunPreParse(root, engine); err != nil {
|
if err := rootRunPreParse(root, engine); err != nil {
|
||||||
err = newPreParseValidationError(err)
|
err = newPreParseValidationError(err)
|
||||||
|
if interrupted, _ := signalState.outcome(); interrupted != nil {
|
||||||
|
err = interrupted
|
||||||
|
}
|
||||||
|
if target, _, findErr := root.Find(os.Args[1:]); findErr == nil && target != nil && output.UsesUnifiedResult(target) {
|
||||||
|
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||||
|
code, emitErr := output.EmitResult(target, result)
|
||||||
|
if emitErr == nil {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
|
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
|
||||||
return apperrors.ExitCode(err)
|
return apperrors.ExitCode(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
executed, err := rootExecuteCommand(root)
|
var err error
|
||||||
|
executed, err = rootExecuteCommand(root)
|
||||||
|
interrupted, primaryCompletedBeforeSignal := signalState.outcome()
|
||||||
|
if interrupted != nil && !primaryCompletedBeforeSignal {
|
||||||
|
if _, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||||
|
if executed == nil {
|
||||||
|
executed = root
|
||||||
|
}
|
||||||
|
fmt.Fprintf(executed.ErrOrStderr(), "Warning: process interrupted after result emission attempt: %v\n", interrupted)
|
||||||
|
return interrupted.ExitCode()
|
||||||
|
}
|
||||||
|
err = interrupted
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if executed == nil {
|
if executed == nil {
|
||||||
executed = root
|
executed = root
|
||||||
}
|
}
|
||||||
|
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||||
|
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
|
||||||
|
var publicationErr *outputPublicationError
|
||||||
|
if stderrors.As(err, &publicationErr) {
|
||||||
|
return apperrors.ExitCode(publicationErr)
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
err = rewordRequiredFlagError(err)
|
err = rewordRequiredFlagError(err)
|
||||||
|
var raw apperrors.RawStderrError
|
||||||
|
if output.UsesUnifiedResult(executed) && !stderrors.As(err, &raw) {
|
||||||
|
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||||
|
code, emitErr := output.EmitResult(executed, result)
|
||||||
|
if emitErr == nil {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||||
|
}
|
||||||
if isUnknownCommandError(err) {
|
if isUnknownCommandError(err) {
|
||||||
executed.SetOut(os.Stderr)
|
executed.SetOut(os.Stderr)
|
||||||
_ = executed.Help()
|
_ = executed.Help()
|
||||||
@@ -129,9 +213,119 @@ func Execute() (exitCode int) {
|
|||||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||||
return apperrors.ExitCode(err)
|
return apperrors.ExitCode(err)
|
||||||
}
|
}
|
||||||
|
if code, emitted := output.StoredExitCode(resultStore); emitted {
|
||||||
|
return code
|
||||||
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errorInfoFromExecutionError projects the repository error model into the unified
|
||||||
|
// failure body. Exit code and category are derived from the same error value,
|
||||||
|
// preventing the wire and process status from drifting apart.
|
||||||
|
func errorInfoFromExecutionError(err error) *output.ErrorInfo {
|
||||||
|
exitCode := apperrors.ExitCode(err)
|
||||||
|
info := &output.ErrorInfo{
|
||||||
|
Type: errorTypeForExitCode(exitCode),
|
||||||
|
ExitCode: exitCode,
|
||||||
|
Message: err.Error(),
|
||||||
|
}
|
||||||
|
var interrupted *processInterruption
|
||||||
|
if stderrors.As(err, &interrupted) && interrupted != nil {
|
||||||
|
info.Type = "internal"
|
||||||
|
info.Subtype = interrupted.Subtype()
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
if stderrors.Is(err, context.DeadlineExceeded) {
|
||||||
|
info.Subtype = "deadline_exceeded"
|
||||||
|
}
|
||||||
|
var cliErr *helpers.CLIError
|
||||||
|
if stderrors.As(err, &cliErr) && cliErr != nil {
|
||||||
|
info.UpstreamCode = cliErr.Code
|
||||||
|
info.Hint = cliErr.Suggestion
|
||||||
|
info.Operation = cliErr.Operation
|
||||||
|
}
|
||||||
|
var callErr *transport.CallError
|
||||||
|
if stderrors.As(err, &callErr) && callErr != nil {
|
||||||
|
info.HTTPStatus = callErr.HTTPStatus
|
||||||
|
info.RPCCode = callErr.RPCCode
|
||||||
|
info.Stage = string(callErr.Stage)
|
||||||
|
if callErr.RequestID != "" {
|
||||||
|
info.RequestID = callErr.RequestID
|
||||||
|
} else if callErr.TraceID != "" {
|
||||||
|
info.RequestID = callErr.TraceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var typed *apperrors.Error
|
||||||
|
if !stderrors.As(err, &typed) || typed == nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
if typed.Category == apperrors.CategoryPartial {
|
||||||
|
// An error lacks the item-level data required by partial_failure.
|
||||||
|
// Callers must use output.Partial; fail closed consistently otherwise.
|
||||||
|
info.Type = string(apperrors.CategoryInternal)
|
||||||
|
} else {
|
||||||
|
info.Type = string(typed.Category)
|
||||||
|
}
|
||||||
|
info.Subtype = typed.Reason
|
||||||
|
if typed.Hint != "" {
|
||||||
|
info.Hint = typed.Hint
|
||||||
|
}
|
||||||
|
info.Actions = append([]string(nil), typed.Actions...)
|
||||||
|
info.Retryable = typed.RetryableSet && typed.Retryable
|
||||||
|
info.RetryAfterSeconds = typed.RetryAfterSeconds
|
||||||
|
if typed.RPCCode != 0 {
|
||||||
|
info.RPCCode = typed.RPCCode
|
||||||
|
}
|
||||||
|
if typed.ServerDiag.TraceID != "" {
|
||||||
|
info.TraceID = typed.ServerDiag.TraceID
|
||||||
|
}
|
||||||
|
info.Operation = typed.Operation
|
||||||
|
info.ServerKey = typed.ServerKey
|
||||||
|
info.Origin = typed.Origin
|
||||||
|
if typed.FailureStage != "" {
|
||||||
|
info.Stage = typed.FailureStage
|
||||||
|
}
|
||||||
|
info.ExecutionStarted = typed.ExecutionStarted
|
||||||
|
if typed.NextRetryAt != nil {
|
||||||
|
info.NextRetryAt = typed.NextRetryAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
info.AvailableFlags = append([]string(nil), typed.AvailableFlags...)
|
||||||
|
info.SnapshotPath = typed.Snapshot
|
||||||
|
info.Details = typed.Details
|
||||||
|
if len(typed.RPCData) > 0 {
|
||||||
|
var rpcData any
|
||||||
|
if json.Unmarshal(typed.RPCData, &rpcData) == nil {
|
||||||
|
info.RPCData = rpcData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.TechnicalDetail = typed.ServerDiag.TechnicalDetail
|
||||||
|
info.FriendlyHint, info.ActionURL = apperrors.ServerGuidance(typed.ServerDiag)
|
||||||
|
if typed.Cause != nil {
|
||||||
|
info.Cause = typed.Cause.Error()
|
||||||
|
}
|
||||||
|
if typed.ServerDiag.ServerErrorCode != "" {
|
||||||
|
info.UpstreamCode = typed.ServerDiag.ServerErrorCode
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorTypeForExitCode(code int) string {
|
||||||
|
switch code {
|
||||||
|
case 1:
|
||||||
|
return "api"
|
||||||
|
case 2:
|
||||||
|
return "auth"
|
||||||
|
case 3:
|
||||||
|
return "validation"
|
||||||
|
case 4:
|
||||||
|
return "permission"
|
||||||
|
case 6:
|
||||||
|
return "discovery"
|
||||||
|
default:
|
||||||
|
return "internal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// newPreParseValidationError keeps pipeline handler identity in internal logs
|
// newPreParseValidationError keeps pipeline handler identity in internal logs
|
||||||
// while exposing only the underlying parameter-domain error to CLI users.
|
// while exposing only the underlying parameter-domain error to CLI users.
|
||||||
func newPreParseValidationError(err error) error {
|
func newPreParseValidationError(err error) error {
|
||||||
@@ -368,6 +562,7 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
|
|||||||
if len(ctx) > 0 && ctx[0] != nil {
|
if len(ctx) > 0 && ctx[0] != nil {
|
||||||
rootCtx = ctx[0]
|
rootCtx = ctx[0]
|
||||||
}
|
}
|
||||||
|
rootCtx, _ = output.WithResultStore(rootCtx)
|
||||||
return newRootCommandWithEngine(rootCtx, nil, true, false)
|
return newRootCommandWithEngine(rootCtx, nil, true, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +585,7 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
|
|||||||
// no pipeline processing is applied.
|
// no pipeline processing is applied.
|
||||||
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
|
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||||
registerSchemaRuntimeDelivery()
|
registerSchemaRuntimeDelivery()
|
||||||
|
rootCtx, _ = output.WithResultStore(rootCtx)
|
||||||
return newRootCommandWithEngine(rootCtx, engine, true, false)
|
return newRootCommandWithEngine(rootCtx, engine, true, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +610,18 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
|||||||
return cmd.Help()
|
return cmd.Help()
|
||||||
},
|
},
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := output.ValidateUnifiedFormat(cmd); err != nil {
|
||||||
|
return apperrors.NewValidation(err.Error(), apperrors.WithReason("unsupported_format"))
|
||||||
|
}
|
||||||
|
// Cobra performs these checks after persistent pre-run hooks. Run
|
||||||
|
// them before opening the transactional sink so validation errors
|
||||||
|
// cannot strand a temporary file during direct ExecuteC calls.
|
||||||
|
if err := cmd.ValidateRequiredFlags(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cmd.ValidateFlagGroups(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// Validate caller-provided identity labels before any edition hook
|
// Validate caller-provided identity labels before any edition hook
|
||||||
// or command network activity can run. Header-only library callers
|
// or command network activity can run. Header-only library callers
|
||||||
// use the best-effort path in resolveIdentityHeaders instead.
|
// use the best-effort path in resolveIdentityHeaders instead.
|
||||||
@@ -439,16 +647,37 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
|||||||
if err := configureOutputSink(cmd); err != nil {
|
if err := configureOutputSink(cmd); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
installOutputSinkErrorCleanup(cmd)
|
||||||
if fn := edition.Get().AfterPersistentPreRun; fn != nil {
|
if fn := edition.Get().AfterPersistentPreRun; fn != nil {
|
||||||
return fn(cmd, args)
|
if err := runWithOutputSinkErrorCleanup(cmd, func() error { return fn(cmd, args) }); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
|
PersistentPostRunE: func(cmd *cobra.Command, args []string) (err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
warnAbortOutputSink(cmd)
|
||||||
|
panic(r)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
warnAbortOutputSink(cmd)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, emitted, emitErr := output.EmitStoredResult(cmd)
|
||||||
StopAllStdioClients()
|
StopAllStdioClients()
|
||||||
CloseAuditSink()
|
CloseAuditSink()
|
||||||
CloseFileLogger()
|
if emitErr != nil {
|
||||||
return closeOutputSink(cmd)
|
return apperrors.NewInternal("emit command result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||||
|
}
|
||||||
|
if output.UsesUnifiedResult(cmd) && !emitted {
|
||||||
|
return apperrors.NewInternal("framework 2.0 command returned without a CommandResult")
|
||||||
|
}
|
||||||
|
if closeErr := closeOutputSink(cmd); closeErr != nil {
|
||||||
|
return closeErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -848,6 +1077,26 @@ func deduplicateCommands(root *cobra.Command) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type outputSinkState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
file *os.File
|
||||||
|
tempPath string
|
||||||
|
target string
|
||||||
|
finished bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type outputPublicationError struct {
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *outputPublicationError) Error() string { return e.cause.Error() }
|
||||||
|
func (e *outputPublicationError) Unwrap() error { return e.cause }
|
||||||
|
func (e *outputPublicationError) ExitCode() int { return 5 }
|
||||||
|
|
||||||
|
func newOutputPublicationError(message string, cause error) error {
|
||||||
|
return &outputPublicationError{cause: fmt.Errorf("%s: %w", message, cause)}
|
||||||
|
}
|
||||||
|
|
||||||
func configureOutputSink(cmd *cobra.Command) error {
|
func configureOutputSink(cmd *cobra.Command) error {
|
||||||
if local := cmd.LocalFlags().Lookup("output"); local != nil {
|
if local := cmd.LocalFlags().Lookup("output"); local != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -866,26 +1115,153 @@ func configureOutputSink(cmd *cobra.Command) error {
|
|||||||
if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
|
||||||
}
|
}
|
||||||
file, err := rootCreateFile(outputPath)
|
tempPattern := "." + filepath.Base(outputPath) + ".tmp-*"
|
||||||
|
file, err := rootCreateTemp(filepath.Dir(outputPath), tempPattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to create temporary output file: %v", err))
|
||||||
}
|
}
|
||||||
cmd.SetOut(file)
|
cmd.SetOut(file)
|
||||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, file))
|
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
|
||||||
|
file: file,
|
||||||
|
tempPath: file.Name(),
|
||||||
|
target: outputPath,
|
||||||
|
}))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func installOutputSinkErrorCleanup(cmd *cobra.Command) {
|
||||||
|
if cmd == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := cmd.Context().Value(outputFileContextKey{}).(*outputSinkState); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cmd.PreRunE != nil {
|
||||||
|
original := cmd.PreRunE
|
||||||
|
cmd.PreRunE = func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runWithOutputSinkErrorCleanup(cmd, func() error { return original(cmd, args) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd.PreRun != nil {
|
||||||
|
original := cmd.PreRun
|
||||||
|
cmd.PreRun = func(cmd *cobra.Command, args []string) {
|
||||||
|
_ = runWithOutputSinkErrorCleanup(cmd, func() error {
|
||||||
|
original(cmd, args)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd.RunE != nil {
|
||||||
|
original := cmd.RunE
|
||||||
|
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runWithOutputSinkErrorCleanup(cmd, func() error { return original(cmd, args) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd.Run != nil {
|
||||||
|
original := cmd.Run
|
||||||
|
cmd.Run = func(cmd *cobra.Command, args []string) {
|
||||||
|
_ = runWithOutputSinkErrorCleanup(cmd, func() error {
|
||||||
|
original(cmd, args)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd.PostRunE != nil {
|
||||||
|
original := cmd.PostRunE
|
||||||
|
cmd.PostRunE = func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runWithOutputSinkErrorCleanup(cmd, func() error { return original(cmd, args) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd.PostRun != nil {
|
||||||
|
original := cmd.PostRun
|
||||||
|
cmd.PostRun = func(cmd *cobra.Command, args []string) {
|
||||||
|
_ = runWithOutputSinkErrorCleanup(cmd, func() error {
|
||||||
|
original(cmd, args)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithOutputSinkErrorCleanup(cmd *cobra.Command, run func() error) (err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
warnAbortOutputSink(cmd)
|
||||||
|
panic(r)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
warnAbortOutputSink(cmd)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func warnAbortOutputSink(cmd *cobra.Command) {
|
||||||
|
if closeErr := abortOutputSink(cmd); closeErr != nil {
|
||||||
|
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: close output sink: %v\n", closeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func closeOutputSink(cmd *cobra.Command) error {
|
func closeOutputSink(cmd *cobra.Command) error {
|
||||||
file, ok := cmd.Context().Value(outputFileContextKey{}).(*os.File)
|
state := outputSinkForCommand(cmd)
|
||||||
if !ok || file == nil {
|
if state == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := rootCloseFile(file); err != nil {
|
state.mu.Lock()
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err))
|
defer state.mu.Unlock()
|
||||||
|
if state.finished {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state.finished = true
|
||||||
|
if err := rootSyncFile(state.file); err != nil {
|
||||||
|
_ = rootCloseFile(state.file)
|
||||||
|
_ = rootRemoveFile(state.tempPath)
|
||||||
|
return newOutputPublicationError("failed to sync output file", err)
|
||||||
|
}
|
||||||
|
if err := rootCloseFile(state.file); err != nil {
|
||||||
|
_ = rootRemoveFile(state.tempPath)
|
||||||
|
return newOutputPublicationError("failed to close output file", err)
|
||||||
|
}
|
||||||
|
if err := rootRenameFile(state.tempPath, state.target); err != nil {
|
||||||
|
_ = rootRemoveFile(state.tempPath)
|
||||||
|
return newOutputPublicationError("failed to publish output file", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func abortOutputSink(cmd *cobra.Command) error {
|
||||||
|
state := outputSinkForCommand(cmd)
|
||||||
|
if state == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
if state.finished {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state.finished = true
|
||||||
|
closeErr := rootCloseFile(state.file)
|
||||||
|
removeErr := rootRemoveFile(state.tempPath)
|
||||||
|
if closeErr != nil {
|
||||||
|
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", closeErr))
|
||||||
|
}
|
||||||
|
if removeErr != nil && !stderrors.Is(removeErr, os.ErrNotExist) {
|
||||||
|
return apperrors.NewInternal(fmt.Sprintf("failed to remove temporary output file: %v", removeErr))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputSinkForCommand(cmd *cobra.Command) *outputSinkState {
|
||||||
|
if cmd == nil || cmd.Context() == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state, _ := cmd.Context().Value(outputFileContextKey{}).(*outputSinkState)
|
||||||
|
if state == nil || state.file == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
func validateOptionalPath(flagName, path string) error {
|
func validateOptionalPath(flagName, path string) error {
|
||||||
path = strings.TrimSpace(path)
|
path = strings.TrimSpace(path)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
|||||||
@@ -157,11 +157,11 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
|
|||||||
})
|
})
|
||||||
|
|
||||||
oldMkdir := rootMkdirAll
|
oldMkdir := rootMkdirAll
|
||||||
oldCreate := rootCreateFile
|
oldCreate := rootCreateTemp
|
||||||
oldClose := rootCloseFile
|
oldClose := rootCloseFile
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
rootMkdirAll = oldMkdir
|
rootMkdirAll = oldMkdir
|
||||||
rootCreateFile = oldCreate
|
rootCreateTemp = oldCreate
|
||||||
rootCloseFile = oldClose
|
rootCloseFile = oldClose
|
||||||
})
|
})
|
||||||
wantErr := errors.New("filesystem")
|
wantErr := errors.New("filesystem")
|
||||||
@@ -193,17 +193,19 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
|
|||||||
t.Fatal("mkdir failure succeeded")
|
t.Fatal("mkdir failure succeeded")
|
||||||
}
|
}
|
||||||
rootMkdirAll = func(string, os.FileMode) error { return nil }
|
rootMkdirAll = func(string, os.FileMode) error { return nil }
|
||||||
rootCreateFile = func(string) (*os.File, error) { return nil, wantErr }
|
rootCreateTemp = func(string, string) (*os.File, error) { return nil, wantErr }
|
||||||
if err := configureOutputSink(newOutputCommand(filepath.Join("create-failure", "out"))); err == nil {
|
if err := configureOutputSink(newOutputCommand(filepath.Join("create-failure", "out"))); err == nil {
|
||||||
t.Fatal("create failure succeeded")
|
t.Fatal("create failure succeeded")
|
||||||
}
|
}
|
||||||
rootCreateFile = oldCreate
|
rootCreateTemp = oldCreate
|
||||||
file, err := os.CreateTemp(t.TempDir(), "close")
|
file, err := os.CreateTemp(t.TempDir(), "close")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cmd := &cobra.Command{Use: "close"}
|
cmd := &cobra.Command{Use: "close"}
|
||||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
|
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
|
||||||
|
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-target"),
|
||||||
|
}))
|
||||||
rootCloseFile = func(*os.File) error { return wantErr }
|
rootCloseFile = func(*os.File) error { return wantErr }
|
||||||
if err := closeOutputSink(cmd); err == nil {
|
if err := closeOutputSink(cmd); err == nil {
|
||||||
t.Fatal("close failure succeeded")
|
t.Fatal("close failure succeeded")
|
||||||
@@ -216,7 +218,9 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
|
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
|
||||||
|
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-success-target"),
|
||||||
|
}))
|
||||||
if err := closeOutputSink(cmd); err != nil {
|
if err := closeOutputSink(cmd); err != nil {
|
||||||
t.Fatalf("close success = %v", err)
|
t.Fatalf("close success = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// 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 app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||||
|
oldNormalize := rootNormalizeProcessProfileArgs
|
||||||
|
oldExecute := rootExecuteCommand
|
||||||
|
oldNewRoot := rootNewRootCommandWithEngine
|
||||||
|
oldPreParse := rootRunPreParse
|
||||||
|
oldStop := rootStopAllStdioClients
|
||||||
|
oldArgs := os.Args
|
||||||
|
t.Cleanup(func() {
|
||||||
|
rootNormalizeProcessProfileArgs = oldNormalize
|
||||||
|
rootExecuteCommand = oldExecute
|
||||||
|
rootNewRootCommandWithEngine = oldNewRoot
|
||||||
|
rootRunPreParse = oldPreParse
|
||||||
|
rootStopAllStdioClients = oldStop
|
||||||
|
os.Args = oldArgs
|
||||||
|
})
|
||||||
|
os.Args = []string{"dws"}
|
||||||
|
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||||
|
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||||
|
rootStopAllStdioClients = func() {}
|
||||||
|
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||||
|
cmd.SetContext(ctx)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
executed := &cobra.Command{Use: "leaf"}
|
||||||
|
output.SetCommandRollout(executed, output.RolloutUnifiedActive)
|
||||||
|
executed.SetOut(&stdout)
|
||||||
|
executed.SetErr(&stderr)
|
||||||
|
rootExecuteCommand = func(root *cobra.Command) (*cobra.Command, error) {
|
||||||
|
executed.SetContext(root.Context())
|
||||||
|
if err := output.StoreResult(executed.Context(), output.Success(map[string]any{"id": "a"})); err != nil {
|
||||||
|
return executed, err
|
||||||
|
}
|
||||||
|
if _, _, err := output.EmitStoredResult(executed); err != nil {
|
||||||
|
return executed, err
|
||||||
|
}
|
||||||
|
return executed, nil
|
||||||
|
}
|
||||||
|
if code := Execute(); code != 0 {
|
||||||
|
t.Fatalf("Execute code=%d, want 0", code)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr=%q, want diagnostics only/empty", stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), `"outcome": "success"`) || strings.Contains(stdout.String(), `"contract_version"`) {
|
||||||
|
t.Fatalf("stdout does not match the unified envelope: %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRootExecutionErrorToStderrOnly 是 B184 的回归断言:失败信封(JSON 错误
|
||||||
|
// 输出)恒走 stderr,stdout 严格为空(契约 §5.1:失败时 stdout 必须为空)。
|
||||||
|
// printExecutionError 把 PrintJSON/PrintHuman 都写 stderr writer,stdout
|
||||||
|
// writer 不得收到任何字节。
|
||||||
|
func TestRootExecutionErrorToStderrOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := &cobra.Command{Use: "dws"}
|
||||||
|
root.PersistentFlags().String("format", "json", "")
|
||||||
|
_ = root.PersistentFlags().Set("format", "json")
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewAuth("token expired")); err != nil {
|
||||||
|
t.Fatalf("printExecutionError() error = %v", err)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), `"error"`) {
|
||||||
|
t.Fatalf("expected JSON error envelope on stderr, got %q", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRootHumanErrorToStderrOnly 是 B184 的人类可读分支断言:非 JSON 模式下,
|
||||||
|
// 失败走 stderr(PrintHuman),stdout 为空。
|
||||||
|
func TestRootHumanErrorToStderrOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := &cobra.Command{Use: "dws"}
|
||||||
|
root.PersistentFlags().String("format", "table", "")
|
||||||
|
_ = root.PersistentFlags().Set("format", "table")
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewInternal("boom")); err != nil {
|
||||||
|
t.Fatalf("printExecutionError() error = %v", err)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "Error:") {
|
||||||
|
t.Fatalf("expected human error on stderr, got %q", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRootExecuteOutcomeToExitCode 是 B185 的 Execute 出口断言:Execute 把
|
||||||
|
// 命令返回的 error 类别映射为进程退出码(apperrors.ExitCode)。ok→0、
|
||||||
|
// confirmation/validation→3、panic 与 unrepresentable partial error→5。
|
||||||
|
func TestRootExecuteOutcomeToExitCode(t *testing.T) {
|
||||||
|
oldNormalize := rootNormalizeProcessProfileArgs
|
||||||
|
oldExecute := rootExecuteCommand
|
||||||
|
oldNewRoot := rootNewRootCommandWithEngine
|
||||||
|
oldPreParse := rootRunPreParse
|
||||||
|
oldStop := rootStopAllStdioClients
|
||||||
|
oldArgs := os.Args
|
||||||
|
t.Cleanup(func() {
|
||||||
|
rootNormalizeProcessProfileArgs = oldNormalize
|
||||||
|
rootExecuteCommand = oldExecute
|
||||||
|
rootNewRootCommandWithEngine = oldNewRoot
|
||||||
|
rootRunPreParse = oldPreParse
|
||||||
|
rootStopAllStdioClients = oldStop
|
||||||
|
os.Args = oldArgs
|
||||||
|
})
|
||||||
|
os.Args = []string{"dws"}
|
||||||
|
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||||
|
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||||
|
rootStopAllStdioClients = func() {}
|
||||||
|
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||||
|
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ok / pending(信封 success/pending 语义)→ 0
|
||||||
|
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, nil }
|
||||||
|
if code := Execute(); code != 0 {
|
||||||
|
t.Fatalf("success Execute code = %d, want 0", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An error cannot carry partial succeeded/failed data and fails closed.
|
||||||
|
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||||
|
return nil, &apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"}
|
||||||
|
}
|
||||||
|
if code := Execute(); code != 5 {
|
||||||
|
t.Fatalf("partial error Execute code = %d, want 5", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmation_required(validation 子类)→ 3
|
||||||
|
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||||
|
return nil, apperrors.NewValidation("blocked", apperrors.WithReason("confirmation_required"))
|
||||||
|
}
|
||||||
|
if code := Execute(); code != 3 {
|
||||||
|
t.Fatalf("confirmation Execute code = %d, want 3", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// plain internal → 5
|
||||||
|
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||||
|
return nil, errors.New("plain")
|
||||||
|
}
|
||||||
|
if code := Execute(); code != 5 {
|
||||||
|
t.Fatalf("plain Execute code = %d, want 5", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRootSilenceErrorsAndDeferTeardown 是 B186 的断言:根命令 SilenceErrors/
|
||||||
|
// SilenceUsage 打开(Cobra 不自行打印),且 Execute 出口 defer 收尾路径
|
||||||
|
// (StopAllStdioClients)在错误路径也被调用。
|
||||||
|
func TestRootSilenceErrorsAndDeferTeardown(t *testing.T) {
|
||||||
|
oldNormalize := rootNormalizeProcessProfileArgs
|
||||||
|
oldExecute := rootExecuteCommand
|
||||||
|
oldNewRoot := rootNewRootCommandWithEngine
|
||||||
|
oldPreParse := rootRunPreParse
|
||||||
|
oldStop := rootStopAllStdioClients
|
||||||
|
oldArgs := os.Args
|
||||||
|
t.Cleanup(func() {
|
||||||
|
rootNormalizeProcessProfileArgs = oldNormalize
|
||||||
|
rootExecuteCommand = oldExecute
|
||||||
|
rootNewRootCommandWithEngine = oldNewRoot
|
||||||
|
rootRunPreParse = oldPreParse
|
||||||
|
rootStopAllStdioClients = oldStop
|
||||||
|
os.Args = oldArgs
|
||||||
|
})
|
||||||
|
os.Args = []string{"dws"}
|
||||||
|
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||||
|
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||||
|
stopped := false
|
||||||
|
rootStopAllStdioClients = func() { stopped = true }
|
||||||
|
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||||
|
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误路径:Execute 返回非零,且 defer 收尾(StopAllStdioClients)被调用。
|
||||||
|
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||||
|
return nil, apperrors.NewInternal("fail")
|
||||||
|
}
|
||||||
|
_ = Execute()
|
||||||
|
if !stopped {
|
||||||
|
t.Fatal("defer teardown (StopAllStdioClients) not called on error path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRootSilenceErrorsFlag 断言根命令的 SilenceErrors/SilenceUsage 为真,
|
||||||
|
// 保证 Cobra 不自行在错误时打印 usage/错误(错误渲染统一走 printExecutionError)。
|
||||||
|
func TestRootSilenceErrorsFlag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := NewRootCommand()
|
||||||
|
if !root.SilenceErrors || !root.SilenceUsage {
|
||||||
|
t.Fatalf("root must set SilenceErrors=%v SilenceUsage=%v", root.SilenceErrors, root.SilenceUsage)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicRootDirectExecuteFailsWhenUnifiedSinkCannotPublish(t *testing.T) {
|
||||||
|
oldClose := rootCloseFile
|
||||||
|
t.Cleanup(func() { rootCloseFile = oldClose })
|
||||||
|
closeCalls := 0
|
||||||
|
rootCloseFile = func(file *os.File) error {
|
||||||
|
closeCalls++
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errors.New("late close diagnostic")
|
||||||
|
}
|
||||||
|
|
||||||
|
root := NewRootCommand(context.Background())
|
||||||
|
leaf := &cobra.Command{
|
||||||
|
Use: "lifecycle-unified",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||||
|
root.AddCommand(leaf)
|
||||||
|
root.SetArgs([]string{"lifecycle-unified", "--output", filepath.Join(t.TempDir(), "result.json")})
|
||||||
|
|
||||||
|
executed, err := root.ExecuteC()
|
||||||
|
if err == nil || apperrors.ExitCode(err) != 5 {
|
||||||
|
t.Fatalf("direct ExecuteC error=%v, want publication failure with exit 5", err)
|
||||||
|
}
|
||||||
|
if executed != leaf {
|
||||||
|
t.Fatalf("executed=%v, want lifecycle leaf", executed)
|
||||||
|
}
|
||||||
|
if closeCalls != 1 {
|
||||||
|
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicRootDirectExecutePreservesLegacyCloseError(t *testing.T) {
|
||||||
|
oldClose := rootCloseFile
|
||||||
|
t.Cleanup(func() { rootCloseFile = oldClose })
|
||||||
|
rootCloseFile = func(file *os.File) error {
|
||||||
|
_ = file.Close()
|
||||||
|
return errors.New("legacy close failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
root := NewRootCommandWithEngine(context.Background(), nil)
|
||||||
|
root.AddCommand(&cobra.Command{Use: "lifecycle-legacy", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||||
|
root.SetArgs([]string{"lifecycle-legacy", "--output", filepath.Join(t.TempDir(), "result.txt")})
|
||||||
|
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "legacy close failed") {
|
||||||
|
t.Fatalf("legacy direct ExecuteC error=%v, want close failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
|
||||||
|
oldClose := rootCloseFile
|
||||||
|
t.Cleanup(func() { rootCloseFile = oldClose })
|
||||||
|
closeCalls := 0
|
||||||
|
rootCloseFile = func(file *os.File) error {
|
||||||
|
closeCalls++
|
||||||
|
return file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
root := NewRootCommand(context.Background())
|
||||||
|
root.AddCommand(&cobra.Command{Use: "lifecycle-error", RunE: func(*cobra.Command, []string) error {
|
||||||
|
return errors.New("handler failed")
|
||||||
|
}})
|
||||||
|
root.SetArgs([]string{"lifecycle-error", "--output", filepath.Join(t.TempDir(), "result.txt")})
|
||||||
|
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "handler failed") {
|
||||||
|
t.Fatalf("direct ExecuteC error=%v, want handler failure", err)
|
||||||
|
}
|
||||||
|
if closeCalls != 1 {
|
||||||
|
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||||
|
oldNormalize := rootNormalizeProcessProfileArgs
|
||||||
|
oldExecute := rootExecuteCommand
|
||||||
|
oldNewRoot := rootNewRootCommandWithEngine
|
||||||
|
oldPreParse := rootRunPreParse
|
||||||
|
oldStop := rootStopAllStdioClients
|
||||||
|
oldArgs := os.Args
|
||||||
|
t.Cleanup(func() {
|
||||||
|
rootNormalizeProcessProfileArgs = oldNormalize
|
||||||
|
rootExecuteCommand = oldExecute
|
||||||
|
rootNewRootCommandWithEngine = oldNewRoot
|
||||||
|
rootRunPreParse = oldPreParse
|
||||||
|
rootStopAllStdioClients = oldStop
|
||||||
|
os.Args = oldArgs
|
||||||
|
})
|
||||||
|
os.Args = []string{"dws"}
|
||||||
|
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||||
|
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||||
|
rootStopAllStdioClients = func() {}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||||
|
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
cmd.SetContext(ctx)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||||
|
result := output.Failure(&output.ErrorInfo{Type: "validation", Message: "bad input"})
|
||||||
|
if err := output.StoreResult(cmd.Context(), result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
panic("after emission")
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := Execute(); code != 3 {
|
||||||
|
t.Fatalf("Execute code=%d, want emitted validation code 3", code)
|
||||||
|
}
|
||||||
|
if got := strings.Count(stdout.String(), `"outcome": "failure"`); got != 1 {
|
||||||
|
t.Fatalf("stdout contains %d envelopes, want one: %s", got, stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "panicked after result emission attempt") {
|
||||||
|
t.Fatalf("panic diagnostic missing: %q", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorInfoProjectionKeepsTraceIDDistinctFromRequestID(t *testing.T) {
|
||||||
|
err := apperrors.NewAPI("failed", apperrors.WithTraceID("trace-1"))
|
||||||
|
info := errorInfoFromExecutionError(err)
|
||||||
|
if info.TraceID != "trace-1" || info.RequestID != "" {
|
||||||
|
t.Fatalf("projection trace_id=%q request_id=%q", info.TraceID, info.RequestID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOutputSinkAtomicallyReplacesTargetWithMode0600(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "result.txt")
|
||||||
|
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tempMode os.FileMode
|
||||||
|
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||||
|
info, err := cmd.OutOrStdout().(*os.File).Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tempMode = info.Mode().Perm()
|
||||||
|
_, err = fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||||
|
if _, err := root.ExecuteC(); err != nil {
|
||||||
|
t.Fatalf("ExecuteC: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertOutputFile(t, target, "replacement", 0o600)
|
||||||
|
if tempMode != 0o600 {
|
||||||
|
t.Fatalf("temporary output mode=%#o, want 0600", tempMode)
|
||||||
|
}
|
||||||
|
assertNoOutputTemps(t, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputSinkHandlerFailurePreservesTarget(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "result.txt")
|
||||||
|
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||||
|
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
|
||||||
|
return errors.New("handler failed")
|
||||||
|
})
|
||||||
|
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||||
|
if _, err := root.ExecuteC(); err == nil {
|
||||||
|
t.Fatal("ExecuteC succeeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertOutputFile(t, target, "original", 0o640)
|
||||||
|
assertNoOutputTemps(t, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputSinkPanicCleansTempAndPreservesTarget(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "result.txt")
|
||||||
|
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||||
|
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
|
||||||
|
panic("boom")
|
||||||
|
})
|
||||||
|
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||||
|
if recovered := executeAndRecover(root); recovered == nil {
|
||||||
|
t.Fatal("ExecuteC did not panic")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertOutputFile(t, target, "original", 0o600)
|
||||||
|
assertNoOutputTemps(t, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputSinkRenameFailurePreservesTarget(t *testing.T) {
|
||||||
|
testseam.Swap(t, &rootRenameFile, func(string, string) error {
|
||||||
|
return errors.New("rename failed")
|
||||||
|
})
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "result.txt")
|
||||||
|
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||||
|
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||||
|
if _, err := root.ExecuteC(); err == nil || err.Error() == "" {
|
||||||
|
t.Fatalf("ExecuteC error=%v, want publication failure", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertOutputFile(t, target, "original", 0o600)
|
||||||
|
assertNoOutputTemps(t, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputSinkSyncAndCloseFailuresPreserveTarget(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
seam func(*testing.T)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "sync",
|
||||||
|
seam: func(t *testing.T) {
|
||||||
|
testseam.Swap(t, &rootSyncFile, func(*os.File) error { return errors.New("sync failed") })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "close",
|
||||||
|
seam: func(t *testing.T) {
|
||||||
|
testseam.Swap(t, &rootCloseFile, func(file *os.File) error {
|
||||||
|
_ = file.Close()
|
||||||
|
return errors.New("close failed")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tt.seam(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "result.txt")
|
||||||
|
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||||
|
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||||
|
if _, err := root.ExecuteC(); err == nil {
|
||||||
|
t.Fatal("ExecuteC succeeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertOutputFile(t, target, "original", 0o600)
|
||||||
|
assertNoOutputTemps(t, target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing.T) {
|
||||||
|
testseam.Swap(t, &rootRenameFile, func(string, string) error {
|
||||||
|
return errors.New("rename failed")
|
||||||
|
})
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "result.json")
|
||||||
|
|
||||||
|
root := NewRootCommand()
|
||||||
|
leaf := &cobra.Command{
|
||||||
|
Use: "atomic-output-unified",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||||
|
root.AddCommand(leaf)
|
||||||
|
root.SetArgs([]string{"atomic-output-unified", "--output", target})
|
||||||
|
if _, err := root.ExecuteC(); err == nil {
|
||||||
|
t.Fatal("unified ExecuteC succeeded without publishing its output")
|
||||||
|
} else if code := apperrors.ExitCode(err); code != 5 {
|
||||||
|
t.Fatalf("publication exit code=%d, want 5: %v", code, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("final output exists after publication failure: %v", err)
|
||||||
|
}
|
||||||
|
assertNoOutputTemps(t, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputSinkEmissionFailurePreservesTarget(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "result.json")
|
||||||
|
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := NewRootCommand()
|
||||||
|
leaf := &cobra.Command{
|
||||||
|
Use: "atomic-emission-failure",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"})); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return cmd.OutOrStdout().(*os.File).Close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||||
|
root.AddCommand(leaf)
|
||||||
|
root.SetArgs([]string{"atomic-emission-failure", "--output", target})
|
||||||
|
if _, err := root.ExecuteC(); err == nil {
|
||||||
|
t.Fatal("ExecuteC succeeded after emission failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertOutputFile(t, target, "original", 0o600)
|
||||||
|
assertNoOutputTemps(t, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputSinkValidationFailureDoesNotCreateTemp(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "result.txt")
|
||||||
|
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := NewRootCommand()
|
||||||
|
leaf := &cobra.Command{Use: "atomic-validation", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||||
|
leaf.Flags().String("required", "", "")
|
||||||
|
_ = leaf.MarkFlagRequired("required")
|
||||||
|
root.AddCommand(leaf)
|
||||||
|
root.SetArgs([]string{"atomic-validation", "--output", target})
|
||||||
|
if _, err := root.ExecuteC(); err == nil {
|
||||||
|
t.Fatal("ExecuteC succeeded without required flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertOutputFile(t, target, "original", 0o600)
|
||||||
|
assertNoOutputTemps(t, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAtomicOutputTestRoot(run func(*cobra.Command) error) *cobra.Command {
|
||||||
|
root := NewRootCommand()
|
||||||
|
root.AddCommand(&cobra.Command{
|
||||||
|
Use: "atomic-output",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return run(cmd)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeAndRecover(cmd *cobra.Command) (recovered any) {
|
||||||
|
defer func() { recovered = recover() }()
|
||||||
|
_, _ = cmd.ExecuteC()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOutputFile(t *testing.T, path, want string, wantMode os.FileMode) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read output: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != want {
|
||||||
|
t.Fatalf("output=%q, want %q", data, want)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat output: %v", err)
|
||||||
|
}
|
||||||
|
if mode := info.Mode().Perm(); mode != wantMode {
|
||||||
|
t.Fatalf("output mode=%#o, want %#o", mode, wantMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoOutputTemps(t *testing.T, target string) {
|
||||||
|
t.Helper()
|
||||||
|
matches, err := filepath.Glob(filepath.Join(filepath.Dir(target), "."+filepath.Base(target)+".tmp-*"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(matches) != 0 {
|
||||||
|
t.Fatalf("temporary output files remain: %v", matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootEscalateSignal = func(sig os.Signal) {
|
||||||
|
signal.Reset(sig)
|
||||||
|
if syscallSig, ok := sig.(syscall.Signal); ok {
|
||||||
|
_ = syscall.Kill(os.Getpid(), syscallSig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type processInterruption struct {
|
||||||
|
signal os.Signal
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *processInterruption) Error() string {
|
||||||
|
return fmt.Sprintf("process interrupted by %s", e.signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *processInterruption) Unwrap() error { return context.Canceled }
|
||||||
|
|
||||||
|
func (e *processInterruption) ExitCode() int {
|
||||||
|
if e.signal == syscall.SIGTERM {
|
||||||
|
return 143
|
||||||
|
}
|
||||||
|
return 130
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *processInterruption) Subtype() string {
|
||||||
|
if e.signal == syscall.SIGTERM {
|
||||||
|
return "terminated"
|
||||||
|
}
|
||||||
|
return "cancelled_by_user"
|
||||||
|
}
|
||||||
|
|
||||||
|
type processSignalState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
interruption *processInterruption
|
||||||
|
primaryCompletedAtSignal bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *processSignalState) record(sig os.Signal, store *output.ResultStore) (first bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.interruption != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, _, s.primaryCompletedAtSignal, _ = output.StoredEmissionState(store)
|
||||||
|
s.interruption = &processInterruption{signal: sig}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *processSignalState) outcome() (*processInterruption, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.interruption, s.primaryCompletedAtSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
func installProcessSignalContext(parent context.Context, store *output.ResultStore) (context.Context, *processSignalState, func()) {
|
||||||
|
signals := make(chan os.Signal, 2)
|
||||||
|
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
|
||||||
|
return manageProcessSignals(parent, store, signals, func() { signal.Stop(signals) }, rootEscalateSignal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func manageProcessSignals(
|
||||||
|
parent context.Context,
|
||||||
|
store *output.ResultStore,
|
||||||
|
signals <-chan os.Signal,
|
||||||
|
stopNotify func(),
|
||||||
|
escalate func(os.Signal),
|
||||||
|
) (context.Context, *processSignalState, func()) {
|
||||||
|
ctx, cancel := context.WithCancelCause(parent)
|
||||||
|
state := &processSignalState{}
|
||||||
|
done := make(chan struct{})
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
var stopOnce sync.Once
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(stopped)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case sig := <-signals:
|
||||||
|
if sig == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if state.record(sig, store) {
|
||||||
|
cancel(state.interruption)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
escalate(sig)
|
||||||
|
return
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
stop := func() {
|
||||||
|
stopOnce.Do(func() {
|
||||||
|
stopNotify()
|
||||||
|
close(done)
|
||||||
|
<-stopped
|
||||||
|
cancel(context.Canceled)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ctx, state, stop
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Writer) {
|
||||||
|
t.Helper()
|
||||||
|
testseam.Protect(t, &os.Args)
|
||||||
|
os.Args = []string{"dws"}
|
||||||
|
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||||
|
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||||
|
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||||
|
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||||
|
if unified {
|
||||||
|
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||||
|
}
|
||||||
|
cmd.SetContext(ctx)
|
||||||
|
cmd.SetOut(stdout)
|
||||||
|
cmd.SetErr(stderr)
|
||||||
|
return cmd
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func signalSelf(t *testing.T, sig syscall.Signal) {
|
||||||
|
t.Helper()
|
||||||
|
if err := syscall.Kill(os.Getpid(), sig); err != nil {
|
||||||
|
t.Fatalf("signal process: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
signal syscall.Signal
|
||||||
|
code int
|
||||||
|
subtype string
|
||||||
|
}{
|
||||||
|
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
|
||||||
|
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
installSignalExecuteSeams(t, true, &stdout, &stderr)
|
||||||
|
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||||
|
signalSelf(t, tc.signal)
|
||||||
|
<-cmd.Context().Done()
|
||||||
|
return cmd, cmd.Context().Err()
|
||||||
|
})
|
||||||
|
|
||||||
|
if code := Execute(); code != tc.code {
|
||||||
|
t.Fatalf("Execute code=%d, want %d", code, tc.code)
|
||||||
|
}
|
||||||
|
var env output.Envelope
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||||
|
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
|
||||||
|
}
|
||||||
|
if env.Error == nil || env.Error.Type != "internal" || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
|
||||||
|
t.Fatalf("error=%+v, want internal/%s exit %d", env.Error, tc.subtype, tc.code)
|
||||||
|
}
|
||||||
|
if bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)) != 1 {
|
||||||
|
t.Fatalf("stdout must contain one failure envelope: %s", stdout.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSignalLegacyExitCodes(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
signal syscall.Signal
|
||||||
|
code int
|
||||||
|
}{{syscall.SIGINT, 130}, {syscall.SIGTERM, 143}} {
|
||||||
|
t.Run(tc.signal.String(), func(t *testing.T) {
|
||||||
|
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
|
||||||
|
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||||
|
signalSelf(t, tc.signal)
|
||||||
|
<-cmd.Context().Done()
|
||||||
|
return cmd, cmd.Context().Err()
|
||||||
|
})
|
||||||
|
if code := Execute(); code != tc.code {
|
||||||
|
t.Fatalf("Execute code=%d, want %d", code, tc.code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteDeadlineIsNotSignalCancellation(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||||
|
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||||
|
return cmd, context.DeadlineExceeded
|
||||||
|
})
|
||||||
|
if code := Execute(); code != 5 {
|
||||||
|
t.Fatalf("Execute code=%d, want internal deadline code 5", code)
|
||||||
|
}
|
||||||
|
var env output.Envelope
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if env.Error == nil || env.Error.Subtype != "deadline_exceeded" || env.Error.ExitCode == 130 || env.Error.ExitCode == 143 {
|
||||||
|
t.Fatalf("deadline error=%+v", env.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignalAfterEmissionAttemptDoesNotEmitAgain(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||||
|
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||||
|
cmd.SetOut(failingWriter{})
|
||||||
|
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _, _ = output.EmitStoredResult(cmd)
|
||||||
|
signalSelf(t, syscall.SIGINT)
|
||||||
|
<-cmd.Context().Done()
|
||||||
|
return cmd, cmd.Context().Err()
|
||||||
|
})
|
||||||
|
if code := Execute(); code != 130 {
|
||||||
|
t.Fatalf("Execute code=%d, want 130", code)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("second envelope emitted: %q", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||||
|
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||||
|
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
signalSelf(t, syscall.SIGINT)
|
||||||
|
<-cmd.Context().Done()
|
||||||
|
return cmd, cmd.Context().Err()
|
||||||
|
})
|
||||||
|
if code := Execute(); code != 0 {
|
||||||
|
t.Fatalf("Execute code=%d, want established success code 0", code)
|
||||||
|
}
|
||||||
|
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
|
||||||
|
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSignalSubprocessExitStatus(t *testing.T) {
|
||||||
|
if os.Getenv("DWS_SIGNAL_HELPER") == "1" {
|
||||||
|
installSignalExecuteSeams(t, true, os.Stdout, os.Stderr)
|
||||||
|
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "READY")
|
||||||
|
<-cmd.Context().Done()
|
||||||
|
return cmd, cmd.Context().Err()
|
||||||
|
})
|
||||||
|
os.Exit(Execute())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
signal syscall.Signal
|
||||||
|
code int
|
||||||
|
subtype string
|
||||||
|
}{
|
||||||
|
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
|
||||||
|
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=^TestExecuteSignalSubprocessExitStatus$")
|
||||||
|
cmd.Env = append(os.Environ(), "DWS_SIGNAL_HELPER=1")
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if scanner := bufio.NewScanner(stderr); !scanner.Scan() || scanner.Text() != "READY" {
|
||||||
|
t.Fatalf("helper readiness failed: %q, err=%v", scanner.Text(), scanner.Err())
|
||||||
|
}
|
||||||
|
if err := cmd.Process.Signal(tc.signal); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
payload, readErr := io.ReadAll(stdout)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatal(readErr)
|
||||||
|
}
|
||||||
|
waitErr := cmd.Wait()
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(waitErr, &exitErr) || exitErr.ExitCode() != tc.code {
|
||||||
|
t.Fatalf("wait error=%v, want exit %d", waitErr, tc.code)
|
||||||
|
}
|
||||||
|
var env output.Envelope
|
||||||
|
if err := json.Unmarshal(payload, &env); err != nil {
|
||||||
|
t.Fatalf("decode helper output: %v; output=%q", err, payload)
|
||||||
|
}
|
||||||
|
if env.Error == nil || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
|
||||||
|
t.Fatalf("helper error=%+v", env.Error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecondSignalUsesEscalationSeam(t *testing.T) {
|
||||||
|
signals := make(chan os.Signal, 2)
|
||||||
|
escalated := make(chan os.Signal, 1)
|
||||||
|
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() {}, func(sig os.Signal) {
|
||||||
|
escalated <- sig
|
||||||
|
})
|
||||||
|
signals <- syscall.SIGINT
|
||||||
|
<-ctx.Done()
|
||||||
|
if !errors.Is(context.Cause(ctx), context.Canceled) {
|
||||||
|
t.Fatalf("cause=%v, want cancellation", context.Cause(ctx))
|
||||||
|
}
|
||||||
|
signals <- syscall.SIGTERM
|
||||||
|
if got := <-escalated; got != syscall.SIGTERM {
|
||||||
|
t.Fatalf("escalated %v, want SIGTERM", got)
|
||||||
|
}
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingWriter struct{}
|
||||||
|
|
||||||
|
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||||
@@ -30,6 +30,7 @@ type ContractFinalPayload struct {
|
|||||||
Parameters []ParamDecl
|
Parameters []ParamDecl
|
||||||
Safety *SafetySpec
|
Safety *SafetySpec
|
||||||
DryRun *DryRunSpec
|
DryRun *DryRunSpec
|
||||||
|
Result *ResultSpec
|
||||||
Interface *InterfaceSpec
|
Interface *InterfaceSpec
|
||||||
Selection *SelectionSpec
|
Selection *SelectionSpec
|
||||||
Identity *ToolIdentitySpec
|
Identity *ToolIdentitySpec
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
|
||||||
|
package contract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeResultSpecCanonicalizesAndCopies(t *testing.T) {
|
||||||
|
in := &ResultSpec{
|
||||||
|
Outcomes: []ResultOutcome{ResultOutcomeFailure, ResultOutcomeSuccess},
|
||||||
|
DataSchema: json.RawMessage(`{ "properties": {"items":{"type":"array"}}, "type":"object" }`),
|
||||||
|
NDJSON: &ResultNDJSONSpec{
|
||||||
|
RecordPath: "items",
|
||||||
|
RecordSchema: json.RawMessage(`{ "type": "object" }`),
|
||||||
|
},
|
||||||
|
Pagination: &ResultPaginationSpec{CursorPath: "nextCursor", ExhaustionPath: "hasMore"},
|
||||||
|
SensitivePaths: []string{"items.secret", "credential"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := NormalizeResultSpec(in, "dev.list")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NormalizeResultSpec() error = %v", err)
|
||||||
|
}
|
||||||
|
if want := []ResultOutcome{ResultOutcomeSuccess, ResultOutcomeFailure}; !reflect.DeepEqual(got.Outcomes, want) {
|
||||||
|
t.Fatalf("outcomes = %#v, want %#v", got.Outcomes, want)
|
||||||
|
}
|
||||||
|
if string(got.DataSchema) != `{"properties":{"items":{"type":"array"}},"type":"object"}` {
|
||||||
|
t.Fatalf("data_schema = %s", got.DataSchema)
|
||||||
|
}
|
||||||
|
if want := []string{"credential", "items.secret"}; !reflect.DeepEqual(got.SensitivePaths, want) {
|
||||||
|
t.Fatalf("sensitive_paths = %#v, want %#v", got.SensitivePaths, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
in.Outcomes[0] = ResultOutcomePending
|
||||||
|
in.DataSchema[0] = '['
|
||||||
|
in.NDJSON.RecordSchema[0] = '['
|
||||||
|
in.SensitivePaths[0] = "changed"
|
||||||
|
if got.Outcomes[0] != ResultOutcomeSuccess || got.DataSchema[0] != '{' || got.NDJSON.RecordSchema[0] != '{' || got.SensitivePaths[0] != "credential" {
|
||||||
|
t.Fatalf("normalized result aliases input: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeResultSpecRejectsInvalidContractsDeterministically(t *testing.T) {
|
||||||
|
valid := func() *ResultSpec {
|
||||||
|
return &ResultSpec{Outcomes: []ResultOutcome{ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object"}`)}
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
edit func(*ResultSpec)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"no outcomes", func(r *ResultSpec) { r.Outcomes = nil }, "no outcomes"},
|
||||||
|
{"unknown outcome", func(r *ResultSpec) { r.Outcomes = []ResultOutcome{"ok"} }, "unknown outcome"},
|
||||||
|
{"duplicate outcome", func(r *ResultSpec) { r.Outcomes = []ResultOutcome{ResultOutcomeSuccess, ResultOutcomeSuccess} }, "duplicate outcome"},
|
||||||
|
{"schema array", func(r *ResultSpec) { r.DataSchema = json.RawMessage(`[]`) }, "data_schema: must be one JSON object"},
|
||||||
|
{"multiple schemas", func(r *ResultSpec) { r.DataSchema = json.RawMessage(`{} {}`) }, "data_schema: must be one JSON object"},
|
||||||
|
{"ndjson pair", func(r *ResultSpec) { r.NDJSON = &ResultNDJSONSpec{RecordPath: "items"} }, "record_path and record_schema together"},
|
||||||
|
{"unsafe path", func(r *ResultSpec) { r.SensitivePaths = []string{"$.token"} }, "unsafe segment"},
|
||||||
|
{"duplicate path", func(r *ResultSpec) { r.SensitivePaths = []string{"token", " token "} }, "duplicate sensitive path"},
|
||||||
|
{"pagination pair", func(r *ResultSpec) { r.Pagination = &ResultPaginationSpec{CursorPath: "cursor"} }, "must declare cursor_path and exhaustion_path together"},
|
||||||
|
{"pagination collision", func(r *ResultSpec) {
|
||||||
|
r.Pagination = &ResultPaginationSpec{CursorPath: "cursor", ExhaustionPath: "cursor"}
|
||||||
|
}, "paths must differ"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
spec := valid()
|
||||||
|
test.edit(spec)
|
||||||
|
_, err := NormalizeResultSpec(spec, "dev.test")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@
|
|||||||
package contract
|
package contract
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -59,6 +62,171 @@ type DryRunSpec struct {
|
|||||||
RemoteReads bool `json:"remote_reads,omitempty"`
|
RemoteReads bool `json:"remote_reads,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResultOutcome is one closed unified-output envelope outcome.
|
||||||
|
type ResultOutcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResultOutcomeSuccess ResultOutcome = "success"
|
||||||
|
ResultOutcomePending ResultOutcome = "pending"
|
||||||
|
ResultOutcomePartialFailure ResultOutcome = "partial_failure"
|
||||||
|
ResultOutcomeFailure ResultOutcome = "failure"
|
||||||
|
)
|
||||||
|
|
||||||
|
var canonicalResultOutcomes = [...]ResultOutcome{
|
||||||
|
ResultOutcomeSuccess,
|
||||||
|
ResultOutcomePending,
|
||||||
|
ResultOutcomePartialFailure,
|
||||||
|
ResultOutcomeFailure,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResultNDJSONSpec identifies the records emitted by NDJSON projections.
|
||||||
|
// RecordPath is relative to the envelope data value.
|
||||||
|
type ResultNDJSONSpec struct {
|
||||||
|
RecordPath string `json:"record_path"`
|
||||||
|
RecordSchema json.RawMessage `json:"record_schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResultPaginationSpec identifies cursor pagination facts in envelope data.
|
||||||
|
// Both paths are relative to data and must be declared together.
|
||||||
|
type ResultPaginationSpec struct {
|
||||||
|
CursorPath string `json:"cursor_path"`
|
||||||
|
ExhaustionPath string `json:"exhaustion_path"`
|
||||||
|
ExhaustedWhen bool `json:"exhausted_when"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResultSpec is the reviewed return-value contract for one command. DataSchema
|
||||||
|
// and RecordSchema are canonical JSON Schema objects; all paths are relative to
|
||||||
|
// the unified-output envelope data value.
|
||||||
|
type ResultSpec struct {
|
||||||
|
Outcomes []ResultOutcome `json:"outcomes"`
|
||||||
|
DataSchema json.RawMessage `json:"data_schema"`
|
||||||
|
NDJSON *ResultNDJSONSpec `json:"ndjson,omitempty"`
|
||||||
|
Pagination *ResultPaginationSpec `json:"pagination,omitempty"`
|
||||||
|
SensitivePaths []string `json:"sensitive_paths,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeResultSpec returns a validated, canonical, defensively copied
|
||||||
|
// result contract. It is shared by declaration, ToolSpec, and snapshot paths.
|
||||||
|
func NormalizeResultSpec(in *ResultSpec, canonical string) (*ResultSpec, error) {
|
||||||
|
if in == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
canonical = defaultString(strings.TrimSpace(canonical), "<unknown>")
|
||||||
|
out := &ResultSpec{}
|
||||||
|
seenOutcomes := make(map[ResultOutcome]bool, len(in.Outcomes))
|
||||||
|
for _, outcome := range in.Outcomes {
|
||||||
|
outcome = ResultOutcome(strings.TrimSpace(string(outcome)))
|
||||||
|
valid := false
|
||||||
|
for _, allowed := range canonicalResultOutcomes {
|
||||||
|
if outcome == allowed {
|
||||||
|
valid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return nil, fmt.Errorf("schema tool %s result has unknown outcome %q", canonical, outcome)
|
||||||
|
}
|
||||||
|
if seenOutcomes[outcome] {
|
||||||
|
return nil, fmt.Errorf("schema tool %s result has duplicate outcome %q", canonical, outcome)
|
||||||
|
}
|
||||||
|
seenOutcomes[outcome] = true
|
||||||
|
}
|
||||||
|
if len(seenOutcomes) == 0 {
|
||||||
|
return nil, fmt.Errorf("schema tool %s result has no outcomes", canonical)
|
||||||
|
}
|
||||||
|
for _, outcome := range canonicalResultOutcomes {
|
||||||
|
if seenOutcomes[outcome] {
|
||||||
|
out.Outcomes = append(out.Outcomes, outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
out.DataSchema, err = canonicalJSONObject(in.DataSchema)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("schema tool %s result data_schema: %w", canonical, err)
|
||||||
|
}
|
||||||
|
if in.NDJSON != nil {
|
||||||
|
path := strings.TrimSpace(in.NDJSON.RecordPath)
|
||||||
|
if path == "" || len(in.NDJSON.RecordSchema) == 0 {
|
||||||
|
return nil, fmt.Errorf("schema tool %s result ndjson must declare record_path and record_schema together", canonical)
|
||||||
|
}
|
||||||
|
if err := validateResultPath(path); err != nil {
|
||||||
|
return nil, fmt.Errorf("schema tool %s result ndjson record_path: %w", canonical, err)
|
||||||
|
}
|
||||||
|
recordSchema, err := canonicalJSONObject(in.NDJSON.RecordSchema)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("schema tool %s result ndjson record_schema: %w", canonical, err)
|
||||||
|
}
|
||||||
|
out.NDJSON = &ResultNDJSONSpec{RecordPath: path, RecordSchema: recordSchema}
|
||||||
|
}
|
||||||
|
if in.Pagination != nil {
|
||||||
|
cursor := strings.TrimSpace(in.Pagination.CursorPath)
|
||||||
|
exhausted := strings.TrimSpace(in.Pagination.ExhaustionPath)
|
||||||
|
if cursor == "" || exhausted == "" {
|
||||||
|
return nil, fmt.Errorf("schema tool %s result pagination must declare cursor_path and exhaustion_path together", canonical)
|
||||||
|
}
|
||||||
|
if cursor == exhausted {
|
||||||
|
return nil, fmt.Errorf("schema tool %s result pagination paths must differ", canonical)
|
||||||
|
}
|
||||||
|
for _, item := range []struct{ name, path string }{{"cursor_path", cursor}, {"exhaustion_path", exhausted}} {
|
||||||
|
if err := validateResultPath(item.path); err != nil {
|
||||||
|
return nil, fmt.Errorf("schema tool %s result pagination %s: %w", canonical, item.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Pagination = &ResultPaginationSpec{CursorPath: cursor, ExhaustionPath: exhausted, ExhaustedWhen: in.Pagination.ExhaustedWhen}
|
||||||
|
}
|
||||||
|
seenPaths := make(map[string]bool, len(in.SensitivePaths))
|
||||||
|
for _, path := range in.SensitivePaths {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if err := validateResultPath(path); err != nil {
|
||||||
|
return nil, fmt.Errorf("schema tool %s result sensitive path: %w", canonical, err)
|
||||||
|
}
|
||||||
|
if seenPaths[path] {
|
||||||
|
return nil, fmt.Errorf("schema tool %s result has duplicate sensitive path %q", canonical, path)
|
||||||
|
}
|
||||||
|
seenPaths[path] = true
|
||||||
|
out.SensitivePaths = append(out.SensitivePaths, path)
|
||||||
|
}
|
||||||
|
sort.Strings(out.SensitivePaths)
|
||||||
|
if len(out.SensitivePaths) == 0 {
|
||||||
|
out.SensitivePaths = nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalJSONObject(raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, fmt.Errorf("must be one JSON object")
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
decoder.UseNumber()
|
||||||
|
var object map[string]json.RawMessage
|
||||||
|
if err := decoder.Decode(&object); err != nil || object == nil {
|
||||||
|
return nil, fmt.Errorf("must be one JSON object")
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||||
|
return nil, fmt.Errorf("must be one JSON object")
|
||||||
|
}
|
||||||
|
canonical, err := json.Marshal(object)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot canonicalize JSON object: %w", err)
|
||||||
|
}
|
||||||
|
return json.RawMessage(canonical), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateResultPath(path string) error {
|
||||||
|
if path == "" || strings.HasPrefix(path, ".") || strings.HasSuffix(path, ".") || strings.Contains(path, "..") {
|
||||||
|
return fmt.Errorf("path %q is not a relative data path", path)
|
||||||
|
}
|
||||||
|
for _, segment := range strings.Split(path, ".") {
|
||||||
|
for i, r := range segment {
|
||||||
|
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_' || (i > 0 && (r == '-' || (r >= '0' && r <= '9')))) {
|
||||||
|
return fmt.Errorf("path %q contains unsafe segment %q", path, segment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DryRunPreviewInvocation = "invocation"
|
DryRunPreviewInvocation = "invocation"
|
||||||
DryRunPreviewRequest = "request"
|
DryRunPreviewRequest = "request"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type ContractDecl struct {
|
|||||||
Positionals []contract.RuntimeSchemaPositional
|
Positionals []contract.RuntimeSchemaPositional
|
||||||
Parameters []contract.ParamDecl
|
Parameters []contract.ParamDecl
|
||||||
DryRun *contract.DryRunSpec
|
DryRun *contract.DryRunSpec
|
||||||
|
Result *contract.ResultSpec
|
||||||
Interface *contract.InterfaceSpec
|
Interface *contract.InterfaceSpec
|
||||||
Selection contract.SelectionSpec
|
Selection contract.SelectionSpec
|
||||||
Identity contract.ToolIdentitySpec
|
Identity contract.ToolIdentitySpec
|
||||||
@@ -144,6 +145,9 @@ func (s ContractDecl) empty() bool {
|
|||||||
if s.DryRun != nil && strings.TrimSpace(s.DryRun.PreviewKind) != "" {
|
if s.DryRun != nil && strings.TrimSpace(s.DryRun.PreviewKind) != "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if s.Result != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if s.Interface != nil {
|
if s.Interface != nil {
|
||||||
iface := s.Interface
|
iface := s.Interface
|
||||||
if strings.TrimSpace(iface.Mode) != "" || strings.TrimSpace(iface.Availability) != "" ||
|
if strings.TrimSpace(iface.Mode) != "" || strings.TrimSpace(iface.Availability) != "" ||
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import (
|
|||||||
func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
|
func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
|
||||||
cmd := &cobra.Command{Use: "x"}
|
cmd := &cobra.Command{Use: "x"}
|
||||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) })
|
t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) })
|
||||||
|
result := &contract.ResultSpec{
|
||||||
|
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||||
|
DataSchema: []byte(`{"type":"object"}`),
|
||||||
|
SensitivePaths: []string{"token"},
|
||||||
|
}
|
||||||
|
|
||||||
RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
||||||
Title: "T",
|
Title: "T",
|
||||||
@@ -32,7 +37,11 @@ func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Selection: &contract.SelectionSpec{AgentSummary: "sum", UseWhen: []string{"u"}},
|
Selection: &contract.SelectionSpec{AgentSummary: "sum", UseWhen: []string{"u"}},
|
||||||
Identity: &contract.ToolIdentitySpec{ProductID: "p", Name: "n"},
|
Identity: &contract.ToolIdentitySpec{ProductID: "p", Name: "n"},
|
||||||
|
Result: result,
|
||||||
})
|
})
|
||||||
|
result.Outcomes[0] = contract.ResultOutcomeFailure
|
||||||
|
result.DataSchema[0] = '['
|
||||||
|
result.SensitivePaths[0] = "changed"
|
||||||
if cmd.Annotations != nil {
|
if cmd.Annotations != nil {
|
||||||
if _, ok := cmd.Annotations["dws.schema.final"]; ok {
|
if _, ok := cmd.Annotations["dws.schema.final"]; ok {
|
||||||
t.Fatal("must not write JSON annotation dws.schema.final")
|
t.Fatal("must not write JSON annotation dws.schema.final")
|
||||||
@@ -45,6 +54,14 @@ func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
|
|||||||
if got.Selection == nil || got.Selection.Reviewed != nil {
|
if got.Selection == nil || got.Selection.Reviewed != nil {
|
||||||
t.Fatalf("selection must not carry reviewed fields: %#v", got.Selection)
|
t.Fatalf("selection must not carry reviewed fields: %#v", got.Selection)
|
||||||
}
|
}
|
||||||
|
if got.Result == nil || got.Result.Outcomes[0] != contract.ResultOutcomeSuccess || got.Result.DataSchema[0] != '{' || got.Result.SensitivePaths[0] != "token" {
|
||||||
|
t.Fatalf("stored result aliases registration input: %#v", got.Result)
|
||||||
|
}
|
||||||
|
got.Result.Outcomes[0] = contract.ResultOutcomeFailure
|
||||||
|
again, _ := RuntimeContractFinal(cmd)
|
||||||
|
if again.Result.Outcomes[0] != contract.ResultOutcomeSuccess {
|
||||||
|
t.Fatal("RuntimeContractFinal result aliases stored payload")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageContractFinalNilCommandGuards(t *testing.T) {
|
func TestCrossPlatformCoverageContractFinalNilCommandGuards(t *testing.T) {
|
||||||
@@ -137,3 +154,37 @@ func TestCrossPlatformCoverageRuntimeContractFinalRejectsForeignStoredValue(t *t
|
|||||||
t.Fatal("typed nil payload must not decode as contract.ContractFinalPayload")
|
t.Fatal("typed nil payload must not decode as contract.ContractFinalPayload")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveRuntimeSafetyUsesCanonicalOrCLIIdentityAndRejectsUnavailable(t *testing.T) {
|
||||||
|
read := &cobra.Command{Use: "read"}
|
||||||
|
t.Cleanup(func() { ClearRuntimeContractFinalForTest(read) })
|
||||||
|
RegisterRuntimeContractFinal(read, contract.ContractFinalPayload{
|
||||||
|
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.read", PrimaryCLIPath: "sample get"},
|
||||||
|
Safety: &contract.SafetySpec{Effect: " read ", Idempotency: " idempotent "},
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, lookup := range []struct {
|
||||||
|
canonical string
|
||||||
|
cli string
|
||||||
|
}{
|
||||||
|
{canonical: "sample.read"},
|
||||||
|
{canonical: "different.rpc", cli: "dws sample get"},
|
||||||
|
} {
|
||||||
|
safety, declared, ok := ResolveRuntimeSafety(lookup.canonical, lookup.cli)
|
||||||
|
if !declared || !ok || safety.Effect != "read" || safety.Idempotency != "idempotent" {
|
||||||
|
t.Fatalf("ResolveRuntimeSafety(%q, %q) = %#v, %v, %v", lookup.canonical, lookup.cli, safety, declared, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
missingSafety := &cobra.Command{Use: "write"}
|
||||||
|
t.Cleanup(func() { ClearRuntimeContractFinalForTest(missingSafety) })
|
||||||
|
RegisterRuntimeContractFinal(missingSafety, contract.ContractFinalPayload{
|
||||||
|
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.write"},
|
||||||
|
})
|
||||||
|
if _, declared, ok := ResolveRuntimeSafety("sample.write", ""); !declared || ok {
|
||||||
|
t.Fatalf("missing safety = declared %v ok %v, want true false", declared, ok)
|
||||||
|
}
|
||||||
|
if _, declared, ok := ResolveRuntimeSafety("legacy.call", "legacy call"); declared || ok {
|
||||||
|
t.Fatalf("legacy lookup = declared %v ok %v, want false false", declared, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
package contractfinal
|
package contractfinal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -34,7 +35,7 @@ func RegisterRuntimeContractFinal(cmd *cobra.Command, payload contract.ContractF
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
runtimeannotate.AnnotateRuntimeContract(cmd)
|
runtimeannotate.AnnotateRuntimeContract(cmd)
|
||||||
p := payload
|
p := cloneContractFinalPayload(payload)
|
||||||
contractFinalByCommand.Store(cmd, &p)
|
contractFinalByCommand.Store(cmd, &p)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +52,89 @@ func RuntimeContractFinal(cmd *cobra.Command) (contract.ContractFinalPayload, bo
|
|||||||
if !ok || p == nil {
|
if !ok || p == nil {
|
||||||
return contract.ContractFinalPayload{}, false
|
return contract.ContractFinalPayload{}, false
|
||||||
}
|
}
|
||||||
return *p, true
|
return cloneContractFinalPayload(*p), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneContractFinalPayload(in contract.ContractFinalPayload) contract.ContractFinalPayload {
|
||||||
|
out := in
|
||||||
|
out.Positionals = cloneSlice(in.Positionals)
|
||||||
|
out.Parameters = cloneSlice(in.Parameters)
|
||||||
|
for i := range out.Parameters {
|
||||||
|
out.Parameters[i].Enum = cloneSlice(in.Parameters[i].Enum)
|
||||||
|
if in.Parameters[i].Required != nil {
|
||||||
|
required := *in.Parameters[i].Required
|
||||||
|
out.Parameters[i].Required = &required
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Safety != nil {
|
||||||
|
value := *in.Safety
|
||||||
|
out.Safety = &value
|
||||||
|
}
|
||||||
|
if in.DryRun != nil {
|
||||||
|
value := *in.DryRun
|
||||||
|
out.DryRun = &value
|
||||||
|
}
|
||||||
|
if in.Result != nil {
|
||||||
|
value := *in.Result
|
||||||
|
value.Outcomes = cloneSlice(in.Result.Outcomes)
|
||||||
|
value.DataSchema = cloneSlice(in.Result.DataSchema)
|
||||||
|
value.SensitivePaths = cloneSlice(in.Result.SensitivePaths)
|
||||||
|
if in.Result.NDJSON != nil {
|
||||||
|
ndjson := *in.Result.NDJSON
|
||||||
|
ndjson.RecordSchema = cloneSlice(in.Result.NDJSON.RecordSchema)
|
||||||
|
value.NDJSON = &ndjson
|
||||||
|
}
|
||||||
|
if in.Result.Pagination != nil {
|
||||||
|
pagination := *in.Result.Pagination
|
||||||
|
value.Pagination = &pagination
|
||||||
|
}
|
||||||
|
out.Result = &value
|
||||||
|
}
|
||||||
|
if in.Interface != nil {
|
||||||
|
value := *in.Interface
|
||||||
|
if in.Interface.Ref != nil {
|
||||||
|
ref := *in.Interface.Ref
|
||||||
|
value.Ref = &ref
|
||||||
|
}
|
||||||
|
out.Interface = &value
|
||||||
|
}
|
||||||
|
if in.Selection != nil {
|
||||||
|
value := *in.Selection
|
||||||
|
value.UseWhen = cloneSlice(in.Selection.UseWhen)
|
||||||
|
value.AvoidWhen = cloneSlice(in.Selection.AvoidWhen)
|
||||||
|
value.Prerequisites = cloneSlice(in.Selection.Prerequisites)
|
||||||
|
value.Tips = cloneSlice(in.Selection.Tips)
|
||||||
|
value.WorkflowRefs = cloneSlice(in.Selection.WorkflowRefs)
|
||||||
|
value.Examples = cloneSlice(in.Selection.Examples)
|
||||||
|
value.SourceRefs = cloneSlice(in.Selection.SourceRefs)
|
||||||
|
value.ExampleDispositions = cloneSlice(in.Selection.ExampleDispositions)
|
||||||
|
for i := range value.ExampleDispositions {
|
||||||
|
if in.Selection.ExampleDispositions[i].Index != nil {
|
||||||
|
index := *in.Selection.ExampleDispositions[i].Index
|
||||||
|
value.ExampleDispositions[i].Index = &index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Selection.Reviewed != nil {
|
||||||
|
reviewed := *in.Selection.Reviewed
|
||||||
|
value.Reviewed = &reviewed
|
||||||
|
}
|
||||||
|
out.Selection = &value
|
||||||
|
}
|
||||||
|
if in.Identity != nil {
|
||||||
|
value := *in.Identity
|
||||||
|
value.Aliases = cloneSlice(in.Identity.Aliases)
|
||||||
|
out.Identity = &value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSlice[T any](in []T) []T {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]T, len(in))
|
||||||
|
copy(out, in)
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasRuntimeContractFinal reports whether the leaf has a registered final overlay.
|
// HasRuntimeContractFinal reports whether the leaf has a registered final overlay.
|
||||||
@@ -62,3 +145,67 @@ func HasRuntimeContractFinal(cmd *cobra.Command) bool {
|
|||||||
_, ok := contractFinalByCommand.Load(cmd)
|
_, ok := contractFinalByCommand.Load(cmd)
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveRuntimeSafety finds the live ContractFinal safety declaration for an
|
||||||
|
// invocation identity. declared distinguishes a matched declaration whose
|
||||||
|
// safety is unavailable or conflicting from a legacy invocation with no unified
|
||||||
|
// declaration context. Repeated equivalent command-tree registrations are
|
||||||
|
// accepted; conflicting matches fail closed with ok=false.
|
||||||
|
func ResolveRuntimeSafety(canonicalPath, cliPath string) (safety contract.SafetySpec, declared, ok bool) {
|
||||||
|
canonicalPath = strings.TrimSpace(canonicalPath)
|
||||||
|
cliPath = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cliPath), "dws "))
|
||||||
|
|
||||||
|
var resolved contract.SafetySpec
|
||||||
|
contractFinalByCommand.Range(func(_, raw any) bool {
|
||||||
|
payload, valid := raw.(*contract.ContractFinalPayload)
|
||||||
|
if !valid || payload == nil || payload.Identity == nil ||
|
||||||
|
!runtimeIdentityMatches(*payload.Identity, canonicalPath, cliPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
declared = true
|
||||||
|
if payload.Safety == nil {
|
||||||
|
ok = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
candidate := normalizedRuntimeSafety(*payload.Safety)
|
||||||
|
if !ok {
|
||||||
|
resolved = candidate
|
||||||
|
ok = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if resolved != candidate {
|
||||||
|
ok = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return resolved, declared, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeIdentityMatches(identity contract.ToolIdentitySpec, canonicalPath, cliPath string) bool {
|
||||||
|
if canonicalPath != "" {
|
||||||
|
for _, value := range []string{identity.CanonicalPath, identity.Path} {
|
||||||
|
if strings.TrimSpace(value) == canonicalPath {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cliPath == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, value := range []string{identity.PrimaryCLIPath, identity.CLIPath} {
|
||||||
|
if strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(value), "dws ")) == cliPath {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedRuntimeSafety(safety contract.SafetySpec) contract.SafetySpec {
|
||||||
|
safety.Effect = strings.TrimSpace(safety.Effect)
|
||||||
|
safety.EffectSource = strings.TrimSpace(safety.EffectSource)
|
||||||
|
safety.Risk = strings.TrimSpace(safety.Risk)
|
||||||
|
safety.Confirmation = strings.TrimSpace(safety.Confirmation)
|
||||||
|
safety.Idempotency = strings.TrimSpace(safety.Idempotency)
|
||||||
|
return safety
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ import (
|
|||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -224,11 +225,12 @@ const (
|
|||||||
// construction time. corecmd stays dispatch-agnostic and never calls a backend:
|
// construction time. corecmd stays dispatch-agnostic and never calls a backend:
|
||||||
// the adapters (FromLeafSpec / FromShortcut) supply the body.
|
// the adapters (FromLeafSpec / FromShortcut) supply the body.
|
||||||
type Spec struct {
|
type Spec struct {
|
||||||
Use string
|
Use string
|
||||||
Short string
|
Short string
|
||||||
Long string
|
Long string
|
||||||
Example string
|
Example string
|
||||||
Hidden bool
|
Hidden bool
|
||||||
|
OutputRollout output.RolloutState
|
||||||
|
|
||||||
Flags []FlagSpec
|
Flags []FlagSpec
|
||||||
Constraints []Constraint
|
Constraints []Constraint
|
||||||
@@ -266,6 +268,8 @@ type Spec struct {
|
|||||||
RunE func(cmd *cobra.Command, args []string) error
|
RunE func(cmd *cobra.Command, args []string) error
|
||||||
// Invoke executes a single-step command with the assembled toolArgs.
|
// Invoke executes a single-step command with the assembled toolArgs.
|
||||||
Invoke func(c *Ctx, toolArgs map[string]any) error
|
Invoke func(c *Ctx, toolArgs map[string]any) error
|
||||||
|
// ResultInvoke executes once and returns an immutable framework 2.0 result.
|
||||||
|
ResultInvoke func(c *Ctx, toolArgs map[string]any) (output.CommandResult, error)
|
||||||
// Orchestrate executes a multi-step command; it assembles whatever payloads
|
// Orchestrate executes a multi-step command; it assembles whatever payloads
|
||||||
// it needs from the Ctx.
|
// it needs from the Ctx.
|
||||||
Orchestrate func(c *Ctx) error
|
Orchestrate func(c *Ctx) error
|
||||||
@@ -385,6 +389,9 @@ func New(spec Spec) *cobra.Command {
|
|||||||
if spec.PostMount != nil {
|
if spec.PostMount != nil {
|
||||||
spec.PostMount(cmd)
|
spec.PostMount(cmd)
|
||||||
}
|
}
|
||||||
|
if spec.OutputRollout != "" {
|
||||||
|
output.SetCommandRollout(cmd, spec.OutputRollout)
|
||||||
|
}
|
||||||
if spec.ConfirmFirst {
|
if spec.ConfirmFirst {
|
||||||
if cmd.Annotations == nil {
|
if cmd.Annotations == nil {
|
||||||
cmd.Annotations = map[string]string{}
|
cmd.Annotations = map[string]string{}
|
||||||
@@ -430,6 +437,16 @@ func New(spec Spec) *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if spec.ResultInvoke != nil {
|
||||||
|
result, err := spec.ResultInvoke(ctx, toolArgs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !output.UsesUnifiedResult(cmd) {
|
||||||
|
return fmt.Errorf("command %q uses ResultInvoke without an active Framework 2.0 rollout", cmd.CommandPath())
|
||||||
|
}
|
||||||
|
return output.StoreResult(cmd.Context(), result)
|
||||||
|
}
|
||||||
return spec.Invoke(ctx, toolArgs)
|
return spec.Invoke(ctx, toolArgs)
|
||||||
}
|
}
|
||||||
return cmd
|
return cmd
|
||||||
@@ -486,12 +503,15 @@ func validateDispatchDecl(spec Spec) {
|
|||||||
if spec.Invoke != nil {
|
if spec.Invoke != nil {
|
||||||
declared++
|
declared++
|
||||||
}
|
}
|
||||||
|
if spec.ResultInvoke != nil {
|
||||||
|
declared++
|
||||||
|
}
|
||||||
if spec.Orchestrate != nil {
|
if spec.Orchestrate != nil {
|
||||||
declared++
|
declared++
|
||||||
}
|
}
|
||||||
if declared != 1 {
|
if declared != 1 {
|
||||||
panic(fmt.Sprintf(
|
panic(fmt.Sprintf(
|
||||||
"command %q must declare exactly one of RunE/Invoke/Orchestrate, got %d",
|
"command %q must declare exactly one of RunE/Invoke/Orchestrate, got %d (ResultInvoke is also a dispatcher)",
|
||||||
spec.Use, declared))
|
spec.Use, declared))
|
||||||
}
|
}
|
||||||
// ConfirmFirst only changes the ordering of a declared confirmation gate.
|
// ConfirmFirst only changes the ordering of a declared confirmation gate.
|
||||||
@@ -669,7 +689,7 @@ func ValidateRequired(cmd *cobra.Command, flags []FlagSpec) error {
|
|||||||
if hint == "" {
|
if hint == "" {
|
||||||
hint = fmt.Sprintf("flag --%s is required", flag.Name)
|
hint = fmt.Sprintf("flag --%s is required", flag.Name)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s", hint)
|
return apperrors.NewValidation(hint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1348,6 +1368,13 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
|
|||||||
d.PreviewKind = strings.TrimSpace(d.PreviewKind)
|
d.PreviewKind = strings.TrimSpace(d.PreviewKind)
|
||||||
payload.DryRun = &d
|
payload.DryRun = &d
|
||||||
}
|
}
|
||||||
|
if decl.Result != nil {
|
||||||
|
result, err := contract.NormalizeResultSpec(decl.Result, decl.Identity.CanonicalPath)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("command %q has invalid Contract.Result: %v", cmd.Name(), err))
|
||||||
|
}
|
||||||
|
payload.Result = result
|
||||||
|
}
|
||||||
if decl.Interface != nil {
|
if decl.Interface != nil {
|
||||||
iface := &contract.InterfaceSpec{
|
iface := &contract.InterfaceSpec{
|
||||||
Mode: strings.TrimSpace(decl.Interface.Mode),
|
Mode: strings.TrimSpace(decl.Interface.Mode),
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package corecmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResultInvokeCarriesOneFrameworkResult(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
ctx, store := output.WithResultStore(context.Background())
|
||||||
|
cmd := New(Spec{
|
||||||
|
Use: "result",
|
||||||
|
OutputRollout: output.RolloutUnifiedActive,
|
||||||
|
Safety: contract.SafetySpec{
|
||||||
|
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||||
|
},
|
||||||
|
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
|
||||||
|
calls++
|
||||||
|
return output.Success(map[string]any{"id": "a"}), nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
cmd.SetContext(ctx)
|
||||||
|
cmd.PersistentFlags().String("format", "json", "")
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||||
|
_, _, err := output.EmitStoredResult(executed)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("calls=%d, want 1", calls)
|
||||||
|
}
|
||||||
|
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
|
||||||
|
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), `"outcome": "success"`) || strings.Contains(stdout.String(), `"contract_version"`) {
|
||||||
|
t.Fatalf("stdout=%s", stdout.String())
|
||||||
|
}
|
||||||
|
if output.CommandRollout(cmd) != output.RolloutUnifiedActive {
|
||||||
|
t.Fatalf("rollout=%s", output.CommandRollout(cmd))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,16 @@ import (
|
|||||||
|
|
||||||
var marshalErrorJSON = jsonutil.MarshalIndent
|
var marshalErrorJSON = jsonutil.MarshalIndent
|
||||||
|
|
||||||
|
// outcomeFailure 是错误通道的结果标记(契约规范 §2.5 outcome 四值枚举的
|
||||||
|
// failure 值,B169)。字面量与 internal/output OutcomeFailure 同源同值;
|
||||||
|
// 不跨包引用是因为 output 已导入 errors,反向引用会形成导入环。
|
||||||
|
const outcomeFailure = "failure"
|
||||||
|
|
||||||
|
// subtypeConfirmationRequired 是门禁拦截的 failure 子类标记(契约规范 §2.4,
|
||||||
|
// B170)。字面量与 internal/output subtypeConfirmationRequired 及 runtime
|
||||||
|
// WithReason("confirmation_required") 形态三方同源。
|
||||||
|
const subtypeConfirmationRequired = "confirmation_required"
|
||||||
|
|
||||||
// Category represents a stable error class with a documented exit code.
|
// Category represents a stable error class with a documented exit code.
|
||||||
type Category string
|
type Category string
|
||||||
|
|
||||||
@@ -38,6 +48,41 @@ const (
|
|||||||
CategoryValidation Category = "validation"
|
CategoryValidation Category = "validation"
|
||||||
CategoryDiscovery Category = "discovery"
|
CategoryDiscovery Category = "discovery"
|
||||||
CategoryInternal Category = "internal"
|
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)
|
||||||
|
//
|
||||||
|
// 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.
|
// Error is the structured repository-local error model for the Go rewrite.
|
||||||
@@ -82,6 +127,13 @@ type Option func(*Error)
|
|||||||
// ExitCodePermission and the exit-code table in docs/reference.md);
|
// ExitCodePermission and the exit-code table in docs/reference.md);
|
||||||
// Discovery therefore uses 6 so hosts can tell "catalog lookup broke"
|
// Discovery therefore uses 6 so hosts can tell "catalog lookup broke"
|
||||||
// apart from "PAT permission insufficient".
|
// 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 {
|
func (e *Error) ExitCode() int {
|
||||||
switch e.Category {
|
switch e.Category {
|
||||||
case CategoryAPI:
|
case CategoryAPI:
|
||||||
@@ -92,6 +144,10 @@ func (e *Error) ExitCode() int {
|
|||||||
return 3
|
return 3
|
||||||
case CategoryDiscovery:
|
case CategoryDiscovery:
|
||||||
return 6
|
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:
|
default:
|
||||||
return 5
|
return 5
|
||||||
}
|
}
|
||||||
@@ -148,6 +204,11 @@ func WithRetryable(retryable bool) Option {
|
|||||||
// WithRetryAfterSeconds records the server-recommended delay before a retry.
|
// WithRetryAfterSeconds records the server-recommended delay before a retry.
|
||||||
// A zero delay is meaningful and is therefore preserved; negative values are
|
// A zero delay is meaningful and is therefore preserved; negative values are
|
||||||
// ignored as invalid server guidance.
|
// ignored as invalid server guidance.
|
||||||
|
//
|
||||||
|
// 本通道只存原值、不钳制(B195/B199,AC-24):服务端给多少存多少,wire 上
|
||||||
|
// retry_after_seconds 原样透传。transport 侧的 RetryMaxDelay 钳制只作用于
|
||||||
|
// 重试延迟选择(retryDelayForAttempt),不得回写或截断本字段(B196 草案:
|
||||||
|
// 钳制上限可配置化后仍须保持「钳制延迟、不钳制透传」双通道分离)。
|
||||||
func WithRetryAfterSeconds(seconds int64) Option {
|
func WithRetryAfterSeconds(seconds int64) Option {
|
||||||
return func(err *Error) {
|
return func(err *Error) {
|
||||||
if seconds < 0 {
|
if seconds < 0 {
|
||||||
@@ -322,17 +383,27 @@ func ExitCode(err error) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PrintJSON writes a machine-readable JSON error object.
|
// PrintJSON writes a machine-readable JSON error object.
|
||||||
|
// wire 键与 internal/output Envelope.Error(契约规范 §2.4)对称:
|
||||||
|
// `category` 为 legacy 兼容键(auth_refresh_retry_runner_test 等既有消费方
|
||||||
|
// 依赖,B173 保留),`type` 为与 output 侧 ErrorInfo.Type 对齐的规范键
|
||||||
|
// (B173,category→type 映射:值同源 = category(err));两者值恒等。
|
||||||
|
// `outcome` 恒为 "failure"(错误信封恒是失败结果,B169);`subtype` 在
|
||||||
|
// Reason 已知时投影(B170,confirmation_required/rate_limit 等)。
|
||||||
func PrintJSON(w io.Writer, err error) error {
|
func PrintJSON(w io.Writer, err error) error {
|
||||||
errorPayload := map[string]any{
|
errorPayload := map[string]any{
|
||||||
"code": ExitCode(err),
|
"code": ExitCode(err),
|
||||||
"category": category(err),
|
"category": category(err),
|
||||||
|
"type": category(err),
|
||||||
"message": err.Error(),
|
"message": err.Error(),
|
||||||
}
|
}
|
||||||
|
|
||||||
var typed *Error
|
var typed *Error
|
||||||
if stderrors.As(err, &typed) {
|
if stderrors.As(err, &typed) {
|
||||||
if typed.Reason != "" {
|
if typed.Reason != "" {
|
||||||
errorPayload["reason"] = typed.Reason
|
errorPayload["reason"] = typed.Reason
|
||||||
|
// Reason→subtype 投影(B170,契约 §2.4):reason 值即 subtype
|
||||||
|
// 规范值(confirmation_required/rate_limit 等),与 output 侧
|
||||||
|
// ErrorInfo.Subtype 对称。仅当 Reason 非空时投影(omitempty)。
|
||||||
|
errorPayload["subtype"] = typed.Reason
|
||||||
}
|
}
|
||||||
if typed.Operation != "" {
|
if typed.Operation != "" {
|
||||||
errorPayload["operation"] = typed.Operation
|
errorPayload["operation"] = typed.Operation
|
||||||
@@ -404,7 +475,7 @@ func PrintJSON(w io.Writer, err error) error {
|
|||||||
errorPayload["cause"] = typed.Cause.Error()
|
errorPayload["cause"] = typed.Cause.Error()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
payload := map[string]any{"error": errorPayload}
|
payload := map[string]any{"outcome": outcomeFailure, "error": errorPayload}
|
||||||
|
|
||||||
data, marshalErr := marshalErrorJSON(payload, "", " ")
|
data, marshalErr := marshalErrorJSON(payload, "", " ")
|
||||||
if marshalErr != nil {
|
if marshalErr != nil {
|
||||||
@@ -555,9 +626,18 @@ func serverGuidance(diag ServerDiagnostics) (string, string) {
|
|||||||
return friendlyHint, actionURL
|
return friendlyHint, actionURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServerGuidance exposes the same recovery projection to repository-local
|
||||||
|
// adapters so legacy JSON and Framework 2.0 errors stay semantically aligned.
|
||||||
|
func ServerGuidance(diag ServerDiagnostics) (string, string) {
|
||||||
|
return serverGuidance(diag)
|
||||||
|
}
|
||||||
|
|
||||||
func category(err error) string {
|
func category(err error) string {
|
||||||
var typed *Error
|
var typed *Error
|
||||||
if stderrors.As(err, &typed) {
|
if stderrors.As(err, &typed) {
|
||||||
|
if typed.Category == CategoryPartial {
|
||||||
|
return string(CategoryInternal)
|
||||||
|
}
|
||||||
return string(typed.Category)
|
return string(typed.Category)
|
||||||
}
|
}
|
||||||
return string(CategoryInternal)
|
return string(CategoryInternal)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// 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 "testing"
|
||||||
|
|
||||||
|
// TestErrorsExitCodeMapConsistentWithExitCode 是 B209 的 errors 侧同源锁定:
|
||||||
|
// exitcodes.go 的 exitCodeByCategory 映射表必须与 ExitCode() 的 switch 分支
|
||||||
|
// 逐类别一致(同源不双轨,契约 §4)。任一单边修改即失败,防止未来漂移。
|
||||||
|
// output 侧同源锁定由 internal/output emitter_phase_c_test.go
|
||||||
|
// TestExitCodeForEnvelopeSameSourceAsErrorsExitCode 交叉断言(B209)。
|
||||||
|
func TestErrorsExitCodeMapConsistentWithExitCode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cats := []Category{
|
||||||
|
CategoryAPI,
|
||||||
|
CategoryAuth,
|
||||||
|
CategoryValidation,
|
||||||
|
CategoryDiscovery,
|
||||||
|
CategoryInternal,
|
||||||
|
CategoryPartial,
|
||||||
|
}
|
||||||
|
for _, cat := range cats {
|
||||||
|
table := exitCodeByCategory[cat]
|
||||||
|
viaSwitch := (&Error{Category: cat, Message: "x"}).ExitCode()
|
||||||
|
if table != viaSwitch {
|
||||||
|
t.Fatalf("exitCodeByCategory[%q]=%d disagrees with ExitCode()=%d", cat, table, viaSwitch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorsExitCodeConstantsEqualMap 锁定类别专属常量与映射表值一致。
|
||||||
|
func TestErrorsExitCodeConstantsEqualMap(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
want := map[Category]int{
|
||||||
|
CategoryAPI: ExitCodeAPI,
|
||||||
|
CategoryAuth: ExitCodeAuth,
|
||||||
|
CategoryValidation: ExitCodeValidation,
|
||||||
|
CategoryDiscovery: ExitCodeDiscovery,
|
||||||
|
CategoryInternal: ExitCodeInternal,
|
||||||
|
CategoryPartial: ExitCodeInternal,
|
||||||
|
}
|
||||||
|
for cat, wantCode := range want {
|
||||||
|
if got := exitCodeByCategory[cat]; got != wantCode {
|
||||||
|
t.Fatalf("exitCodeByCategory[%q]=%d, want %d", cat, got, wantCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
// 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 (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestErrorsPrintJSONFieldInventory 是 B168 的 PrintJSON 字段清单核对测试:
|
||||||
|
// 与 internal/output Envelope.Error / ErrorInfo 的字段逐项对齐(契约规范
|
||||||
|
// §2.4)。wire-stable 组(type/subtype/code/retryable/retry_after_seconds)
|
||||||
|
// 与 informational 组(message/hint/request_id)均由单一 PrintJSON 路径产出,
|
||||||
|
// 字段名与 output 侧 ErrorInfo json tag 一致。
|
||||||
|
func TestErrorsPrintJSONFieldInventory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, NewAPI(
|
||||||
|
"too many requests",
|
||||||
|
WithReason("rate_limit"),
|
||||||
|
WithHint("wait and retry"),
|
||||||
|
WithRetryable(true),
|
||||||
|
WithRetryAfterSeconds(30),
|
||||||
|
)); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
got := b.String()
|
||||||
|
|
||||||
|
// wire-stable 组(B173 category→type:type 键存在且值 = category)
|
||||||
|
for _, want := range []string{
|
||||||
|
`"type": "api"`,
|
||||||
|
`"subtype": "rate_limit"`,
|
||||||
|
`"code": 1`,
|
||||||
|
`"retryable": true`,
|
||||||
|
`"retry_after_seconds": 30`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("missing wire-stable field %s in %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// informational 组
|
||||||
|
for _, want := range []string{
|
||||||
|
`"message": "too many requests"`,
|
||||||
|
`"hint": "wait and retry"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("missing informational field %s in %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorsPrintJSONOutcomeFailure 是 B169 的 outcome 字段断言:错误信封
|
||||||
|
// 顶层恒携带 outcome=failure(契约 §1/§2.5),与 internal/output 侧
|
||||||
|
// OutcomeFailure 同值。非错误信封触发路径(未走 PrintJSON 的错误)不要求。
|
||||||
|
func TestErrorsPrintJSONOutcomeFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, NewAuth("token expired")); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(b.String()), &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload["outcome"] != "failure" {
|
||||||
|
t.Fatalf("top-level outcome=%v, want failure", payload["outcome"])
|
||||||
|
}
|
||||||
|
errorPayload := payload["error"].(map[string]any)
|
||||||
|
if _, nested := errorPayload["outcome"]; nested {
|
||||||
|
t.Fatalf("outcome must be a sibling of error: %s", b.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorsPrintJSONSubtypeProjection 是 B170 的 subtype 投影断言:Reason
|
||||||
|
// 非空时投影为 error.subtype(confirmation_required/rate_limit 等),与
|
||||||
|
// output 侧 ErrorInfo.Subtype 对称;Reason 为空时 subtype 缺席(omitempty)。
|
||||||
|
func TestErrorsPrintJSONSubtypeProjection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("confirmation_required", func(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, NewValidation(
|
||||||
|
"confirmation required",
|
||||||
|
WithReason("confirmation_required"),
|
||||||
|
)); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
got := b.String()
|
||||||
|
if !strings.Contains(got, `"subtype": "confirmation_required"`) {
|
||||||
|
t.Fatalf("expected confirmation_required subtype, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `"type": "validation"`) {
|
||||||
|
t.Fatalf("expected validation type, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no reason omits subtype", func(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, NewAPI("plain")); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(b.String(), `"subtype"`) {
|
||||||
|
t.Fatalf("subtype must be omitted when Reason is empty, got %q", b.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorsConfirmationSharesValidationExitCode 是 B171 的契约断言:
|
||||||
|
// confirmation_required 是 validation 的子类,共享 rc=3(AC-13,规划 v1.2
|
||||||
|
// OQ-1 定案),靠 error.subtype 区分,而非独立退出码。
|
||||||
|
func TestErrorsConfirmationSharesValidationExitCode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
confirmation := NewValidation("blocked", WithReason("confirmation_required"))
|
||||||
|
validation := NewValidation("bad param")
|
||||||
|
|
||||||
|
if got := ExitCode(confirmation); got != 3 {
|
||||||
|
t.Fatalf("confirmation ExitCode = %d, want 3 (shared with validation)", got)
|
||||||
|
}
|
||||||
|
if got := ExitCode(validation); got != 3 {
|
||||||
|
t.Fatalf("validation ExitCode = %d, want 3", got)
|
||||||
|
}
|
||||||
|
// subtype 区分二者(B170 wire 投影)
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, confirmation); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(b.String(), `"subtype": "confirmation_required"`) {
|
||||||
|
t.Fatalf("confirmation must carry subtype, got %q", b.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorsPartialCategoryFailsClosedAsInternal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := &Error{Category: CategoryPartial, Message: "partial"}
|
||||||
|
if got := ExitCode(err); got != ExitCodeInternal {
|
||||||
|
t.Fatalf("ExitCode(partial error) = %d, want internal %d", got, ExitCodeInternal)
|
||||||
|
}
|
||||||
|
if ExitCodePartial != 7 {
|
||||||
|
t.Fatalf("ExitCodePartial = %d, want 7", ExitCodePartial)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, err); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(b.String(), `"code": 5`) || !strings.Contains(b.String(), `"type": "internal"`) {
|
||||||
|
t.Fatalf("partial error must not masquerade as a partial result: %q", b.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorsCategoryMapsToType 是 B173 的 category→error.type 映射断言:
|
||||||
|
// 每个 Category 在 PrintJSON 的 wire 上同时产出 category(legacy)与 type
|
||||||
|
// (规范)两键,且两者值恒等(与 output 侧 ErrorInfo.Type 对齐)。
|
||||||
|
func TestErrorsCategoryMapsToType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"api", NewAPI("x"), "api"},
|
||||||
|
{"auth", NewAuth("x"), "auth"},
|
||||||
|
{"validation", NewValidation("x"), "validation"},
|
||||||
|
{"discovery", NewDiscovery("x"), "discovery"},
|
||||||
|
{"internal", NewInternal("x"), "internal"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, tc.err); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
got := b.String()
|
||||||
|
if !strings.Contains(got, `"type": "`+tc.want+`"`) {
|
||||||
|
t.Fatalf("expected type %q in %s", tc.want, got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `"category": "`+tc.want+`"`) {
|
||||||
|
t.Fatalf("expected legacy category %q in %s", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorsWireStableFieldsSubset 是 B174 的断言:PrintJSON 产出的可编程
|
||||||
|
// 字段键全部落在 wire.go WireStableErrorBodyFields 声明的 wire-stable 集合内
|
||||||
|
// (除 legacy 兼容键 category/reason/operation 等 informational 附属键)。
|
||||||
|
// 本测试核对 wire-stable 组字段(type/subtype/code/retryable/
|
||||||
|
// retry_after_seconds/message/hint/actions/trace_id/rpc_code/rpc_data)均被
|
||||||
|
// PrintJSON 产出(在对应 Option 提供时)。
|
||||||
|
func TestErrorsWireStableFieldsSubset(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, NewAPI(
|
||||||
|
"rpc failed",
|
||||||
|
WithReason("rate_limit"),
|
||||||
|
WithHint("h"),
|
||||||
|
WithRetryable(true),
|
||||||
|
WithRetryAfterSeconds(5),
|
||||||
|
WithActions("retry"),
|
||||||
|
WithServerDiag(ServerDiagnostics{TraceID: "t-1"}),
|
||||||
|
WithRPCCode(-32602),
|
||||||
|
WithRPCData([]byte(`{"field":"x"}`)),
|
||||||
|
)); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
got := b.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`"type"`, `"subtype"`, `"code"`, `"retryable"`, `"retry_after_seconds"`,
|
||||||
|
`"message"`, `"hint"`, `"actions"`, `"trace_id"`, `"rpc_code"`, `"rpc_data"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("wire-stable field %s missing from %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorsRetryableOmitEmpty 是 B175 的断言:retryable 仅 true 时出现在 wire
|
||||||
|
// (与 output 侧 ErrorInfo.Retryable omitempty 一致);未知三态(RetryableSet
|
||||||
|
// 未置)时 retryable 缺席。
|
||||||
|
func TestErrorsRetryableOmitEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("true present", func(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, NewAPI("x", WithRetryable(true))); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(b.String(), `"retryable": true`) {
|
||||||
|
t.Fatalf("expected retryable:true, got %q", b.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("unset omitted", func(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, NewAPI("x")); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(b.String(), `"retryable"`) {
|
||||||
|
t.Fatalf("unknown retryability must be omitted, got %q", b.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorsCategorySnapshots 是 B176/B177 的类别错误信封快照测试:api/auth/
|
||||||
|
// validation(B176)与 discovery/internal/plain(B177)各自产出 stable 的
|
||||||
|
// type/code 组合,plain 错误归 internal(rc=5)。
|
||||||
|
func TestErrorsCategorySnapshots(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
typ string
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"api", NewAPI("x"), "api", `"code": 1`},
|
||||||
|
{"auth", NewAuth("x"), "auth", `"code": 2`},
|
||||||
|
{"validation", NewValidation("x"), "validation", `"code": 3`},
|
||||||
|
{"discovery", NewDiscovery("x"), "discovery", `"code": 6`},
|
||||||
|
{"internal", NewInternal("x"), "internal", `"code": 5`},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, tc.err); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
got := b.String()
|
||||||
|
if !strings.Contains(got, `"type": "`+tc.typ+`"`) || !strings.Contains(got, tc.code) {
|
||||||
|
t.Fatalf("snapshot mismatch for %s: %s", tc.name, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorsActionsArrayPassthrough 是 B178 的 actions 数组透传断言:Actions
|
||||||
|
// (含 --yes 版本补救命令)原样进 wire,空串条目被过滤。
|
||||||
|
func TestErrorsActionsArrayPassthrough(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, NewValidation(
|
||||||
|
"confirm required",
|
||||||
|
WithReason("confirmation_required"),
|
||||||
|
WithActions("dws chat send --yes", "", "dws chat cancel"),
|
||||||
|
)); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
got := b.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`"actions"`,
|
||||||
|
`"dws chat send --yes"`,
|
||||||
|
`"dws chat cancel"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("expected %s in actions, got %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 空串条目被过滤:不应出现空引号动作。
|
||||||
|
if strings.Contains(got, `""`) {
|
||||||
|
t.Fatalf("empty action must be filtered, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorsTraceRPCAndServerDiagPassthrough 是 B179 的透传保留断言:
|
||||||
|
// trace_id/rpc_code/rpc_data 原样保留在 wire(informational,不进分支字段),
|
||||||
|
// 与 output 侧 ErrorInfo 的 ServerDiag/RPC 字段对齐。
|
||||||
|
func TestErrorsTraceRPCAndServerDiagPassthrough(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, NewAPI(
|
||||||
|
"rpc failed",
|
||||||
|
WithServerDiag(ServerDiagnostics{TraceID: "trace-abc"}),
|
||||||
|
WithRPCCode(-32602),
|
||||||
|
WithRPCData([]byte(`{"field":"base_id"}`)),
|
||||||
|
)); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
got := b.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`"trace_id": "trace-abc"`,
|
||||||
|
`"rpc_code": -32602`,
|
||||||
|
`"field"`,
|
||||||
|
`"base_id"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("expected %s in %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorsOutcomeOmitEmptyNonEnvelope 是 B180 的 outcome 语义断言:错误信封
|
||||||
|
// 路径恒携带 outcome=failure;非信封触发路径(纯 error 值,未走 PrintJSON)
|
||||||
|
// 不产生 outcome 字段——PrintJSON 只在错误信封场景补 outcome,不影响普通
|
||||||
|
// 错误文本通道。
|
||||||
|
func TestErrorsOutcomeOmitEmptyNonEnvelope(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// PrintJSON 恒为失败信封:outcome 恒在(B169)。
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, NewInternal("x")); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(b.String(), `"outcome": "failure"`) {
|
||||||
|
t.Fatalf("failure envelope must carry outcome=failure, got %q", b.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithRetryAfterSecondsPassthrough 是 B195 的透传断言:WithRetryAfterSeconds
|
||||||
|
// 把服务端给出的秒数原样存入 RetryAfterSeconds,不做任何钳制(钳制只作用于
|
||||||
|
// transport 重试延迟选择,B196 双通道分离)。
|
||||||
|
func TestWithRetryAfterSecondsPassthrough(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := NewAPI("limit", WithRetryAfterSeconds(900)).(*Error)
|
||||||
|
if err.RetryAfterSeconds == nil || *err.RetryAfterSeconds != 900 {
|
||||||
|
t.Fatalf("RetryAfterSeconds = %v, want 900 (unclamped)", err.RetryAfterSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetryAfterZeroValuePreserved 是 B198 的零值语义断言:0 秒是有意义的
|
||||||
|
// 服务端建议(立即重试),必须保留;负值被视为非法服务端指引而被拒绝
|
||||||
|
// (WithRetryAfterSeconds 忽略负值)。
|
||||||
|
func TestRetryAfterZeroValuePreserved(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
zero := NewAPI("x", WithRetryAfterSeconds(0)).(*Error)
|
||||||
|
if zero.RetryAfterSeconds == nil || *zero.RetryAfterSeconds != 0 {
|
||||||
|
t.Fatalf("zero RetryAfterSeconds must be preserved, got %v", zero.RetryAfterSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
negative := NewAPI("x", WithRetryAfterSeconds(-1)).(*Error)
|
||||||
|
if negative.RetryAfterSeconds != nil {
|
||||||
|
t.Fatalf("negative RetryAfterSeconds must be rejected, got %v", *negative.RetryAfterSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetryAfterSecondsWirePassthrough 是 B199 的 wire 透传断言:retry_after_seconds
|
||||||
|
// 在 PrintJSON 错误 JSON 中原样出现,值未被 transport 钳制改写(0 秒也透传)。
|
||||||
|
func TestRetryAfterSecondsWirePassthrough(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("nonzero", func(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, NewAPI("x", WithRetryAfterSeconds(60))); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(b.String(), `"retry_after_seconds": 60`) {
|
||||||
|
t.Fatalf("expected retry_after_seconds:60 in wire, got %q", b.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("zero preserved", func(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, NewAPI("x", WithRetryAfterSeconds(0))); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(b.String(), `"retry_after_seconds": 0`) {
|
||||||
|
t.Fatalf("expected retry_after_seconds:0 preserved in wire, got %q", b.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("unset omitted", func(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, NewAPI("x")); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(b.String(), `"retry_after_seconds"`) {
|
||||||
|
t.Fatalf("retry_after_seconds must be omitted when unset, got %q", b.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetryAfterSecondsAndNextRetryAtConsistency 是 B200 的一致性断言:
|
||||||
|
// RetryAfterSeconds 与 NextRetryAt 两字段同源(都描述"何时可重试")且可共存
|
||||||
|
// 不互斥;Promise 使用 UTC 归一化(NextRetryAt 转 UTC)。
|
||||||
|
func TestRetryAfterSecondsAndNextRetryAtConsistency(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tz := time.FixedZone("CST", 8*60*60)
|
||||||
|
next := time.Date(2026, time.August, 7, 22, 0, 0, 0, tz)
|
||||||
|
err := NewAPI("x", WithRetryAfterSeconds(30), WithNextRetryAt(next)).(*Error)
|
||||||
|
if err.RetryAfterSeconds == nil || *err.RetryAfterSeconds != 30 {
|
||||||
|
t.Fatalf("RetryAfterSeconds = %v, want 30", err.RetryAfterSeconds)
|
||||||
|
}
|
||||||
|
if err.NextRetryAt == nil {
|
||||||
|
t.Fatal("NextRetryAt must be set")
|
||||||
|
}
|
||||||
|
// 两字段同源并存(B200),NextRetryAt 归一化为 UTC。
|
||||||
|
if got := err.NextRetryAt.UTC().Format(time.RFC3339); got != "2026-08-07T14:00:00Z" {
|
||||||
|
t.Fatalf("NextRetryAt UTC = %s, want 2026-08-07T14:00:00Z", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wire 上两字段同现且互不覆盖。
|
||||||
|
var b strings.Builder
|
||||||
|
if err := PrintJSON(&b, err); err != nil {
|
||||||
|
t.Fatalf("PrintJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
got := b.String()
|
||||||
|
if !strings.Contains(got, `"retry_after_seconds": 30`) {
|
||||||
|
t.Fatalf("missing retry_after_seconds:30 in %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `"next_retry_at": "2026-08-07T14:00:00Z"`) {
|
||||||
|
t.Fatalf("missing next_retry_at in %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
// 统一退出码表(B171/B172;轮 10 裁决⑬,权威 = 规划 v1.2 OQ-1 定案):
|
||||||
|
// 类别与退出码一一对应,`confirmation_required` 是 `validation` 下的子类
|
||||||
|
// 共享 3,不新增独立退出码。
|
||||||
|
//
|
||||||
|
// 跨包同源锁定:internal/output.ExitCodeForEnvelope 对同一信封必须给出
|
||||||
|
// 本表完全一致的码(api=1/auth=2/validation=3/discovery=6/internal=5)。
|
||||||
|
// Typed output.Partial remains the only path to partial_failure exit 7.
|
||||||
|
// 修改本表 = 契约变更,必须双侧同步并更新两侧同源测试。
|
||||||
|
var exitCodeByCategory = map[Category]int{
|
||||||
|
CategoryAPI: ExitCodeAPI,
|
||||||
|
CategoryAuth: ExitCodeAuth,
|
||||||
|
CategoryValidation: ExitCodeValidation,
|
||||||
|
CategoryDiscovery: ExitCodeDiscovery,
|
||||||
|
CategoryInternal: ExitCodeInternal,
|
||||||
|
CategoryPartial: ExitCodeInternal,
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
// 错误信封 wire-stable 字段集(契约规范 §2.4;B174)。
|
||||||
|
//
|
||||||
|
// Agent 可编程分流的字段集合:type/subtype/code/retryable/
|
||||||
|
// retry_after_seconds/message/hint/actions/trace_id/rpc_code/rpc_data/
|
||||||
|
// outcome。wireErrors 的每个字段都必须落在此集合内;新增字段 = 契约
|
||||||
|
// 扩展,需评审。wireErrors 未声明 JSON tag 的字段(如 Cause)是内部
|
||||||
|
// 字段,序列化缺席,不属于 wire。
|
||||||
|
|
||||||
|
// WireStableFields 是错误信封 wire-stable 字段名全集(含 outcome)。
|
||||||
|
var WireStableFields = []string{
|
||||||
|
"type",
|
||||||
|
"subtype",
|
||||||
|
"code",
|
||||||
|
"retryable",
|
||||||
|
"retry_after_seconds",
|
||||||
|
"message",
|
||||||
|
"hint",
|
||||||
|
"actions",
|
||||||
|
"trace_id",
|
||||||
|
"rpc_code",
|
||||||
|
"rpc_data",
|
||||||
|
"outcome",
|
||||||
|
}
|
||||||
|
|
||||||
|
// WireStableErrorBodyFields 是 error 对象体(不含顶层 outcome)的
|
||||||
|
// wire-stable 字段名子集。
|
||||||
|
var WireStableErrorBodyFields = []string{
|
||||||
|
"type",
|
||||||
|
"subtype",
|
||||||
|
"code",
|
||||||
|
"retryable",
|
||||||
|
"retry_after_seconds",
|
||||||
|
"message",
|
||||||
|
"hint",
|
||||||
|
"actions",
|
||||||
|
"trace_id",
|
||||||
|
"rpc_code",
|
||||||
|
"rpc_data",
|
||||||
|
}
|
||||||
+43
-24
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,10 +134,11 @@ type LeafContract = corecmd.ContractDecl
|
|||||||
//
|
//
|
||||||
// 执行面(不算声明):Validate、Call、RunE、PostMount;Server/Tool 仅路由。
|
// 执行面(不算声明):Validate、Call、RunE、PostMount;Server/Tool 仅路由。
|
||||||
type LeafSpec struct {
|
type LeafSpec struct {
|
||||||
Use string
|
Use string
|
||||||
Short string
|
Short string
|
||||||
Long string
|
Long string
|
||||||
Example string
|
Example string
|
||||||
|
OutputRollout output.RolloutState
|
||||||
|
|
||||||
// Server 非空时走 callMCPToolOnServer(显式 server 路由),否则走
|
// Server 非空时走 callMCPToolOnServer(显式 server 路由),否则走
|
||||||
// callMCPTool(按 product 路由)。Call 非空时两者都被忽略。不是 CLI 声明。
|
// callMCPTool(按 product 路由)。Call 非空时两者都被忽略。不是 CLI 声明。
|
||||||
@@ -167,7 +169,8 @@ type LeafSpec struct {
|
|||||||
|
|
||||||
// Call 是执行体:非空时替代默认 MCP 派发。toolArgs 已由 Flags/ConstParams
|
// Call 是执行体:非空时替代默认 MCP 派发。toolArgs 已由 Flags/ConstParams
|
||||||
// 装配完成;Call 不应再写业务参数。分页等横切由领域工具处理,不进声明。
|
// 装配完成;Call 不应再写业务参数。分页等横切由领域工具处理,不进声明。
|
||||||
Call func(cmd *cobra.Command, tool string, args map[string]any) error
|
Call func(cmd *cobra.Command, tool string, args map[string]any) error
|
||||||
|
ResultCall func(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error)
|
||||||
|
|
||||||
// Validate 是编排钩子(条件式校验),不是声明面。单 flag 转换用
|
// Validate 是编排钩子(条件式校验),不是声明面。单 flag 转换用
|
||||||
// LeafFlag.Transform;可声明的互斥/至少一个应写 Constraints。
|
// LeafFlag.Transform;可声明的互斥/至少一个应写 Constraints。
|
||||||
@@ -225,6 +228,9 @@ func DeclareLeafMetadata(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
|||||||
if spec.Call != nil {
|
if spec.Call != nil {
|
||||||
panic(fmt.Sprintf("DeclareLeafMetadata(%q): Call must be nil (metadata-only mode)", name))
|
panic(fmt.Sprintf("DeclareLeafMetadata(%q): Call must be nil (metadata-only mode)", name))
|
||||||
}
|
}
|
||||||
|
if spec.ResultCall != nil {
|
||||||
|
panic(fmt.Sprintf("DeclareLeafMetadata(%q): ResultCall must be nil (metadata-only mode)", name))
|
||||||
|
}
|
||||||
if spec.RunE != nil {
|
if spec.RunE != nil {
|
||||||
panic(fmt.Sprintf("DeclareLeafMetadata(%q): RunE must be nil (metadata-only mode)", name))
|
panic(fmt.Sprintf("DeclareLeafMetadata(%q): RunE must be nil (metadata-only mode)", name))
|
||||||
}
|
}
|
||||||
@@ -241,6 +247,9 @@ func DeclareLeafMetadata(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
|||||||
panic(fmt.Sprintf("DeclareLeafMetadata(%q): Contract is required", name))
|
panic(fmt.Sprintf("DeclareLeafMetadata(%q): Contract is required", name))
|
||||||
}
|
}
|
||||||
corecmd.AttachContract(cmd, spec.Safety, spec.Contract, cmd.Short, cmd.Long)
|
corecmd.AttachContract(cmd, spec.Safety, spec.Contract, cmd.Short, cmd.Long)
|
||||||
|
if spec.OutputRollout != "" {
|
||||||
|
output.SetCommandRollout(cmd, spec.OutputRollout)
|
||||||
|
}
|
||||||
|
|
||||||
confirm := strings.TrimSpace(spec.Safety.Confirmation) == "user_required"
|
confirm := strings.TrimSpace(spec.Safety.Confirmation) == "user_required"
|
||||||
if spec.Validate == nil && !confirm {
|
if spec.Validate == nil && !confirm {
|
||||||
@@ -390,30 +399,40 @@ func HasContractConfirmDeferred(cmd *cobra.Command) bool {
|
|||||||
// dispatch 收敛为一个闭包:Call 优先,其次显式 Server 路由,最后按 product
|
// dispatch 收敛为一个闭包:Call 优先,其次显式 Server 路由,最后按 product
|
||||||
// 自动路由。RunE 逃生舱存在时不设 Dispatch(与旧行为一致)。
|
// 自动路由。RunE 逃生舱存在时不设 Dispatch(与旧行为一致)。
|
||||||
func FromLeafSpec(spec LeafSpec) corecmd.Spec {
|
func FromLeafSpec(spec LeafSpec) corecmd.Spec {
|
||||||
|
if spec.Call != nil && spec.ResultCall != nil {
|
||||||
|
panic(fmt.Sprintf("command %q must not declare both Call and ResultCall", spec.Use))
|
||||||
|
}
|
||||||
cs := corecmd.Spec{
|
cs := corecmd.Spec{
|
||||||
Use: spec.Use,
|
Use: spec.Use,
|
||||||
Short: spec.Short,
|
Short: spec.Short,
|
||||||
Long: spec.Long,
|
Long: spec.Long,
|
||||||
Example: spec.Example,
|
Example: spec.Example,
|
||||||
Flags: spec.Flags,
|
OutputRollout: spec.OutputRollout,
|
||||||
Constraints: spec.Constraints,
|
Flags: spec.Flags,
|
||||||
Safety: spec.Safety,
|
Constraints: spec.Constraints,
|
||||||
ConfirmFirst: spec.ConfirmFirst,
|
Safety: spec.Safety,
|
||||||
ConstParams: spec.ConstParams,
|
ConfirmFirst: spec.ConfirmFirst,
|
||||||
Contract: spec.Contract,
|
ConstParams: spec.ConstParams,
|
||||||
Validate: spec.Validate,
|
Contract: spec.Contract,
|
||||||
PostMount: spec.PostMount,
|
Validate: spec.Validate,
|
||||||
RunE: spec.RunE,
|
PostMount: spec.PostMount,
|
||||||
|
RunE: spec.RunE,
|
||||||
}
|
}
|
||||||
if spec.RunE == nil {
|
if spec.RunE == nil {
|
||||||
cs.Invoke = func(c *corecmd.Ctx, toolArgs map[string]any) error {
|
if spec.ResultCall != nil {
|
||||||
if spec.Call != nil {
|
cs.ResultInvoke = func(c *corecmd.Ctx, toolArgs map[string]any) (output.CommandResult, error) {
|
||||||
return spec.Call(c.Command(), spec.Tool, toolArgs)
|
return spec.ResultCall(c.Command(), spec.Tool, toolArgs)
|
||||||
}
|
}
|
||||||
if spec.Server != "" {
|
} else {
|
||||||
return callMCPToolOnServer(spec.Server, spec.Tool, toolArgs)
|
cs.Invoke = func(c *corecmd.Ctx, toolArgs map[string]any) error {
|
||||||
|
if spec.Call != nil {
|
||||||
|
return spec.Call(c.Command(), spec.Tool, toolArgs)
|
||||||
|
}
|
||||||
|
if spec.Server != "" {
|
||||||
|
return callMCPToolOnServer(spec.Server, spec.Tool, toolArgs)
|
||||||
|
}
|
||||||
|
return callMCPTool(spec.Tool, toolArgs)
|
||||||
}
|
}
|
||||||
return callMCPTool(spec.Tool, toolArgs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cs
|
return cs
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
// 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 output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteEnvelope 是统一信封的出口函数(B0 dev 试点桥接,WS1 改动点2/4)。
|
||||||
|
// 它复用 envelope.go 的权威 Envelope 类型,把信封按当前 --format 分发写出。
|
||||||
|
// Phase B 的 Emitter 类型将在此之上泛化(渲染先进 buffer、exit code 映射等);
|
||||||
|
// 本函数仅提供 Phase F dev 域迁移所需的「信封 → cmd 流」出口,不重复定义类型。
|
||||||
|
//
|
||||||
|
// format 分发(契约规范 §5.2,经 renderEnvelope 实现):
|
||||||
|
// - json(默认):输出完整信封——唯一 JSON 契约(§5.2 首行)。
|
||||||
|
// - table/pretty:业务数据人读视图 + meta 摘要(不输出信封外壳)。
|
||||||
|
// - csv/ndjson:裸记录(无信封);meta.pagination 走 stderr 诊断行。
|
||||||
|
// - raw:数据载荷透传(与 Write 的 raw 语义一致)。
|
||||||
|
//
|
||||||
|
// 数据出口一律写 cmd.OutOrStdout()、诊断出口写 cmd.ErrOrStderr()(均可被
|
||||||
|
// 测试重定向),不硬编码 os.Stdout/os.Stderr。未知 --format 值按 §5.2/AC-09
|
||||||
|
// 降级 fallback 并在此向 stderr 写一条 warning(不崩不静默)。
|
||||||
|
func WriteEnvelope(cmd *cobra.Command, env *Envelope, fallback Format) error {
|
||||||
|
w, errW := io.Writer(io.Discard), io.Writer(io.Discard)
|
||||||
|
format := fallback
|
||||||
|
fields, jq, warning := "", "", ""
|
||||||
|
if cmd != nil {
|
||||||
|
w = cmd.OutOrStdout()
|
||||||
|
errW = cmd.ErrOrStderr()
|
||||||
|
format, warning = resolveFormatWithWarning(cmd, fallback)
|
||||||
|
fields = ResolveFields(cmd)
|
||||||
|
jq = ResolveJQ(cmd)
|
||||||
|
}
|
||||||
|
if env == nil {
|
||||||
|
env = nilFallbackEnvelope()
|
||||||
|
}
|
||||||
|
if err := renderEnvelope(w, errW, env, format, fields, jq); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// AC-09「不静默」:warning 仅在信封成功写出后补写 stderr——
|
||||||
|
// 写出失败时错误优先,不留半截通道。
|
||||||
|
if warning != "" {
|
||||||
|
if _, werr := fmt.Fprintln(errW, "[WARN] "+warning); werr != nil {
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteEnvelopeTo 是 WriteEnvelope 的 writer 级实现(签名自 B205 保持不变),
|
||||||
|
// 供不持有 *cobra.Command 的调用方与单测直接使用。B37 起收编到 renderEnvelope
|
||||||
|
// 统一分发:json/空 format 输出完整信封(唯一 JSON 契约);table/pretty 输出
|
||||||
|
// 业务数据人读视图 + meta 摘要;csv/ndjson 输出裸记录(分页诊断行在本签名下
|
||||||
|
// 无 stderr 出口可走,按 io.Discard 处理——需要诊断行的调用方走 WriteEnvelope
|
||||||
|
// 的 cmd 路径或 Emitter)。nil 信封降级为 I3 合法的 failure 兜底信封(B208):
|
||||||
|
// 携带 internal 类 ErrorInfo——框架最后的兜底不得产出被自身 Validate() 拒绝的形态。
|
||||||
|
func WriteEnvelopeTo(w io.Writer, env *Envelope, format Format, fields, jq string) error {
|
||||||
|
if env == nil {
|
||||||
|
env = nilFallbackEnvelope()
|
||||||
|
}
|
||||||
|
return renderEnvelope(w, io.Discard, env, format, fields, jq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmitResult is the framework 2.0 CLI adapter. Unlike the legacy Emitter,
|
||||||
|
// machine-readable JSON/NDJSON always writes the primary result to stdout for
|
||||||
|
// every outcome; stderr is diagnostics-only. Human failure formats retain the
|
||||||
|
// conventional stderr path. The result is validated before any byte is
|
||||||
|
// rendered, and the returned exit code comes from the same immutable result.
|
||||||
|
func EmitResult(cmd *cobra.Command, result CommandResult) (int, error) {
|
||||||
|
code, _, err := emitResult(cmd, result)
|
||||||
|
return code, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitResult(cmd *cobra.Command, result CommandResult) (int, bool, error) {
|
||||||
|
if err := ValidateResult(result); err != nil {
|
||||||
|
return exitCodeInternal, false, err
|
||||||
|
}
|
||||||
|
env := result.envelope()
|
||||||
|
env = redactEnvelope(env)
|
||||||
|
format, warning := resolveFormatWithWarning(cmd, FormatJSON)
|
||||||
|
fields, jq := ResolveFields(cmd), ResolveJQ(cmd)
|
||||||
|
stdout, stderr := io.Writer(io.Discard), io.Writer(io.Discard)
|
||||||
|
if cmd != nil {
|
||||||
|
stdout, stderr = cmd.OutOrStdout(), cmd.ErrOrStderr()
|
||||||
|
}
|
||||||
|
|
||||||
|
if env.Outcome == OutcomeFailure && format != FormatJSON && format != FormatNDJSON {
|
||||||
|
message := "command failed"
|
||||||
|
if env.Error != nil && strings.TrimSpace(env.Error.Message) != "" {
|
||||||
|
message = env.Error.Message
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := fmt.Fprintln(&buf, "Error: "+message); err != nil {
|
||||||
|
return exitCodeInternal, false, err
|
||||||
|
}
|
||||||
|
n, err := writeAllCount(stderr, buf.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return exitCodeInternal, n > 0, err
|
||||||
|
}
|
||||||
|
return result.ExitCode(), n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure bypasses jq/fields so a filter cannot erase the typed error.
|
||||||
|
if env.Outcome == OutcomeFailure {
|
||||||
|
fields, jq = "", ""
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := renderEnvelope(&buf, stderr, env, format, fields, jq); err != nil {
|
||||||
|
return exitCodeInternal, false, err
|
||||||
|
}
|
||||||
|
n, err := writeAllCount(stdout, buf.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return exitCodeInternal, n > 0, err
|
||||||
|
}
|
||||||
|
if warning != "" {
|
||||||
|
// The primary result is already complete. Diagnostics are best-effort
|
||||||
|
// and must not reverse its outcome when stderr is unavailable.
|
||||||
|
_, _ = fmt.Fprintln(stderr, "[WARN] "+warning)
|
||||||
|
}
|
||||||
|
return result.ExitCode(), n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nilFallbackEnvelope 构造 nil 信封的兜底信封(B208,轮6裁决⑨)。保留
|
||||||
|
// 「不 panic、必产合法 failure」的既有语义,且自身满足不变量 I3
|
||||||
|
// (error 非空 ⇔ outcome==failure)、通过 Envelope.Validate()——框架出口
|
||||||
|
// 兜底不得被自己的校验器判非法。Type 取 "internal":这是框架内部缺陷而非
|
||||||
|
// 业务错误(§4 internal=5),Agent 可据 wire-stable 字段分支处置。
|
||||||
|
func nilFallbackEnvelope() *Envelope {
|
||||||
|
return &Envelope{
|
||||||
|
OK: false,
|
||||||
|
Outcome: OutcomeFailure,
|
||||||
|
Error: &ErrorInfo{
|
||||||
|
Type: "internal",
|
||||||
|
Message: "nil envelope: framework fallback",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// format 分发(B35~B40):信封 → format 矩阵的统一分发点
|
||||||
|
// (契约规范 §5.2;适用域仅 stdout 数据通道——轮8裁决⑪)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// renderEnvelope 是信封渲染的统一分发点(B37,WS1 改动点4;契约规范 §5.2
|
||||||
|
// format 矩阵)。渲染先进 buffer 再一次性写出:数据通道任何一步失败都不向
|
||||||
|
// w 泄漏半截输出(§5.2 末条;与 B25 buffer-first 同口径)。
|
||||||
|
//
|
||||||
|
// 矩阵各行(适用域仅 stdout 数据通道——失败信封在 Emitter 流路由层恒以
|
||||||
|
// 完整 JSON 写 stderr,从不进入本矩阵,轮8裁决⑪):
|
||||||
|
//
|
||||||
|
// - json / 空:完整信封——唯一 JSON 契约(§5.2 首行,B38);
|
||||||
|
// - table / pretty:业务数据人读视图 + meta 层人读摘要(count/pagination),
|
||||||
|
// 不输出信封外壳(§5.2 矩阵行,B39);
|
||||||
|
// - csv / ndjson:裸记录(无信封外壳);meta.pagination 走 errW 的 stderr
|
||||||
|
// 诊断行,不污染裸记录机器可读流(§5.2 矩阵行,B40);
|
||||||
|
// - raw:数据载荷透传(writeRaw 语义)。
|
||||||
|
//
|
||||||
|
// --jq 优先于 format:对**完整信封**求值后直接输出结果(§5.2 矩阵行,B42)。
|
||||||
|
// --fields 先投影数据载荷再按 format 分发(成功通道联动)。errW 为 nil 时按
|
||||||
|
// io.Discard 处理(csv/ndjson 诊断行无出口即丢弃)。
|
||||||
|
//
|
||||||
|
// buffer-first 纪律:全部分发路径(含 json/jq)先渲染进内存 buffer,成功后
|
||||||
|
// 才一次性写出到 w(§5.2 末条对分发点整体成立)——渲染失败零泄漏、写出
|
||||||
|
// 失败原子中止、短写报 io.ErrShortWrite。csv/ndjson 的 stderr 诊断行不经
|
||||||
|
// buffer,且在数据渲染成功后才写(渲染失败不留诊断噪声)。
|
||||||
|
func renderEnvelope(w io.Writer, errW io.Writer, env *Envelope, format Format, fields, jq string) error {
|
||||||
|
if errW == nil {
|
||||||
|
errW = io.Discard
|
||||||
|
}
|
||||||
|
if env == nil {
|
||||||
|
env = nilFallbackEnvelope()
|
||||||
|
}
|
||||||
|
env = redactEnvelope(env)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := renderEnvelopeInto(&buf, errW, env, format, fields, jq); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeAll(w, buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderEnvelopeInto 按 format 矩阵把信封渲染进 buf(renderEnvelope 的渲染段)。
|
||||||
|
func renderEnvelopeInto(buf *bytes.Buffer, errW io.Writer, env *Envelope, format Format, fields, jq string) error {
|
||||||
|
// 失败信封不参与 format/fields/jq 分发(轮8裁决⑪;契约规范 §5.1 v1.1
|
||||||
|
// 回写条款):恒输出完整 JSON 信封。§5.2 矩阵适用域仅 stdout 数据通道,
|
||||||
|
// 失败信封无 data 载荷(I3),若按矩阵分发或经 jq/fields 过滤,§2.4
|
||||||
|
// wire-stable 字段组将从错误通道消失。
|
||||||
|
if env.Outcome == OutcomeFailure {
|
||||||
|
if format == FormatNDJSON {
|
||||||
|
return Write(buf, FormatNDJSON, env)
|
||||||
|
}
|
||||||
|
return WriteFiltered(buf, FormatJSON, env, "", "")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(jq) != "" {
|
||||||
|
// jq 优先于 format:对完整信封求值后直接输出(§5.2 矩阵行)。
|
||||||
|
return ApplyJQ(buf, env, strings.TrimSpace(jq))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case FormatJSON, "":
|
||||||
|
// json(默认)= 完整信封(唯一 JSON 契约);fields 投影经
|
||||||
|
// WriteFiltered 作用于信封顶层键(既有语义,成功通道不变)。
|
||||||
|
return WriteFiltered(buf, FormatJSON, env, fields, "")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := env.Data
|
||||||
|
if trimmed := strings.TrimSpace(fields); trimmed != "" {
|
||||||
|
payload = SelectFields(payload, strings.Split(trimmed, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case FormatTable, FormatPretty:
|
||||||
|
if err := Write(buf, format, payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if summary := renderMetaSummary(env.Meta); summary != "" {
|
||||||
|
if _, err := fmt.Fprintf(buf, "\n%s\n", summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case FormatCSV, FormatNDJSON:
|
||||||
|
if err := Write(buf, format, payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 分页元数据走 stderr 诊断行(§5.2 csv/ndjson 行)。partial_failure
|
||||||
|
// 例外:§2.3 明确「stderr 零输出」,特例优先于 §5.2 诊断行通则。
|
||||||
|
if env.Outcome != OutcomePartialFailure {
|
||||||
|
return writePaginationDiagnostic(errW, env.Meta)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case FormatRaw:
|
||||||
|
return Write(buf, FormatRaw, payload)
|
||||||
|
default:
|
||||||
|
// 未知 Format 常量(归一化/ParseFormat 后理论不可达):按 json
|
||||||
|
// 数据通道语义兜底,绝不静默丢弃载荷。
|
||||||
|
return WriteFiltered(buf, FormatJSON, env, fields, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAll 把 p 一次性写入 w;短写(接受了部分字节却未写全)报
|
||||||
|
// io.ErrShortWrite——与 Emitter.writeOnce 同一短写纪律,绝不把截断输出当成功。
|
||||||
|
func writeAll(w io.Writer, p []byte) error {
|
||||||
|
_, err := writeAllCount(w, p)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAllCount(w io.Writer, p []byte) (int, error) {
|
||||||
|
n, err := w.Write(p)
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
if n < len(p) {
|
||||||
|
return n, io.ErrShortWrite
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderMetaSummary 把信封 meta 层渲染为人读摘要行(§5.2:table/pretty 输出
|
||||||
|
// 「业务数据 + 人读分页摘要」)。无 meta 内容返回空串。count 单独成行;
|
||||||
|
// pagination 摘要复用 renderPaginationSummary。
|
||||||
|
func renderMetaSummary(meta *Meta) string {
|
||||||
|
if meta == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
if meta.Count != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf("count: %d", *meta.Count))
|
||||||
|
}
|
||||||
|
if meta.Pagination != nil {
|
||||||
|
lines = append(lines, "pagination: "+renderPaginationSummary(meta.Pagination))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPaginationSummary 按 wire 字段名输出分页摘要:
|
||||||
|
// endpoint_exhausted/pages/items/next_token(snake_case 与 wire 一致,Agent
|
||||||
|
// 可交叉核对;§3 两态显式语义保留——false 也输出,因为它是「可续跑」的
|
||||||
|
// 有意义状态)。
|
||||||
|
func renderPaginationSummary(p *Pagination) string {
|
||||||
|
parts := []string{fmt.Sprintf("endpoint_exhausted: %t", p.EndpointExhausted)}
|
||||||
|
if p.Pages > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("pages: %d", p.Pages))
|
||||||
|
}
|
||||||
|
if p.Items > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("items: %d", p.Items))
|
||||||
|
}
|
||||||
|
if p.NextToken != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("next_token: %s", p.NextToken))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePaginationDiagnostic 把分页诊断行写到 errW(§5.2:csv/ndjson 裸记录
|
||||||
|
// 格式下分页元数据走 stderr 诊断行)。无 meta/pagination 时零输出——
|
||||||
|
// 不给无分页场景制造噪声。
|
||||||
|
func writePaginationDiagnostic(errW io.Writer, meta *Meta) error {
|
||||||
|
if meta == nil || meta.Pagination == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintln(errW, "[pagination] "+renderPaginationSummary(meta.Pagination))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase B 泛化(B24~B29):Emitter 类型——success/pending/partial 走 stdout、
|
||||||
|
// failure 走 stderr 的双向出口;渲染先进 buffer 再一次性写出。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Emitter 是统一信封的双向出口(B24,WS1 改动点2)。它持有 stdout/stderr 两个
|
||||||
|
// 写入目标与当前 format/fields/jq 配置,按契约规范 §5.1 流向纪律把信封分发到
|
||||||
|
// 正确的流:
|
||||||
|
//
|
||||||
|
// - success / pending / partial_failure → stdout(数据通道);
|
||||||
|
// - failure(含 nil 信封降级)→ stderr,stdout 严格零字节(AC-11)。
|
||||||
|
//
|
||||||
|
// 每次写出都经过「先渲染进内存 buffer、再一次性写出」(B25,契约规范 §5.2
|
||||||
|
// 末条),写一半失败不产生残缺 JSON。渲染复用 WriteEnvelopeTo(B205),
|
||||||
|
// Emitter 不重新定义信封类型或 wire 键。
|
||||||
|
type Emitter struct {
|
||||||
|
stdout io.Writer
|
||||||
|
stderr io.Writer
|
||||||
|
format Format
|
||||||
|
fields string
|
||||||
|
jq string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEmitter 构造 Emitter(B24)。nil writer 按 io.Discard 处理(等价丢弃,
|
||||||
|
// 防 panic)。format 是解析后的当前 format(空值由 WriteEnvelopeTo 按 json
|
||||||
|
// 处理);fields/jq 为命令解析出的 --fields/--jq 过滤条件。
|
||||||
|
func NewEmitter(stdout, stderr io.Writer, format Format, fields, jq string) *Emitter {
|
||||||
|
if stdout == nil {
|
||||||
|
stdout = io.Discard
|
||||||
|
}
|
||||||
|
if stderr == nil {
|
||||||
|
stderr = io.Discard
|
||||||
|
}
|
||||||
|
return &Emitter{stdout: stdout, stderr: stderr, format: format, fields: fields, jq: jq}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit 按 env 的 outcome 把信封写到对应流(B26/B27/B28,契约规范 §5.1):
|
||||||
|
//
|
||||||
|
// - success / pending:完整信封落 stdout(B26,ok:true,§2.1/§2.2);
|
||||||
|
// - partial_failure:三通道明细落 stdout、stderr 零输出(B28,§2.3);
|
||||||
|
// - failure:信封落 stderr、stdout 严格零字节(B27,AC-11);
|
||||||
|
// - nil 信封由 WriteEnvelopeTo 降级为 failure,同样落 stderr。
|
||||||
|
//
|
||||||
|
// exit 0 语义标注(B32 收口,契约规范 §4/I2):Emit 的流路由与
|
||||||
|
// ExitCodeForEnvelope 的退出码语义一一对应——落 stdout 的信封
|
||||||
|
// (success/pending/partial_failure 中 ok:true 的前两者)对应 exit 0,
|
||||||
|
// 落 stderr 的 failure(含 nil 降级)对应非零退出码。Emitter 只负责写出,
|
||||||
|
// 不自行 os.Exit;进程退出码由 runner 出口按同一映射统一决定(§4 规则)。
|
||||||
|
//
|
||||||
|
// 失败信封恒以完整 JSON 信封渲染并绕过 format/fields/jq:失败信封没有数据
|
||||||
|
// 载荷(I3),§5.1 要求 stderr 上的失败信封恒为结构化信封;「非 json 只渲染
|
||||||
|
// data」的分发规则只适用于携带数据的信封。若 --jq 作用于失败信封会把错误
|
||||||
|
// 通道过滤为空,Agent 将丢失错误明细,故同样绕过。
|
||||||
|
func (em *Emitter) Emit(env *Envelope) error {
|
||||||
|
if env == nil || env.Outcome == OutcomeFailure {
|
||||||
|
return em.writeOnce(em.stderr, env, FormatJSON, "", "")
|
||||||
|
}
|
||||||
|
return em.writeOnce(em.stdout, env, em.format, em.fields, em.jq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeOnce 先把信封完整渲染进内存 buffer,再一次性写出到 target(B25,
|
||||||
|
// 契约规范 §5.2 末条「渲染先进 buffer 再一次性写出」):渲染失败不向 target
|
||||||
|
// 泄漏任何字节;写出失败原子中止——writer 报错即原样返回,短写(接受了部分
|
||||||
|
// 字节却未写全)报 io.ErrShortWrite,绝不把截断输出当成功。渲染统一走
|
||||||
|
// renderEnvelope 分发点(B37),csv/ndjson 的分页诊断行(B40)以 em.stderr
|
||||||
|
// 为诊断出口。
|
||||||
|
func (em *Emitter) writeOnce(target io.Writer, env *Envelope, format Format, fields, jq string) error {
|
||||||
|
if env == nil {
|
||||||
|
env = nilFallbackEnvelope()
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := renderEnvelope(&buf, em.stderr, env, format, fields, jq); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p := buf.Bytes()
|
||||||
|
n, err := target.Write(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n < len(p) {
|
||||||
|
return io.ErrShortWrite
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFormat 把字符串形式的 format 值(来自 --format flag 或 caller 接口)
|
||||||
|
// 归一化为 Format 常量;大小写不敏感,未知/空值返回 fallback。它是不持有
|
||||||
|
// *cobra.Command 的调用方(如主漏斗 callMCPToolInternalOpts)的 ResolveFormat
|
||||||
|
// 等价物,二者共用同一归一化规则。
|
||||||
|
func ParseFormat(raw string, fallback Format) Format {
|
||||||
|
return normalizeFormat(raw, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 退出码映射(B32/B33/B34):outcome / error 类别 → exit code
|
||||||
|
// (契约规范 §4;不变量 I2 —— exit code == 0 ⇔ ok == true)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 退出码表取值对齐:契约规范 §4 × 规划 v1.2 决策(OQ-1 已关闭——保留现行
|
||||||
|
// 码表 api=1 / auth=2 / validation=3 / PAT=4 / internal=5 / discovery=6,
|
||||||
|
// 仅新增 partial_failure 专用码 7;草案表中 validation→2 / confirmation→3
|
||||||
|
// 的拆分属 wire 破坏性重排,放弃)。confirmation_required 是 failure 的子类,
|
||||||
|
// 与 validation 共享 rc=3、以 subtype 区分(AC-13;runtime 同源于
|
||||||
|
// internal/errors 的 ExitCode 表)。
|
||||||
|
const (
|
||||||
|
exitCodeOK = 0
|
||||||
|
exitCodeAPI = 1
|
||||||
|
exitCodeAuth = 2
|
||||||
|
exitCodeValidation = 3 // confirmation_required 子类共享此码(OQ-1 定案)
|
||||||
|
exitCodePermission = 4 // PAT / permission failures
|
||||||
|
exitCodeInternal = 5
|
||||||
|
exitCodeDiscovery = 6
|
||||||
|
exitCodePartial = 7 // partial_failure 专用(契约 §4;规划 WS2 第4项;B142 将在 errors 侧补同源常量)
|
||||||
|
)
|
||||||
|
|
||||||
|
// subtypeConfirmationRequired 是门禁拦截的 failure 子类标记(契约规范 §2.4),
|
||||||
|
// 与 runtime apperrors.WithReason("confirmation_required") 形态同源。
|
||||||
|
const subtypeConfirmationRequired = "confirmation_required"
|
||||||
|
|
||||||
|
// ExitCodeForEnvelope 计算信封对应的进程退出码(B33,AC-10/I2;契约规范 §4)。
|
||||||
|
// 纯函数:只读信封字段、不做任何 I/O。映射规则:
|
||||||
|
//
|
||||||
|
// - success / pending → 0(I2:exit 0 ⇔ ok;异步受理不是失败,§1/§4);
|
||||||
|
// - partial_failure → 7(部分成功专用码,区别于普通失败,§4);
|
||||||
|
// - failure → 按 Error.Type 类别映射,confirmation_required 子类恒 3;
|
||||||
|
// - nil 信封 → 5(与 WriteEnvelopeTo 的 internal 类兜底信封同源);
|
||||||
|
// - 非法 outcome → 5(非法信封属框架缺陷,归 internal)。
|
||||||
|
//
|
||||||
|
// 判定只看 outcome / error 类别,不看可能被命令层篡改的 OK 字段(与 IsOK
|
||||||
|
// 同口径,I1)。退出码由 runner 出口统一决定、命令层禁止自行 os.Exit
|
||||||
|
// (§4 规则);本函数为 Emitter 语义标注、runner 映射与测试提供同一依据。
|
||||||
|
func ExitCodeForEnvelope(env *Envelope) int {
|
||||||
|
if env == nil {
|
||||||
|
return exitCodeInternal
|
||||||
|
}
|
||||||
|
switch env.Outcome {
|
||||||
|
case OutcomeSuccess, OutcomePending:
|
||||||
|
return exitCodeOK
|
||||||
|
case OutcomePartialFailure:
|
||||||
|
return exitCodePartial
|
||||||
|
case OutcomeFailure:
|
||||||
|
return exitCodeForErrorInfo(env.Error)
|
||||||
|
default:
|
||||||
|
return exitCodeInternal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// exitCodeForErrorInfo 把失败信封的 error 类别映射为退出码(契约规范 §4)。
|
||||||
|
// subtype=confirmation_required 优先于 type:门禁拦截无论挂在哪个 type 下
|
||||||
|
// 都恒为 3(AC-13)。error 缺席(违反 I3 的形态)按 internal 处理——
|
||||||
|
// 没有明细可分支的失败不得假装可恢复。未知 type 归 internal(不应发生)。
|
||||||
|
func exitCodeForErrorInfo(info *ErrorInfo) int {
|
||||||
|
if info == nil {
|
||||||
|
return exitCodeInternal
|
||||||
|
}
|
||||||
|
if info.Subtype == subtypeConfirmationRequired {
|
||||||
|
return exitCodeValidation
|
||||||
|
}
|
||||||
|
switch info.Type {
|
||||||
|
case "api":
|
||||||
|
return exitCodeAPI
|
||||||
|
case "auth":
|
||||||
|
return exitCodeAuth
|
||||||
|
case "validation":
|
||||||
|
return exitCodeValidation
|
||||||
|
case "permission":
|
||||||
|
return exitCodePermission
|
||||||
|
case "discovery":
|
||||||
|
return exitCodeDiscovery
|
||||||
|
default:
|
||||||
|
return exitCodeInternal
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
// 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 output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errEmitterInjected = errors.New("emitter injected write failure")
|
||||||
|
|
||||||
|
// emitterFailingWriter 记录被接受的字节并在写出时失败,
|
||||||
|
// 用于 B25「写一半失败不产生残缺 JSON」断言。
|
||||||
|
type emitterFailingWriter struct {
|
||||||
|
acceptN int // >0 时先接受 acceptN 字节再报错(模拟写到一半失败)
|
||||||
|
accepted []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *emitterFailingWriter) Write(p []byte) (int, error) {
|
||||||
|
if w.acceptN > 0 && len(w.accepted) < w.acceptN {
|
||||||
|
k := w.acceptN - len(w.accepted)
|
||||||
|
if k > len(p) {
|
||||||
|
k = len(p)
|
||||||
|
}
|
||||||
|
w.accepted = append(w.accepted, p[:k]...)
|
||||||
|
return k, errEmitterInjected
|
||||||
|
}
|
||||||
|
return 0, errEmitterInjected
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitterCountingWriter 统计 Write 调用次数并留存全部字节。
|
||||||
|
type emitterCountingWriter struct {
|
||||||
|
writes int
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *emitterCountingWriter) Write(p []byte) (int, error) {
|
||||||
|
w.writes++
|
||||||
|
return w.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitterShortWriter 接受一半字节但谎报成功(n < len(p), err == nil),
|
||||||
|
// Emitter 必须检出短写而不是把截断输出当成功。
|
||||||
|
type emitterShortWriter struct {
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *emitterShortWriter) Write(p []byte) (int, error) {
|
||||||
|
w.n = len(p) / 2
|
||||||
|
return w.n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPartialEnvelopeFixture() *Envelope {
|
||||||
|
return NewPartialEnvelope(map[string]any{
|
||||||
|
"total": 3,
|
||||||
|
"succeeded": []any{map[string]any{"id": "a", "messageId": "m_1"}},
|
||||||
|
"failed": []any{map[string]any{"id": "b", "error": map[string]any{"type": "api", "code": 40001}}},
|
||||||
|
"unknown": []any{map[string]any{"id": "c", "reason": "timeout after submit"}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B24:Emitter 类型与构造 ---
|
||||||
|
|
||||||
|
func TestNewEmitterHandlesNilWriters(t *testing.T) {
|
||||||
|
em := NewEmitter(nil, nil, FormatJSON, "", "")
|
||||||
|
if em == nil {
|
||||||
|
t.Fatal("NewEmitter returned nil")
|
||||||
|
}
|
||||||
|
// nil writer 等价 io.Discard:不 panic,写出成功且无副作用。
|
||||||
|
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err != nil {
|
||||||
|
t.Fatalf("Emit with nil writers: %v", err)
|
||||||
|
}
|
||||||
|
if err := em.Emit(NewFailureEnvelope(&ErrorInfo{Type: "api"})); err != nil {
|
||||||
|
t.Fatalf("Emit failure with nil writers: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewEmitterKeepsConfig(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatTable, "id,name", ".data")
|
||||||
|
if em.format != FormatTable || em.fields != "id,name" || em.jq != ".data" {
|
||||||
|
t.Fatalf("NewEmitter dropped config: format=%q fields=%q jq=%q", em.format, em.fields, em.jq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B26:成功信封 stdout 出口(恰为一个完整 JSON 文档)---
|
||||||
|
|
||||||
|
func TestEmitterSuccessEnvelopeGoesToStdoutOnly(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
env := NewSuccessEnvelope(map[string]any{"todoId": "t_123"})
|
||||||
|
env.Meta = &Meta{Count: NewCount(1)}
|
||||||
|
|
||||||
|
if err := em.Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit success: %v", err)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("success envelope must not touch stderr, got %d bytes: %s", stderr.Len(), stderr.String())
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stdout is not exactly one complete JSON document: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(stdout.Bytes()))
|
||||||
|
if err := dec.Decode(&map[string]any{}); err != nil {
|
||||||
|
t.Fatalf("first JSON document invalid: %v", err)
|
||||||
|
}
|
||||||
|
if dec.More() {
|
||||||
|
t.Fatalf("stdout must contain exactly one JSON document, got trailing content: %s", stdout.String())
|
||||||
|
}
|
||||||
|
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
|
||||||
|
t.Fatalf("ok = %v (%T), want true (bool)", decoded["ok"], decoded["ok"])
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "success" {
|
||||||
|
t.Fatalf("outcome = %v, want success", decoded["outcome"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitterPendingEnvelopeGoesToStdoutOnly(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
env := NewPendingEnvelope(&OperationInfo{ID: "task_abc", State: "processing"})
|
||||||
|
|
||||||
|
if err := em.Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit pending: %v", err)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("pending envelope must not touch stderr, got %d bytes", stderr.Len())
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
|
||||||
|
t.Fatalf("pending envelope ok = %v, want true", decoded["ok"])
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "pending" {
|
||||||
|
t.Fatalf("outcome = %v, want pending", decoded["outcome"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B27:失败信封 stderr 出口(stdout 严格零字节)---
|
||||||
|
|
||||||
|
func TestEmitterFailureEnvelopeGoesToStderrWithZeroStdout(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
env := NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit", Code: 90018})
|
||||||
|
|
||||||
|
if err := em.Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit failure: %v", err)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("failure envelope must leave stdout empty, got %d bytes: %s", stdout.Len(), stdout.String())
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stderr failure envelope is not valid JSON: %v\n%s", err, stderr.String())
|
||||||
|
}
|
||||||
|
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
|
||||||
|
t.Fatalf("failure envelope ok = %v, want false (bool)", decoded["ok"])
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "failure" {
|
||||||
|
t.Fatalf("outcome = %v, want failure", decoded["outcome"])
|
||||||
|
}
|
||||||
|
if _, hasError := decoded["error"]; !hasError {
|
||||||
|
t.Fatalf("failure envelope must carry error detail: %s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitterNilEnvelopeDegradesToFailureOnStderr(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
if err := em.Emit(nil); err != nil {
|
||||||
|
t.Fatalf("Emit(nil): %v", err)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("nil envelope must leave stdout empty, got %d bytes", stdout.Len())
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stderr is not valid JSON: %v\n%s", err, stderr.String())
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "failure" {
|
||||||
|
t.Fatalf("nil envelope must degrade to failure: %s", stderr.String())
|
||||||
|
}
|
||||||
|
// B208(轮8顺带核查①:兜底修正后同步强化):降级信封必须是 I3
|
||||||
|
// 合法形态——ok:false 恒真、error 明细存在且为 internal 类,
|
||||||
|
// 兜底信封自身通过 Validate()。
|
||||||
|
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
|
||||||
|
t.Fatalf("nil fallback ok = %v, want false (bool): %s", decoded["ok"], stderr.String())
|
||||||
|
}
|
||||||
|
errObj, hasErr := decoded["error"].(map[string]any)
|
||||||
|
if !hasErr {
|
||||||
|
t.Fatalf("nil fallback must carry error detail (I3, B208): %s", stderr.String())
|
||||||
|
}
|
||||||
|
if errObj["type"] != "internal" {
|
||||||
|
t.Fatalf("fallback error.type = %v, want internal: %s", errObj["type"], stderr.String())
|
||||||
|
}
|
||||||
|
if err := nilFallbackEnvelope().Validate(); err != nil {
|
||||||
|
t.Fatalf("nil fallback envelope must pass Validate(), got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitterFailureEnvelopeBypassesFormatAndJQ(t *testing.T) {
|
||||||
|
// 失败信封恒以完整 JSON 信封落 stderr,不受 -f table/--jq 影响:
|
||||||
|
// 失败信封无 data 载荷(I3),按 format 分发只会渲染空值,
|
||||||
|
// Agent 必须总能读到结构化错误明细(§5.1/§2.4)。
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatTable, "id", ".data")
|
||||||
|
env := NewFailureEnvelope(&ErrorInfo{Type: "validation", Message: "bad flag"})
|
||||||
|
|
||||||
|
if err := em.Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit failure with table/jq config: %v", err)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("stdout must stay empty for failure, got %d bytes", stdout.Len())
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stderr failure envelope must stay full JSON regardless of format: %v\n%s", err, stderr.String())
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "failure" || decoded["error"] == nil {
|
||||||
|
t.Fatalf("stderr lost failure envelope shape under -f table: %s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B28:partial_failure 明细落 stdout、stderr 零输出 ---
|
||||||
|
|
||||||
|
func TestEmitterPartialFailureStdoutOnly(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
|
||||||
|
if err := em.Emit(newPartialEnvelopeFixture()); err != nil {
|
||||||
|
t.Fatalf("Emit partial_failure: %v", err)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("partial_failure must keep stderr empty (§2.3), got %d bytes: %s", stderr.Len(), stderr.String())
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
|
||||||
|
t.Fatalf("partial_failure ok = %v, want false (bool)", decoded["ok"])
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "partial_failure" {
|
||||||
|
t.Fatalf("outcome = %v, want partial_failure", decoded["outcome"])
|
||||||
|
}
|
||||||
|
data, isMap := decoded["data"].(map[string]any)
|
||||||
|
if !isMap {
|
||||||
|
t.Fatalf("partial data must be the three-channel detail object: %s", stdout.String())
|
||||||
|
}
|
||||||
|
for _, channel := range []string{"total", "succeeded", "failed", "unknown"} {
|
||||||
|
if _, ok := data[channel]; !ok {
|
||||||
|
t.Fatalf("partial data missing %q channel: %s", channel, stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B25:渲染先进 buffer 再一次性写出 ---
|
||||||
|
|
||||||
|
func TestEmitterRenderErrorLeavesZeroBytes(t *testing.T) {
|
||||||
|
// 渲染失败(marshal 报错)必须不向目标流泄漏任何字节。
|
||||||
|
testseam.Swap(t, &marshalJSONIndent, func(any, string, string) ([]byte, error) {
|
||||||
|
return nil, errEmitterInjected
|
||||||
|
})
|
||||||
|
target := &emitterFailingWriter{acceptN: 64}
|
||||||
|
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
|
||||||
|
err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"}))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Emit must surface render failure")
|
||||||
|
}
|
||||||
|
if len(target.accepted) != 0 {
|
||||||
|
t.Fatalf("render failure leaked %d bytes to the stream: %s", len(target.accepted), target.accepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitterWriteFailureLeavesZeroBytes(t *testing.T) {
|
||||||
|
// 写出失败:buffer 先行意味着目标流要么收到完整文档、要么零字节。
|
||||||
|
target := &emitterFailingWriter{} // 第一次 Write 即整体拒绝
|
||||||
|
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
|
||||||
|
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err == nil {
|
||||||
|
t.Fatal("Emit must surface write failure")
|
||||||
|
}
|
||||||
|
if len(target.accepted) != 0 {
|
||||||
|
t.Fatalf("write failure leaked %d bytes", len(target.accepted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitterWriteOnceEmitsSingleWriteOfFullDocument(t *testing.T) {
|
||||||
|
// 「一次性写出」的直接断言:目标流只收到一次 Write,
|
||||||
|
// 且字节与独立重渲染结果逐字节一致(无分块流式拼接)。
|
||||||
|
target := &emitterCountingWriter{}
|
||||||
|
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
|
||||||
|
env := NewSuccessEnvelope(map[string]any{"id": "x"})
|
||||||
|
if err := em.Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit: %v", err)
|
||||||
|
}
|
||||||
|
if target.writes != 1 {
|
||||||
|
t.Fatalf("expected exactly one Write call (buffer-first single flush), got %d", target.writes)
|
||||||
|
}
|
||||||
|
var want bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&want, env, FormatJSON, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(target.buf.Bytes(), want.Bytes()) {
|
||||||
|
t.Fatalf("streamed bytes differ from re-render:\n got: %s\nwant: %s", target.buf.String(), want.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitterDetectsShortWrite(t *testing.T) {
|
||||||
|
// writer 谎报成功但只接受一半字节(短写):Emitter 必须报
|
||||||
|
// io.ErrShortWrite,绝不把截断(残缺)JSON 当成功。
|
||||||
|
target := &emitterShortWriter{}
|
||||||
|
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
|
||||||
|
err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"}))
|
||||||
|
if !errors.Is(err, io.ErrShortWrite) {
|
||||||
|
t.Fatalf("Emit on short write = %v, want io.ErrShortWrite", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B29:meta.count 渲染位置(meta 层而非 data 层)---
|
||||||
|
|
||||||
|
func TestEmitterMetaCountRenderedInMetaLayer(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
env := NewSuccessEnvelope([]any{
|
||||||
|
map[string]any{"id": "a"},
|
||||||
|
map[string]any{"id": "b"},
|
||||||
|
map[string]any{"id": "c"},
|
||||||
|
})
|
||||||
|
env.Meta = &Meta{Count: NewCount(3)}
|
||||||
|
|
||||||
|
if err := em.Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit: %v", err)
|
||||||
|
}
|
||||||
|
raw := stdout.String()
|
||||||
|
// count 只允许出现在 meta 层:wire 上 "count" 键恰出现一次。
|
||||||
|
if got := strings.Count(raw, `"count"`); got != 1 {
|
||||||
|
t.Fatalf(`"count" appears %d times on the wire, want exactly 1 (meta layer only): %s`, got, raw)
|
||||||
|
}
|
||||||
|
var decoded struct {
|
||||||
|
Data []map[string]any `json:"data"`
|
||||||
|
Meta struct {
|
||||||
|
Count *int `json:"count"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stdout is not valid JSON: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if decoded.Meta.Count == nil || *decoded.Meta.Count != 3 {
|
||||||
|
t.Fatalf("meta.count = %v, want 3: %s", decoded.Meta.Count, raw)
|
||||||
|
}
|
||||||
|
for i, row := range decoded.Data {
|
||||||
|
if _, hasCount := row["count"]; hasCount {
|
||||||
|
t.Fatalf("data[%d] must not carry count (belongs to meta layer): %s", i, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B30:dry_run 顶层字段注入断言(契约规范 §6 场景6)---
|
||||||
|
|
||||||
|
func TestEmitterDryRunFieldInjectedAndOmitEmpty(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
env := NewSuccessEnvelope(map[string]any{"plan": "create app DemoApp"})
|
||||||
|
env.DryRun = true
|
||||||
|
|
||||||
|
if err := em.Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit dry-run envelope: %v", err)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("dry-run success envelope must not touch stderr, got %d bytes", stderr.Len())
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
dryRun, isBool := decoded["dry_run"].(bool)
|
||||||
|
if !isBool {
|
||||||
|
t.Fatalf("dry_run must be a JSON boolean, got %v (%T): %s", decoded["dry_run"], decoded["dry_run"], stdout.String())
|
||||||
|
}
|
||||||
|
if !dryRun {
|
||||||
|
t.Fatalf("dry_run = %v, want true (§6 场景6): %s", dryRun, stdout.String())
|
||||||
|
}
|
||||||
|
if okVal, _ := decoded["ok"].(bool); !okVal {
|
||||||
|
t.Fatalf("dry-run preview keeps ok=true (exit 0 semantics): %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitterDryRunFalseStaysOmitted(t *testing.T) {
|
||||||
|
// dry_run 为 false(非 dry-run)时必须整体缺席(omitempty,不输出 false)。
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err != nil {
|
||||||
|
t.Fatalf("Emit: %v", err)
|
||||||
|
}
|
||||||
|
raw := stdout.String()
|
||||||
|
if strings.Contains(raw, `"dry_run"`) {
|
||||||
|
t.Fatalf("dry_run=false must be omitted from wire (§2.5 omitempty): %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B31:identity 字段(user/app)omitempty 注入断言(契约规范 §2.5)---
|
||||||
|
|
||||||
|
func TestEmitterIdentityInjectedAndOmitEmpty(t *testing.T) {
|
||||||
|
for _, identity := range []string{"user", "app"} {
|
||||||
|
t.Run(identity, func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
env := NewSuccessEnvelope(map[string]any{"id": "x"})
|
||||||
|
env.Identity = identity
|
||||||
|
|
||||||
|
if err := em.Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit: %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if decoded["identity"] != identity {
|
||||||
|
t.Fatalf("identity = %v, want %q: %s", decoded["identity"], identity, stdout.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitterIdentityEmptyStaysOmitted(t *testing.T) {
|
||||||
|
// identity 未注入(空串)时字段缺席,wire 中不出现 "identity"。
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err != nil {
|
||||||
|
t.Fatalf("Emit: %v", err)
|
||||||
|
}
|
||||||
|
if raw := stdout.String(); strings.Contains(raw, `"identity"`) {
|
||||||
|
t.Fatalf("empty identity must be omitted (§2.5 omitempty): %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B32:pending 信封出口完整化(ok:true + operation + exit 0 语义标注)---
|
||||||
|
|
||||||
|
func TestEmitterPendingOutletFullFormWithOperationAndExitZero(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
env := NewPendingEnvelope(&OperationInfo{
|
||||||
|
ID: "task_abc",
|
||||||
|
State: "processing",
|
||||||
|
TimedOut: true, // §2.2:轮询超时保持真实 state + timed_out:true,禁止伪装 success
|
||||||
|
NextCommand: "dws wiki task-result --task task_abc",
|
||||||
|
})
|
||||||
|
env.Data = map[string]any{"spaceId": "s_1", "nodeId": "n_9"}
|
||||||
|
|
||||||
|
if err := em.Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit pending: %v", err)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("pending envelope keeps stderr empty (§2.2 走 stdout), got %d bytes", stderr.Len())
|
||||||
|
}
|
||||||
|
var decoded struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Outcome string `json:"outcome"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
Meta struct {
|
||||||
|
Operation *OperationInfo `json:"operation"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if !decoded.OK || decoded.Outcome != "pending" {
|
||||||
|
t.Fatalf("pending envelope = ok:%v outcome:%q, want ok:true outcome:pending: %s",
|
||||||
|
decoded.OK, decoded.Outcome, stdout.String())
|
||||||
|
}
|
||||||
|
op := decoded.Meta.Operation
|
||||||
|
if op == nil {
|
||||||
|
t.Fatalf("pending envelope must carry meta.operation (§2.2): %s", stdout.String())
|
||||||
|
}
|
||||||
|
if op.State != "processing" || !op.TimedOut {
|
||||||
|
t.Fatalf("operation must keep real state + timed_out:true (§2.2 anti-spoof): got state=%q timed_out=%v", op.State, op.TimedOut)
|
||||||
|
}
|
||||||
|
if op.NextCommand != "dws wiki task-result --task task_abc" {
|
||||||
|
t.Fatalf("next_command = %q, want executable recovery command (§2.2): %s", op.NextCommand, stdout.String())
|
||||||
|
}
|
||||||
|
if decoded.Data == nil {
|
||||||
|
t.Fatalf("pending envelope data payload lost: %s", stdout.String())
|
||||||
|
}
|
||||||
|
// exit 0 语义标注(B32 与 B33 的耦合点):异步受理 = ok:true → exit 0。
|
||||||
|
if code := ExitCodeForEnvelope(env); code != 0 {
|
||||||
|
t.Fatalf("pending envelope exit code = %d, want 0 (§1/§4/I2)", code)
|
||||||
|
}
|
||||||
|
// 非 json format 下 pending 同样走 stdout(数据通道),stderr 保持零字节。
|
||||||
|
var outTable, errTable bytes.Buffer
|
||||||
|
if err := NewEmitter(&outTable, &errTable, FormatTable, "", "").Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit pending under -f table: %v", err)
|
||||||
|
}
|
||||||
|
if errTable.Len() != 0 {
|
||||||
|
t.Fatalf("pending under -f table must keep stderr empty, got %d bytes", errTable.Len())
|
||||||
|
}
|
||||||
|
if err := env.Validate(); err != nil {
|
||||||
|
t.Fatalf("pending envelope must pass Validate(): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B33:ExitCodeForEnvelope 的 outcome→exit code 映射 ---
|
||||||
|
|
||||||
|
func TestExitCodeForEnvelopeOutcomeCategoryMapping(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
env *Envelope
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"success → 0", NewSuccessEnvelope(map[string]any{"id": "x"}), 0},
|
||||||
|
{"pending → 0", NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"}), 0},
|
||||||
|
{"partial_failure → 7", newPartialEnvelopeFixture(), 7},
|
||||||
|
{"failure api → 1", NewFailureEnvelope(&ErrorInfo{Type: "api", Code: 90018}), 1},
|
||||||
|
{"failure auth → 2", NewFailureEnvelope(&ErrorInfo{Type: "auth"}), 2},
|
||||||
|
{"failure validation → 3", NewFailureEnvelope(&ErrorInfo{Type: "validation", Param: "--name"}), 3},
|
||||||
|
{"failure discovery → 6", NewFailureEnvelope(&ErrorInfo{Type: "discovery"}), 6},
|
||||||
|
{"failure internal → 5", NewFailureEnvelope(&ErrorInfo{Type: "internal"}), 5},
|
||||||
|
{"failure unknown type → 5 (internal fallback)", NewFailureEnvelope(&ErrorInfo{Type: "mystery"}), 5},
|
||||||
|
// confirmation 子类 → 3(AC-13;subtype 优先于 type,挂在任何 type 下恒 3)
|
||||||
|
{"failure confirmation subtype on api → 3",
|
||||||
|
NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "confirmation_required",
|
||||||
|
Actions: []string{"dws dev app delete --app-id a1 --yes"}}), 3},
|
||||||
|
{"failure confirmation subtype on validation → 3",
|
||||||
|
NewFailureEnvelope(&ErrorInfo{Type: "validation", Subtype: "confirmation_required"}), 3},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := ExitCodeForEnvelope(c.env); got != c.want {
|
||||||
|
t.Fatalf("ExitCodeForEnvelope = %d, want %d", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExitCodeForEnvelopeNilEnvelopeIsInternal(t *testing.T) {
|
||||||
|
// nil 信封与 WriteEnvelopeTo 兜底的 internal 类 failure 信封同源(B208)。
|
||||||
|
if got := ExitCodeForEnvelope(nil); got != 5 {
|
||||||
|
t.Fatalf("ExitCodeForEnvelope(nil) = %d, want 5 (internal-class fallback)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExitCodeForEnvelopeIgnoresTamperedOKAndInvalidOutcome(t *testing.T) {
|
||||||
|
// 只看 outcome(I1 同口径):被篡改 OK 字段的失败信封仍非零。
|
||||||
|
tampered := &Envelope{OK: true, Outcome: OutcomeFailure, Error: &ErrorInfo{Type: "api"}}
|
||||||
|
if got := ExitCodeForEnvelope(tampered); got == 0 {
|
||||||
|
t.Fatal("ExitCodeForEnvelope must ignore tampered OK field (outcome=failure is never exit 0)")
|
||||||
|
}
|
||||||
|
// 非法 outcome 属框架缺陷 → internal(5),不得落入 0。
|
||||||
|
invalid := &Envelope{OK: true, Outcome: "succeeded"}
|
||||||
|
if got := ExitCodeForEnvelope(invalid); got != 5 {
|
||||||
|
t.Fatalf("ExitCodeForEnvelope(invalid outcome) = %d, want 5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B34:I2 穷举断言——exit 0 ⇔ ok(对 ExitCodeForEnvelope 全组合)---
|
||||||
|
|
||||||
|
func TestInvariantI2ExhaustiveExitZeroIffOK(t *testing.T) {
|
||||||
|
// I2:exit code == 0 ⇔ ok == true。穷举全部规范 outcome(合法装配形态,
|
||||||
|
// 满足 I3)+ nil 信封 + 非法 outcome + 全部 failure 类别细分。
|
||||||
|
legal := []struct {
|
||||||
|
outcome Outcome
|
||||||
|
env *Envelope
|
||||||
|
}{
|
||||||
|
{OutcomeSuccess, NewSuccessEnvelope(map[string]any{"id": "x"})},
|
||||||
|
{OutcomePending, NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"})},
|
||||||
|
{OutcomePartialFailure, newPartialEnvelopeFixture()},
|
||||||
|
{OutcomeFailure, NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit"})},
|
||||||
|
}
|
||||||
|
if len(legal) != 4 {
|
||||||
|
t.Fatalf("I2 exhaustive table must cover exactly the four canonical outcomes, got %d", len(legal))
|
||||||
|
}
|
||||||
|
for _, c := range legal {
|
||||||
|
wantOK := c.outcome == OutcomeSuccess || c.outcome == OutcomePending // I1
|
||||||
|
code := ExitCodeForEnvelope(c.env)
|
||||||
|
if (code == 0) != wantOK {
|
||||||
|
t.Fatalf("outcome=%q: exit=%d but ok=%v (I2: exit 0 iff ok)", c.outcome, code, wantOK)
|
||||||
|
}
|
||||||
|
if (c.env.IsOK() != wantOK) || ((code == 0) != c.env.IsOK()) {
|
||||||
|
t.Fatalf("outcome=%q: IsOK()=%v exit=%d disagree (I1/I2 cross-check)", c.outcome, c.env.IsOK(), code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failure 全类别细分(api/auth/validation/discovery/internal/unknown/无明细)
|
||||||
|
// 无一 exit 0:ok=false 分支的反向穷举。
|
||||||
|
failureVariants := []*Envelope{
|
||||||
|
NewFailureEnvelope(&ErrorInfo{Type: "api"}),
|
||||||
|
NewFailureEnvelope(&ErrorInfo{Type: "auth"}),
|
||||||
|
NewFailureEnvelope(&ErrorInfo{Type: "validation"}),
|
||||||
|
NewFailureEnvelope(&ErrorInfo{Type: "discovery"}),
|
||||||
|
NewFailureEnvelope(&ErrorInfo{Type: "internal"}),
|
||||||
|
NewFailureEnvelope(&ErrorInfo{Type: "mystery"}),
|
||||||
|
NewFailureEnvelope(&ErrorInfo{Subtype: "confirmation_required"}), // type 缺席 + confirmation 子类:仍 3,非 0
|
||||||
|
}
|
||||||
|
for i, env := range failureVariants {
|
||||||
|
if got := ExitCodeForEnvelope(env); got == 0 {
|
||||||
|
t.Fatalf("failure variant[%d] must never be exit 0 (I2)", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 边界组合:nil 信封与非法 outcome 同样非零(ok=false 语义)。
|
||||||
|
if ExitCodeForEnvelope(nil) == 0 {
|
||||||
|
t.Fatal("nil envelope must never be exit 0 (I2)")
|
||||||
|
}
|
||||||
|
if ExitCodeForEnvelope(&Envelope{OK: true, Outcome: "succeeded"}) == 0 {
|
||||||
|
t.Fatal("invalid outcome must never be exit 0 (I2)")
|
||||||
|
}
|
||||||
|
// partial_failure 专用码精确断言(非零且恰为 7,§4)。
|
||||||
|
if got := ExitCodeForEnvelope(newPartialEnvelopeFixture()); got != 7 {
|
||||||
|
t.Fatalf("partial_failure exit code = %d, want dedicated 7 (§4)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B32/B33 联动:Emitter 流路由与 ExitCodeForEnvelope 一致 ---
|
||||||
|
|
||||||
|
func TestEmitterStreamRoutingConsistentWithExitCode(t *testing.T) {
|
||||||
|
// Emit 的流选择与退出码语义一一对应(exit 0 语义标注的行为端):
|
||||||
|
// - ok:true(success/pending)→ stdout + exit 0(I2);
|
||||||
|
// - partial_failure(ok:false)→ stdout(§2.3 stderr 零输出)+ exit 7;
|
||||||
|
// - failure(含 nil 降级)→ stderr + exit 非零,stdout 严格零字节。
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
env *Envelope
|
||||||
|
wantStdout bool // 信封数据通道是否落 stdout
|
||||||
|
wantExitZero bool // I2:exit 0 ⇔ ok
|
||||||
|
}{
|
||||||
|
{"success", NewSuccessEnvelope(map[string]any{"id": "x"}), true, true},
|
||||||
|
{"pending", NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"}), true, true},
|
||||||
|
{"partial_failure", newPartialEnvelopeFixture(), true, false},
|
||||||
|
{"failure", NewFailureEnvelope(&ErrorInfo{Type: "api"}), false, false},
|
||||||
|
{"nil→fallback failure", nil, false, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
if err := em.Emit(c.env); err != nil {
|
||||||
|
t.Fatalf("Emit: %v", err)
|
||||||
|
}
|
||||||
|
code := ExitCodeForEnvelope(c.env)
|
||||||
|
if (code == 0) != c.wantExitZero {
|
||||||
|
t.Fatalf("exit code = %d, wantExitZero=%v (I2)", code, c.wantExitZero)
|
||||||
|
}
|
||||||
|
if c.wantStdout {
|
||||||
|
if stdout.Len() == 0 {
|
||||||
|
t.Fatal("stdout-routed envelope must carry payload on stdout")
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stdout-routed envelope must keep stderr empty (§2.3/§5.1), got %d bytes", stderr.Len())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("failure must leave stdout empty, got %d bytes", stdout.Len())
|
||||||
|
}
|
||||||
|
if stderr.Len() == 0 {
|
||||||
|
t.Fatal("failure envelope must be emitted on stderr")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// partial_failure 专用码精确断言(§4):stdout 通道但非零。
|
||||||
|
if c.env != nil && c.env.Outcome == OutcomePartialFailure && code != 7 {
|
||||||
|
t.Fatalf("partial_failure exit code = %d, want dedicated 7", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B208:nil 兜底信封 I3 合法化(轮6裁决⑨)---
|
||||||
|
|
||||||
|
func TestNilFallbackEnvelopeCarriesInternalErrorInfo(t *testing.T) {
|
||||||
|
// 兜底信封必须携带 internal 类 ErrorInfo(框架内部缺陷),
|
||||||
|
// 自身通过 Validate()——不得再是被校验器拒绝的形态。
|
||||||
|
env := nilFallbackEnvelope()
|
||||||
|
if env.Error == nil {
|
||||||
|
t.Fatal("nil fallback envelope must carry ErrorInfo (I3: error 非空 ⇔ failure)")
|
||||||
|
}
|
||||||
|
if env.Error.Type != "internal" {
|
||||||
|
t.Fatalf("fallback error.type = %q, want internal", env.Error.Type)
|
||||||
|
}
|
||||||
|
if env.Error.Message != "nil envelope: framework fallback" {
|
||||||
|
t.Fatalf("fallback error.message = %q, want %q", env.Error.Message, "nil envelope: framework fallback")
|
||||||
|
}
|
||||||
|
if env.OK || env.Outcome != OutcomeFailure {
|
||||||
|
t.Fatalf("fallback envelope = ok:%v outcome:%q, want ok:false outcome:failure", env.OK, env.Outcome)
|
||||||
|
}
|
||||||
|
if err := env.Validate(); err != nil {
|
||||||
|
t.Fatalf("nil fallback envelope must pass Validate(), got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvelopeToNilEnvelopeEmitsLegalFailure(t *testing.T) {
|
||||||
|
// WriteEnvelopeTo 的 nil 兜底:不 panic、必产合法 failure,
|
||||||
|
// error 明细在 wire 上可解码且类型/消息正确。
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, nil, FormatJSON, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(nil) must not fail: %v", err)
|
||||||
|
}
|
||||||
|
var decoded struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Outcome string `json:"outcome"`
|
||||||
|
Error *ErrorInfo `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("fallback envelope is not valid JSON: %v\n%s", err, buf.String())
|
||||||
|
}
|
||||||
|
if decoded.OK || decoded.Outcome != "failure" {
|
||||||
|
t.Fatalf("fallback = ok:%v outcome:%q, want ok:false outcome:failure: %s", decoded.OK, decoded.Outcome, buf.String())
|
||||||
|
}
|
||||||
|
if decoded.Error == nil {
|
||||||
|
t.Fatalf("fallback envelope must carry error detail on wire: %s", buf.String())
|
||||||
|
}
|
||||||
|
if decoded.Error.Type != "internal" || decoded.Error.Message != "nil envelope: framework fallback" {
|
||||||
|
t.Fatalf("fallback error = %+v, want internal / nil envelope: framework fallback", decoded.Error)
|
||||||
|
}
|
||||||
|
// 兜底信封与 ExitCodeForEnvelope 同源:internal → 5(非零,I2)。
|
||||||
|
if code := ExitCodeForEnvelope(nil); code == 0 {
|
||||||
|
t.Fatal("nil fallback must map to non-zero exit code")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
// 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 output
|
||||||
|
|
||||||
|
// Phase C 收尾(B41/B42;契约规范 §5.2)。落盘策略:轮8裁决⑩新文件——
|
||||||
|
// 不编辑 emitter_phase_c_test.go 既有断言。
|
||||||
|
//
|
||||||
|
// 既有覆盖对照:raw≠json 逐字节差异与 raw=writeRaw(data) 透传已由
|
||||||
|
// TestRawPassThroughEnvelopeDiffersFromJSONByteForByte 覆盖;jq 优先于
|
||||||
|
// -f table 已由 TestJQOverridesFormatAndEvaluatesFullEnvelope 覆盖。
|
||||||
|
// 本文件补全:raw 适用域(仅 stdout 数据通道)+ raw×fields / raw×jq
|
||||||
|
// 穷举断言(B41),以及 jq × 全六格式穷举 + jq 对 fields 的优先级(B42)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- B41:-f raw 透传完整语义——适用域穷举 ---
|
||||||
|
|
||||||
|
func TestRawDispatchScopeIsStdoutDataChannelOnly(t *testing.T) {
|
||||||
|
// §5.2 分发矩阵的适用域仅 stdout 数据通道(轮8裁决⑪):携带数据载荷的
|
||||||
|
// outcome(success/pending/partial_failure)在 raw 下透传 data;failure
|
||||||
|
// 信封即使被配置为 raw 也恒以完整 JSON 走 stderr、stdout 严格零字节。
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
env *Envelope
|
||||||
|
}{
|
||||||
|
{"success", phaseCListFixture()},
|
||||||
|
{"pending", newRawPendingFixture()},
|
||||||
|
{"partial_failure", newRawPartialFixture(t)},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatRaw, "", "")
|
||||||
|
if err := em.Emit(c.env); err != nil {
|
||||||
|
t.Fatalf("Emit raw: %v", err)
|
||||||
|
}
|
||||||
|
if stdout.Len() == 0 {
|
||||||
|
t.Fatal("data-carrying envelope under raw must emit on stdout")
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("data channel must keep stderr empty, got %d bytes: %s", stderr.Len(), stderr.String())
|
||||||
|
}
|
||||||
|
// raw 输出不含信封外壳键(透传的是 data 载荷本身)。
|
||||||
|
if strings.Contains(stdout.String(), `"outcome"`) {
|
||||||
|
t.Fatalf("raw must pass through data only, no envelope wrapper: %s", stdout.String())
|
||||||
|
}
|
||||||
|
// 透传等价于 writeRaw(data)(与分发点单一渲染一致)。
|
||||||
|
var direct bytes.Buffer
|
||||||
|
if err := Write(&direct, FormatRaw, c.env.Data); err != nil {
|
||||||
|
t.Fatalf("Write(raw, data): %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(stdout.Bytes(), direct.Bytes()) {
|
||||||
|
t.Fatalf("raw stdout must be verbatim writeRaw(data):\n got: %s\nwant: %s", stdout.String(), direct.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// failure 信封:raw 配置被绕过(轮8裁决⑪)——stderr 完整 JSON、stdout 零。
|
||||||
|
t.Run("failure bypasses raw", func(t *testing.T) {
|
||||||
|
failure := NewFailureEnvelope(&ErrorInfo{Type: "api", Code: 40001, Message: "boom"})
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatRaw, "id", ".data")
|
||||||
|
if err := em.Emit(failure); err != nil {
|
||||||
|
t.Fatalf("Emit raw failure: %v", err)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("failure must leave stdout empty under any format, got %d bytes", stdout.Len())
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("failure under raw must stay full JSON on stderr: %v\n%s", err, stderr.String())
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "failure" || decoded["error"] == nil {
|
||||||
|
t.Fatalf("failure envelope lost wire-stable fields under raw bypass: %s", stderr.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 分发点层同判:WriteEnvelopeTo(无流路由签名)下 failure + raw 也恒为
|
||||||
|
// 完整 JSON 信封——raw 的适用域不含失败通道。
|
||||||
|
t.Run("dispatch point also bypasses raw for failure", func(t *testing.T) {
|
||||||
|
failure := NewFailureEnvelope(&ErrorInfo{Type: "validation", Param: "--name"})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, failure, FormatRaw, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo failure raw: %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("failure under raw at dispatch point must be full JSON: %v\n%s", err, buf.String())
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "failure" {
|
||||||
|
t.Fatalf("outcome = %v: %s", decoded["outcome"], buf.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRawPendingFixture() *Envelope {
|
||||||
|
// pending 形态携带 data 载荷:raw 适用域覆盖所有携带数据的 outcome。
|
||||||
|
env := NewPendingEnvelope(&OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1"})
|
||||||
|
env.Data = map[string]any{"taskId": "t_1", "accepted": true}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRawPartialFixture(t *testing.T) *Envelope {
|
||||||
|
t.Helper()
|
||||||
|
pd, err := NewPartialData(2,
|
||||||
|
[]any{map[string]any{"id": "a", "messageId": "m_1"}},
|
||||||
|
[]PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api", Code: 40001}}},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPartialData: %v", err)
|
||||||
|
}
|
||||||
|
return NewPartialEnvelope(pd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B41:raw × fields / raw × jq 穷举 ---
|
||||||
|
|
||||||
|
func TestRawTimesFieldsProjectsDataBeforePassThrough(t *testing.T) {
|
||||||
|
// --fields 先投影数据载荷再按 format 分发(成功通道联动):raw 下
|
||||||
|
// fields 作用于 data 载荷本身——透传的是投影后的载荷,信封外壳仍缺席。
|
||||||
|
env := phaseCListFixture()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, env, FormatRaw, "id", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(raw, fields=id): %v", err)
|
||||||
|
}
|
||||||
|
var rows []map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &rows); err != nil {
|
||||||
|
t.Fatalf("raw×fields output must be decodable JSON of projected data: %v\n%s", err, buf.String())
|
||||||
|
}
|
||||||
|
if len(rows) != 3 {
|
||||||
|
t.Fatalf("rows = %d, want 3: %s", len(rows), buf.String())
|
||||||
|
}
|
||||||
|
for i, row := range rows {
|
||||||
|
if len(row) != 1 {
|
||||||
|
t.Fatalf("row %d must be projected to id only: %v", i, row)
|
||||||
|
}
|
||||||
|
if _, has := row["id"]; !has {
|
||||||
|
t.Fatalf("row %d lost id after projection: %v", i, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 信封外壳键不出现(raw 恒只透传 data 通道)。
|
||||||
|
if strings.Contains(buf.String(), `"outcome"`) || strings.Contains(buf.String(), `"meta"`) {
|
||||||
|
t.Fatalf("raw×fields must not emit envelope wrapper: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawTimesJQJQWinsAndEvaluatesFullEnvelope(t *testing.T) {
|
||||||
|
// --jq 优先于 format(含 raw):对**完整信封**求值后直接输出结果——
|
||||||
|
// raw×jq 下 jq 表达式可触及信封外壳键(.ok/.outcome),证明求值对象是
|
||||||
|
// 完整信封而非 raw 的 data 载荷。
|
||||||
|
for _, tc := range []struct {
|
||||||
|
jq string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{".ok", "true"},
|
||||||
|
{".outcome", `"success"`},
|
||||||
|
{".meta.pagination.next_token", `"cursor_xyz"`},
|
||||||
|
} {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatRaw, "", tc.jq); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(raw, jq=%q): %v", tc.jq, err)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(buf.String()); got != tc.want {
|
||||||
|
t.Fatalf("raw×jq %q = %q, want %q (jq must win over raw on the full envelope)", tc.jq, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// raw×jq×fields 三者同设:jq 仍恒赢——输出是 jq 求值结果,fields 不得
|
||||||
|
// 先行改写求值输入(jq 优先级链在分发点入口,fields 投影只在无 jq 分支)。
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatRaw, "id", ".outcome"); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(raw, fields+jq): %v", err)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(buf.String()); got != `"success"` {
|
||||||
|
t.Fatalf("raw×fields×jq = %q, want \"success\" (jq wins over both)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawStringPayloadVerbatimPassThrough(t *testing.T) {
|
||||||
|
// raw 完整语义的字符串分支:data 为字符串时 raw 输出净化后的原文本
|
||||||
|
// (writeRaw 字符串分支),不是 JSON 引号包裹——这是「透传」的第二形态。
|
||||||
|
env := NewSuccessEnvelope("hello raw world")
|
||||||
|
var rawBuf, jsonBuf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&rawBuf, env, FormatRaw, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(raw): %v", err)
|
||||||
|
}
|
||||||
|
if err := WriteEnvelopeTo(&jsonBuf, env, FormatJSON, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(json): %v", err)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(rawBuf.String()); got != "hello raw world" {
|
||||||
|
t.Fatalf("raw string payload = %q, want verbatim text", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(rawBuf.String(), `"hello`) {
|
||||||
|
t.Fatalf("raw string branch must not JSON-quote the payload: %s", rawBuf.String())
|
||||||
|
}
|
||||||
|
// json 形态仍是完整信封(两形态互不污染)。
|
||||||
|
if !strings.Contains(jsonBuf.String(), `"outcome"`) {
|
||||||
|
t.Fatalf("json must keep the envelope wrapper: %s", jsonBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B42:--jq 优先于 format——全六格式穷举对完整信封求值 ---
|
||||||
|
|
||||||
|
func TestJQOverridesAllSixFormatsEvaluatingFullEnvelope(t *testing.T) {
|
||||||
|
// jq 优先级对 §5.2 矩阵全部六格式成立:无论 format 是什么,输出恒为
|
||||||
|
// jq 对完整信封的求值结果——matrix 各行(table/pretty/csv/ndjson/raw/
|
||||||
|
// json)全部被 jq 短路。
|
||||||
|
formats := []Format{FormatJSON, FormatTable, FormatPretty, FormatCSV, FormatNDJSON, FormatRaw}
|
||||||
|
for _, format := range formats {
|
||||||
|
t.Run("format="+string(format), func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), format, "", ".outcome"); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(%s, jq=.outcome): %v", format, err)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(buf.String()); got != `"success"` {
|
||||||
|
t.Fatalf("format %s × jq = %q, want \"success\" (jq must win over every format)", format, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Emitter 路径同判(双向出口共用分发点):-f csv + jq 下 stdout 仍是
|
||||||
|
// jq 结果而非裸记录。
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatCSV, "", ".meta.count")
|
||||||
|
if err := em.Emit(phaseCListFixture()); err != nil {
|
||||||
|
t.Fatalf("Emit csv+jq: %v", err)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(stdout.String()); got != "3" {
|
||||||
|
t.Fatalf("Emitter csv×jq = %q, want 3", got)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("jq path must keep stderr empty, got %d bytes: %s", stderr.Len(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJQWinsOverFieldsAsWell(t *testing.T) {
|
||||||
|
// jq 与 fields 同设:jq 恒赢,fields 不得改写 jq 的求值输入。
|
||||||
|
// 求值对象是可触及 meta 的完整信封——若 fields 先行投影,.meta 将缺席。
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatJSON, "id", ".meta.count"); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(fields+jq): %v", err)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(buf.String()); got != "3" {
|
||||||
|
t.Fatalf("fields×jq = %q, want 3 (jq must win over fields on the full envelope)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJQInvalidExpressionFailsCleanly(t *testing.T) {
|
||||||
|
// jq 表达式非法时求值失败即报错(validation 类)——不静默降级回 format
|
||||||
|
// 输出,也不泄漏半截渲染(buffer-first)。
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", ".data[")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("invalid jq expression must fail, got output: %s", buf.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "jq") {
|
||||||
|
t.Fatalf("error must name jq: %v", err)
|
||||||
|
}
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Fatalf("invalid jq must leave zero bytes on the target (buffer-first): %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,822 @@
|
|||||||
|
// 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 output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// phaseCListFixture 是三格式(table/csv/ndjson)共用的列表载荷:
|
||||||
|
// 3 条同构对象记录,配 meta.count 与 meta.pagination 以便同时断言
|
||||||
|
// 「人读摘要」与「分页诊断行」两条分发行为。
|
||||||
|
func phaseCListFixture() *Envelope {
|
||||||
|
env := NewSuccessEnvelope([]any{
|
||||||
|
map[string]any{"id": "a", "name": "alpha"},
|
||||||
|
map[string]any{"id": "b", "name": "beta"},
|
||||||
|
map[string]any{"id": "c", "name": "gamma"},
|
||||||
|
})
|
||||||
|
env.Meta = &Meta{
|
||||||
|
Count: NewCount(3),
|
||||||
|
Pagination: &Pagination{
|
||||||
|
EndpointExhausted: false,
|
||||||
|
Pages: 2,
|
||||||
|
Items: 60,
|
||||||
|
NextToken: "cursor_xyz",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B35:format 值归一化(trim/小写)——normalizeFormat 既有实现的补差断言 ---
|
||||||
|
|
||||||
|
func TestNormalizeFormatTrimAndCaseExhaustive(t *testing.T) {
|
||||||
|
// 六格式 × (原始小写 / 大写 / 混合大小写 + 前后空白)全部命中对应常量;
|
||||||
|
// 归一化规则 trim + 小写,与 ResolveFormat/ParseFormat 单一事实源。
|
||||||
|
formats := []Format{FormatJSON, FormatTable, FormatRaw, FormatPretty, FormatNDJSON, FormatCSV}
|
||||||
|
for _, want := range formats {
|
||||||
|
base := string(want)
|
||||||
|
for _, raw := range []string{
|
||||||
|
base,
|
||||||
|
strings.ToUpper(base),
|
||||||
|
" " + strings.ToUpper(base[:1]) + base[1:] + "\t",
|
||||||
|
} {
|
||||||
|
if got := normalizeFormat(raw, FormatJSON); got != want {
|
||||||
|
t.Fatalf("normalizeFormat(%q) = %q, want %q", raw, got, want)
|
||||||
|
}
|
||||||
|
// ParseFormat 薄包装与 normalizeFormat 结果恒一致(单一事实源)。
|
||||||
|
if got := ParseFormat(raw, FormatJSON); got != want {
|
||||||
|
t.Fatalf("ParseFormat(%q) = %q, want %q", raw, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 空串 = 未指定 → fallback;未知值 → fallback(B36 降级的归一化前提)。
|
||||||
|
if got := normalizeFormat("", FormatTable); got != FormatTable {
|
||||||
|
t.Fatalf("normalizeFormat empty = %q, want fallback table", got)
|
||||||
|
}
|
||||||
|
if got := normalizeFormat(" \t ", FormatCSV); got != FormatCSV {
|
||||||
|
t.Fatalf("normalizeFormat whitespace-only = %q, want fallback csv", got)
|
||||||
|
}
|
||||||
|
if got := normalizeFormat("yaml", FormatJSON); got != FormatJSON {
|
||||||
|
t.Fatalf("normalizeFormat unknown = %q, want fallback json", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B36:未知 format 降级 + stderr warning(不崩不静默,AC-09)---
|
||||||
|
|
||||||
|
func TestResolveFormatWithWarningKnownValuesSilent(t *testing.T) {
|
||||||
|
formats := []Format{FormatJSON, FormatTable, FormatRaw, FormatPretty, FormatNDJSON, FormatCSV}
|
||||||
|
for _, want := range formats {
|
||||||
|
cmd := &cobra.Command{Use: "t"}
|
||||||
|
cmd.Flags().String("format", "", "")
|
||||||
|
if err := cmd.Flags().Set("format", string(want)); err != nil {
|
||||||
|
t.Fatalf("set format: %v", err)
|
||||||
|
}
|
||||||
|
got, warning := resolveFormatWithWarning(cmd, FormatJSON)
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("resolveFormatWithWarning(%q) = %q, want %q", want, got, want)
|
||||||
|
}
|
||||||
|
if warning != "" {
|
||||||
|
t.Fatalf("known format %q must not warn, got %q", want, warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveFormatWithWarningUnknownDegradesWithWarning(t *testing.T) {
|
||||||
|
for _, fallback := range []Format{FormatJSON, FormatTable} {
|
||||||
|
cmd := &cobra.Command{Use: "t"}
|
||||||
|
cmd.Flags().String("format", "", "")
|
||||||
|
if err := cmd.Flags().Set("format", "yaml"); err != nil {
|
||||||
|
t.Fatalf("set format: %v", err)
|
||||||
|
}
|
||||||
|
got, warning := resolveFormatWithWarning(cmd, fallback)
|
||||||
|
if got != fallback {
|
||||||
|
t.Fatalf("unknown format must degrade to fallback %q, got %q", fallback, got)
|
||||||
|
}
|
||||||
|
if warning == "" {
|
||||||
|
t.Fatal("unknown format must produce a warning (AC-09: no silent swallow)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(warning, "yaml") || !strings.Contains(warning, string(fallback)) {
|
||||||
|
t.Fatalf("warning must name the unknown value and the fallback target: %q", warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveFormatWithWarningEmptyAndMissingSilent(t *testing.T) {
|
||||||
|
// 空值 = 未指定(走 fallback 是正常默认路径),flag 缺席同:均不报警。
|
||||||
|
cmd := &cobra.Command{Use: "t"}
|
||||||
|
cmd.Flags().String("format", "", "")
|
||||||
|
if got, warning := resolveFormatWithWarning(cmd, FormatRaw); got != FormatRaw || warning != "" {
|
||||||
|
t.Fatalf("empty format = (%q, %q), want (raw, silent)", got, warning)
|
||||||
|
}
|
||||||
|
cmd2 := &cobra.Command{Use: "t2"}
|
||||||
|
if got, warning := resolveFormatWithWarning(cmd2, FormatRaw); got != FormatRaw || warning != "" {
|
||||||
|
t.Fatalf("missing flag = (%q, %q), want (raw, silent)", got, warning)
|
||||||
|
}
|
||||||
|
if got, warning := resolveFormatWithWarning(nil, FormatJSON); got != FormatJSON || warning != "" {
|
||||||
|
t.Fatalf("nil cmd = (%q, %q), want (json, silent)", got, warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveFormatKeepsWarningFreeReturnContract(t *testing.T) {
|
||||||
|
// ResolveFormat(既有导出 API)行为不变:只返回 format,未知值静默降级。
|
||||||
|
cmd := &cobra.Command{Use: "t"}
|
||||||
|
cmd.Flags().String("format", "", "")
|
||||||
|
if err := cmd.Flags().Set("format", "bogus"); err != nil {
|
||||||
|
t.Fatalf("set format: %v", err)
|
||||||
|
}
|
||||||
|
if got := ResolveFormat(cmd, FormatJSON); got != FormatJSON {
|
||||||
|
t.Fatalf("ResolveFormat unknown = %q, want json fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvelopeUnknownFormatDegradesToJSONWithStderrWarning(t *testing.T) {
|
||||||
|
// 端到端:未知 --format 不崩不静默——stdout 仍为完整 json 信封(降级
|
||||||
|
// 生效,数据不丢),stderr 恰有一条 warning 点名未知值与降级目标。
|
||||||
|
cmd := &cobra.Command{Use: "t"}
|
||||||
|
cmd.Flags().String("format", "", "")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
if err := cmd.Flags().Set("format", "yaml"); err != nil {
|
||||||
|
t.Fatalf("set format: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
env := NewSuccessEnvelope(map[string]any{"name": "DemoApp"})
|
||||||
|
if err := WriteEnvelope(cmd, env, FormatJSON); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelope with unknown format must not crash: %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stdout must degrade to a full json envelope: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "success" {
|
||||||
|
t.Fatalf("degraded envelope outcome = %v, want success: %s", decoded["outcome"], stdout.String())
|
||||||
|
}
|
||||||
|
data, _ := decoded["data"].(map[string]any)
|
||||||
|
if data == nil || data["name"] != "DemoApp" {
|
||||||
|
t.Fatalf("degraded envelope lost data payload: %s", stdout.String())
|
||||||
|
}
|
||||||
|
if got := strings.Count(stderr.String(), "[WARN]"); got != 1 {
|
||||||
|
t.Fatalf("stderr must carry exactly one warning line, got %d: %s", got, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "yaml") || !strings.Contains(stderr.String(), "json") {
|
||||||
|
t.Fatalf("warning must name unknown value and fallback: %s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvelopeKnownFormatLeavesStderrSilent(t *testing.T) {
|
||||||
|
// 已知 format 不产生 warning(stderr 零字节),防 B36 误伤正常路径。
|
||||||
|
cmd := &cobra.Command{Use: "t"}
|
||||||
|
cmd.Flags().String("format", "", "")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
if err := cmd.Flags().Set("format", "table"); err != nil {
|
||||||
|
t.Fatalf("set format: %v", err)
|
||||||
|
}
|
||||||
|
if err := WriteEnvelope(cmd, NewSuccessEnvelope(map[string]any{"name": "x"}), FormatJSON); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelope: %v", err)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("known format must not warn, stderr = %d bytes: %s", stderr.Len(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B37:信封渲染接统一 format 分发点(WS1 改动点4)---
|
||||||
|
|
||||||
|
func TestRenderEnvelopeSingleDispatchPointEmitterAndWriterAgree(t *testing.T) {
|
||||||
|
// 分发点唯一性:Emitter 路径与 WriteEnvelopeTo 路径对同一信封 + 同一
|
||||||
|
// format 逐字节一致——两条出口共用 renderEnvelope,不存在第二套渲染。
|
||||||
|
formats := []Format{FormatJSON, FormatTable, FormatPretty, FormatCSV, FormatNDJSON, FormatRaw}
|
||||||
|
for _, format := range formats {
|
||||||
|
env := phaseCListFixture()
|
||||||
|
var direct bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&direct, env, format, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(%s): %v", format, err)
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if err := NewEmitter(&stdout, &stderr, format, "", "").Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit(%s): %v", format, err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(direct.Bytes(), stdout.Bytes()) {
|
||||||
|
t.Fatalf("format %s: Emitter and WriteEnvelopeTo disagree:\n writer: %s\nemitter: %s",
|
||||||
|
format, direct.String(), stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderEnvelopeBufferFirstZeroLeakOnRenderFailure(t *testing.T) {
|
||||||
|
// §5.2 末条对整个分发点成立:渲染失败(不可序列化载荷 chan)
|
||||||
|
// 不向目标流泄漏任何字节——渲染段在写出前整体失败。
|
||||||
|
unrenderable := NewSuccessEnvelope(map[string]any{"bad": make(chan int)})
|
||||||
|
for _, format := range []Format{FormatJSON, FormatTable, FormatCSV, FormatNDJSON} {
|
||||||
|
target := &emitterFailingWriter{acceptN: 64}
|
||||||
|
err := WriteEnvelopeTo(target, unrenderable, format, "", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("format %s must surface render failure", format)
|
||||||
|
}
|
||||||
|
if len(target.accepted) != 0 {
|
||||||
|
t.Fatalf("format %s leaked %d bytes on render failure: %s", format, len(target.accepted), target.accepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderEnvelopeFailureEnvelopeBypassesFormatAtDispatchPoint(t *testing.T) {
|
||||||
|
// 轮8裁决⑪在分发点内落地:failure 信封即使被调用方以 table/jq 配置
|
||||||
|
// 送入 renderEnvelope(WriteEnvelopeTo 层无流路由),也恒输出完整 JSON
|
||||||
|
// 信封——format/jq/fields 不得抹掉 §2.4 wire-stable 字段组。
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit", Code: 90018, Retryable: true})
|
||||||
|
if err := WriteEnvelopeTo(&buf, env, FormatTable, "id", ".data"); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo failure under table/jq: %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("failure envelope must stay full JSON regardless of format: %v\n%s", err, buf.String())
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "failure" {
|
||||||
|
t.Fatalf("outcome = %v, want failure: %s", decoded["outcome"], buf.String())
|
||||||
|
}
|
||||||
|
errObj, ok := decoded["error"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("failure envelope lost error detail: %s", buf.String())
|
||||||
|
}
|
||||||
|
for _, key := range []string{"type", "subtype", "code", "retryable"} {
|
||||||
|
if _, has := errObj[key]; !has {
|
||||||
|
t.Fatalf("wire-stable key %q lost under format/jq bypass: %s", key, buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B38:-f json 输出完整信封(唯一 JSON 契约)---
|
||||||
|
|
||||||
|
func TestWriteEnvelopeJSONIsTheOnlyJSONContract(t *testing.T) {
|
||||||
|
// json 与空 format 等价:stdout 恰为一个完整信封 JSON 文档,
|
||||||
|
// ok/outcome/data/meta 外壳键齐备(唯一 JSON 契约,§5.2 首行)。
|
||||||
|
for _, format := range []Format{FormatJSON, ""} {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := phaseCListFixture()
|
||||||
|
if err := WriteEnvelopeTo(&buf, env, format, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(%q): %v", format, err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("format %q stdout is not valid JSON: %v\n%s", format, err, buf.String())
|
||||||
|
}
|
||||||
|
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
|
||||||
|
t.Fatalf("ok = %v (%T), want true (bool)", decoded["ok"], decoded["ok"])
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "success" {
|
||||||
|
t.Fatalf("outcome = %v, want success", decoded["outcome"])
|
||||||
|
}
|
||||||
|
for _, key := range []string{"data", "meta"} {
|
||||||
|
if _, has := decoded[key]; !has {
|
||||||
|
t.Fatalf("full envelope must carry %q: %s", key, buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvelopeJSONFieldsProjectionOnEnvelope(t *testing.T) {
|
||||||
|
// --fields 与 json 联动(成功通道):投影作用于信封顶层键。
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := NewSuccessEnvelope(map[string]any{"name": "DemoApp"})
|
||||||
|
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "ok,outcome", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
||||||
|
}
|
||||||
|
if _, has := decoded["ok"]; !has {
|
||||||
|
t.Fatalf("fields=ok,outcome must keep ok: %s", buf.String())
|
||||||
|
}
|
||||||
|
if _, has := decoded["outcome"]; !has {
|
||||||
|
t.Fatalf("fields=ok,outcome must keep outcome: %s", buf.String())
|
||||||
|
}
|
||||||
|
if _, has := decoded["data"]; has {
|
||||||
|
t.Fatalf("fields=ok,outcome must drop data: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJQOverridesFormatAndEvaluatesFullEnvelope(t *testing.T) {
|
||||||
|
// --jq 优先于 format(§5.2 矩阵行):-f table 下 jq 仍对**完整信封**
|
||||||
|
// 求值——.data 可取业务载荷、.ok/.outcome 可取框架字段。
|
||||||
|
for _, tc := range []struct {
|
||||||
|
jq string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{".ok", "true"},
|
||||||
|
{".outcome", `"success"`},
|
||||||
|
{".meta.pagination.next_token", `"cursor_xyz"`},
|
||||||
|
} {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", tc.jq); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo jq=%q: %v", tc.jq, err)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(buf.String()); got != tc.want {
|
||||||
|
t.Fatalf("jq %q = %q, want %q (jq must win over -f table on the full envelope)", tc.jq, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// .data 提取业务载荷:输出不再是信封(无 outcome 外壳键)。
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", ".data"); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo jq=.data: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(buf.String(), `"outcome"`) {
|
||||||
|
t.Fatalf("jq=.data output must not carry the envelope wrapper: %s", buf.String())
|
||||||
|
}
|
||||||
|
var rows []map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &rows); err != nil {
|
||||||
|
t.Fatalf("jq=.data must emit the bare data payload: %v\n%s", err, buf.String())
|
||||||
|
}
|
||||||
|
if len(rows) != 3 {
|
||||||
|
t.Fatalf("jq=.data rows = %d, want 3", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B39:-f table/pretty 输出业务数据 + 人读摘要(不输出信封外壳)---
|
||||||
|
|
||||||
|
func TestTableRendersDataWithMetaSummaryWithoutEnvelopeWrapper(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(table): %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
// 数据行可见。
|
||||||
|
for _, want := range []string{"alpha", "beta", "gamma"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("table output missing data row %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 人读摘要:count 与分页摘要(wire 字段名 snake_case,两态显式——
|
||||||
|
// endpoint_exhausted:false 也输出,它是「可续跑」语义)。
|
||||||
|
if !strings.Contains(out, "count: 3") {
|
||||||
|
t.Fatalf("table output missing count summary:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "pagination: endpoint_exhausted: false, pages: 2, items: 60, next_token: cursor_xyz") {
|
||||||
|
t.Fatalf("table output missing pagination summary:\n%s", out)
|
||||||
|
}
|
||||||
|
// 不输出信封外壳键。
|
||||||
|
for _, forbidden := range []string{`"ok"`, `"outcome"`, `"endpoint_exhausted": `} {
|
||||||
|
if strings.Contains(out, forbidden) {
|
||||||
|
t.Fatalf("table output must not emit envelope wrapper (%q):\n%s", forbidden, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// stdout 不是 JSON 文档(人读视图)。
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(out), "{") {
|
||||||
|
t.Fatalf("table output should not be a JSON document:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableWithoutMetaEmitsNoSummary(t *testing.T) {
|
||||||
|
// 无 meta 时不追加摘要行,不留空壳。
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := NewSuccessEnvelope([]any{map[string]any{"id": "a"}})
|
||||||
|
if err := WriteEnvelopeTo(&buf, env, FormatTable, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(table): %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if strings.Contains(out, "count:") || strings.Contains(out, "pagination:") {
|
||||||
|
t.Fatalf("no-meta table must not emit summary lines:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "a") {
|
||||||
|
t.Fatalf("table output missing data:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrettyRendersDataWithMetaSummaryWithoutEnvelopeWrapper(t *testing.T) {
|
||||||
|
forceNoColor(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatPretty, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(pretty): %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
for _, want := range []string{"alpha", "count: 3", "endpoint_exhausted: false"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("pretty output missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{`"ok"`, `"outcome"`} {
|
||||||
|
if strings.Contains(out, forbidden) {
|
||||||
|
t.Fatalf("pretty output must not emit envelope wrapper (%q):\n%s", forbidden, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableWithFieldsProjectionNarrowsColumns(t *testing.T) {
|
||||||
|
// --fields 与 table 联动(成功通道):投影先作用于数据载荷再渲染。
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "id", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(table, fields=id): %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
// id 列保留(值 a/b/c 可见),name 列与其值被投影掉。
|
||||||
|
for _, want := range []string{"id", "│ a", "│ b", "│ c"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("fields=id must keep id column/values (%q):\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "name") || strings.Contains(out, "alpha") {
|
||||||
|
t.Fatalf("fields=id must drop name column and its values:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B40:-f csv/ndjson 裸记录;分页元数据走 stderr 诊断行 ---
|
||||||
|
|
||||||
|
// newPhaseCCmd 构造带 --format flag 且 stdout/stderr 均可捕获的测试命令
|
||||||
|
// (诊断行断言必须走 cmd 出口路径:WriteEnvelopeTo 的 writer 级签名无
|
||||||
|
// stderr 出口,诊断行在该签名下按 io.Discard 处理)。
|
||||||
|
func newPhaseCCmd(t *testing.T, formatValue string) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := &cobra.Command{Use: "t"}
|
||||||
|
cmd.Flags().StringP("format", "f", "", "")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
if err := cmd.Flags().Set("format", formatValue); err != nil {
|
||||||
|
t.Fatalf("set format: %v", err)
|
||||||
|
}
|
||||||
|
return cmd, &stdout, &stderr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNDJSONEmitsBareRecordsWithoutEnvelope(t *testing.T) {
|
||||||
|
cmd, stdout, stderr := newPhaseCCmd(t, "ndjson")
|
||||||
|
env := NewSuccessEnvelope([]any{
|
||||||
|
map[string]any{"id": "a"},
|
||||||
|
map[string]any{"id": "b"},
|
||||||
|
map[string]any{"id": "c"},
|
||||||
|
})
|
||||||
|
if err := WriteEnvelope(cmd, env, FormatJSON); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelope(ndjson): %v", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
|
||||||
|
if len(lines) != 3 {
|
||||||
|
t.Fatalf("ndjson must emit one bare record per line, got %d:\n%s", len(lines), stdout.String())
|
||||||
|
}
|
||||||
|
for i, line := range lines {
|
||||||
|
var rec map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||||
|
t.Fatalf("ndjson line %d is not valid JSON: %v", i, err)
|
||||||
|
}
|
||||||
|
if _, has := rec["outcome"]; has {
|
||||||
|
t.Fatalf("ndjson line %d carries envelope wrapper: %s", i, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无 pagination 时 stderr 零诊断行。
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("no-pagination ndjson must keep diagnostics silent, got %d bytes: %s", stderr.Len(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSVEmitsBareRecordsWithoutEnvelope(t *testing.T) {
|
||||||
|
cmd, stdout, stderr := newPhaseCCmd(t, "csv")
|
||||||
|
env := NewSuccessEnvelope([]any{
|
||||||
|
map[string]any{"id": "a", "name": "alpha"},
|
||||||
|
map[string]any{"id": "b", "name": "beta"},
|
||||||
|
})
|
||||||
|
if err := WriteEnvelope(cmd, env, FormatJSON); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelope(csv): %v", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
|
||||||
|
if len(lines) != 3 { // header + 2 records
|
||||||
|
t.Fatalf("csv must emit header + one row per record, got %d lines:\n%s", len(lines), stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(lines[0], "id") || !strings.Contains(lines[0], "name") {
|
||||||
|
t.Fatalf("csv header missing columns: %s", lines[0])
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String(), `"outcome"`) || strings.Contains(stdout.String(), `"ok"`) {
|
||||||
|
t.Fatalf("csv must not emit envelope wrapper keys:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("no-pagination csv must keep diagnostics silent, got %d bytes: %s", stderr.Len(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSVNDJSONPaginationMetadataGoesToStderrDiagnostic(t *testing.T) {
|
||||||
|
// 分页元数据不进裸记录流,走 stderr 诊断行(§5.2 csv/ndjson 行)。
|
||||||
|
for _, format := range []Format{FormatCSV, FormatNDJSON} {
|
||||||
|
cmd, stdout, stderr := newPhaseCCmd(t, string(format))
|
||||||
|
if err := WriteEnvelope(cmd, phaseCListFixture(), FormatJSON); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelope(%s): %v", format, err)
|
||||||
|
}
|
||||||
|
diag := stderr.String()
|
||||||
|
if !strings.Contains(diag, "[pagination]") {
|
||||||
|
t.Fatalf("%s pagination diagnostic missing from stderr: %q", format, diag)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"endpoint_exhausted: false", "next_token: cursor_xyz"} {
|
||||||
|
if !strings.Contains(diag, want) {
|
||||||
|
t.Fatalf("%s diagnostic missing %q: %q", format, want, diag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := strings.Count(diag, "[pagination]"); got != 1 {
|
||||||
|
t.Fatalf("%s must emit exactly one pagination diagnostic line, got %d: %q", format, got, diag)
|
||||||
|
}
|
||||||
|
// stdout 保持纯裸记录:无诊断行、无信封键、无分页键。
|
||||||
|
out := stdout.String()
|
||||||
|
if strings.Contains(out, "[pagination]") || strings.Contains(out, "next_token") || strings.Contains(out, `"outcome"`) {
|
||||||
|
t.Fatalf("%s stdout polluted by diagnostics/envelope:\n%s", format, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitterCSVNDJSONDiagnosticRoutedToEmitterStderr(t *testing.T) {
|
||||||
|
// Emitter 路径:诊断行落 Emitter 的 stderr writer(可注入),stdout
|
||||||
|
// 只承载裸记录;流路由纪律不变(success → stdout)。
|
||||||
|
for _, format := range []Format{FormatCSV, FormatNDJSON} {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, format, "", "")
|
||||||
|
if err := em.Emit(phaseCListFixture()); err != nil {
|
||||||
|
t.Fatalf("Emit(%s): %v", format, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "[pagination]") {
|
||||||
|
t.Fatalf("%s: emitter stderr must carry pagination diagnostic: %q", format, stderr.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String(), "[pagination]") {
|
||||||
|
t.Fatalf("%s: stdout must stay bare records:\n%s", format, stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialFailureCSVKeepsStderrZero(t *testing.T) {
|
||||||
|
// §2.3 特例优先:partial_failure 的 stderr 零输出约束压过 csv/ndjson
|
||||||
|
// 的诊断行通则——即便携带分页元数据也不写诊断行。
|
||||||
|
env := newPartialEnvelopeFixture()
|
||||||
|
env.Meta = &Meta{
|
||||||
|
Count: NewCount(3),
|
||||||
|
Pagination: &Pagination{EndpointExhausted: true},
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatCSV, "", "")
|
||||||
|
if err := em.Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit partial under csv: %v", err)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("partial_failure must keep stderr zero (§2.3), got %d bytes: %s", stderr.Len(), stderr.String())
|
||||||
|
}
|
||||||
|
if stdout.Len() == 0 {
|
||||||
|
t.Fatal("partial_failure details must stay on stdout (§2.3)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B43:--json 语义 = --format json 简写(显式 --format 优先)---
|
||||||
|
|
||||||
|
func TestResolveFormatWithJSONShorthandMatrix(t *testing.T) {
|
||||||
|
// 优先级链穷举(契约规范 §5.2:「--json 自动注册为 --format json 简写
|
||||||
|
// (显式 --format 优先)」):显式 --format 非空值恒优先;--format 缺席
|
||||||
|
// 或为空时 --json=true 等价 --format json;两者皆无 → fallback。
|
||||||
|
newCmd := func(formatSet string, formatValue string, jsonSet bool, jsonValue bool) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "t"}
|
||||||
|
cmd.Flags().String("format", "", "")
|
||||||
|
cmd.Flags().Bool("json", false, "")
|
||||||
|
if formatSet != "" {
|
||||||
|
if err := cmd.Flags().Set("format", formatValue); err != nil {
|
||||||
|
t.Fatalf("set format: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if jsonSet {
|
||||||
|
if err := cmd.Flags().Set("json", jsonFlagValue(jsonValue)); err != nil {
|
||||||
|
t.Fatalf("set json: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cmd *cobra.Command
|
||||||
|
fallback Format
|
||||||
|
want Format
|
||||||
|
}{
|
||||||
|
{"json shorthand alone", newCmd("", "", true, true), FormatTable, FormatJSON},
|
||||||
|
{"explicit format wins over json shorthand", newCmd("set", "table", true, true), FormatJSON, FormatTable},
|
||||||
|
{"explicit raw wins over json shorthand", newCmd("set", "raw", true, true), FormatJSON, FormatRaw},
|
||||||
|
{"empty format does not shadow shorthand", newCmd("set", "", true, true), FormatTable, FormatJSON},
|
||||||
|
{"json=false is not shorthand", newCmd("", "", true, false), FormatTable, FormatTable},
|
||||||
|
{"neither flag", newCmd("", "", false, false), FormatTable, FormatTable},
|
||||||
|
{"unknown format not rescued by json shorthand", newCmd("set", "yaml", true, true), FormatJSON, FormatJSON},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := ResolveFormatWithJSONShorthand(c.cmd, c.fallback); got != c.want {
|
||||||
|
t.Fatalf("ResolveFormatWithJSONShorthand = %q, want %q (fallback=%q)", got, c.want, c.fallback)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if got := ResolveFormatWithJSONShorthand(nil, FormatRaw); got != FormatRaw {
|
||||||
|
t.Fatalf("nil cmd = %q, want fallback raw", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonFlagValue 把 bool 渲染成 pflag.Set 可接受的字符串。
|
||||||
|
func jsonFlagValue(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveFormatWithJSONShorthandIgnoresStringTypedJSONFlag(t *testing.T) {
|
||||||
|
// 业务层存在同名 string flag(如 table create --json 载荷参数):
|
||||||
|
// GetBool 失败即跳过,不得被误判为简写。
|
||||||
|
cmd := &cobra.Command{Use: "t"}
|
||||||
|
cmd.Flags().String("json", "", "base JSON payload")
|
||||||
|
if err := cmd.Flags().Set("json", `{"x":1}`); err != nil {
|
||||||
|
t.Fatalf("set json: %v", err)
|
||||||
|
}
|
||||||
|
if got := ResolveFormatWithJSONShorthand(cmd, FormatTable); got != FormatTable {
|
||||||
|
t.Fatalf("string-typed --json must not act as shorthand, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveFormatWithJSONShorthandReadsInheritedAndPersistentFlags(t *testing.T) {
|
||||||
|
// --format 继承自 root persistent(与 ResolveFormat 同查找面);
|
||||||
|
// --json 挂在子命令本地亦生效。
|
||||||
|
root := &cobra.Command{Use: "dws"}
|
||||||
|
root.PersistentFlags().String("format", "", "")
|
||||||
|
child := &cobra.Command{Use: "list"}
|
||||||
|
child.Flags().Bool("json", false, "")
|
||||||
|
root.AddCommand(child)
|
||||||
|
if err := root.PersistentFlags().Set("format", "csv"); err != nil {
|
||||||
|
t.Fatalf("set format: %v", err)
|
||||||
|
}
|
||||||
|
if err := child.Flags().Set("json", "true"); err != nil {
|
||||||
|
t.Fatalf("set json: %v", err)
|
||||||
|
}
|
||||||
|
// 显式 format(继承)优先于 json 简写。
|
||||||
|
if got := ResolveFormatWithJSONShorthand(child, FormatJSON); got != FormatCSV {
|
||||||
|
t.Fatalf("inherited explicit format must win, got %q", got)
|
||||||
|
}
|
||||||
|
// 清空 format 后 json 简写生效。
|
||||||
|
if err := root.PersistentFlags().Set("format", ""); err != nil {
|
||||||
|
t.Fatalf("reset format: %v", err)
|
||||||
|
}
|
||||||
|
if got := ResolveFormatWithJSONShorthand(child, FormatTable); got != FormatJSON {
|
||||||
|
t.Fatalf("json shorthand = %q, want json", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B209(轮10裁决⑭小批,随 Phase C 同轮收口):ExitCodeForEnvelope 边界
|
||||||
|
// 组合精确值断言 + 与 internal/errors.ExitCode 表同源锁定 ---
|
||||||
|
|
||||||
|
func TestExitCodeForEnvelopeBoundaryCombinationsExactValues(t *testing.T) {
|
||||||
|
// ① failure + Error 缺席(I3 非法的手工形态,构造器不产出)→ internal
|
||||||
|
// 兜底 exit==5,精确值固化(无明细可分支的失败不得假装可恢复)。
|
||||||
|
invalid := &Envelope{OK: false, Outcome: OutcomeFailure}
|
||||||
|
if err := invalid.Validate(); err == nil {
|
||||||
|
t.Fatal("failure without Error must be rejected by Validate (I3)")
|
||||||
|
}
|
||||||
|
if got := ExitCodeForEnvelope(invalid); got != 5 {
|
||||||
|
t.Fatalf("failure without Error = exit %d, want 5 (internal fallback)", got)
|
||||||
|
}
|
||||||
|
// ② failure + Error.Type 空串(wire-stable 字段缺失形态)→ 同样 internal 5。
|
||||||
|
emptyType := NewFailureEnvelope(&ErrorInfo{Message: "no type"})
|
||||||
|
if got := ExitCodeForEnvelope(emptyType); got != 5 {
|
||||||
|
t.Fatalf("failure with empty error.type = exit %d, want 5 (internal fallback)", got)
|
||||||
|
}
|
||||||
|
// ③ failure + Error.Type 未知值(不在码表类别内)→ internal 5。
|
||||||
|
unknown := NewFailureEnvelope(&ErrorInfo{Type: "mystery"})
|
||||||
|
if got := ExitCodeForEnvelope(unknown); got != 5 {
|
||||||
|
t.Fatalf("failure with unknown error.type = exit %d, want 5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExitCodeForEnvelopeSameSourceAsErrorsExitCode(t *testing.T) {
|
||||||
|
// B33 常量块与 internal/errors/errors.go ExitCode 表同源锁定:逐类别值
|
||||||
|
// 相等,防未来单边漂移(轮10裁决⑬/⑭)。PAT=4 边界注记:PAT 行为授权
|
||||||
|
// 专属码经 ExitCoder 接口路径分发,不出现在信封码表(信封 failure 的
|
||||||
|
// error.type 枚举不含 pat 类别),故不在本锁定表内。
|
||||||
|
cases := []struct {
|
||||||
|
category string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{"api", apperrors.NewAPI("x")},
|
||||||
|
{"auth", apperrors.NewAuth("x")},
|
||||||
|
{"validation", apperrors.NewValidation("x")},
|
||||||
|
{"discovery", apperrors.NewDiscovery("x")},
|
||||||
|
{"internal", apperrors.NewInternal("x")},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
want := apperrors.ExitCode(c.err)
|
||||||
|
env := NewFailureEnvelope(&ErrorInfo{Type: c.category})
|
||||||
|
if got := ExitCodeForEnvelope(env); got != want {
|
||||||
|
t.Fatalf("category %q: ExitCodeForEnvelope=%d but errors.ExitCode=%d (code tables drifted)",
|
||||||
|
c.category, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// confirmation_required 子类与 validation 共享 rc=3(OQ-1 定案):
|
||||||
|
// runtime 形态 = NewValidation + WithReason(confirmation_required)。
|
||||||
|
confirmErr := apperrors.NewValidation("confirm", apperrors.WithReason("confirmation_required"))
|
||||||
|
confirmCode := apperrors.ExitCode(confirmErr)
|
||||||
|
confirmEnv := NewFailureEnvelope(&ErrorInfo{Type: "validation", Subtype: "confirmation_required"})
|
||||||
|
if got := ExitCodeForEnvelope(confirmEnv); got != confirmCode {
|
||||||
|
t.Fatalf("confirmation subclass: ExitCodeForEnvelope=%d but runtime errors.ExitCode=%d", got, confirmCode)
|
||||||
|
}
|
||||||
|
if confirmCode != 3 {
|
||||||
|
t.Fatalf("confirmation/validation shared rc = %d, want 3 (OQ-1)", confirmCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B35~B40 综合:六格式在 WriteEnvelopeTo/Emitter 路径的完整分发表 ---
|
||||||
|
|
||||||
|
func TestRawPassThroughEnvelopeDiffersFromJSONByteForByte(t *testing.T) {
|
||||||
|
// -f raw 对信封的透传:只出数据载荷本身(writeRaw 语义),与 json 的
|
||||||
|
// 完整信封逐字节不同——format flag 不是惰性的(对照附录 A.3:
|
||||||
|
// 历史上 --format 六值 stdout 逐字节相同即判 flag 惰性缺陷)。
|
||||||
|
env := NewSuccessEnvelope(map[string]any{"path": "/tmp/a.bin", "size": 42})
|
||||||
|
var rawBuf, jsonBuf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&rawBuf, env, FormatRaw, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(raw): %v", err)
|
||||||
|
}
|
||||||
|
if err := WriteEnvelopeTo(&jsonBuf, env, FormatJSON, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(json): %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Equal(rawBuf.Bytes(), jsonBuf.Bytes()) {
|
||||||
|
t.Fatalf("-f raw must differ from -f json byte-for-byte (format flag must not be inert):\n%s", rawBuf.String())
|
||||||
|
}
|
||||||
|
// raw = 数据载荷透传:恰为 writeRaw(data) 的输出(紧凑单行 JSON +
|
||||||
|
// 终端净化),不含信封外壳键。
|
||||||
|
var direct bytes.Buffer
|
||||||
|
if err := Write(&direct, FormatRaw, env.Data); err != nil {
|
||||||
|
t.Fatalf("Write(raw, data): %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(rawBuf.Bytes(), direct.Bytes()) {
|
||||||
|
t.Fatalf("-f raw output must be a verbatim pass-through of the data payload:\n got: %s\nwant: %s", rawBuf.String(), direct.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rawBuf.String(), `"outcome"`) || strings.Contains(rawBuf.String(), `"ok"`) {
|
||||||
|
t.Fatalf("-f raw must not emit the envelope wrapper: %s", rawBuf.String())
|
||||||
|
}
|
||||||
|
// 数据值可用:raw 输出可解码回业务载荷。
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(rawBuf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("raw output is not decodable JSON for structured payload: %v\n%s", err, rawBuf.String())
|
||||||
|
}
|
||||||
|
if decoded["path"] != "/tmp/a.bin" {
|
||||||
|
t.Fatalf("raw payload lost data: %s", rawBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSixFormatDispatchMatrixOnEnvelopePath(t *testing.T) {
|
||||||
|
// 六格式逐格式断言分发行为(§5.2 矩阵在信封出口的行为端):
|
||||||
|
// json = 完整信封;table/pretty = 数据 + 摘要;csv/ndjson = 裸记录;
|
||||||
|
// raw = 数据透传。全部格式 stdout 非空、均不产出「半个信封」。
|
||||||
|
cases := []struct {
|
||||||
|
format Format
|
||||||
|
wantJSON bool // stdout 是否恰为 JSON 文档(完整信封)
|
||||||
|
wantWrapper bool // 是否含信封外壳键(仅 json 允许)
|
||||||
|
}{
|
||||||
|
{FormatJSON, true, true},
|
||||||
|
{FormatTable, false, false},
|
||||||
|
{FormatPretty, false, false},
|
||||||
|
{FormatCSV, false, false},
|
||||||
|
{FormatNDJSON, false, false},
|
||||||
|
{FormatRaw, false, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(string(c.format), func(t *testing.T) {
|
||||||
|
forceNoColor(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), c.format, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo(%s): %v", c.format, err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if len(out) == 0 {
|
||||||
|
t.Fatalf("format %s produced empty stdout", c.format)
|
||||||
|
}
|
||||||
|
hasWrapper := strings.Contains(out, `"outcome"`)
|
||||||
|
if hasWrapper != c.wantWrapper {
|
||||||
|
t.Fatalf("format %s wrapper presence = %v, want %v:\n%s", c.format, hasWrapper, c.wantWrapper, out)
|
||||||
|
}
|
||||||
|
if c.wantJSON {
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("format %s must emit one complete JSON envelope: %v\n%s", c.format, err, out)
|
||||||
|
}
|
||||||
|
if decoded["ok"] != true {
|
||||||
|
t.Fatalf("format %s envelope ok = %v, want true", c.format, decoded["ok"])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 数据可见性:三格式与 raw 都能见到业务值。
|
||||||
|
if !strings.Contains(out, "alpha") {
|
||||||
|
t.Fatalf("format %s lost data payload:\n%s", c.format, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// 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 output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestEnvelope() *Envelope {
|
||||||
|
return &Envelope{
|
||||||
|
OK: true,
|
||||||
|
Outcome: OutcomeSuccess,
|
||||||
|
Data: map[string]any{"name": "DemoApp"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvelopeTo_JSONFullEnvelope(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, newTestEnvelope(), FormatJSON, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "success" {
|
||||||
|
t.Fatalf("outcome = %v, want success", decoded["outcome"])
|
||||||
|
}
|
||||||
|
// ok 必须序列化为 JSON 布尔(禁止字符串布尔)。
|
||||||
|
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
|
||||||
|
t.Fatalf("ok = %v (%T), want true (bool)", decoded["ok"], decoded["ok"])
|
||||||
|
}
|
||||||
|
if _, hasData := decoded["data"]; !hasData {
|
||||||
|
t.Fatalf("data key missing: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvelopeTo_OmitEmptyNoNull(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := &Envelope{OK: true, Outcome: OutcomeSuccess}
|
||||||
|
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||||
|
}
|
||||||
|
s := buf.String()
|
||||||
|
for _, key := range []string{`"meta"`, `"error"`, `"identity"`, `"dry_run"`, `"_notice"`} {
|
||||||
|
if strings.Contains(s, key) {
|
||||||
|
t.Fatalf("empty field %s must be omitted, got %s", key, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "null") {
|
||||||
|
t.Fatalf("envelope must not emit null: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvelopeTo_NonJSONRendersDataOnly(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, newTestEnvelope(), FormatTable, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||||
|
}
|
||||||
|
s := buf.String()
|
||||||
|
if strings.Contains(s, `"outcome"`) || strings.Contains(s, `"ok"`) {
|
||||||
|
t.Fatalf("table/pretty formats must not emit the envelope wrapper: %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "DemoApp") {
|
||||||
|
t.Fatalf("data payload missing from table output: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvelope_CommandUsesCmdStdoutAndFormat(t *testing.T) {
|
||||||
|
cmd := &cobra.Command{Use: "t"}
|
||||||
|
cmd.Flags().StringP("format", "f", "", "")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
|
||||||
|
if err := cmd.Flags().Set("format", "table"); err != nil {
|
||||||
|
t.Fatalf("set format: %v", err)
|
||||||
|
}
|
||||||
|
if err := WriteEnvelope(cmd, newTestEnvelope(), FormatJSON); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelope: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String(), `"outcome"`) {
|
||||||
|
t.Fatalf("-f table must render data only, got %s", stdout.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认 json:完整信封落 stdout。
|
||||||
|
stdout.Reset()
|
||||||
|
cmd2 := &cobra.Command{Use: "t2"}
|
||||||
|
cmd2.Flags().StringP("format", "f", "", "")
|
||||||
|
cmd2.SetOut(&stdout)
|
||||||
|
cmd2.SetErr(&stderr)
|
||||||
|
if err := WriteEnvelope(cmd2, newTestEnvelope(), FormatJSON); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelope: %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "success" {
|
||||||
|
t.Fatalf("json envelope outcome = %v, want success: %s", decoded["outcome"], stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvelope_NilEnvelopeDoesNotPanic(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
cmd := &cobra.Command{Use: "t"}
|
||||||
|
cmd.Flags().StringP("format", "f", "json", "")
|
||||||
|
cmd.SetOut(&buf)
|
||||||
|
if err := WriteEnvelope(cmd, nil, FormatJSON); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelope: %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
||||||
|
}
|
||||||
|
if decoded["outcome"] != "failure" {
|
||||||
|
t.Fatalf("nil envelope should serialize as failure: %s", buf.String())
|
||||||
|
}
|
||||||
|
// B208(轮6裁决⑨):兜底信封必须是 I3 合法形态——ok:false/outcome:failure
|
||||||
|
// 保留,error 明细存在且兜底信封自身通过 Validate()。
|
||||||
|
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
|
||||||
|
t.Fatalf("nil fallback ok = %v (%T), want false (bool): %s", decoded["ok"], decoded["ok"], buf.String())
|
||||||
|
}
|
||||||
|
errObj, hasErr := decoded["error"].(map[string]any)
|
||||||
|
if !hasErr {
|
||||||
|
t.Fatalf("nil fallback must carry error detail (I3): %s", buf.String())
|
||||||
|
}
|
||||||
|
if errObj["type"] != "internal" {
|
||||||
|
t.Fatalf("fallback error.type = %v, want internal: %s", errObj["type"], buf.String())
|
||||||
|
}
|
||||||
|
if msg, _ := errObj["message"].(string); msg != "nil envelope: framework fallback" {
|
||||||
|
t.Fatalf("fallback error.message = %v, want %q: %s", errObj["message"], "nil envelope: framework fallback", buf.String())
|
||||||
|
}
|
||||||
|
if err := nilFallbackEnvelope().Validate(); err != nil {
|
||||||
|
t.Fatalf("nil fallback envelope must pass Validate(), got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvelope_PaginationWireKeys(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := newTestEnvelope()
|
||||||
|
env.Data = []any{}
|
||||||
|
env.Meta = &Meta{
|
||||||
|
Count: NewCount(0),
|
||||||
|
Pagination: &Pagination{
|
||||||
|
EndpointExhausted: false,
|
||||||
|
NextToken: "cursor_xyz",
|
||||||
|
Pages: 1,
|
||||||
|
Items: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||||
|
}
|
||||||
|
var decoded struct {
|
||||||
|
Meta struct {
|
||||||
|
Count *int `json:"count"`
|
||||||
|
Pagination struct {
|
||||||
|
EndpointExhausted bool `json:"endpoint_exhausted"`
|
||||||
|
NextToken string `json:"next_token"`
|
||||||
|
Pages int `json:"pages"`
|
||||||
|
Items int `json:"items"`
|
||||||
|
} `json:"pagination"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
||||||
|
}
|
||||||
|
if decoded.Meta.Count == nil || *decoded.Meta.Count != 0 {
|
||||||
|
t.Fatalf("meta.count = %v, want 0: %s", decoded.Meta.Count, buf.String())
|
||||||
|
}
|
||||||
|
// 契约 §3:endpoint_exhausted 两态显式——false 必须可解码区分缺席。
|
||||||
|
if decoded.Meta.Pagination.EndpointExhausted {
|
||||||
|
t.Fatalf("endpoint_exhausted = true, want false: %s", buf.String())
|
||||||
|
}
|
||||||
|
if decoded.Meta.Pagination.NextToken != "cursor_xyz" {
|
||||||
|
t.Fatalf("next_token = %q, want cursor_xyz: %s", decoded.Meta.Pagination.NextToken, buf.String())
|
||||||
|
}
|
||||||
|
// wire 层 snake_case 恒等:camelCase 键禁止出现。
|
||||||
|
s := buf.String()
|
||||||
|
for _, key := range []string{`endpointExhausted`, `nextToken`, `nextCommand`, `timedOut`} {
|
||||||
|
if strings.Contains(s, key) {
|
||||||
|
t.Fatalf("camelCase wire key %s forbidden: %s", key, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// endpoint_exhausted:false 必须显式出现在 wire(无 omitempty)。
|
||||||
|
if !strings.Contains(s, `"endpoint_exhausted": false`) && !strings.Contains(s, `"endpoint_exhausted":false`) {
|
||||||
|
t.Fatalf("endpoint_exhausted must be explicit when false: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
// 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 output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outcome 是一次 CLI 调用的结果类别(互斥,四值)。
|
||||||
|
// 门禁拦截(confirmation_required)是 failure 的子类,不占第五值
|
||||||
|
// (契约规范 §1 结果总图)。
|
||||||
|
type Outcome string
|
||||||
|
|
||||||
|
// 四个规范 Outcome 值。wire 值恒为这些小写字符串(契约规范 §2.5)。
|
||||||
|
const (
|
||||||
|
OutcomeSuccess Outcome = "success"
|
||||||
|
OutcomePending Outcome = "pending"
|
||||||
|
OutcomePartialFailure Outcome = "partial_failure"
|
||||||
|
OutcomeFailure Outcome = "failure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String 返回 Outcome 的 wire 表示(恒为小写规范值)。
|
||||||
|
func (o Outcome) String() string { return string(o) }
|
||||||
|
|
||||||
|
// MarshalJSON 把 Outcome 序列化为小写规范字符串。仅接受四个规范值,
|
||||||
|
// 非法值(含零值)在序列化期即报错,杜绝静默产出无效 wire 值
|
||||||
|
// (契约规范 §1/§2.5「仅四值」)。
|
||||||
|
func (o Outcome) MarshalJSON() ([]byte, error) {
|
||||||
|
switch o {
|
||||||
|
case OutcomeSuccess, OutcomePending, OutcomePartialFailure, OutcomeFailure:
|
||||||
|
return json.Marshal(string(o))
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("output: cannot marshal invalid outcome %q", string(o))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseOutcome 严格解析 s 为 Outcome:仅接受四个小写规范值,
|
||||||
|
// 拒绝大小写变体、前后空白与空串(契约规范 §1)。
|
||||||
|
func ParseOutcome(s string) (Outcome, error) {
|
||||||
|
switch s {
|
||||||
|
case string(OutcomeSuccess):
|
||||||
|
return OutcomeSuccess, nil
|
||||||
|
case string(OutcomePending):
|
||||||
|
return OutcomePending, nil
|
||||||
|
case string(OutcomePartialFailure):
|
||||||
|
return OutcomePartialFailure, nil
|
||||||
|
case string(OutcomeFailure):
|
||||||
|
return OutcomeFailure, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("output: invalid outcome %q: must be one of %q, %q, %q, %q",
|
||||||
|
s, OutcomeSuccess, OutcomePending, OutcomePartialFailure, OutcomeFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envelope 是统一输出信封(契约规范 §2 信封结构)。
|
||||||
|
// 字段声明顺序对齐 §2.5 字段总表;Go 序列化按声明顺序输出,
|
||||||
|
// 由此得到稳定的顶层键顺序(golden 断言的前提)。
|
||||||
|
type Envelope struct {
|
||||||
|
// OK 为框架计算值:ok == (outcome ∈ {success, pending})(不变量 I1)。
|
||||||
|
// 恒序列化(无 omitempty),类型为 bool,禁止字符串布尔。
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
|
||||||
|
// Outcome 仅四值(见 Outcome 常量)。恒序列化。
|
||||||
|
Outcome Outcome `json:"outcome"`
|
||||||
|
|
||||||
|
// Identity 为执行身份(user/app),空则省略(§2.5)。
|
||||||
|
Identity string `json:"identity,omitempty"`
|
||||||
|
|
||||||
|
// DryRun 在 dry-run 预览时为 true,默认省略(§6 场景6)。
|
||||||
|
DryRun bool `json:"dry_run,omitempty"`
|
||||||
|
|
||||||
|
// Data 是业务载荷(L2):强类型 DTO 序列化,禁止手拼 JSON。
|
||||||
|
// 可为 object 或 array;空数组 [] 是合法载荷(原样序列化,
|
||||||
|
// 见空结果表达 data:[] + count:0)。
|
||||||
|
Data any `json:"data,omitempty"`
|
||||||
|
|
||||||
|
// Meta 是元数据层(count/operation/pagination),整体 omitempty。
|
||||||
|
Meta *Meta `json:"meta,omitempty"`
|
||||||
|
|
||||||
|
// Error 是失败明细(L1):非空 ⇔ outcome==failure(不变量 I3)。
|
||||||
|
Error *ErrorInfo `json:"error,omitempty"`
|
||||||
|
|
||||||
|
// Notice 是系统级通知注入位(_notice,如升级提示),空则省略。
|
||||||
|
// 契约仅规定 object 注入位;具体通知 shape 由后续批次定型。
|
||||||
|
Notice any `json:"_notice,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta 是信封元数据层(契约规范 §2.5)。
|
||||||
|
// 分页/异步操作元数据挂 meta 层而非 data 层(§3)。
|
||||||
|
type Meta struct {
|
||||||
|
// Count 是列表结果条数。指针类型区分「未设置」与「0 条」:
|
||||||
|
// 空结果场景必须显式输出 count:0(§3 / AC-06),
|
||||||
|
// nil 时字段缺席(omitempty)。
|
||||||
|
Count *int `json:"count,omitempty"`
|
||||||
|
|
||||||
|
// Operation 是异步操作信息(pending 信封,§2.2)。
|
||||||
|
Operation *OperationInfo `json:"operation,omitempty"`
|
||||||
|
|
||||||
|
// Pagination 是分页元数据(§3)。
|
||||||
|
Pagination *Pagination `json:"pagination,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCount 返回 n 的指针,供 Meta.Count 使用,
|
||||||
|
// 以区分「count:0」(空结果,必须输出)与「count 缺席」(非列表场景)。
|
||||||
|
func NewCount(n int) *int { return &n }
|
||||||
|
|
||||||
|
// OperationInfo 是异步操作元数据(契约规范 §2.2)。
|
||||||
|
// 轮询超时必须保持 State 真实值并置 TimedOut:true,禁止伪装 success;
|
||||||
|
// NextCommand 必须是可直接执行的完整命令(恢复方式)。
|
||||||
|
type OperationInfo struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
TimedOut bool `json:"timed_out,omitempty"`
|
||||||
|
NextCommand string `json:"next_command,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination 是分页元数据(契约规范 §3)。
|
||||||
|
// EndpointExhausted 仅表示「观察到服务端分页耗尽」,不承诺索引健康或
|
||||||
|
// 数据全覆盖;弃用 complete 键。EndpointExhausted:false 必须携带
|
||||||
|
// NextToken(可续跑)——两者合起来即全部状态,不设 stop_reason。
|
||||||
|
// EndpointExhausted 不加 omitempty:true/false 两态都必须在 wire 上显式出现。
|
||||||
|
type Pagination struct {
|
||||||
|
EndpointExhausted bool `json:"endpoint_exhausted"`
|
||||||
|
Pages int `json:"pages,omitempty"`
|
||||||
|
Items int `json:"items,omitempty"`
|
||||||
|
NextToken string `json:"next_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorInfo 是失败信封的错误明细(契约规范 §2.4)。
|
||||||
|
// Type/Subtype/Code/Retryable/RetryAfterSeconds 为 wire-stable(Agent 可分支);
|
||||||
|
// Message/Hint/RequestID 为 informational(禁止分支)。
|
||||||
|
type ErrorInfo struct {
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Subtype string `json:"subtype,omitempty"`
|
||||||
|
ExitCode int `json:"exit_code,omitempty"`
|
||||||
|
HTTPStatus int `json:"http_status,omitempty"`
|
||||||
|
UpstreamCode any `json:"upstream_code,omitempty"`
|
||||||
|
RPCCode int `json:"rpc_code,omitempty"`
|
||||||
|
Code int `json:"code,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
|
||||||
|
// Retryable 仅为 true 时出现在 wire(§2.4;重试设计 §2.1):
|
||||||
|
// bool 零值 false 遇 omitempty 即字段缺席。CLI 不自动重放。
|
||||||
|
Retryable bool `json:"retryable,omitempty"`
|
||||||
|
|
||||||
|
// RetryAfterSeconds 是服务端给出的重试等待秒数。指针类型保留
|
||||||
|
// 零值语义:0 秒有意义(nil 缺席、&0 序列化为 0),负值应在
|
||||||
|
// 构造侧拒绝。
|
||||||
|
RetryAfterSeconds *int64 `json:"retry_after_seconds,omitempty"`
|
||||||
|
|
||||||
|
RequestID string `json:"request_id,omitempty"`
|
||||||
|
TraceID string `json:"trace_id,omitempty"`
|
||||||
|
Operation string `json:"operation,omitempty"`
|
||||||
|
ServerKey string `json:"server_key,omitempty"`
|
||||||
|
Origin string `json:"origin,omitempty"`
|
||||||
|
Stage string `json:"stage,omitempty"`
|
||||||
|
ExecutionStarted *bool `json:"execution_started,omitempty"`
|
||||||
|
NextRetryAt string `json:"next_retry_at,omitempty"`
|
||||||
|
AvailableFlags []string `json:"available_flags,omitempty"`
|
||||||
|
SnapshotPath string `json:"snapshot_path,omitempty"`
|
||||||
|
Details map[string]any `json:"details,omitempty"`
|
||||||
|
RPCData any `json:"rpc_data,omitempty"`
|
||||||
|
TechnicalDetail string `json:"technical_detail,omitempty"`
|
||||||
|
FriendlyHint string `json:"friendly_hint,omitempty"`
|
||||||
|
ActionURL string `json:"action_url,omitempty"`
|
||||||
|
Cause string `json:"cause,omitempty"`
|
||||||
|
|
||||||
|
// Param/Params 在校验错误时指明违规参数(§2.4)。
|
||||||
|
Param string `json:"param,omitempty"`
|
||||||
|
Params []string `json:"params,omitempty"`
|
||||||
|
|
||||||
|
// Actions 是可执行的补救命令(门禁拦截 subtype=confirmation_required
|
||||||
|
// 时携带,含 --yes 版本;§2.4)。
|
||||||
|
Actions []string `json:"actions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDataErrorExclusivity 校验 wire 层面的 data/error 互斥(契约规范
|
||||||
|
// §1 不变量 I3 的字段级推论;§2.3/§2.4 形态):error 非空时 data 必须缺席——
|
||||||
|
// 失败信封只承载 error 明细,部分成功明细走 data 且不携带顶层 error。
|
||||||
|
// 无违反返回 nil;聚合校验入口(B18 Envelope.Validate)将组合本检查。
|
||||||
|
func (e *Envelope) ValidateDataErrorExclusivity() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if e.Error != nil && e.Data != nil {
|
||||||
|
return fmt.Errorf("output: envelope violates invariant I3: data and error are mutually exclusive, data must be absent when error is present")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 是信封的聚合校验入口(B18,契约规范 §1):一次性收集全部违反项
|
||||||
|
// (errors.Join)而非命中即返回,便于装配侧一轮定位所有契约缺陷。校验项:
|
||||||
|
//
|
||||||
|
// - outcome 必须是四个规范值之一(§1/§2.5,复用 ParseOutcome);
|
||||||
|
// - 不变量 I1:OK == (outcome ∈ {success, pending})——框架计算值一致性;
|
||||||
|
// - 不变量 I3:Error 非空 ⇔ outcome == failure(双向);
|
||||||
|
// - I3 字段级推论:data/error 互斥(复用 ValidateDataErrorExclusivity);
|
||||||
|
// - Data 为类型化 *PartialData 时的三通道纪律(对账、非空 succeeded、
|
||||||
|
// id 可标识与跨通道互斥,§2.3;裸 map 等业务载荷形态不做解释)。
|
||||||
|
//
|
||||||
|
// outcome 非法时其派生检查(I1/I3)失去判定基准,仅报告 outcome 本身。
|
||||||
|
// Validate 只做静态字段校验,不解释业务载荷内容(L3 真实性归产品命令,§9)。
|
||||||
|
// nil 信封返回 nil:wire 出口(WriteEnvelopeTo)对 nil 已有降级 failure
|
||||||
|
// 信封的兜底,类型层不重复拦截。
|
||||||
|
func (e *Envelope) Validate() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
outcome, err := ParseOutcome(string(e.Outcome))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var errs []error
|
||||||
|
wantOK := outcome == OutcomeSuccess || outcome == OutcomePending
|
||||||
|
if e.OK != wantOK {
|
||||||
|
errs = append(errs, fmt.Errorf("output: envelope violates invariant I1: ok=%v but outcome=%q requires ok=%v", e.OK, outcome, wantOK))
|
||||||
|
}
|
||||||
|
errorPresent := e.Error != nil
|
||||||
|
isFailure := outcome == OutcomeFailure
|
||||||
|
if errorPresent != isFailure {
|
||||||
|
errs = append(errs, fmt.Errorf("output: envelope violates invariant I3: error present=%v but outcome=%q (error must be present if and only if outcome is failure)", errorPresent, outcome))
|
||||||
|
}
|
||||||
|
if err := e.ValidateDataErrorExclusivity(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
if pd, ok := e.Data.(*PartialData); ok && pd != nil {
|
||||||
|
if err := pd.Validate(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Meta != nil && e.Meta.Operation != nil {
|
||||||
|
if err := e.Meta.Operation.Validate(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Meta != nil && e.Meta.Pagination != nil {
|
||||||
|
if err := e.Meta.Pagination.Validate(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Error != nil {
|
||||||
|
if err := e.Error.Validate(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pagination) Validate() error {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p.EndpointExhausted && strings.TrimSpace(p.NextToken) != "" {
|
||||||
|
return fmt.Errorf("output: exhausted pagination must not carry next_token")
|
||||||
|
}
|
||||||
|
if !p.EndpointExhausted && strings.TrimSpace(p.NextToken) == "" {
|
||||||
|
return fmt.Errorf("output: non-exhausted pagination requires next_token")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ErrorInfo) Validate() error {
|
||||||
|
if e == nil {
|
||||||
|
return fmt.Errorf("output: failure error is nil")
|
||||||
|
}
|
||||||
|
errorType := strings.TrimSpace(e.Type)
|
||||||
|
if errorType == "" {
|
||||||
|
return fmt.Errorf("output: failure error.type is required")
|
||||||
|
}
|
||||||
|
// error.type is a wire-stable Agent branch key, not an open-ended label.
|
||||||
|
// Keep this set aligned with exitCodeForErrorInfo. "permission" is the
|
||||||
|
// compatibility projection for PAT failures (rc=4).
|
||||||
|
switch errorType {
|
||||||
|
case "api", "auth", "validation", "permission", "discovery", "internal":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("output: unsupported failure error.type %q", e.Type)
|
||||||
|
}
|
||||||
|
if e.ExitCode < 0 {
|
||||||
|
return fmt.Errorf("output: failure error.exit_code must not be negative")
|
||||||
|
}
|
||||||
|
if e.RetryAfterSeconds != nil && *e.RetryAfterSeconds < 0 {
|
||||||
|
return fmt.Errorf("output: failure retry_after_seconds must be non-negative")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSuccessEnvelope 构造成功信封(B19,契约规范 §2.1)。
|
||||||
|
// OK 由框架按 outcome 计算(I1),调用层不得自行改写。data 必须是强类型
|
||||||
|
// DTO 序列化产物,禁止手拼 JSON(§2.5/§7);空数组 [] 是合法载荷(AC-06)。
|
||||||
|
func NewSuccessEnvelope(data any) *Envelope {
|
||||||
|
return &Envelope{OK: true, Outcome: OutcomeSuccess, Data: data}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPendingEnvelope 构造异步受理信封(B19,契约规范 §2.2)。
|
||||||
|
// op 携带 id/state/timed_out/next_command;op 为 nil 时 meta 整体缺席。
|
||||||
|
// 轮询超时必须保持 State 真实值并置 TimedOut:true,禁止伪装 success;
|
||||||
|
// NextCommand 必须是可直接执行的完整命令(§2.2 规则)。
|
||||||
|
//
|
||||||
|
// 本构造器保持「构造宽松 + 校验严格」分层(轮6裁决⑨):§2.2 两条硬规则
|
||||||
|
// 不在构造期拦截,由 OperationInfo.Validate 与严格装配助手
|
||||||
|
// NewPendingEnvelopeForPolling(B147/B148)把守。
|
||||||
|
func NewPendingEnvelope(op *OperationInfo) *Envelope {
|
||||||
|
env := &Envelope{OK: true, Outcome: OutcomePending}
|
||||||
|
if op != nil {
|
||||||
|
env.Meta = &Meta{Operation: op}
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFailureEnvelope 构造失败信封(B20,契约规范 §2.4)。
|
||||||
|
// error 非空 ⇔ outcome==failure(I3):info 为 nil 的失败信封无法通过
|
||||||
|
// Validate(),调用侧不得提交;data 必须缺席(I3 字段级推论)。
|
||||||
|
func NewFailureEnvelope(info *ErrorInfo) *Envelope {
|
||||||
|
return &Envelope{OK: false, Outcome: OutcomeFailure, Error: info}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPartialEnvelope 构造部分成功信封(B20,契约规范 §2.3)。
|
||||||
|
// 三通道明细(total/succeeded/failed/unknown)进 data,不携带顶层 error。
|
||||||
|
// 通道规则(B140/B141/B144 已落盘):推荐以 NewPartialData 装配明细——
|
||||||
|
// 构造期拒绝空 succeeded(全失败禁用 partial_failure);Envelope.Validate
|
||||||
|
// 对 *PartialData 载荷追加通道校验(同 id 跨通道互斥、total 对账等)。
|
||||||
|
func NewPartialEnvelope(data any) *Envelope {
|
||||||
|
return &Envelope{OK: false, Outcome: OutcomePartialFailure, Data: data}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsOK 计算不变量 I1(B21,契约规范 §1):ok == (outcome ∈ {success, pending})。
|
||||||
|
// 这是框架侧的唯一计算函数,命令层不可写或改写该结果;Envelope.OK 字段值
|
||||||
|
// 必须与 IsOK 一致(Validate 交叉校验,违反即 I1 缺陷)。
|
||||||
|
// nil 信封视为不 ok:没有结果可言,不得汇报为成功/受理。
|
||||||
|
// 非法(非四值)outcome 必然返回 false:I1 只定义在四个规范值上,
|
||||||
|
// 非法值由 ParseOutcome/Validate/MarshalJSON 各自拒绝。
|
||||||
|
func (e *Envelope) IsOK() bool {
|
||||||
|
if e == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return e.Outcome == OutcomeSuccess || e.Outcome == OutcomePending
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase G:partial_failure 三通道明细(B139~B146;契约规范 §2.3)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// PartialFailedEntry 是 partial 失败通道的单条明细(B139,契约规范 §2.3):
|
||||||
|
// id 标识条目,Error 携带该条目的失败明细(复用 §2.4 ErrorInfo 形态,
|
||||||
|
// wire-stable 字段组可供 Agent 分支)。
|
||||||
|
type PartialFailedEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Error *ErrorInfo `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartialUnknownEntry 是 partial unknown 通道的单条明细(B144,契约规范
|
||||||
|
// §2.3/P0#9「报错却写入」类问题的诚实通道):shape = id + reason——
|
||||||
|
// 已提交但终态无法确认的条目放这里;reason 必须非空,说明不确定的原因。
|
||||||
|
type PartialUnknownEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartialData 是部分成功三通道明细的类型化形态(B139,契约规范 §2.3),
|
||||||
|
// 整体放入 Envelope.Data。声明顺序 = wire 顺序:total/succeeded/failed/
|
||||||
|
// unknown(对齐契约示例)。三通道纪律(B140/B141/B144/B146)由
|
||||||
|
// NewPartialData 构造期校验 + Validate 集中复验:
|
||||||
|
//
|
||||||
|
// - succeeded 必须完整保留(构造器不接受丢弃明细,§2.3 规则1);
|
||||||
|
// - unknown 条目禁止归入 succeeded/failed(同一 id 跨通道互斥,§2.3 规则2);
|
||||||
|
// - 空 succeeded 禁用 partial_failure(全失败必须用普通 failure,§2.3 规则4)。
|
||||||
|
//
|
||||||
|
// 三个切片字段不带 omitempty:空通道以 [] 显式表达(不输出 null,
|
||||||
|
// §2.5 无 null 纪律),与 data:[] 空态合法载荷同口径。
|
||||||
|
type PartialData struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Succeeded []any `json:"succeeded"`
|
||||||
|
Failed []PartialFailedEntry `json:"failed"`
|
||||||
|
Unknown []PartialUnknownEntry `json:"unknown"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPartialData 装配三通道明细(B139/B140/B141/B144/B146,契约规范 §2.3)。
|
||||||
|
// 装配期即校验通道纪律:
|
||||||
|
//
|
||||||
|
// 1. succeeded 非空且原样保留——空 succeeded 的 partial_failure 被拒绝
|
||||||
|
// (全部失败必须用 outcome:failure,§2.3 规则4;B141);构造器不丢弃、
|
||||||
|
// 不过滤任何 succeeded 条目(B146:Agent 需要知道哪些已生效,避免重复提交);
|
||||||
|
// 2. failed/unknown 条目 id 必须非空(条目可标识是跨通道互斥的唯一依据);
|
||||||
|
// unknown 条目 reason 必须非空(诚实通道必须说明不确定原因,B144);
|
||||||
|
// 3. 同一 id 不得跨通道或同通道重复出现——一个条目只有一个终态通道
|
||||||
|
// (「unknown 禁止归入 succeeded/failed」,§2.3 规则2;B140)。succeeded
|
||||||
|
// 条目是异构业务 DTO,其 id 按 JSON 泛化形态的 "id" 键提取;无 id 或非
|
||||||
|
// object 的条目不参与互斥判定(框架不解释业务载荷内容,§9);
|
||||||
|
// 4. total 对账:total == len(succeeded)+len(failed)+len(unknown)——
|
||||||
|
// 三通道明细是 total 的完整划分,不允许出现无明细的条目。
|
||||||
|
//
|
||||||
|
// nil 的 failed/unknown 入参归一为空切片(空通道 wire 上是 [] 而非 null)。
|
||||||
|
func NewPartialData(total int, succeeded []any, failed []PartialFailedEntry, unknown []PartialUnknownEntry) (*PartialData, error) {
|
||||||
|
if failed == nil {
|
||||||
|
failed = []PartialFailedEntry{}
|
||||||
|
}
|
||||||
|
if unknown == nil {
|
||||||
|
unknown = []PartialUnknownEntry{}
|
||||||
|
}
|
||||||
|
d := &PartialData{Total: total, Succeeded: succeeded, Failed: failed, Unknown: unknown}
|
||||||
|
return d, d.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 校验三通道纪律(B140)。NewPartialData 构造期已校验;本方法供
|
||||||
|
// 手工装配形态(绕过构造器)集中复验,并由 Envelope.Validate 在
|
||||||
|
// outcome==partial_failure 且 Data 为 *PartialData 时聚合调用。
|
||||||
|
func (d *PartialData) Validate() error {
|
||||||
|
if d == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var errs []error
|
||||||
|
if d.Succeeded == nil || d.Failed == nil || d.Unknown == nil {
|
||||||
|
errs = append(errs, fmt.Errorf("output: partial channels must serialize as [] not null (§2.5 no-null discipline)"))
|
||||||
|
}
|
||||||
|
// B141:全失败禁用 partial_failure(§2.3 规则4)。
|
||||||
|
if len(d.Succeeded) == 0 {
|
||||||
|
errs = append(errs, fmt.Errorf("output: partial_failure with empty succeeded is forbidden: all-failed batches must use outcome:failure (§2.3)"))
|
||||||
|
}
|
||||||
|
// B146:succeeded 完整保留——不接受 nil(null)条目混入明细。
|
||||||
|
for i, item := range d.Succeeded {
|
||||||
|
if item == nil {
|
||||||
|
errs = append(errs, fmt.Errorf("output: partial succeeded[%d] is nil: succeeded details must be fully preserved entries (§2.3)", i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, entry := range d.Failed {
|
||||||
|
if strings.TrimSpace(entry.ID) == "" {
|
||||||
|
errs = append(errs, fmt.Errorf("output: partial failed[%d] has empty id: entries must be identifiable (§2.3)", i))
|
||||||
|
}
|
||||||
|
if entry.Error == nil {
|
||||||
|
errs = append(errs, fmt.Errorf("output: partial failed[%d] requires a typed error (§2.3)", i))
|
||||||
|
} else if err := entry.Error.Validate(); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("output: partial failed[%d] has invalid typed error: %w", i, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// B144:unknown shape = id + reason,两者都必须非空。
|
||||||
|
for i, entry := range d.Unknown {
|
||||||
|
if strings.TrimSpace(entry.ID) == "" {
|
||||||
|
errs = append(errs, fmt.Errorf("output: partial unknown[%d] has empty id: entries must be identifiable (§2.3)", i))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(entry.Reason) == "" {
|
||||||
|
errs = append(errs, fmt.Errorf("output: partial unknown[%d] has empty reason: the honest channel must state why the terminal state is unconfirmed (§2.3/P0#9)", i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// B141/§2.3:total 对账——三通道明细是 total 的完整划分。
|
||||||
|
if sum := len(d.Succeeded) + len(d.Failed) + len(d.Unknown); d.Total != sum {
|
||||||
|
errs = append(errs, fmt.Errorf("output: partial total=%d does not reconcile with channel details (succeeded %d + failed %d + unknown %d = %d) (§2.3)",
|
||||||
|
d.Total, len(d.Succeeded), len(d.Failed), len(d.Unknown), sum))
|
||||||
|
}
|
||||||
|
// B140:同一 id 跨通道/同通道互斥(unknown 不得归入 succeeded/failed)。
|
||||||
|
seen := make(map[string]string)
|
||||||
|
register := func(id, channel string) {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if prev, dup := seen[id]; dup {
|
||||||
|
errs = append(errs, fmt.Errorf("output: partial entry id %q appears in both %s and %s: one entry has exactly one terminal channel (§2.3)", id, prev, channel))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[id] = channel
|
||||||
|
}
|
||||||
|
for _, item := range d.Succeeded {
|
||||||
|
register(partialEntryID(item), "succeeded")
|
||||||
|
}
|
||||||
|
for _, entry := range d.Failed {
|
||||||
|
register(entry.ID, "failed")
|
||||||
|
}
|
||||||
|
for _, entry := range d.Unknown {
|
||||||
|
register(entry.ID, "unknown")
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// partialEntryID 从异构 succeeded 条目中提取 "id"(JSON 泛化形态的字符串键)。
|
||||||
|
// 无 id、id 非字符串或非 object 的条目返回空串(不参与互斥判定)。
|
||||||
|
func partialEntryID(item any) string {
|
||||||
|
generic, ok := toGeneric(item).(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id, _ := generic["id"].(string)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase H:operation / pagination 装配助手(B147~B152;契约规范 §2.2/§3)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// operation.state 取值表(B150,契约规范 §2.2):state 描述异步操作所处阶段,
|
||||||
|
// 规范值为以下集合(开放枚举——业务特化阶段容忍,但以下三值是 Agent 必须能
|
||||||
|
// 分支的最小集)。timed_out:true 禁止与完成态共存(§2.2 规则1,见
|
||||||
|
// OperationInfo.ValidateTimeoutState)。
|
||||||
|
const (
|
||||||
|
OperationStateProcessing = "processing"
|
||||||
|
OperationStateCompleted = "completed"
|
||||||
|
OperationStateFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateNextCommand 校验 next_command 的可执行形态(B148,契约规范 §2.2
|
||||||
|
// 规则2「next_command 必须是可直接执行的完整命令」):非空时必须是无前后
|
||||||
|
// 空白、不含换行的单行命令串——这是框架侧对「可直接执行」的形态代理;
|
||||||
|
// 命令真实可执行性(命令存在、参数有效)归装配侧保证(§9)。
|
||||||
|
// 空串在此不拦截:omitempty wire 允许无 operation 明细的裸 pending 信封,
|
||||||
|
// 轮询形态装配 NewPendingEnvelopeForPolling 才要求非空。
|
||||||
|
func (op *OperationInfo) ValidateNextCommand() error {
|
||||||
|
if op == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if op.NextCommand == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(op.NextCommand) != op.NextCommand {
|
||||||
|
return fmt.Errorf("output: operation.next_command %q must carry no leading/trailing whitespace (§2.2: directly executable)", op.NextCommand)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(op.NextCommand, "\n\r") {
|
||||||
|
return fmt.Errorf("output: operation.next_command must be a single executable command line (§2.2)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateTimeoutState 执行 §2.2 规则1 的防伪装校验(B149):轮询超时必须
|
||||||
|
// 保持 state 真实值并置 timed_out:true——TimedOut 时 State 必须非空(真实值
|
||||||
|
// 必报),且不得宣称任何完成态(completed/success):超时操作宣称完成态
|
||||||
|
// 即伪装 success(对齐 lark wiki_node_delete 教训)。
|
||||||
|
func (op *OperationInfo) ValidateTimeoutState() error {
|
||||||
|
if op == nil || !op.TimedOut {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state := strings.TrimSpace(op.State)
|
||||||
|
if state == "" {
|
||||||
|
return fmt.Errorf("output: operation.timed_out=true requires the real state to be reported (empty state, §2.2 anti-spoof)")
|
||||||
|
}
|
||||||
|
if state == OperationStateCompleted || strings.EqualFold(state, string(OutcomeSuccess)) {
|
||||||
|
return fmt.Errorf("output: operation.timed_out=true must keep the real state %q: a timed-out operation must not claim a completed state (§2.2 anti-spoof)", state)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 聚合 operation 的 §2.2 形态校验。必填字段由新框架的
|
||||||
|
// CommandResult 边界校验;这里保持旧 Envelope 构造 API 的兼容性。
|
||||||
|
func (op *OperationInfo) Validate() error {
|
||||||
|
if op == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.Join(op.ValidateNextCommand(), op.ValidateTimeoutState())
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPendingEnvelopeForPolling 装配轮询形态的 pending 信封(B147 增强,
|
||||||
|
// 契约规范 §2.2):比 NewPendingEnvelope 更严格——轮询形态的恢复方式只有
|
||||||
|
// next_command 一条出口,故 op 与 next_command 必须非空且通过可执行形态
|
||||||
|
// 校验;state 必须遵守 timed_out 防伪装规则。返回 I1 一致(ok:true/
|
||||||
|
// outcome:pending)的信封;装配失败返回 error(信封为 nil)。
|
||||||
|
func NewPendingEnvelopeForPolling(op *OperationInfo) (*Envelope, error) {
|
||||||
|
if op == nil {
|
||||||
|
return nil, fmt.Errorf("output: polling-form pending envelope requires operation (§2.2: meta.operation is the recovery carrier)")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(op.NextCommand) == "" {
|
||||||
|
return nil, fmt.Errorf("output: polling-form pending envelope requires a non-empty, directly executable next_command (§2.2 rule 2)")
|
||||||
|
}
|
||||||
|
if err := op.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return NewPendingEnvelope(op), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPagination 装配分页元数据(B152,契约规范 §3):两态完备校验——
|
||||||
|
// endpoint_exhausted:false 必须携带 next_token(可续跑:无 token 则
|
||||||
|
// 「可续跑」不可表达,破坏 §3 两态完备性);endpoint_exhausted:true
|
||||||
|
// 不得携带 next_token(耗尽即无续跑凭据,两者合起来即全部状态,
|
||||||
|
// 不设 stop_reason)。Pages/Items 为信息性计数(omitempty),由调用方
|
||||||
|
// 按观察到的页数/条数在返回结构上另行设置。
|
||||||
|
func NewPagination(endpointExhausted bool, nextToken string) (*Pagination, error) {
|
||||||
|
token := strings.TrimSpace(nextToken)
|
||||||
|
if !endpointExhausted && token == "" {
|
||||||
|
return nil, fmt.Errorf("output: pagination endpoint_exhausted=false requires next_token (§3: resumable state must carry the resume handle)")
|
||||||
|
}
|
||||||
|
if endpointExhausted && token != "" {
|
||||||
|
return nil, fmt.Errorf("output: pagination endpoint_exhausted=true must not carry next_token (§3: exhausted and resumable are the only two states)")
|
||||||
|
}
|
||||||
|
pg := &Pagination{EndpointExhausted: endpointExhausted, NextToken: token}
|
||||||
|
return pg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// 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 output
|
||||||
|
|
||||||
|
// Phase I:信封渲染字节稳定性(B188;AC-07)。落盘策略:轮8裁决⑩新文件。
|
||||||
|
//
|
||||||
|
// 契约卫生的可复现前提:同一信封输入,多次渲染必须逐字节一致。Go 的 map
|
||||||
|
// 迭代顺序随机、encoding/json 对 map 按键排序序列化——本测试用大 map 载荷
|
||||||
|
// 反复渲染,锁死「字节稳定」这一性质:任何引入非确定性渲染(如依赖 map
|
||||||
|
// 迭代序、时间戳、指针地址、随机序)的改动都会让多次渲染产生字节漂移,
|
||||||
|
// 从而命中断言。golden 比对(B157)锁的是「与基准一致」,本测试锁的是
|
||||||
|
// 「自身多次渲染一致」,两者互补。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildLargeMapPayload 构造一个多键 map 载荷:键数足够多,使 map 迭代顺序
|
||||||
|
// 随机性的影响面充分暴露(小 map 可能偶然同序,掩盖非确定性)。
|
||||||
|
func buildLargeMapPayload(n int) map[string]any {
|
||||||
|
m := make(map[string]any, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
m[fmt.Sprintf("key_%03d", i)] = map[string]any{
|
||||||
|
"id": fmt.Sprintf("id_%03d", i),
|
||||||
|
"name": fmt.Sprintf("name_%03d", i),
|
||||||
|
"weight": i,
|
||||||
|
"active": i%2 == 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderEnvelopeBytes 用生产路径同一序列化函数渲染信封(两空格缩进)。
|
||||||
|
func renderEnvelopeBytes(t *testing.T, env *Envelope) []byte {
|
||||||
|
t.Helper()
|
||||||
|
data, err := jsonutil.MarshalIndent(env, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("jsonutil.MarshalIndent: %v", err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnvelopeRenderByteStabilityAcrossRepeatedRenders 是 B188 的核心断言:
|
||||||
|
// 同一信封反复渲染 N 次,每次输出逐字节相同(防 map 序抖动)。
|
||||||
|
func TestEnvelopeRenderByteStabilityAcrossRepeatedRenders(t *testing.T) {
|
||||||
|
// 载荷用大 map,放大迭代序随机性;envelope 字段声明序是确定的,
|
||||||
|
// 但 data 内的 map 若被非确定性渲染即会漂移。
|
||||||
|
env := NewSuccessEnvelope(buildLargeMapPayload(64))
|
||||||
|
env.Meta = &Meta{Count: NewCount(64)}
|
||||||
|
|
||||||
|
first := renderEnvelopeBytes(t, env)
|
||||||
|
if len(first) == 0 {
|
||||||
|
t.Fatal("render produced empty output")
|
||||||
|
}
|
||||||
|
const iterations = 50
|
||||||
|
for i := 1; i < iterations; i++ {
|
||||||
|
got := renderEnvelopeBytes(t, env)
|
||||||
|
if !bytes.Equal(got, first) {
|
||||||
|
t.Fatalf("render #%d drifted byte-for-byte from render #0 (map-order jitter / nondeterminism): first diff at byte %d",
|
||||||
|
i, firstDiffIndex(first, got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstDiffIndex 返回两个字节切片首个不同位置的索引(全等返回 -1),
|
||||||
|
// 便于定位漂移起点。
|
||||||
|
func firstDiffIndex(a, b []byte) int {
|
||||||
|
n := len(a)
|
||||||
|
if len(b) < n {
|
||||||
|
n = len(b)
|
||||||
|
}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnvelopeRenderByteStabilityAcrossAllFourOutcomes 把字节稳定性断言扩展到
|
||||||
|
// 四类 outcome(含携带 map 载荷的 pending/partial),确保各形态渲染均稳定。
|
||||||
|
func TestEnvelopeRenderByteStabilityAcrossAllFourOutcomes(t *testing.T) {
|
||||||
|
envs := map[string]*Envelope{
|
||||||
|
"success": NewSuccessEnvelope(buildLargeMapPayload(40)),
|
||||||
|
"pending": newStabilityPendingFixture(),
|
||||||
|
"partial_failure": newStabilityPartialFixture(t),
|
||||||
|
"failure": NewFailureEnvelope(&ErrorInfo{Type: "api", Code: 90018, Message: "rate limited"}),
|
||||||
|
}
|
||||||
|
for name, env := range envs {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
first := renderEnvelopeBytes(t, env)
|
||||||
|
for i := 1; i < 20; i++ {
|
||||||
|
got := renderEnvelopeBytes(t, env)
|
||||||
|
if !bytes.Equal(got, first) {
|
||||||
|
t.Fatalf("%s render #%d drifted (nondeterminism): first diff at byte %d",
|
||||||
|
name, i, firstDiffIndex(first, got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStabilityPendingFixture() *Envelope {
|
||||||
|
env := NewPendingEnvelope(&OperationInfo{ID: "t_stab", State: OperationStateProcessing, NextCommand: "dws op get t_stab"})
|
||||||
|
env.Data = buildLargeMapPayload(40)
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStabilityPartialFixture(t *testing.T) *Envelope {
|
||||||
|
t.Helper()
|
||||||
|
succeeded := make([]any, 0, 8)
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
succeeded = append(succeeded, map[string]any{
|
||||||
|
"id": fmt.Sprintf("s_%03d", i),
|
||||||
|
"detail": buildLargeMapPayload(8), // 每条 succeeded 内嵌多键 map
|
||||||
|
"message": fmt.Sprintf("msg_%03d", i),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pd, err := NewPartialData(len(succeeded)+1,
|
||||||
|
succeeded,
|
||||||
|
[]PartialFailedEntry{{ID: "f_001", Error: &ErrorInfo{Type: "api", Code: 40001}}},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPartialData: %v", err)
|
||||||
|
}
|
||||||
|
return NewPartialEnvelope(pd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnvelopeRenderByteStabilityIndependentOfConstructionOrder 验证字节稳定
|
||||||
|
// 不依赖构造顺序:两个字段插入顺序不同但内容等价的 map,渲染结果逐字节一致
|
||||||
|
// (encoding/json 对 map 按键排序,故构造序不影响 wire——锁死这一性质)。
|
||||||
|
func TestEnvelopeRenderByteStabilityIndependentOfConstructionOrder(t *testing.T) {
|
||||||
|
// 正向插入。
|
||||||
|
a := map[string]any{}
|
||||||
|
for _, k := range []string{"alpha", "beta", "gamma", "delta"} {
|
||||||
|
a[k] = k + "_value"
|
||||||
|
}
|
||||||
|
// 逆向插入(同一键集,不同插入序)。
|
||||||
|
b := map[string]any{}
|
||||||
|
for _, k := range []string{"delta", "gamma", "beta", "alpha"} {
|
||||||
|
b[k] = k + "_value"
|
||||||
|
}
|
||||||
|
gotA := renderEnvelopeBytes(t, NewSuccessEnvelope(a))
|
||||||
|
gotB := renderEnvelopeBytes(t, NewSuccessEnvelope(b))
|
||||||
|
if !bytes.Equal(gotA, gotB) {
|
||||||
|
t.Fatalf("construction order leaked into wire bytes (map keys must be sorted on marshal):\n--- a ---\n%s\n--- b ---\n%s", gotA, gotB)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// 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 output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- B21:IsOK() I1 框架计算函数 ---
|
||||||
|
|
||||||
|
func TestEnvelopeIsOKComputesFromOutcome(t *testing.T) {
|
||||||
|
// IsOK 只看 outcome,不看(可能被命令层篡改的)OK 字段——
|
||||||
|
// 「由框架计算,命令层不可写」(契约规范 §1 I1)。
|
||||||
|
env := &Envelope{OK: true, Outcome: OutcomeFailure}
|
||||||
|
if env.IsOK() {
|
||||||
|
t.Fatalf("IsOK must ignore the tampered OK field: outcome=failure requires false")
|
||||||
|
}
|
||||||
|
env2 := &Envelope{OK: false, Outcome: OutcomeSuccess}
|
||||||
|
if !env2.IsOK() {
|
||||||
|
t.Fatalf("IsOK must compute from outcome: success requires true regardless of OK field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopeIsOKNilAndInvalidOutcome(t *testing.T) {
|
||||||
|
var env *Envelope
|
||||||
|
if env.IsOK() {
|
||||||
|
t.Fatalf("nil envelope must not be ok: no outcome means no success")
|
||||||
|
}
|
||||||
|
for _, bad := range []Outcome{"", "SUCCESS", "Failure", "ok", "partial"} {
|
||||||
|
if (&Envelope{Outcome: bad}).IsOK() {
|
||||||
|
t.Fatalf("IsOK(%q) = true, want false: I1 is defined only over the four canonical outcomes", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B22:I1 全四值穷举 ---
|
||||||
|
|
||||||
|
func TestInvariantI1ExhaustiveOverFourOutcomes(t *testing.T) {
|
||||||
|
// I1:ok == (outcome ∈ {success, pending}),四值全穷举。
|
||||||
|
cases := []struct {
|
||||||
|
outcome Outcome
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{OutcomeSuccess, true},
|
||||||
|
{OutcomePending, true},
|
||||||
|
{OutcomePartialFailure, false},
|
||||||
|
{OutcomeFailure, false},
|
||||||
|
}
|
||||||
|
if len(cases) != 4 {
|
||||||
|
t.Fatalf("I1 exhaustive table must cover exactly the four canonical outcomes, got %d", len(cases))
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
// failure 形态附 error 以同时满足 I3(error 非空 ⇔ outcome==failure),
|
||||||
|
// 使 Validate() 对 I1 一致信封整体放行。
|
||||||
|
var errPtr *ErrorInfo
|
||||||
|
if c.outcome == OutcomeFailure {
|
||||||
|
errPtr = &ErrorInfo{Type: "api"}
|
||||||
|
}
|
||||||
|
env := &Envelope{OK: c.wantOK, Outcome: c.outcome, Error: errPtr}
|
||||||
|
if got := env.IsOK(); got != c.wantOK {
|
||||||
|
t.Fatalf("IsOK() for outcome=%q = %v, want %v (I1)", c.outcome, got, c.wantOK)
|
||||||
|
}
|
||||||
|
if env.OK != env.IsOK() {
|
||||||
|
t.Fatalf("envelope ok=%v disagrees with framework-computed IsOK=%v for outcome=%q (I1)",
|
||||||
|
env.OK, env.IsOK(), c.outcome)
|
||||||
|
}
|
||||||
|
if err := env.Validate(); err != nil {
|
||||||
|
t.Fatalf("Validate() on I1-consistent outcome=%q envelope = %v, want nil", c.outcome, err)
|
||||||
|
}
|
||||||
|
// 反向:人为翻转 OK 字段 → Validate 必须报 I1 违反。
|
||||||
|
tampered := &Envelope{OK: !c.wantOK, Outcome: c.outcome}
|
||||||
|
if tampered.IsOK() != c.wantOK {
|
||||||
|
t.Fatalf("IsOK must stay outcome-driven when OK field is tampered (outcome=%q)", c.outcome)
|
||||||
|
}
|
||||||
|
if err := tampered.Validate(); err == nil || !strings.Contains(err.Error(), "I1") {
|
||||||
|
t.Fatalf("Validate() on tampered outcome=%q envelope must report I1 violation, got %v", c.outcome, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvariantI1ConstructorAgreement(t *testing.T) {
|
||||||
|
// 四个构造器产出的信封,其 OK 字段与 IsOK() 恒一致(I1 的装配端闭环)。
|
||||||
|
envs := map[string]*Envelope{
|
||||||
|
"success": NewSuccessEnvelope(nil),
|
||||||
|
"pending": NewPendingEnvelope(nil),
|
||||||
|
"partial_failure": NewPartialEnvelope(nil),
|
||||||
|
"failure": NewFailureEnvelope(&ErrorInfo{Type: "api"}),
|
||||||
|
}
|
||||||
|
for name, env := range envs {
|
||||||
|
if env.OK != env.IsOK() {
|
||||||
|
t.Fatalf("constructor %q: OK=%v but IsOK()=%v (I1)", name, env.OK, env.IsOK())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B23:I3 双向穷举(error 非空 ⇔ outcome==failure)---
|
||||||
|
|
||||||
|
func TestInvariantI3BidirectionalExhaustive(t *testing.T) {
|
||||||
|
// 双向穷举:4 个 outcome × {error 缺席, error 非空} = 8 组合,
|
||||||
|
// 仅 outcome==failure ⇔ error 非空 的 2 组合合法(对角线两侧各一)。
|
||||||
|
errInfo := &ErrorInfo{Type: "api", Code: 90018, Message: "too many requests"}
|
||||||
|
for _, outcome := range canonicalOutcomes {
|
||||||
|
for _, errPtr := range []*ErrorInfo{nil, errInfo} {
|
||||||
|
env := &Envelope{
|
||||||
|
OK: outcome == OutcomeSuccess || outcome == OutcomePending,
|
||||||
|
Outcome: outcome,
|
||||||
|
Error: errPtr,
|
||||||
|
}
|
||||||
|
errorPresent := env.Error != nil
|
||||||
|
isFailure := env.Outcome == OutcomeFailure
|
||||||
|
wantValid := errorPresent == isFailure
|
||||||
|
err := env.Validate()
|
||||||
|
if wantValid && err != nil {
|
||||||
|
t.Fatalf("outcome=%q errorPresent=%v must satisfy I3, Validate()=%v", outcome, errorPresent, err)
|
||||||
|
}
|
||||||
|
if !wantValid {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("outcome=%q errorPresent=%v violates I3 but Validate() passed", outcome, errorPresent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "I3") {
|
||||||
|
t.Fatalf("I3 violation must be reported as such, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvariantI3WireDirectionFromConstructors(t *testing.T) {
|
||||||
|
// wire 方向断言:四类构造器产出的信封中,error 字段出现当且仅当
|
||||||
|
// outcome==failure(「⇔」的两个方向都要显式断言)。
|
||||||
|
envs := []*Envelope{
|
||||||
|
NewSuccessEnvelope(map[string]any{"id": "x"}),
|
||||||
|
NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"}),
|
||||||
|
NewPartialEnvelope(map[string]any{"total": 1}),
|
||||||
|
NewFailureEnvelope(&ErrorInfo{Type: "validation", Message: "bad"}),
|
||||||
|
}
|
||||||
|
for _, env := range envs {
|
||||||
|
errorPresent := env.Error != nil
|
||||||
|
isFailure := env.Outcome == OutcomeFailure
|
||||||
|
if errorPresent != isFailure {
|
||||||
|
t.Fatalf("constructor envelope outcome=%q: error present=%v, want == isFailure(%v) (I3)",
|
||||||
|
env.Outcome, errorPresent, isFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
// 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 output
|
||||||
|
|
||||||
|
// Phase G:partial_failure / unknown 通道(B139~B146;契约规范 §2.3)。
|
||||||
|
// 落盘策略:轮8裁决⑩新文件——不编辑 envelope_test.go/emitter_test.go。
|
||||||
|
// B143(partial 信封 → exit 7 出口映射)已由轮9 B34 覆盖
|
||||||
|
// (emitter_phase_b_test.go:TestInvariantI2ExhaustiveOverFourOutcomes 的
|
||||||
|
// partial==7 精确断言 + TestEmitterStreamRoutingConsistentWithExitCode 的
|
||||||
|
// stdout 通道 + exit 7 联动断言),本文件不重复造测试。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- B139:PartialData 三通道明细类型(total/succeeded[]/failed[]/unknown[])---
|
||||||
|
|
||||||
|
func TestPartialDataWireShapeAndKeyOrder(t *testing.T) {
|
||||||
|
// wire 形态对齐契约 §2.3 示例:顶层键序 total/succeeded/failed/unknown
|
||||||
|
// (声明序 = wire 序),空通道显式 [] 而非 null(§2.5 无 null 纪律)。
|
||||||
|
pd, err := NewPartialData(2,
|
||||||
|
[]any{map[string]any{"id": "a", "messageId": "m_1"}},
|
||||||
|
nil, // nil failed 归一为 []
|
||||||
|
[]PartialUnknownEntry{{ID: "c", Reason: "timeout after submit"}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPartialData: %v", err)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(pd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal(PartialData): %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("PartialData wire is not valid JSON: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if got, ok := decoded["total"].(float64); !ok || got != 2 {
|
||||||
|
t.Fatalf("total = %v, want 2: %s", decoded["total"], raw)
|
||||||
|
}
|
||||||
|
for _, channel := range []string{"succeeded", "failed", "unknown"} {
|
||||||
|
arr, ok := decoded[channel].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("channel %q must serialize as a JSON array: %s", channel, raw)
|
||||||
|
}
|
||||||
|
if arr == nil {
|
||||||
|
t.Fatalf("channel %q must be [] not null: %s", channel, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), "null") {
|
||||||
|
t.Fatalf("partial channels must never emit null (§2.5): %s", raw)
|
||||||
|
}
|
||||||
|
// 键序断言:json.Decoder 保序读入。
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
if tok, err := dec.Token(); err != nil || tok != json.Delim('{') {
|
||||||
|
t.Fatalf("PartialData must be an object: %v (%v)", tok, err)
|
||||||
|
}
|
||||||
|
var keys []string
|
||||||
|
for dec.More() {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token: %v", err)
|
||||||
|
}
|
||||||
|
keys = append(keys, tok.(string))
|
||||||
|
var skip json.RawMessage
|
||||||
|
if err := dec.Decode(&skip); err != nil {
|
||||||
|
t.Fatalf("decode value: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if want := []string{"total", "succeeded", "failed", "unknown"}; !reflect.DeepEqual(keys, want) {
|
||||||
|
t.Fatalf("PartialData key order = %v, want %v (§2.3)", keys, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialFailedEntryCarriesErrorInfoShape(t *testing.T) {
|
||||||
|
// failed 通道条目 = id + error(复用 §2.4 ErrorInfo,wire-stable 字段可分支)。
|
||||||
|
entry := PartialFailedEntry{ID: "b", Error: &ErrorInfo{Type: "api", Code: 40001, Message: "invalid recipient"}}
|
||||||
|
raw, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal(failed entry): %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("failed entry wire invalid: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if decoded["id"] != "b" {
|
||||||
|
t.Fatalf("failed entry id = %v: %s", decoded["id"], raw)
|
||||||
|
}
|
||||||
|
errObj, ok := decoded["error"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("failed entry must carry error detail (§2.3): %s", raw)
|
||||||
|
}
|
||||||
|
if errObj["type"] != "api" {
|
||||||
|
t.Fatalf("failed entry error.type = %v: %s", errObj["type"], raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B141:全失败禁用 partial_failure(空 succeeded 拒绝构造)---
|
||||||
|
|
||||||
|
func TestNewPartialDataRejectsEmptySucceeded(t *testing.T) {
|
||||||
|
// §2.3 规则4:全部失败时必须用 outcome:failure,禁止空 succeeded 的
|
||||||
|
// partial_failure。构造期即拒绝。
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
failed []PartialFailedEntry
|
||||||
|
unknown []PartialUnknownEntry
|
||||||
|
}{
|
||||||
|
{"all failed", []PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api"}}}, nil},
|
||||||
|
{"all unknown", nil, []PartialUnknownEntry{{ID: "c", Reason: "timeout"}}},
|
||||||
|
{"failed and unknown only",
|
||||||
|
[]PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api"}}},
|
||||||
|
[]PartialUnknownEntry{{ID: "c", Reason: "timeout"}}},
|
||||||
|
{"empty everything", nil, nil},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
total := len(tc.failed) + len(tc.unknown)
|
||||||
|
pd, err := NewPartialData(total, nil, tc.failed, tc.unknown)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("NewPartialData with empty succeeded must be rejected (§2.3 rule 4), got %+v", pd)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "succeeded") || !strings.Contains(err.Error(), "failure") {
|
||||||
|
t.Fatalf("rejection must name the empty-succeeded/all-failure rule: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 全失败的正确形态:普通 failure 信封(构造合法、Validate 通过)。
|
||||||
|
failure := NewFailureEnvelope(&ErrorInfo{Type: "api", Message: "all 3 items failed"})
|
||||||
|
if err := failure.Validate(); err != nil {
|
||||||
|
t.Fatalf("all-failed batch must use a legal failure envelope: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopeValidateRejectsHandBuiltAllFailedPartial(t *testing.T) {
|
||||||
|
// 绕过构造器手工装配的全失败 partial 同样被 Validate 拒绝
|
||||||
|
// (校验集中,§2.3 规则4)。
|
||||||
|
pd := &PartialData{
|
||||||
|
Total: 2,
|
||||||
|
Succeeded: []any{},
|
||||||
|
Failed: []PartialFailedEntry{
|
||||||
|
{ID: "a", Error: &ErrorInfo{Type: "api"}},
|
||||||
|
{ID: "b", Error: &ErrorInfo{Type: "api"}},
|
||||||
|
},
|
||||||
|
Unknown: []PartialUnknownEntry{},
|
||||||
|
}
|
||||||
|
env := NewPartialEnvelope(pd)
|
||||||
|
err := env.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Validate must reject all-failed partial_failure (§2.3 rule 4)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "partial_failure with empty succeeded") {
|
||||||
|
t.Fatalf("error must name the rule: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialDataTotalReconciliation(t *testing.T) {
|
||||||
|
// total 对账:total != 三通道之和即拒绝(明细是 total 的完整划分)。
|
||||||
|
_, err := NewPartialData(5, []any{map[string]any{"id": "a"}},
|
||||||
|
[]PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api"}}}, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("total=5 with 2 detailed entries must be rejected (§2.3 reconciliation)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "reconcile") {
|
||||||
|
t.Fatalf("error must name reconciliation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B140:通道校验规则——unknown 条目禁止归入 succeeded/failed ---
|
||||||
|
|
||||||
|
func TestPartialDataRejectsCrossChannelDuplicateIDs(t *testing.T) {
|
||||||
|
succeeded := func(id string) []any { return []any{map[string]any{"id": id, "messageId": "m_1"}} }
|
||||||
|
failed := func(id string) []PartialFailedEntry {
|
||||||
|
return []PartialFailedEntry{{ID: id, Error: &ErrorInfo{Type: "api"}}}
|
||||||
|
}
|
||||||
|
unknown := func(id string) []PartialUnknownEntry {
|
||||||
|
return []PartialUnknownEntry{{ID: id, Reason: "timeout after submit"}}
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
succeeded []any
|
||||||
|
failed []PartialFailedEntry
|
||||||
|
unknown []PartialUnknownEntry
|
||||||
|
}{
|
||||||
|
{"unknown id also in succeeded", succeeded("c"), nil, unknown("c")},
|
||||||
|
{"unknown id also in failed", nil, failed("c"), unknown("c")},
|
||||||
|
{"failed id also in succeeded", succeeded("b"), failed("b"), nil},
|
||||||
|
{"duplicate within unknown", succeeded("a"), nil,
|
||||||
|
append(unknown("c"), PartialUnknownEntry{ID: "c", Reason: "second report"})},
|
||||||
|
{"duplicate within succeeded", append(succeeded("a"), map[string]any{"id": "a"}), nil, nil},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
total := len(tc.succeeded) + len(tc.failed) + len(tc.unknown)
|
||||||
|
_, err := NewPartialData(total, tc.succeeded, tc.failed, tc.unknown)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("cross/duplicate channel id must be rejected (§2.3 rule 2)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "one terminal channel") && !strings.Contains(err.Error(), "exactly one terminal channel") {
|
||||||
|
t.Fatalf("error must name the one-channel-per-entry rule: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialDataAcceptsDistinctChannelIDs(t *testing.T) {
|
||||||
|
// 三通道 id 互不相交时合法(契约 §2.3 标准形态)。
|
||||||
|
pd, err := NewPartialData(3,
|
||||||
|
[]any{map[string]any{"id": "a", "messageId": "m_1"}},
|
||||||
|
[]PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api", Code: 40001}}},
|
||||||
|
[]PartialUnknownEntry{{ID: "c", Reason: "timeout after submit"}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("legal three-channel data rejected: %v", err)
|
||||||
|
}
|
||||||
|
env := NewPartialEnvelope(pd)
|
||||||
|
env.Meta = &Meta{Count: NewCount(3)}
|
||||||
|
if err := env.Validate(); err != nil {
|
||||||
|
t.Fatalf("legal partial envelope must pass Validate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialDataEntriesMustBeIdentifiable(t *testing.T) {
|
||||||
|
// failed/unknown 条目 id 必须非空——条目可标识是跨通道互斥的唯一依据。
|
||||||
|
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}},
|
||||||
|
[]PartialFailedEntry{{Error: &ErrorInfo{Type: "api"}}}, nil); err == nil {
|
||||||
|
t.Fatal("failed entry without id must be rejected (§2.3)")
|
||||||
|
}
|
||||||
|
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}},
|
||||||
|
nil, []PartialUnknownEntry{{Reason: "timeout"}}); err == nil {
|
||||||
|
t.Fatal("unknown entry without id must be rejected (§2.3)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B144:unknown[] 条目 shape(id + reason)---
|
||||||
|
|
||||||
|
func TestPartialUnknownEntryShapeIDPlusReason(t *testing.T) {
|
||||||
|
// shape 恰为 id + reason 两键(P0#9「报错却写入」类条目的诚实通道)。
|
||||||
|
entry := PartialUnknownEntry{ID: "c", Reason: "timeout after submit"}
|
||||||
|
raw, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal(unknown entry): %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("unknown entry wire invalid: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if len(decoded) != 2 {
|
||||||
|
t.Fatalf("unknown entry must carry exactly id+reason, got %v: %s", decoded, raw)
|
||||||
|
}
|
||||||
|
if decoded["id"] != "c" || decoded["reason"] != "timeout after submit" {
|
||||||
|
t.Fatalf("unknown entry = %v, want id+reason preserved", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialUnknownEntryRequiresReason(t *testing.T) {
|
||||||
|
// 诚实通道必须说明不确定原因:空 reason 拒绝。
|
||||||
|
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}},
|
||||||
|
nil, []PartialUnknownEntry{{ID: "c"}}); err == nil {
|
||||||
|
t.Fatal("unknown entry without reason must be rejected (§2.3/P0#9)")
|
||||||
|
}
|
||||||
|
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}},
|
||||||
|
nil, []PartialUnknownEntry{{ID: "c", Reason: " "}}); err == nil {
|
||||||
|
t.Fatal("unknown entry with whitespace-only reason must be rejected (§2.3/P0#9)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B146:succeeded 明细完整保留(构造器不接受丢弃明细)---
|
||||||
|
|
||||||
|
func TestNewPartialDataPreservesSucceededDetailsVerbatim(t *testing.T) {
|
||||||
|
// 构造器不丢弃、不过滤、不重排任何 succeeded 条目(§2.3 规则1:
|
||||||
|
// Agent 需要知道哪些已生效,避免重复提交)。
|
||||||
|
succeeded := []any{
|
||||||
|
map[string]any{"id": "a", "messageId": "m_1"},
|
||||||
|
map[string]any{"id": "d", "messageId": "m_4", "extra": map[string]any{"nested": true}},
|
||||||
|
"d-plain-string-entry", // 异构条目同样原样保留
|
||||||
|
}
|
||||||
|
pd, err := NewPartialData(3, succeeded, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPartialData: %v", err)
|
||||||
|
}
|
||||||
|
if len(pd.Succeeded) != len(succeeded) {
|
||||||
|
t.Fatalf("succeeded length = %d, want %d (no dropping)", len(pd.Succeeded), len(succeeded))
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(pd.Succeeded, succeeded) {
|
||||||
|
t.Fatalf("succeeded details altered:\n got %+v\nwant %+v", pd.Succeeded, succeeded)
|
||||||
|
}
|
||||||
|
// wire 层同样完整:异构条目(object 与 string)均可解码回原样。
|
||||||
|
raw, err := json.Marshal(pd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
var generic struct {
|
||||||
|
Succeeded []any `json:"succeeded"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &generic); err != nil {
|
||||||
|
t.Fatalf("decode: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if len(generic.Succeeded) != 3 {
|
||||||
|
t.Fatalf("wire succeeded length = %d, want 3: %s", len(generic.Succeeded), raw)
|
||||||
|
}
|
||||||
|
first, _ := generic.Succeeded[0].(map[string]any)
|
||||||
|
if first == nil || first["messageId"] != "m_1" {
|
||||||
|
t.Fatalf("wire lost succeeded detail fields: %s", raw)
|
||||||
|
}
|
||||||
|
if generic.Succeeded[2] != "d-plain-string-entry" {
|
||||||
|
t.Fatalf("wire lost heterogeneous succeeded entry: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialDataRejectsNilSucceededEntries(t *testing.T) {
|
||||||
|
// nil(null)条目混入明细即拒绝:完整保留意味着每条都是真实明细。
|
||||||
|
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}, nil}, nil, nil); err == nil {
|
||||||
|
t.Fatal("nil succeeded entry must be rejected (§2.3 full preservation)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B145:partial 装配样例——2 成 1 败 1 unknown(契约规范 §6 场景3)---
|
||||||
|
|
||||||
|
func TestPartialAssemblyExampleTwoOneOne(t *testing.T) {
|
||||||
|
// 场景3 行为端:ok:false / rc=7 映射 / stderr 空;三通道明细完整落 stdout。
|
||||||
|
pd, err := NewPartialData(4,
|
||||||
|
[]any{
|
||||||
|
map[string]any{"id": "a", "messageId": "m_1"},
|
||||||
|
map[string]any{"id": "b", "messageId": "m_2"},
|
||||||
|
},
|
||||||
|
[]PartialFailedEntry{{ID: "c", Error: &ErrorInfo{Type: "api", Code: 40001, Message: "invalid recipient"}}},
|
||||||
|
[]PartialUnknownEntry{{ID: "d", Reason: "timeout after submit"}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPartialData: %v", err)
|
||||||
|
}
|
||||||
|
env := NewPartialEnvelope(pd)
|
||||||
|
env.Meta = &Meta{Count: NewCount(4)}
|
||||||
|
|
||||||
|
// 不变量面:I1 ok==false(partial 非 ok)、I3 无顶层 error、Validate 通过。
|
||||||
|
if env.OK || env.Outcome != OutcomePartialFailure {
|
||||||
|
t.Fatalf("partial envelope = ok:%v outcome:%q, want false/partial_failure", env.OK, env.Outcome)
|
||||||
|
}
|
||||||
|
if env.Error != nil {
|
||||||
|
t.Fatalf("partial envelope must not carry top-level error (I3/§2.3)")
|
||||||
|
}
|
||||||
|
if err := env.Validate(); err != nil {
|
||||||
|
t.Fatalf("Validate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 出口面:明细落 stdout、stderr 零输出(§2.3)。
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
if err := em.Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit partial: %v", err)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("partial_failure must keep stderr empty (§2.3), got %d bytes: %s", stderr.Len(), stderr.String())
|
||||||
|
}
|
||||||
|
var decoded struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Outcome string `json:"outcome"`
|
||||||
|
Data PartialData `json:"data"`
|
||||||
|
Meta struct {
|
||||||
|
Count *int `json:"count"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stdout is not one complete JSON envelope: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if decoded.OK {
|
||||||
|
t.Fatalf("partial ok = true, want false (§1): %s", stdout.String())
|
||||||
|
}
|
||||||
|
if decoded.Outcome != "partial_failure" {
|
||||||
|
t.Fatalf("outcome = %q: %s", decoded.Outcome, stdout.String())
|
||||||
|
}
|
||||||
|
if decoded.Data.Total != 4 || len(decoded.Data.Succeeded) != 2 ||
|
||||||
|
len(decoded.Data.Failed) != 1 || len(decoded.Data.Unknown) != 1 {
|
||||||
|
t.Fatalf("channel counts = %d/%d/%d/%d, want 4/2/1/1: %s",
|
||||||
|
decoded.Data.Total, len(decoded.Data.Succeeded), len(decoded.Data.Failed), len(decoded.Data.Unknown), stdout.String())
|
||||||
|
}
|
||||||
|
if decoded.Data.Failed[0].Error == nil || decoded.Data.Failed[0].Error.Type != "api" {
|
||||||
|
t.Fatalf("failed entry lost error detail: %s", stdout.String())
|
||||||
|
}
|
||||||
|
if decoded.Data.Unknown[0].Reason != "timeout after submit" {
|
||||||
|
t.Fatalf("unknown entry lost reason: %s", stdout.String())
|
||||||
|
}
|
||||||
|
if decoded.Meta.Count == nil || *decoded.Meta.Count != 4 {
|
||||||
|
t.Fatalf("meta.count = %v, want 4: %s", decoded.Meta.Count, stdout.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// rc=7 映射(§4 partial 专用码;ExitCodeForEnvelope 与 errors 侧同源见 B209)。
|
||||||
|
if code := ExitCodeForEnvelope(env); code != 7 {
|
||||||
|
t.Fatalf("partial exit code = %d, want dedicated 7 (§4)", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B143 覆盖核实说明(不重复造测试)---
|
||||||
|
//
|
||||||
|
// partial 信封 → exit 7 的出口断言已由轮9 B34 落盘并评审通过:
|
||||||
|
// - emitter_phase_b_test.go TestInvariantI2ExhaustiveOverFourOutcomes:
|
||||||
|
// 「partial_failure 专用码精确断言(非零且恰为 7)」;
|
||||||
|
// - emitter_phase_b_test.go TestEmitterStreamRoutingConsistentWithExitCode:
|
||||||
|
// partial_failure 走 stdout 通道且 exit 7 的联动断言。
|
||||||
|
// 本文件 TestPartialAssemblyExampleTwoOneOne 在装配样例内顺带复核 rc=7,
|
||||||
|
// 仅为场景自洽,不构成新的覆盖重复主张。
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
// 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 output
|
||||||
|
|
||||||
|
// Phase H:operation / pagination 装配助手(B147~B154;契约规范 §2.2/§3)。
|
||||||
|
// 落盘策略:轮8裁决⑩新文件——不编辑 envelope_test.go/emitter_test.go。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- B150:operation.state 取值表测试 ---
|
||||||
|
|
||||||
|
func TestOperationStateValueTable(t *testing.T) {
|
||||||
|
// state 取值表(§2.2):规范三值 processing/completed/failed 是 Agent
|
||||||
|
// 必须能分支的最小集;开放枚举容忍业务特化阶段(如 importing、queued)。
|
||||||
|
// 表驱动:每个规范值装配合法、wire 原样、Validate 通过。
|
||||||
|
normative := []struct {
|
||||||
|
state string
|
||||||
|
// timed_out 共存合法性由 ValidateTimeoutState 把守,这里只表 state 装配。
|
||||||
|
}{
|
||||||
|
{OperationStateProcessing},
|
||||||
|
{OperationStateCompleted},
|
||||||
|
{OperationStateFailed},
|
||||||
|
}
|
||||||
|
if len(normative) != 3 {
|
||||||
|
t.Fatalf("operation.state normative table must cover exactly 3 values (§2.2), got %d", len(normative))
|
||||||
|
}
|
||||||
|
for _, tc := range normative {
|
||||||
|
t.Run("state="+tc.state, func(t *testing.T) {
|
||||||
|
op := &OperationInfo{ID: "t_1", State: tc.state, NextCommand: "dws op get t_1"}
|
||||||
|
env, err := NewPendingEnvelopeForPolling(op)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPendingEnvelopeForPolling(state=%q): %v", tc.state, err)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
var decoded struct {
|
||||||
|
Meta struct {
|
||||||
|
Operation struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
} `json:"operation"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("decode: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if decoded.Meta.Operation.State != tc.state {
|
||||||
|
t.Fatalf("wire state = %q, want %q: %s", decoded.Meta.Operation.State, tc.state, raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 开放枚举:业务特化阶段不在规范表内但可装配(框架不解释语义,§9)。
|
||||||
|
op := &OperationInfo{ID: "t_2", State: "importing", NextCommand: "dws op get t_2"}
|
||||||
|
if _, err := NewPendingEnvelopeForPolling(op); err != nil {
|
||||||
|
t.Fatalf("specialized state must remain assemblable (open enum, §2.2): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B148:next_command 非空可执行断言 ---
|
||||||
|
|
||||||
|
func TestPollingPendingEnvelopeRequiresNonEmptyNextCommand(t *testing.T) {
|
||||||
|
// 轮询形态的恢复方式只有 next_command 一条出口(§2.2 规则2):
|
||||||
|
// 非空、无前后空白、单行——缺任一项即装配失败。
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
op *OperationInfo
|
||||||
|
}{
|
||||||
|
{"nil operation", nil},
|
||||||
|
{"empty next_command", &OperationInfo{ID: "t_1", State: OperationStateProcessing}},
|
||||||
|
{"whitespace next_command", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: " "}},
|
||||||
|
{"leading space", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: " dws op get t_1"}},
|
||||||
|
{"trailing space", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1 "}},
|
||||||
|
{"multi-line", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1\ndws op get t_1"}},
|
||||||
|
{"carriage return", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1\r"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
env, err := NewPendingEnvelopeForPolling(tc.op)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("NewPendingEnvelopeForPolling(%+v) must fail (§2.2 rule 2), got %+v", tc.op, env)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 合法形态:可执行单行命令装配通过。
|
||||||
|
env, err := NewPendingEnvelopeForPolling(&OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("legal next_command rejected: %v", err)
|
||||||
|
}
|
||||||
|
if env.Meta == nil || env.Meta.Operation == nil || env.Meta.Operation.NextCommand != "dws op get t_1" {
|
||||||
|
t.Fatalf("assembled envelope lost next_command: %+v", env)
|
||||||
|
}
|
||||||
|
// ValidateNextCommand 对空串不拦截(裸 pending 信封允许无 operation 明细),
|
||||||
|
// 只对非空值做形态校验——严格性由装配助手把守。
|
||||||
|
if err := (&OperationInfo{}).ValidateNextCommand(); err != nil {
|
||||||
|
t.Fatalf("empty next_command must not be rejected by shape validator alone: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B149:timed_out:true 时 state 保留断言(§2.2 防伪装)---
|
||||||
|
|
||||||
|
func TestTimedOutKeepsRealState(t *testing.T) {
|
||||||
|
// §2.2 规则1(lark wiki_node_delete 教训):轮询超时必须保持 state 真实值
|
||||||
|
// 并置 timed_out:true;超时操作不得宣称完成态。
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
op *OperationInfo
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"timed_out with real processing state",
|
||||||
|
&OperationInfo{ID: "t_1", State: OperationStateProcessing, TimedOut: true, NextCommand: "dws op get t_1"}, false},
|
||||||
|
{"timed_out with specialized real state",
|
||||||
|
&OperationInfo{ID: "t_1", State: "importing", TimedOut: true, NextCommand: "dws op get t_1"}, false},
|
||||||
|
{"timed_out with empty state",
|
||||||
|
&OperationInfo{ID: "t_1", TimedOut: true, NextCommand: "dws op get t_1"}, true},
|
||||||
|
{"timed_out claiming completed",
|
||||||
|
&OperationInfo{ID: "t_1", State: OperationStateCompleted, TimedOut: true, NextCommand: "dws op get t_1"}, true},
|
||||||
|
{"timed_out claiming success (case-insensitive)",
|
||||||
|
&OperationInfo{ID: "t_1", State: "SUCCESS", TimedOut: true, NextCommand: "dws op get t_1"}, true},
|
||||||
|
{"no timeout keeps completed legal",
|
||||||
|
&OperationInfo{ID: "t_1", State: OperationStateCompleted, NextCommand: "dws op get t_1"}, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := tc.op.ValidateTimeoutState()
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatalf("ValidateTimeoutState(%+v) must reject (§2.2 anti-spoof)", tc.op)
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("ValidateTimeoutState(%+v) must accept: %v", tc.op, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// wire 层:timed_out:true 与真实 state 同现。
|
||||||
|
env, err := NewPendingEnvelopeForPolling(&OperationInfo{ID: "t_1", State: OperationStateProcessing, TimedOut: true, NextCommand: "dws op get t_1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("assembling timed_out envelope: %v", err)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
var decoded struct {
|
||||||
|
Meta struct {
|
||||||
|
Operation struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
TimedOut bool `json:"timed_out"`
|
||||||
|
} `json:"operation"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("decode: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if decoded.Meta.Operation.State != OperationStateProcessing || !decoded.Meta.Operation.TimedOut {
|
||||||
|
t.Fatalf("wire must keep real state + timed_out:true (§2.2), got %+v: %s", decoded.Meta.Operation, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B147:NewPendingEnvelope 装配助手增强 / 轮询形态 ---
|
||||||
|
|
||||||
|
func TestPendingEnvelopeAssemblyAssistants(t *testing.T) {
|
||||||
|
// 宽松构造器(B19)保持构造宽松:无 op / 空 next_command 均可构造。
|
||||||
|
if env := NewPendingEnvelope(nil); env.Meta != nil || !env.OK || env.Outcome != OutcomePending {
|
||||||
|
t.Fatalf("NewPendingEnvelope(nil) = %+v, want bare pending", env)
|
||||||
|
}
|
||||||
|
if env := NewPendingEnvelope(&OperationInfo{ID: "t_1"}); env.Meta == nil || env.Meta.Operation.ID != "t_1" {
|
||||||
|
t.Fatalf("NewPendingEnvelope lost operation: %+v", env)
|
||||||
|
}
|
||||||
|
// 严格装配助手(B147):轮询形态必须携带 op + 可执行 next_command。
|
||||||
|
env, err := NewPendingEnvelopeForPolling(&OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPendingEnvelopeForPolling: %v", err)
|
||||||
|
}
|
||||||
|
// I1 一致性:pending → ok:true。
|
||||||
|
if !env.OK || env.Outcome != OutcomePending {
|
||||||
|
t.Fatalf("polling pending envelope = ok:%v outcome:%q, want true/pending (I1)", env.OK, env.Outcome)
|
||||||
|
}
|
||||||
|
if err := env.Validate(); err != nil {
|
||||||
|
t.Fatalf("Validate: %v", err)
|
||||||
|
}
|
||||||
|
// 出口面:pending 落 stdout、exit 0。
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
if err := em.Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit: %v", err)
|
||||||
|
}
|
||||||
|
if stdout.Len() == 0 || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("pending must route stdout (stdout=%d stderr=%d)", stdout.Len(), stderr.Len())
|
||||||
|
}
|
||||||
|
if code := ExitCodeForEnvelope(env); code != 0 {
|
||||||
|
t.Fatalf("pending exit code = %d, want 0 (I2)", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B151:import_flow 字段映射断言 ---
|
||||||
|
|
||||||
|
func TestImportFlowOperationFieldMapping(t *testing.T) {
|
||||||
|
// 契约 §2.2 的 import_flow 场景映射:业务侧 task 终态 → operation 字段。
|
||||||
|
// 框架只断言映射后的 wire 形态(§9 语义归装配侧):
|
||||||
|
// task_id → operation.id
|
||||||
|
// status → operation.state
|
||||||
|
// timeout → operation.timed_out(超时时 true,且 state 保留真实值)
|
||||||
|
// 轮询命令 → operation.next_command
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
taskID string
|
||||||
|
status string
|
||||||
|
timedOut bool
|
||||||
|
nextCmd string
|
||||||
|
wantTimed bool
|
||||||
|
wantState string
|
||||||
|
wantNext string
|
||||||
|
}{
|
||||||
|
{"running task", "task_01", "processing", false, "dws op get task_01", false, "processing", "dws op get task_01"},
|
||||||
|
{"timed-out task keeps real state", "task_02", "processing", true, "dws op get task_02", true, "processing", "dws op get task_02"},
|
||||||
|
{"completed task", "task_03", "completed", false, "dws op get task_03", false, "completed", "dws op get task_03"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
env, err := NewPendingEnvelopeForPolling(&OperationInfo{
|
||||||
|
ID: tc.taskID,
|
||||||
|
State: tc.status,
|
||||||
|
TimedOut: tc.timedOut,
|
||||||
|
NextCommand: tc.nextCmd,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPendingEnvelopeForPolling: %v", err)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
var decoded struct {
|
||||||
|
Meta struct {
|
||||||
|
Operation struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
State string `json:"state"`
|
||||||
|
TimedOut bool `json:"timed_out"`
|
||||||
|
NextCommand string `json:"next_command"`
|
||||||
|
} `json:"operation"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("decode: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
op := decoded.Meta.Operation
|
||||||
|
if op.ID != tc.taskID {
|
||||||
|
t.Fatalf("operation.id = %q, want task_id %q: %s", op.ID, tc.taskID, raw)
|
||||||
|
}
|
||||||
|
if op.State != tc.wantState {
|
||||||
|
t.Fatalf("operation.state = %q, want %q: %s", op.State, tc.wantState, raw)
|
||||||
|
}
|
||||||
|
if op.TimedOut != tc.wantTimed {
|
||||||
|
t.Fatalf("operation.timed_out = %v, want %v: %s", op.TimedOut, tc.wantTimed, raw)
|
||||||
|
}
|
||||||
|
if op.NextCommand != tc.wantNext {
|
||||||
|
t.Fatalf("operation.next_command = %q, want %q: %s", op.NextCommand, tc.wantNext, raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B152:NewPagination 装配助手 ---
|
||||||
|
|
||||||
|
func TestNewPaginationTwoStateCompleteness(t *testing.T) {
|
||||||
|
// §3 两态完备:resumable(携带 next_token)与 exhausted(无 token)是
|
||||||
|
// 仅有的两个状态;不设 stop_reason。装配助手强制两态互斥完备。
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
exhausted bool
|
||||||
|
token string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"resumable with token", false, "tok_2", false},
|
||||||
|
{"exhausted without token", true, "", false},
|
||||||
|
{"resumable missing token", false, "", true},
|
||||||
|
{"exhausted carrying token", true, "tok_2", true},
|
||||||
|
{"resumable whitespace-only token", false, " ", true},
|
||||||
|
{"exhausted whitespace token accepted as empty", true, " ", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
pg, err := NewPagination(tc.exhausted, tc.token)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("NewPagination(exhausted=%v, token=%q) must fail (§3)", tc.exhausted, tc.token)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPagination(exhausted=%v, token=%q): %v", tc.exhausted, tc.token, err)
|
||||||
|
}
|
||||||
|
if pg.EndpointExhausted != tc.exhausted {
|
||||||
|
t.Fatalf("endpoint_exhausted = %v, want %v", pg.EndpointExhausted, tc.exhausted)
|
||||||
|
}
|
||||||
|
wantToken := strings.TrimSpace(tc.token)
|
||||||
|
if pg.NextToken != wantToken {
|
||||||
|
t.Fatalf("next_token = %q, want %q", pg.NextToken, wantToken)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B153:Pagination pages/items 字段 ---
|
||||||
|
|
||||||
|
func TestPaginationPagesItemsFields(t *testing.T) {
|
||||||
|
// pages/items 为信息性计数(omitempty):设置后 wire 携带,未设置则缺席。
|
||||||
|
pg, err := NewPagination(false, "tok_2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPagination: %v", err)
|
||||||
|
}
|
||||||
|
pages, items := 2, 50
|
||||||
|
pg.Pages = pages
|
||||||
|
pg.Items = items
|
||||||
|
raw, err := json.Marshal(pg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("decode: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if got, ok := decoded["pages"].(float64); !ok || got != 2 {
|
||||||
|
t.Fatalf("pages = %v, want 2: %s", decoded["pages"], raw)
|
||||||
|
}
|
||||||
|
if got, ok := decoded["items"].(float64); !ok || got != 50 {
|
||||||
|
t.Fatalf("items = %v, want 50: %s", decoded["items"], raw)
|
||||||
|
}
|
||||||
|
if decoded["next_token"] != "tok_2" || decoded["endpoint_exhausted"] != false {
|
||||||
|
t.Fatalf("pagination wire lost core fields: %s", raw)
|
||||||
|
}
|
||||||
|
// 未设置时 omitempty 缺席。
|
||||||
|
bare, err := NewPagination(true, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPagination(exhausted): %v", err)
|
||||||
|
}
|
||||||
|
raw, err = json.Marshal(bare)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
var bareDecoded map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &bareDecoded); err != nil {
|
||||||
|
t.Fatalf("decode: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if _, present := bareDecoded["pages"]; present {
|
||||||
|
t.Fatalf("unset pages must be omitted: %s", raw)
|
||||||
|
}
|
||||||
|
if _, present := bareDecoded["items"]; present {
|
||||||
|
t.Fatalf("unset items must be omitted: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B154:空结果 data:[] + count:0 + endpoint_exhausted 断言 ---
|
||||||
|
|
||||||
|
func TestEmptyResultSetWireAssertion(t *testing.T) {
|
||||||
|
// AC-06/§3:空结果合法形态 = data:[] + meta.count:0 +
|
||||||
|
// pagination.endpoint_exhausted:true。三要素齐备且 wire 原样。
|
||||||
|
pg, err := NewPagination(true, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPagination: %v", err)
|
||||||
|
}
|
||||||
|
env := NewSuccessEnvelope([]any{})
|
||||||
|
env.Meta = &Meta{Count: NewCount(0), Pagination: pg}
|
||||||
|
if err := env.Validate(); err != nil {
|
||||||
|
t.Fatalf("Validate: %v", err)
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
|
||||||
|
if err := em.Emit(env); err != nil {
|
||||||
|
t.Fatalf("Emit: %v", err)
|
||||||
|
}
|
||||||
|
var decoded struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Outcome string `json:"outcome"`
|
||||||
|
Data []any `json:"data"`
|
||||||
|
Meta struct {
|
||||||
|
Count *int `json:"count"`
|
||||||
|
Pagination struct {
|
||||||
|
EndpointExhausted bool `json:"endpoint_exhausted"`
|
||||||
|
} `json:"pagination"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("stdout is not one complete JSON envelope: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if !decoded.OK || decoded.Outcome != "success" {
|
||||||
|
t.Fatalf("empty result = ok:%v outcome:%q, want true/success", decoded.OK, decoded.Outcome)
|
||||||
|
}
|
||||||
|
if decoded.Data == nil || len(decoded.Data) != 0 {
|
||||||
|
t.Fatalf("data must be [] (not null, not missing): %s", stdout.String())
|
||||||
|
}
|
||||||
|
if decoded.Meta.Count == nil || *decoded.Meta.Count != 0 {
|
||||||
|
t.Fatalf("meta.count = %v, want 0: %s", decoded.Meta.Count, stdout.String())
|
||||||
|
}
|
||||||
|
if !decoded.Meta.Pagination.EndpointExhausted {
|
||||||
|
t.Fatalf("empty result must mark endpoint_exhausted:true (§3): %s", stdout.String())
|
||||||
|
}
|
||||||
|
// raw 字符串层面:data 必须是 [] 而非 null。
|
||||||
|
if !strings.Contains(stdout.String(), `"data": []`) && !strings.Contains(stdout.String(), `"data":[]`) {
|
||||||
|
t.Fatalf("data must serialize as [] on the wire: %s", stdout.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String(), `"data": null`) || strings.Contains(stdout.String(), `"data":null`) {
|
||||||
|
t.Fatalf("data must never be null (§2.5): %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
+103
-11
@@ -64,17 +64,116 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func ResolveFormat(cmd *cobra.Command, fallback Format) Format {
|
func ResolveFormat(cmd *cobra.Command, fallback Format) Format {
|
||||||
|
format, _ := resolveFormatWithWarning(cmd, fallback)
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveFormatWithWarning 解析命令的 --format flag(B36,AC-09):已知值
|
||||||
|
// 归一化为对应 Format 常量;未知非空值**降级 fallback 并产出一条 warning
|
||||||
|
// 文本**(不崩不静默,契约规范 §5.2「未知值 → 降级 + stderr warning」)。
|
||||||
|
// warning 的写出由调用方负责(信封出口 WriteEnvelope 写 cmd.ErrOrStderr()),
|
||||||
|
// 本函数不做 I/O——ResolveFormat 的既有调用方(只读 format 值、不持有出口
|
||||||
|
// 语义)行为保持不变。flag 缺席或值为空返回 fallback 且不产生 warning
|
||||||
|
// (空值是「未指定」而非「未知」)。查找顺序与 ResolveFormat 一致:
|
||||||
|
// cmd.Flags() 优先于 InheritedFlags(),命中 format flag 即返回。
|
||||||
|
func resolveFormatWithWarning(cmd *cobra.Command, fallback Format) (Format, string) {
|
||||||
|
if cmd == nil {
|
||||||
|
return fallback, ""
|
||||||
|
}
|
||||||
|
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} {
|
||||||
|
value, ok := formatValueFromFlagSet(flags)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
format := normalizeFormat(value, fallback)
|
||||||
|
return format, unknownFormatWarning(value, format)
|
||||||
|
}
|
||||||
|
return fallback, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatValueFromFlagSet 返回 flagSet 上 --format flag 的原始字符串值
|
||||||
|
// (B35/B36/B43 共用的查找辅助)。flag 缺席或类型非 string 时返回 ok=false——
|
||||||
|
// 与 formatFromFlagSet 的容错口径一致(不报错、交由 fallback 兜底)。
|
||||||
|
func formatValueFromFlagSet(flags *pflag.FlagSet) (string, bool) {
|
||||||
|
if flags == nil || flags.Lookup("format") == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
value, err := flags.GetString("format")
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknownFormatWarning 在 raw 为非空未知值(归一化后不等于 resolved,即不是
|
||||||
|
// 任何已知 format 的大小写/空白变体)时返回 warning 文本,否则返回空串
|
||||||
|
// (B36,AC-09 不崩不静默)。文本只陈述事实(未知值 + 降级目标),
|
||||||
|
// 不做过度承诺。
|
||||||
|
func unknownFormatWarning(raw string, resolved Format) string {
|
||||||
|
trimmed := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
if trimmed == "" || trimmed == string(resolved) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("unknown --format %q, falling back to %q", raw, string(resolved))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFormatWithJSONShorthand 解析命令的输出 format,实现 --json 简写语义
|
||||||
|
// (B43,契约规范 §5.2:「--json 自动注册为 --format json 简写(显式
|
||||||
|
// --format 优先)」)。优先级链:
|
||||||
|
//
|
||||||
|
// 1. 显式 --format(非空值)恒优先——含未知值(按 normalizeFormat 降级
|
||||||
|
// fallback,--json 不 rescues 未知值);
|
||||||
|
// 2. --format 缺席或为空时,--json 布尔 flag(Changed 且为 true)等价
|
||||||
|
// --format json;
|
||||||
|
// 3. 两者皆无 → fallback。
|
||||||
|
//
|
||||||
|
// --json 判定只接受 bool 型 flag(GetBool 成功):业务层存在同名 string
|
||||||
|
// flag(如 table create --json 载荷参数)的命令不会被误判为简写。本函数
|
||||||
|
// 纯判定不做 I/O,供不直接走 WriteEnvelope 的调用方(如主漏斗桥接,B44)
|
||||||
|
// 复用;与 resolveFormatWithWarning 共用 formatValueFromFlagSet /
|
||||||
|
// normalizeFormat,归一化规则单一事实源。
|
||||||
|
func ResolveFormatWithJSONShorthand(cmd *cobra.Command, fallback Format) Format {
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} {
|
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} {
|
||||||
if format, ok := formatFromFlagSet(flags, fallback); ok {
|
value, ok := formatValueFromFlagSet(flags)
|
||||||
return format
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return normalizeFormat(value, fallback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if jsonShorthandActive(cmd) {
|
||||||
|
return FormatJSON
|
||||||
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jsonShorthandActive 报告 cmd 上是否有生效的 --json 简写(bool flag 被显式
|
||||||
|
// 设置为 true)。查找 Flags / InheritedFlags / PersistentFlags 三组,与
|
||||||
|
// internal/app 侧 commandRequestsJSONErrors 的判定面一致;GetBool 失败
|
||||||
|
// (非 bool 同名 flag)跳过该组继续。
|
||||||
|
func jsonShorthandActive(cmd *cobra.Command) bool {
|
||||||
|
if cmd == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags(), cmd.PersistentFlags()} {
|
||||||
|
if flags == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flag := flags.Lookup("json")
|
||||||
|
if flag == nil || !flag.Changed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if value, err := flags.GetBool("json"); err == nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func WriteCommandPayload(cmd *cobra.Command, payload any, fallback Format) error {
|
func WriteCommandPayload(cmd *cobra.Command, payload any, fallback Format) error {
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
return Write(io.Discard, fallback, payload)
|
return Write(io.Discard, fallback, payload)
|
||||||
@@ -127,15 +226,8 @@ func unwrapCompatRuntimePayload(payload any) any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func formatFromFlagSet(flags *pflag.FlagSet, fallback Format) (Format, bool) {
|
func formatFromFlagSet(flags *pflag.FlagSet, fallback Format) (Format, bool) {
|
||||||
if flags == nil {
|
value, ok := formatValueFromFlagSet(flags)
|
||||||
return fallback, false
|
if !ok {
|
||||||
}
|
|
||||||
flag := flags.Lookup("format")
|
|
||||||
if flag == nil {
|
|
||||||
return fallback, false
|
|
||||||
}
|
|
||||||
value, err := flags.GetString("format")
|
|
||||||
if err != nil {
|
|
||||||
return fallback, false
|
return fallback, false
|
||||||
}
|
}
|
||||||
return normalizeFormat(value, fallback), true
|
return normalizeFormat(value, fallback), true
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
|
||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MCPResult is the protocol-neutral projection consumed by MCP transports.
|
||||||
|
// Both CLI and MCP adapters start from the same immutable CommandResult; MCP
|
||||||
|
// handlers must not rebuild a second business envelope.
|
||||||
|
type MCPResult struct {
|
||||||
|
StructuredContent map[string]any `json:"structuredContent"`
|
||||||
|
IsError bool `json:"isError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdaptMCP projects the exact unified envelope into MCP structuredContent. Failure
|
||||||
|
// and partial failure set isError, while pending remains a successfully
|
||||||
|
// accepted request whose terminal operation state is carried in the envelope.
|
||||||
|
func AdaptMCP(result CommandResult) (*MCPResult, error) {
|
||||||
|
env, err := EnvelopeFromResult(result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
env = redactEnvelope(env)
|
||||||
|
raw, err := json.Marshal(env)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("output: marshal MCP result: %w", err)
|
||||||
|
}
|
||||||
|
var structured map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &structured); err != nil {
|
||||||
|
return nil, fmt.Errorf("output: decode MCP structured result: %w", err)
|
||||||
|
}
|
||||||
|
return &MCPResult{
|
||||||
|
StructuredContent: structured,
|
||||||
|
IsError: env.Outcome == OutcomeFailure || env.Outcome == OutcomePartialFailure,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
|
||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const redactedValue = "[REDACTED]"
|
||||||
|
|
||||||
|
var (
|
||||||
|
urlCredentialsPattern = regexp.MustCompile(`(?i)\b(https?://)[^/@\s]+@`)
|
||||||
|
urlQueryValuePattern = regexp.MustCompile(`(?i)[?&][^=&#\s]+=[^&#\s"'<>]+`)
|
||||||
|
headerSecretPattern = regexp.MustCompile(`(?im)\b(authorization|cookie|set-cookie)(\s*:\s*)[^\r\n]+`)
|
||||||
|
jsonSecretPattern = regexp.MustCompile(`(?i)("(?:authorization|token|access_token|refresh_token|client_secret|cookie|set-cookie|password)"\s*:\s*)("(?:\\.|[^"\\])*"|[^,}\s]+)`)
|
||||||
|
assignedSecretPattern = regexp.MustCompile(`(?i)\b(authorization|token|access_token|refresh_token|client_secret|cookie|set-cookie|password)(\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;&]+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// redactEnvelope is the single pre-render boundary for framework-owned
|
||||||
|
// diagnostics. Business data remains authoritative: commands such as explicit
|
||||||
|
// credential reads intentionally return secrets and declare those fields in
|
||||||
|
// ToolSpec.result.sensitive_paths. Error, operation, and notice channels are
|
||||||
|
// never intentional secret-delivery surfaces and are always redacted.
|
||||||
|
func redactEnvelope(source *Envelope) *Envelope {
|
||||||
|
if source == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := cloneEnvelope(*source)
|
||||||
|
seen := make(map[redactVisit]struct{})
|
||||||
|
redactReflectValue(reflect.ValueOf(&out.Meta), "meta", seen)
|
||||||
|
redactReflectValue(reflect.ValueOf(&out.Error), "error", seen)
|
||||||
|
redactReflectValue(reflect.ValueOf(&out.Notice), "_notice", seen)
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
type redactVisit struct {
|
||||||
|
typ reflect.Type
|
||||||
|
ptr uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactReflectValue(value reflect.Value, jsonKey string, seen map[redactVisit]struct{}) {
|
||||||
|
if !value.IsValid() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isSensitiveOutputKey(jsonKey) && setRedactedValue(value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Interface:
|
||||||
|
if value.IsNil() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copy := reflect.New(value.Elem().Type()).Elem()
|
||||||
|
copy.Set(value.Elem())
|
||||||
|
redactReflectValue(copy, jsonKey, seen)
|
||||||
|
value.Set(copy)
|
||||||
|
case reflect.Pointer:
|
||||||
|
if value.IsNil() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visit := redactVisit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
|
if _, ok := seen[visit]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[visit] = struct{}{}
|
||||||
|
redactReflectValue(value.Elem(), jsonKey, seen)
|
||||||
|
case reflect.Map:
|
||||||
|
if value.IsNil() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visit := redactVisit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
|
if _, ok := seen[visit]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[visit] = struct{}{}
|
||||||
|
iter := value.MapRange()
|
||||||
|
for iter.Next() {
|
||||||
|
key, item := iter.Key(), iter.Value()
|
||||||
|
copy := reflect.New(item.Type()).Elem()
|
||||||
|
copy.Set(item)
|
||||||
|
name := ""
|
||||||
|
if key.Kind() == reflect.String {
|
||||||
|
name = key.String()
|
||||||
|
}
|
||||||
|
redactReflectValue(copy, name, seen)
|
||||||
|
value.SetMapIndex(key, copy)
|
||||||
|
}
|
||||||
|
case reflect.Slice:
|
||||||
|
if value.IsNil() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visit := redactVisit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
|
if _, ok := seen[visit]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[visit] = struct{}{}
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
redactReflectValue(value.Index(i), "", seen)
|
||||||
|
}
|
||||||
|
case reflect.Array:
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
redactReflectValue(value.Index(i), "", seen)
|
||||||
|
}
|
||||||
|
case reflect.Struct:
|
||||||
|
typ := value.Type()
|
||||||
|
for i := 0; i < value.NumField(); i++ {
|
||||||
|
fieldInfo := typ.Field(i)
|
||||||
|
if fieldInfo.PkgPath != "" || fieldInfo.Tag.Get("json") == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.Split(fieldInfo.Tag.Get("json"), ",")[0]
|
||||||
|
if name == "" {
|
||||||
|
name = fieldInfo.Name
|
||||||
|
}
|
||||||
|
redactReflectValue(value.Field(i), name, seen)
|
||||||
|
}
|
||||||
|
case reflect.String:
|
||||||
|
if value.CanSet() {
|
||||||
|
value.SetString(redactRecognizableSecrets(value.String()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRedactedValue(value reflect.Value) bool {
|
||||||
|
if !value.CanSet() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
value.SetString(redactedValue)
|
||||||
|
return true
|
||||||
|
case reflect.Interface:
|
||||||
|
replacement := reflect.ValueOf(redactedValue)
|
||||||
|
if replacement.Type().AssignableTo(value.Type()) || replacement.Type().Implements(value.Type()) {
|
||||||
|
value.Set(replacement)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case reflect.Pointer:
|
||||||
|
replacement := reflect.New(value.Type().Elem())
|
||||||
|
if setRedactedValue(replacement.Elem()) {
|
||||||
|
value.Set(replacement)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSensitiveOutputKey(key string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||||
|
case "authorization", "token", "access_token", "refresh_token", "client_secret", "cookie", "set-cookie", "password":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactRecognizableSecrets(text string) string {
|
||||||
|
text = urlCredentialsPattern.ReplaceAllString(text, `${1}`+redactedValue+`@`)
|
||||||
|
text = urlQueryValuePattern.ReplaceAllStringFunc(text, func(parameter string) string {
|
||||||
|
equals := strings.IndexByte(parameter, '=')
|
||||||
|
if equals < 2 {
|
||||||
|
return parameter
|
||||||
|
}
|
||||||
|
key, err := url.QueryUnescape(parameter[1:equals])
|
||||||
|
if err != nil || !isSensitiveQueryKey(key) {
|
||||||
|
return parameter
|
||||||
|
}
|
||||||
|
return parameter[:equals+1] + redactedValue
|
||||||
|
})
|
||||||
|
text = headerSecretPattern.ReplaceAllString(text, `${1}${2}`+redactedValue)
|
||||||
|
text = jsonSecretPattern.ReplaceAllString(text, `${1}"`+redactedValue+`"`)
|
||||||
|
return assignedSecretPattern.ReplaceAllString(text, `${1}${2}`+redactedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSensitiveQueryKey(key string) bool {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||||
|
return isSensitiveOutputKey(normalized) || strings.Contains(normalized, "signature")
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
|
||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type redactionFixture struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Nested map[string]any `json:"nested"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvelopePreservesDeclaredBusinessDataAndRedactsFrameworkChannels(t *testing.T) {
|
||||||
|
data := &redactionFixture{
|
||||||
|
Password: "data-password-canary",
|
||||||
|
ClientID: "client-id-canary",
|
||||||
|
RequestID: "request-id-canary",
|
||||||
|
Nested: map[string]any{
|
||||||
|
"ToKeN": "nested-token-canary",
|
||||||
|
"items": []any{
|
||||||
|
map[string]string{"access_token": "slice-token-canary"},
|
||||||
|
"https://user:url-password-canary@example.test/path?x=usable&x-oss-signature=url-signature-canary",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
notice := map[string]any{"cookie": "notice-cookie-canary", "message": "token=notice-text-canary"}
|
||||||
|
env := &Envelope{
|
||||||
|
OK: true,
|
||||||
|
Outcome: OutcomeSuccess,
|
||||||
|
Data: data,
|
||||||
|
Meta: &Meta{Operation: &OperationInfo{
|
||||||
|
ID: "task-id-canary",
|
||||||
|
State: OperationStateProcessing,
|
||||||
|
NextCommand: "dws op get 'https://example.test/poll?token=next-command-canary&safe=yes'",
|
||||||
|
}},
|
||||||
|
Notice: notice,
|
||||||
|
}
|
||||||
|
originalData := cloneResultData(data)
|
||||||
|
originalNotice := cloneResultData(notice)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "", ""); err != nil {
|
||||||
|
t.Fatalf("WriteEnvelopeTo: %v", err)
|
||||||
|
}
|
||||||
|
output := buf.String()
|
||||||
|
for _, secret := range []string{
|
||||||
|
"notice-cookie-canary", "notice-text-canary", "next-command-canary",
|
||||||
|
} {
|
||||||
|
if strings.Contains(output, secret) {
|
||||||
|
t.Errorf("output leaked %q: %s", secret, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, retained := range []string{
|
||||||
|
"data-password-canary", "nested-token-canary", "slice-token-canary",
|
||||||
|
"url-password-canary", "url-signature-canary", "client-id-canary",
|
||||||
|
"request-id-canary", "task-id-canary", "x=usable", "safe=yes",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(output, retained) {
|
||||||
|
t.Errorf("output over-redacted %q: %s", retained, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := strings.Count(output, redactedValue); got < 3 {
|
||||||
|
t.Errorf("redaction placeholder count = %d, want at least 3: %s", got, output)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(data, originalData) || !reflect.DeepEqual(notice, originalNotice) {
|
||||||
|
t.Fatalf("redaction mutated caller values\ndata: %#v\nnotice: %#v", data, notice)
|
||||||
|
}
|
||||||
|
if err := env.Validate(); err != nil {
|
||||||
|
t.Fatalf("caller envelope became invalid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitResultRedactsEveryErrorInfoCanary(t *testing.T) {
|
||||||
|
info := &ErrorInfo{
|
||||||
|
Type: "api",
|
||||||
|
Message: "Authorization: Bearer message-auth-canary",
|
||||||
|
Hint: "use https://hint-user:hint-pass-canary@example.test/help?access_token=hint-query-canary&doc=usable",
|
||||||
|
Details: map[string]any{"PASSWORD": "details-password-canary"},
|
||||||
|
RPCData: map[string]any{"client_secret": "rpc-secret-canary", "task_id": "rpc-task-id-canary"},
|
||||||
|
TechnicalDetail: `upstream {"refresh_token":"technical-token-canary"}`,
|
||||||
|
FriendlyHint: "cookie=friendly-cookie-canary",
|
||||||
|
ActionURL: "https://example.test/retry?signature=action-signature-canary&step=2",
|
||||||
|
Cause: "token:cause-token-canary",
|
||||||
|
Actions: []string{"dws retry --url 'https://example.test/run?x-oss-signature=actions-signature-canary&mode=safe'"},
|
||||||
|
RequestID: "error-request-id-canary",
|
||||||
|
}
|
||||||
|
original := cloneErrorInfo(info)
|
||||||
|
result := Failure(info)
|
||||||
|
cmd := &cobra.Command{Use: "test"}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
|
||||||
|
code, err := EmitResult(cmd, result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EmitResult: %v", err)
|
||||||
|
}
|
||||||
|
if code != exitCodeAPI {
|
||||||
|
t.Fatalf("exit code = %d, want %d", code, exitCodeAPI)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
output := stdout.String()
|
||||||
|
for _, secret := range []string{
|
||||||
|
"message-auth-canary", "hint-pass-canary", "hint-query-canary",
|
||||||
|
"details-password-canary", "rpc-secret-canary", "technical-token-canary",
|
||||||
|
"friendly-cookie-canary", "action-signature-canary", "cause-token-canary",
|
||||||
|
"actions-signature-canary",
|
||||||
|
} {
|
||||||
|
if strings.Contains(output, secret) {
|
||||||
|
t.Errorf("output leaked %q: %s", secret, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, retained := range []string{"error-request-id-canary", "rpc-task-id-canary", "doc=usable", "step=2", "mode=safe"} {
|
||||||
|
if !strings.Contains(output, retained) {
|
||||||
|
t.Errorf("output over-redacted %q: %s", retained, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var decoded Envelope
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("output is not a typed envelope: %v", err)
|
||||||
|
}
|
||||||
|
if err := decoded.Validate(); err != nil {
|
||||||
|
t.Fatalf("redacted envelope is invalid: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(info, original) {
|
||||||
|
t.Fatalf("EmitResult mutated ErrorInfo\ngot: %#v\nwant: %#v", info, original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitResultHumanFailureRedactsBeforeShortcutRendering(t *testing.T) {
|
||||||
|
cmd := &cobra.Command{Use: "test"}
|
||||||
|
cmd.Flags().String("format", "table", "")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
_, err := EmitResult(cmd, Failure(&ErrorInfo{Type: "api", Message: "password=human-failure-canary"}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EmitResult: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(stderr.String(), "human-failure-canary") || !strings.Contains(stderr.String(), redactedValue) {
|
||||||
|
t.Fatalf("human failure was not redacted: %q", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptMCPPreservesBusinessData(t *testing.T) {
|
||||||
|
data := map[string]any{
|
||||||
|
"authorization": "mcp-auth-canary",
|
||||||
|
"client_id": "mcp-client-id-canary",
|
||||||
|
}
|
||||||
|
result := Success(data)
|
||||||
|
mcpResult, err := AdaptMCP(result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AdaptMCP: %v", err)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(mcpResult)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal MCPResult: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), "mcp-auth-canary") {
|
||||||
|
t.Fatalf("MCP business data was unexpectedly redacted: %s", raw)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), "mcp-client-id-canary") {
|
||||||
|
t.Fatalf("MCP output over-redacted client_id: %s", raw)
|
||||||
|
}
|
||||||
|
if data["authorization"] != "mcp-auth-canary" {
|
||||||
|
t.Fatalf("AdaptMCP mutated caller data: %#v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// Never report the original success code after an output failure. Do not
|
||||||
|
// attempt a second envelope: an io.Writer may have consumed bytes even
|
||||||
|
// when it reported n == 0 or returned a rendering-adjacent error.
|
||||||
|
store.exitCode = exitCodeInternal
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
|
||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContractMode is an internal renderer identity. It is deliberately not a CLI
|
||||||
|
// flag: one command has exactly one externally active contract in one release.
|
||||||
|
type ContractMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContractLegacy ContractMode = "legacy"
|
||||||
|
// ContractUnified is the single framework-owned result shape. It is not a
|
||||||
|
// consumer-selectable protocol version: a command either still renders its
|
||||||
|
// historical legacy shape or has been promoted to the unified result.
|
||||||
|
ContractUnified ContractMode = "unified"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RolloutState is internal release metadata. Consumers never select it. A
|
||||||
|
// command advances through validation and activation; rollback is performed by
|
||||||
|
// changing the command declaration/release, not by changing Agent argv.
|
||||||
|
type RolloutState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RolloutLegacyOnly RolloutState = "legacy_only"
|
||||||
|
RolloutDualValidate RolloutState = "dual_validate"
|
||||||
|
RolloutUnifiedActive RolloutState = "unified_active"
|
||||||
|
RolloutUnifiedStable RolloutState = "unified_stable"
|
||||||
|
RolloutUnifiedOnly RolloutState = "unified_only"
|
||||||
|
)
|
||||||
|
|
||||||
|
const rolloutAnnotation = "dws.output.rollout"
|
||||||
|
|
||||||
|
func ParseRolloutState(raw string) (RolloutState, error) {
|
||||||
|
state := RolloutState(strings.TrimSpace(raw))
|
||||||
|
switch state {
|
||||||
|
case RolloutLegacyOnly, RolloutDualValidate, RolloutUnifiedActive, RolloutUnifiedStable, RolloutUnifiedOnly:
|
||||||
|
return state, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("invalid output rollout state %q", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRolloutTransition defines the release order for rollout tooling. It
|
||||||
|
// is not an end-user capability. CI/ledger enforcement is wired separately.
|
||||||
|
func ValidateRolloutTransition(from, to RolloutState, rollback bool) error {
|
||||||
|
if _, err := ParseRolloutState(string(from)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := ParseRolloutState(string(to)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fromRank, toRank := rolloutRank(from), rolloutRank(to)
|
||||||
|
if fromRank == toRank || toRank == fromRank+1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if rollback && toRank < fromRank {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if toRank < fromRank {
|
||||||
|
return fmt.Errorf("output rollout transition %s -> %s is a rollback and requires explicit rollback approval", from, to)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("output rollout transition %s -> %s skips intermediate states", from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rolloutRank(state RolloutState) int {
|
||||||
|
switch state {
|
||||||
|
case RolloutLegacyOnly:
|
||||||
|
return 0
|
||||||
|
case RolloutDualValidate:
|
||||||
|
return 1
|
||||||
|
case RolloutUnifiedActive:
|
||||||
|
return 2
|
||||||
|
case RolloutUnifiedStable:
|
||||||
|
return 3
|
||||||
|
case RolloutUnifiedOnly:
|
||||||
|
return 4
|
||||||
|
default:
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetCommandRollout(cmd *cobra.Command, state RolloutState) {
|
||||||
|
if cmd == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := ParseRolloutState(string(state)); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if cmd.Annotations == nil {
|
||||||
|
cmd.Annotations = map[string]string{}
|
||||||
|
}
|
||||||
|
cmd.Annotations[rolloutAnnotation] = string(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandRollout fails closed. Merely linking Framework 2.0 into the binary
|
||||||
|
// cannot change an undeclared command's wire contract.
|
||||||
|
func CommandRollout(cmd *cobra.Command) RolloutState {
|
||||||
|
if cmd != nil && cmd.Annotations != nil {
|
||||||
|
if state, err := ParseRolloutState(cmd.Annotations[rolloutAnnotation]); err == nil {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RolloutLegacyOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveContract is the only contract exposed by a command in this release.
|
||||||
|
func ActiveContract(cmd *cobra.Command) ContractMode {
|
||||||
|
switch CommandRollout(cmd) {
|
||||||
|
case RolloutUnifiedActive, RolloutUnifiedStable, RolloutUnifiedOnly:
|
||||||
|
return ContractUnified
|
||||||
|
default:
|
||||||
|
return ContractLegacy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsesUnifiedResult(cmd *cobra.Command) bool { return ActiveContract(cmd) == ContractUnified }
|
||||||
|
|
||||||
|
// ValidateUnifiedFormat is retained for compatibility. All commands normalize an
|
||||||
|
// unknown presentation value to their fallback and emit a diagnostic warning.
|
||||||
|
func ValidateUnifiedFormat(cmd *cobra.Command) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateRolloutTransition(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
from, to RolloutState
|
||||||
|
rollback bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{RolloutLegacyOnly, RolloutDualValidate, false, false},
|
||||||
|
{RolloutDualValidate, RolloutUnifiedActive, false, false},
|
||||||
|
{RolloutUnifiedActive, RolloutUnifiedStable, false, false},
|
||||||
|
{RolloutUnifiedStable, RolloutUnifiedOnly, false, false},
|
||||||
|
{RolloutLegacyOnly, RolloutUnifiedActive, false, true},
|
||||||
|
{RolloutUnifiedStable, RolloutUnifiedActive, false, true},
|
||||||
|
{RolloutUnifiedStable, RolloutUnifiedActive, true, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if err := ValidateRolloutTransition(tc.from, tc.to, tc.rollback); (err != nil) != tc.wantErr {
|
||||||
|
t.Fatalf("ValidateRolloutTransition(%s,%s,rollback=%v) err=%v, wantErr=%v", tc.from, tc.to, tc.rollback, err, tc.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptMCPUsesSameEnvelope(t *testing.T) {
|
||||||
|
result := Success(map[string]any{"id": "a"})
|
||||||
|
mcp, err := AdaptMCP(result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if mcp.IsError {
|
||||||
|
t.Fatal("success must not set MCP isError")
|
||||||
|
}
|
||||||
|
if _, exists := mcp.StructuredContent["contract_version"]; exists {
|
||||||
|
t.Fatalf("structured content must not expose contract_version: %#v", mcp.StructuredContent)
|
||||||
|
}
|
||||||
|
if got := mcp.StructuredContent["outcome"]; got != string(OutcomeSuccess) {
|
||||||
|
t.Fatalf("outcome=%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandResultDetachesMutableFrameworkPayload(t *testing.T) {
|
||||||
|
payload := map[string]any{"nested": map[string]any{"value": "before"}}
|
||||||
|
result := Success(payload)
|
||||||
|
payload["nested"].(map[string]any)["value"] = "after"
|
||||||
|
env, err := EnvelopeFromResult(result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := env.Data.(map[string]any)["nested"].(map[string]any)["value"]
|
||||||
|
if got != "before" {
|
||||||
|
t.Fatalf("detached payload value=%v, want before", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailureExitCodeIsFrameworkDerived(t *testing.T) {
|
||||||
|
result := Failure(&ErrorInfo{Type: "validation", ExitCode: 99, Message: "bad input"})
|
||||||
|
if result.ExitCode() != 3 {
|
||||||
|
t.Fatalf("normal failure exit code=%d, want framework validation rc=3", result.ExitCode())
|
||||||
|
}
|
||||||
|
env, err := EnvelopeFromResult(result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if env.Error == nil || env.Error.ExitCode != 3 {
|
||||||
|
t.Fatalf("wire error=%+v, want exit_code=3", env.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRootCompatibilityAdapterCanPreserveSignalExitCode(t *testing.T) {
|
||||||
|
result := FailureWithExitCode(&ErrorInfo{Type: "internal", Message: "cancelled"}, 130)
|
||||||
|
if err := ValidateResult(result); err != nil {
|
||||||
|
t.Fatalf("ValidateResult: %v", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode() != 130 {
|
||||||
|
t.Fatalf("compatibility exit code=%d, want 130", result.ExitCode())
|
||||||
|
}
|
||||||
|
env, _ := EnvelopeFromResult(result)
|
||||||
|
if env.Error.ExitCode != 130 {
|
||||||
|
t.Fatalf("wire exit_code=%d, want 130", env.Error.ExitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateResultRejectsMalformedPendingPartialAndPagination(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
result CommandResult
|
||||||
|
}{
|
||||||
|
{"pending nil operation", Pending(map[string]any{"id": "x"}, nil)},
|
||||||
|
{"pending empty operation", Pending(nil, &OperationInfo{})},
|
||||||
|
{"partial nil", Partial(nil)},
|
||||||
|
{"pagination exhausted with token", Success([]any{}, WithMeta(&Meta{Pagination: &Pagination{EndpointExhausted: true, NextToken: "next"}}))},
|
||||||
|
{"pagination open without token", Success([]any{}, WithMeta(&Meta{Pagination: &Pagination{EndpointExhausted: false}}))},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if err := ValidateResult(tc.result); err == nil {
|
||||||
|
t.Fatalf("ValidateResult(%s) succeeded", tc.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialRequiresTypedPerItemError(t *testing.T) {
|
||||||
|
for _, entry := range []PartialFailedEntry{
|
||||||
|
{ID: "b"},
|
||||||
|
{ID: "b", Error: &ErrorInfo{}},
|
||||||
|
} {
|
||||||
|
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}}, []PartialFailedEntry{entry}, nil); err == nil {
|
||||||
|
t.Fatalf("NewPartialData accepted malformed failed entry: %+v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitResultUnknownFormatDegradesToJSONWithWarning(t *testing.T) {
|
||||||
|
cmd := &cobra.Command{Use: "sample"}
|
||||||
|
stdout, stderr := new(bytes.Buffer), new(bytes.Buffer)
|
||||||
|
cmd.SetOut(stdout)
|
||||||
|
cmd.SetErr(stderr)
|
||||||
|
cmd.Flags().String("format", "bogus", "")
|
||||||
|
SetCommandRollout(cmd, RolloutUnifiedActive)
|
||||||
|
code, err := EmitResult(cmd, Success(map[string]any{"id": "a"}))
|
||||||
|
if err != nil || code != 0 {
|
||||||
|
t.Fatalf("EmitResult code=%d err=%v, want successful JSON fallback", code, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), `"outcome": "success"`) {
|
||||||
|
t.Fatalf("fallback output is not a success envelope: %q", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "[WARN]") || !strings.Contains(stderr.String(), "bogus") {
|
||||||
|
t.Fatalf("fallback warning missing: %q", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingWithMetaDoesNotMutateCallerMetadata(t *testing.T) {
|
||||||
|
callerOperation := &OperationInfo{ID: "original", State: "waiting", NextCommand: "dws original"}
|
||||||
|
meta := &Meta{Operation: callerOperation}
|
||||||
|
want := &OperationInfo{ID: "new", State: "processing", NextCommand: "dws status"}
|
||||||
|
result := Pending(nil, want, WithMeta(meta))
|
||||||
|
|
||||||
|
if meta.Operation != callerOperation || meta.Operation.ID != "original" {
|
||||||
|
t.Fatalf("Pending mutated caller metadata: %+v", meta.Operation)
|
||||||
|
}
|
||||||
|
env, err := EnvelopeFromResult(result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if env.Meta.Operation.ID != "new" {
|
||||||
|
t.Fatalf("result operation=%+v, want new operation", env.Meta.Operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeThenError struct {
|
||||||
|
accepted int
|
||||||
|
writes int
|
||||||
|
full bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *writeThenError) Write(p []byte) (int, error) {
|
||||||
|
w.writes++
|
||||||
|
n := len(p)
|
||||||
|
if !w.full {
|
||||||
|
n /= 2
|
||||||
|
}
|
||||||
|
w.accepted += n
|
||||||
|
return n, io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitStoredResultRecordsAttemptAndByteRiskOnWriteError(t *testing.T) {
|
||||||
|
for _, full := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("full=%t", full), func(t *testing.T) {
|
||||||
|
ctx, store := WithResultStore(context.Background())
|
||||||
|
cmd := &cobra.Command{Use: "sample"}
|
||||||
|
SetCommandRollout(cmd, RolloutUnifiedActive)
|
||||||
|
writer := &writeThenError{full: full}
|
||||||
|
cmd.SetOut(writer)
|
||||||
|
cmd.SetErr(io.Discard)
|
||||||
|
cmd.SetContext(ctx)
|
||||||
|
if err := StoreResult(ctx, Success(map[string]any{"id": "a"})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := EmitStoredResult(cmd); err == nil {
|
||||||
|
t.Fatal("write error was not returned")
|
||||||
|
}
|
||||||
|
code, attempted, emitted, bytesRisk := StoredEmissionState(store)
|
||||||
|
if code != exitCodeInternal || !attempted || emitted || !bytesRisk {
|
||||||
|
t.Fatalf("state=(%d,%t,%t,%t), want (%d,true,false,true)", code, attempted, emitted, bytesRisk, exitCodeInternal)
|
||||||
|
}
|
||||||
|
if _, _, err := EmitStoredResult(cmd); err != nil {
|
||||||
|
t.Fatalf("second call should report existing state without writing: %v", err)
|
||||||
|
}
|
||||||
|
if writer.writes != 1 {
|
||||||
|
t.Fatalf("writer called %d times, want one emission attempt", writer.writes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithResultStoreIsIdempotent(t *testing.T) {
|
||||||
|
ctx, first := WithResultStore(context.Background())
|
||||||
|
ctxAgain, second := WithResultStore(ctx)
|
||||||
|
if ctxAgain != ctx || second != first {
|
||||||
|
t.Fatal("WithResultStore replaced an existing store")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreResultWithoutExecutionBoundaryHasActionableDiagnostic(t *testing.T) {
|
||||||
|
err := StoreResult(context.Background(), Success(map[string]any{"id": "a"}))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "output.WithResultStore") ||
|
||||||
|
!strings.Contains(err.Error(), "root execution boundary") {
|
||||||
|
t.Fatalf("StoreResult diagnostic = %v, want execution-boundary repair hint", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user