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(
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#[utoipa::path(
186    get, path = "/api/v1/engine/workflow/workflows/{id}/events",
187    tag = "workflows",
188    params(("id" = String, Path, description = "Workflow ID")),
189    responses(
190        (status = 200, description = "Event history", body = Vec<WorkflowEvent>),
191    ),
192)]
193pub async fn get_events<S: WorkflowStore>(
194    State(state): State<Arc<WorkflowCtx<S>>>,
195    Path(id): Path<String>,
196) -> Result<Json<Vec<serde_json::Value>>, AppError> {
197    let events = state.get_events(&id).await?;
198    let json: Vec<serde_json::Value> = events
199        .into_iter()
200        .map(|e| serde_json::to_value(e).unwrap_or_default())
201        .collect();
202    Ok(Json(json))
203}
204
205#[derive(Deserialize, ToSchema)]
206pub struct SignalBody {
207    pub payload: Option<serde_json::Value>,
208}
209
210#[utoipa::path(
211    post, path = "/api/v1/engine/workflow/workflows/{id}/signal/{name}",
212    tag = "workflows",
213    params(
214        ("id" = String, Path, description = "Workflow ID"),
215        ("name" = String, Path, description = "Signal name"),
216    ),
217    responses(
218        (status = 200, description = "Signal sent"),
219    ),
220)]
221pub async fn send_signal<S: WorkflowStore>(
222    State(state): State<Arc<WorkflowCtx<S>>>,
223    Path((id, name)): Path<(String, String)>,
224    Json(body): Json<Option<SignalBody>>,
225) -> Result<axum::http::StatusCode, AppError> {
226    let payload = body.and_then(|b| b.payload).map(|v| v.to_string());
227    state.send_signal(&id, &name, payload.as_deref()).await?;
228    Ok(axum::http::StatusCode::OK)
229}
230
231#[derive(Deserialize, ToSchema, Default)]
232pub struct CancelBody {
233    /// Why the workflow is being cancelled. Recorded in the
234    /// `WorkflowCancelRequested` event payload for audit. Symmetric
235    /// with the terminate endpoint's reason field.
236    pub reason: Option<String>,
237}
238
239#[utoipa::path(
240    post, path = "/api/v1/engine/workflow/workflows/{id}/cancel",
241    tag = "workflows",
242    params(("id" = String, Path, description = "Workflow ID")),
243    request_body = CancelBody,
244    responses(
245        (status = 200, description = "Workflow cancelled"),
246        (status = 404, description = "Workflow not found or already terminal"),
247    ),
248)]
249pub async fn cancel_workflow<S: WorkflowStore>(
250    State(state): State<Arc<WorkflowCtx<S>>>,
251    Path(id): Path<String>,
252    body: Bytes,
253) -> Result<axum::http::StatusCode, AppError> {
254    // Accept any of: no body, "{}", "[]", '{"reason":"..."}'. Older Lua stdlib
255    // builds (and any caller that auto-fills empty tables) send "[]" which
256    // does not deserialize into CancelBody; treat such bodies as no-reason
257    // rather than 400. See issue #66.
258    let reason = if body.is_empty() {
259        None
260    } else {
261        serde_json::from_slice::<CancelBody>(&body)
262            .ok()
263            .and_then(|b| b.reason)
264    };
265    let cancelled = state.cancel_workflow(&id, reason.as_deref()).await?;
266    if cancelled {
267        Ok(axum::http::StatusCode::OK)
268    } else {
269        Err(AppError::NotFound(format!(
270            "workflow {id} not found or already terminal"
271        )))
272    }
273}
274
275#[derive(Deserialize, ToSchema)]
276pub struct TerminateBody {
277    pub reason: Option<String>,
278}
279
280#[utoipa::path(
281    post, path = "/api/v1/engine/workflow/workflows/{id}/terminate",
282    tag = "workflows",
283    params(("id" = String, Path, description = "Workflow ID")),
284    responses(
285        (status = 200, description = "Workflow terminated"),
286        (status = 404, description = "Workflow not found or already terminal"),
287    ),
288)]
289pub async fn terminate_workflow<S: WorkflowStore>(
290    State(state): State<Arc<WorkflowCtx<S>>>,
291    Path(id): Path<String>,
292    Json(body): Json<Option<TerminateBody>>,
293) -> Result<axum::http::StatusCode, AppError> {
294    let reason = body.and_then(|b| b.reason);
295    let terminated = state.terminate_workflow(&id, reason.as_deref()).await?;
296    if terminated {
297        Ok(axum::http::StatusCode::OK)
298    } else {
299        Err(AppError::NotFound(format!(
300            "workflow {id} not found or already terminal"
301        )))
302    }
303}
304
305#[utoipa::path(
306    get, path = "/api/v1/engine/workflow/workflows/{id}/children",
307    tag = "workflows",
308    params(("id" = String, Path, description = "Parent workflow ID")),
309    responses(
310        (status = 200, description = "Child workflows", body = Vec<WorkflowRecord>),
311    ),
312)]
313pub async fn list_children<S: WorkflowStore>(
314    State(state): State<Arc<WorkflowCtx<S>>>,
315    Path(id): Path<String>,
316) -> Result<Json<Vec<serde_json::Value>>, AppError> {
317    let children = state.list_child_workflows(&id).await?;
318    let json: Vec<serde_json::Value> = children
319        .into_iter()
320        .map(|w| serde_json::to_value(w).unwrap_or_default())
321        .collect();
322    Ok(Json(json))
323}
324
325#[derive(Deserialize, ToSchema)]
326pub struct ContinueAsNewBody {
327    /// New input for the continued workflow run
328    pub input: Option<serde_json::Value>,
329    /// Optional explicit id for the new run. When omitted, the engine
330    /// derives one from the source workflow id + timestamp. Dashboard
331    /// users can override to keep ids sensible after several continues
332    /// (otherwise each run stacks `-continued-<ts>` suffixes forever).
333    pub workflow_id: Option<String>,
334}
335
336#[utoipa::path(
337    post, path = "/api/v1/engine/workflow/workflows/{id}/continue-as-new",
338    tag = "workflows",
339    params(("id" = String, Path, description = "Workflow ID to continue")),
340    request_body = ContinueAsNewBody,
341    responses(
342        (status = 201, description = "New workflow run started", body = WorkflowResponse),
343    ),
344)]
345pub async fn continue_as_new<S: WorkflowStore>(
346    State(state): State<Arc<WorkflowCtx<S>>>,
347    Path(id): Path<String>,
348    Json(body): Json<ContinueAsNewBody>,
349) -> Result<(axum::http::StatusCode, Json<WorkflowResponse>), AppError> {
350    let input = body.input.map(|v| v.to_string());
351    let new_id = body.workflow_id.as_deref().filter(|s| !s.trim().is_empty());
352    let wf = state.continue_as_new(&id, input.as_deref(), new_id).await?;
353
354    Ok((
355        axum::http::StatusCode::CREATED,
356        Json(WorkflowResponse {
357            workflow_id: wf.id,
358            run_id: wf.run_id,
359            status: wf.status,
360        }),
361    ))
362}
363
364// ── Live state (register_query) ─────────────────────────────
365
366/// Read the latest snapshot of a workflow's query-handler state.
367///
368/// Populated by workflow code that calls `ctx:register_query(name, fn)` —
369/// each worker replay re-evaluates the registered handlers and persists the
370/// combined result. Returns 404 if no workflow run has written a snapshot
371/// yet (either the workflow hasn't registered any queries, or the first
372/// replay hasn't completed).
373#[utoipa::path(
374    get, path = "/api/v1/engine/workflow/workflows/{id}/state",
375    tag = "workflows",
376    params(("id" = String, Path, description = "Workflow ID")),
377    responses(
378        (status = 200, description = "Latest state snapshot"),
379        (status = 404, description = "No snapshot recorded for this workflow"),
380    ),
381)]
382pub async fn get_workflow_state<S: WorkflowStore>(
383    State(state): State<Arc<WorkflowCtx<S>>>,
384    Path(id): Path<String>,
385) -> Result<Json<serde_json::Value>, AppError> {
386    let snapshot = state
387        .get_latest_snapshot(&id)
388        .await?
389        .ok_or_else(|| AppError::NotFound(format!("state for workflow {id}")))?;
390
391    let parsed: serde_json::Value =
392        serde_json::from_str(&snapshot.state_json).unwrap_or(serde_json::Value::Null);
393
394    Ok(Json(serde_json::json!({
395        "state": parsed,
396        "event_seq": snapshot.event_seq,
397        "created_at": snapshot.created_at,
398    })))
399}
400
401/// Read a single named query result from a workflow's latest snapshot.
402///
403/// Returns the value under the given key in the latest snapshot's state
404/// object, or 404 if no snapshot exists or the key is absent.
405#[utoipa::path(
406    get, path = "/api/v1/engine/workflow/workflows/{id}/state/{name}",
407    tag = "workflows",
408    params(
409        ("id" = String, Path, description = "Workflow ID"),
410        ("name" = String, Path, description = "Query handler name"),
411    ),
412    responses(
413        (status = 200, description = "Query value"),
414        (status = 404, description = "No snapshot or key not present"),
415    ),
416)]
417pub async fn get_workflow_state_by_name<S: WorkflowStore>(
418    State(state): State<Arc<WorkflowCtx<S>>>,
419    Path((id, name)): Path<(String, String)>,
420) -> Result<Json<serde_json::Value>, AppError> {
421    let snapshot = state
422        .get_latest_snapshot(&id)
423        .await?
424        .ok_or_else(|| AppError::NotFound(format!("state for workflow {id}")))?;
425
426    let parsed: serde_json::Value =
427        serde_json::from_str(&snapshot.state_json).unwrap_or(serde_json::Value::Null);
428
429    let value = parsed
430        .get(&name)
431        .cloned()
432        .ok_or_else(|| AppError::NotFound(format!("query '{name}' for workflow {id}")))?;
433
434    Ok(Json(serde_json::json!({
435        "value": value,
436        "event_seq": snapshot.event_seq,
437        "created_at": snapshot.created_at,
438    })))
439}
440
441// ── Error type ──────────────────────────────────────────────
442
443pub enum AppError {
444    Internal(anyhow::Error),
445    NotFound(String),
446}
447
448impl From<anyhow::Error> for AppError {
449    fn from(e: anyhow::Error) -> Self {
450        Self::Internal(e)
451    }
452}
453
454impl From<serde_json::Error> for AppError {
455    fn from(e: serde_json::Error) -> Self {
456        Self::Internal(e.into())
457    }
458}
459
460impl axum::response::IntoResponse for AppError {
461    fn into_response(self) -> axum::response::Response {
462        match self {
463            Self::Internal(e) => {
464                tracing::error!("Internal error: {e}");
465                (
466                    axum::http::StatusCode::INTERNAL_SERVER_ERROR,
467                    Json(serde_json::json!({ "error": e.to_string() })),
468                )
469                    .into_response()
470            }
471            Self::NotFound(msg) => (
472                axum::http::StatusCode::NOT_FOUND,
473                Json(serde_json::json!({ "error": format!("not found: {msg}") })),
474            )
475                .into_response(),
476        }
477    }
478}
479
480// Type alias for utoipa references (the actual type is WorkflowRecord from types.rs)
481use crate::types::{WorkflowEvent, WorkflowRecord};