- Refactor plan, code-review, finalize-task-routing, refine-local-plans, router skills - Add agent-workflow-loop-orchestration skill and plan agent configs - Update roadmap: knowledge-tool-optimization milestones, stream-evidence-gate-core SDD - Add stream-evidence-gate-core task, archive, and Go streamgate package - Update dev-test inventory (edge/node smoke), agent-contract, edge-local-dev-guide - Deprecate USER_REVIEW for output-validation-filters SDD
562 lines
18 KiB
Go
562 lines
18 KiB
Go
package streamgate
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// CommitState records the commit status of a release payload to the
|
|
// downstream sink. Values follow the transport lifecycle: uncommitted,
|
|
// stream open after first user-visible write, and terminal committed.
|
|
type CommitState string
|
|
|
|
const (
|
|
// CommitStateTransportUncommitted indicates the payload has not yet been
|
|
// committed to the downstream sink.
|
|
CommitStateTransportUncommitted CommitState = "transport_uncommitted"
|
|
|
|
// CommitStateStreamOpen indicates the first user-visible write (HTTP
|
|
// status/header flush, SSE/agent opening event, or first body chunk)
|
|
// has occurred.
|
|
CommitStateStreamOpen CommitState = "stream_open"
|
|
|
|
// CommitStateTerminalCommitted indicates the terminal event has been
|
|
// definitively sent to the downstream sink.
|
|
CommitStateTerminalCommitted CommitState = "terminal_committed"
|
|
)
|
|
|
|
// Validate returns nil when the CommitState is a known lifecycle value.
|
|
func (cs CommitState) Validate() error {
|
|
switch cs {
|
|
case CommitStateTransportUncommitted, CommitStateStreamOpen, CommitStateTerminalCommitted:
|
|
return nil
|
|
}
|
|
return errors.New("streamgate: unknown commit state: " + string(cs))
|
|
}
|
|
|
|
// MaxFailureCauses is the maximum number of failure causes retained in a
|
|
// FailureCauseChain. When the chain exceeds this limit, the oldest entry is
|
|
// dropped.
|
|
const MaxFailureCauses = 4
|
|
|
|
// StableToken is a bounded, ASCII-only identifier used across the event
|
|
// contract for stable references such as consumer ids, filter ids, rule ids,
|
|
// descriptor types/codes, template ids, param names, and recovery directive
|
|
// references. The grammar is [a-z0-9][a-z0-9._:-]* and the encoded value is
|
|
// at most 128 bytes. Accessors return only string; the type itself is
|
|
// unexported storage.
|
|
type StableToken struct {
|
|
value string
|
|
}
|
|
|
|
// NewStableTokenRequired creates a StableToken that must be non-empty. It
|
|
// enforces the ASCII grammar and 128-byte limit.
|
|
func NewStableTokenRequired(name, v string) (StableToken, error) {
|
|
if v == "" {
|
|
return StableToken{}, errors.New("streamgate: " + name + " stable token is required")
|
|
}
|
|
return validateStableToken(name, v)
|
|
}
|
|
|
|
// NewStableTokenOptional creates a StableToken that may be empty. When empty
|
|
// the accessor returns "".
|
|
func NewStableTokenOptional(name, v string) (StableToken, error) {
|
|
if v == "" {
|
|
return StableToken{}, nil
|
|
}
|
|
return validateStableToken(name, v)
|
|
}
|
|
|
|
func validateStableToken(name, v string) (StableToken, error) {
|
|
if len(v) > maxStableTokenBytes {
|
|
return StableToken{}, errors.New("streamgate: " + name + " stable token exceeds maximum length")
|
|
}
|
|
for i := 0; i < len(v); i++ {
|
|
b := v[i]
|
|
if i == 0 {
|
|
if !isAlphaLower(b) && !isDigit(b) {
|
|
return StableToken{}, errors.New("streamgate: " + name + " stable token must start with [a-z0-9]")
|
|
}
|
|
} else {
|
|
if !isAlphaLower(b) && !isDigit(b) && b != '.' && b != '_' && b != ':' && b != '-' {
|
|
return StableToken{}, errors.New("streamgate: " + name + " stable token contains invalid character")
|
|
}
|
|
}
|
|
}
|
|
return StableToken{value: v}, nil
|
|
}
|
|
|
|
// validateStableTokenIfSet re-validates a non-empty StableToken value against
|
|
// the same grammar used at construction. Empty values pass through.
|
|
func validateStableTokenIfSet(name, v string) error {
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
if _, err := validateStableToken(name, v); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateStableTokenRequired re-validates a non-empty StableToken value against
|
|
// the same grammar used at construction. Empty values return a required error.
|
|
func validateStableTokenRequired(name, v string) error {
|
|
if v == "" {
|
|
return errors.New("streamgate: " + name + " stable token is required")
|
|
}
|
|
if _, err := validateStableToken(name, v); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isAlphaLower(b byte) bool { return b >= 'a' && b <= 'z' }
|
|
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
|
|
|
|
const maxStableTokenBytes = 128
|
|
|
|
// String returns the token value.
|
|
func (t StableToken) String() string { return t.value }
|
|
|
|
// FailureCause records a single step in a terminal failure with bounded,
|
|
// sanitized information. It contains only stage, code, consumer, filter, and
|
|
// rule id fields. Raw stack traces, provider body content, prompts, outputs,
|
|
// and authentication material are not permitted.
|
|
type FailureCause struct {
|
|
stage StableToken
|
|
code StableToken
|
|
consumer StableToken
|
|
filter StableToken
|
|
ruleID StableToken
|
|
}
|
|
|
|
// NewFailureCause creates a FailureCause with validation. The returned value
|
|
// is immutable to callers.
|
|
func NewFailureCause(stage, code, consumer, filter, ruleID string) (FailureCause, error) {
|
|
s, err := NewStableTokenRequired("stage", stage)
|
|
if err != nil {
|
|
return FailureCause{}, err
|
|
}
|
|
c, err := NewStableTokenRequired("code", code)
|
|
if err != nil {
|
|
return FailureCause{}, err
|
|
}
|
|
con, err := NewStableTokenOptional("consumer", consumer)
|
|
if err != nil {
|
|
return FailureCause{}, err
|
|
}
|
|
f, err := NewStableTokenOptional("filter", filter)
|
|
if err != nil {
|
|
return FailureCause{}, err
|
|
}
|
|
r, err := NewStableTokenOptional("ruleID", ruleID)
|
|
if err != nil {
|
|
return FailureCause{}, err
|
|
}
|
|
cause := FailureCause{
|
|
stage: s,
|
|
code: c,
|
|
consumer: con,
|
|
filter: f,
|
|
ruleID: r,
|
|
}
|
|
if err := cause.Validate(); err != nil {
|
|
return FailureCause{}, err
|
|
}
|
|
return cause, nil
|
|
}
|
|
|
|
// Validate returns nil when the FailureCause is in a consistent state.
|
|
// Re-validates nested StableToken values against the same grammar.
|
|
func (fc FailureCause) Validate() error {
|
|
if err := validateStableTokenRequired("stage", fc.stage.value); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStableTokenRequired("code", fc.code.value); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStableTokenIfSet("consumer", fc.consumer.value); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStableTokenIfSet("filter", fc.filter.value); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStableTokenIfSet("ruleID", fc.ruleID.value); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Stage returns the failure cause stage token.
|
|
func (fc FailureCause) Stage() string { return fc.stage.value }
|
|
|
|
// Code returns the failure cause code token.
|
|
func (fc FailureCause) Code() string { return fc.code.value }
|
|
|
|
// Consumer returns the failure cause consumer token, or "" if not set.
|
|
func (fc FailureCause) Consumer() string { return fc.consumer.value }
|
|
|
|
// Filter returns the failure cause filter token, or "" if not set.
|
|
func (fc FailureCause) Filter() string { return fc.filter.value }
|
|
|
|
// RuleID returns the failure cause rule id token, or "" if not set.
|
|
func (fc FailureCause) RuleID() string { return fc.ruleID.value }
|
|
|
|
// FailureCauseChain is a bounded sequence of FailureCause entries that
|
|
// describes the full chain of a terminal failure. It retains at most
|
|
// MaxFailureCauses entries; when the chain exceeds this limit, the oldest
|
|
// entry is dropped. All public accessors return defensive copies.
|
|
type FailureCauseChain struct {
|
|
causes []FailureCause
|
|
}
|
|
|
|
// NewFailureCauseChain creates a FailureCauseChain from a slice of causes.
|
|
// All causes are validated, and the chain is capped to the latest
|
|
// MaxFailureCauses entries. The returned chain is a defensive copy.
|
|
func NewFailureCauseChain(causes []FailureCause) (FailureCauseChain, error) {
|
|
if len(causes) == 0 {
|
|
return FailureCauseChain{}, nil
|
|
}
|
|
cp := make([]FailureCause, len(causes))
|
|
copy(cp, causes)
|
|
for i, c := range cp {
|
|
if err := c.Validate(); err != nil {
|
|
return FailureCauseChain{}, errors.New("streamgate: failure cause chain entry " + strconv.Itoa(i) + ": " + err.Error())
|
|
}
|
|
}
|
|
// Cap to latest MaxFailureCauses.
|
|
if len(cp) > MaxFailureCauses {
|
|
cp = cp[len(cp)-MaxFailureCauses:]
|
|
}
|
|
return FailureCauseChain{causes: cp}, nil
|
|
}
|
|
|
|
// Append adds a cause to the chain. The input cause is validated. If the
|
|
// chain already contains MaxFailureCauses entries, the oldest entry is dropped
|
|
// first. The returned chain is a defensive copy.
|
|
func (c FailureCauseChain) Append(cause FailureCause) (FailureCauseChain, error) {
|
|
if err := cause.Validate(); err != nil {
|
|
return FailureCauseChain{}, err
|
|
}
|
|
cp := make([]FailureCause, len(c.causes))
|
|
copy(cp, c.causes)
|
|
cp = append(cp, cause)
|
|
if len(cp) > MaxFailureCauses {
|
|
cp = cp[len(cp)-MaxFailureCauses:]
|
|
}
|
|
return FailureCauseChain{causes: cp}, nil
|
|
}
|
|
|
|
// Copy returns a defensive copy of the failure cause chain.
|
|
func (c FailureCauseChain) Copy() FailureCauseChain {
|
|
if c.causes == nil {
|
|
return FailureCauseChain{}
|
|
}
|
|
out := make([]FailureCause, len(c.causes))
|
|
copy(out, c.causes)
|
|
return FailureCauseChain{causes: out}
|
|
}
|
|
|
|
// Len returns the number of causes in the chain.
|
|
func (c FailureCauseChain) Len() int {
|
|
return len(c.causes)
|
|
}
|
|
|
|
// At returns a defensive copy of the cause at the given index.
|
|
func (c FailureCauseChain) At(i int) (FailureCause, error) {
|
|
if i < 0 || i >= len(c.causes) {
|
|
return FailureCause{}, errors.New("streamgate: failure cause chain index out of range")
|
|
}
|
|
cp := c.causes[i]
|
|
return cp, nil
|
|
}
|
|
|
|
// All returns a defensive copy of all causes in the chain.
|
|
func (c FailureCauseChain) All() []FailureCause {
|
|
if c.causes == nil {
|
|
return nil
|
|
}
|
|
out := make([]FailureCause, len(c.causes))
|
|
copy(out, c.causes)
|
|
return out
|
|
}
|
|
|
|
// ExternalDescriptor carries only safe, external-facing information about a
|
|
// terminal failure. It must not contain raw stack traces, provider body
|
|
// content, prompts, outputs, or authentication material. The type field is a
|
|
// stable token, the code is an optional stable code, the message is a safe
|
|
// template, and the param is an optional sanitized parameter.
|
|
type ExternalDescriptor struct {
|
|
errorType StableToken
|
|
code StableToken
|
|
message StableToken
|
|
param StableToken
|
|
}
|
|
|
|
// NewExternalDescriptor creates an ExternalDescriptor with validation. The
|
|
// returned value is immutable to callers.
|
|
func NewExternalDescriptor(errorType, code, message, param string) (ExternalDescriptor, error) {
|
|
et, err := NewStableTokenRequired("errorType", errorType)
|
|
if err != nil {
|
|
return ExternalDescriptor{}, err
|
|
}
|
|
c, err := NewStableTokenOptional("code", code)
|
|
if err != nil {
|
|
return ExternalDescriptor{}, err
|
|
}
|
|
m, err := NewStableTokenRequired("message", message)
|
|
if err != nil {
|
|
return ExternalDescriptor{}, err
|
|
}
|
|
p, err := NewStableTokenOptional("param", param)
|
|
if err != nil {
|
|
return ExternalDescriptor{}, err
|
|
}
|
|
desc := ExternalDescriptor{
|
|
errorType: et,
|
|
code: c,
|
|
message: m,
|
|
param: p,
|
|
}
|
|
if err := desc.Validate(); err != nil {
|
|
return ExternalDescriptor{}, err
|
|
}
|
|
return desc, nil
|
|
}
|
|
|
|
// Validate returns nil when the ExternalDescriptor is in a consistent state.
|
|
// Re-validates nested StableToken values against the same grammar.
|
|
func (ed ExternalDescriptor) Validate() error {
|
|
if err := validateStableTokenRequired("errorType", ed.errorType.value); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStableTokenIfSet("code", ed.code.value); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStableTokenRequired("message", ed.message.value); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStableTokenIfSet("param", ed.param.value); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Type returns the error type token.
|
|
func (ed ExternalDescriptor) Type() string { return ed.errorType.value }
|
|
|
|
// Code returns the optional stable code.
|
|
func (ed ExternalDescriptor) Code() string { return ed.code.value }
|
|
|
|
// Message returns the safe message template.
|
|
func (ed ExternalDescriptor) Message() string { return ed.message.value }
|
|
|
|
// Param returns the optional sanitized parameter.
|
|
func (ed ExternalDescriptor) Param() string { return ed.param.value }
|
|
|
|
// TerminalResult delivers a single, typed terminal outcome for a channel. It
|
|
// carries either success or error with a safe external descriptor. Exactly
|
|
// one of Success or Error must be set; both or neither is invalid. All fields
|
|
// are privately stored with defensive copies on access.
|
|
type TerminalResult struct {
|
|
channel string
|
|
success bool
|
|
err bool
|
|
externalDesc *ExternalDescriptor
|
|
failureCauses FailureCauseChain
|
|
occurredAt time.Time
|
|
}
|
|
|
|
// NewSuccessTerminalResult creates a TerminalResult of kind success. The
|
|
// result must not carry an external descriptor or failure causes.
|
|
func NewSuccessTerminalResult(channel string, occurredAt time.Time) (TerminalResult, error) {
|
|
if channel == "" {
|
|
return TerminalResult{}, errors.New("streamgate: terminal result channel is required")
|
|
}
|
|
if occurredAt.IsZero() {
|
|
return TerminalResult{}, errors.New("streamgate: terminal result occurred timestamp is required")
|
|
}
|
|
tr := TerminalResult{
|
|
channel: channel,
|
|
success: true,
|
|
occurredAt: occurredAt,
|
|
}
|
|
if err := tr.Validate(); err != nil {
|
|
return TerminalResult{}, err
|
|
}
|
|
return tr, nil
|
|
}
|
|
|
|
// NewErrorTerminalResult creates a TerminalResult of kind error. It requires
|
|
// a validated external descriptor and an optional bounded failure cause chain.
|
|
func NewErrorTerminalResult(
|
|
channel string,
|
|
desc ExternalDescriptor,
|
|
causes FailureCauseChain,
|
|
occurredAt time.Time,
|
|
) (TerminalResult, error) {
|
|
if channel == "" {
|
|
return TerminalResult{}, errors.New("streamgate: terminal result channel is required")
|
|
}
|
|
if occurredAt.IsZero() {
|
|
return TerminalResult{}, errors.New("streamgate: terminal result occurred timestamp is required")
|
|
}
|
|
if err := desc.Validate(); err != nil {
|
|
return TerminalResult{}, err
|
|
}
|
|
tr := TerminalResult{
|
|
channel: channel,
|
|
err: true,
|
|
externalDesc: copyExternalDescriptor(&desc),
|
|
failureCauses: causes.Copy(),
|
|
occurredAt: occurredAt,
|
|
}
|
|
if err := tr.Validate(); err != nil {
|
|
return TerminalResult{}, err
|
|
}
|
|
return tr, nil
|
|
}
|
|
|
|
// NewTerminalResult is retained for internal use only. Prefer
|
|
// NewSuccessTerminalResult and NewErrorTerminalResult for new code.
|
|
func newTerminalResult(
|
|
channel string,
|
|
success, errResult bool,
|
|
desc *ExternalDescriptor,
|
|
causes FailureCauseChain,
|
|
occurredAt time.Time,
|
|
) (TerminalResult, error) {
|
|
if channel == "" {
|
|
return TerminalResult{}, errors.New("streamgate: terminal result channel is required")
|
|
}
|
|
if !success && !errResult {
|
|
return TerminalResult{}, errors.New("streamgate: terminal result must be either success or error")
|
|
}
|
|
if success && errResult {
|
|
return TerminalResult{}, errors.New("streamgate: terminal result cannot be both success and error")
|
|
}
|
|
if errResult && desc == nil {
|
|
return TerminalResult{}, errors.New("streamgate: terminal result error requires an external descriptor")
|
|
}
|
|
if occurredAt.IsZero() {
|
|
return TerminalResult{}, errors.New("streamgate: terminal result occurred timestamp is required")
|
|
}
|
|
|
|
cpDesc := desc
|
|
if desc != nil {
|
|
descCopy := *desc
|
|
cpDesc = &descCopy
|
|
}
|
|
|
|
cpCauses := causes.Copy()
|
|
|
|
tr := TerminalResult{
|
|
channel: channel,
|
|
success: success,
|
|
err: errResult,
|
|
externalDesc: cpDesc,
|
|
failureCauses: cpCauses,
|
|
occurredAt: occurredAt,
|
|
}
|
|
if err := tr.Validate(); err != nil {
|
|
return TerminalResult{}, err
|
|
}
|
|
return tr, nil
|
|
}
|
|
|
|
// Validate returns nil when the TerminalResult is in a consistent state.
|
|
func (tr TerminalResult) Validate() error {
|
|
if tr.channel == "" {
|
|
return errors.New("streamgate: terminal result channel is required")
|
|
}
|
|
if !tr.success && !tr.err {
|
|
return errors.New("streamgate: terminal result must be either success or error")
|
|
}
|
|
if tr.success && tr.err {
|
|
return errors.New("streamgate: terminal result cannot be both success and error")
|
|
}
|
|
if tr.err {
|
|
if tr.externalDesc == nil {
|
|
return errors.New("streamgate: terminal result error requires an external descriptor")
|
|
}
|
|
if err := tr.externalDesc.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if tr.failureCauses.Len() > MaxFailureCauses {
|
|
return errors.New("streamgate: terminal result failure cause chain exceeds maximum")
|
|
}
|
|
for i := 0; i < tr.failureCauses.Len(); i++ {
|
|
c, err := tr.failureCauses.At(i)
|
|
if err != nil {
|
|
return errors.New("streamgate: terminal result failure cause chain: " + err.Error())
|
|
}
|
|
if err := c.Validate(); err != nil {
|
|
return errors.New("streamgate: terminal result failure cause chain entry " + strconv.Itoa(i) + ": " + err.Error())
|
|
}
|
|
}
|
|
}
|
|
if tr.success {
|
|
if tr.externalDesc != nil {
|
|
return errors.New("streamgate: terminal result success must not have an external descriptor")
|
|
}
|
|
if tr.failureCauses.Len() > 0 {
|
|
return errors.New("streamgate: terminal result success must not have failure causes")
|
|
}
|
|
}
|
|
if tr.occurredAt.IsZero() {
|
|
return errors.New("streamgate: terminal result occurred timestamp is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Channel returns the terminal result channel.
|
|
func (tr TerminalResult) Channel() string { return tr.channel }
|
|
|
|
// Success returns whether this is a success terminal result.
|
|
func (tr TerminalResult) Success() bool { return tr.success }
|
|
|
|
// Error returns whether this is an error terminal result.
|
|
func (tr TerminalResult) Error() bool { return tr.err }
|
|
|
|
// ExternalDesc returns a defensive copy of the external descriptor, or nil
|
|
// for success results.
|
|
func (tr TerminalResult) ExternalDesc() *ExternalDescriptor {
|
|
if tr.externalDesc == nil {
|
|
return nil
|
|
}
|
|
cp := *tr.externalDesc
|
|
return &cp
|
|
}
|
|
|
|
// FailureCauses returns a defensive copy of the failure cause chain.
|
|
func (tr TerminalResult) FailureCauses() FailureCauseChain {
|
|
return tr.failureCauses.Copy()
|
|
}
|
|
|
|
// OccurredAt returns the timestamp when the terminal result occurred.
|
|
func (tr TerminalResult) OccurredAt() time.Time { return tr.occurredAt }
|
|
|
|
// ReleaseSink is the host interface that the stream gate calls to commit
|
|
// release payloads to the downstream sink. The actual state machine
|
|
// implementation belongs to a subsequent Milestone Task; this interface
|
|
// definition provides the contract only.
|
|
//
|
|
// CommitResponseStart, Release, and CommitTerminal are mutually exclusive
|
|
// roles: response-start, safe event, and terminal each have a dedicated
|
|
// entry point. ReleaseEvent must never carry response-start or terminal
|
|
// payloads; those are handled exclusively through the dedicated methods.
|
|
type ReleaseSink interface {
|
|
// CommitResponseStart commits a staged response start to the downstream
|
|
// sink. It returns the resulting commit state and any error.
|
|
CommitResponseStart(ctx context.Context, rs ResponseStart) (CommitState, error)
|
|
|
|
// Release commits a release event to the downstream sink. It returns the
|
|
// resulting commit state and any error.
|
|
Release(ctx context.Context, ev ReleaseEvent) (CommitState, error)
|
|
|
|
// CommitTerminal commits a terminal result to the downstream sink. It
|
|
// returns the resulting commit state and any error.
|
|
CommitTerminal(ctx context.Context, tr TerminalResult) (CommitState, error)
|
|
}
|