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