1use 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
28fn workflow_not_found(workflow_id: &WorkflowId) -> WireError {
30 WireError::not_found(format!("workflow {workflow_id} not found"))
31 .with_error_type("WorkflowNotFound")
32}
33
34async 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#[derive(Clone, Debug, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct TriggerRunRequest {
61 pub namespace: String,
63 pub workflow_type: String,
65 pub input: serde_json::Value,
67}
68
69#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
71pub struct TriggerRunResponse {
72 pub workflow_id: String,
74 pub run_id: String,
76 pub stream_subscription: StreamSubscription,
80}
81
82#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
84pub struct StreamSubscription {
85 pub path: String,
87 pub subscribe: serde_json::Value,
89}
90
91pub 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
130fn 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#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
149#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
150pub enum MockOutcome {
151 Succeeds {
153 result: serde_json::Value,
155 },
156 Fails {
158 message: String,
160 },
161}
162
163#[derive(Clone, Debug, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct RegisterMockRequest {
167 pub namespace: String,
169 pub workflow_id: String,
171 pub activity_name: String,
173 pub outcome: MockOutcome,
175}
176
177#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
179pub struct RegisterMockResponse {
180 pub workflow_id: String,
182 pub activity_name: String,
184}
185
186pub 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 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#[derive(Clone, Debug, Deserialize)]
241#[serde(deny_unknown_fields)]
242pub struct ReplayRunRequest {
243 pub namespace: String,
245 pub workflow_id: String,
247}
248
249#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
251pub struct ReplayRunResponse {
252 pub replayed_workflow_id: String,
254 pub workflow_type: String,
256 pub workflow_id: String,
258 pub run_id: String,
260 pub stream_subscription: StreamSubscription,
262}
263
264pub 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
343fn 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
356fn 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}