use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::schemas::{
BillingPolicy, Conversation, ConversationTurn, IntentContract, TurnRole, WorkQueue,
WorkersFile, YardConfig,
};
use crate::yaml;
pub const STATE_DIR: &str = ".agents";
pub const CONFIG_FILE: &str = "yardlet.yaml";
pub const LEGACY_CONFIG_FILE: &str = "yard.yaml";
#[derive(Debug, Clone)]
pub struct Workspace {
pub root: PathBuf,
}
impl Workspace {
pub fn discover(start: &Path) -> Option<Workspace> {
let mut dir = Some(start);
while let Some(d) = dir {
let agents = d.join(STATE_DIR);
if agents.join(CONFIG_FILE).is_file() || agents.join(LEGACY_CONFIG_FILE).is_file() {
return Some(Workspace {
root: d.to_path_buf(),
});
}
dir = d.parent();
}
None
}
pub fn at(root: &Path) -> Workspace {
Workspace {
root: root.to_path_buf(),
}
}
pub fn agents_dir(&self) -> PathBuf {
self.root.join(STATE_DIR)
}
pub fn is_initialized(&self) -> bool {
self.agents_dir().join(CONFIG_FILE).is_file()
|| self.agents_dir().join(LEGACY_CONFIG_FILE).is_file()
}
pub fn config_path(&self) -> PathBuf {
let canonical = self.agents_dir().join(CONFIG_FILE);
let legacy = self.agents_dir().join(LEGACY_CONFIG_FILE);
if !canonical.is_file() && legacy.is_file() {
legacy
} else {
canonical
}
}
pub fn queue_path(&self) -> PathBuf {
self.agents_dir().join("work-queue.yaml")
}
pub fn intent_path(&self) -> PathBuf {
self.agents_dir().join("intent-contract.yaml")
}
pub fn workers_path(&self) -> PathBuf {
self.agents_dir().join("workers.yaml")
}
pub fn conversations_dir(&self) -> PathBuf {
self.agents_dir().join("conversations")
}
pub fn conversation_path(&self, task_id: &str) -> PathBuf {
self.conversations_dir().join(format!("{task_id}.yaml"))
}
pub fn billing_path(&self) -> PathBuf {
self.agents_dir().join("billing-policy.yaml")
}
pub fn runs_dir(&self) -> PathBuf {
self.agents_dir().join("runs")
}
pub fn checkpoints_dir(&self) -> PathBuf {
self.agents_dir().join("checkpoints")
}
pub fn handoffs_dir(&self) -> PathBuf {
self.agents_dir().join("handoffs")
}
pub fn load_config(&self) -> Result<YardConfig> {
load_yaml(&self.config_path())
}
pub fn load_queue(&self) -> Result<WorkQueue> {
load_yaml(&self.queue_path())
}
pub fn save_queue(&self, queue: &WorkQueue) -> Result<()> {
save_yaml(&self.queue_path(), queue)
}
pub fn load_workers(&self) -> Result<WorkersFile> {
load_yaml(&self.workers_path())
}
pub fn load_conversation(&self, task_id: &str) -> Conversation {
let p = self.conversation_path(task_id);
if !p.is_file() {
return Conversation {
task_id: task_id.to_string(),
turns: Vec::new(),
};
}
load_yaml(&p).unwrap_or_else(|_| Conversation {
task_id: task_id.to_string(),
turns: Vec::new(),
})
}
pub fn load_billing(&self) -> Result<BillingPolicy> {
load_yaml(&self.billing_path())
}
pub fn load_intent(&self) -> Result<Option<IntentContract>> {
let p = self.intent_path();
if !p.is_file() {
return Ok(None);
}
Ok(Some(load_yaml(&p)?))
}
}
pub fn load_yaml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
yaml::from_str(&text).with_context(|| format!("parsing {}", path.display()))
}
pub fn save_yaml<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let text = yaml::to_string(value)?;
fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
pub fn append_conversation_turn(
ws: &Workspace,
task_id: &str,
turn: ConversationTurn,
) -> Result<()> {
let mut conv = ws.load_conversation(task_id);
if conv.task_id.is_empty() {
conv.task_id = task_id.to_string();
}
if turn.role == TurnRole::Worker
&& !turn.run_id.is_empty()
&& conv
.turns
.iter()
.any(|t| t.role == TurnRole::Worker && t.run_id == turn.run_id)
{
return Ok(());
}
if conv
.turns
.last()
.is_some_and(|t| t.role == turn.role && t.text.trim() == turn.text.trim())
{
return Ok(());
}
conv.turns.push(turn);
save_yaml(&ws.conversation_path(task_id), &conv)
}
pub fn write_str(path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
fs::write(path, contents).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn worker(text: &str, run_id: &str) -> ConversationTurn {
ConversationTurn {
role: TurnRole::Worker,
text: text.into(),
run_id: run_id.into(),
ts: String::new(),
}
}
fn user(text: &str) -> ConversationTurn {
ConversationTurn {
role: TurnRole::User,
text: text.into(),
run_id: String::new(),
ts: String::new(),
}
}
#[test]
fn conversation_appends_dedupes_and_roundtrips() {
let dir = std::env::temp_dir().join(format!("yard-conv-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let ws = Workspace::at(&dir);
append_conversation_turn(&ws, "YARD-1", worker("Forward+ or GL?", "run-1")).unwrap();
append_conversation_turn(&ws, "YARD-1", worker("dup of run-1", "run-1")).unwrap();
append_conversation_turn(&ws, "YARD-1", user("what is Forward+?")).unwrap();
append_conversation_turn(&ws, "YARD-1", user("what is Forward+?")).unwrap();
append_conversation_turn(
&ws,
"YARD-1",
worker("Forward+ is the advanced path", "run-2"),
)
.unwrap();
let conv = ws.load_conversation("YARD-1");
assert_eq!(conv.task_id, "YARD-1");
assert_eq!(conv.turns.len(), 3, "the two duplicate turns are dropped");
assert_eq!(conv.turns[0].role, TurnRole::Worker);
assert_eq!(conv.turns[0].text, "Forward+ or GL?");
assert_eq!(conv.turns[1].role, TurnRole::User);
assert_eq!(conv.turns[2].run_id, "run-2");
assert!(ws.load_conversation("YARD-2").turns.is_empty());
let _ = fs::remove_dir_all(&dir);
}
}