feat: add phase 3 readonly models

This commit is contained in:
Yoilun
2026-05-25 18:21:02 +08:00
parent 37e3d77110
commit d573bde194
18 changed files with 964 additions and 12 deletions

View File

@@ -6,13 +6,14 @@
## Target Architecture ## Target Architecture
计划架构为Go 后端提供 localhost HTTP APIVue 3 + Vite 前端提供中文工作台界面。当前后端已提供健康检查`.codex/agents/*.toml` 只读读取;目标后端将继续增加只读 SQLite 状态库、安全读取项目配置,并仅在用户确认后写回 `.codex/agents/*.toml` 计划架构为Go 后端提供 localhost HTTP APIVue 3 + Vite 前端提供中文工作台界面。当前后端已提供健康检查`.codex/agents/*.toml` 只读读取、项目配置只读读取、运行线程只读模型和动态工作流事件模型;目标后端后续仅在用户确认后写回 `.codex/agents/*.toml`
## Configuration ## Configuration
- `CODEX_HOME`: 默认 `/Users/yoilun/.codex` - `CODEX_HOME`: 默认 `/Users/yoilun/.codex`
- 后端监听地址:默认 `127.0.0.1:18083` - 后端监听地址:默认 `127.0.0.1:18083`
- 前端开发地址:默认 `127.0.0.1:13083` - 前端开发地址:默认 `127.0.0.1:13083`
- `WorkspaceRoot`: 默认当前进程工作目录,用于读取 `task_plan.md` 等计划文件证据。
## Runbook ## Runbook
@@ -34,14 +35,36 @@ curl http://127.0.0.1:18083/api/health
curl http://127.0.0.1:18083/api/agents 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 ## Security Boundaries
- 不读取 `.codex/auth.json` - 不读取 `.codex/auth.json`
- 不写入 Codex SQLite。 - 不写入 Codex SQLite。
- SQLite 通过纯 Go `modernc.org/sqlite``mode=ro&immutable=1` 打开;缺失 SQLite 返回空列表和低置信度来源说明。
- `.codex/agents/*.toml` 写回必须先备份。 - `.codex/agents/*.toml` 写回必须先备份。
- 当前 `/api/agents` 只读列出 `.codex/agents` 直属 `.toml` 文件,读取前通过 Codex home 边界和 agent TOML 专用 resolver坏 TOML 以单条 `invalid` 状态返回,不导致服务崩溃。 - 当前 `/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 ## Known Risks
- Codex 内部 SQLite schema 可能变化。 - Codex 内部 SQLite schema 可能变化。
- 运行状态由多来源推断,必须显示置信度。 - 运行状态由多来源推断,必须显示置信度。
- Phase 3 真实 SQLite 读取已覆盖临时测试库;如果真实 Codex schema 与当前查询列名不同,需要新增兼容查询而不是降级写死流程。

View File

@@ -10,8 +10,8 @@
- `.codex/agents/*.toml`: 智能体配置。 - `.codex/agents/*.toml`: 智能体配置。
- `.codex/config.toml`: 项目信任配置。 - `.codex/config.toml`: 项目信任配置。
- `.codex/state_5.sqlite`: threads、thread_spawn_edges、thread_dynamic_tools - `.codex/state_5.sqlite`: threads、thread_spawn_edgesPhase 3 通过 `mode=ro&immutable=1` 只读打开
- `.codex/goals_1.sqlite`: thread_goals。 - `.codex/goals_1.sqlite`: thread_goalsPhase 3 通过 `mode=ro&immutable=1` 只读打开
- 本机进程表Codex 进程辅助判断。 - 本机进程表Codex 进程辅助判断。
- 项目工作流文件task_plan.md、findings.md、progress.md、docs/project.md。 - 项目工作流文件task_plan.md、findings.md、progress.md、docs/project.md。
@@ -27,3 +27,6 @@
- 工作流显示使用动态事件流/DAG不写死阶段或 agent 顺序。 - 工作流显示使用动态事件流/DAG不写死阶段或 agent 顺序。
- 所有推断状态必须显示来源和置信度。 - 所有推断状态必须显示来源和置信度。
- UI 全中文,技术缩写保留英文。 - UI 全中文,技术缩写保留英文。
- Phase 3 使用纯 Go `modernc.org/sqlite` 读取 SQLite避免 CGO 运行时依赖。
- SQLite 文件不存在时返回空列表和 `sqlite_missing`/`low` 来源证据,不返回 500。
- 动态工作流事件从 threads、spawn edges、goals、`task_plan.md` 证据构建,不假设固定角色顺序。

16
go.mod
View File

@@ -1,3 +1,17 @@
module codex-agent-manager 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
)

51
go.sum Normal file
View File

@@ -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=

View File

@@ -6,15 +6,17 @@ import (
) )
type Config struct { type Config struct {
CodexHome string CodexHome string
HTTPAddr string HTTPAddr string
WorkspaceRoot string
} }
func DefaultConfig() Config { func DefaultConfig() Config {
if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" { if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" {
return Config{ return Config{
CodexHome: codexHome, CodexHome: codexHome,
HTTPAddr: "127.0.0.1:18083", HTTPAddr: "127.0.0.1:18083",
WorkspaceRoot: ".",
} }
} }
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
@@ -22,7 +24,8 @@ func DefaultConfig() Config {
home = "." home = "."
} }
return Config{ return Config{
CodexHome: filepath.Join(home, ".codex"), CodexHome: filepath.Join(home, ".codex"),
HTTPAddr: "127.0.0.1:18083", HTTPAddr: "127.0.0.1:18083",
WorkspaceRoot: ".",
} }
} }

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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))
}
}

40
internal/runtime/model.go Normal file
View File

@@ -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"`
}

157
internal/runtime/store.go Normal file
View File

@@ -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))
}

View File

@@ -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)
}
}

View File

@@ -6,11 +6,21 @@ import (
"codex-agent-manager/internal/agents" "codex-agent-manager/internal/agents"
"codex-agent-manager/internal/app" "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 { func New(cfg app.Config) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
agentStore := agents.Store{CodexHome: cfg.CodexHome} 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) { mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) 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}) 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 return mux
} }

View File

@@ -26,7 +26,7 @@ description = "负责实现"
req := httptest.NewRequest(http.MethodGet, "/api/agents", nil) req := httptest.NewRequest(http.MethodGet, "/api/agents", nil)
rec := httptest.NewRecorder() 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 { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) 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) 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)
}
}
}

View File

@@ -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)
}

109
internal/workflow/store.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -15,6 +15,7 @@
| 2026-05-25 | 2 | coding agent | TDD 修复 agent TOML parser 和 symlink 边界 | 完成;提交 `fix: validate agent toml boundaries` | | 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 | 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 | 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 ## Test Results
@@ -59,6 +60,18 @@
| 2026-05-25 | `go test ./...` | PASS | Required verification | | 2026-05-25 | `go test ./...` | PASS | Required verification |
| 2026-05-25 | `git diff --check` | PASS | Required verification | | 2026-05-25 | `git diff --check` | PASS | Required verification |
| 2026-05-25 | `git status --short` | PASS | Required verificationPhase 2 symlinked directory fix 文件待提交 | | 2026-05-25 | `git status --short` | PASS | Required verificationPhase 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 ## 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 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 | 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 | | 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 |

View File

@@ -20,7 +20,7 @@
| 0 | complete | 项目初始化与风险边界 | 计划文件存在;安全边界明确;不读取 auth.json不改 .codex | | 0 | complete | 项目初始化与风险边界 | 计划文件存在;安全边界明确;不读取 auth.json不改 .codex |
| 1 | complete | Go 项目骨架和安全边界 | 后端健康检查可运行Codex home 路径边界有测试;未读取真实 `.codex` 数据 | | 1 | complete | Go 项目骨架和安全边界 | 后端健康检查可运行Codex home 路径边界有测试;未读取真实 `.codex` 数据 |
| 2 | complete | Agent TOML 只读读取 | 能安全读取 `.codex/agents/*.toml`;坏 TOML 不导致崩溃;提供 `/api/agents` | | 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 只读工作台 | 项目/工作流/智能体视图可浏览;空数据可用 | | 4 | pending | 中文 UI 只读工作台 | 项目/工作流/智能体视图可浏览;空数据可用 |
| 5 | pending | API 集成和只读数据显示 | 前端连接只读 API显示真实 agent 数据和错误状态 | | 5 | pending | API 集成和只读数据显示 | 前端连接只读 API显示真实 agent 数据和错误状态 |
| 6 | pending | 草稿、TOML 校验、diff、备份、写回 | 草稿不覆盖原文件;无效 TOML 阻止写回;备份成功后才能写回 | | 6 | pending | 草稿、TOML 校验、diff、备份、写回 | 草稿不覆盖原文件;无效 TOML 阻止写回;备份成功后才能写回 |