fix: tolerate runtime schema drift
This commit is contained in:
@@ -10,7 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestStoreMissingSQLiteReturnsEmptySnapshot(t *testing.T) {
|
||||
snapshot, err := Store{CodexHome: t.TempDir()}.Snapshot()
|
||||
root := t.TempDir()
|
||||
snapshot, err := Store{CodexHome: root}.Snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot returned error: %v", err)
|
||||
}
|
||||
@@ -26,6 +27,12 @@ func TestStoreMissingSQLiteReturnsEmptySnapshot(t *testing.T) {
|
||||
if source := snapshot.Sources["goals"]; source.Kind != "sqlite_missing" || source.Confidence != "low" {
|
||||
t.Fatalf("unexpected goals source evidence: %#v", source)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "state_5.sqlite")); !os.IsNotExist(err) {
|
||||
t.Fatalf("state sqlite was created or stat failed unexpectedly: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "goals_1.sqlite")); !os.IsNotExist(err) {
|
||||
t.Fatalf("goals sqlite was created or stat failed unexpectedly: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreReadsThreadsEdgesAndGoalsFromReadonlySQLite(t *testing.T) {
|
||||
@@ -82,7 +89,7 @@ func TestStoreMarksStateMissingWhenOnlyGoalsSQLiteExists(t *testing.T) {
|
||||
if source := snapshot.Sources["goals"]; source.Kind != "sqlite_readonly" || source.Confidence != "high" {
|
||||
t.Fatalf("unexpected goals source evidence: %#v", source)
|
||||
}
|
||||
if snapshot.Source.Confidence != "partial" || snapshot.Source.Kind != "sqlite_partial" {
|
||||
if snapshot.Source.Confidence != "medium" || snapshot.Source.Kind != "sqlite_partial" {
|
||||
t.Fatalf("unexpected aggregate source evidence: %#v", snapshot.Source)
|
||||
}
|
||||
}
|
||||
@@ -110,19 +117,167 @@ func TestStoreMarksGoalsMissingWhenOnlyStateSQLiteExists(t *testing.T) {
|
||||
if source := snapshot.Sources["goals"]; source.Kind != "sqlite_missing" || source.Confidence != "low" {
|
||||
t.Fatalf("unexpected goals source evidence: %#v", source)
|
||||
}
|
||||
if snapshot.Source.Confidence != "partial" || snapshot.Source.Kind != "sqlite_partial" {
|
||||
if snapshot.Source.Confidence != "medium" || snapshot.Source.Kind != "sqlite_partial" {
|
||||
t.Fatalf("unexpected aggregate source evidence: %#v", snapshot.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreToleratesMissingOptionalRuntimeColumns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
stateDB := openWritableSQLite(t, filepath.Join(root, "state_5.sqlite"))
|
||||
defer stateDB.Close()
|
||||
execSQL(t, stateDB, `CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
role TEXT,
|
||||
status TEXT,
|
||||
created_at TEXT
|
||||
)`)
|
||||
execSQL(t, stateDB, `CREATE TABLE thread_spawn_edges (
|
||||
from_thread_id TEXT,
|
||||
to_thread_id TEXT,
|
||||
created_at TEXT
|
||||
)`)
|
||||
execSQL(t, stateDB, `INSERT INTO threads (id, role, status, created_at) VALUES
|
||||
('thread-a', 'analyst', 'done', '2026-05-25T01:00:00Z')`)
|
||||
execSQL(t, stateDB, `INSERT INTO thread_spawn_edges (from_thread_id, to_thread_id, created_at) VALUES
|
||||
('thread-a', 'thread-b', '2026-05-25T01:06:00Z')`)
|
||||
|
||||
snapshot, err := Store{CodexHome: root}.Snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot returned error: %v", err)
|
||||
}
|
||||
if len(snapshot.Threads) != 1 || snapshot.Threads[0].UpdatedAt != "" {
|
||||
t.Fatalf("unexpected threads for missing updated_at: %#v", snapshot.Threads)
|
||||
}
|
||||
if len(snapshot.SpawnEdges) != 1 || snapshot.SpawnEdges[0].Reason != "" {
|
||||
t.Fatalf("unexpected edges for missing reason: %#v", snapshot.SpawnEdges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreToleratesNullRuntimeValues(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
stateDB := openWritableSQLite(t, filepath.Join(root, "state_5.sqlite"))
|
||||
defer stateDB.Close()
|
||||
execSQL(t, stateDB, `CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
role TEXT,
|
||||
status TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)`)
|
||||
execSQL(t, stateDB, `CREATE TABLE thread_spawn_edges (
|
||||
from_thread_id TEXT,
|
||||
to_thread_id TEXT,
|
||||
reason TEXT,
|
||||
created_at TEXT
|
||||
)`)
|
||||
execSQL(t, stateDB, `INSERT INTO threads (id, role, status, created_at, updated_at) VALUES
|
||||
('thread-a', NULL, NULL, NULL, NULL)`)
|
||||
execSQL(t, stateDB, `INSERT INTO thread_spawn_edges (from_thread_id, to_thread_id, reason, created_at) VALUES
|
||||
('thread-a', 'thread-b', NULL, NULL)`)
|
||||
goalsDB := openWritableSQLite(t, filepath.Join(root, "goals_1.sqlite"))
|
||||
defer goalsDB.Close()
|
||||
execSQL(t, goalsDB, `CREATE TABLE thread_goals (
|
||||
thread_id TEXT,
|
||||
goal TEXT,
|
||||
status TEXT,
|
||||
updated_at TEXT
|
||||
)`)
|
||||
execSQL(t, goalsDB, `INSERT INTO thread_goals (thread_id, goal, status, updated_at) VALUES
|
||||
('thread-a', NULL, NULL, NULL)`)
|
||||
|
||||
snapshot, err := Store{CodexHome: root}.Snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot returned error: %v", err)
|
||||
}
|
||||
if snapshot.Threads[0].Role != "" || snapshot.Threads[0].Status != "" || snapshot.Threads[0].CreatedAt != "" || snapshot.Threads[0].UpdatedAt != "" {
|
||||
t.Fatalf("NULL thread fields were not converted to empty strings: %#v", snapshot.Threads[0])
|
||||
}
|
||||
if snapshot.SpawnEdges[0].Reason != "" || snapshot.SpawnEdges[0].CreatedAt != "" {
|
||||
t.Fatalf("NULL edge fields were not converted to empty strings: %#v", snapshot.SpawnEdges[0])
|
||||
}
|
||||
if snapshot.Goals[0].Goal != "" || snapshot.Goals[0].Status != "" || snapshot.Goals[0].UpdatedAt != "" {
|
||||
t.Fatalf("NULL goal fields were not converted to empty strings: %#v", snapshot.Goals[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCastsNumericRuntimeValuesToText(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
stateDB := openWritableSQLite(t, filepath.Join(root, "state_5.sqlite"))
|
||||
defer stateDB.Close()
|
||||
execSQL(t, stateDB, `CREATE TABLE threads (
|
||||
id INTEGER PRIMARY KEY,
|
||||
role INTEGER,
|
||||
status INTEGER,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)`)
|
||||
execSQL(t, stateDB, `CREATE TABLE thread_spawn_edges (
|
||||
from_thread_id INTEGER,
|
||||
to_thread_id INTEGER,
|
||||
reason INTEGER,
|
||||
created_at INTEGER
|
||||
)`)
|
||||
execSQL(t, stateDB, `INSERT INTO threads (id, role, status, created_at, updated_at) VALUES
|
||||
(7, 8, 9, 10, 11)`)
|
||||
execSQL(t, stateDB, `INSERT INTO thread_spawn_edges (from_thread_id, to_thread_id, reason, created_at) VALUES
|
||||
(7, 12, 13, 14)`)
|
||||
goalsDB := openWritableSQLite(t, filepath.Join(root, "goals_1.sqlite"))
|
||||
defer goalsDB.Close()
|
||||
execSQL(t, goalsDB, `CREATE TABLE thread_goals (
|
||||
thread_id INTEGER,
|
||||
goal INTEGER,
|
||||
status INTEGER,
|
||||
updated_at INTEGER
|
||||
)`)
|
||||
execSQL(t, goalsDB, `INSERT INTO thread_goals (thread_id, goal, status, updated_at) VALUES
|
||||
(7, 15, 16, 17)`)
|
||||
|
||||
snapshot, err := Store{CodexHome: root}.Snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot returned error: %v", err)
|
||||
}
|
||||
if snapshot.Threads[0].ID != "7" || snapshot.Threads[0].Role != "8" || snapshot.Threads[0].UpdatedAt != "11" {
|
||||
t.Fatalf("numeric thread fields were not cast to text: %#v", snapshot.Threads[0])
|
||||
}
|
||||
if snapshot.SpawnEdges[0].FromThreadID != "7" || snapshot.SpawnEdges[0].Reason != "13" {
|
||||
t.Fatalf("numeric edge fields were not cast to text: %#v", snapshot.SpawnEdges[0])
|
||||
}
|
||||
if snapshot.Goals[0].ThreadID != "7" || snapshot.Goals[0].Goal != "15" {
|
||||
t.Fatalf("numeric goal fields were not cast to text: %#v", snapshot.Goals[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreReturnsEmptyTableEvidenceWhenCriticalColumnMissing(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
stateDB := openWritableSQLite(t, filepath.Join(root, "state_5.sqlite"))
|
||||
defer stateDB.Close()
|
||||
execSQL(t, stateDB, `CREATE TABLE threads (
|
||||
role TEXT,
|
||||
status TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)`)
|
||||
execSQL(t, stateDB, `INSERT INTO threads (role, status, created_at, updated_at) VALUES
|
||||
('analyst', 'done', '2026-05-25T01:00:00Z', '2026-05-25T01:05:00Z')`)
|
||||
|
||||
snapshot, err := Store{CodexHome: root}.Snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot returned error: %v", err)
|
||||
}
|
||||
if len(snapshot.Threads) != 0 {
|
||||
t.Fatalf("threads = %#v, want empty when critical id column is missing", snapshot.Threads)
|
||||
}
|
||||
if source := snapshot.Sources["threads"]; source.Kind != "sqlite_schema_drift" || source.Confidence != "low" {
|
||||
t.Fatalf("unexpected threads schema source: %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
func createRuntimeSQLite(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
statePath := filepath.Join(root, "state_5.sqlite")
|
||||
goalsPath := filepath.Join(root, "goals_1.sqlite")
|
||||
stateDB, err := sql.Open("sqlite", statePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stateDB := openWritableSQLite(t, statePath)
|
||||
defer stateDB.Close()
|
||||
execSQL(t, stateDB, `CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -143,10 +298,7 @@ func createRuntimeSQLite(t *testing.T, root string) {
|
||||
execSQL(t, stateDB, `INSERT INTO thread_spawn_edges (from_thread_id, to_thread_id, reason, created_at) VALUES
|
||||
('thread-a', 'thread-b', 'handoff', '2026-05-25T01:06:00Z')`)
|
||||
|
||||
goalsDB, err := sql.Open("sqlite", goalsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
goalsDB := openWritableSQLite(t, goalsPath)
|
||||
defer goalsDB.Close()
|
||||
execSQL(t, goalsDB, `CREATE TABLE thread_goals (
|
||||
thread_id TEXT,
|
||||
@@ -158,6 +310,15 @@ func createRuntimeSQLite(t *testing.T, root string) {
|
||||
('thread-b', 'ship phase 3', 'in_progress', '2026-05-25T01:08:00Z')`)
|
||||
}
|
||||
|
||||
func openWritableSQLite(t *testing.T, path string) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func execSQL(t *testing.T, db *sql.DB, query string) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(query); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user