Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,24 @@ jobs:
- name: Restore pricing snapshot
run: go run ./internal/pricing/cmd/litellm-snapshot -restore

- name: Run Go tests
run: go test -tags "fts5" ${{ runner.os == 'Linux' && '-coverprofile=coverage.out' || '' }} ./... -v -count=1 -timeout=20m
- name: Run Go tests (Linux)
if: runner.os == 'Linux'
run: go test -tags "fts5" -coverprofile=coverage.out ./... -v -count=1 -timeout=20m
env:
CGO_ENABLED: "1"

- name: Run Go tests (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
# DuckDB runs in its own job below so its CGo-heavy package does not
# occupy one of this four-core runner's package workers for minutes.
$packages = @(go list ./...)
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$packages = $packages | Where-Object { $_ -ne 'go.kenn.io/agentsview/internal/duckdb' }
go test -tags "fts5" $packages -count=1 -timeout=20m
env:
CGO_ENABLED: "1"

Expand All @@ -185,6 +201,47 @@ jobs:
if: runner.os == 'Linux' && steps.codecov.outcome == 'failure'
run: echo "::warning::Codecov upload failed"

# Keep the derived-mirror suite concurrent with the main Windows package set.
test-duckdb-windows:
name: Go Test (windows-latest, DuckDB)
runs-on: windows-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false

- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod

- name: Setup MinGW
id: setup_mingw
continue-on-error: true
uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2
with:
msystem: MINGW64
update: false
install: mingw-w64-x86_64-gcc
path-type: inherit

- name: Setup MinGW (retry on transient failure)
if: steps.setup_mingw.outcome == 'failure'
uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2
with:
msystem: MINGW64
update: false
install: mingw-w64-x86_64-gcc
path-type: inherit

- name: Restore pricing snapshot
run: go run ./internal/pricing/cmd/litellm-snapshot -restore

- name: Run DuckDB tests
run: go test -tags "fts5" ./internal/duckdb -count=1 -timeout=20m
env:
CGO_ENABLED: "1"

test-race:
name: Go Test (race detector)
runs-on: ${{ github.repository == 'kenn-io/agentsview' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.repo.full_name == github.repository)) && 'kenn-linux-x64-public' || 'ubuntu-latest' }}
Expand Down
67 changes: 43 additions & 24 deletions internal/artifact/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package artifact

import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -45,6 +46,38 @@ func testExportDB(t *testing.T) *db.DB {
return database
}

// seedBareExportSessions inserts the minimal rows these export cardinality
// tests need in one transaction. The real sessions-table export triggers still
// enqueue every local row; parser validation and message replacement are not
// part of the behavior under test here.
func seedBareExportSessions(
t *testing.T,
database *db.DB,
count int,
idFormat string,
machine string,
) {
t.Helper()
ctx := t.Context()
require.NoError(t, database.Update(func(tx *sql.Tx) error {
stmt, err := tx.PrepareContext(ctx, `INSERT INTO sessions
(id, project, machine, agent, created_at)
VALUES (?, 'project', ?, 'claude', '2026-06-14T01:02:03Z')`)
if err != nil {
return err
}
defer func() { _ = stmt.Close() }()
for i := range count {
if _, err := stmt.ExecContext(
ctx, fmt.Sprintf(idFormat, i), machine,
); err != nil {
return err
}
}
return nil
}), "seed bare export sessions")
}

