Skip to main content

assay_workflow/api/
tasks.rs

1use std::sync::Arc;
2
3use axum::extract::{Path, State};
4use axum::routing::post;
5use axum::{Json, Router};
6use serde::{Deserialize, Serialize};
7use utoipa::ToSchema;
8
9use crate::api::workflows::AppError;
10use crate::ctx::WorkflowCtx;
11use crate::store::WorkflowStore;
12use crate::types::WorkflowWorker;
13
14pub fn router<S: WorkflowStore + 'static>() -> Router<Arc<WorkflowCtx<S>>> {
15    Router::new()
16        .route("/workers/register", post(register_worker))
17        .route("/workers/heartbeat", post(worker_heartbeat))
18        .route("/tasks/poll", post(poll_task))
19        .route("/tasks/{id}/complete", post(complete_task))
20        .route("/tasks/{id}/fail", post(fail_task))
21        .route("/tasks/{id}/heartbeat", post(heartbeat_task))
22}
23
24#[derive(Deserialize, ToSchema)]
25pub struct RegisterWorkerRequest {
26    /// Namespace (default: "main")
27    #[serde(default = "default_namespace")]
28    pub namespace: String,
29    /// Human-readable worker identity (e.g. "pipeline-pod-1")
30    pub identity: String,
31    /// Task queue this worker listens on
32    pub queue: String,
33    /// Workflow types this worker can execute
34    pub workflows: Option<Vec<String>>,
35    /// Activity types this worker can execute
36    pub activities: Option<Vec<String>>,
37    #[serde(default = "default_concurrent")]
38    pub max_concurrent_workflows: i32,
39    #[serde(default = "default_concurrent")]
40    pub max_concurrent_activities: i32,
41}
42
43fn default_namespace() -> String {
44    "main".to_string()
45}
46
47fn default_concurrent() -> i32 {
48    10
49}
50
51#[derive(Serialize, ToSchema)]
52pub struct RegisterWorkerResponse {
53    /// Server-assigned worker ID
54    pub worker_id: String,
55}
56
57#[utoipa::path(
58    post, path = "/api/v1/workers/register",
59    tag = "tasks",
60    request_body = RegisterWorkerRequest,
61    responses(
62        (status = 200, description = "Worker registered", body = RegisterWorkerResponse),
63    ),
64)]
65pub async fn register_worker<S: WorkflowStore>(
66    State(state): State<Arc<WorkflowCtx<S>>>,
67    Json(req): Json<RegisterWorkerRequest>,
68) -> Result<Json<RegisterWorkerResponse>, AppError> {
69    let now = timestamp_now();
70    let worker_id = format!("w-{}", &uuid_short());
71
72    let worker = WorkflowWorker {
73        id: worker_id.clone(),
74        namespace: req.namespace,
75        identity: req.identity,
76        task_queue: req.queue,
77        workflows: req.workflows.map(|v| serde_json::to_string(&v).unwrap()),
78        activities: req.activities.map(|v| serde_json::to_string(&v).unwrap()),
79        max_concurrent_workflows: req.max_concurrent_workflows,
80        max_concurrent_activities: req.max_concurrent_activities,
81        active_tasks: 0,
82        last_heartbeat: now,
83        registered_at: now,
84    };
85
86    state.register_worker(&worker).await?;
87    Ok(Json(RegisterWorkerResponse { worker_id }))
88}
89
90#[derive(Deserialize, ToSchema)]
91pub struct HeartbeatRequest {
92    pub worker_id: String,
93}
94
95#[utoipa::path(
96    post, path = "/api/v1/workers/heartbeat",
97    tag = "tasks",
98    responses((status = 200, description = "Heartbeat recorded")),
99)]
100pub async fn worker_heartbeat<S: WorkflowStore>(
101    State(state): State<Arc<WorkflowCtx<S>>>,
102    Json(req): Json<HeartbeatRequest>,
103) -> Result<axum::http::StatusCode, AppError> {
104    state.heartbeat_worker(&req.worker_id).await?;
105    Ok(axum::http::StatusCode::OK)
106}
107
108#[derive(Deserialize, ToSchema)]
109pub struct PollRequest {
110    /// Task queue to poll from
111    pub queue: String,
112    /// Worker ID (from register response)
113    pub worker_id: String,
114}
115
116#[utoipa::path(
117    post, path = "/api/v1/tasks/poll",
118    tag = "tasks",
119    request_body = PollRequest,
120    responses(
121        (status = 200, description = "Activity task (or null if none available)", body = WorkflowActivity),
122    ),
123)]
124pub async fn poll_task<S: WorkflowStore>(
125    State(state): State<Arc<WorkflowCtx<S>>>,
126    Json(req): Json<PollRequest>,
127) -> Result<Json<serde_json::Value>, AppError> {
128    let activity = state
129        .claim_activity(&req.queue, &req.worker_id)
130        .await?;
131
132    match activity {
133        Some(act) => Ok(Json(serde_json::to_value(act)?)),
134        None => Ok(Json(serde_json::json!({ "task": null }))),
135    }
136}
137
138#[derive(Deserialize, ToSchema)]
139pub struct CompleteTaskBody {
140    /// JSON result from the completed activity
141    pub result: Option<serde_json::Value>,
142}
143
144#[utoipa::path(
145    post, path = "/api/v1/tasks/{id}/complete",
146    tag = "tasks",
147    params(("id" = i64, Path, description = "Activity task ID")),
148    request_body = CompleteTaskBody,
149    responses((status = 200, description = "Task completed")),
150)]
151pub async fn complete_task<S: WorkflowStore>(
152    State(state): State<Arc<WorkflowCtx<S>>>,
153    Path(id): Path<i64>,
154    Json(body): Json<CompleteTaskBody>,
155) -> Result<axum::http::StatusCode, AppError> {
156    let result = body.result.map(|v| v.to_string());
157    state
158        .complete_activity(id, result.as_deref(), None, false)
159        .await?;
160    Ok(axum::http::StatusCode::OK)
161}
162
163#[derive(Deserialize, ToSchema)]
164pub struct FailTaskBody {
165    /// Error message describing why the task failed
166    pub error: String,
167}
168
169#[utoipa::path(
170    post, path = "/api/v1/tasks/{id}/fail",
171    tag = "tasks",
172    params(("id" = i64, Path, description = "Activity task ID")),
173    request_body = FailTaskBody,
174    responses((status = 200, description = "Task marked as failed")),
175)]
176pub async fn fail_task<S: WorkflowStore>(
177    State(state): State<Arc<WorkflowCtx<S>>>,
178    Path(id): Path<i64>,
179    Json(body): Json<FailTaskBody>,
180) -> Result<axum::http::StatusCode, AppError> {
181    // fail_activity honors the activity's retry policy: re-queues with
182    // backoff while attempts remain, otherwise marks FAILED + appends
183    // ActivityFailed event.
184    state.fail_activity(id, &body.error).await?;
185    Ok(axum::http::StatusCode::OK)
186}
187
188#[derive(Deserialize, ToSchema)]
189pub struct HeartbeatTaskBody {
190    pub details: Option<String>,
191}
192
193#[utoipa::path(
194    post, path = "/api/v1/tasks/{id}/heartbeat",
195    tag = "tasks",
196    params(("id" = i64, Path, description = "Activity task ID")),
197    responses((status = 200, description = "Heartbeat recorded")),
198)]
199pub async fn heartbeat_task<S: WorkflowStore>(
200    State(state): State<Arc<WorkflowCtx<S>>>,
201    Path(id): Path<i64>,
202    Json(body): Json<HeartbeatTaskBody>,
203) -> Result<axum::http::StatusCode, AppError> {
204    state
205        .heartbeat_activity(id, body.details.as_deref())
206        .await?;
207    Ok(axum::http::StatusCode::OK)
208}
209
210fn timestamp_now() -> f64 {
211    std::time::SystemTime::now()
212        .duration_since(std::time::UNIX_EPOCH)
213        .unwrap()
214        .as_secs_f64()
215}
216
217fn uuid_short() -> String {
218    use std::collections::hash_map::DefaultHasher;
219    use std::hash::{Hash, Hasher};
220    let mut h = DefaultHasher::new();
221    std::time::SystemTime::now().hash(&mut h);
222    std::thread::current().id().hash(&mut h);
223    format!("{:016x}", h.finish())
224}
225
226use crate::types::WorkflowActivity;