d347dc48d2
- agentapi: non-2xx GETs now surface as typed *StatusError (same text); features.go adds Feature/SupportState/SupportCache (route probe, TTL 5m, Yes/No cached, Unknown never cached or refused) + Client.Supports - web: handleNetStorageAdd refuses up front (412, code agent_outdated, Hungarian message) when the agent predates /netstorage/verify-status (= pre-0.81 add semantics); gate runs BEFORE the single-flight claim; SupportUnknown passes through to the existing agent-error paths - netAddSupport page-render helper lands here; its template consumer follows - tests: T1 gate refusal (job never starts, slot free), T2 unchanged happy path + warm-cache negative assertion, T3 indeterminate never 'too old', T4 classification incl. the string-match trap, T6 TTL, wire-level 404-typing; red-proofs RP1-RP4 run and reverted Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
127 lines
4.8 KiB
Go
127 lines
4.8 KiB
Go
package agentapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Agent-capability probing (the publish-train backstop). A controller release that depends on
|
|
// coupled agent behavior must not fail mid-pipeline against an older agent — it detects support up
|
|
// front and refuses honestly. Detection is a ROUTE PROBE: a route that shipped together with the
|
|
// coupled semantics either answers (2xx ⇒ supported) or 404s (older agent). Transport errors and
|
|
// 5xx are INDETERMINATE — an agent problem is never claimed as "too old".
|
|
|
|
// Feature names one coupled controller↔agent capability.
|
|
type Feature string
|
|
|
|
// FeatureNetstorageVerify is the NAS verify-before-commit add semantics (agent v0.81.0): the
|
|
// coupled add behavior shipped together with GET /netstorage/verify-status, so that route IS the
|
|
// capability signal.
|
|
const FeatureNetstorageVerify Feature = "netstorage_verify"
|
|
|
|
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
|
|
// refuses — the existing agent-error paths speak honestly when the agent is down).
|
|
type SupportState int
|
|
|
|
const (
|
|
// SupportUnknown — the probe could not decide (transport error, timeout, auth, 5xx).
|
|
SupportUnknown SupportState = iota
|
|
// SupportYes — the agent answered 2xx on the feature's probe route.
|
|
SupportYes
|
|
// SupportNo — the agent answered 404: it predates the route, and with it the coupled semantics.
|
|
SupportNo
|
|
)
|
|
|
|
// SupportProber is the minimal agent surface a probe needs. *Client satisfies it, and so does the
|
|
// web layer's netAgent seam — tests inject fakes there.
|
|
type SupportProber interface {
|
|
NetVerifyStatus(ctx context.Context) (NetVerifyStatus, error)
|
|
}
|
|
|
|
// featureProbes maps each coupled feature to its route probe.
|
|
//
|
|
// CONVENTION (publish-train rules doc, felhom.eu/documentation/runbooks/publish-train-rules.md):
|
|
// every future coupled feature adds a row here plus a Supports gate call at its entry point, and
|
|
// declares MinAgent in its CHANGELOG header. When the agent someday reports an explicit version in
|
|
// its envelope, Supports should prefer that version comparison over route probing — that
|
|
// enhancement is roadmap, not built yet.
|
|
var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error{
|
|
FeatureNetstorageVerify: func(ctx context.Context, p SupportProber) error {
|
|
_, err := p.NetVerifyStatus(ctx)
|
|
return err
|
|
},
|
|
}
|
|
|
|
// supportTTL bounds how long a probe verdict (either polarity) is trusted. An agent updated
|
|
// mid-window flips within this — no invalidation plumbing by design.
|
|
const supportTTL = 5 * time.Minute
|
|
|
|
type supportEntry struct {
|
|
state SupportState
|
|
at time.Time
|
|
}
|
|
|
|
// SupportCache caches per-feature probe verdicts. Yes and No are cached for supportTTL; Unknown is
|
|
// NEVER cached (a down agent re-probes on the next call, so recovery is immediate). The zero value
|
|
// is ready to use.
|
|
type SupportCache struct {
|
|
mu sync.Mutex
|
|
now func() time.Time // test seam; nil → time.Now
|
|
entries map[Feature]supportEntry
|
|
}
|
|
|
|
// Supports reports whether the agent behind p provides feature f, answering from the cache inside
|
|
// the TTL window and probing otherwise. The probe runs OUTSIDE the lock — concurrent misses may
|
|
// double-probe (harmless: the probe is one cheap GET).
|
|
func (sc *SupportCache) Supports(ctx context.Context, p SupportProber, f Feature) SupportState {
|
|
probe, ok := featureProbes[f]
|
|
if !ok {
|
|
return SupportUnknown // unregistered feature — never refuse on a table gap
|
|
}
|
|
sc.mu.Lock()
|
|
nowFn := sc.now
|
|
if nowFn == nil {
|
|
nowFn = time.Now
|
|
}
|
|
if e, hit := sc.entries[f]; hit && nowFn().Sub(e.at) < supportTTL {
|
|
sc.mu.Unlock()
|
|
return e.state
|
|
}
|
|
sc.mu.Unlock()
|
|
|
|
state := classifySupportErr(probe(ctx, p))
|
|
if state != SupportUnknown {
|
|
sc.mu.Lock()
|
|
if sc.entries == nil {
|
|
sc.entries = map[Feature]supportEntry{}
|
|
}
|
|
sc.entries[f] = supportEntry{state: state, at: nowFn()}
|
|
sc.mu.Unlock()
|
|
}
|
|
return state
|
|
}
|
|
|
|
// Supports probes (cached, TTL 5m, both polarities) whether the connected agent provides the
|
|
// feature. 2xx ⇒ Yes. 404 ⇒ No. Anything else ⇒ Unknown (never "too old"). The web layer drives
|
|
// the same machinery through its netAgent seam (Server.netFeatures) so tests can fake the probe.
|
|
func (c *Client) Supports(ctx context.Context, f Feature) SupportState {
|
|
return c.features.Supports(ctx, c, f)
|
|
}
|
|
|
|
// classifySupportErr maps a probe outcome to a SupportState. ONLY a typed HTTP 404 means
|
|
// "unsupported" — every other error (transport, timeout, 401, 5xx, envelope problems) is Unknown,
|
|
// so a merely-down agent is never reported as outdated. Typed errors only; never string-match.
|
|
func classifySupportErr(err error) SupportState {
|
|
if err == nil {
|
|
return SupportYes
|
|
}
|
|
var se *StatusError
|
|
if errors.As(err, &se) && se.Code == http.StatusNotFound {
|
|
return SupportNo
|
|
}
|
|
return SupportUnknown
|
|
}
|