Connor McCutcheon
/ SkyCode
session.go
go
package models
import (
	"context"
	"errors"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"sync/atomic"
	"time"
	"github.com/google/uuid"
)
const gitCloneTimeout = 60 * time.Second
const (
	WorkspaceRoot  = "/tmp/skycode"
	CacheDir       = "/tmp/skycode/cache"
	UsersDir       = "/tmp/skycode/users"
	SessionTimeout = 30 * time.Minute
)
type Session struct {
	ID          string
	UserID      string
	WorkDir     string // /tmp/skycode/{sessionID}/workspace
	CreatedAt   time.Time
	LastUsed    time.Time
	initialized int32      // atomic flag: 1 if files have been materialized
	initOnce    sync.Once  // ensures MaterializeFiles runs only once
	initErr     error      // stores error from initialization
	syncMu      sync.Mutex // serializes all sync operations
}
// IsInitialized returns whether the session workspace has been initialized
func (s *Session) IsInitialized() bool {
	return atomic.LoadInt32(&s.initialized) == 1
}
// SetInitialized marks the session as initialized (thread-safe)
func (s *Session) SetInitialized() {
	atomic.StoreInt32(&s.initialized, 1)
}
// InitializeOnce ensures MaterializeFiles runs exactly once, thread-safe
func (s *Session) InitializeOnce() error {
	s.initOnce.Do(func() {
		s.initErr = s.MaterializeFiles()
	})
	return s.initErr
}
var (
	sessions = make(map[string]*Session)
	sessMu   sync.RWMutex
)
// isValidUUID checks if a string is a valid UUID format
func isValidUUID(s string) bool {
	_, err := uuid.Parse(s)
	return err == nil
}
// GetOrCreateSession returns existing session or creates new one
func GetOrCreateSession(sessionID, userID string) (*Session, error) {
	// Validate sessionID is a valid UUID to prevent path traversal
	if !isValidUUID(sessionID) {
		return nil, errors.New("invalid session ID format")
	}
	sessMu.Lock()
	defer sessMu.Unlock()
	if sess, ok := sessions[sessionID]; ok {
		// Validate session belongs to this user
		if sess.UserID != userID {
			return nil, errors.New("session belongs to different user")
		}
		sess.LastUsed = time.Now()
		return sess, nil
	}
	// Create workspace directory
	workDir := filepath.Join(WorkspaceRoot, sessionID, "workspace")
	if err := os.MkdirAll(workDir, 0755); err != nil {
		return nil, err
	}
	sess := &Session{
		ID:          sessionID,
		UserID:      userID,
		WorkDir:     workDir,
		CreatedAt:   time.Now(),
		LastUsed:    time.Now(),
		initialized: 0, // not initialized yet
	}
	sessions[sessionID] = sess
	return sess, nil
}
// GetSession returns an existing session without creating
func GetSession(sessionID string) *Session {
	sessMu.Lock()
	defer sessMu.Unlock()
	if sess, ok := sessions[sessionID]; ok {
		sess.LastUsed = time.Now()
		return sess
	}
	return nil
}
// safePath joins base and path, returning error if result escapes base
func (s *Session) safePath(path string) (string, error) {
	clean := NormalizePath(path)
	if clean == "" {
		return "", errors.New("empty path")
	}
	fullPath := filepath.Join(s.WorkDir, clean)
	// Ensure the path is still within workspace
	if !strings.HasPrefix(fullPath, s.WorkDir+string(filepath.Separator)) && fullPath != s.WorkDir {
		return "", errors.New("path escapes workspace")
	}
	return fullPath, nil
}
// MaterializeFiles writes all user files from DB to workspace
// For projects with RemoteURL, it re-clones the repo to restore .git directory
func (s *Session) MaterializeFiles() error {
	// First, clone any projects that have remote URLs to restore .git directories
	projects := ListProjects(s.UserID)
	clonedPaths := make(map[string]bool)
	for _, project := range projects {
		if project.RemoteURL == "" {
			continue
		}
		targetDir := filepath.Join(s.WorkDir, project.Path)
		// Skip if already exists (shouldn't happen on fresh session)
		if _, err := os.Stat(targetDir); err == nil {
			continue
		}
		log.Printf("MaterializeFiles: cloning %s to %s", project.RemoteURL, project.Path)
		// Try to clone main branch explicitly first
		ctx, cancel := context.WithTimeout(context.Background(), gitCloneTimeout)
		cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--branch", "main", "--single-branch", project.RemoteURL, targetDir)
		cmd.Dir = s.WorkDir
		output, err := cmd.CombinedOutput()
		cancel()
		if err != nil {
			// If main branch doesn't exist, try default branch
			ctx2, cancel2 := context.WithTimeout(context.Background(), gitCloneTimeout)
			cmd = exec.CommandContext(ctx2, "git", "clone", "--depth", "1", project.RemoteURL, targetDir)
			cmd.Dir = s.WorkDir
			output, err = cmd.CombinedOutput()
			cancel2()
			if err != nil {
				log.Printf("MaterializeFiles: clone failed for %s: %s", project.Path, string(output))
				// Continue with other projects - files will be materialized from DB
				continue
			}
		}
		// Mark this project path as cloned (skip DB file overlay for git-tracked files)
		clonedPaths[project.Path] = true
		log.Printf("MaterializeFiles: successfully cloned %s", project.Path)
	}
	// Now materialize files from DB (for non-cloned projects and modified files)
	files := ListFiles(s.UserID)
	log.Printf("MaterializeFiles: user=%s, session=%s, found %d files in DB", s.UserID, s.ID, len(files))
	for _, f := range files {
		// Skip .dir marker files
		if strings.HasSuffix(f.Path, "/.dir") {
			continue
		}
		path, err := s.safePath(f.Path)
		if err != nil {
			continue // Skip invalid paths
		}
		// For cloned projects, only overlay files that differ from git
		// (i.e., local modifications that haven't been pushed)
		projectPath := strings.Split(f.Path, "/")[0]
		if clonedPaths[projectPath] {
			// Check if file exists and has same content - skip if unchanged
			existing, err := os.ReadFile(path)
			if err == nil && string(existing) == f.Content {
				continue // File matches git, no need to overlay
			}
		}
		if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
			return err
		}
		if err := os.WriteFile(path, []byte(f.Content), 0644); err != nil {
			return err
		}
	}
	s.SetInitialized()
	log.Printf("MaterializeFiles: successfully materialized files to %s", s.WorkDir)
	return nil
}
// SyncFileToWorkspace writes a single file to workspace (after editor save)
// Thread-safe: uses syncMu to serialize sync operations
func (s *Session) SyncFileToWorkspace(path, content string) error {
	s.syncMu.Lock()
	defer s.syncMu.Unlock()
	fullPath, err := s.safePath(path)
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
		return err
	}
	return os.WriteFile(fullPath, []byte(content), 0644)
}
// DeleteFileFromWorkspace removes a file from the workspace
// Thread-safe: uses syncMu to serialize sync operations
func (s *Session) DeleteFileFromWorkspace(path string) error {
	s.syncMu.Lock()
	defer s.syncMu.Unlock()
	fullPath, err := s.safePath(path)
	if err != nil {
		return err
	}
	return os.Remove(fullPath)
}
// RenameFileInWorkspace renames a file in the workspace
// Thread-safe: uses syncMu to serialize sync operations
func (s *Session) RenameFileInWorkspace(oldPath, newPath string) error {
	s.syncMu.Lock()
	defer s.syncMu.Unlock()
	oldFull, err := s.safePath(oldPath)
	if err != nil {
		return err
	}
	newFull, err := s.safePath(newPath)
	if err != nil {
		return err
	}
	// Ensure parent directory exists
	if err := os.MkdirAll(filepath.Dir(newFull), 0755); err != nil {
		return err
	}
	return os.Rename(oldFull, newFull)
}
// SyncWorkspaceToDB syncs all files from workspace filesystem to database
// All root-level dotfiles are synced since workspace is the terminal home directory
// Thread-safe: uses syncMu to serialize sync operations
func (s *Session) SyncWorkspaceToDB() error {
	s.syncMu.Lock()
	defer s.syncMu.Unlock()
	// Track directories we've seen (for empty directory support)
	seenDirs := make(map[string]bool)
	err := filepath.Walk(s.WorkDir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return nil // Skip errors
		}
		// Get relative path
		relPath, err := filepath.Rel(s.WorkDir, path)
		if err != nil {
			return nil
		}
		// Handle directories
		if info.IsDir() {
			// Skip .git and other hidden directories (but not the root)
			if strings.HasPrefix(info.Name(), ".") && info.Name() != "." {
				return filepath.SkipDir
			}
			// Track non-root directories for empty dir support
			if relPath != "." {
				seenDirs[relPath] = true
			}
			return nil
		}
		// Skip large files (>1MB)
		if info.Size() > 1024*1024 {
			return nil
		}
		// Handle hidden files - sync ALL root-level dotfiles (workspace is user's home)
		if strings.HasPrefix(info.Name(), ".") {
			// Skip nested hidden files (in subdirectories)
			if strings.Contains(relPath, "/") {
				return nil
			}
			// Root-level dotfiles are synced
		} else {
			// Skip if path contains hidden directory
			if strings.Contains(relPath, "/.") {
				return nil
			}
		}
		// Mark parent directory as having files
		parentDir := filepath.Dir(relPath)
		if parentDir != "." {
			delete(seenDirs, parentDir) // Has files, not empty
		}
		// Read file content
		content, err := os.ReadFile(path)
		if err != nil {
			return nil
		}
		// Skip binary files (files with null bytes)
		if containsBinaryData(content) {
			return nil
		}
		// Save to DB (will create or update)
		_, _ = SaveFile(s.UserID, relPath, string(content))
		return nil // Continue even on error
	})
	// Save empty directories as marker files
	// A directory is "empty" if no files were saved with that prefix
	files := ListFiles(s.UserID)
	filePaths := make(map[string]bool)
	for _, f := range files {
		filePaths[f.Path] = true
	}
	for dir := range seenDirs {
		// Check if any file exists under this directory
		hasContent := false
		prefix := dir + "/"
		for path := range filePaths {
			if strings.HasPrefix(path, prefix) {
				hasContent = true
				break
			}
		}
		if !hasContent {
			// Create a directory marker (empty content, path ends with /.dir)
			_, _ = SaveFile(s.UserID, dir+"/.dir", "")
		}
	}
	return err
}
// containsBinaryData checks if content appears to be binary
func containsBinaryData(content []byte) bool {
	// Check first 8KB for null bytes (common indicator of binary)
	checkLen := len(content)
	if checkLen > 8192 {
		checkLen = 8192
	}
	for i := 0; i < checkLen; i++ {
		if content[i] == 0 {
			return true
		}
	}
	return false
}
// SyncDBToWorkspace materializes files from database to workspace (DB → filesystem)
// Only writes files that don't exist in the workspace (filesystem wins for conflicts)
// Thread-safe: uses syncMu to serialize sync operations
func (s *Session) SyncDBToWorkspace() error {
	s.syncMu.Lock()
	defer s.syncMu.Unlock()
	files := ListFiles(s.UserID)
	log.Printf("SyncDBToWorkspace: user=%s, session=%s, found %d files in DB", s.UserID, s.ID, len(files))
	for _, f := range files {
		targetPath, err := s.safePath(f.Path)
		if err != nil {
			continue // Skip invalid paths
		}
		// Skip if file exists in workspace (filesystem wins)
		if _, err := os.Stat(targetPath); err == nil {
			continue
		}
		// Create parent directories
		if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
			log.Printf("SyncDBToWorkspace: failed to create dir for %s: %v", f.Path, err)
			continue
		}
		// Write file from DB
		if err := os.WriteFile(targetPath, []byte(f.Content), 0644); err != nil {
			log.Printf("SyncDBToWorkspace: failed to write %s: %v", f.Path, err)
			continue
		}
	}
	log.Printf("SyncDBToWorkspace: completed sync to %s", s.WorkDir)
	return nil
}
// EnsureWorkspaceReady checks if workspace exists and re-materializes if lost (Docker Swarm recovery)
func (s *Session) EnsureWorkspaceReady() error {
	// Check if workspace directory exists
	if _, err := os.Stat(s.WorkDir); os.IsNotExist(err) {
		// Workspace was lost (container moved in Docker Swarm), recreate and re-materialize
		log.Printf("EnsureWorkspaceReady: workspace lost for session=%s, re-materializing", s.ID)
		// Reset initialized flag
		atomic.StoreInt32(&s.initialized, 0)
		// Recreate workspace directory
		if err := os.MkdirAll(s.WorkDir, 0755); err != nil {
			return err
		}
		// Re-materialize files from DB
		return s.MaterializeFiles()
	}
	return nil
}
// Cleanup removes workspace directory
func (s *Session) Cleanup() error {
	sessMu.Lock()
	delete(sessions, s.ID)
	sessMu.Unlock()
	return os.RemoveAll(filepath.Join(WorkspaceRoot, s.ID))
}
// CleanupStaleSessions removes sessions idle for > SessionTimeout
func CleanupStaleSessions() {
	sessMu.Lock()
	defer sessMu.Unlock()
	for id, sess := range sessions {
		if time.Since(sess.LastUsed) > SessionTimeout {
			os.RemoveAll(filepath.Join(WorkspaceRoot, id))
			delete(sessions, id)
		}
	}
}
// EnsureWorkspaceRoot creates the root workspace directory
func EnsureWorkspaceRoot() error {
	return os.MkdirAll(WorkspaceRoot, 0755)
}
// GenerateSessionID creates a new unique session ID
func GenerateSessionID() string {
	return uuid.NewString()
}
// EnsureUserToolDirs creates per-user tool directories (persists across sessions)
// Returns the user's home directory path
func EnsureUserToolDirs(userID string) (string, error) {
	// Validate userID to prevent path traversal
	if !isValidUUID(userID) {
		return "", errors.New("invalid user ID format")
	}
	userDir := filepath.Join(UsersDir, userID)
	dirs := []string{
		filepath.Join(userDir, ".local/bin"),
		filepath.Join(userDir, ".npm-global"),
		filepath.Join(userDir, "go/bin"),
		filepath.Join(userDir, ".cargo/bin"),
	}
	for _, dir := range dirs {
		if err := os.MkdirAll(dir, 0755); err != nil {
			return "", err
		}
	}
	return userDir, nil
}
// EnsureSharedCache creates shared cache directories for all users
func EnsureSharedCache() error {
	dirs := []string{
		filepath.Join(CacheDir, "npm"),
		filepath.Join(CacheDir, "go/mod"),
	}
	for _, dir := range dirs {
		if err := os.MkdirAll(dir, 0755); err != nil {
			return err
		}
	}
	return nil
}
No comments yet.