- Stream evidence gate routing in edge config and runtime - Ingress snapshot and allocation - Recovery coordinator and plan - Runtime contract for gate filters - OpenAI-compatible request rebuilder and stream gate dispatcher - Comprehensive tests for all new components - Updated contracts and roadmap milestones
1355 lines
49 KiB
Go
1355 lines
49 KiB
Go
package streamgate
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
)
|
|
|
|
const (
|
|
// MaxRequestFaultRecoveryAttempts is the MVP absolute request-total cap.
|
|
// The initial provider attempt is not counted.
|
|
MaxRequestFaultRecoveryAttempts = 3
|
|
)
|
|
|
|
var (
|
|
// ErrRecoveryCallerCanceled indicates that request cancellation won the
|
|
// recovery race.
|
|
ErrRecoveryCallerCanceled = errors.New("streamgate: recovery caller canceled")
|
|
|
|
// ErrRecoveryTerminalCommitted indicates that the downstream terminal was
|
|
// already committed.
|
|
ErrRecoveryTerminalCommitted = errors.New("streamgate: recovery terminal already committed")
|
|
|
|
// ErrRecoverySideEffect indicates that retry could duplicate a completed
|
|
// tool call or another externally visible side effect.
|
|
ErrRecoverySideEffect = errors.New("streamgate: recovery blocked by side effect")
|
|
|
|
// ErrRecoveryPlanReentry indicates that the same plan id was already used
|
|
// for this request.
|
|
ErrRecoveryPlanReentry = errors.New("streamgate: recovery plan re-entry")
|
|
|
|
// ErrRecoveryCommitIneligible indicates that the strategy cannot run in the
|
|
// current downstream commit state.
|
|
ErrRecoveryCommitIneligible = errors.New("streamgate: recovery strategy is not eligible for commit state")
|
|
|
|
// ErrRecoveryRequestBudgetExhausted is checked before any strategy-specific
|
|
// cap so changing strategies cannot bypass the request-total cap.
|
|
ErrRecoveryRequestBudgetExhausted = errors.New("streamgate: request fault recovery budget exhausted")
|
|
|
|
// ErrRecoveryPlanNotReady indicates that optional preparation has not
|
|
// produced a final typed directive yet.
|
|
ErrRecoveryPlanNotReady = errors.New("streamgate: recovery plan is not ready for rebuild or dispatch")
|
|
|
|
// ErrRecoveryStrategyBudgetExhausted indicates the selected fault strategy
|
|
// has no remaining dispatches.
|
|
ErrRecoveryStrategyBudgetExhausted = errors.New("streamgate: strategy fault recovery budget exhausted")
|
|
|
|
// ErrManagedTrajectoryExhausted indicates that attempt, caller, or context
|
|
// allowance leaves no tokens for another managed continuation.
|
|
ErrManagedTrajectoryExhausted = errors.New("streamgate: managed continuation trajectory exhausted")
|
|
|
|
// ErrRecoverySnapshotLimitExceeded indicates that a snapshot reference or
|
|
// rebuilt request exceeded its retained or peak memory limits.
|
|
ErrRecoverySnapshotLimitExceeded = errors.New("streamgate: recovery snapshot limit exceeded")
|
|
)
|
|
|
|
var faultRecoveryStrategies = []RecoveryStrategy{
|
|
RecoveryStrategyExactReplay,
|
|
RecoveryStrategyContinuationRepair,
|
|
RecoveryStrategySchemaRepair,
|
|
}
|
|
|
|
func isFaultRecoveryStrategy(strategy RecoveryStrategy) bool {
|
|
switch strategy {
|
|
case RecoveryStrategyExactReplay, RecoveryStrategyContinuationRepair, RecoveryStrategySchemaRepair:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// RecoveryResumeMode identifies whether a recovery replaces an uncommitted
|
|
// provider attempt or continues an already-open downstream stream.
|
|
type RecoveryResumeMode string
|
|
|
|
const (
|
|
RecoveryResumeModeReplaceAttempt RecoveryResumeMode = "replace_attempt"
|
|
RecoveryResumeModeContinueStream RecoveryResumeMode = "continue_stream"
|
|
)
|
|
|
|
// Validate returns nil for a known recovery resume mode.
|
|
func (m RecoveryResumeMode) Validate() error {
|
|
switch m {
|
|
case RecoveryResumeModeReplaceAttempt, RecoveryResumeModeContinueStream:
|
|
return nil
|
|
default:
|
|
return errors.New("streamgate: unknown recovery resume mode: " + string(m))
|
|
}
|
|
}
|
|
|
|
// RecoveryPreparationStatus is the immutable lifecycle state of optional
|
|
// plan preparation.
|
|
type RecoveryPreparationStatus string
|
|
|
|
const (
|
|
RecoveryPreparationNotRequired RecoveryPreparationStatus = "not_required"
|
|
RecoveryPreparationRequired RecoveryPreparationStatus = "required"
|
|
RecoveryPreparationPrepared RecoveryPreparationStatus = "prepared"
|
|
)
|
|
|
|
// Validate returns nil for a known preparation status.
|
|
func (s RecoveryPreparationStatus) Validate() error {
|
|
switch s {
|
|
case RecoveryPreparationNotRequired, RecoveryPreparationRequired, RecoveryPreparationPrepared:
|
|
return nil
|
|
default:
|
|
return errors.New("streamgate: unknown recovery preparation status: " + string(s))
|
|
}
|
|
}
|
|
|
|
// RecoveryPolicySnapshot is the immutable request-start snapshot of fault
|
|
// recovery limits. Missing strategy limits inherit the request-total limit
|
|
// only when the supplied map is empty; in a non-empty map, missing strategies
|
|
// are disabled.
|
|
type RecoveryPolicySnapshot struct {
|
|
maxRequestFaultRecoveries int
|
|
strategyLimits map[RecoveryStrategy]int
|
|
}
|
|
|
|
// NewRecoveryPolicySnapshot validates and snapshots request-total and
|
|
// strategy-specific fault recovery limits.
|
|
func NewRecoveryPolicySnapshot(maxTotal int, strategyLimits map[RecoveryStrategy]int) (RecoveryPolicySnapshot, error) {
|
|
if maxTotal < 0 || maxTotal > MaxRequestFaultRecoveryAttempts {
|
|
return RecoveryPolicySnapshot{}, fmt.Errorf(
|
|
"streamgate: max request fault recoveries must be between 0 and %d",
|
|
MaxRequestFaultRecoveryAttempts,
|
|
)
|
|
}
|
|
|
|
limits := make(map[RecoveryStrategy]int, len(strategyLimits))
|
|
if len(strategyLimits) == 0 {
|
|
for _, strategy := range faultRecoveryStrategies {
|
|
limits[strategy] = maxTotal
|
|
}
|
|
} else {
|
|
for strategy, limit := range strategyLimits {
|
|
if !isFaultRecoveryStrategy(strategy) {
|
|
return RecoveryPolicySnapshot{}, errors.New("streamgate: recovery policy contains non-fault strategy")
|
|
}
|
|
if limit < 0 || limit > maxTotal {
|
|
return RecoveryPolicySnapshot{}, errors.New("streamgate: recovery strategy limit must be between zero and request-total limit")
|
|
}
|
|
limits[strategy] = limit
|
|
}
|
|
}
|
|
|
|
snapshot := RecoveryPolicySnapshot{
|
|
maxRequestFaultRecoveries: maxTotal,
|
|
strategyLimits: limits,
|
|
}
|
|
if err := snapshot.Validate(); err != nil {
|
|
return RecoveryPolicySnapshot{}, err
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
// Validate returns nil when the policy snapshot is internally consistent.
|
|
func (p RecoveryPolicySnapshot) Validate() error {
|
|
if p.maxRequestFaultRecoveries < 0 || p.maxRequestFaultRecoveries > MaxRequestFaultRecoveryAttempts {
|
|
return errors.New("streamgate: recovery policy request-total limit is out of range")
|
|
}
|
|
for strategy, limit := range p.strategyLimits {
|
|
if !isFaultRecoveryStrategy(strategy) {
|
|
return errors.New("streamgate: recovery policy contains non-fault strategy")
|
|
}
|
|
if limit < 0 || limit > p.maxRequestFaultRecoveries {
|
|
return errors.New("streamgate: recovery policy strategy limit is out of range")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MaxRequestFaultRecoveries returns the request-total fault recovery cap.
|
|
func (p RecoveryPolicySnapshot) MaxRequestFaultRecoveries() int {
|
|
return p.maxRequestFaultRecoveries
|
|
}
|
|
|
|
// StrategyLimit returns a fault strategy limit. A missing strategy in an
|
|
// explicitly partial policy is disabled and therefore returns zero.
|
|
func (p RecoveryPolicySnapshot) StrategyLimit(strategy RecoveryStrategy) int {
|
|
return p.strategyLimits[strategy]
|
|
}
|
|
|
|
// StrategyLimits returns a defensive copy of all configured strategy limits.
|
|
func (p RecoveryPolicySnapshot) StrategyLimits() map[RecoveryStrategy]int {
|
|
return copyRecoveryStrategyCounts(p.strategyLimits)
|
|
}
|
|
|
|
func copyRecoveryPolicySnapshot(p RecoveryPolicySnapshot) RecoveryPolicySnapshot {
|
|
return RecoveryPolicySnapshot{
|
|
maxRequestFaultRecoveries: p.maxRequestFaultRecoveries,
|
|
strategyLimits: copyRecoveryStrategyCounts(p.strategyLimits),
|
|
}
|
|
}
|
|
|
|
// RecoveryUsageSnapshot is the immutable request-local fault dispatch ledger.
|
|
// Managed continuation is deliberately absent from this ledger.
|
|
type RecoveryUsageSnapshot struct {
|
|
requestFaultRecoveries int
|
|
strategyUsage map[RecoveryStrategy]int
|
|
}
|
|
|
|
// NewRecoveryUsageSnapshot validates and snapshots total and per-strategy
|
|
// fault recovery dispatch usage.
|
|
func NewRecoveryUsageSnapshot(totalUsed int, strategyUsage map[RecoveryStrategy]int) (RecoveryUsageSnapshot, error) {
|
|
usage := RecoveryUsageSnapshot{
|
|
requestFaultRecoveries: totalUsed,
|
|
strategyUsage: copyRecoveryStrategyCounts(strategyUsage),
|
|
}
|
|
if err := usage.Validate(); err != nil {
|
|
return RecoveryUsageSnapshot{}, err
|
|
}
|
|
return usage, nil
|
|
}
|
|
|
|
// Validate returns nil when total usage exactly accounts for each fault
|
|
// strategy dispatch.
|
|
func (u RecoveryUsageSnapshot) Validate() error {
|
|
if u.requestFaultRecoveries < 0 {
|
|
return errors.New("streamgate: recovery request usage must not be negative")
|
|
}
|
|
sum := 0
|
|
for strategy, used := range u.strategyUsage {
|
|
if !isFaultRecoveryStrategy(strategy) {
|
|
return errors.New("streamgate: recovery usage contains non-fault strategy")
|
|
}
|
|
if used < 0 {
|
|
return errors.New("streamgate: recovery strategy usage must not be negative")
|
|
}
|
|
sum += used
|
|
}
|
|
if sum != u.requestFaultRecoveries {
|
|
return errors.New("streamgate: recovery request usage must equal summed strategy usage")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateAgainst checks this usage snapshot against a policy snapshot.
|
|
func (u RecoveryUsageSnapshot) ValidateAgainst(policy RecoveryPolicySnapshot) error {
|
|
if err := policy.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if err := u.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if u.requestFaultRecoveries > policy.maxRequestFaultRecoveries {
|
|
return errors.New("streamgate: recovery request usage exceeds policy")
|
|
}
|
|
for strategy, used := range u.strategyUsage {
|
|
if used > policy.StrategyLimit(strategy) {
|
|
return errors.New("streamgate: recovery strategy usage exceeds policy")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RequestFaultRecoveries returns total fault dispatches already consumed.
|
|
func (u RecoveryUsageSnapshot) RequestFaultRecoveries() int {
|
|
return u.requestFaultRecoveries
|
|
}
|
|
|
|
// StrategyUsage returns dispatches already consumed by a fault strategy.
|
|
func (u RecoveryUsageSnapshot) StrategyUsage(strategy RecoveryStrategy) int {
|
|
return u.strategyUsage[strategy]
|
|
}
|
|
|
|
// StrategyUsages returns a defensive copy of all per-strategy usage.
|
|
func (u RecoveryUsageSnapshot) StrategyUsages() map[RecoveryStrategy]int {
|
|
return copyRecoveryStrategyCounts(u.strategyUsage)
|
|
}
|
|
|
|
// RecoveryBudget combines the immutable request-start policy and current
|
|
// request-local fault usage snapshots. Managed continuation is intentionally
|
|
// excluded and uses ManagedTrajectoryBudget.
|
|
type RecoveryBudget struct {
|
|
policy RecoveryPolicySnapshot
|
|
usage RecoveryUsageSnapshot
|
|
}
|
|
|
|
// NewRecoveryBudget creates a validated immutable fault budget snapshot.
|
|
func NewRecoveryBudget(policy RecoveryPolicySnapshot, usage RecoveryUsageSnapshot) (RecoveryBudget, error) {
|
|
budget := RecoveryBudget{
|
|
policy: copyRecoveryPolicySnapshot(policy),
|
|
usage: copyRecoveryUsageSnapshot(usage),
|
|
}
|
|
if err := budget.Validate(); err != nil {
|
|
return RecoveryBudget{}, err
|
|
}
|
|
return budget, nil
|
|
}
|
|
|
|
// Validate returns nil when usage is valid under the snapshotted policy.
|
|
func (b RecoveryBudget) Validate() error {
|
|
return b.usage.ValidateAgainst(b.policy)
|
|
}
|
|
|
|
// PolicySnapshot returns a defensive copy of the policy half.
|
|
func (b RecoveryBudget) PolicySnapshot() RecoveryPolicySnapshot {
|
|
return copyRecoveryPolicySnapshot(b.policy)
|
|
}
|
|
|
|
// UsageSnapshot returns a defensive copy of the usage half.
|
|
func (b RecoveryBudget) UsageSnapshot() RecoveryUsageSnapshot {
|
|
return copyRecoveryUsageSnapshot(b.usage)
|
|
}
|
|
|
|
func copyRecoveryUsageSnapshot(u RecoveryUsageSnapshot) RecoveryUsageSnapshot {
|
|
return RecoveryUsageSnapshot{
|
|
requestFaultRecoveries: u.requestFaultRecoveries,
|
|
strategyUsage: copyRecoveryStrategyCounts(u.strategyUsage),
|
|
}
|
|
}
|
|
|
|
func copyRecoveryStrategyCounts(in map[RecoveryStrategy]int) map[RecoveryStrategy]int {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := make(map[RecoveryStrategy]int, len(in))
|
|
for strategy, count := range in {
|
|
out[strategy] = count
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (u RecoveryUsageSnapshot) nextFaultDispatch(
|
|
strategy RecoveryStrategy,
|
|
policy RecoveryPolicySnapshot,
|
|
) (RecoveryUsageSnapshot, int, int, error) {
|
|
if !isFaultRecoveryStrategy(strategy) {
|
|
return RecoveryUsageSnapshot{}, 0, 0, errors.New("streamgate: managed continuation does not consume fault budget")
|
|
}
|
|
if err := u.ValidateAgainst(policy); err != nil {
|
|
return RecoveryUsageSnapshot{}, 0, 0, err
|
|
}
|
|
|
|
// Request-total is deliberately checked first to prevent strategy changes
|
|
// from bypassing the shared cap.
|
|
if u.requestFaultRecoveries >= policy.maxRequestFaultRecoveries {
|
|
return RecoveryUsageSnapshot{}, 0, 0, ErrRecoveryRequestBudgetExhausted
|
|
}
|
|
if u.StrategyUsage(strategy) >= policy.StrategyLimit(strategy) {
|
|
return RecoveryUsageSnapshot{}, 0, 0, ErrRecoveryStrategyBudgetExhausted
|
|
}
|
|
|
|
next := copyRecoveryUsageSnapshot(u)
|
|
if next.strategyUsage == nil {
|
|
next.strategyUsage = make(map[RecoveryStrategy]int)
|
|
}
|
|
next.requestFaultRecoveries++
|
|
next.strategyUsage[strategy]++
|
|
return next, next.requestFaultRecoveries, next.strategyUsage[strategy], nil
|
|
}
|
|
|
|
// ManagedTrajectoryBudget is an immutable request-local allowance snapshot for
|
|
// provider output-cap continuation. RebuiltPromptTokens already includes the
|
|
// preserved assistant prefix, so cumulative output is not subtracted again
|
|
// from context allowance.
|
|
type ManagedTrajectoryBudget struct {
|
|
providerAttemptCap int
|
|
contextWindow int
|
|
reserve int
|
|
rebuiltPromptTokens int
|
|
callerLogicalOutputCap *int
|
|
callerLogicalOutputUsage int
|
|
}
|
|
|
|
// NewManagedTrajectoryBudget creates a validated trajectory budget. A nil
|
|
// callerLogicalOutputCap means the caller did not set a logical output cap.
|
|
func NewManagedTrajectoryBudget(
|
|
providerAttemptCap int,
|
|
contextWindow int,
|
|
reserve int,
|
|
rebuiltPromptTokens int,
|
|
callerLogicalOutputCap *int,
|
|
callerLogicalOutputUsage int,
|
|
) (ManagedTrajectoryBudget, error) {
|
|
var capCopy *int
|
|
if callerLogicalOutputCap != nil {
|
|
value := *callerLogicalOutputCap
|
|
capCopy = &value
|
|
}
|
|
budget := ManagedTrajectoryBudget{
|
|
providerAttemptCap: providerAttemptCap,
|
|
contextWindow: contextWindow,
|
|
reserve: reserve,
|
|
rebuiltPromptTokens: rebuiltPromptTokens,
|
|
callerLogicalOutputCap: capCopy,
|
|
callerLogicalOutputUsage: callerLogicalOutputUsage,
|
|
}
|
|
if err := budget.Validate(); err != nil {
|
|
return ManagedTrajectoryBudget{}, err
|
|
}
|
|
return budget, nil
|
|
}
|
|
|
|
// Validate returns nil for a consistent trajectory ledger. Exhaustion is a
|
|
// valid state and is reported by NextAllowance, not by validation.
|
|
func (b ManagedTrajectoryBudget) Validate() error {
|
|
if b.providerAttemptCap <= 0 {
|
|
return errors.New("streamgate: managed provider attempt cap must be positive")
|
|
}
|
|
if b.contextWindow <= 0 {
|
|
return errors.New("streamgate: managed context window must be positive")
|
|
}
|
|
if b.reserve < 0 {
|
|
return errors.New("streamgate: managed context reserve must not be negative")
|
|
}
|
|
if b.rebuiltPromptTokens < 0 {
|
|
return errors.New("streamgate: managed rebuilt prompt tokens must not be negative")
|
|
}
|
|
if b.callerLogicalOutputUsage < 0 {
|
|
return errors.New("streamgate: managed caller output usage must not be negative")
|
|
}
|
|
if b.callerLogicalOutputCap != nil {
|
|
if *b.callerLogicalOutputCap < 0 {
|
|
return errors.New("streamgate: managed caller output cap must not be negative")
|
|
}
|
|
if b.callerLogicalOutputUsage > *b.callerLogicalOutputCap {
|
|
return errors.New("streamgate: managed caller output usage exceeds cap")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NextAllowance returns min(attempt cap, optional remaining caller logical
|
|
// cap, context window - rebuilt prompt tokens - reserve), clamped at zero.
|
|
func (b ManagedTrajectoryBudget) NextAllowance() int {
|
|
allowance := b.providerAttemptCap
|
|
contextRemaining := b.contextWindow - b.rebuiltPromptTokens - b.reserve
|
|
if contextRemaining < allowance {
|
|
allowance = contextRemaining
|
|
}
|
|
if b.callerLogicalOutputCap != nil {
|
|
callerRemaining := *b.callerLogicalOutputCap - b.callerLogicalOutputUsage
|
|
if callerRemaining < allowance {
|
|
allowance = callerRemaining
|
|
}
|
|
}
|
|
if allowance < 0 {
|
|
return 0
|
|
}
|
|
return allowance
|
|
}
|
|
|
|
func (b ManagedTrajectoryBudget) ProviderAttemptCap() int { return b.providerAttemptCap }
|
|
func (b ManagedTrajectoryBudget) ContextWindow() int { return b.contextWindow }
|
|
func (b ManagedTrajectoryBudget) Reserve() int { return b.reserve }
|
|
func (b ManagedTrajectoryBudget) RebuiltPromptTokens() int { return b.rebuiltPromptTokens }
|
|
func (b ManagedTrajectoryBudget) CallerLogicalOutputUsage() int {
|
|
return b.callerLogicalOutputUsage
|
|
}
|
|
|
|
// CallerLogicalOutputCap returns the cap and whether the caller set one.
|
|
func (b ManagedTrajectoryBudget) CallerLogicalOutputCap() (int, bool) {
|
|
if b.callerLogicalOutputCap == nil {
|
|
return 0, false
|
|
}
|
|
return *b.callerLogicalOutputCap, true
|
|
}
|
|
|
|
func copyManagedTrajectoryBudget(b ManagedTrajectoryBudget) ManagedTrajectoryBudget {
|
|
out := b
|
|
if b.callerLogicalOutputCap != nil {
|
|
value := *b.callerLogicalOutputCap
|
|
out.callerLogicalOutputCap = &value
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RecoveryContributor identifies the selected semantic consumer/filter/rule
|
|
// without carrying evidence or raw content.
|
|
type RecoveryContributor struct {
|
|
consumerID StableToken
|
|
filterID StableToken
|
|
ruleID StableToken
|
|
}
|
|
|
|
func NewRecoveryContributor(consumerID, filterID, ruleID string) (RecoveryContributor, error) {
|
|
consumer, err := NewStableTokenRequired("consumerID", consumerID)
|
|
if err != nil {
|
|
return RecoveryContributor{}, err
|
|
}
|
|
filter, err := NewStableTokenRequired("filterID", filterID)
|
|
if err != nil {
|
|
return RecoveryContributor{}, err
|
|
}
|
|
rule, err := NewStableTokenRequired("ruleID", ruleID)
|
|
if err != nil {
|
|
return RecoveryContributor{}, err
|
|
}
|
|
return RecoveryContributor{consumerID: consumer, filterID: filter, ruleID: rule}, nil
|
|
}
|
|
|
|
func (c RecoveryContributor) Validate() error {
|
|
if err := validateStableTokenRequired("consumerID", c.consumerID.value); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStableTokenRequired("filterID", c.filterID.value); err != nil {
|
|
return err
|
|
}
|
|
return validateStableTokenRequired("ruleID", c.ruleID.value)
|
|
}
|
|
|
|
func (c RecoveryContributor) ConsumerID() string { return c.consumerID.value }
|
|
func (c RecoveryContributor) FilterID() string { return c.filterID.value }
|
|
func (c RecoveryContributor) RuleID() string { return c.ruleID.value }
|
|
|
|
// RecoveryRequestSnapshotRef is a raw-free, bounded reference to a
|
|
// request-local ingress snapshot.
|
|
type RecoveryRequestSnapshotRef struct {
|
|
snapshotRef StableToken
|
|
retainedBytes uint64
|
|
peakBytes uint64
|
|
maxBytes uint64
|
|
}
|
|
|
|
// NewRecoveryRequestSnapshotRef validates and creates an immutable snapshot reference.
|
|
func NewRecoveryRequestSnapshotRef(
|
|
snapshotRef string,
|
|
retainedBytes, peakBytes, maxBytes uint64,
|
|
) (RecoveryRequestSnapshotRef, error) {
|
|
refToken, err := NewStableTokenRequired("snapshotRef", snapshotRef)
|
|
if err != nil {
|
|
return RecoveryRequestSnapshotRef{}, err
|
|
}
|
|
ref := RecoveryRequestSnapshotRef{
|
|
snapshotRef: refToken,
|
|
retainedBytes: retainedBytes,
|
|
peakBytes: peakBytes,
|
|
maxBytes: maxBytes,
|
|
}
|
|
if err := ref.Validate(); err != nil {
|
|
return RecoveryRequestSnapshotRef{}, err
|
|
}
|
|
return ref, nil
|
|
}
|
|
|
|
// Validate returns nil when the snapshot reference accounting is internally consistent.
|
|
func (r RecoveryRequestSnapshotRef) Validate() error {
|
|
if err := validateStableTokenRequired("snapshotRef", r.snapshotRef.value); err != nil {
|
|
return err
|
|
}
|
|
if r.maxBytes == 0 {
|
|
return errors.New("streamgate: recovery request snapshot max bytes must be positive")
|
|
}
|
|
if r.retainedBytes > r.peakBytes || r.peakBytes > r.maxBytes {
|
|
return ErrRecoverySnapshotLimitExceeded
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r RecoveryRequestSnapshotRef) SnapshotRef() string { return r.snapshotRef.value }
|
|
func (r RecoveryRequestSnapshotRef) RetainedBytes() uint64 { return r.retainedBytes }
|
|
func (r RecoveryRequestSnapshotRef) PeakBytes() uint64 { return r.peakBytes }
|
|
func (r RecoveryRequestSnapshotRef) MaxBytes() uint64 { return r.maxBytes }
|
|
|
|
// RecoveryEligibilityInput is the mutable call-site input to plan creation.
|
|
// NewRecoveryPlan validates and defensively snapshots every mutable member.
|
|
type RecoveryEligibilityInput struct {
|
|
PlanID string
|
|
IdempotencyKey string
|
|
Intent RecoveryIntent
|
|
Contributors []RecoveryContributor
|
|
CommitState CommitState
|
|
CallerCanceled bool
|
|
HasToolSideEffect bool
|
|
HasCompleteToolCall bool
|
|
PreviouslyUsedPlanIDs []string
|
|
Policy RecoveryPolicySnapshot
|
|
Usage RecoveryUsageSnapshot
|
|
ManagedTrajectory *ManagedTrajectoryBudget
|
|
TerminalReason TerminalReason
|
|
PreparerID string
|
|
PreparationSnapshotRef string
|
|
RequiredCapabilities []string
|
|
FailureCauses FailureCauseChain
|
|
}
|
|
|
|
// RecoveryPlan is an immutable, transport-agnostic recovery decision. It does
|
|
// not contain raw request/auth data, provider selection, or executable callbacks.
|
|
type RecoveryPlan struct {
|
|
planID StableToken
|
|
idempotencyKey StableToken
|
|
intent RecoveryIntent
|
|
contributors []RecoveryContributor
|
|
resumeMode RecoveryResumeMode
|
|
commitState CommitState
|
|
policy RecoveryPolicySnapshot
|
|
usage RecoveryUsageSnapshot
|
|
requestAttempt int
|
|
strategyAttempt int
|
|
managedTrajectory *ManagedTrajectoryBudget
|
|
managedAllowance int
|
|
managedFinalized bool
|
|
terminalReason TerminalReason
|
|
preparerID StableToken
|
|
preparationSnapshotRef StableToken
|
|
preparationStatus RecoveryPreparationStatus
|
|
requiredCapabilities []StableToken
|
|
failureCauses FailureCauseChain
|
|
}
|
|
|
|
// NewRecoveryPlan validates eligibility and returns an immutable plan. Merely
|
|
// creating or preparing a plan never consumes recovery budget.
|
|
func NewRecoveryPlan(input RecoveryEligibilityInput) (RecoveryPlan, error) {
|
|
planID, err := NewStableTokenRequired("planID", input.PlanID)
|
|
if err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
idempotencyKey, err := NewStableTokenRequired("idempotencyKey", input.IdempotencyKey)
|
|
if err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
if err := input.Intent.Validate(); err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
if len(input.Contributors) == 0 {
|
|
return RecoveryPlan{}, errors.New("streamgate: recovery plan requires at least one contributor")
|
|
}
|
|
contributors := make([]RecoveryContributor, len(input.Contributors))
|
|
copy(contributors, input.Contributors)
|
|
for _, contributor := range contributors {
|
|
if err := contributor.Validate(); err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
}
|
|
if err := input.CommitState.Validate(); err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
if err := input.Policy.Validate(); err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
if err := input.Usage.ValidateAgainst(input.Policy); err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
if input.CallerCanceled {
|
|
return RecoveryPlan{}, ErrRecoveryCallerCanceled
|
|
}
|
|
if input.CommitState == CommitStateTerminalCommitted {
|
|
return RecoveryPlan{}, ErrRecoveryTerminalCommitted
|
|
}
|
|
if input.HasToolSideEffect || input.HasCompleteToolCall {
|
|
return RecoveryPlan{}, ErrRecoverySideEffect
|
|
}
|
|
for _, usedID := range input.PreviouslyUsedPlanIDs {
|
|
if err := validateStableTokenRequired("previousPlanID", usedID); err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
if usedID == input.PlanID {
|
|
return RecoveryPlan{}, ErrRecoveryPlanReentry
|
|
}
|
|
}
|
|
|
|
preparerID, err := NewStableTokenOptional("preparerID", input.PreparerID)
|
|
if err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
preparationRef, err := NewStableTokenOptional("preparationSnapshotRef", input.PreparationSnapshotRef)
|
|
if err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
if (preparerID.value == "") != (preparationRef.value == "") {
|
|
return RecoveryPlan{}, errors.New("streamgate: recovery preparer id and snapshot ref must be set together")
|
|
}
|
|
requiredCapabilities, err := snapshotStableTokens("requiredCapability", input.RequiredCapabilities)
|
|
if err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
if input.FailureCauses.Len() > MaxFailureCauses {
|
|
return RecoveryPlan{}, errors.New("streamgate: recovery failure cause chain exceeds maximum")
|
|
}
|
|
for _, cause := range input.FailureCauses.All() {
|
|
if err := cause.Validate(); err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
}
|
|
|
|
strategy := input.Intent.Strategy()
|
|
var (
|
|
resumeMode RecoveryResumeMode
|
|
requestAttempt int
|
|
strategyAttempt int
|
|
managed *ManagedTrajectoryBudget
|
|
allowance int
|
|
)
|
|
switch strategy {
|
|
case RecoveryStrategyExactReplay, RecoveryStrategySchemaRepair:
|
|
if input.CommitState != CommitStateTransportUncommitted {
|
|
return RecoveryPlan{}, ErrRecoveryCommitIneligible
|
|
}
|
|
resumeMode = RecoveryResumeModeReplaceAttempt
|
|
case RecoveryStrategyContinuationRepair, RecoveryStrategyManagedContinuation:
|
|
if input.CommitState != CommitStateStreamOpen {
|
|
return RecoveryPlan{}, ErrRecoveryCommitIneligible
|
|
}
|
|
resumeMode = RecoveryResumeModeContinueStream
|
|
default:
|
|
return RecoveryPlan{}, errors.New("streamgate: recovery plan unknown strategy")
|
|
}
|
|
|
|
if strategy == RecoveryStrategyManagedContinuation {
|
|
if input.TerminalReason != TerminalReasonOutputCap {
|
|
return RecoveryPlan{}, errors.New("streamgate: managed continuation requires output-cap terminal reason")
|
|
}
|
|
if input.ManagedTrajectory == nil {
|
|
return RecoveryPlan{}, errors.New("streamgate: managed continuation requires trajectory budget")
|
|
}
|
|
budgetCopy := copyManagedTrajectoryBudget(*input.ManagedTrajectory)
|
|
if err := budgetCopy.Validate(); err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
managed = &budgetCopy
|
|
allowance = 0
|
|
} else {
|
|
if input.TerminalReason != "" {
|
|
return RecoveryPlan{}, errors.New("streamgate: fault recovery must not carry managed terminal reason")
|
|
}
|
|
if input.ManagedTrajectory != nil {
|
|
return RecoveryPlan{}, errors.New("streamgate: fault recovery must not carry managed trajectory budget")
|
|
}
|
|
_, requestAttempt, strategyAttempt, err = input.Usage.nextFaultDispatch(strategy, input.Policy)
|
|
if err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
}
|
|
preparationStatus := RecoveryPreparationNotRequired
|
|
if preparerID.value != "" {
|
|
preparationStatus = RecoveryPreparationRequired
|
|
}
|
|
|
|
plan := RecoveryPlan{
|
|
planID: planID,
|
|
idempotencyKey: idempotencyKey,
|
|
intent: input.Intent,
|
|
contributors: contributors,
|
|
resumeMode: resumeMode,
|
|
commitState: input.CommitState,
|
|
policy: copyRecoveryPolicySnapshot(input.Policy),
|
|
usage: copyRecoveryUsageSnapshot(input.Usage),
|
|
requestAttempt: requestAttempt,
|
|
strategyAttempt: strategyAttempt,
|
|
managedTrajectory: managed,
|
|
managedAllowance: allowance,
|
|
managedFinalized: false,
|
|
terminalReason: input.TerminalReason,
|
|
preparerID: preparerID,
|
|
preparationSnapshotRef: preparationRef,
|
|
preparationStatus: preparationStatus,
|
|
requiredCapabilities: requiredCapabilities,
|
|
failureCauses: input.FailureCauses.Copy(),
|
|
}
|
|
if err := plan.Validate(); err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
return plan, nil
|
|
}
|
|
|
|
// Validate returns nil when the plan remains internally consistent.
|
|
func (p RecoveryPlan) Validate() error {
|
|
if err := validateStableTokenRequired("planID", p.planID.value); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStableTokenRequired("idempotencyKey", p.idempotencyKey.value); err != nil {
|
|
return err
|
|
}
|
|
if err := p.intent.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if err := p.resumeMode.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if err := p.commitState.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if len(p.contributors) == 0 {
|
|
return errors.New("streamgate: recovery plan requires contributors")
|
|
}
|
|
seenContributors := make(map[string]struct{}, len(p.contributors))
|
|
for _, contributor := range p.contributors {
|
|
if err := contributor.Validate(); err != nil {
|
|
return err
|
|
}
|
|
key := contributor.ConsumerID() + "\x00" + contributor.FilterID() + "\x00" + contributor.RuleID()
|
|
if _, exists := seenContributors[key]; exists {
|
|
return errors.New("streamgate: recovery plan contains duplicate contributor")
|
|
}
|
|
seenContributors[key] = struct{}{}
|
|
}
|
|
if err := p.usage.ValidateAgainst(p.policy); err != nil {
|
|
return err
|
|
}
|
|
if (p.preparerID.value == "") != (p.preparationSnapshotRef.value == "") {
|
|
return errors.New("streamgate: recovery plan preparer fields mismatch")
|
|
}
|
|
if err := p.preparationStatus.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStableTokenIfSet("preparerID", p.preparerID.value); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStableTokenIfSet("preparationSnapshotRef", p.preparationSnapshotRef.value); err != nil {
|
|
return err
|
|
}
|
|
if p.preparerID.value == "" && p.preparationStatus != RecoveryPreparationNotRequired {
|
|
return errors.New("streamgate: recovery plan without preparer has invalid preparation status")
|
|
}
|
|
if p.preparerID.value != "" && p.preparationStatus == RecoveryPreparationNotRequired {
|
|
return errors.New("streamgate: recovery plan with preparer has invalid preparation status")
|
|
}
|
|
if err := validateUniqueStableTokens("requiredCapability", p.requiredCapabilities); err != nil {
|
|
return err
|
|
}
|
|
for _, cause := range p.failureCauses.All() {
|
|
if err := cause.Validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
strategy := p.intent.Strategy()
|
|
switch strategy {
|
|
case RecoveryStrategyExactReplay, RecoveryStrategySchemaRepair:
|
|
if p.resumeMode != RecoveryResumeModeReplaceAttempt || p.commitState != CommitStateTransportUncommitted {
|
|
return errors.New("streamgate: replace recovery plan has invalid commit contract")
|
|
}
|
|
case RecoveryStrategyContinuationRepair, RecoveryStrategyManagedContinuation:
|
|
if p.resumeMode != RecoveryResumeModeContinueStream || p.commitState != CommitStateStreamOpen {
|
|
return errors.New("streamgate: continuation recovery plan has invalid commit contract")
|
|
}
|
|
default:
|
|
return errors.New("streamgate: recovery plan unknown strategy")
|
|
}
|
|
|
|
if strategy == RecoveryStrategyManagedContinuation {
|
|
if p.managedTrajectory == nil || p.terminalReason != TerminalReasonOutputCap {
|
|
return errors.New("streamgate: managed recovery plan missing trajectory contract")
|
|
}
|
|
if err := p.managedTrajectory.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if p.managedFinalized {
|
|
if p.managedAllowance <= 0 || p.managedAllowance != p.managedTrajectory.NextAllowance() {
|
|
return errors.New("streamgate: managed recovery plan allowance mismatch")
|
|
}
|
|
} else {
|
|
if p.managedAllowance != 0 {
|
|
return errors.New("streamgate: unfinalized managed recovery plan allowance must be zero")
|
|
}
|
|
}
|
|
if p.requestAttempt != 0 || p.strategyAttempt != 0 {
|
|
return errors.New("streamgate: managed recovery plan must not consume fault attempt ordinals")
|
|
}
|
|
} else {
|
|
if p.managedTrajectory != nil || p.managedAllowance != 0 || p.terminalReason != "" {
|
|
return errors.New("streamgate: fault recovery plan contains managed trajectory fields")
|
|
}
|
|
_, requestAttempt, strategyAttempt, err := p.usage.nextFaultDispatch(strategy, p.policy)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if p.requestAttempt != requestAttempt || p.strategyAttempt != strategyAttempt {
|
|
return errors.New("streamgate: fault recovery plan attempt ordinal mismatch")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UsageAfterDispatch returns the immutable usage snapshot that must be stored
|
|
// immediately before outbound dispatch. Managed continuation returns the same
|
|
// fault ledger because it uses a separate trajectory budget.
|
|
func (p RecoveryPlan) UsageAfterDispatch() (RecoveryUsageSnapshot, error) {
|
|
if err := p.Validate(); err != nil {
|
|
return RecoveryUsageSnapshot{}, err
|
|
}
|
|
if !p.ReadyForDispatch() {
|
|
return RecoveryUsageSnapshot{}, ErrRecoveryPlanNotReady
|
|
}
|
|
if p.intent.Strategy() == RecoveryStrategyManagedContinuation {
|
|
return copyRecoveryUsageSnapshot(p.usage), nil
|
|
}
|
|
next, _, _, err := p.usage.nextFaultDispatch(p.intent.Strategy(), p.policy)
|
|
return next, err
|
|
}
|
|
|
|
func (p RecoveryPlan) PlanID() string { return p.planID.value }
|
|
func (p RecoveryPlan) IdempotencyKey() string { return p.idempotencyKey.value }
|
|
func (p RecoveryPlan) Strategy() RecoveryStrategy { return p.intent.Strategy() }
|
|
func (p RecoveryPlan) Intent() RecoveryIntent { return p.intent }
|
|
func (p RecoveryPlan) Directive() RecoveryDirective { return p.intent.Directive() }
|
|
func (p RecoveryPlan) Reason() string { return p.intent.Reason() }
|
|
func (p RecoveryPlan) Priority() int { return p.intent.Priority() }
|
|
func (p RecoveryPlan) ResumeMode() RecoveryResumeMode { return p.resumeMode }
|
|
func (p RecoveryPlan) CommitState() CommitState { return p.commitState }
|
|
func (p RecoveryPlan) RequestAttempt() int { return p.requestAttempt }
|
|
func (p RecoveryPlan) StrategyAttempt() int { return p.strategyAttempt }
|
|
func (p RecoveryPlan) ManagedAllowance() int { return p.managedAllowance }
|
|
func (p RecoveryPlan) ManagedFinalized() bool { return p.managedFinalized }
|
|
func (p RecoveryPlan) TerminalReason() TerminalReason { return p.terminalReason }
|
|
func (p RecoveryPlan) PreparerID() string { return p.preparerID.value }
|
|
func (p RecoveryPlan) PreparationSnapshotRef() string { return p.preparationSnapshotRef.value }
|
|
|
|
func (p RecoveryPlan) PreparationStatus() RecoveryPreparationStatus { return p.preparationStatus }
|
|
func (p RecoveryPlan) ReadyForRebuild() bool {
|
|
return p.preparationStatus != RecoveryPreparationRequired
|
|
}
|
|
|
|
func (p RecoveryPlan) ReadyForDispatch() bool {
|
|
if p.intent.Strategy() == RecoveryStrategyManagedContinuation {
|
|
return p.ReadyForRebuild() && p.managedFinalized && p.managedAllowance > 0
|
|
}
|
|
return p.ReadyForRebuild()
|
|
}
|
|
|
|
// FinalizeRebuiltRequest validates a measured rebuilt request draft against the plan,
|
|
// calculates the final managed allowance for managed continuation strategies (or 0
|
|
// for fault recovery strategies), and returns the finalized RecoveryPlan together
|
|
// with an allowance-bound dispatchable RebuiltRequest.
|
|
func (p RecoveryPlan) FinalizeRebuiltRequest(draft RebuiltRequestDraft) (RecoveryPlan, RebuiltRequest, error) {
|
|
if err := p.Validate(); err != nil {
|
|
return RecoveryPlan{}, RebuiltRequest{}, err
|
|
}
|
|
if !p.ReadyForRebuild() {
|
|
return RecoveryPlan{}, RebuiltRequest{}, ErrRecoveryPlanNotReady
|
|
}
|
|
if err := draft.Validate(); err != nil {
|
|
return RecoveryPlan{}, RebuiltRequest{}, err
|
|
}
|
|
if draft.PlanID() != p.PlanID() || draft.IdempotencyKey() != p.IdempotencyKey() {
|
|
return RecoveryPlan{}, RebuiltRequest{}, errors.New("streamgate: rebuilt request plan or idempotency key mismatch")
|
|
}
|
|
if !slices.Equal(draft.RequiredCapabilities(), p.RequiredCapabilities()) {
|
|
return RecoveryPlan{}, RebuiltRequest{}, errors.New("streamgate: rebuilt request required capabilities mismatch")
|
|
}
|
|
|
|
strategy := p.intent.Strategy()
|
|
if strategy == RecoveryStrategyManagedContinuation {
|
|
if p.managedTrajectory == nil {
|
|
return RecoveryPlan{}, RebuiltRequest{}, errors.New("streamgate: managed recovery plan missing trajectory budget")
|
|
}
|
|
|
|
updatedBudget, err := NewManagedTrajectoryBudget(
|
|
p.managedTrajectory.providerAttemptCap,
|
|
p.managedTrajectory.contextWindow,
|
|
p.managedTrajectory.reserve,
|
|
draft.RebuiltPromptTokens(),
|
|
p.managedTrajectory.callerLogicalOutputCap,
|
|
p.managedTrajectory.callerLogicalOutputUsage,
|
|
)
|
|
if err != nil {
|
|
return RecoveryPlan{}, RebuiltRequest{}, err
|
|
}
|
|
|
|
allowance := updatedBudget.NextAllowance()
|
|
if allowance <= 0 {
|
|
return RecoveryPlan{}, RebuiltRequest{}, ErrManagedTrajectoryExhausted
|
|
}
|
|
|
|
out := p
|
|
out.managedTrajectory = &updatedBudget
|
|
out.managedAllowance = allowance
|
|
out.managedFinalized = true
|
|
if err := out.Validate(); err != nil {
|
|
return RecoveryPlan{}, RebuiltRequest{}, err
|
|
}
|
|
|
|
request, err := newRebuiltRequest(draft, allowance)
|
|
if err != nil {
|
|
return RecoveryPlan{}, RebuiltRequest{}, err
|
|
}
|
|
return out, request, nil
|
|
}
|
|
|
|
request, err := newRebuiltRequest(draft, 0)
|
|
if err != nil {
|
|
return RecoveryPlan{}, RebuiltRequest{}, err
|
|
}
|
|
return p, request, nil
|
|
}
|
|
|
|
func (p RecoveryPlan) PolicySnapshot() RecoveryPolicySnapshot {
|
|
return copyRecoveryPolicySnapshot(p.policy)
|
|
}
|
|
|
|
func (p RecoveryPlan) UsageSnapshot() RecoveryUsageSnapshot {
|
|
return copyRecoveryUsageSnapshot(p.usage)
|
|
}
|
|
|
|
// FaultBudget returns the immutable fault policy/usage pair used to select
|
|
// this plan.
|
|
func (p RecoveryPlan) FaultBudget() RecoveryBudget {
|
|
return RecoveryBudget{policy: copyRecoveryPolicySnapshot(p.policy), usage: copyRecoveryUsageSnapshot(p.usage)}
|
|
}
|
|
|
|
func (p RecoveryPlan) ManagedTrajectoryBudget() (*ManagedTrajectoryBudget, bool) {
|
|
if p.managedTrajectory == nil {
|
|
return nil, false
|
|
}
|
|
copyBudget := copyManagedTrajectoryBudget(*p.managedTrajectory)
|
|
return ©Budget, true
|
|
}
|
|
|
|
func (p RecoveryPlan) Contributors() []RecoveryContributor {
|
|
out := make([]RecoveryContributor, len(p.contributors))
|
|
copy(out, p.contributors)
|
|
return out
|
|
}
|
|
|
|
func (p RecoveryPlan) RequiredCapabilities() []string {
|
|
if p.requiredCapabilities == nil {
|
|
return nil
|
|
}
|
|
out := make([]string, len(p.requiredCapabilities))
|
|
for i, capability := range p.requiredCapabilities {
|
|
out[i] = capability.value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p RecoveryPlan) FailureCauses() FailureCauseChain {
|
|
return p.failureCauses.Copy()
|
|
}
|
|
|
|
func (p RecoveryPlan) RequiresPreparation() bool {
|
|
return p.preparerID.value != ""
|
|
}
|
|
|
|
// WithPreparedDirective returns a new final plan after successful one-shot
|
|
// preparation. The original plan remains in required state.
|
|
func (p RecoveryPlan) WithPreparedDirective(directive RecoveryDirective) (RecoveryPlan, error) {
|
|
if err := p.Validate(); err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
if p.preparationStatus != RecoveryPreparationRequired {
|
|
return RecoveryPlan{}, errors.New("streamgate: recovery plan is not awaiting preparation")
|
|
}
|
|
if err := directive.Validate(); err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
if !directive.MatchStrategy(p.intent.Strategy()) {
|
|
return RecoveryPlan{}, errors.New("streamgate: prepared directive does not match recovery strategy")
|
|
}
|
|
intent, err := NewRecoveryIntent(p.intent.Strategy(), directive, p.intent.Reason(), p.intent.Priority())
|
|
if err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
out := p
|
|
out.intent = intent
|
|
out.preparationStatus = RecoveryPreparationPrepared
|
|
if err := out.Validate(); err != nil {
|
|
return RecoveryPlan{}, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func snapshotStableTokens(name string, values []string) ([]StableToken, error) {
|
|
if values == nil {
|
|
return nil, nil
|
|
}
|
|
seen := make(map[string]struct{}, len(values))
|
|
out := make([]StableToken, len(values))
|
|
for i, value := range values {
|
|
if _, ok := seen[value]; ok {
|
|
return nil, errors.New("streamgate: duplicate " + name)
|
|
}
|
|
token, err := NewStableTokenRequired(name, value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seen[value] = struct{}{}
|
|
out[i] = token
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func validateUniqueStableTokens(name string, values []StableToken) error {
|
|
seen := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
if err := validateStableTokenRequired(name, value.value); err != nil {
|
|
return err
|
|
}
|
|
if _, exists := seen[value.value]; exists {
|
|
return errors.New("streamgate: duplicate " + name)
|
|
}
|
|
seen[value.value] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RebuiltRequestDraft is an unfinalized, raw-free reference to an endpoint/family-specific
|
|
// rebuilt request retained by the host. It carries prompt measurements, peak/retained memory
|
|
// limits, and required capabilities before post-rebuild allowance finalization.
|
|
type RebuiltRequestDraft struct {
|
|
planID StableToken
|
|
idempotencyKey StableToken
|
|
requestRef StableToken
|
|
endpoint string
|
|
family string
|
|
retainedBytes uint64
|
|
peakBytes uint64
|
|
maxBytes uint64
|
|
rebuiltPromptTokens int
|
|
requiredCapabilities []StableToken
|
|
}
|
|
|
|
// NewRebuiltRequestDraft validates and creates an unfinalized rebuilt request draft.
|
|
func NewRebuiltRequestDraft(
|
|
planID, requestRef, endpoint, family string,
|
|
retainedBytes, peakBytes, maxBytes uint64,
|
|
rebuiltPromptTokens int,
|
|
requiredCapabilities []string,
|
|
) (RebuiltRequestDraft, error) {
|
|
return NewRebuiltRequestDraftWithIdempotency(
|
|
planID, planID, requestRef, endpoint, family,
|
|
retainedBytes, peakBytes, maxBytes, rebuiltPromptTokens, requiredCapabilities,
|
|
)
|
|
}
|
|
|
|
// NewRebuiltRequestDraftWithIdempotency creates a rebuilt request draft that
|
|
// preserves the selected plan's independent outbound idempotency key.
|
|
func NewRebuiltRequestDraftWithIdempotency(
|
|
planID, idempotencyKey, requestRef, endpoint, family string,
|
|
retainedBytes, peakBytes, maxBytes uint64,
|
|
rebuiltPromptTokens int,
|
|
requiredCapabilities []string,
|
|
) (RebuiltRequestDraft, error) {
|
|
planToken, err := NewStableTokenRequired("planID", planID)
|
|
if err != nil {
|
|
return RebuiltRequestDraft{}, err
|
|
}
|
|
idempotencyToken, err := NewStableTokenRequired("idempotencyKey", idempotencyKey)
|
|
if err != nil {
|
|
return RebuiltRequestDraft{}, err
|
|
}
|
|
requestToken, err := NewStableTokenRequired("requestRef", requestRef)
|
|
if err != nil {
|
|
return RebuiltRequestDraft{}, err
|
|
}
|
|
capabilities, err := snapshotStableTokens("requiredCapability", requiredCapabilities)
|
|
if err != nil {
|
|
return RebuiltRequestDraft{}, err
|
|
}
|
|
draft := RebuiltRequestDraft{
|
|
planID: planToken,
|
|
idempotencyKey: idempotencyToken,
|
|
requestRef: requestToken,
|
|
endpoint: endpoint,
|
|
family: family,
|
|
retainedBytes: retainedBytes,
|
|
peakBytes: peakBytes,
|
|
maxBytes: maxBytes,
|
|
rebuiltPromptTokens: rebuiltPromptTokens,
|
|
requiredCapabilities: capabilities,
|
|
}
|
|
if err := draft.Validate(); err != nil {
|
|
return RebuiltRequestDraft{}, err
|
|
}
|
|
return draft, nil
|
|
}
|
|
|
|
func (d RebuiltRequestDraft) Validate() error {
|
|
if err := validateStableTokenRequired("planID", d.planID.value); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStableTokenRequired("idempotencyKey", d.idempotencyKey.value); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStableTokenRequired("requestRef", d.requestRef.value); err != nil {
|
|
return err
|
|
}
|
|
if d.endpoint == "" || d.family == "" {
|
|
return errors.New("streamgate: rebuilt request endpoint and family are required")
|
|
}
|
|
if d.maxBytes == 0 {
|
|
return errors.New("streamgate: rebuilt request max bytes must be positive")
|
|
}
|
|
if d.retainedBytes > d.peakBytes || d.peakBytes > d.maxBytes {
|
|
return ErrRecoverySnapshotLimitExceeded
|
|
}
|
|
if d.rebuiltPromptTokens < 0 {
|
|
return errors.New("streamgate: rebuilt request prompt tokens must not be negative")
|
|
}
|
|
if err := validateUniqueStableTokens("requiredCapability", d.requiredCapabilities); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d RebuiltRequestDraft) PlanID() string { return d.planID.value }
|
|
func (d RebuiltRequestDraft) IdempotencyKey() string { return d.idempotencyKey.value }
|
|
func (d RebuiltRequestDraft) RequestRef() string { return d.requestRef.value }
|
|
func (d RebuiltRequestDraft) Endpoint() string { return d.endpoint }
|
|
func (d RebuiltRequestDraft) Family() string { return d.family }
|
|
func (d RebuiltRequestDraft) RetainedBytes() uint64 { return d.retainedBytes }
|
|
func (d RebuiltRequestDraft) PeakBytes() uint64 { return d.peakBytes }
|
|
func (d RebuiltRequestDraft) MaxBytes() uint64 { return d.maxBytes }
|
|
func (d RebuiltRequestDraft) RebuiltPromptTokens() int { return d.rebuiltPromptTokens }
|
|
func (d RebuiltRequestDraft) RequiredCapabilities() []string {
|
|
out := make([]string, len(d.requiredCapabilities))
|
|
for i, capability := range d.requiredCapabilities {
|
|
out[i] = capability.value
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RebuiltRequest is an allowance-bound, dispatchable reference to a rebuilt request.
|
|
// It couples measured request parameters with the Core-calculated managed allowance.
|
|
type RebuiltRequest struct {
|
|
draft RebuiltRequestDraft
|
|
managedAllowance int
|
|
}
|
|
|
|
// newRebuiltRequest creates an allowance-bound final rebuilt request from a validated draft
|
|
// and managed allowance.
|
|
func newRebuiltRequest(draft RebuiltRequestDraft, managedAllowance int) (RebuiltRequest, error) {
|
|
if err := draft.Validate(); err != nil {
|
|
return RebuiltRequest{}, err
|
|
}
|
|
if managedAllowance < 0 {
|
|
return RebuiltRequest{}, errors.New("streamgate: rebuilt request managed allowance must not be negative")
|
|
}
|
|
request := RebuiltRequest{
|
|
draft: draft,
|
|
managedAllowance: managedAllowance,
|
|
}
|
|
if err := request.Validate(); err != nil {
|
|
return RebuiltRequest{}, err
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func (r RebuiltRequest) Validate() error {
|
|
if err := r.draft.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if r.managedAllowance < 0 {
|
|
return errors.New("streamgate: rebuilt request managed allowance must not be negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r RebuiltRequest) PlanID() string { return r.draft.PlanID() }
|
|
func (r RebuiltRequest) IdempotencyKey() string { return r.draft.IdempotencyKey() }
|
|
func (r RebuiltRequest) RequestRef() string { return r.draft.RequestRef() }
|
|
func (r RebuiltRequest) Endpoint() string { return r.draft.Endpoint() }
|
|
func (r RebuiltRequest) Family() string { return r.draft.Family() }
|
|
func (r RebuiltRequest) RetainedBytes() uint64 { return r.draft.RetainedBytes() }
|
|
func (r RebuiltRequest) PeakBytes() uint64 { return r.draft.PeakBytes() }
|
|
func (r RebuiltRequest) MaxBytes() uint64 { return r.draft.MaxBytes() }
|
|
func (r RebuiltRequest) RebuiltPromptTokens() int { return r.draft.RebuiltPromptTokens() }
|
|
func (r RebuiltRequest) ManagedAllowance() int { return r.managedAllowance }
|
|
func (r RebuiltRequest) RequiredCapabilities() []string {
|
|
return r.draft.RequiredCapabilities()
|
|
}
|
|
|
|
// NormalizedEventSource is the host-owned source of codec-normalized events
|
|
// for one provider attempt.
|
|
type NormalizedEventSource interface {
|
|
NextEvent(ctx context.Context) (NormalizedEvent, error)
|
|
}
|
|
|
|
// AttemptController owns only the current provider transport. AbortAttempt
|
|
// must be idempotent; nil means ownership is closed, including already-closed.
|
|
type AttemptController interface {
|
|
AbortAttempt(ctx context.Context) error
|
|
}
|
|
|
|
// AttemptBinding binds a dispatched attempt to its actual admission result and
|
|
// normalized event source without exposing provider auth or request body.
|
|
type AttemptBinding struct {
|
|
attemptID StableToken
|
|
model string
|
|
provider string
|
|
executionPath string
|
|
eventSource NormalizedEventSource
|
|
controller AttemptController
|
|
}
|
|
|
|
func NewAttemptBinding(
|
|
attemptID, model, provider, executionPath string,
|
|
eventSource NormalizedEventSource,
|
|
controller AttemptController,
|
|
) (AttemptBinding, error) {
|
|
attemptToken, err := NewStableTokenRequired("attemptID", attemptID)
|
|
if err != nil {
|
|
return AttemptBinding{}, err
|
|
}
|
|
if model == "" || provider == "" || executionPath == "" {
|
|
return AttemptBinding{}, errors.New("streamgate: attempt binding model, provider, and execution path are required")
|
|
}
|
|
if eventSource == nil || controller == nil {
|
|
return AttemptBinding{}, errors.New("streamgate: attempt binding event source and controller are required")
|
|
}
|
|
binding := AttemptBinding{
|
|
attemptID: attemptToken,
|
|
model: model,
|
|
provider: provider,
|
|
executionPath: executionPath,
|
|
eventSource: eventSource,
|
|
controller: controller,
|
|
}
|
|
if err := binding.Validate(); err != nil {
|
|
return AttemptBinding{}, err
|
|
}
|
|
return binding, nil
|
|
}
|
|
|
|
// Validate returns nil when the binding contains a complete actual admission
|
|
// result and both host ownership seams.
|
|
func (b AttemptBinding) Validate() error {
|
|
if err := validateStableTokenRequired("attemptID", b.attemptID.value); err != nil {
|
|
return err
|
|
}
|
|
if b.model == "" || b.provider == "" || b.executionPath == "" {
|
|
return errors.New("streamgate: attempt binding model, provider, and execution path are required")
|
|
}
|
|
if b.eventSource == nil || b.controller == nil {
|
|
return errors.New("streamgate: attempt binding event source and controller are required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b AttemptBinding) AttemptID() string { return b.attemptID.value }
|
|
func (b AttemptBinding) Model() string { return b.model }
|
|
func (b AttemptBinding) Provider() string { return b.provider }
|
|
func (b AttemptBinding) ExecutionPath() string { return b.executionPath }
|
|
func (b AttemptBinding) EventSource() NormalizedEventSource { return b.eventSource }
|
|
func (b AttemptBinding) Controller() AttemptController { return b.controller }
|
|
|
|
// AttemptDispatcher performs host admission and concrete provider/model/auth
|
|
// calculation for one rebuilt request.
|
|
type AttemptDispatcher interface {
|
|
DispatchAttempt(ctx context.Context, request RebuiltRequest) (AttemptBinding, error)
|
|
}
|
|
|
|
// RecoveryPreparationSnapshot is a bounded request-local snapshot seam. It
|
|
// exposes only its stable reference and idempotent release lifecycle; a host
|
|
// preparer may type-assert its own concrete snapshot without adding raw
|
|
// accessors to the shared Core contract.
|
|
type RecoveryPreparationSnapshot interface {
|
|
SnapshotRef() string
|
|
Release() error
|
|
}
|
|
|
|
// RecoveryPlanPreparer optionally enriches the typed directive after attempt
|
|
// ownership is closed. It must be called at most once per plan/idempotency key
|
|
// under the caller's bounded context and must not dispatch or mutate budgets.
|
|
type RecoveryPlanPreparer interface {
|
|
PrepareRecoveryPlan(ctx context.Context, plan RecoveryPlan, snapshot RecoveryPreparationSnapshot) (RecoveryDirective, error)
|
|
}
|
|
|
|
// RequestRebuilder applies the selected typed plan to a bounded, request-local
|
|
// ingress snapshot. Concrete auth and provider selection remain outside it.
|
|
type RequestRebuilder interface {
|
|
RebuildRequest(ctx context.Context, snapshot RecoveryRequestSnapshotRef, plan RecoveryPlan) (RebuiltRequestDraft, error)
|
|
}
|