mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
refactor(decision): move snapshot SQLite storage into internal/state/decisionstore
The decision tool package no longer opens SQLite itself; it maps the new leaf package's typed errors to its domain codes. Schema, retention, capacity and version/parent semantics are unchanged. Also restores the Registry comment in control.Options to its original wording.
This commit is contained in:
1 parent
4b9ab779e1
commit
19a5520014
9 files changed
+439
-287
No files matched your search
@@ -299,9 +299,8 @@ type Options struct {
|
||||
// SubagentGate is the shared gate every headless-only sub-agent surface
|
||||
// reads; nil disables gating there. The approval-mode setters Update it so
|
||||
// a runtime switch reaches sub-agents, not only the executor's own gate.
|
||||
SubagentGate *SharedHeadlessGate
|
||||
Label string
|
||||
ModelRef string
|
||||
SubagentGate *SharedHeadlessGate
|
||||
Label, ModelRef string
|
||||
// ModelModes are the optional modes the session's model declares.
|
||||
ModelModes []config.ModelMode
|
||||
SystemPrompt string
|
||||
@@ -325,6 +324,7 @@ type Options struct {
|
||||
ReadOnlySkillRunner skill.SubagentRunner
|
||||
SkillProfile skill.ProfileResolver
|
||||
Hooks *hook.Runner
|
||||
DecisionReporter DecisionReporter
|
||||
Memory *memory.Set
|
||||
Cleanup func()
|
||||
// Balance reads the active provider's optional wallet endpoint. Nil, or a
|
||||
@@ -339,10 +339,10 @@ type Options struct {
|
||||
TaskStore taskmonitor.WriteStore
|
||||
// WorkspaceLease is the Delivery writer owner shared with the executor.
|
||||
WorkspaceLease *workspacelease.Owner
|
||||
// Registry and PluginCtx support session-scoped MCP server registration.
|
||||
Registry *tool.Registry
|
||||
DecisionReporter DecisionReporter
|
||||
PluginCtx context.Context
|
||||
// Registry is the executor's live tool set, and PluginCtx the session-scoped
|
||||
// context; both are needed for hot-adding MCP servers via AddMCPServer.
|
||||
Registry *tool.Registry
|
||||
PluginCtx context.Context
|
||||
// MCPDefaultCallTimeout is the global MCP call cap used by hot-connected
|
||||
// servers when they do not declare a server- or tool-specific override.
|
||||
MCPDefaultCallTimeout time.Duration
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Package decisionstore persists immutable decision request snapshots in a
|
||||
// SQLite file. A snapshot is an opaque byte payload bound to an optional parent
|
||||
// version; the package knows nothing about its shape. Entries expire after
|
||||
// TTL, at most Capacity are kept, and every read verifies the stored digest and
|
||||
// record checksum. Failures are typed sentinels the caller classifies with
|
||||
// errors.Is.
|
||||
package decisionstore
|
||||
@@ -0,0 +1,243 @@
|
||||
package decisionstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
TTL = 7 * 24 * time.Hour
|
||||
Capacity = 256
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("decisionstore: snapshot not found")
|
||||
ErrExpired = errors.New("decisionstore: snapshot expired")
|
||||
ErrCorrupt = errors.New("decisionstore: snapshot corrupt")
|
||||
ErrCapacity = errors.New("decisionstore: capacity reached")
|
||||
ErrOpen = errors.New("decisionstore: open failed")
|
||||
ErrVersionUnsupported = errors.New("decisionstore: unsupported store version")
|
||||
ErrRead = errors.New("decisionstore: read failed")
|
||||
ErrWrite = errors.New("decisionstore: write failed")
|
||||
ErrID = errors.New("decisionstore: id generation failed")
|
||||
)
|
||||
|
||||
// Record is one stored snapshot.
|
||||
type Record struct {
|
||||
Payload []byte
|
||||
Parent string
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
// Store is a SQLite-backed snapshot repository at Path.
|
||||
type Store struct {
|
||||
Path string
|
||||
Now func() time.Time
|
||||
// Valid, when set, must accept a payload for its record to count as intact.
|
||||
Valid func([]byte) bool
|
||||
}
|
||||
|
||||
type query interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
func ValidID(id string) bool {
|
||||
if len(id) != 32 {
|
||||
return false
|
||||
}
|
||||
for _, c := range id {
|
||||
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func checksum(id, parent string, expires int64, raw []byte) string {
|
||||
hash := sha256.Sum256(append(fmt.Appendf(nil, "%s\n%s\n%d\n", id, parent, expires), raw...))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func (s *Store) open(ctx context.Context, writable bool) (*sql.DB, error) {
|
||||
if writable {
|
||||
if err := prepareFile(s.Path); err != nil {
|
||||
return nil, ErrOpen
|
||||
}
|
||||
}
|
||||
abs, err := filepath.Abs(s.Path)
|
||||
if err != nil {
|
||||
return nil, ErrOpen
|
||||
}
|
||||
slash := filepath.ToSlash(abs)
|
||||
if runtime.GOOS == "windows" && len(slash) >= 2 && slash[1] == ':' {
|
||||
slash = "/" + slash
|
||||
}
|
||||
u := url.URL{Scheme: "file", Path: slash}
|
||||
dsn := u.String() + "?_pragma=busy_timeout%285000%29&_pragma=synchronous%28FULL%29"
|
||||
if !writable {
|
||||
dsn += "&mode=ro"
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, ErrOpen
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := initialize(ctx, db, writable); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func prepareFile(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
func initialize(ctx context.Context, db *sql.DB, writable bool) error {
|
||||
var version int
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
|
||||
return ErrOpen
|
||||
}
|
||||
if version != 0 && version != 1 {
|
||||
return ErrVersionUnsupported
|
||||
}
|
||||
if !writable {
|
||||
if version != 1 {
|
||||
return ErrVersionUnsupported
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS decision_snapshots (
|
||||
id TEXT PRIMARY KEY, parent TEXT NOT NULL, expires INTEGER NOT NULL,
|
||||
request BLOB NOT NULL, digest TEXT NOT NULL, checksum TEXT NOT NULL,
|
||||
UNIQUE(parent,digest))`); err != nil {
|
||||
return ErrOpen
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA user_version=1"); err != nil {
|
||||
return ErrOpen
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) read(ctx context.Context, q query, id string, now time.Time) (Record, error) {
|
||||
var parent, digest, sum string
|
||||
var expires int64
|
||||
var raw []byte
|
||||
err := q.QueryRowContext(ctx, `SELECT parent,expires,CASE WHEN length(request)<=262144 THEN request ELSE NULL END,digest,checksum FROM decision_snapshots WHERE id=?`, id).Scan(&parent, &expires, &raw, &digest, &sum)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Record{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Record{}, ErrRead
|
||||
}
|
||||
hash := sha256.Sum256(raw)
|
||||
if len(raw) == 0 || parent != "" && !ValidID(parent) || hex.EncodeToString(hash[:]) != digest || checksum(id, parent, expires, raw) != sum {
|
||||
return Record{}, ErrCorrupt
|
||||
}
|
||||
until := time.Unix(0, expires).UTC()
|
||||
if !now.Before(until) {
|
||||
return Record{}, ErrExpired
|
||||
}
|
||||
if s.Valid != nil && !s.Valid(raw) {
|
||||
return Record{}, ErrCorrupt
|
||||
}
|
||||
return Record{Payload: raw, Parent: parent, Expires: until}, nil
|
||||
}
|
||||
|
||||
// Load returns the snapshot id without creating the store file.
|
||||
func (s *Store) Load(ctx context.Context, id string) (Record, error) {
|
||||
if _, err := os.Stat(s.Path); errors.Is(err, os.ErrNotExist) {
|
||||
return Record{}, ErrNotFound
|
||||
}
|
||||
db, err := s.open(ctx, false)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
defer db.Close()
|
||||
return s.read(ctx, db, id, s.Now())
|
||||
}
|
||||
|
||||
// Save stores payload under parent, returning the existing id for an identical
|
||||
// (parent, payload) pair.
|
||||
func (s *Store) Save(ctx context.Context, payload []byte, parent string) (string, time.Time, error) {
|
||||
db, err := s.open(ctx, true)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
defer db.Close()
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return "", time.Time{}, ErrWrite
|
||||
}
|
||||
defer conn.Close()
|
||||
if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
|
||||
return "", time.Time{}, ErrWrite
|
||||
}
|
||||
defer func() { _, _ = conn.ExecContext(context.Background(), "ROLLBACK") }()
|
||||
id, expires, err := s.insert(ctx, conn, payload, parent)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil {
|
||||
return "", time.Time{}, ErrWrite
|
||||
}
|
||||
return id, expires, nil
|
||||
}
|
||||
|
||||
func (s *Store) insert(ctx context.Context, conn *sql.Conn, raw []byte, parent string) (string, time.Time, error) {
|
||||
now := s.Now().UTC()
|
||||
if parent != "" {
|
||||
if _, err := s.read(ctx, conn, parent, now); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, "DELETE FROM decision_snapshots WHERE expires<=?", now.UnixNano()); err != nil {
|
||||
return "", time.Time{}, ErrWrite
|
||||
}
|
||||
digest := sha256.Sum256(raw)
|
||||
hexdigest := hex.EncodeToString(digest[:])
|
||||
var existing string
|
||||
err := conn.QueryRowContext(ctx, "SELECT id FROM decision_snapshots WHERE parent=? AND digest=?", parent, hexdigest).Scan(&existing)
|
||||
if err == nil {
|
||||
row, err := s.read(ctx, conn, existing, now)
|
||||
return existing, row.Expires, err
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", time.Time{}, ErrRead
|
||||
}
|
||||
var count int
|
||||
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM decision_snapshots").Scan(&count); err != nil {
|
||||
return "", time.Time{}, ErrRead
|
||||
}
|
||||
if count >= Capacity {
|
||||
return "", time.Time{}, ErrCapacity
|
||||
}
|
||||
var nonce [16]byte
|
||||
if _, err := rand.Read(nonce[:]); err != nil {
|
||||
return "", time.Time{}, ErrID
|
||||
}
|
||||
id, expires := hex.EncodeToString(nonce[:]), now.Add(TTL)
|
||||
if _, err := conn.ExecContext(ctx, "INSERT INTO decision_snapshots VALUES(?,?,?,?,?,?)", id, parent, expires.UnixNano(), raw, hexdigest, checksum(id, parent, expires.UnixNano(), raw)); err != nil {
|
||||
return "", time.Time{}, ErrWrite
|
||||
}
|
||||
return id, expires, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package decisionstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T, now func() time.Time) *Store {
|
||||
t.Helper()
|
||||
return &Store{Path: filepath.Join(t.TempDir(), "state.sqlite"), Now: now}
|
||||
}
|
||||
|
||||
func TestCorruptionDetected(t *testing.T) {
|
||||
for _, column := range []string{"request", "expires", "parent", "digest", "checksum"} {
|
||||
t.Run(column, func(t *testing.T) {
|
||||
s := newStore(t, time.Now)
|
||||
id, _, err := s.Save(context.Background(), []byte(`{"a":1}`), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := s.open(context.Background(), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Exec("UPDATE decision_snapshots SET "+column+"=? WHERE id=?", "corrupt", id)
|
||||
_ = db.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Load(context.Background(), id); !errors.Is(err, ErrCorrupt) && !errors.Is(err, ErrRead) {
|
||||
t.Fatalf("corruption not rejected: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentDedupAndExpiry(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
clock := func() time.Time { return now }
|
||||
s := newStore(t, clock)
|
||||
payload := []byte(`{"a":1}`)
|
||||
id, expires, err := s.Save(context.Background(), payload, "")
|
||||
if err != nil || expires.Sub(now) != TTL {
|
||||
t.Fatal("incorrect retention")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Go(func() {
|
||||
other := &Store{Path: s.Path, Now: clock}
|
||||
got, until, err := other.Save(context.Background(), payload, "")
|
||||
if err != nil || got != id || !until.Equal(expires) {
|
||||
t.Errorf("concurrent retry changed identity: %s %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
now = expires
|
||||
if _, err := s.Load(context.Background(), id); !errors.Is(err, ErrExpired) {
|
||||
t.Fatal("expired snapshot accepted")
|
||||
}
|
||||
newID, _, err := s.Save(context.Background(), payload, "")
|
||||
if err != nil || newID == id {
|
||||
t.Fatal("expired snapshot not replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentLinkAndMissingParent(t *testing.T) {
|
||||
s := newStore(t, time.Now)
|
||||
ctx := context.Background()
|
||||
parent, _, err := s.Save(ctx, []byte(`{"v":1}`), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child, _, err := s.Save(ctx, []byte(`{"v":1}`), parent)
|
||||
if err != nil || child == parent {
|
||||
t.Fatalf("same payload under a new parent must be a new version: %v", err)
|
||||
}
|
||||
if rec, err := s.Load(ctx, child); err != nil || rec.Parent != parent {
|
||||
t.Fatalf("parent link lost: %v", err)
|
||||
}
|
||||
if _, _, err := s.Save(ctx, []byte(`{"v":2}`), "00000000000000000000000000000000"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("missing parent accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDoesNotCreateFile(t *testing.T) {
|
||||
s := newStore(t, time.Now)
|
||||
if _, err := s.Load(context.Background(), "00000000000000000000000000000000"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapacityAndUnsupportedVersion(t *testing.T) {
|
||||
s := newStore(t, time.Now)
|
||||
ctx := context.Background()
|
||||
for i := range Capacity {
|
||||
if _, _, err := s.Save(ctx, fmt.Appendf(nil, `{"n":%d}`, i), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, _, err := s.Save(ctx, []byte(`{"n":"x"}`), ""); !errors.Is(err, ErrCapacity) {
|
||||
t.Fatal("capacity ignored")
|
||||
}
|
||||
db, err := s.open(ctx, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Exec("PRAGMA user_version=99")
|
||||
_ = db.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Save(ctx, []byte(`{"n":"y"}`), ""); !errors.Is(err, ErrVersionUnsupported) {
|
||||
t.Fatal("unsupported store overwritten")
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package decision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const diskTTL = 7 * 24 * time.Hour
|
||||
const diskCapacity = 256
|
||||
|
||||
type diskRepository struct {
|
||||
path string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (r *diskRepository) persistent() bool { return true }
|
||||
|
||||
func (r *diskRepository) open(ctx context.Context, writable bool) (*sql.DB, *problem) {
|
||||
if writable {
|
||||
if err := prepareDiskFile(r.path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
abs, err := filepath.Abs(r.path)
|
||||
if err != nil {
|
||||
return nil, &problem{Code: "decision.store_open_failed"}
|
||||
}
|
||||
slash := filepath.ToSlash(abs)
|
||||
if runtime.GOOS == "windows" && len(slash) >= 2 && slash[1] == ':' {
|
||||
slash = "/" + slash
|
||||
}
|
||||
u := url.URL{Scheme: "file", Path: slash}
|
||||
dsn := u.String() + "?_pragma=busy_timeout%285000%29&_pragma=synchronous%28FULL%29"
|
||||
if !writable {
|
||||
dsn += "&mode=ro"
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, &problem{Code: "decision.store_open_failed"}
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if invalid := initializeDisk(ctx, db, writable); invalid != nil {
|
||||
_ = db.Close()
|
||||
return nil, invalid
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func prepareDiskFile(path string) *problem {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return &problem{Code: "decision.store_open_failed"}
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return &problem{Code: "decision.store_open_failed"}
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return &problem{Code: "decision.store_open_failed"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeDisk(ctx context.Context, db *sql.DB, writable bool) *problem {
|
||||
var version int
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
|
||||
return &problem{Code: "decision.store_open_failed"}
|
||||
}
|
||||
if version != 0 && version != 1 {
|
||||
return &problem{Code: "decision.store_version_unsupported"}
|
||||
}
|
||||
if !writable {
|
||||
if version != 1 {
|
||||
return &problem{Code: "decision.store_version_unsupported"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS decision_snapshots (
|
||||
id TEXT PRIMARY KEY, parent TEXT NOT NULL, expires INTEGER NOT NULL,
|
||||
request BLOB NOT NULL, digest TEXT NOT NULL, checksum TEXT NOT NULL,
|
||||
UNIQUE(parent,digest))`); err != nil {
|
||||
return &problem{Code: "decision.store_open_failed"}
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA user_version=1"); err != nil {
|
||||
return &problem{Code: "decision.store_open_failed"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,62 +2,66 @@ package decision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/state/decisionstore"
|
||||
)
|
||||
|
||||
type diskQuery interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
type diskRepository struct {
|
||||
store *decisionstore.Store
|
||||
}
|
||||
|
||||
func recordChecksum(id, parent string, expires int64, raw []byte) string {
|
||||
hash := sha256.Sum256(append(fmt.Appendf(nil, "%s\n%s\n%d\n", id, parent, expires), raw...))
|
||||
return hex.EncodeToString(hash[:])
|
||||
func newDiskRepository(path string, now func() time.Time) *diskRepository {
|
||||
return &diskRepository{store: &decisionstore.Store{
|
||||
Path: path,
|
||||
Now: now,
|
||||
Valid: func(raw []byte) bool {
|
||||
_, invalid := decode(raw)
|
||||
return invalid == nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func readDisk(ctx context.Context, query diskQuery, id string, now time.Time) (savedRequest, *problem) {
|
||||
var parent, digest, checksum string
|
||||
var expires int64
|
||||
var raw []byte
|
||||
err := query.QueryRowContext(ctx, `SELECT parent,expires,CASE WHEN length(request)<=262144 THEN request ELSE NULL END,digest,checksum FROM decision_snapshots WHERE id=?`, id).Scan(&parent, &expires, &raw, &digest, &checksum)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return savedRequest{}, &problem{Code: "decision.snapshot_not_found", ID: id}
|
||||
func (r *diskRepository) persistent() bool { return true }
|
||||
|
||||
func storeProblem(err error, id string) *problem {
|
||||
codes := []struct {
|
||||
err error
|
||||
code string
|
||||
}{
|
||||
{decisionstore.ErrNotFound, "decision.snapshot_not_found"},
|
||||
{decisionstore.ErrExpired, "decision.snapshot_expired"},
|
||||
{decisionstore.ErrCorrupt, "decision.snapshot_corrupt"},
|
||||
{decisionstore.ErrRead, "decision.store_read_failed"},
|
||||
{decisionstore.ErrCapacity, "decision.snapshot_capacity"},
|
||||
{decisionstore.ErrOpen, "decision.store_open_failed"},
|
||||
{decisionstore.ErrVersionUnsupported, "decision.store_version_unsupported"},
|
||||
{decisionstore.ErrID, "decision.snapshot_id_failed"},
|
||||
}
|
||||
if err != nil {
|
||||
return savedRequest{}, &problem{Code: "decision.store_read_failed", ID: id}
|
||||
for _, c := range codes {
|
||||
if errors.Is(err, c.err) {
|
||||
out := &problem{Code: c.code}
|
||||
if errors.Is(err, decisionstore.ErrNotFound) || errors.Is(err, decisionstore.ErrExpired) || errors.Is(err, decisionstore.ErrCorrupt) || errors.Is(err, decisionstore.ErrRead) {
|
||||
out.ID = id
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
hash := sha256.Sum256(raw)
|
||||
if len(raw) == 0 || parent != "" && !validSnapshotID(parent) || hex.EncodeToString(hash[:]) != digest || recordChecksum(id, parent, expires, raw) != checksum {
|
||||
return savedRequest{}, &problem{Code: "decision.snapshot_corrupt", ID: id}
|
||||
}
|
||||
until := time.Unix(0, expires).UTC()
|
||||
if !now.Before(until) {
|
||||
return savedRequest{}, &problem{Code: "decision.snapshot_expired", ID: id}
|
||||
}
|
||||
req, invalid := decode(raw)
|
||||
if invalid != nil {
|
||||
return savedRequest{}, &problem{Code: "decision.snapshot_corrupt", ID: id}
|
||||
}
|
||||
return savedRequest{req, until, parent}, nil
|
||||
return &problem{Code: "decision.store_write_failed"}
|
||||
}
|
||||
|
||||
func (r *diskRepository) load(ctx context.Context, id string) (savedRequest, *problem) {
|
||||
if _, err := os.Stat(r.path); errors.Is(err, os.ErrNotExist) {
|
||||
return savedRequest{}, &problem{Code: "decision.snapshot_not_found", ID: id}
|
||||
rec, err := r.store.Load(ctx, id)
|
||||
if err != nil {
|
||||
return savedRequest{}, storeProblem(err, id)
|
||||
}
|
||||
db, invalid := r.open(ctx, false)
|
||||
req, invalid := decode(rec.Payload)
|
||||
if invalid != nil {
|
||||
return savedRequest{}, invalid
|
||||
return savedRequest{}, &problem{Code: "decision.snapshot_corrupt", ID: id}
|
||||
}
|
||||
defer db.Close()
|
||||
return readDisk(ctx, db, id, r.now())
|
||||
return savedRequest{req, rec.Expires, rec.Parent}, nil
|
||||
}
|
||||
|
||||
func (r *diskRepository) save(ctx context.Context, req request, parent string) (string, time.Time, *problem) {
|
||||
@@ -68,66 +72,9 @@ func (r *diskRepository) save(ctx context.Context, req request, parent string) (
|
||||
if len(raw) > 256*1024 {
|
||||
return "", time.Time{}, &problem{Code: "decision.snapshot_input_limit"}
|
||||
}
|
||||
db, invalid := r.open(ctx, true)
|
||||
if invalid != nil {
|
||||
return "", time.Time{}, invalid
|
||||
}
|
||||
defer db.Close()
|
||||
conn, err := db.Conn(ctx)
|
||||
id, expires, err := r.store.Save(ctx, raw, parent)
|
||||
if err != nil {
|
||||
return "", time.Time{}, &problem{Code: "decision.store_write_failed"}
|
||||
}
|
||||
defer conn.Close()
|
||||
if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
|
||||
return "", time.Time{}, &problem{Code: "decision.store_write_failed"}
|
||||
}
|
||||
defer func() { _, _ = conn.ExecContext(context.Background(), "ROLLBACK") }()
|
||||
id, expires, invalid := r.insert(ctx, conn, raw, parent)
|
||||
if invalid != nil {
|
||||
return "", time.Time{}, invalid
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil {
|
||||
return "", time.Time{}, &problem{Code: "decision.store_write_failed"}
|
||||
}
|
||||
return id, expires, nil
|
||||
}
|
||||
|
||||
func (r *diskRepository) insert(ctx context.Context, conn *sql.Conn, raw []byte, parent string) (string, time.Time, *problem) {
|
||||
now := r.now().UTC()
|
||||
if parent != "" {
|
||||
if _, invalid := readDisk(ctx, conn, parent, now); invalid != nil {
|
||||
return "", time.Time{}, invalid
|
||||
}
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, "DELETE FROM decision_snapshots WHERE expires<=?", now.UnixNano()); err != nil {
|
||||
return "", time.Time{}, &problem{Code: "decision.store_write_failed"}
|
||||
}
|
||||
digest := sha256.Sum256(raw)
|
||||
hexdigest := hex.EncodeToString(digest[:])
|
||||
var existing string
|
||||
err := conn.QueryRowContext(ctx, "SELECT id FROM decision_snapshots WHERE parent=? AND digest=?", parent, hexdigest).Scan(&existing)
|
||||
if err == nil {
|
||||
row, invalid := readDisk(ctx, conn, existing, now)
|
||||
return existing, row.Expires, invalid
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", time.Time{}, &problem{Code: "decision.store_read_failed"}
|
||||
}
|
||||
var count int
|
||||
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM decision_snapshots").Scan(&count); err != nil {
|
||||
return "", time.Time{}, &problem{Code: "decision.store_read_failed"}
|
||||
}
|
||||
if count >= diskCapacity {
|
||||
return "", time.Time{}, &problem{Code: "decision.snapshot_capacity"}
|
||||
}
|
||||
var nonce [16]byte
|
||||
if _, err := rand.Read(nonce[:]); err != nil {
|
||||
return "", time.Time{}, &problem{Code: "decision.snapshot_id_failed"}
|
||||
}
|
||||
id, expires := hex.EncodeToString(nonce[:]), now.Add(diskTTL)
|
||||
_, err = conn.ExecContext(ctx, "INSERT INTO decision_snapshots VALUES(?,?,?,?,?,?)", id, parent, expires.UnixNano(), raw, hexdigest, recordChecksum(id, parent, expires.UnixNano(), raw))
|
||||
if err != nil {
|
||||
return "", time.Time{}, &problem{Code: "decision.store_write_failed"}
|
||||
return "", time.Time{}, storeProblem(err, parent)
|
||||
}
|
||||
return id, expires, nil
|
||||
}
|
||||
@@ -3,11 +3,9 @@ package decision
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -57,7 +55,7 @@ func TestPersistentRestartAndEvidenceVersions(t *testing.T) {
|
||||
t.Fatalf("version binding lost: %+v", got)
|
||||
}
|
||||
}
|
||||
repo := &diskRepository{path: path, now: time.Now}
|
||||
repo := newDiskRepository(path, time.Now)
|
||||
old, invalid := repo.load(context.Background(), original.SnapshotID)
|
||||
if invalid != nil || !reflect.DeepEqual(old.Request, fixture()) {
|
||||
t.Fatal("old request changed")
|
||||
@@ -85,60 +83,6 @@ func TestPersistentFailureAndIsolation(t *testing.T) {
|
||||
assertSnapshotError(t, runtimeResult(t, tools[0], fixture()), "decision.store_open_failed")
|
||||
}
|
||||
|
||||
func TestPersistentCorruptionDetected(t *testing.T) {
|
||||
for _, column := range []string{"request", "expires", "parent", "digest", "checksum"} {
|
||||
t.Run(column, func(t *testing.T) {
|
||||
repo := &diskRepository{path: filepath.Join(t.TempDir(), "state.sqlite"), now: time.Now}
|
||||
id, _, invalid := repo.save(context.Background(), fixture(), "")
|
||||
if invalid != nil {
|
||||
t.Fatal(invalid)
|
||||
}
|
||||
db, invalid := repo.open(context.Background(), true)
|
||||
if invalid != nil {
|
||||
t.Fatal(invalid)
|
||||
}
|
||||
_, err := db.Exec("UPDATE decision_snapshots SET "+column+"=? WHERE id=?", "corrupt", id)
|
||||
_ = db.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, invalid = repo.load(context.Background(), id)
|
||||
if invalid == nil || (invalid.Code != "decision.snapshot_corrupt" && invalid.Code != "decision.store_read_failed") {
|
||||
t.Fatalf("corruption not rejected: %v", invalid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentConcurrentDedupAndExpiry(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.sqlite")
|
||||
now := time.Now().UTC()
|
||||
repo := &diskRepository{path: path, now: func() time.Time { return now }}
|
||||
id, expires, invalid := repo.save(context.Background(), fixture(), "")
|
||||
if invalid != nil || expires.Sub(now) != diskTTL {
|
||||
t.Fatal("incorrect retention")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Go(func() {
|
||||
other := &diskRepository{path: path, now: func() time.Time { return now }}
|
||||
got, until, invalid := other.save(context.Background(), fixture(), "")
|
||||
if invalid != nil || got != id || !until.Equal(expires) {
|
||||
t.Errorf("concurrent retry changed identity: %s %v", got, invalid)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
now = expires
|
||||
if _, invalid := repo.load(context.Background(), id); invalid == nil || invalid.Code != "decision.snapshot_expired" {
|
||||
t.Fatal("expired snapshot accepted")
|
||||
}
|
||||
newID, _, invalid := repo.save(context.Background(), fixture(), "")
|
||||
if invalid != nil || newID == id {
|
||||
t.Fatal("expired snapshot not replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidenceUpdateRejectsImplicitReplacement(t *testing.T) {
|
||||
tools := NewRuntime()
|
||||
original := decodeResult(t, runtimeResult(t, tools[0], fixture()))
|
||||
@@ -159,29 +103,21 @@ func TestEvidenceUpdateRejectsImplicitReplacement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentCapacityAndUnsupportedVersion(t *testing.T) {
|
||||
repo := &diskRepository{path: filepath.Join(t.TempDir(), "state.sqlite"), now: time.Now}
|
||||
func TestPersistentStoreErrorsKeepDomainCodes(t *testing.T) {
|
||||
repo := newDiskRepository(filepath.Join(t.TempDir(), "state.sqlite"), time.Now)
|
||||
ctx := context.Background()
|
||||
for i := range diskCapacity {
|
||||
req := fixture()
|
||||
req.Options[0].Name = fmt.Sprintf("option-%d", i)
|
||||
if _, _, invalid := repo.save(ctx, req, ""); invalid != nil {
|
||||
t.Fatal(invalid)
|
||||
}
|
||||
}
|
||||
if _, _, invalid := repo.save(ctx, fixture(), ""); invalid == nil || invalid.Code != "decision.snapshot_capacity" {
|
||||
t.Fatal("capacity ignored")
|
||||
}
|
||||
db, invalid := repo.open(ctx, true)
|
||||
id, _, invalid := repo.save(ctx, fixture(), "")
|
||||
if invalid != nil {
|
||||
t.Fatal(invalid)
|
||||
}
|
||||
_, err := db.Exec("PRAGMA user_version=99")
|
||||
_ = db.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
missing := "00000000000000000000000000000000"
|
||||
if _, invalid := repo.load(ctx, missing); invalid == nil || invalid.Code != "decision.snapshot_not_found" || invalid.ID != missing {
|
||||
t.Fatalf("missing: %v", invalid)
|
||||
}
|
||||
if _, _, invalid := repo.save(ctx, fixture(), ""); invalid == nil || invalid.Code != "decision.store_version_unsupported" {
|
||||
t.Fatal("unsupported store overwritten")
|
||||
if _, _, invalid := repo.save(ctx, fixture(), missing); invalid == nil || invalid.Code != "decision.snapshot_not_found" || invalid.ID != missing {
|
||||
t.Fatalf("missing parent: %v", invalid)
|
||||
}
|
||||
if got, invalid := repo.load(ctx, id); invalid != nil || !reflect.DeepEqual(got.Request, fixture()) {
|
||||
t.Fatal("round trip changed request")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/state/decisionstore"
|
||||
)
|
||||
|
||||
type runtimeCompare struct {
|
||||
@@ -27,7 +28,7 @@ func NewRuntime() []tool.Tool {
|
||||
|
||||
// NewPersistentRuntime uses a host-selected database path without memory fallback.
|
||||
func NewPersistentRuntime(path string) []tool.Tool {
|
||||
store := &diskRepository{path: path, now: time.Now}
|
||||
store := newDiskRepository(path, time.Now)
|
||||
return runtimeTools(store)
|
||||
}
|
||||
|
||||
@@ -99,14 +100,4 @@ func (t runtimeCheck) Execute(ctx context.Context, args json.RawMessage) (string
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
func validSnapshotID(id string) bool {
|
||||
if len(id) != 32 {
|
||||
return false
|
||||
}
|
||||
for _, character := range id {
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func validSnapshotID(id string) bool { return decisionstore.ValidID(id) }
|
||||
@@ -31,6 +31,7 @@ var leaves = []string{
|
||||
"internal/platform/computer",
|
||||
"internal/base/diff",
|
||||
"internal/ext/extension/rpcwire",
|
||||
"internal/state/decisionstore",
|
||||
"internal/state/execgraph",
|
||||
"internal/state/execjournal",
|
||||
"internal/ext/extensioncontract",
|
||||
|
||||
Reference in new issue
Block a user