Files
codex-agent-manager/internal/runtime/store.go
2026-05-25 18:21:02 +08:00

158 lines
4.0 KiB
Go

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