Skip to main content

aion_server/dev_ui/
handlers.rs

1//! Transport-agnostic dev-server handlers over the REAL engine, store, and
2//! event stream.
3//!
4//! Every operation here drives the same production engine surface the public
5//! transports drive — there is no dev-only engine and no execution path whose
6//! semantics diverge from production (CN4):
7//!
8//! * [`trigger_run`] starts a workflow through the same
9//!   [`crate::api::handlers::workflows::start`] path `/workflows/start` uses,
10//!   and returns the firehose subscription a client uses to watch the run live
11//!   over the existing `/events/stream` WebSocket (no second stream is built);
12//! * [`register_mock`] installs an opt-in per-run activity mock in the shared
13//!   [`ActivityMockRegistry`] the engine's dispatcher already consults — the
14//!   engine is untouched;
15//! * [`replay_run`] re-drives a failed run by reading its recorded start
16//!   (workflow type and input) from the real store and starting a fresh run of
17//!   the same workflow through the real engine — no mock-only re-execution.
18
19use aion_core::{Event, WorkflowId, WorkflowStatus, WorkflowSummary};
20use aion_proto::{ProtoDescribeWorkflowRequest, ProtoStartWorkflowRequest, WireError};
21use serde::{Deserialize, Serialize};
22
23use crate::api::handlers::start;
24use crate::{CallerIdentity, NamespaceOperation, ServerState, WorkflowTarget};
25
26use super::mock::MockedActivity;
27
28/// Builds the anti-existence-leak not-found wire error for a workflow.
29fn workflow_not_found(workflow_id: &WorkflowId) -> WireError {
30    WireError::not_found(format!("workflow {workflow_id} not found"))
31        .with_error_type("WorkflowNotFound")
32}
33
34/// Scopes a dev operation to a target workflow: authorizes the caller for the
35/// namespace, verifies the workflow is visible in it (foreign and nonexistent
36/// are indistinguishable), and yields the engine to read or drive it.
37async fn scope_to_workflow(
38    state: &ServerState,
39    caller: &CallerIdentity,
40    namespace: &str,
41    workflow_id: &WorkflowId,
42) -> Result<crate::namespace::ScopedEngine, WireError> {
43    let describe = ProtoDescribeWorkflowRequest {
44        namespace: namespace.to_owned(),
45        workflow_id: Some(aion_proto::ProtoWorkflowId::from(workflow_id.clone())),
46        run_id: None,
47        include_history: false,
48    };
49    let operation = NamespaceOperation::describe(&describe, WorkflowTarget::workflow(workflow_id));
50    state
51        .namespace_guard()
52        .scope(caller, &operation)
53        .await
54        .map_err(|error| error.to_wire_error())
55}
56
57/// Request to trigger a fresh workflow run from the dev server.
58#[derive(Clone, Debug, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct TriggerRunRequest {
61    /// Namespace the run is scoped to.
62    pub namespace: String,
63    /// Registered workflow type to start.
64    pub workflow_type: String,
65    /// JSON input payload for the workflow.
66    pub input: serde_json::Value,
67}
68
69/// The started run plus the firehose subscription that streams its events.
70#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
71pub struct TriggerRunResponse {
72    /// Started workflow id.
73    pub workflow_id: String,
74    /// Started run id.
75    pub run_id: String,
76    /// The exact `subscribe` frame a client sends on the existing
77    /// `/events/stream` WebSocket to watch this run live — the dev server reuses
78    /// the production firehose rather than opening a second stream.
79    pub stream_subscription: StreamSubscription,
80}
81
82/// The per-workflow subscription frame for the existing event-stream socket.
83#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
84pub struct StreamSubscription {
85    /// WebSocket path the client connects to (the existing firehose).
86    pub path: String,
87    /// The subscription request body to send once connected.
88    pub subscribe: serde_json::Value,
89}
90
91/// Triggers a run and returns the existing-firehose subscription for it.
92///
93/// # Errors
94///
95/// Returns a [`WireError`] when the input payload is malformed or the real
96/// start path rejects the request (unknown type, namespace denial, engine
97/// failure).
98pub async fn trigger_run(
99    state: &ServerState,
100    caller: &CallerIdentity,
101    request: TriggerRunRequest,
102) -> Result<TriggerRunResponse, WireError> {
103    let input = aion_core::Payload::from_json(&request.input)
104        .map_err(|error| WireError::invalid_input(format!("invalid run input JSON: {error}")))?;
105    let start_request = ProtoStartWorkflowRequest {
106        namespace: request.namespace.clone(),
107        workflow_type: request.workflow_type.clone(),
108        input: Some(input.into()),
109        routing_key: None,
110    };
111    let response = start(state.namespace_guard(), caller, start_request).await?;
112    let workflow_id = response
113        .workflow_id
114        .ok_or_else(|| WireError::backend("start response missing workflow id"))?;
115    let run_id = response
116        .run_id
117        .ok_or_else(|| WireError::backend("start response missing run id"))?;
118    let workflow_id = WorkflowId::try_from(workflow_id)?;
119    let run_id = aion_core::RunId::try_from(run_id)?;
120
121    Ok(TriggerRunResponse {
122        stream_subscription: per_workflow_subscription(&request.namespace, &workflow_id),
123        workflow_id: workflow_id.to_string(),
124        run_id: run_id.to_string(),
125    })
126}
127
128/// Builds the `subscribe` frame for the existing `/events/stream` firehose,
129/// scoped to one workflow.
130fn per_workflow_subscription(namespace: &str, workflow_id: &WorkflowId) -> StreamSubscription {
131    StreamSubscription {
132        path: "/events/stream".to_owned(),
133        subscribe: serde_json::json!({
134            "type": "subscribe",
135            "subscription": {
136                "per_workflow": {
137                    "namespace": namespace,
138                    "workflow_id": workflow_id.to_string(),
139                }
140            }
141        }),
142    }
143}
144
145/// Outcome a per-run activity mock should produce.
146#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
147#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
148pub enum MockOutcome {
149    /// The activity succeeds, returning this JSON-encoded typed result.
150    Succeeds {
151        /// JSON value returned verbatim to the workflow.
152        result: serde_json::Value,
153    },
154    /// The activity fails with this message.
155    Fails {
156        /// Failure message returned to the workflow.
157        message: String,
158    },
159}
160
161/// Request to install an opt-in per-run activity mock.
162#[derive(Clone, Debug, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct RegisterMockRequest {
165    /// Namespace the target run is scoped to (authorized like any operation).
166    pub namespace: String,
167    /// The run whose activity is mocked.
168    pub workflow_id: String,
169    /// The named activity to mock.
170    pub activity_name: String,
171    /// The canned outcome the mocked activity returns.
172    pub outcome: MockOutcome,
173}
174
175/// Acknowledgement of an installed mock.
176#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
177pub struct RegisterMockResponse {
178    /// The run the mock was installed for.
179    pub workflow_id: String,
180    /// The mocked activity name.
181    pub activity_name: String,
182}
183
184/// Installs an opt-in per-run activity mock for one run, without changing the
185/// engine.
186///
187/// # Errors
188///
189/// Returns a [`WireError`] when the workflow id is malformed, the caller is not
190/// authorized for the namespace, the JSON result cannot be encoded, the dev
191/// surface holds no mock registry (a wiring fault), or the registry mutex is
192/// poisoned.
193pub async fn register_mock(
194    state: &ServerState,
195    caller: &CallerIdentity,
196    request: RegisterMockRequest,
197) -> Result<RegisterMockResponse, WireError> {
198    let workflow_id = WorkflowId::try_from(parse_workflow_id(&request.workflow_id)?)?;
199    // Authorize the caller for the run's namespace and confirm the run is
200    // visible in it, using the same guard every run operation uses; a foreign
201    // namespace is denied and a foreign workflow is not-found, identically.
202    scope_to_workflow(state, caller, &request.namespace, &workflow_id).await?;
203
204    let registry = state
205        .activity_mock_registry()
206        .ok_or_else(|| WireError::backend("dev activity mocking is not enabled on this server"))?;
207    let mock = match request.outcome {
208        MockOutcome::Succeeds { result } => {
209            let payload = aion_core::Payload::from_json(&result).map_err(|error| {
210                WireError::invalid_input(format!("invalid mock result JSON: {error}"))
211            })?;
212            let result_json = String::from_utf8(payload.bytes().to_vec()).map_err(|error| {
213                WireError::invalid_input(format!("mock result is not valid UTF-8 JSON: {error}"))
214            })?;
215            MockedActivity::Succeeds { result_json }
216        }
217        MockOutcome::Fails { message } => MockedActivity::Fails { message },
218    };
219    registry
220        .register(workflow_id.clone(), request.activity_name.clone(), mock)
221        .map_err(WireError::backend)?;
222
223    tracing::info!(
224        operation = "dev.register_mock",
225        subject = caller.subject(),
226        namespace = %request.namespace,
227        workflow_id = %workflow_id,
228        activity_name = %request.activity_name,
229        "dev activity mock registered"
230    );
231    Ok(RegisterMockResponse {
232        workflow_id: workflow_id.to_string(),
233        activity_name: request.activity_name,
234    })
235}
236
237/// Request to replay a failed run through the real engine.
238#[derive(Clone, Debug, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct ReplayRunRequest {
241    /// Namespace the failed run is scoped to.
242    pub namespace: String,
243    /// The failed run to replay.
244    pub workflow_id: String,
245}
246
247/// The fresh run started to replay a failed one.
248#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
249pub struct ReplayRunResponse {
250    /// The original failed run that was replayed.
251    pub replayed_workflow_id: String,
252    /// The workflow type re-driven from the recorded start.
253    pub workflow_type: String,
254    /// The fresh run started through the real engine.
255    pub workflow_id: String,
256    /// The fresh run id.
257    pub run_id: String,
258    /// The firehose subscription for the fresh run.
259    pub stream_subscription: StreamSubscription,
260}
261
262/// Replays a failed run by re-driving it through the real engine and store.
263///
264/// Reads the failed run's recorded `WorkflowStarted` (workflow type and input)
265/// from the real store and starts a fresh run of the same workflow through the
266/// real engine. There is no separate mock-only engine and no divergent
267/// execution path (CN4): the replay is an ordinary start of the same code with
268/// the same input.
269///
270/// # Errors
271///
272/// Returns a [`WireError`] when the workflow id is malformed, the caller is not
273/// authorized, the run is unknown, the run is not in a failed terminal state,
274/// its recorded start cannot be read, or the real start path fails.
275pub async fn replay_run(
276    state: &ServerState,
277    caller: &CallerIdentity,
278    request: ReplayRunRequest,
279) -> Result<ReplayRunResponse, WireError> {
280    let workflow_id = WorkflowId::try_from(parse_workflow_id(&request.workflow_id)?)?;
281    let scoped = scope_to_workflow(state, caller, &request.namespace, &workflow_id).await?;
282    let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
283
284    let history = engine
285        .store()
286        .read_history(&workflow_id)
287        .await
288        .map_err(|error| crate::ServerError::from(error).to_wire_error())?;
289    let summary =
290        WorkflowSummary::from_history(&history).ok_or_else(|| workflow_not_found(&workflow_id))?;
291    if summary.status != WorkflowStatus::Failed {
292        return Err(WireError::invalid_input(format!(
293            "workflow {workflow_id} is {:?}, not Failed; only a failed run can be replayed",
294            summary.status
295        )));
296    }
297    let (workflow_type, input) = recorded_start(&history).ok_or_else(|| {
298        WireError::backend(format!(
299            "workflow {workflow_id} has no recorded start event to replay from"
300        ))
301    })?;
302
303    let start_request = ProtoStartWorkflowRequest {
304        namespace: request.namespace.clone(),
305        workflow_type: workflow_type.clone(),
306        input: Some(input.into()),
307        routing_key: None,
308    };
309    let response = start(state.namespace_guard(), caller, start_request).await?;
310    let fresh_workflow_id = WorkflowId::try_from(
311        response
312            .workflow_id
313            .ok_or_else(|| WireError::backend("replay start response missing workflow id"))?,
314    )?;
315    let fresh_run_id = aion_core::RunId::try_from(
316        response
317            .run_id
318            .ok_or_else(|| WireError::backend("replay start response missing run id"))?,
319    )?;
320
321    tracing::info!(
322        operation = "dev.replay_run",
323        subject = caller.subject(),
324        namespace = %request.namespace,
325        replayed_workflow_id = %workflow_id,
326        workflow_id = %fresh_workflow_id,
327        workflow_type = %workflow_type,
328        "dev replay re-drove a failed run through the real engine"
329    );
330    Ok(ReplayRunResponse {
331        replayed_workflow_id: workflow_id.to_string(),
332        stream_subscription: per_workflow_subscription(&request.namespace, &fresh_workflow_id),
333        workflow_id: fresh_workflow_id.to_string(),
334        run_id: fresh_run_id.to_string(),
335        workflow_type,
336    })
337}
338
339/// Extracts the recorded workflow type and input from a run's first
340/// `WorkflowStarted` event.
341fn recorded_start(history: &[Event]) -> Option<(String, aion_core::Payload)> {
342    history.iter().find_map(|event| match event {
343        Event::WorkflowStarted {
344            workflow_type,
345            input,
346            ..
347        } => Some((workflow_type.clone(), input.clone())),
348        _ => None,
349    })
350}
351
352/// Parses a workflow id string into the proto id, surfacing a malformed id as
353/// an invalid-input wire error rather than a panic.
354fn parse_workflow_id(raw: &str) -> Result<aion_proto::ProtoWorkflowId, WireError> {
355    let uuid = uuid::Uuid::parse_str(raw).map_err(|error| {
356        WireError::invalid_input(format!("invalid workflow id `{raw}`: {error}"))
357    })?;
358    Ok(aion_proto::ProtoWorkflowId::from(WorkflowId::new(uuid)))
359}
360
361#[cfg(test)]
362mod tests {
363    use aion_core::{Event, Payload};
364
365    use super::{
366        MockOutcome, RegisterMockRequest, TriggerRunRequest, parse_workflow_id, recorded_start,
367    };
368
369    #[test]
370    fn trigger_request_rejects_unknown_fields() {
371        let raw = r#"{"namespace":"default","workflow_type":"order","input":{},"extra":1}"#;
372        assert!(serde_json::from_str::<TriggerRunRequest>(raw).is_err());
373    }
374
375    #[test]
376    fn mock_outcome_parses_succeeds_and_fails() -> Result<(), serde_json::Error> {
377        let succeeds: RegisterMockRequest = serde_json::from_str(
378            r#"{"namespace":"default","workflow_id":"00000000-0000-0000-0000-000000000001","activity_name":"charge","outcome":{"kind":"succeeds","result":{"ok":true}}}"#,
379        )?;
380        assert!(matches!(succeeds.outcome, MockOutcome::Succeeds { .. }));
381        let fails: RegisterMockRequest = serde_json::from_str(
382            r#"{"namespace":"default","workflow_id":"00000000-0000-0000-0000-000000000001","activity_name":"charge","outcome":{"kind":"fails","message":"declined"}}"#,
383        )?;
384        assert!(matches!(fails.outcome, MockOutcome::Fails { .. }));
385        Ok(())
386    }
387
388    #[test]
389    fn parse_workflow_id_rejects_a_non_uuid() {
390        assert!(parse_workflow_id("not-a-uuid").is_err());
391        assert!(parse_workflow_id("00000000-0000-0000-0000-000000000001").is_ok());
392    }
393
394    #[test]
395    fn recorded_start_extracts_type_and_input() -> Result<(), Box<dyn std::error::Error>> {
396        let payload = Payload::from_json(&serde_json::json!({"amount": 1}))?;
397        let started = Event::WorkflowStarted {
398            envelope: aion_core::EventEnvelope {
399                seq: 1,
400                recorded_at: chrono::DateTime::from_timestamp(0, 0).unwrap_or_default(),
401                workflow_id: aion_core::WorkflowId::new(uuid::Uuid::from_u128(1)),
402            },
403            workflow_type: "order".to_owned(),
404            input: payload.clone(),
405            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(2)),
406            parent_run_id: None,
407            package_version: aion_core::PackageVersion::new("a".repeat(64)),
408        };
409        let extracted = recorded_start(std::slice::from_ref(&started));
410        assert_eq!(extracted, Some(("order".to_owned(), payload)));
411        assert!(recorded_start(&[]).is_none());
412        Ok(())
413    }
414}