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 };
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
129fn 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#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
148#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
149pub enum MockOutcome {
150 Succeeds {
152 result: serde_json::Value,
154 },
155 Fails {
157 message: String,
159 },
160}
161
162#[derive(Clone, Debug, Deserialize)]
164#[serde(deny_unknown_fields)]
165pub struct RegisterMockRequest {
166 pub namespace: String,
168 pub workflow_id: String,
170 pub activity_name: String,
172 pub outcome: MockOutcome,
174}
175
176#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
178pub struct RegisterMockResponse {
179 pub workflow_id: String,
181 pub activity_name: String,
183}
184
185pub 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 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#[derive(Clone, Debug, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct ReplayRunRequest {
242 pub namespace: String,
244 pub workflow_id: String,
246}
247
248#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
250pub struct ReplayRunResponse {
251 pub replayed_workflow_id: String,
253 pub workflow_type: String,
255 pub workflow_id: String,
257 pub run_id: String,
259 pub stream_subscription: StreamSubscription,
261}
262
263pub 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
341fn 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
354fn 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}