roboticus-api 0.11.3

HTTP routes, WebSocket, auth, rate limiting, and dashboard for the Roboticus agent runtime
Documentation
/// Returns a task event feed for the workspace dashboard.
///
/// Includes recent events (up to `limit`) and a summary of all currently
/// active (non-terminal) tasks.
pub(crate) fn task_event_feed(db: &roboticus_db::Database) -> serde_json::Value {
    let events = roboticus_db::task_events::recent_task_events(db, 50).unwrap_or_default();
    let active = roboticus_db::task_events::active_task_summaries(db).unwrap_or_default();
    let active_count = active.len();
    serde_json::json!({
        "recent_events": events,
        "active_tasks": active,
        "active_count": active_count,
    })
}

/// Determine the current task state for a subagent.
///
/// Returns `"idle"`, `"running"`, or `"failed"` based on the latest
/// non-terminal task event recorded for the agent.
pub(crate) fn agent_task_state(
    db: &roboticus_db::Database,
    agent_name: &str,
) -> &'static str {
    use roboticus_db::task_events::TaskLifecycleState;

    let events =
        roboticus_db::task_events::task_events_for_agent(db, agent_name).unwrap_or_default();
    if events.is_empty() {
        return "idle";
    }
    // Events are ordered by created_at DESC. Collect the latest event per task_id.
    let mut seen = std::collections::HashSet::new();
    let mut latest_per_task: Vec<&roboticus_db::task_events::TaskEventRow> = Vec::new();
    for evt in &events {
        if seen.insert(evt.task_id.as_str()) {
            latest_per_task.push(evt);
        }
    }

    // Check all latest-per-task events (not just non-terminal) for the most
    // significant state. Priority: failed > running > assigned/retry/progress > idle.
    let has_failed = latest_per_task
        .iter()
        .any(|e| e.event_type == TaskLifecycleState::Failed);
    if has_failed {
        return "failed";
    }
    let has_running = latest_per_task
        .iter()
        .any(|e| e.event_type == TaskLifecycleState::Running);
    if has_running {
        return "running";
    }
    let has_in_progress = latest_per_task.iter().any(|e| {
        matches!(
            e.event_type,
            TaskLifecycleState::Assigned
                | TaskLifecycleState::Progress
                | TaskLifecycleState::Retry
                | TaskLifecycleState::Pending
        )
    });
    if has_in_progress {
        return "working";
    }
    // All tasks are terminal (completed/cancelled) — agent is idle.
    "idle"
}

/// GET /api/admin/task-events — recent task lifecycle events for the dashboard.
///
/// Accepts optional query params:
/// - `limit` — maximum number of events to return (default 50, max 200)
/// - `task_id` — filter to events for a single task
pub async fn get_task_events(
    State(state): State<AppState>,
    Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
    let limit = params
        .get("limit")
        .and_then(|v| v.parse::<i64>().ok())
        .unwrap_or(50)
        .min(200);
    let task_id = params.get("task_id").map(|s| s.as_str());

    let events = if let Some(tid) = task_id {
        roboticus_db::task_events::task_events_for_task(&state.db, tid).unwrap_or_default()
    } else {
        roboticus_db::task_events::recent_task_events(&state.db, limit).unwrap_or_default()
    };

    let count = events.len();
    Json(serde_json::json!({
        "events": events,
        "count": count,
    }))
}

