use super::{DaemonSet, runtime};
use crate::config::{Config, Settings};
use crate::task::Task;
use eyre::{Result, bail};
use indexmap::{IndexMap, IndexSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub(crate) fn declares_daemons(task: &Task) -> bool {
match &task.daemons {
None | Some(crate::task::TaskDaemons::All(false)) => false,
Some(crate::task::TaskDaemons::Names(names)) => !names.is_empty(),
Some(_) => true,
}
}
fn project_for_task(project_root: Option<&Path>, task: &Task) -> Result<Option<PathBuf>> {
if !declares_daemons(task) {
return Ok(None);
}
task.config_root
.clone()
.or_else(|| project_root.map(Path::to_path_buf))
.map(Some)
.ok_or_else(|| {
eyre::eyre!(
"task {} requires daemons but has no project root",
task.display_name
)
})
}
fn key_for(set: &DaemonSet, name: &str) -> Option<String> {
if set.daemons.contains_key(name) {
return Some(name.to_string());
}
let resolved = set.resolve_alias(name);
set.daemons.contains_key(&resolved).then_some(resolved)
}
pub(crate) fn required(tasks: &[Task], set: &DaemonSet) -> Result<IndexSet<String>> {
let mut names = IndexSet::new();
for task in tasks {
let Some(daemons) = &task.daemons else {
continue;
};
let requested: Vec<String> = match daemons {
crate::task::TaskDaemons::All(false) => continue,
crate::task::TaskDaemons::All(true) => set
.daemons
.iter()
.filter(|(_, d)| !d.imported)
.map(|(key, _)| key.clone())
.collect(),
crate::task::TaskDaemons::One(name) => vec![name.clone()],
crate::task::TaskDaemons::Names(requested) => requested.clone(),
};
for name in requested {
let Some(key) = key_for(set, &name) else {
bail!(
"task {} requires daemon {name:?}, which is not defined in [daemons]",
task.display_name
);
};
names.insert(key);
}
}
Ok(names)
}
pub(crate) fn gate(experimental: bool, tasks: &[Task]) -> Result<bool> {
let declared = tasks.iter().any(declares_daemons);
if declared && !experimental {
bail!("{}", super::EXPERIMENTAL);
}
Ok(declared)
}
pub(crate) async fn start(
config: &Arc<Config>,
tasks: &[Task],
dry_run: bool,
install_tools: bool,
) -> Result<()> {
if crate::env::var_is_true(super::DAEMON_TASK_MARKER) {
return Ok(());
}
if !gate(Settings::get().experimental, tasks)? {
return Ok(());
}
Settings::ensure_not_safe("starting task daemons")?;
let mut by_project: IndexMap<PathBuf, Vec<Task>> = IndexMap::new();
for task in tasks {
let Some(project) = project_for_task(config.project_root.as_deref(), task)? else {
continue;
};
by_project.entry(project).or_default().push(task.clone());
}
let mut wanted: IndexMap<PathBuf, IndexSet<String>> = IndexMap::new();
let mut foreign: IndexSet<PathBuf> = IndexSet::new();
let mut owned: IndexSet<PathBuf> = IndexSet::new();
for (project, tasks) in by_project {
let scoped = runtime::config_for_root(config, &project).await?;
let set = scoped.daemons()?;
let keys = required(&tasks, set)?;
let starting = set.with_dependencies(&keys.iter().cloned().collect::<Vec<_>>());
super::ensure_not_blocked(set, &starting, None)?;
for daemon in starting.daemons.values() {
if daemon.imported {
foreign.insert(daemon.root.clone());
} else {
owned.insert(daemon.root.clone());
}
wanted
.entry(daemon.root.clone())
.or_default()
.insert(daemon.name.clone());
}
}
foreign.retain(|root| !owned.contains(root));
if dry_run {
for (root, names) in &wanted {
let scoped = runtime::config_for_root(config, root).await?;
let set = scoped.daemons()?.for_root(root);
let starting = set.with_dependencies(&names.iter().cloned().collect::<Vec<_>>());
if foreign.contains(root) {
starting.validate_tasks(&scoped).await?;
} else {
set.validate_tasks(&scoped).await?;
}
super::ensure_not_blocked(&set, &starting, Some(root))?;
for name in names {
info!("[dry-run] would start daemon {name} in {}", root.display());
}
}
return Ok(());
}
wanted.sort_keys();
let mut pending = Vec::new();
for (root, names) in wanted {
let scoped = runtime::config_for_root(config, &root).await?;
let set = scoped.daemons()?.for_root(&root);
if set.daemons.is_empty() {
continue;
}
let will_start = set.with_dependencies(&names.iter().cloned().collect::<Vec<_>>());
super::presets::ensure_set_runnable_as_user(&will_start)?;
let previous = runtime::read_state(&root)?;
let (scoped, ts) = if install_tools {
runtime::toolset(&scoped, true).await?
} else {
let ts = runtime::toolset_resolved(&scoped, false).await?;
(scoped, ts)
};
let rt = runtime::Runtime::from_toolset(&scoped, &ts, Some(&previous.bin)).await?;
let owned = !foreign.contains(&root);
let starting = if owned {
set.clone()
} else {
set.with_dependencies(&names.iter().cloned().collect::<Vec<_>>())
};
runtime::validate_tools(&starting, &scoped, &ts).await?;
starting.validate_tasks(&scoped).await?;
if install_tools {
super::providers::install_set(&will_start).await?;
}
super::ensure_not_blocked(&set, &will_start, Some(&root))?;
let required: Vec<String> = will_start
.daemons
.values()
.map(|daemon| daemon.name.clone())
.collect();
let (state, _project_lock) = rt.prepare(&root, &set, false, owned, &required).await?;
let ids: Vec<String> = state
.ids
.iter()
.filter(|id| {
let name = id.rsplit('/').next().unwrap_or(id);
names.contains(name) && set.find(name).is_some()
})
.cloned()
.collect();
if ids.is_empty() {
continue;
}
pending.push((rt, root, ids, _project_lock));
}
for (rt, root, ids, _project_lock) in pending {
rt.exec(&root, [vec!["start".into()], ids].concat()).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::daemons::Daemon;
use crate::task::TaskDaemons;
fn task(name: &str, daemons: Option<TaskDaemons>) -> Task {
Task {
name: name.to_string(),
display_name: name.to_string(),
daemons,
..Default::default()
}
}
fn set_with_import() -> DaemonSet {
let mut set = set(&["api"]);
let mut worker = set.daemons["api"].clone();
worker.name = "worker".into();
worker.root = PathBuf::from("/mirror");
worker.imported = true;
set.daemons.insert("mirror/worker".into(), worker);
set.aliases.insert(
(PathBuf::from("/project"), "pipeline".into()),
"mirror/worker".into(),
);
set.namespaces
.insert(PathBuf::from("/mirror"), "mirror".into());
set.namespaces
.insert(PathBuf::from("/project"), "project".into());
set
}
#[test]
fn a_task_can_require_a_daemon_imported_from_another_project() {
let set = set_with_import();
let by_alias = [task("dev", Some(TaskDaemons::One("pipeline".into())))];
assert_eq!(
required(&by_alias, &set)
.unwrap()
.into_iter()
.collect::<Vec<_>>(),
["mirror/worker"]
);
let by_id = [task("dev", Some(TaskDaemons::One("mirror/worker".into())))];
assert_eq!(
required(&by_id, &set)
.unwrap()
.into_iter()
.collect::<Vec<_>>(),
["mirror/worker"]
);
let all = [task("dev", Some(TaskDaemons::All(true)))];
assert_eq!(
required(&all, &set)
.unwrap()
.into_iter()
.collect::<Vec<_>>(),
["api"]
);
let unknown = [task("dev", Some(TaskDaemons::One("nope".into())))];
assert!(
required(&unknown, &set)
.unwrap_err()
.to_string()
.contains("not defined in [daemons]")
);
}
fn set(names: &[&str]) -> DaemonSet {
DaemonSet {
daemons: names
.iter()
.map(|name| {
(
(*name).to_string(),
Daemon {
name: (*name).to_string(),
source: PathBuf::from("/project/mise.toml"),
root: PathBuf::from("/project"),
table: toml::Table::new(),
preset: None,
data_dir: None,
task: None,
tool: None,
provider: None,
exports: Default::default(),
imported: false,
port: None,
host: None,
},
)
})
.collect(),
..Default::default()
}
}
#[test]
fn required_daemons_are_deduplicated_and_checked() {
let set = set(&["postgres", "nats"]);
let tasks = [
task("dev", Some(TaskDaemons::Names(vec!["postgres".into()]))),
task("api", Some(TaskDaemons::One("nats".into()))),
task("web", Some(TaskDaemons::Names(vec!["postgres".into()]))),
task("test", None),
];
assert_eq!(
required(&tasks, &set)
.unwrap()
.into_iter()
.collect::<Vec<_>>(),
["postgres", "nats"]
);
let all = [task("dev", Some(TaskDaemons::All(true)))];
assert_eq!(required(&all, &set).unwrap().len(), 2);
let none = [task("dev", Some(TaskDaemons::All(false)))];
assert!(required(&none, &set).unwrap().is_empty());
let unknown = [task("dev", Some(TaskDaemons::One("redis".into())))];
let err = required(&unknown, &set).unwrap_err().to_string();
assert!(err.contains("task dev requires daemon \"redis\""), "{err}");
}
#[test]
fn tasks_requiring_daemons_fail_without_experimental() {
let dev = [task("dev", Some(TaskDaemons::All(true)))];
let err = gate(false, &dev).unwrap_err().to_string();
assert_eq!(err, super::super::EXPERIMENTAL);
assert!(gate(true, &dev).unwrap());
assert!(!gate(false, &[task("test", None)]).unwrap());
assert!(!gate(false, &[task("test", Some(TaskDaemons::All(false)))]).unwrap());
assert!(!gate(false, &[task("test", Some(TaskDaemons::Names(vec![])))]).unwrap());
}
#[test]
fn daemon_requirement_without_project_root_errors() {
let task = task("server", Some(TaskDaemons::One("postgres".into())));
let err = project_for_task(None, &task).unwrap_err();
assert_eq!(
err.to_string(),
"task server requires daemons but has no project root"
);
}
}