c8a87f1733
executus CI / test (pull_request) Successful in 1m34s
All 3 cloud models converged on real concurrency bugs in the SQLite stores: - AppendVersion (HIGH): the seq key was `SELECT MAX(seq)+1` then INSERT in two un-transacted statements with a NON-unique index, AND the Scan error was swallowed (seq stayed 0 on failure). Concurrent appends could both land the same seq, silently breaking newest-first ordering. Now: one transaction, the Scan error is propagated, the (skill_id, seq) index is UNIQUE (the loser of a race fails loudly), and an empty SkillID is rejected. - MarkScheduledRun / MarkAgentScheduledRun (all 3): replaced the Get→mutate→Save read-modify-write (lost-update window) with a single atomic UPDATE using json_set, so a concurrent Mark/edit can't clobber it. json_set keeps the JSON blob's NextRunAt/LastScheduledRunAt consistent with the indexed column; RFC3339Nano matches Go's time encoding so the blob still round-trips (tested). - Open: actually applies PRAGMA busy_timeout=5000 (the doc advertised it but it was never set) — a contended writer waits instead of erroring SQLITE_BUSY. - budgetStore.Add: rejects NaN/Inf secondsUsed (would irrecoverably poison the column). Triaged-but-kept: plaintext webhook secret (documented design, high-entropy URL key, pre-existing); SQL()/free-form `where` helpers (no untrusted input reaches them — defense-in-depth notes only). Core go.sum still free of host/DB deps; contrib/store green (incl. a json_set blob-round-trip test). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
107 lines
3.4 KiB
Go
107 lines
3.4 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/executus/persona"
|
|
)
|
|
|
|
func TestSQLitePersonaStore(t *testing.T) {
|
|
ctx := context.Background()
|
|
db, err := Open(":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
st := db.Personas()
|
|
if err := st.InitializeAgentStorage(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Full struct round-trips through the JSON blob (incl. nested + map fields).
|
|
a := &persona.Agent{
|
|
ID: "a1", Name: "helper", OwnerID: "o1", SystemPrompt: "be nice",
|
|
ModelTier: "fast", SkillPalette: []string{"animate"},
|
|
StateReactEmoji: map[string]string{"running": "⏳"},
|
|
ChatbotChannelFilter: "general",
|
|
}
|
|
if err := st.SaveAgent(ctx, a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := st.GetAgent(ctx, "a1")
|
|
if err != nil || got.SystemPrompt != "be nice" || len(got.SkillPalette) != 1 ||
|
|
got.StateReactEmoji["running"] != "⏳" {
|
|
t.Fatalf("round-trip lost fields: %+v (err %v)", got, err)
|
|
}
|
|
if byName, err := st.GetAgentByName(ctx, "o1", "helper"); err != nil || byName.ID != "a1" {
|
|
t.Fatalf("GetAgentByName: %v %+v", err, byName)
|
|
}
|
|
if cf, _ := st.ListAgentsByChatbotChannelFilter(ctx); len(cf) != 1 {
|
|
t.Errorf("ListAgentsByChatbotChannelFilter = %d, want 1", len(cf))
|
|
}
|
|
|
|
// Scheduling: due query + MarkAgentScheduledRun round-trip.
|
|
now := time.Now().UTC()
|
|
sched := &persona.Agent{ID: "s1", Name: "cron", OwnerID: "o1", Schedule: "0 * * * *"}
|
|
due := now.Add(-time.Minute)
|
|
sched.NextRunAt = &due
|
|
if err := st.SaveAgent(ctx, sched); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dueList, _ := st.ListScheduledAgents(ctx, now)
|
|
if len(dueList) != 1 || dueList[0].ID != "s1" {
|
|
t.Fatalf("ListScheduledAgents = %+v", dueList)
|
|
}
|
|
next := now.Add(time.Hour)
|
|
if err := st.MarkAgentScheduledRun(ctx, "s1", now, next); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again, _ := st.ListScheduledAgents(ctx, now); len(again) != 0 {
|
|
t.Errorf("after MarkAgentScheduledRun, nothing should be due before now: %+v", again)
|
|
}
|
|
|
|
if err := st.DeleteAgent(ctx, "a1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.GetAgent(ctx, "a1"); err != persona.ErrNotFound {
|
|
t.Errorf("GetAgent after delete = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
// TestMarkAgentScheduledRunBlobRoundTrips guards the json_set atomic update:
|
|
// the JSON blob must stay parseable and reflect the new scheduled times.
|
|
func TestMarkAgentScheduledRunBlobRoundTrips(t *testing.T) {
|
|
ctx := context.Background()
|
|
db, _ := Open(":memory:")
|
|
defer db.Close()
|
|
st := db.Personas()
|
|
st.InitializeAgentStorage(ctx)
|
|
start := time.Now().UTC()
|
|
a := &persona.Agent{ID: "m1", Name: "n", OwnerID: "o", Schedule: "0 * * * *"}
|
|
a.NextRunAt = &start
|
|
if err := st.SaveAgent(ctx, a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ran := start
|
|
next := start.Add(time.Hour)
|
|
if err := st.MarkAgentScheduledRun(ctx, "m1", ran, next); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := st.GetAgent(ctx, "m1") // blob must still unmarshal
|
|
if err != nil {
|
|
t.Fatalf("GetAgent after json_set Mark failed (blob corrupt?): %v", err)
|
|
}
|
|
if got.NextRunAt == nil || !got.NextRunAt.Equal(next) {
|
|
t.Errorf("blob NextRunAt = %v, want %v", got.NextRunAt, next)
|
|
}
|
|
if got.LastScheduledRunAt == nil || !got.LastScheduledRunAt.Equal(ran) {
|
|
t.Errorf("blob LastScheduledRunAt = %v, want %v", got.LastScheduledRunAt, ran)
|
|
}
|
|
// Unknown id -> ErrNotFound.
|
|
if err := st.MarkAgentScheduledRun(ctx, "nope", ran, next); err != persona.ErrNotFound {
|
|
t.Errorf("Mark(unknown) = %v, want ErrNotFound", err)
|
|
}
|
|
}
|