- Update roadmap milestones and phase docs across multiple phases - Update plan, code-review, create-roadmap, update-roadmap, finalize-task-routing skills - Update dev-corp-runtime-deploy, dev-runtime-deploy, orchestrate-agent-task-loop skills - Refactor agent-task-loop dispatch script - Add streamgate Go package (commit_boundary, evidence_tail, filter_registry, stream_release) - Add test inventory files (dev, dev-corp, unified) - Update test smoke tests and rules for dev/dev-corp - Update docs/edge-local-dev-guide and e2e scripts - Update inventory-query Go package - Remove deprecated templates and inventory.yaml files - Add orchestrate-agent-task-loop tests
1902 lines
69 KiB
Go
1902 lines
69 KiB
Go
// Package streamgate provides transport-agnostic event contract types for the
|
|
// stream evidence gate. It owns the normalized event lifecycle, immutable
|
|
// evidence/filter decision types, and terminal/failure/release payloads.
|
|
//
|
|
// This package must not import apps/, proto/, or packages/go/config/.
|
|
// It depends only on the Go standard library.
|
|
package streamgate
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// FilterHoldMode identifies how a filter holds pending evidence before
|
|
// allowing release. It is a closed set of modes; each mode defines its own
|
|
// trigger condition and field requirements.
|
|
type FilterHoldMode string
|
|
|
|
// knownFilterHoldModes is the closed set of valid FilterHoldMode values.
|
|
var knownFilterHoldModes = map[FilterHoldMode]struct{}{
|
|
FilterHoldModeNone: {},
|
|
FilterHoldModeRolling: {},
|
|
FilterHoldModeTerminalGate: {},
|
|
FilterHoldModeFragmentGate: {},
|
|
}
|
|
|
|
const (
|
|
// FilterHoldModeNone indicates no hold. Events pass through immediately.
|
|
FilterHoldModeNone FilterHoldMode = "none"
|
|
|
|
// FilterHoldModeRolling indicates the filter holds until the rolling
|
|
// rune evidence window meets a configured threshold. Time is not a
|
|
// release condition.
|
|
FilterHoldModeRolling FilterHoldMode = "rolling_window"
|
|
|
|
// FilterHoldModeTerminalGate indicates the filter holds all events until
|
|
// a terminal event is received. Terminal or provider-error events serve
|
|
// as the trigger.
|
|
FilterHoldModeTerminalGate FilterHoldMode = "terminal_gate"
|
|
|
|
// FilterHoldModeFragmentGate indicates the filter holds tool-call
|
|
// fragments until a CompleteFragment signal arrives for the keyed ID.
|
|
FilterHoldModeFragmentGate FilterHoldMode = "fragment_gate"
|
|
)
|
|
|
|
// Validate returns nil when the mode is a known lifecycle value.
|
|
func (m FilterHoldMode) Validate() error {
|
|
switch m {
|
|
case FilterHoldModeNone, FilterHoldModeRolling, FilterHoldModeTerminalGate, FilterHoldModeFragmentGate:
|
|
return nil
|
|
}
|
|
return errors.New("streamgate: unknown filter hold mode: " + string(m))
|
|
}
|
|
|
|
// IsValidMode returns true when m is a known hold mode value.
|
|
func IsValidMode(m FilterHoldMode) bool {
|
|
_, ok := knownFilterHoldModes[m]
|
|
return ok
|
|
}
|
|
|
|
// defaultEvidenceRunes is the default Unicode rune evidence window used when
|
|
// no policy override is provided.
|
|
const defaultEvidenceRunes = 500
|
|
|
|
// minEvidenceRunes is the absolute minimum allowed rune window.
|
|
const minEvidenceRunes = 1
|
|
|
|
// maxEvidenceRunes is the absolute maximum allowed rune window.
|
|
const maxEvidenceRunes = 65536
|
|
|
|
// defaultMaxBufferRunes is the default hard buffer limit in runes.
|
|
const defaultMaxBufferRunes = 4096
|
|
|
|
// minMaxBufferRunes is the absolute minimum allowed buffer limit.
|
|
const minMaxBufferRunes = 10
|
|
|
|
// maxMaxBufferRunes is the absolute maximum allowed buffer limit.
|
|
const maxMaxBufferRunes = 1048576
|
|
|
|
// FilterHoldRequirement is an immutable specification of how a filter holds
|
|
// pending evidence before releasing it. The fields match the SDD naming and
|
|
// semantics exactly. A requirement is mode-specific: rolling requires a
|
|
// positive rune threshold, terminal gate requires a terminal trigger kind,
|
|
// fragment gate requires a fragment trigger kind, and none is a no-op.
|
|
type FilterHoldRequirement struct {
|
|
channel string
|
|
mode FilterHoldMode
|
|
subscribedKinds []EventKind
|
|
evidenceRunes int
|
|
triggerKind EventKind
|
|
maxBufferRunes int
|
|
}
|
|
|
|
// NewFilterHoldRequirementNone creates a FilterHoldRequirement of mode none.
|
|
// None mode has no rune threshold, no trigger, and releases immediately.
|
|
// Subscribed kinds are required but unused.
|
|
func NewFilterHoldRequirementNone(channel string, subscribedKinds []EventKind) (FilterHoldRequirement, error) {
|
|
if err := validateHoldChannel("none", channel); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
kinds, err := validateSubscribedKinds("none", subscribedKinds)
|
|
if err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
req := FilterHoldRequirement{
|
|
channel: channel,
|
|
mode: FilterHoldModeNone,
|
|
subscribedKinds: kinds,
|
|
evidenceRunes: 0,
|
|
triggerKind: "",
|
|
maxBufferRunes: 0,
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// NewFilterHoldRequirementRollingWithMaxBuffer creates a FilterHoldRequirement
|
|
// of mode rolling_window with an explicit max_buffer_runes override. The
|
|
// rune threshold must be within the allowed range. The max_buffer_runes must
|
|
// be >= evidence_runes to ensure the buffer can hold the evidence window.
|
|
func NewFilterHoldRequirementRollingWithMaxBuffer(channel string, subscribedKinds []EventKind, evidenceRunes int, maxBufferRunes int) (FilterHoldRequirement, error) {
|
|
if err := validateHoldChannel("rolling_window", channel); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
if evidenceRunes < minEvidenceRunes || evidenceRunes > maxEvidenceRunes {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: rolling_window evidence_runes must be between %d and %d, got %d", minEvidenceRunes, maxEvidenceRunes, evidenceRunes)
|
|
}
|
|
if maxBufferRunes < evidenceRunes {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: rolling_window max_buffer_runes must be >= evidence_runes, got evidence=%d buffer=%d", evidenceRunes, maxBufferRunes)
|
|
}
|
|
if maxBufferRunes < minMaxBufferRunes || maxBufferRunes > maxMaxBufferRunes {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: rolling_window max_buffer_runes must be between %d and %d, got %d", minMaxBufferRunes, maxMaxBufferRunes, maxBufferRunes)
|
|
}
|
|
kinds, err := validateSubscribedKinds("rolling_window", subscribedKinds)
|
|
if err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
req := FilterHoldRequirement{
|
|
channel: channel,
|
|
mode: FilterHoldModeRolling,
|
|
subscribedKinds: kinds,
|
|
evidenceRunes: evidenceRunes,
|
|
triggerKind: "",
|
|
maxBufferRunes: maxBufferRunes,
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// NewFilterHoldRequirementRolling creates a FilterHoldRequirement of mode
|
|
// rolling_window. The rune threshold must be within the allowed range. The
|
|
// subscribed kinds define which event types contribute to the rolling count.
|
|
func NewFilterHoldRequirementRolling(channel string, subscribedKinds []EventKind, evidenceRunes int) (FilterHoldRequirement, error) {
|
|
return NewFilterHoldRequirementRollingWithMaxBuffer(channel, subscribedKinds, evidenceRunes, defaultMaxBufferRunes)
|
|
}
|
|
|
|
// NewFilterHoldRequirementTerminalGateWithMaxBuffer creates a FilterHoldRequirement
|
|
// of mode terminal_gate with an explicit max_buffer_runes override.
|
|
func NewFilterHoldRequirementTerminalGateWithMaxBuffer(channel string, subscribedKinds []EventKind, triggerKind EventKind, maxBufferRunes int) (FilterHoldRequirement, error) {
|
|
if err := validateHoldChannel("terminal_gate", channel); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
if triggerKind != EventKindTerminal && triggerKind != EventKindProviderError {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: terminal_gate trigger must be terminal or provider_error, got %q", triggerKind)
|
|
}
|
|
if maxBufferRunes < minMaxBufferRunes || maxBufferRunes > maxMaxBufferRunes {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: terminal_gate max_buffer_runes must be between %d and %d, got %d", minMaxBufferRunes, maxMaxBufferRunes, maxBufferRunes)
|
|
}
|
|
kinds, err := validateSubscribedKinds("terminal_gate", subscribedKinds)
|
|
if err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
req := FilterHoldRequirement{
|
|
channel: channel,
|
|
mode: FilterHoldModeTerminalGate,
|
|
subscribedKinds: kinds,
|
|
evidenceRunes: 0,
|
|
triggerKind: triggerKind,
|
|
maxBufferRunes: maxBufferRunes,
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// NewFilterHoldRequirementTerminalGate creates a FilterHoldRequirement of
|
|
// mode terminal_gate. The trigger kind must be terminal or provider_error.
|
|
func NewFilterHoldRequirementTerminalGate(channel string, subscribedKinds []EventKind, triggerKind EventKind) (FilterHoldRequirement, error) {
|
|
return NewFilterHoldRequirementTerminalGateWithMaxBuffer(channel, subscribedKinds, triggerKind, defaultMaxBufferRunes)
|
|
}
|
|
|
|
// NewFilterHoldRequirementFragmentGateWithMaxBuffer creates a FilterHoldRequirement
|
|
// of mode fragment_gate with an explicit max_buffer_runes override.
|
|
func NewFilterHoldRequirementFragmentGateWithMaxBuffer(channel string, subscribedKinds []EventKind, triggerKind EventKind, maxBufferRunes int) (FilterHoldRequirement, error) {
|
|
if err := validateHoldChannel("fragment_gate", channel); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
if triggerKind != EventKindToolCallFragment {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: fragment_gate trigger must be tool_call_fragment, got %q", triggerKind)
|
|
}
|
|
if maxBufferRunes < minMaxBufferRunes || maxBufferRunes > maxMaxBufferRunes {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: fragment_gate max_buffer_runes must be between %d and %d, got %d", minMaxBufferRunes, maxMaxBufferRunes, maxBufferRunes)
|
|
}
|
|
kinds, err := validateSubscribedKinds("fragment_gate", subscribedKinds)
|
|
if err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
req := FilterHoldRequirement{
|
|
channel: channel,
|
|
mode: FilterHoldModeFragmentGate,
|
|
subscribedKinds: kinds,
|
|
evidenceRunes: 0,
|
|
triggerKind: triggerKind,
|
|
maxBufferRunes: maxBufferRunes,
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// NewFilterHoldRequirementFragmentGate creates a FilterHoldRequirement of
|
|
// mode fragment_gate. The trigger kind must be tool_call_fragment.
|
|
func NewFilterHoldRequirementFragmentGate(channel string, subscribedKinds []EventKind, triggerKind EventKind) (FilterHoldRequirement, error) {
|
|
return NewFilterHoldRequirementFragmentGateWithMaxBuffer(channel, subscribedKinds, triggerKind, defaultMaxBufferRunes)
|
|
}
|
|
|
|
func validateHoldChannel(mode, channel string) error {
|
|
if channel == "" {
|
|
return errors.New("streamgate: hold requirement channel is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSubscribedKinds(mode string, kinds []EventKind) ([]EventKind, error) {
|
|
if len(kinds) == 0 {
|
|
return nil, fmt.Errorf("streamgate: %s hold requirement subscribed kinds must not be empty", mode)
|
|
}
|
|
seen := make(map[EventKind]struct{}, len(kinds))
|
|
for _, k := range kinds {
|
|
if err := k.Validate(); err != nil {
|
|
return nil, fmt.Errorf("streamgate: %s hold requirement subscribed kind: %v", mode, err)
|
|
}
|
|
if _, dup := seen[k]; dup {
|
|
return nil, fmt.Errorf("streamgate: %s hold requirement duplicate subscribed kind: %s", mode, k)
|
|
}
|
|
seen[k] = struct{}{}
|
|
}
|
|
out := make([]EventKind, len(kinds))
|
|
copy(out, kinds)
|
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
return out, nil
|
|
}
|
|
|
|
// Validate returns nil when the requirement is in a consistent state.
|
|
// Mode-specific field rules are enforced.
|
|
func (r FilterHoldRequirement) Validate() error {
|
|
if r.channel == "" {
|
|
return errors.New("streamgate: hold requirement channel is required")
|
|
}
|
|
if err := r.mode.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if len(r.subscribedKinds) == 0 {
|
|
return errors.New("streamgate: hold requirement subscribed kinds must not be empty")
|
|
}
|
|
switch r.mode {
|
|
case FilterHoldModeNone:
|
|
if r.evidenceRunes != 0 {
|
|
return errors.New("streamgate: none mode must not have evidence_runes")
|
|
}
|
|
if r.triggerKind != "" {
|
|
return errors.New("streamgate: none mode must not have a trigger")
|
|
}
|
|
if r.maxBufferRunes != 0 {
|
|
return errors.New("streamgate: none mode must not have max_buffer_runes")
|
|
}
|
|
case FilterHoldModeRolling:
|
|
if r.evidenceRunes < minEvidenceRunes || r.evidenceRunes > maxEvidenceRunes {
|
|
return errors.New("streamgate: rolling_window evidence_runes out of range")
|
|
}
|
|
if r.triggerKind != "" {
|
|
return errors.New("streamgate: rolling_window mode must not have a trigger")
|
|
}
|
|
if r.maxBufferRunes < minMaxBufferRunes || r.maxBufferRunes > maxMaxBufferRunes {
|
|
return errors.New("streamgate: rolling_window max_buffer_runes out of range")
|
|
}
|
|
case FilterHoldModeTerminalGate:
|
|
if r.evidenceRunes != 0 {
|
|
return errors.New("streamgate: terminal_gate mode must not have evidence_runes")
|
|
}
|
|
if r.triggerKind != EventKindTerminal && r.triggerKind != EventKindProviderError {
|
|
return errors.New("streamgate: terminal_gate trigger must be terminal or provider_error")
|
|
}
|
|
if r.maxBufferRunes < minMaxBufferRunes || r.maxBufferRunes > maxMaxBufferRunes {
|
|
return errors.New("streamgate: terminal_gate max_buffer_runes out of range")
|
|
}
|
|
case FilterHoldModeFragmentGate:
|
|
if r.evidenceRunes != 0 {
|
|
return errors.New("streamgate: fragment_gate mode must not have evidence_runes")
|
|
}
|
|
if r.triggerKind != EventKindToolCallFragment {
|
|
return errors.New("streamgate: fragment_gate trigger must be tool_call_fragment")
|
|
}
|
|
if r.maxBufferRunes < minMaxBufferRunes || r.maxBufferRunes > maxMaxBufferRunes {
|
|
return errors.New("streamgate: fragment_gate max_buffer_runes out of range")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Channel returns the requirement channel key.
|
|
func (r FilterHoldRequirement) Channel() string { return r.channel }
|
|
|
|
// Mode returns the hold mode.
|
|
func (r FilterHoldRequirement) Mode() FilterHoldMode { return r.mode }
|
|
|
|
// SubscribedKinds returns a defensive copy of the subscribed event kinds.
|
|
func (r FilterHoldRequirement) SubscribedKinds() []EventKind {
|
|
if r.subscribedKinds == nil {
|
|
return nil
|
|
}
|
|
out := make([]EventKind, len(r.subscribedKinds))
|
|
copy(out, r.subscribedKinds)
|
|
return out
|
|
}
|
|
|
|
// EvidenceRunes returns the rolling rune threshold. Returns 0 for non-rolling
|
|
// modes.
|
|
func (r FilterHoldRequirement) EvidenceRunes() int { return r.evidenceRunes }
|
|
|
|
// TriggerKind returns the trigger event kind. Returns "" for modes that do
|
|
// not use a trigger.
|
|
func (r FilterHoldRequirement) TriggerKind() EventKind { return r.triggerKind }
|
|
|
|
// MaxBufferRunes returns the hard buffer limit in runes.
|
|
func (r FilterHoldRequirement) MaxBufferRunes() int { return r.maxBufferRunes }
|
|
|
|
// IsBlocking returns true when this requirement's mode blocks channel release.
|
|
// None and observe-only modes are non-blocking; rolling, terminal_gate, and
|
|
// fragment_gate are blocking.
|
|
func (r FilterHoldRequirement) IsBlocking() bool {
|
|
switch r.mode {
|
|
case FilterHoldModeRolling, FilterHoldModeTerminalGate, FilterHoldModeFragmentGate:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// FilterHoldBinding pairs a stable filter ID with its requirement and an
|
|
// explicit BlocksRelease flag resolved by the caller. The binding is
|
|
// immutable to callers.
|
|
type FilterHoldBinding struct {
|
|
filterID StableToken
|
|
requirement FilterHoldRequirement
|
|
blocksRelease bool
|
|
}
|
|
|
|
// NewFilterHoldBinding creates a FilterHoldBinding with validation. The
|
|
// returned value is immutable to callers.
|
|
func NewFilterHoldBinding(filterID string, req FilterHoldRequirement, blocksRelease bool) (FilterHoldBinding, error) {
|
|
f, err := NewStableTokenRequired("filterID", filterID)
|
|
if err != nil {
|
|
return FilterHoldBinding{}, err
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
return FilterHoldBinding{}, err
|
|
}
|
|
return FilterHoldBinding{
|
|
filterID: f,
|
|
requirement: req,
|
|
blocksRelease: blocksRelease,
|
|
}, nil
|
|
}
|
|
|
|
// FilterID returns the stable filter id token.
|
|
func (b FilterHoldBinding) FilterID() string { return b.filterID.value }
|
|
|
|
// Requirement returns the hold requirement.
|
|
func (b FilterHoldBinding) Requirement() FilterHoldRequirement { return b.requirement }
|
|
|
|
// BlocksRelease returns whether this binding blocks channel release.
|
|
func (b FilterHoldBinding) BlocksRelease() bool { return b.blocksRelease }
|
|
|
|
// evidencePlanKey is the internal key for grouping bindings by channel.
|
|
type evidencePlanKey struct {
|
|
channel string
|
|
mode FilterHoldMode
|
|
}
|
|
|
|
// evidencePlan holds the compiled per-channel hold plan derived from bindings.
|
|
// It only tracks blocking requirements; observe-only bindings contribute to
|
|
// observation only and never affect the channel hold decision.
|
|
type evidencePlan struct {
|
|
// blockingRequirements is keyed by channel, value is the strongest
|
|
// blocking requirement found for that channel. "Strongest" follows a
|
|
// deterministic priority: terminal_gate > fragment_gate > rolling_window.
|
|
blockingRequirements map[string]FilterHoldRequirement
|
|
|
|
// observeOnlyKinds is the union of subscribed event kinds from all
|
|
// non-blocking (observe-only) bindings per channel.
|
|
observeOnlyKinds map[string][]EventKind
|
|
|
|
// allKinds is the union of all subscribed kinds (blocking + observe) per
|
|
// channel. Used for deciding which events to route.
|
|
allKinds map[string][]EventKind
|
|
|
|
// blockingKinds is the union of subscribed kinds from all blocking
|
|
// bindings per channel. Only these kinds contribute to pending state.
|
|
blockingKinds map[string][]EventKind
|
|
}
|
|
|
|
// compileEvidencePlan produces a compiled plan from a slice of bindings.
|
|
// Only blocking bindings affect the hold decision; observe-only bindings
|
|
// do not create a channel hold. Weaker blocking bindings contribute kinds
|
|
// that are not in the strongest binding's subscribedKinds to observeOnlyKinds.
|
|
func compileEvidencePlan(bindings []FilterHoldBinding) (evidencePlan, error) {
|
|
plan := evidencePlan{
|
|
blockingRequirements: make(map[string]FilterHoldRequirement),
|
|
observeOnlyKinds: make(map[string][]EventKind),
|
|
allKinds: make(map[string][]EventKind),
|
|
blockingKinds: make(map[string][]EventKind),
|
|
}
|
|
|
|
// Track the strongest binding's original subscribedKinds per channel so
|
|
// we can determine which weaker binding kinds become observe-only.
|
|
type strongestInfo struct {
|
|
originalSubscribed []EventKind
|
|
}
|
|
strongestMap := make(map[string]strongestInfo)
|
|
|
|
for _, b := range bindings {
|
|
req := b.requirement
|
|
ch := req.Channel()
|
|
|
|
// Merge kinds for this channel.
|
|
plan.allKinds[ch] = mergeKinds(plan.allKinds[ch], req.SubscribedKinds())
|
|
if !b.blocksRelease || !req.IsBlocking() {
|
|
plan.observeOnlyKinds[ch] = mergeKinds(plan.observeOnlyKinds[ch], req.SubscribedKinds())
|
|
continue
|
|
}
|
|
// For blocking bindings, merge the blocking kinds union too.
|
|
plan.blockingKinds[ch] = mergeKinds(plan.blockingKinds[ch], req.SubscribedKinds())
|
|
// For blocking bindings, merge deterministically per channel.
|
|
existing, exists := plan.blockingRequirements[ch]
|
|
if !exists {
|
|
plan.blockingRequirements[ch] = req
|
|
strongestMap[ch] = strongestInfo{originalSubscribed: req.SubscribedKinds()}
|
|
continue
|
|
}
|
|
if strongerMode(req.Mode(), existing.Mode()) {
|
|
// New strongest: kinds from old strongest that aren't in new strongest's
|
|
// subscribedKinds become observe-only.
|
|
oldKinds := strongestMap[ch].originalSubscribed
|
|
for _, k := range oldKinds {
|
|
if !isKindSubscribed(k, req.SubscribedKinds()) {
|
|
plan.observeOnlyKinds[ch] = appendUniqueKind(plan.observeOnlyKinds[ch], k)
|
|
}
|
|
}
|
|
merged, err := mergeBlockingRequirement(existing, req)
|
|
if err != nil {
|
|
return evidencePlan{}, err
|
|
}
|
|
plan.blockingRequirements[ch] = merged
|
|
strongestMap[ch] = strongestInfo{originalSubscribed: req.SubscribedKinds()}
|
|
} else {
|
|
// Existing strongest: kinds from this (weaker) binding that aren't in
|
|
// strongest's subscribedKinds become observe-only.
|
|
strongestKinds := strongestMap[ch].originalSubscribed
|
|
for _, k := range req.SubscribedKinds() {
|
|
if !isKindSubscribed(k, strongestKinds) {
|
|
plan.observeOnlyKinds[ch] = appendUniqueKind(plan.observeOnlyKinds[ch], k)
|
|
}
|
|
}
|
|
merged, err := mergeBlockingRequirement(existing, req)
|
|
if err != nil {
|
|
return evidencePlan{}, err
|
|
}
|
|
plan.blockingRequirements[ch] = merged
|
|
}
|
|
}
|
|
|
|
return plan, nil
|
|
}
|
|
|
|
// isKindSubscribed returns true when kind is in the subscribed kinds slice.
|
|
func isKindSubscribed(kind EventKind, subscribed []EventKind) bool {
|
|
for _, k := range subscribed {
|
|
if k == kind {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// appendUniqueKind appends kind to existing only if not already present.
|
|
// This is intentionally lightweight (no sort) since we only de-dup, not
|
|
// order. Ordering is handled by mergeKinds where sorted output is required.
|
|
func appendUniqueKind(existing []EventKind, kind EventKind) []EventKind {
|
|
for _, k := range existing {
|
|
if k == kind {
|
|
return existing
|
|
}
|
|
}
|
|
return append(existing, kind)
|
|
}
|
|
|
|
// strongerMode returns true when a is strictly stronger than b in the
|
|
// hold priority: terminal_gate > fragment_gate > rolling_window.
|
|
func strongerMode(a, b FilterHoldMode) bool {
|
|
priority := map[FilterHoldMode]int{
|
|
FilterHoldModeRolling: 1,
|
|
FilterHoldModeFragmentGate: 2,
|
|
FilterHoldModeTerminalGate: 3,
|
|
}
|
|
return priority[a] > priority[b]
|
|
}
|
|
|
|
// mergeBlockingRequirement merges two blocking requirements for the same
|
|
// channel into a single deterministic requirement. The merged result preserves
|
|
// the subscribed kinds union, uses max for evidence threshold and min for hard
|
|
// bounds, and rejects incompatible combinations. When modes differ, the
|
|
// merged result takes the strongest mode but unions kinds and mins positive
|
|
// bounds across both requirements. Same-mode terminal triggers must match.
|
|
func mergeBlockingRequirement(existing, next FilterHoldRequirement) (FilterHoldRequirement, error) {
|
|
if existing.Channel() != next.Channel() {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: cannot merge requirements for different channels: %s vs %s", existing.Channel(), next.Channel())
|
|
}
|
|
// When modes differ, the strongest mode wins. All subscription kinds are
|
|
// unioned and all positive hard bounds are minimized.
|
|
if existing.Mode() != next.Mode() {
|
|
merged := FilterHoldRequirement{
|
|
channel: existing.Channel(),
|
|
subscribedKinds: mergeKinds(existing.SubscribedKinds(), next.SubscribedKinds()),
|
|
}
|
|
if strongerMode(next.Mode(), existing.Mode()) {
|
|
merged.mode = next.Mode()
|
|
merged.triggerKind = next.triggerKind
|
|
} else {
|
|
merged.mode = existing.Mode()
|
|
merged.triggerKind = existing.triggerKind
|
|
}
|
|
merged.maxBufferRunes = minPositiveBound(existing.MaxBufferRunes(), next.MaxBufferRunes())
|
|
if merged.mode == FilterHoldModeRolling {
|
|
merged.evidenceRunes = maxInt(existing.EvidenceRunes(), next.EvidenceRunes())
|
|
}
|
|
if err := validateCompatibleTrigger(existing, next); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
if err := merged.Validate(); err != nil {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: merged requirement invalid: %v", err)
|
|
}
|
|
return merged, nil
|
|
}
|
|
// Same mode: merge deterministically.
|
|
merged := FilterHoldRequirement{
|
|
channel: existing.Channel(),
|
|
mode: existing.Mode(),
|
|
subscribedKinds: mergeKinds(existing.SubscribedKinds(), next.SubscribedKinds()),
|
|
triggerKind: existing.TriggerKind(),
|
|
}
|
|
switch existing.Mode() {
|
|
case FilterHoldModeRolling:
|
|
// Use max for evidence threshold, min for hard buffer.
|
|
if existing.EvidenceRunes() > next.EvidenceRunes() {
|
|
merged.evidenceRunes = existing.EvidenceRunes()
|
|
} else {
|
|
merged.evidenceRunes = next.EvidenceRunes()
|
|
}
|
|
if existing.MaxBufferRunes() < next.MaxBufferRunes() {
|
|
merged.maxBufferRunes = existing.MaxBufferRunes()
|
|
} else {
|
|
merged.maxBufferRunes = next.MaxBufferRunes()
|
|
}
|
|
if merged.maxBufferRunes < merged.evidenceRunes {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: merged rolling buffer %d < evidence threshold %d", merged.maxBufferRunes, merged.evidenceRunes)
|
|
}
|
|
case FilterHoldModeTerminalGate:
|
|
// Same terminal mode: triggers must match.
|
|
if err := validateCompatibleTrigger(existing, next); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
if existing.MaxBufferRunes() < next.MaxBufferRunes() {
|
|
merged.maxBufferRunes = existing.MaxBufferRunes()
|
|
} else {
|
|
merged.maxBufferRunes = next.MaxBufferRunes()
|
|
}
|
|
case FilterHoldModeFragmentGate:
|
|
// Same fragment mode: triggers must match.
|
|
if err := validateCompatibleTrigger(existing, next); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
if existing.MaxBufferRunes() < next.MaxBufferRunes() {
|
|
merged.maxBufferRunes = existing.MaxBufferRunes()
|
|
} else {
|
|
merged.maxBufferRunes = next.MaxBufferRunes()
|
|
}
|
|
}
|
|
if err := merged.Validate(); err != nil {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: merged requirement invalid: %v", err)
|
|
}
|
|
return merged, nil
|
|
}
|
|
|
|
// validateCompatibleTrigger rejects two requirements whose terminal triggers
|
|
// are incompatible: different non-none terminal triggers for the same mode.
|
|
func validateCompatibleTrigger(a, b FilterHoldRequirement) error {
|
|
aTrigger := a.TriggerKind()
|
|
bTrigger := b.TriggerKind()
|
|
if aTrigger == "" && bTrigger == "" {
|
|
return nil
|
|
}
|
|
if aTrigger == bTrigger {
|
|
return nil
|
|
}
|
|
// One or both have a trigger; if they differ, reject.
|
|
if aTrigger != "" && bTrigger != "" && aTrigger != bTrigger {
|
|
return fmt.Errorf("streamgate: incompatible terminal triggers %q vs %q", aTrigger, bTrigger)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// minPositiveBound returns the smaller of two positive values; if either is
|
|
// zero (e.g. none mode has no hard bound) it returns the other.
|
|
func minPositiveBound(a, b int) int {
|
|
if a == 0 {
|
|
return b
|
|
}
|
|
if b == 0 {
|
|
return a
|
|
}
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// maxInt returns the larger of two ints.
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func mergeKinds(a, b []EventKind) []EventKind {
|
|
seen := make(map[EventKind]struct{}, len(a)+len(b))
|
|
var out []EventKind
|
|
for _, k := range a {
|
|
if _, ok := seen[k]; !ok {
|
|
seen[k] = struct{}{}
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
for _, k := range b {
|
|
if _, ok := seen[k]; !ok {
|
|
seen[k] = struct{}{}
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
return out
|
|
}
|
|
|
|
// EvidencePlan is the compiled channel plan for evidence holding. It is
|
|
// immutable to callers once created. The plan only includes blocking
|
|
// requirements; observe-only and none-mode bindings never create a channel
|
|
// hold on their own.
|
|
type EvidencePlan struct {
|
|
plan evidencePlan
|
|
}
|
|
|
|
// NewEvidencePlan compiles a channel hold plan from the provided bindings.
|
|
// Only blocking bindings affect the hold decision. Returns an error if any
|
|
// binding's requirement fails validation.
|
|
func NewEvidencePlan(bindings []FilterHoldBinding) (EvidencePlan, error) {
|
|
if len(bindings) == 0 {
|
|
return EvidencePlan{plan: evidencePlan{
|
|
blockingRequirements: make(map[string]FilterHoldRequirement),
|
|
observeOnlyKinds: make(map[string][]EventKind),
|
|
allKinds: make(map[string][]EventKind),
|
|
blockingKinds: make(map[string][]EventKind),
|
|
}}, nil
|
|
}
|
|
compiled, err := compileEvidencePlan(bindings)
|
|
if err != nil {
|
|
return EvidencePlan{}, err
|
|
}
|
|
return EvidencePlan{plan: compiled}, nil
|
|
}
|
|
|
|
// BlockingRequirementFor returns the strongest blocking requirement for the
|
|
// channel and true, or an empty requirement and false if no blocking
|
|
// requirement exists.
|
|
func (p EvidencePlan) BlockingRequirementFor(channel string) (FilterHoldRequirement, bool) {
|
|
req, ok := p.plan.blockingRequirements[channel]
|
|
return req, ok
|
|
}
|
|
|
|
// HasBlockingRequirement returns true when the given channel has at least one
|
|
// blocking requirement in the plan.
|
|
func (p EvidencePlan) HasBlockingRequirement(channel string) bool {
|
|
_, ok := p.plan.blockingRequirements[channel]
|
|
return ok
|
|
}
|
|
|
|
// BlockingRequirement returns the strongest blocking requirement for the
|
|
// channel, or an error if no blocking requirement exists.
|
|
func (p EvidencePlan) BlockingRequirement(channel string) (FilterHoldRequirement, error) {
|
|
req, ok := p.plan.blockingRequirements[channel]
|
|
if !ok {
|
|
return FilterHoldRequirement{}, errors.New("streamgate: no blocking requirement for channel: " + channel)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// SubscribeKinds returns a defensive copy of the merged subscribed kinds for the channel.
|
|
func (p EvidencePlan) SubscribeKinds(channel string) []EventKind {
|
|
kinds := p.plan.allKinds[channel]
|
|
if kinds == nil {
|
|
return nil
|
|
}
|
|
out := make([]EventKind, len(kinds))
|
|
copy(out, kinds)
|
|
return out
|
|
}
|
|
|
|
// BlockingSubscribeKinds returns a defensive copy of the blocking subscribed
|
|
// kinds for the channel. These are the kinds that contribute to pending state.
|
|
// Returns nil when the channel has no blocking requirement.
|
|
func (p EvidencePlan) BlockingSubscribeKinds(channel string) []EventKind {
|
|
kinds := p.plan.blockingKinds[channel]
|
|
if kinds == nil {
|
|
return nil
|
|
}
|
|
out := make([]EventKind, len(kinds))
|
|
copy(out, kinds)
|
|
return out
|
|
}
|
|
|
|
// BlockingKinds returns the union of event kinds that contribute to the
|
|
// channel's blocking pending state. Returns nil when the channel has no
|
|
// blocking requirement.
|
|
func (p EvidencePlan) BlockingKinds(channel string) []EventKind {
|
|
kinds := p.plan.blockingKinds[channel]
|
|
if kinds == nil {
|
|
return nil
|
|
}
|
|
out := make([]EventKind, len(kinds))
|
|
copy(out, kinds)
|
|
return out
|
|
}
|
|
|
|
// ObserveKinds returns the union of event kinds that contribute to
|
|
// observation-only subscriptions but never to blocking pending state.
|
|
// Returns nil when the channel has no observe-only subscription.
|
|
func (p EvidencePlan) ObserveKinds(channel string) []EventKind {
|
|
kinds := p.plan.observeOnlyKinds[channel]
|
|
if kinds == nil {
|
|
return nil
|
|
}
|
|
out := make([]EventKind, len(kinds))
|
|
copy(out, kinds)
|
|
return out
|
|
}
|
|
|
|
// EvidenceEpoch is an immutable snapshot of evidence state at the moment of
|
|
// a state transition (e.g. threshold reached, trigger fired). It carries
|
|
// only safe, externally-facing identification.
|
|
type EvidenceEpoch struct {
|
|
id uint64
|
|
channel string
|
|
mode FilterHoldMode
|
|
triggered bool
|
|
reason string
|
|
}
|
|
|
|
// NewEvidenceEpoch creates an EvidenceEpoch with the given parameters.
|
|
func NewEvidenceEpoch(id uint64, channel string, mode FilterHoldMode, triggered bool, reason string) (EvidenceEpoch, error) {
|
|
if channel == "" {
|
|
return EvidenceEpoch{}, errors.New("streamgate: epoch channel is required")
|
|
}
|
|
if err := mode.Validate(); err != nil {
|
|
return EvidenceEpoch{}, err
|
|
}
|
|
return EvidenceEpoch{
|
|
id: id,
|
|
channel: channel,
|
|
mode: mode,
|
|
triggered: triggered,
|
|
reason: reason,
|
|
}, nil
|
|
}
|
|
|
|
// ID returns the epoch identifier.
|
|
func (e EvidenceEpoch) ID() uint64 { return e.id }
|
|
|
|
// Channel returns the epoch channel.
|
|
func (e EvidenceEpoch) Channel() string { return e.channel }
|
|
|
|
// Mode returns the epoch hold mode.
|
|
func (e EvidenceEpoch) Mode() FilterHoldMode { return e.mode }
|
|
|
|
// Triggered returns whether the epoch was a trigger event.
|
|
func (e EvidenceEpoch) Triggered() bool { return e.triggered }
|
|
|
|
// Reason returns the epoch reason string.
|
|
func (e EvidenceEpoch) Reason() string { return e.reason }
|
|
|
|
// EvidenceTailSignal identifies the kind of signal emitted by evidence tail
|
|
// operations.
|
|
type EvidenceTailSignal string
|
|
|
|
const (
|
|
// EvidenceTailSignalNone indicates no signal was produced.
|
|
EvidenceTailSignalNone EvidenceTailSignal = "none"
|
|
|
|
// EvidenceTailSignalThreshold indicates the rolling rune threshold was
|
|
// reached and a batch is ready.
|
|
EvidenceTailSignalThreshold EvidenceTailSignal = "threshold"
|
|
|
|
// EvidenceTailSignalTrigger indicates the trigger event (terminal or
|
|
// fragment) was received.
|
|
EvidenceTailSignalTrigger EvidenceTailSignal = "trigger"
|
|
|
|
// EvidenceTailSignalBufferOverflow indicates the hard buffer limit was
|
|
// exceeded. No release is produced.
|
|
EvidenceTailSignalBufferOverflow EvidenceTailSignal = "overflow"
|
|
|
|
// EvidenceTailSignalReady indicates the state is ready for release
|
|
// (used when all accumulated events are eligible).
|
|
EvidenceTailSignalReady EvidenceTailSignal = "ready"
|
|
)
|
|
|
|
// EvidenceTailSignalOverflow is a typed signal for buffer overflow. It carries
|
|
// only the channel, bound, and cause code; no raw content.
|
|
type EvidenceTailSignalOverflow struct {
|
|
channel string
|
|
bound int
|
|
code string
|
|
}
|
|
|
|
// NewEvidenceTailSignalOverflow creates a typed overflow signal with the
|
|
// given parameters.
|
|
func NewEvidenceTailSignalOverflow(channel string, bound int, code string) (EvidenceTailSignalOverflow, error) {
|
|
if channel == "" {
|
|
return EvidenceTailSignalOverflow{}, errors.New("streamgate: overflow signal channel is required")
|
|
}
|
|
if bound <= 0 {
|
|
return EvidenceTailSignalOverflow{}, errors.New("streamgate: overflow signal bound must be positive")
|
|
}
|
|
if code == "" {
|
|
return EvidenceTailSignalOverflow{}, errors.New("streamgate: overflow signal cause code is required")
|
|
}
|
|
return EvidenceTailSignalOverflow{
|
|
channel: channel,
|
|
bound: bound,
|
|
code: code,
|
|
}, nil
|
|
}
|
|
|
|
// Channel returns the overflow channel.
|
|
func (s EvidenceTailSignalOverflow) Channel() string { return s.channel }
|
|
|
|
// Bound returns the overflow rune bound.
|
|
func (s EvidenceTailSignalOverflow) Bound() int { return s.bound }
|
|
|
|
// Code returns the overflow cause code.
|
|
func (s EvidenceTailSignalOverflow) Code() string { return s.code }
|
|
|
|
// EvidenceTail manages per-channel pending/look-behind state and tool-call
|
|
// fragment state for evidence holding. It is the core state machine that
|
|
// accumulates normalized events, validates UTF-8, counts Unicode runes, and
|
|
// produces epochs when thresholds or triggers are met.
|
|
type EvidenceTail struct {
|
|
plan EvidencePlan
|
|
epochCounter uint64
|
|
|
|
// channelState is keyed by channel name.
|
|
channelState map[string]*channelState
|
|
|
|
// epochs tracks epoch records by ID for validation during PrepareRelease
|
|
// and ConfirmRelease. Each epoch is bound to a specific channel and can
|
|
// only be prepared once.
|
|
epochs map[uint64]*epochRecord
|
|
|
|
// preparedByChannel tracks which channel has a pending prepared release
|
|
// for each epoch, preventing overlapping prepared tokens.
|
|
preparedByChannel map[string]string
|
|
|
|
// tokenNonce is a monotonic counter that ensures every generated release
|
|
// token is globally unique. It prevents stale token reuse when the same
|
|
// epoch is re-prepared after zero or partial confirm.
|
|
tokenNonce uint64
|
|
}
|
|
|
|
// channelState holds the runtime state for a single channel.
|
|
type channelState struct {
|
|
// pendingEntries is the ordered list of pending entry records for this channel.
|
|
// Each entry tracks the event, its rune count, and associated fragment ID (if any).
|
|
pendingEntries []pendingEntry
|
|
|
|
// nextSequence is a monotonic counter that assigns an immutable identity
|
|
// to each pending entry. It is never decremented or reused, even after
|
|
// confirm removes entries from the front. This allows epoch records to
|
|
// anchor their snapshot to a fixed sequence range rather than a mutable
|
|
// slice index.
|
|
nextSequence int
|
|
|
|
// committedLookBehind is the bounded list of events already confirmed
|
|
// released downstream. Used for cross-boundary repeat detection.
|
|
committedLookBehind []NormalizedEvent
|
|
|
|
// committedCursor is the total number of events confirmed released
|
|
// downstream. This is a monotonic counter that increases with each
|
|
// successful ConfirmRelease and resets on replace.
|
|
committedCursor int
|
|
|
|
// pendingRunes is the cumulative Unicode rune count of pendingEntries.
|
|
pendingRunes int
|
|
|
|
// effectiveEvidenceRunes is the rolling evidence window used for
|
|
// look-behind trimming on this channel. For non-rolling modes it equals
|
|
// maxBufferRunes.
|
|
effectiveEvidenceRunes int
|
|
|
|
// fragmentState tracks incomplete tool-call fragments keyed by toolCallID.
|
|
fragmentState map[string]*fragmentState
|
|
|
|
// maxBufferRunes is the hard buffer limit for this channel.
|
|
maxBufferRunes int
|
|
}
|
|
|
|
// pendingEntry represents a single pending event with its metadata.
|
|
type pendingEntry struct {
|
|
event NormalizedEvent
|
|
runes int
|
|
kind EventKind
|
|
toolCallID string // empty if not a fragment
|
|
sequence int // monotonic sequence number
|
|
}
|
|
|
|
// fragmentState tracks an incomplete tool-call fragment.
|
|
// entries is the ordered list of pending entry sequences for this fragment ID.
|
|
// runes is the total rune count of all entries.
|
|
type fragmentState struct {
|
|
toolCallID string
|
|
entries []int // sequence numbers of pending entries
|
|
runes int
|
|
completed bool // true when CompleteFragment has been called for this ID
|
|
}
|
|
|
|
// epochRecord tracks an epoch's state through its lifecycle. Each epoch
|
|
// is bound to an exact pending-entry sequence range captured at creation
|
|
// time; remainingSnapshot returns only entries within that range that
|
|
// have not yet been confirmed.
|
|
//
|
|
// A record is valid for PrepareRelease only when:
|
|
// - consumed is false
|
|
// - invalidated is false
|
|
// - prepared and token carry a pending token whose channel matches
|
|
//
|
|
// Once invalidated (by recovery/continuation/terminal discard or a newer
|
|
// completion epoch), all token/prepared state is cleared and ConfirmRelease
|
|
// will reject any stale outstanding token with a deterministic error.
|
|
type epochRecord struct {
|
|
epochID uint64
|
|
channel string
|
|
mode FilterHoldMode // the hold mode at epoch creation
|
|
consumed bool // true after full confirm or replace
|
|
prepared bool // true after successful PrepareRelease
|
|
token string // the prepared release token, if any
|
|
snapshotSize int // number of events captured in the prepared snapshot
|
|
snapStartSeq int // first pending-entry monotonic sequence in the snapshot
|
|
snapEndSeq int // last pending-entry monotonic sequence (exclusive) in the snapshot
|
|
confirmedCount int // total events confirmed for this epoch across partial confirms
|
|
invalidated bool // true after ResetForReplace/PrepareContinuation/DiscardPendingForTerminal
|
|
}
|
|
|
|
// NewEvidenceTail creates a new EvidenceTail from the given compiled plan.
|
|
// It initializes per-channel state on first use.
|
|
func NewEvidenceTail(plan EvidencePlan) (*EvidenceTail, error) {
|
|
if err := plan.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &EvidenceTail{
|
|
plan: plan,
|
|
epochCounter: 0,
|
|
channelState: make(map[string]*channelState),
|
|
epochs: make(map[uint64]*epochRecord),
|
|
preparedByChannel: make(map[string]string),
|
|
tokenNonce: 0,
|
|
}, nil
|
|
}
|
|
|
|
// validate checks the plan is consistent.
|
|
func (p EvidencePlan) validate() error {
|
|
for ch, req := range p.plan.blockingRequirements {
|
|
if err := req.Validate(); err != nil {
|
|
return fmt.Errorf("streamgate: compiled plan channel %s: %v", ch, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
// errStalePreparedRelease is returned by ConfirmRelease when the token
|
|
// has already been consumed, invalidated, or does not match the current
|
|
// prepared state for the epoch's channel.
|
|
errStalePreparedRelease = errors.New("streamgate: stale prepared release token")
|
|
|
|
// errPreparedSnapshotMismatch is returned when the current pending
|
|
// prefix does not match the sequence range captured at PrepareRelease.
|
|
errPreparedSnapshotMismatch = errors.New("streamgate: prepared snapshot prefix mismatch")
|
|
)
|
|
|
|
// nextObservationEpoch generates an epoch ID for a non-blocking observation
|
|
// event. No epoch record is created; the epoch cannot be prepared or
|
|
// confirmed. This is used for events that pass through (unsubscribed,
|
|
// observe-only, sub-threshold, overflow) and must never acquire release
|
|
// capability.
|
|
func (t *EvidenceTail) nextObservationEpoch(channel string, mode FilterHoldMode, triggered bool, reason string) (EvidenceEpoch, error) {
|
|
t.epochCounter++
|
|
epoch, err := NewEvidenceEpoch(t.epochCounter, channel, mode, triggered, reason)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, err
|
|
}
|
|
return epoch, nil
|
|
}
|
|
|
|
// nextReadyEpochRecord generates an epoch ID, creates a release-capable
|
|
// epoch record bound to the exact monotonic sequence range [firstSeq, lastSeq),
|
|
// and stores it for PrepareRelease/ConfirmRelease. Only ready transitions
|
|
// (threshold reached, trigger fired, fragment completion) should call this.
|
|
func (t *EvidenceTail) nextReadyEpochRecord(channel string, mode FilterHoldMode, triggered bool, reason string, firstSeq, lastSeqExclusive int) (EvidenceEpoch, *epochRecord, error) {
|
|
t.epochCounter++
|
|
epoch, err := NewEvidenceEpoch(t.epochCounter, channel, mode, triggered, reason)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, nil, err
|
|
}
|
|
record := &epochRecord{
|
|
epochID: t.epochCounter,
|
|
channel: channel,
|
|
mode: mode,
|
|
consumed: false,
|
|
prepared: false,
|
|
token: "",
|
|
snapshotSize: 0,
|
|
snapStartSeq: firstSeq,
|
|
snapEndSeq: lastSeqExclusive,
|
|
}
|
|
t.epochs[t.epochCounter] = record
|
|
return epoch, record, nil
|
|
}
|
|
|
|
// unconfirmedSnapshot returns only the release events within the epoch's
|
|
// fixed sequence range that are still present in the current pending slice
|
|
// (i.e., not yet confirmed). It uses monotonic sequence identity rather
|
|
// than mutable slice indices, so it is safe to call after partial confirm.
|
|
func (cs *channelState) unconfirmedSnapshot(record *epochRecord) []ReleaseEvent {
|
|
var releaseEvents []ReleaseEvent
|
|
for _, entry := range cs.pendingEntries {
|
|
if entry.sequence >= record.snapStartSeq && entry.sequence < record.snapEndSeq {
|
|
re, err := normalizedToReleaseEvent(entry.event)
|
|
if err == nil {
|
|
releaseEvents = append(releaseEvents, re)
|
|
}
|
|
}
|
|
}
|
|
return releaseEvents
|
|
}
|
|
|
|
// validatePreparedOwnership checks whether the given token matches the
|
|
// currently prepared release for the epoch's channel. Returns nil when the
|
|
// token is valid and still pending; returns errStalePreparedRelease otherwise.
|
|
func (t *EvidenceTail) validatePreparedOwnership(record *epochRecord, token string) error {
|
|
if record.invalidated {
|
|
return errStalePreparedRelease
|
|
}
|
|
if t.preparedByChannel[record.channel] != token {
|
|
return errStalePreparedRelease
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pendingPrefixMatchesRange verifies that the n leading entries of the
|
|
// channel's current pending slice have monotonic sequences exactly equal
|
|
// to [snapStartSeq, snapStartSeq+n). This guards against a prepared token
|
|
// being confirmed after the pending prefix has diverged from the original
|
|
// snapshot.
|
|
func (cs *channelState) pendingPrefixMatchesRange(n int, snapStartSeq int) bool {
|
|
if n > len(cs.pendingEntries) {
|
|
return false
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
if cs.pendingEntries[i].sequence != snapStartSeq+i {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// getOrCreateChannel returns the channel state, creating it if needed.
|
|
func (t *EvidenceTail) getOrCreateChannel(channel string, maxBuffer int, evidenceRunes int) (*channelState, error) {
|
|
cs, ok := t.channelState[channel]
|
|
if !ok {
|
|
// For rolling modes effectiveEvidenceRunes is the evidence window;
|
|
// for non-rolling modes (terminal_gate, fragment_gate, none) it falls
|
|
// back to maxBufferRunes so look-behind trimming uses the hard bound.
|
|
effective := evidenceRunes
|
|
if effective <= 0 {
|
|
effective = maxBuffer
|
|
}
|
|
cs = &channelState{
|
|
pendingEntries: make([]pendingEntry, 0),
|
|
fragmentState: make(map[string]*fragmentState),
|
|
maxBufferRunes: maxBuffer,
|
|
effectiveEvidenceRunes: effective,
|
|
}
|
|
t.channelState[channel] = cs
|
|
}
|
|
return cs, nil
|
|
}
|
|
|
|
// Append adds a normalized event to the evidence tail. It validates UTF-8,
|
|
// counts Unicode runes, updates pending/look-behind state, and checks for
|
|
// threshold/trigger/overflow conditions. It returns an epoch and signal if a
|
|
// state transition occurred.
|
|
//
|
|
// The Append function first validates the event, then checks subscription
|
|
// applicability. Non-subscribed events pass through without mutating state.
|
|
// Observe-only (non-blocking) kinds pass through with no state mutation and
|
|
// no epoch. Subscribed blocking events are validated for UTF-8, counted by
|
|
// Unicode runes, and added to pending state with overflow protection.
|
|
//
|
|
// For terminal_gate mode, trigger events (terminal/provider-error) are
|
|
// detected BEFORE adding to pending so that nextSequence remains strictly
|
|
// monotonic and the control event never appears in release snapshots.
|
|
func (t *EvidenceTail) Append(event NormalizedEvent) (EvidenceEpoch, EvidenceTailSignal, error) {
|
|
if err := event.Validate(); err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, fmt.Errorf("streamgate: append event validate: %v", err)
|
|
}
|
|
|
|
channel := event.Channel()
|
|
req, hasBlocking := t.plan.BlockingRequirementFor(channel)
|
|
|
|
// Fail-closed: validate UTF-8 before returning Ready or pass-through.
|
|
// Non-subscribed or invalid events never mutate hold state.
|
|
if err := validateEventUTF8(event); err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
|
|
if !hasBlocking {
|
|
// No blocking requirement: events pass through immediately.
|
|
epoch, _ := t.nextObservationEpoch(channel, FilterHoldModeNone, false, "pass_through_no_requirement")
|
|
return epoch, EvidenceTailSignalReady, nil
|
|
}
|
|
|
|
// For terminal_gate mode, configured trigger events (terminal/provider-error)
|
|
// are detected BEFORE subscription checks so the control event never
|
|
// enters pending state. UTF-8 and plan validation already passed above.
|
|
if req.Mode() == FilterHoldModeTerminalGate && event.Kind() == req.TriggerKind() {
|
|
cs, err := t.getOrCreateChannel(channel, req.MaxBufferRunes(), req.EvidenceRunes())
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
// Trigger event is a control event: do NOT add to pending.
|
|
// Create a ready epoch for the safe prefix of data events only.
|
|
safeEnd := cs.nextSequence
|
|
firstSeq := cs.firstPendingSequence()
|
|
epoch, _, err := t.nextReadyEpochRecord(
|
|
channel, FilterHoldModeTerminalGate, true, "configured_trigger",
|
|
firstSeq, safeEnd,
|
|
)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
return epoch, EvidenceTailSignalTrigger, nil
|
|
}
|
|
|
|
// Check subscription applicability. Only blocking kinds contribute to
|
|
// pending state. Observe-only kinds pass through without mutating state.
|
|
blockingKinds := t.plan.BlockingKinds(channel)
|
|
observeKinds := t.plan.ObserveKinds(channel)
|
|
|
|
if !isKindSubscribed(event.Kind(), blockingKinds) {
|
|
// Non-subscribed or observe-only event: no state mutation.
|
|
epoch, _ := t.nextObservationEpoch(channel, req.Mode(), false, "event_not_subscribed")
|
|
return epoch, EvidenceTailSignalNone, nil
|
|
}
|
|
|
|
// Observe-only event (subscribed but not blocking): pass through.
|
|
if isKindSubscribed(event.Kind(), observeKinds) {
|
|
epoch, _ := t.nextObservationEpoch(channel, req.Mode(), false, "observe_only_pass_through")
|
|
return epoch, EvidenceTailSignalNone, nil
|
|
}
|
|
|
|
cs, err := t.getOrCreateChannel(channel, req.MaxBufferRunes(), req.EvidenceRunes())
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
|
|
// Count runes contributed by this event.
|
|
runesAdded := runeCountForEvent(event)
|
|
newPendingRunes := cs.pendingRunes + runesAdded
|
|
|
|
// Check hard buffer overflow before adding.
|
|
if newPendingRunes > cs.maxBufferRunes {
|
|
// Overflow: create observation epoch (no release capability) and signal overflow.
|
|
// The typed overflow signal is not required for the public return path;
|
|
// only the EvidenceTailSignalBufferOverflow constant is emitted.
|
|
epoch, _ := t.nextObservationEpoch(channel, req.Mode(), false, "overflow_beyond_buffer_limit")
|
|
return epoch, EvidenceTailSignalBufferOverflow, nil
|
|
}
|
|
|
|
// Add event to pending entry with monotonic sequence identity.
|
|
seq := cs.nextSequence
|
|
cs.nextSequence++
|
|
entry := pendingEntry{
|
|
event: cloneNormalizedEvent(event),
|
|
runes: runesAdded,
|
|
kind: event.Kind(),
|
|
sequence: seq,
|
|
}
|
|
// Extract toolCallID for fragment events.
|
|
if event.Kind() == EventKindToolCallFragment {
|
|
tc, err := event.AsToolCallFragment()
|
|
if err == nil {
|
|
entry.toolCallID = tc.ID
|
|
// Accumulate fragment state.
|
|
fragment := cs.fragmentState[tc.ID]
|
|
if fragment == nil {
|
|
fragment = &fragmentState{
|
|
toolCallID: tc.ID,
|
|
entries: []int{},
|
|
runes: 0,
|
|
completed: false,
|
|
}
|
|
cs.fragmentState[tc.ID] = fragment
|
|
}
|
|
newFragmentRunes := fragment.runes + runesAdded
|
|
if newFragmentRunes > cs.maxBufferRunes {
|
|
// Per-ID overflow: no pending state, no epoch, no release.
|
|
return EvidenceEpoch{}, EvidenceTailSignalBufferOverflow, nil
|
|
}
|
|
fragment.entries = append(fragment.entries, entry.sequence)
|
|
fragment.runes = newFragmentRunes
|
|
}
|
|
}
|
|
cs.pendingEntries = append(cs.pendingEntries, entry)
|
|
cs.pendingRunes = newPendingRunes
|
|
|
|
epoch, signal, err := t.completeFragmentGateAndRolling(
|
|
channel, req, cs, event, runesAdded,
|
|
)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
|
|
return epoch, signal, nil
|
|
}
|
|
|
|
// firstPendingSequence returns the monotonic sequence of the first pending
|
|
// entry, or 0 if the pending slice is empty. This is used to anchor epoch
|
|
// records to the actual sequence range of the current pending prefix.
|
|
func (cs *channelState) firstPendingSequence() int {
|
|
if len(cs.pendingEntries) == 0 {
|
|
return 0
|
|
}
|
|
return cs.pendingEntries[0].sequence
|
|
}
|
|
|
|
// completeFragmentGateAndRolling checks trigger/threshold conditions for
|
|
// terminal_gate and rolling modes. For terminal_gate, it detects trigger
|
|
// events before they enter pending so that the control event is excluded
|
|
// from the release snapshot and nextSequence remains strictly monotonic.
|
|
// For rolling, it checks if the rune threshold is reached.
|
|
func (t *EvidenceTail) completeFragmentGateAndRolling(
|
|
channel string, req FilterHoldRequirement, cs *channelState, event NormalizedEvent, runesAdded int,
|
|
) (EvidenceEpoch, EvidenceTailSignal, error) {
|
|
var signal EvidenceTailSignal = EvidenceTailSignalNone
|
|
var epoch EvidenceEpoch
|
|
|
|
switch req.Mode() {
|
|
case FilterHoldModeRolling:
|
|
if cs.pendingRunes >= req.EvidenceRunes() {
|
|
// Rolling threshold reached: create a ready epoch for the
|
|
// current safe prefix (all data events accumulated so far).
|
|
safeEnd := cs.nextSequence // exclusive bound for all current entries
|
|
firstSeq := cs.firstPendingSequence()
|
|
var err error
|
|
epoch, _, err = t.nextReadyEpochRecord(
|
|
channel, FilterHoldModeRolling, true, "rolling_threshold_reached",
|
|
firstSeq, safeEnd,
|
|
)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
signal = EvidenceTailSignalThreshold
|
|
}
|
|
case FilterHoldModeTerminalGate:
|
|
if event.Kind() == req.TriggerKind() {
|
|
// Trigger event is a control event: do NOT add to pending.
|
|
// Revert the append above to keep the trigger out of the release snapshot.
|
|
cs.pendingEntries = cs.pendingEntries[:len(cs.pendingEntries)-1]
|
|
cs.pendingRunes = cs.pendingRunes - runesAdded
|
|
|
|
// Create a ready epoch for the safe prefix of data events only.
|
|
safeEnd := cs.nextSequence
|
|
firstSeq := cs.firstPendingSequence()
|
|
var err error
|
|
epoch, _, err = t.nextReadyEpochRecord(
|
|
channel, FilterHoldModeTerminalGate, true, "configured_trigger",
|
|
firstSeq, safeEnd,
|
|
)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
signal = EvidenceTailSignalTrigger
|
|
}
|
|
case FilterHoldModeFragmentGate:
|
|
// Fragment gate is triggered externally via CompleteFragment.
|
|
// Just accumulate.
|
|
}
|
|
|
|
return epoch, signal, nil
|
|
}
|
|
|
|
// CompleteFragment signals that a tool-call fragment is complete for the
|
|
// given channel and toolCallID. It returns the completed status, the
|
|
// EvidenceEpoch if a contiguous safe prefix was created, a signal indicating
|
|
// the kind of transition, and any error. A new epoch is created when the
|
|
// completion makes the prefix up to the first incomplete fragment (or all
|
|
// entries) contiguous with completed fragments.
|
|
//
|
|
// When a new safe-prefix epoch is created, any previously prepared (but
|
|
// unconfirmed) token on the same channel is invalidated so that stale
|
|
// snapshots cannot be re-prepared after a more-complete epoch supersedes them.
|
|
func (t *EvidenceTail) CompleteFragment(
|
|
channel, toolCallID string,
|
|
) (EvidenceEpoch, EvidenceTailSignal, bool, error) {
|
|
if channel == "" {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, false, errors.New("streamgate: complete fragment channel is required")
|
|
}
|
|
if toolCallID == "" {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, false, errors.New("streamgate: complete fragment tool call id is required")
|
|
}
|
|
|
|
req, hasBlocking := t.plan.BlockingRequirementFor(channel)
|
|
if !hasBlocking {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, false, errors.New("streamgate: no blocking requirement for channel: " + channel)
|
|
}
|
|
if req.Mode() != FilterHoldModeFragmentGate {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, false, fmt.Errorf("streamgate: channel %s is not fragment_gate mode (got %s)", channel, req.Mode())
|
|
}
|
|
|
|
cs, ok := t.channelState[channel]
|
|
if !ok {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, false, nil
|
|
}
|
|
|
|
// Check if the specific fragment exists and is not already completed.
|
|
fragment, exists := cs.fragmentState[toolCallID]
|
|
if !exists || fragment.completed {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, false, nil
|
|
}
|
|
|
|
// Mark as completed.
|
|
fragment.completed = true
|
|
|
|
// Check if a contiguous safe prefix was formed.
|
|
safeLen := cs.fragmentSafePrefixLength()
|
|
if safeLen == 0 {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, true, nil
|
|
}
|
|
|
|
// Invalidate any previously prepared (but unconfirmed) epoch on this
|
|
// channel so that superseded safe-prefix snapshots cannot be re-prepared.
|
|
// Only affects the same channel; unrelated channel tokens remain valid.
|
|
t.markChannelEpochsInvalidated(channel)
|
|
delete(t.preparedByChannel, channel)
|
|
|
|
// Create an epoch for the newly completed safe prefix using the
|
|
// standardized ready-epoch path so the record inherits the same
|
|
// lifecycle invariants as threshold/trigger epochs.
|
|
firstSeq := cs.firstPendingSequence()
|
|
safeEndSeq := firstSeq + safeLen
|
|
epoch, _, err := t.nextReadyEpochRecord(
|
|
channel, FilterHoldModeFragmentGate, true, "fragment_completion_safe_prefix",
|
|
firstSeq, safeEndSeq,
|
|
)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, true, err
|
|
}
|
|
return epoch, EvidenceTailSignalTrigger, true, nil
|
|
}
|
|
|
|
// isFragmentCompleted returns true when the fragment for the given ID is completed.
|
|
func (cs *channelState) isFragmentCompleted(toolCallID string) bool {
|
|
fragment, exists := cs.fragmentState[toolCallID]
|
|
return exists && fragment.completed
|
|
}
|
|
|
|
// fragmentSafePrefixLength returns the index in pendingEntries up to which
|
|
// all fragments are completed and the prefix is contiguous. Returns 0 if no
|
|
// fragments are completed.
|
|
func (cs *channelState) fragmentSafePrefixLength() int {
|
|
if len(cs.pendingEntries) == 0 {
|
|
return 0
|
|
}
|
|
// Find the first incomplete fragment ID.
|
|
firstIncompleteID := ""
|
|
for _, entry := range cs.pendingEntries {
|
|
if entry.kind == EventKindToolCallFragment && !cs.isFragmentCompleted(entry.toolCallID) {
|
|
firstIncompleteID = entry.toolCallID
|
|
break
|
|
}
|
|
}
|
|
if firstIncompleteID == "" {
|
|
// All fragments are completed.
|
|
return len(cs.pendingEntries)
|
|
}
|
|
// Find the index of the first incomplete fragment.
|
|
for i, entry := range cs.pendingEntries {
|
|
if entry.toolCallID == firstIncompleteID {
|
|
return i
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// PreparedRelease is an opaque token representing a prepared release that
|
|
// has not yet been confirmed. It is immutable and cannot be tampered with.
|
|
type PreparedRelease struct {
|
|
token string
|
|
releaseEvents []ReleaseEvent
|
|
}
|
|
|
|
// Token returns the prepared release token string.
|
|
func (pr PreparedRelease) Token() string { return pr.token }
|
|
|
|
// ReleaseEvents returns a defensive copy of the prepared release events.
|
|
func (pr PreparedRelease) ReleaseEvents() []ReleaseEvent {
|
|
if pr.releaseEvents == nil {
|
|
return nil
|
|
}
|
|
out := make([]ReleaseEvent, len(pr.releaseEvents))
|
|
copy(out, pr.releaseEvents)
|
|
return out
|
|
}
|
|
|
|
// ReleaseConfirmation carries the number of events successfully released
|
|
// during a confirm operation.
|
|
type ReleaseConfirmation struct {
|
|
ReleasedEvents int
|
|
}
|
|
|
|
// PrepareRelease creates a prepared release for the given epoch ID.
|
|
// Only epoch records that were created via a ready transition (threshold
|
|
// reached, trigger fired, fragment completion) carry release capability.
|
|
// Observation-only epochs (unsubscribed, overflow, observe-only, sub-threshold)
|
|
// have no record and cannot be prepared.
|
|
//
|
|
// PrepareRelease validates the epoch record exists and is not consumed or
|
|
// invalidated, generates a release snapshot from the epoch's fixed sequence
|
|
// range using unconfirmedSnapshot (which respects partial confirms), and
|
|
// creates an opaque token. It does NOT modify pending/look-behind/cursor
|
|
// state.
|
|
//
|
|
// PrepareRelease rejects: unknown/observation epochs, already consumed/
|
|
// invalidated epochs, overlapping prepared tokens for the same channel,
|
|
// and already-prepared epochs.
|
|
func (t *EvidenceTail) PrepareRelease(epochID uint64) (PreparedRelease, error) {
|
|
// Validate epoch record exists and is not consumed or invalidated.
|
|
record, ok := t.epochs[epochID]
|
|
if !ok {
|
|
return PreparedRelease{}, fmt.Errorf("streamgate: prepare release unknown or observation-only epoch %d", epochID)
|
|
}
|
|
if record.consumed || record.invalidated {
|
|
return PreparedRelease{}, fmt.Errorf("streamgate: prepare release epoch %d already consumed or invalidated", epochID)
|
|
}
|
|
// Overlapping prepared token on the same channel is rejected.
|
|
if t.preparedByChannel[record.channel] != "" {
|
|
return PreparedRelease{}, fmt.Errorf("streamgate: prepare release channel %s already has prepared token %s", record.channel, t.preparedByChannel[record.channel])
|
|
}
|
|
if record.prepared && record.token != "" {
|
|
return PreparedRelease{}, fmt.Errorf("streamgate: prepare release epoch %d already prepared with token %s", epochID, record.token)
|
|
}
|
|
|
|
channel := record.channel
|
|
|
|
// Get the release snapshot from the epoch's fixed sequence range.
|
|
// This uses monotonic sequence identity so it is safe after partial confirm.
|
|
channelCS := t.channelState[channel]
|
|
if channelCS == nil {
|
|
return PreparedRelease{}, errors.New("streamgate: prepare release no channel state for epoch channel")
|
|
}
|
|
releaseEvents := channelCS.unconfirmedSnapshot(record)
|
|
|
|
if len(releaseEvents) == 0 {
|
|
return PreparedRelease{}, fmt.Errorf("streamgate: prepare release epoch %d has no unconfirmed events", epochID)
|
|
}
|
|
|
|
// Generate an opaque, globally unique token with a monotonic nonce.
|
|
// This prevents stale token reuse when the same epoch is re-prepared
|
|
// after zero or partial confirm (the snapshot content could be identical).
|
|
t.tokenNonce++
|
|
token := fmt.Sprintf("prepared-%d-%d-%d", epochID, len(releaseEvents), t.tokenNonce)
|
|
|
|
// Store snapshot size for downstream ConfirmRelease validation.
|
|
record.snapshotSize = len(releaseEvents)
|
|
record.prepared = true
|
|
record.token = token
|
|
t.preparedByChannel[channel] = token
|
|
|
|
return PreparedRelease{
|
|
token: token,
|
|
releaseEvents: releaseEvents,
|
|
}, nil
|
|
}
|
|
|
|
// ConfirmRelease applies the confirmed number of released events to the
|
|
// committed look-behind and cursor. Only this method modifies pending/
|
|
// look-behind/cursor state. Zero, partial, and full confirmations are
|
|
// supported. Stale tokens and duplicate confirms are rejected.
|
|
//
|
|
// ConfirmRelease validates: token format and existence, that the token
|
|
// matches the epoch's prepared token, that ReleasedEvents does not exceed
|
|
// the prepared count, and that the epoch has not been invalidated.
|
|
//
|
|
// ConfirmRelease validates:
|
|
// - The token matches the epoch record and matches the current prepared
|
|
// token for the epoch's channel.
|
|
// - The epoch is not invalidated.
|
|
// - The pending prefix starting at the epoch's snapStartSeq matches
|
|
// the expected monotonic sequence.
|
|
// - ReleasedEvents does not exceed the prepared count.
|
|
func (t *EvidenceTail) ConfirmRelease(token string, confirmation ReleaseConfirmation) error {
|
|
if token == "" {
|
|
return errors.New("streamgate: confirm release token is required")
|
|
}
|
|
if !strings.HasPrefix(token, "prepared-") {
|
|
return errors.New("streamgate: confirm release invalid token format")
|
|
}
|
|
|
|
// Find the epoch record associated with this token.
|
|
var targetEpoch *epochRecord
|
|
for _, record := range t.epochs {
|
|
if record.token == token {
|
|
targetEpoch = record
|
|
break
|
|
}
|
|
}
|
|
if targetEpoch == nil {
|
|
return errors.New("streamgate: confirm release token not found")
|
|
}
|
|
if targetEpoch.consumed {
|
|
return fmt.Errorf("streamgate: confirm release epoch %d already consumed", targetEpoch.epochID)
|
|
}
|
|
|
|
// Validate ownership: token must match current prepared state and
|
|
// epoch must not be invalidated.
|
|
if err := t.validatePreparedOwnership(targetEpoch, token); err != nil {
|
|
return err
|
|
}
|
|
|
|
channel := targetEpoch.channel
|
|
cs, ok := t.channelState[channel]
|
|
if !ok {
|
|
return errors.New("streamgate: confirm release channel state not found")
|
|
}
|
|
|
|
n := confirmation.ReleasedEvents
|
|
if n < 0 {
|
|
return errors.New("streamgate: confirm release released events must be non-negative")
|
|
}
|
|
if n > targetEpoch.snapshotSize {
|
|
return fmt.Errorf("streamgate: confirm release %d exceeds prepared snapshot %d", n, targetEpoch.snapshotSize)
|
|
}
|
|
|
|
// Validate that the pending prefix matches the epoch's sequence range.
|
|
// This guards against a prepared token being confirmed after the pending
|
|
// prefix has diverged from the original snapshot (e.g., post-append
|
|
// contamination).
|
|
if !cs.pendingPrefixMatchesRange(n, targetEpoch.snapStartSeq) {
|
|
return errPreparedSnapshotMismatch
|
|
}
|
|
|
|
// Move confirmed events to look-behind.
|
|
confirmed := make([]NormalizedEvent, n)
|
|
for i := 0; i < n; i++ {
|
|
confirmed[i] = cloneNormalizedEvent(cs.pendingEntries[i].event)
|
|
}
|
|
cs.committedLookBehind = append(cs.committedLookBehind, confirmed...)
|
|
|
|
// Remove confirmed from pending.
|
|
cs.pendingEntries = cs.pendingEntries[n:]
|
|
|
|
// Adjust rune count.
|
|
removedRunes := 0
|
|
for i := 0; i < n; i++ {
|
|
removedRunes += runeCountForEvent(confirmed[i])
|
|
}
|
|
cs.pendingRunes -= removedRunes
|
|
|
|
// Update committed cursor.
|
|
cs.committedCursor += n
|
|
|
|
// Remove confirmed fragment states (if all their entries are confirmed).
|
|
// Use the absolute confirmed range so that non-zero sequence fragments
|
|
// clean up correctly: only entries already covered by this confirm are
|
|
// removed, and their absolute sequences are preserved for subsequent
|
|
// partial/full confirms.
|
|
confirmedEndSeq := targetEpoch.snapStartSeq + n
|
|
for id, fragment := range cs.fragmentState {
|
|
var remaining []int
|
|
for _, seq := range fragment.entries {
|
|
if seq >= confirmedEndSeq {
|
|
remaining = append(remaining, seq)
|
|
}
|
|
}
|
|
if len(remaining) == 0 {
|
|
delete(cs.fragmentState, id)
|
|
} else {
|
|
fragment.entries = remaining
|
|
}
|
|
}
|
|
|
|
// Trim look-behind to bounded size using the channel's effective evidence
|
|
// window (evidence_runes for rolling, maxBufferRunes otherwise).
|
|
cs.committedLookBehind = trimLookBehind(cs.committedLookBehind, cs.effectiveEvidenceRunes)
|
|
|
|
// Mark consumed when the full snapshot has been confirmed.
|
|
if n >= targetEpoch.snapshotSize {
|
|
targetEpoch.consumed = true
|
|
}
|
|
// Advance the epoch's unconfirmed start so that a subsequent re-prepare
|
|
// or partial/full confirm sees only the remaining suffix of the same epoch.
|
|
targetEpoch.snapStartSeq += n
|
|
targetEpoch.confirmedCount += n
|
|
// Clear prepared token regardless of completion.
|
|
targetEpoch.prepared = false
|
|
targetEpoch.token = ""
|
|
delete(t.preparedByChannel, channel)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ResetForReplace clears all attempt-local state including pending entries,
|
|
// look-behind, fragment state, prepared tokens, and the token nonce. Used
|
|
// when a new attempt replaces the current one.
|
|
func (t *EvidenceTail) ResetForReplace() {
|
|
t.channelState = make(map[string]*channelState)
|
|
t.epochs = make(map[uint64]*epochRecord)
|
|
t.preparedByChannel = make(map[string]string)
|
|
t.tokenNonce = 0
|
|
}
|
|
|
|
// PrepareContinuation preserves committed look-behind and cursor while
|
|
// discarding pending entries and prepared tokens. Used when resuming after
|
|
// a recovery continuation.
|
|
func (t *EvidenceTail) PrepareContinuation() {
|
|
for _, cs := range t.channelState {
|
|
cs.pendingEntries = nil
|
|
cs.pendingRunes = 0
|
|
cs.fragmentState = make(map[string]*fragmentState)
|
|
// committedLookBehind, committedCursor are preserved.
|
|
}
|
|
t.markEpochsInvalidated()
|
|
t.preparedByChannel = make(map[string]string)
|
|
}
|
|
|
|
// DiscardPendingForTerminal discards all pending entries and prepared tokens
|
|
// without releasing anything. Used before terminal error or when the
|
|
// terminal gate overflows. No downstream write occurs.
|
|
func (t *EvidenceTail) DiscardPendingForTerminal() {
|
|
for _, cs := range t.channelState {
|
|
cs.pendingEntries = nil
|
|
cs.pendingRunes = 0
|
|
cs.fragmentState = make(map[string]*fragmentState)
|
|
// committedLookBehind and committedCursor are preserved.
|
|
}
|
|
t.markEpochsInvalidated()
|
|
t.preparedByChannel = make(map[string]string)
|
|
}
|
|
|
|
// markEpochsInvalidated marks all epoch records as invalidated and clears
|
|
// all token/prepared/snapshot state. This prevents any stale outstanding
|
|
// token from being confirmed after recovery/continuation/terminal discard.
|
|
func (t *EvidenceTail) markEpochsInvalidated() {
|
|
for _, record := range t.epochs {
|
|
record.invalidated = true
|
|
record.prepared = false
|
|
record.token = ""
|
|
record.snapshotSize = 0
|
|
}
|
|
}
|
|
|
|
// markChannelEpochsInvalidated marks all epoch records belonging to the given
|
|
// channel as invalidated and clears all token/prepared/snapshot state for
|
|
// those records. This prevents stale outstanding tokens on the same channel
|
|
// from being confirmed after fragment completion supersedes an older safe
|
|
// prefix snapshot. It does NOT affect epoch records on other channels.
|
|
func (t *EvidenceTail) markChannelEpochsInvalidated(channel string) {
|
|
for _, record := range t.epochs {
|
|
if record.channel == channel {
|
|
record.invalidated = true
|
|
record.prepared = false
|
|
record.token = ""
|
|
record.snapshotSize = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
// validateEventUTF8 checks that text/reasoning/tool argument content in the
|
|
// event is valid UTF-8. Invalid UTF-8 is rejected with a stable error.
|
|
func validateEventUTF8(event NormalizedEvent) error {
|
|
var texts []string
|
|
switch event.Kind() {
|
|
case EventKindTextDelta:
|
|
text, err := event.AsTextDelta()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
texts = append(texts, text)
|
|
case EventKindReasoningDelta:
|
|
text, err := event.AsReasoningDelta()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
texts = append(texts, text)
|
|
case EventKindToolCallFragment:
|
|
tc, err := event.AsToolCallFragment()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
texts = append(texts, tc.Arguments)
|
|
}
|
|
for _, text := range texts {
|
|
if text != "" && !utf8.ValidString(text) {
|
|
return fmt.Errorf("streamgate: event %s contains invalid UTF-8", event.Kind())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runeCountForEvent returns the Unicode rune count for an event's text
|
|
// content. It operates on valid UTF-8 strings only.
|
|
func runeCountForEvent(event NormalizedEvent) int {
|
|
var total int
|
|
switch event.Kind() {
|
|
case EventKindTextDelta:
|
|
text, _ := event.AsTextDelta()
|
|
total += utf8.RuneCountInString(text)
|
|
case EventKindReasoningDelta:
|
|
text, _ := event.AsReasoningDelta()
|
|
total += utf8.RuneCountInString(text)
|
|
case EventKindToolCallFragment:
|
|
tc, _ := event.AsToolCallFragment()
|
|
total += utf8.RuneCountInString(tc.Arguments)
|
|
}
|
|
return total
|
|
}
|
|
|
|
// normalizedToReleaseEvent converts a NormalizedEvent to a ReleaseEvent.
|
|
// Only releasable kinds (text_delta, reasoning_delta, tool_call_fragment)
|
|
// are supported.
|
|
func normalizedToReleaseEvent(ev NormalizedEvent) (ReleaseEvent, error) {
|
|
switch ev.Kind() {
|
|
case EventKindTextDelta:
|
|
text, err := ev.AsTextDelta()
|
|
if err != nil {
|
|
return ReleaseEvent{}, err
|
|
}
|
|
return NewReleaseTextDeltaEvent(ev.Channel(), text, ev.Timestamp())
|
|
case EventKindReasoningDelta:
|
|
text, err := ev.AsReasoningDelta()
|
|
if err != nil {
|
|
return ReleaseEvent{}, err
|
|
}
|
|
return NewReleaseReasoningDeltaEvent(ev.Channel(), text, ev.Timestamp())
|
|
case EventKindToolCallFragment:
|
|
tc, err := ev.AsToolCallFragment()
|
|
if err != nil {
|
|
return ReleaseEvent{}, err
|
|
}
|
|
return NewReleaseToolCallFragmentEvent(ev.Channel(), tc.ID, tc.Name, tc.Arguments, ev.Timestamp())
|
|
default:
|
|
return ReleaseEvent{}, fmt.Errorf("streamgate: cannot release event kind %s", ev.Kind())
|
|
}
|
|
}
|
|
|
|
// CommittedCursor returns the total number of events confirmed released
|
|
// downstream for the given channel. Returns 0 if the channel has no state.
|
|
func (t *EvidenceTail) CommittedCursor(channel string) int {
|
|
cs, ok := t.channelState[channel]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
return cs.committedCursor
|
|
}
|
|
|
|
// EffectiveLookBehind returns a defensive deep copy of the bounded look-behind
|
|
// events for the given channel. Returns nil if the channel has no state.
|
|
func (t *EvidenceTail) EffectiveLookBehind(channel string) []NormalizedEvent {
|
|
cs, ok := t.channelState[channel]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if len(cs.committedLookBehind) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]NormalizedEvent, len(cs.committedLookBehind))
|
|
for i, ev := range cs.committedLookBehind {
|
|
out[i] = cloneNormalizedEvent(ev)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sliceEventAtRuneBoundary returns a new event with content sliced to at most
|
|
// maxRunes runes from the trailing edge. Returns an empty NormalizedEvent if
|
|
// the kind cannot be sliced (terminal, response_start, etc.).
|
|
func sliceEventAtRuneBoundary(ev NormalizedEvent, maxRunes int) (NormalizedEvent, error) {
|
|
var text string
|
|
switch ev.Kind() {
|
|
case EventKindTextDelta:
|
|
text, _ = ev.AsTextDelta()
|
|
case EventKindReasoningDelta:
|
|
text, _ = ev.AsReasoningDelta()
|
|
case EventKindToolCallFragment:
|
|
tc, _ := ev.AsToolCallFragment()
|
|
text = tc.Arguments
|
|
default:
|
|
return NormalizedEvent{}, nil
|
|
}
|
|
|
|
runes := []rune(text)
|
|
if len(runes) <= maxRunes {
|
|
return ev, nil
|
|
}
|
|
sliced := string(runes[len(runes)-maxRunes:])
|
|
|
|
switch ev.Kind() {
|
|
case EventKindTextDelta:
|
|
return NewTextDeltaEvent(ev.Channel(), sliced, ev.Timestamp())
|
|
case EventKindReasoningDelta:
|
|
return NewReasoningDeltaEvent(ev.Channel(), sliced, ev.Timestamp())
|
|
case EventKindToolCallFragment:
|
|
tc, _ := ev.AsToolCallFragment()
|
|
return NewToolCallFragmentEvent(ev.Channel(), tc.ID, tc.Name, sliced, ev.Timestamp())
|
|
}
|
|
return NormalizedEvent{}, nil
|
|
}
|
|
|
|
// trimLookBehind trims the look-behind to at most maxRunes worth of events,
|
|
// keeping the most recent events. Single oversized events are sliced at
|
|
// Unicode rune boundaries to preserve partial content as a defensive snapshot.
|
|
func trimLookBehind(events []NormalizedEvent, maxRunes int) []NormalizedEvent {
|
|
if len(events) == 0 || maxRunes <= 0 {
|
|
return events
|
|
}
|
|
|
|
// Calculate rune counts for each event.
|
|
runeCounts := make([]int, len(events))
|
|
totalRunes := 0
|
|
for i, ev := range events {
|
|
runeCounts[i] = runeCountForEvent(ev)
|
|
totalRunes += runeCounts[i]
|
|
}
|
|
|
|
if totalRunes <= maxRunes {
|
|
return events
|
|
}
|
|
|
|
// Keep from the end until we're within the limit.
|
|
var result []NormalizedEvent
|
|
remainingRunes := maxRunes
|
|
for i := len(events) - 1; i >= 0; i-- {
|
|
if remainingRunes >= runeCounts[i] {
|
|
result = append([]NormalizedEvent{events[i]}, result...)
|
|
remainingRunes -= runeCounts[i]
|
|
} else {
|
|
// Slice this event at rune boundary to preserve partial content.
|
|
sliced, err := sliceEventAtRuneBoundary(events[i], remainingRunes)
|
|
if err == nil && sliced.Kind() != "" {
|
|
result = append([]NormalizedEvent{sliced}, result...)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|