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))
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("/workflows/{id}/state/{name}", get(get_workflow_state_by_name))
26}
27
28#[derive(Deserialize, ToSchema)]
29pub struct StartWorkflowRequest {
30    /// Namespace (default: "main")
31    pub namespace: Option<String>,
32    /// Workflow type name (e.g. "IngestData", "DeployService")
33    pub workflow_type: String,
34    /// Unique workflow ID (caller-provided for idempotency)
35    pub workflow_id: String,
36    /// Optional JSON input passed to the workflow
37    pub input: Option<serde_json::Value>,
38    /// Task queue to route the workflow to (default: "main")
39    #[serde(default = "default_queue")]
40    pub task_queue: String,
41    /// Optional indexed metadata (JSON object). Used by list-filtering;
42    /// workflows can also update it at runtime via
43    /// `ctx:upsert_search_attributes(...)`.
44    pub search_attributes: Option<serde_json::Value>,
45}
46
47fn default_queue() -> String {
48    "main".to_string()
49}
50
51#[derive(Serialize, ToSchema)]
52pub struct WorkflowResponse {
53    pub workflow_id: String,
54    pub run_id: String,
55    pub status: String,
56}
57
58#[utoipa::path(
59    post, path = "/api/v1/engine/workflow/workflows",
60    tag = "workflows",
61    request_body = StartWorkflowRequest,
62    responses(
63        (status = 201, description = "Workflow started", body = WorkflowResponse),
64        (status = 500, description = "Internal error"),
65    ),
66)]
67pub async fn start_workflow<S: WorkflowStore>(
68    State(state): State<Arc<WorkflowCtx<S>>>,
69    Json(req): Json<StartWorkflowRequest>,
70) -> Result<(axum::http::StatusCode, Json<WorkflowResponse>), AppError> {
71    let input = req.input.map(|v| v.to_string());
72    let namespace = req.namespace.as_deref().unwrap_or("main");
73    let search_attributes = req.search_attributes.map(|v| v.to_string());
74    let wf = state
75        .start_workflow(
76            namespace,
77            &req.workflow_type,
78            &req.workflow_id,
79            input.as_deref(),
80            &req.task_queue,
81            search_attributes.as_deref(),
82        )
83        .await?;
84
85    Ok((
86        axum::http::StatusCode::CREATED,
87        Json(WorkflowResponse {
88            workflow_id: wf.id,
89            run_id: wf.run_id,
90            status: wf.status,
91        }),
92    ))
93}
94
95#[derive(Deserialize)]
96pub struct ListQuery {
97    #[serde(default = "default_namespace")]
98    pub namespace: String,
99    pub status: Option<String>,
100    #[serde(rename = "type")]
101    pub workflow_type: Option<String>,
102    /// URL-encoded JSON object; matches workflows whose `search_attributes`
103    /// contain every listed key at the given value. e.g.
104    /// `?search_attrs=%7B%22env%22%3A%22prod%22%7D` for `{"env":"prod"}`.
105    pub search_attrs: Option<String>,
106    #[serde(default = "default_limit")]
107    pub limit: i64,
108    #[serde(default)]
109    pub offset: i64,
110}
111
112fn default_namespace() -> String {
113    "main".to_string()
114}
115
116fn default_limit() -> i64 {
117    50
118}
119
120#[utoipa::path(
121    get, path = "/api/v1/engine/workflow/workflows",
122    tag = "workflows",
123    params(
124        ("status" = Option<String>, Query, description = "Filter by status"),
125        ("type" = Option<String>, Query, description = "Filter by workflow type"),
126        ("limit" = Option<i64>, Query, description = "Max results (default 50)"),
127        ("offset" = Option<i64>, Query, description = "Pagination offset"),
128    ),
129    responses(
130        (status = 200, description = "List of workflows", body = Vec<WorkflowRecord>),
131    ),
132)]
133pub async fn list_workflows<S: WorkflowStore>(
134    State(state): State<Arc<WorkflowCtx<S>>>,
135    Query(q): Query<ListQuery>,
136) -> Result<Json<Vec<serde_json::Value>>, AppError> {
137    let status = q
138        .status
139        .as_deref()
140        .and_then(|s| s.parse::<WorkflowStatus>().ok());
141
142    let workflows = state
143        .list_workflows(
144            &q.namespace,
145            status,
146            q.workflow_type.as_deref(),
147            q.search_attrs.as_deref(),
148            q.limit,
149            q.offset,
150        )
151        .await?;
152
153    let json: Vec<serde_json::Value> = workflows
154        .into_iter()
155        .map(|w| serde_json::to_value(w).unwrap_or_default())
156        .collect();
157
158    Ok(Json(json))
159}
160
161#[utoipa::path(
162    get, path = "/api/v1/engine/workflow/workflows/{id}",
163    tag = "workflows",
164    params(("id" = String, Path, description = "Workflow ID")),
165    responses(
166        (status = 200, description = "Workflow details", body = WorkflowRecord),
167        (status = 404, description = "Workflow not found"),
168    ),
169)]
170pub async fn describe_workflow<S: WorkflowStore>(
171    State(state): State<Arc<WorkflowCtx<S>>>,
172    Path(id): Path<String>,
173) -> Result<Json<serde_json::Value>, AppError> {
174    let wf = state
175        .get_workflow(&id)
176        .await?
177        .ok_or(AppError::NotFound(format!("workflow {id}")))?;
178
179    Ok(Json(serde_json::to_value(wf)?))
180}
181
182#[utoipa::path(
183    get, path = "/api/v1/engine/workflow/workflows/{id}/events",
184    tag = "workflows",
185    params(("id" = String, Path, description = "Workflow ID")),
186    responses(
187        (status = 200, description = "Event history", body = Vec<WorkflowEvent>),
188    ),
189)]
190pub async fn get_events<S: WorkflowStore>(
191    State(state): State<Arc<WorkflowCtx<S>>>,
192    Path(id): Path<String>,
193) -> Result<Json<Vec<serde_json::Value>>, AppError> {
194    let events = state.get_events(&id).await?;
195    let json: Vec<serde_json::Value> = events
196        .into_iter()
197        .map(|e| serde_json::to_value(e).unwrap_or_default())
198        .collect();
199    Ok(Json(json))
200}
201
202#[derive(Deserialize, ToSchema)]
203pub struct SignalBody {
204    pub payload: Option<serde_json::Value>,
205}
206
207#[utoipa::path(
208    post, path = "/api/v1/engine/workflow/workflows/{id}/signal/{name}",
209    tag = "workflows",
210    params(
211        ("id" = String, Path, description = "Workflow ID"),
212        ("name" = String, Path, description = "Signal name"),
213    ),
214    responses(
215        (status = 200, description = "Signal sent"),
216    ),
217)]
218pub async fn send_signal<S: WorkflowStore>(
219    State(state): State<Arc<WorkflowCtx<S>>>,
220    Path((id, name)): Path<(String, String)>,
221    Json(body): Json<Option<SignalBody>>,
222) -> Result<axum::http::StatusCode, AppError> {
223    let payload = body.and_then(|b| b.payload).map(|v| v.to_string());
224    state
225        .send_signal(&id, &name, payload.as_deref())
226        .await?;
227    Ok(axum::http::StatusCode::OK)
228}
229
230#[derive(Deserialize, ToSchema, Default)]
231pub struct CancelBody {
232    /// Why the workflow is being cancelled. Recorded in the
233    /// `WorkflowCancelRequested` event payload for audit. Symmetric
234    /// with the terminate endpoint's reason field.
235    pub reason: Option<String>,
236}
237
238#[utoipa::path(
239    post, path = "/api/v1/engine/workflow/workflows/{id}/cancel",
240    tag = "workflows",
241    params(("id" = String, Path, description = "Workflow ID")),
242    request_body = CancelBody,
243    responses(
244        (status = 200, description = "Workflow cancelled"),
245        (status = 404, description = "Workflow not found or already terminal"),
246    ),
247)]
248pub async fn cancel_workflow<S: WorkflowStore>(
249    State(state): State<Arc<WorkflowCtx<S>>>,
250    Path(id): Path<String>,
251    body: Bytes,
252) -> Result<axum::http::StatusCode, AppError> {
253    // Accept any of: no body, "{}", "[]", '{"reason":"..."}'. Older Lua stdlib
254    // builds (and any caller that auto-fills empty tables) send "[]" which
255    // does not deserialize into CancelBody; treat such bodies as no-reason
256    // rather than 400. See issue #66.
257    let reason = if body.is_empty() {
258        None
259    } else {
260        serde_json::from_slice::<CancelBody>(&body)
261            .ok()
262            .and_then(|b| b.reason)
263    };
264    let cancelled = state.cancel_workflow(&id, reason.as_deref()).await?;
265    if cancelled {
266        Ok(axum::http::StatusCode::OK)
267    } else {
268        Err(AppError::NotFound(format!(
269            "workflow {id} not found or already terminal"
270        )))
271    }
272}
273
274#[derive(Deserialize, ToSchema)]
275pub struct TerminateBody {
276    pub reason: Option<String>,
277}
278
279#[utoipa::path(
280    post, path = "/api/v1/engine/workflow/workflows/{id}/terminate",
281    tag = "workflows",
282    params(("id" = String, Path, description = "Workflow ID")),
283    responses(
284        (status = 200, description = "Workflow terminated"),
285        (status = 404, description = "Workflow not found or already terminal"),
286    ),
287)]
288pub async fn terminate_workflow<S: WorkflowStore>(
289    State(state): State<Arc<WorkflowCtx<S>>>,
290    Path(id): Path<String>,
291    Json(body): Json<Option<TerminateBody>>,
292) -> Result<axum::http::StatusCode, AppError> {
293    let reason = body.and_then(|b| b.reason);
294    let terminated = state
295        .terminate_workflow(&id, reason.as_deref())
296        .await?;
297    if terminated {
298        Ok(axum::http::StatusCode::OK)
299    } else {
300        Err(AppError::NotFound(format!(
301            "workflow {id} not found or already terminal"
302        )))
303    }
304}
305
306#[utoipa::path(
307    get, path = "/api/v1/engine/workflow/workflows/{id}/children",
308    tag = "workflows",
309    params(("id" = String, Path, description = "Parent workflow ID")),
310    responses(
311        (status = 200, description = "Child workflows", body = Vec<WorkflowRecord>),
312    ),
313)]
314pub async fn list_children<S: WorkflowStore>(
315    State(state): State<Arc<WorkflowCtx<S>>>,
316    Path(id): Path<String>,
317) -> Result<Json<Vec<serde_json::Value>>, AppError> {
318    let children = state.list_child_workflows(&id).await?;
319    let json: Vec<serde_json::Value> = children
320        .into_iter()
321        .map(|w| serde_json::to_value(w).unwrap_or_default())
322        .collect();
323    Ok(Json(json))
324}
325
326#[derive(Deserialize, ToSchema)]
327pub struct ContinueAsNewBody {
328    /// New input for the continued workflow run
329    pub input: Option<serde_json::Value>,
330    /// Optional explicit id for the new run. When omitted, the engine
331    /// derives one from the source workflow id + timestamp. Dashboard
332    /// users can override to keep ids sensible after several continues
333    /// (otherwise each run stacks `-continued-<ts>` suffixes forever).
334    pub workflow_id: Option<String>,
335}
336
337#[utoipa::path(
338    post, path = "/api/v1/engine/workflow/workflows/{id}/continue-as-new",
339    tag = "workflows",
340    params(("id" = String, Path, description = "Workflow ID to continue")),
341    request_body = ContinueAsNewBody,
342    responses(
343        (status = 201, description = "New workflow run started", body = WorkflowResponse),
344    ),
345)]
346pub async fn continue_as_new<S: WorkflowStore>(
347    State(state): State<Arc<WorkflowCtx<S>>>,
348    Path(id): Path<String>,
349    Json(body): Json<ContinueAsNewBody>,
350) -> Result<(axum::http::StatusCode, Json<WorkflowResponse>), AppError> {
351    let input = body.input.map(|v| v.to_string());
352    let new_id = body
353        .workflow_id
354        .as_deref()
355        .filter(|s| !s.trim().is_empty());
356    let wf = state
357        .continue_as_new(&id, input.as_deref(), new_id)
358        .await?;
359
360    Ok((
361        axum::http::StatusCode::CREATED,
362        Json(WorkflowResponse {
363            workflow_id: wf.id,
364            run_id: wf.run_id,
365            status: wf.status,
366        }),
367    ))
368}
369
370// ── Live state (register_query) ─────────────────────────────
371
372/// Read the latest snapshot of a workflow's query-handler state.
373///
374/// Populated by workflow code that calls `ctx:register_query(name, fn)` —
375/// each worker replay re-evaluates the registered handlers and persists the
376/// combined result. Returns 404 if no workflow run has written a snapshot
377/// yet (either the workflow hasn't registered any queries, or the first
378/// replay hasn't completed).
379#[utoipa::path(
380    get, path = "/api/v1/engine/workflow/workflows/{id}/state",
381    tag = "workflows",
382    params(("id" = String, Path, description = "Workflow ID")),
383    responses(
384        (status = 200, description = "Latest state snapshot"),
385        (status = 404, description = "No snapshot recorded for this workflow"),
386    ),
387)]
388pub async fn get_workflow_state<S: WorkflowStore>(
389    State(state): State<Arc<WorkflowCtx<S>>>,
390    Path(id): Path<String>,
391) -> Result<Json<serde_json::Value>, AppError> {
392    let snapshot = state
393        .get_latest_snapshot(&id)
394        .await?
395        .ok_or_else(|| AppError::NotFound(format!("state for workflow {id}")))?;
396
397    let parsed: serde_json::Value =
398        serde_json::from_str(&snapshot.state_json).unwrap_or(serde_json::Value::Null);
399
400    Ok(Json(serde_json::json!({
401        "state": parsed,
402        "event_seq": snapshot.event_seq,
403        "created_at": snapshot.created_at,
404    })))
405}
406
407/// Read a single named query result from a workflow's latest snapshot.
408///
409/// Returns the value under the given key in the latest snapshot's state
410/// object, or 404 if no snapshot exists or the key is absent.
411#[utoipa::path(
412    get, path = "/api/v1/engine/workflow/workflows/{id}/state/{name}",
413    tag = "workflows",
414    params(
415        ("id" = String, Path, description = "Workflow ID"),
416        ("name" = String, Path, description = "Query handler name"),
417    ),
418    responses(
419        (status = 200, description = "Query value"),
420        (status = 404, description = "No snapshot or key not present"),
421    ),
422)]
423pub async fn get_workflow_state_by_name<S: WorkflowStore>(
424    State(state): State<Arc<WorkflowCtx<S>>>,
425    Path((id, name)): Path<(String, String)>,
426) -> Result<Json<serde_json::Value>, AppError> {
427    let snapshot = state
428        .get_latest_snapshot(&id)
429        .await?
430        .ok_or_else(|| AppError::NotFound(format!("state for workflow {id}")))?;
431
432    let parsed: serde_json::Value =
433        serde_json::from_str(&snapshot.state_json).unwrap_or(serde_json::Value::Null);
434
435    let value = parsed
436        .get(&name)
437        .cloned()
438        .ok_or_else(|| AppError::NotFound(format!("query '{name}' for workflow {id}")))?;
439
440    Ok(Json(serde_json::json!({
441        "value": value,
442        "event_seq": snapshot.event_seq,
443        "created_at": snapshot.created_at,
444    })))
445}
446
447// ── Error type ──────────────────────────────────────────────
448
449pub enum AppError {
450    Internal(anyhow::Error),
451    NotFound(String),
452}
453
454impl From<anyhow::Error> for AppError {
455    fn from(e: anyhow::Error) -> Self {
456        Self::Internal(e)
457    }
458}
459
460impl From<serde_json::Error> for AppError {
461    fn from(e: serde_json::Error) -> Self {
462        Self::Internal(e.into())
463    }
464}
465
466impl axum::response::IntoResponse for AppError {
467    fn into_response(self) -> axum::response::Response {
468        match self {
469            Self::Internal(e) => {
470                tracing::error!("Internal error: {e}");
471                (
472                    axum::http::StatusCode::INTERNAL_SERVER_ERROR,
473                    Json(serde_json::json!({ "error": e.to_string() })),
474                )
475                    .into_response()
476            }
477            Self::NotFound(msg) => (
478                axum::http::StatusCode::NOT_FOUND,
479                Json(serde_json::json!({ "error": format!("not found: {msg}") })),
480            )
481                .into_response(),
482        }
483    }
484}
485
486// Type alias for utoipa references (the actual type is WorkflowRecord from types.rs)
487use crate::types::{WorkflowEvent, WorkflowRecord};