WIP experiment with 95% vibe coding
tiny-workshop docs design persistence-system.md
37 kB
Markdown
at main

Persistence System Design #

Component: Persistence System (Rust) Status: MVP Complete Related: Frontend Architecture, Room System


Implementation Status #

Feature Status Location
Room State ✅ Complete game/src/persistence/room_persistence.rs
File Locking ✅ Complete game/src/persistence/lock.rs
Schema Versioning ✅ Complete game/src/persistence/schema.rs
Session Logs ✅ Complete game/src/persistence/session_log.rs
Session Picker UI ✅ Complete game/src/session_picker.rs
Session Resumption ✅ Complete game/src/lib.rs (resume flow + history display)
Compaction Handling ✅ Complete extract_sdk_session_id() tracks latest session ID
Project Metadata ✅ Complete game/src/persistence/project_persistence.rs
Application Settings ✅ Complete game/src/persistence/settings.rs

Note: The session logging implementation uses a simplified schema compared to this design doc. See session_log.rs for the actual SessionLogEntry enum variants. Streaming deltas and subagent logging are planned for future phases.


Overview #

The persistence system manages all durable state for Agent Study: global configuration, project metadata, room state, and session conversation logs. It provides crash resilience, session resumption, and multi-instance safety through file locking.

Design Goals #

  1. Crash-resilient: Atomic writes, append-only logs, no partial state
  2. Fast recovery: Load project and resume session in <500ms
  3. Multi-instance safe: File locks prevent concurrent access to same project
  4. Versionable: Schema migration for forward compatibility
  5. Human-readable: JSON/JSONL for debugging and manual recovery
  6. Minimal I/O: Lazy loading, incremental updates, no polling

Storage Layout #

~/.agent-study/                              # User data directory
├── config.toml                              # Global configuration
├── instance.sock                            # Single-instance coordination (Unix)
└── projects/
    └── {project_hash}/                      # SHA256(working_dir)
        ├── .lock                            # File lock (prevents concurrent access)
        ├── project.json                     # Project metadata
        ├── room.json                        # Room state (separate for atomic updates)
        ├── images/                          # Image storage
        │   ├── {image_id}.png
        │   ├── {image_id}.jpg
        │   └── ...
        ├── .tmp/                            # Temporary files
        │   └── {temp_id}.{ext}              # Images from backend before moving
        └── sessions/
            ├── {session_id}.jsonl           # Append-only message log
            ├── {session_id}.jsonl
            └── ...

Why SHA256 for project hash? #

fn project_hash(working_dir: &Path) -> String {
    use sha2::{Sha256, Digest};
    let mut hasher = Sha256::new();
    hasher.update(working_dir.to_string_lossy().as_bytes());
    format!("{:x}", hasher.finalize())[..16].to_string()
}

Rationale:

  • Collision-resistant: 16 hex chars (64 bits) = negligible collision probability
  • Deterministic: Same working directory always maps to same hash
  • Safe for filesystems: No special characters, case-insensitive safe
  • Anonymous: Hash doesn't reveal working directory path

Alternative considered: URL-encode working directory

  • Problem: Long paths → long directory names (Windows MAX_PATH limit)
  • Problem: Special characters may cause issues on some filesystems

Global Configuration #

Storage Format #

File: ~/.agent-study/config.toml

Format: TOML (human-editable, comments supported)

version = { major = 1, minor = 0 }

[auth]
method = "api_key"  # or "oauth"
keychain_service = "agent-study"

[auth.api_key]
source = "environment"  # or "keychain", "config_file"

# If source = "keychain":
# [auth.api_key.keychain]
# account = "default"

# If source = "config_file":
# [auth.api_key.config_file]
# path = "/path/to/key.txt"

[graphics]
preset = "Low"  # Low, Medium, High, Custom
vsync = true
max_fps = 60
shadow_quality = "Low"
texture_quality = "Medium"
antialiasing = "None"
ambient_occlusion = false
bloom = false

[audio]
enabled = true
volume = 0.7
ambient_volume = 0.3

[updates]
auto_check = true
channel = "Stable"  # Stable, Beta, Alpha

Rust Schema #

