🏡 index : quine-global/quintodrome.git

package artwork

import (
	"context"
	"encoding/json"
	"io"
	"slices"
	"sync"

	"github.com/navidrome/navidrome/utils/str"
)

// Outcome is what the priority chain observed for one candidate; the CLI renders and branches on these.
type Outcome string

const (
	OutcomeHit        Outcome = "hit"
	OutcomeMiss       Outcome = "miss"
	OutcomeUnreadable Outcome = "unreadable"
	OutcomeSkipped    Outcome = "skipped"
	OutcomeError      Outcome = "error"
)

const (
	// externalCandidate labels the external tier itself, for the cases that never reach an agent.
	externalCandidate = "external"
	// ExternalPrefix qualifies a candidate or a stored source with the agent that produced it.
	ExternalPrefix = externalCandidate + ":"
)

// TraceStep is one candidate the priority chain considered.
type TraceStep struct {
	Candidate string
	Outcome   Outcome
	Detail    string
}

// ChainTrace collects the walk of a single resolution: the worker attaches one per queue
// item so it can be stored, and the CLI attaches one per explain.
type ChainTrace struct {
	mu    sync.Mutex
	steps []TraceStep
}

func (t *ChainTrace) add(step TraceStep) {
	if t == nil {
		return
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	t.steps = append(t.steps, step)
}

func (t *ChainTrace) Steps() []TraceStep {
	if t == nil {
		return nil
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	return slices.Clone(t.steps)
}

// maxTraceDetail bounds a stored Detail, which on the failure paths is an error string of
// unknown length. Past ~1kB a row spills to an overflow page, slowing every scan of the table.
const maxTraceDetail = 200

// storedStep is the persisted shape of a TraceStep. The keys are single letters because a trace
// is written for every item, and the encoded length is repeated across the whole library.
type storedStep struct {
	C string  `json:"c"`
	O Outcome `json:"o"`
	D string  `json:"d,omitempty"`
}

// encode serializes the trace for storage, without the copy Steps would make for a caller
// that only wants to write it.
func (t *ChainTrace) encode(sourcePath string) string {
	if t == nil {
		return encodeSteps(nil, sourcePath)
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	return encodeSteps(t.steps, sourcePath)
}

// encodeSteps writes the stored form. A hit's Detail is the winning source's path, which the
// same row already stores as source_path, so it is dropped and DecodeTrace puts it back.
func encodeSteps(steps []TraceStep, sourcePath string) string {
	out := make([]storedStep, 0, len(steps))
	for _, s := range steps {
		d := s.Detail
		if s.Outcome == OutcomeHit && d == sourcePath {
			d = ""
		}
		out = append(out, storedStep{C: s.Candidate, O: s.Outcome, D: str.TruncateRunes(d, maxTraceDetail, "...")})
	}
	b, _ := json.Marshal(out) // []storedStep is all strings, so this cannot fail
	return string(b)
}

// DecodeTrace reverses the stored form. A trace that will not parse is reported as no trace at all,
// since a diagnostic command must not fail on a bad row.
func DecodeTrace(encoded, sourcePath string) []TraceStep {
	if encoded == "" {
		return nil
	}
	var stored []storedStep
	if err := json.Unmarshal([]byte(encoded), &stored); err != nil {
		return nil
	}
	steps := make([]TraceStep, 0, len(stored))
	for _, s := range stored {
		d := s.D
		if d == "" && s.O == OutcomeHit {
			d = sourcePath
		}
		steps = append(steps, TraceStep{Candidate: s.C, Outcome: s.O, Detail: d})
	}
	return steps
}

type traceCtxKey struct{}

func withTrace(ctx context.Context, t *ChainTrace) context.Context {
	return context.WithValue(ctx, traceCtxKey{}, t)
}

func traceFrom(ctx context.Context) *ChainTrace {
	t, _ := ctx.Value(traceCtxKey{}).(*ChainTrace)
	return t
}

// recordAgent files what one external agent answered. The agent loops call this rather than a
// gate wrapper, because only they hold the context that carries the trace.
func recordAgent(ctx context.Context, name string, r io.ReadCloser, path string, err error) {
	t := traceFrom(ctx)
	candidate := ExternalPrefix + name
	switch {
	case r != nil:
		t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path})
	case isTransientExternal(err):
		t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()})
	default:
		t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss})
	}
}

// traceStage records a failure from the stages that run after the priority chain has already
// picked a winner: most ways an item can fail are here, not in the chain walk.
func traceStage(ctx context.Context, stage string, err error) {
	traceFrom(ctx).add(TraceStep{Candidate: stage, Outcome: OutcomeError, Detail: err.Error()})
}