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 };
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
128fn 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#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
147#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
148pub enum MockOutcome {
149 Succeeds {
151 result: serde_json::Value,
153 },
154 Fails {
156 message: String,
158 },
159}
160
161#[derive(Clone, Debug, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct RegisterMockRequest {
165 pub namespace: String,
167 pub workflow_id: String,
169 pub activity_name: String,
171 pub outcome: MockOutcome,
173}
174
175#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
177pub struct RegisterMockResponse {
178 pub workflow_id: String,
180 pub activity_name: String,
182}
183
184pub 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 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#[derive(Clone, Debug, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct ReplayRunRequest {
241 pub namespace: String,
243 pub workflow_id: String,
245}
246
247#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
249pub struct ReplayRunResponse {
250 pub replayed_workflow_id: String,
252 pub workflow_type: String,
254 pub workflow_id: String,
256 pub run_id: String,
258 pub stream_subscription: StreamSubscription,
260}
261
262pub 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
339fn 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
352fn 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}