#[derive(Serialize, Deserialize, Debug, Clone)]
struct GlobalConfig {
    version: SchemaVersion,
    auth: AuthConfig,
    graphics: GraphicsConfig,
    audio: AudioConfig,
    updates: UpdateConfig,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
struct SchemaVersion {
    major: u32,
    minor: u32,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
struct AuthConfig {
    method: AuthMethod,
    keychain_service: String,  // Default: "agent-study"
}

#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
enum AuthMethod {
    ApiKey {
        source: ApiKeySource,
    },
    OAuth,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ApiKeySource {
    Environment,
    Keychain {
        account: String,
    },
    ConfigFile {
        path: PathBuf,
    },
}

#[derive(Serialize, Deserialize, Debug, Clone)]
struct GraphicsConfig {
    preset: GraphicsPreset,
    vsync: bool,
    max_fps: Option<u32>,
    shadow_quality: ShadowQuality,
    texture_quality: TextureQuality,
    antialiasing: AntiAliasing,
    ambient_occlusion: bool,
    bloom: bool,
}

#[derive(Serialize, Deserialize, Debug, Clone, Default)]
enum GraphicsPreset {
    #[default]
    Low,
    Medium,
    High,
    Custom,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
struct AudioConfig {
    enabled: bool,
    volume: f32,          // 0.0 to 1.0
    ambient_volume: f32,  // 0.0 to 1.0
}

#[derive(Serialize, Deserialize, Debug, Clone)]
struct UpdateConfig {
    auto_check: bool,
    channel: UpdateChannel,
}

#[derive(Serialize, Deserialize, Debug, Clone, Default)]
enum UpdateChannel {
    #[default]
    Stable,
    Beta,
    Alpha,
}

impl Default for GlobalConfig {
    fn default() -> Self {
        Self {
            version: SchemaVersion { major: 1, minor: 0 },
            auth: AuthConfig {
                method: AuthMethod::ApiKey {
                    source: ApiKeySource::Environment,
                },
                keychain_service: "agent-study".to_string(),
            },
            graphics: GraphicsConfig {
                preset: GraphicsPreset::Low,
                vsync: true,
                max_fps: Some(60),
                shadow_quality: ShadowQuality::Low,
                texture_quality: TextureQuality::Medium,
                antialiasing: AntiAliasing::None,
                ambient_occlusion: false,
                bloom: false,
            },
            audio: AudioConfig {
                enabled: true,
                volume: 0.7,
                ambient_volume: 0.3,
            },
            updates: UpdateConfig {
                auto_check: true,
                channel: UpdateChannel::Stable,
            },
        }
    }
}

Loading and Saving #

impl GlobalConfig {
    pub fn load() -> Result<Self> {
        let path = Self::config_path()?;

        if !path.exists() {
            let config = Self::default();
            config.save()?;
            return Ok(config);
        }

        let content = fs::read_to_string(&path)?;
        let value: toml::Value = toml::from_str(&content)?;

        // Extract version for migration
        let version = value.get("version")
            .and_then(|v| v.as_table())
            .ok_or(ConfigError::MissingVersion)?;

        let major = version.get("major")
            .and_then(|v| v.as_integer())
            .ok_or(ConfigError::MissingVersion)? as u32;
        let minor = version.get("minor")
            .and_then(|v| v.as_integer())
            .ok_or(ConfigError::MissingVersion)? as u32;

        let current_version = SchemaVersion {
            major: env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap(),
            minor: env!("CARGO_PKG_VERSION_MINOR").parse().unwrap(),
        };

        if major > current_version.major {
            return Err(ConfigError::VersionTooNew { major, minor });
        }

        // Migrate if needed
        let value = if major < current_version.major || minor < current_version.minor {
            Self::migrate(value, SchemaVersion { major, minor })?
        } else {
            value
        };

        Ok(toml::from_str(&toml::to_string(&value)?)?)
    }

    pub fn save(&self) -> Result<()> {
        let path = Self::config_path()?;
        let content = toml::to_string_pretty(self)?;

        // Atomic write
        let temp_path = path.with_extension("tmp");
        fs::write(&temp_path, content)?;
        fs::rename(&temp_path, &path)?;

        Ok(())
    }

    fn config_path() -> Result<PathBuf> {
        let data_dir = dirs::data_dir()
            .ok_or(ConfigError::NoDataDir)?;
        Ok(data_dir.join("agent-study").join("config.toml"))
    }

