feat: add phase 3 readonly models
This commit is contained in:
@@ -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: ".",
|
||||
}
|
||||
}
|
||||
|
||||
16
internal/projects/model.go
Normal file
16
internal/projects/model.go
Normal 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"`
|
||||
}
|
||||
92
internal/projects/store.go
Normal file
92
internal/projects/store.go
Normal 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
|
||||
}
|
||||
71
internal/projects/store_test.go
Normal file
71
internal/projects/store_test.go
Normal 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
40
internal/runtime/model.go
Normal 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
157
internal/runtime/store.go
Normal 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))
|
||||
}
|
||||
97
internal/runtime/store_test.go
Normal file
97
internal/runtime/store_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
43
internal/workflow/model.go
Normal file
43
internal/workflow/model.go
Normal 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
109
internal/workflow/store.go
Normal 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
|
||||
}
|
||||
65
internal/workflow/store_test.go
Normal file
65
internal/workflow/store_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user