//! Project-root discovery, subsystem initialisation (log, identity, DB, CRDT), //! and CRDT-sync configuration. use crate::config; use crate::crdt_state; use crate::crdt_sync; use crate::db; use crate::io::fs::find_story_kit_root; use crate::log_buffer; use crate::node_identity; use crate::state::SessionState; use crate::store::JsonFileStore; use crate::worktree; use std::path::{Path, PathBuf}; use std::sync::Arc; /// Open (or scaffold) the project root according to the CLI flags and CWD. /// /// Handles `--init`, an explicit path argument, and the default auto-detect /// behaviour. Modifies `app_state.project_root` as a side effect. pub(crate) async fn open_project_root( is_init: bool, explicit_path: Option, cwd: &Path, app_state: &Arc, store: &Arc, port: u16, ) { if is_init { let init_root = explicit_path.unwrap_or_else(|| cwd.to_path_buf()); if !init_root.exists() { std::fs::create_dir_all(&init_root).unwrap_or_else(|e| { eprintln!( "error: cannot create directory {}: {e}", init_root.display() ); std::process::exit(1); }); } match crate::io::fs::open_project( init_root.to_string_lossy().to_string(), app_state, store.as_ref(), port, ) .await { Ok(_) => { if let Some(root) = app_state.project_root.lock().unwrap().as_ref() { config::ProjectConfig::load(root) .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); crate::io::wizard::WizardState::init_if_missing(root); } } Err(e) => { eprintln!("error: {e}"); std::process::exit(1); } } } else if let Some(explicit_root) = explicit_path { match crate::io::fs::open_project( explicit_root.to_string_lossy().to_string(), app_state, store.as_ref(), port, ) .await { Ok(_) => { if let Some(root) = app_state.project_root.lock().unwrap().as_ref() { config::ProjectConfig::load(root) .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); } } Err(e) => { eprintln!("error: {e}"); std::process::exit(1); } } } else if let Some(project_root) = find_story_kit_root(cwd) { crate::io::fs::open_project( project_root.to_string_lossy().to_string(), app_state, store.as_ref(), port, ) .await .unwrap_or_else(|e| { crate::slog!("Warning: failed to auto-open project at {project_root:?}: {e}"); project_root.to_string_lossy().to_string() }); config::ProjectConfig::load(&project_root) .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); } else { crate::io::fs::open_project( cwd.to_string_lossy().to_string(), app_state, store.as_ref(), port, ) .await .unwrap_or_else(|e| { crate::slog!("Warning: failed to scaffold project at {cwd:?}: {e}"); cwd.to_string_lossy().to_string() }); } } /// One-shot migration: read existing JSON store files and insert their rows /// into the SQLite tables created by the migration scripts, then rename the /// originals to `*.migrated` so this function is a no-op on the next startup. /// /// Runs after `db::init()` so the tables already exist and the shared pool is /// available. Errors are logged but never fatal. async fn migrate_json_stores_to_sqlite(huskies_dir: &Path) { let pool = match crate::db::get_shared_pool() { Some(p) => p, None => { crate::slog!("[db-migrate] Shared pool not available; skipping JSON migration"); return; } }; // ── event_triggers.json ─────────────────────────────────────────────────── let et_path = huskies_dir.join("event_triggers.json"); if et_path.exists() { match std::fs::read_to_string(&et_path) { Ok(s) => { let triggers: Vec = serde_json::from_str(&s).unwrap_or_default(); for t in triggers { let predicate_json = serde_json::to_string(&t.predicate).unwrap_or_default(); let action_json = serde_json::to_string(&t.action).unwrap_or_default(); let mode = match t.mode { crate::service::event_triggers::FireMode::Once => "once", crate::service::event_triggers::FireMode::Persistent => "persistent", }; let created_at = t.created_at.to_rfc3339(); let _ = sqlx::query( "INSERT OR IGNORE INTO event_triggers \ (id, predicate_json, action_json, mode, created_at) \ VALUES (?1, ?2, ?3, ?4, ?5)", ) .bind(&t.id) .bind(&predicate_json) .bind(&action_json) .bind(mode) .bind(&created_at) .execute(pool) .await; } let migrated = huskies_dir.join("event_triggers.json.migrated"); let _ = std::fs::rename(&et_path, &migrated); crate::slog!("[db-migrate] Migrated event_triggers.json → SQLite"); } Err(e) => crate::slog!("[db-migrate] Could not read event_triggers.json: {e}"), } } // ── timers.json ─────────────────────────────────────────────────────────── let timers_path = huskies_dir.join("timers.json"); if timers_path.exists() { match std::fs::read_to_string(&timers_path) { Ok(s) => { let entries: Vec = serde_json::from_str(&s).unwrap_or_default(); for e in entries { let _ = sqlx::query( "INSERT OR IGNORE INTO timers (story_id, scheduled_at) \ VALUES (?1, ?2)", ) .bind(&e.story_id) .bind(e.scheduled_at.to_rfc3339()) .execute(pool) .await; } let migrated = huskies_dir.join("timers.json.migrated"); let _ = std::fs::rename(&timers_path, &migrated); crate::slog!("[db-migrate] Migrated timers.json → SQLite"); } Err(e) => crate::slog!("[db-migrate] Could not read timers.json: {e}"), } } // ── scheduled_timers.json ───────────────────────────────────────────────── let st_path = huskies_dir.join("scheduled_timers.json"); if st_path.exists() { match std::fs::read_to_string(&st_path) { Ok(s) => { use crate::service::timer::scheduled::ScheduledTimer; let entries: Vec = serde_json::from_str(&s).unwrap_or_default(); for t in entries { let action_json = serde_json::to_string(&t.action).unwrap_or_default(); let mode_json = serde_json::to_string(&t.mode).unwrap_or_default(); let _ = sqlx::query( "INSERT OR IGNORE INTO scheduled_timers \ (id, label, fire_at, action_json, mode_json, created_at) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6)", ) .bind(&t.id) .bind(&t.label) .bind(t.fire_at.to_rfc3339()) .bind(&action_json) .bind(&mode_json) .bind(t.created_at.to_rfc3339()) .execute(pool) .await; } let migrated = huskies_dir.join("scheduled_timers.json.migrated"); let _ = std::fs::rename(&st_path, &migrated); crate::slog!("[db-migrate] Migrated scheduled_timers.json → SQLite"); } Err(e) => crate::slog!("[db-migrate] Could not read scheduled_timers.json: {e}"), } } } /// Set up the server log file, node identity keypair, pipeline DB, and CRDT state. /// /// When `is_agent` is `true` the pipeline database is opened at an isolated /// temporary path (or at `HUSKIES_DB_PATH` if that env-var is set) so that the /// headless build agent never touches the production `.huskies/pipeline.db`. /// This prevents feature-branch migrations from being applied to the shared /// database and bricking the next server restart. pub(crate) async fn init_subsystems(app_state: &Arc, cwd: &Path, is_agent: bool) { // Enable persistent server log file now that the project root is known. if let Some(ref root) = *app_state.project_root.lock().unwrap() { let log_dir = root.join(".huskies").join("logs"); let _ = std::fs::create_dir_all(&log_dir); log_buffer::global().set_log_dir(log_dir); } // Initialise the node's Ed25519 identity keypair (file-based, mode 0600). // The key is stored at .huskies/node_identity.key and persisted across restarts. { let key_path = app_state .project_root .lock() .unwrap() .as_ref() .map(|root| root.join(".huskies").join("node_identity.key")) .unwrap_or_else(|| cwd.join(".huskies").join("node_identity.key")); if let Err(e) = node_identity::init_identity(&key_path) { crate::slog!("[identity] Failed to initialise node identity keypair: {e}"); } else if let Some(id) = node_identity::get_identity() { crate::slog!("[identity] Node ID: {}", id.node_id); } } // Resolve the pipeline DB path. // // Priority order: // 1. HUSKIES_DB_PATH env var (operator override, any mode) // 2. Agent mode: process-local temp file so the production DB is never touched // 3. Default: {project_root}/.huskies/pipeline.db let pipeline_db_path: Option = if let Ok(env_path) = std::env::var("HUSKIES_DB_PATH") { let p = PathBuf::from(&env_path); crate::slog!("[db] HUSKIES_DB_PATH override: {}", p.display()); Some(p) } else if is_agent { // Headless agent: use an isolated temp DB so that any migrations compiled // into this binary (e.g. from a feature branch) are never applied to the // production database. The temp file is process-unique and harmless to // leave behind after the agent exits. let pid = std::process::id(); let temp_path = std::env::temp_dir().join(format!("huskies-agent-{pid}.db")); crate::slog!( "[db] Agent mode: using isolated DB at {} (not touching production pipeline.db)", temp_path.display() ); Some(temp_path) } else { // Server mode: use the project-local production database. app_state .project_root .lock() .unwrap() .as_ref() .map(|root| root.join(".huskies").join("pipeline.db")) }; if let Some(ref db_path) = pipeline_db_path { if let Err(e) = db::init(db_path).await { crate::slog!("[db] Failed to initialise pipeline.db: {e}"); } else { // ── Migration drift self-check (server mode only) ───────────────────── // // In server mode, detect whether the live database contains migrations // that were applied by a newer binary (e.g. a feature-branch agent that // ran before the feature was merged). If so, log each unknown migration // and exit with a clear actionable message. This is the root cause of // the 2026-05-14 21:07 production outage where the server came up but // the CRDT never initialised. if !is_agent && let Some(pool) = db::get_shared_pool() { let drift = db::check_schema_drift(pool).await; if !drift.is_empty() { for m in &drift { crate::slog!( "[db] UNKNOWN migration {} ('{}') applied at {} \ is not in the compiled-in set", m.version, m.description, m.installed_on, ); } eprintln!(); eprintln!( "error: pipeline.db contains {} migration(s) that are not \ recognised by this binary:", drift.len() ); for m in &drift { eprintln!( " \u{2022} migration {} ('{}') applied at {}", m.version, m.description, m.installed_on ); } eprintln!(); eprintln!( "This means the database was previously opened by a newer \ version of huskies." ); eprintln!( "To fix: rebuild huskies from the latest source (the branch \ that added these migrations) and restart." ); eprintln!( "Do NOT start the old binary against this database — it will \ behave incorrectly." ); std::process::exit(1); } } // One-shot migration: move any existing JSON store files into SQLite. let huskies_dir = db_path.parent().unwrap_or(db_path); migrate_json_stores_to_sqlite(huskies_dir).await; } if let Err(e) = crdt_state::init(db_path).await { crate::slog!("[crdt] Failed to initialise CRDT state layer: {e}"); } else { crdt_state::migrate_names_from_slugs(); // Story 934 stage 6: rewrite any pre-934 directory-style stage // strings to the clean post-934 wire vocabulary, and set the new // `frozen` flag on items that were previously at `Stage::Frozen`. // Must run before legacy stage-string acceptance is dropped from // `Stage::from_dir` (also part of stage 6). crdt_state::migrate_legacy_stage_strings(); let id_migrations = crdt_state::migrate_story_ids_to_numeric(); if !id_migrations.is_empty() && let Some(project_root) = db_path.parent().and_then(|p| p.parent()) { worktree::migrate_slug_paths(project_root, &id_migrations); } // Story 987: upgrade four-bool MergeJob entries to typed MergeResult enum. crdt_state::migrate_merge_job(db_path); // Story 1009: drop legacy node-hex claims that can't be converted to AgentName. crdt_state::migrate_node_claims_to_agent_claims(); // Story 1052: remove stale MergeJob entries for terminal-stage // stories so they can never cause "FAILED" labels in the UI. crdt_state::purge_done_stage_merge_jobs(); // Story 1094: delete pipeline_items rows whose CRDT entry is // tombstoned but whose row survived with a non-terminal stage // (pre-1094 fire-and-forget delete could be lost on restart). crdt_state::migrate_zombie_pipeline_rows().await; } } } /// Wire up CRDT sync: trusted keys, token auth, and the rendezvous client. /// /// In agent mode the rendezvous URL comes from the CLI; otherwise it is read /// from `project.toml`. pub(crate) fn configure_crdt_sync( app_state: &Arc, is_agent: bool, agent_rendezvous: Option, crdt_join_token: Option, ) { let sync_config = if is_agent { agent_rendezvous .clone() .map(|url| (url, Vec::new(), false, Vec::new())) } else { app_state .project_root .lock() .unwrap() .as_ref() .and_then(|root| config::ProjectConfig::load(root).ok()) .and_then(|cfg| { cfg.rendezvous.map(|url| { ( url, cfg.trusted_keys, cfg.crdt_require_token, cfg.crdt_tokens, ) }) }) }; if let Some((rendezvous_url, trusted_keys, require_token, crdt_tokens)) = sync_config { crdt_sync::init_trusted_keys(trusted_keys); crdt_sync::init_token_auth(require_token, crdt_tokens); crdt_sync::spawn_rendezvous_client(rendezvous_url, crdt_join_token); } else { let (keys, require_token, crdt_tokens) = app_state .project_root .lock() .unwrap() .as_ref() .and_then(|root| config::ProjectConfig::load(root).ok()) .map(|cfg| (cfg.trusted_keys, cfg.crdt_require_token, cfg.crdt_tokens)) .unwrap_or_default(); crdt_sync::init_trusted_keys(keys); crdt_sync::init_token_auth(require_token, crdt_tokens); } }