    fn migrate(mut value: toml::Value, from: SchemaVersion) -> Result<toml::Value> {
        // Apply migrations sequentially
        if from < SchemaVersion { major: 1, minor: 1 } {
            value = migrations::global::v1_0_to_v1_1(value)?;
        }
        // Future migrations go here

        Ok(value)
    }
}

Rationale:

  • Atomic writes: Write to .tmp, then rename (rename is atomic on POSIX and Windows)
  • Default on missing: If no config file, create with sensible defaults
  • Migration on load: Automatically upgrade old configs
  • Version check: Refuse to load configs from future versions (user downgraded app)

Actual Implementation (Application Settings) #

The implemented settings system uses JSON instead of TOML for consistency with other persistence files. It stores a subset of the planned configuration options.

File: Platform-specific data directory + settings.json

  • Linux: ~/.local/share/agent-study/settings.json
  • macOS: ~/Library/Application Support/agent-study/settings.json
  • Windows: %APPDATA%/agent-study/settings.json

Format: JSON (machine-generated)

{
  "version": { "major": 1, "minor": 0 },
  "quality_preset": "Medium",
  "vsync_enabled": true,
  "fps_limit": "Fps60",
  "audio_enabled": true,
  "volume": 0.8,
  "update_channel": "Stable"
}

Rust Schema (see game/src/persistence/settings.rs):

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings {
    pub version: SchemaVersion,
    pub quality_preset: QualityPreset,  // Low, Medium, High
    pub vsync_enabled: bool,
    pub fps_limit: FpsLimit,            // Unlimited, Fps30, Fps60, Fps120, Fps144
    pub audio_enabled: bool,
    pub volume: f32,                    // 0.0 to 1.0
    pub update_channel: UpdateChannel,  // Stable, Beta
}

Access: Settings are loaded at startup and exposed through the Settings Dialog UI (Ctrl+,). The dialog has four tabs: Graphics, Audio, Authentication, and Advanced.

Project State #

Storage Format #

File: ~/.agent-study/projects/{project_hash}/project.json

Format: JSON (machine-generated, pretty-printed for debugging)

{
  "version": { "major": 1, "minor": 0 },
  "directory": "/absolute/path/to/project",
  "name": "my-project",
  "created_at": "2026-01-03T10:30:00Z",
  "last_opened_at": "2026-01-03T15:45:00Z",
  "sessions": [
    {
      "id": "session-001",
      "sdk_session_id": "abc123-def456",
      "started_at": "2026-01-03T10:30:00Z",
      "ended_at": "2026-01-03T12:00:00Z",
      "message_count": 42
    },
    {
      "id": "session-002",
      "sdk_session_id": "xyz789-uvw012",
      "started_at": "2026-01-03T15:45:00Z",
      "ended_at": null,
      "message_count": 15
    }
  ],
  "active_session": "session-002"
}

Rust Schema #

#[derive(Serialize, Deserialize, Debug, Clone)]
struct ProjectState {
    version: SchemaVersion,
    directory: PathBuf,
    name: String,
    created_at: DateTime<Utc>,
    last_opened_at: DateTime<Utc>,
    sessions: Vec<SessionRef>,
    active_session: Option<String>,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
struct SessionRef {
    id: String,                        // session-001, session-002, ...
    sdk_session_id: String,            // UUID from SDK
    started_at: DateTime<Utc>,
    ended_at: Option<DateTime<Utc>>,
    message_count: usize,
}

impl ProjectState {
    pub fn load(working_dir: &Path) -> Result<Self> {
        let project_dir = Self::project_dir(working_dir)?;
        let path = project_dir.join("project.json");

        if !path.exists() {
            return Ok(Self::new(working_dir));
        }

        let content = fs::read_to_string(&path)?;
        let value: serde_json::Value = serde_json::from_str(&content)?;

        // Extract and check version
        let version = extract_version(&value)?;
        let current = SchemaVersion { major: 1, minor: 0 };

        if version.major > current.major {
            return Err(ProjectError::VersionTooNew {
                major: version.major,
                minor: version.minor,
            });
        }

        // Migrate if needed
        let value = if version < current {
            Self::migrate(value, version)?
        } else {
            value
        };

        Ok(serde_json::from_value(value)?)
    }

