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::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}/children", get(list_children))
23 .route("/workflows/{id}/continue-as-new", post(continue_as_new))
24 .route("/workflows/{id}/state", get(get_workflow_state))
25 .route(
26 "/workflows/{id}/state/{name}",
27 get(get_workflow_state_by_name),
28 )
29}
30
31#[derive(Deserialize, ToSchema)]
32pub struct StartWorkflowRequest {
33 pub namespace: Option<String>,
35 pub workflow_type: String,
37 pub workflow_id: String,
39 pub input: Option<serde_json::Value>,
41 #[serde(default = "default_queue")]
43 pub task_queue: String,
44 pub search_attributes: Option<serde_json::Value>,
48}
49
50fn default_queue() -> String {
51 "main".to_string()
52}
53
54#[derive(Serialize, ToSchema)]
55pub struct WorkflowResponse {
56 pub workflow_id: String,
57 pub run_id: String,
58 pub status: String,
59}
60
61#[utoipa::path(
62 post, path = "/api/v1/engine/workflow/workflows",
63 tag = "workflows",
64 request_body = StartWorkflowRequest,
65 responses(
66 (status = 201, description = "Workflow started", body = WorkflowResponse),
67 (status = 500, description = "Internal error"),
68 ),
69)]
70pub async fn start_workflow<S: WorkflowStore>(
71 State(state): State<Arc<WorkflowCtx<S>>>,
72 Json(req): Json<StartWorkflowRequest>,
73) -> Result<(axum::http::StatusCode, Json<WorkflowResponse>), AppError> {
74 let input = req.input.map(|v| v.to_string());
75 let namespace = req.namespace.as_deref().unwrap_or("main");
76 let search_attributes = req.search_attributes.map(|v| v.to_string());
77 let wf = state
78 .start_workflow(
79 namespace,
80 &req.workflow_type,
81 &req.workflow_id,
82 input.as_deref(),
83 &req.task_queue,
84 search_attributes.as_deref(),
85 )
86 .await?;
87
88 Ok((
89 axum::http::StatusCode::CREATED,
90 Json(WorkflowResponse {
91 workflow_id: wf.id,
92 run_id: wf.run_id,
93 status: wf.status,
94 }),
95 ))
96}
97
98#[derive(Deserialize)]
99pub struct ListQuery {
100 #[serde(default = "default_namespace")]
101 pub namespace: String,
102 pub status: Option<String>,
103 #[serde(rename = "type")]
104 pub workflow_type: Option<String>,
105 pub search_attrs: Option<String>,
109 #[serde(default = "default_limit")]
110 pub limit: i64,
111 #[serde(default)]
112 pub offset: i64,
113}
114
115fn default_namespace() -> String {
116 "main".to_string()
117}
118
119fn default_limit() -> i64 {
120 50
121}
122
123#[utoipa::path(
124 get, path = "/api/v1/engine/workflow/workflows",
125 tag = "workflows",
126 params(
127 ("status" = Option<String>, Query, description = "Filter by status"),
128 ("type" = Option<String>, Query, description = "Filter by workflow type"),
129 ("limit" = Option<i64>, Query, description = "Max results (default 50)"),
130 ("offset" = Option<i64>, Query, description = "Pagination offset"),
131 ),
132 responses(
133 (status = 200, description = "List of workflows", body = Vec<WorkflowRecord>),
134 ),
135)]
136pub async fn list_workflows<S: WorkflowStore>(
137 State(state): State<Arc<WorkflowCtx<S>>>,
138 Query(q): Query<ListQuery>,
139) -> Result<Json<Vec<serde_json::Value>>, AppError> {
140 let status = q
141 .status
142 .as_deref()
143 .and_then(|s| s.parse::<WorkflowStatus>().ok());
144
145 let workflows = state
146 .list_workflows(
147 &q.namespace,
148 status,
149 q.workflow_type.as_deref(),
150 q.search_attrs.as_deref(),
151 q.limit,
152 q.offset,
153 )
154 .await?;
155
156 let json: Vec<serde_json::Value> = workflows
157 .into_iter()
158 .map(|w| serde_json::to_value(w).unwrap_or_default())
159 .collect();
160
161 Ok(Json(json))
162}
163
164#[utoipa::path(
165 get, path = "/api/v1/engine/workflow/workflows/{id}",
166 tag = "workflows",
167 params(("id" = String, Path, description = "Workflow ID")),
168 responses(
169 (status = 200, description = "Workflow details", body = WorkflowRecord),
170 (status = 404, description = "Workflow not found"),
171 ),
172)]
173pub async fn describe_workflow<S: WorkflowStore>(
174 State(state): State<Arc<WorkflowCtx<S>>>,
175 Path(id): Path<String>,
176) -> Result<Json<serde_json::Value>, AppError> {
177 let wf = state
178 .get_workflow(&id)
179 .await?
180 .ok_or(AppError::NotFound(format!("workflow {id}")))?;
181
182 Ok(Json(serde_json::to_value(wf)?))
183}
184
185#[derive(Default, Deserialize, PartialEq)]
186#[serde(rename_all = "lowercase")]
187pub enum EventOrder {
188 #[default]
189 Asc,
190 Desc,
191}
192
193#[derive(Default, Deserialize)]
194pub struct EventsQuery {
195 pub limit: Option<u16>,
196 pub cursor: Option<i32>,
197 pub order: Option<EventOrder>,
198}
199
200#[utoipa::path(
201 get, path = "/api/v1/engine/workflow/workflows/{id}/events",
202 tag = "workflows",
203 params(
204 ("id" = String, Path, description = "Workflow ID"),
205 ("limit" = Option<u16>, Query, description = "Bounded page size, capped at 1000"),
206 ("cursor" = Option<i32>, Query, description = "Exclusive event sequence cursor"),
207 ("order" = Option<String>, Query, description = "Sequence order: asc or desc"),
208 ),
209 responses(
210 (status = 200, description = "Event history", body = Vec<WorkflowEvent>),
211 ),
212)]
213pub async fn get_events<S: WorkflowStore>(
214 State(state): State<Arc<WorkflowCtx<S>>>,
215 Path(id): Path<String>,
216) -> Result<Json<Vec<serde_json::Value>>, AppError> {
217 Ok(events_json(state.get_events(&id).await?))
218}
219
220async fn get_events_route<S: WorkflowStore>(
221 State(state): State<Arc<WorkflowCtx<S>>>,
222 Path(id): Path<String>,
223 Query(query): Query<EventsQuery>,
224) -> Result<Json<Vec<serde_json::Value>>, AppError> {
225 let paged = query.limit.is_some() || query.cursor.is_some() || query.order.is_some();
226 if !paged {
227 return get_events(State(state), Path(id)).await;
228 }
229 let events = state
230 .get_events_page(
231 &id,
232 query.cursor,
233 i64::from(query.limit.unwrap_or(50).clamp(1, 1_000)),
234 query.order == Some(EventOrder::Desc),
235 )
236 .await?;
237 Ok(events_json(events))
238}
239
240fn events_json(events: Vec<WorkflowEvent>) -> Json<Vec<serde_json::Value>> {
241 Json(
242 events
243 .into_iter()
244 .map(|e| serde_json::to_value(e).unwrap_or_default())
245 .collect(),
246 )
247}
248
249#[derive(Deserialize, ToSchema)]
250pub struct SignalBody {
251 pub payload: Option<serde_json::Value>,
252}
253
254#[utoipa::path(
255 post, path = "/api/v1/engine/workflow/workflows/{id}/signal/{name}",
256 tag = "workflows",
257 params(
258 ("id" = String, Path, description = "Workflow ID"),
259 ("name" = String, Path, description = "Signal name"),
260 ),
261 responses(
262 (status = 200, description = "Signal sent"),
263 ),
264)]
265pub async fn send_signal<S: WorkflowStore>(
266 State(state): State<Arc<WorkflowCtx<S>>>,
267 Path((id, name)): Path<(String, String)>,
268 Json(body): Json<Option<SignalBody>>,
269) -> Result<axum::http::StatusCode, AppError> {
270 let payload = body.and_then(|b| b.payload).map(|v| v.to_string());
271 state.send_signal(&id, &name, payload.as_deref()).await?;
272 Ok(axum::http::StatusCode::OK)
273}
274
275#[derive(Deserialize, ToSchema, Default)]
276pub struct CancelBody {
277 pub reason: Option<String>,
281}
282
283#[utoipa::path(
284 post, path = "/api/v1/engine/workflow/workflows/{id}/cancel",
285 tag = "workflows",
286 params(("id" = String, Path, description = "Workflow ID")),
287 request_body = CancelBody,
288 responses(
289 (status = 200, description = "Workflow cancelled"),
290 (status = 404, description = "Workflow not found or already terminal"),
291 ),
292)]
293pub async fn cancel_workflow<S: WorkflowStore>(
294 State(state): State<Arc<WorkflowCtx<S>>>,
295 Path(id): Path<String>,
296 body: Bytes,
297) -> Result<axum::http::StatusCode, AppError> {
298 let reason = if body.is_empty() {
303 None
304 } else {
305 serde_json::from_slice::<CancelBody>(&body)
306 .ok()
307 .and_then(|b| b.reason)
308 };
309 let cancelled = state.cancel_workflow(&id, reason.as_deref()).await?;
310 if cancelled {
311 Ok(axum::http::StatusCode::OK)
312 } else {
313 Err(AppError::NotFound(format!(
314 "workflow {id} not found or already terminal"
315 )))
316 }
317}
318
319#[derive(Deserialize, ToSchema)]
320pub struct TerminateBody {
321 pub reason: Option<String>,
322}
323
324#[utoipa::path(
325 post, path = "/api/v1/engine/workflow/workflows/{id}/terminate",
326 tag = "workflows",
327 params(("id" = String, Path, description = "Workflow ID")),
328 responses(
329 (status = 200, description = "Workflow terminated"),
330 (status = 404, description = "Workflow not found or already terminal"),
331 ),
332)]
333pub async fn terminate_workflow<S: WorkflowStore>(
334 State(state): State<Arc<WorkflowCtx<S>>>,
335 Path(id): Path<String>,
336 Json(body): Json<Option<TerminateBody>>,
337) -> Result<axum::http::StatusCode, AppError> {
338 let reason = body.and_then(|b| b.reason);
339 let terminated = state.terminate_workflow(&id, reason.as_deref()).await?;
340 if terminated {
341 Ok(axum::http::StatusCode::OK)
342 } else {
343 Err(AppError::NotFound(format!(
344 "workflow {id} not found or already terminal"
345 )))
346 }
347}
348
349#[utoipa::path(
350 get, path = "/api/v1/engine/workflow/workflows/{id}/children",
351 tag = "workflows",
352 params(("id" = String, Path, description = "Parent workflow ID")),
353 responses(
354 (status = 200, description = "Child workflows", body = Vec<WorkflowRecord>),
355 ),
356)]
357pub async fn list_children<S: WorkflowStore>(
358 State(state): State<Arc<WorkflowCtx<S>>>,
359 Path(id): Path<String>,
360) -> Result<Json<Vec<serde_json::Value>>, AppError> {
361 let children = state.list_child_workflows(&id).await?;
362 let json: Vec<serde_json::Value> = children
363 .into_iter()
364 .map(|w| serde_json::to_value(w).unwrap_or_default())
365 .collect();
366 Ok(Json(json))
367}
368
369#[derive(Deserialize, ToSchema)]
370pub struct ContinueAsNewBody {
371 pub input: Option<serde_json::Value>,
373 pub workflow_id: Option<String>,
378}
379
380#[utoipa::path(
381 post, path = "/api/v1/engine/workflow/workflows/{id}/continue-as-new",
382 tag = "workflows",
383 params(("id" = String, Path, description = "Workflow ID to continue")),
384 request_body = ContinueAsNewBody,
385 responses(
386 (status = 201, description = "New workflow run started", body = WorkflowResponse),
387 ),
388)]
389pub async fn continue_as_new<S: WorkflowStore>(
390 State(state): State<Arc<WorkflowCtx<S>>>,
391 Path(id): Path<String>,
392 Json(body): Json<ContinueAsNewBody>,
393) -> Result<(axum::http::StatusCode, Json<WorkflowResponse>), AppError> {
394 let input = body.input.map(|v| v.to_string());
395 let new_id = body.workflow_id.as_deref().filter(|s| !s.trim().is_empty());
396 let wf = state.continue_as_new(&id, input.as_deref(), new_id).await?;
397
398 Ok((
399 axum::http::StatusCode::CREATED,
400 Json(WorkflowResponse {
401 workflow_id: wf.id,
402 run_id: wf.run_id,
403 status: wf.status,
404 }),
405 ))
406}
407
408#[utoipa::path(
418 get, path = "/api/v1/engine/workflow/workflows/{id}/state",
419 tag = "workflows",
420 params(("id" = String, Path, description = "Workflow ID")),
421 responses(
422 (status = 200, description = "Latest state snapshot"),
423 (status = 404, description = "No snapshot recorded for this workflow"),
424 ),
425)]
426pub async fn get_workflow_state<S: WorkflowStore>(
427 State(state): State<Arc<WorkflowCtx<S>>>,
428 Path(id): Path<String>,
429) -> Result<Json<serde_json::Value>, AppError> {
430 let snapshot = state
431 .get_latest_snapshot(&id)
432 .await?
433 .ok_or_else(|| AppError::NotFound(format!("state for workflow {id}")))?;
434
435 let parsed: serde_json::Value =
436 serde_json::from_str(&snapshot.state_json).unwrap_or(serde_json::Value::Null);
437
438 Ok(Json(serde_json::json!({
439 "state": parsed,
440 "event_seq": snapshot.event_seq,
441 "created_at": snapshot.created_at,
442 })))
443}
444
445#[utoipa::path(
450 get, path = "/api/v1/engine/workflow/workflows/{id}/state/{name}",
451 tag = "workflows",
452 params(
453 ("id" = String, Path, description = "Workflow ID"),
454 ("name" = String, Path, description = "Query handler name"),
455 ),
456 responses(
457 (status = 200, description = "Query value"),
458 (status = 404, description = "No snapshot or key not present"),
459 ),
460)]
461pub async fn get_workflow_state_by_name<S: WorkflowStore>(
462 State(state): State<Arc<WorkflowCtx<S>>>,
463 Path((id, name)): Path<(String, String)>,
464) -> Result<Json<serde_json::Value>, AppError> {
465 let snapshot = state
466 .get_latest_snapshot(&id)
467 .await?
468 .ok_or_else(|| AppError::NotFound(format!("state for workflow {id}")))?;
469
470 let parsed: serde_json::Value =
471 serde_json::from_str(&snapshot.state_json).unwrap_or(serde_json::Value::Null);
472
473 let value = parsed
474 .get(&name)
475 .cloned()
476 .ok_or_else(|| AppError::NotFound(format!("query '{name}' for workflow {id}")))?;
477
478 Ok(Json(serde_json::json!({
479 "value": value,
480 "event_seq": snapshot.event_seq,
481 "created_at": snapshot.created_at,
482 })))
483}
484
485pub enum AppError {
488 Internal(anyhow::Error),
489 NotFound(String),
490}
491
492impl From<anyhow::Error> for AppError {
493 fn from(e: anyhow::Error) -> Self {
494 Self::Internal(e)
495 }
496}
497
498impl From<serde_json::Error> for AppError {
499 fn from(e: serde_json::Error) -> Self {
500 Self::Internal(e.into())
501 }
502}
503
504impl axum::response::IntoResponse for AppError {
505 fn into_response(self) -> axum::response::Response {
506 match self {
507 Self::Internal(e) => {
508 tracing::error!("Internal error: {e}");
509 (
510 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
511 Json(serde_json::json!({ "error": e.to_string() })),
512 )
513 .into_response()
514 }
515 Self::NotFound(msg) => (
516 axum::http::StatusCode::NOT_FOUND,
517 Json(serde_json::json!({ "error": format!("not found: {msg}") })),
518 )
519 .into_response(),
520 }
521 }
522}
523
524use crate::types::{WorkflowEvent, WorkflowRecord};