use std::sync::{Arc, OnceLock};
use tokio::sync::Mutex;
use super::types::{ThreadGoal, ThreadGoalStatus};
use crate::error::{Result, TinyAgentsError};
use crate::graph::thread_locks::ThreadLockMap;
use crate::harness::ids::{next_seq, now_ms};
use crate::harness::store::Store;
pub const GOALS_NAMESPACE: &str = "graph.goals";
fn thread_lock(thread_id: &str) -> Arc<Mutex<()>> {
static LOCKS: OnceLock<ThreadLockMap> = OnceLock::new();
LOCKS
.get_or_init(|| ThreadLockMap::new("goal lock map"))
.lock_for(thread_id)
}
fn key(thread_id: &str) -> String {
thread_id
.as_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn new_goal_id() -> String {
format!("goal-{}", next_seq())
}
fn validate_thread_id(thread_id: &str) -> Result<String> {
let trimmed = thread_id.trim();
if trimmed.is_empty() {
return Err(TinyAgentsError::Validation(
"thread goal thread_id must not be empty or whitespace".to_string(),
));
}
Ok(trimmed.to_string())
}
async fn load(store: &Arc<dyn Store>, thread_id: &str) -> Result<Option<ThreadGoal>> {
match store.get(GOALS_NAMESPACE, &key(thread_id)).await? {
Some(value) => Ok(Some(serde_json::from_value(value)?)),
None => Ok(None),
}
}
async fn save(store: &Arc<dyn Store>, goal: &ThreadGoal) -> Result<()> {
let value = serde_json::to_value(goal)?;
store
.put(GOALS_NAMESPACE, &key(&goal.thread_id), value)
.await
}
pub async fn get(store: &Arc<dyn Store>, thread_id: &str) -> Result<Option<ThreadGoal>> {
let thread_id = validate_thread_id(thread_id)?;
load(store, &thread_id).await
}
pub async fn list_all(store: &Arc<dyn Store>) -> Result<Vec<ThreadGoal>> {
let mut goals = Vec::new();
for k in store.list(GOALS_NAMESPACE).await? {
if let Some(value) = store.get(GOALS_NAMESPACE, &k).await?
&& let Ok(goal) = serde_json::from_value::<ThreadGoal>(value)
{
goals.push(goal);
}
}
Ok(goals)
}
async fn compute_and_put_set(
store: &Arc<dyn Store>,
thread_id: &str,
objective: &str,
token_budget: Option<u64>,
) -> Result<ThreadGoal> {
let now = now_ms();
let goal = match load(store, thread_id).await? {
Some(mut existing) if existing.objective == objective => {
existing.token_budget = token_budget;
existing.continuation_suppressed = false;
existing.updated_at_ms = now;
existing.status = if existing.over_budget() {
ThreadGoalStatus::BudgetLimited
} else {
ThreadGoalStatus::Active
};
existing
}
existing => {
let created_at_ms = existing.as_ref().map(|g| g.created_at_ms).unwrap_or(now);
ThreadGoal {
thread_id: thread_id.to_string(),
goal_id: new_goal_id(),
objective: objective.to_string(),
status: ThreadGoalStatus::Active,
token_budget,
tokens_used: 0,
time_used_seconds: 0,
created_at_ms,
updated_at_ms: now,
continuation_suppressed: false,
}
}
};
save(store, &goal).await?;
Ok(goal)
}
pub async fn set(
store: &Arc<dyn Store>,
thread_id: &str,
objective: &str,
token_budget: Option<u64>,
) -> Result<ThreadGoal> {
let objective = objective.trim();
if objective.is_empty() {
return Err(TinyAgentsError::Validation(
"thread goal objective must not be empty".to_string(),
));
}
let thread_id = validate_thread_id(thread_id)?;
let lock = thread_lock(&thread_id);
let _guard = lock.lock().await;
compute_and_put_set(store, &thread_id, objective, token_budget).await
}
pub async fn set_if_absent(
store: &Arc<dyn Store>,
thread_id: &str,
objective: &str,
token_budget: Option<u64>,
) -> Result<Option<ThreadGoal>> {
let objective = objective.trim();
if objective.is_empty() {
return Err(TinyAgentsError::Validation(
"thread goal objective must not be empty".to_string(),
));
}
let thread_id = validate_thread_id(thread_id)?;
let lock = thread_lock(&thread_id);
let _guard = lock.lock().await;
if load(store, &thread_id).await?.is_some() {
return Ok(None);
}
Ok(Some(
compute_and_put_set(store, &thread_id, objective, token_budget).await?,
))
}
pub async fn clear(store: &Arc<dyn Store>, thread_id: &str) -> Result<bool> {
let thread_id = validate_thread_id(thread_id)?;
let lock = thread_lock(&thread_id);
let _guard = lock.lock().await;
let existed = load(store, &thread_id).await?.is_some();
store.delete(GOALS_NAMESPACE, &key(&thread_id)).await?;
Ok(existed)
}
async fn mutate<F>(store: &Arc<dyn Store>, thread_id: &str, f: F) -> Result<ThreadGoal>
where
F: FnOnce(&mut ThreadGoal),
{
let thread_id = validate_thread_id(thread_id)?;
let lock = thread_lock(&thread_id);
let _guard = lock.lock().await;
let mut goal = load(store, &thread_id).await?.ok_or_else(|| {
TinyAgentsError::Validation(format!("no thread goal for thread '{thread_id}'"))
})?;
f(&mut goal);
goal.updated_at_ms = now_ms();
save(store, &goal).await?;
Ok(goal)
}
pub async fn complete(store: &Arc<dyn Store>, thread_id: &str) -> Result<ThreadGoal> {
mutate(store, thread_id, |g| {
g.status = ThreadGoalStatus::Complete;
g.continuation_suppressed = true;
})
.await
}
pub async fn pause(store: &Arc<dyn Store>, thread_id: &str) -> Result<ThreadGoal> {
mutate(store, thread_id, |g| {
if g.status.is_active() {
g.status = ThreadGoalStatus::Paused;
}
})
.await
}
pub async fn resume(store: &Arc<dyn Store>, thread_id: &str) -> Result<ThreadGoal> {
mutate(store, thread_id, |g| {
if matches!(g.status, ThreadGoalStatus::Paused) {
g.status = ThreadGoalStatus::Active;
g.continuation_suppressed = false;
}
})
.await
}
pub async fn set_continuation_suppressed_if(
store: &Arc<dyn Store>,
thread_id: &str,
expected_goal_id: &str,
suppressed: bool,
) -> Result<Option<ThreadGoal>> {
let thread_id = validate_thread_id(thread_id)?;
let lock = thread_lock(&thread_id);
let _guard = lock.lock().await;
let Some(mut goal) = load(store, &thread_id).await? else {
return Ok(None);
};
if goal.goal_id != expected_goal_id
|| !goal.status.is_active()
|| goal.continuation_suppressed == suppressed
{
return Ok(Some(goal));
}
goal.continuation_suppressed = suppressed;
goal.updated_at_ms = now_ms();
save(store, &goal).await?;
Ok(Some(goal))
}
pub async fn account_usage(
store: &Arc<dyn Store>,
thread_id: &str,
expected_goal_id: &str,
token_delta: u64,
secs_delta: u64,
) -> Result<Option<ThreadGoal>> {
let thread_id = validate_thread_id(thread_id)?;
let lock = thread_lock(&thread_id);
let _guard = lock.lock().await;
let Some(mut goal) = load(store, &thread_id).await? else {
return Ok(None);
};
if goal.goal_id != expected_goal_id {
return Ok(Some(goal));
}
if token_delta == 0 && secs_delta == 0 {
return Ok(Some(goal));
}
goal.tokens_used = goal.tokens_used.saturating_add(token_delta);
goal.time_used_seconds = goal.time_used_seconds.saturating_add(secs_delta);
if goal.status.is_active() && goal.over_budget() {
goal.status = ThreadGoalStatus::BudgetLimited;
}
goal.updated_at_ms = now_ms();
save(store, &goal).await?;
Ok(Some(goal))
}