diff --git a/contracts/agent-vault/src/lib.rs b/contracts/agent-vault/src/lib.rs index 57f5b6e..ab69f30 100644 --- a/contracts/agent-vault/src/lib.rs +++ b/contracts/agent-vault/src/lib.rs @@ -241,6 +241,15 @@ pub struct TaskInfo { pub created_at: u64, } +/// Authoritative lifecycle state for a task at the current ledger timestamp. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TaskStatus { + Active, + Stale, + Completed, +} + // Constants /// Tasks older than this that haven't completed can be force-finalized by anyone. @@ -268,7 +277,7 @@ const INSTANCE_TTL_EXTEND_TO: u32 = 518_400; // ~30 days /// deployment before assuming a given function or storage layout /// exists, especially important on Soroban where the same address /// can be upgraded in place. -const CONTRACT_VERSION: u32 = 2; +const CONTRACT_VERSION: u32 = 3; // Contract @@ -843,10 +852,7 @@ impl AgentVault { return Err(VaultError::TaskAlreadyCompleted); } - let now = env.ledger().timestamp(); - let elapsed = now - task.created_at; - let threshold = Self::get_stale_threshold(env.clone()); - if elapsed <= threshold { + if !Self::is_task_stale(&env, &task) { return Err(VaultError::TaskNotStale); } @@ -856,6 +862,12 @@ impl AgentVault { // Internal helpers + /// Uses the live threshold so status queries and force completion cannot drift. + fn is_task_stale(env: &Env, task: &TaskInfo) -> bool { + let elapsed = env.ledger().timestamp() - task.created_at; + elapsed > Self::get_stale_threshold(env.clone()) + } + /// Panics if the contract is paused. fn require_not_paused(env: &Env) -> Result<(), VaultError> { let paused = env @@ -1163,6 +1175,21 @@ impl AgentVault { result } + /// Returns the task's lifecycle state at the current ledger timestamp. + pub fn get_task_status(env: Env, task_id: u64) -> Option { + let key = DataKey::Task(task_id); + let task: TaskInfo = env.storage().persistent().get(&key)?; + Self::extend_persistent_ttl(&env, &key); + + if task.completed { + Some(TaskStatus::Completed) + } else if Self::is_task_stale(&env, &task) { + Some(TaskStatus::Stale) + } else { + Some(TaskStatus::Active) + } + } + pub fn get_user_tasks(env: Env, user: Address) -> soroban_sdk::Vec { let key = DataKey::UserTasks(user); let result = env diff --git a/contracts/agent-vault/src/tests.rs b/contracts/agent-vault/src/tests.rs index 27e0e51..a705312 100644 --- a/contracts/agent-vault/src/tests.rs +++ b/contracts/agent-vault/src/tests.rs @@ -1,4 +1,4 @@ -use crate::{AgentVault, AgentVaultClient, DataKey, VaultError}; +use crate::{AgentVault, AgentVaultClient, DataKey, TaskStatus, VaultError}; use soroban_sdk::testutils::storage::Persistent as _; use soroban_sdk::testutils::{Address as _, Events, Ledger as _}; use soroban_sdk::{token, Address, Env}; @@ -63,6 +63,25 @@ fn create_task_history(test_env: &TestEnv, task_count: u32) -> Address { user } +fn create_status_test_task(test_env: &TestEnv, created_at: u64) -> (Address, Address, u64) { + let user = Address::generate(&test_env.env); + let orchestrator = Address::generate(&test_env.env); + let name = soroban_sdk::String::from_str(&test_env.env, "status-orchestrator"); + + test_env.token_admin_client.mint(&user, &1000); + test_env.client.deposit(&user, &test_env.usdc_sac, &500); + test_env + .client + .register_orchestrator(&user, &orchestrator, &name); + + test_env.env.ledger().set_timestamp(created_at); + let task_id = test_env + .client + .create_task(&orchestrator, &test_env.usdc_sac, &300); + + (user, orchestrator, task_id) +} + // 1. Init Tests #[test] @@ -1867,6 +1886,82 @@ fn test_get_user_task_infos_caps_limit() { // 11. Stale Task Threshold Tests +#[test] +fn test_get_task_status_returns_none_for_unknown_task() { + let t = setup_test(); + t.client.init(&t.admin, &t.usdc_sac); + + assert_eq!(t.client.get_task_status(&999), None); +} + +#[test] +fn test_get_task_status_matches_force_complete_boundary() { + let t = setup_test(); + t.client.init(&t.admin, &t.usdc_sac); + let (_, _, task_id) = create_status_test_task(&t, 1000); + + t.env.ledger().set_timestamp(1000 + 1800); + assert_eq!(t.client.get_task_status(&task_id), Some(TaskStatus::Active)); + let result = t.client.try_force_complete_stale_task(&task_id); + assert!(result == Err(Ok(VaultError::TaskNotStale))); + + t.env.ledger().set_timestamp(1000 + 1801); + assert_eq!(t.client.get_task_status(&task_id), Some(TaskStatus::Stale)); + + t.client.force_complete_stale_task(&task_id); + assert_eq!( + t.client.get_task_status(&task_id), + Some(TaskStatus::Completed) + ); +} + +#[test] +fn test_get_task_status_completed_wins_after_threshold() { + let t = setup_test(); + t.client.init(&t.admin, &t.usdc_sac); + let (_, orchestrator, task_id) = create_status_test_task(&t, 1000); + + t.client.complete_task(&orchestrator, &task_id); + t.env.ledger().set_timestamp(1000 + 1801); + + assert_eq!( + t.client.get_task_status(&task_id), + Some(TaskStatus::Completed) + ); +} + +#[test] +fn test_get_task_status_cancelled_task_is_completed() { + let t = setup_test(); + t.client.init(&t.admin, &t.usdc_sac); + let (user, _, task_id) = create_status_test_task(&t, 1000); + + t.client.cancel_task(&user, &task_id); + + assert_eq!( + t.client.get_task_status(&task_id), + Some(TaskStatus::Completed) + ); +} + +#[test] +fn test_get_task_status_uses_threshold_changed_after_creation() { + let t = setup_test(); + t.client.init(&t.admin, &t.usdc_sac); + let (_, _, task_id) = create_status_test_task(&t, 1000); + + t.client.set_stale_threshold(&t.admin, &3600); + + t.env.ledger().set_timestamp(1000 + 1801); + assert_eq!(t.client.get_task_status(&task_id), Some(TaskStatus::Active)); + + t.env.ledger().set_timestamp(1000 + 3600); + assert_eq!(t.client.get_task_status(&task_id), Some(TaskStatus::Active)); + + t.env.ledger().set_timestamp(1000 + 3601); + assert_eq!(t.client.get_task_status(&task_id), Some(TaskStatus::Stale)); +} + #[test] fn test_get_stale_threshold_default() { let t = setup_test(); @@ -2639,5 +2734,5 @@ mod invariant_tests { #[test] fn test_version_returns_contract_version() { let test_env = setup_test(); - assert_eq!(test_env.client.version(), 2); + assert_eq!(test_env.client.version(), 3); }