From 5b2369c389d86e37ea4486fe1212d5b415b2e327 Mon Sep 17 00:00:00 2001 From: Marcus Pamelia Date: Sun, 26 Apr 2026 10:16:36 +0200 Subject: [PATCH] fix(core): move generated project state under home flok dir --- crates/flok-core/src/compaction.rs | 2 +- crates/flok-core/src/config.rs | 50 +++++++++++++++++++++++++ crates/flok-core/src/plan.rs | 4 +- crates/flok-core/src/snapshot.rs | 33 ++-------------- crates/flok-core/src/team.rs | 8 ++-- crates/flok-core/src/tool/memory.rs | 6 +-- crates/flok-core/src/tool/plan.rs | 24 +++++++----- crates/flok-core/src/tool/plan_tools.rs | 6 +-- crates/flok-core/src/worktree.rs | 33 ++-------------- 9 files changed, 86 insertions(+), 80 deletions(-) diff --git a/crates/flok-core/src/compaction.rs b/crates/flok-core/src/compaction.rs index 08c9f4e..bd22117 100644 --- a/crates/flok-core/src/compaction.rs +++ b/crates/flok-core/src/compaction.rs @@ -266,7 +266,7 @@ impl CompactionStore { } fn compactions_dir(&self) -> PathBuf { - self.project_root.join(".flok").join("compactions") + crate::config::project_state_dir(&self.project_root).join("compactions") } fn project_memory_path(&self) -> PathBuf { diff --git a/crates/flok-core/src/config.rs b/crates/flok-core/src/config.rs index 923e40a..db0c47f 100644 --- a/crates/flok-core/src/config.rs +++ b/crates/flok-core/src/config.rs @@ -755,6 +755,7 @@ pub fn ensure_directories() -> anyhow::Result<()> { dirs.config_dir().join("flok"), dirs.data_dir().join("flok"), dirs.cache_dir().join("flok"), + flok_state_root(), ]; for path in &paths { std::fs::create_dir_all(path)?; @@ -763,6 +764,47 @@ pub fn ensure_directories() -> anyhow::Result<()> { Ok(()) } +/// Root directory for generated flok runtime state. +/// +/// This intentionally lives under `~/.flok` instead of the project tree so +/// compactions, plans, memory, snapshots, and other agent artifacts do not +/// pollute repositories. +#[must_use] +pub fn flok_state_root() -> PathBuf { + if cfg!(test) { + return std::env::temp_dir().join("flok-test-state"); + } + + if let Some(home) = directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf()) { + return home.join(".flok"); + } + + std::env::temp_dir().join("flok") +} + +/// Stable per-project directory for generated runtime state. +#[must_use] +pub fn project_state_dir(project_root: &Path) -> PathBuf { + let canonical = + std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf()); + let slug = canonical + .file_name() + .and_then(std::ffi::OsStr::to_str) + .map(sanitize_project_slug) + .filter(|slug| !slug.is_empty()) + .unwrap_or_else(|| "project".to_string()); + let hash = blake3::hash(canonical.to_string_lossy().as_bytes()).to_hex().to_string(); + flok_state_root().join("projects").join(format!("{slug}-{}", &hash[..16])) +} + +fn sanitize_project_slug(value: &str) -> String { + let slug: String = value + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { ch } else { '-' }) + .collect(); + slug.trim_matches('-').to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -786,6 +828,14 @@ mod tests { assert_eq!(config.output_compression, OutputCompressionConfig::default()); } + #[test] + fn project_state_dir_is_outside_project_root() { + let dir = tempfile::tempdir().unwrap(); + let state_dir = project_state_dir(dir.path()); + assert!(!state_dir.starts_with(dir.path())); + assert!(state_dir.to_string_lossy().contains("projects")); + } + #[test] fn parse_output_compression_config() { let toml_str = r#" diff --git a/crates/flok-core/src/plan.rs b/crates/flok-core/src/plan.rs index 9617c5e..6e21d50 100644 --- a/crates/flok-core/src/plan.rs +++ b/crates/flok-core/src/plan.rs @@ -1,6 +1,6 @@ //! Typed execution plans persisted in the workspace. //! -//! Plans are stored as JSON under `.flok/plans/.json` so they can be +//! Plans are stored under flok's generated per-project state directory so they can be //! reviewed, diffed, resumed, and executed later. use std::collections::{HashMap, HashSet, VecDeque}; @@ -285,7 +285,7 @@ impl PlanStore { } fn plans_dir(&self) -> PathBuf { - self.project_root.join(".flok").join("plans") + crate::config::project_state_dir(&self.project_root).join("plans") } } diff --git a/crates/flok-core/src/snapshot.rs b/crates/flok-core/src/snapshot.rs index d41a803..5ad29e5 100644 --- a/crates/flok-core/src/snapshot.rs +++ b/crates/flok-core/src/snapshot.rs @@ -12,7 +12,7 @@ //! ## Shadow repo location //! //! ```text -//! /flok/snapshot/// +//! ~/.flok/snapshot/// //! ``` //! //! The shadow repo uses `--git-dir` and `--work-tree` flags so it never @@ -89,20 +89,11 @@ impl SnapshotManager { /// `worktree` is the absolute path to the workspace root. /// /// The shadow git repo will be created at: - /// `/flok/snapshot///` + /// `~/.flok/snapshot///` pub fn new(project_id: &str, worktree: PathBuf) -> Self { let worktree_hash = hash_path(&worktree); - let fallback_base = worktree.join(".flok").join("snapshot").join(project_id); - let snapshot_base = if cfg!(test) { - let _ = std::fs::create_dir_all(&fallback_base); - fallback_base - } else { - select_writable_storage_dir( - directories::BaseDirs::new() - .map(|dirs| dirs.data_dir().join("flok").join("snapshot").join(project_id)), - fallback_base, - ) - }; + let snapshot_base = crate::config::flok_state_root().join("snapshot").join(project_id); + let _ = std::fs::create_dir_all(&snapshot_base); let gitdir = snapshot_base.join(worktree_hash); // Only enable if the worktree has a .git directory (is a git project) @@ -639,22 +630,6 @@ impl SnapshotManager { } } -fn select_writable_storage_dir(primary: Option, fallback: PathBuf) -> PathBuf { - if let Some(primary) = primary { - if std::fs::create_dir_all(&primary).is_ok() { - return primary; - } - tracing::warn!( - path = %primary.display(), - fallback = %fallback.display(), - "snapshot storage directory unavailable, using project-local fallback" - ); - } - - let _ = std::fs::create_dir_all(&fallback); - fallback -} - impl std::fmt::Debug for SnapshotManager { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SnapshotManager") diff --git a/crates/flok-core/src/team.rs b/crates/flok-core/src/team.rs index 3e29936..832b334 100644 --- a/crates/flok-core/src/team.rs +++ b/crates/flok-core/src/team.rs @@ -101,7 +101,7 @@ impl TeamStore { } fn teams_dir(&self) -> PathBuf { - self.project_root.join(".flok").join("teams") + crate::config::project_state_dir(&self.project_root).join("teams") } } @@ -364,7 +364,7 @@ impl TeamRegistry { Self { teams: Arc::new(DashMap::new()), store: None } } - /// Create a registry backed by `.flok/teams` under the project root. + /// Create a registry backed by flok's generated per-project state directory. pub fn new_with_project_root(project_root: PathBuf) -> anyhow::Result { let store = Arc::new(TeamStore::new(project_root)); let registry = Self { teams: Arc::new(DashMap::new()), store: Some(Arc::clone(&store)) }; @@ -583,7 +583,9 @@ mod tests { let registry = TeamRegistry::new_with_project_root(temp.path().to_path_buf()).unwrap(); let team = registry.create_team("ephemeral").unwrap(); let team_id = team.id.clone(); - let team_path = temp.path().join(".flok").join("teams").join(format!("{team_id}.json")); + let team_path = crate::config::project_state_dir(temp.path()) + .join("teams") + .join(format!("{team_id}.json")); assert!(team_path.exists()); assert!(registry.delete(&team_id).unwrap()); diff --git a/crates/flok-core/src/tool/memory.rs b/crates/flok-core/src/tool/memory.rs index 811869b..1cda0b2 100644 --- a/crates/flok-core/src/tool/memory.rs +++ b/crates/flok-core/src/tool/memory.rs @@ -1,7 +1,7 @@ //! The `agent_memory` tool — read/write persistent per-agent memory. //! -//! Memory is stored in `.flok/memory/.md` files. Each agent has -//! independent memory scoped to the project. +//! Memory is stored under flok's generated per-project state directory. Each +//! agent has independent memory scoped to the project. use std::path::PathBuf; @@ -49,7 +49,7 @@ impl Tool for AgentMemoryTool { .as_str() .ok_or_else(|| anyhow::anyhow!("missing required parameter: operation"))?; - let memory_dir = ctx.project_root.join(".flok").join("memory"); + let memory_dir = crate::config::project_state_dir(&ctx.project_root).join("memory"); let memory_file = memory_dir.join(format!("{}.md", ctx.agent)); match operation { diff --git a/crates/flok-core/src/tool/plan.rs b/crates/flok-core/src/tool/plan.rs index 9f6fe61..89ae367 100644 --- a/crates/flok-core/src/tool/plan.rs +++ b/crates/flok-core/src/tool/plan.rs @@ -1,11 +1,11 @@ //! The `plan` tool — writes structured plan output. //! //! Available in plan mode only. Writes a structured markdown plan -//! to `.flok/plan.md` in the project root. +//! under flok's generated per-project state directory. use super::{Tool, ToolContext, ToolOutput}; -/// Write a structured plan to `.flok/plan.md`. +/// Write a structured plan to flok's generated state directory. pub struct PlanTool; #[async_trait::async_trait] @@ -15,7 +15,7 @@ impl Tool for PlanTool { } fn description(&self) -> &'static str { - "Write a structured plan to .flok/plan.md. Use this in plan mode to document \ + "Write a structured plan to flok's generated state directory. Use this in plan mode to document \ your analysis and proposed changes before switching to build mode." } @@ -37,12 +37,12 @@ impl Tool for PlanTool { } fn permission_level(&self) -> super::PermissionLevel { - // Plan tool is safe — it only writes to .flok/plan.md + // Plan tool is safe — it only writes to flok-managed internal state. super::PermissionLevel::Safe } fn describe_invocation(&self, _args: &serde_json::Value) -> String { - "plan: write to .flok/plan.md".to_string() + "plan: write to flok internal plan state".to_string() } async fn execute( @@ -55,7 +55,7 @@ impl Tool for PlanTool { .ok_or_else(|| anyhow::anyhow!("missing required parameter: content"))?; let append = args["append"].as_bool().unwrap_or(false); - let plan_dir = ctx.project_root.join(".flok"); + let plan_dir = crate::config::project_state_dir(&ctx.project_root); let plan_file = plan_dir.join("plan.md"); tokio::fs::create_dir_all(&plan_dir).await?; @@ -72,13 +72,13 @@ impl Tool for PlanTool { existing.push_str(content); tokio::fs::write(&plan_file, &existing).await?; Ok(ToolOutput::success(format!( - "Plan appended to .flok/plan.md ({} chars total)", + "Plan appended to internal plan state ({} chars total)", existing.len() ))) } else { tokio::fs::write(&plan_file, content).await?; Ok(ToolOutput::success(format!( - "Plan written to .flok/plan.md ({} chars)", + "Plan written to internal plan state ({} chars)", content.len() ))) } @@ -106,7 +106,9 @@ mod tests { .unwrap(); assert!(!result.is_error); - let content = std::fs::read_to_string(dir.path().join(".flok/plan.md")).unwrap(); + let content = + std::fs::read_to_string(crate::config::project_state_dir(dir.path()).join("plan.md")) + .unwrap(); assert!(content.contains("# Plan")); } @@ -119,7 +121,9 @@ mod tests { tool.execute(serde_json::json!({"content": "Step 1"}), &ctx).await.unwrap(); tool.execute(serde_json::json!({"content": "Step 2", "append": true}), &ctx).await.unwrap(); - let content = std::fs::read_to_string(dir.path().join(".flok/plan.md")).unwrap(); + let content = + std::fs::read_to_string(crate::config::project_state_dir(dir.path()).join("plan.md")) + .unwrap(); assert!(content.contains("Step 1")); assert!(content.contains("Step 2")); } diff --git a/crates/flok-core/src/tool/plan_tools.rs b/crates/flok-core/src/tool/plan_tools.rs index a7eb2d1..3a93b8c 100644 --- a/crates/flok-core/src/tool/plan_tools.rs +++ b/crates/flok-core/src/tool/plan_tools.rs @@ -11,7 +11,7 @@ use crate::plan::{ use super::{Tool, ToolContext, ToolOutput}; -/// Create a structured execution plan persisted to `.flok/plans/.json`. +/// Create a structured execution plan persisted to flok's generated state directory. pub struct PlanCreateTool; /// Update plan-level or step-level status for an existing plan. @@ -63,7 +63,7 @@ impl Tool for PlanCreateTool { } fn description(&self) -> &'static str { - "Create a typed execution plan and persist it under .flok/plans/.json. \ + "Create a typed execution plan and persist it under flok's generated per-project state directory. \ Use this in plan mode when a task is complex enough to require explicit steps, \ dependencies, and later approval/execution." } @@ -295,7 +295,7 @@ mod tests { assert!(!result.is_error); assert!(result.content.contains("Plan file:")); - assert!(ctx.project_root.join(".flok/plans").exists()); + assert!(crate::config::project_state_dir(&ctx.project_root).join("plans").exists()); } #[tokio::test] diff --git a/crates/flok-core/src/worktree.rs b/crates/flok-core/src/worktree.rs index b239cfe..64981c7 100644 --- a/crates/flok-core/src/worktree.rs +++ b/crates/flok-core/src/worktree.rs @@ -54,7 +54,7 @@ pub enum MergeResult { pub struct WorktreeManager { /// The main project root (where `.git` lives). project_root: PathBuf, - /// Base directory for worktrees: `$XDG_STATE_HOME/flok/worktrees/{project_id}/`. + /// Base directory for worktrees: `~/.flok/worktrees/{project_id}/`. worktree_base: PathBuf, /// Serializes merge operations to prevent concurrent merges. merge_lock: Mutex<()>, @@ -65,21 +65,12 @@ pub struct WorktreeManager { impl WorktreeManager { /// Create a new worktree manager. /// - /// Worktrees are stored under `$XDG_STATE_HOME/flok/worktrees/{project_id}/`. + /// Worktrees are stored under `~/.flok/worktrees/{project_id}/`. /// Disabled if the project root does not contain a `.git` directory. pub fn new(project_id: &str, project_root: PathBuf) -> Self { let enabled = project_root.join(".git").exists(); - let fallback_base = project_root.join(".flok").join("worktrees").join(project_id); - let state_base = if cfg!(test) { - let _ = std::fs::create_dir_all(&fallback_base); - fallback_base - } else { - select_writable_storage_dir( - directories::BaseDirs::new() - .map(|dirs| dirs.data_dir().join("flok").join("worktrees").join(project_id)), - fallback_base, - ) - }; + let state_base = crate::config::flok_state_root().join("worktrees").join(project_id); + let _ = std::fs::create_dir_all(&state_base); Self { project_root, worktree_base: state_base, merge_lock: Mutex::new(()), enabled } } @@ -328,22 +319,6 @@ impl WorktreeManager { } } -fn select_writable_storage_dir(primary: Option, fallback: PathBuf) -> PathBuf { - if let Some(primary) = primary { - if std::fs::create_dir_all(&primary).is_ok() { - return primary; - } - tracing::warn!( - path = %primary.display(), - fallback = %fallback.display(), - "worktree storage directory unavailable, using project-local fallback" - ); - } - - let _ = std::fs::create_dir_all(&fallback); - fallback -} - impl std::fmt::Debug for WorktreeManager { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("WorktreeManager")