roboticus-api 0.11.3

HTTP routes, WebSocket, auth, rate limiting, and dashboard for the Roboticus agent runtime
Documentation
// Creates an in-memory database with the task_events table bootstrapped inline.
// The embedded schema does not include task_events (added via migration), so
// tests must create the table directly.
fn task_events_test_db() -> roboticus_db::Database {
    let db = roboticus_db::Database::new(":memory:").expect("in-memory db");
    db.conn()
        .execute_batch(
            "CREATE TABLE IF NOT EXISTS task_events ( \
                id TEXT PRIMARY KEY, \
                task_id TEXT NOT NULL, \
                parent_task_id TEXT, \
                assigned_to TEXT, \
                event_type TEXT NOT NULL CHECK ( \
                    event_type IN ('pending','assigned','running','progress','completed','failed','cancelled','retry') \
                ), \
                summary TEXT, \
                detail_json TEXT, \
                percentage REAL, \
                retry_count INTEGER NOT NULL DEFAULT 0, \
                created_at TEXT NOT NULL DEFAULT (datetime('now')) \
            ); \
            CREATE INDEX IF NOT EXISTS idx_task_events_task_id ON task_events(task_id); \
            CREATE INDEX IF NOT EXISTS idx_task_events_assigned_to ON task_events(assigned_to); \
            CREATE INDEX IF NOT EXISTS idx_task_events_created ON task_events(created_at DESC);",
        )
        .expect("create task_events table");
    db
}

#[test]
fn task_events_api_response_shape() {
    use roboticus_db::task_events::{TaskEventRow, TaskLifecycleState};
    let event = TaskEventRow {
        id: "evt-1".into(),
        task_id: "task-1".into(),
        parent_task_id: None,
        assigned_to: Some("code-analyst".into()),
        event_type: TaskLifecycleState::Running,
        summary: Some("Analyzing code".into()),
        detail_json: None,
        percentage: Some(30.0),
        retry_count: 0,
        created_at: "2026-03-23T10:00:00".into(),
    };
    let json = serde_json::to_value(&event).unwrap();
    assert_eq!(json["event_type"], "running");
    assert_eq!(json["task_id"], "task-1");
    assert_eq!(json["percentage"], 30.0);
}

#[test]
fn task_lifecycle_state_is_terminal() {
    use roboticus_db::task_events::TaskLifecycleState;
    assert!(TaskLifecycleState::Completed.is_terminal());
    assert!(TaskLifecycleState::Failed.is_terminal());
    assert!(TaskLifecycleState::Cancelled.is_terminal());
    assert!(!TaskLifecycleState::Running.is_terminal());
    assert!(!TaskLifecycleState::Pending.is_terminal());
    assert!(!TaskLifecycleState::Assigned.is_terminal());
}

#[test]
fn task_lifecycle_state_as_str_roundtrip() {
    use roboticus_db::task_events::TaskLifecycleState;
    let states = [
        TaskLifecycleState::Pending,
        TaskLifecycleState::Running,
        TaskLifecycleState::Failed,
        TaskLifecycleState::Completed,
    ];
    for s in states {
        let parsed = TaskLifecycleState::from_str_opt(s.as_str());
        assert_eq!(parsed, Some(s), "round-trip failed for {}", s.as_str());
    }
}

#[test]
fn agent_task_state_returns_idle_for_no_events() {
    let db = task_events_test_db();
    let state = agent_task_state(&db, "nonexistent-agent");
    assert_eq!(state, "idle");
}

#[test]
fn agent_task_state_returns_running_for_active_task() {
    use roboticus_db::task_events::{TaskEventRow, TaskLifecycleState, insert_task_event};
    let db = task_events_test_db();
    insert_task_event(
        &db,
        &TaskEventRow {
            id: "evt-run-1".into(),
            task_id: "task-run-1".into(),
            parent_task_id: None,
            assigned_to: Some("worker-agent".into()),
            event_type: TaskLifecycleState::Running,
            summary: Some("Doing work".into()),
            detail_json: None,
            percentage: Some(50.0),
            retry_count: 0,
            created_at: String::new(),
        },
    )
    .unwrap();
    assert_eq!(agent_task_state(&db, "worker-agent"), "running");
}

#[test]
fn agent_task_state_returns_idle_after_completed_task() {
    use roboticus_db::task_events::{TaskEventRow, TaskLifecycleState, insert_task_event};
    let db = task_events_test_db();
    // Use explicit timestamps so ordering is deterministic: running before completed.
    insert_task_event(
        &db,
        &TaskEventRow {
            id: "evt-done-1".into(),
            task_id: "task-done-1".into(),
            parent_task_id: None,
            assigned_to: Some("done-agent".into()),
            event_type: TaskLifecycleState::Running,
            summary: None,
            detail_json: None,
            percentage: None,
            retry_count: 0,
            created_at: "2026-03-23 10:00:00".into(),
        },
    )
    .unwrap();
    insert_task_event(
        &db,
        &TaskEventRow {
            id: "evt-done-2".into(),
            task_id: "task-done-1".into(),
            parent_task_id: None,
            assigned_to: Some("done-agent".into()),
            event_type: TaskLifecycleState::Completed,
            summary: None,
            detail_json: None,
            percentage: None,
            retry_count: 0,
            created_at: "2026-03-23 10:00:01".into(),
        },
    )
    .unwrap();
    // The latest event per task is Completed (terminal), so the agent should be idle.
    assert_eq!(agent_task_state(&db, "done-agent"), "idle");
}

#[test]
fn task_event_feed_shape() {
    let db = task_events_test_db();
    let feed = task_event_feed(&db);
    assert!(feed["recent_events"].is_array());
    assert!(feed["active_tasks"].is_array());
    assert_eq!(feed["active_count"].as_u64(), Some(0));
}