1use std::sync::Arc;
2
3use axum::body::Bytes;
4use axum::extract::{Path, Query, State};
5use axum::routing::{get, post};
6use axum::{Json, Router};
7use serde::{Deserialize, Serialize};
8use utoipa::ToSchema;
9
10use crate::ctx::WorkflowCtx;
11use crate::store::WorkflowStore;
12use crate::types::{RetryFailedActivityResult, WorkflowStatus};
13
14pub fn router<S: WorkflowStore + 'static>() -> Router<Arc<WorkflowCtx<S>>> {
15 Router::new()
16 .route("/workflows", post(start_workflow).get(list_workflows))
17 .route("/workflows/{id}", get(describe_workflow))
18 .route("/workflows/{id}/events", get(get_events_route))
19 .route("/workflows/{id}/signal/{name}", post(send_signal))
20 .route("/workflows/{id}/cancel", post(cancel_workflow))
21 .route("/workflows/{id}/terminate", post(terminate_workflow))
22 .route("/workflows/{id}/retry", post(retry_failed_activity))
23 .route("/workflows/{id}/children", get(list_children))
24 .route("/workflows/{id}/continue-as-new", post(continue_as_new))
25 .route("/workflows/{id}/state", get(get_workflow_state))
26 .route(
27 "/workflows/{id}/state/{name}",
28 get(get_workflow_state_by_name),
29 )
30}
31
32#[derive(Deserialize, ToSchema)]
33pub struct StartWorkflowRequest {
34 pub namespace: Option<String>,
36 pub workflow_type: String,
38 pub workflow_id: String,
40 pub input: Option<serde_json::Value>,
42 #[serde(default = "default_queue")]
44 pub task_queue: String,
45 pub search_attributes: Option<serde_json::Value>,
49}
50
51fn default_queue() -> String {
52 "main".to_string()
53}
54
55#[derive(Serialize, ToSchema)]
56pub struct WorkflowResponse {
57 pub workflow_id: String,
58 pub run_id: String,
59 pub status: String,
60}
61
62#[utoipa::path(
63 post, path = "/api/v1/engine/workflow/workflows",
64 tag = "workflows",
65 request_body = StartWorkflowRequest,
66 responses(
67 (status = 201, description = "Workflow started", body = WorkflowResponse),
68 (status = 500, description = "Internal error"),
69 ),
70)]
71pub async fn start_workflow<S: WorkflowStore>(
72 State(state): State<Arc<WorkflowCtx<S>>>,
73 Json(req): Json<StartWorkflowRequest>,
74) -> Result<(axum::http::StatusCode, Json<WorkflowResponse>), AppError> {
75 let input = req.input.map(|v| v.to_string());
76 let namespace = req.namespace.as_deref().unwrap_or("main");
77 let search_attributes = req.search_attributes.map(|v| v.to_string());
78 let wf = state
79 .start_workflow(
80 namespace,
81 &req.workflow_type,
82 &req.workflow_id,
83 input.as_deref(),
84 &req.task_queue,
85 search_attributes.as_deref(),
86 )
87 .await?;
88
89 Ok((
90 axum::http::StatusCode::CREATED,
91 Json(WorkflowResponse {
92 workflow_id: wf.id,
93 run_id: wf.run_id,
94 status: wf.status,
95 }),
96 ))
97}
98
99#[derive(Deserialize)]
100pub struct ListQuery {
101 #[serde(default = "default_namespace")]
102 pub namespace: String,
103 pub status: Option<String>,
104 #[serde(rename = "type")]
105 pub workflow_type: Option<String>,
106 pub search_attrs: Option<String>,
110 #[serde(default = "default_limit")]
111 pub limit: i64,
112 #[serde(default)]
113 pub offset: i64,
114}
115
116fn default_namespace() -> String {
117 "main".to_string()
118}
119
120fn default_limit() -> i64 {
121 50
122}
123
124#[utoipa::path(
125 get, path = "/api/v1/engine/workflow/workflows",
126 tag = "workflows",
127 params(
128 ("status" = Option<String>, Query, description = "Filter by status"),
129 ("type" = Option<String>, Query, description = "Filter by workflow type"),
130 ("limit" = Option<i64>, Query, description = "Max results (default 50)"),
131 ("offset" = Option<i64>, Query, description = "Pagination offset"),
132 ),
133 responses(
134 (status = 200, description = "List of workflows", body = Vec<WorkflowRecord>),
135 ),
136)]
137pub async fn list_workflows<S: WorkflowStore>(
138 State(state): State<Arc<WorkflowCtx<S>>>,
139 Query(q): Query<ListQuery>,
140) -> Result<Json<Vec<serde_json::Value>>, AppError> {
141 let status = q
142 .status
143 .as_deref()
144 .and_then(|s| s.parse::<WorkflowStatus>().ok());
145
146 let workflows = state
147 .list_workflows(
148 &q.namespace,
149 status,
150 q.workflow_type.as_deref(),
151 q.search_attrs.as_deref(),
152 q.limit,
153 q.offset,
154 )
155 .await?;
156
157 let json: Vec<serde_json::Value> = workflows
158 .into_iter()
159 .map(|w| serde_json::to_value(w).unwrap_or_default())
160 .collect();
161
162 Ok(Json(json))
163}
164
165#[utoipa::path(
166 get, path = "/api/v1/engine/workflow/workflows/{id}",
167 tag = "workflows",
168 params(("id" = String, Path, description = "Workflow ID")),
169 responses(
170 (status = 200, description = "Workflow details", body = WorkflowRecord),
171 (status = 404, description = "Workflow not found"),
172 ),
173)]
174pub async fn describe_workflow<S: WorkflowStore>(
175 State(state): State<Arc<WorkflowCtx<S>>>,
176 Path(id): Path<String>,
177) -> Result<Json<serde_json::Value>, AppError> {
178 let wf = state
179 .get_workflow(&id)
180 .await?
181 .ok_or(AppError::NotFound(format!("workflow {id}")))?;
182
183 Ok(Json(serde_json::to_value(wf)?))
184}
185
186#[derive(Default, Deserialize, PartialEq)]
187#[serde(rename_all = "lowercase")]
188pub enum EventOrder {
189 #[default]
190 Asc,
191 Desc,
192}
193
194#[derive(Default, Deserialize)]
195pub struct EventsQuery {
196 pub limit: Option<u16>,
197 pub cursor: Option<i32>,
198 pub order: Option<EventOrder>,
199}
200
201#[utoipa::path(
202 get, path = "/api/v1/engine/workflow/workflows/{id}/events",
203 tag = "workflows",
204 params(
205 ("id" = String, Path, description = "Workflow ID"),
206 ("limit" = Option<u16>, Query, description = "Bounded page size, capped at 1000"),
207 ("cursor" = Option<i32>, Query, description = "Exclusive event sequence cursor"),
208 ("order" = Option<String>, Query, description = "Sequence order: asc or desc"),
209 ),
210 responses(
211 (status = 200, description = "Event history", body = Vec<WorkflowEvent>),
212 ),
213)]
214pub async fn get_events<S: WorkflowStore>(
215 State(state): State<Arc<WorkflowCtx<S>>>,
216 Path(id): Path<String>,
217) -> Result<Json<Vec<serde_json::Value>>, AppError> {
218 Ok(events_json(state.get_events(&id).await?))
219}
220
221async fn get_events_route<S: WorkflowStore>(
222 State(state): State<Arc<WorkflowCtx<S>>>,
223 Path(id): Path<String>,
224 Query(query): Query<EventsQuery>,
225) -> Result<Json<Vec<serde_json::Value>>, AppError> {
226 let paged = query.limit.is_some() || query.cursor.is_some() || query.order.is_some();
227 if !paged {
228 return get_events(State(state), Path(id)).await;
229 }
230 let events = state
231 .get_events_page(
232 &id,
233 query.cursor,
234 i64::from(query.limit.unwrap_or(50).clamp(1, 1_000)),
235 query.order == Some(EventOrder::Desc),
236 )
237 .await?;
238 Ok(events_json(events))
239}
240
241fn events_json(events: Vec<WorkflowEvent>) -> Json<Vec<serde_json::Value>> {
242 Json(
243 events
244 .into_iter()
245 .map(|e| serde_json::to_value(e).unwrap_or_default())
246 .collect(),
247 )
248}
249
250#[derive(Deserialize, ToSchema)]
251pub struct SignalBody {
252 pub payload: Option<serde_json::Value>,
253}
254
255#[utoipa::path(
256 post, path = "/api/v1/engine/workflow/workflows/{id}/signal/{name}",
257 tag = "workflows",
258 params(
259 ("id" = String, Path, description = "Workflow ID"),
260 ("name" = String, Path, description = "Signal name"),
261 ),
262 responses(
263 (status = 200, description = "Signal sent"),
264 ),
265)]
266pub async fn send_signal<S: WorkflowStore>(
267 State(state): State<Arc<WorkflowCtx<S>>>,
268 Path((id, name)): Path<(String, String)>,
269 Json(body): Json<Option<SignalBody>>,
270) -> Result<axum::http::StatusCode, AppError> {
271 let payload = body.and_then(|b| b.payload).map(|v| v.to_string());
272 state.send_signal(&id, &name, payload.as_deref()).await?;
273 Ok(axum::http::StatusCode::OK)
274}
275
276#[derive(Deserialize, ToSchema, Default)]
277pub struct CancelBody {
278 pub reason: Option<String>,
282}
283
284#[utoipa::path(
285 post, path = "/api/v1/engine/workflow/workflows/{id}/cancel",
286 tag = "workflows",
287 params(("id" = String, Path, description = "Workflow ID")),
288 request_body = CancelBody,
289 responses(
290 (status = 200, description = "Workflow cancelled"),
291 (status = 404, description = "Workflow not found or already terminal"),
292 ),
293)]
294pub async fn cancel_workflow<S: WorkflowStore>(
295 State(state): State<Arc<WorkflowCtx<S>>>,
296 Path(id): Path<String>,
297 body: Bytes,
298) -> Result<axum::http::StatusCode, AppError> {
299 let reason = if body.is_empty() {
304 None
305 } else {
306 serde_json::from_slice::<CancelBody>(&body)
307 .ok()
308 .and_then(|b| b.reason)
309 };
310 let cancelled = state.cancel_workflow(&id, reason.as_deref()).await?;
311 if cancelled {
312 Ok(axum::http::StatusCode::OK)
313 } else {
314 Err(AppError::NotFound(format!(
315 "workflow {id} not found or already terminal"
316 )))
317 }
318}
319
320#[derive(Deserialize, ToSchema)]
321pub struct TerminateBody {
322 pub reason: Option<String>,
323}
324
325#[utoipa::path(
326 post, path = "/api/v1/engine/workflow/workflows/{id}/terminate",
327 tag = "workflows",
328 params(("id" = String, Path, description = "Workflow ID")),
329 responses(
330 (status = 200, description = "Workflow terminated"),
331 (status = 404, description = "Workflow not found or already terminal"),
332 ),
333)]
334pub async fn terminate_workflow<S: WorkflowStore>(
335 State(state): State<Arc<WorkflowCtx<S>>>,
336 Path(id): Path<String>,
337 Json(body): Json<Option<TerminateBody>>,
338) -> Result<axum::http::StatusCode, AppError> {
339 let reason = body.and_then(|b| b.reason);
340 let terminated = state.terminate_workflow(&id, reason.as_deref()).await?;
341 if terminated {
342 Ok(axum::http::StatusCode::OK)
343 } else {
344 Err(AppError::NotFound(format!(
345 "workflow {id} not found or already terminal"
346 )))
347 }
348}
349
350#[derive(Deserialize, ToSchema)]
351pub struct RetryFailedActivityBody {
352 pub requested_by: String,
353 pub reason: String,
354}
355
356#[derive(Serialize, ToSchema)]
357pub struct RetryFailedActivityResponse {
358 pub workflow_id: String,
359 pub status: String,
360 pub activity: crate::types::WorkflowActivity,
361 pub invalidated_activities: u64,
362}
363
364#[utoipa::path(
365 post, path = "/api/v1/engine/workflow/workflows/{id}/retry",
366 tag = "workflows",
367 params(("id" = String, Path, description = "Workflow ID")),
368 request_body = RetryFailedActivityBody,
369 responses(
370 (status = 200, description = "Failed activity requeued", body = RetryFailedActivityResponse),
371 (status = 404, description = "Workflow not found"),
372 (status = 409, description = "Workflow cannot be retried"),
373 ),
374)]
375pub async fn retry_failed_activity<S: WorkflowStore>(
376 State(state): State<Arc<WorkflowCtx<S>>>,
377 Path(id): Path<String>,
378 Json(body): Json<RetryFailedActivityBody>,
379) -> Result<Json<RetryFailedActivityResponse>, AppError> {
380 if body.requested_by.trim().is_empty() || body.reason.trim().is_empty() {
381 return Err(AppError::bad_request(
382 "requested_by and reason are required".to_string(),
383 ));
384 }
385 match state
386 .retry_failed_activity(&id, body.requested_by.trim(), body.reason.trim())
387 .await?
388 {
389 RetryFailedActivityResult::Retried(retried) => Ok(Json(RetryFailedActivityResponse {
390 workflow_id: id,
391 status: "WAITING".to_string(),
392 activity: retried.activity,
393 invalidated_activities: retried.invalidated_activities,
394 })),
395 RetryFailedActivityResult::NotFound => Err(AppError::NotFound(format!("workflow {id}"))),
396 RetryFailedActivityResult::NotFailed { status } => Err(AppError::conflict(format!(
397 "workflow {id} is {status}; only FAILED workflows can retry an activity"
398 ))),
399 RetryFailedActivityResult::Archived => {
400 Err(AppError::conflict(format!("workflow {id} is archived")))
401 }
402 RetryFailedActivityResult::ChildWorkflow => Err(AppError::conflict(format!(
403 "child workflow {id} cannot be retried independently"
404 ))),
405 RetryFailedActivityResult::NoFailedActivity => Err(AppError::conflict(format!(
406 "workflow {id} has no failed activity"
407 ))),
408 RetryFailedActivityResult::Unsupported => Err(AppError::conflict(
409 "the configured workflow store does not support activity retry".to_string(),
410 )),
411 }
412}
413
414#[utoipa::path(
415 get, path = "/api/v1/engine/workflow/workflows/{id}/children",
416 tag = "workflows",
417 params(("id" = String, Path, description = "Parent workflow ID")),
418 responses(
419 (status = 200, description = "Child workflows", body = Vec<WorkflowRecord>),
420 ),
421)]
422pub async fn list_children<S: WorkflowStore>(
423 State(state): State<Arc<WorkflowCtx<S>>>,
424 Path(id): Path<String>,
425) -> Result<Json<Vec<serde_json::Value>>, AppError> {
426 let children = state.list_child_workflows(&id).await?;
427 let json: Vec<serde_json::Value> = children
428 .into_iter()
429 .map(|w| serde_json::to_value(w).unwrap_or_default())
430 .collect();
431 Ok(Json(json))
432}
433
434#[derive(Deserialize, ToSchema)]
435pub struct ContinueAsNewBody {
436 pub input: Option<serde_json::Value>,
438 pub workflow_id: Option<String>,
443}
444
445#[utoipa::path(
446 post, path = "/api/v1/engine/workflow/workflows/{id}/continue-as-new",
447 tag = "workflows",
448 params(("id" = String, Path, description = "Workflow ID to continue")),
449 request_body = ContinueAsNewBody,
450 responses(
451 (status = 201, description = "New workflow run started", body = WorkflowResponse),
452 ),
453)]
454pub async fn continue_as_new<S: WorkflowStore>(
455 State(state): State<Arc<WorkflowCtx<S>>>,
456 Path(id): Path<String>,
457 Json(body): Json<ContinueAsNewBody>,
458) -> Result<(axum::http::StatusCode, Json<WorkflowResponse>), AppError> {
459 let input = body.input.map(|v| v.to_string());
460 let new_id = body.workflow_id.as_deref().filter(|s| !s.trim().is_empty());
461 let wf = state.continue_as_new(&id, input.as_deref(), new_id).await?;
462
463 Ok((
464 axum::http::StatusCode::CREATED,
465 Json(WorkflowResponse {
466 workflow_id: wf.id,
467 run_id: wf.run_id,
468 status: wf.status,
469 }),
470 ))
471}
472
473#[utoipa::path(
483 get, path = "/api/v1/engine/workflow/workflows/{id}/state",
484 tag = "workflows",
485 params(("id" = String, Path, description = "Workflow ID")),
486 responses(
487 (status = 200, description = "Latest state snapshot"),
488 (status = 404, description = "No snapshot recorded for this workflow"),
489 ),
490)]
491pub async fn get_workflow_state<S: WorkflowStore>(
492 State(state): State<Arc<WorkflowCtx<S>>>,
493 Path(id): Path<String>,
494) -> Result<Json<serde_json::Value>, AppError> {
495 let snapshot = state
496 .get_latest_snapshot(&id)
497 .await?
498 .ok_or_else(|| AppError::NotFound(format!("state for workflow {id}")))?;
499
500 let parsed: serde_json::Value =
501 serde_json::from_str(&snapshot.state_json).unwrap_or(serde_json::Value::Null);
502
503 Ok(Json(serde_json::json!({
504 "state": parsed,
505 "event_seq": snapshot.event_seq,
506 "created_at": snapshot.created_at,
507 })))
508}
509
510#[utoipa::path(
515 get, path = "/api/v1/engine/workflow/workflows/{id}/state/{name}",
516 tag = "workflows",
517 params(
518 ("id" = String, Path, description = "Workflow ID"),
519 ("name" = String, Path, description = "Query handler name"),
520 ),
521 responses(
522 (status = 200, description = "Query value"),
523 (status = 404, description = "No snapshot or key not present"),
524 ),
525)]
526pub async fn get_workflow_state_by_name<S: WorkflowStore>(
527 State(state): State<Arc<WorkflowCtx<S>>>,
528 Path((id, name)): Path<(String, String)>,
529) -> Result<Json<serde_json::Value>, AppError> {
530 let snapshot = state
531 .get_latest_snapshot(&id)
532 .await?
533 .ok_or_else(|| AppError::NotFound(format!("state for workflow {id}")))?;
534
535 let parsed: serde_json::Value =
536 serde_json::from_str(&snapshot.state_json).unwrap_or(serde_json::Value::Null);
537
538 let value = parsed
539 .get(&name)
540 .cloned()
541 .ok_or_else(|| AppError::NotFound(format!("query '{name}' for workflow {id}")))?;
542
543 Ok(Json(serde_json::json!({
544 "value": value,
545 "event_seq": snapshot.event_seq,
546 "created_at": snapshot.created_at,
547 })))
548}
549
550pub enum AppError {
553 Internal(anyhow::Error),
554 NotFound(String),
555}
556
557#[derive(Debug)]
558struct HttpError {
559 status: axum::http::StatusCode,
560 message: String,
561}
562
563impl std::fmt::Display for HttpError {
564 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565 f.write_str(&self.message)
566 }
567}
568
569impl std::error::Error for HttpError {}
570
571impl AppError {
572 fn bad_request(message: String) -> Self {
573 Self::http(axum::http::StatusCode::BAD_REQUEST, message)
574 }
575
576 fn conflict(message: String) -> Self {
577 Self::http(axum::http::StatusCode::CONFLICT, message)
578 }
579
580 fn http(status: axum::http::StatusCode, message: String) -> Self {
581 Self::Internal(HttpError { status, message }.into())
582 }
583}
584
585impl From<anyhow::Error> for AppError {
586 fn from(e: anyhow::Error) -> Self {
587 Self::Internal(e)
588 }
589}
590
591impl From<serde_json::Error> for AppError {
592 fn from(e: serde_json::Error) -> Self {
593 Self::Internal(e.into())
594 }
595}
596
597impl axum::response::IntoResponse for AppError {
598 fn into_response(self) -> axum::response::Response {
599 match self {
600 Self::Internal(e) => {
601 if let Some(http_error) = e.downcast_ref::<HttpError>() {
602 return (
603 http_error.status,
604 Json(serde_json::json!({ "error": http_error.message })),
605 )
606 .into_response();
607 }
608 tracing::error!("Internal error: {e}");
609 (
610 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
611 Json(serde_json::json!({ "error": e.to_string() })),
612 )
613 .into_response()
614 }
615 Self::NotFound(msg) => (
616 axum::http::StatusCode::NOT_FOUND,
617 Json(serde_json::json!({ "error": format!("not found: {msg}") })),
618 )
619 .into_response(),
620 }
621 }
622}
623
624use crate::types::{WorkflowEvent, WorkflowRecord};