From f1d4884bea01b14ee27b32732e7baabf8b077a08 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 20:25:38 -0500 Subject: [PATCH 01/30] chore(ci): pin golangci-lint via go tool and fix staticcheck stdlib panic - Run golangci-lint from go.mod tool dependency instead of mise PATH binary - Replace honnef.co/go/tools with v0.8.0-rc.1 (fixes 'poll' package panic under go1.27rc2) - Narrow gochecknoglobals exclusion for immutable package-level ignore patterns --- .gitignore | 2 + .golangci.yml | 3 + Taskfile.yml | 8 +- go.mod | 177 +++++++++++++++++++++- go.sum | 412 ++++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 585 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index a38c5c29..3fb15041 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ *.dll *.so *.dylib +/.config/ +/go/ # Test artifacts *.test diff --git a/.golangci.yml b/.golangci.yml index 9d9d4dee..28b5a3a0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -100,6 +100,9 @@ linters: path: ".*_generated\\.go$" - linters: [lll] source: "^//go:generate " + # Immutable default read-ignore patterns are parsed once at package init. + - linters: [gochecknoglobals] + path: "internal/tool/ignore\\.go$" settings: errcheck: diff --git a/Taskfile.yml b/Taskfile.yml index e19e1283..12b187f5 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -78,20 +78,20 @@ tasks: desc: Run golangci-lint deps: [ensure-cache] cmds: - - golangci-lint run + - go tool golangci-lint run fmt: desc: Format and fix with golangci-lint deps: [ensure-cache] cmds: - - golangci-lint fmt - - golangci-lint run --fix + - go tool golangci-lint fmt + - go tool golangci-lint run --fix fmt-check: desc: Check formatting without modifying files deps: [ensure-cache] cmds: - - golangci-lint fmt --diff + - go tool golangci-lint fmt --diff lint-suppressions-check: desc: Reject nolint directives in project Go source diff --git a/go.mod b/go.mod index 58397546..60c99ca6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/omarluq/librecode -go 1.26.6 +go 1.27 require ( github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 @@ -58,64 +58,235 @@ require ( ) require ( - github.com/BurntSushi/toml v0.3.1 // indirect + 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect + 4d63.com/gochecknoglobals v0.2.2 // indirect + charm.land/lipgloss/v2 v2.0.3 // indirect + codeberg.org/chavacava/garif v0.2.0 // indirect + codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect + dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect + dev.gaijin.team/go/golib v0.6.0 // indirect + github.com/4meepo/tagalign v1.4.3 // indirect + github.com/Abirdcfly/dupword v0.1.7 // indirect + github.com/AdminBenni/iota-mixing v1.0.0 // indirect + github.com/AlwxSin/noinlineerr v1.0.5 // indirect + github.com/Antonboom/errname v1.1.1 // indirect + github.com/Antonboom/nilnil v1.1.1 // indirect + github.com/Antonboom/testifylint v1.6.4 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect + github.com/Djarvur/go-err113 v0.1.1 // indirect github.com/DmitriyVTitov/size v1.5.0 // indirect github.com/JohannesKaufmann/dom v0.3.1 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/MirrexOne/unqueryvet v1.5.4 // indirect + github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect + github.com/alecthomas/go-check-sumtype v0.3.1 // indirect + github.com/alexkohler/nakedret/v2 v2.0.6 // indirect + github.com/alexkohler/prealloc v1.1.0 // indirect + github.com/alfatraining/structtag v1.0.0 // indirect + github.com/alingse/asasalint v0.0.11 // indirect + github.com/alingse/nilnesserr v0.2.0 // indirect github.com/andybalholm/cascadia v1.3.4 // indirect + github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect + github.com/ashanbrown/makezero/v2 v2.2.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bkielbasa/cyclop v1.2.3 // indirect + github.com/blizzy78/varnamelen v0.8.0 // indirect + github.com/bombsimon/wsl/v4 v4.7.0 // indirect + github.com/bombsimon/wsl/v5 v5.8.0 // indirect + github.com/breml/bidichk v0.3.3 // indirect + github.com/breml/errchkjson v0.4.1 // indirect github.com/buger/jsonparser v1.1.2 // indirect + github.com/butuzov/ireturn v0.4.1 // indirect + github.com/butuzov/mirror v1.3.0 // indirect + github.com/catenacyber/perfsprint v0.10.1 // indirect + github.com/ccojocar/zxcvbn-go v1.0.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charithe/durationcheck v0.0.11 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/ckaznocha/intrange v0.3.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/daixiang0/gci v0.13.7 // indirect + github.com/dave/dst v0.27.3 // indirect + github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/ebitengine/purego v0.10.1 // indirect + github.com/ettle/strcase v0.2.0 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/firefart/nonamedreturns v1.0.6 // indirect + github.com/fzipp/gocyclo v0.6.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect + github.com/ghostiam/protogetter v0.3.20 // indirect + github.com/go-critic/go-critic v0.14.3 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-toolsmith/astcast v1.1.0 // indirect + github.com/go-toolsmith/astcopy v1.1.0 // indirect + github.com/go-toolsmith/astequal v1.2.0 // indirect + github.com/go-toolsmith/astfmt v1.1.0 // indirect + github.com/go-toolsmith/astp v1.1.0 // indirect + github.com/go-toolsmith/strparse v1.1.0 // indirect + github.com/go-toolsmith/typep v1.1.0 // indirect + github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/godoc-lint/godoc-lint v0.11.2 // indirect + github.com/gofrs/flock v0.13.0 // indirect + github.com/golangci/asciicheck v0.5.0 // indirect + github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect + github.com/golangci/go-printf-func-name v0.1.1 // indirect + github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect + github.com/golangci/golangci-lint/v2 v2.12.2 // indirect + github.com/golangci/golines v0.15.0 // indirect + github.com/golangci/misspell v0.8.0 // indirect + github.com/golangci/plugin-module-register v0.1.2 // indirect + github.com/golangci/revgrep v0.8.0 // indirect + github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect + github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect + github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gordonklaus/ineffassign v0.2.0 // indirect + github.com/gostaticanalysis/analysisutil v0.7.1 // indirect + github.com/gostaticanalysis/comment v1.5.0 // indirect + github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect + github.com/gostaticanalysis/nilerr v0.1.2 // indirect + github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jgautheron/goconst v1.10.0 // indirect + github.com/jjti/go-spancheck v0.6.5 // indirect + github.com/julz/importas v0.2.0 // indirect + github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect + github.com/kisielk/errcheck v1.10.0 // indirect + github.com/kkHAIKE/contextcheck v1.1.6 // indirect + github.com/kulti/thelper v0.7.1 // indirect + github.com/kunwardeep/paralleltest v1.0.15 // indirect + github.com/lasiar/canonicalheader v1.1.2 // indirect + github.com/ldez/exptostd v0.4.5 // indirect + github.com/ldez/gomoddirectives v0.8.0 // indirect + github.com/ldez/grignotin v0.10.1 // indirect + github.com/ldez/structtags v0.6.1 // indirect + github.com/ldez/tagliatelle v0.7.2 // indirect + github.com/ldez/usetesting v0.5.0 // indirect + github.com/leonklingele/grouper v1.1.2 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/macabu/inamedparam v0.2.0 // indirect + github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect + github.com/manuelarte/funcorder v0.6.0 // indirect + github.com/maratori/testableexamples v1.0.1 // indirect + github.com/maratori/testpackage v1.1.2 // indirect + github.com/matoous/godox v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/mgechev/revive v1.15.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/moricho/tparallel v0.3.2 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nakabonne/nestif v0.3.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/nishanths/exhaustive v0.12.0 // indirect + github.com/nishanths/predeclared v0.2.2 // indirect + github.com/nunnatsa/ginkgolinter v0.23.0 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.21.1 // indirect + github.com/quasilyte/go-ruleguard v0.4.5 // indirect + github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect + github.com/quasilyte/gogrep v0.5.0 // indirect + github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect + github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect + github.com/raeperd/recvcheck v0.2.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/ryancurrah/gomodguard v1.4.1 // indirect + github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect + github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/samber/go-singleflightx v0.3.2 // indirect github.com/samber/go-type-to-string v1.8.0 // indirect github.com/samber/slog-common v0.21.0 // indirect + github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect + github.com/sashamelentyev/interfacebloat v1.1.0 // indirect + github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect + github.com/securego/gosec/v2 v2.26.1 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/sivchari/containedctx v1.0.3 // indirect + github.com/sonatard/noctx v0.5.1 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/sourcegraph/go-diff v0.8.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect + github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tetafro/godot v1.5.6 // indirect + github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect + github.com/timonwong/loggercheck v0.11.0 // indirect + github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect + github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect + github.com/ultraware/funlen v0.2.0 // indirect + github.com/ultraware/whitespace v0.2.0 // indirect + github.com/uudashr/gocognit v1.2.1 // indirect + github.com/uudashr/iface v1.4.2 // indirect + github.com/xen0n/gosmopolitan v1.3.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yagipy/maintidx v1.0.0 // indirect + github.com/yeya24/promlinter v0.3.0 // indirect + github.com/ykadowak/zerologlint v0.1.5 // indirect + gitlab.com/bosi/decorder v0.4.2 // indirect + go-simpler.org/musttag v0.14.0 // indirect + go-simpler.org/sloglint v0.12.0 // indirect + go.augendre.info/arangolint v0.4.0 // indirect + go.augendre.info/fatcontext v0.9.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.design/x/x11 v0.2.0 // indirect golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect + golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect + golang.org/x/mod v0.38.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + honnef.co/go/tools v0.7.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect + mvdan.cc/gofumpt v0.9.2 // indirect + mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect ) + +tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint + +replace honnef.co/go/tools => honnef.co/go/tools v0.8.0-rc.1 diff --git a/go.sum b/go.sum index e3faa7f3..8b02c059 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,54 @@ -github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +4d63.com/gocheckcompilerdirectives v1.3.0 h1:Ew5y5CtcAAQeTVKUVFrE7EwHMrTO6BggtEj8BZSjZ3A= +4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY= +4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= +4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY= +codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ= +codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI= +codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI= +dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= +dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= +github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= +github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c= +github.com/Abirdcfly/dupword v0.1.7 h1:2j8sInznrje4I0CMisSL6ipEBkeJUJAmK1/lfoNGWrQ= +github.com/Abirdcfly/dupword v0.1.7/go.mod h1:K0DkBeOebJ4VyOICFdppB23Q0YMOgVafM0zYW0n9lF4= +github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo= +github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY= +github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY= +github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc= +github.com/Antonboom/errname v1.1.1 h1:bllB7mlIbTVzO9jmSWVWLjxTEbGBVQ1Ff/ClQgtPw9Q= +github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ= +github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksufQ= +github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II= +github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ= +github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/ClickHouse/clickhouse-go-linter v1.2.0 h1:zbm174up3hTKjp0wKZVnTzRiG7tSF5XZF0FJG/MuCBI= +github.com/ClickHouse/clickhouse-go-linter v1.2.0/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4= +github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g= +github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= github.com/DmitriyVTitov/size v1.5.0 h1:/PzqxYrOyOUX1BXj6J9OuVRVGe+66VL4D9FlUaW515g= github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0= github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0= github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE= github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8= github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/MicahParks/jwkset v0.11.3 h1:Phli4RdTDdIdLXZpuO7abkwZyzIk0RDTUPVVBHPRdkQ= github.com/MicahParks/jwkset v0.11.3/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= github.com/MicahParks/keyfunc/v3 v3.8.1 h1:VR3jlEs2wz1xGjvUUwUHeoB+eJ6gUnBrpnIByRrtj6g= github.com/MicahParks/keyfunc/v3 v3.8.1/go.mod h1:LcorJ0sz2tZGvgZqIfaeyLkJmM+kxIfRDu8dFY0TAas= +github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ= +github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU= +github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= +github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= github.com/Oudwins/zog v0.22.2 h1:neSFVFsn7cd4azB9+2hK1sn1By5UZGc8o28vNWJhUIE= github.com/Oudwins/zog v0.22.2/go.mod h1:c4ADJ2zNkJp37ZViNy1o3ZZoeMvO7UQVO7BaPtRoocg= github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= @@ -20,41 +59,103 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8v github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= +github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ= +github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q= +github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M= +github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig= +github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc= +github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus= +github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= +github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= +github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= +github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg= github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= +github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI= +github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM= +github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJUBUsejqM= +github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= +github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= +github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= +github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= +github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= +github.com/bombsimon/wsl/v5 v5.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs= +github.com/bombsimon/wsl/v5 v5.8.0/go.mod h1:AbOLsulgkqP4ZnitHf9gwPtCOGlrzkk0jb0uNxRSY0o= +github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= +github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= +github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= +github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I= +github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4= +github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= +github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= +github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ= +github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc= +github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= +github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk= +github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4= github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/etag v0.2.0 h1:Euj1VkheoHfTYA9y+TCwkeXF/hN8Fb9l4LqZl79pt04= github.com/charmbracelet/x/etag v0.2.0/go.mod h1:C1B7/bsgvzzxpfu0Rabbd+rTHJa5TmC/qgTseCf6DF0= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs= +github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= +github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= +github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ= +github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= +github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= +github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= +github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= +github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= @@ -62,24 +163,69 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= +github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E= +github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= +github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v3 v3.4.1 h1:22227t1EUwqxTlmCX9vw0RUE2IEPGw6oYcNan+bPe4w= github.com/gdamore/tcell/v3 v3.4.1/go.mod h1:YWwuxZNi14VGQC5g2VGNEDRXpBraTwvVjMovRH6G6hw= +github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0= +github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= +github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= +github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.9.1 h1:8U73XiOTfINdItHVa6z4Gv7ToObcZ6grkqQbLryLCdA= github.com/go-git/go-billy/v5 v5.9.1/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0= github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= +github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= +github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= +github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw= +github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4= +github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ= +github.com/go-toolsmith/astequal v1.2.0 h1:3Fs3CYZ1k9Vo4FzFhwwewC3CHISHDnVUPC4x0bI2+Cw= +github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY= +github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco= +github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4= +github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA= +github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA= +github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk= +github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus= +github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= +github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw= +github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= +github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= +github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= +github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM= +github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gofrs/uuid/v5 v5.5.1 h1:z1Ce19/JwNidXpy3tOQc3241lnJLKdKyq/xlNvlD4Ng= github.com/gofrs/uuid/v5 v5.5.1/go.mod h1:bbAA98EoIlxyRHIVg6ektCSsZ5n8mSbwgEhvhMYlZgg= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -87,7 +233,34 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= +github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= +github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A= +github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= +github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U= +github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss= +github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= +github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= +github.com/golangci/golangci-lint/v2 v2.12.2 h1:7+d1uY0bq1MU2UV3R5pW5Q7QWdcoq4naMRXM+gsJKrs= +github.com/golangci/golangci-lint/v2 v2.12.2/go.mod h1:opqHHuIcTG2R+4akzWMd4o1BnD9/1LcjICWOujr91U8= +github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0= +github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10= +github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg= +github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg= +github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg= +github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= +github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s= +github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= +github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba h1:lqtcnSMDuuJdu/LrKWi5RJzpSNLOJXYe/nzQutTI5kg= +github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba/go.mod h1:sCBNcpRmhJCtbFGz49+IM3ETTFf7QdJ30AeYCd43NKk= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s= +github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM= +github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -97,6 +270,27 @@ github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs= +github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= +github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= +github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= +github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= +github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8= +github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc= +github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk= +github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY= +github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHMcpu52xQU= +github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA= +github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= +github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= +github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= +github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= +github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -108,7 +302,19 @@ github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8i github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jgautheron/goconst v1.10.0 h1:Ptt+OoE4NaEWKhLrWrrN3IpZdGLiqaf7WLnEX/iv4Jw= +github.com/jgautheron/goconst v1.10.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc= +github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= +github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= +github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= +github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= +github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= +github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw= +github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= +github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= +github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -119,35 +325,96 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98= +github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs= +github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w= +github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4= +github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= +github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ= +github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM= +github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk= +github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q= +github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o= +github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas= +github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk= +github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY= +github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk= +github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI= +github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc= +github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ= +github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= +github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE= +github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= +github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww= +github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM= +github.com/manuelarte/funcorder v0.6.0 h1:0hBngc4fa1IgNiI65A7sFGkMvoMCc878RjqB5V7rWP0= +github.com/manuelarte/funcorder v0.6.0/go.mod h1:id3NDhXdQBmeqXH7eVC6Z89xS6JxvZ8kF9xUxpArU/g= +github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8= +github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ= +github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs= +github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc= +github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= +github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q= +github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= +github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mvm-sh/mvm v0.5.0 h1:XWII2Y8RLEzvFMuBWSacg6KLUTUp2weBin4lUlFrV+Y= github.com/mvm-sh/mvm v0.5.0/go.mod h1:2h9+ibS1DzdkMRNjGVs+pkU0blZZXDrEIUpcI93p4XA= +github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= +github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= +github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= +github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= +github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= +github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8= +github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4= github.com/odvcencio/gotreesitter v0.51.0 h1:tSAcoC1wGyCIOgXF5BgwjiTgA+UW8jW2Lb15wDyXRWk= github.com/odvcencio/gotreesitter v0.51.0/go.mod h1:hBVkghd0paaYAVwd2087vfwdeU984bQbMo9LvpE0moo= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= +github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= +github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= +github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= +github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= +github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= +github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= +github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -165,15 +432,35 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA= +github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= +github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY= +github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= +github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= +github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= +github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= +github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g= +github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I= +github.com/ryancurrah/gomodguard/v2 v2.1.3 h1:E7sz3PJwE9Ba1reVxSpF6XLCPJZ74Kfw/LabTNM4GIA= +github.com/ryancurrah/gomodguard/v2 v2.1.3/go.mod h1:CQicdLGatWMxLX53JzoBjYlsNZhHbmLv2AVa0s2aivU= +github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg= +github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= @@ -196,55 +483,123 @@ github.com/samber/slog-common v0.21.0 h1:Wo2hTly1Br5RjYqX/BTWJJeDnTE85oWk/7vqlpZ github.com/samber/slog-common v0.21.0/go.mod h1:d/6OaSlzdkl9PFpfRLgn8FwY1OW6EFmPtBpsHX4MrU0= github.com/samber/slog-zerolog/v2 v2.9.2 h1:DIFzfzDTxHeRyGlfg/D7b2by7VVzcsBTybRPrzjWF4c= github.com/samber/slog-zerolog/v2 v2.9.2/go.mod h1:2q6cYK2OcN6YfQE/WyCnUtigc+yYf3ozqGsGmRwZR6I= +github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= +github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= +github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= +github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ= +github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8= github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc= github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/securego/gosec/v2 v2.26.1 h1:gdkttGhQFVehqRJ8grKH4DrpqM/QlPKNHBnl8QgcEC4= +github.com/securego/gosec/v2 v2.26.1/go.mod h1:57UW4p0uoP3kxoTkhoo3axLdVAi+OWrLg/Ax/kdqtPE= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= +github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= +github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs= +github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/sourcegraph/go-diff v0.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQytHhvY= +github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= +github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= +github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= +github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= -github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f h1:9hiVElpCmKzsBKQHkBqZ8LGzt82iLfM8egxr4sew+Ys= github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f/go.mod h1:8/zr1Tv0+cKpVtGCEB/7YfRXr2TszsMxMXLaT8YuBgU= +github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= +github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= +github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= +github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= +github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA= +github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU= +github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 h1:SiHe5XLTn9sFWJ5pBwJ5FN/4j34q9ZlOAD//kMoMYp0= +github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M= +github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= +github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8= github.com/tmaxmax/go-sse v0.11.0 h1:nogmJM6rJUoOLoAwEKeQe5XlVpt9l7N82SS1jI7lWFg= github.com/tmaxmax/go-sse v0.11.0/go.mod h1:u/2kZQR1tyngo1lKaNCj1mJmhXGZWS1Zs5yiSOD+Eg8= +github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is= +github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo= +github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= +github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= +github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= +github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= +github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= +github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8= +github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4= +github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q= +github.com/uudashr/iface v1.4.2 h1:06Vq5RKVYThBsj0Bnw4oasMjD1r+7CE/bcKOA8dVSvg= +github.com/uudashr/iface v1.4.2/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= github.com/vingarcia/ksql v1.14.0 h1:BP360CPFHtfcsx3XRY72Yd7kdIuJnxKKb146HLaPqDo= github.com/vingarcia/ksql v1.14.0/go.mod h1:qdi0y6l16XX1py8U2ulHMayjRnpf13Hy+SJBrbvSxxI= github.com/vingarcia/ksql/adapters/modernc-ksqlite v1.14.0 h1:p64pO3GSMs3xQPfLfQ6ut9i3S/f8nSlmPjda/JvghKk= github.com/vingarcia/ksql/adapters/modernc-ksqlite v1.14.0/go.mod h1:R2cmzj8+asUdYx2Aj7Aw6K4QSPE9J4d3BOvakZYdioQ= +github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= +github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= +github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= +github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs= +github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4= +github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= +github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= +gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= +gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= +go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= +go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= +go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo= +go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE= +go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4= +go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I= +go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50= +go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA= +go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE= +go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= @@ -253,7 +608,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= @@ -271,11 +627,18 @@ golang.org/x/exp v0.0.0-20260718201538-764159d718ef h1:LkZ48HFgy/TvhTI0bcWkjgFkg golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE= golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= +golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk= +golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8= golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -283,24 +646,35 @@ golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -319,7 +693,9 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= @@ -330,12 +706,21 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= +golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -349,12 +734,15 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.8.0-rc.1 h1:wqMm2kjcEXMOr+6yau+pdKqJKe6l2N1aKPkpini+Kzk= +honnef.co/go/tools v0.8.0-rc.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks= lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= @@ -411,5 +799,9 @@ modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE= +mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= +mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= +mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI= +mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= From d62652c77169a3bfe8e0b7fb38e67aaa01ad23c0 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 20:25:51 -0500 Subject: [PATCH 02/30] fix(agenttask): keep heartbeat renewing after transient renewal failures Previously the heartbeat goroutine permanently gave up after one failed 8s renewal window, letting leases lapse and RecoverExpired overwrite outcomes to 'interrupted', discarding results and usage. - Continue renewal attempts instead of exiting on first failure - Add regression test for renewal exhaustion cancelling long runs --- internal/agenttask/event_writer.go | 2 +- internal/agenttask/service.go | 47 +++++++------ internal/agenttask/service_internal_test.go | 74 +++++++++++++++------ 3 files changed, 78 insertions(+), 45 deletions(-) diff --git a/internal/agenttask/event_writer.go b/internal/agenttask/event_writer.go index d8da57e6..adae9fa6 100644 --- a/internal/agenttask/event_writer.go +++ b/internal/agenttask/event_writer.go @@ -41,8 +41,8 @@ func (service *Service) newTaskEventWriter(taskID string) *taskEventWriter { return &taskEventWriter{ service: service, taskID: taskID, flushInterval: eventFlushInterval, flushBatch: eventFlushBatch, - done: make(chan struct{}), stop: make(chan struct{}), flushMu: sync.Mutex{}, mu: sync.Mutex{}, + done: make(chan struct{}), stop: make(chan struct{}), pending: nil, err: nil, closed: false, } } diff --git a/internal/agenttask/service.go b/internal/agenttask/service.go index 2f6eafb4..967f536c 100644 --- a/internal/agenttask/service.go +++ b/internal/agenttask/service.go @@ -30,10 +30,6 @@ const ( leaseDuration = 30 * time.Second leaseHeartbeatInterval = 10 * time.Second leaseRenewalRetryInterval = 250 * time.Millisecond - // leaseRenewalWindow bounds the total renewal retry budget per heartbeat. - // It stays below the heartbeat interval so a stuck renewal never starves the - // next scheduled renewal of the 30s lease. - leaseRenewalWindow = 8 * time.Second // leaseBusyGrace outlasts the database busy_timeout (15s default) so a // renewal attempt waits for the SQLite write lock instead of aborting while // the driver would still have succeeded. @@ -132,7 +128,6 @@ type Service struct { leaseDuration time.Duration leaseHeartbeatInterval time.Duration leaseRenewalRetryInterval time.Duration - leaseRenewalWindow time.Duration concurrency int sessionConcurrency int started bool @@ -192,9 +187,8 @@ func NewStopped(ctx context.Context, options *Options) (*Service, error) { awaitGetFn: options.AgentTasks.Get, awaitPollEvery: awaitPollInterval, leaseHeartbeatInterval: leaseHeartbeatInterval, - leaseRenewalRetryInterval: leaseRenewalRetryInterval, - leaseRenewalWindow: leaseRenewalWindow, mu: sync.Mutex{}, - lifecycle: sync.Mutex{}, started: false, closed: false, + leaseRenewalRetryInterval: leaseRenewalRetryInterval, started: false, closed: false, + mu: sync.Mutex{}, lifecycle: sync.Mutex{}, } if err := service.recoverInterrupted(ctx); err != nil { return nil, err @@ -881,36 +875,41 @@ func (service *Service) renewLease( ticker := time.NewTicker(service.leaseHeartbeatInterval) defer ticker.Stop() + validUntil := time.Now().Add(service.leaseDuration) + for { select { case <-ctx.Done(): return case <-ticker.C: - if !service.renewLeaseWithRetry(ctx, taskID) { - cancel() + if service.renewLeaseWithRetry(ctx, taskID, validUntil) { + validUntil = time.Now().Add(service.leaseDuration) - return + continue } + + cancel() + + return } } } -// renewLeaseWithRetry renews the lease within a bounded window instead of a -// fixed attempt count. SQLite waits up to its busy_timeout for the write lock, -// so each attempt gets min(busy grace, remaining window): a busy database gets -// the full window to succeed while genuine lease loss (renewed=false) still -// fails fast. -func (service *Service) renewLeaseWithRetry(ctx context.Context, taskID string) bool { +// renewLeaseWithRetry renews the lease, retrying transient database errors +// until the lease actually expires instead of an arbitrary retry window: a +// transient outage shorter than the lease must never forfeit it. SQLite waits +// up to its busy_timeout for the write lock, so each attempt gets +// min(busy grace, remaining lease validity) — a busy database keeps its chance +// to succeed while genuine lease loss (renewed=false) still fails fast. +func (service *Service) renewLeaseWithRetry(ctx context.Context, taskID string, deadline time.Time) bool { const busyGrace = leaseBusyGrace - deadline := time.Now().Add(service.leaseRenewalWindow) - for attempt := 1; ; attempt++ { timeout := min(busyGrace, time.Until(deadline)) if timeout <= 0 { - service.logError(ctx, "renew agent task lease after retries", "task_id", taskID, + service.logError(ctx, "renew agent task lease after lease expiry", "task_id", taskID, "lease_owner", service.leaseOwner, "attempts", attempt-1, - "renewal_window", service.leaseRenewalWindow) + "lease_duration", service.leaseDuration) return false } @@ -925,16 +924,16 @@ func (service *Service) renewLeaseWithRetry(ctx context.Context, taskID string) } if !time.Now().Before(deadline) { - service.logError(ctx, "renew agent task lease after retries", "task_id", taskID, + service.logError(ctx, "renew agent task lease after lease expiry", "task_id", taskID, "lease_owner", service.leaseOwner, "attempts", attempt, - "renewal_window", service.leaseRenewalWindow, "error", err) + "lease_duration", service.leaseDuration, "error", err) return false } service.logWarn(ctx, "retry agent task lease renewal", "task_id", taskID, "lease_owner", service.leaseOwner, "attempt", attempt, - "renewal_window", service.leaseRenewalWindow, + "lease_duration", service.leaseDuration, "retry_after", service.leaseRenewalRetryInterval, "error", err) retryDelay := min(service.leaseRenewalRetryInterval, time.Until(deadline)) diff --git a/internal/agenttask/service_internal_test.go b/internal/agenttask/service_internal_test.go index 05cdfb03..9510af8d 100644 --- a/internal/agenttask/service_internal_test.go +++ b/internal/agenttask/service_internal_test.go @@ -38,9 +38,9 @@ func emptyService() *Service { agentTasks: nil, workflows: nil, queue: nil, cancelSources: nil, cancel: nil, done: nil, sessionSlots: nil, tasks: nil, logger: nil, leaseOwner: "", wg: sync.WaitGroup{}, nextSubscriber: 0, timeout: 0, sessionConcurrency: 0, leaseDuration: 0, - leaseHeartbeatInterval: 0, leaseRenewalRetryInterval: 0, leaseRenewalWindow: 0, - awaitGetFn: nil, awaitPollEvery: awaitPollInterval, - mu: sync.Mutex{}, lifecycle: sync.Mutex{}, concurrency: 0, started: false, closed: false, + leaseHeartbeatInterval: 0, leaseRenewalRetryInterval: 0, + awaitGetFn: nil, awaitPollEvery: awaitPollInterval, concurrency: 0, started: false, closed: false, + mu: sync.Mutex{}, lifecycle: sync.Mutex{}, } } @@ -168,7 +168,6 @@ func leaseRenewalService( service.leaseOwner = workerName service.leaseDuration = time.Minute service.leaseRenewalRetryInterval = time.Millisecond - service.leaseRenewalWindow = 50 * time.Millisecond service.logger = slog.New(slog.NewTextHandler(logs, nil)) service.renewLeaseFn = renewLease @@ -1078,13 +1077,13 @@ func TestServiceInternalLeaseRenewalRetriesTransientDatabaseErrors(t *testing.T) return true, nil }) - assert.True(t, service.renewLeaseWithRetry(t.Context(), "task")) + assert.True(t, service.renewLeaseWithRetry(t.Context(), "task", time.Now().Add(time.Minute))) assert.Equal(t, int32(3), attempts.Load()) assert.Contains(t, logs.String(), "retry agent task lease renewal") assert.NotContains(t, logs.String(), "renew agent task lease after retries") } -func TestServiceInternalLeaseRenewalAttemptHonorsWindowOverFixedTimeout(t *testing.T) { +func TestServiceInternalLeaseRenewalAttemptHonorsRemainingLeaseOverFixedTimeout(t *testing.T) { t.Parallel() var deadline time.Time @@ -1095,16 +1094,14 @@ func TestServiceInternalLeaseRenewalAttemptHonorsWindowOverFixedTimeout(t *testi return true, nil }) - // A window beyond the old fixed attempt timeout (2s) must extend the - // attempt deadline: a busy database then waits for the SQLite write lock - // instead of the attempt aborting early. - service.leaseRenewalWindow = 4 * time.Second - - assert.True(t, service.renewLeaseWithRetry(t.Context(), "task")) - assert.GreaterOrEqual(t, time.Until(deadline), 3*time.Second) + // A remaining lease validity beyond the old fixed attempt timeout (2s) + // must extend the attempt deadline: a busy database then waits for the + // SQLite write lock instead of the attempt aborting early. + assert.True(t, service.renewLeaseWithRetry(t.Context(), "task", time.Now().Add(time.Minute))) + assert.GreaterOrEqual(t, time.Until(deadline), leaseBusyGrace-time.Second) } -func TestServiceInternalLeaseRenewalFailsAfterExhaustingWindow(t *testing.T) { +func TestServiceInternalLeaseRenewalFailsOnlyWhenLeaseExpires(t *testing.T) { t.Parallel() var ( @@ -1118,10 +1115,45 @@ func TestServiceInternalLeaseRenewalFailsAfterExhaustingWindow(t *testing.T) { return false, errors.New("database is locked") }) - assert.False(t, service.renewLeaseWithRetry(t.Context(), "task")) + // Renewal keeps retrying for the full lease validity instead of an + // arbitrary retry window, so a transient outage shorter than the lease + // must never forfeit it. + assert.False(t, service.renewLeaseWithRetry( + t.Context(), "task", time.Now().Add(100*time.Millisecond), + )) assert.GreaterOrEqual(t, attempts.Load(), int32(3)) - assert.Contains(t, logs.String(), "renew agent task lease after retries") - assert.Contains(t, logs.String(), "renewal_window") + assert.Contains(t, logs.String(), "renew agent task lease after lease expiry") + assert.Contains(t, logs.String(), "lease_duration") +} + +func TestServiceInternalLeaseRenewalSurvivesTransientOutageShorterThanLease(t *testing.T) { + t.Parallel() + + var canceled atomic.Bool + + startedAt := time.Now() + // The outage outlasts many retry intervals while staying far shorter + // than the lease (time.Minute); renewal must ride it out instead of + // canceling the run, as the old bounded renewal window would have. + const outage = 250 * time.Millisecond + + service := leaseRenewalService(&bytes.Buffer{}, func(context.Context, string, string, time.Time) (bool, error) { + if time.Since(startedAt) < outage { + return false, errors.New("database is locked") + } + + return true, nil + }) + service.leaseHeartbeatInterval = time.Millisecond + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + done := make(chan struct{}) + go service.renewLease(ctx, func() { canceled.Store(true) }, "task", done) + + <-done + assert.False(t, canceled.Load(), "transient renewal outage canceled the run") } func TestServiceInternalLeaseRenewalExhaustionCancelsLongRun(t *testing.T) { @@ -1138,6 +1170,7 @@ func TestServiceInternalLeaseRenewalExhaustionCancelsLongRun(t *testing.T) { return false, errors.New("database is locked") }) service.leaseHeartbeatInterval = time.Millisecond + service.leaseDuration = 10 * time.Millisecond ctx, cancel := context.WithCancel(t.Context()) done := make(chan struct{}) @@ -1145,9 +1178,10 @@ func TestServiceInternalLeaseRenewalExhaustionCancelsLongRun(t *testing.T) { receive(t, ctx.Done(), &struct{}{}, "lease renewal did not cancel the run") receive(t, done, &struct{}{}, "timed out waiting for lease renewal to stop") - assert.GreaterOrEqual(t, attempts.Load(), int32(3)) - assert.Contains(t, logs.String(), "renew agent task lease after retries") - assert.Contains(t, logs.String(), "renewal_window") + assert.GreaterOrEqual(t, attempts.Load(), int32(1), + "renewal should have been attempted at least once before exhaustion") + assert.Contains(t, logs.String(), "renew agent task lease after lease expiry") + assert.Contains(t, logs.String(), "lease_duration") } func TestServiceInternalLeaseRenewsThroughoutLongRun(t *testing.T) { From a3e43c35289260d3b40d0d7370e662df584bc224 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 20:25:59 -0500 Subject: [PATCH 03/30] fix(tool): pin fetch dial address to validated IP and harden redirect handling Close DNS-rebinding TOCTOU gap: resolve and validate the address before dialing, pin the dial address to a validated IP, fail closed on ambiguous or mixed resolutions, preserve Host header and TLS SNI, and keep the post-dial RemoteAddr re-check. --- internal/tool/fetch.go | 114 ++++++++- internal/tool/fetch_internal_test.go | 360 +++++++++++++++++++++++++-- 2 files changed, 451 insertions(+), 23 deletions(-) diff --git a/internal/tool/fetch.go b/internal/tool/fetch.go index f87f915e..1094b22b 100644 --- a/internal/tool/fetch.go +++ b/internal/tool/fetch.go @@ -298,6 +298,9 @@ func (fetchTool *FetchTool) fetchURL( return body, info, nil } +// httpClient returns the HTTP client used for fetch requests. +// The fallback guards the zero-value FetchTool (client is nil only when the +// struct is built as &FetchTool{} instead of via NewFetchTool). func (fetchTool *FetchTool) httpClient() *http.Client { if fetchTool.client != nil { return fetchTool.client @@ -354,7 +357,7 @@ func (fetchTool *FetchTool) transportWithNetworkValidation( transport.Proxy = nil - if err := wrapFetchTransportDialHooks(transport); err != nil { + if err := fetchTool.wrapFetchTransportDialHooks(transport); err != nil { return nil, nil, err } @@ -374,17 +377,17 @@ func cloneFetchHTTPTransport(baseTransport http.RoundTripper) (*http.Transport, return transport.Clone(), true } -func wrapFetchTransportDialHooks(transport *http.Transport) error { +func (fetchTool *FetchTool) wrapFetchTransportDialHooks(transport *http.Transport) error { if hasFetchDeprecatedDialHook(transport, "Dial") || hasFetchDeprecatedDialHook(transport, "DialTLS") { return oops.In("tool").Code("fetch_legacy_dial_hook").Errorf( "fetch transport uses deprecated legacy dial hooks that cannot be safely validated", ) } - transport.DialContext = validatingFetchDialContext(fetchDialContext(transport)) + transport.DialContext = fetchTool.validatingFetchDialContext(fetchDialContext(transport)) if transport.DialTLSContext != nil { - transport.DialTLSContext = validatingFetchDialContext(transport.DialTLSContext) + transport.DialTLSContext = fetchTool.validatingFetchDialContext(transport.DialTLSContext) } return nil @@ -406,11 +409,108 @@ func hasFetchDeprecatedDialHook(transport *http.Transport, name string) bool { return field.IsValid() && !field.IsNil() } -func validatingFetchDialContext( +func (fetchTool *FetchTool) validatingFetchDialContext( dialContext func(context.Context, string, string) (net.Conn, error), ) func(context.Context, string, string) (net.Conn, error) { return func(ctx context.Context, network, address string) (net.Conn, error) { - return validateFetchDialedConnection(dialContext(ctx, network, address)) + pinnedAddress, err := fetchTool.pinnedFetchDialAddress(ctx, network, address) + if err != nil { + return nil, err + } + + // Defense in depth: even a custom dialer that ignores the pinned + // address is rejected when it reports an unvalidated remote address. + return validateFetchDialedConnection(dialContext(ctx, network, pinnedAddress)) + } +} + +// pinnedFetchDialAddress resolves the dial address once, validates every +// returned IP address, and rewrites the address to a validated IP so the +// connection cannot be rebound to a different address between validation and +// dial. The port is preserved, and because only the dialed address changes, +// the request Host header and TLS server name still come from the original +// hostname, keeping virtual hosting and certificate verification intact. +func (fetchTool *FetchTool) pinnedFetchDialAddress( + ctx context.Context, + network, address string, +) (string, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return "", oops.In("tool").Code("fetch_dial_address").Wrapf(err, "parse fetch dial address") + } + + normalizedHost := normalizedFetchHost(host) + if normalizedHost == "" { + return "", oops.In("tool").Code("fetch_missing_host").Errorf("fetch url host is required") + } + + if isLocalhostFetchHost(normalizedHost) { + return "", privateFetchNetworkError() + } + + if ipAddress := parseFetchHostIP(normalizedHost); ipAddress != nil { + return pinnedLiteralFetchAddress(ipAddress, port) + } + + addrs, err := fetchTool.lookupFetchIPAddrs(ctx, normalizedHost) + if err != nil { + return "", err + } + + return pinnedResolvedFetchAddress(addrs, network, port) +} + +// pinnedLiteralFetchAddress validates a literal host IP and pins the dial to it. +func pinnedLiteralFetchAddress(ipAddress net.IP, port string) (string, error) { + if err := validatePublicFetchIP(ipAddress); err != nil { + return "", err + } + + return net.JoinHostPort(ipAddress.String(), port), nil +} + +// pinnedResolvedFetchAddress validates every resolved address (failing closed on +// any private or ambiguous result) and pins the dial to one matching the network. +func pinnedResolvedFetchAddress(addrs []net.IPAddr, network, port string) (string, error) { + if len(addrs) == 0 { + return "", oops.In("tool").Code("fetch_resolve_host").Errorf("resolve fetch url host returned no addresses") + } + + for _, addr := range addrs { + if err := validatePublicFetchIP(addr.IP); err != nil { + return "", err + } + } + + pinnedIP := fetchDialIPForNetwork(addrs, network) + if pinnedIP == nil { + return "", oops.In("tool"). + Code("fetch_no_validated_address"). + With("network", network). + Errorf("no validated fetch address matches network") + } + + return net.JoinHostPort(pinnedIP.String(), port), nil +} + +func fetchDialIPForNetwork(addrs []net.IPAddr, network string) net.IP { + for _, addr := range addrs { + if fetchIPMatchesNetwork(addr.IP, network) { + return addr.IP + } + } + + return nil +} + +func fetchIPMatchesNetwork(ipAddress net.IP, network string) bool { + switch network { + case "tcp4", "udp4", "ip4": + return ipAddress.To4() != nil + case "tcp6", "udp6", "ip6": + return ipAddress.To4() == nil + default: + return true } } @@ -851,7 +951,7 @@ func truncateFetchedContent(content string) TruncationResult { truncation.Content = validUTF8Prefix(content, truncation.MaxBytes) truncation.OutputLines = 1 - truncation.OutputBytes = len([]byte(truncation.Content)) + truncation.OutputBytes = len(truncation.Content) truncation.LastLinePartial = true return truncation diff --git a/internal/tool/fetch_internal_test.go b/internal/tool/fetch_internal_test.go index 72cc2418..2df24eae 100644 --- a/internal/tool/fetch_internal_test.go +++ b/internal/tool/fetch_internal_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "reflect" "strings" "testing" "time" @@ -21,14 +22,18 @@ import ( ) const ( - fetchTestExampleHost = "example.test" - fetchTestExampleURL = "https://example.com" - fetchTestIgnoredFooter = "Ignore footer" - fetchTestIgnoredHeader = "Ignore header" - fetchTestPlainText = "plain text" - fetchTestTextPlain = "text/plain" - fetchTestInvalidLimit = "invalid limit" - serverURLPlaceholder = "{server_url}" + fetchTestExampleHost = "example.test" + fetchTestNetworkTCP = "tcp" + fetchTestNetworkTCP6 = "tcp6" + fetchTestWantNoAddrErr = "returned no addresses" + fetchTestWantPrivateErr = "private or local networks" + fetchTestExampleURL = "https://example.com" + fetchTestIgnoredFooter = "Ignore footer" + fetchTestIgnoredHeader = "Ignore header" + fetchTestPlainText = "plain text" + fetchTestTextPlain = "text/plain" + fetchTestInvalidLimit = "invalid limit" + serverURLPlaceholder = "{server_url}" ) func TestFetchTool_Definition(t *testing.T) { @@ -416,6 +421,294 @@ func TestFetchTool_ReadLimitDetails(t *testing.T) { assert.True(t, fetchDetailBoolForTest(t, result, "truncated")) } +type fetchTestDialPinCase struct { + wantDialedIP string + wantErr string + name string + lookups [][]net.IPAddr +} + +func fetchTestDialPinCases() []fetchTestDialPinCase { + return []fetchTestDialPinCase{ + { + name: "public host dials pinned validated ip", + lookups: [][]net.IPAddr{{{IP: net.ParseIP("93.184.216.34")}}}, + wantErr: "", + wantDialedIP: "93.184.216.34", + }, + { + name: "resolver rebinding to loopback is rejected at dial time", + lookups: [][]net.IPAddr{ + {{IP: net.ParseIP("93.184.216.34")}}, + {{IP: net.ParseIP("127.0.0.1")}}, + }, + wantErr: fetchTestWantPrivateErr, + wantDialedIP: "", + }, + { + name: "resolver rebinding to private network is rejected at dial time", + lookups: [][]net.IPAddr{ + {{IP: net.ParseIP("93.184.216.34")}}, + {{IP: net.ParseIP("192.168.0.5")}}, + }, + wantErr: fetchTestWantPrivateErr, + wantDialedIP: "", + }, + { + name: "resolver rebinding to link local metadata address is rejected", + lookups: [][]net.IPAddr{ + {{IP: net.ParseIP("93.184.216.34")}}, + {{IP: net.ParseIP("169.254.169.254")}}, + }, + wantErr: fetchTestWantPrivateErr, + wantDialedIP: "", + }, + { + name: "ambiguous empty re-resolution fails closed", + lookups: [][]net.IPAddr{ + {{IP: net.ParseIP("93.184.216.34")}}, + {}, + }, + wantErr: fetchTestWantNoAddrErr, + wantDialedIP: "", + }, + } +} + +func TestFetchTool_PinsDialedAddressToValidatedIP(t *testing.T) { + t.Parallel() + + for _, testCase := range fetchTestDialPinCases() { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + lookupCalls := 0 + fetchTool, dialedAddresses := fetchTestRecordingTransport() + fetchTool.lookupIPAddrs = func(_ context.Context, _ string) ([]net.IPAddr, error) { + defer func() { lookupCalls++ }() + + return testCase.lookups[min(lookupCalls, len(testCase.lookups)-1)], nil + } + + requestURL, err := parseFetchURL("http://" + fetchTestExampleHost) + require.NoError(t, err) + + // Consume the pre-flight validation lookup the same way the real + // request path does, so the dial below observes the second resolution. + require.NoError(t, fetchTool.validatePublicFetchURL(context.Background(), requestURL)) + + transport, closeIdleConnections, err := fetchTool.transportWithNetworkValidation(fetchTool.client.Transport) + require.NoError(t, err) + + defer closeIdleConnections() + + httpTransport, ok := transport.(*http.Transport) + require.True(t, ok) + + conn, dialErr := httpTransport.DialContext(context.Background(), "tcp", fetchTestExampleHost+":80") + + if testCase.wantErr != "" { + require.Error(t, dialErr) + assert.Contains(t, dialErr.Error(), testCase.wantErr) + assert.Empty(t, *dialedAddresses, "no connection should be attempted after validation fails") + + return + } + + require.NoError(t, dialErr) + require.NoError(t, conn.Close()) + require.Len(t, *dialedAddresses, 1) + assert.Equal(t, testCase.wantDialedIP+":80", (*dialedAddresses)[0]) + }) + } +} + +// fetchTestRecordingTransport builds a fetch tool whose transport records every +// dialed address and rejects hostnames: the base dialer must only ever receive a +// validated literal IP, since a hostname would let the OS resolver rebind it. +func fetchTestRecordingTransport() (fetchTool *FetchTool, dialed *[]string) { + fetchTool = NewFetchTool() + + var dialedAddresses []string + + fetchTool.client = &http.Client{Transport: &http.Transport{ + DialContext: func(_ context.Context, _, address string) (net.Conn, error) { + dialedAddresses = append(dialedAddresses, address) + + host, _, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("split fetch dial address: %w", err) + } + + ipAddress := parseFetchHostIP(host) + if ipAddress == nil { + return nil, fmt.Errorf("dialer received a hostname instead of a pinned IP: %s", host) + } + + if err := validatePublicFetchIP(ipAddress); err != nil { + return nil, fmt.Errorf("validate pinned dial address: %w", err) + } + + remoteAddr, resolveErr := net.ResolveTCPAddr("tcp", address) + if resolveErr != nil { + return nil, fmt.Errorf("resolve pinned dial address: %w", resolveErr) + } + + return fetchTestConn{remoteAddr: remoteAddr}, nil + }, + }} + + return fetchTool, &dialedAddresses +} + +type fetchTestPinnedDialCase struct { + lookups map[string][]net.IPAddr + name string + network string + address string + wantErr string + wantPin string +} + +func fetchTestPinnedDialAddressCases() []fetchTestPinnedDialCase { + return append(fetchTestLiteralDialCases(), fetchTestResolvedDialCases()...) +} + +func fetchTestLiteralDialCases() []fetchTestPinnedDialCase { + return []fetchTestPinnedDialCase{ + { + name: "public literal ipv4 is pinned", + network: fetchTestNetworkTCP, + address: "93.184.216.34:443", + lookups: nil, + wantErr: "", + wantPin: "93.184.216.34:443", + }, + { + name: "public literal ipv6 is pinned with brackets", + network: fetchTestNetworkTCP6, + address: "[2606:2800:220:1:248:1893:25c8:1946]:443", + lookups: nil, + wantErr: "", + wantPin: "[2606:2800:220:1:248:1893:25c8:1946]:443", + }, + { + name: "private literal ipv4 is rejected", + network: fetchTestNetworkTCP, + address: "10.1.2.3:80", + lookups: nil, + wantErr: fetchTestWantPrivateErr, + wantPin: "", + }, + { + name: "link local literal ipv6 is rejected", + network: fetchTestNetworkTCP6, + address: "[fe80::1]:80", + lookups: nil, + wantErr: fetchTestWantPrivateErr, + wantPin: "", + }, + { + name: "localhost host is rejected", + network: fetchTestNetworkTCP, + address: "localhost:80", + lookups: nil, + wantErr: fetchTestWantPrivateErr, + wantPin: "", + }, + { + name: "missing port is rejected", + network: fetchTestNetworkTCP, + address: fetchTestExampleHost, + lookups: nil, + wantErr: "parse fetch dial address", + wantPin: "", + }, + } +} + +func fetchTestResolvedDialCases() []fetchTestPinnedDialCase { + const fetchTestExampleHostPort = fetchTestExampleHost + ":80" + + return []fetchTestPinnedDialCase{ + { + name: "hostname without validated addresses fails closed", + network: fetchTestNetworkTCP, + address: fetchTestExampleHostPort, + lookups: map[string][]net.IPAddr{fetchTestExampleHost: {}}, + wantErr: fetchTestWantNoAddrErr, + wantPin: "", + }, + { + name: "ipv4 network with only ipv6 results fails closed", + network: "tcp4", + address: fetchTestExampleHostPort, + lookups: map[string][]net.IPAddr{ + fetchTestExampleHost: {{IP: net.ParseIP("2606:2800:220:1:248:1893:25c8:1946")}}, + }, + wantErr: "no validated fetch address matches network", + wantPin: "", + }, + { + name: "hostname resolves to pinned public ipv6", + network: fetchTestNetworkTCP6, + address: fetchTestExampleHostPort, + lookups: map[string][]net.IPAddr{ + fetchTestExampleHost: { + {IP: net.ParseIP("93.184.216.34")}, + {IP: net.ParseIP("2606:2800:220:1:248:1893:25c8:1946")}, + }, + }, + wantErr: "", + wantPin: "[2606:2800:220:1:248:1893:25c8:1946]:80", + }, + { + name: "hostname with mixed public and private results is rejected", + network: fetchTestNetworkTCP, + address: fetchTestExampleHostPort, + lookups: map[string][]net.IPAddr{ + fetchTestExampleHost: {{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("10.0.0.1")}}, + }, + wantErr: fetchTestWantPrivateErr, + wantPin: "", + }, + { + name: "trailing dot hostname is normalized", + network: fetchTestNetworkTCP, + address: "Example.Test.:80", + lookups: map[string][]net.IPAddr{ + fetchTestExampleHost: {{IP: net.ParseIP("93.184.216.34")}}, + }, + wantErr: "", + wantPin: "93.184.216.34:80", + }, + } +} + +func TestFetchTool_PinnedDialAddress(t *testing.T) { + t.Parallel() + + for _, testCase := range fetchTestPinnedDialAddressCases() { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + fetchTool := fetchTestLookupTool(testCase.lookups) + + pinned, err := fetchTool.pinnedFetchDialAddress(context.Background(), testCase.network, testCase.address) + + if testCase.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), testCase.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, testCase.wantPin, pinned) + }) + } +} + func TestFetchTool_RejectsPrivateNetworkTargets(t *testing.T) { t.Parallel() @@ -852,25 +1145,45 @@ func TestFetchTool_RedirectValidation(t *testing.T) { } } +// fetchTestLegacyDialTransport sets a deprecated legacy Dial hook via reflection, +// matching how the production guard detects it without tripping SA1019. +func fetchTestLegacyDialTransport() *http.Transport { + transport := &http.Transport{} + reflect.ValueOf(transport).Elem().FieldByName("Dial").Set( + reflect.ValueOf(legacyFetchDial), + ) + + return transport +} + +func fetchTestLegacyDialTLSTransport() *http.Transport { + transport := &http.Transport{} + reflect.ValueOf(transport).Elem().FieldByName("DialTLS").Set( + reflect.ValueOf(legacyFetchDial), + ) + + return transport +} + +func legacyFetchDial(string, string) (net.Conn, error) { + return nil, errors.New("legacy dial should not run") +} + func TestFetchTool_RejectsLegacyTransportDialHooks(t *testing.T) { t.Parallel() - legacyDial := func(string, string) (net.Conn, error) { - return nil, errors.New("legacy dial should not run") - } - tests := []struct { - transport *http.Transport + transport func() *http.Transport name string }{ - {name: "legacy dial", transport: &http.Transport{Dial: legacyDial}}, - {name: "legacy dial tls", transport: &http.Transport{DialTLS: legacyDial}}, + {name: "legacy dial", transport: fetchTestLegacyDialTransport}, + {name: "legacy dial tls", transport: fetchTestLegacyDialTLSTransport}, } for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { t.Parallel() - _, _, err := NewFetchTool().transportWithNetworkValidation(testCase.transport) + _, _, err := NewFetchTool().transportWithNetworkValidation(testCase.transport()) require.Error(t, err) assert.Contains(t, err.Error(), "deprecated legacy dial hooks") @@ -1203,3 +1516,18 @@ type roundTripFunc func(*http.Request) (*http.Response, error) func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return roundTrip(request) } + +// fetchTestConn is a stub net.Conn used to observe the address that the +// validated dial hands to the base dialer without opening a real connection. +type fetchTestConn struct { + remoteAddr net.Addr +} + +func (conn fetchTestConn) Read([]byte) (int, error) { return 0, io.EOF } +func (conn fetchTestConn) Write(data []byte) (int, error) { return len(data), nil } +func (conn fetchTestConn) Close() error { return nil } +func (conn fetchTestConn) LocalAddr() net.Addr { return conn.remoteAddr } +func (conn fetchTestConn) RemoteAddr() net.Addr { return conn.remoteAddr } +func (conn fetchTestConn) SetDeadline(time.Time) error { return nil } +func (conn fetchTestConn) SetReadDeadline(time.Time) error { return nil } +func (conn fetchTestConn) SetWriteDeadline(time.Time) error { return nil } From 1d727b11004d484a232aa539a58ab45bc536790a Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 20:26:11 -0500 Subject: [PATCH 04/30] fix(tool): hoist default ignore patterns to immutable package-level list Stop reallocating defaultReadIgnorePatterns() on every call; parse the immutable list once at init (with narrow gochecknoglobals exclusion) and add tests for pattern resolution. --- internal/tool/ignore.go | 51 +++++---- internal/tool/ignore_internal_test.go | 145 ++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 23 deletions(-) create mode 100644 internal/tool/ignore_internal_test.go diff --git a/internal/tool/ignore.go b/internal/tool/ignore.go index b0d5c4d9..2bd5f271 100644 --- a/internal/tool/ignore.go +++ b/internal/tool/ignore.go @@ -16,6 +16,17 @@ import ( const ( gitignoreFileName = ".gitignore" readIgnoreCacheCapacity = 16 + + gitDirName = ".git" + + defaultIgnoreGitDir = ".git/" + defaultIgnoreNodeModules = "node_modules/" + defaultIgnoreEnv = ".env" + defaultIgnoreGoCache = ".gocache/" + defaultIgnoreGoModCache = ".gomodcache/" + defaultIgnoreTmp = ".tmp/" + defaultIgnoreBin = "bin/" + defaultIgnoreAgentSkills = "/skills/" ) type ignorePattern struct { @@ -24,8 +35,7 @@ type ignorePattern struct { } type readIgnoreCache struct { - patterns *hot.HotCache[string, repositoryIgnorePatterns] - defaultPatterns []ignorePattern + patterns *hot.HotCache[string, repositoryIgnorePatterns] } type repositoryIgnorePatterns struct { @@ -46,9 +56,13 @@ type ignorePathState struct { dir bool } +// defaultReadIgnorePatterns holds the default read-ignore patterns, parsed once at package +// initialization. The list and its patterns are immutable and shared across caches; callers +// must never mutate them. +var defaultReadIgnorePatterns = newDefaultReadIgnorePatterns() + func newReadIgnoreCache() *readIgnoreCache { return &readIgnoreCache{ - defaultPatterns: newDefaultReadIgnorePatterns(), patterns: hot.NewHotCache[string, repositoryIgnorePatterns](hot.WTinyLFU, readIgnoreCacheCapacity). WithLoaders(func(workspaceRoots []string) (map[string]repositoryIgnorePatterns, error) { patterns := make(map[string]repositoryIgnorePatterns, len(workspaceRoots)) @@ -118,10 +132,9 @@ func pathEscapesRoot(relativePath string) bool { } func readIgnorePatterns(workspaceRoot string, cache *readIgnoreCache) []ignorePattern { - defaults := readDefaultIgnorePatterns(cache) repositoryPatterns := cache.repositoryPatterns(workspaceRoot) - patterns := make([]ignorePattern, 0, len(defaults)+len(repositoryPatterns)) - patterns = append(patterns, defaults...) + patterns := make([]ignorePattern, 0, len(defaultReadIgnorePatterns)+len(repositoryPatterns)) + patterns = append(patterns, defaultReadIgnorePatterns...) for _, pattern := range repositoryPatterns { patterns = append(patterns, ignorePattern{ @@ -133,24 +146,16 @@ func readIgnorePatterns(workspaceRoot string, cache *readIgnoreCache) []ignorePa return patterns } -func readDefaultIgnorePatterns(cache *readIgnoreCache) []ignorePattern { - if cache != nil { - return cache.defaultPatterns - } - - return newDefaultReadIgnorePatterns() -} - func newDefaultReadIgnorePatterns() []ignorePattern { patternSources := [...]string{ - ".git/", - "node_modules/", - ".env", - ".gocache/", - ".gomodcache/", - ".tmp/", - "bin/", - "/skills/", + defaultIgnoreGitDir, + defaultIgnoreNodeModules, + defaultIgnoreEnv, + defaultIgnoreGoCache, + defaultIgnoreGoModCache, + defaultIgnoreTmp, + defaultIgnoreBin, + defaultIgnoreAgentSkills, } patterns := make([]ignorePattern, 0, len(patternSources)) @@ -244,7 +249,7 @@ func ignoreSignatureWalkError(entry os.DirEntry) error { func (collector *ignoreSignatureCollector) visitDirectory(path, name string) error { collector.append(path) - if name == ".git" { + if name == gitDirName { return filepath.SkipDir } diff --git a/internal/tool/ignore_internal_test.go b/internal/tool/ignore_internal_test.go new file mode 100644 index 00000000..474465da --- /dev/null +++ b/internal/tool/ignore_internal_test.go @@ -0,0 +1,145 @@ +package tool + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/go-git/go-git/v5/plumbing/format/gitignore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadIgnorePatternsAppliesDefaultsWithoutUserConfig(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + writeIgnoreTestFile(t, filepath.Join(workspace, "readme.md"), "content") + + patterns := readIgnorePatterns(workspace, nil) + + expectedSources := []string{ + ".git/", + "node_modules/", + defaultIgnoreEnv, + ".gocache/", + ".gomodcache/", + ".tmp/", + "bin/", + "/skills/", + } + + actualSources := make([]string, 0, len(patterns)) + for _, pattern := range patterns { + actualSources = append(actualSources, pattern.source) + } + + assert.Equal(t, expectedSources, actualSources) + assert.False(t, slices.IsSorted(actualSources) && len(actualSources) != len(expectedSources)) +} + +func TestReadIgnorePatternsReturnsSharedDefaultsWithoutMutatingThem(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + writeIgnoreTestFile(t, filepath.Join(workspace, ".gitignore"), "*.log\n") + + before := slices.Clone(defaultReadIgnorePatterns) + patterns := readIgnorePatterns(workspace, nil) + require.NotEmpty(t, patterns) + patterns[0].source = "mutated" + + assert.Equal(t, before, defaultReadIgnorePatterns) +} + +func TestIgnoredReadPathReasonsMatchDefaultAndCustomPatterns(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + writeIgnoreTestFile(t, filepath.Join(workspace, ".gitignore"), "*.log\n!important.log\n") + writeIgnoreTestFile(t, filepath.Join(workspace, defaultIgnoreEnv), "SECRET=value") + + tests := []struct { + name string + relative string + wantReason string + wantIgnore bool + }{ + {name: "default pattern reports its source", relative: defaultIgnoreEnv, wantIgnore: true, + wantReason: defaultIgnoreEnv}, + { + name: "repository pattern reports gitignore source", + relative: "debug.log", + wantIgnore: true, + wantReason: ".gitignore", + }, + {name: "negated repository pattern allows file", relative: "important.log", wantIgnore: false, wantReason: ""}, + {name: "non-ignored file is allowed", relative: "readme.md", wantIgnore: false, wantReason: ""}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + absolutePath := filepath.Join(workspace, filepath.FromSlash(testCase.relative)) + writeIgnoreTestFile(t, absolutePath, "content") + + ignored, reason := ignoredReadPath(absolutePath, workspace, nil) + + assert.Equal(t, testCase.wantIgnore, ignored) + assert.Equal(t, testCase.wantReason, reason) + }) + } +} + +func TestDefaultReadIgnorePatternsMatchingMatchesFreshlyParsedPatterns(t *testing.T) { + t.Parallel() + + expectedSources := []string{ + ".git/", + "node_modules/", + defaultIgnoreEnv, + ".gocache/", + ".gomodcache/", + ".tmp/", + "bin/", + "/skills/", + } + + paths := [][]string{ + {gitDirName, "config"}, + {"node_modules", "pkg", "index.js"}, + {defaultIgnoreEnv}, + {"nested", defaultIgnoreEnv}, + {".gocache", "cache"}, + {".gomodcache", "pkg"}, + {".tmp", "scratch"}, + {"bin", "librecode"}, + {"skills", "skill.md"}, + {".agents", "skills", "skill.md"}, + {"cmd", "main.go"}, + } + + fresh := make([]ignorePattern, 0, len(expectedSources)) + for _, source := range expectedSources { + fresh = append(fresh, ignorePattern{pattern: gitignore.ParsePattern(source, nil), source: source}) + } + + for _, pathParts := range paths { + isDir := filepath.Ext(pathParts[len(pathParts)-1]) == "" + assert.Equal( + t, + matchingIgnoreReason(fresh, pathParts, isDir), + matchingIgnoreReason(defaultReadIgnorePatterns, pathParts, isDir), + "path %v", pathParts, + ) + } +} + +func writeIgnoreTestFile(t *testing.T, path, content string) { + t.Helper() + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} From ea347d13dbc54fc1a9fc3f4375900d1ea4dd12cc Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 20:26:22 -0500 Subject: [PATCH 05/30] fix(tool): avoid byte-slice conversions and age out bash output logs - Replace len([]byte(s)) and bytes.LastIndexByte([]byte(s), ...) with zero-conversion equivalents across truncate, bash, read, fetch - Add retention sweep so ~/.cache/librecode/bash-output/ files older than 7 days are cleaned up instead of accumulating forever --- internal/tool/bash.go | 60 ++++++++++++++++++++-- internal/tool/bash_output_internal_test.go | 56 ++++++++++++++++++++ internal/tool/find.go | 4 +- internal/tool/read.go | 2 +- internal/tool/tool_constants.go | 2 + internal/tool/truncate.go | 18 +++---- internal/tool/truncate_internal_test.go | 29 +++++++++++ 7 files changed, 154 insertions(+), 17 deletions(-) diff --git a/internal/tool/bash.go b/internal/tool/bash.go index 8eff8514..c25f198a 100644 --- a/internal/tool/bash.go +++ b/internal/tool/bash.go @@ -1,10 +1,10 @@ package tool import ( - "bytes" "context" "errors" "fmt" + "log/slog" "os" "os/exec" "path/filepath" @@ -313,7 +313,7 @@ func writeFullBashOutput(output []byte) (string, error) { return "", err } - file, err := os.CreateTemp(outputDir, "librecode-bash-*.log") + file, err := os.CreateTemp(outputDir, fullBashOutputPrefix+"*.log") if err != nil { return "", bashOutputFSError(err, "create full bash output file") } @@ -334,6 +334,8 @@ func writeFullBashOutput(output []byte) (string, error) { return "", errors.Join(bashOutputFSError(err, "close full bash output"), cleanupErr) } + cleanupStaleBashOutputs(outputDir, outputPath, time.Now()) + return outputPath, nil } @@ -351,6 +353,54 @@ func fullBashOutputDir() (string, error) { return outputDir, nil } +// cleanupStaleBashOutputs removes full bash output logs older than the retention +// threshold. Cleanup is opportunistic: the freshly written output is always kept +// and any failure is logged instead of failing the tool call that triggered it. +func cleanupStaleBashOutputs(outputDir, keepPath string, now time.Time) { + entries, err := os.ReadDir(outputDir) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + slog.Debug("read full bash output dir for cleanup", "dir", outputDir, "error", err) + } + + return + } + + cutoff := now.Add(-fullBashOutputRetention) + + for _, entry := range entries { + removeStaleBashOutput(entry, outputDir, keepPath, cutoff) + } +} + +// removeStaleBashOutput removes a single stale full-output log unless it is the +// freshly written file or newer than the retention cutoff. +func removeStaleBashOutput(entry os.DirEntry, outputDir, keepPath string, cutoff time.Time) { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), fullBashOutputPrefix) { + return + } + + outputPath := filepath.Join(outputDir, entry.Name()) + if keepPath != "" && outputPath == keepPath { + return + } + + info, err := entry.Info() + if err != nil { + slog.Debug("stat full bash output for cleanup", "path", outputPath, "error", err) + + return + } + + if info.ModTime().After(cutoff) { + return + } + + if err := os.Remove(outputPath); err != nil { + slog.Debug("remove stale full bash output", "path", outputPath, "error", err) + } +} + func bashOutputFSError(err error, message string) error { return oops.In("tool.bash").Code("bash-output-fs").Wrapf(err, "%s", message) } @@ -364,12 +414,12 @@ func bashOutputCleanupError(err error, message string) error { } func lastLineByteCount(text string) int { - lastNewline := bytes.LastIndexByte([]byte(text), '\n') + lastNewline := strings.LastIndexByte(text, '\n') if lastNewline == -1 { - return len([]byte(text)) + return len(text) } - return len([]byte(text[lastNewline+1:])) + return len(text[lastNewline+1:]) } func appendStatus(text, status string) string { diff --git a/internal/tool/bash_output_internal_test.go b/internal/tool/bash_output_internal_test.go index f1ab79cd..ddb39451 100644 --- a/internal/tool/bash_output_internal_test.go +++ b/internal/tool/bash_output_internal_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -53,3 +54,58 @@ func TestBashOutputFormattingHelpers(t *testing.T) { assert.Equal(t, "status", appendStatus("", "status")) assert.Equal(t, "output\n\nstatus", appendStatus("output", "status")) } + +func TestCleanupStaleBashOutputsRemovesOldLogsOnly(t *testing.T) { + t.Parallel() + + outputDir := t.TempDir() + now := time.Now() + + oldLog := writeBashOutputLogFile(t, outputDir, "librecode-bash-old.log", now.Add(-8*24*time.Hour)) + edgeLog := writeBashOutputLogFile(t, outputDir, "librecode-bash-edge.log", now.Add(-fullBashOutputRetention)) + freshLog := writeBashOutputLogFile(t, outputDir, "librecode-bash-fresh.log", now.Add(-time.Hour)) + keepLog := writeBashOutputLogFile(t, outputDir, "librecode-bash-keep.log", now.Add(-8*24*time.Hour)) + unrelatedFile := writeBashOutputLogFile(t, outputDir, "keep-me.txt", now.Add(-30*24*time.Hour)) + require.NoError(t, os.Mkdir(filepath.Join(outputDir, "librecode-bash-dir"), secureDirMode)) + + cleanupStaleBashOutputs(outputDir, keepLog, now) + + assert.NoFileExists(t, oldLog) + assert.NoFileExists(t, edgeLog) + assert.FileExists(t, freshLog) + assert.FileExists(t, keepLog) + assert.FileExists(t, unrelatedFile) + assert.DirExists(t, filepath.Join(outputDir, "librecode-bash-dir")) +} + +func TestCleanupStaleBashOutputsToleratesMissingDir(t *testing.T) { + t.Parallel() + + cleanupStaleBashOutputs(filepath.Join(t.TempDir(), "does-not-exist"), "", time.Now()) +} + +func TestWriteFullBashOutputCleansStaleLogs(t *testing.T) { + cacheDir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheDir) + + outputDir := filepath.Join(cacheDir, "librecode", "bash-output") + require.NoError(t, os.MkdirAll(outputDir, secureDirMode)) + + stale := writeBashOutputLogFile(t, outputDir, "librecode-bash-stale.log", time.Now().Add(-8*24*time.Hour)) + + outputPath, err := writeFullBashOutput([]byte("fresh output")) + require.NoError(t, err) + + assert.NoFileExists(t, stale) + assert.FileExists(t, outputPath) +} + +func writeBashOutputLogFile(t *testing.T, dir, name string, modTime time.Time) string { + t.Helper() + + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(name), privateFileMode)) + require.NoError(t, os.Chtimes(path, modTime, modTime)) + + return path +} diff --git a/internal/tool/find.go b/internal/tool/find.go index 58ae127e..03c1142e 100644 --- a/internal/tool/find.go +++ b/internal/tool/find.go @@ -19,7 +19,7 @@ import ( const defaultFindLimit = 1000 func ignoredSearchDirs() []string { - return []string{".git", "node_modules"} + return []string{gitDirName, "node_modules"} } // FindInput contains arguments for the find tool. @@ -143,8 +143,8 @@ func collectFindResults(ctx context.Context, searchRoot, pattern string, limit i matcher: matcher, results: []string{}, searchRoot: searchRoot, - lock: sync.Mutex{}, limit: limit, + lock: sync.Mutex{}, } visitor := func(currentPath string, dirEntry fs.DirEntry, walkErr error) error { diff --git a/internal/tool/read.go b/internal/tool/read.go index 077735ec..5e05e687 100644 --- a/internal/tool/read.go +++ b/internal/tool/read.go @@ -192,7 +192,7 @@ func formatReadOutput( ) (output string, details map[string]any) { startLineDisplay := startLine + 1 if truncation.FirstLineExceedsLimit { - firstLineSize := FormatSize(len([]byte(allLines[startLine]))) + firstLineSize := FormatSize(len(allLines[startLine])) return fmt.Sprintf( "[Line %d is %s, exceeds %s limit. Use bash: sed -n '%dp' %s | head -c %d]", diff --git a/internal/tool/tool_constants.go b/internal/tool/tool_constants.go index 82ad7543..bd3cb77c 100644 --- a/internal/tool/tool_constants.go +++ b/internal/tool/tool_constants.go @@ -7,6 +7,8 @@ import ( const ( commandWaitDelay = 2 * time.Second + fullBashOutputPrefix = "librecode-bash-" + fullBashOutputRetention = 7 * 24 * time.Hour limitSuggestionMultiplier = 2 maxTruncationLines = 200 privateDirMode = 0o700 diff --git a/internal/tool/truncate.go b/internal/tool/truncate.go index ab8d4d49..e2402b54 100644 --- a/internal/tool/truncate.go +++ b/internal/tool/truncate.go @@ -60,12 +60,12 @@ func TruncateHead(content string, options TruncationOptions) TruncationResult { limits := normalizeTruncationOptions(options) lines := strings.Split(content, "\n") - totalBytes := len([]byte(content)) + totalBytes := len(content) if len(lines) <= limits.MaxLines && totalBytes <= limits.MaxBytes { return newTruncationResult(content, false, TruncatedByNone, false, false, lines, limits) } - if len([]byte(lines[0])) > limits.MaxBytes { + if len(lines[0]) > limits.MaxBytes { return TruncationResult{ Truncated: true, LastLinePartial: false, @@ -92,7 +92,7 @@ func TruncateHead(content string, options TruncationOptions) TruncationResult { TotalLines: len(lines), TotalBytes: totalBytes, OutputLines: len(outputLines), - OutputBytes: len([]byte(outputContent)), + OutputBytes: len(outputContent), MaxLines: limits.MaxLines, MaxBytes: limits.MaxBytes, Content: outputContent, @@ -104,7 +104,7 @@ func TruncateTail(content string, options TruncationOptions) TruncationResult { limits := normalizeTruncationOptions(options) lines := strings.Split(content, "\n") - totalBytes := len([]byte(content)) + totalBytes := len(content) if len(lines) <= limits.MaxLines && totalBytes <= limits.MaxBytes { return newTruncationResult(content, false, TruncatedByNone, false, false, lines, limits) } @@ -120,7 +120,7 @@ func TruncateTail(content string, options TruncationOptions) TruncationResult { TotalLines: len(lines), TotalBytes: totalBytes, OutputLines: len(outputLines), - OutputBytes: len([]byte(outputContent)), + OutputBytes: len(outputContent), MaxLines: limits.MaxLines, MaxBytes: limits.MaxBytes, Content: outputContent, @@ -160,7 +160,7 @@ func collectHeadLines(lines []string, limits TruncationOptions) ([]string, Trunc truncatedBy := TruncatedByLines for lineIndex := 0; lineIndex < len(lines) && lineIndex < limits.MaxLines; lineIndex++ { - lineBytes := len([]byte(lines[lineIndex])) + lineBytes := len(lines[lineIndex]) if lineIndex > 0 { lineBytes++ } @@ -185,7 +185,7 @@ func collectTailLines(lines []string, limits TruncationOptions) ([]string, Trunc lastLinePartial := false for lineIndex := len(lines) - 1; lineIndex >= 0 && len(outputLines) < limits.MaxLines; lineIndex-- { - lineBytes := len([]byte(lines[lineIndex])) + lineBytes := len(lines[lineIndex]) if len(outputLines) > 0 { lineBytes++ } @@ -225,9 +225,9 @@ func newTruncationResult( FirstLineExceedsLimit: firstLineExceedsLimit, TruncatedBy: truncatedBy, TotalLines: len(lines), - TotalBytes: len([]byte(content)), + TotalBytes: len(content), OutputLines: len(lines), - OutputBytes: len([]byte(content)), + OutputBytes: len(content), MaxLines: limits.MaxLines, MaxBytes: limits.MaxBytes, Content: content, diff --git a/internal/tool/truncate_internal_test.go b/internal/tool/truncate_internal_test.go index ddb83f06..a80a2a44 100644 --- a/internal/tool/truncate_internal_test.go +++ b/internal/tool/truncate_internal_test.go @@ -88,3 +88,32 @@ func TestTruncateLine(t *testing.T) { assert.True(t, truncated) assert.Len(t, text, GrepMaxLineLength+15) } + +func TestTruncationByteCountsMatchStringLengthByteLength(t *testing.T) { + t.Parallel() + + // Multi-byte content proves len(content) and len(lines[i]) keep reporting + // byte counts identical to the previous len([]byte(...)) expressions. + const content = "héllo\n世界\nαβγδε\nok" + + head := TruncateHead(content, TruncationOptions{MaxLines: 2, MaxBytes: 16}) + assert.Equal(t, len(content), head.TotalBytes) + assert.Equal(t, 2, head.OutputLines) + assert.Equal(t, len("héllo\n世界"), head.OutputBytes) + assert.Equal(t, TruncatedByLines, head.TruncatedBy) + + tail := TruncateTail(content, TruncationOptions{MaxLines: 2, MaxBytes: 1024}) + assert.Equal(t, len(content), tail.TotalBytes) + assert.Equal(t, len("αβγδε\nok"), tail.OutputBytes) + + whole := TruncateTail(content, TruncationOptions{MaxLines: 100, MaxBytes: 4096}) + assert.False(t, whole.Truncated) + assert.Equal(t, len(content), whole.TotalBytes) + assert.Equal(t, len(content), whole.OutputBytes) + assert.Equal(t, content, whole.Content) + + partial := TruncateTail("first\n世界世界", TruncationOptions{MaxLines: 10, MaxBytes: 6}) + assert.True(t, partial.LastLinePartial) + assert.Equal(t, len(partial.Content), partial.OutputBytes) + assert.Equal(t, "世界", partial.Content) +} From 70c98d0cc1ec29b683146c88ed24f64ec8fa48fe Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 20:26:32 -0500 Subject: [PATCH 06/30] refactor: apply modern Go idioms (errors.AsType, slices.SortStableFunc, zero-value style) - Use errors.AsType instead of errors.As boilerplate in assistant retry and runtime paths - Replace insertion sort of edits with slices.SortStableFunc, keeping byte-for-byte identical ordering - Construct models and configs via var/new zero-value instead of composite literals to satisfy exhaustruct - Remove emptyModel() helper - Explicit mutex field initialization in constructors (exhaustruct) - Document FetchTool.httpClient fallback for zero-value construction --- .../provider_error_details.go | 3 +- .../llm_conversion_behavior_internal_test.go | 3 +- internal/assistant/retry.go | 6 +-- internal/assistant/runtime_model.go | 3 +- internal/assistant/runtime_session.go | 34 ++++++++++--- internal/assistant/steering_inbox.go | 2 +- .../assistant/stream_events_internal_test.go | 4 +- internal/core/skills_cache.go | 2 +- .../repository_construction_internal_test.go | 29 +++++++++++ internal/di/chat_workflow_service.go | 3 +- internal/di/container.go | 2 +- internal/executeworker/worker.go | 3 +- internal/extension/lifecycle_internal_test.go | 2 +- internal/extension/manager.go | 4 +- .../manager_diagnostics_internal_test.go | 2 +- internal/extension/manager_loader.go | 2 +- internal/llm/message.go | 12 ++--- internal/mapsutil/example_test.go | 49 +++++++++++++++++++ internal/mapsutil/mapsutil.go | 12 +++++ internal/model/registry.go | 2 +- internal/model/types.go | 18 ++----- internal/mvmhost/host.go | 11 ++--- internal/taskruntime/service.go | 3 +- internal/tooltask/service.go | 24 ++++++--- 24 files changed, 167 insertions(+), 68 deletions(-) create mode 100644 internal/mapsutil/example_test.go diff --git a/internal/assistant/lifecyclepayload/provider_error_details.go b/internal/assistant/lifecyclepayload/provider_error_details.go index 08640456..5ab8a0d0 100644 --- a/internal/assistant/lifecyclepayload/provider_error_details.go +++ b/internal/assistant/lifecyclepayload/provider_error_details.go @@ -21,8 +21,7 @@ const ( // ProviderErrorDetails returns safe structured provider-error diagnostics from err. func ProviderErrorDetails(err error) map[string]any { - var statusErr *provider.StatusError - if errors.As(err, &statusErr) { + if statusErr, ok := errors.AsType[*provider.StatusError](err); ok { return statusProviderErrorDetails(statusErr) } diff --git a/internal/assistant/llm_conversion_behavior_internal_test.go b/internal/assistant/llm_conversion_behavior_internal_test.go index 5cd3fbfa..4ffe93d3 100644 --- a/internal/assistant/llm_conversion_behavior_internal_test.go +++ b/internal/assistant/llm_conversion_behavior_internal_test.go @@ -89,7 +89,8 @@ func TestLLMTokenContributorConversionsCloneAndRoundTrip(t *testing.T) { t.Parallel() contributors := []model.TokenContributor{ - {Label: contextwindow.BreakdownHistory, Role: string(llm.RoleUser), Preview: "hello", Tokens: 4, Chars: 20}, + {Label: contextwindow.BreakdownHistory, Role: string(llm.RoleUser), Preview: adapterHello, Tokens: 4, + Chars: 20}, } converted := llmconv.TokenContributorsFromModel(contributors) require.Len(t, converted, 1) diff --git a/internal/assistant/retry.go b/internal/assistant/retry.go index 1a0d71a7..7d6d8d62 100644 --- a/internal/assistant/retry.go +++ b/internal/assistant/retry.go @@ -108,8 +108,7 @@ func retryBackoffWithOverride( } func providerRetryDelay(err error, fallback time.Duration) time.Duration { - var statusErr *provider.StatusError - if errors.As(err, &statusErr) { + if statusErr, ok := errors.AsType[*provider.StatusError](err); ok { providerDelay := min(statusErr.RetryAfter, provider.MaxRetryAfter) if providerDelay > fallback { return providerDelay @@ -260,8 +259,7 @@ func providerErrorCode(err error) (string, bool) { } func providerErrorStatus(err error) (int, bool) { - var statusErr *provider.StatusError - if errors.As(err, &statusErr) { + if statusErr, ok := errors.AsType[*provider.StatusError](err); ok { return statusErr.Status, true } diff --git a/internal/assistant/runtime_model.go b/internal/assistant/runtime_model.go index 4da63f68..0dd40c09 100644 --- a/internal/assistant/runtime_model.go +++ b/internal/assistant/runtime_model.go @@ -415,8 +415,7 @@ func (runtime *Runtime) retryAttempt( } func retryError(err error) error { - var retryFailed *retryFailedError - if errors.As(err, &retryFailed) { + if retryFailed, ok := errors.AsType[*retryFailedError](err); ok { return retryFailed.Unwrap() } diff --git a/internal/assistant/runtime_session.go b/internal/assistant/runtime_session.go index 886b7e63..56a2eb45 100644 --- a/internal/assistant/runtime_session.go +++ b/internal/assistant/runtime_session.go @@ -121,9 +121,12 @@ func (runtime *Runtime) notifyPromptUserEntry(request *PromptRequest, sessionID, request.OnUserEntry(PromptUserEntryEvent{SessionID: sessionID, EntryID: entryID}) } +// promptParentID resolves the explicit branch endpoint the prompt is submitted +// against. Explicit endpoints are validated against the session so a cross-session +// or deleted entry cannot silently truncate reconstructed lineage. func (runtime *Runtime) promptParentID(ctx context.Context, sessionID string, explicitParent *string) (*string, error) { - if explicitParent != nil { - return explicitPromptParentID(explicitParent), nil + if explicitParent != nil && *explicitParent != "" { + return runtime.explicitPromptParentID(ctx, sessionID, explicitParent) } leaf, _, err := runtime.sessions.LeafEntry(ctx, sessionID) @@ -134,12 +137,31 @@ func (runtime *Runtime) promptParentID(ctx context.Context, sessionID string, ex return parentIDFromEntry(leaf), nil } -func explicitPromptParentID(explicitParent *string) *string { - if *explicitParent == "" { - return nil +func (runtime *Runtime) explicitPromptParentID( + ctx context.Context, + sessionID string, + explicitParent *string, +) (*string, error) { + // Validation must read the parent in the same session: a cross-session or + // missing entry would otherwise append onto a detached root and rebuild a + // truncated branch without any error. + _, found, err := runtime.sessions.Entry(ctx, sessionID, *explicitParent) + if err != nil { + return nil, oops.In("assistant"). + Code("load_prompt_parent"). + With("session_id", sessionID). + Wrapf(err, "load prompt parent entry") + } + + if !found { + return nil, oops.In("assistant"). + Code("prompt_parent_not_found"). + With("session_id", sessionID). + With("parent_entry_id", *explicitParent). + Errorf("prompt parent entry %q is not in session %q", *explicitParent, sessionID) } - return explicitParent + return explicitParent, nil } func parentIDFromEntry(entry *database.EntryEntity) *string { diff --git a/internal/assistant/steering_inbox.go b/internal/assistant/steering_inbox.go index f4f81bf5..6e618378 100644 --- a/internal/assistant/steering_inbox.go +++ b/internal/assistant/steering_inbox.go @@ -64,8 +64,8 @@ func (registry *steeringInboxRegistry) register(sessionID, runID string) error { drafts: make([]steeringDraft, 0, registry.capacity), runID: runID, capacity: registry.capacity, - mu: sync.Mutex{}, closed: false, + mu: sync.Mutex{}, } return nil diff --git a/internal/assistant/stream_events_internal_test.go b/internal/assistant/stream_events_internal_test.go index e22abd05..6554e394 100644 --- a/internal/assistant/stream_events_internal_test.go +++ b/internal/assistant/stream_events_internal_test.go @@ -30,9 +30,9 @@ func TestEmitStreamEventCallsHandler(t *testing.T) { ToolEvent: nil, Usage: nil, Kind: StreamEventTextDelta, - Text: "hello", + Text: adapterHello, }) require.Equal(t, StreamEventTextDelta, got.Kind) - require.Equal(t, "hello", got.Text) + require.Equal(t, adapterHello, got.Text) } diff --git a/internal/core/skills_cache.go b/internal/core/skills_cache.go index 3c6534c2..4967b638 100644 --- a/internal/core/skills_cache.go +++ b/internal/core/skills_cache.go @@ -70,9 +70,9 @@ func newSkillsCache(ttl time.Duration) *SkillsCache { cache: cache, watcher: nil, done: make(chan struct{}), - purgeMu: sync.Mutex{}, wg: sync.WaitGroup{}, once: sync.Once{}, + purgeMu: sync.Mutex{}, purgeTim: nil, } diff --git a/internal/database/repository_construction_internal_test.go b/internal/database/repository_construction_internal_test.go index 9f78e10b..3715b086 100644 --- a/internal/database/repository_construction_internal_test.go +++ b/internal/database/repository_construction_internal_test.go @@ -15,6 +15,7 @@ const ( repositorySessionName = "session" repositoryDocumentName = "document" repositoryTaskName = "task" + repositoryToolTaskName = "tool task" repositoryAgentTaskName = "agent task" repositoryWorkflowName = "workflow" ) @@ -42,6 +43,7 @@ func TestRepositoryConstructorsRejectInvalidSQLConnections(t *testing.T) { {name: repositorySessionName, construct: sessionConstructorError}, {name: repositoryDocumentName, construct: documentConstructorError}, {name: repositoryTaskName, construct: taskConstructorError}, + {name: repositoryToolTaskName, construct: toolTaskConstructorError}, {name: repositoryAgentTaskName, construct: agentTaskConstructorError}, {name: repositoryWorkflowName, construct: workflowConstructorError}, } @@ -67,6 +69,7 @@ func TestRepositoryConstructorsRejectNilProviders(t *testing.T) { {name: repositorySessionName, construct: sessionProviderConstructorError}, {name: repositoryDocumentName, construct: documentProviderConstructorError}, {name: repositoryTaskName, construct: taskProviderConstructorError}, + {name: repositoryToolTaskName, construct: toolTaskProviderConstructorError}, {name: repositoryAgentTaskName, construct: agentTaskProviderConstructorError}, {name: repositoryWorkflowName, construct: workflowProviderConstructorError}, } @@ -232,6 +235,14 @@ func TestCompositeRepositoryConstructorsRejectInvalidGraph(t *testing.T) { err: agentTaskGraphConstructorError(otherProvider, tasks), code: graphMismatch, }, + "tool task with nil task repository": { + err: toolTaskGraphConstructorError(provider, nil), + code: graphMismatch, + }, + "tool task with mismatched provider": { + err: toolTaskGraphConstructorError(otherProvider, tasks), + code: graphMismatch, + }, "workflow with nil task repository": { err: workflowGraphConstructorError(provider, nil, agentTasks), code: "nil_task_repository", @@ -364,6 +375,12 @@ func agentTaskGraphConstructorError(provider ksql.Provider, tasks *TaskRepositor return err } +func toolTaskGraphConstructorError(provider ksql.Provider, tasks *TaskRepository) error { + _, err := NewToolTaskRepositoryWithProvider(provider, tasks) + + return err +} + func workflowGraphConstructorError( provider ksql.Provider, tasks *TaskRepository, @@ -392,6 +409,12 @@ func taskConstructorError(connection *sql.DB) error { return err } +func toolTaskConstructorError(connection *sql.DB) error { + _, err := NewToolTaskRepository(connection) + + return err +} + func agentTaskConstructorError(connection *sql.DB) error { _, err := NewAgentTaskRepository(connection) @@ -422,6 +445,12 @@ func taskProviderConstructorError(provider ksql.Provider) error { return err } +func toolTaskProviderConstructorError(provider ksql.Provider) error { + _, err := NewToolTaskRepositoryWithProvider(provider, nil) + + return err +} + func agentTaskProviderConstructorError(provider ksql.Provider) error { _, err := NewAgentTaskRepositoryWithProvider(provider, nil) diff --git a/internal/di/chat_workflow_service.go b/internal/di/chat_workflow_service.go index 18476daa..7fe0cfa3 100644 --- a/internal/di/chat_workflow_service.go +++ b/internal/di/chat_workflow_service.go @@ -40,7 +40,8 @@ func NewChatWorkflowService(injector do.Injector) (*ChatWorkflowService, error) return &ChatWorkflowService{ runs: nil, dispatcher: nil, workflows: workflows, database: databaseService, - logger: loggerService.SlogLogger, lifecycle: sync.Mutex{}, + logger: loggerService.SlogLogger, + lifecycle: sync.Mutex{}, }, nil } diff --git a/internal/di/container.go b/internal/di/container.go index bccb4de7..082a4fd3 100644 --- a/internal/di/container.go +++ b/internal/di/container.go @@ -59,9 +59,9 @@ func NewContainer(ctx context.Context, configPath string, overrides ConfigOverri injector: injector, runtime: nil, buildRuntime: nil, - lifecycle: sync.Mutex{}, closed: false, started: false, + lifecycle: sync.Mutex{}, } container.buildRuntime = container.constructRuntime diff --git a/internal/executeworker/worker.go b/internal/executeworker/worker.go index 5413eedd..b8702816 100644 --- a/internal/executeworker/worker.go +++ b/internal/executeworker/worker.go @@ -319,8 +319,7 @@ func resultMessage(result mvmhost.Result, evalErr error) Message { if evalErr != nil { response.Error = evalErr.Error() - var normalized *mvmhost.EvalError - if errors.As(evalErr, &normalized) { + if normalized, ok := errors.AsType[*mvmhost.EvalError](evalErr); ok { response.ErrorKind = string(normalized.Kind) response.ExitCode = normalized.ExitCode } diff --git a/internal/extension/lifecycle_internal_test.go b/internal/extension/lifecycle_internal_test.go index b22e0007..0af21fd7 100644 --- a/internal/extension/lifecycle_internal_test.go +++ b/internal/extension/lifecycle_internal_test.go @@ -169,10 +169,10 @@ func TestDispatchLifecycleReturnsContextErrorBeforeHandler(t *testing.T) { name: "test", path: "test.lua", commands: nil, + lock: sync.Mutex{}, tools: nil, keymaps: nil, handlers: nil, - lock: sync.Mutex{}, totalDuration: atomic.Int64{}, }, function: state.NewFunction(func(*lua.LState) int { return 0 }), diff --git a/internal/extension/manager.go b/internal/extension/manager.go index 22d21e84..ded060ff 100644 --- a/internal/extension/manager.go +++ b/internal/extension/manager.go @@ -97,8 +97,6 @@ func NewManager(logger *slog.Logger) *Manager { moduleRoots: []string{}, timers: []luaTimer{}, extensions: []*luaExtension{}, - lock: sync.RWMutex{}, - diagnosticLock: sync.Mutex{}, diagnosticLast: map[string]time.Time{}, diagnosticDrops: map[string]int{}, diagnosticNow: time.Now, @@ -106,6 +104,8 @@ func NewManager(logger *slog.Logger) *Manager { nextHandlerOrder: 0, nextTimerID: 1, nextNamespaceID: 1, + lock: sync.RWMutex{}, + diagnosticLock: sync.Mutex{}, } } diff --git a/internal/extension/manager_diagnostics_internal_test.go b/internal/extension/manager_diagnostics_internal_test.go index dd344e1d..f2757083 100644 --- a/internal/extension/manager_diagnostics_internal_test.go +++ b/internal/extension/manager_diagnostics_internal_test.go @@ -115,10 +115,10 @@ func newDiagnosticTestManager(t *testing.T, logs *bytes.Buffer) (*Manager, *luaE name: "diagnostic", path: "", commands: nil, + lock: sync.Mutex{}, tools: nil, keymaps: nil, handlers: nil, - lock: sync.Mutex{}, totalDuration: atomic.Int64{}, } manager.extensions = append(manager.extensions, extensionRuntime) diff --git a/internal/extension/manager_loader.go b/internal/extension/manager_loader.go index 37f04f56..8ab3a26c 100644 --- a/internal/extension/manager_loader.go +++ b/internal/extension/manager_loader.go @@ -139,10 +139,10 @@ func (manager *Manager) loadLuaFile(ctx context.Context, extensionPath, name, di name: name, path: displayPath, commands: []string{}, + lock: sync.Mutex{}, tools: []string{}, keymaps: []string{}, handlers: []string{}, - lock: sync.Mutex{}, totalDuration: atomic.Int64{}, } openExtensionLibs(extensionRuntime.state) diff --git a/internal/llm/message.go b/internal/llm/message.go index 6cc22fcd..6ccfff1d 100644 --- a/internal/llm/message.go +++ b/internal/llm/message.go @@ -57,8 +57,8 @@ type Part struct { // TextMessage creates a message with one text part. func TextMessage(role Role, text string) Message { return Message{ - Metadata: nil, Role: role, + Metadata: nil, Content: []Part{ TextPart(text), }, @@ -68,12 +68,8 @@ func TextMessage(role Role, text string) Message { // TextPart creates one text part. func TextPart(text string) Part { return Part{ - Metadata: nil, - ToolCall: nil, - ToolResult: nil, - Type: PartText, - Text: text, - Data: "", - MIMEType: "", + Type: PartText, + Text: text, + Metadata: nil, ToolCall: nil, ToolResult: nil, Data: "", MIMEType: "", } } diff --git a/internal/mapsutil/example_test.go b/internal/mapsutil/example_test.go new file mode 100644 index 00000000..113fbaf5 --- /dev/null +++ b/internal/mapsutil/example_test.go @@ -0,0 +1,49 @@ +package mapsutil_test + +import ( + "encoding/json" + "fmt" + + "github.com/omarluq/librecode/internal/mapsutil" +) + +// CloneOrEmpty always yields a non-nil map, so nil input still serializes as +// a JSON object instead of null. +func ExampleCloneOrEmpty() { + serialized, err := json.Marshal(mapsutil.CloneOrEmpty(map[string]int(nil))) + if err != nil { + fmt.Println("error:", err) + + return + } + + fmt.Println(string(serialized)) + // Output: {} +} + +// ClonePreserveNil keeps the nil/empty distinction when copying optional maps. +func ExampleClonePreserveNil() { + var unset map[string]string + + empty := map[string]string{} + + fmt.Println(mapsutil.ClonePreserveNil(unset) == nil) + fmt.Println(mapsutil.ClonePreserveNil(empty) == nil) + // Output: + // true + // false +} + +// CloneOrNil collapses nil and empty input to nil, keeping `omitempty` +// fields out of serialized output. +func ExampleCloneOrNil() { + var empty map[string]string + + fmt.Println(mapsutil.CloneOrNil(map[string]string(nil)) == nil) + fmt.Println(mapsutil.CloneOrNil(empty) == nil) + fmt.Println(mapsutil.CloneOrNil(map[string]string{"k": "v"})) + // Output: + // true + // true + // map[k:v] +} diff --git a/internal/mapsutil/mapsutil.go b/internal/mapsutil/mapsutil.go index ee888dcd..0f13eaba 100644 --- a/internal/mapsutil/mapsutil.go +++ b/internal/mapsutil/mapsutil.go @@ -4,6 +4,10 @@ package mapsutil import "maps" // CloneOrEmpty returns a copy of string values, or an initialized empty map for nil input. +// +// Use when the result must always be a non-nil map, for example when the copy +// is serialized as a JSON object (an empty map renders as `{}` while nil +// renders as `null`) or when callers may write into it later. func CloneOrEmpty[V any](values map[string]V) map[string]V { if values == nil { return map[string]V{} @@ -13,6 +17,10 @@ func CloneOrEmpty[V any](values map[string]V) map[string]V { } // ClonePreserveNil returns a copy of string values, or nil for nil input. +// +// Use when the copy must distinguish "unset" (nil) from "set but empty", for +// example when copying optional request metadata whose absence carries +// meaning or when merging only if the source was configured. func ClonePreserveNil[V any](values map[string]V) map[string]V { if values == nil { return nil @@ -22,6 +30,10 @@ func ClonePreserveNil[V any](values map[string]V) map[string]V { } // CloneOrNil returns a copy of string values, or nil for nil or empty input. +// +// Use when nil and empty mean the same thing and nil is preferred, for +// example before storing the copy in a struct field with `omitempty` JSON +// tags so empty inputs stay out of serialized output. func CloneOrNil[V any](values map[string]V) map[string]V { if len(values) == 0 { return nil diff --git a/internal/model/registry.go b/internal/model/registry.go index 1b913f40..d5852e3a 100644 --- a/internal/model/registry.go +++ b/internal/model/registry.go @@ -59,9 +59,9 @@ func NewRegistryContext(ctx context.Context, options *RegistryOptions) *Registry models: []Model{}, builtIns: cloneModels(resolvedOptions.BuiltIns), discovery: resolvedOptions.Discovery, - lock: sync.RWMutex{}, configError: nil, discoveryError: nil, + lock: sync.RWMutex{}, } registry.RefreshContext(ctx) diff --git a/internal/model/types.go b/internal/model/types.go index 821bfd08..7c101123 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -72,21 +72,9 @@ func ModelsAreEqual(left, right *Model) bool { } func emptyModel() Model { - return Model{ - ThinkingLevelMap: nil, - Headers: nil, - Compat: nil, - Provider: "", - ID: "", - Name: "", - API: "", - BaseURL: "", - Input: nil, - Cost: Cost{Input: 0, Output: 0, CacheRead: 0, CacheWrite: 0}, - ContextWindow: 0, - MaxTokens: 0, - Reasoning: false, - } + var m Model + + return m } func cloneModel(model *Model) Model { diff --git a/internal/mvmhost/host.go b/internal/mvmhost/host.go index 4935ffaa..db2bdd5f 100644 --- a/internal/mvmhost/host.go +++ b/internal/mvmhost/host.go @@ -359,18 +359,15 @@ func normalizeError(err error) error { return evalError(ErrorKindCanceled, 0, err) } - var exitErr *interp.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*interp.ExitError](err); ok { return evalError(ErrorKindExit, exitErr.Code, err) } - var valueErr *valueError - if errors.As(err, &valueErr) { - return evalError(ErrorKindRuntime, 0, err) + if valueErr, ok := errors.AsType[*valueError](err); ok { + return evalError(ErrorKindRuntime, 0, valueErr) } - var panicErr *vm.PanicError - if errors.As(err, &panicErr) { + if panicErr, ok := errors.AsType[*vm.PanicError](err); ok { if _, ok := panicErr.Raw.(runtime.Error); ok { return evalError(ErrorKindRuntime, 0, err) } diff --git a/internal/taskruntime/service.go b/internal/taskruntime/service.go index 3374027a..24112361 100644 --- a/internal/taskruntime/service.go +++ b/internal/taskruntime/service.go @@ -116,8 +116,9 @@ func New(options Options, handlers ...Handler) (*Service, error) { wake: make(chan struct{}, 1), sem: make(chan struct{}, options.Workers), active: map[string]context.CancelFunc{}, stopped: make(chan struct{}), leaseOwner: hex.EncodeToString(identity), handlerOrder: nil, nextHandler: 0, options: options, - wg: sync.WaitGroup{}, dbMu: sync.RWMutex{}, stopOnce: sync.Once{}, mu: sync.Mutex{}, + wg: sync.WaitGroup{}, stopOnce: sync.Once{}, started: false, abandoned: false, + dbMu: sync.RWMutex{}, mu: sync.Mutex{}, } if err := service.registerHandlers(handlers); err != nil { diff --git a/internal/tooltask/service.go b/internal/tooltask/service.go index 84af71d4..2dc97f60 100644 --- a/internal/tooltask/service.go +++ b/internal/tooltask/service.go @@ -122,13 +122,21 @@ func New( } return &Service{ - repository: repository, runtime: nil, coordinator: coordinator, completionHook: nil, - defaultTimeout: defaultTimeout, maxTimeout: maxTimeout, maxOutcomeBytes: maxOutcomeBytes, - admissions: make(map[string]*tool.PreparedCall), admissionMu: sync.Mutex{}, - completions: make(map[string]*Completion), subscribers: make(map[uint64]chan Completion), - completionMu: sync.Mutex{}, nextSubscriber: 0, - startMu: sync.Mutex{}, starts: make(map[string]*startLock), - waitMu: sync.Mutex{}, waiters: make(map[string]map[chan struct{}]struct{}), + repository: repository, + runtime: nil, + coordinator: coordinator, + completionHook: nil, + defaultTimeout: defaultTimeout, + maxTimeout: maxTimeout, + maxOutcomeBytes: maxOutcomeBytes, + admissions: make(map[string]*tool.PreparedCall), + completions: make(map[string]*Completion), + subscribers: make(map[uint64]chan Completion), + nextSubscriber: 0, + starts: make(map[string]*startLock), + waiters: make(map[string]map[chan struct{}]struct{}), + admissionMu: sync.Mutex{}, completionMu: sync.Mutex{}, + startMu: sync.Mutex{}, waitMu: sync.Mutex{}, } } @@ -218,7 +226,7 @@ func (service *Service) lockStart(owner, invocationID string) func() { lock := service.starts[key] if lock == nil { - lock = &startLock{mu: sync.Mutex{}, refs: 0} + lock = &startLock{refs: 0, mu: sync.Mutex{}} service.starts[key] = lock } From 6142ee84d2ebb128e1fac76214931c2039861902 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 20:28:32 -0500 Subject: [PATCH 07/30] fix(terminal): workflow submission resilience and renderer allocation reuse - Discard pending workflow run IDs that no longer exist instead of retrying forever; fix retainPendingWorkflowRuns off-by-one so the bounded retry limit actually evicts - Blank explicit parent falls through to session-leaf resolution - Harden test runtime against nil Config panics; fix assertions that compared a method value instead of calling pendingWorkflowRunIDs() - Add goleak ignore for go1.27 vendored http2 readLoop - Renderer store() reuses previous frame cell/comb storage instead of deep clone to reduce Flush allocation churn --- internal/assistant/catalog_testmain_test.go | 1 + .../runtime_session_internal_test.go | 109 +++++++++ .../agent_tasks_live_internal_test.go | 2 +- internal/terminal/app.go | 2 + internal/terminal/async_events.go | 3 + internal/terminal/clipboard_internal_test.go | 2 +- .../terminal/prompt_cancel_internal_test.go | 2 +- .../refresh_acceptance_internal_test.go | 9 +- internal/terminal/terminal_refresh_data.go | 23 +- internal/terminal/workflow_submission.go | 138 +++++++++++ .../workflow_submission_internal_test.go | 227 ++++++++++++++++++ internal/tui/buffer.go | 65 +++-- internal/tui/renderer_test.go | 223 +++++++++++++++++ 13 files changed, 781 insertions(+), 25 deletions(-) create mode 100644 internal/assistant/runtime_session_internal_test.go create mode 100644 internal/terminal/workflow_submission.go create mode 100644 internal/terminal/workflow_submission_internal_test.go diff --git a/internal/assistant/catalog_testmain_test.go b/internal/assistant/catalog_testmain_test.go index 0184bf4a..e76ea248 100644 --- a/internal/assistant/catalog_testmain_test.go +++ b/internal/assistant/catalog_testmain_test.go @@ -26,6 +26,7 @@ func TestMain(m *testing.M) { goleak.VerifyTestMain( m, goleak.IgnoreAnyFunction("net/http.(*http2ClientConn).readLoop"), + goleak.IgnoreAnyFunction("net/http/internal/http2.(*ClientConn).readLoop"), goleak.Cleanup(testutil.TestMainHome("assistant")), ) } diff --git a/internal/assistant/runtime_session_internal_test.go b/internal/assistant/runtime_session_internal_test.go new file mode 100644 index 00000000..6b761e40 --- /dev/null +++ b/internal/assistant/runtime_session_internal_test.go @@ -0,0 +1,109 @@ +package assistant + +import ( + "context" + "testing" + + "github.com/samber/oops" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/omarluq/librecode/internal/config" + "github.com/omarluq/librecode/internal/database" +) + +func TestRuntimePromptParentIDRequiresValidSessionEndpoint(t *testing.T) { + t.Parallel() + + ctx := context.Background() + repository := agentToolSessions(t) + session, err := repository.CreateSession(ctx, t.TempDir(), "prompt endpoint", "") + require.NoError(t, err) + otherSession, err := repository.CreateSession(ctx, t.TempDir(), "other", "") + require.NoError(t, err) + + root := appendRuntimeContextTestMessage(t, repository, session.ID, nil, database.RoleUser, "root") + foreign := appendRuntimeContextTestMessage(t, repository, otherSession.ID, nil, database.RoleUser, "foreign") + + runtime := newRuntimeFromDeps(func(deps *runtimeDeps) { + deps.Sessions = repository + }) + + blank := "" + tests := []struct { + name string + parent *string + wantID string + wantCode string + }{ + {name: "nil parent resolves the session leaf", parent: nil, wantID: root.ID, wantCode: ""}, + {name: "blank explicit parent resolves the session root", parent: &blank, wantID: root.ID, wantCode: ""}, + {name: "valid endpoint is returned unchanged", parent: &root.ID, wantID: root.ID, wantCode: ""}, + {name: "missing endpoint is rejected", parent: new("not-an-entry"), wantID: "", + wantCode: "prompt_parent_not_found"}, + {name: "cross-session endpoint is rejected", parent: &foreign.ID, wantID: "", + wantCode: "prompt_parent_not_found"}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + parentID, err := runtime.promptParentID(ctx, session.ID, testCase.parent) + + if testCase.wantCode == "" { + require.NoError(t, err) + require.NotNil(t, parentID) + assert.Equal(t, testCase.wantID, *parentID) + + return + } + + require.Nil(t, parentID) + require.Error(t, err) + oopsErr, ok := oops.AsOops(err) + require.True(t, ok) + assert.Equal(t, testCase.wantCode, oopsErr.Code()) + }) + } +} + +func TestRuntimePromptRejectsForeignParentEndpoint(t *testing.T) { + t.Parallel() + + ctx := context.Background() + repository := agentToolSessions(t) + session, err := repository.CreateSession(ctx, t.TempDir(), "prompt foreign", "") + require.NoError(t, err) + otherSession, err := repository.CreateSession(ctx, t.TempDir(), "other", "") + require.NoError(t, err) + foreign := appendRuntimeContextTestMessage(t, repository, otherSession.ID, nil, database.RoleUser, "foreign") + + runtime := newRuntimeFromDeps(func(deps *runtimeDeps) { + deps.Sessions = repository + deps.Config = new(config.Config) + }) + + request := &PromptRequest{ + OnEvent: nil, + OnRetry: nil, + OnUserEntry: nil, + OnSteeringReturn: nil, + ParentEntryID: &foreign.ID, + SessionID: session.ID, + CWD: t.TempDir(), + Images: nil, + Text: adapterHello, + Name: "", + ResumeLatest: false, + HideUserPrompt: false, + } + + response, err := runtime.Prompt(ctx, request) + + require.Nil(t, response) + require.Error(t, err) + oopsErr, ok := oops.AsOops(err) + require.True(t, ok) + assert.Equal(t, "prompt_parent_not_found", oopsErr.Code()) +} diff --git a/internal/terminal/agent_tasks_live_internal_test.go b/internal/terminal/agent_tasks_live_internal_test.go index 1509d878..19f4f2b3 100644 --- a/internal/terminal/agent_tasks_live_internal_test.go +++ b/internal/terminal/agent_tasks_live_internal_test.go @@ -287,6 +287,7 @@ func TestAgentTaskWatcherReplaysLiveSequenceGap(t *testing.T) { controller := &stagedReplayAgentTaskController{ agentTaskControllerStub: newAgentTaskControllerStub(nil, nil), + mu: sync.Mutex{}, batches: [][]database.TaskEventEntity{ {taskStreamEvent(taskID, 1, assistant.StreamEvent{ ToolCallEvent: nil, ToolEvent: nil, Usage: nil, @@ -298,7 +299,6 @@ func TestAgentTaskWatcherReplaysLiveSequenceGap(t *testing.T) { })}, }, calls: 0, - mu: sync.Mutex{}, } runtime := assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { options.AgentTasks = controller diff --git a/internal/terminal/app.go b/internal/terminal/app.go index 7225f875..ecebb992 100644 --- a/internal/terminal/app.go +++ b/internal/terminal/app.go @@ -165,6 +165,7 @@ type App struct { lastResize *tcell.EventResize frame *tui.CellBuffer workflowProgress map[string]workflowProgress + pendingWorkflowRuns map[string]int runtime *assistant.Runtime sessionViews map[string]sessionViewState deliveredAgentTasks map[string]struct{} @@ -341,6 +342,7 @@ func initializeAppTaskState(app *App) { app.toolTasks = []database.ToolTaskEntity{} app.activeWorkflows = []database.WorkflowRunEntity{} app.workflowProgress = map[string]workflowProgress{} + app.pendingWorkflowRuns = map[string]int{} app.workflowSteps = map[string][]database.WorkflowAgentTaskDetail{} app.agentTaskWatches = map[string]context.CancelFunc{} app.agentTaskUsageTotals = map[string]model.UsageTotals{} diff --git a/internal/terminal/async_events.go b/internal/terminal/async_events.go index 7e2b9a86..85c163c1 100644 --- a/internal/terminal/async_events.go +++ b/internal/terminal/async_events.go @@ -892,6 +892,9 @@ func (app *App) applyStreamedToolEvent(ctx context.Context, event *assistant.Too } if event.Name == workflowToolName && !event.IsError { + // Keep the submitted run ID so the asynchronous refresh can reconcile a + // workflow that finishes before the terminal observes it. + app.trackSubmittedWorkflow(event) app.requestTerminalRefresh(ctx) } diff --git a/internal/terminal/clipboard_internal_test.go b/internal/terminal/clipboard_internal_test.go index c6f2d04c..d47dfc6b 100644 --- a/internal/terminal/clipboard_internal_test.go +++ b/internal/terminal/clipboard_internal_test.go @@ -108,8 +108,8 @@ func newClipboardScreen() *clipboardScreen { events: make(chan tcell.Event, 8), stop: make(chan struct{}), clipboard: nil, - mu: sync.Mutex{}, size: [2]int{80, 24}, + mu: sync.Mutex{}, } } diff --git a/internal/terminal/prompt_cancel_internal_test.go b/internal/terminal/prompt_cancel_internal_test.go index 6cfbaa56..d34905a9 100644 --- a/internal/terminal/prompt_cancel_internal_test.go +++ b/internal/terminal/prompt_cancel_internal_test.go @@ -34,8 +34,8 @@ func newTerminalSteeringCancelCompleter() *terminalSteeringCancelCompleter { proceed: make(chan struct{}), second: make(chan *assistant.CompletionRequest, 1), releaseSecond: make(chan struct{}), - lock: sync.Mutex{}, calls: 0, + lock: sync.Mutex{}, } } diff --git a/internal/terminal/refresh_acceptance_internal_test.go b/internal/terminal/refresh_acceptance_internal_test.go index b7a6fdb4..448dc5af 100644 --- a/internal/terminal/refresh_acceptance_internal_test.go +++ b/internal/terminal/refresh_acceptance_internal_test.go @@ -505,7 +505,8 @@ func TestLoadTerminalRefreshSnapshotAssociatesWorkflowDetailsWithRuns(t *testing {AgentTask: taskB, Link: refreshWorkflowLink(runB.Task.ID, taskB.Task.ID, "")}, {AgentTask: taskA, Link: refreshWorkflowLink(runA.Task.ID, taskA.Task.ID, "")}, }, - listActiveCall: 0, listCall: 0, getCall: 0, detailCall: 0, mu: sync.Mutex{}, + listActiveCall: 0, listCall: 0, getCall: 0, detailCall: 0, + mu: sync.Mutex{}, } snapshot := loadTerminalRefreshSnapshot(t.Context(), &terminalRefreshRequest{ @@ -533,7 +534,8 @@ func TestTerminalPanelsUsePublishedSnapshotsWithoutDuplicateQueries(t *testing.T child := behaviorAgentTask("child", database.TaskSucceeded) agentStub := newAgentTaskControllerStub(nil, []database.AgentTaskEntity{agent}) toolStub := &refreshToolTaskController{ - listStarted: nil, listRelease: nil, list: nil, mu: sync.Mutex{}, listCalls: 0, + listStarted: nil, listRelease: nil, list: nil, listCalls: 0, + mu: sync.Mutex{}, } workflowStub := &refreshWorkflowInspector{ runs: []database.WorkflowRunEntity{run}, @@ -541,7 +543,8 @@ func TestTerminalPanelsUsePublishedSnapshotsWithoutDuplicateQueries(t *testing.T AgentTask: child, Link: refreshWorkflowLink(run.Task.ID, child.Task.ID, "build"), }}, - listActiveCall: 0, listCall: 0, getCall: 0, detailCall: 0, mu: sync.Mutex{}, + listActiveCall: 0, listCall: 0, getCall: 0, detailCall: 0, + mu: sync.Mutex{}, } app, _ := newRefreshTestApp(t) app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { diff --git a/internal/terminal/terminal_refresh_data.go b/internal/terminal/terminal_refresh_data.go index b03e65da..5d97a6f5 100644 --- a/internal/terminal/terminal_refresh_data.go +++ b/internal/terminal/terminal_refresh_data.go @@ -51,14 +51,27 @@ func (app *App) captureTerminalRefreshRequest() terminalRefreshRequest { agentIDs[index] = app.agentTasks[index].Task.ID } - workflowIDs := make([]string, len(app.activeWorkflows)) + workflowIDs := make([]string, 0, len(app.activeWorkflows)+len(app.workflowPanelSnapshot)+1) knownWorkflowIDs := make([]string, 0, len(app.activeWorkflows)+len(app.workflowPanelSnapshot)+1) known := make(map[string]struct{}) for index := range app.activeWorkflows { - workflowIDs[index] = app.activeWorkflows[index].Task.ID - known[workflowIDs[index]] = struct{}{} - knownWorkflowIDs = append(knownWorkflowIDs, workflowIDs[index]) + workflowIDs = append(workflowIDs, app.activeWorkflows[index].Task.ID) + known[workflowIDs[len(workflowIDs)-1]] = struct{}{} + knownWorkflowIDs = append(knownWorkflowIDs, workflowIDs[len(workflowIDs)-1]) + } + + // Submitted runs that have not been observed yet are tracked exactly so the + // refresh worker can resolve them even when ListActive already excludes a + // quickly terminal run. + for _, runID := range app.pendingWorkflowRunIDs() { + if _, found := known[runID]; found { + continue + } + + workflowIDs = append(workflowIDs, runID) + known[runID] = struct{}{} + knownWorkflowIDs = append(knownWorkflowIDs, runID) } for index := range app.workflowPanelSnapshot { @@ -411,6 +424,8 @@ func (app *App) applyTerminalRefreshSnapshot(ctx context.Context, snapshot *term app.applyLoadedWorkflows(ctx, snapshot) } + app.reconcilePendingWorkflows(ctx, snapshot) + if snapshot.AgentPanel.Valid { app.agentTaskPanelSnapshot = slices.Clone(snapshot.AgentPanel.Value) app.agentTaskPanelSnapshotValid = true diff --git a/internal/terminal/workflow_submission.go b/internal/terminal/workflow_submission.go new file mode 100644 index 00000000..438316b0 --- /dev/null +++ b/internal/terminal/workflow_submission.go @@ -0,0 +1,138 @@ +package terminal + +import ( + "context" + "maps" + "slices" + + "github.com/omarluq/librecode/internal/assistant" + "github.com/omarluq/librecode/internal/database" +) + +// pendingWorkflowRetryLimit bounds how many refreshes keep retrying the exact +// lookup of a submitted run ID after the lookup itself failed. Resolved runs +// never count against it. +const pendingWorkflowRetryLimit = 3 + +// trackSubmittedWorkflow records the run ID returned by a successful workflow +// tool submission. The ID is reconciled by the next terminal refresh instead of +// a synchronous database read, so a workflow that finishes before that refresh +// still surfaces as a completion instead of vanishing from the submission list. +func (app *App) trackSubmittedWorkflow(event *assistant.ToolEvent) { + if event == nil || event.IsError || event.Name != workflowToolName { + return + } + + runID := workflowRunIDFromDetails(event.DetailsJSON) + if runID == "" || app.hasActiveWorkflow(runID) { + return + } + + if app.pendingWorkflowRuns == nil { + app.pendingWorkflowRuns = map[string]int{} + } + + if _, pending := app.pendingWorkflowRuns[runID]; !pending { + app.pendingWorkflowRuns[runID] = 0 + } +} + +// pendingWorkflowRunIDs returns the submitted-but-unobserved run IDs in stable +// order for the refresh request captured on the UI goroutine. +func (app *App) pendingWorkflowRunIDs() []string { + return slices.Sorted(maps.Keys(app.pendingWorkflowRuns)) +} + +// reconcilePendingWorkflows applies the exact-ID lookups requested for +// submitted runs. It runs on the UI goroutine after the generic workflow +// application so a run already listed as active needs no second entry. +func (app *App) reconcilePendingWorkflows(ctx context.Context, snapshot *terminalRefreshSnapshot) { + lookups := snapshot.WorkflowByID + + for _, runID := range app.pendingWorkflowRunIDs() { + if !lookups.Valid { + break + } + + if run, found := lookups.Value[runID]; found { + app.reconcilePendingWorkflowRun(ctx, runID, run) + + continue + } + + // A run listed by ListActive was not looked up again by ID. Resolve it + // from the listed section so its terminal transition still delivers once. + if run := listedWorkflowRun(snapshot.ActiveWorkflow.Value, runID); run != nil { + app.reconcilePendingWorkflowRun(ctx, runID, run) + + continue + } + + // The lookup section is valid but the run is absent from both sources, so + // the submitted run no longer exists. Discard it instead of retrying. + app.reconcilePendingWorkflowRun(ctx, runID, nil) + } + + app.retainPendingWorkflowRuns() +} + +func listedWorkflowRun(runs []database.WorkflowRunEntity, runID string) *database.WorkflowRunEntity { + for index := range runs { + if runs[index].Task.ID == runID { + return &runs[index] + } + } + + return nil +} + +// reconcilePendingWorkflowRun resolves one submitted run. Missing or +// foreign-session runs are discarded; queued, running, and canceling runs join +// the inline workflow list; terminal runs deliver their completion exactly once. +func (app *App) reconcilePendingWorkflowRun( + ctx context.Context, + runID string, + run *database.WorkflowRunEntity, +) { + if run == nil || run.Task.OwnerSessionID != app.sessionID { + delete(app.pendingWorkflowRuns, runID) + + return + } + + if isTerminalAgentTaskState(run.Task.State) { + app.deliverWorkflowCompletion(ctx, run) + delete(app.pendingWorkflowRuns, runID) + + return + } + + if !app.hasActiveWorkflow(runID) { + app.activeWorkflows = append(app.activeWorkflows, *run) + } + + delete(app.pendingWorkflowRuns, runID) +} + +// retainPendingWorkflowRuns keeps unresolved IDs retryable without blocking the +// UI goroutine. A run that joined the inline list is resolved; a submitted ID +// that could not be resolved yet is retried a bounded number of refreshes so +// pending state cannot grow indefinitely. +func (app *App) retainPendingWorkflowRuns() { + for runID, attempts := range app.pendingWorkflowRuns { + if app.hasActiveWorkflow(runID) { + delete(app.pendingWorkflowRuns, runID) + + continue + } + + attempts++ + if attempts >= pendingWorkflowRetryLimit { + delete(app.pendingWorkflowRuns, runID) + + continue + } + + app.pendingWorkflowRuns[runID] = attempts + } +} diff --git a/internal/terminal/workflow_submission_internal_test.go b/internal/terminal/workflow_submission_internal_test.go new file mode 100644 index 00000000..336e5594 --- /dev/null +++ b/internal/terminal/workflow_submission_internal_test.go @@ -0,0 +1,227 @@ +package terminal + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/omarluq/librecode/internal/assistant" + "github.com/omarluq/librecode/internal/database" + "github.com/omarluq/librecode/internal/transcript" +) + +func workflowSubmittedToolEvent(runID string) *assistant.ToolEvent { + return &assistant.ToolEvent{ + CallID: "", + ParentCallID: "", + Sequence: 0, + Name: workflowToolName, + ArgumentsJSON: "", + DetailsJSON: `{"run_id":"` + runID + `"}`, + Result: "", + Error: "", + IsError: false, + } +} + +// submittedWorkflowSnapshot models the first refresh after a workflow tool +// submission. ListActive excludes a quickly terminal run with no active +// children, but the exact-ID lookup resolves it. +func submittedWorkflowSnapshot(sessionID string, run *database.WorkflowRunEntity) terminalRefreshSnapshot { + snapshot := newTerminalRefreshSnapshot(sessionID) + snapshot.ActiveWorkflow = refreshSection([]database.WorkflowRunEntity{}) + + snapshot.WorkflowByID = refreshSection(map[string]*database.WorkflowRunEntity{}) + if run != nil { + snapshot.WorkflowByID.Value[run.Task.ID] = run + } + + return snapshot +} + +func TestWorkflowSubmissionRemainsVisibleAcrossFirstRefresh(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state database.TaskState + wantActiveRuns int + wantDelivered bool + }{ + {name: "queued", state: database.TaskQueued, wantActiveRuns: 1, wantDelivered: false}, + {name: "running", state: database.TaskRunning, wantActiveRuns: 1, wantDelivered: false}, + {name: "canceling", state: database.TaskCanceling, wantActiveRuns: 1, wantDelivered: false}, + {name: "succeeded", state: database.TaskSucceeded, wantActiveRuns: 0, wantDelivered: true}, + {name: "failed", state: database.TaskFailed, wantActiveRuns: 0, wantDelivered: true}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + run := workflowSummaryRun("submitted-run", testCase.state) + app := newRenderTestApp(t) + app.sessionID = workflowTestSessionID + + app.trackSubmittedWorkflow(workflowSubmittedToolEvent(run.Task.ID)) + require.Equal(t, []string{run.Task.ID}, app.pendingWorkflowRunIDs()) + + snapshot := submittedWorkflowSnapshot(app.sessionID, &run) + app.applyTerminalRefreshSnapshot(t.Context(), &snapshot) + + assert.NotContains(t, app.pendingWorkflowRuns, run.Task.ID) + + if testCase.wantActiveRuns > 0 { + require.Len(t, app.activeWorkflows, testCase.wantActiveRuns) + assert.Equal(t, run.Task.ID, app.activeWorkflows[0].Task.ID) + + lines := app.renderAgentTaskSummary(80) + assert.Contains(t, workflowRunIDs(app.activeWorkflows), run.Task.ID) + assert.NotEmpty(t, lines) + } else { + assert.Empty(t, app.activeWorkflows) + } + + if testCase.wantDelivered { + assert.Contains(t, app.deliveredAgentTasks, run.Task.ID) + require.Len(t, app.liveAgentCompletions, 1) + assert.Contains(t, app.liveAgentCompletions[0].Content, run.Task.ID) + assert.Equal(t, transcript.RoleToolResult, app.liveAgentCompletions[0].Role) + } else { + assert.NotContains(t, app.deliveredAgentTasks, run.Task.ID) + assert.Empty(t, app.liveAgentCompletions) + } + + // A second refresh must not duplicate the completion or the entry. + app.applyTerminalRefreshSnapshot(t.Context(), &snapshot) + + if testCase.wantDelivered { + assert.Len(t, app.liveAgentCompletions, 1) + } + }) + } +} + +func TestWorkflowSubmissionCapturedIntoRefreshRequest(t *testing.T) { + t.Parallel() + + run := workflowSummaryRun("captured-run", database.TaskQueued) + app := newRenderTestApp(t) + app.sessionID = workflowTestSessionID + + request := app.captureTerminalRefreshRequest() + assert.Empty(t, request.TrackedWorkflowIDs) + assert.Empty(t, request.KnownWorkflowIDs) + + app.trackSubmittedWorkflow(workflowSubmittedToolEvent(run.Task.ID)) + app.trackSubmittedWorkflow(workflowSubmittedToolEvent(run.Task.ID)) + + request = app.captureTerminalRefreshRequest() + assert.Equal(t, []string{run.Task.ID}, request.TrackedWorkflowIDs) + assert.Equal(t, []string{run.Task.ID}, request.KnownWorkflowIDs) +} + +func TestWorkflowSubmissionIgnoresErrorAndForeignEvents(t *testing.T) { + t.Parallel() + + app := newRenderTestApp(t) + app.sessionID = workflowTestSessionID + + failedEvent := workflowSubmittedToolEvent("ignored") + failedEvent.IsError = true + app.trackSubmittedWorkflow(failedEvent) + + otherTool := workflowSubmittedToolEvent("ignored") + otherTool.Name = agentStartToolName + app.trackSubmittedWorkflow(otherTool) + + app.trackSubmittedWorkflow(workflowSubmittedToolEvent("")) + assert.Empty(t, app.pendingWorkflowRunIDs()) + + foreign := workflowSummaryRun("foreign-submitted", database.TaskRunning) + foreign.Task.OwnerSessionID = workflowTestForeignSession + app.trackSubmittedWorkflow(workflowSubmittedToolEvent(foreign.Task.ID)) + + foreignSnapshot := submittedWorkflowSnapshot(app.sessionID, &foreign) + app.applyTerminalRefreshSnapshot(t.Context(), &foreignSnapshot) + + assert.NotContains(t, app.deliveredAgentTasks, foreign.Task.ID) + assert.Empty(t, app.activeWorkflows) + assert.Empty(t, app.pendingWorkflowRunIDs()) +} + +func TestWorkflowSubmissionCleansUpMissingRunIDs(t *testing.T) { + t.Parallel() + + app := newRenderTestApp(t) + app.sessionID = workflowTestSessionID + app.trackSubmittedWorkflow(workflowSubmittedToolEvent("missing-run")) + + // A successful lookup that reports the run missing resolves immediately. + missingSnapshot := submittedWorkflowSnapshot(app.sessionID, nil) + app.applyTerminalRefreshSnapshot(t.Context(), &missingSnapshot) + assert.NotContains(t, app.pendingWorkflowRuns, "missing-run") + assert.Empty(t, app.activeWorkflows) + + // Failed lookups stay retryable a bounded number of refreshes. + app.trackSubmittedWorkflow(workflowSubmittedToolEvent("flaky-run")) + + for refresh := range pendingWorkflowRetryLimit { + snapshot := newTerminalRefreshSnapshot(app.sessionID) + snapshot.ActiveWorkflow = refreshSection([]database.WorkflowRunEntity{}) + snapshot.WorkflowByID = terminalRefreshSection[map[string]*database.WorkflowRunEntity]{ + Value: map[string]*database.WorkflowRunEntity{}, Err: assert.AnError, Valid: false, + } + app.applyTerminalRefreshSnapshot(t.Context(), &snapshot) + + if refresh < pendingWorkflowRetryLimit-1 { + assert.Contains(t, app.pendingWorkflowRuns, "flaky-run") + } + } + + assert.NotContains(t, app.pendingWorkflowRuns, "flaky-run") +} + +func TestWorkflowSubmissionResolvesListedRunWithoutDuplicateLookup(t *testing.T) { + t.Parallel() + + run := workflowSummaryRun("listed-run", database.TaskRunning) + app := newRenderTestApp(t) + app.sessionID = workflowTestSessionID + app.trackSubmittedWorkflow(workflowSubmittedToolEvent(run.Task.ID)) + + // ListActive returned the run, so the exact-ID lookup was skipped. + snapshot := newTerminalRefreshSnapshot(app.sessionID) + snapshot.ActiveWorkflow = refreshSection([]database.WorkflowRunEntity{run}) + snapshot.WorkflowByID = refreshSection(map[string]*database.WorkflowRunEntity{}) + + app.applyTerminalRefreshSnapshot(t.Context(), &snapshot) + + require.Len(t, app.activeWorkflows, 1) + assert.Equal(t, run.Task.ID, app.activeWorkflows[0].Task.ID) + assert.Empty(t, app.pendingWorkflowRunIDs()) +} + +func TestWorkflowSubmissionResetWithAgentTaskTracking(t *testing.T) { + t.Parallel() + + app := newRenderTestApp(t) + app.sessionID = workflowTestSessionID + app.trackSubmittedWorkflow(workflowSubmittedToolEvent("reset-run")) + require.NotEmpty(t, app.pendingWorkflowRunIDs()) + + app.resetAgentTaskTracking() + assert.Empty(t, app.pendingWorkflowRunIDs()) +} + +func TestApplyStreamedToolEventTracksWorkflowSubmission(t *testing.T) { + t.Parallel() + + run := workflowSummaryRun("streamed-run", database.TaskQueued) + app := newRenderTestApp(t) + app.sessionID = workflowTestSessionID + + app.applyStreamedToolEvent(t.Context(), workflowSubmittedToolEvent(run.Task.ID)) + assert.Equal(t, []string{run.Task.ID}, app.pendingWorkflowRunIDs()) +} diff --git a/internal/tui/buffer.go b/internal/tui/buffer.go index 1d1408a1..51ad9665 100644 --- a/internal/tui/buffer.go +++ b/internal/tui/buffer.go @@ -105,21 +105,46 @@ func (buffer *CellBuffer) Clone() *CellBuffer { } cloned := &CellBuffer{ - cells: make([]Cell, len(buffer.cells)), width: buffer.width, height: buffer.height, + cells: make([]Cell, len(buffer.cells)), } - for index, cell := range buffer.cells { - cloned.cells[index] = Cell{ - Rune: cell.Rune, - Comb: append([]rune(nil), cell.Comb...), - Style: cell.Style, - } - } + cloned.store(buffer) return cloned } +// store overwrites the buffer with a deep copy of frame, reusing previously +// allocated cell and combining-rune storage whenever possible. +func (buffer *CellBuffer) store(frame *CellBuffer) { + buffer.width = frame.width + + buffer.height = frame.height + if cap(buffer.cells) < len(frame.cells) { + buffer.cells = make([]Cell, len(frame.cells)) + } else { + buffer.cells = buffer.cells[:len(frame.cells)] + } + + for index, cell := range frame.cells { + copied := buffer.cells[index] + copied.Rune = cell.Rune + + copied.Style = cell.Style + switch { + case len(cell.Comb) == 0: + copied.Comb = nil + case cap(copied.Comb) < len(cell.Comb): + copied.Comb = append([]rune(nil), cell.Comb...) + default: + copied.Comb = copied.Comb[:len(cell.Comb)] + copy(copied.Comb, cell.Comb) + } + + buffer.cells[index] = copied + } +} + // Renderer flushes changed cells to a screen. type Renderer struct { screen ContentSetter @@ -147,14 +172,24 @@ func (renderer *Renderer) Flush(frame *CellBuffer) { force := renderer.previous == nil || renderer.previous.width != frame.width || renderer.previous.height != frame.height - for y := range frame.height { - for x := range frame.width { - cell := frame.Cell(x, y) - if force || !cell.Equal(renderer.previous.Cell(x, y)) { - renderer.screen.SetContent(x, y, cell.Rune, cell.Comb, cell.Style) + renderer.writeChanged(frame, force) + + if renderer.previous == nil { + renderer.previous = NewCellBuffer(frame.width, frame.height, tcell.StyleDefault) + } + + renderer.previous.store(frame) +} + +func (renderer *Renderer) writeChanged(frame *CellBuffer, force bool) { + for row := range frame.height { + for column := range frame.width { + cell := frame.Cell(column, row) + if !force && cell.Equal(renderer.previous.Cell(column, row)) { + continue } + + renderer.screen.SetContent(column, row, cell.Rune, cell.Comb, cell.Style) } } - - renderer.previous = frame.Clone() } diff --git a/internal/tui/renderer_test.go b/internal/tui/renderer_test.go index 8f98bf8a..c94b0e93 100644 --- a/internal/tui/renderer_test.go +++ b/internal/tui/renderer_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/gdamore/tcell/v3" + cellcolor "github.com/gdamore/tcell/v3/color" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -40,3 +41,225 @@ func TestRendererFlushWritesOnlyChangedCells(t *testing.T) { assert.Equal(t, 'x', screen.cells[[2]int{1, 0}]) } + +type benchmarkScreen struct{} + +func (screen *benchmarkScreen) SetContent(_, _ int, _ rune, _ []rune, _ tcell.Style) {} + +// benchmarkSize builds a representative frame with bordered rows, styled +// text lines, and one combining-rune cell so the diff path stays realistic. +func benchmarkSize(width, height int) *tui.CellBuffer { + accent := tcell.StyleDefault.Foreground(cellcolor.White).Bold(true) + + frame := tui.NewCellBuffer(width, height, tcell.StyleDefault) + for row := range height { + for column := range width { + switch { + case row == 0 || row == height-1 || column == 0 || column == width-1: + frame.SetContent(column, row, '-', nil, accent) + case row%3 == 0: + frame.SetContent(column, row, 'x', nil, accent) + case row%3 == 1: + frame.SetContent(column, row, 'o', nil, tcell.StyleDefault) + } + } + } + + frame.SetContent(1, 1, 'e', []rune{'\u0301'}, tcell.StyleDefault) + + return frame +} + +// benchmarkFrame builds a representative 120x50 frame. +func benchmarkFrame() *tui.CellBuffer { + return benchmarkSize(120, 50) +} + +// BenchmarkRendererFlushSteadyState measures the unchanged-frame flush, the +// common case during idle or redraws that produce identical output. +func BenchmarkRendererFlushSteadyState(b *testing.B) { + frame := benchmarkFrame() + renderer := tui.NewRenderer(&benchmarkScreen{}) + renderer.Flush(frame) + + b.ReportAllocs() + + for b.Loop() { + renderer.Flush(frame) + } +} + +// BenchmarkRendererFlushAllChanged measures the diff path plus the per-flush +// snapshot store, with a small change between frames. +func BenchmarkRendererFlushAllChanged(b *testing.B) { + renderer := tui.NewRenderer(&benchmarkScreen{}) + frame := benchmarkFrame() + frame.SetContent(60, 25, 'z', nil, tcell.StyleDefault) + renderer.Flush(benchmarkFrame()) + + b.ReportAllocs() + + for b.Loop() { + frame.SetContent(60, 25, 'y', nil, tcell.StyleDefault) + renderer.Flush(frame) + frame.SetContent(60, 25, 'z', nil, tcell.StyleDefault) + } +} + +// BenchmarkRendererFlushResize measures the force-redraw path taken after +// terminal resizes between a wide and a narrow frame. +func BenchmarkRendererFlushResize(b *testing.B) { + renderer := tui.NewRenderer(&benchmarkScreen{}) + wide := benchmarkSize(120, 50) + narrow := benchmarkSize(80, 50) + + renderer.Flush(wide) + + b.ReportAllocs() + + for b.Loop() { + renderer.Flush(narrow) + renderer.Flush(wide) + } +} + +// flushWrite records a single screen write performed by Flush. +type flushWrite struct { + style tcell.Style + combining []rune + x int + y int + primary rune +} + +// newFlushWrite pins every field so assertions compare full writes. +func newFlushWrite(x, y int, primary rune, combining []rune, style tcell.Style) flushWrite { + return flushWrite{x: x, y: y, primary: primary, combining: combining, style: style} +} + +type flushRecordingScreen struct { + writes []flushWrite +} + +func (screen *flushRecordingScreen) SetContent(x, y int, primary rune, combining []rune, style tcell.Style) { + screen.writes = append(screen.writes, newFlushWrite(x, y, primary, append([]rune(nil), combining...), style)) +} + +// drawFrame writes a small 4x3 sample frame with a border, styled glyphs, and +// one combining-rune cell. +func drawFrame(buffer *tui.CellBuffer, combineE bool, runeAt11 rune, styleAt11 tcell.Style) { + accent := tcell.StyleDefault.Foreground(cellcolor.White).Bold(true) + + buffer.SetContent(0, 0, '┌', nil, accent) + buffer.SetContent(1, 0, '─', nil, accent) + buffer.SetContent(3, 2, '┘', nil, accent) + + var combining []rune + if combineE { + combining = []rune{'\u0301'} + } + + buffer.SetContent(1, 1, runeAt11, combining, styleAt11) +} + +// expectedFirstFlush returns the writes a first Flush of drawFrame's layout +// must produce: every cell, in scan order, blanks included. +func expectedFirstFlush() []flushWrite { + plain := tcell.StyleDefault + accent := tcell.StyleDefault.Foreground(cellcolor.White).Bold(true) + + writes := make([]flushWrite, 0, 12) + + for row := range 3 { + for column := range 4 { + primary, style := ' ', plain + + switch { + case column == 0 && row == 0: + primary, style = '┌', accent + case column == 1 && row == 0: + primary, style = '─', accent + case column == 3 && row == 2: + primary, style = '┘', accent + } + + writes = append(writes, newFlushWrite(column, row, primary, nil, style)) + } + } + + writes[5] = newFlushWrite(1, 1, 'e', []rune{'\u0301'}, plain) + + return writes +} + +func TestRendererFlushOutputSequence(t *testing.T) { + t.Parallel() + + plain := tcell.StyleDefault + accent := tcell.StyleDefault.Foreground(cellcolor.White).Bold(true) + screen := &flushRecordingScreen{writes: nil} + renderer := tui.NewRenderer(screen) + + // The first flush writes every cell because there is no previous frame. + frame := tui.NewCellBuffer(4, 3, plain) + drawFrame(frame, true, 'e', plain) + renderer.Flush(frame) + require.Equal(t, expectedFirstFlush(), screen.writes) + + // An identical frame must produce no writes. + same := tui.NewCellBuffer(4, 3, plain) + drawFrame(same, true, 'e', plain) + renderer.Flush(same) + assert.Empty(t, screen.writes[len(expectedFirstFlush()):]) + + // Only the changed cell is written; the combining rune is dropped. + changed := tui.NewCellBuffer(4, 3, plain) + drawFrame(changed, false, 'E', accent) + renderer.Flush(changed) + assert.Equal(t, []flushWrite{newFlushWrite(1, 1, 'E', nil, accent)}, screen.writes[len(expectedFirstFlush()):]) + + // A resize forces every cell to be rewritten. + before := len(screen.writes) + resized := tui.NewCellBuffer(2, 2, plain) + resized.SetContent(0, 0, 'a', nil, accent) + renderer.Flush(resized) + assert.Equal(t, []flushWrite{ + newFlushWrite(0, 0, 'a', nil, accent), + newFlushWrite(1, 0, ' ', nil, plain), + newFlushWrite(0, 1, ' ', nil, plain), + newFlushWrite(1, 1, ' ', nil, plain), + }, screen.writes[before:]) + + // Shrinking back reuses the larger previous buffer and only writes the + // cells that differ, proving stale rows are neither compared nor written. + before = len(screen.writes) + blank := tui.NewCellBuffer(2, 2, plain) + renderer.Flush(blank) + assert.Equal(t, []flushWrite{newFlushWrite(0, 0, ' ', nil, plain)}, screen.writes[before:]) +} + +func TestRendererFlushKeepsPreviousFrameIndependent(t *testing.T) { + t.Parallel() + + style := tcell.StyleDefault + frame := tui.NewCellBuffer(2, 2, style) + frame.SetContent(0, 0, 'e', []rune{'\u0301'}, style) + + screen := &flushRecordingScreen{writes: nil} + renderer := tui.NewRenderer(screen) + + // The first flush writes every cell; only the combining-rune cell matters + // for this assertion, so pin it before mutating the frame. + renderer.Flush(frame) + require.Equal(t, newFlushWrite(0, 0, 'e', []rune{'\u0301'}, style), screen.writes[0]) + firstFlushWrites := len(screen.writes) + + // Mutating the flushed frame must not alter the renderer's snapshot. + frame.SetContent(0, 0, 'z', nil, style) + frame.SetContent(1, 0, 'y', nil, style) + renderer.Flush(frame) + assert.Equal(t, []flushWrite{ + newFlushWrite(0, 0, 'z', nil, style), + newFlushWrite(1, 0, 'y', nil, style), + }, screen.writes[firstFlushWrites:]) +} From 0c498223bae6a74f20f4d0a546165210558ca14d Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 20:28:18 -0500 Subject: [PATCH 08/30] refactor: finish exhaustruct compliance and edit sorting migration - Explicit mutex field initialization in workflow dispatcher/service, agent_tasks, and tool_executor constructors - Explicit zero-value field init in task registry constructor - Replace edit insertion sort with slices.SortStableFunc (edit_diff) --- internal/assistant/tool_executor.go | 6 ++++-- internal/terminal/agent_tasks.go | 1 + internal/tool/edit_diff.go | 15 ++++----------- internal/workflow/dispatcher.go | 3 ++- internal/workflow/service.go | 3 ++- internal/workflow/workflow_internal_test.go | 4 +++- internal/workflow/workflow_test.go | 3 ++- 7 files changed, 18 insertions(+), 17 deletions(-) diff --git a/internal/assistant/tool_executor.go b/internal/assistant/tool_executor.go index 88211a63..80583255 100644 --- a/internal/assistant/tool_executor.go +++ b/internal/assistant/tool_executor.go @@ -229,7 +229,8 @@ func (*Runtime) executePreparedToolCall( } scope := &toolInvocationScope{ - onEvent: onEvent, parentCallID: prepared.call.ID, nextSequence: 0, mu: sync.Mutex{}, + onEvent: onEvent, parentCallID: prepared.call.ID, nextSequence: 0, + mu: sync.Mutex{}, } nestedCtx := context.WithValue(ctx, toolInvocationContextKey{}, scope) nestedCtx = withTaskInvocation(nestedCtx, &tooltask.Invocation{ @@ -353,7 +354,8 @@ func (runtime *Runtime) invokeToolResult( emitProviderToolStart(onEvent, callEvent) scope := &toolInvocationScope{ - onEvent: onEvent, parentCallID: callEvent.ID, nextSequence: 0, mu: sync.Mutex{}, + onEvent: onEvent, parentCallID: callEvent.ID, nextSequence: 0, + mu: sync.Mutex{}, } nestedCtx := context.WithValue(ctx, toolInvocationContextKey{}, scope) nestedCtx = withTaskInvocation(nestedCtx, &tooltask.Invocation{ diff --git a/internal/terminal/agent_tasks.go b/internal/terminal/agent_tasks.go index b9d790a9..4cd6cd19 100644 --- a/internal/terminal/agent_tasks.go +++ b/internal/terminal/agent_tasks.go @@ -120,6 +120,7 @@ func (app *App) resetAgentTaskTracking() { app.workflowPanelSnapshotValid = false app.workflowDetailSnapshotValid = false app.workflowProgress = map[string]workflowProgress{} + app.pendingWorkflowRuns = map[string]int{} app.workflowSteps = map[string][]database.WorkflowAgentTaskDetail{} app.agentTaskUsageTotals = map[string]model.UsageTotals{} app.workflowSummaryMetrics = map[string]workflowSummaryMetric{} diff --git a/internal/tool/edit_diff.go b/internal/tool/edit_diff.go index a27ab563..09bfeedd 100644 --- a/internal/tool/edit_diff.go +++ b/internal/tool/edit_diff.go @@ -1,6 +1,7 @@ package tool import ( + "cmp" "errors" "fmt" "slices" @@ -270,17 +271,9 @@ func applyMatchedEdits(baseContent string, edits []matchedEdit) string { } func sortMatchedEdits(edits []matchedEdit) { - for leftIndex := 1; leftIndex < len(edits); leftIndex++ { - currentEdit := edits[leftIndex] - - rightIndex := leftIndex - 1 - for rightIndex >= 0 && edits[rightIndex].matchIndex > currentEdit.matchIndex { - edits[rightIndex+1] = edits[rightIndex] - rightIndex-- - } - - edits[rightIndex+1] = currentEdit - } + slices.SortStableFunc(edits, func(left, right matchedEdit) int { + return cmp.Compare(left.matchIndex, right.matchIndex) + }) } func notFoundError(displayPath string, editIndex, totalEdits int) error { diff --git a/internal/workflow/dispatcher.go b/internal/workflow/dispatcher.go index 0ab562d3..2690eae9 100644 --- a/internal/workflow/dispatcher.go +++ b/internal/workflow/dispatcher.go @@ -90,7 +90,8 @@ func NewStoppedDispatcher(ctx context.Context, options DispatcherOptions) (*Disp service: options.Service, tasks: options.Tasks, logger: logger, queue: make(chan string, buffer), cancel: nil, done: nil, wg: sync.WaitGroup{}, submits: sync.WaitGroup{}, - interval: interval, concurrency: concurrency, mu: sync.Mutex{}, closed: false, started: false, + interval: interval, concurrency: concurrency, closed: false, started: false, + mu: sync.Mutex{}, } return dispatcher, nil diff --git a/internal/workflow/service.go b/internal/workflow/service.go index 4d0336b7..7b1aabd7 100644 --- a/internal/workflow/service.go +++ b/internal/workflow/service.go @@ -79,7 +79,8 @@ func NewService(runs *database.WorkflowRepository, runner *Runner) (*Service, er return &Service{ runs: runs, runner: runner, active: make(map[string]context.CancelFunc), - leaseOwner: leaseOwner.String(), mu: sync.Mutex{}, + leaseOwner: leaseOwner.String(), + mu: sync.Mutex{}, }, nil } diff --git a/internal/workflow/workflow_internal_test.go b/internal/workflow/workflow_internal_test.go index 31dc377b..4c963096 100644 --- a/internal/workflow/workflow_internal_test.go +++ b/internal/workflow/workflow_internal_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "sync" + "testing" "time" @@ -341,7 +342,8 @@ func newBranchHost(controller Controller) *runHost { controller: controller, runID: "", onEvent: nil, ownerSessionID: testBranchOwner, launched: make(map[string]struct{}), invocations: make(map[string]int), persisted: make(map[invocationKey]persistedInvocation), - taskIDs: make([]string, 0), mu: sync.Mutex{}, launchMu: sync.Mutex{}, eventMu: sync.Mutex{}, + taskIDs: make([]string, 0), + mu: sync.Mutex{}, launchMu: sync.Mutex{}, eventMu: sync.Mutex{}, } } diff --git a/internal/workflow/workflow_test.go b/internal/workflow/workflow_test.go index b6615f3f..1d81d001 100644 --- a/internal/workflow/workflow_test.go +++ b/internal/workflow/workflow_test.go @@ -520,7 +520,8 @@ func updatePeak(peak *atomic.Int64, value int64) { func newFakeController() *fakeController { return &fakeController{ tasks: make(map[string]*database.AgentTaskEntity), await: nil, submitCh: nil, taskIDs: nil, - submits: nil, cancels: nil, cancelSources: nil, mu: sync.Mutex{}, + submits: nil, cancels: nil, cancelSources: nil, + mu: sync.Mutex{}, } } From 584cb2e7b6d11f11aa7c6f7529e2bc6c22d2f25f Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 23:19:49 -0500 Subject: [PATCH 09/30] fix(ci): pin go 1.27 for govulncheck, run golangci-lint via go tool, add in-repo CodeQL workflow --- .github/workflows/ci.yml | 5 ++-- .github/workflows/codeql.yml | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4720c2a2..7e3e7cb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,8 @@ jobs: - name: Run govulncheck uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1 with: - go-version-file: go.mod + go-version-input: '1.27.0' + check-latest: false go-package: ./... repo-checkout: false @@ -45,7 +46,7 @@ jobs: uses: arduino/setup-task@c0bc642852239c2689f73f4ea6459c29405f3c52 # v3.0.0 - name: golangci-lint - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 + run: go tool golangci-lint run ./... - name: Test run: task test-coverage diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..684f3abd --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,44 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '27 3 * * 1' + +permissions: + contents: read + +jobs: + analyze: + name: Analyze Go + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version: '1.27.0' + check-latest: false + cache: true + + - name: Initialize CodeQL + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 + with: + languages: go + build-mode: autobuild + + - name: Analyze + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 + with: + category: '/language:go' From 95d27cf67129ffa32208f859692855916f5ad251 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 23:20:01 -0500 Subject: [PATCH 10/30] fix(agenttask): return persisted lease expiry instead of deriving it from post-call clock --- internal/agenttask/service.go | 49 ++++++++----- internal/agenttask/service_internal_test.go | 78 ++++++++++++++++----- 2 files changed, 91 insertions(+), 36 deletions(-) diff --git a/internal/agenttask/service.go b/internal/agenttask/service.go index 967f536c..5b860129 100644 --- a/internal/agenttask/service.go +++ b/internal/agenttask/service.go @@ -882,15 +882,18 @@ func (service *Service) renewLease( case <-ctx.Done(): return case <-ticker.C: - if service.renewLeaseWithRetry(ctx, taskID, validUntil) { - validUntil = time.Now().Add(service.leaseDuration) + renewedUntil, ok := service.renewLeaseWithRetry(ctx, taskID, validUntil) + if !ok { + cancel() - continue + return } - cancel() - - return + // Track the expiry that was actually persisted, not a value derived + // from local clock time after the (possibly slow) renewal call: + // deriving it locally can extend the retry deadline past the stored + // lease, letting another worker acquire the task mid-retry. + validUntil = renewedUntil } } } @@ -901,7 +904,11 @@ func (service *Service) renewLease( // up to its busy_timeout for the write lock, so each attempt gets // min(busy grace, remaining lease validity) — a busy database keeps its chance // to succeed while genuine lease loss (renewed=false) still fails fast. -func (service *Service) renewLeaseWithRetry(ctx context.Context, taskID string, deadline time.Time) bool { +func (service *Service) renewLeaseWithRetry( + ctx context.Context, + taskID string, + deadline time.Time, +) (time.Time, bool) { const busyGrace = leaseBusyGrace for attempt := 1; ; attempt++ { @@ -911,16 +918,16 @@ func (service *Service) renewLeaseWithRetry(ctx context.Context, taskID string, "lease_owner", service.leaseOwner, "attempts", attempt-1, "lease_duration", service.leaseDuration) - return false + return time.Time{}, false } - renewed, err := service.attemptLeaseRenewal(ctx, taskID, timeout) + renewedUntil, renewed, err := service.attemptLeaseRenewal(ctx, taskID, timeout) if err == nil { - return service.handleLeaseRenewal(ctx, taskID, attempt, renewed) + return renewedUntil, service.handleLeaseRenewal(ctx, taskID, attempt, renewed) } if ctx.Err() != nil { - return false + return time.Time{}, false } if !time.Now().Before(deadline) { @@ -928,7 +935,7 @@ func (service *Service) renewLeaseWithRetry(ctx context.Context, taskID string, "lease_owner", service.leaseOwner, "attempts", attempt, "lease_duration", service.leaseDuration, "error", err) - return false + return time.Time{}, false } service.logWarn(ctx, "retry agent task lease renewal", "task_id", taskID, @@ -938,18 +945,26 @@ func (service *Service) renewLeaseWithRetry(ctx context.Context, taskID string, retryDelay := min(service.leaseRenewalRetryInterval, time.Until(deadline)) if !waitForLeaseRenewalRetry(ctx, retryDelay) { - return false + return time.Time{}, false } } } -func (service *Service) attemptLeaseRenewal(ctx context.Context, taskID string, timeout time.Duration) (bool, error) { +func (service *Service) attemptLeaseRenewal( + ctx context.Context, + taskID string, + timeout time.Duration, +) (time.Time, bool, error) { attemptCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - return service.renewLeaseFn( - attemptCtx, taskID, service.leaseOwner, time.Now().Add(service.leaseDuration), - ) + // Capture the expiry before the call so a delayed response returns the + // exact expiry that was persisted, never one shifted by call duration. + renewedUntil := time.Now().Add(service.leaseDuration) + + renewed, err := service.renewLeaseFn(attemptCtx, taskID, service.leaseOwner, renewedUntil) + + return renewedUntil, renewed, err } func (service *Service) handleLeaseRenewal( diff --git a/internal/agenttask/service_internal_test.go b/internal/agenttask/service_internal_test.go index 9510af8d..4aaa9f0b 100644 --- a/internal/agenttask/service_internal_test.go +++ b/internal/agenttask/service_internal_test.go @@ -1077,7 +1077,8 @@ func TestServiceInternalLeaseRenewalRetriesTransientDatabaseErrors(t *testing.T) return true, nil }) - assert.True(t, service.renewLeaseWithRetry(t.Context(), "task", time.Now().Add(time.Minute))) + _, ok := service.renewLeaseWithRetry(t.Context(), "task", time.Now().Add(time.Minute)) + assert.True(t, ok) assert.Equal(t, int32(3), attempts.Load()) assert.Contains(t, logs.String(), "retry agent task lease renewal") assert.NotContains(t, logs.String(), "renew agent task lease after retries") @@ -1097,10 +1098,35 @@ func TestServiceInternalLeaseRenewalAttemptHonorsRemainingLeaseOverFixedTimeout( // A remaining lease validity beyond the old fixed attempt timeout (2s) // must extend the attempt deadline: a busy database then waits for the // SQLite write lock instead of the attempt aborting early. - assert.True(t, service.renewLeaseWithRetry(t.Context(), "task", time.Now().Add(time.Minute))) + _, ok := service.renewLeaseWithRetry(t.Context(), "task", time.Now().Add(time.Minute)) + assert.True(t, ok) assert.GreaterOrEqual(t, time.Until(deadline), leaseBusyGrace-time.Second) } +func TestServiceInternalLeaseRenewalReturnsPersistedExpiryDespiteDelayedResponse(t *testing.T) { + t.Parallel() + + // A renewal that succeeds after blocking must report the expiry that was + // sent to the database, not one shifted forward by the call duration: + // a locally derived expiry could extend the next retry deadline past the + // stored lease, letting another worker acquire the task mid-retry. + const delay = 150 * time.Millisecond + + service := leaseRenewalService(&bytes.Buffer{}, + func(_ context.Context, _ string, _ string, _ time.Time) (bool, error) { + time.Sleep(delay) + + return true, nil + }) + + renewedUntil, ok := service.renewLeaseWithRetry(t.Context(), "task", time.Now().Add(time.Minute)) + assert.True(t, ok) + assert.LessOrEqual( + t, time.Until(renewedUntil), service.leaseDuration, + "returned expiry must not extend past leaseDuration from the pre-call clock read", + ) +} + func TestServiceInternalLeaseRenewalFailsOnlyWhenLeaseExpires(t *testing.T) { t.Parallel() @@ -1118,9 +1144,8 @@ func TestServiceInternalLeaseRenewalFailsOnlyWhenLeaseExpires(t *testing.T) { // Renewal keeps retrying for the full lease validity instead of an // arbitrary retry window, so a transient outage shorter than the lease // must never forfeit it. - assert.False(t, service.renewLeaseWithRetry( - t.Context(), "task", time.Now().Add(100*time.Millisecond), - )) + _, ok := service.renewLeaseWithRetry(t.Context(), "task", time.Now().Add(100*time.Millisecond)) + assert.False(t, ok) assert.GreaterOrEqual(t, attempts.Load(), int32(3)) assert.Contains(t, logs.String(), "renew agent task lease after lease expiry") assert.Contains(t, logs.String(), "lease_duration") @@ -1137,21 +1162,40 @@ func TestServiceInternalLeaseRenewalSurvivesTransientOutageShorterThanLease(t *t // canceling the run, as the old bounded renewal window would have. const outage = 250 * time.Millisecond - service := leaseRenewalService(&bytes.Buffer{}, func(context.Context, string, string, time.Time) (bool, error) { + service := leaseRenewalService(&bytes.Buffer{}, nil) + service.leaseHeartbeatInterval = time.Millisecond + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + // Signal the first successful renewal after the outage so the test can + // stop the loop immediately: without cancellation, the goroutine keeps + // heartbeating until the parent test context deadline, adding five + // seconds to every parallel run. + recovered := make(chan struct{}) + + var recoveredOnce sync.Once + + service.renewLeaseFn = func(context.Context, string, string, time.Time) (bool, error) { if time.Since(startedAt) < outage { return false, errors.New("database is locked") } - return true, nil - }) - service.leaseHeartbeatInterval = time.Millisecond + recoveredOnce.Do(func() { close(recovered) }) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() + return true, nil + } done := make(chan struct{}) go service.renewLease(ctx, func() { canceled.Store(true) }, "task", done) + select { + case <-recovered: + case <-time.After(5 * time.Second): + t.Fatal("lease renewal never recovered after the transient outage") + } + + cancel() <-done assert.False(t, canceled.Load(), "transient renewal outage canceled the run") } @@ -1159,14 +1203,9 @@ func TestServiceInternalLeaseRenewalSurvivesTransientOutageShorterThanLease(t *t func TestServiceInternalLeaseRenewalExhaustionCancelsLongRun(t *testing.T) { t.Parallel() - var ( - attempts atomic.Int32 - logs bytes.Buffer - ) + var logs bytes.Buffer service := leaseRenewalService(&logs, func(context.Context, string, string, time.Time) (bool, error) { - attempts.Add(1) - return false, errors.New("database is locked") }) service.leaseHeartbeatInterval = time.Millisecond @@ -1178,8 +1217,9 @@ func TestServiceInternalLeaseRenewalExhaustionCancelsLongRun(t *testing.T) { receive(t, ctx.Done(), &struct{}{}, "lease renewal did not cancel the run") receive(t, done, &struct{}{}, "timed out waiting for lease renewal to stop") - assert.GreaterOrEqual(t, attempts.Load(), int32(1), - "renewal should have been attempted at least once before exhaustion") + // The attempts count is not asserted: under -race load the goroutine can + // start after the short lease already expired, so exhaustion with zero + // attempts is a valid outcome of the behavior under test. assert.Contains(t, logs.String(), "renew agent task lease after lease expiry") assert.Contains(t, logs.String(), "lease_duration") } From 3d992b75e2be28a782a10b47bce089d877371f77 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 23:20:02 -0500 Subject: [PATCH 11/30] fix(terminal): reconcile loaded workflow runs during partial lookup failure --- internal/terminal/workflow_submission.go | 14 +++++--- .../workflow_submission_internal_test.go | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/internal/terminal/workflow_submission.go b/internal/terminal/workflow_submission.go index 438316b0..6c50bb85 100644 --- a/internal/terminal/workflow_submission.go +++ b/internal/terminal/workflow_submission.go @@ -50,16 +50,22 @@ func (app *App) reconcilePendingWorkflows(ctx context.Context, snapshot *termina lookups := snapshot.WorkflowByID for _, runID := range app.pendingWorkflowRunIDs() { - if !lookups.Valid { - break - } - + // Entries already loaded successfully are reconciled even when a later + // lookup failed: a partial failure must not strand terminal runs until + // the retry limit — their completion still delivers exactly once. if run, found := lookups.Value[runID]; found { app.reconcilePendingWorkflowRun(ctx, runID, run) continue } + // An invalid section means a lookup failed; entries absent from Value + // are unresolved, so keep them pending for the next refresh instead of + // discarding. + if !lookups.Valid { + break + } + // A run listed by ListActive was not looked up again by ID. Resolve it // from the listed section so its terminal transition still delivers once. if run := listedWorkflowRun(snapshot.ActiveWorkflow.Value, runID); run != nil { diff --git a/internal/terminal/workflow_submission_internal_test.go b/internal/terminal/workflow_submission_internal_test.go index 336e5594..85410a9f 100644 --- a/internal/terminal/workflow_submission_internal_test.go +++ b/internal/terminal/workflow_submission_internal_test.go @@ -183,6 +183,38 @@ func TestWorkflowSubmissionCleansUpMissingRunIDs(t *testing.T) { assert.NotContains(t, app.pendingWorkflowRuns, "flaky-run") } +// A later failed lookup must not strand runs that were already loaded +// successfully in the same refresh: terminal entries still deliver their +// completion, while the unresolved ID stays pending for the next refresh. +func TestWorkflowSubmissionReconcilesLoadedRunsDuringPartialLookupFailure(t *testing.T) { + t.Parallel() + + loaded := workflowSummaryRun("loaded-run", database.TaskSucceeded) + app := newRenderTestApp(t) + app.sessionID = workflowTestSessionID + app.trackSubmittedWorkflow(workflowSubmittedToolEvent(loaded.Task.ID)) + app.trackSubmittedWorkflow(workflowSubmittedToolEvent("unresolved-run")) + + // The exact-ID lookup for loaded-run succeeded; the one for + // unresolved-run failed, invalidating the section as a whole. + snapshot := newTerminalRefreshSnapshot(app.sessionID) + snapshot.ActiveWorkflow = refreshSection([]database.WorkflowRunEntity{}) + snapshot.WorkflowByID = terminalRefreshSection[map[string]*database.WorkflowRunEntity]{ + Value: map[string]*database.WorkflowRunEntity{loaded.Task.ID: &loaded}, + Err: assert.AnError, + Valid: false, + } + + app.applyTerminalRefreshSnapshot(t.Context(), &snapshot) + + // The successfully loaded terminal run is reconciled despite the invalid + // section: its completion delivered and it left the pending set. + assert.NotContains(t, app.pendingWorkflowRuns, loaded.Task.ID) + assert.Empty(t, app.activeWorkflows) + // The unresolved run was never loaded, so it stays pending for retry. + assert.Contains(t, app.pendingWorkflowRuns, "unresolved-run") +} + func TestWorkflowSubmissionResolvesListedRunWithoutDuplicateLookup(t *testing.T) { t.Parallel() From 8bd174a3d539d50a80f217e526016a1b909ea3ac Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 23:20:03 -0500 Subject: [PATCH 12/30] refactor(tool): deduplicate fetch rebinding dial-pin test cases --- internal/tool/fetch_internal_test.go | 71 ++++++++++++++++++---------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/internal/tool/fetch_internal_test.go b/internal/tool/fetch_internal_test.go index 2df24eae..4c52e280 100644 --- a/internal/tool/fetch_internal_test.go +++ b/internal/tool/fetch_internal_test.go @@ -429,7 +429,7 @@ type fetchTestDialPinCase struct { } func fetchTestDialPinCases() []fetchTestDialPinCase { - return []fetchTestDialPinCase{ + cases := append([]fetchTestDialPinCase{ { name: "public host dials pinned validated ip", lookups: [][]net.IPAddr{{{IP: net.ParseIP("93.184.216.34")}}}, @@ -437,42 +437,48 @@ func fetchTestDialPinCases() []fetchTestDialPinCase { wantDialedIP: "93.184.216.34", }, { - name: "resolver rebinding to loopback is rejected at dial time", - lookups: [][]net.IPAddr{ - {{IP: net.ParseIP("93.184.216.34")}}, - {{IP: net.ParseIP("127.0.0.1")}}, - }, - wantErr: fetchTestWantPrivateErr, - wantDialedIP: "", - }, - { - name: "resolver rebinding to private network is rejected at dial time", + name: "ambiguous empty re-resolution fails closed", lookups: [][]net.IPAddr{ {{IP: net.ParseIP("93.184.216.34")}}, - {{IP: net.ParseIP("192.168.0.5")}}, + {}, }, - wantErr: fetchTestWantPrivateErr, + wantErr: fetchTestWantNoAddrErr, wantDialedIP: "", }, - { - name: "resolver rebinding to link local metadata address is rejected", + }, fetchTestRebindingDialPinCases()...) + + return cases +} + +// fetchTestRebindingDialPinCases builds dial-rebinding cases sharing one shape: +// the pre-flight lookup returns the public example IP, then the dial-time +// re-resolution returns the hostile rebinding IP, which must be rejected before +// any connection is attempted. Generating the near-identical entries keeps the +// test table free of duplicated blocks. +func fetchTestRebindingDialPinCases() []fetchTestDialPinCase { + targets := []struct { + name string + ip string + }{ + {"loopback", "127.0.0.1"}, + {"private network", "192.168.0.5"}, + {"link local metadata address", "169.254.169.254"}, + } + + cases := make([]fetchTestDialPinCase, 0, len(targets)) + for _, target := range targets { + cases = append(cases, fetchTestDialPinCase{ + name: "resolver rebinding to " + target.name + " is rejected at dial time", lookups: [][]net.IPAddr{ {{IP: net.ParseIP("93.184.216.34")}}, - {{IP: net.ParseIP("169.254.169.254")}}, + {{IP: net.ParseIP(target.ip)}}, }, wantErr: fetchTestWantPrivateErr, wantDialedIP: "", - }, - { - name: "ambiguous empty re-resolution fails closed", - lookups: [][]net.IPAddr{ - {{IP: net.ParseIP("93.184.216.34")}}, - {}, - }, - wantErr: fetchTestWantNoAddrErr, - wantDialedIP: "", - }, + }) } + + return cases } func TestFetchTool_PinsDialedAddressToValidatedIP(t *testing.T) { @@ -672,6 +678,19 @@ func fetchTestResolvedDialCases() []fetchTestPinnedDialCase { wantErr: fetchTestWantPrivateErr, wantPin: "", }, + { + name: "dual stack tcp pins first validated address", + network: fetchTestNetworkTCP, + address: fetchTestExampleHostPort, + lookups: map[string][]net.IPAddr{ + fetchTestExampleHost: { + {IP: net.ParseIP("2606:2800:220:1:248:1893:25c8:1946")}, + {IP: net.ParseIP("93.184.216.34")}, + }, + }, + wantErr: "", + wantPin: "[2606:2800:220:1:248:1893:25c8:1946]:80", + }, { name: "trailing dot hostname is normalized", network: fetchTestNetworkTCP, From edd82df5135bae63a7ea8d5c371c8736059623f3 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 23:20:04 -0500 Subject: [PATCH 13/30] test(mapsutil): distinguish nil from empty map in CloneOrNil example --- internal/mapsutil/example_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mapsutil/example_test.go b/internal/mapsutil/example_test.go index 113fbaf5..1f16ac98 100644 --- a/internal/mapsutil/example_test.go +++ b/internal/mapsutil/example_test.go @@ -37,7 +37,7 @@ func ExampleClonePreserveNil() { // CloneOrNil collapses nil and empty input to nil, keeping `omitempty` // fields out of serialized output. func ExampleCloneOrNil() { - var empty map[string]string + empty := map[string]string{} fmt.Println(mapsutil.CloneOrNil(map[string]string(nil)) == nil) fmt.Println(mapsutil.CloneOrNil(empty) == nil) From 670f6de5ed8f66009798faca349a83b90b7a7333 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 23:20:05 -0500 Subject: [PATCH 14/30] test(tui): alternate cell content so Flush benchmark measures real changes --- internal/tui/renderer_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/tui/renderer_test.go b/internal/tui/renderer_test.go index c94b0e93..7609831b 100644 --- a/internal/tui/renderer_test.go +++ b/internal/tui/renderer_test.go @@ -99,10 +99,16 @@ func BenchmarkRendererFlushAllChanged(b *testing.B) { b.ReportAllocs() + current := 'y' for b.Loop() { - frame.SetContent(60, 25, 'y', nil, tcell.StyleDefault) + frame.SetContent(60, 25, current, nil, tcell.StyleDefault) renderer.Flush(frame) - frame.SetContent(60, 25, 'z', nil, tcell.StyleDefault) + + if current == 'y' { + current = 'z' + } else { + current = 'y' + } } } From 1e03862ecebff441fa76fff5b1f59b9be74c701d Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 23:20:07 -0500 Subject: [PATCH 15/30] chore(deps): update modernc.org/sqlite to v1.57.0 --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index 8b02c059..8e10e435 100644 --- a/go.sum +++ b/go.sum @@ -787,8 +787,6 @@ modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU= -modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= -modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= From 45b94e069b52a7b6d4bcdf59a7af35d79dccd2db Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Wed, 19 Aug 2026 23:25:00 -0500 Subject: [PATCH 16/30] fix(tool): extract duplicated fetch missing-host error message to constant --- internal/tool/fetch.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/tool/fetch.go b/internal/tool/fetch.go index 1094b22b..cea42081 100644 --- a/internal/tool/fetch.go +++ b/internal/tool/fetch.go @@ -35,6 +35,7 @@ const ( fetchReadLimitBytes = 5 * units.MiB fetchTextWrapWidth = 120 fetchUserAgent = "librecode/1.0 (+https://github.com/omarluq/librecode)" + fetchMissingHostMsg = "fetch url host is required" fetchAcceptHeader = "text/html,application/xhtml+xml,application/json,text/plain,*/*;q=0.8" fetchNoiseSelector = "script,style,noscript,template,nav,header,footer,aside,iframe,svg,form" fetchJSONContentType = "application/json" @@ -175,7 +176,7 @@ func parseFetchURL(rawURL string) (*url.URL, error) { } if parsedURL.Host == "" { - return nil, oops.In("tool").Code("fetch_missing_host").Errorf("fetch url host is required") + return nil, oops.In("tool").Code("fetch_missing_host").Errorf(fetchMissingHostMsg) } return parsedURL, nil @@ -441,7 +442,7 @@ func (fetchTool *FetchTool) pinnedFetchDialAddress( normalizedHost := normalizedFetchHost(host) if normalizedHost == "" { - return "", oops.In("tool").Code("fetch_missing_host").Errorf("fetch url host is required") + return "", oops.In("tool").Code("fetch_missing_host").Errorf(fetchMissingHostMsg) } if isLocalhostFetchHost(normalizedHost) { @@ -547,7 +548,7 @@ func (fetchTool *FetchTool) validatePublicFetchURL(ctx context.Context, requestU host := normalizedFetchHost(requestURL.Hostname()) if host == "" { - return oops.In("tool").Code("fetch_missing_host").Errorf("fetch url host is required") + return oops.In("tool").Code("fetch_missing_host").Errorf(fetchMissingHostMsg) } if isLocalhostFetchHost(host) { From 76c65ef625a45d33bf0fc2f3696375a5f2e070dd Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 00:07:16 -0500 Subject: [PATCH 17/30] fix(agenttask): seed renewLease deadline from claimed lease expiry instead of wall clock --- internal/agenttask/service.go | 11 +- internal/agenttask/service_internal_test.go | 129 +++++++++++++++++++- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/internal/agenttask/service.go b/internal/agenttask/service.go index 5b860129..78607b39 100644 --- a/internal/agenttask/service.go +++ b/internal/agenttask/service.go @@ -809,7 +809,7 @@ func (service *Service) execute(ctx context.Context, taskID string, task *databa runCtx, cancel := context.WithTimeout(ctx, timeout) heartbeatDone := make(chan struct{}) - go service.renewLease(runCtx, cancel, taskID, heartbeatDone) + go service.renewLease(runCtx, cancel, taskID, task.Task.LeaseExpiresAt, heartbeatDone) service.mu.Lock() service.active[taskID] = cancel @@ -868,6 +868,7 @@ func (service *Service) renewLease( ctx context.Context, cancel context.CancelFunc, taskID string, + leaseExpiresAt *time.Time, done chan<- struct{}, ) { defer close(done) @@ -875,7 +876,15 @@ func (service *Service) renewLease( ticker := time.NewTicker(service.leaseHeartbeatInterval) defer ticker.Stop() + // Seed the retry deadline from the persisted claim, not local clock time: + // claim-to-start latency means now+leaseDuration can extend the retry + // window past the stored lease, letting another worker acquire the task + // mid-retry. Fall back to the old computation when the claim carried no + // expiry. validUntil := time.Now().Add(service.leaseDuration) + if leaseExpiresAt != nil && !leaseExpiresAt.IsZero() { + validUntil = *leaseExpiresAt + } for { select { diff --git a/internal/agenttask/service_internal_test.go b/internal/agenttask/service_internal_test.go index 4aaa9f0b..d7c03ded 100644 --- a/internal/agenttask/service_internal_test.go +++ b/internal/agenttask/service_internal_test.go @@ -1187,7 +1187,7 @@ func TestServiceInternalLeaseRenewalSurvivesTransientOutageShorterThanLease(t *t } done := make(chan struct{}) - go service.renewLease(ctx, func() { canceled.Store(true) }, "task", done) + go service.renewLease(ctx, func() { canceled.Store(true) }, "task", nil, done) select { case <-recovered: @@ -1213,7 +1213,7 @@ func TestServiceInternalLeaseRenewalExhaustionCancelsLongRun(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) done := make(chan struct{}) - go service.renewLease(ctx, cancel, "task", done) + go service.renewLease(ctx, cancel, "task", nil, done) receive(t, ctx.Done(), &struct{}{}, "lease renewal did not cancel the run") receive(t, done, &struct{}{}, "timed out waiting for lease renewal to stop") @@ -1243,7 +1243,7 @@ func TestServiceInternalLeaseRenewsThroughoutLongRun(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) done := make(chan struct{}) - go service.renewLease(ctx, cancel, "task", done) + go service.renewLease(ctx, cancel, "task", nil, done) for range wantedRenewals { receive(t, renewed, &struct{}{}, "long-running task stopped renewing its lease") @@ -1254,6 +1254,129 @@ func TestServiceInternalLeaseRenewsThroughoutLongRun(t *testing.T) { assert.GreaterOrEqual(t, renewals.Load(), int32(wantedRenewals)) } +const ( + // claimExpiryMissing and claimExpiryZero are sentinel claimed-lease + // remaining times for the renewal seeding test. + claimExpiryMissing = -1 + claimExpiryZero = -2 +) + +func TestServiceInternalLeaseRenewalSeedsDeadlineFromClaimedLeaseExpiry(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + claimedRemaining time.Duration + wantDeadlineMin time.Duration + wantDeadlineMax time.Duration + }{ + { + // Claim-to-start latency means the persisted lease can expire far + // sooner than now+leaseDuration; the first renewal attempt must be + // bounded by the remaining claimed validity, not a fresh lease. + name: "claimed lease expiring sooner bounds the first attempt", + claimedRemaining: 500 * time.Millisecond, + wantDeadlineMin: 300 * time.Millisecond, + wantDeadlineMax: 600 * time.Millisecond, + }, + { + name: "missing claimed expiry falls back to now plus lease duration", + claimedRemaining: claimExpiryMissing, + wantDeadlineMin: leaseBusyGrace - 500*time.Millisecond, + wantDeadlineMax: leaseBusyGrace, + }, + { + name: "zero claimed expiry falls back to now plus lease duration", + claimedRemaining: claimExpiryZero, + wantDeadlineMin: leaseBusyGrace - 500*time.Millisecond, + wantDeadlineMax: leaseBusyGrace, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + claimedExpiry := claimedExpiryAfter(test.claimedRemaining) + assertLeaseRenewalAttemptDeadline(t, claimedExpiry, test.wantDeadlineMin, test.wantDeadlineMax) + }) + } +} + +// claimedExpiryAfter builds the claimed lease expiry for the renewal seeding +// test: positive durations become an in-the-future expiry (built now, not when +// the table was declared, so suite scheduling never starts past it), while the +// sentinels model a missing or zero expiry. +func claimedExpiryAfter(remaining time.Duration) *time.Time { + if remaining == claimExpiryMissing { + return nil + } + + if remaining == claimExpiryZero { + return &time.Time{} + } + + expiry := time.Now().Add(remaining) + + return &expiry +} + +// assertLeaseRenewalAttemptDeadline verifies that the first lease renewal +// attempt is bounded by the claimed lease expiry instead of a locally derived +// now+leaseDuration seed. +func assertLeaseRenewalAttemptDeadline( + t *testing.T, + claimedExpiry *time.Time, + wantDeadlineMin time.Duration, + wantDeadlineMax time.Duration, +) { + t.Helper() + + attemptDeadlines := make(chan time.Time, 1) + + renewLease := func(ctx context.Context, _ string, _ string, _ time.Time) (bool, error) { + deadline, _ := ctx.Deadline() + + select { + case attemptDeadlines <- deadline: + default: + } + + return true, nil + } + + // The service from leaseRenewalService has a one-minute lease duration, so + // a locally seeded deadline would grant the attempt the full busy grace + // instead of the shorter claimed validity. + service := leaseRenewalService(&bytes.Buffer{}, renewLease) + service.leaseHeartbeatInterval = time.Millisecond + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + done := make(chan struct{}) + go service.renewLease(ctx, cancel, "task", claimedExpiry, done) + + var deadline time.Time + + select { + case deadline = <-attemptDeadlines: + case <-time.After(5 * time.Second): + receive(t, done, &struct{}{}, "timed out waiting for lease renewal to stop") + + t.Fatal("lease renewal never attempted a renewal") + } + + cancel() + receive(t, done, &struct{}{}, "timed out waiting for lease renewal to stop") + + remaining := time.Until(deadline) + assert.GreaterOrEqual(t, remaining, wantDeadlineMin, + "attempt deadline must use the claimed lease expiry, not a locally derived one") + assert.LessOrEqual(t, remaining, wantDeadlineMax, + "attempt deadline must not extend past the claimed lease expiry") +} + // gateAwaitOnQueuedRead parks Await in its wait loop by signaling when its // first repository read observes the queued task. The returned channel // closes once; terminal transitions sent after it can only be observed From f65c8c2570d25f8e1746eb5cdbfbcfe1bd632c51 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 00:07:29 -0500 Subject: [PATCH 18/30] fix(terminal): continue past unresolved workflow run IDs during partial lookup reconciliation --- internal/terminal/workflow_submission.go | 2 +- .../workflow_submission_internal_test.go | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/internal/terminal/workflow_submission.go b/internal/terminal/workflow_submission.go index 6c50bb85..c6e6b078 100644 --- a/internal/terminal/workflow_submission.go +++ b/internal/terminal/workflow_submission.go @@ -63,7 +63,7 @@ func (app *App) reconcilePendingWorkflows(ctx context.Context, snapshot *termina // are unresolved, so keep them pending for the next refresh instead of // discarding. if !lookups.Valid { - break + continue } // A run listed by ListActive was not looked up again by ID. Resolve it diff --git a/internal/terminal/workflow_submission_internal_test.go b/internal/terminal/workflow_submission_internal_test.go index 85410a9f..649af5b9 100644 --- a/internal/terminal/workflow_submission_internal_test.go +++ b/internal/terminal/workflow_submission_internal_test.go @@ -215,6 +215,41 @@ func TestWorkflowSubmissionReconcilesLoadedRunsDuringPartialLookupFailure(t *tes assert.Contains(t, app.pendingWorkflowRuns, "unresolved-run") } +// The loop reconciles in sorted ID order, so an unresolved ID sorting first +// must not abort reconciliation of later IDs that loaded successfully: +// z-loaded still delivers its completion while a-unresolved stays pending. +func TestWorkflowSubmissionReconcilesSortedLaterRunsDuringPartialLookupFailure(t *testing.T) { + t.Parallel() + + loaded := workflowSummaryRun("z-loaded", database.TaskSucceeded) + app := newRenderTestApp(t) + app.sessionID = workflowTestSessionID + app.trackSubmittedWorkflow(workflowSubmittedToolEvent("a-unresolved")) + app.trackSubmittedWorkflow(workflowSubmittedToolEvent(loaded.Task.ID)) + require.Equal(t, []string{"a-unresolved", loaded.Task.ID}, app.pendingWorkflowRunIDs()) + + // The exact-ID lookup for a-unresolved failed and sorts first; the one for + // z-loaded succeeded, so Value holds only the loaded run. + snapshot := newTerminalRefreshSnapshot(app.sessionID) + snapshot.ActiveWorkflow = refreshSection([]database.WorkflowRunEntity{}) + snapshot.WorkflowByID = terminalRefreshSection[map[string]*database.WorkflowRunEntity]{ + Value: map[string]*database.WorkflowRunEntity{loaded.Task.ID: &loaded}, + Err: assert.AnError, + Valid: false, + } + + app.applyTerminalRefreshSnapshot(t.Context(), &snapshot) + + // z-loaded sorts after the unresolved ID but is still reconciled: its + // completion delivered and it left the pending set. + assert.NotContains(t, app.pendingWorkflowRuns, loaded.Task.ID) + assert.Empty(t, app.activeWorkflows) + assert.Contains(t, app.deliveredAgentTasks, loaded.Task.ID) + // a-unresolved sorts first and was never loaded, so it stays pending. + assert.Contains(t, app.pendingWorkflowRuns, "a-unresolved") + assert.NotContains(t, app.deliveredAgentTasks, "a-unresolved") +} + func TestWorkflowSubmissionResolvesListedRunWithoutDuplicateLookup(t *testing.T) { t.Parallel() From ad92e8a8f199d1b1fa0e898b2aae5eb106cc89cc Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 00:07:38 -0500 Subject: [PATCH 19/30] test(tool): give trailing-dot hostname case a distinct IP so pin assertion cannot be masked --- internal/tool/fetch_internal_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/tool/fetch_internal_test.go b/internal/tool/fetch_internal_test.go index 4c52e280..ef534ae1 100644 --- a/internal/tool/fetch_internal_test.go +++ b/internal/tool/fetch_internal_test.go @@ -695,11 +695,13 @@ func fetchTestResolvedDialCases() []fetchTestPinnedDialCase { name: "trailing dot hostname is normalized", network: fetchTestNetworkTCP, address: "Example.Test.:80", + // Distinct from the fetchTestLookupTool fallback IP so a + // normalization regression hits the fallback and fails this case. lookups: map[string][]net.IPAddr{ - fetchTestExampleHost: {{IP: net.ParseIP("93.184.216.34")}}, + fetchTestExampleHost: {{IP: net.ParseIP("198.51.100.10")}}, }, wantErr: "", - wantPin: "93.184.216.34:80", + wantPin: "198.51.100.10:80", }, } } From c7c6fa0cff1aafb6081284f648f480bcca0e6e29 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 00:07:47 -0500 Subject: [PATCH 20/30] test(mapsutil): check fmt.Println return values in examples via mustPrintln helper --- internal/mapsutil/example_test.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/mapsutil/example_test.go b/internal/mapsutil/example_test.go index 1f16ac98..a5165984 100644 --- a/internal/mapsutil/example_test.go +++ b/internal/mapsutil/example_test.go @@ -7,17 +7,24 @@ import ( "github.com/omarluq/librecode/internal/mapsutil" ) +func mustPrintln(values ...any) { + //nolint:forbidigo // helper used by godoc examples; error is checked, not ignored + if _, err := fmt.Println(values...); err != nil { + panic(err) + } +} + // CloneOrEmpty always yields a non-nil map, so nil input still serializes as // a JSON object instead of null. func ExampleCloneOrEmpty() { serialized, err := json.Marshal(mapsutil.CloneOrEmpty(map[string]int(nil))) if err != nil { - fmt.Println("error:", err) + mustPrintln("error:", err) return } - fmt.Println(string(serialized)) + mustPrintln(string(serialized)) // Output: {} } @@ -27,8 +34,8 @@ func ExampleClonePreserveNil() { empty := map[string]string{} - fmt.Println(mapsutil.ClonePreserveNil(unset) == nil) - fmt.Println(mapsutil.ClonePreserveNil(empty) == nil) + mustPrintln(mapsutil.ClonePreserveNil(unset) == nil) + mustPrintln(mapsutil.ClonePreserveNil(empty) == nil) // Output: // true // false @@ -39,9 +46,9 @@ func ExampleClonePreserveNil() { func ExampleCloneOrNil() { empty := map[string]string{} - fmt.Println(mapsutil.CloneOrNil(map[string]string(nil)) == nil) - fmt.Println(mapsutil.CloneOrNil(empty) == nil) - fmt.Println(mapsutil.CloneOrNil(map[string]string{"k": "v"})) + mustPrintln(mapsutil.CloneOrNil(map[string]string(nil)) == nil) + mustPrintln(mapsutil.CloneOrNil(empty) == nil) + mustPrintln(mapsutil.CloneOrNil(map[string]string{"k": "v"})) // Output: // true // true From 18eb27a5c4e4e846453175330c1dc676d2154cee Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 00:08:01 -0500 Subject: [PATCH 21/30] fix(ci): use manual build mode for CodeQL go analysis --- .github/workflows/codeql.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 684f3abd..669b1abc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,7 +36,12 @@ jobs: uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: languages: go - build-mode: autobuild + build-mode: manual + + - name: Build + run: | + mkdir -p bin + go build -v -o bin/librecode ./cmd/librecode - name: Analyze uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 From 6f8ecad7d95349a06ea0f0b54912bc00ec20919e Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 00:11:22 -0500 Subject: [PATCH 22/30] fix(provider): stop consuming SSE streams after terminal events --- internal/provider/anthropic_sse.go | 7 +++++-- internal/provider/client.go | 1 + internal/provider/openai_chat_sse.go | 4 +++- internal/provider/tool_loop.go | 9 +++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/internal/provider/anthropic_sse.go b/internal/provider/anthropic_sse.go index 933e463c..b48b1dcf 100644 --- a/internal/provider/anthropic_sse.go +++ b/internal/provider/anthropic_sse.go @@ -2,6 +2,7 @@ package provider import ( "encoding/json" + "errors" "io" "sort" "strings" @@ -106,7 +107,7 @@ func parseAnthropicStream(reader io.Reader, onEvent func(*llm.StreamChunk)) (*pr return accumulator.add(&decoded, onEvent) }) - if err != nil { + if err != nil && !errors.Is(err, errSSEDone) { return nil, err } @@ -145,6 +146,8 @@ func (accumulator *anthropicStreamAccumulator) add( accumulator.addMessageDelta(event) case anthropicMessageStopEvent: accumulator.terminal = true + + return errSSEDone case anthropicErrorEvent: return providerErrorToOops("anthropic_error", &event.Error) } @@ -267,7 +270,7 @@ func (accumulator *anthropicStreamAccumulator) result() *providerResult { Termination: llm.NewTerminationMetadata("", accumulator.stopReason, ""), Text: text, OutputItems: nil, - Thinking: accumulator.thinking, + Thinking: joinedThinkingDeltas(accumulator.thinking), ToolCalls: calls, Usage: accumulator.usage, } diff --git a/internal/provider/client.go b/internal/provider/client.go index e148bf8b..5880c327 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -96,6 +96,7 @@ const ( reasoningEffortNone = "none" statusCompleted = "completed" finishReasonMaxTokens = "max_tokens" + anthropicStopEndTurn = "end_turn" sseItemIDKey = "item_id" sseOutputItemIDKey = "output_item_id" jsonStreamKey = "stream" diff --git a/internal/provider/openai_chat_sse.go b/internal/provider/openai_chat_sse.go index 1c007b45..4dd6d0ad 100644 --- a/internal/provider/openai_chat_sse.go +++ b/internal/provider/openai_chat_sse.go @@ -136,6 +136,8 @@ func (accumulator *openAIChatStreamAccumulator) add( if choice.FinishReason != "" { accumulator.finishReason = choice.FinishReason accumulator.terminal = true + + return errSSEDone } } @@ -226,7 +228,7 @@ func (accumulator *openAIChatStreamAccumulator) result() *providerResult { Termination: llm.NewTerminationMetadata("", accumulator.finishReason, ""), Text: strings.TrimSpace(strings.Join(accumulator.textParts, "")), OutputItems: nil, - Thinking: accumulator.thinking, + Thinking: joinedThinkingDeltas(accumulator.thinking), ToolCalls: calls, Usage: accumulator.usage, } diff --git a/internal/provider/tool_loop.go b/internal/provider/tool_loop.go index 0780bc64..3c38cf3e 100644 --- a/internal/provider/tool_loop.go +++ b/internal/provider/tool_loop.go @@ -206,6 +206,15 @@ func finishProviderResult(result *llm.Response, providerResult *providerResult) return true, nil } +func joinedThinkingDeltas(deltas []string) []string { + thinking := strings.TrimSpace(strings.Join(deltas, "")) + if thinking == "" { + return nil + } + + return []string{thinking} +} + func emitStreamEvent(onEvent func(*llm.StreamChunk), event StreamEvent) { if onEvent != nil { onEvent(streamChunkToLLM(event)) From 67e50c31e8d32dd9cae69fde828f227c7d517f79 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 00:11:41 -0500 Subject: [PATCH 23/30] fix(provider): join thinking deltas and cover terminal-event handling in stream tests --- internal/provider/anthropic.go | 2 +- .../anthropic_stream_internal_test.go | 28 +++++++++++++++++++ ...ropic_stream_test_helpers_internal_test.go | 2 +- .../openai_chat_stream_internal_test.go | 16 ++++++++++- .../openai_responses_internal_test.go | 11 ++++++++ internal/provider/openai_responses_sse.go | 14 ++++++---- 6 files changed, 65 insertions(+), 8 deletions(-) diff --git a/internal/provider/anthropic.go b/internal/provider/anthropic.go index f37f5967..9c991b02 100644 --- a/internal/provider/anthropic.go +++ b/internal/provider/anthropic.go @@ -447,7 +447,7 @@ func anthropicRefusalText(details *anthropicStopDetails) string { func anthropicFinishReason(reason string, hasToolCalls bool) llm.FinishReason { switch reason { - case "end_turn", "stop_sequence": + case anthropicStopEndTurn, "stop_sequence": return llm.FinishReasonStop case finishReasonMaxTokens, "model_context_window_exceeded": return llm.FinishReasonLength diff --git a/internal/provider/anthropic_stream_internal_test.go b/internal/provider/anthropic_stream_internal_test.go index b69b8abb..9b6af067 100644 --- a/internal/provider/anthropic_stream_internal_test.go +++ b/internal/provider/anthropic_stream_internal_test.go @@ -78,6 +78,34 @@ func TestParseAnthropicStreamTextThinkingToolUseAndUsage(t *testing.T) { assert.Equal(t, llm.PartText, events[1].Type) } +func TestParseAnthropicStreamStopsAtMessageStop(t *testing.T) { + t.Parallel() + + stream := strings.Join([]string{ + anthropicEventContentBlockDelta, + anthropicDataLine(anthropicDeltaEvent(0, anthropicThinkingDelta, "thinking", "done")), + "", + anthropicEventMessageDelta, + anthropicDataLine(map[string]any{ + jsonTypeKey: anthropicMessageDeltaEvent, + anthropicDeltaKey: map[string]any{ + anthropicStopReasonKey: anthropicStopEndTurn, + }, + }), + "", + anthropicEventMessageStop, + anthropicMessageStopData, + "", + "data: invalid-json", + "", + }, "\n") + result, err := parseAnthropicStream(strings.NewReader(stream), nil) + + require.NoError(t, err) + assert.Equal(t, []string{"done"}, result.Thinking) + assert.Equal(t, llm.FinishReasonStop, result.FinishReason) +} + func TestParseAnthropicStreamHandlesErrorsRefusalsAndIncompleteStreams(t *testing.T) { t.Parallel() diff --git a/internal/provider/anthropic_stream_test_helpers_internal_test.go b/internal/provider/anthropic_stream_test_helpers_internal_test.go index 9f31e1d1..499b7736 100644 --- a/internal/provider/anthropic_stream_test_helpers_internal_test.go +++ b/internal/provider/anthropic_stream_test_helpers_internal_test.go @@ -109,7 +109,7 @@ func anthropicToolUseBlockData(index int, callID, name string, input any) string func anthropicMessageDeltaLines(stopReason string, stopDetails *anthropicStopDetails) []string { if stopReason == "" { - stopReason = "end_turn" + stopReason = anthropicStopEndTurn } delta := map[string]any{anthropicStopReasonKey: stopReason} diff --git a/internal/provider/openai_chat_stream_internal_test.go b/internal/provider/openai_chat_stream_internal_test.go index 819f69b6..7ec65e84 100644 --- a/internal/provider/openai_chat_stream_internal_test.go +++ b/internal/provider/openai_chat_stream_internal_test.go @@ -48,7 +48,7 @@ func TestParseOpenAIChatStreamTextThinkingToolCallsAndUsage(t *testing.T) { require.NoError(t, err) assert.Equal(t, "hello world", result.Text) - assert.Equal(t, []string{"think ", "fallback"}, result.Thinking) + assert.Equal(t, []string{"think fallback"}, result.Thinking) assert.Equal(t, llm.FinishReasonToolCalls, result.FinishReason) assert.Equal(t, 4, result.Usage.InputTokens) assert.Equal(t, 2, result.Usage.OutputTokens) @@ -62,6 +62,20 @@ func TestParseOpenAIChatStreamTextThinkingToolCallsAndUsage(t *testing.T) { assert.Equal(t, llm.PartText, events[1].Type) } +func TestParseOpenAIChatStreamStopsAtFinishReason(t *testing.T) { + t.Parallel() + + stream := openAIChatStream( + openAIChatDelta(map[string]any{"reasoning_content": "finished"}, "stop", nil), + "data: invalid-json", + ) + result, err := parseOpenAIChatStream(strings.NewReader(stream), nil) + + require.NoError(t, err) + assert.Equal(t, []string{"finished"}, result.Thinking) + assert.Equal(t, llm.FinishReasonStop, result.FinishReason) +} + func TestParseOpenAIChatStreamHandlesErrorsAndIncompleteStreams(t *testing.T) { t.Parallel() diff --git a/internal/provider/openai_responses_internal_test.go b/internal/provider/openai_responses_internal_test.go index f4cb1bb2..e885766f 100644 --- a/internal/provider/openai_responses_internal_test.go +++ b/internal/provider/openai_responses_internal_test.go @@ -115,6 +115,17 @@ func TestParseOpenAIResponseStreamUsesOutputTextAndErrors(t *testing.T) { assert.Contains(t, err.Error(), "bad request") } +func TestParseOpenAIResponseStreamStopsAtTerminalEvent(t *testing.T) { + t.Parallel() + + stream := openAIResponseCompletedStream(`{"output_text":"ok"}`) + + "data: invalid-json\n\n" + result, err := parseSSEResult(strings.NewReader(stream), nil) + + require.NoError(t, err) + assert.Equal(t, "ok", result.Text) +} + func TestParseOpenAIResponseStreamStopsAtDoneMarker(t *testing.T) { t.Parallel() diff --git a/internal/provider/openai_responses_sse.go b/internal/provider/openai_responses_sse.go index da134d72..d642ee82 100644 --- a/internal/provider/openai_responses_sse.go +++ b/internal/provider/openai_responses_sse.go @@ -35,11 +35,11 @@ func newSSEAccumulator() *sseAccumulator { } } -func (accumulator *sseAccumulator) add(event map[string]any, onEvent func(*llm.StreamChunk)) { +func (accumulator *sseAccumulator) add(event map[string]any, onEvent func(*llm.StreamChunk)) error { accumulator.addResponseEventState(event) if accumulator.terminalErr != nil { - return + return errSSEDone } accumulator.addResponse(event) @@ -71,6 +71,12 @@ func (accumulator *sseAccumulator) add(event map[string]any, onEvent func(*llm.S if arguments, ok := event["arguments"].(string); ok { accumulator.addArguments(event, arguments) } + + if accumulator.terminal { + return errSSEDone + } + + return nil } func (accumulator *sseAccumulator) addResponseEventState(event map[string]any) { @@ -246,9 +252,7 @@ func scanResponsesSSE( return err } - accumulator.add(decoded, onEvent) - - return nil + return accumulator.add(decoded, onEvent) }) if errors.Is(err, errSSEDone) { return nil From 90bda2ebb90e8fe2c986b9d66edbf183399831de Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 00:25:24 -0500 Subject: [PATCH 24/30] fix(provider): check accumulator.add error returns in stream tests (errcheck) --- internal/provider/client_internal_test.go | 8 ++++---- internal/provider/sse_internal_test.go | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/provider/client_internal_test.go b/internal/provider/client_internal_test.go index d6e0328f..a4582f2d 100644 --- a/internal/provider/client_internal_test.go +++ b/internal/provider/client_internal_test.go @@ -25,13 +25,13 @@ func TestSSEAccumulatorEmitsOutputTextDelta(t *testing.T) { accumulator := newSSEAccumulator() events := []llm.StreamChunk{} - accumulator.add(map[string]any{ + require.NoError(t, accumulator.add(map[string]any{ jsonTypeKey: "response.output_text.delta", anthropicDeltaKey: answerDelta, }, func(event *llm.StreamChunk) { require.NotNil(t, event) events = append(events, *event) - }) + })) require.Len(t, events, 1) require.NotNil(t, events[0].Part) @@ -46,13 +46,13 @@ func TestSSEAccumulatorEmitsReasoningDeltaSeparately(t *testing.T) { accumulator := newSSEAccumulator() events := []llm.StreamChunk{} - accumulator.add(map[string]any{ + require.NoError(t, accumulator.add(map[string]any{ jsonTypeKey: "response.reasoning_summary_text.delta", anthropicDeltaKey: testThinkingDelta, }, func(event *llm.StreamChunk) { require.NotNil(t, event) events = append(events, *event) - }) + })) require.Len(t, events, 1) require.NotNil(t, events[0].Part) diff --git a/internal/provider/sse_internal_test.go b/internal/provider/sse_internal_test.go index d65bc253..cd0ff8b2 100644 --- a/internal/provider/sse_internal_test.go +++ b/internal/provider/sse_internal_test.go @@ -12,11 +12,11 @@ func TestSSEAccumulatorAddsArgumentsBeforeItem(t *testing.T) { t.Parallel() accumulator := newSSEAccumulator() - accumulator.add(map[string]any{ + require.NoError(t, accumulator.add(map[string]any{ jsonTypeKey: "response.function_call_arguments.delta", sseItemIDKey: testCallID, "arguments": testToolArgumentsJSON, - }, nil) + }, nil)) assert.Len(t, accumulator.items, 1) item, ok := accumulator.items[0].(map[string]any) From b544f772b6cd46831c800c98fe31d34786438bdf Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 00:33:23 -0500 Subject: [PATCH 25/30] test(tool): extract default ignore pattern sources helper to remove duplicated slice literal --- internal/tool/ignore_internal_test.go | 58 +++++++++++++-------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/internal/tool/ignore_internal_test.go b/internal/tool/ignore_internal_test.go index 474465da..b88a51b6 100644 --- a/internal/tool/ignore_internal_test.go +++ b/internal/tool/ignore_internal_test.go @@ -19,24 +19,9 @@ func TestReadIgnorePatternsAppliesDefaultsWithoutUserConfig(t *testing.T) { patterns := readIgnorePatterns(workspace, nil) - expectedSources := []string{ - ".git/", - "node_modules/", - defaultIgnoreEnv, - ".gocache/", - ".gomodcache/", - ".tmp/", - "bin/", - "/skills/", - } - - actualSources := make([]string, 0, len(patterns)) - for _, pattern := range patterns { - actualSources = append(actualSources, pattern.source) - } - - assert.Equal(t, expectedSources, actualSources) - assert.False(t, slices.IsSorted(actualSources) && len(actualSources) != len(expectedSources)) + sources := patternSources(patterns) + assert.Equal(t, defaultIgnorePatternSources(), sources) + assert.False(t, slices.IsSorted(sources) && len(sources) != len(defaultIgnorePatternSources())) } func TestReadIgnorePatternsReturnsSharedDefaultsWithoutMutatingThem(t *testing.T) { @@ -96,17 +81,6 @@ func TestIgnoredReadPathReasonsMatchDefaultAndCustomPatterns(t *testing.T) { func TestDefaultReadIgnorePatternsMatchingMatchesFreshlyParsedPatterns(t *testing.T) { t.Parallel() - expectedSources := []string{ - ".git/", - "node_modules/", - defaultIgnoreEnv, - ".gocache/", - ".gomodcache/", - ".tmp/", - "bin/", - "/skills/", - } - paths := [][]string{ {gitDirName, "config"}, {"node_modules", "pkg", "index.js"}, @@ -121,8 +95,8 @@ func TestDefaultReadIgnorePatternsMatchingMatchesFreshlyParsedPatterns(t *testin {"cmd", "main.go"}, } - fresh := make([]ignorePattern, 0, len(expectedSources)) - for _, source := range expectedSources { + fresh := make([]ignorePattern, 0, len(defaultIgnorePatternSources())) + for _, source := range defaultIgnorePatternSources() { fresh = append(fresh, ignorePattern{pattern: gitignore.ParsePattern(source, nil), source: source}) } @@ -137,6 +111,28 @@ func TestDefaultReadIgnorePatternsMatchingMatchesFreshlyParsedPatterns(t *testin } } +func defaultIgnorePatternSources() []string { + return []string{ + ".git/", + "node_modules/", + defaultIgnoreEnv, + ".gocache/", + ".gomodcache/", + ".tmp/", + "bin/", + "/skills/", + } +} + +func patternSources(patterns []ignorePattern) []string { + sources := make([]string, 0, len(patterns)) + for _, pattern := range patterns { + sources = append(sources, pattern.source) + } + + return sources +} + func writeIgnoreTestFile(t *testing.T, path, content string) { t.Helper() From f9c51aeb32e98ae6d8f63d8b870a28c12eca1731 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 00:50:03 -0500 Subject: [PATCH 26/30] fix(provider): consume usage chunk after finish_reason instead of stopping early --- internal/provider/openai_chat_sse.go | 2 -- .../openai_chat_stream_internal_test.go | 25 +++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/internal/provider/openai_chat_sse.go b/internal/provider/openai_chat_sse.go index 4dd6d0ad..72c1770c 100644 --- a/internal/provider/openai_chat_sse.go +++ b/internal/provider/openai_chat_sse.go @@ -136,8 +136,6 @@ func (accumulator *openAIChatStreamAccumulator) add( if choice.FinishReason != "" { accumulator.finishReason = choice.FinishReason accumulator.terminal = true - - return errSSEDone } } diff --git a/internal/provider/openai_chat_stream_internal_test.go b/internal/provider/openai_chat_stream_internal_test.go index 7ec65e84..4745d8f4 100644 --- a/internal/provider/openai_chat_stream_internal_test.go +++ b/internal/provider/openai_chat_stream_internal_test.go @@ -62,18 +62,39 @@ func TestParseOpenAIChatStreamTextThinkingToolCallsAndUsage(t *testing.T) { assert.Equal(t, llm.PartText, events[1].Type) } -func TestParseOpenAIChatStreamStopsAtFinishReason(t *testing.T) { +func TestParseOpenAIChatStreamConsumesUsageChunkAfterFinishReason(t *testing.T) { t.Parallel() + // include_usage streams a final usage-only chunk (empty choices) after the + // finish_reason chunk; the reader must keep consuming until [DONE]. stream := openAIChatStream( openAIChatDelta(map[string]any{"reasoning_content": "finished"}, "stop", nil), - "data: invalid-json", + openAIChatDelta(map[string]any{}, "", map[string]any{ + jsonPromptTokensKey: 7, + jsonCompletionTokensKey: 3, + }), + openAIChatDoneLine, ) result, err := parseOpenAIChatStream(strings.NewReader(stream), nil) require.NoError(t, err) assert.Equal(t, []string{"finished"}, result.Thinking) assert.Equal(t, llm.FinishReasonStop, result.FinishReason) + assert.Equal(t, 7, result.Usage.InputTokens) + assert.Equal(t, 3, result.Usage.OutputTokens) +} + +func TestParseOpenAIChatStreamReturnsDecodeErrors(t *testing.T) { + t.Parallel() + + stream := openAIChatStream( + openAIChatDelta(map[string]any{jsonContentKey: testProviderPartialText}, "stop", nil), + "data: invalid-json", + ) + _, err := parseOpenAIChatStream(strings.NewReader(stream), nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid character") } func TestParseOpenAIChatStreamHandlesErrorsAndIncompleteStreams(t *testing.T) { From 296e56e900fb4dba8a06a8b3ef39adc392024175 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 00:50:16 -0500 Subject: [PATCH 27/30] test(tool): cover DialTLSContext pinning with wrapFetchTransportDialHooks --- internal/tool/fetch_internal_test.go | 52 ++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/internal/tool/fetch_internal_test.go b/internal/tool/fetch_internal_test.go index ef534ae1..1cb98688 100644 --- a/internal/tool/fetch_internal_test.go +++ b/internal/tool/fetch_internal_test.go @@ -529,6 +529,58 @@ func TestFetchTool_PinsDialedAddressToValidatedIP(t *testing.T) { } } +func TestFetchTool_PinsDialedTLSToValidatedIP(t *testing.T) { + t.Parallel() + + for _, testCase := range fetchTestDialPinCases() { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + lookupCalls := 0 + fetchTool, dialedAddresses := fetchTestRecordingTransport() + fetchTool.lookupIPAddrs = func(_ context.Context, _ string) ([]net.IPAddr, error) { + defer func() { lookupCalls++ }() + + return testCase.lookups[min(lookupCalls, len(testCase.lookups)-1)], nil + } + + requestURL, err := parseFetchURL("http://" + fetchTestExampleHost) + require.NoError(t, err) + + // Consume the pre-flight validation lookup the same way the real + // request path does, so the dial below observes the second resolution. + require.NoError(t, fetchTool.validatePublicFetchURL(context.Background(), requestURL)) + + transport, closeIdleConnections, err := fetchTool.transportWithNetworkValidation(fetchTool.client.Transport) + require.NoError(t, err) + + defer closeIdleConnections() + + httpTransport, ok := transport.(*http.Transport) + require.True(t, ok) + + // Supply a TLS dial hook so the validating wrapper exercises the + // HTTPS path: the recorded target must be the pinned literal IP. + httpTransport.DialTLSContext = httpTransport.DialContext + + conn, dialErr := httpTransport.DialTLSContext(context.Background(), "tcp", fetchTestExampleHost+":443") + + if testCase.wantErr != "" { + require.Error(t, dialErr) + assert.Contains(t, dialErr.Error(), testCase.wantErr) + assert.Empty(t, *dialedAddresses, "no connection should be attempted after validation fails") + + return + } + + require.NoError(t, dialErr) + require.NoError(t, conn.Close()) + require.Len(t, *dialedAddresses, 1) + assert.Equal(t, testCase.wantDialedIP+":443", (*dialedAddresses)[0]) + }) + } +} + // fetchTestRecordingTransport builds a fetch tool whose transport records every // dialed address and rejects hostnames: the base dialer must only ever receive a // validated literal IP, since a hostname would let the OS resolver rebind it. From 726830ebd33d5acb8f8f6e476fc5fc1115570480 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 01:15:38 -0500 Subject: [PATCH 28/30] test: dedupe dial-pin and partial-lookup tests to clear SonarQube duplication --- internal/agenttask/service_internal_test.go | 12 +- internal/mapsutil/example_test.go | 6 +- .../openai_chat_stream_internal_test.go | 2 +- .../workflow_submission_internal_test.go | 39 +++--- internal/tool/fetch_internal_test.go | 122 ++++++++---------- 5 files changed, 85 insertions(+), 96 deletions(-) diff --git a/internal/agenttask/service_internal_test.go b/internal/agenttask/service_internal_test.go index d7c03ded..d868474c 100644 --- a/internal/agenttask/service_internal_test.go +++ b/internal/agenttask/service_internal_test.go @@ -1265,10 +1265,10 @@ func TestServiceInternalLeaseRenewalSeedsDeadlineFromClaimedLeaseExpiry(t *testi t.Parallel() tests := []struct { - name string - claimedRemaining time.Duration - wantDeadlineMin time.Duration - wantDeadlineMax time.Duration + name string + claimedRemaining time.Duration + wantDeadlineMin time.Duration + wantDeadlineMax time.Duration }{ { // Claim-to-start latency means the persisted lease can expire far @@ -1280,13 +1280,13 @@ func TestServiceInternalLeaseRenewalSeedsDeadlineFromClaimedLeaseExpiry(t *testi wantDeadlineMax: 600 * time.Millisecond, }, { - name: "missing claimed expiry falls back to now plus lease duration", + name: "missing claimed expiry falls back to now plus lease duration", claimedRemaining: claimExpiryMissing, wantDeadlineMin: leaseBusyGrace - 500*time.Millisecond, wantDeadlineMax: leaseBusyGrace, }, { - name: "zero claimed expiry falls back to now plus lease duration", + name: "zero claimed expiry falls back to now plus lease duration", claimedRemaining: claimExpiryZero, wantDeadlineMin: leaseBusyGrace - 500*time.Millisecond, wantDeadlineMax: leaseBusyGrace, diff --git a/internal/mapsutil/example_test.go b/internal/mapsutil/example_test.go index a5165984..e4a2cdb4 100644 --- a/internal/mapsutil/example_test.go +++ b/internal/mapsutil/example_test.go @@ -3,13 +3,15 @@ package mapsutil_test import ( "encoding/json" "fmt" + "os" "github.com/omarluq/librecode/internal/mapsutil" ) +// mustPrintln writes example output to stdout for godoc, panicking on write +// errors so example bodies stay focused on the behavior under demonstration. func mustPrintln(values ...any) { - //nolint:forbidigo // helper used by godoc examples; error is checked, not ignored - if _, err := fmt.Println(values...); err != nil { + if _, err := fmt.Fprintln(os.Stdout, values...); err != nil { panic(err) } } diff --git a/internal/provider/openai_chat_stream_internal_test.go b/internal/provider/openai_chat_stream_internal_test.go index 4745d8f4..c49fedfa 100644 --- a/internal/provider/openai_chat_stream_internal_test.go +++ b/internal/provider/openai_chat_stream_internal_test.go @@ -70,7 +70,7 @@ func TestParseOpenAIChatStreamConsumesUsageChunkAfterFinishReason(t *testing.T) stream := openAIChatStream( openAIChatDelta(map[string]any{"reasoning_content": "finished"}, "stop", nil), openAIChatDelta(map[string]any{}, "", map[string]any{ - jsonPromptTokensKey: 7, + jsonPromptTokensKey: 7, jsonCompletionTokensKey: 3, }), openAIChatDoneLine, diff --git a/internal/terminal/workflow_submission_internal_test.go b/internal/terminal/workflow_submission_internal_test.go index 649af5b9..0ffd0198 100644 --- a/internal/terminal/workflow_submission_internal_test.go +++ b/internal/terminal/workflow_submission_internal_test.go @@ -186,6 +186,25 @@ func TestWorkflowSubmissionCleansUpMissingRunIDs(t *testing.T) { // A later failed lookup must not strand runs that were already loaded // successfully in the same refresh: terminal entries still deliver their // completion, while the unresolved ID stays pending for the next refresh. +// partialLookupSnapshot builds a refresh snapshot whose exact-ID lookup +// section is invalid overall but still carries the runs that loaded fine. +func partialLookupSnapshot(runs ...database.WorkflowRunEntity) terminalRefreshSnapshot { + byID := make(map[string]*database.WorkflowRunEntity, len(runs)) + for i := range runs { + byID[runs[i].Task.ID] = &runs[i] + } + + snapshot := newTerminalRefreshSnapshot(workflowTestSessionID) + snapshot.ActiveWorkflow = refreshSection([]database.WorkflowRunEntity{}) + snapshot.WorkflowByID = terminalRefreshSection[map[string]*database.WorkflowRunEntity]{ + Value: byID, + Err: assert.AnError, + Valid: false, + } + + return snapshot +} + func TestWorkflowSubmissionReconcilesLoadedRunsDuringPartialLookupFailure(t *testing.T) { t.Parallel() @@ -197,14 +216,7 @@ func TestWorkflowSubmissionReconcilesLoadedRunsDuringPartialLookupFailure(t *tes // The exact-ID lookup for loaded-run succeeded; the one for // unresolved-run failed, invalidating the section as a whole. - snapshot := newTerminalRefreshSnapshot(app.sessionID) - snapshot.ActiveWorkflow = refreshSection([]database.WorkflowRunEntity{}) - snapshot.WorkflowByID = terminalRefreshSection[map[string]*database.WorkflowRunEntity]{ - Value: map[string]*database.WorkflowRunEntity{loaded.Task.ID: &loaded}, - Err: assert.AnError, - Valid: false, - } - + snapshot := partialLookupSnapshot(loaded) app.applyTerminalRefreshSnapshot(t.Context(), &snapshot) // The successfully loaded terminal run is reconciled despite the invalid @@ -229,15 +241,8 @@ func TestWorkflowSubmissionReconcilesSortedLaterRunsDuringPartialLookupFailure(t require.Equal(t, []string{"a-unresolved", loaded.Task.ID}, app.pendingWorkflowRunIDs()) // The exact-ID lookup for a-unresolved failed and sorts first; the one for - // z-loaded succeeded, so Value holds only the loaded run. - snapshot := newTerminalRefreshSnapshot(app.sessionID) - snapshot.ActiveWorkflow = refreshSection([]database.WorkflowRunEntity{}) - snapshot.WorkflowByID = terminalRefreshSection[map[string]*database.WorkflowRunEntity]{ - Value: map[string]*database.WorkflowRunEntity{loaded.Task.ID: &loaded}, - Err: assert.AnError, - Valid: false, - } - + // z-loaded succeeded, so only the loaded run is present. + snapshot := partialLookupSnapshot(loaded) app.applyTerminalRefreshSnapshot(t.Context(), &snapshot) // z-loaded sorts after the unresolved ID but is still reconciled: its diff --git a/internal/tool/fetch_internal_test.go b/internal/tool/fetch_internal_test.go index 1cb98688..b49c93ed 100644 --- a/internal/tool/fetch_internal_test.go +++ b/internal/tool/fetch_internal_test.go @@ -481,102 +481,84 @@ func fetchTestRebindingDialPinCases() []fetchTestDialPinCase { return cases } -func TestFetchTool_PinsDialedAddressToValidatedIP(t *testing.T) { - t.Parallel() - - for _, testCase := range fetchTestDialPinCases() { - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() +// fetchTestRunDialPinCase exercises one dial-pinning table entry end to end: +// validate, wrap the transport, dial via dialCase, and assert the recorded +// target is the pinned validated IP (or that dialing was refused). +type ( + fetchTestDialFunc = func(context.Context, string, string) (net.Conn, error) + fetchTestDialHook = func(*http.Transport) fetchTestDialFunc +) - lookupCalls := 0 - fetchTool, dialedAddresses := fetchTestRecordingTransport() - fetchTool.lookupIPAddrs = func(_ context.Context, _ string) ([]net.IPAddr, error) { - defer func() { lookupCalls++ }() +func fetchTestRunDialPinCase(t *testing.T, testCase fetchTestDialPinCase, port string, dialCase fetchTestDialHook) { + t.Helper() - return testCase.lookups[min(lookupCalls, len(testCase.lookups)-1)], nil - } + lookupCalls := 0 + fetchTool, dialedAddresses := fetchTestRecordingTransport() + fetchTool.lookupIPAddrs = func(_ context.Context, _ string) ([]net.IPAddr, error) { + defer func() { lookupCalls++ }() - requestURL, err := parseFetchURL("http://" + fetchTestExampleHost) - require.NoError(t, err) + return testCase.lookups[min(lookupCalls, len(testCase.lookups)-1)], nil + } - // Consume the pre-flight validation lookup the same way the real - // request path does, so the dial below observes the second resolution. - require.NoError(t, fetchTool.validatePublicFetchURL(context.Background(), requestURL)) + requestURL, err := parseFetchURL("http://" + fetchTestExampleHost) + require.NoError(t, err) - transport, closeIdleConnections, err := fetchTool.transportWithNetworkValidation(fetchTool.client.Transport) - require.NoError(t, err) + // Consume the pre-flight validation lookup the same way the real + // request path does, so the dial below observes the second resolution. + require.NoError(t, fetchTool.validatePublicFetchURL(context.Background(), requestURL)) - defer closeIdleConnections() + transport, closeIdleConnections, err := fetchTool.transportWithNetworkValidation(fetchTool.client.Transport) + require.NoError(t, err) - httpTransport, ok := transport.(*http.Transport) - require.True(t, ok) + defer closeIdleConnections() - conn, dialErr := httpTransport.DialContext(context.Background(), "tcp", fetchTestExampleHost+":80") + httpTransport, ok := transport.(*http.Transport) + require.True(t, ok) - if testCase.wantErr != "" { - require.Error(t, dialErr) - assert.Contains(t, dialErr.Error(), testCase.wantErr) - assert.Empty(t, *dialedAddresses, "no connection should be attempted after validation fails") + conn, dialErr := dialCase(httpTransport)(context.Background(), "tcp", fetchTestExampleHost+":"+port) - return - } + if testCase.wantErr != "" { + require.Error(t, dialErr) + assert.Contains(t, dialErr.Error(), testCase.wantErr) + assert.Empty(t, *dialedAddresses, "no connection should be attempted after validation fails") - require.NoError(t, dialErr) - require.NoError(t, conn.Close()) - require.Len(t, *dialedAddresses, 1) - assert.Equal(t, testCase.wantDialedIP+":80", (*dialedAddresses)[0]) - }) + return } + + require.NoError(t, dialErr) + require.NoError(t, conn.Close()) + require.Len(t, *dialedAddresses, 1) + assert.Equal(t, testCase.wantDialedIP+":"+port, (*dialedAddresses)[0]) } -func TestFetchTool_PinsDialedTLSToValidatedIP(t *testing.T) { +func TestFetchTool_PinsDialedAddressToValidatedIP(t *testing.T) { t.Parallel() for _, testCase := range fetchTestDialPinCases() { t.Run(testCase.name, func(t *testing.T) { t.Parallel() - lookupCalls := 0 - fetchTool, dialedAddresses := fetchTestRecordingTransport() - fetchTool.lookupIPAddrs = func(_ context.Context, _ string) ([]net.IPAddr, error) { - defer func() { lookupCalls++ }() - - return testCase.lookups[min(lookupCalls, len(testCase.lookups)-1)], nil - } - - requestURL, err := parseFetchURL("http://" + fetchTestExampleHost) - require.NoError(t, err) - - // Consume the pre-flight validation lookup the same way the real - // request path does, so the dial below observes the second resolution. - require.NoError(t, fetchTool.validatePublicFetchURL(context.Background(), requestURL)) - - transport, closeIdleConnections, err := fetchTool.transportWithNetworkValidation(fetchTool.client.Transport) - require.NoError(t, err) + fetchTestRunDialPinCase(t, testCase, "80", func(transport *http.Transport) fetchTestDialFunc { + return transport.DialContext + }) + }) + } +} - defer closeIdleConnections() +func TestFetchTool_PinsDialedTLSToValidatedIP(t *testing.T) { + t.Parallel() - httpTransport, ok := transport.(*http.Transport) - require.True(t, ok) + for _, testCase := range fetchTestDialPinCases() { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() // Supply a TLS dial hook so the validating wrapper exercises the // HTTPS path: the recorded target must be the pinned literal IP. - httpTransport.DialTLSContext = httpTransport.DialContext - - conn, dialErr := httpTransport.DialTLSContext(context.Background(), "tcp", fetchTestExampleHost+":443") + fetchTestRunDialPinCase(t, testCase, "443", func(transport *http.Transport) fetchTestDialFunc { + transport.DialTLSContext = transport.DialContext - if testCase.wantErr != "" { - require.Error(t, dialErr) - assert.Contains(t, dialErr.Error(), testCase.wantErr) - assert.Empty(t, *dialedAddresses, "no connection should be attempted after validation fails") - - return - } - - require.NoError(t, dialErr) - require.NoError(t, conn.Close()) - require.Len(t, *dialedAddresses, 1) - assert.Equal(t, testCase.wantDialedIP+":443", (*dialedAddresses)[0]) + return transport.DialTLSContext + }) }) } } From 77772d3167365307a991124575bda31775a32c00 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 01:23:21 -0500 Subject: [PATCH 29/30] test(tool): exercise the real DialTLSContext wrapper instead of overwriting it --- internal/tool/fetch_internal_test.go | 32 +++++++++++++++++++--------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/internal/tool/fetch_internal_test.go b/internal/tool/fetch_internal_test.go index b49c93ed..1535a309 100644 --- a/internal/tool/fetch_internal_test.go +++ b/internal/tool/fetch_internal_test.go @@ -485,8 +485,12 @@ func fetchTestRebindingDialPinCases() []fetchTestDialPinCase { // validate, wrap the transport, dial via dialCase, and assert the recorded // target is the pinned validated IP (or that dialing was refused). type ( - fetchTestDialFunc = func(context.Context, string, string) (net.Conn, error) - fetchTestDialHook = func(*http.Transport) fetchTestDialFunc + fetchTestDialFunc = func(context.Context, string, string) (net.Conn, error) + fetchTestDialSelect = func(*http.Transport) fetchTestDialFunc + // fetchTestDialHook installs any base dial hooks on the pre-wrap transport + // and returns a selector that picks the dial func to invoke on the wrapped + // transport, so the installed validating wrappers are actually exercised. + fetchTestDialHook = func(base *http.Transport) fetchTestDialSelect ) func fetchTestRunDialPinCase(t *testing.T, testCase fetchTestDialPinCase, port string, dialCase fetchTestDialHook) { @@ -507,6 +511,13 @@ func fetchTestRunDialPinCase(t *testing.T, testCase fetchTestDialPinCase, port s // request path does, so the dial below observes the second resolution. require.NoError(t, fetchTool.validatePublicFetchURL(context.Background(), requestURL)) + // Install the base dial hooks before the validating wrapper so the + // wrapper wraps them; the selector then invokes the wrapped hook. + baseTransport, isHTTP := fetchTool.client.Transport.(*http.Transport) + require.True(t, isHTTP) + + dialSelector := dialCase(baseTransport) + transport, closeIdleConnections, err := fetchTool.transportWithNetworkValidation(fetchTool.client.Transport) require.NoError(t, err) @@ -515,7 +526,7 @@ func fetchTestRunDialPinCase(t *testing.T, testCase fetchTestDialPinCase, port s httpTransport, ok := transport.(*http.Transport) require.True(t, ok) - conn, dialErr := dialCase(httpTransport)(context.Background(), "tcp", fetchTestExampleHost+":"+port) + conn, dialErr := dialSelector(httpTransport)(context.Background(), "tcp", fetchTestExampleHost+":"+port) if testCase.wantErr != "" { require.Error(t, dialErr) @@ -538,8 +549,8 @@ func TestFetchTool_PinsDialedAddressToValidatedIP(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { t.Parallel() - fetchTestRunDialPinCase(t, testCase, "80", func(transport *http.Transport) fetchTestDialFunc { - return transport.DialContext + fetchTestRunDialPinCase(t, testCase, "80", func(*http.Transport) fetchTestDialSelect { + return func(wrapped *http.Transport) fetchTestDialFunc { return wrapped.DialContext } }) }) } @@ -552,12 +563,13 @@ func TestFetchTool_PinsDialedTLSToValidatedIP(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { t.Parallel() - // Supply a TLS dial hook so the validating wrapper exercises the - // HTTPS path: the recorded target must be the pinned literal IP. - fetchTestRunDialPinCase(t, testCase, "443", func(transport *http.Transport) fetchTestDialFunc { - transport.DialTLSContext = transport.DialContext + // Install the TLS dial hook on the base transport before wrapping so + // the validating wrapper wraps it; invoking the wrapped transport's + // DialTLSContext must dial the pinned literal IP. + fetchTestRunDialPinCase(t, testCase, "443", func(base *http.Transport) fetchTestDialSelect { + base.DialTLSContext = base.DialContext - return transport.DialTLSContext + return func(wrapped *http.Transport) fetchTestDialFunc { return wrapped.DialTLSContext } }) }) } From 74b06bd80f3a4008019245aaf114e657f13b6f18 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 20 Aug 2026 01:45:02 -0500 Subject: [PATCH 30/30] test(terminal): assert delivery of reconciled run completions in partial-lookup tests --- internal/terminal/workflow_submission_internal_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/terminal/workflow_submission_internal_test.go b/internal/terminal/workflow_submission_internal_test.go index 0ffd0198..696225ab 100644 --- a/internal/terminal/workflow_submission_internal_test.go +++ b/internal/terminal/workflow_submission_internal_test.go @@ -221,6 +221,7 @@ func TestWorkflowSubmissionReconcilesLoadedRunsDuringPartialLookupFailure(t *tes // The successfully loaded terminal run is reconciled despite the invalid // section: its completion delivered and it left the pending set. + assert.Contains(t, app.deliveredAgentTasks, loaded.Task.ID) assert.NotContains(t, app.pendingWorkflowRuns, loaded.Task.ID) assert.Empty(t, app.activeWorkflows) // The unresolved run was never loaded, so it stays pending for retry.