    pub fn save(&self) -> Result<()> {
        let project_dir = Self::project_dir(&self.directory)?;
        fs::create_dir_all(&project_dir)?;

        let path = project_dir.join("project.json");
        let content = serde_json::to_string_pretty(self)?;

        // Atomic write
        let temp_path = project_dir.join("project.json.tmp");
        fs::write(&temp_path, content)?;
        fs::rename(&temp_path, &path)?;

        Ok(())
    }

    fn new(working_dir: &Path) -> Self {
        let name = working_dir
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("project")
            .to_string();

        Self {
            version: SchemaVersion { major: 1, minor: 0 },
            directory: working_dir.to_path_buf(),
            name,
            created_at: Utc::now(),
            last_opened_at: Utc::now(),
            sessions: Vec::new(),
            active_session: None,
        }
    }

    fn project_dir(working_dir: &Path) -> Result<PathBuf> {
        let hash = project_hash(working_dir);
        let data_dir = dirs::data_dir().ok_or(ProjectError::NoDataDir)?;
        Ok(data_dir.join("agent-study").join("projects").join(hash))
    }

    fn migrate(value: serde_json::Value, from: SchemaVersion) -> Result<serde_json::Value> {
        let mut value = value;

        if from < SchemaVersion { major: 1, minor: 1 } {
            value = migrations::project::v1_0_to_v1_1(value)?;
        }

        Ok(value)
    }
}

Room State #

Storage Format #

File: ~/.agent-study/projects/{project_hash}/room.json

Rationale for separate file: Room state is updated frequently (agent creates notes, adds images). Separating from project state reduces write amplification and enables atomic room-only updates.

{
  "version": { "major": 1, "minor": 0 },
  "interactables": [
    {
      "id": "note-001",
      "kind": {
        "type": "Note",
        "title": "TODO",
        "content": "# Things to remember\n- Check error handling\n- Add tests"
      },
      "position": {
        "surface": "windowside_table",
        "slot": 0
      },
      "created_at": "2026-01-03T10:45:00Z",
      "created_by": {
        "type": "Agent",
        "session": "session-001"
      },
      "metadata": {}
    },
    {
      "id": "image-stack-001",
      "kind": {
        "type": "ImageStack",
        "images": [
          {
            "id": "img-001",
            "path": "images/img-001.png",
            "thumbnail": "images/img-001_thumb.png",
            "mime": "image/png",
            "source": "Bash",
            "added_at": "2026-01-03T11:00:00Z"
          }
        ]
      },
      "position": {
        "surface": "desk",
        "slot": 0
      },
      "created_at": "2026-01-03T11:00:00Z",
      "created_by": {
        "type": "Agent",
        "session": "session-001"
      },
      "metadata": {}
    }
  ]
}

Rust Schema #

#[derive(Serialize, Deserialize, Debug, Clone)]
struct RoomState {
    version: SchemaVersion,
    interactables: Vec<Interactable>,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
struct Interactable {
    id: String,
    kind: InteractableKind,
    position: Placement,
    created_at: DateTime<Utc>,
    created_by: CreatedBy,
    metadata: serde_json::Value,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type")]
enum InteractableKind {
    Note {
        title: String,
        content: String,  // Markdown
    },
    ImageStack {
        images: Vec<ImageRef>,
    },
}

#[derive(Serialize, Deserialize, Debug, Clone)]
struct ImageRef {
    id: String,
    path: PathBuf,                  // Relative to project images/ directory
    thumbnail: Option<PathBuf>,     // Optional thumbnail
    mime: String,
    source: Option<String>,         // Tool that generated it
    added_at: DateTime<Utc>,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type")]
enum Placement {
    Surface {
        surface: String,  // "desk", "windowside_table", etc.
        slot: usize,
    },
}

#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type")]
enum CreatedBy {
    User,
    Agent {
        session: String,
    },
}

impl RoomState {
    pub fn load(project_dir: &Path) -> Result<Self> {
        let path = project_dir.join("room.json");

        if !path.exists() {
            return Ok(Self::default());
        }

        let content = fs::read_to_string(&path)?;
        let value: serde_json::Value = serde_json::from_str(&content)?;

        let version = extract_version(&value)?;
        let current = SchemaVersion { major: 1, minor: 0 };

        if version.major > current.major {
            return Err(RoomError::VersionTooNew {
                major: version.major,
                minor: version.minor,
            });
        }

        let value = if version < current {
            Self::migrate(value, version)?
        } else {
            value
        };

        Ok(serde_json::from_value(value)?)
    }

