nub/internal/session/store.go
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

230 lines
5.5 KiB
Go

package session
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/oklog/ulid/v2"
"nub/internal/llm"
)
const (
dirPerm = 0o700
filePerm = 0o600
)
// record ist die JSONL-Zeile: entweder ein Node oder ein Head-Pointer.
// Jeder Record trägt "v":1 (4.5) — unbekannte höhere Version ist ein Fehler,
// kein stilles Teilparsen.
type record struct {
V int `json:"v"`
Node *Node `json:"node,omitempty"`
Head string `json:"head,omitempty"`
}
// Store bindet eine Session an ihre Append-only-JSONL-Datei unter
// .nub/sessions/<id>.jsonl.
type Store struct {
*Session
file *os.File
mu sync.Mutex
}
func sessionsDir(repoRoot string) string {
return filepath.Join(repoRoot, ".nub", "sessions")
}
// Create legt eine neue Session mit frischer ULID an und öffnet ihre Datei.
func Create(repoRoot string) (*Store, error) {
dir := sessionsDir(repoRoot)
if err := os.MkdirAll(dir, dirPerm); err != nil {
return nil, fmt.Errorf("create session dir: %w", err)
}
if err := EnsureGitExclude(repoRoot); err != nil {
return nil, fmt.Errorf("ensure git exclude: %w", err)
}
id := ulid.Make().String()
path := filepath.Join(dir, id+".jsonl")
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|os.O_EXCL, filePerm)
if err != nil {
return nil, fmt.Errorf("create session file: %w", err)
}
return &Store{Session: New(id), file: f}, nil
}
// Load liest eine existierende Session vollständig und hält die Datei zum
// Weiterschreiben offen.
func Load(repoRoot, id string) (*Store, error) {
dir := sessionsDir(repoRoot)
path := filepath.Join(dir, id+".jsonl")
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read session %s: %w", id, err)
}
sess := New(id)
for i, line := range bytes.Split(data, []byte("\n")) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var rec record
if err := json.Unmarshal(line, &rec); err != nil {
return nil, fmt.Errorf("session %s: corrupt record at line %d: %w", id, i+1, err)
}
if rec.V != 1 {
return nil, fmt.Errorf("session %s: unsupported record version %d at line %d", id, rec.V, i+1)
}
if rec.Node != nil {
sess.Nodes[rec.Node.ID] = rec.Node
}
if rec.Head != "" {
sess.Head = rec.Head
}
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, filePerm)
if err != nil {
return nil, fmt.Errorf("open session %s for append: %w", id, err)
}
return &Store{Session: sess, file: f}, nil
}
func (st *Store) Close() error {
return st.file.Close()
}
// Append hängt den Node an den In-Memory-Baum an und schreibt ihn plus den
// neuen Head-Pointer als JSONL-Zeilen.
func (st *Store) Append(n *Node) error {
st.mu.Lock()
defer st.mu.Unlock()
if err := st.Session.Append(n); err != nil {
return err
}
if err := st.writeRecord(record{V: 1, Node: n}); err != nil {
return err
}
return st.writeRecord(record{V: 1, Head: st.Head})
}
func (st *Store) writeRecord(rec record) error {
data, err := json.Marshal(rec)
if err != nil {
return err
}
data = append(data, '\n')
_, err = st.file.Write(data)
return err
}
// Delete entfernt eine Sessiondatei unwiderruflich. Der Aufrufer muss
// sicherstellen, dass es nicht die gerade offene/aktive Session ist.
func Delete(repoRoot, id string) error {
path := filepath.Join(sessionsDir(repoRoot), id+".jsonl")
if err := os.Remove(path); err != nil {
return fmt.Errorf("delete session %s: %w", id, err)
}
return nil
}
// Info ist die Kurzübersicht für `nub sessions`.
type Info struct {
ID string
Head string
NodeCount int
Created time.Time
Summary string
}
// List liest alle Sessions unter .nub/sessions und liefert ihre Kurzübersicht,
// neueste zuerst. Beschädigte Sessiondateien werden übersprungen statt den
// gesamten Aufruf scheitern zu lassen.
func List(repoRoot string) ([]Info, error) {
dir := sessionsDir(repoRoot)
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var infos []Info
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
continue
}
id := strings.TrimSuffix(e.Name(), ".jsonl")
info, err := loadInfo(dir, id)
if err != nil {
continue
}
infos = append(infos, info)
}
sort.Slice(infos, func(i, j int) bool { return infos[i].Created.After(infos[j].Created) })
return infos, nil
}
func loadInfo(dir, id string) (Info, error) {
data, err := os.ReadFile(filepath.Join(dir, id+".jsonl"))
if err != nil {
return Info{}, err
}
info := Info{ID: id}
for _, line := range bytes.Split(data, []byte("\n")) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var rec record
if err := json.Unmarshal(line, &rec); err != nil {
return Info{}, err
}
if rec.V != 1 {
return Info{}, fmt.Errorf("unsupported record version %d", rec.V)
}
if rec.Node != nil {
info.NodeCount++
if info.Created.IsZero() || rec.Node.Meta.CreatedAt.Before(info.Created) {
info.Created = rec.Node.Meta.CreatedAt
}
if info.Summary == "" && rec.Node.Message.Role == llm.RoleUser {
info.Summary = firstText(rec.Node.Message.Content)
}
}
if rec.Head != "" {
info.Head = rec.Head
}
}
return info, nil
}
func firstText(blocks []llm.Block) string {
for _, b := range blocks {
if b.Kind == llm.KindText && b.Text != "" {
return truncate(b.Text, 80)
}
}
return ""
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}