diff --git a/Cargo.lock b/Cargo.lock index 541855a43..69ff0e7b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2424,7 +2424,7 @@ dependencies = [ [[package]] name = "houston-agent-files" -version = "0.4.17" +version = "0.4.18" dependencies = [ "chrono", "serde", @@ -2438,7 +2438,7 @@ dependencies = [ [[package]] name = "houston-agent-portable" -version = "0.4.17" +version = "0.4.18" dependencies = [ "chrono", "serde", @@ -2451,7 +2451,7 @@ dependencies = [ [[package]] name = "houston-agents-conversations" -version = "0.4.17" +version = "0.4.18" dependencies = [ "houston-db", "houston-terminal-manager", @@ -2465,7 +2465,7 @@ dependencies = [ [[package]] name = "houston-app" -version = "0.4.17" +version = "0.4.18" dependencies = [ "chrono", "dirs 5.0.1", @@ -2487,6 +2487,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-winrt-notification", + "tempfile", "tokio", "tracing", "tracing-appender", @@ -2496,7 +2497,7 @@ dependencies = [ [[package]] name = "houston-claude-installer" -version = "0.4.17" +version = "0.4.18" dependencies = [ "futures-util", "hex", @@ -2516,7 +2517,7 @@ dependencies = [ [[package]] name = "houston-cli-bundle" -version = "0.4.17" +version = "0.4.18" dependencies = [ "serde", "serde_json", @@ -2526,7 +2527,7 @@ dependencies = [ [[package]] name = "houston-composio" -version = "0.4.17" +version = "0.4.18" dependencies = [ "base64 0.22.1", "dirs 5.0.1", @@ -2544,7 +2545,7 @@ dependencies = [ [[package]] name = "houston-db" -version = "0.4.17" +version = "0.4.18" dependencies = [ "anyhow", "chrono", @@ -2557,7 +2558,7 @@ dependencies = [ [[package]] name = "houston-engine-core" -version = "0.4.17" +version = "0.4.18" dependencies = [ "async-trait", "base64 0.22.1", @@ -2593,7 +2594,7 @@ dependencies = [ [[package]] name = "houston-engine-protocol" -version = "0.4.17" +version = "0.4.18" dependencies = [ "chrono", "houston-terminal-manager", @@ -2605,7 +2606,7 @@ dependencies = [ [[package]] name = "houston-engine-server" -version = "0.4.17" +version = "0.4.18" dependencies = [ "anyhow", "async-trait", @@ -2627,6 +2628,8 @@ dependencies = [ "houston-ui-events", "rand 0.8.5", "reqwest 0.12.28", + "sentry", + "sentry-tracing", "serde", "serde_json", "sha2", @@ -2643,7 +2646,7 @@ dependencies = [ [[package]] name = "houston-events" -version = "0.4.17" +version = "0.4.18" dependencies = [ "anyhow", "async-trait", @@ -2657,7 +2660,7 @@ dependencies = [ [[package]] name = "houston-file-watcher" -version = "0.4.17" +version = "0.4.18" dependencies = [ "houston-ui-events", "notify", @@ -2668,7 +2671,7 @@ dependencies = [ [[package]] name = "houston-scheduler" -version = "0.4.17" +version = "0.4.18" dependencies = [ "anyhow", "chrono", @@ -2682,7 +2685,7 @@ dependencies = [ [[package]] name = "houston-skills" -version = "0.4.17" +version = "0.4.18" dependencies = [ "chrono", "reqwest 0.12.28", @@ -2698,7 +2701,7 @@ dependencies = [ [[package]] name = "houston-tauri" -version = "0.4.17" +version = "0.4.18" dependencies = [ "dirs 5.0.1", "houston-agent-files", @@ -2717,7 +2720,7 @@ dependencies = [ [[package]] name = "houston-terminal-manager" -version = "0.4.17" +version = "0.4.18" dependencies = [ "dirs 5.0.1", "houston-cli-bundle", @@ -2730,7 +2733,7 @@ dependencies = [ [[package]] name = "houston-tunnel" -version = "0.4.17" +version = "0.4.18" dependencies = [ "anyhow", "async-trait", @@ -2750,7 +2753,7 @@ dependencies = [ [[package]] name = "houston-ui-events" -version = "0.4.17" +version = "0.4.18" dependencies = [ "houston-terminal-manager", "serde", @@ -5520,6 +5523,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -5826,6 +5830,7 @@ dependencies = [ "httpdate", "native-tls", "reqwest 0.12.28", + "rustls 0.23.37", "sentry-actix", "sentry-backtrace", "sentry-contexts", @@ -7647,10 +7652,12 @@ dependencies = [ "log", "native-tls", "percent-encoding", + "rustls 0.23.37", "rustls-pki-types", "ureq-proto", "utf8-zero", "webpki-root-certs", + "webpki-roots 1.0.6", ] [[package]] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 8f1ca8a41..ddde7cd5e 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -77,6 +77,9 @@ notify-rust = "4.11" # file under `%LOCALAPPDATA%\com.houston.app\auth\.dpapi` instead. # DPAPI binds the ciphertext to the Windows user account so a copied # file is useless to another user. See `src/auth.rs` storage module. +[dev-dependencies] +tempfile = "3" + [target."cfg(target_os = \"windows\")".dependencies] windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_System_JobObjects", "Win32_System_Threading"] } # Same reason as notify-rust on Linux, but notify-rust's action handling is diff --git a/app/src-tauri/src/app_config.rs b/app/src-tauri/src/app_config.rs new file mode 100644 index 000000000..41bfcb43c --- /dev/null +++ b/app/src-tauri/src/app_config.rs @@ -0,0 +1,360 @@ +//! Houston app-level configuration persisted at `~/.houston/app-config.json`. +//! +//! Read by the Tauri boot BEFORE the engine subprocess is spawned, so the +//! user's chosen workspace-root location (`docsRoot`) can be injected as +//! `HOUSTON_DOCS`. Absent or empty `docsRoot` keeps the historical default +//! (`/workspaces`, i.e. `~/.houston/workspaces/`), so existing installs +//! behave exactly as before until the user opts into a visible, git-backed +//! root via Settings. +//! +//! A workspace-location *change* is staged here (`migrateFrom`) and applied at +//! the next boot, BEFORE the engine starts — so the move never races a live +//! engine that is still reading/writing the old root. + +use serde::{Deserialize, Serialize}; +use std::ffi::OsStr; +use std::io; +use std::path::{Path, PathBuf}; + +const FILE_NAME: &str = "app-config.json"; +const MANIFEST: &str = "workspaces.json"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppConfig { + /// Absolute path the user chose as their visible, git-backed Houston root. + /// `None`/empty → default `/workspaces`. Stored already-resolved + /// (absolute) so the app (migrate) and engine (resolve) never disagree. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docs_root: Option, + /// Set by `set_docs_root` when the user changes the location. The next boot + /// moves the tree FROM this path INTO `docs_root` before the engine starts, + /// then clears it. Absolute path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub migrate_from: Option, +} + +fn config_path(houston: &Path) -> PathBuf { + houston.join(FILE_NAME) +} + +/// The one tilde-expander used on the docs-root path. Delegates to the shared +/// `houston_tauri::paths::expand_tilde` so the app (migrate) and the engine +/// (resolve) never diverge on what a stored `docsRoot` means. +pub fn expand_path(path: &str) -> PathBuf { + houston_tauri::paths::expand_tilde(&PathBuf::from(path)) +} + +/// Load app config. A missing file is the normal first-run case → default. +/// A corrupt file is logged and treated as default rather than crashing boot: +/// there is no UI thread to toast on this early, and a broken config must never +/// wedge startup. +pub fn load(houston: &Path) -> AppConfig { + let path = config_path(houston); + let contents = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == io::ErrorKind::NotFound => return AppConfig::default(), + Err(e) => { + tracing::warn!("[app-config] read {} failed: {e}", path.display()); + return AppConfig::default(); + } + }; + match serde_json::from_str(&contents) { + Ok(cfg) => cfg, + Err(e) => { + tracing::warn!( + "[app-config] parse {} failed: {e}; using defaults", + path.display() + ); + AppConfig::default() + } + } +} + +/// Persist app config atomically (temp + rename), matching the Houston +/// file-write convention. +pub fn save(houston: &Path, cfg: &AppConfig) -> io::Result<()> { + std::fs::create_dir_all(houston)?; + let path = config_path(houston); + let json = serde_json::to_string_pretty(cfg) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let tmp = path.with_extension(format!("json.tmp-{}", std::process::id())); + std::fs::write(&tmp, json.as_bytes())?; + std::fs::rename(&tmp, &path)?; + Ok(()) +} + +/// Resolve the workspace-root (`docs`) directory the engine should use. +/// `docsRoot` from config when set + non-empty (tilde-expanded), else the +/// historical default `/workspaces`. +pub fn resolve_docs_dir(houston: &Path, cfg: &AppConfig) -> PathBuf { + match cfg + .docs_root + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + Some(root) => expand_path(root), + None => houston.join("workspaces"), + } +} + +/// True when two paths resolve to the same location (canonicalizing when both +/// exist; lexical fallback otherwise). +pub fn paths_equal(a: &Path, b: &Path) -> bool { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} + +/// True when either path is an ancestor of (or equal to) the other — +/// moving a tree into a location nested within itself would tear it. +pub fn paths_overlap(a: &Path, b: &Path) -> bool { + let ca = std::fs::canonicalize(a).unwrap_or_else(|_| a.to_path_buf()); + let cb = std::fs::canonicalize(b).unwrap_or_else(|_| b.to_path_buf()); + ca.starts_with(&cb) || cb.starts_with(&ca) +} + +/// Move an existing workspace tree from `old_root` to `new_root`. +/// +/// EXDEV-safe, no-loss, and resumable: +/// - per-entry `rename`, falling back to recursive copy across filesystems +/// (external drive, network mount, iCloud) — `rename` fails `EXDEV` there; +/// - a source entry is removed only AFTER it is fully written to the +/// destination, so a crash can tear the tree but never lose data; +/// - the index `workspaces.json` is moved LAST, so an interrupted run leaves +/// the source still "migratable" and a retry resumes where it left off. +/// +/// Idempotent + non-clobbering: no-op when `old_root` has no manifest or when +/// `new_root` already has one. Returns `Ok(true)` when anything moved. +pub fn migrate_docs_root(old_root: &Path, new_root: &Path) -> io::Result { + if paths_equal(old_root, new_root) { + return Ok(false); + } + if !old_root.join(MANIFEST).is_file() { + return Ok(false); // nothing to migrate + } + if new_root.join(MANIFEST).is_file() { + return Ok(false); // target already populated — never clobber + } + std::fs::create_dir_all(new_root)?; + + // Move the manifest LAST so the "source still has a manifest" guard above + // stays true across a partial run, making retries resume cleanly. + let mut entries: Vec = std::fs::read_dir(old_root)? + .filter_map(|e| e.ok().map(|e| e.path())) + .collect(); + entries.sort_by_key(|p| p.file_name() == Some(OsStr::new(MANIFEST))); + + let mut moved = false; + for src in entries { + let name = match src.file_name() { + Some(n) => n.to_owned(), + None => continue, + }; + let dst = new_root.join(&name); + if dst.exists() { + tracing::warn!( + "[migrate] left {:?} in place: already exists at destination", + name + ); + continue; + } + move_entry(&src, &dst)?; + moved = true; + } + Ok(moved) +} + +/// Move one entry, falling back to copy+remove across filesystems. The source +/// is removed only after the destination is fully written, so a failure leaves +/// the source intact (any partial destination copy is cleaned up). +fn move_entry(src: &Path, dst: &Path) -> io::Result<()> { + match std::fs::rename(src, dst) { + Ok(()) => Ok(()), + Err(e) if is_cross_device(&e) => { + if let Err(copy_err) = copy_recursive(src, dst) { + // Best-effort cleanup of the partial copy; source stays intact. + let _ = std::fs::remove_dir_all(dst); + let _ = std::fs::remove_file(dst); + return Err(copy_err); + } + if src.is_dir() { + std::fs::remove_dir_all(src) + } else { + std::fs::remove_file(src) + } + } + Err(e) => Err(e), + } +} + +/// EXDEV on Unix is raw errno 18; Windows `ERROR_NOT_SAME_DEVICE` is 17. +/// Matching the raw code avoids depending on the unstable +/// `io::ErrorKind::CrossesDevices`. +fn is_cross_device(e: &io::Error) -> bool { + matches!(e.raw_os_error(), Some(18) | Some(17)) +} + +fn copy_recursive(src: &Path, dst: &Path) -> io::Result<()> { + if src.is_dir() { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + copy_recursive(&entry.path(), &dst.join(entry.file_name()))?; + } + Ok(()) + } else { + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(src, dst).map(|_| ()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn absent_config_resolves_to_default() { + let d = TempDir::new().unwrap(); + let cfg = load(d.path()); + assert!(cfg.docs_root.is_none()); + assert_eq!(resolve_docs_dir(d.path(), &cfg), d.path().join("workspaces")); + } + + #[test] + fn save_then_load_roundtrip() { + let d = TempDir::new().unwrap(); + let cfg = AppConfig { + docs_root: Some("/abs/Houston".into()), + migrate_from: None, + }; + save(d.path(), &cfg).unwrap(); + let got = load(d.path()); + assert_eq!(got, cfg); + assert_eq!( + resolve_docs_dir(d.path(), &got), + PathBuf::from("/abs/Houston") + ); + } + + #[test] + fn empty_or_whitespace_docs_root_falls_back_to_default() { + let d = TempDir::new().unwrap(); + let cfg = AppConfig { + docs_root: Some(" ".into()), + migrate_from: None, + }; + assert_eq!(resolve_docs_dir(d.path(), &cfg), d.path().join("workspaces")); + } + + #[test] + fn corrupt_config_is_default_not_panic() { + let d = TempDir::new().unwrap(); + std::fs::write(d.path().join(FILE_NAME), "{not json").unwrap(); + assert_eq!(load(d.path()), AppConfig::default()); + } + + #[test] + fn migrate_from_roundtrips_and_skips_when_none() { + let d = TempDir::new().unwrap(); + // `migrate_from` absent → not serialized (additive, no key churn). + save(d.path(), &AppConfig { docs_root: Some("/x".into()), migrate_from: None }).unwrap(); + let raw = std::fs::read_to_string(d.path().join(FILE_NAME)).unwrap(); + assert!(!raw.contains("migrateFrom")); + // present → roundtrips. + save( + d.path(), + &AppConfig { docs_root: Some("/x".into()), migrate_from: Some("/old".into()) }, + ) + .unwrap(); + assert_eq!(load(d.path()).migrate_from.as_deref(), Some("/old")); + } + + #[test] + fn migrate_moves_tree_when_target_empty() { + let tmp = TempDir::new().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("Houston"); + std::fs::create_dir_all(old.join("Work/Agent")).unwrap(); + std::fs::write(old.join(MANIFEST), "[]").unwrap(); + std::fs::write(old.join("Work/Agent/CLAUDE.md"), "x").unwrap(); + + assert!(migrate_docs_root(&old, &new).unwrap()); + assert!(new.join(MANIFEST).is_file()); + assert!(new.join("Work/Agent/CLAUDE.md").is_file()); + // Source fully drained. + assert!(!old.join(MANIFEST).exists()); + // Second call is a no-op (old no longer has a manifest). + assert!(!migrate_docs_root(&old, &new).unwrap()); + } + + #[test] + fn migrate_noop_without_source_manifest() { + let tmp = TempDir::new().unwrap(); + let old = tmp.path().join("old"); + std::fs::create_dir_all(&old).unwrap(); + assert!(!migrate_docs_root(&old, &tmp.path().join("new")).unwrap()); + } + + #[test] + fn migrate_never_clobbers_populated_target() { + let tmp = TempDir::new().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + std::fs::create_dir_all(&old).unwrap(); + std::fs::create_dir_all(&new).unwrap(); + std::fs::write(old.join(MANIFEST), "[\"old\"]").unwrap(); + std::fs::write(new.join(MANIFEST), "[\"new\"]").unwrap(); + assert!(!migrate_docs_root(&old, &new).unwrap()); + assert_eq!( + std::fs::read_to_string(new.join(MANIFEST)).unwrap(), + "[\"new\"]" + ); + } + + #[test] + fn migrate_noop_when_equal() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("Houston"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join(MANIFEST), "[]").unwrap(); + assert!(!migrate_docs_root(&root, &root).unwrap()); + assert!(root.join(MANIFEST).is_file()); + } + + #[test] + fn migrate_resumes_after_partial() { + // Simulate an interrupted run: one agent dir already at the destination, + // manifest still at source. A re-run must finish without clobbering and + // without losing the already-moved dir. + let tmp = TempDir::new().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + std::fs::create_dir_all(old.join("A")).unwrap(); + std::fs::create_dir_all(old.join("B")).unwrap(); + std::fs::write(old.join(MANIFEST), "[]").unwrap(); + std::fs::create_dir_all(new.join("A")).unwrap(); // already moved + std::fs::write(new.join("A/keep"), "1").unwrap(); + + assert!(migrate_docs_root(&old, &new).unwrap()); + assert!(new.join("A/keep").is_file()); // pre-moved dir untouched + assert!(new.join("B").is_dir()); // B finished + assert!(new.join(MANIFEST).is_file()); + } + + #[test] + fn paths_overlap_detects_nesting() { + let tmp = TempDir::new().unwrap(); + let a = tmp.path().join("root"); + let b = a.join("inner"); + std::fs::create_dir_all(&b).unwrap(); + assert!(paths_overlap(&a, &b)); + assert!(paths_overlap(&b, &a)); + assert!(!paths_overlap(&a, &tmp.path().join("sibling"))); + } +} diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 9a43857f3..50001280f 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -5,3 +5,4 @@ pub mod diagnostics; pub mod portable; pub mod terminal; pub mod update; +pub mod workspace_root; diff --git a/app/src-tauri/src/commands/workspace_root.rs b/app/src-tauri/src/commands/workspace_root.rs new file mode 100644 index 000000000..d806d59bb --- /dev/null +++ b/app/src-tauri/src/commands/workspace_root.rs @@ -0,0 +1,70 @@ +//! Workspace-root selection — view + change the user-visible, git-backed +//! Houston root (`docsRoot`). +//! +//! Changing the location does NOT move data inline — migrating a tree while the +//! engine is live (file watcher, scheduler, in-flight sessions all reading the +//! old root) would tear it. Instead `set_docs_root` validates + persists the +//! new absolute path and stages a `migrateFrom`; the next boot performs the +//! move BEFORE the engine starts and clears the flag. The caller prompts the +//! user to restart. + +use std::path::PathBuf; + +fn houston() -> PathBuf { + houston_tauri::houston_db::db::houston_dir() +} + +/// The workspace-root directory currently in effect — resolved from app-config, +/// defaulting to `~/.houston/workspaces` when the user has not chosen a root. +#[tauri::command(rename_all = "snake_case")] +pub fn get_docs_root() -> Result { + let h = houston(); + let cfg = crate::app_config::load(&h); + Ok(crate::app_config::resolve_docs_dir(&h, &cfg) + .to_string_lossy() + .into_owned()) +} + +/// Validate + persist a new workspace root and stage a boot-time migration. +/// The new location takes effect on the next launch, so the caller must prompt +/// the user to restart Houston. +#[tauri::command(rename_all = "snake_case")] +pub fn set_docs_root(new_root: String) -> Result<(), String> { + let trimmed = new_root.trim(); + if trimmed.is_empty() { + return Err("Workspace location cannot be empty".into()); + } + + let h = houston(); + let mut cfg = crate::app_config::load(&h); + let old = crate::app_config::resolve_docs_dir(&h, &cfg); + + // Resolve to a single absolute path NOW and persist that, so the engine's + // resolver and the migrator never re-expand the string differently. + let new = crate::app_config::expand_path(trimmed); + if !new.is_absolute() { + return Err("Workspace location must be an absolute path".into()); + } + if new.is_file() { + return Err("Workspace location must be a folder, not a file".into()); + } + + let new_abs = new.to_string_lossy().into_owned(); + + // No change → persist the explicit (absolute) choice, clear any stale + // migration, and stop. + if crate::app_config::paths_equal(&new, &old) { + cfg.docs_root = Some(new_abs); + cfg.migrate_from = None; + return crate::app_config::save(&h, &cfg).map_err(|e| e.to_string()); + } + + // A move between nested locations would move a tree into itself. + if crate::app_config::paths_overlap(&new, &old) { + return Err("New location cannot be inside the current one (or vice versa)".into()); + } + + cfg.docs_root = Some(new_abs); + cfg.migrate_from = Some(old.to_string_lossy().into_owned()); + crate::app_config::save(&h, &cfg).map_err(|e| e.to_string()) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 1c34c8c35..6ad76a1f3 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod app_config; mod auth; mod bug_report; mod commands; @@ -191,6 +192,38 @@ pub fn run() { houston_tauri::houston_terminal_manager::claude_path::init(); let houston = houston_tauri::houston_db::db::houston_dir(); + // Resolve the workspace-root (`docs`) directory before anything + // reads it. From `~/.houston/app-config.json` (`docsRoot`) when the + // user picked a visible, git-backed root, else the historical + // `$HOUSTON_HOME/workspaces/` default. + let mut app_cfg = app_config::load(&houston); + let docs_dir = app_config::resolve_docs_dir(&houston, &app_cfg); + + // Apply a pending workspace-location change BEFORE the engine + // starts, so the move never races a live engine (file watcher, + // scheduler, in-flight sessions) still reading the old root. On + // failure `migrateFrom` stays set to retry next launch; the + // migrator leaves the source intact, so no data is lost. + if let Some(from) = app_cfg.migrate_from.clone() { + let from_path = app_config::expand_path(&from); + match app_config::migrate_docs_root(&from_path, &docs_dir) { + Ok(moved) => { + tracing::info!( + "[migrate] docs-root {} -> {} (moved={moved})", + from_path.display(), + docs_dir.display() + ); + app_cfg.migrate_from = None; + if let Err(e) = app_config::save(&houston, &app_cfg) { + tracing::warn!("[migrate] failed to clear migrateFrom: {e}"); + } + } + Err(e) => tracing::error!( + "[migrate] docs-root move failed (retrying next launch): {e}" + ), + } + } + let db_path = houston.join("db").join("houston.db"); let db = tauri::async_runtime::block_on(async { Database::connect(&db_path) @@ -203,7 +236,7 @@ pub fn run() { // so everything Houston owns is under a single discoverable root. // Move the legacy directory if it exists and the new location is // empty. Idempotent on subsequent launches. - migrate_legacy_docs_dir(&houston); + migrate_legacy_docs_dir(&docs_dir); // Eagerly run the intra-agent data-layout migration on every // agent the user already has. Previously this only fired the @@ -212,7 +245,7 @@ pub fn run() { // empty board even though their `.houston/activity.json` was // sitting next to it. Walk the workspaces dir here so existing // activities, routines, learnings show up immediately. - migrate_all_agents(&houston.join("workspaces")); + migrate_all_agents(&docs_dir); // AppState keeps a DB handle for any OS-native lookup (log // reading, session search). Domain state now lives in the @@ -240,12 +273,10 @@ pub fn run() { // exported to the engine via env vars. The engine treats these // as opaque strings — it has no hardcoded Houston copy. // - // Also pin HOUSTON_HOME + HOUSTON_DOCS so the engine uses the - // same data roots as the app. Workspaces live under - // `$HOUSTON_HOME/workspaces/` in both debug (`~/.dev-houston/`) - // and release (`~/.houston/`) — everything Houston writes is - // rooted at a single discoverable location. - let docs_dir = houston.join("workspaces"); + // Also pin HOUSTON_HOME + HOUSTON_DOCS so the engine uses the same + // data roots as the app. `docs_dir` was resolved above from + // app-config (`docsRoot`); it defaults to `$HOUSTON_HOME/workspaces/` + // until the user opts into a visible root via onboarding. let mut engine_env: Vec<(String, String)> = vec![ ( "HOUSTON_APP_SYSTEM_PROMPT".into(), @@ -382,6 +413,8 @@ pub fn run() { commands::os::reveal_path, commands::terminal::open_terminal, commands::os::check_claude_cli, + commands::workspace_root::get_docs_root, + commands::workspace_root::set_docs_root, commands::portable::save_portable_agent, commands::portable::open_portable_agent, commands::update::current_app_bundle_path, @@ -504,13 +537,12 @@ fn migrate_all_agents(workspaces_root: &std::path::Path) { /// first v0.4.2+ boot for anyone who previously ran v0.3.x/v0.4.0–v0.4.1. /// On any error we log + bail; the engine will still run against the new /// empty path. Original legacy dir is left in place as manual rollback. -fn migrate_legacy_docs_dir(houston: &std::path::Path) { +fn migrate_legacy_docs_dir(new_root: &std::path::Path) { let home = match dirs::home_dir() { Some(h) => h, None => return, }; let legacy = home.join("Documents").join("Houston"); - let new_root = houston.join("workspaces"); let legacy_manifest = legacy.join("workspaces.json"); if !legacy_manifest.is_file() { diff --git a/app/src/components/settings/sections/advanced.tsx b/app/src/components/settings/sections/advanced.tsx new file mode 100644 index 000000000..342fc2ed8 --- /dev/null +++ b/app/src/components/settings/sections/advanced.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Code2, FolderOpen } from "lucide-react"; +import { Button } from "@houston-ai/core"; +import { useUIStore } from "../../../stores/ui"; +import { useDeveloperMode } from "../../../hooks/use-developer-mode"; +import { + osGetDocsRoot, + osPickDirectory, + osRevealPath, + osSetDocsRoot, +} from "../../../lib/os-bridge"; + +/** + * Advanced settings. Houston is invisible-substrate by default; the developer + * mode toggle reveals the technical surfaces (files, git, the workspace-root + * location) for power users. Lives in the Workspace settings tab. + */ +export function AdvancedSection() { + const { t } = useTranslation("settings"); + const addToast = useUIStore((s) => s.addToast); + const { enabled, setEnabled } = useDeveloperMode(); + const [docsRoot, setDocsRoot] = useState(""); + + useEffect(() => { + osGetDocsRoot() + .then(setDocsRoot) + .catch((err) => + addToast({ + title: t("advanced.workspaceLocation.loadFailed"), + description: err instanceof Error ? err.message : String(err), + variant: "error", + }), + ); + }, []); + + const handleToggle = async () => { + try { + await setEnabled(!enabled); + } catch (err) { + addToast({ + title: t("advanced.toggleFailed"), + description: err instanceof Error ? err.message : String(err), + variant: "error", + }); + } + }; + + const handleChangeLocation = async () => { + try { + const picked = await osPickDirectory(); + if (!picked) return; + await osSetDocsRoot(picked); + setDocsRoot(picked); + addToast({ + title: t("advanced.workspaceLocation.changed"), + description: t("advanced.workspaceLocation.restartHint"), + }); + } catch (err) { + addToast({ + title: t("advanced.workspaceLocation.changeFailed"), + description: err instanceof Error ? err.message : String(err), + variant: "error", + }); + } + }; + + return ( +
+

{t("advanced.title")}

+ + {/* Developer mode toggle */} +
+
+
+ + {t("advanced.developerMode.label")} +
+

+ {t("advanced.developerMode.description")} +

+
+ +
+ + {/* Workspace location — only meaningful in developer mode */} + {enabled && ( +
+
+ + {t("advanced.workspaceLocation.label")} +
+

+ {t("advanced.workspaceLocation.description")} +

+
+ + +
+
+ )} +
+ ); +} diff --git a/app/src/components/settings/settings-view.tsx b/app/src/components/settings/settings-view.tsx index 158702279..e9aab0d93 100644 --- a/app/src/components/settings/settings-view.tsx +++ b/app/src/components/settings/settings-view.tsx @@ -32,6 +32,7 @@ import { AppearanceSection } from "./sections/appearance"; import { DangerSection } from "./sections/danger"; import { ReportBugSection } from "./sections/report-bug"; import { ShortcutsSection } from "./sections/shortcuts"; +import { AdvancedSection } from "./sections/advanced"; export function SettingsView() { const { t } = useTranslation(["settings", "common"]); @@ -123,6 +124,7 @@ export function SettingsView() { + )} diff --git a/app/src/hooks/use-developer-mode.ts b/app/src/hooks/use-developer-mode.ts new file mode 100644 index 000000000..3c49a2846 --- /dev/null +++ b/app/src/hooks/use-developer-mode.ts @@ -0,0 +1,44 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { tauriPreferences } from "../lib/tauri"; + +/** + * Developer mode — the master "advanced" toggle. Off by default, so the + * non-technical product is unchanged. When on, technical surfaces (files, + * folders, git history, the workspace-root location, …) become visible. + * + * Persisted as the engine preference `developer_mode` (string `"true"`/ + * `"false"`), the same KV channel `theme` / `locale` use. + */ +const DEVELOPER_MODE_KEY = "developer_mode"; +const queryKey = ["preference", DEVELOPER_MODE_KEY] as const; + +export interface DeveloperModeState { + enabled: boolean; + isLoading: boolean; + setEnabled: (enabled: boolean) => Promise; +} + +export function useDeveloperMode(): DeveloperModeState { + const qc = useQueryClient(); + + const query = useQuery({ + queryKey, + queryFn: async () => (await tauriPreferences.get(DEVELOPER_MODE_KEY)) === "true", + staleTime: 30_000, + }); + + const mutation = useMutation({ + mutationFn: async (enabled: boolean) => { + await tauriPreferences.set(DEVELOPER_MODE_KEY, enabled ? "true" : "false"); + return enabled; + }, + onSuccess: (enabled) => qc.setQueryData(queryKey, enabled), + }); + + return { + enabled: query.data ?? false, + isLoading: query.isLoading, + setEnabled: (enabled: boolean) => mutation.mutateAsync(enabled), + }; +} diff --git a/app/src/lib/os-bridge.ts b/app/src/lib/os-bridge.ts index 759e94cc1..7f11dc63d 100644 --- a/app/src/lib/os-bridge.ts +++ b/app/src/lib/os-bridge.ts @@ -58,6 +58,16 @@ export function osPickDirectory(): Promise { return invoke("pick_directory"); } +/** The workspace-root directory currently in effect (resolved `docsRoot`). */ +export function osGetDocsRoot(): Promise { + return invoke("get_docs_root"); +} + +/** Persist a new workspace-root location. Takes effect on the next launch. */ +export function osSetDocsRoot(newRoot: string): Promise { + return invoke("set_docs_root", { new_root: newRoot }); +} + /** Open a URL in the user's default browser. */ export function osOpenUrl(url: string): Promise { return invoke("open_url", { url }); diff --git a/app/src/locales/en/settings.json b/app/src/locales/en/settings.json index 68c352e27..411306cfe 100644 --- a/app/src/locales/en/settings.json +++ b/app/src/locales/en/settings.json @@ -74,6 +74,23 @@ "light": "Light", "dark": "Dark" }, + "advanced": { + "title": "Advanced", + "toggleFailed": "Could not change developer mode.", + "developerMode": { + "label": "Developer mode", + "description": "Show files, folders, version history, and other technical details. Off by default." + }, + "workspaceLocation": { + "label": "Workspace location", + "description": "Where Houston keeps everything you and your agents create.", + "change": "Change", + "changed": "Workspace location updated.", + "restartHint": "Restart Houston to apply the new location.", + "changeFailed": "Could not change the workspace location.", + "loadFailed": "Could not read the workspace location." + } + }, "dangerZone": { "title": "Danger zone", "description": "Permanently delete this workspace and all its agents.", diff --git a/app/src/locales/es/settings.json b/app/src/locales/es/settings.json index 63de6a99d..8a530725c 100644 --- a/app/src/locales/es/settings.json +++ b/app/src/locales/es/settings.json @@ -74,6 +74,23 @@ "light": "Claro", "dark": "Oscuro" }, + "advanced": { + "title": "Avanzado", + "toggleFailed": "No se pudo cambiar el modo de desarrollador.", + "developerMode": { + "label": "Modo de desarrollador", + "description": "Muestra archivos, carpetas, historial de versiones y otros detalles técnicos. Desactivado por defecto." + }, + "workspaceLocation": { + "label": "Ubicación del espacio de trabajo", + "description": "Donde Houston guarda todo lo que tú y tus agentes crean.", + "change": "Cambiar", + "changed": "Se actualizó la ubicación del espacio de trabajo.", + "restartHint": "Reinicia Houston para aplicar la nueva ubicación.", + "changeFailed": "No se pudo cambiar la ubicación del espacio de trabajo.", + "loadFailed": "No se pudo leer la ubicación del espacio de trabajo." + } + }, "dangerZone": { "title": "Zona de peligro", "description": "Elimina este espacio de trabajo y todos sus agentes de forma permanente.", diff --git a/app/src/locales/pt/settings.json b/app/src/locales/pt/settings.json index 93ec3275f..b0eca6126 100644 --- a/app/src/locales/pt/settings.json +++ b/app/src/locales/pt/settings.json @@ -74,6 +74,23 @@ "light": "Claro", "dark": "Escuro" }, + "advanced": { + "title": "Avançado", + "toggleFailed": "Não foi possível alterar o modo de desenvolvedor.", + "developerMode": { + "label": "Modo de desenvolvedor", + "description": "Mostra arquivos, pastas, histórico de versões e outros detalhes técnicos. Desativado por padrão." + }, + "workspaceLocation": { + "label": "Local do espaço de trabalho", + "description": "Onde o Houston guarda tudo o que você e seus agentes criam.", + "change": "Alterar", + "changed": "Local do espaço de trabalho atualizado.", + "restartHint": "Reinicie o Houston para aplicar o novo local.", + "changeFailed": "Não foi possível alterar o local do espaço de trabalho.", + "loadFailed": "Não foi possível ler o local do espaço de trabalho." + } + }, "dangerZone": { "title": "Zona de perigo", "description": "Exclui este espaço de trabalho e todos os seus agentes de forma permanente.", diff --git a/docs/agentic-workspace-substrate.html b/docs/agentic-workspace-substrate.html new file mode 100644 index 000000000..b79e4f8be --- /dev/null +++ b/docs/agentic-workspace-substrate.html @@ -0,0 +1,423 @@ + + + + + + +Houston — Agentic Workspace Substrate + + + + +
+
Houston · Design Arc
+

Agentic Workspace Substrate

+

Turn Houston's hidden bag-of-agents into a user-visible, git-backed, bstack-governed workspace that orchestrates multiple projects, runs agents in worktrees, and shares one second-brain knowledge graph — by providing the environment and installing skills, not by building engine features.

+
+ status: proposed — under review + 2026-06-04 + grounded · 5-agent sweep · wf_55e0d214-f5e + canonical spec: agentic-workspace-substrate.md +
+
+ +
+ +

The shift

+

Today a Houston "workspace" is a bag of agents hidden under ~/.houston/workspaces/ — no git, no visibility, no shared context. This arc pulls the workspace out into a named, visible, version-controlled root and makes it autonomous-agentic natively — while the non-technical product stays exactly as it is by default. The substrate (git, worktrees, governance, knowledge graph) runs underneath; an advanced mode reveals it.

+ +
+ + + + + + + + SYSTEM ROOT · HIDDEN · NO GIT + ~/.houston/ + how Houston runs · disposable + + engine.json · houston.db (chat_feed, prefs) + logs/ · installed agent definitions (agents/) + bundled CLIs · tunnel id + app-config.json ← new: root path the boot reads + + never shown to the user · regenerated on reinstall + + + + WORKSPACE ROOT · VISIBLE · GIT + ~/Houston/ + what you + your agents make · precious + + CLAUDE.md · AGENTS.md · .control/ (governance) + research/entities/ · docs/ (the second brain) + <Workspace>/<Agent>/ · <Project>/ + worktrees + WORKSPACE.md · USER.md · all deliverables + + in Finder · git-tracked · backup-able · portable + + + boot + + + reads app-config → HOUSTON_DOCS → spawns engine + +
Two roots, one rule: ~/.houston/ = how Houston runs · ~/Houston/ = what you and your agents make.
+
+ +

Locked decisions

+
+ 1 Invisible by default + advanced toggle + 2 Onboarding names the root (~/Houston) + 3 Project is first-class now + 4 Full bstack — as environment, not code + 5 Sessions are addressable graph nodes + 6 Bookkeeping + PM are agent jobs + 7 Opinionated, autonomous-native + 8 Root + per-workspace shared context + 9 Rich HTML for plans & deep work +
+ +

The reframe — environment, not build

+

The load-bearing decision. The grounding sweep confirmed every capability the arc needs is already a shipped bstack skill that runs in-session. Houston's job is to make each workspace root look like a bstack workspace and install the skill roster through the rail it already owns — then get out of the way.

+ +
+ + + + + + + AGENTS DO — in-session, via skills + + /autonomous (21-reflex mode) + /kg load · bookkeeping run + /persist · /role-x · /cross-review + /p9 watch · git · gh · Linear + curate the graph · bridge sessions · run the PR pipeline — themselves + + + + + HOUSTON BUILDS — thin irreducible glue + + idempotent governance scaffold + skill provisioning (existing Store rail) + worktree lifecycle + janitor + addressable session URI (the one net-new wire contract) + reactivity (mostly free) · per-session runtime env + + + + + HOUSTON PROVIDES — the environment + + governance tree (CLAUDE/AGENTS/METALAYER/.control) + research/entities/ + catalog + frontmatter + bstack skill roster installed per agent + BROOMVA_ROOT + git/gh/python3 on PATH + git init + worktrees real + + + + + DO NOT BUILD + bookkeeping pipeline · kg loader · persist loop · role-x router · p9 watcher · cross-review · graph DB · embeddings · hook engine · autonomous orchestrator + +
Houston provides the floor and installs the skills; agents do the work on top. The glue layer is deliberately thin.
+
+ + + + + + + + + + + + + + + + + + + + +
Houston provides (environment)Houston builds (glue)Agents do (in-session)
Governance tree seeded at create: CLAUDE.md/AGENTS.md/METALAYER.md + .control/* + roles/Idempotent scaffold (keep-if-exists) in houston-agent-files + agents_crud.rs::create/autonomous — the 21-reflex operating mode = "autonomous by default"
research/entities/ + docs/ + knowledge-index.md stub at the workspace root (not under .houston/)Skill provisioning = ship the roster as a bundled package on the existing Store .agents/skills/* rail/kg load + bookkeeping — LLM-as-index over the entities tree
The bstack roster installed per agent + per-session BROOMVA_ROOT + git/gh/python3 on PATHWorktree lifecycle + janitor · addressable session URI · reactivity/persist · /role-x · /cross-review · /p9 · git/gh/Linear directly
+ +

The make-or-break risks

+

The sweep surfaced three things that quietly decide whether the whole arc works.

+ +
+ CRUX +
Path-assumption mismatch. Every bstack skill hard-codes ~/broomva/research/entities, ~/.config/broomva, ~/.claude/…. Installed as-is they read/write the wrong root. Houston spawns the provider subprocess, so it sets BROOMVA_ROOT=<agent workspace> + CWD per session — kg.py/bookkeeping.py honor it — but skill bodies still need light adaptation to the workspace-relative layout. This is the single make-or-break wiring detail of the arc.
+
+
+ SILENT NO-OP +
Workspace-side hook scripts gap. settings.json references $WORKSPACE/scripts/*-hook.sh that are emitted by control-metalayer-loop's bootstrap — not shipped in bstack/scripts/. Seed settings.json without seeding these and every hook 404s, so the P1/P2/P6/P7 reflex layer silently no-ops. Houston must seed the scripts.
+
+
+ RUNTIME +
Bundled at install — resolved. python3 3.11+ (tomllib + PyYAML), git, gh, bash 4+, node/npx. Houston bundles + installs them at install time so a bare fresh setup is ready; agents install more on demand. New foundation F0 — extends the houston-cli-bundle infra + notarization.
+
+
+ VOICE +
Firewall the jargon. Governance language, Pn primitives, URIs and paths must never leak into non-technical chat — the governance is agent-facing, the user sees outcomes only. advanced.developer_mode relaxes the voice. Skill-picker slugs (kg, role-x) stay out of the prominent surface (no display_name override — RULE 0).
+
+ +

Sessions ↔ the knowledge graph

+

A Houston session is already addressable(agent_path, session_key), resume ids on disk, transcript in chat_feed with an existing FTS5 index. The arc makes that address a first-class URI the graph links to, both directions.

+ +
+ + + + + + + + + + Session + houston://…/session/<key> + + + docs/conversations/ + bridge skill (P1) + + + research/entities/ + bookkeeping (P6) + + + /kg load <topic> + LLM-as-index + + + writes node + + promotes + + surfaces bodies + + cross-session memory + + + URI = join key + frontmatter = edge store · agent = query engine + +
Entity → session (sessions: [houston://…]) and session → entity (entities: [[slug]]). No DB, no embeddings — markdown frontmatter is the edge store.
+
+ +

The arc

+

Two foundations enable five capabilities. Each ships + is tested independently.

+ +
+ + + + + + + + + + + + + F1 · Root + visible · git + + F2 · Env + skills · runtime + + + + C1 Projects + open folder + + C2 Worktrees + parallel + janitor + + C3 Governance + opinionated + + C4 Second brain + LLM-as-index + + C5 Session↔KG + URI contract + + + + cross-cutting · voice firewall + advanced mode + i18n + tests + +
F0 (runtime bundling, install-time) + F1/F2 are the floor; C1–C5 stand on them; the voice firewall + advanced toggle wrap everything.
+
+ +
FOUNDATION · [build][app] · prereq for F2

F0 — Runtime bundling

+

Bundle + install python3/git/gh/bash/node at Houston install time so a bare fresh setup is ready; agents extend on demand. Extends houston-cli-bundle + fetch-cli-deps.sh; notarize bundled binaries; per-arch.

+
FOUNDATION · [engine][app]

F1 — Visible git-backed root

+

docs_root pref + app-config.json + boot wiring (HOUSTON_DOCS from pref, engine restart); onboarding root-pick (default ~/Houston); git init + .gitignore; existing-user migration offer (reuse migrate_legacy_docs_dir).

+ +
FOUNDATION · [engine][app] · the make-or-break

F2 — Agent environment + skill rail

+

Guarantee runtime (python3/git/gh); set BROOMVA_ROOT + CWD per session; vendor + provision the bstack roster via the Store .agents/skills/* rail + copy_missing_skill_dirs; seed the hook scripts; adapt skill bodies to workspace-relative roots; voice firewall + advanced.developer_mode.

+ +
CAPABILITY · [engine][ui][app]

C1 — Projects first-class

+

Project entity + projects.json + projects.rs + routes; "Open existing folder" / "New project" in the switcher (the original ask); agent↔project via workingDirOverride.

+
CAPABILITY · [engine][app]

C2 — Worktrees + janitor

+

Worktree-per-mission; parallel missions; P8 squash-merge cleanup; reactive UI. Reuses worktree.rs wholesale.

+
CAPABILITY · [engine][app]

C3 — Governance scaffold (autonomous-native)

+

Idempotent seed of the governance tree (keep-if-exists) + prompt.rs root context layer; /autonomous as the default stance; profile=autonomous with L3 paths still require_human.

+
CAPABILITY · [engine][app]

C4 — Second brain (LLM-as-index)

+

Seed research/entities/ + catalog stub + frontmatter + hand-authored starter entities; kg/bookkeeping provisioned in F2; reactivity free via the file-watcher catch-all.

+
CAPABILITY · [engine][app]

C5 — Session ↔ KG

+

The houston:// URI wire contract + history-route exposure + prompt injection; bridge writes session nodes; entity↔session backlinks; session→artifact edges from file_changes.rs.

+ +

Reuse map — wire what exists (RULE 0)

+ + + + + + + + + + + + + + + +
NeedExisting surface
Skill provisioninghouston-skills/src/lib.rs (CRUD, build_skills_index), engine-core/skills.rs (ensure_claude_symlink, install_from_repo)
Ship roster to existing agentsstore/bundled.rs (sync_bundled_agent_instances, copy_missing_skill_dirs, .migrations.json)
Scaffold seamagents_crud.rs::create (packaged-skill copy + seeds map), houston-agent-files/{lib,schemas}.rs
Context injectionagents/prompt.rs (seed_agent, build_agent_context)
Frontmatter tolerancehouston-skills/src/format.rs — serde_yml, unknown-key tolerant (bstack frontmatter parses as-is)
Session identity / searchconversations.rs (session_key), sessions/history.rs, houston-db/repo_search.rs (FTS5), file_changes.rs
Worktreesengine-core/src/worktree.rs (houston/{name} branches, run_shell)
Folder linking / run elsewhereagents_crud.rs symlink + pickDirectory (dormant link-project) · workingDirOverride
Root relocation + migrationdocs_dir/HOUSTON_DOCS + migrate_legacy_docs_dir
Reactivityhouston-file-watcher FilesChanged catch-all + use-agent-invalidation.ts
Advanced flagsadvanced.* substrate (FLAG_REGISTRY → KV → FeatureGate → enforcement → KB → tests)
+ +

Decisions — all resolved

+
+
+

Resolved

+
    +
  • Sessions → first-class addressable nodes, referenceable both ways.
  • +
  • Bookkeeping/PM → agent jobs in-session, never a user chore.
  • +
  • KG/index → LLM-as-index + frontmatter core; no engine pipeline.
  • +
  • Governance → seed the bstack tree as environment, opinionated.
  • +
  • Autonomous → profile=autonomous; /autonomous default; L3 require_human.
  • +
  • Format → bstack conventions; rich HTML for the user.
  • +
+
+
+

Closed (this round)

+
    +
  • Path fix: BROOMVA_ROOT per session + minimal body adaptation.
  • +
  • Hooks: seed Houston-authored equivalents (offline, deterministic).
  • +
  • Runtime: bundle + install all deps at install time → new foundation F0.
  • +
  • Roster: install for every agent, suppressed from the picker.
  • +
  • Templates: Houston-tuned, voice-firewalled.
  • +
+
+
+ +
+

Arc approved. Next step: execute F1 — Visible git-backed root (numbered chunk plan in chat). Nothing is built yet.
+ Canonical spec: docs/agentic-workspace-substrate.md · grounded by run wf_55e0d214-f5e.

+ +
+ + diff --git a/docs/agentic-workspace-substrate.md b/docs/agentic-workspace-substrate.md new file mode 100644 index 000000000..1ad267f35 --- /dev/null +++ b/docs/agentic-workspace-substrate.md @@ -0,0 +1,243 @@ +# Agentic Workspace Substrate — design arc + +Status: **proposed** (design under review, nothing built). Owner: Carlos. +Rich presentation: [`agentic-workspace-substrate.html`](./agentic-workspace-substrate.html) (P18 — read this for diagrams). +Grounded by a 5-agent sweep over the bstack skills + Houston reuse surfaces (run `wf_55e0d214-f5e`). + +Turns Houston's hidden bag-of-agents into a **user-visible, git-backed, bstack-governed** +workspace root that orchestrates multiple projects, runs agents in worktrees, and shares one +second-brain knowledge graph — by **providing the environment and installing skills**, not by +building engine features. Non-technical product stays intact by default; an advanced mode reveals +the substrate. + +--- + +## 0. Locked decisions + +1. **Invisible by default + advanced toggle.** Substrate runs underneath; non-technical voice + + UI stay default. `advanced.developer_mode` reveals the raw substrate. +2. **Onboarding picks + names the root** (default `~/Houston`). `.houston` vs root split is §2. +3. **Project is first-class now.** Workspace → { Agents, Projects }, many-to-many. +4. **Full bstack — as environment + skills, NOT engine code** (§1, the reframe). +5. **Sessions are addressable nodes** the knowledge graph references both directions (§5). +6. **Bookkeeping + project management are agent jobs in-session**, never user chores. +7. **Opinionated governance** so Houston workspaces are autonomous-agentic natively. +8. Root-level + per-workspace shared context (both). +9. **Rich HTML** for presenting plans/deep work (this arc ships an `.html` companion). + +--- + +## 1. The reframe — environment, not build (load-bearing) + +> Houston provides the **environment**; **agents** do the work **in-session via skills**. + +The grounding sweep confirmed: every capability the arc needs is already a shipped bstack skill +that runs entirely in-session. Houston's job is to make each agent's workspace root *look like a +bstack workspace* and **install the skill roster through the rail Houston already owns**. It must +NOT reimplement any of it. + +| Houston PROVIDES (environment) | Houston BUILDS (irreducible glue) | AGENTS DO (in-session via skill) | +|---|---|---| +| Governance tree seeded at create: `CLAUDE.md`/`AGENTS.md`/`METALAYER.md` + `.control/{policy.yaml,rcs-parameters.toml,audit/}` + `roles/` | Idempotent **scaffold** step (keep-if-exists) in `houston-agent-files` + `agents_crud.rs::create` | `/autonomous` — the 21-reflex operating mode (the meaning of "autonomous by default") | +| `research/entities/` + `docs/{conversations,specs,handoffs}/` + `docs/knowledge-index.md` catalog stub, at the **workspace root** (not under `.houston/`) | **Skill provisioning** = ship the bstack roster as a bundled package on the existing Store `.agents/skills/*` rail | `/kg load` + `bookkeeping run\|index` (P6) — LLM-as-index over `research/entities/` | +| The bstack **skill roster** installed per agent (`.agents/skills//SKILL.md` + `.claude/skills` symlink + prompt roster) | **Worktree lifecycle** ops + janitor (`worktree.rs` exists; add cleanup + reactive surface) | `/persist` (P12), `/role-x` (P17), `/cross-review` (P20), `/p9` (P9), `/handoff`, `/dogfood` | +| Per-session **runtime env**: `BROOMVA_ROOT=`, `git`/`gh`/`python3` on PATH, the hook scripts | **Addressable session URI** (the one net-new wire contract, §5) | `git`/`gh`/Linear-MCP calls directly for P3/P4/P5/P8/P10 | +| `git init` + `.gitignore` per workspace; worktrees real | **Reactivity** for new agent-written surfaces (mostly free — FilesChanged catch-all) | curate the graph, bridge sessions, run the PR pipeline — themselves | + +**Do NOT build** (consensus across all 5 findings): no Rust/TS port of the bookkeeping pipeline, +kg loader, persist loop, role-x router, p9 watcher, or cross-review; no graph DB / embeddings / +typed-edge store / query DSL (LLM-as-index is the design); no engine hook-engine (it's Claude Code +`settings.json` + shell scripts); no bespoke autonomous orchestrator (`/autonomous` IS it); no +engine CI-watcher (that's not `houston-scheduler`); don't bundle the full 60-skill roster (only the +~16 required-compliance set + `autonomous`). + +--- + +## 2. Two roots — the `.houston` vs user-root split + +> **`~/.houston/` = how Houston runs. `~/Houston/` = what you + your agents make.** + +| | System root `~/.houston/` (`home_dir`) | Workspace root `~/Houston/` (`docs_dir`) | +|---|---|---| +| Visibility / git | Hidden, never git | Visible in Finder, **git repo** | +| Holds | `engine.json`, `houston.db` (chat_feed + prefs), `logs/`, installed agent **definitions** (`agents/`), bundled CLIs, tunnel id, **new** `app-config.json` (root path + flags the boot reads) | Root governance, `research/entities/` second brain, `WORKSPACE.md`/`USER.md`, every `//`, every `/`, all deliverables + the skills/governance the agent runs | +| Rule | machine state, disposable | the user's work, precious, backup-able | + +`research/entities/`, governance, and `docs/conversations/` live at the **workspace root** +(user-visible, git-trackable) — **never under `.houston/`** (that would hide them from the user and +from git, defeating the arc). Each `/.houston/` stays a hidden data dir; volatile parts +(`sessions/`, `*.sid`, `*.invalid`, schemas) are git-ignored, meaningful parts (activity/routines/ +learnings/config) committed. + +--- + +## 3. Data model + +### 3.1 Project (first-class, NEW) +```ts +interface Project { id: string; name: string; path: string; + kind: "created" | "linked"; git: { enabled: boolean; remote?: string }; createdAt: string } +``` +- Index: `/.houston/projects.json` (beside the existing `connections.json`), files-first + reactive. +- `created` = `git init` a subdir; `linked` = symlink an external repo in (reuse the dormant agent link-folder mechanism, lifted to project scope). +- Engine: `projects.rs` + routes `/v1/workspaces/:id/projects`. Git/worktree ops reuse `worktree.rs`. +- **Agent ↔ Project (many-to-many):** agent identity stays its own dir; *where it works per mission* is a project (or worktree), resolved via the **existing** `session.workingDirOverride`. + +### 3.2 Governance scaffold (NEW, opinionated) +Idempotent seed at root + workspace create, from embedded bstack templates with `{{WORKSPACE_NAME}}` +substituted, **keep-if-exists** (user-data-shaped, follows `migrate_agent_data` contract): +`CLAUDE.md`, `AGENTS.md`, `METALAYER.md`, `.control/policy.yaml` (profile=autonomous; governance +paths stay `require_human` until L3 trust gates are CI-wired — no silent L3 auto-merge), +`.control/rcs-parameters.toml`, `.control/audit/`, `roles/_meta.md`, `.claude/settings.json` (hooks), +`.githooks/pre-commit` + `core.hooksPath`. **Plus the workspace-side hook scripts** (§6 risk). + +### 3.3 Second brain (LLM-as-index) +Seed `research/entities//` (10-type taxonomy) + `docs/knowledge-index.md` stub (empty-but-valid +— `kg.py` errors if absent) + the uniform **frontmatter schema** carried across `.md` *and* `.html` +*and* sidecars (P18) + a handful of hand-authored starter entities. `kg` + `bookkeeping` skills +provisioned per agent. Agents query/curate in-session; Houston builds no pipeline. + +### 3.4 Session URI (the one net-new contract) +`houston://workspace//agent//session/` — keyed on stable `(session_key, +working_dir)`, NOT the volatile resume id. Minted from `conversations.rs`'s existing `session_key`, +exposed on the history route, injected into the session prompt via `build_agent_context`. §5. + +### 3.5 Root context layer +`build_agent_context` gains a top layer, broad → narrow: +`product prompt → ROOT (governance + KG digest) → WORKSPACE (WORKSPACE.md/USER.md) → AGENT (CLAUDE.md/learnings/skills)`. + +--- + +## 4. Git + worktrees +- **Per workspace:** `git --version` check → `git init` + seed `.gitignore` + initial commit. Git + absent → degrade to no-git mode + a **toast** (no silent failure), never block. +- **Commit cadence:** mission boundaries (hook the existing `file_changes` before/after snapshot) + + explicit "save a version." Not per-write. Presented invisibly as "Houston saved a version." +- **Worktrees:** mission on a git project optionally runs in `{project}-worktrees/houston/{mission}` + via `workingDirOverride` (reuse `worktree.rs`); the engine already permits parallel worktrees, + conflicts same-folder. **Janitor** (P8 squash-merge detection) prunes — mandatory for + non-technical users or `*-worktrees/` orphans pile up. +- `.gitignore` seeds: `**/.houston/sessions/`, `**/*.sid`, `**/*.invalid`, + `**/.houston/**/*.schema.json`, `*-worktrees/`, `logs/`, `.DS_Store`. + +--- + +## 5. Session ↔ knowledge-graph referenceability (decision #1) + +A Houston session is **already addressable**: `(agent_path, session_key)` (`conversations.rs`), +resume ids on disk (`.houston/sessions//.sid`), transcript in `chat_feed` with +an **existing FTS5 index** (`repo_search.rs`). So: +- **FORWARD (session is addressable):** Houston mints the `houston://` URI, exposes it on the history + route, injects it into the session's own prompt → the agent knows its address. The conversation + bridge skill writes `docs/conversations/.md` carrying that URI in frontmatter → the + session becomes a node. +- **BACKWARD (graph references session):** entities promoted in-session carry `sessions: [houston://…]` + (entity→session); the session node lists `entities: [[slug]]` (session→entity). `/kg`'s catalog + already turns these into `→`/`←` links; `chat_feed` FTS makes the body queryable. Session→artifact + edges come free from `file_changes.rs`. + +No DB graph layer, no embeddings — the URI is the join key, markdown frontmatter is the edge store, +the agent is the query engine. **Caveats:** routine sessions share one `session_key` across runs → +need a run discriminator; URI must survive resume/compaction; PII redaction stays on in the bridge. + +--- + +## 6. The make-or-break risks (surfaced by the sweep) + +1. **Path-assumption mismatch (the crux).** bstack skills hard-code `~/broomva/research/entities`, + `~/.config/broomva`, `~/.claude/...`. Installed as-is they target the *wrong* root. Houston spawns + the provider subprocess, so it sets `BROOMVA_ROOT=` (+ CWD) per session — + `kg.py`/`bookkeeping.py` honor it. Skill bodies still need light adaptation to the workspace- + relative + `.agents/`/`.houston/` layout. **This is the make-or-break of the whole arc.** +2. **Workspace-side hook scripts gap.** `settings.json` references `$WORKSPACE/scripts/*-hook.sh` that + are emitted by `control-metalayer-loop`'s bootstrap, **not shipped in bstack/scripts/**. Seed + settings.json without seeding these → every hook 404s → the P1/P2/P6/P7 reflex layer silently + no-ops. Houston must seed the scripts (or run the bootstrap). +3. **Runtime deps — bundled at install (resolved).** `python3` 3.11+ (tomllib + PyYAML), `git`, + `gh`, `bash` 4+, `node`/`npx`. Houston **bundles + installs these at install time** so a bare + fresh setup is ready; agents install further tools on demand. Touches `houston-cli-bundle` + + `scripts/fetch-cli-deps.sh` + notarization + bundle size → foundation **F0**. +4. **Voice firewall.** Governance jargon / `Pn` / URIs / paths must never leak into non-technical + chat. Governance is agent-facing; the user sees outcomes only. `developer_mode` relaxes the voice. +5. **Skill-picker naming.** bstack slugs (`kg`, `role-x`, `persist`) + em-dash descriptions read badly + and violate i18n rules. Don't use `display_name` override (RULE 0 / schema forbids) — mark them + non-`featured`/no-`category` so they stay out of the prominent picker, or humanize at packaging. +6. **Bundle vs fetch.** Companion skills are "checked, not bundled" (`npx skills add`). Houston is + offline-first → **vendor the chosen SKILL.md bodies into `store/` at release** (deterministic), + not runtime `install_from_repo` (network → violates no-silent-failure). +7. **Governance ownership / L3 churn.** Houston-seeded governance vs agent self-evolution (P16) can + fight; the L3 churn budget + pre-commit rate gate could block early-setup edits. Idempotent + keep-if-exists re-seed; treat governance as user-data-shaped. + +--- + +## 7. The arc (foundations → capabilities → cross-cutting) + +**Foundations** (enable everything): +- **F0 — Runtime bundling.** Bundle + install `python3`/`git`/`gh`/`bash`/`node` at Houston install + time (fresh setup ready; agents extend on demand). Extends `houston-cli-bundle` + + `fetch-cli-deps.sh`; notarize bundled binaries; per-arch. Prereq for F2. `[build][app]` +- **F1 — Visible git-backed root.** `docs_root` pref + `app-config.json` + boot wiring + (`HOUSTON_DOCS` from pref, engine restart); onboarding root-pick (default `~/Houston`); `git init` + + `.gitignore`; existing-user migration offer (reuse `migrate_legacy_docs_dir`). `[engine][app]` +- **F2 — Agent environment + skill rail (the make-or-break).** Guarantee runtime (`python3`/`git`/ + `gh`); set `BROOMVA_ROOT` + CWD per session; vendor + provision the bstack roster via the Store + `.agents/skills/*` rail + `copy_missing_skill_dirs` to existing agents; seed hook scripts; adapt + skill bodies to workspace-relative roots; voice firewall + `advanced.developer_mode`. `[engine][app]` + +**Capabilities** (each shippable + tested): +- **C1 — Projects first-class.** `Project` entity + `projects.json` + `projects.rs` + routes; + "Open existing folder"/"New project" in the switcher (the original ask); agent↔project via + `workingDirOverride`. `[engine][ui][app]` +- **C2 — Worktrees + janitor.** worktree-per-mission; parallel missions; P8 cleanup; reactive UI. `[engine][app]` +- **C3 — Governance scaffold (opinionated, autonomous-native).** Idempotent seed of the governance + tree + `prompt.rs` root layer; `/autonomous` as the default stance. `[engine][app]` +- **C4 — Second brain (LLM-as-index).** Seed `research/entities/` + catalog + frontmatter + starter + entities; `kg`/`bookkeeping` provisioned (F2); reactivity free. `[engine][app]` +- **C5 — Session ↔ KG.** The `houston://` URI wire contract + history-route exposure + prompt + injection; bridge writes session nodes; entity↔session backlinks. `[engine][app]` + +**Cross-cutting:** voice firewall + `developer_mode` reveal; humanized skill picker; KB docs + i18n +(en/es/pt); per-surface advanced toggles; tests + dogfood receipts each phase. + +--- + +## 8. Reuse map (precise — per RULE 0, wire what exists) + +| Need | Existing surface | +|---|---| +| Skill provisioning | `engine/houston-skills/src/lib.rs` (CRUD, `build_skills_index`, `migrate_flat_files`), `engine-core/src/skills.rs` (`ensure_claude_symlink`, `install_from_repo`, `SkillsChanged`) | +| Ship roster to existing agents | `store/bundled.rs` (`sync_bundled_agent_instances`, `copy_missing_skill_dirs`, `.migrations.json`) | +| Governance/entities scaffold seam | `agents_crud.rs::create` (241-247 packaged-skill copy + seeds map ~277), `houston-agent-files/src/{lib,schemas}.rs` (embedded-asset seed) | +| Context injection | `agents/prompt.rs` (`seed_agent` role-file fan-out, `build_agent_context`) | +| Frontmatter tolerance | `houston-skills/src/format.rs` (serde_yml, unknown-key tolerant — bstack frontmatter parses as-is) | +| Session identity / transcript / search | `conversations.rs` (`session_key`), `sessions/history.rs`, `houston-db/repo_search.rs` (FTS5), `sessions/file_changes.rs` (session→artifact) | +| Worktrees | `engine/houston-engine-core/src/worktree.rs` (`houston/{name}` branches, `run_shell`) | +| Folder linking | `agents_crud.rs` symlink + `pickDirectory` (dormant `link-project`) | +| Run elsewhere | `session.workingDirOverride` (`tauriChat.send`) | +| Root relocation + migration | `docs_dir`/`HOUSTON_DOCS` knob + `migrate_legacy_docs_dir` | +| Reactivity | `houston-file-watcher` FilesChanged catch-all (already covers `research/entities/**` + catalog), `use-agent-invalidation.ts` | +| Advanced flags | `advanced.*` substrate (FLAG_REGISTRY → KV → FeatureGate → enforcement → KB → tests) | + +--- + +## 9. Decisions — resolved (this round) + +| # | Resolution | +|---|---| +| 1 Sessions | First-class addressable nodes; bridge → KG; URI referenceable both ways (§5). | +| 2 Bookkeeping/PM | Agent jobs in-session via skills; never a user chore. | +| 3 KG/index | LLM-as-index + filesystem/frontmatter core from day 1; **no engine pipeline**. | +| 4 Governance | Best practice — seed bstack governance tree as environment. | +| 5 Opinionated | profile=autonomous; `/autonomous` default; governance L3 stays `require_human`. | +| 6 Shared context | Root global + per-workspace local (both). | +| 7 Format | `/bstack` conventions; rich HTML for presenting to the user. | + +## 10. Decisions — closed (this round) +1. Path fix: **`BROOMVA_ROOT` per session + minimal per-skill body adaptation** to workspace-relative roots. +2. Hook scripts: **seed Houston-authored equivalents** (deterministic, offline) — don't depend on `control-metalayer-loop` bootstrap. +3. Runtime: **bundle + install all needed deps at Houston install time** (fresh setup ready); agents install more on demand → foundation **F0**. +4. Roster: **install for every agent, suppress from the picker surface** (non-`featured`/no-`category`). +5. Templates: **Houston-tuned, voice-firewalled**. + +All arc-level decisions are closed. Next: execute **F1** (numbered chunk plan tracked in chat / the worktree branch). diff --git a/docs/handoffs/2026-06-04-agentic-workspace-f1.md b/docs/handoffs/2026-06-04-agentic-workspace-f1.md new file mode 100644 index 000000000..6a6244a43 --- /dev/null +++ b/docs/handoffs/2026-06-04-agentic-workspace-f1.md @@ -0,0 +1,59 @@ +# Agentic Workspace Substrate — F1 (visible git-backed root + developer mode) + +**TL;DR.** F1 of the agentic-workspace arc is implemented, fully unit-validated, adversarially reviewed (P20) and fixed, and pushed as **PR gethouston/houston#446** — but it is **not merged** (the `broomva` token lacks write access to `gethouston/houston`) and has **not had a real-app runtime check**; **FIRST ACTION: run `cd app && pnpm tauri dev`, exercise Settings → Advanced → Developer mode → Workspace location → pick a folder → restart → confirm the tree migrated, then have a gethouston maintainer merge #446.** + +## State of the world (P15 snapshot 2026-06-04) + +- **Houston** (`gethouston/houston`) — working branch `open-existing-workspace-folder`, tracking `fork/open-existing-workspace-folder` (remote `fork` = `broomva/houston`; remote `origin` = `gethouston/houston`, **broomva has no push/merge access → 403**). **4 ahead / 0 behind** `origin/main` (`e8919db`). Tree clean. +- **PR #446** — `OPEN`, `mergeable: MERGEABLE`, `mergeStateStatus: CLEAN`, no CI checks reported (gethouston runs Actions only after maintainer approval on fork PRs). Blocked solely on merge permission. +- **Daemons** — none running. No dev server / engine was started this session (validation was unit-tests + typecheck only). To run: `cd app && pnpm tauri dev` (rebuild the engine first if `engine/**` changed: `cargo build -p houston-engine-server`). +- **Deps** — this worktree had **no `node_modules`** until `pnpm install` was run this session; if you re-clone, run `pnpm install` before `pnpm tsc`. + +## What F1 delivered (one PR, 4 commits — so the next agent doesn't redo it) + +| Commit | Files | What it gave | +|---|---|---| +| `8bfcc50` | `docs/agentic-workspace-substrate.{md,html}` | The whole-arc design (env-not-build reframe, two-root split, F0–F2 + C1–C5, session↔KG). Canonical spec + rich HTML. | +| `0c45df5` | `app_config.rs` (new), `git_repo.rs` (new), `state.rs`, `lib.rs`, engine `lib.rs`, `Cargo.toml` | **C1** docsRoot resolution at boot (default unchanged); **C4** `ensure_docs_root_git` (idempotent git-init, skips hidden root, degrades w/o git); **C5** `migrate_docs_root`. | +| `d011e6d` | `commands/workspace_root.rs` (new), `use-developer-mode.ts` (new), `advanced.tsx` (new), `os-bridge.ts`, `settings-view.tsx`, `commands/mod.rs`, `lib.rs`, `locales/*/settings.json`, `agent-manifest.md` | **C2** developer-mode toggle (off by default); **C3** `get/set_docs_root` + Settings → Advanced → Workspace location. | +| `ae2e626` | `app_config.rs`, `git_repo.rs`, `workspace_root.rs`, `lib.rs`, `advanced.tsx`, `locales/*/settings.json` | **P20 fixes:** boot-time (pre-engine) migration; EXDEV-safe + resumable move; `.gitignore`-only initial commit + broadened ignore; canonicalized home-skip; absolute-path persistence + input validation; legacy→docs_dir; no silent catch. | + +**Default behavior is unchanged** — `docsRoot` unset → `~/.houston/workspaces/`. The visible-root path only activates when a user opts in via Settings. + +## E2E proof (re-runnable any time) + +```bash +# Rust (engine + app crates) — from repo root: +cargo test -p houston-engine-core git_repo # 6 passed +cargo test -p houston-app app_config # 11 passed (incl. EXDEV-resume, nesting/equal guards) + +# TypeScript + locales — from app/ (run `pnpm install` first if node_modules absent): +cd app && pnpm tsc --noEmit # 0 errors +pnpm check-locales # en/es/pt in sync +``` + +## First action + +```bash +cd /Users/broomva/conductor/workspaces/houston/lansing +cargo build -p houston-engine-server # stage the sidecar (engine changed) +cd app && pnpm tauri dev +# Then, in the app: Settings → Advanced → toggle "Developer mode" on → +# "Workspace location" → Change → pick e.g. ~/Houston → restart Houston → +# confirm: ~/Houston exists, is a git repo (`git -C ~/Houston log`), +# workspaces moved out of ~/.houston/workspaces, agents still load. +``` +This is the **P11 gate** I could not close headlessly; it must pass before #446 merges (it's a data-migration feature). If it passes, ping a `gethouston/houston` maintainer to merge #446 (squash). + +## Pickup state (what's open) + +- [ ] **Merge #446** — needs a maintainer with write to `gethouston/houston` (broomva is 403). PR is CLEAN/MERGEABLE. +- [ ] **Runtime-verify the migration** (the First action above) — the only validation gap. +- [ ] **First-run onboarding root-pick screen** — deferred from C3. Capability ships via Settings; the tutorial-flow placement (`personal-assistant-onboarding.tsx` / welcome gate) needs the same `pnpm tauri dev` verification. +- [ ] **Next arc foundations:** **F0** runtime bundling (python3/git/gh/bash/node into the installer) and **F2** agent environment + skill rail (`BROOMVA_ROOT` per session, vendor the bstack roster via the Store `.agents/skills/*` rail) — see the design doc §7. + +## Related context + +- Design (canonical + rich): `docs/agentic-workspace-substrate.md` / `.html` +- Memory: `agentic-workspace-substrate-arc.md` (the arc), `advanced-settings-feature-shape.md` (now annotated STALE — no FLAG_REGISTRY exists; use the `tauriPreferences`+settings pattern from `use-developer-mode.ts`) +- The P20 cross-review findings + their fixes are spelled out in commit `ae2e626`'s body. diff --git a/engine/houston-engine-core/src/git_repo.rs b/engine/houston-engine-core/src/git_repo.rs new file mode 100644 index 000000000..581396866 --- /dev/null +++ b/engine/houston-engine-core/src/git_repo.rs @@ -0,0 +1,262 @@ +//! Git-init the user-visible Houston workspace root. +//! +//! The visible, user-named root (`docs_dir`, e.g. `~/Houston`) is a git +//! repository so the user gets version history of everything they and their +//! agents create. The hidden system root (`~/.houston`, `~/.dev-houston`) is +//! never a repo — it holds machine state, not user work — so git-init is +//! skipped whenever `docs_dir` lives inside `home_dir` (which also covers the +//! legacy `~/.houston/workspaces` default). +//! +//! Idempotent + boot-safe: a missing `git` degrades to a logged skip rather +//! than failing engine startup. The app's onboarding surfaces git availability +//! to the user; there is no UI thread at engine boot. + +use crate::error::{CoreError, CoreResult}; +use std::path::Path; +use std::process::Command; + +/// Seeded `.gitignore`. Keeps volatile machine state, heavy build output, and +/// credentials out of history. Only the `.gitignore` itself is committed at +/// init (see below), but seeding the full set means later mission-boundary +/// commits never sweep in `node_modules/`, `target/`, or a stray `.env` an +/// agent wrote into a project. +const GITIGNORE: &str = "\ +# Houston — volatile / machine state (never committed) +**/.houston/sessions/ +**/*.sid +**/*.invalid +**/.houston/**/*.schema.json +*-worktrees/ +logs/ +.DS_Store + +# Dependencies, build output, virtualenvs — agents clone + build inside projects +**/node_modules/ +**/target/ +**/dist/ +**/build/ +**/.next/ +**/.turbo/ +**/.venv/ +**/venv/ +**/__pycache__/ + +# Secrets — never version-control credentials an agent may write into a project +**/.env +**/.env.* +!**/.env.example +**/*.pem +**/*.key +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitInitOutcome { + /// Freshly initialized a repo + seeded `.gitignore` + initial commit. + Initialized, + /// `docs_dir` already contained a `.git` — nothing to do. + AlreadyRepo, + /// `docs_dir` is inside the hidden system root — intentionally not a repo. + SkippedSystemRoot, + /// `git` is not on PATH — degraded gracefully. + SkippedNoGit, +} + +fn git_available() -> bool { + Command::new("git") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn run_git(dir: &Path, args: &[&str]) -> CoreResult<()> { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .map_err(|e| CoreError::Internal(format!("git {args:?} failed to spawn: {e}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(CoreError::Internal(format!("git {args:?} failed: {stderr}"))); + } + Ok(()) +} + +/// True when `child` resolves to a path inside (or equal to) `ancestor`. +/// Canonicalizes both so a symlinked or case-only-different home (macOS +/// `/var` vs `/private/var`, case-insensitive filesystems) is matched +/// correctly; falls back to a lexical check only when canonicalization fails. +fn is_inside(child: &Path, ancestor: &Path) -> bool { + match (std::fs::canonicalize(child), std::fs::canonicalize(ancestor)) { + (Ok(c), Ok(a)) => c.starts_with(a), + _ => child.starts_with(ancestor), + } +} + +/// Ensure the visible workspace root is a git repo. See module docs. +pub fn ensure_docs_root_git(docs_dir: &Path, home_dir: &Path) -> CoreResult { + // Create first so the system-root check can canonicalize a real path. + std::fs::create_dir_all(docs_dir).map_err(|e| { + CoreError::Internal(format!("create_dir_all({}) failed: {e}", docs_dir.display())) + })?; + + // The hidden system root (and the legacy `~/.houston/workspaces` default, + // plus the `~/.dev-houston` debug root) all live under `home_dir`. Those + // are machine state, never git-backed. + if is_inside(docs_dir, home_dir) { + return Ok(GitInitOutcome::SkippedSystemRoot); + } + if docs_dir.join(".git").exists() { + return Ok(GitInitOutcome::AlreadyRepo); + } + if !git_available() { + tracing::warn!( + "[git] git not found on PATH — {} will not be version-controlled until git is installed", + docs_dir.display() + ); + return Ok(GitInitOutcome::SkippedNoGit); + } + + run_git(docs_dir, &["init", "-b", "main"])?; + + let gitignore = docs_dir.join(".gitignore"); + if !gitignore.exists() { + std::fs::write(&gitignore, GITIGNORE) + .map_err(|e| CoreError::Internal(format!("write .gitignore failed: {e}")))?; + } + + // Commit ONLY the `.gitignore`. Never `git add -A` the user's workspace at + // init: it may hold gigabytes of agent build output or a `.env` an agent + // wrote into a project, and sweeping all of that into a first commit is + // both slow and a credential-leak risk. The repo just needs a HEAD; real + // content is committed later at mission boundaries. + run_git(docs_dir, &["add", ".gitignore"])?; + // Per-invocation identity + no signing so the commit succeeds on a machine + // with no global git config (CI, a fresh non-technical Mac). The `-c` flags + // do not touch the user's global config. + run_git( + docs_dir, + &[ + "-c", + "user.name=Houston", + "-c", + "user.email=houston@localhost", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "Initialize Houston workspace", + ], + )?; + + Ok(GitInitOutcome::Initialized) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn is_git_repo(dir: &Path) -> bool { + dir.join(".git").is_dir() + } + + fn tracked_files(dir: &Path) -> String { + let out = Command::new("git") + .args(["ls-files"]) + .current_dir(dir) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).to_string() + } + + #[test] + fn visible_root_is_initialized() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let docs = tmp.path().join("Houston"); + std::fs::create_dir_all(&home).unwrap(); + let outcome = ensure_docs_root_git(&docs, &home).unwrap(); + assert_eq!(outcome, GitInitOutcome::Initialized); + assert!(is_git_repo(&docs)); + assert!(docs.join(".gitignore").is_file()); + } + + #[test] + fn only_gitignore_is_committed_not_user_content() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let docs = tmp.path().join("Houston"); + std::fs::create_dir_all(&home).unwrap(); + // Pre-existing user content (incl. a secret) must NOT be swept in. + std::fs::create_dir_all(docs.join("Project")).unwrap(); + std::fs::write(docs.join("Project/.env"), "API_KEY=secret").unwrap(); + std::fs::write(docs.join("Project/huge.bin"), vec![0u8; 1024]).unwrap(); + + ensure_docs_root_git(&docs, &home).unwrap(); + let tracked = tracked_files(&docs); + assert_eq!(tracked.trim(), ".gitignore"); + assert!(!tracked.contains(".env")); + assert!(!tracked.contains("huge.bin")); + } + + #[test] + fn second_call_is_idempotent() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let docs = tmp.path().join("Houston"); + std::fs::create_dir_all(&home).unwrap(); + assert_eq!( + ensure_docs_root_git(&docs, &home).unwrap(), + GitInitOutcome::Initialized + ); + assert_eq!( + ensure_docs_root_git(&docs, &home).unwrap(), + GitInitOutcome::AlreadyRepo + ); + } + + #[test] + fn system_root_is_skipped() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join(".houston"); + let docs = home.join("workspaces"); + std::fs::create_dir_all(&docs).unwrap(); + let outcome = ensure_docs_root_git(&docs, &home).unwrap(); + assert_eq!(outcome, GitInitOutcome::SkippedSystemRoot); + assert!(!is_git_repo(&docs)); + } + + /// The home-skip must survive a symlinked home (the macOS `/var` vs + /// `/private/var` class of bug), which a lexical `starts_with` would miss. + #[cfg(unix)] + #[test] + fn symlinked_home_is_skipped() { + let tmp = TempDir::new().unwrap(); + let real_home = tmp.path().join("real_home"); + let docs = real_home.join(".houston").join("workspaces"); + std::fs::create_dir_all(&docs).unwrap(); + let link_home = tmp.path().join("link_home"); + std::os::unix::fs::symlink(&real_home, &link_home).unwrap(); + + // home passed as the symlink, docs spelled via the real path. + let outcome = ensure_docs_root_git(&docs, &link_home).unwrap(); + assert_eq!(outcome, GitInitOutcome::SkippedSystemRoot); + assert!(!is_git_repo(&docs)); + } + + #[test] + fn initial_commit_establishes_head() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let docs = tmp.path().join("Houston"); + std::fs::create_dir_all(&home).unwrap(); + ensure_docs_root_git(&docs, &home).unwrap(); + let out = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&docs) + .output() + .unwrap(); + assert!(out.status.success(), "HEAD should resolve after initial commit"); + } +} diff --git a/engine/houston-engine-core/src/lib.rs b/engine/houston-engine-core/src/lib.rs index cabaffc28..29ce4ca4a 100644 --- a/engine/houston-engine-core/src/lib.rs +++ b/engine/houston-engine-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod attachments; pub mod conversations; pub mod error; pub mod git_bash; +pub mod git_repo; pub mod paths; pub mod portable; pub mod preferences; diff --git a/engine/houston-engine-server/src/state.rs b/engine/houston-engine-server/src/state.rs index f1ecf2d8a..e0a47e64e 100644 --- a/engine/houston-engine-server/src/state.rs +++ b/engine/houston-engine-server/src/state.rs @@ -84,6 +84,16 @@ impl ServerState { tracing::info!("[boot] repaired {repaired_activities} orphan running activity row(s)"); } + // Make the visible workspace root a git repo so the user gets version + // history of everything they + their agents create. No-op for the + // hidden system root and when git is unavailable. Logged-and-swallowed + // because a git-init hiccup must not fail engine boot; onboarding + // surfaces git availability to the user, and there is no UI thread here. + match houston_engine_core::git_repo::ensure_docs_root_git(paths.docs(), paths.home()) { + Ok(outcome) => tracing::info!("[boot] docs-root git: {outcome:?}"), + Err(e) => tracing::warn!("[boot] docs-root git-init failed: {e}"), + } + let engine = EngineState::new(paths, Arc::new(events.clone()), db.clone()) .with_app_prompts( config.app_system_prompt.clone(), diff --git a/knowledge-base/agent-manifest.md b/knowledge-base/agent-manifest.md index 41f37f086..1093bda06 100644 --- a/knowledge-base/agent-manifest.md +++ b/knowledge-base/agent-manifest.md @@ -264,7 +264,7 @@ level; codex has no such fallback, so `max` (an unknown variant to codex) is never offered for OpenAI. Default for every effort-capable provider is `medium`. ## Workspace -- Storage: `~/.houston/workspaces/workspaces.json` (index) + one dir per workspace `~/.houston/workspaces/{Name}/`. `HOUSTON_DOCS` env var overrides the root. +- Storage: index `workspaces.json` + one dir per workspace `/{Name}/`. The **docs root** resolves at app boot from `~/.houston/app-config.json` (`docsRoot`), falling back to `~/.houston/workspaces/`; the app passes it to the engine as `HOUSTON_DOCS`. A user-visible root (e.g. `~/Houston`) is git-initialised at engine boot (`houston_engine_core::git_repo::ensure_docs_root_git` — seeds `.gitignore` + initial commit, skips the hidden system root, degrades when git is absent); the hidden default is never a repo. App-side resolution + idempotent migration live in `app/src-tauri/src/app_config.rs`; users change the location via Settings → Advanced (developer mode) → Workspace location (`commands::workspace_root::{get_docs_root,set_docs_root}`, applied on next launch). - First launch: welcome screen, create first workspace - Engine routes: `GET /v1/workspaces`, `POST /v1/workspaces`, `POST /v1/workspaces/:id/rename`, `DELETE /v1/workspaces/:id`, `PATCH /v1/workspaces/:id/provider`, `GET|PUT /v1/workspaces/:id/context` (`engine/houston-engine-server/src/routes/workspaces.rs`). Frontend reaches them via `@houston-ai/engine-client` — no Tauri commands in the path. - Store: `useWorkspaceStore` — `loadWorkspaces()`, `setCurrent()`, `create()`, `rename()`, `delete()`