    pub fn save(&self, project_dir: &Path) -> Result<()> {
        let path = project_dir.join("room.json");
        let content = serde_json::to_string_pretty(self)?;

        // Atomic write
        let temp_path = project_dir.join("room.json.tmp");
        fs::write(&temp_path, content)?;
        fs::rename(&temp_path, &path)?;

        Ok(())
    }
}

impl Default for RoomState {
    fn default() -> Self {
        Self {
            version: SchemaVersion { major: 1, minor: 0 },
            interactables: Vec::new(),
        }
    }
}

Session Logs #

Storage Format #

File: ~/.agent-study/projects/{project_hash}/sessions/{session_id}.jsonl

Format: JSON Lines (one JSON object per line, append-only)

Session ID format: session-YYYYMMDD-HHMMSS-mmm (timestamp with milliseconds for collision safety)

{"type":"session_started","sdk_session_id":"abc123","model":"claude-sonnet-4","tools":["create_note","update_note"],"timestamp":"2026-01-03T10:30:00.123Z"}
{"type":"user_message","content":"Hello","timestamp":"2026-01-03T10:30:05Z"}
{"type":"tool_started","id":"tool-1","tool":"create_note","input":{"title":"Test","content":"Hello"},"timestamp":"2026-01-03T10:31:01Z"}
{"type":"tool_completed","id":"tool-1","success":true,"output":"Note created","timestamp":"2026-01-03T10:31:02Z"}
{"type":"room_action_performed","action":{"type":"CreateNote","title":"Test","content":"Hello","surface":"desk"},"timestamp":"2026-01-03T10:31:02Z"}
{"type":"assistant_message_complete","full_content":"I've created a note for you!","timestamp":"2026-01-03T10:31:03Z"}
{"type":"session_ended","reason":"shutdown","timestamp":"2026-01-03T12:00:00Z"}

Rust Schema (Implemented) #

/// A single entry in a session log file.
/// Each entry is serialized as a single JSON line in the JSONL file.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SessionLogEntry {
    // === Session lifecycle ===
    SessionStarted {
        sdk_session_id: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        model: Option<String>,
        tools: Vec<String>,
        timestamp: DateTime<Utc>,
    },
    SessionEnded {
        reason: String,  // "shutdown", "disconnect", "error", "cancelled"
        timestamp: DateTime<Utc>,
    },

    // === Messages ===
    UserMessage {
        content: String,
        timestamp: DateTime<Utc>,
    },
    AssistantMessageComplete {
        full_content: String,
        timestamp: DateTime<Utc>,
    },

    // === Tools ===
    ToolStarted {
        id: String,
        tool: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        input: Option<serde_json::Value>,
        timestamp: DateTime<Utc>,
    },
    ToolCompleted {
        id: String,
        success: bool,
        #[serde(skip_serializing_if = "Option::is_none")]
        output: Option<serde_json::Value>,
        timestamp: DateTime<Utc>,
    },

    // === Room actions ===
    RoomActionPerformed {
        action: RoomAction,
        timestamp: DateTime<Utc>,
    },
}

Note on tool input/output types: Tool inputs and outputs are stored as Option<serde_json::Value> to handle arbitrary JSON structures. The tool name is stored separately in the tool field for ToolStarted, and can be looked up by matching id to the corresponding ToolStarted entry.

Implemented Entry Types #

The following entry types are fully implemented in SessionLogEntry:

  • SessionStarted: Session lifecycle with SDK session ID, model, tools
  • SessionEnded: Clean shutdown or disconnect tracking
  • CompactionOccurred: SDK session compaction with old/new session ID tracking
  • UserMessage: User messages with content and timestamp
  • AssistantMessageComplete: Complete assistant responses
  • ToolStarted/ToolCompleted: Tool execution lifecycle
  • RoomAction: Room state changes (note creation, etc.)

Not Yet Implemented #

The following features from the original design are planned for future phases:

