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/engine/workflow/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/engine/workflow/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/engine/workflow/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.claim_activity(&req.queue, &req.worker_id).await?;
129
130    match activity {
131        Some(act) => Ok(Json(serde_json::to_value(act)?)),
132        None => Ok(Json(serde_json::json!({ "task": null }))),
133    }
134}
135
136#[derive(Deserialize, ToSchema)]
137pub struct CompleteTaskBody {
138    /// JSON result from the completed activity
139    pub result: Option<serde_json::Value>,
140}
141
142#[utoipa::path(
143    post, path = "/api/v1/engine/workflow/tasks/{id}/complete",
144    tag = "tasks",
145    params(("id" = i64, Path, description = "Activity task ID")),
146    request_body = CompleteTaskBody,
147    responses((status = 200, description = "Task completed")),
148)]
149pub async fn complete_task<S: WorkflowStore>(
150    State(state): State<Arc<WorkflowCtx<S>>>,
151    Path(id): Path<i64>,
152    Json(body): Json<CompleteTaskBody>,
153) -> Result<axum::http::StatusCode, AppError> {
154    let result = body.result.map(|v| v.to_string());
155    state
156        .complete_activity(id, result.as_deref(), None, false)
157        .await?;
158    Ok(axum::http::StatusCode::OK)
159}
160
161#[derive(Deserialize, ToSchema)]
162pub struct FailTaskBody {
163    /// Error message describing why the task failed
164    pub error: String,
165}
166
167#[utoipa::path(
168    post, path = "/api/v1/engine/workflow/tasks/{id}/fail",
169    tag = "tasks",
170    params(("id" = i64, Path, description = "Activity task ID")),
171    request_body = FailTaskBody,
172    responses((status = 200, description = "Task marked as failed")),
173)]
174pub async fn fail_task<S: WorkflowStore>(
175    State(state): State<Arc<WorkflowCtx<S>>>,
176    Path(id): Path<i64>,
177    Json(body): Json<FailTaskBody>,
178) -> Result<axum::http::StatusCode, AppError> {
179    // fail_activity honors the activity's retry policy: re-queues with
180    // backoff while attempts remain, otherwise marks FAILED + appends
181    // ActivityFailed event.
182    state.fail_activity(id, &body.error).await?;
183    Ok(axum::http::StatusCode::OK)
184}
185
186#[derive(Deserialize, ToSchema)]
187pub struct HeartbeatTaskBody {
188    pub details: Option<String>,
189}
190
191#[utoipa::path(
192    post, path = "/api/v1/engine/workflow/tasks/{id}/heartbeat",
193    tag = "tasks",
194    params(("id" = i64, Path, description = "Activity task ID")),
195    responses((status = 200, description = "Heartbeat recorded")),
196)]
197pub async fn heartbeat_task<S: WorkflowStore>(
198    State(state): State<Arc<WorkflowCtx<S>>>,
199    Path(id): Path<i64>,
200    Json(body): Json<HeartbeatTaskBody>,
201) -> Result<axum::http::StatusCode, AppError> {
202    state
203        .heartbeat_activity(id, body.details.as_deref())
204        .await?;
205    Ok(axum::http::StatusCode::OK)
206}
207
208fn timestamp_now() -> f64 {
209    std::time::SystemTime::now()
210        .duration_since(std::time::UNIX_EPOCH)
211        .unwrap()
212        .as_secs_f64()
213}
214
215fn uuid_short() -> String {
216    use std::collections::hash_map::DefaultHasher;
217    use std::hash::{Hash, Hasher};
218    let mut h = DefaultHasher::new();
219    std::time::SystemTime::now().hash(&mut h);
220    std::thread::current().id().hash(&mut h);
221    format!("{:016x}", h.finish())
222}
223
224use crate::types::WorkflowActivity;