diff --git a/go-opencode/docs/session-persistence.md b/go-opencode/docs/session-persistence.md new file mode 100644 index 00000000000..307e83e1a78 --- /dev/null +++ b/go-opencode/docs/session-persistence.md @@ -0,0 +1,449 @@ +# Session Persistence and Management + +## Overview + +OpenCode uses a file-based persistence system that enables conversation continuity across server restarts. Sessions, messages, and their parts are stored as JSON files in a hierarchical directory structure, allowing the TUI client to reconnect to any previous session seamlessly. + +This document describes how session management works in Go OpenCode, covering the data structures, storage mechanisms, and how persistence enables conversation continuity. + +## Key Concepts + +### Session + +A **Session** represents a single conversation thread with the LLM. Each session: +- Belongs to a specific **Project** (identified by the git repository) +- Contains multiple **Messages** (user prompts and assistant responses) +- Tracks code changes made during the conversation +- Can be forked to create parallel conversation branches + +### Project + +A **Project** represents a git repository or working directory. Projects: +- Are identified by the git repository's initial commit SHA (ensuring consistency with TypeScript OpenCode) +- Group related sessions together +- Enable session isolation between different codebases + +### Message + +A **Message** represents either a user prompt or an assistant response. Messages: +- Contain multiple **Parts** (text, tool calls, files, etc.) +- Track token usage and costs +- Link to parent messages (for assistant responses) + +### Part + +A **Part** is a component of a message. Parts include: +- **TextPart**: Plain text content +- **ToolPart**: Tool invocations and their results +- **FilePart**: File attachments +- **ReasoningPart**: Extended thinking/reasoning content +- **StepStartPart/StepFinishPart**: Inference step markers +- **CompactionPart**: Conversation summarization markers +- And more... + +## Data Structures + +### Session Structure + +```go +type Session struct { + ID string `json:"id"` // ULID identifier + ProjectID string `json:"projectID"` // Git commit SHA (first 16 chars) + Directory string `json:"directory"` // Working directory path + ParentID *string `json:"parentID"` // For forked sessions + Title string `json:"title"` // Session title + Version string `json:"version"` // Schema version + Summary SessionSummary `json:"summary"` // Code change statistics + Share *SessionShare `json:"share"` // Sharing information + Time SessionTime `json:"time"` // Timestamps + Revert *SessionRevert `json:"revert"` // Revert state + CustomPrompt *CustomPrompt `json:"customPrompt"` // Custom system prompt +} +``` + +### Message Structure + +```go +type Message struct { + ID string `json:"id"` // ULID identifier + SessionID string `json:"sessionID"` // Parent session + Role string `json:"role"` // "user" | "assistant" + Time MessageTime `json:"time"` // Timestamps + + // User-specific fields + Agent string `json:"agent"` // Agent name + Model *ModelRef `json:"model"` // Model reference + + // Assistant-specific fields + ParentID string `json:"parentID"` // User message that prompted this + ProviderID string `json:"providerID"` // LLM provider + ModelID string `json:"modelID"` // Model used + Cost float64 `json:"cost"` // API cost + Tokens *TokenUsage `json:"tokens"` // Token statistics +} +``` + +## Storage Architecture + +### Directory Structure + +All data is stored under the XDG data directory: + +``` +~/.local/share/opencode/storage/ +├── session/ +│ └── {projectID}/ +│ ├── {sessionID}.json # Session metadata +│ └── ... +├── message/ +│ └── {sessionID}/ +│ ├── {messageID}.json # Message metadata +│ └── ... +├── part/ +│ └── {messageID}/ +│ ├── {partID}.json # Message parts +│ └── ... +└── project/ + └── {projectID}.json # Project metadata +``` + +### Storage Operations + +The storage system provides five core operations: + +| Operation | Method | Description | +|-----------|--------|-------------| +| Create/Update | `Put()` | Writes JSON to file with atomic rename | +| Read | `Get()` | Reads and unmarshals JSON file | +| Delete | `Delete()` | Removes file from storage | +| List | `List()` | Lists items in a directory | +| Scan | `Scan()` | Iterates over all items in a directory | + +### Atomic Writes + +All write operations use atomic file operations to prevent data corruption: + +```go +// 1. Write to temporary file +tmpPath := filePath + ".tmp" +os.WriteFile(tmpPath, data, 0644) + +// 2. Atomic rename (cannot be interrupted) +os.Rename(tmpPath, filePath) +``` + +### File Locking + +The storage system uses file-level locking to prevent concurrent write conflicts: + +```go +lock := s.getLock(filePath) +lock.Lock() +defer lock.Unlock() +// ... perform write operation +``` + +## Project ID Generation + +### Git-Based Project IDs + +Project IDs are derived from the git repository's initial commit SHA, ensuring: +- **Consistency**: Same repository always gets the same project ID +- **Compatibility**: Sessions created by TypeScript OpenCode are visible to Go OpenCode +- **Stability**: Project ID doesn't change even if directory path changes + +```go +// Get the first (root) commit SHA +cmd := exec.Command("git", "rev-list", "--max-parents=0", "--all") +roots := strings.Split(output, "\n") +sort.Strings(roots) +projectID := roots[0] // Use first root commit +``` + +### Caching + +The project ID is cached in `.git/opencode` for fast subsequent lookups: + +``` +.git/ +└── opencode # Contains the project ID +``` + +### Non-Git Directories + +For directories not in a git repository, sessions are stored under the `"global"` project ID. + +## Session Lifecycle + +### 1. Session Creation + +``` +User requests new session + │ + ▼ +┌─────────────────────┐ +│ Detect project ID │ ◄── git rev-list --max-parents=0 --all +│ (from git SHA) │ +└─────────┬───────────┘ + │ + ▼ +┌─────────────────────┐ +│ Check for migration │ ◄── Migrate from hash-based IDs +└─────────┬───────────┘ + │ + ▼ +┌─────────────────────┐ +│ Generate session ID │ ◄── ULID (time-sortable) +└─────────┬───────────┘ + │ + ▼ +┌─────────────────────┐ +│ Write to storage │ ◄── session/{projectID}/{sessionID}.json +└─────────────────────┘ +``` + +### 2. Message Processing + +``` +User sends message + │ + ▼ +┌─────────────────────┐ +│ Create user message │ ◄── message/{sessionID}/{messageID}.json +└─────────┬───────────┘ + │ + ▼ +┌─────────────────────┐ +│ Process with LLM │ +│ (streaming) │ +└─────────┬───────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Create assistant message │ +│ + parts (text, tools, etc.) │ ◄── part/{messageID}/{partID}.json +└─────────┬───────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ Update session │ ◄── Update timestamps, summary +└─────────────────────┘ +``` + +### 3. Session Recovery (On Server Restart) + +``` +Server starts + │ + ▼ +┌─────────────────────┐ +│ Initialize storage │ ◄── Point to ~/.local/share/opencode/storage +└─────────┬───────────┘ + │ + ▼ +┌─────────────────────┐ +│ Client requests │ +│ session list │ +└─────────┬───────────┘ + │ + ▼ +┌─────────────────────┐ +│ Scan storage for │ ◄── session/{projectID}/*.json +│ sessions │ +└─────────┬───────────┘ + │ + ▼ +┌─────────────────────┐ +│ Return to client │ ◄── Sessions available immediately +└─────────────────────┘ +``` + +## Migration System + +### Hash-Based to Git-Based Migration + +When Go OpenCode starts, it automatically migrates sessions from the old hash-based project ID format to the new git-based format: + +```go +func migrateFromHashBasedID(directory, newProjectID string) { + oldProjectID := SHA256(directory)[:16] // Old format + + // Find sessions under old project ID + oldSessions := storage.List(["session", oldProjectID]) + + for _, session := range oldSessions { + if session.Directory == directory { + // Move to new location + session.ProjectID = newProjectID + storage.Put(["session", newProjectID, session.ID], session) + storage.Delete(["session", oldProjectID, session.ID]) + } + } +} +``` + +### Global Project Migration + +Sessions created before git detection was implemented are migrated from `"global"` to project-specific storage: + +```go +func migrateFromGlobal(directory, newProjectID string) { + globalSessions := storage.List(["session", "global"]) + + for _, session := range globalSessions { + if session.Directory == directory { + session.ProjectID = newProjectID + storage.Put(["session", newProjectID, session.ID], session) + storage.Delete(["session", "global", session.ID]) + } + } +} +``` + +## Session Features + +### Session Forking + +Sessions can be forked to create parallel conversation branches: + +```go +func Fork(sessionID, messageID string) *Session { + // Create new session with parent reference + newSession := Create(directory, title + " (fork)") + newSession.ParentID = &sessionID + + // Copy messages up to fork point + for _, msg := range GetMessages(sessionID) { + CopyMessage(msg, newSession.ID) + if msg.ID == messageID { + break + } + } + + return newSession +} +``` + +### Session Compaction + +When conversations become too long, sessions support compaction to summarize older messages: + +```go +type CompactionPart struct { + ID string `json:"id"` + Type string `json:"type"` // "compaction" + Auto bool `json:"auto"` // Automatic vs manual trigger +} +``` + +### Session Sharing + +Sessions can be shared via URL: + +```go +type SessionShare struct { + URL string `json:"url"` // Public share URL +} +``` + +### Session Revert + +Sessions can be reverted to a previous state: + +```go +type SessionRevert struct { + MessageID string `json:"messageID"` // Target message + PartID *string `json:"partID"` // Optional part + Snapshot *string `json:"snapshot"` // Git snapshot +} +``` + +## API Endpoints + +### Session Management + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/session` | GET | List all sessions for current project | +| `/session` | POST | Create new session | +| `/session/{id}` | GET | Get session details | +| `/session/{id}` | PATCH | Update session | +| `/session/{id}` | DELETE | Delete session | +| `/session/{id}/children` | GET | Get forked sessions | +| `/session/{id}/message` | GET | Get session messages | +| `/session/{id}/message` | POST | Send message (streaming) | + +### Project Management + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/project` | GET | List all projects | +| `/project/current` | GET | Get current project | + +## Compatibility + +### TypeScript OpenCode Compatibility + +Go OpenCode maintains full compatibility with TypeScript OpenCode sessions: + +1. **Same Storage Location**: Both use XDG data directories +2. **Same Project IDs**: Both use git commit SHA +3. **Same JSON Schema**: Session, message, and part structures match +4. **Automatic Migration**: Legacy sessions are migrated on first access + +### TUI Client Compatibility + +The persistence system enables the TUI client to: + +1. **List Previous Sessions**: Immediately available after server restart +2. **Resume Conversations**: Continue from any previous message +3. **Switch Sessions**: Move between different conversations +4. **View History**: Access complete message and tool call history + +## Performance Considerations + +### Lazy Loading + +Sessions are loaded on-demand, not at startup: +- Server starts immediately regardless of session count +- Only requested sessions are read from disk +- Memory usage scales with active sessions + +### File-Based vs Database + +The file-based approach provides: +- **Simplicity**: No database setup or management +- **Debuggability**: Human-readable JSON files +- **Portability**: Easy backup and migration +- **Durability**: Files survive process crashes + +Trade-offs: +- **Query Performance**: No indexing (full directory scan) +- **Concurrency**: File-level locking (not row-level) + +## Troubleshooting + +### Sessions Not Appearing + +1. **Check Project ID**: Verify `.git/opencode` contains the correct SHA +2. **Check Directory**: Ensure sessions were created in the same directory +3. **Check Migration**: Look for sessions under old hash-based project IDs + +### Session Data Corruption + +1. **Check File Permissions**: Ensure write access to storage directory +2. **Check Disk Space**: Atomic writes need space for temp files +3. **Check JSON Validity**: Parse session files manually + +### Migration Issues + +1. **Manual Migration**: Move session files between project directories +2. **Reset Project ID**: Delete `.git/opencode` to regenerate + +## References + +- **Storage Implementation**: `go-opencode/internal/storage/storage.go` +- **Session Service**: `go-opencode/internal/session/service.go` +- **Project Package**: `go-opencode/internal/project/project.go` +- **Type Definitions**: `go-opencode/pkg/types/` +- **TypeScript Implementation**: `packages/opencode/src/session/` diff --git a/go-opencode/internal/project/project.go b/go-opencode/internal/project/project.go new file mode 100644 index 00000000000..2a0582ae184 --- /dev/null +++ b/go-opencode/internal/project/project.go @@ -0,0 +1,212 @@ +// Package project provides project detection and identification functionality. +// It mirrors the TypeScript OpenCode implementation to ensure session compatibility. +package project + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" +) + +// Info contains project metadata. +type Info struct { + ID string `json:"id"` + Worktree string `json:"worktree"` + VCSDir *string `json:"vcsDir,omitempty"` + VCS *string `json:"vcs,omitempty"` +} + +// cache stores project info by directory to avoid repeated git calls. +var ( + cacheMu sync.RWMutex + cache = make(map[string]*Info) +) + +// FromDirectory detects project information from a directory. +// It mirrors the TypeScript implementation: +// 1. Finds the .git directory by walking up the tree +// 2. Uses the git initial commit SHA as project ID (cached in .git/opencode) +// 3. Falls back to "global" for non-git directories +func FromDirectory(directory string) (*Info, error) { + // Normalize directory path + directory, err := filepath.Abs(directory) + if err != nil { + return nil, err + } + + // Check cache first + cacheMu.RLock() + if info, ok := cache[directory]; ok { + cacheMu.RUnlock() + return info, nil + } + cacheMu.RUnlock() + + // Find .git directory + gitDir := findGitDir(directory) + if gitDir == "" { + // Not a git repository - use "global" project + info := &Info{ + ID: "global", + Worktree: "/", + } + cacheProject(directory, info) + return info, nil + } + + // Get worktree (git root directory) + worktree := filepath.Dir(gitDir) + worktreeCmd := exec.Command("git", "rev-parse", "--show-toplevel") + worktreeCmd.Dir = worktree + if output, err := worktreeCmd.Output(); err == nil { + worktree = strings.TrimSpace(string(output)) + } + + // Get actual git dir (handles worktrees) + gitDirCmd := exec.Command("git", "rev-parse", "--git-dir") + gitDirCmd.Dir = worktree + if output, err := gitDirCmd.Output(); err == nil { + resolvedGitDir := strings.TrimSpace(string(output)) + if !filepath.IsAbs(resolvedGitDir) { + resolvedGitDir = filepath.Join(worktree, resolvedGitDir) + } + gitDir = resolvedGitDir + } + + // Try to read cached project ID from .git/opencode + cacheFile := filepath.Join(gitDir, "opencode") + projectID, err := os.ReadFile(cacheFile) + if err == nil && len(projectID) > 0 { + id := strings.TrimSpace(string(projectID)) + vcs := "git" + info := &Info{ + ID: id, + Worktree: worktree, + VCSDir: &gitDir, + VCS: &vcs, + } + cacheProject(directory, info) + return info, nil + } + + // Get project ID from git's initial commit SHA + // This matches the TypeScript: git rev-list --max-parents=0 --all + id := getGitProjectID(worktree) + if id == "" { + id = "global" + } + + // Cache the project ID in .git/opencode for future use + if id != "global" { + _ = os.WriteFile(cacheFile, []byte(id), 0644) + } + + vcs := "git" + info := &Info{ + ID: id, + Worktree: worktree, + VCSDir: &gitDir, + VCS: &vcs, + } + cacheProject(directory, info) + return info, nil +} + +// GetProjectID returns just the project ID for a directory. +// This is a convenience function for the session service. +func GetProjectID(directory string) (string, error) { + info, err := FromDirectory(directory) + if err != nil { + return "", err + } + return info.ID, nil +} + +// HashDirectory creates a hash-based project ID from a directory path. +// This is the OLD method - kept for migration purposes only. +func HashDirectory(directory string) string { + h := sha256.New() + h.Write([]byte(directory)) + return hex.EncodeToString(h.Sum(nil))[:16] +} + +// findGitDir walks up from the given directory looking for a .git directory. +func findGitDir(start string) string { + current := start + for { + gitPath := filepath.Join(current, ".git") + if info, err := os.Stat(gitPath); err == nil { + if info.IsDir() { + return gitPath + } + // .git might be a file (for worktrees/submodules) + // Read the gitdir from it + if content, err := os.ReadFile(gitPath); err == nil { + line := strings.TrimSpace(string(content)) + if strings.HasPrefix(line, "gitdir: ") { + gitdir := strings.TrimPrefix(line, "gitdir: ") + if !filepath.IsAbs(gitdir) { + gitdir = filepath.Join(current, gitdir) + } + return gitdir + } + } + } + + parent := filepath.Dir(current) + if parent == current { + // Reached filesystem root + return "" + } + current = parent + } +} + +// getGitProjectID gets the project ID from git's initial commit(s). +// It matches the TypeScript implementation which uses: +// git rev-list --max-parents=0 --all +// and takes the first (alphabetically sorted) root commit SHA. +func getGitProjectID(worktree string) string { + cmd := exec.Command("git", "rev-list", "--max-parents=0", "--all") + cmd.Dir = worktree + output, err := cmd.Output() + if err != nil { + return "" + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var roots []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + roots = append(roots, line) + } + } + + if len(roots) == 0 { + return "" + } + + // Sort and take the first one (matches TypeScript behavior) + sort.Strings(roots) + return roots[0] +} + +// cacheProject adds a project to the in-memory cache. +func cacheProject(directory string, info *Info) { + cacheMu.Lock() + defer cacheMu.Unlock() + cache[directory] = info +} + +// ClearCache clears the project cache. Useful for testing. +func ClearCache() { + cacheMu.Lock() + defer cacheMu.Unlock() + cache = make(map[string]*Info) +} diff --git a/go-opencode/internal/project/project_test.go b/go-opencode/internal/project/project_test.go new file mode 100644 index 00000000000..682f679b745 --- /dev/null +++ b/go-opencode/internal/project/project_test.go @@ -0,0 +1,159 @@ +package project + +import ( + "os" + "path/filepath" + "testing" +) + +func TestHashDirectory(t *testing.T) { + // Test that hash is deterministic + hash1 := HashDirectory("/home/user/test") + hash2 := HashDirectory("/home/user/test") + if hash1 != hash2 { + t.Errorf("HashDirectory not deterministic: %s != %s", hash1, hash2) + } + + // Test that different paths produce different hashes + hash3 := HashDirectory("/home/user/other") + if hash1 == hash3 { + t.Errorf("Different paths should produce different hashes") + } + + // Test hash length + if len(hash1) != 16 { + t.Errorf("Hash should be 16 characters, got %d", len(hash1)) + } +} + +func TestFindGitDir(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + + // Test directory without .git + result := findGitDir(tmpDir) + if result != "" { + t.Errorf("Expected empty string for non-git dir, got %s", result) + } + + // Create .git directory + gitDir := filepath.Join(tmpDir, ".git") + if err := os.Mkdir(gitDir, 0755); err != nil { + t.Fatal(err) + } + + // Test from root + result = findGitDir(tmpDir) + if result != gitDir { + t.Errorf("Expected %s, got %s", gitDir, result) + } + + // Test from subdirectory + subDir := filepath.Join(tmpDir, "sub", "dir") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatal(err) + } + result = findGitDir(subDir) + if result != gitDir { + t.Errorf("Expected %s, got %s", gitDir, result) + } +} + +func TestFromDirectoryNonGit(t *testing.T) { + ClearCache() + tmpDir := t.TempDir() + + info, err := FromDirectory(tmpDir) + if err != nil { + t.Fatal(err) + } + + if info.ID != "global" { + t.Errorf("Expected 'global' project ID for non-git dir, got %s", info.ID) + } + + if info.Worktree != "/" { + t.Errorf("Expected '/' worktree for non-git dir, got %s", info.Worktree) + } +} + +func TestFromDirectoryGit(t *testing.T) { + ClearCache() + tmpDir := t.TempDir() + + // Initialize a git repo + gitDir := filepath.Join(tmpDir, ".git") + if err := os.Mkdir(gitDir, 0755); err != nil { + t.Fatal(err) + } + + // Create initial commit (simulated - just create the necessary structure) + // In a real git repo, we'd have commit objects, but for testing without + // running git commands, we'll just test the caching mechanism + + // Write a cached project ID + cacheFile := filepath.Join(gitDir, "opencode") + expectedID := "testprojectid123" + if err := os.WriteFile(cacheFile, []byte(expectedID), 0644); err != nil { + t.Fatal(err) + } + + info, err := FromDirectory(tmpDir) + if err != nil { + t.Fatal(err) + } + + if info.ID != expectedID { + t.Errorf("Expected cached project ID %s, got %s", expectedID, info.ID) + } + + if info.VCS == nil || *info.VCS != "git" { + t.Error("Expected VCS to be 'git'") + } +} + +func TestGetProjectID(t *testing.T) { + ClearCache() + tmpDir := t.TempDir() + + id, err := GetProjectID(tmpDir) + if err != nil { + t.Fatal(err) + } + + if id != "global" { + t.Errorf("Expected 'global' for non-git dir, got %s", id) + } +} + +func TestCaching(t *testing.T) { + ClearCache() + tmpDir := t.TempDir() + + // First call + info1, err := FromDirectory(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Second call should return cached result + info2, err := FromDirectory(tmpDir) + if err != nil { + t.Fatal(err) + } + + if info1 != info2 { + t.Error("Expected cached result to be same pointer") + } + + // Clear cache and call again + ClearCache() + info3, err := FromDirectory(tmpDir) + if err != nil { + t.Fatal(err) + } + + if info1 == info3 { + t.Error("Expected new result after cache clear") + } +} diff --git a/go-opencode/internal/session/service.go b/go-opencode/internal/session/service.go index 3220c7416ff..ec10913369e 100644 --- a/go-opencode/internal/session/service.go +++ b/go-opencode/internal/session/service.go @@ -3,8 +3,6 @@ package session import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "sync" @@ -14,6 +12,7 @@ import ( "github.com/opencode-ai/opencode/internal/event" "github.com/opencode-ai/opencode/internal/permission" + "github.com/opencode-ai/opencode/internal/project" "github.com/opencode-ai/opencode/internal/provider" "github.com/opencode-ai/opencode/internal/storage" "github.com/opencode-ai/opencode/internal/tool" @@ -75,7 +74,21 @@ func (s *Service) GetProcessor() *Processor { // Create creates a new session. func (s *Service) Create(ctx context.Context, directory string, title string) (*types.Session, error) { now := time.Now().UnixMilli() - projectID := hashDirectory(directory) + projectID, err := project.GetProjectID(directory) + if err != nil { + return nil, fmt.Errorf("failed to get project ID: %w", err) + } + + // Migrate any existing sessions from the old hash-based project ID + if err := s.migrateFromHashBasedID(ctx, directory, projectID); err != nil { + // Log but don't fail - migration is best-effort + _ = err + } + + // Also migrate from "global" if applicable + if err := s.migrateFromGlobal(ctx, directory, projectID); err != nil { + _ = err + } // Use default title if not provided if title == "" { @@ -196,8 +209,23 @@ func (s *Service) List(ctx context.Context, directory string) ([]*types.Session, } // List sessions for a specific directory/project - projectID := hashDirectory(directory) - err := s.storage.Scan(ctx, []string{"session", projectID}, func(key string, data json.RawMessage) error { + projectID, err := project.GetProjectID(directory) + if err != nil { + return nil, fmt.Errorf("failed to get project ID: %w", err) + } + + // Also check for sessions under the old hash-based project ID and migrate them + if err := s.migrateFromHashBasedID(ctx, directory, projectID); err != nil { + // Log but don't fail - migration is best-effort + _ = err + } + + // Also migrate from "global" if applicable + if err := s.migrateFromGlobal(ctx, directory, projectID); err != nil { + _ = err + } + + err = s.storage.Scan(ctx, []string{"session", projectID}, func(key string, data json.RawMessage) error { var session types.Session if err := json.Unmarshal(data, &session); err != nil { return err @@ -592,18 +620,17 @@ func (s *Service) GetCurrentProject(ctx context.Context, directory string) (*typ return nil, fmt.Errorf("directory is required") } - projectID := hashDirectory(directory) - now := time.Now().UnixMilli() + info, err := project.FromDirectory(directory) + if err != nil { + return nil, fmt.Errorf("failed to get project info: %w", err) + } - // Check if directory is a git repository - var vcs *string - gitVCS := "git" - vcs = &gitVCS // Assume git for now + now := time.Now().UnixMilli() return &types.Project{ - ID: projectID, - Worktree: directory, - VCS: vcs, + ID: info.ID, + Worktree: info.Worktree, + VCS: info.VCS, Time: types.ProjectTime{ Created: now, }, @@ -682,9 +709,81 @@ func generateID() string { return ulid.Make().String() } -// hashDirectory creates a project ID from a directory path. -func hashDirectory(directory string) string { - h := sha256.New() - h.Write([]byte(directory)) - return hex.EncodeToString(h.Sum(nil))[:16] +// migrateFromHashBasedID migrates sessions from the old hash-based project ID +// to the new git-based project ID. This ensures compatibility with TypeScript OpenCode. +func (s *Service) migrateFromHashBasedID(ctx context.Context, directory, newProjectID string) error { + // Calculate the old hash-based project ID + oldProjectID := project.HashDirectory(directory) + + // If they're the same (e.g., "global"), no migration needed + if oldProjectID == newProjectID { + return nil + } + + // Check if there are any sessions under the old project ID + oldSessions, err := s.storage.List(ctx, []string{"session", oldProjectID}) + if err != nil || len(oldSessions) == 0 { + return nil // No sessions to migrate + } + + // Migrate each session + for _, sessionID := range oldSessions { + var session types.Session + if err := s.storage.Get(ctx, []string{"session", oldProjectID, sessionID}, &session); err != nil { + continue // Skip sessions that can't be read + } + + // Only migrate sessions that belong to this directory + if session.Directory != directory { + continue + } + + // Update project ID and save to new location + session.ProjectID = newProjectID + if err := s.storage.Put(ctx, []string{"session", newProjectID, session.ID}, &session); err != nil { + continue // Skip on error + } + + // Delete from old location + _ = s.storage.Delete(ctx, []string{"session", oldProjectID, sessionID}) + } + + return nil +} + +// migrateFromGlobal migrates sessions from the "global" project to a specific project. +// This handles cases where sessions were created before git detection was implemented. +func (s *Service) migrateFromGlobal(ctx context.Context, directory, newProjectID string) error { + if newProjectID == "global" { + return nil // Can't migrate to global + } + + // Check for sessions in "global" that belong to this directory + globalSessions, err := s.storage.List(ctx, []string{"session", "global"}) + if err != nil || len(globalSessions) == 0 { + return nil + } + + for _, sessionID := range globalSessions { + var session types.Session + if err := s.storage.Get(ctx, []string{"session", "global", sessionID}, &session); err != nil { + continue + } + + // Only migrate sessions that belong to this directory + if session.Directory != directory { + continue + } + + // Update project ID and save to new location + session.ProjectID = newProjectID + if err := s.storage.Put(ctx, []string{"session", newProjectID, session.ID}, &session); err != nil { + continue + } + + // Delete from global + _ = s.storage.Delete(ctx, []string{"session", "global", sessionID}) + } + + return nil }