  • AssistantTextDelta: Streaming deltas (currently we log only complete messages)
  • SubagentSpawned/Message/Ended: Subagent lifecycle logging
  • attachments field on UserMessage

Session Logger Implementation #

pub struct SessionLogger {
    session_id: String,
    writer: BufWriter<File>,
    path: PathBuf,
    entry_count: usize,
    bytes_written: usize,  // Track total bytes for size limits
}

impl SessionLogger {
    /// Create a new session logger with a timestamp-based session ID.
    /// Creates the sessions directory if needed.
    /// Writes the initial SessionStarted entry.
    pub fn create(
        sessions_dir: &Path,
        sdk_session_id: String,
        model: Option<String>,
        tools: Vec<String>,
    ) -> Result<Self, PersistenceError>;

    /// Append an entry to the log.
    /// Flushes after each write for durability.
    pub fn log(&mut self, entry: SessionLogEntry) -> Result<(), PersistenceError>;

    // Getters
    pub fn session_id(&self) -> &str;
    pub fn entry_count(&self) -> usize;
    pub fn bytes_written(&self) -> usize;
    pub fn path(&self) -> &Path;

    // Size limit methods
    pub fn should_rotate(&self) -> bool;    // True if >= MAX_LOG_ENTRIES or MAX_LOG_BYTES
    pub fn is_near_limit(&self) -> bool;    // True if >= 90% of either limit
    pub fn limit_usage(&self) -> (f64, f64); // (entry_percent, bytes_percent)
}

/// Load all entries from a session log file.
/// Invalid lines are skipped with a warning (crash-resilient parsing).
pub fn load_session(path: &Path) -> Result<Vec<SessionLogEntry>, PersistenceError>;

/// List all session log files in a sessions directory.
/// Returns session IDs sorted by timestamp (newest first).
pub fn list_sessions(sessions_dir: &Path) -> Result<Vec<String>, PersistenceError>;

Limitations and Size Limits #

Size limits (implemented 2026-01-11):

  • MAX_LOG_ENTRIES: 1000 entries per session log file
  • MAX_LOG_BYTES: 10 MB per session log file
  • Use SessionLogger::should_rotate() to check if limits are exceeded
  • Use SessionLogger::is_near_limit() for early warning at 90% threshold

Future work:

  • Automatic cleanup of old sessions (> 30 days)
  • Compression of archived sessions

Rationale:

  • Append-only: No seeking, always write to end (crash-safe)
  • Flush after write: Ensures each entry is durable before continuing
  • JSONL format: Each line is independent (partial writes only lose last incomplete line)
  • One file per session: Easier to manage, delete old sessions individually

Project Locking (Multi-Instance Safety) #

use fs2::FileExt;
use std::fs::{File, OpenOptions};

pub struct ProjectLock {
    _file: File,  // Keep file open to maintain lock
    path: PathBuf,
}

impl ProjectLock {
    pub fn acquire(project_dir: &Path) -> Result<Self, LockError> {
        let lock_path = project_dir.join(".lock");

        // Create or open lock file
        let file = OpenOptions::new()
            .create(true)
            .write(true)
            .open(&lock_path)?;

        // Try exclusive lock (non-blocking)
        match file.try_lock_exclusive() {
            Ok(()) => Ok(Self {
                _file: file,
                path: lock_path,
            }),
            Err(_) => {
                // Lock is held by another process
                Err(LockError::AlreadyOpen {
                    project: project_dir.to_path_buf(),
                })
            }
        }
    }
}

impl Drop for ProjectLock {
    fn drop(&mut self) {
        // Unlock is automatic when file is closed
        // Remove lock file (best effort, ignore errors)
        let _ = fs::remove_file(&self.path);
    }
}

Rationale:

