From 69e1af7a17085dffb34afc86ee3e31903fc4f7d4 Mon Sep 17 00:00:00 2001 From: Yoilun Date: Mon, 25 May 2026 18:43:46 +0800 Subject: [PATCH] fix: tolerate runtime schema drift --- docs/project.md | 5 +- findings.md | 4 +- internal/runtime/store.go | 110 +++++++++++++++++-- internal/runtime/store_test.go | 183 ++++++++++++++++++++++++++++++-- internal/server/server_test.go | 2 +- internal/workflow/store.go | 8 ++ internal/workflow/store_test.go | 13 +++ progress.md | 13 +++ 8 files changed, 313 insertions(+), 25 deletions(-) diff --git a/docs/project.md b/docs/project.md index baacda9..3dc5674 100644 --- a/docs/project.md +++ b/docs/project.md @@ -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。 diff --git a/findings.md b/findings.md index 20ada40..2a619b5 100644 --- a/findings.md +++ b/findings.md @@ -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` 证据构建,不假设固定角色顺序。 diff --git a/internal/runtime/store.go b/internal/runtime/store.go index e1f78b1..9714173 100644 --- a/internal/runtime/store.go +++ b/internal/runtime/store.go @@ -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() diff --git a/internal/runtime/store_test.go b/internal/runtime/store_test.go index a5b902c..fc588c5 100644 --- a/internal/runtime/store_test.go +++ b/internal/runtime/store_test.go @@ -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 { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 93a4515..f04ad9c 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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" { diff --git a/internal/workflow/store.go b/internal/workflow/store.go index 0f70141..715d0ba 100644 --- a/internal/workflow/store.go +++ b/internal/workflow/store.go @@ -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 diff --git a/internal/workflow/store_test.go b/internal/workflow/store_test.go index 9581cba..95cdd45 100644 --- a/internal/workflow/store_test.go +++ b/internal/workflow/store_test.go @@ -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 } diff --git a/progress.md b/progress.md index ad8c383..90d4ea0 100644 --- a/progress.md +++ b/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 |