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