Skip to main content

routa_core/rpc/methods/
tasks.rs

1//! RPC methods for task management.
2//!
3//! Methods:
4//! - `tasks.list`         — list tasks with optional filters
5//! - `tasks.get`          — get a single task by id
6//! - `tasks.create`       — create a new task
7//! - `tasks.delete`       — delete a task
8//! - `tasks.updateStatus` — update a task's status
9//! - `tasks.findReady`    — find tasks ready for execution
10//! - `tasks.listArtifacts` — list artifacts attached to a task
11//! - `tasks.provideArtifact` — attach an artifact to a task
12
13use chrono::Utc;
14use serde::{Deserialize, Serialize};
15use std::collections::{BTreeMap, BTreeSet};
16
17use crate::models::artifact::{Artifact, ArtifactStatus, ArtifactType};
18use crate::models::kanban::KanbanBoard;
19use crate::models::task::{Task, TaskLaneSessionStatus, TaskStatus};
20use crate::rpc::error::RpcError;
21use crate::state::AppState;
22
23const KANBAN_HAPPY_PATH_COLUMN_ORDER: [&str; 5] = ["backlog", "todo", "dev", "review", "done"];
24
25#[derive(Debug, Serialize)]
26#[serde(rename_all = "camelCase")]
27pub struct TaskArtifactSummary {
28    pub total: usize,
29    pub by_type: BTreeMap<String, usize>,
30    pub required_satisfied: bool,
31    pub missing_required: Vec<String>,
32}
33
34#[derive(Debug, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct TaskVerificationSummary {
37    pub has_verdict: bool,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub verdict: Option<String>,
40    pub has_report: bool,
41}
42
43#[derive(Debug, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct TaskCompletionSummary {
46    pub has_summary: bool,
47}
48
49#[derive(Debug, Serialize)]
50#[serde(rename_all = "camelCase")]
51pub struct TaskRunSummary {
52    pub total: usize,
53    pub latest_status: String,
54}
55
56#[derive(Debug, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct TaskEvidenceSummary {
59    pub artifact: TaskArtifactSummary,
60    pub verification: TaskVerificationSummary,
61    pub completion: TaskCompletionSummary,
62    pub runs: TaskRunSummary,
63}
64
65// ---------------------------------------------------------------------------
66// tasks.list
67// ---------------------------------------------------------------------------
68
69#[derive(Debug, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct ListParams {
72    #[serde(default = "default_workspace_id")]
73    pub workspace_id: String,
74    pub session_id: Option<String>,
75    pub status: Option<String>,
76    pub assigned_to: Option<String>,
77}
78
79fn default_workspace_id() -> String {
80    "default".into()
81}
82
83#[derive(Debug, Serialize)]
84pub struct ListResult {
85    pub tasks: Vec<serde_json::Value>,
86}
87
88pub async fn list(state: &AppState, params: ListParams) -> Result<ListResult, RpcError> {
89    let tasks = if let Some(session_id) = &params.session_id {
90        // Filter by session_id takes priority
91        state.task_store.list_by_session(session_id).await?
92    } else if let Some(assignee) = &params.assigned_to {
93        state.task_store.list_by_assignee(assignee).await?
94    } else if let Some(status_str) = &params.status {
95        let status = TaskStatus::from_str(status_str)
96            .ok_or_else(|| RpcError::BadRequest(format!("Invalid status: {}", status_str)))?;
97        state
98            .task_store
99            .list_by_status(&params.workspace_id, &status)
100            .await?
101    } else {
102        state
103            .task_store
104            .list_by_workspace(&params.workspace_id)
105            .await?
106    };
107
108    Ok(ListResult {
109        tasks: serialize_tasks_with_evidence(state, &tasks).await?,
110    })
111}
112
113// ---------------------------------------------------------------------------
114// tasks.get
115// ---------------------------------------------------------------------------
116
117#[derive(Debug, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct GetParams {
120    pub id: String,
121}
122
123pub async fn get(state: &AppState, params: GetParams) -> Result<serde_json::Value, RpcError> {
124    let task = state
125        .task_store
126        .get(&params.id)
127        .await?
128        .ok_or_else(|| RpcError::NotFound(format!("Task {} not found", params.id)))?;
129    serialize_task_with_evidence(state, &task).await
130}
131
132// ---------------------------------------------------------------------------
133// tasks.create
134// ---------------------------------------------------------------------------
135
136#[derive(Debug, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct CreateParams {
139    pub title: String,
140    pub objective: String,
141    #[serde(default = "default_workspace_id")]
142    pub workspace_id: String,
143    pub session_id: Option<String>,
144    pub scope: Option<String>,
145    pub acceptance_criteria: Option<Vec<String>>,
146    pub verification_commands: Option<Vec<String>>,
147    pub test_cases: Option<Vec<String>>,
148    pub dependencies: Option<Vec<String>>,
149    pub parallel_group: Option<String>,
150}
151
152#[derive(Debug, Serialize)]
153pub struct CreateResult {
154    pub task: serde_json::Value,
155}
156
157pub async fn create(state: &AppState, params: CreateParams) -> Result<CreateResult, RpcError> {
158    let task = Task::new(
159        uuid::Uuid::new_v4().to_string(),
160        params.title,
161        params.objective,
162        params.workspace_id,
163        params.session_id,
164        params.scope,
165        params.acceptance_criteria,
166        params.verification_commands,
167        params.test_cases,
168        params.dependencies,
169        params.parallel_group,
170    );
171
172    state.task_store.save(&task).await?;
173    Ok(CreateResult {
174        task: serialize_task_with_evidence(state, &task).await?,
175    })
176}
177
178// ---------------------------------------------------------------------------
179// tasks.delete
180// ---------------------------------------------------------------------------
181
182#[derive(Debug, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct DeleteParams {
185    pub id: String,
186}
187
188#[derive(Debug, Serialize)]
189pub struct DeleteResult {
190    pub deleted: bool,
191}
192
193pub async fn delete(state: &AppState, params: DeleteParams) -> Result<DeleteResult, RpcError> {
194    state.task_store.delete(&params.id).await?;
195    Ok(DeleteResult { deleted: true })
196}
197
198// ---------------------------------------------------------------------------
199// tasks.updateStatus
200// ---------------------------------------------------------------------------
201
202#[derive(Debug, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct UpdateStatusParams {
205    pub id: String,
206    pub status: String,
207}
208
209#[derive(Debug, Serialize)]
210pub struct UpdateStatusResult {
211    pub updated: bool,
212}
213
214pub async fn update_status(
215    state: &AppState,
216    params: UpdateStatusParams,
217) -> Result<UpdateStatusResult, RpcError> {
218    let status = TaskStatus::from_str(&params.status)
219        .ok_or_else(|| RpcError::BadRequest(format!("Invalid status: {}", params.status)))?;
220    state.task_store.update_status(&params.id, &status).await?;
221    Ok(UpdateStatusResult { updated: true })
222}
223
224// ---------------------------------------------------------------------------
225// tasks.findReady
226// ---------------------------------------------------------------------------
227
228#[derive(Debug, Deserialize)]
229#[serde(rename_all = "camelCase")]
230pub struct FindReadyParams {
231    #[serde(default = "default_workspace_id")]
232    pub workspace_id: String,
233}
234
235pub async fn find_ready(state: &AppState, params: FindReadyParams) -> Result<ListResult, RpcError> {
236    let tasks = state
237        .task_store
238        .find_ready_tasks(&params.workspace_id)
239        .await?;
240    Ok(ListResult {
241        tasks: serialize_tasks_with_evidence(state, &tasks).await?,
242    })
243}
244
245// ---------------------------------------------------------------------------
246// tasks.listArtifacts
247// ---------------------------------------------------------------------------
248
249#[derive(Debug, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub struct ListArtifactsParams {
252    pub task_id: String,
253    #[serde(rename = "type")]
254    pub artifact_type: Option<String>,
255}
256
257#[derive(Debug, Serialize)]
258pub struct ListArtifactsResult {
259    pub artifacts: Vec<Artifact>,
260}
261
262pub async fn list_artifacts(
263    state: &AppState,
264    params: ListArtifactsParams,
265) -> Result<ListArtifactsResult, RpcError> {
266    let artifacts = if let Some(artifact_type) = params.artifact_type.as_deref() {
267        let artifact_type = parse_artifact_type(artifact_type)?;
268        state
269            .artifact_store
270            .list_by_task_and_type(&params.task_id, &artifact_type)
271            .await?
272    } else {
273        state.artifact_store.list_by_task(&params.task_id).await?
274    };
275
276    Ok(ListArtifactsResult { artifacts })
277}
278
279// ---------------------------------------------------------------------------
280// tasks.provideArtifact
281// ---------------------------------------------------------------------------
282
283#[derive(Debug, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct ProvideArtifactParams {
286    pub task_id: String,
287    pub agent_id: String,
288    #[serde(rename = "type")]
289    pub artifact_type: String,
290    pub content: String,
291    pub context: Option<String>,
292    pub request_id: Option<String>,
293    pub metadata: Option<BTreeMap<String, String>>,
294}
295
296#[derive(Debug, Serialize)]
297pub struct ProvideArtifactResult {
298    pub artifact: Artifact,
299}
300
301pub async fn provide_artifact(
302    state: &AppState,
303    params: ProvideArtifactParams,
304) -> Result<ProvideArtifactResult, RpcError> {
305    let task = state
306        .task_store
307        .get(&params.task_id)
308        .await?
309        .ok_or_else(|| RpcError::NotFound(format!("Task {} not found", params.task_id)))?;
310
311    let agent_id = params.agent_id.trim();
312    if agent_id.is_empty() {
313        return Err(RpcError::BadRequest(
314            "agentId is required for artifact submission".to_string(),
315        ));
316    }
317
318    let content = params.content.trim();
319    if content.is_empty() {
320        return Err(RpcError::BadRequest(
321            "artifact content cannot be blank".to_string(),
322        ));
323    }
324
325    let artifact = Artifact {
326        id: uuid::Uuid::new_v4().to_string(),
327        artifact_type: parse_artifact_type(&params.artifact_type)?,
328        task_id: task.id,
329        workspace_id: task.workspace_id,
330        provided_by_agent_id: Some(agent_id.to_string()),
331        requested_by_agent_id: None,
332        request_id: params.request_id,
333        content: Some(content.to_string()),
334        context: params
335            .context
336            .as_deref()
337            .map(str::trim)
338            .filter(|value| !value.is_empty())
339            .map(str::to_string),
340        status: ArtifactStatus::Provided,
341        expires_at: None,
342        metadata: params.metadata,
343        created_at: Utc::now(),
344        updated_at: Utc::now(),
345    };
346
347    state.artifact_store.save(&artifact).await?;
348    Ok(ProvideArtifactResult { artifact })
349}
350
351fn parse_artifact_type(value: &str) -> Result<ArtifactType, RpcError> {
352    ArtifactType::from_str(value).ok_or_else(|| {
353        RpcError::BadRequest(format!(
354            "Invalid artifact type: {}. Expected one of: screenshot, test_results, code_diff, logs",
355            value
356        ))
357    })
358}
359
360async fn serialize_tasks_with_evidence(
361    state: &AppState,
362    tasks: &[Task],
363) -> Result<Vec<serde_json::Value>, RpcError> {
364    let mut serialized = Vec::with_capacity(tasks.len());
365    for task in tasks {
366        serialized.push(serialize_task_with_evidence(state, task).await?);
367    }
368    Ok(serialized)
369}
370
371async fn serialize_task_with_evidence(
372    state: &AppState,
373    task: &Task,
374) -> Result<serde_json::Value, RpcError> {
375    let evidence_summary = build_task_evidence_summary(state, task).await?;
376    let mut task_value = serde_json::to_value(task)
377        .map_err(|error| RpcError::Internal(format!("Failed to serialize task: {error}")))?;
378    let task_object = task_value.as_object_mut().ok_or_else(|| {
379        RpcError::Internal("Task payload must serialize to a JSON object".to_string())
380    })?;
381    task_object.insert(
382        "artifactSummary".to_string(),
383        serde_json::to_value(&evidence_summary.artifact).map_err(|error| {
384            RpcError::Internal(format!(
385                "Failed to serialize task artifact summary: {error}"
386            ))
387        })?,
388    );
389    task_object.insert(
390        "evidenceSummary".to_string(),
391        serde_json::to_value(&evidence_summary).map_err(|error| {
392            RpcError::Internal(format!(
393                "Failed to serialize task evidence summary: {error}"
394            ))
395        })?,
396    );
397    Ok(task_value)
398}
399
400async fn build_task_evidence_summary(
401    state: &AppState,
402    task: &Task,
403) -> Result<TaskEvidenceSummary, RpcError> {
404    let artifacts = state.artifact_store.list_by_task(&task.id).await?;
405    let mut by_type = BTreeMap::new();
406    for artifact in &artifacts {
407        let key = artifact.artifact_type.as_str().to_string();
408        *by_type.entry(key).or_insert(0) += 1;
409    }
410
411    let board = match task.board_id.as_deref() {
412        Some(board_id) => state.kanban_store.get(board_id).await?,
413        None => None,
414    };
415    let required_artifacts =
416        resolve_next_required_artifacts(board.as_ref(), task.column_id.as_deref());
417    let present_artifacts = by_type.keys().cloned().collect::<BTreeSet<_>>();
418    let missing_required = required_artifacts
419        .into_iter()
420        .filter(|artifact| !present_artifacts.contains(artifact))
421        .collect::<Vec<_>>();
422
423    let latest_status = task
424        .lane_sessions
425        .last()
426        .map(|session| task_lane_session_status_as_str(&session.status).to_string())
427        .unwrap_or_else(|| {
428            if task.session_ids.is_empty() {
429                "idle".to_string()
430            } else {
431                "unknown".to_string()
432            }
433        });
434
435    Ok(TaskEvidenceSummary {
436        artifact: TaskArtifactSummary {
437            total: artifacts.len(),
438            by_type,
439            required_satisfied: missing_required.is_empty(),
440            missing_required,
441        },
442        verification: TaskVerificationSummary {
443            has_verdict: task.verification_verdict.is_some(),
444            verdict: task
445                .verification_verdict
446                .as_ref()
447                .map(|verdict| verdict.as_str().to_string()),
448            has_report: task
449                .verification_report
450                .as_ref()
451                .is_some_and(|report| !report.trim().is_empty()),
452        },
453        completion: TaskCompletionSummary {
454            has_summary: task
455                .completion_summary
456                .as_ref()
457                .is_some_and(|summary| !summary.trim().is_empty()),
458        },
459        runs: TaskRunSummary {
460            total: task.session_ids.len(),
461            latest_status,
462        },
463    })
464}
465
466fn resolve_next_required_artifacts(
467    board: Option<&KanbanBoard>,
468    current_column_id: Option<&str>,
469) -> Vec<String> {
470    let current_column_id = current_column_id.unwrap_or("backlog").to_ascii_lowercase();
471    let next_column_id = KANBAN_HAPPY_PATH_COLUMN_ORDER
472        .iter()
473        .position(|column_id| *column_id == current_column_id)
474        .and_then(|index| KANBAN_HAPPY_PATH_COLUMN_ORDER.get(index + 1))
475        .copied();
476    let Some(next_column_id) = next_column_id else {
477        return Vec::new();
478    };
479
480    board
481        .and_then(|board| {
482            board
483                .columns
484                .iter()
485                .find(|column| column.id == next_column_id)
486        })
487        .and_then(|column| column.automation.as_ref())
488        .and_then(|automation| automation.required_artifacts.clone())
489        .unwrap_or_default()
490}
491
492fn task_lane_session_status_as_str(status: &TaskLaneSessionStatus) -> &'static str {
493    match status {
494        TaskLaneSessionStatus::Running => "running",
495        TaskLaneSessionStatus::Completed => "completed",
496        TaskLaneSessionStatus::Failed => "failed",
497        TaskLaneSessionStatus::TimedOut => "timed_out",
498        TaskLaneSessionStatus::Transitioned => "transitioned",
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use crate::models::kanban::KanbanColumnAutomation;
506    use crate::models::task::{TaskLaneSession, VerificationVerdict};
507    use crate::{AppState, AppStateInner, Database};
508    use std::sync::Arc;
509
510    async fn setup_state() -> AppState {
511        let db = Database::open_in_memory().expect("in-memory db should open");
512        let state: AppState = Arc::new(AppStateInner::new(db));
513        state
514            .workspace_store
515            .ensure_default()
516            .await
517            .expect("default workspace should exist");
518        state
519    }
520
521    #[tokio::test]
522    async fn provide_and_list_artifacts_roundtrip() {
523        let state = setup_state().await;
524        let created = create(
525            &state,
526            CreateParams {
527                title: "Artifact task".to_string(),
528                objective: "Store screenshot evidence".to_string(),
529                workspace_id: "default".to_string(),
530                session_id: None,
531                scope: None,
532                acceptance_criteria: None,
533                verification_commands: None,
534                test_cases: None,
535                dependencies: None,
536                parallel_group: None,
537            },
538        )
539        .await
540        .expect("task should be created");
541        let created_task_id = created.task["id"]
542            .as_str()
543            .expect("created task id")
544            .to_string();
545
546        let provided = provide_artifact(
547            &state,
548            ProvideArtifactParams {
549                task_id: created_task_id.clone(),
550                agent_id: "agent-1".to_string(),
551                artifact_type: "screenshot".to_string(),
552                content: "base64-content".to_string(),
553                context: Some("Verification screenshot".to_string()),
554                request_id: None,
555                metadata: None,
556            },
557        )
558        .await
559        .expect("artifact should be created");
560
561        assert_eq!(provided.artifact.artifact_type, ArtifactType::Screenshot);
562        assert_eq!(
563            provided.artifact.provided_by_agent_id.as_deref(),
564            Some("agent-1")
565        );
566
567        let listed = list_artifacts(
568            &state,
569            ListArtifactsParams {
570                task_id: created_task_id,
571                artifact_type: Some("screenshot".to_string()),
572            },
573        )
574        .await
575        .expect("artifacts should be listed");
576
577        assert_eq!(listed.artifacts.len(), 1);
578        assert_eq!(
579            listed.artifacts[0].context.as_deref(),
580            Some("Verification screenshot")
581        );
582    }
583
584    #[tokio::test]
585    async fn rpc_task_methods_include_evidence_summary() {
586        let state = setup_state().await;
587        let mut board = state
588            .kanban_store
589            .ensure_default_board("default")
590            .await
591            .expect("default board should exist");
592        let dev_column = board
593            .columns
594            .iter_mut()
595            .find(|column| column.id == "dev")
596            .expect("dev column");
597        dev_column.automation = Some(KanbanColumnAutomation {
598            enabled: true,
599            required_artifacts: Some(vec!["screenshot".to_string()]),
600            ..Default::default()
601        });
602        state
603            .kanban_store
604            .update(&board)
605            .await
606            .expect("board should update");
607
608        let mut task = Task::new(
609            "task-rpc-1".to_string(),
610            "RPC evidence".to_string(),
611            "Return parity task payload".to_string(),
612            "default".to_string(),
613            None,
614            None,
615            None,
616            None,
617            None,
618            None,
619            None,
620        );
621        task.board_id = Some(board.id.clone());
622        task.column_id = Some("todo".to_string());
623        task.session_ids = vec!["session-1".to_string()];
624        task.lane_sessions = vec![TaskLaneSession {
625            session_id: "session-1".to_string(),
626            routa_agent_id: None,
627            column_id: Some("todo".to_string()),
628            column_name: Some("Todo".to_string()),
629            step_id: None,
630            step_index: None,
631            step_name: None,
632            provider: None,
633            role: None,
634            specialist_id: None,
635            specialist_name: None,
636            transport: None,
637            external_task_id: None,
638            context_id: None,
639            attempt: None,
640            loop_mode: None,
641            completion_requirement: None,
642            objective: None,
643            last_activity_at: None,
644            recovered_from_session_id: None,
645            recovery_reason: None,
646            status: TaskLaneSessionStatus::Running,
647            started_at: "2026-03-27T00:00:00Z".to_string(),
648            completed_at: None,
649        }];
650        task.completion_summary = Some("Done".to_string());
651        task.verification_verdict = Some(VerificationVerdict::Approved);
652        task.verification_report = Some("Verified".to_string());
653        state
654            .task_store
655            .save(&task)
656            .await
657            .expect("task should save");
658
659        let artifact = Artifact {
660            id: "artifact-rpc-1".to_string(),
661            artifact_type: ArtifactType::Screenshot,
662            task_id: task.id.clone(),
663            workspace_id: task.workspace_id.clone(),
664            provided_by_agent_id: Some("agent-1".to_string()),
665            requested_by_agent_id: None,
666            request_id: None,
667            content: Some("base64".to_string()),
668            context: None,
669            status: ArtifactStatus::Provided,
670            expires_at: None,
671            metadata: None,
672            created_at: Utc::now(),
673            updated_at: Utc::now(),
674        };
675        state
676            .artifact_store
677            .save(&artifact)
678            .await
679            .expect("artifact should save");
680
681        let get_value = get(
682            &state,
683            GetParams {
684                id: task.id.clone(),
685            },
686        )
687        .await
688        .expect("task should load");
689        assert_eq!(get_value["artifactSummary"]["total"], serde_json::json!(1));
690        assert_eq!(
691            get_value["evidenceSummary"]["artifact"]["requiredSatisfied"],
692            serde_json::json!(true)
693        );
694        assert_eq!(
695            get_value["evidenceSummary"]["verification"]["verdict"],
696            serde_json::json!("APPROVED")
697        );
698        assert_eq!(
699            get_value["evidenceSummary"]["runs"]["latestStatus"],
700            serde_json::json!("running")
701        );
702
703        let listed = list(
704            &state,
705            ListParams {
706                workspace_id: "default".to_string(),
707                session_id: None,
708                status: None,
709                assigned_to: None,
710            },
711        )
712        .await
713        .expect("tasks should list");
714        assert_eq!(listed.tasks.len(), 1);
715        assert_eq!(
716            listed.tasks[0]["evidenceSummary"]["completion"]["hasSummary"],
717            serde_json::json!(true)
718        );
719
720        let ready = find_ready(
721            &state,
722            FindReadyParams {
723                workspace_id: "default".to_string(),
724            },
725        )
726        .await
727        .expect("ready tasks should list");
728        assert_eq!(ready.tasks.len(), 1);
729        assert_eq!(
730            ready.tasks[0]["artifactSummary"]["byType"]["screenshot"],
731            serde_json::json!(1)
732        );
733
734        let created = create(
735            &state,
736            CreateParams {
737                title: "Fresh task".to_string(),
738                objective: "No evidence yet".to_string(),
739                workspace_id: "default".to_string(),
740                session_id: None,
741                scope: None,
742                acceptance_criteria: None,
743                verification_commands: None,
744                test_cases: None,
745                dependencies: None,
746                parallel_group: None,
747            },
748        )
749        .await
750        .expect("task should create");
751        assert_eq!(
752            created.task["artifactSummary"]["total"],
753            serde_json::json!(0)
754        );
755        assert_eq!(
756            created.task["evidenceSummary"]["runs"]["latestStatus"],
757            serde_json::json!("idle")
758        );
759    }
760}