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