fix: tolerate runtime schema drift
This commit is contained in:
@@ -60,7 +60,8 @@ curl http://127.0.0.1:18083/api/workflow/events
|
||||
- 不读取 `.codex/auth.json`。
|
||||
- 不写入 Codex SQLite。
|
||||
- SQLite 通过纯 Go `modernc.org/sqlite v1.35.0` 和 `mode=ro&immutable=1` 打开;缺失 SQLite 返回空列表和低置信度来源说明。
|
||||
- 运行线程 API 返回聚合 `source` 和分数据源 `sources`;仅 `state_5.sqlite` 或仅 `goals_1.sqlite` 缺失时,聚合来源为 `sqlite_partial`,缺失的一侧为 `sqlite_missing` / `low`。
|
||||
- 运行线程 API 返回聚合 `source` 和分数据源 `sources`;仅 `state_5.sqlite` 或仅 `goals_1.sqlite` 缺失时,聚合来源为 `sqlite_partial` / `medium`,缺失的一侧为 `sqlite_missing` / `low`。
|
||||
- 运行线程读取使用 `PRAGMA table_info` 适配 schema drift;缺关键列时对应表返回空列表和 `sqlite_schema_drift` / `low` 证据,可选列缺失、NULL 和数值字段按空字符串或文本值处理。
|
||||
- `.codex/agents/*.toml` 写回必须先备份。
|
||||
- 当前 `/api/agents` 只读列出 `.codex/agents` 直属 `.toml` 文件,读取前通过 Codex home 边界和 agent TOML 专用 resolver;坏 TOML 以单条 `invalid` 状态返回,不导致服务崩溃。
|
||||
- 当前 `/api/projects` 只读解析 `.codex/config.toml` 中的 `[projects."..."]`,展示路径、显示名、信任等级和目录存在性。
|
||||
@@ -70,4 +71,4 @@ curl http://127.0.0.1:18083/api/workflow/events
|
||||
|
||||
- Codex 内部 SQLite schema 可能变化。
|
||||
- 运行状态由多来源推断,必须显示置信度。
|
||||
- Phase 3 真实 SQLite 读取已覆盖临时测试库;如果真实 Codex schema 与当前查询列名不同,需要新增兼容查询而不是降级写死流程。
|
||||
- Phase 3 真实 SQLite 读取已覆盖临时测试库;如果真实 Codex schema 新增字段或缺少可选字段,应继续走 schema-aware 查询和来源证据,而不是让 API 500。
|
||||
|
||||
@@ -28,5 +28,7 @@
|
||||
- 所有推断状态必须显示来源和置信度。
|
||||
- UI 全中文,技术缩写保留英文。
|
||||
- Phase 3 使用纯 Go `modernc.org/sqlite v1.35.0` 读取 SQLite,避免 CGO 运行时依赖,并保持项目 `go 1.22` 兼容。
|
||||
- SQLite 文件不存在时返回空列表和 `sqlite_missing`/`low` 来源证据;仅 state 或仅 goals 缺失时返回 `sqlite_partial` 聚合证据,并在 `sources.state` / `sources.goals` 分别标注缺失或只读来源。
|
||||
- SQLite 文件不存在时返回空列表和 `sqlite_missing`/`low` 来源证据;仅 state 或仅 goals 缺失时返回 `sqlite_partial`/`medium` 聚合证据,并在 `sources.state` / `sources.goals` 分别标注缺失或只读来源。
|
||||
- Runtime SQLite 读取先检查 `PRAGMA table_info`;缺关键列时对应表返回空列表和 `sqlite_schema_drift`/`low` 证据,可选列缺失、NULL 值和数值类型字段不导致 Snapshot 失败。
|
||||
- Workflow store 未配置 runtime reader 时返回空视图和 `runtime_missing`/`low` 证据,不 panic、不让 API 500。
|
||||
- 动态工作流事件从 threads、spawn edges、goals、`task_plan.md` 证据构建,不假设固定角色顺序。
|
||||
|
||||
@@ -44,11 +44,11 @@ func (s Store) Snapshot() (Snapshot, error) {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
defer db.Close()
|
||||
snapshot.Threads, err = readThreads(db, statePath)
|
||||
snapshot.Threads, err = readThreads(db, statePath, snapshot.Sources)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
snapshot.SpawnEdges, err = readSpawnEdges(db, statePath)
|
||||
snapshot.SpawnEdges, err = readSpawnEdges(db, statePath, snapshot.Sources)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func (s Store) Snapshot() (Snapshot, error) {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
defer db.Close()
|
||||
snapshot.Goals, err = readGoals(db, goalsPath)
|
||||
snapshot.Goals, err = readGoals(db, goalsPath, snapshot.Sources)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func aggregateSource(sources SourceMap) SourceEvidence {
|
||||
}
|
||||
return SourceEvidence{
|
||||
Kind: "sqlite_partial",
|
||||
Confidence: "partial",
|
||||
Confidence: "medium",
|
||||
Message: "One Codex SQLite source is missing; available data was read from existing sources only.",
|
||||
}
|
||||
}
|
||||
@@ -108,8 +108,21 @@ func openReadonlySQLite(path string) (*sql.DB, error) {
|
||||
return sql.Open("sqlite", uri.String())
|
||||
}
|
||||
|
||||
func readThreads(db *sql.DB, sourcePath string) ([]Thread, error) {
|
||||
rows, err := db.Query(`SELECT id, role, status, created_at, updated_at FROM threads ORDER BY created_at, id`)
|
||||
func readThreads(db *sql.DB, sourcePath string, sources SourceMap) ([]Thread, error) {
|
||||
columns, err := tableColumns(db, "threads")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
sources["threads"] = tableSource("sqlite_missing_table", sourcePath, "threads table was not found.")
|
||||
return []Thread{}, nil
|
||||
}
|
||||
if !columns["id"] {
|
||||
sources["threads"] = tableSource("sqlite_schema_drift", sourcePath, "threads table is missing required id column.")
|
||||
return []Thread{}, nil
|
||||
}
|
||||
query := `SELECT ` + textColumn(columns, "id") + `, ` + textColumn(columns, "role") + `, ` + textColumn(columns, "status") + `, ` + textColumn(columns, "created_at") + `, ` + textColumn(columns, "updated_at") + ` FROM threads ORDER BY ` + orderBy(columns, "created_at", "id")
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
if isMissingTable(err) {
|
||||
return []Thread{}, nil
|
||||
@@ -126,11 +139,25 @@ func readThreads(db *sql.DB, sourcePath string) ([]Thread, error) {
|
||||
item.Source = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"}
|
||||
threads = append(threads, item)
|
||||
}
|
||||
sources["threads"] = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"}
|
||||
return threads, rows.Err()
|
||||
}
|
||||
|
||||
func readSpawnEdges(db *sql.DB, sourcePath string) ([]SpawnEdge, error) {
|
||||
rows, err := db.Query(`SELECT from_thread_id, to_thread_id, reason, created_at FROM thread_spawn_edges ORDER BY created_at, from_thread_id, to_thread_id`)
|
||||
func readSpawnEdges(db *sql.DB, sourcePath string, sources SourceMap) ([]SpawnEdge, error) {
|
||||
columns, err := tableColumns(db, "thread_spawn_edges")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
sources["thread_spawn_edges"] = tableSource("sqlite_missing_table", sourcePath, "thread_spawn_edges table was not found.")
|
||||
return []SpawnEdge{}, nil
|
||||
}
|
||||
if !columns["from_thread_id"] || !columns["to_thread_id"] {
|
||||
sources["thread_spawn_edges"] = tableSource("sqlite_schema_drift", sourcePath, "thread_spawn_edges table is missing required endpoint columns.")
|
||||
return []SpawnEdge{}, nil
|
||||
}
|
||||
query := `SELECT ` + textColumn(columns, "from_thread_id") + `, ` + textColumn(columns, "to_thread_id") + `, ` + textColumn(columns, "reason") + `, ` + textColumn(columns, "created_at") + ` FROM thread_spawn_edges ORDER BY ` + orderBy(columns, "created_at", "from_thread_id", "to_thread_id")
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
if isMissingTable(err) {
|
||||
return []SpawnEdge{}, nil
|
||||
@@ -147,11 +174,25 @@ func readSpawnEdges(db *sql.DB, sourcePath string) ([]SpawnEdge, error) {
|
||||
item.Source = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"}
|
||||
edges = append(edges, item)
|
||||
}
|
||||
sources["thread_spawn_edges"] = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"}
|
||||
return edges, rows.Err()
|
||||
}
|
||||
|
||||
func readGoals(db *sql.DB, sourcePath string) ([]Goal, error) {
|
||||
rows, err := db.Query(`SELECT thread_id, goal, status, updated_at FROM thread_goals ORDER BY updated_at, thread_id`)
|
||||
func readGoals(db *sql.DB, sourcePath string, sources SourceMap) ([]Goal, error) {
|
||||
columns, err := tableColumns(db, "thread_goals")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
sources["thread_goals"] = tableSource("sqlite_missing_table", sourcePath, "thread_goals table was not found.")
|
||||
return []Goal{}, nil
|
||||
}
|
||||
if !columns["thread_id"] {
|
||||
sources["thread_goals"] = tableSource("sqlite_schema_drift", sourcePath, "thread_goals table is missing required thread_id column.")
|
||||
return []Goal{}, nil
|
||||
}
|
||||
query := `SELECT ` + textColumn(columns, "thread_id") + `, ` + textColumn(columns, "goal") + `, ` + textColumn(columns, "status") + `, ` + textColumn(columns, "updated_at") + ` FROM thread_goals ORDER BY ` + orderBy(columns, "updated_at", "thread_id")
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
if isMissingTable(err) {
|
||||
return []Goal{}, nil
|
||||
@@ -168,9 +209,58 @@ func readGoals(db *sql.DB, sourcePath string) ([]Goal, error) {
|
||||
item.Source = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"}
|
||||
goals = append(goals, item)
|
||||
}
|
||||
sources["thread_goals"] = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"}
|
||||
return goals, rows.Err()
|
||||
}
|
||||
|
||||
func tableColumns(db *sql.DB, table string) (map[string]bool, error) {
|
||||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
columns := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid sql.NullInt64
|
||||
var name sql.NullString
|
||||
var typ sql.NullString
|
||||
var notNull sql.NullInt64
|
||||
var defaultValue sql.NullString
|
||||
var pk sql.NullInt64
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name.Valid {
|
||||
columns[name.String] = true
|
||||
}
|
||||
}
|
||||
return columns, rows.Err()
|
||||
}
|
||||
|
||||
func textColumn(columns map[string]bool, name string) string {
|
||||
if !columns[name] {
|
||||
return "''"
|
||||
}
|
||||
return "COALESCE(CAST(" + name + " AS TEXT), '')"
|
||||
}
|
||||
|
||||
func orderBy(columns map[string]bool, names ...string) string {
|
||||
var order []string
|
||||
for _, name := range names {
|
||||
if columns[name] {
|
||||
order = append(order, name)
|
||||
}
|
||||
}
|
||||
if len(order) == 0 {
|
||||
return "rowid"
|
||||
}
|
||||
return strings.Join(order, ", ")
|
||||
}
|
||||
|
||||
func tableSource(kind string, sourcePath string, message string) SourceEvidence {
|
||||
return SourceEvidence{Kind: kind, Path: sourcePath, Confidence: "low", Message: message}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -151,7 +151,7 @@ func TestRuntimeThreadsEndpointReturnsPartialSourceEvidence(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
if body.Source.Kind != "sqlite_partial" || body.Source.Confidence != "partial" {
|
||||
if body.Source.Kind != "sqlite_partial" || body.Source.Confidence != "medium" {
|
||||
t.Fatalf("unexpected aggregate source: %#v", body.Source)
|
||||
}
|
||||
if body.Sources["state"].Kind != "sqlite_missing" || body.Sources["state"].Confidence != "low" {
|
||||
|
||||
@@ -16,6 +16,14 @@ type Store struct {
|
||||
}
|
||||
|
||||
func (s Store) View() (View, error) {
|
||||
if s.Runtime == nil {
|
||||
return View{
|
||||
Events: []Event{},
|
||||
HandoffEdges: []HandoffEdge{},
|
||||
Phases: []Phase{},
|
||||
Source: SourceEvidence{Kind: "runtime_missing", Confidence: "low", Message: "Runtime reader is not configured; returning an empty workflow view."},
|
||||
}, nil
|
||||
}
|
||||
snapshot, err := s.Runtime.Snapshot()
|
||||
if err != nil {
|
||||
return View{}, err
|
||||
|
||||
@@ -46,6 +46,19 @@ func TestStoreBuildsDynamicEventsWithoutFixedRoles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreNilRuntimeReturnsEmptyLowConfidenceView(t *testing.T) {
|
||||
view, err := Store{WorkspaceRoot: t.TempDir()}.View()
|
||||
if err != nil {
|
||||
t.Fatalf("View returned error: %v", err)
|
||||
}
|
||||
if len(view.Events) != 0 || len(view.HandoffEdges) != 0 || len(view.Phases) != 0 {
|
||||
t.Fatalf("expected empty view, got %#v", view)
|
||||
}
|
||||
if view.Source.Kind != "runtime_missing" || view.Source.Confidence != "low" {
|
||||
t.Fatalf("unexpected source: %#v", view.Source)
|
||||
}
|
||||
}
|
||||
|
||||
type StaticRuntime struct {
|
||||
SnapshotValue runtime.Snapshot
|
||||
}
|
||||
|
||||
13
progress.md
13
progress.md
@@ -17,6 +17,7 @@
|
||||
| 2026-05-25 | 2 | coding agent | TDD 修复 symlinked `agents` 目录边界 | 完成;提交 `fix: reject symlinked agents directory` |
|
||||
| 2026-05-25 | 3 | coding agent | TDD 实现项目配置、运行线程和动态工作流只读模型 | 完成;新增 `/api/projects`、`/api/runtime/threads`、`/api/workflow/events` |
|
||||
| 2026-05-25 | 3 | spec review | 规格审查未通过:SQLite 依赖提升 Go 下限到 1.25;单侧 DB 缺失来源证据不足 | coding agent 按 blocking 范围修复 |
|
||||
| 2026-05-25 | 3 | quality review | 代码质量审查未通过:SQLite schema drift 可导致 500、`partial` 置信度不在契约内、workflow nil Runtime panic | coding agent 按 blocking 范围修复 |
|
||||
|
||||
## Test Results
|
||||
|
||||
@@ -85,6 +86,15 @@
|
||||
| 2026-05-25 | `git diff --check` | PASS | Phase 3 review fix whitespace 检查通过 |
|
||||
| 2026-05-25 | `go test ./...` | PASS | Phase 3 review fix 全量 Go 测试通过 |
|
||||
| 2026-05-25 | `go test -count=1 ./...` | PASS | Phase 3 review fix 非缓存全量 Go 测试通过 |
|
||||
| 2026-05-25 | `go test ./internal/runtime ./internal/workflow` | FAIL | TDD 红灯:`partial` 置信度、缺可选列、NULL、缺关键列和 nil Runtime 均复现失败 |
|
||||
| 2026-05-25 | `go test ./internal/runtime ./internal/workflow` | PASS | schema-aware SQLite 读取和 nil Runtime 空视图通过 |
|
||||
| 2026-05-25 | `go test ./internal/server` | PASS | runtime API 的 `sqlite_partial` 置信度更新为 `medium` |
|
||||
| 2026-05-25 | `go test ./internal/runtime ./internal/workflow ./internal/server` | PASS | Phase 3 quality fix 目标包测试通过 |
|
||||
| 2026-05-25 | `go test ./...` | PASS | Phase 3 quality fix 全量 Go 测试通过 |
|
||||
| 2026-05-25 | `go test -count=1 ./...` | PASS | Phase 3 quality fix 非缓存全量 Go 测试通过 |
|
||||
| 2026-05-25 | `go vet ./...` | PASS | Phase 3 quality fix vet 通过 |
|
||||
| 2026-05-25 | `gofmt -l internal/runtime internal/workflow internal/server internal/projects internal/app cmd` | PASS | 无需格式化输出 |
|
||||
| 2026-05-25 | `git diff --check` | PASS | Phase 3 quality fix whitespace 检查通过 |
|
||||
|
||||
## Bug Loop
|
||||
|
||||
@@ -101,3 +111,6 @@
|
||||
| 3 | runtime 测试初次失败于未使用的 `os` import | 删除测试中不再使用的 import | `go test ./internal/runtime` PASS |
|
||||
| 3 | `modernc.org/sqlite v1.50.1` 将 module 最低版本提升到 Go 1.25 | 降级到 `modernc.org/sqlite v1.35.0`,清理高版本间接依赖,并恢复 `go 1.22` | `go test ./internal/runtime` PASS |
|
||||
| 3 | 单侧 SQLite 缺失时聚合来源仍可能显示整体 high confidence | 增加 `Snapshot.Sources`,按 `state` / `goals` 分别记录 `sqlite_missing` 或 `sqlite_readonly`,聚合来源使用 `sqlite_partial` | `go test ./internal/runtime` PASS |
|
||||
| 3 | SQLite schema drift、NULL 或数值字段可让 Snapshot 返回 error 并导致 API 500 | 使用 `PRAGMA table_info` 构造宽容 SELECT;缺关键列返回空表和 `sqlite_schema_drift` 证据;可选列/NULL/数值字段转为空字符串或文本 | `go test ./internal/runtime` PASS |
|
||||
| 3 | `SourceEvidence.Confidence` 出现设计外值 `partial` | 保留 `Kind: sqlite_partial`,将 `Confidence` 改为 `medium` | `go test ./internal/runtime ./internal/server` PASS |
|
||||
| 3 | `workflow.Store` 未配置 Runtime 会 panic | nil Runtime 返回空 view 和 `runtime_missing`/`low` 证据 | `go test ./internal/workflow` PASS |
|
||||
|
||||
Reference in New Issue
Block a user