/// GET /api/admin/workspace-tasks — workspace state enriched with per-agent
/// task indicators and a task event feed.
///
/// Builds on the existing workspace state (agents, systems, files) and adds:
/// - `task_state` and `active_task_*` fields to each agent card
/// - `task_feed` — recent events and active task summaries
/// - `delegation_flows` — first-seen task assignment records
pub async fn get_workspace_tasks(State(state): State<AppState>) -> impl IntoResponse {
    let agents = state.registry.list_agents().await;
    let config = state.config.read().await;
    let now = chrono::Utc::now();
    let workspace_root = std::path::Path::new(&config.agent.workspace);
    let files = workspace_files_snapshot(workspace_root);

    let systems: Vec<serde_json::Value> = vec![
        serde_json::json!({ "id": "llm",        "name": "LLM Inference",   "kind": "Inference",   "x": 0.18, "y": 0.22 }),
        serde_json::json!({ "id": "memory",     "name": "Memory",          "kind": "Storage",     "x": 0.82, "y": 0.22 }),
        serde_json::json!({ "id": "exec",       "name": "Code Execution",  "kind": "Execution",   "x": 0.18, "y": 0.78 }),
        serde_json::json!({ "id": "blockchain", "name": "Blockchain",      "kind": "Blockchain",  "x": 0.82, "y": 0.78 }),
        serde_json::json!({ "id": "web",        "name": "Web / APIs",      "kind": "Tool",        "x": 0.50, "y": 0.12 }),
        serde_json::json!({ "id": "files",      "name": "File System",     "kind": "Tool",        "x": 0.50, "y": 0.88 }),
        serde_json::json!({ "id": "tools_plugins", "name": "Tools / Plugins", "kind": "Plugin", "x": 0.965, "y": 0.50 }),
        serde_json::json!({ "id": "shelter",    "name": "Idle Agents",     "kind": "Shelter",     "x": 0.035, "y": 0.50 }),
    ];

    let skills = roboticus_db::skills::list_skills(&state.db)
        .inspect_err(|e| tracing::error!(error = %e, "workspace-tasks: failed to load skills"))
        .unwrap_or_default();
    let enabled_skills: Vec<String> = skills
        .iter()
        .filter(|s| s.enabled)
        .map(|s| s.name.clone())
        .collect();

    // Build per-agent cards with task state enrichment.
    let agent_list: Vec<serde_json::Value> = agents
        .iter()
        .enumerate()
        .map(|(i, a)| {
            let color = WORKSPACE_PALETTE[(i + 1) % WORKSPACE_PALETTE.len()];
            let running = format!("{:?}", a.state).to_lowercase() == "running";
            let (workstation, activity, active_skill) =
                derive_workspace_activity(&state.db, &a.id, running, now);
            let task_state = agent_task_state(&state.db, &a.name);

            let mut card = serde_json::json!({
                "id": a.id,
                "name": a.name,
                "role": ROLE_SUBAGENT,
                "state": a.state,
                "color": color,
                "model": a.model,
                "current_workstation": workstation,
                "activity": activity,
                "active_skill": active_skill,
                "updated_at": chrono::Utc::now().to_rfc3339(),
                "subordinates": [],
                "supervisor": config.agent.id,
                "task_state": task_state,
            });

            // Enrich with task event data — use the real event timestamp
            // instead of now() so the status panel shows accurate recency.
            let events =
                roboticus_db::task_events::task_events_for_agent(&state.db, &a.name)
                    .unwrap_or_default();
            if let Some(latest) = events.first() {
                card["last_event_at"] = serde_json::json!(latest.created_at);
                if task_state != "idle" {
                    card["active_task_id"] = serde_json::json!(latest.task_id);
                    if let Some(ref summary) = latest.summary {
                        card["active_task_summary"] = serde_json::json!(summary);
                    }
                    if let Some(pct) = latest.percentage {
                        card["active_task_percentage"] = serde_json::json!(pct);
                    }
                }
            }

            card
        })
        .collect();

    let (main_workstation, main_activity, main_active_skill) =
        derive_workspace_activity(&state.db, &config.agent.id, true, now);
    let main_task_state = agent_task_state(&state.db, &config.agent.name);

    let mut main_agent = serde_json::json!({
        "id": config.agent.id,
        "name": config.agent.name,
        "role": "agent",
        "state": "Running",
        "color": WORKSPACE_PALETTE[0],
        "model": config.models.primary,
        "current_workstation": main_workstation,
        "activity": main_activity,
        "active_skill": main_active_skill.or_else(|| enabled_skills.first().cloned()),
        "skills": enabled_skills,
        "updated_at": chrono::Utc::now().to_rfc3339(),
        "subordinates": agent_list.iter()
            .filter(|a| a["role"] == ROLE_SUBAGENT)
            .map(|a| a["id"].clone())
            .collect::<Vec<_>>(),
        "supervisor": serde_json::Value::Null,
        "task_state": main_task_state,
    });

    // Enrich main agent with task event data — same as subagent cards.
    let main_events =
        roboticus_db::task_events::task_events_for_agent(&state.db, &config.agent.name)
            .unwrap_or_default();
    if let Some(latest) = main_events.first() {
        main_agent["last_event_at"] = serde_json::json!(latest.created_at);
        if main_task_state != "idle" {
            main_agent["active_task_id"] = serde_json::json!(latest.task_id);
            if let Some(ref summary) = latest.summary {
                main_agent["active_task_summary"] = serde_json::json!(summary);
            }
            if let Some(pct) = latest.percentage {
                main_agent["active_task_percentage"] = serde_json::json!(pct);
            }
        }
    }

    let mut all_agents = vec![main_agent];
    all_agents.extend(agent_list);

    // Build delegation flow summary (first-seen assignment per task).
    let recent = roboticus_db::task_events::recent_task_events(&state.db, 100).unwrap_or_default();
    let mut seen_tasks = std::collections::HashSet::new();
    let mut flows: Vec<serde_json::Value> = Vec::new();
    for evt in &recent {
        if seen_tasks.insert(evt.task_id.as_str())
            && let Some(ref agent) = evt.assigned_to
        {
            flows.push(serde_json::json!({
                "task_id": evt.task_id,
                "assigned_to": agent,
                "state": evt.event_type.as_str(),
                "summary": evt.summary,
                "updated_at": evt.created_at,
            }));
        }
    }

    Json(serde_json::json!({
        "agents": all_agents,
        "systems": systems,
        "files": files,
        "interactions": [],
        "task_feed": task_event_feed(&state.db),
        "delegation_flows": flows,
    }))
}