First Version
This commit is contained in:
+227
@@ -0,0 +1,227 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub use core_api::provider::LlmStrength;
|
||||
pub use crate::core::config::{
|
||||
LlmConfig, TicConfig, CronConfig,
|
||||
CompactionConfig, DatetimeConfig, LlmRequestsLogConfig,
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: &str = "default.config.yaml";
|
||||
const CONFIG: &str = "config.yml";
|
||||
|
||||
/// Default config baked into the binary at compile time.
|
||||
///
|
||||
/// Used by [`bootstrap_data_dir`] (desktop mode) to seed `config.yml` on first
|
||||
/// launch, where the bundled binary cannot rely on `default.config.yaml` being
|
||||
/// next to it on disk (the cwd has already been relocated to the per-user data
|
||||
/// dir). Headless mode still copies `default.config.yaml` from the source tree
|
||||
/// as before.
|
||||
const DEFAULT_CONFIG_EMBEDDED: &str = include_str!("../default.config.yaml");
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
pub web: WebConfig,
|
||||
pub llm: LlmConfig,
|
||||
#[serde(default)]
|
||||
pub tic: TicConfig,
|
||||
#[serde(default)]
|
||||
pub cron: CronConfig,
|
||||
/// Global IANA timezone name (e.g. `"Europe/Rome"`).
|
||||
/// Applied to: cron expression evaluation, datetime injected into the LLM context.
|
||||
/// When omitted, the server's local system timezone is used everywhere.
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WebConfig {
|
||||
pub static_dir: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn into_split(self) -> (crate::core::config::CoreConfig, crate::frontend::config::FrontendConfig) {
|
||||
let tz = self.timezone.clone();
|
||||
(
|
||||
crate::core::config::CoreConfig {
|
||||
llm: self.llm,
|
||||
tic: self.tic,
|
||||
cron: self.cron,
|
||||
timezone: self.timezone,
|
||||
},
|
||||
crate::frontend::config::FrontendConfig {
|
||||
server: self.server,
|
||||
web: self.web,
|
||||
timezone: tz,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let config_path = Path::new(CONFIG);
|
||||
let default_path = Path::new(DEFAULT_CONFIG);
|
||||
|
||||
if !config_path.exists() {
|
||||
std::fs::copy(default_path, config_path)
|
||||
.with_context(|| format!("Failed to copy {DEFAULT_CONFIG} to {CONFIG}"))?;
|
||||
crate::boot::section(format!("Created {CONFIG} from {DEFAULT_CONFIG}"));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(config_path)
|
||||
.with_context(|| format!("Failed to read {CONFIG}"))?;
|
||||
|
||||
serde_yaml::from_str(&content).with_context(|| format!("Failed to parse {CONFIG}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute directory for log files.
|
||||
///
|
||||
/// `init_logging()` runs **before** [`bootstrap_data_dir`] relocates the cwd, so
|
||||
/// a relative `"logs"` path would resolve against the bundle's launch cwd (`/`
|
||||
/// for a Finder-launched `.app`) — un-writable, so the log folder stays empty.
|
||||
/// In a packaged bundle, return an absolute path under the per-user data dir
|
||||
/// instead. In every other mode (headless, desktop dev) keep the historical
|
||||
/// relative `"logs"`, unchanged.
|
||||
pub fn resolved_log_dir() -> std::path::PathBuf {
|
||||
#[cfg(feature = "desktop")]
|
||||
{
|
||||
if running_from_bundle() {
|
||||
if let Some(dir) = dirs::data_dir() {
|
||||
return dir.join("Skald").join("logs");
|
||||
}
|
||||
}
|
||||
}
|
||||
std::path::PathBuf::from("logs")
|
||||
}
|
||||
|
||||
/// In desktop mode (Tauri bundle), relocate the process working directory to
|
||||
/// the OS-appropriate per-user data dir so that every relative path in
|
||||
/// `config.yml` (db, logs, data, secrets, models, agents, …) resolves there
|
||||
/// instead of `/` (the default cwd of a `.app` bundle on macOS, or the Windows
|
||||
/// equivalent). Also seeds `config.yml` from the bundled default if missing.
|
||||
///
|
||||
/// | OS | Location |
|
||||
/// |---------|-----------------------------------------------------|
|
||||
/// | macOS | `~/Library/Application Support/Skald` |
|
||||
/// | Windows | `%APPDATA%\Skald` (= `C:\Users\<u>\AppData\Roaming`)|
|
||||
/// | Linux | `~/.local/share/Skald` |
|
||||
///
|
||||
/// ## When relocation happens
|
||||
/// Only when the process is running from a packaged bundle (e.g. inside
|
||||
/// `Skald.app/Contents/MacOS/`). In dev mode (`cargo run --features desktop`),
|
||||
/// the cwd is left untouched so all source-tree assets (`agents/`, `skills/`,
|
||||
/// `web/`, `config.yml`, …) keep resolving from the crate root as in headless
|
||||
/// mode.
|
||||
///
|
||||
/// In headless mode this is always a no-op: the cwd stays as the user launched
|
||||
/// it, preserving today's behaviour (`./database.db`, `./logs/`, …).
|
||||
#[cfg(feature = "desktop")]
|
||||
pub fn bootstrap_data_dir() -> Result<()> {
|
||||
use tracing::info;
|
||||
if !running_from_bundle() {
|
||||
info!("desktop mode (dev): cwd unchanged — using source-tree assets");
|
||||
return Ok(());
|
||||
}
|
||||
let data_dir = dirs::data_dir()
|
||||
.context("could not determine OS data directory")?
|
||||
.join("Skald");
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.with_context(|| format!("failed to create data dir at {}", data_dir.display()))?;
|
||||
info!(path = %data_dir.display(), "desktop mode: relocating cwd to per-user data dir");
|
||||
std::env::set_current_dir(&data_dir)
|
||||
.with_context(|| format!("failed to cd to {}", data_dir.display()))?;
|
||||
|
||||
// Seed config.yml from the bundled default if absent (first launch).
|
||||
let config_path = Path::new(CONFIG);
|
||||
if !config_path.exists() {
|
||||
let _ = std::fs::write(CONFIG, DEFAULT_CONFIG_EMBEDDED);
|
||||
info!(path = %data_dir.display(), "seeded config.yml from embedded default");
|
||||
}
|
||||
|
||||
// Make the read-only bundled assets reachable from the relocated cwd.
|
||||
link_bundled_assets(&data_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read-only asset directories shipped inside the `.app` bundle's `Resources/`
|
||||
/// dir (see `tauri.conf.json > bundle > resources`). The backend looks these up
|
||||
/// by relative path from the cwd (agent discovery reads `agents/`, Axum serves
|
||||
/// `web/`, etc.), but the cwd has just been relocated to the data dir — where
|
||||
/// they don't exist. Without this, `Skald::new` fails with
|
||||
/// "Failed to read agents directory 'agents'" and the app exits on launch.
|
||||
#[cfg(feature = "desktop")]
|
||||
const BUNDLED_ASSETS: &[&str] = &["agents", "web", "skills", "commands"];
|
||||
|
||||
/// (Re)link each bundled asset dir into the per-user data dir as a symlink to
|
||||
/// the copy inside the app bundle's `Resources/`, so the existing relative-path
|
||||
/// lookups resolve while mutable state (db, config, logs, secrets) stays in the
|
||||
/// data dir itself.
|
||||
///
|
||||
/// Symlinking (rather than copying) keeps the assets in sync with the installed
|
||||
/// app version automatically. A pre-existing **real** directory is treated as a
|
||||
/// user override and left untouched; only symlinks are refreshed.
|
||||
#[cfg(feature = "desktop")]
|
||||
fn link_bundled_assets(data_dir: &Path) -> Result<()> {
|
||||
use tracing::{info, warn};
|
||||
let exe = std::env::current_exe().context("could not resolve current_exe")?;
|
||||
// `.../Skald.app/Contents/MacOS/skald` → `.../Skald.app/Contents/Resources`
|
||||
let resource_dir = match exe.parent().and_then(|p| p.parent()) {
|
||||
Some(contents) => contents.join("Resources"),
|
||||
None => {
|
||||
warn!("could not derive bundle Resources dir from exe path — skipping asset link");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
for name in BUNDLED_ASSETS {
|
||||
let src = resource_dir.join(name);
|
||||
if !src.exists() {
|
||||
warn!(asset = name, "bundled asset missing from Resources — skipping");
|
||||
continue;
|
||||
}
|
||||
let dst = data_dir.join(name);
|
||||
match std::fs::symlink_metadata(&dst) {
|
||||
// Stale symlink from a previous launch — replace it.
|
||||
Ok(meta) if meta.file_type().is_symlink() => { let _ = std::fs::remove_file(&dst); }
|
||||
// A real dir/file the user created — respect it, don't clobber.
|
||||
Ok(_) => continue,
|
||||
// Absent — fall through and create.
|
||||
Err(_) => {}
|
||||
}
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&src, &dst)
|
||||
.with_context(|| format!("failed to symlink {} -> {}", dst.display(), src.display()))?;
|
||||
info!(asset = name, target = %src.display(), "linked bundled asset into data dir");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Heuristic: are we running inside a packaged bundle (e.g. `Foo.app`)?
|
||||
/// Used to decide whether to relocate the cwd to the per-user data dir.
|
||||
#[cfg(feature = "desktop")]
|
||||
fn running_from_bundle() -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
return exe.to_string_lossy().contains(".app/");
|
||||
}
|
||||
}
|
||||
// Windows / Linux: TBD when packaging targets land. For now treat all
|
||||
// launches as dev mode (no cwd relocation).
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "desktop"))]
|
||||
pub fn bootstrap_data_dir() -> Result<()> {
|
||||
// Headless mode: keep cwd as launched, no relocation.
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user