mirror of
https://github.com/coder/coder.git
synced 2026-06-05 14:08:20 +00:00
246 lines
6.2 KiB
Go
246 lines
6.2 KiB
Go
package chatdebug
|
|
|
|
import (
|
|
"context"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/pubsub"
|
|
)
|
|
|
|
// This branch-02 compatibility shim forward-declares recorder, service,
|
|
// and summary symbols that land in later stacked branches. Delete this
|
|
// file once recorder.go, service.go, and summary.go are available here.
|
|
|
|
// RecorderOptions identifies the chat/model context for debug recording.
|
|
type RecorderOptions struct {
|
|
ChatID uuid.UUID
|
|
OwnerID uuid.UUID
|
|
Provider string
|
|
Model string
|
|
}
|
|
|
|
// Service is a placeholder for the later chat debug persistence service.
|
|
type Service struct{}
|
|
|
|
// NewService constructs the branch-02 placeholder chat debug service.
|
|
func NewService(_ database.Store, _ slog.Logger, _ pubsub.Pubsub) *Service {
|
|
return &Service{}
|
|
}
|
|
|
|
type attemptSink struct{}
|
|
|
|
type attemptSinkKey struct{}
|
|
|
|
func withAttemptSink(ctx context.Context, sink *attemptSink) context.Context {
|
|
if sink == nil {
|
|
panic("chatdebug: nil attemptSink")
|
|
}
|
|
return context.WithValue(ctx, attemptSinkKey{}, sink)
|
|
}
|
|
|
|
func attemptSinkFromContext(ctx context.Context) *attemptSink {
|
|
sink, _ := ctx.Value(attemptSinkKey{}).(*attemptSink)
|
|
return sink
|
|
}
|
|
|
|
var stepCounters sync.Map // map[uuid.UUID]*atomic.Int32
|
|
|
|
// runRefCounts tracks how many live RunContext instances reference each
|
|
// RunID. Cleanup of shared state (step counters) is deferred until the
|
|
// last RunContext for a given RunID is garbage collected.
|
|
var (
|
|
runRefCounts sync.Map // map[uuid.UUID]*atomic.Int32
|
|
// refCountMu serializes trackRunRef and releaseRunRef so the
|
|
// decrement-to-zero check and subsequent map deletions are
|
|
// atomic with respect to new references being added.
|
|
refCountMu sync.Mutex
|
|
)
|
|
|
|
func trackRunRef(runID uuid.UUID) {
|
|
refCountMu.Lock()
|
|
defer refCountMu.Unlock()
|
|
val, _ := runRefCounts.LoadOrStore(runID, &atomic.Int32{})
|
|
counter, ok := val.(*atomic.Int32)
|
|
if !ok {
|
|
panic("chatdebug: runRefCounts contains non-*atomic.Int32 value")
|
|
}
|
|
counter.Add(1)
|
|
}
|
|
|
|
// releaseRunRef decrements the reference count for runID and cleans up
|
|
// shared state when the last reference is released. The mutex ensures
|
|
// no concurrent trackRunRef can increment between the zero check and
|
|
// the map deletions.
|
|
func releaseRunRef(runID uuid.UUID) {
|
|
refCountMu.Lock()
|
|
defer refCountMu.Unlock()
|
|
val, ok := runRefCounts.Load(runID)
|
|
if !ok {
|
|
return
|
|
}
|
|
counter, ok := val.(*atomic.Int32)
|
|
if !ok {
|
|
panic("chatdebug: runRefCounts contains non-*atomic.Int32 value")
|
|
}
|
|
if counter.Add(-1) <= 0 {
|
|
runRefCounts.Delete(runID)
|
|
stepCounters.Delete(runID)
|
|
}
|
|
}
|
|
|
|
func nextStepNumber(runID uuid.UUID) int32 {
|
|
val, _ := stepCounters.LoadOrStore(runID, &atomic.Int32{})
|
|
counter, ok := val.(*atomic.Int32)
|
|
if !ok {
|
|
panic("chatdebug: invalid step counter type")
|
|
}
|
|
return counter.Add(1)
|
|
}
|
|
|
|
// CleanupStepCounter removes per-run step counter and reference count
|
|
// state. This is used by tests and later stacked branches that have a
|
|
// real run lifecycle.
|
|
func CleanupStepCounter(runID uuid.UUID) {
|
|
stepCounters.Delete(runID)
|
|
runRefCounts.Delete(runID)
|
|
}
|
|
|
|
type stepHandle struct {
|
|
stepCtx *StepContext
|
|
sink *attemptSink
|
|
mu sync.Mutex
|
|
status Status
|
|
response any
|
|
usage any
|
|
err any
|
|
metadata any
|
|
}
|
|
|
|
func beginStep(
|
|
ctx context.Context,
|
|
svc *Service,
|
|
opts RecorderOptions,
|
|
op Operation,
|
|
_ any,
|
|
) (*stepHandle, context.Context) {
|
|
if svc == nil {
|
|
return nil, ctx
|
|
}
|
|
|
|
rc, ok := RunFromContext(ctx)
|
|
if !ok || rc.RunID == uuid.Nil {
|
|
return nil, ctx
|
|
}
|
|
|
|
if holder, reuseStep := reuseHolderFromContext(ctx); reuseStep {
|
|
holder.mu.Lock()
|
|
defer holder.mu.Unlock()
|
|
// Only reuse the cached handle if it belongs to the same run.
|
|
// A different RunContext means a new logical run, so we must
|
|
// create a fresh step to avoid cross-run attribution.
|
|
if holder.handle != nil && holder.handle.stepCtx.RunID == rc.RunID {
|
|
enriched := ContextWithStep(ctx, holder.handle.stepCtx)
|
|
enriched = withAttemptSink(enriched, holder.handle.sink)
|
|
return holder.handle, enriched
|
|
}
|
|
|
|
handle, enriched := newStepHandle(ctx, rc, opts, op)
|
|
holder.handle = handle
|
|
return handle, enriched
|
|
}
|
|
|
|
return newStepHandle(ctx, rc, opts, op)
|
|
}
|
|
|
|
func newStepHandle(
|
|
ctx context.Context,
|
|
rc *RunContext,
|
|
opts RecorderOptions,
|
|
op Operation,
|
|
) (*stepHandle, context.Context) {
|
|
if rc == nil || rc.RunID == uuid.Nil {
|
|
return nil, ctx
|
|
}
|
|
|
|
chatID := opts.ChatID
|
|
if chatID == uuid.Nil {
|
|
chatID = rc.ChatID
|
|
}
|
|
|
|
handle := &stepHandle{
|
|
stepCtx: &StepContext{
|
|
StepID: uuid.New(),
|
|
RunID: rc.RunID,
|
|
ChatID: chatID,
|
|
StepNumber: nextStepNumber(rc.RunID),
|
|
Operation: op,
|
|
HistoryTipMessageID: rc.HistoryTipMessageID,
|
|
},
|
|
sink: &attemptSink{},
|
|
}
|
|
enriched := ContextWithStep(ctx, handle.stepCtx)
|
|
enriched = withAttemptSink(enriched, handle.sink)
|
|
return handle, enriched
|
|
}
|
|
|
|
func (h *stepHandle) finish(
|
|
_ context.Context,
|
|
status Status,
|
|
response any,
|
|
usage any,
|
|
err any,
|
|
metadata any,
|
|
) {
|
|
if h == nil || h.stepCtx == nil {
|
|
return
|
|
}
|
|
// Guard with a mutex so concurrent callers (e.g. retried stream
|
|
// wrappers sharing a reused handle) don't race. Unlike sync.Once,
|
|
// later retries are allowed to overwrite earlier failure results so
|
|
// the step reflects the final outcome.
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.status = status
|
|
h.response = response
|
|
h.usage = usage
|
|
h.err = err
|
|
h.metadata = metadata
|
|
}
|
|
|
|
// whitespaceRun matches one or more consecutive whitespace characters.
|
|
var whitespaceRun = regexp.MustCompile(`\s+`)
|
|
|
|
// truncateRunes truncates s to maxLen runes, appending an ellipsis
|
|
// when truncation occurs. Returns "" when maxLen <= 0.
|
|
func truncateRunes(s string, maxLen int) string {
|
|
if maxLen <= 0 {
|
|
return ""
|
|
}
|
|
if utf8.RuneCountInString(s) <= maxLen {
|
|
return s
|
|
}
|
|
if maxLen == 1 {
|
|
return "…"
|
|
}
|
|
runes := []rune(s)
|
|
return string(runes[:maxLen-1]) + "…"
|
|
}
|
|
|
|
// TruncateLabel whitespace-normalizes and truncates text to maxLen runes.
|
|
// Returns "" if input is empty or whitespace-only.
|
|
func TruncateLabel(text string, maxLen int) string {
|
|
normalized := strings.TrimSpace(whitespaceRun.ReplaceAllString(text, " "))
|
|
if normalized == "" {
|
|
return ""
|
|
}
|
|
return truncateRunes(normalized, maxLen)
|
|
}
|