package session import ( "context" "database/sql" "fmt" "sync" "time" "github.com/google/uuid" "github.com/sst/opencode/internal/db" "github.com/sst/opencode/internal/pubsub" ) type Session struct { ID string ParentSessionID string Title string MessageCount int64 PromptTokens int64 CompletionTokens int64 Cost float64 Summary string SummarizedAt time.Time CreatedAt time.Time UpdatedAt time.Time } const ( EventSessionCreated pubsub.EventType = "session_created" EventSessionUpdated pubsub.EventType = "session_updated" EventSessionDeleted pubsub.EventType = "session_deleted" ) type Service interface { pubsub.Subscriber[Session] Create(ctx context.Context, title string) (Session, error) CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error) Get(ctx context.Context, id string) (Session, error) List(ctx context.Context) ([]Session, error) Update(ctx context.Context, session Session) (Session, error) Delete(ctx context.Context, id string) error } type service struct { db *db.Queries broker *pubsub.Broker[Session] mu sync.RWMutex } var globalSessionService *service func InitService(dbConn *sql.DB) error { if globalSessionService != nil { return fmt.Errorf("session service already initialized") } queries := db.New(dbConn) broker := pubsub.NewBroker[Session]() globalSessionService = &service{ db: queries, broker: broker, } return nil } func GetService() Service { if globalSessionService == nil { panic("session service not initialized. Call session.InitService() first.") } return globalSessionService } func (s *service) Create(ctx context.Context, title string) (Session, error) { s.mu.Lock() defer s.mu.Unlock() if title == "" { title = "New Session - " + time.Now().Format("2006-01-02 15:04:05") } dbSessParams := db.CreateSessionParams{ ID: uuid.New().String(), Title: title, } dbSession, err := s.db.CreateSession(ctx, dbSessParams) if err != nil { return Session{}, fmt.Errorf("db.CreateSession: %w", err) } session := s.fromDBItem(dbSession) s.broker.Publish(EventSessionCreated, session) return session, nil } func (s *service) CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error) { s.mu.Lock() defer s.mu.Unlock() if title == "" { title = "Task Session - " + time.Now().Format("2006-01-02 15:04:05") } if toolCallID == "" { toolCallID = uuid.New().String() } dbSessParams := db.CreateSessionParams{ ID: toolCallID, ParentSessionID: sql.NullString{String: parentSessionID, Valid: parentSessionID != ""}, Title: title, } dbSession, err := s.db.CreateSession(ctx, dbSessParams) if err != nil { return Session{}, fmt.Errorf("db.CreateTaskSession: %w", err) } session := s.fromDBItem(dbSession) s.broker.Publish(EventSessionCreated, session) return session, nil } func (s *service) Get(ctx context.Context, id string) (Session, error) { s.mu.RLock() defer s.mu.RUnlock() dbSession, err := s.db.GetSessionByID(ctx, id) if err != nil { if err == sql.ErrNoRows { return Session{}, fmt.Errorf("session ID '%s' not found", id) } return Session{}, fmt.Errorf("db.GetSessionByID: %w", err) } return s.fromDBItem(dbSession), nil } func (s *service) List(ctx context.Context) ([]Session, error) { s.mu.RLock() defer s.mu.RUnlock() dbSessions, err := s.db.ListSessions(ctx) if err != nil { return nil, fmt.Errorf("db.ListSessions: %w", err) } sessions := make([]Session, len(dbSessions)) for i, dbSess := range dbSessions { sessions[i] = s.fromDBItem(dbSess) } return sessions, nil } func (s *service) Update(ctx context.Context, session Session) (Session, error) { s.mu.Lock() defer s.mu.Unlock() if session.ID == "" { return Session{}, fmt.Errorf("cannot update session with empty ID") } params := db.UpdateSessionParams{ ID: session.ID, Title: session.Title, PromptTokens: session.PromptTokens, CompletionTokens: session.CompletionTokens, Cost: session.Cost, Summary: sql.NullString{String: session.Summary, Valid: session.Summary != ""}, SummarizedAt: sql.NullString{String: session.SummarizedAt.UTC().Format(time.RFC3339Nano), Valid: !session.SummarizedAt.IsZero()}, } dbSession, err := s.db.UpdateSession(ctx, params) if err != nil { return Session{}, fmt.Errorf("db.UpdateSession: %w", err) } updatedSession := s.fromDBItem(dbSession) s.broker.Publish(EventSessionUpdated, updatedSession) return updatedSession, nil } func (s *service) Delete(ctx context.Context, id string) error { s.mu.Lock() dbSess, err := s.db.GetSessionByID(ctx, id) if err != nil { s.mu.Unlock() if err == sql.ErrNoRows { return fmt.Errorf("session ID '%s' not found for deletion", id) } return fmt.Errorf("db.GetSessionByID before delete: %w", err) } sessionToPublish := s.fromDBItem(dbSess) s.mu.Unlock() s.mu.Lock() defer s.mu.Unlock() err = s.db.DeleteSession(ctx, id) if err != nil { return fmt.Errorf("db.DeleteSession: %w", err) } s.broker.Publish(EventSessionDeleted, sessionToPublish) return nil } func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[Session] { return s.broker.Subscribe(ctx) } func (s *service) fromDBItem(item db.Session) Session { var summarizedAt time.Time if item.SummarizedAt.Valid { parsedTime, err := time.Parse(time.RFC3339Nano, item.SummarizedAt.String) if err == nil { summarizedAt = parsedTime } } createdAt, _ := time.Parse(time.RFC3339Nano, item.CreatedAt) updatedAt, _ := time.Parse(time.RFC3339Nano, item.UpdatedAt) return Session{ ID: item.ID, ParentSessionID: item.ParentSessionID.String, Title: item.Title, MessageCount: item.MessageCount, PromptTokens: item.PromptTokens, CompletionTokens: item.CompletionTokens, Cost: item.Cost, Summary: item.Summary.String, SummarizedAt: summarizedAt, CreatedAt: createdAt, UpdatedAt: updatedAt, } } func Create(ctx context.Context, title string) (Session, error) { return GetService().Create(ctx, title) } func CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error) { return GetService().CreateTaskSession(ctx, toolCallID, parentSessionID, title) } func Get(ctx context.Context, id string) (Session, error) { return GetService().Get(ctx, id) } func List(ctx context.Context) ([]Session, error) { return GetService().List(ctx) } func Update(ctx context.Context, session Session) (Session, error) { return GetService().Update(ctx, session) } func Delete(ctx context.Context, id string) error { return GetService().Delete(ctx, id) } func Subscribe(ctx context.Context) <-chan pubsub.Event[Session] { return GetService().Subscribe(ctx) }