1use 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#[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) = ¶ms.session_id {
90 state.task_store.list_by_session(session_id).await?
92 } else if let Some(assignee) = ¶ms.assigned_to {
93 state.task_store.list_by_assignee(assignee).await?
94 } else if let Some(status_str) = ¶ms.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(¶ms.workspace_id, &status)
100 .await?
101 } else {
102 state
103 .task_store
104 .list_by_workspace(¶ms.workspace_id)
105 .await?
106 };
107
108 Ok(ListResult {
109 tasks: serialize_tasks_with_evidence(state, &tasks).await?,
110 })
111}
112
113#[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(¶ms.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#[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#[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(¶ms.id).await?;
195 Ok(DeleteResult { deleted: true })
196}
197
198#[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(¶ms.status)
219 .ok_or_else(|| RpcError::BadRequest(format!("Invalid status: {}", params.status)))?;
220 state.task_store.update_status(¶ms.id, &status).await?;
221 Ok(UpdateStatusResult { updated: true })
222}
223
224#[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(¶ms.workspace_id)
239 .await?;
240 Ok(ListResult {
241 tasks: serialize_tasks_with_evidence(state, &tasks).await?,
242 })
243}
244
245#[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(¶ms.task_id, &artifact_type)
271 .await?
272 } else {
273 state.artifact_store.list_by_task(¶ms.task_id).await?
274 };
275
276 Ok(ListArtifactsResult { artifacts })
277}
278
279#[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(¶ms.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(¶ms.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}