# Persistence System Design **Component:** Persistence System (Rust) **Status:** MVP Complete **Related:** [Frontend Architecture](./frontend-architecture.md), [Room System](./room-system.md) --- ## 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? ```rust 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) ```toml 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 ```rust #[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, 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 ```rust impl GlobalConfig { pub fn load() -> Result { 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 { 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 { // 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) ```json { "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`): ```rust #[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) ```json { "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 ```rust #[derive(Serialize, Deserialize, Debug, Clone)] struct ProjectState { version: SchemaVersion, directory: PathBuf, name: String, created_at: DateTime, last_opened_at: DateTime, sessions: Vec, active_session: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] struct SessionRef { id: String, // session-001, session-002, ... sdk_session_id: String, // UUID from SDK started_at: DateTime, ended_at: Option>, message_count: usize, } impl ProjectState { pub fn load(working_dir: &Path) -> Result { 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 { 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 { 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. ```json { "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 ```rust #[derive(Serialize, Deserialize, Debug, Clone)] struct RoomState { version: SchemaVersion, interactables: Vec, } #[derive(Serialize, Deserialize, Debug, Clone)] struct Interactable { id: String, kind: InteractableKind, position: Placement, created_at: DateTime, 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, }, } #[derive(Serialize, Deserialize, Debug, Clone)] struct ImageRef { id: String, path: PathBuf, // Relative to project images/ directory thumbnail: Option, // Optional thumbnail mime: String, source: Option, // Tool that generated it added_at: DateTime, } #[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 { 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) ```jsonl {"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) ```rust /// 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, tools: Vec, timestamp: DateTime, }, SessionEnded { reason: String, // "shutdown", "disconnect", "error", "cancelled" timestamp: DateTime, }, // === Messages === UserMessage { content: String, timestamp: DateTime, }, AssistantMessageComplete { full_content: String, timestamp: DateTime, }, // === Tools === ToolStarted { id: String, tool: String, #[serde(skip_serializing_if = "Option::is_none")] input: Option, timestamp: DateTime, }, ToolCompleted { id: String, success: bool, #[serde(skip_serializing_if = "Option::is_none")] output: Option, timestamp: DateTime, }, // === Room actions === RoomActionPerformed { action: RoomAction, timestamp: DateTime, }, } ``` **Note on tool input/output types**: Tool inputs and outputs are stored as `Option` 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 ```rust pub struct SessionLogger { session_id: String, writer: BufWriter, 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, tools: Vec, ) -> Result; /// 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, 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, 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) ```rust 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 { 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: ```rust use std::os::unix::net::{UnixListener, UnixStream}; pub struct InstanceCoordinator { socket_path: PathBuf, listener: Option, active_projects: Arc>>, } 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>>) { 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 { 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: ```rust 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 { 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 { // 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> { 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 ```rust pub mod migrations { pub mod global { pub fn v1_0_to_v1_1(mut value: toml::Value) -> Result { // 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 { // 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 ```rust #[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? --- ## Related Documents - [Frontend Architecture](./frontend-architecture.md) - [Room System](./room-system.md)