Skip to main content

assay_workflow/api/
workflows.rs

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    /// Namespace (default: "main")
35    pub namespace: Option<String>,
36    /// Workflow type name (e.g. "IngestData", "DeployService")
37    pub workflow_type: String,
38    /// Unique workflow ID (caller-provided for idempotency)
39    pub workflow_id: String,
40    /// Optional JSON input passed to the workflow
41    pub input: Option<serde_json::Value>,
42    /// Task queue to route the workflow to (default: "main")
43    #[serde(default = "default_queue")]
44    pub task_queue: String,
45    /// Optional indexed metadata (JSON object). Used by list-filtering;
46    /// workflows can also update it at runtime via
47    /// `ctx:upsert_search_attributes(...)`.
48    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    /// URL-encoded JSON object; matches workflows whose `search_attributes`
107    /// contain every listed key at the given value. e.g.
108    /// `?search_attrs=%7B%22env%22%3A%22prod%22%7D` for `{"env":"prod"}`.
109    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    /// Why the workflow is being cancelled. Recorded in the
279    /// `WorkflowCancelRequested` event payload for audit. Symmetric
280    /// with the terminate endpoint's reason field.
281    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    // Accept any of: no body, "{}", "[]", '{"reason":"..."}'. Older Lua stdlib
300    // builds (and any caller that auto-fills empty tables) send "[]" which
301    // does not deserialize into CancelBody; treat such bodies as no-reason
302    // rather than 400. See issue #66.
303    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    /// New input for the continued workflow run
437    pub input: Option<serde_json::Value>,
438    /// Optional explicit id for the new run. When omitted, the engine
439    /// derives one from the source workflow id + timestamp. Dashboard
440    /// users can override to keep ids sensible after several continues
441    /// (otherwise each run stacks `-continued-<ts>` suffixes forever).
442    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// ── Live state (register_query) ─────────────────────────────
474
475/// Read the latest snapshot of a workflow's query-handler state.
476///
477/// Populated by workflow code that calls `ctx:register_query(name, fn)` —
478/// each worker replay re-evaluates the registered handlers and persists the
479/// combined result. Returns 404 if no workflow run has written a snapshot
480/// yet (either the workflow hasn't registered any queries, or the first
481/// replay hasn't completed).
482#[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/// Read a single named query result from a workflow's latest snapshot.
511///
512/// Returns the value under the given key in the latest snapshot's state
513/// object, or 404 if no snapshot exists or the key is absent.
514#[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
550// ── Error type ──────────────────────────────────────────────
551
552pub 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
624// Type alias for utoipa references (the actual type is WorkflowRecord from types.rs)
625use crate::types::{WorkflowEvent, WorkflowRecord};