// latestStoreCheckpointForTest locates the highest-sequence checkpoint for
// origin and decodes its full session map. It stands in for peer.go's
// latestStoreCheckpointSummary (out of PR2 scope; see plan hazard 6) so
Expand Down Expand Up @@ -1364,12 +1397,9 @@ func TestArtifactExportCardinalityLoadsOnlyDirtyBatch(t *testing.T) {
for _, archiveSize := range []int{20, 2000} {
t.Run(fmt.Sprintf("archive-%d", archiveSize), func(t *testing.T) {
database := testExportDB(t)
for i := range archiveSize {
require.NoError(t, database.UpsertSession(db.Session{
ID: fmt.Sprintf("peer-%04d", i), Project: "project",
Machine: "peer-a1b2c3", Agent: "claude",
}))
}
seedBareExportSessions(
t, database, archiveSize, "peer-%04d", "peer-a1b2c3",
)
require.NoError(t, database.UpsertSession(db.Session{
ID: "dirty", Project: "project", Machine: "local", Agent: "claude",
}))
Expand Down Expand Up @@ -1404,12 +1434,9 @@ func TestExportToStoreCardinalityIgnoresUnrelatedArchiveBodies(t *testing.T) {
for _, archiveSize := range []int{20, 2000} {
t.Run(fmt.Sprintf("archive-%d", archiveSize), func(t *testing.T) {
database := testExportDB(t)
for i := range archiveSize {
require.NoError(t, database.UpsertSession(db.Session{
ID: fmt.Sprintf("peer-%04d", i), Project: "project",
Machine: "peer-a1b2c3", Agent: "claude",
}))
}
seedBareExportSessions(
t, database, archiveSize, "peer-%04d", "peer-a1b2c3",
)
seedSession(t, database, "dirty", "project")
counted := &countingCanonicalExportDB{DB: database}
filesystem, err := newProtocolTestStore(t.TempDir())
Expand Down Expand Up @@ -1473,12 +1500,9 @@ func TestExportToStoreIncrementalBatchIsBoundedAndFullStreamsAllBodies(t *testin
func TestExportToStoreFullDrainsMoreThanOneClaimPage(t *testing.T) {
database := testExportDB(t)
const total = 1025
for i := range total {
require.NoError(t, database.UpsertSession(db.Session{
ID: fmt.Sprintf("session-%04d", i), Project: "project",
Machine: "local", Agent: "claude", CreatedAt: "2026-06-14T01:02:03Z",
}))
}
seedBareExportSessions(
t, database, total, "session-%04d", "local",
)
counted := &countingCanonicalExportDB{DB: database}
filesystem, err := newProtocolTestStore(t.TempDir())
require.NoError(t, err)
Expand Down Expand Up @@ -1512,12 +1536,7 @@ func TestExportToStoreFullDrainsMoreThanOneClaimPage(t *testing.T) {
func TestExportToStoreExplicitSessionIDsClaimBeyondOldestQueuePage(t *testing.T) {
database := testExportDB(t)
const total = 1025
for i := range total {
require.NoError(t, database.UpsertSession(db.Session{
ID: fmt.Sprintf("session-%04d", i), Project: "project",
Machine: "local", Agent: "claude",
}))
}
seedBareExportSessions(t, database, total, "session-%04d", "local")
filesystem, err := newProtocolTestStore(t.TempDir())
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, filesystem.Close()) })
Expand Down
61 changes: 61 additions & 0 deletions internal/db/session_stats_race_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//go:build race

package db

import (
"context"
"sync"
"testing"

"github.com/stretchr/testify/require"
)

// TestOutcomeStatsWriterCloseRaceDoesNotPanic exercises the maintenance
// writer transition under the race detector. The normal suite covers the
// closed-writer state deterministically without paying for hundreds of git
// scans on every platform.
func TestOutcomeStatsWriterCloseRaceDoesNotPanic(t *testing.T) {
skipIfNoGit(t)
d := testDB(t)
repo := statsOutcomeRepo(t)
insertSessionFixture(t, d, sessionFixture{
id: "writer-race", agent: "claude", userMsgs: 5,
startedAt: hoursAgo(5), cwd: repo,
})

done := make(chan struct{})
toggleErr := make(chan error, 1)
var wg sync.WaitGroup
wg.Go(func() {
for {
select {
case <-done:
return
default:
}
if err := d.CloseWriter(); err != nil {
toggleErr <- err
return
}
if err := d.ReopenWriter(); err != nil {
toggleErr <- err
return
}
}
})

for range 300 {
// The race detector validates synchronization; transient errors while
// the writer is closed are acceptable for this stress case.
_, _ = d.GetSessionStats(context.Background(), StatsFilter{
Since: "28d", IncludeGitOutcomes: true,
})
}
close(done)
wg.Wait()
select {
case err := <-toggleErr:
require.NoError(t, err, "writer toggling failed")
default:
}
}
52 changes: 13 additions & 39 deletions internal/db/session_stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2612,54 +2612,28 @@ func TestGetSessionStats_OutcomeStats_Happy(t *testing.T) {
assert.Nil(t, out.PRsMerged, "PRsMerged want nil (no GHToken)")
}

// TestOutcomeStatsWriterCloseRaceDoesNotPanic guards the writer snapshot in
// computeOutcomeStats: closing the writer for a maintenance pass concurrently
// with the git outcome-stats path must fall back to the read-only cache and
// never hand git.NewCache a nil writer pool (which would panic on first use).
func TestOutcomeStatsWriterCloseRaceDoesNotPanic(t *testing.T) {
// TestOutcomeStatsClosedWriterUsesReadOnlyCache guards the writer snapshot in
// computeOutcomeStats: when a maintenance pass has closed the writer, the git
// outcome-stats path must use the read-only cache and return the same result.
// The concurrent close/reopen stress case lives in session_stats_race_test.go.
func TestOutcomeStatsClosedWriterUsesReadOnlyCache(t *testing.T) {
skipIfNoGit(t)
d := testDB(t)
ctx := context.Background()
repo := statsOutcomeRepo(t)
insertSessionFixture(t, d, sessionFixture{
id: "race1", agent: "claude", userMsgs: 5,
id: "closed-writer", agent: "claude", userMsgs: 5,
startedAt: hoursAgo(5), cwd: repo,
})

done := make(chan struct{})
toggleErr := make(chan error, 1)
var wg sync.WaitGroup
wg.Go(func() {
for {
select {
case <-done:
return
default:
}
if err := d.CloseWriter(); err != nil {
toggleErr <- err
return
}
if err := d.ReopenWriter(); err != nil {
toggleErr <- err
return
}
}
require.NoError(t, d.CloseWriter(), "close writer")
stats, err := d.GetSessionStats(ctx, StatsFilter{
Since: "28d", IncludeGitOutcomes: true,
})

for range 300 {
// Must never panic; a transient error while the writer is closed is fine.
_, _ = d.GetSessionStats(ctx, StatsFilter{
Since: "28d", IncludeGitOutcomes: true,
})
}
close(done)
wg.Wait()
select {
case err := <-toggleErr:
require.NoError(t, err, "writer toggling failed")
default:
}
require.NoError(t, err, "GetSessionStats with closed writer")
require.NotNil(t, stats.OutcomeStats, "OutcomeStats")
assert.Equal(t, 1, stats.OutcomeStats.ReposActive, "ReposActive")
assert.Equal(t, 3, stats.OutcomeStats.Commits, "Commits")
}

// TestGetSessionStats_OutcomeStats_NoCwd verifies that sessions without
Expand Down
10 changes: 7 additions & 3 deletions internal/parser/hermes_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -842,19 +842,23 @@ func TestHermesMemberCoreSeedRetainedIDBytesStayBounded(t *testing.T) {
stateDB := filepath.Join(root, "state.db")
conn, err := sql.Open("sqlite3", stateDB)
require.NoError(t, err)
_, err = conn.Exec("DELETE FROM messages; DELETE FROM sessions")
tx, err := conn.Begin()
require.NoError(t, err)
defer func() { _ = tx.Rollback() }()
_, err = tx.Exec("DELETE FROM messages; DELETE FROM sessions")
require.NoError(t, err)
for i := range sessionCount {
id := fmt.Sprintf("member-%06d", i)
_, err = conn.Exec(`INSERT INTO sessions
_, err = tx.Exec(`INSERT INTO sessions
(id, source, started_at, estimated_cost_usd, actual_cost_usd)
VALUES (?, 'cli', ?, 0, 0)`, id, i)
require.NoError(t, err)
_, err = conn.Exec(`INSERT INTO messages
_, err = tx.Exec(`INSERT INTO messages
(session_id, role, content, timestamp)
VALUES (?, 'user', 'hello', ?)`, id, i)
require.NoError(t, err)
}
require.NoError(t, tx.Commit())
require.NoError(t, conn.Close())

var retained, peak int64
Expand Down
16 changes: 9 additions & 7 deletions internal/parser/opencode_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1386,13 +1386,15 @@ func TestOpenCodeChangedPathWatermarkMergeMaterializesOnlyChangedBatch(
seeder.AddProject("proj", "/home/user/app")
const base = int64(1779012000000)
var stored []StoredMemberFreshness
for i := range sessions {
id := fmt.Sprintf("ses-%06d", i)
seeder.AddSession(id, "proj", "", id, base, base)
stored = append(stored, StoredMemberFreshness{
Path: dbPath + "#" + id, CoveredThroughNS: base * 1_000_000,
})
}
seeder.InTransaction(func(seeder *OpenCodeSeeder) {
for i := range sessions {
id := fmt.Sprintf("ses-%06d", i)
seeder.AddSession(id, "proj", "", id, base, base)
stored = append(stored, StoredMemberFreshness{
Path: dbPath + "#" + id, CoveredThroughNS: base * 1_000_000,
})
}
})
// One session advances past its stored coverage.
changed := fmt.Sprintf("ses-%06d", sessions/2)
_, err := seeder.db.ExecContext(t.Context(),
Expand Down
Loading
Loading