Skip to main content

fission_core/
effect.rs

1//! Side-effect primitives for async operations.
2//!
3//! Reducers are pure functions -- they must not perform I/O. When a reducer
4//! needs to trigger a host capability, async job, or runtime-control effect, it
5//! pushes an [`EffectEnvelope`] through the [`Effects`](crate::Effects) builder.
6//! The platform executor fulfils the effect outside the deterministic core and
7//! dispatches the `on_ok` / `on_err` callback actions back into the pipeline.
8
9use crate::action::ActionEnvelope;
10use crate::async_runtime::{
11    JobRef, JobRequestPayload, JobSpec, ResourceExecutionContext, ServiceBindings,
12    ServiceCommandPayload, ServiceSpec, ServiceStartPayload, ServiceStopPayload, ServiceType,
13};
14use crate::capability::CapabilityInvocationPayload;
15use crate::capability::{CapabilityType, OperationCapability};
16use fission_ir::NodeId;
17use serde::{Deserialize, Serialize};
18
19/// An opaque request identifier assigned to each emitted effect.
20///
21/// The platform executor returns this id when delivering the result so the
22/// runtime can correlate responses.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
24pub struct ReqId(pub u64);
25
26/// An opaque handle to a platform-managed resource (e.g. a large binary blob).
27///
28/// Resources live outside the action pipeline to avoid copying large payloads.
29/// Use [`RuntimeEffect::ReleaseResource`] to free them when no longer needed.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
31pub struct ResourceId(pub u64);
32
33/// Runtime-managed effects that are not host capabilities.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub enum RuntimeEffect {
36    /// Cancel a previously issued effect by its request id.
37    Cancel { req_id: u64 },
38    /// Release a platform-managed resource.
39    ReleaseResource { resource_id: u64 },
40}
41
42/// A side-effect emitted by a reducer.
43///
44/// `Runtime` variants are handled by the runtime itself.
45/// All host-facing work is expressed as typed capabilities, jobs, or services.
46#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
47pub enum Effect {
48    /// A runtime-managed effect (cancellation, resource release).
49    Runtime(RuntimeEffect),
50    /// A typed one-shot host capability invocation.
51    Capability(CapabilityInvocationPayload),
52    /// A typed one-shot async job.
53    Job(JobRequestPayload),
54    /// Start a long-lived service for a logical slot.
55    StartService(ServiceStartPayload),
56    /// Send a command to an already-running service slot.
57    ServiceCommand(ServiceCommandPayload),
58    /// Stop a running service slot.
59    StopService(ServiceStopPayload),
60}
61
62/// A queued effect with optional success/failure callbacks.
63///
64/// The platform executor processes the [`Effect`], then dispatches either
65/// `on_ok` or `on_err` back into the runtime. The `req_id` is assigned
66/// automatically by the runtime and is globally unique within a session.
67///
68/// # Example
69///
70/// ```rust,ignore
71/// // Built via the Effects builder -- you rarely construct this manually.
72/// ctx.effects.capability(MY_CAPABILITY, request)
73///     .on_ok(ok_envelope)
74///     .on_err(err_envelope);
75/// ```
76#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
77pub struct EffectEnvelope {
78    /// Unique request identifier (assigned by the runtime).
79    pub req_id: u64,
80    /// The effect to execute.
81    pub effect: Effect,
82    /// Action dispatched when the effect completes successfully.
83    pub on_ok: Option<ActionEnvelope>,
84    /// Action dispatched when the effect fails.
85    pub on_err: Option<ActionEnvelope>,
86    /// Additional bindings used by service lifecycle operations.
87    pub service_bindings: Option<ServiceBindings>,
88    /// Optional resource ownership metadata used to suppress stale completions.
89    pub resource: Option<ResourceExecutionContext>,
90}
91
92/// Extra input data passed alongside an action dispatch.
93///
94/// When the platform delivers an effect result or a drag-and-drop event, it
95/// attaches an `ActionInput` so the reducer can access the associated data
96/// without encoding it in the action payload.
97///
98/// # Example
99///
100/// ```rust,ignore
101/// fn on_file_loaded(
102///     state: &mut MyState,
103///     _action: FileLoaded,
104///     ctx: &mut ReducerContext<MyState>,
105/// ) {
106///     if let Some(bytes) = ctx.input.as_bytes() {
107///         state.file_contents = String::from_utf8_lossy(bytes).into_owned();
108///     }
109/// }
110/// ```
111#[derive(Clone, Debug, PartialEq)]
112pub enum ActionInput {
113    /// No extra input.
114    None,
115    /// A typed async job completed successfully.
116    JobOk {
117        job_name: String,
118        req_id: u64,
119        payload: Vec<u8>,
120    },
121    /// A typed async job failed.
122    JobErr {
123        job_name: String,
124        req_id: u64,
125        payload: Option<Vec<u8>>,
126        message: Option<String>,
127    },
128    /// A service slot started successfully.
129    ServiceStarted {
130        service_name: String,
131        slot_key: String,
132        instance_id: u64,
133    },
134    /// A service slot failed to start.
135    ServiceStartFailed {
136        service_name: String,
137        slot_key: String,
138        payload: Option<Vec<u8>>,
139        message: Option<String>,
140    },
141    /// A running service emitted an event.
142    ServiceEvent {
143        service_name: String,
144        slot_key: String,
145        instance_id: u64,
146        payload: Vec<u8>,
147    },
148    /// A running service stopped.
149    ServiceStopped {
150        service_name: String,
151        slot_key: String,
152        instance_id: u64,
153    },
154    /// A service command completed successfully.
155    ServiceCommandOk {
156        service_name: String,
157        slot_key: String,
158        instance_id: u64,
159        req_id: u64,
160        payload: Option<Vec<u8>>,
161    },
162    /// A service command failed.
163    ServiceCommandErr {
164        service_name: String,
165        slot_key: String,
166        instance_id: u64,
167        req_id: u64,
168        payload: Option<Vec<u8>>,
169        message: Option<String>,
170    },
171    /// A typed capability operation succeeded.
172    CapabilityOk {
173        capability: String,
174        req_id: u64,
175        payload: Vec<u8>,
176    },
177    /// A typed capability operation failed.
178    CapabilityErr {
179        capability: String,
180        req_id: u64,
181        payload: Option<Vec<u8>>,
182        message: Option<String>,
183    },
184    /// A timer resource fired.
185    TimerTick { payload: Vec<u8> },
186    /// Pointer coordinates and deltas (used by drag/gesture handlers).
187    Pointer {
188        x: f32,
189        y: f32,
190        delta_x: f32,
191        delta_y: f32,
192    },
193    /// External file drop (e.g. from the OS file manager).
194    Drop { paths: Vec<String>, x: f32, y: f32 },
195    /// Internal drag-and-drop with an opaque byte payload.
196    InternalDrop { payload: Vec<u8>, x: f32, y: f32 },
197    /// The action was dispatched from a subtree with a raw action scope.
198    ScopedRaw {
199        scope_id: u128,
200        target: NodeId,
201        input: Box<ActionInput>,
202    },
203}
204
205impl ActionInput {
206    pub fn scoped_raw(scope_id: u128, target: NodeId, input: ActionInput) -> Self {
207        Self::ScopedRaw {
208            scope_id,
209            target,
210            input: Box::new(input),
211        }
212    }
213
214    pub fn action_scope_id(&self) -> Option<u128> {
215        match self {
216            ActionInput::ScopedRaw { scope_id, .. } => Some(*scope_id),
217            _ => None,
218        }
219    }
220
221    pub fn scoped_target(&self) -> Option<NodeId> {
222        match self {
223            ActionInput::ScopedRaw { target, .. } => Some(*target),
224            _ => None,
225        }
226    }
227
228    pub fn unscoped(&self) -> &ActionInput {
229        match self {
230            ActionInput::ScopedRaw { input, .. } => input.unscoped(),
231            _ => self,
232        }
233    }
234
235    pub fn as_bytes(&self) -> Option<&[u8]> {
236        match self.unscoped() {
237            ActionInput::JobOk { payload, .. } => Some(payload),
238            ActionInput::CapabilityOk { payload, .. } => Some(payload),
239            ActionInput::TimerTick { payload } => Some(payload),
240            ActionInput::InternalDrop { payload, .. } => Some(payload),
241            _ => None,
242        }
243    }
244
245    pub fn as_pointer(&self) -> Option<(f32, f32, f32, f32)> {
246        match self.unscoped() {
247            ActionInput::Pointer {
248                x,
249                y,
250                delta_x,
251                delta_y,
252            } => Some((*x, *y, *delta_x, *delta_y)),
253            ActionInput::Drop { x, y, .. } => Some((*x, *y, 0.0, 0.0)),
254            ActionInput::InternalDrop { x, y, .. } => Some((*x, *y, 0.0, 0.0)),
255            _ => None,
256        }
257    }
258
259    pub fn as_drop_paths(&self) -> Option<&[String]> {
260        match self.unscoped() {
261            ActionInput::Drop { paths, .. } => Some(paths),
262            _ => None,
263        }
264    }
265
266    pub fn as_internal_drop(&self) -> Option<&[u8]> {
267        match self.unscoped() {
268            ActionInput::InternalDrop { payload, .. } => Some(payload),
269            _ => None,
270        }
271    }
272
273    pub fn job_ok<J: JobSpec>(&self, job: JobRef<J>) -> Option<J::Ok> {
274        match self.unscoped() {
275            ActionInput::JobOk {
276                job_name, payload, ..
277            } if job_name == job.name => serde_json::from_slice(payload).ok(),
278            _ => None,
279        }
280    }
281
282    pub fn job_err<J: JobSpec>(&self, job: JobRef<J>) -> Option<J::Err> {
283        match self.unscoped() {
284            ActionInput::JobErr {
285                job_name,
286                payload: Some(payload),
287                ..
288            } if job_name == job.name => serde_json::from_slice(payload).ok(),
289            _ => None,
290        }
291    }
292
293    pub fn job_error_message<J: JobSpec>(&self, job: JobRef<J>) -> Option<&str> {
294        match self.unscoped() {
295            ActionInput::JobErr {
296                job_name,
297                message: Some(message),
298                ..
299            } if job_name == job.name => Some(message.as_str()),
300            _ => None,
301        }
302    }
303
304    pub fn capability_ok<C: OperationCapability>(
305        &self,
306        capability: CapabilityType<C>,
307    ) -> Option<C::Ok> {
308        match self.unscoped() {
309            ActionInput::CapabilityOk {
310                capability: actual,
311                payload,
312                ..
313            } if actual == capability.name => serde_json::from_slice(payload).ok(),
314            _ => None,
315        }
316    }
317
318    pub fn capability_error<C: OperationCapability>(
319        &self,
320        capability: CapabilityType<C>,
321    ) -> Option<C::Err> {
322        match self.unscoped() {
323            ActionInput::CapabilityErr {
324                capability: actual,
325                payload: Some(payload),
326                ..
327            } if actual == capability.name => serde_json::from_slice(payload).ok(),
328            _ => None,
329        }
330    }
331
332    pub fn capability_error_message<C: OperationCapability>(
333        &self,
334        capability: CapabilityType<C>,
335    ) -> Option<&str> {
336        match self.unscoped() {
337            ActionInput::CapabilityErr {
338                capability: actual,
339                message: Some(message),
340                ..
341            } if actual == capability.name => Some(message),
342            _ => None,
343        }
344    }
345
346    pub fn service_event<S: ServiceSpec>(&self, service: ServiceType<S>) -> Option<S::Event> {
347        match self.unscoped() {
348            ActionInput::ServiceEvent {
349                service_name,
350                payload,
351                ..
352            } if service_name == service.name => serde_json::from_slice(payload).ok(),
353            _ => None,
354        }
355    }
356
357    pub fn service_start_err<S: ServiceSpec>(
358        &self,
359        service: ServiceType<S>,
360    ) -> Option<S::StartErr> {
361        match self.unscoped() {
362            ActionInput::ServiceStartFailed {
363                service_name,
364                payload: Some(payload),
365                ..
366            } if service_name == service.name => serde_json::from_slice(payload).ok(),
367            _ => None,
368        }
369    }
370
371    pub fn service_start_error_message<S: ServiceSpec>(
372        &self,
373        service: ServiceType<S>,
374    ) -> Option<&str> {
375        match self.unscoped() {
376            ActionInput::ServiceStartFailed {
377                service_name,
378                message: Some(message),
379                ..
380            } if service_name == service.name => Some(message.as_str()),
381            _ => None,
382        }
383    }
384
385    pub fn service_command_ok<S: ServiceSpec>(
386        &self,
387        service: ServiceType<S>,
388    ) -> Option<S::CommandOk> {
389        match self.unscoped() {
390            ActionInput::ServiceCommandOk {
391                service_name,
392                payload: Some(payload),
393                ..
394            } if service_name == service.name => serde_json::from_slice(payload).ok(),
395            _ => None,
396        }
397    }
398
399    pub fn service_command_err<S: ServiceSpec>(
400        &self,
401        service: ServiceType<S>,
402    ) -> Option<S::CommandErr> {
403        match self.unscoped() {
404            ActionInput::ServiceCommandErr {
405                service_name,
406                payload: Some(payload),
407                ..
408            } if service_name == service.name => serde_json::from_slice(payload).ok(),
409            _ => None,
410        }
411    }
412
413    pub fn timer_tick<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
414        match self.unscoped() {
415            ActionInput::TimerTick { payload } => serde_json::from_slice(payload).ok(),
416            _ => None,
417        }
418    }
419
420    pub fn service_slot_key(&self) -> Option<&str> {
421        match self.unscoped() {
422            ActionInput::ServiceStarted { slot_key, .. }
423            | ActionInput::ServiceStartFailed { slot_key, .. }
424            | ActionInput::ServiceEvent { slot_key, .. }
425            | ActionInput::ServiceStopped { slot_key, .. }
426            | ActionInput::ServiceCommandOk { slot_key, .. }
427            | ActionInput::ServiceCommandErr { slot_key, .. } => Some(slot_key.as_str()),
428            _ => None,
429        }
430    }
431
432    pub fn service_instance_id(&self) -> Option<u64> {
433        match self.unscoped() {
434            ActionInput::ServiceStarted { instance_id, .. }
435            | ActionInput::ServiceEvent { instance_id, .. }
436            | ActionInput::ServiceStopped { instance_id, .. }
437            | ActionInput::ServiceCommandOk { instance_id, .. }
438            | ActionInput::ServiceCommandErr { instance_id, .. } => Some(*instance_id),
439            _ => None,
440        }
441    }
442}