1use 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#[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) = ¶ms.session_id {
47 state.task_store.list_by_session(session_id).await?
49 } else if let Some(assignee) = ¶ms.assigned_to {
50 state.task_store.list_by_assignee(assignee).await?
51 } else if let Some(status_str) = ¶ms.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(¶ms.workspace_id, &status)
57 .await?
58 } else {
59 state
60 .task_store
61 .list_by_workspace(¶ms.workspace_id)
62 .await?
63 };
64
65 Ok(ListResult { tasks })
66}
67
68#[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(¶ms.id)
82 .await?
83 .ok_or_else(|| RpcError::NotFound(format!("Task {} not found", params.id)))
84}
85
86#[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#[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(¶ms.id).await?;
147 Ok(DeleteResult { deleted: true })
148}
149
150#[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(¶ms.status)
171 .ok_or_else(|| RpcError::BadRequest(format!("Invalid status: {}", params.status)))?;
172 state.task_store.update_status(¶ms.id, &status).await?;
173 Ok(UpdateStatusResult { updated: true })
174}
175
176#[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(¶ms.workspace_id)
191 .await?;
192 Ok(ListResult { tasks })
193}
194
195#[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(¶ms.task_id, &artifact_type)
221 .await?
222 } else {
223 state.artifact_store.list_by_task(¶ms.task_id).await?
224 };
225
226 Ok(ListArtifactsResult { artifacts })
227}
228
229#[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(¶ms.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(¶ms.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}