Skip to main content

routa_server/api/tasks/
evidence.rs

1use routa_core::models::artifact::{Artifact, ArtifactType};
2use routa_core::models::kanban::KanbanBoard;
3use routa_core::models::task::{
4    build_task_invest_validation, build_task_story_readiness, Task, TaskLaneSessionStatus,
5};
6use std::collections::{BTreeMap, BTreeSet};
7
8use super::dto::{
9    TaskArtifactSummary, TaskCompletionSummary, TaskEvidenceSummary, TaskRunLedgerEntry,
10    TaskRunResumeTarget, TaskRunSummary, TaskVerificationSummary, UpdateTaskRequest,
11};
12use crate::error::ServerError;
13use crate::state::AppState;
14
15const KANBAN_HAPPY_PATH_COLUMN_ORDER: [&str; 5] = ["backlog", "todo", "dev", "review", "done"];
16
17/// Serialize task with evidence, readiness, and validation summaries
18/// Queries board and artifacts if not already loaded (for single-task operations)
19pub async fn serialize_task_with_evidence(
20    state: &AppState,
21    task: &Task,
22) -> Result<serde_json::Value, ServerError> {
23    // Load board once
24    let board = match task.board_id.as_deref() {
25        Some(board_id) => state.kanban_store.get(board_id).await?,
26        None => None,
27    };
28
29    // Build evidence summary with pre-loaded board
30    let evidence_summary =
31        build_task_evidence_summary_with_board(state, task, board.as_ref()).await?;
32
33    let story_readiness = build_task_story_readiness(
34        task,
35        &resolve_next_required_task_fields(board.as_ref(), task.column_id.as_deref()),
36    );
37    let invest_validation = build_task_invest_validation(task);
38    let mut task_value = serde_json::to_value(task)
39        .map_err(|error| ServerError::Internal(format!("Failed to serialize task: {error}")))?;
40    let task_object = task_value.as_object_mut().ok_or_else(|| {
41        ServerError::Internal("Task payload must serialize to a JSON object".to_string())
42    })?;
43    task_object.insert(
44        "artifactSummary".to_string(),
45        serde_json::to_value(&evidence_summary.artifact).map_err(|error| {
46            ServerError::Internal(format!(
47                "Failed to serialize task artifact summary: {error}"
48            ))
49        })?,
50    );
51    task_object.insert(
52        "evidenceSummary".to_string(),
53        serde_json::to_value(&evidence_summary).map_err(|error| {
54            ServerError::Internal(format!(
55                "Failed to serialize task evidence summary: {error}"
56            ))
57        })?,
58    );
59    task_object.insert(
60        "storyReadiness".to_string(),
61        serde_json::to_value(&story_readiness).map_err(|error| {
62            ServerError::Internal(format!(
63                "Failed to serialize task story readiness summary: {error}"
64            ))
65        })?,
66    );
67    task_object.insert(
68        "investValidation".to_string(),
69        serde_json::to_value(&invest_validation).map_err(|error| {
70            ServerError::Internal(format!(
71                "Failed to serialize task INVEST validation summary: {error}"
72            ))
73        })?,
74    );
75    Ok(task_value)
76}
77
78/// Build task run ledger from lane sessions
79pub async fn build_task_run_ledger(
80    state: &AppState,
81    task: &Task,
82) -> Result<Vec<TaskRunLedgerEntry>, ServerError> {
83    let mut lane_sessions = task.lane_sessions.clone();
84    lane_sessions.sort_by(|left, right| right.started_at.cmp(&left.started_at));
85
86    let mut runs = Vec::with_capacity(lane_sessions.len());
87    for lane_session in lane_sessions {
88        let session = state
89            .acp_session_store
90            .get(&lane_session.session_id)
91            .await?;
92        let is_a2a = lane_session.transport.as_deref() == Some("a2a");
93        let resume_target = if is_a2a {
94            lane_session
95                .external_task_id
96                .clone()
97                .map(|id| TaskRunResumeTarget {
98                    r#type: "external_task".to_string(),
99                    id,
100                })
101        } else {
102            Some(TaskRunResumeTarget {
103                r#type: "session".to_string(),
104                id: lane_session.session_id.clone(),
105            })
106        };
107
108        runs.push(TaskRunLedgerEntry {
109            id: lane_session.session_id.clone(),
110            kind: if is_a2a {
111                "a2a_task".to_string()
112            } else {
113                "embedded_acp".to_string()
114            },
115            status: serde_json::to_value(&lane_session.status)
116                .ok()
117                .and_then(|value| value.as_str().map(str::to_string))
118                .unwrap_or_else(|| "unknown".to_string()),
119            session_id: Some(lane_session.session_id.clone()),
120            external_task_id: lane_session.external_task_id.clone(),
121            context_id: lane_session.context_id.clone(),
122            column_id: lane_session.column_id.clone(),
123            step_id: lane_session.step_id.clone(),
124            step_name: lane_session.step_name.clone(),
125            provider: lane_session
126                .provider
127                .clone()
128                .or_else(|| session.as_ref().and_then(|row| row.provider.clone())),
129            specialist_name: lane_session.specialist_name.clone(),
130            started_at: Some(lane_session.started_at.clone()),
131            completed_at: lane_session.completed_at.clone(),
132            owner_instance_id: None,
133            resume_target,
134        });
135    }
136
137    Ok(runs)
138}
139
140/// Build task evidence summary including artifacts, verification, completion, and runs
141/// Queries artifacts and board (for backward compatibility)
142pub async fn build_task_evidence_summary(
143    state: &AppState,
144    task: &Task,
145) -> Result<TaskEvidenceSummary, ServerError> {
146    let board = match task.board_id.as_deref() {
147        Some(board_id) => state.kanban_store.get(board_id).await?,
148        None => None,
149    };
150    build_task_evidence_summary_with_board(state, task, board.as_ref()).await
151}
152
153/// Build task evidence summary with pre-loaded board to avoid duplicate queries
154async fn build_task_evidence_summary_with_board(
155    state: &AppState,
156    task: &Task,
157    board: Option<&KanbanBoard>,
158) -> Result<TaskEvidenceSummary, ServerError> {
159    let artifacts = state.artifact_store.list_by_task(&task.id).await?;
160    let mut by_type = BTreeMap::new();
161    for artifact in &artifacts {
162        let key = artifact.artifact_type.as_str().to_string();
163        *by_type.entry(key).or_insert(0) += 1;
164    }
165
166    let required_artifacts = resolve_next_required_artifacts(board, task.column_id.as_deref());
167    let present_artifacts = by_type.keys().cloned().collect::<BTreeSet<_>>();
168    let missing_required = required_artifacts
169        .into_iter()
170        .filter(|artifact| !present_artifacts.contains(artifact))
171        .collect::<Vec<_>>();
172
173    let latest_status = task
174        .lane_sessions
175        .last()
176        .map(|session| task_lane_session_status_as_str(&session.status).to_string())
177        .unwrap_or_else(|| {
178            if task.session_ids.is_empty() {
179                "idle".to_string()
180            } else {
181                "unknown".to_string()
182            }
183        });
184
185    Ok(TaskEvidenceSummary {
186        artifact: TaskArtifactSummary {
187            total: artifacts.len(),
188            by_type,
189            required_satisfied: missing_required.is_empty(),
190            missing_required,
191        },
192        verification: TaskVerificationSummary {
193            has_verdict: task.verification_verdict.is_some(),
194            verdict: task
195                .verification_verdict
196                .as_ref()
197                .map(|verdict| verdict.as_str().to_string()),
198            has_report: task
199                .verification_report
200                .as_ref()
201                .is_some_and(|report| !report.trim().is_empty()),
202        },
203        completion: TaskCompletionSummary {
204            has_summary: task
205                .completion_summary
206                .as_ref()
207                .is_some_and(|summary| !summary.trim().is_empty()),
208        },
209        runs: TaskRunSummary {
210            total: task.session_ids.len(),
211            latest_status,
212        },
213    })
214}
215
216/// Resolve required task fields for next column transition
217pub fn resolve_next_required_task_fields(
218    board: Option<&KanbanBoard>,
219    current_column_id: Option<&str>,
220) -> Vec<String> {
221    let current_column_id = current_column_id.unwrap_or("backlog").to_ascii_lowercase();
222    let next_column_id = KANBAN_HAPPY_PATH_COLUMN_ORDER
223        .iter()
224        .position(|column_id| *column_id == current_column_id)
225        .and_then(|index| KANBAN_HAPPY_PATH_COLUMN_ORDER.get(index + 1))
226        .copied();
227    let Some(next_column_id) = next_column_id else {
228        return Vec::new();
229    };
230
231    board
232        .and_then(|board| {
233            board
234                .columns
235                .iter()
236                .find(|column| column.id == next_column_id)
237        })
238        .and_then(|column| column.automation.as_ref())
239        .and_then(|automation| automation.required_task_fields.clone())
240        .unwrap_or_default()
241}
242
243/// Resolve required artifacts for next column transition
244pub fn resolve_next_required_artifacts(
245    board: Option<&KanbanBoard>,
246    current_column_id: Option<&str>,
247) -> Vec<String> {
248    let current_column_id = current_column_id.unwrap_or("backlog").to_ascii_lowercase();
249    let next_column_id = KANBAN_HAPPY_PATH_COLUMN_ORDER
250        .iter()
251        .position(|column_id| *column_id == current_column_id)
252        .and_then(|index| KANBAN_HAPPY_PATH_COLUMN_ORDER.get(index + 1))
253        .copied();
254    let Some(next_column_id) = next_column_id else {
255        return Vec::new();
256    };
257
258    board
259        .and_then(|board| {
260            board
261                .columns
262                .iter()
263                .find(|column| column.id == next_column_id)
264        })
265        .and_then(|column| column.automation.as_ref())
266        .and_then(|automation| automation.required_artifacts.clone())
267        .unwrap_or_default()
268}
269
270/// Convert TaskLaneSessionStatus to string
271pub fn task_lane_session_status_as_str(status: &TaskLaneSessionStatus) -> &'static str {
272    match status {
273        TaskLaneSessionStatus::Running => "running",
274        TaskLaneSessionStatus::Completed => "completed",
275        TaskLaneSessionStatus::Failed => "failed",
276        TaskLaneSessionStatus::TimedOut => "timed_out",
277        TaskLaneSessionStatus::Transitioned => "transitioned",
278    }
279}
280
281/// Ensure required artifacts exist before task column transition
282pub async fn ensure_transition_artifacts(
283    state: &AppState,
284    task_id: &str,
285    body: &UpdateTaskRequest,
286) -> Result<(), ServerError> {
287    let Some(target_column_id) = body.column_id.as_deref() else {
288        return Ok(());
289    };
290    let existing = state
291        .task_store
292        .get(task_id)
293        .await?
294        .ok_or_else(|| ServerError::NotFound(format!("Task {task_id} not found")))?;
295    if existing.column_id.as_deref() == Some(target_column_id) {
296        return Ok(());
297    }
298
299    let Some(board_id) = body.board_id.as_deref().or(existing.board_id.as_deref()) else {
300        return Ok(());
301    };
302    let Some(board) = state.kanban_store.get(board_id).await? else {
303        return Ok(());
304    };
305    let Some(target_column) = board
306        .columns
307        .iter()
308        .find(|column| column.id == target_column_id)
309    else {
310        return Ok(());
311    };
312
313    if let Some(required_task_fields) = target_column
314        .automation
315        .as_ref()
316        .and_then(|automation| automation.required_task_fields.as_ref())
317    {
318        let mut candidate_task = existing.clone();
319        if let Some(title) = body.title.as_ref() {
320            candidate_task.title = title.clone();
321        }
322        if let Some(objective) = body.objective.as_ref() {
323            candidate_task.objective = objective.clone();
324        }
325        if let Some(scope) = body.scope.as_ref() {
326            candidate_task.scope = Some(scope.clone());
327        }
328        if let Some(acceptance_criteria) = body.acceptance_criteria.as_ref() {
329            candidate_task.acceptance_criteria = Some(acceptance_criteria.clone());
330        }
331        if let Some(verification_commands) = body.verification_commands.as_ref() {
332            candidate_task.verification_commands = Some(verification_commands.clone());
333        }
334        if let Some(test_cases) = body.test_cases.as_ref() {
335            candidate_task.test_cases = Some(test_cases.clone());
336        }
337        if let Some(dependencies) = body.dependencies.as_ref() {
338            candidate_task.dependencies = dependencies.clone();
339        }
340        if let Some(parallel_group) = body.parallel_group.as_ref() {
341            candidate_task.parallel_group = Some(parallel_group.clone());
342        }
343
344        let readiness = build_task_story_readiness(&candidate_task, required_task_fields);
345        if !readiness.ready {
346            let missing_task_fields = readiness
347                .missing
348                .iter()
349                .map(|field| match field.as_str() {
350                    "acceptance_criteria" => "acceptance criteria",
351                    "verification_commands" => "verification commands",
352                    "test_cases" => "test cases",
353                    "verification_plan" => "verification plan",
354                    "dependencies_declared" => "dependency declaration",
355                    other => other,
356                })
357                .collect::<Vec<_>>();
358            return Err(ServerError::BadRequest(format!(
359                "Cannot move task to \"{}\": missing required task fields: {}. Please complete this story definition before moving the task.",
360                target_column.name,
361                missing_task_fields.join(", ")
362            )));
363        }
364    }
365
366    let Some(required_artifacts) = target_column
367        .automation
368        .as_ref()
369        .and_then(|automation| automation.required_artifacts.as_ref())
370    else {
371        return Ok(());
372    };
373
374    let mut missing_artifacts = Vec::new();
375    for artifact_name in required_artifacts {
376        let artifact_type = ArtifactType::from_str(artifact_name).ok_or_else(|| {
377            ServerError::BadRequest(format!(
378                "Invalid required artifact type configured on column {}: {}",
379                target_column.id, artifact_name
380            ))
381        })?;
382        let artifacts = state
383            .artifact_store
384            .list_by_task_and_type(task_id, &artifact_type)
385            .await?;
386        if artifacts.is_empty() {
387            missing_artifacts.push(artifact_name.clone());
388        }
389    }
390
391    if missing_artifacts.is_empty() {
392        return Ok(());
393    }
394
395    Err(ServerError::BadRequest(format!(
396        "Cannot move task to \"{}\": missing required artifacts: {}. Please provide these artifacts before moving the task.",
397        target_column.name,
398        missing_artifacts.join(", ")
399    )))
400}
401
402/// Batch serialize tasks with evidence - optimized to avoid N+1 queries
403/// Preloads all artifacts and boards in batch before serializing
404pub async fn serialize_tasks_batch(
405    state: &AppState,
406    tasks: &[Task],
407) -> Result<Vec<serde_json::Value>, ServerError> {
408    if tasks.is_empty() {
409        return Ok(Vec::new());
410    }
411
412    // Step 1: Collect all unique task_ids and board_ids
413    let task_ids: Vec<String> = tasks.iter().map(|t| t.id.clone()).collect();
414    let board_ids: Vec<String> = tasks
415        .iter()
416        .filter_map(|t| t.board_id.clone())
417        .collect::<BTreeSet<_>>()
418        .into_iter()
419        .collect();
420
421    // Step 2: Batch load all artifacts and boards
422    let artifacts_map = state.artifact_store.list_by_tasks(&task_ids).await?;
423    let boards_map = state.kanban_store.get_many(&board_ids).await?;
424
425    // Step 3: Serialize each task with pre-loaded data
426    let mut results = Vec::with_capacity(tasks.len());
427    for task in tasks {
428        let artifacts = artifacts_map
429            .get(&task.id)
430            .map(|v| v.as_slice())
431            .unwrap_or(&[]);
432        let board = task.board_id.as_ref().and_then(|id| boards_map.get(id));
433
434        let serialized = serialize_task_with_preloaded_data(task, artifacts, board).await?;
435        results.push(serialized);
436    }
437
438    Ok(results)
439}
440
441/// Serialize a single task with pre-loaded artifacts and board (no queries)
442async fn serialize_task_with_preloaded_data(
443    task: &Task,
444    artifacts: &[Artifact],
445    board: Option<&KanbanBoard>,
446) -> Result<serde_json::Value, ServerError> {
447    // Build evidence summary from pre-loaded data
448    let evidence_summary = build_task_evidence_summary_from_artifacts(task, artifacts, board)?;
449
450    // Build story readiness
451    let story_readiness = build_task_story_readiness(
452        task,
453        &resolve_next_required_task_fields(board, task.column_id.as_deref()),
454    );
455
456    // Build INVEST validation
457    let invest_validation = build_task_invest_validation(task);
458
459    // Serialize task and add computed fields
460    let mut task_value = serde_json::to_value(task)
461        .map_err(|error| ServerError::Internal(format!("Failed to serialize task: {error}")))?;
462    let task_object = task_value.as_object_mut().ok_or_else(|| {
463        ServerError::Internal("Task payload must serialize to a JSON object".to_string())
464    })?;
465
466    task_object.insert(
467        "artifactSummary".to_string(),
468        serde_json::to_value(&evidence_summary.artifact).map_err(|error| {
469            ServerError::Internal(format!(
470                "Failed to serialize task artifact summary: {error}"
471            ))
472        })?,
473    );
474    task_object.insert(
475        "evidenceSummary".to_string(),
476        serde_json::to_value(&evidence_summary).map_err(|error| {
477            ServerError::Internal(format!(
478                "Failed to serialize task evidence summary: {error}"
479            ))
480        })?,
481    );
482    task_object.insert(
483        "storyReadiness".to_string(),
484        serde_json::to_value(&story_readiness).map_err(|error| {
485            ServerError::Internal(format!(
486                "Failed to serialize task story readiness summary: {error}"
487            ))
488        })?,
489    );
490    task_object.insert(
491        "investValidation".to_string(),
492        serde_json::to_value(&invest_validation).map_err(|error| {
493            ServerError::Internal(format!(
494                "Failed to serialize task INVEST validation summary: {error}"
495            ))
496        })?,
497    );
498
499    Ok(task_value)
500}
501
502/// Build evidence summary from pre-loaded artifacts (no queries)
503fn build_task_evidence_summary_from_artifacts(
504    task: &Task,
505    artifacts: &[Artifact],
506    board: Option<&KanbanBoard>,
507) -> Result<TaskEvidenceSummary, ServerError> {
508    // Count artifacts by type
509    let mut by_type = BTreeMap::new();
510    for artifact in artifacts {
511        let key = artifact.artifact_type.as_str().to_string();
512        *by_type.entry(key).or_insert(0) += 1;
513    }
514
515    // Determine required artifacts
516    let required_artifacts = resolve_next_required_artifacts(board, task.column_id.as_deref());
517    let present_artifacts = by_type.keys().cloned().collect::<BTreeSet<_>>();
518    let missing_required = required_artifacts
519        .into_iter()
520        .filter(|artifact| !present_artifacts.contains(artifact))
521        .collect::<Vec<_>>();
522
523    // Get latest status
524    let latest_status = task
525        .lane_sessions
526        .last()
527        .map(|session| task_lane_session_status_as_str(&session.status).to_string())
528        .unwrap_or_else(|| {
529            if task.session_ids.is_empty() {
530                "idle".to_string()
531            } else {
532                "unknown".to_string()
533            }
534        });
535
536    Ok(TaskEvidenceSummary {
537        artifact: TaskArtifactSummary {
538            total: artifacts.len(),
539            by_type,
540            required_satisfied: missing_required.is_empty(),
541            missing_required,
542        },
543        verification: TaskVerificationSummary {
544            has_verdict: task.verification_verdict.is_some(),
545            verdict: task
546                .verification_verdict
547                .as_ref()
548                .map(|verdict| verdict.as_str().to_string()),
549            has_report: task
550                .verification_report
551                .as_ref()
552                .is_some_and(|report| !report.trim().is_empty()),
553        },
554        completion: TaskCompletionSummary {
555            has_summary: task
556                .completion_summary
557                .as_ref()
558                .is_some_and(|summary| !summary.trim().is_empty()),
559        },
560        runs: TaskRunSummary {
561            total: task.session_ids.len(),
562            latest_status,
563        },
564    })
565}