use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tokio::sync::broadcast;
use crate::chat::turns::ChatTurn;
use crate::wave::journal::{
fold_thread, journal_path, Attribution, EventKind, Journal, MessageId, MessageOp,
};
pub fn matches_prefix(channel: &str, prefix: &str) -> bool {
channel == prefix
|| channel
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('.'))
}
pub fn family_head(channel: &str) -> &str {
channel.split('.').next().unwrap_or(channel)
}
pub fn child_worktree_path(origin: &Path, channel: &str) -> PathBuf {
let repo_name = origin
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("repo");
origin
.parent()
.unwrap_or(origin)
.join(format!("{repo_name}.{channel}"))
}
pub fn child_journal_path(origin: &Path, channel: &str) -> PathBuf {
journal_path(&child_worktree_path(origin, channel), channel)
}
#[derive(Debug, Clone)]
pub struct ChannelFrame {
pub channel: String,
pub turn: std::sync::Arc<ChatTurn>,
pub json: std::sync::Arc<str>,
}
pub fn tagged_turn_json(channel: &str, turn: &ChatTurn) -> String {
let mut value = serde_json::to_value(turn).unwrap_or(serde_json::Value::Null);
if let serde_json::Value::Object(map) = &mut value {
map.insert(
"channel".to_string(),
serde_json::Value::String(channel.to_string()),
);
}
value.to_string()
}
#[derive(Debug)]
pub struct ChildChannel {
name: String,
worktree: PathBuf,
inner: Mutex<ChildInner>,
}
#[derive(Debug)]
struct ChildInner {
journal: Journal,
thread: Vec<ChatTurn>,
}
impl ChildChannel {
pub fn open(origin: &Path, name: &str) -> anyhow::Result<Option<Self>> {
let worktree = child_worktree_path(origin, name);
if !worktree.is_dir() {
return Ok(None);
}
let (journal, events) = Journal::open(&journal_path(&worktree, name))?;
let fold = fold_thread(&events);
let mut thread = fold.turns;
thread.extend(fold.open);
Ok(Some(Self {
name: name.to_string(),
worktree,
inner: Mutex::new(ChildInner { journal, thread }),
}))
}
pub fn name(&self) -> &str {
&self.name
}
pub fn alive(&self) -> bool {
self.worktree.is_dir()
}
pub fn thread_snapshot(&self) -> Vec<ChatTurn> {
self.inner
.lock()
.expect("child channel lock poisoned")
.thread
.clone()
}
pub fn deliver(
&self,
family_tx: &broadcast::Sender<ChannelFrame>,
op: MessageOp,
text: String,
from: Option<Attribution>,
) -> anyhow::Result<Option<ChatTurn>> {
if op == MessageOp::Interrupt && text.trim().is_empty() {
return Ok(None);
}
if !self.alive() {
anyhow::bail!(
"channel '{}' ended: its worktree is gone ({})",
self.name,
self.worktree.display()
);
}
let mut inner = self.inner.lock().expect("child channel lock poisoned");
let event = inner.journal.append(|seq| EventKind::UserMessage {
id: MessageId(format!("msg-{seq}")),
op,
text: text.clone(),
from: from.clone(),
});
let mut turn = ChatTurn::user(format!("turn-{}", event.seq), text);
turn.created_at = event.at_rfc3339();
turn.from = from.map(|from| from.label);
inner.thread.push(turn.clone());
let _ = family_tx.send(ChannelFrame {
channel: self.name.clone(),
json: tagged_turn_json(&self.name, &turn).into(),
turn: std::sync::Arc::new(turn.clone()),
});
Ok(Some(turn))
}
}
pub fn scan_child_channels(origin: &Path, wave: &str) -> Vec<String> {
let repo_name = origin
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("repo");
let Some(parent) = origin.parent() else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(parent) else {
return Vec::new();
};
let prefix = format!("{repo_name}.{wave}.");
let mut channels: Vec<String> = entries
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.file_name().to_str().map(str::to_string))
.filter_map(|dir| {
dir.strip_prefix(&prefix)
.map(|rest| format!("{wave}.{rest}"))
})
.filter(|channel| child_journal_path(origin, channel).is_file())
.collect();
channels.sort();
channels
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn family_naming_rules() {
assert_eq!(family_head("goals.148e0e02"), "goals");
assert_eq!(family_head("goals"), "goals");
assert!(matches_prefix("goals", "goals"));
assert!(matches_prefix("goals.148e0e02", "goals"));
assert!(matches_prefix("goals.a.b", "goals.a"));
assert!(!matches_prefix("goals.ab", "goals.a"));
assert!(!matches_prefix("goalsmith", "goals"));
assert!(!matches_prefix("concerto", "goals"));
}
#[test]
fn child_paths_follow_the_ownership_naming() {
let origin = Path::new("/src/loopflow");
assert_eq!(
child_worktree_path(origin, "goals.148e0e02"),
Path::new("/src/loopflow.goals.148e0e02")
);
assert_eq!(
child_journal_path(origin, "goals.148e0e02"),
Path::new(
"/src/loopflow.goals.148e0e02/.lf/journal/waves/goals.148e0e02/journal.jsonl"
)
);
}
#[test]
fn open_returns_none_for_a_missing_worktree() {
let tmp = tempfile::tempdir().expect("tempdir");
let origin = tmp.path().join("repo");
std::fs::create_dir_all(&origin).unwrap();
assert!(ChildChannel::open(&origin, "ship.dead")
.expect("open tolerates absence")
.is_none());
}
#[test]
fn deliver_journals_commits_and_broadcasts_tagged() {
let tmp = tempfile::tempdir().expect("tempdir");
let origin = tmp.path().join("repo");
std::fs::create_dir_all(child_worktree_path(&origin, "ship.abc")).unwrap();
let channel = ChildChannel::open(&origin, "ship.abc")
.expect("open")
.expect("worktree exists");
let (tx, mut rx) = broadcast::channel(8);
let turn = channel
.deliver(
&tx,
MessageOp::Say,
"landed".into(),
Some(Attribution {
session_id: None,
label: "worker".into(),
}),
)
.expect("deliver")
.expect("a say appends");
assert_eq!(turn.text, "landed");
assert_eq!(turn.from.as_deref(), Some("worker"));
let frame = rx.try_recv().expect("tagged frame");
assert_eq!(frame.channel, "ship.abc");
assert_eq!(frame.turn.id, turn.id);
assert!(channel
.deliver(&tx, MessageOp::Interrupt, " ".into(), None)
.expect("noop")
.is_none());
drop(channel);
let reopened = ChildChannel::open(&origin, "ship.abc")
.expect("reopen")
.expect("still there");
let thread = reopened.thread_snapshot();
assert_eq!(thread.len(), 1);
assert_eq!(thread[0].text, "landed");
}
#[test]
fn scan_finds_only_journaled_family_worktrees() {
let tmp = tempfile::tempdir().expect("tempdir");
let origin = tmp.path().join("repo");
std::fs::create_dir_all(&origin).unwrap();
for name in ["ship.a", "ship.b"] {
std::fs::create_dir_all(child_worktree_path(&origin, name)).unwrap();
Journal::open(&child_journal_path(&origin, name)).expect("init journal");
}
std::fs::create_dir_all(child_worktree_path(&origin, "ship.bare")).unwrap();
std::fs::create_dir_all(tmp.path().join("repo.other.c")).unwrap();
assert_eq!(
scan_child_channels(&origin, "ship"),
vec!["ship.a", "ship.b"]
);
}
}