tasker-rb 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/DEVELOPMENT.md +548 -0
  3. data/README.md +87 -0
  4. data/ext/tasker_core/Cargo.lock +4720 -0
  5. data/ext/tasker_core/Cargo.toml +76 -0
  6. data/ext/tasker_core/extconf.rb +38 -0
  7. data/ext/tasker_core/src/CLAUDE.md +7 -0
  8. data/ext/tasker_core/src/bootstrap.rs +320 -0
  9. data/ext/tasker_core/src/bridge.rs +400 -0
  10. data/ext/tasker_core/src/client_ffi.rs +173 -0
  11. data/ext/tasker_core/src/conversions.rs +131 -0
  12. data/ext/tasker_core/src/diagnostics.rs +57 -0
  13. data/ext/tasker_core/src/event_handler.rs +179 -0
  14. data/ext/tasker_core/src/event_publisher_ffi.rs +239 -0
  15. data/ext/tasker_core/src/ffi_logging.rs +245 -0
  16. data/ext/tasker_core/src/global_event_system.rs +16 -0
  17. data/ext/tasker_core/src/in_process_event_ffi.rs +319 -0
  18. data/ext/tasker_core/src/lib.rs +41 -0
  19. data/ext/tasker_core/src/observability_ffi.rs +339 -0
  20. data/lib/tasker_core/batch_processing/batch_aggregation_scenario.rb +85 -0
  21. data/lib/tasker_core/batch_processing/batch_worker_context.rb +238 -0
  22. data/lib/tasker_core/bootstrap.rb +394 -0
  23. data/lib/tasker_core/domain_events/base_publisher.rb +220 -0
  24. data/lib/tasker_core/domain_events/base_subscriber.rb +178 -0
  25. data/lib/tasker_core/domain_events/publisher_registry.rb +253 -0
  26. data/lib/tasker_core/domain_events/subscriber_registry.rb +152 -0
  27. data/lib/tasker_core/domain_events.rb +43 -0
  28. data/lib/tasker_core/errors/CLAUDE.md +7 -0
  29. data/lib/tasker_core/errors/common.rb +305 -0
  30. data/lib/tasker_core/errors/error_classifier.rb +61 -0
  31. data/lib/tasker_core/errors.rb +4 -0
  32. data/lib/tasker_core/event_bridge.rb +330 -0
  33. data/lib/tasker_core/handlers.rb +159 -0
  34. data/lib/tasker_core/internal.rb +31 -0
  35. data/lib/tasker_core/logger.rb +234 -0
  36. data/lib/tasker_core/models.rb +337 -0
  37. data/lib/tasker_core/observability/types.rb +158 -0
  38. data/lib/tasker_core/observability.rb +292 -0
  39. data/lib/tasker_core/registry/handler_registry.rb +453 -0
  40. data/lib/tasker_core/registry/resolver_chain.rb +258 -0
  41. data/lib/tasker_core/registry/resolvers/base_resolver.rb +90 -0
  42. data/lib/tasker_core/registry/resolvers/class_constant_resolver.rb +156 -0
  43. data/lib/tasker_core/registry/resolvers/explicit_mapping_resolver.rb +146 -0
  44. data/lib/tasker_core/registry/resolvers/method_dispatch_wrapper.rb +144 -0
  45. data/lib/tasker_core/registry/resolvers/registry_resolver.rb +229 -0
  46. data/lib/tasker_core/registry/resolvers.rb +42 -0
  47. data/lib/tasker_core/registry.rb +12 -0
  48. data/lib/tasker_core/step_handler/api.rb +48 -0
  49. data/lib/tasker_core/step_handler/base.rb +354 -0
  50. data/lib/tasker_core/step_handler/batchable.rb +50 -0
  51. data/lib/tasker_core/step_handler/decision.rb +53 -0
  52. data/lib/tasker_core/step_handler/mixins/api.rb +452 -0
  53. data/lib/tasker_core/step_handler/mixins/batchable.rb +465 -0
  54. data/lib/tasker_core/step_handler/mixins/decision.rb +252 -0
  55. data/lib/tasker_core/step_handler/mixins.rb +66 -0
  56. data/lib/tasker_core/subscriber.rb +212 -0
  57. data/lib/tasker_core/task_handler/base.rb +254 -0
  58. data/lib/tasker_core/tasker_rb.so +0 -0
  59. data/lib/tasker_core/template_discovery.rb +181 -0
  60. data/lib/tasker_core/tracing.rb +166 -0
  61. data/lib/tasker_core/types/batch_processing_outcome.rb +301 -0
  62. data/lib/tasker_core/types/client_types.rb +145 -0
  63. data/lib/tasker_core/types/decision_point_outcome.rb +177 -0
  64. data/lib/tasker_core/types/error_types.rb +72 -0
  65. data/lib/tasker_core/types/simple_message.rb +151 -0
  66. data/lib/tasker_core/types/step_context.rb +328 -0
  67. data/lib/tasker_core/types/step_handler_call_result.rb +307 -0
  68. data/lib/tasker_core/types/step_message.rb +112 -0
  69. data/lib/tasker_core/types/step_types.rb +207 -0
  70. data/lib/tasker_core/types/task_template.rb +240 -0
  71. data/lib/tasker_core/types/task_types.rb +148 -0
  72. data/lib/tasker_core/types.rb +132 -0
  73. data/lib/tasker_core/version.rb +13 -0
  74. data/lib/tasker_core/worker/CLAUDE.md +7 -0
  75. data/lib/tasker_core/worker/event_poller.rb +224 -0
  76. data/lib/tasker_core/worker/in_process_domain_event_poller.rb +271 -0
  77. data/lib/tasker_core.rb +160 -0
  78. metadata +322 -0
