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
}