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