@@ -0,0 +1,131 @@
1
+ use magnus::{RHash, Value as RValue};
2
+ use serde_magnus::serialize;
3
+ use tasker_shared::errors::{TaskerError, TaskerResult};
4
+ use tasker_shared::messaging::StepExecutionResult;
5
+ use tasker_shared::models::batch_worker::CheckpointYieldData;
6
+ use tasker_shared::types::TaskSequenceStep;
7
+ use tasker_worker::worker::FfiStepEvent;
8
+
9
+ // Convert TaskSequenceStep to Ruby hash with full fidelity
10
+ fn convert_task_sequence_step_to_ruby(tss: &TaskSequenceStep) -> TaskerResult<RHash> {
11
+ let ruby = magnus::Ruby::get().map_err(|err| {
12
+ TaskerError::FFIError(format!("Ruby Magnus FFI Ruby Acquire Error: {err}"))
13
+ })?;
14
+
15
+ let ruby_hash: RHash = serialize(&ruby, tss).map_err(|err| {
16
+ TaskerError::FFIError(format!(
17
+ "Could not serialize TaskSequenceStep to Ruby hash: {err}"
18
+ ))
19
+ })?;
20
+
21
+ Ok(ruby_hash)
22
+ }
23
+
24
+ pub fn tasker_error_from_magnus_error(err: &magnus::Error) -> TaskerError {
25
+ TaskerError::FFIError(format!("Ruby Magnus FFI Error: {err}"))
26
+ }
27
+
28
+ /// TAS-67: Convert FfiStepEvent to Ruby hash
29
+ ///
30
+ /// This converts the new dispatch channel event format to Ruby.
31
+ /// Includes the event_id for correlation when completing the step.
32
+ pub fn convert_ffi_step_event_to_ruby(event: &FfiStepEvent) -> TaskerResult<RHash> {
33
+ let ruby = magnus::Ruby::get().map_err(|err| {
34
+ TaskerError::FFIError(format!("Ruby Magnus FFI Ruby Acquire Error: {err}"))
35
+ })?;
36
+
37
+ let event_hash = ruby.hash_new();
38
+
39
+ // TAS-67: Event ID is critical for completion correlation
40
+ event_hash
41
+ .aset("event_id", event.event_id.to_string())
42
+ .map_err(|err| tasker_error_from_magnus_error(&err))?;
43
+
44
+ event_hash
45
+ .aset("task_uuid", event.task_uuid.to_string())
46
+ .map_err(|err| tasker_error_from_magnus_error(&err))?;
47
+
48
+ event_hash
49
+ .aset("step_uuid", event.step_uuid.to_string())
50
+ .map_err(|err| tasker_error_from_magnus_error(&err))?;
51
+
52
+ event_hash
53
+ .aset("correlation_id", event.correlation_id.to_string())
54
+ .map_err(|err| tasker_error_from_magnus_error(&err))?;
55
+
56
+ // Optional trace context
57
+ if let Some(ref trace_id) = event.trace_id {
58
+ event_hash
59
+ .aset("trace_id", trace_id.clone())
60
+ .map_err(|err| tasker_error_from_magnus_error(&err))?;
61
+ }
62
+ if let Some(ref span_id) = event.span_id {
63
+ event_hash
64
+ .aset("span_id", span_id.clone())
65
+ .map_err(|err| tasker_error_from_magnus_error(&err))?;
66
+ }
67
+
68
+ // Convert the full execution event payload (contains TaskSequenceStep)
69
+ let payload = &event.execution_event.payload;
70
+
71
+ // TAS-29: Also expose correlation_id from task if different
72
+ let task_correlation_id = payload.task_sequence_step.task.task.correlation_id;
73
+ event_hash
74
+ .aset("task_correlation_id", task_correlation_id.to_string())
75
+ .map_err(|err| tasker_error_from_magnus_error(&err))?;
76
+
77
+ // Parent correlation ID if present
78
+ if let Some(parent_id) = payload.task_sequence_step.task.task.parent_correlation_id {
79
+ event_hash
80
+ .aset("parent_correlation_id", parent_id.to_string())
81
+ .map_err(|err| tasker_error_from_magnus_error(&err))?;
82
+ }
83
+
84
+ // Convert TaskSequenceStep to Ruby hash
85
+ let task_sequence_step_hash = convert_task_sequence_step_to_ruby(&payload.task_sequence_step)?;
86
+ event_hash
87
+ .aset("task_sequence_step", task_sequence_step_hash)
88
+ .map_err(|err| tasker_error_from_magnus_error(&err))?;
89
+
90
+ Ok(event_hash)
91
+ }
92
+
93
+ /// TAS-67: Convert Ruby completion hash to StepExecutionResult
94
+ ///
95
+ /// This is the new conversion for the FfiDispatchChannel flow.
96
+ /// The Ruby hash should contain the result data that can be deserialized
97
+ /// into a StepExecutionResult.
98
+ pub fn convert_ruby_completion_to_step_result(value: RValue) -> TaskerResult<StepExecutionResult> {
99
+ let ruby = magnus::Ruby::get().map_err(|err| {
100
+ TaskerError::FFIError(format!("Ruby Magnus FFI Ruby Acquire Error: {err}"))
101
+ })?;
102
+
103
+ let result: StepExecutionResult = serde_magnus::deserialize(&ruby, value).map_err(|err| {
104
+ TaskerError::FFIError(format!(
105
+ "Could not deserialize to StepExecutionResult: {err}"
106
+ ))
107
+ })?;
108
+
109
+ Ok(result)
110
+ }
111
+
112
+ /// TAS-125: Convert Ruby checkpoint data hash to CheckpointYieldData
113
+ ///
114
+ /// The Ruby hash should contain:
115
+ /// - step_uuid: String (UUID of the step)
116
+ /// - cursor: Any JSON value (position to resume from)
117
+ /// - items_processed: Integer (count of items processed so far)
118
+ /// - accumulated_results: Optional JSON value (partial results to carry forward)
119
+ pub fn convert_ruby_checkpoint_to_yield_data(value: RValue) -> TaskerResult<CheckpointYieldData> {
120
+ let ruby = magnus::Ruby::get().map_err(|err| {
121
+ TaskerError::FFIError(format!("Ruby Magnus FFI Ruby Acquire Error: {err}"))
122
+ })?;
123
+
124
+ let data: CheckpointYieldData = serde_magnus::deserialize(&ruby, value).map_err(|err| {
125
+ TaskerError::FFIError(format!(
126
+ "Could not deserialize to CheckpointYieldData: {err}"
127
+ ))
128
+ })?;
129
+
130
+ Ok(data)
131
+ }
@@ -0,0 +1,57 @@
1
+ //! System diagnostics for Ruby FFI
2
+ //!
3
+ //! Provides diagnostic information about the Ruby FFI environment to help
4
+ //! troubleshoot architecture-specific issues (M2 vs M4 Pro, etc.)
5
+
6
+ use magnus::value::ReprValue;
7
+ use magnus::{Error, ExceptionClass, Ruby, Value};
8
+
9
+ /// Helper to get RuntimeError exception class
10
+ fn runtime_error_class() -> ExceptionClass {
11
+ Ruby::get()
12
+ .expect("Ruby runtime should be available")
13
+ .exception_runtime_error()
14
+ }
15
+
16
+ /// Get system diagnostics for troubleshooting
17
+ ///
18
+ /// Returns a hash with:
19
+ /// - rust_version: Cargo package version
20
+ /// - ruby_version: Ruby version string
21
+ /// - ruby_abi: Ruby ABI version
22
+ /// - thread_count: Available parallelism (CPU cores)
23
+ /// - arch: System architecture (aarch64, x86_64, etc.)
24
+ /// - tokio_runtime: Information about Tokio runtime if available
25
+ pub fn system_diagnostics() -> Result<Value, Error> {
26
+ let ruby = Ruby::get().map_err(|err| {
27
+ Error::new(
28
+ runtime_error_class(),
29
+ format!("Failed to get Ruby runtime: {}", err),
30
+ )
31
+ })?;
32
+
33
+ let hash = ruby.hash_new();
34
+
35
+ // Rust environment
36
+ hash.aset("rust_version", env!("CARGO_PKG_VERSION"))?;
37
+ hash.aset("magnus_version", "0.8")?;
38
+
39
+ // Ruby environment
40
+ let ruby_version = ruby
41
+ .eval::<String>("RUBY_VERSION")
42
+ .unwrap_or_else(|_| "unknown".to_string());
43
+ hash.aset("ruby_version", ruby_version)?;
44
+
45
+ // System information
46
+ let thread_count = std::thread::available_parallelism()
47
+ .map(|n| n.get())
48
+ .unwrap_or(0);
49
+ hash.aset("thread_count", thread_count)?;
50
+ hash.aset("arch", std::env::consts::ARCH)?;
51
+ hash.aset("os", std::env::consts::OS)?;
52
+
53
+ // Build information - embed feature was removed for M4 Pro compatibility
54
+ hash.aset("magnus_embed_feature", false)?;
55
+
56
+ Ok(hash.as_value())
57
+ }
@@ -0,0 +1,179 @@
1
+ //! # Ruby Event Handler
2
+ //!
3
+ //! Bridges `WorkerEventSystem` to Ruby dry-events without circular dependencies.
4
+ //! Events flow: Rust → Ruby for execution, Ruby → Rust for completion.
5
+ //!
6
+ //! Uses MPSC channel for thread-safe communication between tokio tasks and Ruby threads.
7
+
8
+ use crate::bridge;
9
+ use crate::conversions::convert_ruby_completion_to_rust;
10
+ use magnus::{Error as MagnusError, Value as RValue};
11
+ use std::sync::Arc;
12
+ use tasker_shared::monitoring::ChannelMonitor;
13
+ use tasker_shared::{
14
+ events::{WorkerEventSubscriber, WorkerEventSystem},
15
+ types::{StepExecutionCompletionEvent, StepExecutionEvent},
16
+ };
17
+ use tasker_shared::{TaskerError, TaskerResult};
18
+ use tokio::sync::{broadcast, mpsc};
19
+ use tracing::{debug, error, info, warn};
20
+
21
+ /// Ruby Event Handler - forwards Rust events to Ruby via MPSC channel (TAS-51: bounded)
22
+ pub struct RubyEventHandler {
23
+ event_subscriber: Arc<WorkerEventSubscriber>,
24
+ worker_id: String,
25
+ event_sender: mpsc::Sender<StepExecutionEvent>,
26
+ channel_monitor: ChannelMonitor,
27
+ }
28
+
29
+ impl RubyEventHandler {
30
+ /// Create new Ruby event handler with bounded channel
31
+ ///
32
+ /// # Arguments
33
+ /// * `event_system` - Worker event system to subscribe to
34
+ /// * `worker_id` - Worker identifier
35
+ /// * `buffer_size` - MPSC channel buffer size (TAS-51: bounded channels)
36
+ ///
37
+ /// # Note
38
+ /// TAS-51: Migrated from unbounded to bounded channel to provide backpressure.
39
+ /// Buffer size should come from: `config.mpsc_channels.shared.ffi.ruby_event_buffer_size`
40
+ pub fn new(
41
+ event_system: Arc<WorkerEventSystem>,
42
+ worker_id: String,
43
+ buffer_size: usize,
44
+ ) -> (Self, mpsc::Receiver<StepExecutionEvent>) {
45
+ let event_subscriber = Arc::new(WorkerEventSubscriber::new((*event_system).clone()));
46
+ let (event_sender, event_receiver) = mpsc::channel(buffer_size);
47
+
48
+ // TAS-51: Initialize channel monitor for observability
49
+ let channel_monitor = ChannelMonitor::new("ruby_ffi_event_handler", buffer_size);
50
+
51
+ let handler = Self {
52
+ event_subscriber,
53
+ worker_id,
54
+ event_sender,
55
+ channel_monitor,
56
+ };
57
+
58
+ (handler, event_receiver)
59
+ }
60
+
61
+ pub async fn start(&self) -> TaskerResult<()> {
62
+ info!(
63
+ worker_id = %self.worker_id,
64
+ channel_monitor = %self.channel_monitor.channel_name(),
65
+ buffer_size = self.channel_monitor.buffer_size(),
66
+ "Starting Ruby event handler with channel monitoring"
67
+ );
68
+
69
+ let mut receiver = self.event_subscriber.subscribe_to_step_executions();
70
+ let event_sender = self.event_sender.clone();
71
+ // TAS-51: Clone channel monitor for observability in spawned task
72
+ let monitor = self.channel_monitor.clone();
73
+
74
+ tokio::spawn(async move {
75
+ loop {
76
+ match receiver.recv().await {
77
+ Ok(event) => {
78
+ debug!(
79
+ event_id = %event.event_id,
80
+ step_name = %event.payload.task_sequence_step.workflow_step.name,
81
+ "Received step execution event - sending to channel for Ruby processing"
82
+ );
83
+
84
+ // Send to channel instead of directly calling Ruby
85
+ match event_sender.send(event).await {
86
+ Ok(()) => {
87
+ // TAS-51: Record send and periodically check saturation (optimized)
88
+ if monitor.record_send_success() {
89
+ monitor.check_and_warn_saturation(event_sender.capacity());
90
+ }
91
+ }
92
+ Err(e) => {
93
+ error!(
94
+ error = %e,
95
+ "Failed to send event to Ruby channel"
96
+ );
97
+ }
98
+ }
99
+ }
100
+ Err(broadcast::error::RecvError::Lagged(count)) => {
101
+ warn!(lagged_count = count, "Ruby event handler lagged behind");
102
+ }
103
+ Err(broadcast::error::RecvError::Closed) => {
104
+ info!("Event channel closed - stopping Ruby event handler");
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ });
110
+
111
+ Ok(())
112
+ }
113
+
114
+ // This method is no longer used - events are sent through the channel instead
115
+ // Ruby will poll for events using the receiver returned from new()
116
+
117
+ /// Handle completion event from Ruby and forward to Rust event system
118
+ pub async fn handle_completion(
119
+ &self,
120
+ completion: StepExecutionCompletionEvent,
121
+ ) -> TaskerResult<()> {
122
+ // Get the global event system
123
+ let event_system = crate::global_event_system::get_global_event_system();
124
+
125
+ // Publish completion to the event system
126
+ event_system
127
+ .publish_step_completion(completion)
128
+ .await
129
+ .map_err(|err| TaskerError::WorkerError(err.to_string()))?;
130
+
131
+ Ok(())
132
+ }
133
+ }
134
+
135
+ /// Called by Ruby when step processing completes
136
+ /// This properly sends the completion event to the global event system
137
+ pub fn send_step_completion_event(completion_data: RValue) -> Result<(), MagnusError> {
138
+ // Convert Ruby completion to Rust event
139
+ let rust_completion = convert_ruby_completion_to_rust(completion_data).map_err(|err| {
140
+ error!("Could not convert magnus Value to StepExecutionCompletionEvent: {err}");
141
+ MagnusError::new(
142
+ magnus::exception::runtime_error(),
143
+ format!("Could not convert magnus Value to StepExecutionCompletionEvent: {err}"),
144
+ )
145
+ })?;
146
+
147
+ // Get the bridge handle to access the event handler
148
+ let handle_guard = bridge::WORKER_SYSTEM.lock().map_err(|err| {
149
+ error!("Could not acquire WORKER_SYSTEM handle lock: {err}");
150
+ MagnusError::new(
151
+ magnus::exception::runtime_error(),
152
+ format!("Could not acquire WORKER_SYSTEM handle lock: {err}"),
153
+ )
154
+ })?;
155
+
156
+ let handle = handle_guard.as_ref().ok_or_else(|| {
157
+ error!("Could not acquire WORKER_SYSTEM handle");
158
+ MagnusError::new(
159
+ magnus::exception::runtime_error(),
160
+ "Could not acquire WORKER_SYSTEM handle".to_string(),
161
+ )
162
+ })?;
163
+
164
+ // Use the event handler to publish the completion
165
+ handle.runtime_handle().block_on(async {
166
+ handle
167
+ .event_handler()
168
+ .handle_completion(rust_completion)
169
+ .await
170
+ .map_err(|err| {
171
+ error!("Unable to handle event completion: {err}");
172
+ MagnusError::new(
173
+ magnus::exception::runtime_error(),
174
+ format!("Unable to handle event completion: {err}"),
175
+ )
176
+ })
177
+ })?;
178
+ Ok(())
179
+ }
@@ -0,0 +1,239 @@
1
+ //! # TAS-65 Phase 2.4a: Ruby FFI Bindings for Domain Event Publishing
2
+ //!
3
+ //! Exposes `DomainEventPublisher` to Ruby for step handler event publishing.
4
+ //! Allows Ruby handlers to publish domain events with full execution context.
5
+ //!
6
+ //! ## Architecture
7
+ //!
8
+ //! This FFI layer accepts Ruby hashes that match our domain types and deserializes
9
+ //! them into proper Rust structs using `serde_magnus`. The key types are:
10
+ //!
11
+ //! - `EventPublishRequest`: Top-level FFI input containing all event data
12
+ //! - `TaskSequenceStep`: Full task/step context (reuses tasker_shared types)
13
+ //! - `StepExecutionResult`: Complete execution result (reuses tasker_shared types)
14
+ //! - `EventMetadataInput`: FFI-friendly metadata that converts to `EventMetadata`
15
+
16
+ use crate::bridge::WORKER_SYSTEM;
17
+ use chrono::{DateTime, Utc};
18
+ use magnus::{
19
+ function, prelude::*, Error as MagnusError, ExceptionClass, RModule, Ruby, Value as RValue,
20
+ };
21
+ use serde::Deserialize;
22
+ use serde_magnus::deserialize;
23
+ use tasker_shared::events::domain_events::{DomainEventPayload, EventMetadata};
24
+ use tasker_shared::messaging::execution_types::StepExecutionResult;
25
+ use tasker_shared::types::TaskSequenceStep;
26
+ use tracing::{debug, error};
27
+ use uuid::Uuid;
28
+
29
+ /// Helper to get RuntimeError exception class (magnus 0.8 API)
30
+ fn runtime_error_class() -> ExceptionClass {
31
+ Ruby::get()
32
+ .expect("Ruby runtime should be available")
33
+ .exception_runtime_error()
34
+ }
35
+
36
+ /// Helper to get ArgumentError exception class (magnus 0.8 API)
37
+ fn arg_error_class() -> ExceptionClass {
38
+ Ruby::get()
39
+ .expect("Ruby runtime should be available")
40
+ .exception_arg_error()
41
+ }
42
+
43
+ /// FFI input structure for publishing domain events from Ruby
44
+ ///
45
+ /// This struct is deserialized directly from a Ruby hash using serde_magnus.
46
+ /// All nested types use the same structures as tasker_shared for consistency.
47
+ #[derive(Debug, Deserialize)]
48
+ pub struct EventPublishRequest {
49
+ /// Event name in dot notation (e.g., "payment.processed")
50
+ pub event_name: String,
51
+
52
+ /// Full task sequence step context
53
+ pub task_sequence_step: TaskSequenceStep,
54
+
55
+ /// Complete step execution result
56
+ pub execution_result: StepExecutionResult,
57
+
58
+ /// Business-specific event payload
59
+ pub business_payload: serde_json::Value,
60
+
61
+ /// Event metadata for routing and correlation
62
+ pub metadata: EventMetadataInput,
63
+ }
64
+
65
+ /// FFI-friendly event metadata input
66
+ ///
67
+ /// Similar to `EventMetadata` but with String UUIDs for easier Ruby interop.
68
+ /// Converts to `EventMetadata` with proper UUID parsing.
69
+ #[derive(Debug, Deserialize)]
70
+ pub struct EventMetadataInput {
71
+ /// Task UUID as string
72
+ pub task_uuid: String,
73
+
74
+ /// Step UUID as string (optional)
75
+ pub step_uuid: Option<String>,
76
+
77
+ /// Step name (optional)
78
+ pub step_name: Option<String>,
79
+
80
+ /// Namespace for queue routing
81
+ pub namespace: String,
82
+
83
+ /// Correlation ID as string
84
+ pub correlation_id: String,
85
+
86
+ /// Handler name that fired the event
87
+ pub fired_by: String,
88
+
89
+ /// When the event was fired (optional, defaults to now)
90
+ #[serde(default = "Utc::now")]
91
+ pub fired_at: DateTime<Utc>,
92
+ }
93
+
94
+ impl TryFrom<EventMetadataInput> for EventMetadata {
95
+ type Error = MagnusError;
96
+
97
+ fn try_from(input: EventMetadataInput) -> Result<Self, Self::Error> {
98
+ let task_uuid = parse_uuid(&input.task_uuid, "task_uuid")?;
99
+ let correlation_id = parse_uuid(&input.correlation_id, "correlation_id")?;
100
+ let step_uuid = input
101
+ .step_uuid
102
+ .map(|s| parse_uuid(&s, "step_uuid"))
103
+ .transpose()?;
104
+
105
+ Ok(EventMetadata {
106
+ task_uuid,
107
+ step_uuid,
108
+ step_name: input.step_name,
109
+ namespace: input.namespace,
110
+ correlation_id,
111
+ fired_at: input.fired_at,
112
+ fired_by: input.fired_by,
113
+ })
114
+ }
115
+ }
116
+
117
+ /// Parse a UUID string with a descriptive error message
118
+ fn parse_uuid(s: &str, field_name: &str) -> Result<Uuid, MagnusError> {
119
+ Uuid::parse_str(s).map_err(|e| {
120
+ MagnusError::new(
121
+ arg_error_class(),
122
+ format!("Invalid {} format: {}", field_name, e),
123
+ )
124
+ })
125
+ }
126
+
127
+ /// FFI function to publish a domain event from Ruby
128
+ ///
129
+ /// # Arguments
130
+ ///
131
+ /// A Ruby hash containing:
132
+ /// - `event_name`: String - Event name in dot notation (e.g., "payment.processed")
133
+ /// - `task_sequence_step`: Hash - Full TaskSequenceStep structure
134
+ /// - `execution_result`: Hash - Full StepExecutionResult structure
135
+ /// - `business_payload`: Hash - Business-specific event data
136
+ /// - `metadata`: Hash - Event metadata for routing and correlation
137
+ ///
138
+ /// # Returns
139
+ ///
140
+ /// String - The generated event_id (UUID v7)
141
+ ///
142
+ /// # Ruby Example
143
+ ///
144
+ /// ```ruby
145
+ /// TaskerCore::FFI.publish_domain_event({
146
+ /// event_name: "payment.processed",
147
+ /// task_sequence_step: task_sequence_step.to_h,
148
+ /// execution_result: execution_result.to_h,
149
+ /// business_payload: { transaction_id: "txn_123", amount: 100.00 },
150
+ /// metadata: {
151
+ /// task_uuid: task.task_uuid,
152
+ /// step_uuid: workflow_step.workflow_step_uuid,
153
+ /// step_name: workflow_step.name,
154
+ /// namespace: task.namespace_name,
155
+ /// correlation_id: task.task.correlation_id,
156
+ /// fired_by: handler_name
157
+ /// }
158
+ /// })
159
+ /// # => "0199c8f0-1234-7abc-9def-0123456789ab"
160
+ /// ```
161
+ pub fn publish_domain_event(event_params: RValue) -> Result<String, MagnusError> {
162
+ // Get Ruby handle for serde_magnus
163
+ let ruby = magnus::Ruby::get().map_err(|e| {
164
+ error!("Failed to acquire Ruby handle: {}", e);
165
+ MagnusError::new(
166
+ runtime_error_class(),
167
+ format!("Failed to acquire Ruby handle: {}", e),
168
+ )
169
+ })?;
170
+
171
+ // Deserialize the entire request using serde_magnus
172
+ let request: EventPublishRequest = deserialize(&ruby, event_params).map_err(|e| {
173
+ error!("Failed to deserialize event publish request: {}", e);
174
+ MagnusError::new(
175
+ arg_error_class(),
176
+ format!("Invalid event publish request: {}", e),
177
+ )
178
+ })?;
179
+
180
+ // Convert metadata input to EventMetadata
181
+ let metadata: EventMetadata = request.metadata.try_into()?;
182
+
183
+ debug!(
184
+ event_name = %request.event_name,
185
+ namespace = %metadata.namespace,
186
+ correlation_id = %metadata.correlation_id,
187
+ step_success = request.execution_result.success,
188
+ "Publishing domain event from Ruby FFI"
189
+ );
190
+
191
+ // Construct the domain event payload with full context
192
+ let domain_payload = DomainEventPayload {
193
+ task_sequence_step: request.task_sequence_step,
194
+ execution_result: request.execution_result,
195
+ payload: request.business_payload,
196
+ };
197
+
198
+ // Get worker system to access domain event publisher
199
+ let handle_guard = WORKER_SYSTEM.lock().map_err(|e| {
200
+ error!("Failed to acquire worker system lock: {}", e);
201
+ MagnusError::new(runtime_error_class(), "Lock acquisition failed")
202
+ })?;
203
+
204
+ let handle = handle_guard.as_ref().ok_or_else(|| {
205
+ MagnusError::new(
206
+ runtime_error_class(),
207
+ "Worker system not running - call bootstrap_worker first",
208
+ )
209
+ })?;
210
+
211
+ // Access the domain event publisher created during bootstrap
212
+ let event_publisher = &handle.domain_event_publisher;
213
+
214
+ // Publish event using the async runtime
215
+ let runtime_handle = handle.runtime_handle();
216
+ let event_id = runtime_handle
217
+ .block_on(event_publisher.publish_event(&request.event_name, domain_payload, metadata))
218
+ .map_err(|e| {
219
+ error!("Failed to publish domain event: {}", e);
220
+ MagnusError::new(
221
+ runtime_error_class(),
222
+ format!("Event publication failed: {}", e),
223
+ )
224
+ })?;
225
+
226
+ debug!(
227
+ event_id = %event_id,
228
+ event_name = %request.event_name,
229
+ "Domain event published successfully from Ruby"
230
+ );
231
+
232
+ Ok(event_id.to_string())
233
+ }
234
+
235
+ /// Initialize the event publisher FFI module
236
+ pub fn init_event_publisher_ffi(module: &RModule) -> Result<(), MagnusError> {
237
+ module.define_singleton_method("publish_domain_event", function!(publish_domain_event, 1))?;
238
+ Ok(())
239
+ }