From d573bde194e007248065b62a2c5b075f898c57c5 Mon Sep 17 00:00:00 2001 From: Yoilun Date: Mon, 25 May 2026 18:21:02 +0800 Subject: [PATCH] feat: add phase 3 readonly models --- docs/project.md | 25 ++++- findings.md | 7 +- go.mod | 16 +++- go.sum | 51 +++++++++++ internal/app/config.go | 15 +-- internal/projects/model.go | 16 ++++ internal/projects/store.go | 92 +++++++++++++++++++ internal/projects/store_test.go | 71 +++++++++++++++ internal/runtime/model.go | 40 ++++++++ internal/runtime/store.go | 157 ++++++++++++++++++++++++++++++++ internal/runtime/store_test.go | 97 ++++++++++++++++++++ internal/server/server.go | 56 ++++++++++++ internal/server/server_test.go | 100 +++++++++++++++++++- internal/workflow/model.go | 43 +++++++++ internal/workflow/store.go | 109 ++++++++++++++++++++++ internal/workflow/store_test.go | 65 +++++++++++++ progress.md | 14 +++ task_plan.md | 2 +- 18 files changed, 964 insertions(+), 12 deletions(-) create mode 100644 go.sum create mode 100644 internal/projects/model.go create mode 100644 internal/projects/store.go create mode 100644 internal/projects/store_test.go create mode 100644 internal/runtime/model.go create mode 100644 internal/runtime/store.go create mode 100644 internal/runtime/store_test.go create mode 100644 internal/workflow/model.go create mode 100644 internal/workflow/store.go create mode 100644 internal/workflow/store_test.go diff --git a/docs/project.md b/docs/project.md index 5bf4e2a..36320da 100644 --- a/docs/project.md +++ b/docs/project.md @@ -6,13 +6,14 @@ ## Target Architecture -计划架构为:Go 后端提供 localhost HTTP API,Vue 3 + Vite 前端提供中文工作台界面。当前后端已提供健康检查和 `.codex/agents/*.toml` 只读读取;目标后端将继续增加只读 SQLite 状态库、安全读取项目配置,并仅在用户确认后写回 `.codex/agents/*.toml`。 +计划架构为:Go 后端提供 localhost HTTP API,Vue 3 + Vite 前端提供中文工作台界面。当前后端已提供健康检查、`.codex/agents/*.toml` 只读读取、项目配置只读读取、运行线程只读模型和动态工作流事件模型;目标后端后续仅在用户确认后写回 `.codex/agents/*.toml`。 ## Configuration - `CODEX_HOME`: 默认 `/Users/yoilun/.codex` - 后端监听地址:默认 `127.0.0.1:18083` - 前端开发地址:默认 `127.0.0.1:13083` +- `WorkspaceRoot`: 默认当前进程工作目录,用于读取 `task_plan.md` 等计划文件证据。 ## Runbook @@ -34,14 +35,36 @@ curl http://127.0.0.1:18083/api/health curl http://127.0.0.1:18083/api/agents ``` +读取项目配置: + +```bash +curl http://127.0.0.1:18083/api/projects +``` + +读取运行线程: + +```bash +curl http://127.0.0.1:18083/api/runtime/threads +``` + +读取动态工作流事件: + +```bash +curl http://127.0.0.1:18083/api/workflow/events +``` + ## Security Boundaries - 不读取 `.codex/auth.json`。 - 不写入 Codex SQLite。 +- SQLite 通过纯 Go `modernc.org/sqlite` 和 `mode=ro&immutable=1` 打开;缺失 SQLite 返回空列表和低置信度来源说明。 - `.codex/agents/*.toml` 写回必须先备份。 - 当前 `/api/agents` 只读列出 `.codex/agents` 直属 `.toml` 文件,读取前通过 Codex home 边界和 agent TOML 专用 resolver;坏 TOML 以单条 `invalid` 状态返回,不导致服务崩溃。 +- 当前 `/api/projects` 只读解析 `.codex/config.toml` 中的 `[projects."..."]`,展示路径、显示名、信任等级和目录存在性。 +- 当前 `/api/workflow/events` 从运行线程、spawn edges、goals 和计划文件证据生成事件流/交接边/阶段状态,不写死固定流程。 ## Known Risks - Codex 内部 SQLite schema 可能变化。 - 运行状态由多来源推断,必须显示置信度。 +- Phase 3 真实 SQLite 读取已覆盖临时测试库;如果真实 Codex schema 与当前查询列名不同,需要新增兼容查询而不是降级写死流程。 diff --git a/findings.md b/findings.md index 7f71443..dc8d2aa 100644 --- a/findings.md +++ b/findings.md @@ -10,8 +10,8 @@ - `.codex/agents/*.toml`: 智能体配置。 - `.codex/config.toml`: 项目信任配置。 -- `.codex/state_5.sqlite`: threads、thread_spawn_edges、thread_dynamic_tools。 -- `.codex/goals_1.sqlite`: thread_goals。 +- `.codex/state_5.sqlite`: threads、thread_spawn_edges;Phase 3 通过 `mode=ro&immutable=1` 只读打开。 +- `.codex/goals_1.sqlite`: thread_goals;Phase 3 通过 `mode=ro&immutable=1` 只读打开。 - 本机进程表:Codex 进程辅助判断。 - 项目工作流文件:task_plan.md、findings.md、progress.md、docs/project.md。 @@ -27,3 +27,6 @@ - 工作流显示使用动态事件流/DAG,不写死阶段或 agent 顺序。 - 所有推断状态必须显示来源和置信度。 - UI 全中文,技术缩写保留英文。 +- Phase 3 使用纯 Go `modernc.org/sqlite` 读取 SQLite,避免 CGO 运行时依赖。 +- SQLite 文件不存在时返回空列表和 `sqlite_missing`/`low` 来源证据,不返回 500。 +- 动态工作流事件从 threads、spawn edges、goals、`task_plan.md` 证据构建,不假设固定角色顺序。 diff --git a/go.mod b/go.mod index b9ab3a4..72f96ea 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,17 @@ module codex-agent-manager -go 1.22 +go 1.25.0 + +require modernc.org/sqlite v1.50.1 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..38131a8 --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/app/config.go b/internal/app/config.go index e0d256c..2144b69 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -6,15 +6,17 @@ import ( ) type Config struct { - CodexHome string - HTTPAddr string + CodexHome string + HTTPAddr string + WorkspaceRoot string } func DefaultConfig() Config { if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" { return Config{ - CodexHome: codexHome, - HTTPAddr: "127.0.0.1:18083", + CodexHome: codexHome, + HTTPAddr: "127.0.0.1:18083", + WorkspaceRoot: ".", } } home, err := os.UserHomeDir() @@ -22,7 +24,8 @@ func DefaultConfig() Config { home = "." } return Config{ - CodexHome: filepath.Join(home, ".codex"), - HTTPAddr: "127.0.0.1:18083", + CodexHome: filepath.Join(home, ".codex"), + HTTPAddr: "127.0.0.1:18083", + WorkspaceRoot: ".", } } diff --git a/internal/projects/model.go b/internal/projects/model.go new file mode 100644 index 0000000..e535d22 --- /dev/null +++ b/internal/projects/model.go @@ -0,0 +1,16 @@ +package projects + +type Project struct { + Path string `json:"path"` + DisplayName string `json:"displayName"` + TrustLevel string `json:"trustLevel"` + DirectoryExists bool `json:"directoryExists"` + Source SourceEvidence `json:"source"` +} + +type SourceEvidence struct { + Kind string `json:"kind"` + Path string `json:"path,omitempty"` + Confidence string `json:"confidence"` + Message string `json:"message,omitempty"` +} diff --git a/internal/projects/store.go b/internal/projects/store.go new file mode 100644 index 0000000..968e867 --- /dev/null +++ b/internal/projects/store.go @@ -0,0 +1,92 @@ +package projects + +import ( + "bufio" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "codex-agent-manager/internal/codexhome" +) + +type Store struct { + CodexHome string +} + +func (s Store) List() ([]Project, error) { + configPath, err := codexhome.ResolveInside(s.CodexHome, "config.toml") + if err != nil { + return nil, err + } + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return []Project{}, nil + } + return nil, err + } + + projects := parseProjectsConfig(string(data), configPath) + sort.Slice(projects, func(i, j int) bool { + return projects[i].Path < projects[j].Path + }) + for i := range projects { + if projects[i].DisplayName == "" { + projects[i].DisplayName = filepath.Base(projects[i].Path) + } + if info, err := os.Stat(projects[i].Path); err == nil && info.IsDir() { + projects[i].DirectoryExists = true + } + } + return projects, nil +} + +func parseProjectsConfig(input string, sourcePath string) []Project { + var result []Project + var current *Project + scanner := bufio.NewScanner(strings.NewReader(input)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + current = nil + section := strings.TrimSuffix(strings.TrimPrefix(line, "["), "]") + if !strings.HasPrefix(section, "projects.") { + continue + } + path, err := strconv.Unquote(strings.TrimPrefix(section, "projects.")) + if err != nil || path == "" { + continue + } + result = append(result, Project{ + Path: path, + Source: SourceEvidence{Kind: "config_toml", Path: sourcePath, Confidence: "high"}, + TrustLevel: "unknown", + }) + current = &result[len(result)-1] + continue + } + if current == nil { + continue + } + key, raw, ok := strings.Cut(line, "=") + if !ok { + continue + } + value, err := strconv.Unquote(strings.TrimSpace(raw)) + if err != nil { + continue + } + switch strings.TrimSpace(key) { + case "trust_level": + current.TrustLevel = value + case "display_name": + current.DisplayName = value + } + } + return result +} diff --git a/internal/projects/store_test.go b/internal/projects/store_test.go new file mode 100644 index 0000000..0e02d2a --- /dev/null +++ b/internal/projects/store_test.go @@ -0,0 +1,71 @@ +package projects + +import ( + "os" + "path/filepath" + "testing" +) + +func TestStoreListsProjectsFromConfig(t *testing.T) { + root := t.TempDir() + existing := filepath.Join(root, "workspace-a") + missing := filepath.Join(root, "workspace-b") + if err := os.MkdirAll(existing, 0o755); err != nil { + t.Fatal(err) + } + config := `[projects."` + existing + `"] +trust_level = "trusted" +display_name = "Alpha" + +[projects."` + missing + `"] +trust_level = "untrusted" +` + if err := os.WriteFile(filepath.Join(root, "config.toml"), []byte(config), 0o644); err != nil { + t.Fatal(err) + } + + items, err := Store{CodexHome: root}.List() + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if len(items) != 2 { + t.Fatalf("len(items) = %d, want 2: %#v", len(items), items) + } + if items[0].Path != existing || items[0].DisplayName != "Alpha" || items[0].TrustLevel != "trusted" || !items[0].DirectoryExists { + t.Fatalf("unexpected first project: %#v", items[0]) + } + if items[1].Path != missing || items[1].DisplayName != filepath.Base(missing) || items[1].TrustLevel != "untrusted" || items[1].DirectoryExists { + t.Fatalf("unexpected second project: %#v", items[1]) + } +} + +func TestStoreListsProjectsInStablePathOrder(t *testing.T) { + root := t.TempDir() + config := `[projects."/tmp/zeta"] +trust_level = "trusted" + +[projects."/tmp/alpha"] +trust_level = "trusted" +` + if err := os.WriteFile(filepath.Join(root, "config.toml"), []byte(config), 0o644); err != nil { + t.Fatal(err) + } + + items, err := Store{CodexHome: root}.List() + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if got, want := []string{items[0].Path, items[1].Path}, []string{"/tmp/alpha", "/tmp/zeta"}; got[0] != want[0] || got[1] != want[1] { + t.Fatalf("paths = %#v, want %#v", got, want) + } +} + +func TestStoreMissingConfigReturnsEmptyListWithLowConfidenceSource(t *testing.T) { + items, err := Store{CodexHome: t.TempDir()}.List() + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if len(items) != 0 { + t.Fatalf("len(items) = %d, want 0", len(items)) + } +} diff --git a/internal/runtime/model.go b/internal/runtime/model.go new file mode 100644 index 0000000..05dc8a0 --- /dev/null +++ b/internal/runtime/model.go @@ -0,0 +1,40 @@ +package runtime + +type Snapshot struct { + Threads []Thread `json:"threads"` + SpawnEdges []SpawnEdge `json:"spawnEdges"` + Goals []Goal `json:"goals"` + Source SourceEvidence `json:"source"` +} + +type Thread struct { + ID string `json:"id"` + Role string `json:"role"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Source SourceEvidence `json:"source"` +} + +type SpawnEdge struct { + FromThreadID string `json:"fromThreadId"` + ToThreadID string `json:"toThreadId"` + Reason string `json:"reason"` + CreatedAt string `json:"createdAt"` + Source SourceEvidence `json:"source"` +} + +type Goal struct { + ThreadID string `json:"threadId"` + Goal string `json:"goal"` + Status string `json:"status"` + UpdatedAt string `json:"updatedAt"` + Source SourceEvidence `json:"source"` +} + +type SourceEvidence struct { + Kind string `json:"kind"` + Path string `json:"path,omitempty"` + Confidence string `json:"confidence"` + Message string `json:"message,omitempty"` +} diff --git a/internal/runtime/store.go b/internal/runtime/store.go new file mode 100644 index 0000000..ca32b1e --- /dev/null +++ b/internal/runtime/store.go @@ -0,0 +1,157 @@ +package runtime + +import ( + "database/sql" + "errors" + "net/url" + "os" + "strings" + + "codex-agent-manager/internal/codexhome" + + _ "modernc.org/sqlite" +) + +type Store struct { + CodexHome string +} + +func (s Store) Snapshot() (Snapshot, error) { + statePath, err := codexhome.ResolveInside(s.CodexHome, "state_5.sqlite") + if err != nil { + return Snapshot{}, err + } + goalsPath, err := codexhome.ResolveInside(s.CodexHome, "goals_1.sqlite") + if err != nil { + return Snapshot{}, err + } + stateExists := fileExists(statePath) + goalsExists := fileExists(goalsPath) + if !stateExists && !goalsExists { + return Snapshot{ + Threads: []Thread{}, + SpawnEdges: []SpawnEdge{}, + Goals: []Goal{}, + Source: SourceEvidence{ + Kind: "sqlite_missing", + Confidence: "low", + Message: "Codex SQLite files were not found; returning an empty read-only snapshot.", + }, + }, nil + } + + snapshot := Snapshot{ + Threads: []Thread{}, + SpawnEdges: []SpawnEdge{}, + Goals: []Goal{}, + Source: SourceEvidence{Kind: "sqlite_readonly", Path: statePath, Confidence: "high"}, + } + if stateExists { + db, err := openReadonlySQLite(statePath) + if err != nil { + return Snapshot{}, err + } + defer db.Close() + snapshot.Threads, err = readThreads(db, statePath) + if err != nil { + return Snapshot{}, err + } + snapshot.SpawnEdges, err = readSpawnEdges(db, statePath) + if err != nil { + return Snapshot{}, err + } + } + if goalsExists { + db, err := openReadonlySQLite(goalsPath) + if err != nil { + return Snapshot{}, err + } + defer db.Close() + snapshot.Goals, err = readGoals(db, goalsPath) + if err != nil { + return Snapshot{}, err + } + } + return snapshot, nil +} + +func openReadonlySQLite(path string) (*sql.DB, error) { + uri := url.URL{Scheme: "file", Path: path} + query := uri.Query() + query.Set("mode", "ro") + query.Set("immutable", "1") + uri.RawQuery = query.Encode() + 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`) + if err != nil { + if isMissingTable(err) { + return []Thread{}, nil + } + return nil, err + } + defer rows.Close() + var threads []Thread + for rows.Next() { + var item Thread + if err := rows.Scan(&item.ID, &item.Role, &item.Status, &item.CreatedAt, &item.UpdatedAt); err != nil { + return nil, err + } + item.Source = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"} + threads = append(threads, item) + } + 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`) + if err != nil { + if isMissingTable(err) { + return []SpawnEdge{}, nil + } + return nil, err + } + defer rows.Close() + var edges []SpawnEdge + for rows.Next() { + var item SpawnEdge + if err := rows.Scan(&item.FromThreadID, &item.ToThreadID, &item.Reason, &item.CreatedAt); err != nil { + return nil, err + } + item.Source = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"} + edges = append(edges, item) + } + 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`) + if err != nil { + if isMissingTable(err) { + return []Goal{}, nil + } + return nil, err + } + defer rows.Close() + var goals []Goal + for rows.Next() { + var item Goal + if err := rows.Scan(&item.ThreadID, &item.Goal, &item.Status, &item.UpdatedAt); err != nil { + return nil, err + } + item.Source = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"} + goals = append(goals, item) + } + return goals, rows.Err() +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func isMissingTable(err error) bool { + return err != nil && (strings.Contains(err.Error(), "no such table") || errors.Is(err, sql.ErrNoRows)) +} diff --git a/internal/runtime/store_test.go b/internal/runtime/store_test.go new file mode 100644 index 0000000..4ee8441 --- /dev/null +++ b/internal/runtime/store_test.go @@ -0,0 +1,97 @@ +package runtime + +import ( + "database/sql" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" +) + +func TestStoreMissingSQLiteReturnsEmptySnapshot(t *testing.T) { + snapshot, err := Store{CodexHome: t.TempDir()}.Snapshot() + if err != nil { + t.Fatalf("Snapshot returned error: %v", err) + } + if len(snapshot.Threads) != 0 || len(snapshot.SpawnEdges) != 0 || len(snapshot.Goals) != 0 { + t.Fatalf("expected empty snapshot, got %#v", snapshot) + } + if snapshot.Source.Confidence != "low" || snapshot.Source.Kind != "sqlite_missing" { + t.Fatalf("unexpected source evidence: %#v", snapshot.Source) + } +} + +func TestStoreReadsThreadsEdgesAndGoalsFromReadonlySQLite(t *testing.T) { + root := t.TempDir() + createRuntimeSQLite(t, root) + + snapshot, err := Store{CodexHome: root}.Snapshot() + if err != nil { + t.Fatalf("Snapshot returned error: %v", err) + } + if len(snapshot.Threads) != 2 { + t.Fatalf("threads = %#v", snapshot.Threads) + } + if snapshot.Threads[0].ID != "thread-a" || snapshot.Threads[0].Role != "analyst" { + t.Fatalf("unexpected first thread: %#v", snapshot.Threads[0]) + } + if len(snapshot.SpawnEdges) != 1 || snapshot.SpawnEdges[0].FromThreadID != "thread-a" || snapshot.SpawnEdges[0].ToThreadID != "thread-b" || snapshot.SpawnEdges[0].Reason != "handoff" { + t.Fatalf("unexpected edges: %#v", snapshot.SpawnEdges) + } + if len(snapshot.Goals) != 1 || snapshot.Goals[0].ThreadID != "thread-b" || snapshot.Goals[0].Status != "in_progress" { + t.Fatalf("unexpected goals: %#v", snapshot.Goals) + } + if snapshot.Source.Confidence != "high" || snapshot.Source.Kind != "sqlite_readonly" { + t.Fatalf("unexpected source evidence: %#v", snapshot.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) + } + 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', 'analyst', 'done', '2026-05-25T01:00:00Z', '2026-05-25T01:05:00Z'), + ('thread-b', 'operator', 'running', '2026-05-25T01:06:00Z', '2026-05-25T01:07:00Z')`) + 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) + } + 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-b', 'ship phase 3', 'in_progress', '2026-05-25T01:08:00Z')`) +} + +func execSQL(t *testing.T, db *sql.DB, query string) { + t.Helper() + if _, err := db.Exec(query); err != nil { + t.Fatalf("exec %q: %v", query, err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 7405f5c..0d00ec4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -6,11 +6,21 @@ import ( "codex-agent-manager/internal/agents" "codex-agent-manager/internal/app" + "codex-agent-manager/internal/projects" + "codex-agent-manager/internal/runtime" + "codex-agent-manager/internal/workflow" ) func New(cfg app.Config) http.Handler { mux := http.NewServeMux() agentStore := agents.Store{CodexHome: cfg.CodexHome} + projectStore := projects.Store{CodexHome: cfg.CodexHome} + runtimeStore := runtime.Store{CodexHome: cfg.CodexHome} + workspaceRoot := cfg.WorkspaceRoot + if workspaceRoot == "" { + workspaceRoot = "." + } + workflowStore := workflow.Store{WorkspaceRoot: workspaceRoot, Runtime: runtimeStore} mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) @@ -27,6 +37,52 @@ func New(cfg app.Config) http.Handler { } writeJSON(w, http.StatusOK, map[string]any{"items": items}) }) + mux.HandleFunc("/api/projects", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) + return + } + items, err := projectStore.List() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) + }) + mux.HandleFunc("/api/runtime/threads", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) + return + } + snapshot, err := runtimeStore.Snapshot() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": snapshot.Threads, + "edges": snapshot.SpawnEdges, + "goals": snapshot.Goals, + "source": snapshot.Source, + }) + }) + mux.HandleFunc("/api/workflow/events", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) + return + } + view, err := workflowStore.View() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": view.Events, + "handoffEdges": view.HandoffEdges, + "phases": view.Phases, + "source": view.Source, + }) + }) return mux } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 6933637..666d3ff 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -26,7 +26,7 @@ description = "负责实现" req := httptest.NewRequest(http.MethodGet, "/api/agents", nil) rec := httptest.NewRecorder() - New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0"}).ServeHTTP(rec, req) + New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0", WorkspaceRoot: root}).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) @@ -56,3 +56,101 @@ func TestAgentsEndpointRejectsUnsupportedMethod(t *testing.T) { t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) } } + +func TestProjectsEndpointReturnsProjects(t *testing.T) { + root := t.TempDir() + projectPath := filepath.Join(root, "repo") + if err := os.MkdirAll(projectPath, 0o755); err != nil { + t.Fatal(err) + } + config := `[projects."` + projectPath + `"] +trust_level = "trusted" +display_name = "Repo" +` + if err := os.WriteFile(filepath.Join(root, "config.toml"), []byte(config), 0o644); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/projects", nil) + rec := httptest.NewRecorder() + New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0", WorkspaceRoot: root}).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var body struct { + Items []struct { + Path string `json:"path"` + DisplayName string `json:"displayName"` + TrustLevel string `json:"trustLevel"` + DirectoryExists bool `json:"directoryExists"` + } `json:"items"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(body.Items) != 1 || body.Items[0].Path != projectPath || body.Items[0].DisplayName != "Repo" || !body.Items[0].DirectoryExists { + t.Fatalf("unexpected response: %#v", body) + } +} + +func TestRuntimeThreadsEndpointReturnsEmptyWhenSQLiteMissing(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/runtime/threads", nil) + rec := httptest.NewRecorder() + New(app.Config{CodexHome: t.TempDir(), HTTPAddr: "127.0.0.1:0"}).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var body struct { + Items []any `json:"items"` + Source struct { + Kind string `json:"kind"` + Confidence string `json:"confidence"` + } `json:"source"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(body.Items) != 0 || body.Source.Kind != "sqlite_missing" || body.Source.Confidence != "low" { + t.Fatalf("unexpected response: %#v", body) + } +} + +func TestWorkflowEventsEndpointReturnsEvents(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "task_plan.md"), []byte("| 3 | in_progress | Runtime model |\n"), 0o644); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/workflow/events", nil) + rec := httptest.NewRecorder() + New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0", WorkspaceRoot: root}).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var body struct { + Items []struct { + Kind string `json:"kind"` + } `json:"items"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(body.Items) != 1 || body.Items[0].Kind != "plan_file" { + t.Fatalf("unexpected response: %#v", body) + } +} + +func TestReadOnlyEndpointsRejectUnsupportedMethods(t *testing.T) { + for _, path := range []string{"/api/projects", "/api/runtime/threads", "/api/workflow/events"} { + req := httptest.NewRequest(http.MethodPost, path, nil) + rec := httptest.NewRecorder() + New(app.Config{CodexHome: t.TempDir(), HTTPAddr: "127.0.0.1:0"}).ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("%s status = %d, want %d", path, rec.Code, http.StatusMethodNotAllowed) + } + } +} diff --git a/internal/workflow/model.go b/internal/workflow/model.go new file mode 100644 index 0000000..cbedcea --- /dev/null +++ b/internal/workflow/model.go @@ -0,0 +1,43 @@ +package workflow + +import "codex-agent-manager/internal/runtime" + +type View struct { + Events []Event `json:"events"` + HandoffEdges []HandoffEdge `json:"handoffEdges"` + Phases []Phase `json:"phases"` + Source SourceEvidence `json:"source"` +} + +type Event struct { + Kind string `json:"kind"` + Label string `json:"label"` + ThreadID string `json:"threadId,omitempty"` + RelatedID string `json:"relatedId,omitempty"` + OccurredAt string `json:"occurredAt,omitempty"` + Source SourceEvidence `json:"source"` +} + +type HandoffEdge struct { + FromThreadID string `json:"fromThreadId"` + ToThreadID string `json:"toThreadId"` + Label string `json:"label"` + Source SourceEvidence `json:"source"` +} + +type Phase struct { + Name string `json:"name"` + Status string `json:"status"` + Source SourceEvidence `json:"source"` +} + +type SourceEvidence struct { + Kind string `json:"kind"` + Path string `json:"path,omitempty"` + Confidence string `json:"confidence"` + Message string `json:"message,omitempty"` +} + +type RuntimeReader interface { + Snapshot() (runtime.Snapshot, error) +} diff --git a/internal/workflow/store.go b/internal/workflow/store.go new file mode 100644 index 0000000..0f70141 --- /dev/null +++ b/internal/workflow/store.go @@ -0,0 +1,109 @@ +package workflow + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "codex-agent-manager/internal/runtime" +) + +type Store struct { + WorkspaceRoot string + Runtime RuntimeReader +} + +func (s Store) View() (View, error) { + snapshot, err := s.Runtime.Snapshot() + if err != nil { + return View{}, err + } + view := View{ + Events: []Event{}, + HandoffEdges: []HandoffEdge{}, + Phases: []Phase{}, + Source: SourceEvidence{Kind: snapshot.Source.Kind, Path: snapshot.Source.Path, Confidence: snapshot.Source.Confidence, Message: snapshot.Source.Message}, + } + for _, thread := range snapshot.Threads { + view.Events = append(view.Events, Event{ + Kind: "thread", + Label: thread.Role, + ThreadID: thread.ID, + OccurredAt: thread.CreatedAt, + Source: fromRuntimeSource(thread.Source), + }) + } + for _, edge := range snapshot.SpawnEdges { + source := fromRuntimeSource(edge.Source) + view.Events = append(view.Events, Event{ + Kind: "handoff", + Label: edge.Reason, + ThreadID: edge.FromThreadID, + RelatedID: edge.ToThreadID, + OccurredAt: edge.CreatedAt, + Source: source, + }) + view.HandoffEdges = append(view.HandoffEdges, HandoffEdge{ + FromThreadID: edge.FromThreadID, + ToThreadID: edge.ToThreadID, + Label: edge.Reason, + Source: source, + }) + } + for _, goal := range snapshot.Goals { + view.Events = append(view.Events, Event{ + Kind: "goal", + Label: goal.Status, + ThreadID: goal.ThreadID, + RelatedID: goal.Goal, + OccurredAt: goal.UpdatedAt, + Source: fromRuntimeSource(goal.Source), + }) + } + planEvents, phases := readPlanEvidence(s.WorkspaceRoot) + view.Events = append(view.Events, planEvents...) + view.Phases = phases + sort.SliceStable(view.Events, func(i, j int) bool { + if view.Events[i].OccurredAt == view.Events[j].OccurredAt { + return view.Events[i].Kind < view.Events[j].Kind + } + return view.Events[i].OccurredAt < view.Events[j].OccurredAt + }) + return view, nil +} + +func fromRuntimeSource(source runtime.SourceEvidence) SourceEvidence { + return SourceEvidence{ + Kind: source.Kind, + Path: source.Path, + Confidence: source.Confidence, + Message: source.Message, + } +} + +func readPlanEvidence(root string) ([]Event, []Phase) { + path := filepath.Join(root, "task_plan.md") + data, err := os.ReadFile(path) + if err != nil { + return []Event{}, []Phase{} + } + source := SourceEvidence{Kind: "plan_file", Path: path, Confidence: "medium"} + events := []Event{{Kind: "plan_file", Label: filepath.Base(path), Source: source}} + var phases []Phase + re := regexp.MustCompile(`^\|\s*([^|]+?)\s*\|\s*([A-Za-z_]+)\s*\|`) + for _, line := range strings.Split(string(data), "\n") { + match := re.FindStringSubmatch(line) + if len(match) != 3 { + continue + } + name := strings.TrimSpace(match[1]) + status := strings.TrimSpace(match[2]) + if strings.EqualFold(name, "Phase") || strings.EqualFold(status, "Status") { + continue + } + phases = append(phases, Phase{Name: name, Status: status, Source: source}) + } + return events, phases +} diff --git a/internal/workflow/store_test.go b/internal/workflow/store_test.go new file mode 100644 index 0000000..9581cba --- /dev/null +++ b/internal/workflow/store_test.go @@ -0,0 +1,65 @@ +package workflow + +import ( + "os" + "path/filepath" + "testing" + + "codex-agent-manager/internal/runtime" +) + +func TestStoreBuildsDynamicEventsWithoutFixedRoles(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "task_plan.md"), []byte("| 3 | in_progress | Runtime model |\n"), 0o644); err != nil { + t.Fatal(err) + } + snapshot := runtime.Snapshot{ + Threads: []runtime.Thread{ + {ID: "thread-a", Role: "cartographer", Status: "done", CreatedAt: "2026-05-25T01:00:00Z"}, + {ID: "thread-b", Role: "navigator", Status: "running", CreatedAt: "2026-05-25T01:02:00Z"}, + }, + SpawnEdges: []runtime.SpawnEdge{ + {FromThreadID: "thread-a", ToThreadID: "thread-b", Reason: "map complete", CreatedAt: "2026-05-25T01:02:00Z"}, + }, + Goals: []runtime.Goal{ + {ThreadID: "thread-b", Goal: "verify route", Status: "blocked", UpdatedAt: "2026-05-25T01:03:00Z"}, + }, + Source: runtime.SourceEvidence{Kind: "test", Confidence: "high", Path: "memory"}, + } + + view, err := Store{WorkspaceRoot: root, Runtime: StaticRuntime{SnapshotValue: snapshot}}.View() + if err != nil { + t.Fatalf("View returned error: %v", err) + } + if len(view.Events) != 5 { + t.Fatalf("events = %#v", view.Events) + } + assertHasEvent(t, view.Events, "thread", "cartographer") + assertHasEvent(t, view.Events, "handoff", "map complete") + assertHasEvent(t, view.Events, "goal", "blocked") + assertHasEvent(t, view.Events, "plan_file", "task_plan.md") + if len(view.HandoffEdges) != 1 || view.HandoffEdges[0].FromThreadID != "thread-a" || view.HandoffEdges[0].ToThreadID != "thread-b" { + t.Fatalf("unexpected handoff edges: %#v", view.HandoffEdges) + } + if len(view.Phases) != 1 || view.Phases[0].Name != "3" || view.Phases[0].Status != "in_progress" || view.Phases[0].Source.Confidence != "medium" { + t.Fatalf("unexpected phases: %#v", view.Phases) + } +} + +type StaticRuntime struct { + SnapshotValue runtime.Snapshot +} + +func (s StaticRuntime) Snapshot() (runtime.Snapshot, error) { + return s.SnapshotValue, nil +} + +func assertHasEvent(t *testing.T, events []Event, kind string, contains string) { + t.Helper() + for _, event := range events { + if event.Kind == kind && event.Label == contains { + return + } + } + t.Fatalf("missing event kind=%q label=%q in %#v", kind, contains, events) +} diff --git a/progress.md b/progress.md index 04a270b..edc246c 100644 --- a/progress.md +++ b/progress.md @@ -15,6 +15,7 @@ | 2026-05-25 | 2 | coding agent | TDD 修复 agent TOML parser 和 symlink 边界 | 完成;提交 `fix: validate agent toml boundaries` | | 2026-05-25 | 2 | spec review | 复审未通过:`agents -> .` 目录 symlink 可读取 root `config.toml` | coding agent 按 blocking 范围修复 | | 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` | ## Test Results @@ -59,6 +60,18 @@ | 2026-05-25 | `go test ./...` | PASS | Required verification | | 2026-05-25 | `git diff --check` | PASS | Required verification | | 2026-05-25 | `git status --short` | PASS | Required verification;Phase 2 symlinked directory fix 文件待提交 | +| 2026-05-25 | `go test ./internal/projects` | FAIL | TDD 红灯:`Store` 未定义 | +| 2026-05-25 | `go test ./internal/workflow` | FAIL | TDD 红灯:runtime 包无实现文件 | +| 2026-05-25 | `go test ./internal/server` | FAIL | TDD 红灯:Phase 3 API 端点返回 404/405 不符合预期 | +| 2026-05-25 | `go test ./internal/runtime` | FAIL | TDD 红灯:缺少 `modernc.org/sqlite` 依赖 | +| 2026-05-25 | `go get modernc.org/sqlite` | PASS_WITH_ESCALATION | 普通 sandbox 因代理连接权限失败;提升权限后下载纯 Go SQLite 驱动 | +| 2026-05-25 | `go test ./internal/projects` | PASS | projects config 解析、稳定排序、缺失 config 空列表通过 | +| 2026-05-25 | `go test ./internal/runtime` | PASS | SQLite 缺失空快照;临时 SQLite 只读读取 threads、edges、goals 通过 | +| 2026-05-25 | `go test ./internal/workflow` | PASS | 任意角色 edge/goal/plan file 生成动态事件和阶段证据通过 | +| 2026-05-25 | `go test ./internal/server` | PASS | Phase 3 GET 端点与非 GET 405 通过 | +| 2026-05-25 | `go test ./...` | PASS | 全量 Go 测试通过 | +| 2026-05-25 | `git diff --check` | PASS | Phase 3 whitespace 检查通过 | +| 2026-05-25 | `go test -count=1 ./...` | PASS | Phase 3 非缓存全量 Go 测试通过 | ## Bug Loop @@ -72,3 +85,4 @@ | 2 | Agent TOML parser 对重复键使用 map 覆盖,且未校验 bare key | 增加 duplicate key 和 invalid key 检测,遇到 malformed TOML 返回单条 invalid | `go test ./internal/agents` PASS | | 2 | Agent symlink 只校验最终路径在 Codex home 内,可读取 root `config.toml` | 在 agent store 层拒绝 `.toml` symlink,避免读取非 agent TOML 内容 | `go test ./internal/agents` PASS | | 2 | `agents` 目录 symlink 会让枚举逻辑读取 Codex home root 的 `.toml` 文件 | 在 `Store.List` 对 lexical `CodexHome/agents` 先 `Lstat`,发现 symlink 直接返回 forbidden error | `go test ./internal/agents` PASS | +| 3 | runtime 测试初次失败于未使用的 `os` import | 删除测试中不再使用的 import | `go test ./internal/runtime` PASS | diff --git a/task_plan.md b/task_plan.md index db36e1a..28f1b18 100644 --- a/task_plan.md +++ b/task_plan.md @@ -20,7 +20,7 @@ | 0 | complete | 项目初始化与风险边界 | 计划文件存在;安全边界明确;不读取 auth.json;不改 .codex | | 1 | complete | Go 项目骨架和安全边界 | 后端健康检查可运行;Codex home 路径边界有测试;未读取真实 `.codex` 数据 | | 2 | complete | Agent TOML 只读读取 | 能安全读取 `.codex/agents/*.toml`;坏 TOML 不导致崩溃;提供 `/api/agents` | -| 3 | pending | 项目配置、运行线程和工作流模型 | 能读取 projects、threads、spawn edges、goals;状态含来源/置信度;工作流不写死固定流程 | +| 3 | complete | 项目配置、运行线程和工作流模型 | 能读取 projects、threads、spawn edges、goals;状态含来源/置信度;工作流不写死固定流程 | | 4 | pending | 中文 UI 只读工作台 | 项目/工作流/智能体视图可浏览;空数据可用 | | 5 | pending | API 集成和只读数据显示 | 前端连接只读 API;显示真实 agent 数据和错误状态 | | 6 | pending | 草稿、TOML 校验、diff、备份、写回 | 草稿不覆盖原文件;无效 TOML 阻止写回;备份成功后才能写回 |