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        task_queue: None,
111        display_name: None,
112    };
113    let response = start(state.namespace_guard(), caller, start_request).await?;
114    let workflow_id = response
115        .workflow_id
116        .ok_or_else(|| WireError::backend("start response missing workflow id"))?;
117    let run_id = response
118        .run_id
119        .ok_or_else(|| WireError::backend("start response missing run id"))?;
120    let workflow_id = WorkflowId::try_from(workflow_id)?;
121    let run_id = aion_core::RunId::try_from(run_id)?;
122
123    Ok(TriggerRunResponse {
124        stream_subscription: per_workflow_subscription(&request.namespace, &workflow_id),
125        workflow_id: workflow_id.to_string(),
126        run_id: run_id.to_string(),
127    })
128}
129
130/// Builds the `subscribe` frame for the existing `/events/stream` firehose,
131/// scoped to one workflow.
132fn per_workflow_subscription(namespace: &str, workflow_id: &WorkflowId) -> StreamSubscription {
133    StreamSubscription {
134        path: "/events/stream".to_owned(),
135        subscribe: serde_json::json!({
136            "type": "subscribe",
137            "subscription": {
138                "per_workflow": {
139                    "namespace": namespace,
140                    "workflow_id": workflow_id.to_string(),
141                }
142            }
143        }),
144    }
145}
146
147/// Outcome a per-run activity mock should produce.
148#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
149#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
150pub enum MockOutcome {
151    /// The activity succeeds, returning this JSON-encoded typed result.
152    Succeeds {
153        /// JSON value returned verbatim to the workflow.
154        result: serde_json::Value,
155    },
156    /// The activity fails with this message.
157    Fails {
158        /// Failure message returned to the workflow.
159        message: String,
160    },
161}
162
163/// Request to install an opt-in per-run activity mock.
164#[derive(Clone, Debug, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct RegisterMockRequest {
167    /// Namespace the target run is scoped to (authorized like any operation).
168    pub namespace: String,
169    /// The run whose activity is mocked.
170    pub workflow_id: String,
171    /// The named activity to mock.
172    pub activity_name: String,
173    /// The canned outcome the mocked activity returns.
174    pub outcome: MockOutcome,
175}
176
177/// Acknowledgement of an installed mock.
178#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
179pub struct RegisterMockResponse {
180    /// The run the mock was installed for.
181    pub workflow_id: String,
182    /// The mocked activity name.
183    pub activity_name: String,
184}
185
186/// Installs an opt-in per-run activity mock for one run, without changing the
187/// engine.
188///
189/// # Errors
190///
191/// Returns a [`WireError`] when the workflow id is malformed, the caller is not
192/// authorized for the namespace, the JSON result cannot be encoded, the dev
193/// surface holds no mock registry (a wiring fault), or the registry mutex is
194/// poisoned.
195pub async fn register_mock(
196    state: &ServerState,
197    caller: &CallerIdentity,
198    request: RegisterMockRequest,
199) -> Result<RegisterMockResponse, WireError> {
200    let workflow_id = WorkflowId::try_from(parse_workflow_id(&request.workflow_id)?)?;
201    // Authorize the caller for the run's namespace and confirm the run is
202    // visible in it, using the same guard every run operation uses; a foreign
203    // namespace is denied and a foreign workflow is not-found, identically.
204    scope_to_workflow(state, caller, &request.namespace, &workflow_id).await?;
205
206    let registry = state
207        .activity_mock_registry()
208        .ok_or_else(|| WireError::backend("dev activity mocking is not enabled on this server"))?;
209    let mock = match request.outcome {
210        MockOutcome::Succeeds { result } => {
211            let payload = aion_core::Payload::from_json(&result).map_err(|error| {
212                WireError::invalid_input(format!("invalid mock result JSON: {error}"))
213            })?;
214            let result_json = String::from_utf8(payload.bytes().to_vec()).map_err(|error| {
215                WireError::invalid_input(format!("mock result is not valid UTF-8 JSON: {error}"))
216            })?;
217            MockedActivity::Succeeds { result_json }
218        }
219        MockOutcome::Fails { message } => MockedActivity::Fails { message },
220    };
221    registry
222        .register(workflow_id.clone(), request.activity_name.clone(), mock)
223        .map_err(WireError::backend)?;
224
225    tracing::info!(
226        operation = "dev.register_mock",
227        subject = caller.subject(),
228        namespace = %request.namespace,
229        workflow_id = %workflow_id,
230        activity_name = %request.activity_name,
231        "dev activity mock registered"
232    );
233    Ok(RegisterMockResponse {
234        workflow_id: workflow_id.to_string(),
235        activity_name: request.activity_name,
236    })
237}
238
239/// Request to replay a failed run through the real engine.
240#[derive(Clone, Debug, Deserialize)]
241#[serde(deny_unknown_fields)]
242pub struct ReplayRunRequest {
243    /// Namespace the failed run is scoped to.
244    pub namespace: String,
245    /// The failed run to replay.
246    pub workflow_id: String,
247}
248
249/// The fresh run started to replay a failed one.
250#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
251pub struct ReplayRunResponse {
252    /// The original failed run that was replayed.
253    pub replayed_workflow_id: String,
254    /// The workflow type re-driven from the recorded start.
255    pub workflow_type: String,
256    /// The fresh run started through the real engine.
257    pub workflow_id: String,
258    /// The fresh run id.
259    pub run_id: String,
260    /// The firehose subscription for the fresh run.
261    pub stream_subscription: StreamSubscription,
262}
263
264/// Replays a failed run by re-driving it through the real engine and store.
265///
266/// Reads the failed run's recorded `WorkflowStarted` (workflow type and input)
267/// from the real store and starts a fresh run of the same workflow through the
268/// real engine. There is no separate mock-only engine and no divergent
269/// execution path (CN4): the replay is an ordinary start of the same code with
270/// the same input.
271///
272/// # Errors
273///
274/// Returns a [`WireError`] when the workflow id is malformed, the caller is not
275/// authorized, the run is unknown, the run is not in a failed terminal state,
276/// its recorded start cannot be read, or the real start path fails.
277pub async fn replay_run(
278    state: &ServerState,
279    caller: &CallerIdentity,
280    request: ReplayRunRequest,
281) -> Result<ReplayRunResponse, WireError> {
282    let workflow_id = WorkflowId::try_from(parse_workflow_id(&request.workflow_id)?)?;
283    let scoped = scope_to_workflow(state, caller, &request.namespace, &workflow_id).await?;
284    let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
285
286    let history = engine
287        .store()
288        .read_history(&workflow_id)
289        .await
290        .map_err(|error| crate::ServerError::from(error).to_wire_error())?;
291    let summary =
292        WorkflowSummary::from_history(&history).ok_or_else(|| workflow_not_found(&workflow_id))?;
293    if summary.status != WorkflowStatus::Failed {
294        return Err(WireError::invalid_input(format!(
295            "workflow {workflow_id} is {:?}, not Failed; only a failed run can be replayed",
296            summary.status
297        )));
298    }
299    let (workflow_type, input) = recorded_start(&history).ok_or_else(|| {
300        WireError::backend(format!(
301            "workflow {workflow_id} has no recorded start event to replay from"
302        ))
303    })?;
304
305    let start_request = ProtoStartWorkflowRequest {
306        namespace: request.namespace.clone(),
307        workflow_type: workflow_type.clone(),
308        input: Some(input.into()),
309        routing_key: None,
310        task_queue: None,
311        display_name: None,
312    };
313    let response = start(state.namespace_guard(), caller, start_request).await?;
314    let fresh_workflow_id = WorkflowId::try_from(
315        response
316            .workflow_id
317            .ok_or_else(|| WireError::backend("replay start response missing workflow id"))?,
318    )?;
319    let fresh_run_id = aion_core::RunId::try_from(
320        response
321            .run_id
322            .ok_or_else(|| WireError::backend("replay start response missing run id"))?,
323    )?;
324
325    tracing::info!(
326        operation = "dev.replay_run",
327        subject = caller.subject(),
328        namespace = %request.namespace,
329        replayed_workflow_id = %workflow_id,
330        workflow_id = %fresh_workflow_id,
331        workflow_type = %workflow_type,
332        "dev replay re-drove a failed run through the real engine"
333    );
334    Ok(ReplayRunResponse {
335        replayed_workflow_id: workflow_id.to_string(),
336        stream_subscription: per_workflow_subscription(&request.namespace, &fresh_workflow_id),
337        workflow_id: fresh_workflow_id.to_string(),
338        run_id: fresh_run_id.to_string(),
339        workflow_type,
340    })
341}
342
343/// Extracts the recorded workflow type and input from a run's first
344/// `WorkflowStarted` event.
345fn recorded_start(history: &[Event]) -> Option<(String, aion_core::Payload)> {
346    history.iter().find_map(|event| match event {
347        Event::WorkflowStarted {
348            workflow_type,
349            input,
350            ..
351        } => Some((workflow_type.clone(), input.clone())),
352        _ => None,
353    })
354}
355
356/// Parses a workflow id string into the proto id, surfacing a malformed id as
357/// an invalid-input wire error rather than a panic.
358fn parse_workflow_id(raw: &str) -> Result<aion_proto::ProtoWorkflowId, WireError> {
359    let uuid = uuid::Uuid::parse_str(raw).map_err(|error| {
360        WireError::invalid_input(format!("invalid workflow id `{raw}`: {error}"))
361    })?;
362    Ok(aion_proto::ProtoWorkflowId::from(WorkflowId::new(uuid)))
363}
364
365#[cfg(test)]
366mod tests {
367    use aion_core::{Event, Payload};
368
369    use super::{
370        MockOutcome, RegisterMockRequest, TriggerRunRequest, parse_workflow_id, recorded_start,
371    };
372
373    #[test]
374    fn trigger_request_rejects_unknown_fields() {
375        let raw = r#"{"namespace":"default","workflow_type":"order","input":{},"extra":1}"#;
376        assert!(serde_json::from_str::<TriggerRunRequest>(raw).is_err());
377    }
378
379    #[test]
380    fn mock_outcome_parses_succeeds_and_fails() -> Result<(), serde_json::Error> {
381        let succeeds: RegisterMockRequest = serde_json::from_str(
382            r#"{"namespace":"default","workflow_id":"00000000-0000-0000-0000-000000000001","activity_name":"charge","outcome":{"kind":"succeeds","result":{"ok":true}}}"#,
383        )?;
384        assert!(matches!(succeeds.outcome, MockOutcome::Succeeds { .. }));
385        let fails: RegisterMockRequest = serde_json::from_str(
386            r#"{"namespace":"default","workflow_id":"00000000-0000-0000-0000-000000000001","activity_name":"charge","outcome":{"kind":"fails","message":"declined"}}"#,
387        )?;
388        assert!(matches!(fails.outcome, MockOutcome::Fails { .. }));
389        Ok(())
390    }
391
392    #[test]
393    fn parse_workflow_id_rejects_a_non_uuid() {
394        assert!(parse_workflow_id("not-a-uuid").is_err());
395        assert!(parse_workflow_id("00000000-0000-0000-0000-000000000001").is_ok());
396    }
397
398    #[test]
399    fn recorded_start_extracts_type_and_input() -> Result<(), Box<dyn std::error::Error>> {
400        let payload = Payload::from_json(&serde_json::json!({"amount": 1}))?;
401        let started = Event::WorkflowStarted {
402            envelope: aion_core::EventEnvelope {
403                seq: 1,
404                recorded_at: chrono::DateTime::from_timestamp(0, 0).unwrap_or_default(),
405                workflow_id: aion_core::WorkflowId::new(uuid::Uuid::from_u128(1)),
406            },
407            workflow_type: "order".to_owned(),
408            input: payload.clone(),
409            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(2)),
410            parent_run_id: None,
411            parent_workflow_id: None,
412            package_version: aion_core::PackageVersion::new("a".repeat(64)),
413        };
414        let extracted = recorded_start(std::slice::from_ref(&started));
415        assert_eq!(extracted, Some(("order".to_owned(), payload)));
416        assert!(recorded_start(&[]).is_none());
417        Ok(())
418    }
419}