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;
16
17use crate::models::artifact::{Artifact, ArtifactStatus, ArtifactType};
18use crate::models::task::{Task, TaskStatus};
19use crate::rpc::error::RpcError;
20use crate::state::AppState;
21
22// ---------------------------------------------------------------------------
23// tasks.list
24// ---------------------------------------------------------------------------
25
26#[derive(Debug, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct ListParams {
29    #[serde(default = "default_workspace_id")]
30    pub workspace_id: String,
31    pub session_id: Option<String>,
32    pub status: Option<String>,
33    pub assigned_to: Option<String>,
34}
35
36fn default_workspace_id() -> String {
37    "default".into()
38}
39
40#[derive(Debug, Serialize)]
41pub struct ListResult {
42    pub tasks: Vec<Task>,
43}
44
45pub async fn list(state: &AppState, params: ListParams) -> Result<ListResult, RpcError> {
46    let tasks = if let Some(session_id) = &params.session_id {
47        // Filter by session_id takes priority
48        state.task_store.list_by_session(session_id).await?
49    } else if let Some(assignee) = &params.assigned_to {
50        state.task_store.list_by_assignee(assignee).await?
51    } else if let Some(status_str) = &params.status {
52        let status = TaskStatus::from_str(status_str)
53            .ok_or_else(|| RpcError::BadRequest(format!("Invalid status: {}", status_str)))?;
54        state
55            .task_store
56            .list_by_status(&params.workspace_id, &status)
57            .await?
58    } else {
59        state
60            .task_store
61            .list_by_workspace(&params.workspace_id)
62            .await?
63    };
64
65    Ok(ListResult { tasks })
66}
67
68// ---------------------------------------------------------------------------
69// tasks.get
70// ---------------------------------------------------------------------------
71
72#[derive(Debug, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct GetParams {
75    pub id: String,
76}
77
78pub async fn get(state: &AppState, params: GetParams) -> Result<Task, RpcError> {
79    state
80        .task_store
81        .get(&params.id)
82        .await?
83        .ok_or_else(|| RpcError::NotFound(format!("Task {} not found", params.id)))
84}
85
86// ---------------------------------------------------------------------------
87// tasks.create
88// ---------------------------------------------------------------------------
89
90#[derive(Debug, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct CreateParams {
93    pub title: String,
94    pub objective: String,
95    #[serde(default = "default_workspace_id")]
96    pub workspace_id: String,
97    pub session_id: Option<String>,
98    pub scope: Option<String>,
99    pub acceptance_criteria: Option<Vec<String>>,
100    pub verification_commands: Option<Vec<String>>,
101    pub test_cases: Option<Vec<String>>,
102    pub dependencies: Option<Vec<String>>,
103    pub parallel_group: Option<String>,
104}
105
106#[derive(Debug, Serialize)]
107pub struct CreateResult {
108    pub task: Task,
109}
110
111pub async fn create(state: &AppState, params: CreateParams) -> Result<CreateResult, RpcError> {
112    let task = Task::new(
113        uuid::Uuid::new_v4().to_string(),
114        params.title,
115        params.objective,
116        params.workspace_id,
117        params.session_id,
118        params.scope,
119        params.acceptance_criteria,
120        params.verification_commands,
121        params.test_cases,
122        params.dependencies,
123        params.parallel_group,
124    );
125
126    state.task_store.save(&task).await?;
127    Ok(CreateResult { task })
128}
129
130// ---------------------------------------------------------------------------
131// tasks.delete
132// ---------------------------------------------------------------------------
133
134#[derive(Debug, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct DeleteParams {
137    pub id: String,
138}
139
140#[derive(Debug, Serialize)]
141pub struct DeleteResult {
142    pub deleted: bool,
143}
144
145pub async fn delete(state: &AppState, params: DeleteParams) -> Result<DeleteResult, RpcError> {
146    state.task_store.delete(&params.id).await?;
147    Ok(DeleteResult { deleted: true })
148}
149
150// ---------------------------------------------------------------------------
151// tasks.updateStatus
152// ---------------------------------------------------------------------------
153
154#[derive(Debug, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct UpdateStatusParams {
157    pub id: String,
158    pub status: String,
159}
160
161#[derive(Debug, Serialize)]
162pub struct UpdateStatusResult {
163    pub updated: bool,
164}
165
166pub async fn update_status(
167    state: &AppState,
168    params: UpdateStatusParams,
169) -> Result<UpdateStatusResult, RpcError> {
170    let status = TaskStatus::from_str(&params.status)
171        .ok_or_else(|| RpcError::BadRequest(format!("Invalid status: {}", params.status)))?;
172    state.task_store.update_status(&params.id, &status).await?;
173    Ok(UpdateStatusResult { updated: true })
174}
175
176// ---------------------------------------------------------------------------
177// tasks.findReady
178// ---------------------------------------------------------------------------
179
180#[derive(Debug, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct FindReadyParams {
183    #[serde(default = "default_workspace_id")]
184    pub workspace_id: String,
185}
186
187pub async fn find_ready(state: &AppState, params: FindReadyParams) -> Result<ListResult, RpcError> {
188    let tasks = state
189        .task_store
190        .find_ready_tasks(&params.workspace_id)
191        .await?;
192    Ok(ListResult { tasks })
193}
194
195// ---------------------------------------------------------------------------
196// tasks.listArtifacts
197// ---------------------------------------------------------------------------
198
199#[derive(Debug, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct ListArtifactsParams {
202    pub task_id: String,
203    #[serde(rename = "type")]
204    pub artifact_type: Option<String>,
205}
206
207#[derive(Debug, Serialize)]
208pub struct ListArtifactsResult {
209    pub artifacts: Vec<Artifact>,
210}
211
212pub async fn list_artifacts(
213    state: &AppState,
214    params: ListArtifactsParams,
215) -> Result<ListArtifactsResult, RpcError> {
216    let artifacts = if let Some(artifact_type) = params.artifact_type.as_deref() {
217        let artifact_type = parse_artifact_type(artifact_type)?;
218        state
219            .artifact_store
220            .list_by_task_and_type(&params.task_id, &artifact_type)
221            .await?
222    } else {
223        state.artifact_store.list_by_task(&params.task_id).await?
224    };
225
226    Ok(ListArtifactsResult { artifacts })
227}
228
229// ---------------------------------------------------------------------------
230// tasks.provideArtifact
231// ---------------------------------------------------------------------------
232
233#[derive(Debug, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct ProvideArtifactParams {
236    pub task_id: String,
237    pub agent_id: String,
238    #[serde(rename = "type")]
239    pub artifact_type: String,
240    pub content: String,
241    pub context: Option<String>,
242    pub request_id: Option<String>,
243    pub metadata: Option<BTreeMap<String, String>>,
244}
245
246#[derive(Debug, Serialize)]
247pub struct ProvideArtifactResult {
248    pub artifact: Artifact,
249}
250
251pub async fn provide_artifact(
252    state: &AppState,
253    params: ProvideArtifactParams,
254) -> Result<ProvideArtifactResult, RpcError> {
255    let task = state
256        .task_store
257        .get(&params.task_id)
258        .await?
259        .ok_or_else(|| RpcError::NotFound(format!("Task {} not found", params.task_id)))?;
260
261    let agent_id = params.agent_id.trim();
262    if agent_id.is_empty() {
263        return Err(RpcError::BadRequest(
264            "agentId is required for artifact submission".to_string(),
265        ));
266    }
267
268    let content = params.content.trim();
269    if content.is_empty() {
270        return Err(RpcError::BadRequest(
271            "artifact content cannot be blank".to_string(),
272        ));
273    }
274
275    let artifact = Artifact {
276        id: uuid::Uuid::new_v4().to_string(),
277        artifact_type: parse_artifact_type(&params.artifact_type)?,
278        task_id: task.id,
279        workspace_id: task.workspace_id,
280        provided_by_agent_id: Some(agent_id.to_string()),
281        requested_by_agent_id: None,
282        request_id: params.request_id,
283        content: Some(content.to_string()),
284        context: params
285            .context
286            .as_deref()
287            .map(str::trim)
288            .filter(|value| !value.is_empty())
289            .map(str::to_string),
290        status: ArtifactStatus::Provided,
291        expires_at: None,
292        metadata: params.metadata,
293        created_at: Utc::now(),
294        updated_at: Utc::now(),
295    };
296
297    state.artifact_store.save(&artifact).await?;
298    Ok(ProvideArtifactResult { artifact })
299}
300
301fn parse_artifact_type(value: &str) -> Result<ArtifactType, RpcError> {
302    ArtifactType::from_str(value).ok_or_else(|| {
303        RpcError::BadRequest(format!(
304            "Invalid artifact type: {}. Expected one of: screenshot, test_results, code_diff, logs",
305            value
306        ))
307    })
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::{AppState, AppStateInner, Database};
314    use std::sync::Arc;
315
316    async fn setup_state() -> AppState {
317        let db = Database::open_in_memory().expect("in-memory db should open");
318        let state: AppState = Arc::new(AppStateInner::new(db));
319        state
320            .workspace_store
321            .ensure_default()
322            .await
323            .expect("default workspace should exist");
324        state
325    }
326
327    #[tokio::test]
328    async fn provide_and_list_artifacts_roundtrip() {
329        let state = setup_state().await;
330        let created = create(
331            &state,
332            CreateParams {
333                title: "Artifact task".to_string(),
334                objective: "Store screenshot evidence".to_string(),
335                workspace_id: "default".to_string(),
336                session_id: None,
337                scope: None,
338                acceptance_criteria: None,
339                verification_commands: None,
340                test_cases: None,
341                dependencies: None,
342                parallel_group: None,
343            },
344        )
345        .await
346        .expect("task should be created");
347
348        let provided = provide_artifact(
349            &state,
350            ProvideArtifactParams {
351                task_id: created.task.id.clone(),
352                agent_id: "agent-1".to_string(),
353                artifact_type: "screenshot".to_string(),
354                content: "base64-content".to_string(),
355                context: Some("Verification screenshot".to_string()),
356                request_id: None,
357                metadata: None,
358            },
359        )
360        .await
361        .expect("artifact should be created");
362
363        assert_eq!(provided.artifact.artifact_type, ArtifactType::Screenshot);
364        assert_eq!(
365            provided.artifact.provided_by_agent_id.as_deref(),
366            Some("agent-1")
367        );
368
369        let listed = list_artifacts(
370            &state,
371            ListArtifactsParams {
372                task_id: created.task.id,
373                artifact_type: Some("screenshot".to_string()),
374            },
375        )
376        .await
377        .expect("artifacts should be listed");
378
379        assert_eq!(listed.artifacts.len(), 1);
380        assert_eq!(
381            listed.artifacts[0].context.as_deref(),
382            Some("Verification screenshot")
383        );
384    }
385}