feat: add phase 3 readonly models
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user