use anyhow::Context;
use serde::Serialize;
use std::collections::BTreeSet;
use std::os::unix::fs::symlink;
use std::path::Path;
use crate::template::Effective;
pub const INSTANCE_CONFIG: &str = r#"#:schema ./onlyne-config.schema.json
[workspace]
name = "swarm-node"
[logging]
level = "info"
[io]
in_format = "markdown"
out_content = "latest_only"
out_cursor = "consume"
history_context_messages = 20
[loopback.io]
in_format = "markdown"
out_content = "latest_only"
out_cursor = "consume"
history_context_messages = 20
[adapters.telegram]
enabled = false
[adapters.feishu]
enabled = false
[adapters.qqbot]
enabled = false
[adapters.wechat]
enabled = false
[swarm]
enabled = true
"#;
#[derive(Debug, Default, Serialize)]
pub struct SyncReport {
pub created: Vec<String>,
pub orphans: Vec<String>,
pub dangling: Vec<String>,
pub workspaces: usize,
}
pub fn run_sync(root: &Path) -> anyhow::Result<SyncReport> {
let tree = crate::template::load_tree(root)?;
let mut report = SyncReport {
workspaces: tree.len(),
..Default::default()
};
let known: BTreeSet<&str> = tree.iter().map(|e| e.path.as_str()).collect();
for e in &tree {
let ws = crate::root::resolve_instance(root, &e.path);
let onlyne = ws.join(".onlyne");
let is_new = !onlyne.join("config.toml").exists();
std::fs::create_dir_all(onlyne.join("channels/loopback"))
.with_context(|| format!("create {}", onlyne.display()))?;
std::fs::create_dir_all(onlyne.join("run"))?;
std::fs::create_dir_all(onlyne.join("logs"))?;
if is_new {
std::fs::write(onlyne.join("config.toml"), INSTANCE_CONFIG)?;
let snap = serde_json::to_string_pretty(&serde_json::json!({
"name": e.name,
"role": e.role,
"model": {"provider": e.model.provider, "model": e.model.model, "effort": e.model.effort},
"back_edges": e.back_edges,
}))?;
std::fs::write(onlyne.join("swarm.workspace.jsonc"), snap + "\n")?;
report.created.push(display_path(&e.path));
}
}
let inst = crate::root::instances_dir(root);
if inst.is_dir() {
let mut found: Vec<String> = vec![];
collect_rel(&inst, &inst, &mut found)?;
for f in found {
if !known.contains(f.as_str()) {
report.orphans.push(f);
}
}
}
refresh_links(root, &tree, &mut report)?;
Ok(report)
}
fn display_path(p: &str) -> String {
if p.is_empty() {
".".into()
} else {
p.into()
}
}
fn collect_rel(base: &Path, dir: &Path, out: &mut Vec<String>) -> anyhow::Result<()> {
for e in std::fs::read_dir(dir)? {
let e = e?;
if e.file_type()?.is_dir() {
let rel = e
.path()
.strip_prefix(base)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
if e.path().join(".onlyne").is_dir() {
out.push(rel.clone());
}
collect_rel(base, &e.path(), out)?;
}
}
Ok(())
}
fn refresh_links(root: &Path, tree: &[Effective], report: &mut SyncReport) -> anyhow::Result<()> {
let targets: Vec<&str> = tree.iter().map(|e| e.path.as_str()).collect();
for e in tree {
let ws = crate::root::resolve_instance(root, &e.path);
let view = ws.join("onlyne_in");
std::fs::create_dir_all(&view)?;
for t in &targets {
if *t == e.path {
continue;
}
let link_rel = if t.is_empty() { "_root".into() } else { (*t).to_string() };
let link_path = view.join(&link_rel);
let target_in = crate::root::loopback_in(&crate::root::resolve_instance(root, t));
if let Some(parent) = link_path.parent() {
std::fs::create_dir_all(parent)?;
}
let _ = std::fs::remove_file(&link_path);
symlink(&target_in, &link_path).with_context(|| {
format!(
"symlink {} -> {}",
link_path.display(),
target_in.display()
)
})?;
if std::fs::symlink_metadata(&target_in).is_err() {
report.dangling.push(format!(
"{}: onlyne_in/{}",
display_path(&e.path),
link_rel
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_creates_instances_and_links() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".agents/.schedule/a")).unwrap();
std::fs::write(
root.join(".agents/.schedule/a/template.workspace.jsonc"),
r#"{"name": "a", "role": "r"}"#,
)
.unwrap();
let rep = run_sync(root).unwrap();
assert_eq!(rep.workspaces, 2);
assert!(rep.created.iter().any(|c| c == "a"));
let cfg = std::fs::read_to_string(
root.join(".ws/a/.onlyne/config.toml"),
)
.unwrap();
assert!(cfg.contains("[swarm]"));
let link = root.join("onlyne_in/a");
assert!(link.is_symlink());
assert!(root
.join(".ws/a/onlyne_in/_root")
.is_symlink());
std::fs::write(
root.join(".ws/a/.onlyne/swarm.workspace.jsonc"),
r#"{"role": "mine"}"#,
)
.unwrap();
run_sync(root).unwrap();
let snap = std::fs::read_to_string(
root.join(".ws/a/.onlyne/swarm.workspace.jsonc"),
)
.unwrap();
assert!(snap.contains("mine"));
}
#[test]
fn orphan_instances_are_reported_not_deleted() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".ws/ghost/.onlyne")).unwrap();
let rep = run_sync(root).unwrap();
assert!(rep.orphans.iter().any(|o| o == "ghost"));
assert!(root.join(".ws/ghost").exists());
}
}