317 lines
9.2 KiB
Go
317 lines
9.2 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)
|
|
sources := SourceMap{
|
|
"state": sqliteSource("state", statePath, stateExists),
|
|
"goals": sqliteSource("goals", goalsPath, goalsExists),
|
|
}
|
|
snapshot := Snapshot{
|
|
Threads: []Thread{},
|
|
SpawnEdges: []SpawnEdge{},
|
|
Goals: []Goal{},
|
|
Source: aggregateSource(sources),
|
|
Sources: sources,
|
|
}
|
|
if stateExists {
|
|
db, err := openReadonlySQLite(statePath)
|
|
if err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
defer db.Close()
|
|
snapshot.Threads, err = readThreads(db, statePath, snapshot.Sources)
|
|
if err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
snapshot.SpawnEdges, err = readSpawnEdges(db, statePath, snapshot.Sources)
|
|
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, snapshot.Sources)
|
|
if err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
func sqliteSource(name string, path string, exists bool) SourceEvidence {
|
|
if exists {
|
|
return SourceEvidence{Kind: "sqlite_readonly", Path: path, Confidence: "high"}
|
|
}
|
|
return SourceEvidence{
|
|
Kind: "sqlite_missing",
|
|
Path: path,
|
|
Confidence: "low",
|
|
Message: name + " SQLite file was not found; returning empty data for that source.",
|
|
}
|
|
}
|
|
|
|
func aggregateSource(sources SourceMap) SourceEvidence {
|
|
state := sources["state"]
|
|
goals := sources["goals"]
|
|
if state.Kind == "sqlite_readonly" && goals.Kind == "sqlite_readonly" {
|
|
return SourceEvidence{Kind: "sqlite_readonly", Confidence: "high"}
|
|
}
|
|
if state.Kind == "sqlite_missing" && goals.Kind == "sqlite_missing" {
|
|
return SourceEvidence{
|
|
Kind: "sqlite_missing",
|
|
Confidence: "low",
|
|
Message: "Codex SQLite files were not found; returning an empty read-only snapshot.",
|
|
}
|
|
}
|
|
return SourceEvidence{
|
|
Kind: "sqlite_partial",
|
|
Confidence: "medium",
|
|
Message: "One Codex SQLite source is missing; available data was read from existing sources only.",
|
|
}
|
|
}
|
|
|
|
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, sources SourceMap) ([]Thread, error) {
|
|
columns, err := tableColumns(db, "threads")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(columns) == 0 {
|
|
sources["threads"] = tableSource("sqlite_missing_table", sourcePath, "threads table was not found.")
|
|
return []Thread{}, nil
|
|
}
|
|
if !columns["id"] {
|
|
sources["threads"] = tableSource("sqlite_schema_drift", sourcePath, "threads table is missing required id column.")
|
|
return []Thread{}, nil
|
|
}
|
|
query := `SELECT ` +
|
|
textColumn(columns, "id") + `, ` +
|
|
firstTextColumn(columns, "agent_role", "role") + `, ` +
|
|
textColumn(columns, "status") + `, ` +
|
|
firstTextColumn(columns, "created_at_ms", "created_at") + `, ` +
|
|
firstTextColumn(columns, "updated_at_ms", "updated_at") + `, ` +
|
|
textColumn(columns, "cwd") + `, ` +
|
|
textColumn(columns, "title") + `, ` +
|
|
textColumn(columns, "agent_nickname") + `, ` +
|
|
textColumn(columns, "agent_role") + `, ` +
|
|
textColumn(columns, "agent_path") + `, ` +
|
|
textColumn(columns, "thread_source") + `, ` +
|
|
textColumn(columns, "preview") +
|
|
` FROM threads ORDER BY ` + orderBy(columns, "created_at_ms", "created_at", "id")
|
|
rows, err := db.Query(query)
|
|
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,
|
|
&item.CWD,
|
|
&item.Title,
|
|
&item.AgentNickname,
|
|
&item.AgentRole,
|
|
&item.AgentPath,
|
|
&item.ThreadSource,
|
|
&item.Preview,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
item.Source = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"}
|
|
threads = append(threads, item)
|
|
}
|
|
sources["threads"] = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"}
|
|
return threads, rows.Err()
|
|
}
|
|
|
|
func readSpawnEdges(db *sql.DB, sourcePath string, sources SourceMap) ([]SpawnEdge, error) {
|
|
columns, err := tableColumns(db, "thread_spawn_edges")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(columns) == 0 {
|
|
sources["thread_spawn_edges"] = tableSource("sqlite_missing_table", sourcePath, "thread_spawn_edges table was not found.")
|
|
return []SpawnEdge{}, nil
|
|
}
|
|
if !(columns["from_thread_id"] && columns["to_thread_id"]) && !(columns["parent_thread_id"] && columns["child_thread_id"]) {
|
|
sources["thread_spawn_edges"] = tableSource("sqlite_schema_drift", sourcePath, "thread_spawn_edges table is missing required endpoint columns.")
|
|
return []SpawnEdge{}, nil
|
|
}
|
|
query := `SELECT ` +
|
|
firstTextColumn(columns, "from_thread_id", "parent_thread_id") + `, ` +
|
|
firstTextColumn(columns, "to_thread_id", "child_thread_id") + `, ` +
|
|
firstTextColumn(columns, "reason", "status") + `, ` +
|
|
textColumn(columns, "created_at") +
|
|
` FROM thread_spawn_edges ORDER BY ` + orderBy(columns, "created_at", "from_thread_id", "parent_thread_id", "to_thread_id", "child_thread_id")
|
|
rows, err := db.Query(query)
|
|
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)
|
|
}
|
|
sources["thread_spawn_edges"] = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"}
|
|
return edges, rows.Err()
|
|
}
|
|
|
|
func readGoals(db *sql.DB, sourcePath string, sources SourceMap) ([]Goal, error) {
|
|
columns, err := tableColumns(db, "thread_goals")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(columns) == 0 {
|
|
sources["thread_goals"] = tableSource("sqlite_missing_table", sourcePath, "thread_goals table was not found.")
|
|
return []Goal{}, nil
|
|
}
|
|
if !columns["thread_id"] {
|
|
sources["thread_goals"] = tableSource("sqlite_schema_drift", sourcePath, "thread_goals table is missing required thread_id column.")
|
|
return []Goal{}, nil
|
|
}
|
|
query := `SELECT ` +
|
|
textColumn(columns, "thread_id") + `, ` +
|
|
firstTextColumn(columns, "goal", "objective") + `, ` +
|
|
textColumn(columns, "status") + `, ` +
|
|
firstTextColumn(columns, "updated_at_ms", "updated_at") +
|
|
` FROM thread_goals ORDER BY ` + orderBy(columns, "updated_at_ms", "updated_at", "thread_id")
|
|
rows, err := db.Query(query)
|
|
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)
|
|
}
|
|
sources["thread_goals"] = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"}
|
|
return goals, rows.Err()
|
|
}
|
|
|
|
func tableColumns(db *sql.DB, table string) (map[string]bool, error) {
|
|
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
columns := map[string]bool{}
|
|
for rows.Next() {
|
|
var cid sql.NullInt64
|
|
var name sql.NullString
|
|
var typ sql.NullString
|
|
var notNull sql.NullInt64
|
|
var defaultValue sql.NullString
|
|
var pk sql.NullInt64
|
|
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil {
|
|
return nil, err
|
|
}
|
|
if name.Valid {
|
|
columns[name.String] = true
|
|
}
|
|
}
|
|
return columns, rows.Err()
|
|
}
|
|
|
|
func textColumn(columns map[string]bool, name string) string {
|
|
if !columns[name] {
|
|
return "''"
|
|
}
|
|
return "COALESCE(CAST(" + name + " AS TEXT), '')"
|
|
}
|
|
|
|
func firstTextColumn(columns map[string]bool, names ...string) string {
|
|
for _, name := range names {
|
|
if columns[name] {
|
|
return textColumn(columns, name)
|
|
}
|
|
}
|
|
return "''"
|
|
}
|
|
|
|
func orderBy(columns map[string]bool, names ...string) string {
|
|
var order []string
|
|
for _, name := range names {
|
|
if columns[name] {
|
|
order = append(order, name)
|
|
}
|
|
}
|
|
if len(order) == 0 {
|
|
return "rowid"
|
|
}
|
|
return strings.Join(order, ", ")
|
|
}
|
|
|
|
func tableSource(kind string, sourcePath string, message string) SourceEvidence {
|
|
return SourceEvidence{Kind: kind, Path: sourcePath, Confidence: "low", Message: message}
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
func isMissingTable(err error) bool {
|
|
return err != nil && (strings.Contains(err.Error(), "no such table") || errors.Is(err, sql.ErrNoRows))
|
|
}
|