597 lines
27 KiB
Go
597 lines
27 KiB
Go
// Copyright 2026 Alibaba Group
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
// you may not use this file except in compliance with the License.
|
||
// You may obtain a copy of the License at
|
||
//
|
||
// http://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing, software
|
||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
// See the License for the specific language governing permissions and
|
||
// limitations under the License.
|
||
|
||
package 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
|
||
}
|