  • Exclusive lock: Only one process can hold lock at a time
  • Non-blocking: try_lock_exclusive() returns immediately (no waiting)
  • Automatic unlock: Lock is released when process exits (even on crash)
  • Cross-platform: fs2 crate abstracts platform differences (flock on Unix, LockFileEx on Windows)

User experience:

User opens project in instance A → Lock acquired
User tries to open same project in instance B → Error dialog:
  "This project is already open in another window.
   Close the other window and try again."

Alternative: Instead of error, focus existing window (see single-instance coordination below).


Single-Instance Coordination (Optional) #

For projects already open, instead of showing an error, focus the existing window:

use std::os::unix::net::{UnixListener, UnixStream};

pub struct InstanceCoordinator {
    socket_path: PathBuf,
    listener: Option<UnixListener>,
    active_projects: Arc<Mutex<HashSet<PathBuf>>>,
}

impl InstanceCoordinator {
    pub fn try_focus_existing(project_dir: &Path) -> Option<()> {
        let socket_path = Self::socket_path().ok()?;

        if !socket_path.exists() {
            return None;
        }

        // Connect to existing instance
        let mut stream = UnixStream::connect(&socket_path).ok()?;

        // Send focus request
        let request = FocusRequest {
            project: project_dir.to_path_buf(),
        };
        serde_json::to_writer(&mut stream, &request).ok()?;

        // Read response
        let response: FocusResponse = serde_json::from_reader(&stream).ok()?;

        if response.focused {
            Some(())
        } else {
            None
        }
    }

    pub fn start_listening(&mut self) -> Result<()> {
        let socket_path = Self::socket_path()?;

        // Remove stale socket
        let _ = fs::remove_file(&socket_path);

        let listener = UnixListener::bind(&socket_path)?;
        self.socket_path = socket_path;
        self.listener = Some(listener);

        // Spawn listener thread
        let active = self.active_projects.clone();
        std::thread::spawn(move || {
            for stream in listener.incoming() {
                if let Ok(stream) = stream {
                    Self::handle_focus_request(stream, &active);
                }
            }
        });

        Ok(())
    }

    fn handle_focus_request(stream: UnixStream, active: &Arc<Mutex<HashSet<PathBuf>>>) {
        let request: FocusRequest = match serde_json::from_reader(&stream) {
            Ok(r) => r,
            Err(_) => return,
        };

        let focused = {
            let projects = active.lock().unwrap();
            projects.contains(&request.project)
        };

        if focused {
            // Send focus message to windowing system
            // (Platform-specific implementation)
            // ...
        }

        let response = FocusResponse { focused };
        let _ = serde_json::to_writer(stream, &response);
    }

    fn socket_path() -> Result<PathBuf> {
        let data_dir = dirs::data_dir().ok_or(ConfigError::NoDataDir)?;
        Ok(data_dir.join("agent-study").join("instance.sock"))
    }
}

#[derive(Serialize, Deserialize)]
struct FocusRequest {
    project: PathBuf,
}

#[derive(Serialize, Deserialize)]
struct FocusResponse {
    focused: bool,
}

Rationale: Provides better UX than error dialog, but adds complexity. Should be opt-in or platform-specific (macOS has better window management for this).


Image Storage #

Images are stored in images/ directory with unique IDs:

pub struct ImageStorage {
    images_dir: PathBuf,
}

impl ImageStorage {
    pub fn new(project_dir: &Path) -> Self {
        let images_dir = project_dir.join("images");
        Self { images_dir }
    }

    pub fn store_image(&self, source: &Path) -> Result<ImageRef> {
        fs::create_dir_all(&self.images_dir)?;

        let id = uuid::Uuid::new_v4().to_string();
        let ext = source.extension()
            .and_then(|e| e.to_str())
            .unwrap_or("png");

        let filename = format!("{}.{}", id, ext);
        let dest = self.images_dir.join(&filename);

        // Copy image to storage
        fs::copy(source, &dest)?;

        // Generate thumbnail (if needed)
        let thumbnail = if Self::should_thumbnail(ext) {
            Some(self.generate_thumbnail(&dest, &id, ext)?)
        } else {
            None
        };

        Ok(ImageRef {
            id,
            path: PathBuf::from(filename),
            thumbnail,
            mime: Self::mime_type(ext),
            source: None,
            added_at: Utc::now(),
        })
    }

    fn should_thumbnail(ext: &str) -> bool {
        matches!(ext, "png" | "jpg" | "jpeg" | "webp")
    }

    fn generate_thumbnail(&self, source: &Path, id: &str, ext: &str) -> Result<PathBuf> {
        // Use image crate to resize
        let img = image::open(source)?;
        let thumbnail = img.thumbnail(200, 200);

        let filename = format!("{}_thumb.{}", id, ext);
        let dest = self.images_dir.join(&filename);
        thumbnail.save(&dest)?;

        Ok(PathBuf::from(filename))
    }

