f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
282 lines
9.1 KiB
Go
282 lines
9.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
kitvec "go.kenn.io/kit/vector"
|
|
|
|
"go.kenn.io/agentsview/internal/config"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/postgres"
|
|
"go.kenn.io/agentsview/internal/vector"
|
|
)
|
|
|
|
// enabledVectorConfig returns a minimal Config with [vector] enabled and a
|
|
// fresh DataDir, so the push source resolves a vectors.db path under a temp
|
|
// dir that no other test touches.
|
|
func enabledVectorConfig(t *testing.T) config.Config {
|
|
t.Helper()
|
|
return config.Config{
|
|
DataDir: t.TempDir(),
|
|
Vector: config.VectorConfig{
|
|
Enabled: true,
|
|
Embeddings: config.VectorEmbeddingsConfig{
|
|
Model: "fake-model",
|
|
Dimension: 4,
|
|
MaxInputChars: 8192,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// fakePushUnitSource is a vector.UnitSource that replays a fixed slice of
|
|
// units, enough to build a tiny active generation for the round-trip test.
|
|
type fakePushUnitSource struct {
|
|
units []db.EmbeddableUnit
|
|
}
|
|
|
|
func (f fakePushUnitSource) ScanEmbeddableUnits(
|
|
_ context.Context, _ string, _ bool, fn func(db.EmbeddableUnit) error,
|
|
) (string, error) {
|
|
for _, u := range f.units {
|
|
if err := fn(u); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
return "2024-01-01T00:00:03Z", nil
|
|
}
|
|
|
|
// fakePushEncoder returns a deterministic 4-dimensional encoder matching the
|
|
// gen dimension the round-trip test builds with.
|
|
func fakePushEncoder() kitvec.EncodeFunc {
|
|
return func(_ context.Context, texts []string) ([][]float32, error) {
|
|
out := make([][]float32, len(texts))
|
|
for i := range texts {
|
|
out[i] = []float32{1, 0, 0, 0}
|
|
}
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
// testPushUnitSource returns the fixed unit set the push-source tests build
|
|
// their vectors.db from: two sessions, three documents.
|
|
func testPushUnitSource() fakePushUnitSource {
|
|
return fakePushUnitSource{units: []db.EmbeddableUnit{
|
|
{SessionID: "session-1", Kind: "user", SourceUUID: "u1", Content: "hello"},
|
|
{
|
|
SessionID: "session-1", Kind: "run", SourceUUID: "a1",
|
|
Ordinal: 1, OrdinalEnd: 2, Content: "first\n\nsecond",
|
|
Offsets: []db.UnitOffset{{Ordinal: 1}, {Ordinal: 2, RuneStart: 7, ByteStart: 7}},
|
|
},
|
|
{SessionID: "session-2", Kind: "user", SourceUUID: "u2", Content: "world"},
|
|
}}
|
|
}
|
|
|
|
// buildTestVectorsDB opens a read-write index at cfg's resolved vectors.db
|
|
// path, builds and activates one generation over two sessions, then closes
|
|
// it so the push source can reopen the file read-only.
|
|
func buildTestVectorsDB(t *testing.T, cfg config.Config) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
ix, err := vector.Open(
|
|
ctx, cfg.Vector.ResolvedDBPath(cfg.DataDir), false,
|
|
cfg.Vector.Embeddings.MaxInputChars,
|
|
)
|
|
require.NoError(t, err)
|
|
defer ix.Close()
|
|
|
|
gen := kitvec.Generation{Model: "fake-model", Dimensions: 4}
|
|
result, err := ix.Build(
|
|
ctx, testPushUnitSource(), fakePushEncoder(), gen, vector.BuildOptions{},
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, result.Activated)
|
|
}
|
|
|
|
// closePushSource registers a cleanup that closes the adapter's vectors.db
|
|
// handle. Required on Windows, where TempDir removal fails while the sqlite
|
|
// file is still open.
|
|
func closePushSource(t *testing.T, src postgres.VectorPushSource) {
|
|
t.Helper()
|
|
t.Cleanup(func() {
|
|
require.NoError(t, src.(*vectorPushSource).Close())
|
|
})
|
|
}
|
|
|
|
func TestNewVectorPushSourceDisabled(t *testing.T) {
|
|
cfg := config.Config{} // [vector] disabled
|
|
assert.Nil(t, newVectorPushSource(cfg))
|
|
}
|
|
|
|
func TestVectorPushSourceMissingFile(t *testing.T) {
|
|
cfg := enabledVectorConfig(t)
|
|
src := newVectorPushSource(cfg)
|
|
require.NotNil(t, src)
|
|
closePushSource(t, src)
|
|
|
|
_, ok, err := src.Generation(context.Background())
|
|
require.NoError(t, err)
|
|
assert.False(t, ok) // no vectors.db yet -> nothing to push, not an error
|
|
}
|
|
|
|
// TestVectorPushSourceMissingFileThenBuilt pins that a missing vectors.db is
|
|
// not memoized: the SAME adapter reports "nothing to push" before any build and
|
|
// then picks up a generation built at the same path afterward, as a daemon push
|
|
// that starts before embeddings exist must.
|
|
func TestVectorPushSourceMissingFileThenBuilt(t *testing.T) {
|
|
ctx := context.Background()
|
|
cfg := enabledVectorConfig(t)
|
|
src := newVectorPushSource(cfg)
|
|
require.NotNil(t, src)
|
|
closePushSource(t, src)
|
|
|
|
_, ok, err := src.Generation(ctx)
|
|
require.NoError(t, err)
|
|
require.False(t, ok, "no vectors.db yet -> nothing to push")
|
|
|
|
buildTestVectorsDB(t, cfg)
|
|
|
|
gen, ok, err := src.Generation(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, ok, "same adapter must pick up a later build")
|
|
assert.Equal(t, "fake-model", gen.Model)
|
|
|
|
hashes, err := src.SessionDocHashes(ctx)
|
|
require.NoError(t, err)
|
|
assert.Len(t, hashes, 2)
|
|
}
|
|
|
|
// TestVectorPushSourceNotReadyDuringRebuild pins the partial-coverage gate: a
|
|
// full rebuild clears the active generation's stamps in place before
|
|
// re-embedding, so an aborted (or still running) rebuild leaves the active
|
|
// generation with missing embeddings. Exporting that view would evict valid
|
|
// PG vectors, so Generation must refuse with ErrVectorSourceNotReady until a
|
|
// build completes.
|
|
func TestVectorPushSourceNotReadyDuringRebuild(t *testing.T) {
|
|
ctx := context.Background()
|
|
cfg := enabledVectorConfig(t)
|
|
buildTestVectorsDB(t, cfg)
|
|
|
|
// Start a full rebuild whose encoder fails: resetGeneration has already
|
|
// cleared the active generation's stamps, so coverage is now partial —
|
|
// exactly the state a concurrent push would observe mid-rebuild.
|
|
ix, err := vector.Open(
|
|
ctx, cfg.Vector.ResolvedDBPath(cfg.DataDir), false,
|
|
cfg.Vector.Embeddings.MaxInputChars,
|
|
)
|
|
require.NoError(t, err)
|
|
failingEncoder := func(_ context.Context, _ []string) ([][]float32, error) {
|
|
return nil, errors.New("embeddings endpoint down")
|
|
}
|
|
_, err = ix.Build(ctx, testPushUnitSource(), failingEncoder,
|
|
kitvec.Generation{Model: "fake-model", Dimensions: 4},
|
|
vector.BuildOptions{FullRebuild: true},
|
|
)
|
|
require.Error(t, err, "rebuild must abort on encoder failure")
|
|
require.NoError(t, ix.Close())
|
|
|
|
src := newVectorPushSource(cfg)
|
|
require.NotNil(t, src)
|
|
closePushSource(t, src)
|
|
|
|
_, ok, err := src.Generation(ctx)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, postgres.ErrVectorSourceNotReady)
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func TestVectorPushSourceRoundTrip(t *testing.T) {
|
|
ctx := context.Background()
|
|
cfg := enabledVectorConfig(t)
|
|
buildTestVectorsDB(t, cfg)
|
|
|
|
src := newVectorPushSource(cfg)
|
|
require.NotNil(t, src)
|
|
closePushSource(t, src)
|
|
|
|
gen, ok, err := src.Generation(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
assert.NotEmpty(t, gen.Fingerprint)
|
|
assert.Equal(t, "fake-model", gen.Model)
|
|
assert.Equal(t, 4, gen.Dimension)
|
|
|
|
hashes, err := src.SessionDocHashes(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, hashes, 2)
|
|
assert.Contains(t, hashes, "session-1")
|
|
assert.Contains(t, hashes, "session-2")
|
|
|
|
// Cross-check the adapter's mapping against the raw export API: every
|
|
// VectorPushDoc field must mirror its ExportDoc source exactly.
|
|
ix, err := vector.Open(
|
|
ctx, cfg.Vector.ResolvedDBPath(cfg.DataDir), true,
|
|
cfg.Vector.Embeddings.MaxInputChars,
|
|
)
|
|
require.NoError(t, err)
|
|
defer ix.Close()
|
|
exp, ok, err := ix.ActiveExport(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
want, wantHash, err := ix.ExportSessionDocs(ctx, exp.Ordinal, "session-1")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, want)
|
|
assert.Equal(t, hashes["session-1"], wantHash,
|
|
"export hash must match the delta-scan aggregate for an unchanged index")
|
|
|
|
docs, gotHash, err := src.SessionDocs(ctx, "session-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, wantHash, gotHash)
|
|
require.Len(t, docs, len(want))
|
|
for i, got := range docs {
|
|
w := want[i]
|
|
assert.Equal(t, w.DocKey, got.DocKey)
|
|
assert.Equal(t, w.SessionID, got.SessionID)
|
|
assert.Equal(t, w.SourceUUID, got.SourceUUID)
|
|
assert.Equal(t, w.Ordinal, got.Ordinal)
|
|
assert.Equal(t, w.OrdinalEnd, got.OrdinalEnd)
|
|
assert.Equal(t, w.Subordinate, got.Subordinate)
|
|
assert.Equal(t, w.OffsetsJSON, got.OffsetsJSON)
|
|
assert.Equal(t, w.Content, got.Content)
|
|
assert.Equal(t, w.ContentHash, got.ContentHash)
|
|
require.Len(t, got.Chunks, len(w.Chunks))
|
|
for j, gc := range got.Chunks {
|
|
assert.Equal(t, w.Chunks[j].ChunkIndex, gc.ChunkIndex)
|
|
assert.Equal(t, w.Chunks[j].Embedding, gc.Embedding)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCloseVectorPushSource pins the creator-owned handle lifecycle: closing
|
|
// releases the memoized read-only vectors.db handle (so per-push and
|
|
// per-watch-loop sources do not leak SQLite handles), a nil source is a
|
|
// no-op, and a closed adapter transparently reopens on the next call.
|
|
func TestCloseVectorPushSource(t *testing.T) {
|
|
closeVectorPushSource(nil) // must not panic
|
|
|
|
cfg := enabledVectorConfig(t)
|
|
buildTestVectorsDB(t, cfg)
|
|
src := newVectorPushSource(cfg)
|
|
require.NotNil(t, src)
|
|
|
|
ctx := context.Background()
|
|
_, ok, err := src.Generation(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
adapter := src.(*vectorPushSource)
|
|
require.NotNil(t, adapter.ix, "Generation memoizes the open handle")
|
|
|
|
closeVectorPushSource(src)
|
|
assert.Nil(t, adapter.ix, "close releases the memoized handle")
|
|
closeVectorPushSource(src) // idempotent
|
|
|
|
_, ok, err = src.Generation(ctx)
|
|
require.NoError(t, err)
|
|
assert.True(t, ok, "a closed adapter reopens lazily")
|
|
closePushSource(t, src)
|
|
}
|