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
17pub async fn serialize_task_with_evidence(
20 state: &AppState,
21 task: &Task,
22) -> Result<serde_json::Value, ServerError> {
23 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 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
78pub 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
140pub 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
153async 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
216pub 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
243pub 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
270pub 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
281pub 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 {} not found", task_id)))?;
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
402pub 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 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 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 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
441async fn serialize_task_with_preloaded_data(
443 task: &Task,
444 artifacts: &[Artifact],
445 board: Option<&KanbanBoard>,
446) -> Result<serde_json::Value, ServerError> {
447 let evidence_summary = build_task_evidence_summary_from_artifacts(task, artifacts, board)?;
449
450 let story_readiness = build_task_story_readiness(
452 task,
453 &resolve_next_required_task_fields(board, task.column_id.as_deref()),
454 );
455
456 let invest_validation = build_task_invest_validation(task);
458
459 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
502fn build_task_evidence_summary_from_artifacts(
504 task: &Task,
505 artifacts: &[Artifact],
506 board: Option<&KanbanBoard>,
507) -> Result<TaskEvidenceSummary, ServerError> {
508 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 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 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}