    fn mime_type(ext: &str) -> String {
        match ext {
            "png" => "image/png",
            "jpg" | "jpeg" => "image/jpeg",
            "webp" => "image/webp",
            "gif" => "image/gif",
            _ => "application/octet-stream",
        }.to_string()
    }

    pub fn load_image(&self, image_ref: &ImageRef) -> Result<Vec<u8>> {
        let path = self.images_dir.join(&image_ref.path);
        Ok(fs::read(path)?)
    }

    pub fn delete_image(&self, image_ref: &ImageRef) -> Result<()> {
        let path = self.images_dir.join(&image_ref.path);
        fs::remove_file(path)?;

        if let Some(thumb) = &image_ref.thumbnail {
            let thumb_path = self.images_dir.join(thumb);
            let _ = fs::remove_file(thumb_path);  // Ignore errors
        }

        Ok(())
    }
}

Rationale:

  • UUID filenames: Prevents collisions, no special characters
  • Preserve extension: Makes debugging easier (can open images directly)
  • Thumbnails: For image stacks UI, avoid loading full-size images
  • Lazy loading: Images loaded on demand, not at startup (see Frontend Architecture)

Schema Migration #

Migration Pattern #

pub mod migrations {
    pub mod global {
        pub fn v1_0_to_v1_1(mut value: toml::Value) -> Result<toml::Value> {
            // Example: Add new field with default value
            if let Some(table) = value.as_table_mut() {
                table.insert(
                    "new_field".to_string(),
                    toml::Value::String("default".to_string())
                );
            }

            // Update version
            if let Some(version) = value.get_mut("version")
                .and_then(|v| v.as_table_mut())
            {
                version.insert("minor".to_string(), toml::Value::Integer(1));
            }

            Ok(value)
        }
    }

    pub mod project {
        pub fn v1_0_to_v1_1(mut value: serde_json::Value) -> Result<serde_json::Value> {
            // Example: Rename field
            if let Some(obj) = value.as_object_mut() {
                if let Some(old_value) = obj.remove("old_field_name") {
                    obj.insert("new_field_name".to_string(), old_value);
                }

                // Update version
                if let Some(version) = obj.get_mut("version")
                    .and_then(|v| v.as_object_mut())
                {
                    version.insert("minor".to_string(), serde_json::json!(1));
                }
            }

            Ok(value)
        }
    }
}

Testing Migrations #

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_migrate_project_v1_0_to_v1_1() {
        let v1_0 = serde_json::json!({
            "version": { "major": 1, "minor": 0 },
            "old_field_name": "value",
            "other_field": 42
        });

        let result = migrations::project::v1_0_to_v1_1(v1_0).unwrap();

        assert_eq!(result["version"]["minor"], 1);
        assert_eq!(result["new_field_name"], "value");
        assert!(result.get("old_field_name").is_none());
        assert_eq!(result["other_field"], 42);
    }
}

Performance Targets #

Operation Target Strategy
Load config <10ms Small file, cached
Load project state <50ms Small JSON file
Load room state <100ms Larger JSON, lazy image loading
Load session log <200ms Sequential read, 1000 lines/session
Save room state <50ms Atomic write, small file
Log session entry <5ms Append + flush
Acquire project lock <10ms Non-blocking lock attempt

Open Questions #

  1. Session log retention: How many sessions should we keep? Options:

    • Keep all sessions forever (could be 100s of files)
    • Delete sessions older than 30 days
    • Keep last N sessions per project
    • User-configurable retention policy
  2. Room state versioning: Should we track room state history (like git)?

    • Pro: User can restore old room state (undo accidental clear)
    • Con: Storage overhead, implementation complexity
    • Alternative: Single-level undo (keep previous room state in memory)
  3. Image cleanup: When should we delete unused images?

    • Manual "Clean up images" button?
    • Automatic garbage collection on app startup?
    • Reference counting (delete when no interactables reference it)?
  4. Crash recovery testing: How do we test crash resilience?

    • Inject crashes at random points during writes?
    • Verify state is recoverable after kill -9?
    • Automated fuzzing of persistence layer?