Skip to main content

assay_workflow/api/
workflows.rs

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