Skip to main content

tilo_app/framework/
effect.rs

1//! Effects and jobs: work with no document delta, invoked through the same
2//! door as actions, answered by the app.
3//!
4//! An app names its effects as a typed vocabulary (`#[derive(Effect)]` on an
5//! enum, see [`tilo_resource::effect`]) and handles them where the state they
6//! need lives: a view registers a handler with
7//! [`ViewCx::effects`](super::ViewCx::effects), a custom surface implements
8//! [`ResourceSurface::effect`](super::ResourceSurface::effect). The framework
9//! publishes the vocabulary on the resource (with the availability and labels
10//! the handler reports right now), routes every invocation — a button, the
11//! palette, an agent, a script — to the handler as a typed value, and runs the
12//! job the handler starts: progress, cancellation and the result go back to
13//! whoever invoked it. A job's completion runs on the UI thread with the
14//! [`Dispatcher`], so what the effect changes in the document it changes with
15//! ordinary actions.
16//!
17//! An outside consequence a verb queued in an
18//! [`Outbox`](tilo_resource::Outbox) is carried out with
19//! [`RecordEnv::outbox`](super::RecordEnv::outbox): the framework runs the app's
20//! handler in the live app on the device that owns the entry and records how
21//! it settled.
22
23use std::any::Any;
24use std::cell::{Cell, RefCell};
25use std::collections::HashMap;
26use std::hash::Hash;
27use std::rc::{Rc, Weak};
28
29mod causal;
30use std::sync::Arc;
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
33use ui_core::automerge::transaction::Transactable;
34
35use tilo_actions::{
36    ActionFailure, ActionOutput, ActionSpec, Availability, InvocationId, InvocationUpdate,
37};
38use tilo_resource::{ActionRef, Effect, Id, Outbox, Path, Resource, Settled};
39use ui_core::WakeHandle;
40use ui_core::host_operation::{ResourceOperation, ResourceOperationStart};
41use ui_core::invocation_scope::{ResourceEffectScope, ResourceInvocationScope};
42use ui_core::portal::{ContentsOutcome, PickRequest, PickedContents, PortalOutcome, SaveOutcome};
43
44use super::dispatch::Dispatcher;
45use super::live::Live;
46
47fn attach_approved_check_reporter(
48    checked: ui_core::invocation_scope::ApprovedCheckReportedWork,
49    progress: Progress,
50) -> ui_core::invocation_scope::ApprovedCheckFuture {
51    checked(Box::new(move |observation| {
52        progress.with_effect(|| {
53            match observation {
54                ui_core::invocation_scope::ApprovedCheckReportObservation::Reported {
55                    phase,
56                    completed,
57                    total,
58                } => {
59                    progress.report(phase, completed, total);
60                }
61                ui_core::invocation_scope::ApprovedCheckReportObservation::Disabled { .. } => {
62                    progress.report(
63                        ui_core::i18n::I18nManager::lookup_plain("approved-check-reports-disabled"),
64                        None,
65                        None,
66                    );
67                }
68            }
69            Ok(())
70        })
71    }))
72}
73
74/// A file the person chose with the host's chooser
75/// ([`EffectCx::choose_file`]).
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct ChosenFile {
78    /// The name shown to the person.
79    pub name: String,
80    /// The media type the host reported, when it did.
81    pub media_type: Option<String>,
82    pub bytes: Vec<u8>,
83    /// The file's durable identity on this device, when the host chose a
84    /// file it can name: the same file chosen again, even renamed or moved,
85    /// has the same identity, so an import can tell it already came in.
86    /// `None` where files have no paths (a browser page).
87    pub identity: Option<ui_core::record::RecordId>,
88}
89
90impl From<PickedContents> for ChosenFile {
91    fn from(picked: PickedContents) -> Self {
92        Self {
93            identity: picked
94                .location
95                .as_deref()
96                .map(ui_core::record::RecordId::identify_file),
97            name: picked.name,
98            media_type: picked.media_type,
99            bytes: picked.bytes,
100        }
101    }
102}
103
104/// Why a file chooser answered nothing.
105const CHOOSER_GONE: &str = "the file chooser closed without answering";
106
107/// What an effect handler (or a job's completion) answers.
108#[derive(Clone, Debug, PartialEq)]
109pub enum Effected {
110    /// Finished: the output goes back to whoever invoked the effect.
111    Done(ActionOutput),
112    /// Still running: the job started through the [`EffectCx`] answers later.
113    Pending,
114}
115
116impl Effected {
117    /// Finished, with nothing to report.
118    pub fn done() -> Self {
119        Self::Done(ActionOutput::default())
120    }
121
122    /// Finished, answering with `value` (an effect declared `returns`, or a
123    /// job's result).
124    pub fn value(value: impl serde::Serialize) -> Self {
125        Self::Done(ActionOutput {
126            value: Some(serde_json::to_value(value).unwrap_or(serde_json::Value::Null)),
127            resources: Vec::new(),
128        })
129    }
130
131    /// An outbox entry carried out, or failed for good: how it settled
132    /// ([`RecordEnv::outbox`](super::RecordEnv::outbox)). [`Self::value`] with the
133    /// result alone settles it done.
134    pub fn settled<O: serde::Serialize>(settled: &Settled<O>) -> Self {
135        Self::value(settled)
136    }
137
138    /// Finished, answering with the content of `hash` in this device's
139    /// content store (an effect declared `content`), which another app's
140    /// content-collecting operation takes.
141    ///
142    /// # Errors
143    /// Returns why `hash` names no content.
144    pub fn content(hash: &str) -> Result<Self, String> {
145        let resource = tilo_actions::content_resource(hash)
146            .ok_or_else(|| format!("{hash:?} is not a content hash"))?;
147        Ok(Self::Done(ActionOutput {
148            value: None,
149            resources: vec![resource],
150        }))
151    }
152}
153
154/// What a handler or a job's completion returns: the answer, or why the
155/// effect failed.
156pub type EffectAnswer = Result<Effected, String>;
157
158/// A message from a running job to the UI thread.
159enum JobEvent {
160    Progress {
161        phase: String,
162        completed: Option<u64>,
163        total: Option<u64>,
164    },
165    Finished(Box<dyn Any + Send>),
166    Failed(ActionFailure),
167}
168
169/// A running job's channel back to the app: report progress, notice
170/// cancellation. Cloneable and `Send`, for the worker.
171#[derive(Clone)]
172pub struct Progress {
173    events: Sender<JobEvent>,
174    cancelled: Arc<AtomicBool>,
175    wake: WakeHandle,
176    custody: Option<ResourceEffectScope>,
177}
178
179impl Progress {
180    /// Report where the job is: a phase, and how far through it.
181    pub fn report(&self, phase: impl Into<String>, completed: Option<u64>, total: Option<u64>) {
182        if self
183            .events
184            .send(JobEvent::Progress {
185                phase: phase.into(),
186                completed,
187                total,
188            })
189            .is_ok()
190        {
191            self.wake.wake();
192        }
193    }
194
195    /// Whether the caller cancelled the job (an effect declared
196    /// `cancellable`). A worker checks between units of work and stops; its
197    /// result is discarded. For caller-scoped work this also performs a fresh,
198    /// potentially blocking authority check. Do not call while holding an owner
199    /// store guard that the authority check may need.
200    pub fn is_cancelled(&self) -> bool {
201        self.with_effect(|| Ok(())).is_err()
202    }
203
204    /// Recheck the original caller immediately before a unit of external work.
205    /// Blocking workers should use this between units as well as checking
206    /// cancellation. It grants no access to the canonical document.
207    /// The authority check may block; release owner store guards before calling.
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if the job was cancelled, the original caller's authority
212    /// lapsed, or the unit of work returned an error.
213    pub fn with_effect<T>(&self, effect: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
214        if self.cancelled.load(Ordering::Acquire) {
215            return Err("the effect was cancelled".into());
216        }
217        let admitted = || {
218            // Authority checks may block while cancellation arrives. Recheck at
219            // the actual work boundary, not only before acquiring authority.
220            if self.cancelled.load(Ordering::Acquire) {
221                return Err("the effect was cancelled".into());
222            }
223            effect()
224        };
225        match &self.custody {
226            Some(custody) => custody.with_effect(admitted),
227            None => admitted(),
228        }
229    }
230
231    fn fail(&self, error: String) {
232        self.cancelled.store(true, Ordering::Release);
233        if self.events.send(JobEvent::Failed(failure(error))).is_ok() {
234            self.wake.wake();
235        }
236    }
237
238    fn finish(&self, value: Box<dyn Any + Send>) {
239        if self.events.send(JobEvent::Finished(value)).is_ok() {
240            self.wake.wake();
241        }
242    }
243}
244
245/// The answer of an effect, given later from wherever its work settles — a
246/// reply from another service, a callback, a worker. `Send`. Dropping it
247/// unanswered fails the effect.
248pub struct EffectReply {
249    progress: Progress,
250    completion: bool,
251}
252
253impl EffectReply {
254    /// Report progress, as [`Progress::report`].
255    pub fn progress(&self) -> &Progress {
256        &self.progress
257    }
258
259    /// Whether the caller cancelled the effect.
260    pub fn is_cancelled(&self) -> bool {
261        self.progress.is_cancelled()
262    }
263
264    /// Answer with `result`; a failure is told to the caller as its
265    /// message.
266    pub fn finish(self, result: Result<ActionOutput, String>) {
267        self.answer(result.map_err(failure));
268    }
269
270    /// Answer with `result`, a failure carrying its code (`portal_failed`,
271    /// `device_busy`) for programs and whether trying again may help.
272    pub fn answer(self, result: Result<ActionOutput, ActionFailure>) {
273        if self.completion {
274            match result {
275                Ok(output) => self.progress.finish(Box::new(output)),
276                Err(error) => {
277                    if self.progress.events.send(JobEvent::Failed(error)).is_ok() {
278                        self.progress.wake.wake();
279                    }
280                }
281            }
282        } else {
283            self.progress.finish(Box::new(result));
284        }
285    }
286}
287
288impl std::fmt::Debug for EffectReply {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        f.debug_struct("EffectReply").finish_non_exhaustive()
291    }
292}
293
294type Then<R, H> = Box<dyn FnOnce(&mut H, Box<dyn Any + Send>, &mut EffectCx<R, H>) -> EffectAnswer>;
295
296/// An answer a job waits for without a worker: polled on the UI thread each
297/// time the job is, `Some` once it arrived.
298type Source = Box<dyn FnMut() -> Option<Box<dyn Any + Send>>>;
299
300/// What a job reports and how it is stopped: the same for every resource,
301/// so one copy of it serves them all.
302struct JobCore {
303    events: Receiver<JobEvent>,
304    cancelled: Arc<AtomicBool>,
305    task: Option<ui_core::rt::JoinHandle<()>>,
306    source: Option<Source>,
307    sequence: u64,
308    custody: Option<ResourceInvocationScope>,
309}
310
311impl JobCore {
312    fn stop(self) {
313        self.cancelled.store(true, Ordering::Release);
314        if let Some(task) = self.task {
315            task.abort();
316        }
317    }
318
319    /// The next thing the job reports, if it reported anything.
320    fn next(&mut self) -> Option<Next> {
321        if let Some(error) = self
322            .custody
323            .as_ref()
324            .and_then(|custody| custody.check().err())
325        {
326            return Some(Next::Failed(failure(error)));
327        }
328        let answered = self.source.as_mut().and_then(|source| source());
329        let event = match answered.map_or_else(
330            || self.events.try_recv(),
331            |value| Ok(JobEvent::Finished(value)),
332        ) {
333            Ok(event) => event,
334            Err(TryRecvError::Empty) => return None,
335            Err(TryRecvError::Disconnected) => {
336                return Some(Next::Failed(failure(
337                    "the effect ended without an answer".into(),
338                )));
339            }
340        };
341        if let Some(error) = self
342            .custody
343            .as_ref()
344            .and_then(|custody| custody.check().err())
345        {
346            return Some(Next::Failed(failure(error)));
347        }
348        Some(match event {
349            JobEvent::Failed(error) => Next::Failed(error),
350            JobEvent::Progress {
351                phase,
352                completed,
353                total,
354            } => {
355                self.sequence = self.sequence.saturating_add(1);
356                Next::Progress(Polled::Progress {
357                    sequence: self.sequence,
358                    phase,
359                    completed,
360                    total,
361                })
362            }
363            JobEvent::Finished(value) => Next::Finished(value),
364        })
365    }
366}
367
368/// What [`JobCore::next`] found.
369enum Next {
370    Progress(Polled),
371    /// The job failed or lost its custody: it is over.
372    Failed(ActionFailure),
373    Finished(Box<dyn Any + Send>),
374}
375
376/// The answer a job without a completion of its own gave.
377fn answered(value: Box<dyn Any + Send>) -> Polled {
378    Polled::Finished(Some(
379        match value.downcast::<Result<ActionOutput, ActionFailure>>() {
380            Ok(result) => *result,
381            Err(_) => Err(failure("the effect answered with another type".into())),
382        },
383    ))
384}
385
386/// What a completion's answer, with the job it may have chained, comes to.
387fn completed(answer: EffectAnswer, chained: bool) -> Option<Result<ActionOutput, ActionFailure>> {
388    match answer {
389        Ok(Effected::Pending) if chained => None,
390        Ok(Effected::Pending) => Some(Err(failure(
391            "the effect's completion answered later but started no job".into(),
392        ))),
393        Ok(Effected::Done(output)) => Some(Ok(output)),
394        Err(error) => Some(Err(failure(error))),
395    }
396}
397
398/// A job a handler or a completion started: what it reports, and the
399/// completion that runs with what it finished with. The same type for
400/// every resource and host, so one copy of the job bookkeeping serves them
401/// all; only running the completion is typed ([`complete`]).
402struct Job {
403    core: JobCore,
404    /// The completion, a `Then<R, H>`.
405    then: Option<Box<dyn Any>>,
406    /// The `Then<R, H>` type the job was started for: whose jobs it is.
407    owner: std::any::TypeId,
408}
409
410impl Job {
411    fn stop(self) {
412        self.core.stop();
413    }
414}
415
416/// The custody a job runs under: its worker's, and the invocation's its
417/// completion is checked against.
418struct JobCustody {
419    worker: Option<ResourceEffectScope>,
420    custody: Option<ResourceInvocationScope>,
421}
422
423impl JobCustody {
424    /// The custody of a job an effect running under `worker` and `custody`
425    /// starts.
426    #[inline(never)]
427    fn of(worker: Option<&ResourceEffectScope>, custody: Option<&ResourceInvocationScope>) -> Self {
428        Self {
429            worker: worker.cloned(),
430            custody: custody.cloned(),
431        }
432    }
433}
434
435/// `custody` owned, for an effect context to keep.
436#[inline(never)]
437fn owned_scope(custody: Option<&ResourceInvocationScope>) -> Option<ResourceInvocationScope> {
438    custody.cloned()
439}
440
441/// Start tracking a job in `started` for `owner`, with the completion
442/// `then`; the [`Progress`] its work reports through. A job already
443/// started there is stopped: an effect runs one job at a time.
444fn track_job(
445    started: &mut Option<Job>,
446    wake: &WakeHandle,
447    custody: JobCustody,
448    then: Option<Box<dyn Any>>,
449    owner: std::any::TypeId,
450) -> Progress {
451    let (events, receiver) = channel();
452    let cancelled = Arc::new(AtomicBool::new(false));
453    let progress = Progress {
454        events,
455        cancelled: Arc::clone(&cancelled),
456        wake: wake.clone(),
457        custody: custody.worker,
458    };
459    if let Some(previous) = started.replace(Job {
460        core: JobCore {
461            events: receiver,
462            cancelled,
463            task: None,
464            source: None,
465            sequence: 0,
466            custody: custody.custody,
467        },
468        then,
469        owner,
470    }) {
471        tracing::warn!("an effect started a second job; the first one is stopped");
472        previous.stop();
473    }
474    progress
475}
476
477/// Run `run`, a handler or a completion, for `invocation` and under
478/// `custody` when it has one: it fails once the custody is gone.
479fn run_under(
480    invocation: Option<InvocationId>,
481    custody: Option<&ResourceInvocationScope>,
482    run: &mut dyn FnMut() -> EffectAnswer,
483) -> EffectAnswer {
484    let _acting = ActingFor::enter(invocation);
485    match custody {
486        Some(custody) => custody
487            .with_effect(run)
488            .and_then(|answer| custody.check().map(|()| answer)),
489        None => run(),
490    }
491}
492
493/// A job's completion run: the completion, what the job finished with, its
494/// custody and the invocation it answers; what it answered and the job it
495/// chained, if any.
496type Complete<'a> = dyn FnMut(
497        Box<dyn Any>,
498        Box<dyn Any + Send>,
499        Option<ResourceInvocationScope>,
500        Option<InvocationId>,
501    ) -> Result<(EffectAnswer, Option<Job>), String>
502    + 'a;
503
504/// Run a job's completion `then` (a `Then<R, H>`) with `host` and what the
505/// job finished with, on the UI thread: the typed part of polling a job.
506fn complete<R: Resource, H: 'static>(
507    env: &EffectEnv<R>,
508    host: &mut H,
509    then: Box<dyn Any>,
510    value: Box<dyn Any + Send>,
511    custody: Option<ResourceInvocationScope>,
512    invocation: Option<InvocationId>,
513) -> Result<(EffectAnswer, Option<Job>), String> {
514    let Ok(then) = then.downcast::<Then<R, H>>() else {
515        return Err("a job's completion is of another type".into());
516    };
517    let scope = owned_scope(custody.as_ref());
518    let mut cx = EffectCx::<R, H>::new(env.clone(), invocation).with_custody(custody)?;
519    let mut then = Some(*then);
520    let mut value = Some(value);
521    let answer = run_under(
522        invocation,
523        scope.as_ref(),
524        &mut || match (then.take(), value.take()) {
525            (Some(then), Some(value)) => then(host, value, &mut cx),
526            _ => Err("a job's completion ran twice".into()),
527        },
528    );
529    Ok((answer, cx.started.take()))
530}
531
532/// What every effect context of one resource shares.
533pub(crate) struct EffectEnv<R: Resource> {
534    pub(crate) dispatch: Dispatcher<R>,
535    pub(crate) live: Live<R>,
536    pub(crate) wake: WakeHandle,
537}
538
539impl<R: Resource> Clone for EffectEnv<R> {
540    fn clone(&self) -> Self {
541        Self {
542            dispatch: self.dispatch.clone(),
543            live: self.live.clone(),
544            wake: self.wake.clone(),
545        }
546    }
547}
548
549/// What an effect handler, a job's completion and a projection drain
550/// receive: the resource, its dispatcher, and the means to start a job.
551///
552/// `H` is what a completion gets mutable access to: the surface that
553/// handles the effect (`&mut self`), or `()` for a handler a view registered.
554pub struct EffectCx<R: Resource, H = ()> {
555    env: EffectEnv<R>,
556    invocation: Option<InvocationId>,
557    started: Option<Job>,
558    /// The resource's document while a surface's handler answers an
559    /// invocation, lent to it for reading.
560    document: Option<Rc<RefCell<tilo_resource::Doc>>>,
561    /// The lent document's heads, taken when it was lent.
562    heads: Vec<ui_core::automerge::ChangeHash>,
563    custody: Option<ResourceInvocationScope>,
564    worker_custody: Option<ResourceEffectScope>,
565    target: Option<ui_core::host_operation::OperationTarget>,
566    /// Where the invoker asked the effect to act (`#[param(place)]`).
567    place: Option<tilo_resource::Place>,
568    /// What its completions get `&mut` access to.
569    host: std::marker::PhantomData<fn(&mut H)>,
570}
571
572/// The place `effect` was told to act at: its argument declared
573/// `#[param(place)]`, when given.
574fn place_of<E: Effect>(
575    decl: &tilo_resource::effect::EffectDecl,
576    effect: &E,
577) -> Option<tilo_resource::Place> {
578    let param = decl.params.iter().find(|param| param.is_place())?;
579    let value = effect.arguments().remove(param.name.as_ref())?;
580    serde_json::from_value(value).ok()
581}
582
583/// The resource as it was at a past version a view asked for: its
584/// document there, the heads that name it and the name a caller gives it
585/// back ([`EffectCx::version`]).
586pub struct DocumentVersion {
587    pub doc: tilo_resource::Doc,
588    pub heads: Vec<ui_core::automerge::ChangeHash>,
589    pub name: String,
590}
591
592/// An effect's future, its output boxed.
593type BoxedFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Box<dyn Any + Send>> + Send>>;
594
595/// An effect's blocking work, its output boxed.
596type BoxedWork = Box<dyn FnOnce(Progress) -> Box<dyn Any + Send> + Send>;
597
598/// Run an effect's future on the app runtime, polled under its custody, and
599/// deliver what it finished with. Every effect's future runs through this
600/// one function and task type.
601fn spawn_future(progress: Progress, future: BoxedFuture) -> ui_core::rt::JoinHandle<()> {
602    ui_core::rt::app().spawn(async move {
603        let mut future = future;
604        let value = std::future::poll_fn(|cx| {
605            match progress.with_effect(|| Ok(future.as_mut().poll(cx))) {
606                Ok(std::task::Poll::Ready(value)) => std::task::Poll::Ready(Ok(value)),
607                Ok(std::task::Poll::Pending) => std::task::Poll::Pending,
608                Err(error) => std::task::Poll::Ready(Err(error)),
609            }
610        })
611        .await;
612        match value {
613            Ok(value) => progress.finish(value),
614            Err(error) => progress.fail(error),
615        }
616    })
617}
618
619/// Run an effect's blocking work on the app runtime's blocking threads,
620/// under its custody, and deliver what it finished with ([`spawn_future`]).
621fn spawn_work(progress: Progress, work: BoxedWork) -> ui_core::rt::JoinHandle<()> {
622    ui_core::rt::app().spawn_blocking(move || {
623        match progress.with_effect(|| Ok(work(progress.clone()))) {
624            Ok(value) => progress.finish(value),
625            Err(error) => progress.fail(error),
626        }
627    })
628}
629
630impl<R: Resource, H: 'static> EffectCx<R, H> {
631    pub(crate) fn new(env: EffectEnv<R>, invocation: Option<InvocationId>) -> Self {
632        Self {
633            env,
634            invocation,
635            started: None,
636            document: None,
637            heads: Vec::new(),
638            custody: None,
639            worker_custody: None,
640            target: None,
641            place: None,
642            host: std::marker::PhantomData,
643        }
644    }
645
646    /// Where the invoker asked this effect to act (its `#[param(place)]`
647    /// argument): act there instead of at their caret. Once it succeeds
648    /// for a participant, they land there ([`Live::place_of`]).
649    pub fn place(&self) -> Option<&tilo_resource::Place> {
650        self.place.as_ref()
651    }
652
653    fn with_custody(mut self, custody: Option<ResourceInvocationScope>) -> Result<Self, String> {
654        let (worker, lent) = custody_scopes(custody.as_ref())?;
655        self.worker_custody = worker;
656        if let Some(scope) = lent {
657            self.env.dispatch = self.env.dispatch.for_custody(scope);
658        }
659        self.custody = custody;
660        Ok(self)
661    }
662
663    /// The resource's document as it is now, to read what a view answers
664    /// with: the state at its heads, a selection's measurements, a picture
665    /// of it. Only a surface's handler answering an invocation has it; a
666    /// job's completion, which runs later, reads the live state instead.
667    /// Changes go through the dispatcher, never here.
668    pub fn document(&self) -> Option<std::cell::Ref<'_, tilo_resource::Doc>> {
669        self.document.as_ref().map(|document| document.borrow())
670    }
671
672    /// The heads of the document as it is now; empty without one.
673    pub fn heads(&self) -> Vec<ui_core::automerge::ChangeHash> {
674        self.heads.clone()
675    }
676
677    /// The resource at `version`, as a caller names one: a change id from
678    /// its history (or enough of one) or a checkpoint's name.
679    ///
680    /// # Errors
681    /// Returns why `version` names no version of this document, or that
682    /// the handler has no document to read.
683    pub fn version(&self, version: &str) -> Result<DocumentVersion, String> {
684        let document = self
685            .document
686            .as_ref()
687            .ok_or_else(|| "this effect has no document to read".to_owned())?;
688        let mut doc = document
689            .try_borrow_mut()
690            .map_err(|_| "read a past version before borrowing the document".to_owned())?;
691        let heads = ui_core::history_source::steps::resolve(&doc, version)?;
692        let past = doc
693            .fork_at(&heads)
694            .map_err(|error| format!("version {version:?} is not in this document: {error}"))?;
695        Ok(DocumentVersion {
696            doc: past,
697            name: super::history::version_name(&heads),
698            heads,
699        })
700    }
701
702    /// Where the effect's document changes go: ordinary actions, checked,
703    /// applied, undone and replicated like any other.
704    pub fn dispatcher(&self) -> Dispatcher<R> {
705        self.env.dispatch.clone()
706    }
707
708    /// Submit one typed action, as [`Dispatcher::apply`].
709    pub fn apply(&self, action: &R::Action) {
710        self.env.dispatch.apply(action);
711    }
712
713    /// Submit a field edit or a verb, as [`Dispatcher::submit`].
714    pub fn submit(&self, submit: impl super::Submit<R>) {
715        self.env.dispatch.submit(submit);
716    }
717
718    /// The resource's live state.
719    pub fn live(&self) -> &Live<R> {
720        &self.env.live
721    }
722
723    /// The actual source invocation this work continues. Ordinary background
724    /// projection drains have none; causal drains retain the current Document
725    /// caller. Its source identity may differ from the local reply operation.
726    pub fn invocation(&self) -> Option<InvocationId> {
727        self.invocation
728    }
729
730    /// Run an approved project check through this effect's admitted host channel.
731    /// Commands, source paths and resource bounds are chosen by the project host.
732    /// Pass the returned future to the existing effect job with [`Self::spawn`].
733    ///
734    /// # Errors
735    /// Refuses projection drains, absent original custody or unavailable transport.
736    pub fn approved_check(
737        &self,
738        check_id: &str,
739    ) -> Result<ui_core::invocation_scope::ApprovedCheckFuture, String> {
740        if let Some(custody) = &self.custody {
741            return custody.approved_check(check_id);
742        }
743        let target = self
744            .target
745            .as_ref()
746            .ok_or("approved checks require an exact dispatched record target")?;
747        if target.route().app_id != self.env.dispatch.app_id() {
748            return Err("approved check dispatch app changed".into());
749        }
750        target.approved_check(
751            self.invocation
752                .ok_or("approved checks require a dispatched invocation")?,
753            check_id,
754        )
755    }
756
757    /// Attach approved-check reports to this action's existing job progress.
758    /// The app remains the sole publisher of invocation-update sequences.
759    ///
760    /// # Errors
761    /// Refuses absent dispatch custody or unavailable approved transport.
762    pub fn approved_check_reported(
763        &self,
764        check_id: &str,
765    ) -> Result<
766        impl FnOnce(Progress) -> ui_core::invocation_scope::ApprovedCheckFuture
767        + Send
768        + 'static
769        + use<R, H>,
770        String,
771    > {
772        let checked = if let Some(custody) = &self.custody {
773            custody.approved_check_reported(check_id)?
774        } else {
775            let target = self
776                .target
777                .as_ref()
778                .ok_or("approved checks require an exact dispatched record target")?;
779            if target.route().app_id != self.env.dispatch.app_id() {
780                return Err("approved check dispatch app changed".into());
781            }
782            target.approved_check_reported(
783                self.invocation
784                    .ok_or("approved checks require a dispatched invocation")?,
785                check_id,
786            )?
787        };
788        Ok(move |progress: Progress| attach_approved_check_reporter(checked, progress))
789    }
790
791    /// Wakes the app from another thread, to draw what a worker changed.
792    pub fn waker(&self) -> WakeHandle {
793        self.env.wake.clone()
794    }
795
796    fn track(&mut self, then: Option<Box<dyn Any>>) -> Progress {
797        track_job(
798            &mut self.started,
799            &self.env.wake,
800            JobCustody::of(self.worker_custody.as_ref(), self.custody.as_ref()),
801            then,
802            std::any::TypeId::of::<Then<R, H>>(),
803        )
804    }
805
806    fn then<T: Send + 'static>(
807        then: impl FnOnce(&mut H, T, &mut Self) -> EffectAnswer + 'static,
808    ) -> Box<dyn Any> {
809        let then: Then<R, H> = Box::new(move |host, value, cx| match value.downcast::<T>() {
810            Ok(value) => then(host, *value, cx),
811            Err(_) => Err("a job finished with a value of another type".into()),
812        });
813        Box::new(then)
814    }
815
816    /// Run `work`'s future on the app runtime, then `then` with its output on
817    /// the UI thread. The future reports through the [`Progress`] it is
818    /// built with; cancelling the effect aborts it.
819    pub fn spawn<T, F>(
820        &mut self,
821        work: impl FnOnce(Progress) -> F,
822        then: impl FnOnce(&mut H, T, &mut Self) -> EffectAnswer + 'static,
823    ) -> Effected
824    where
825        T: Send + 'static,
826        F: std::future::Future<Output = T> + Send + 'static,
827    {
828        let progress = self.track(Some(Self::then(then)));
829        let future = work(progress.clone());
830        let task = spawn_future(
831            progress,
832            Box::pin(async move { Box::new(future.await) as Box<dyn Any + Send> }),
833        );
834        if let Some(started) = self.started.as_mut() {
835            started.core.task = Some(task);
836        }
837        Effected::Pending
838    }
839
840    /// Run blocking `work` off the UI thread, then `then` with its result on
841    /// the UI thread. `work` reports through its [`Progress`] and checks it
842    /// for cancellation.
843    pub fn thread<T>(
844        &mut self,
845        work: impl FnOnce(Progress) -> T + Send + 'static,
846        then: impl FnOnce(&mut H, T, &mut Self) -> EffectAnswer + 'static,
847    ) -> Effected
848    where
849        T: Send + 'static,
850    {
851        let progress = self.track(Some(Self::then(then)));
852        let task = spawn_work(
853            progress,
854            Box::new(move |progress| Box::new(work(progress)) as Box<dyn Any + Send>),
855        );
856        if let Some(started) = self.started.as_mut() {
857            started.core.task = Some(task);
858        }
859        Effected::Pending
860    }
861
862    /// Answer later, from wherever the work settles. Return
863    /// [`Effected::Pending`] and keep the reply until then.
864    pub fn reply(&mut self) -> EffectReply {
865        let progress = self.track(None);
866        EffectReply {
867            progress,
868            completion: false,
869        }
870    }
871
872    /// Answer later, then settle the successful receipt on the UI thread
873    /// under this effect's retained invocation. Refusal skips the completion
874    /// and preserves its structured failure. Cancellation and current custody
875    /// are checked by the same job path as [`Self::spawn`].
876    pub fn reply_then(
877        &mut self,
878        then: impl FnOnce(&mut H, ActionOutput, &mut Self) -> EffectAnswer + 'static,
879    ) -> EffectReply {
880        let progress = self.track(Some(Self::then(then)));
881        EffectReply {
882            progress,
883            completion: true,
884        }
885    }
886
887    /// Answer once what this effect submitted through its
888    /// [`dispatcher`](Self::dispatcher) landed: each submission goes through
889    /// the door like any other, checked again for the effect's invoker, and
890    /// `then` runs with the last one's [`Landed`] — what the door answered
891    /// (what an edit changed, as the resource's surface reports it) and the
892    /// items it created — to reply to the effect's caller. A refused
893    /// submission fails the effect with why. The effect must be declared
894    /// `job`, since it answers after the edits apply.
895    ///
896    /// ```ignore
897    /// cx.dispatcher().submit(Doc::BODY.edit(op));
898    /// cx.when_landed(|_, landed, _| Ok(Effected::value(landed.answer)))
899    /// ```
900    ///
901    /// # Errors
902    /// When the effect was not invoked (a view's own job) or submitted
903    /// nothing to wait for.
904    pub fn when_landed(
905        &mut self,
906        then: impl FnOnce(&mut H, super::Landed, &mut Self) -> EffectAnswer + 'static,
907    ) -> EffectAnswer {
908        let invocation = self
909            .invocation
910            .ok_or("only an invoked effect waits for what it submitted")?;
911        let receiver = self
912            .env
913            .dispatch
914            .wait_landed(invocation)
915            .ok_or("the effect submitted nothing to wait for")?;
916        Ok(self.wait_for(receiver, |host, landed, cx| match landed {
917            Some(Ok(landed)) => then(host, landed, cx),
918            Some(Err(refused)) => Err(refused),
919            None => Err("the submission was dropped before it landed".into()),
920        }))
921    }
922
923    /// Run `then` on the UI thread with what `receiver` delivers, `None`
924    /// when its sender went away unanswered. Nothing waits on a thread (a
925    /// browser page has none to spare): the job looks each time the app
926    /// wakes, so the sender must wake it, as the host's services do.
927    fn wait_for<T: Send + 'static>(
928        &mut self,
929        receiver: Receiver<T>,
930        then: impl FnOnce(&mut H, Option<T>, &mut Self) -> EffectAnswer + 'static,
931    ) -> Effected {
932        let progress = self.track(Some(Self::then(then)));
933        if let Some(started) = self.started.as_mut() {
934            started.core.source = Some(Box::new(move || {
935                // The job's own channel stays open while it waits here.
936                let _waiting = &progress;
937                match receiver.try_recv() {
938                    Ok(value) => Some(Box::new(Some(value)) as Box<dyn Any + Send>),
939                    Err(TryRecvError::Empty) => None,
940                    Err(TryRecvError::Disconnected) => Some(Box::new(None::<T>)),
941                }
942            }));
943        }
944        Effected::Pending
945    }
946
947    /// Ask the person to choose a file with the host's chooser (`request`
948    /// is an Open request), then run `then` on the UI thread with its name
949    /// and contents, or `None` when they cancelled. Works where files have
950    /// no paths, such as a browser page. Bound reads with
951    /// [`PickRequest::max_contents_bytes`]; an oversized selection fails the
952    /// job before calling `then`. The effect must be declared `job`.
953    pub fn choose_file(
954        &mut self,
955        request: PickRequest,
956        then: impl FnOnce(&mut H, Option<ChosenFile>, &mut Self) -> EffectAnswer + 'static,
957    ) -> Effected {
958        let chosen = request.send_contents();
959        self.wait_for(chosen, move |host, outcome, cx| match outcome {
960            Some(ContentsOutcome::Picked(files)) => {
961                then(host, files.into_iter().next().map(ChosenFile::from), cx)
962            }
963            Some(ContentsOutcome::Cancelled) => then(host, None, cx),
964            Some(ContentsOutcome::Failed(reason)) => Err(reason),
965            None => Err(CHOOSER_GONE.into()),
966        })
967    }
968
969    /// [`Self::choose_file`] for a request that lets the person choose
970    /// several files ([`PickRequest::open_multiple`]): `then` receives every
971    /// chosen file, none when they cancelled. A request contents limit applies
972    /// to the total bytes across the entire selection.
973    pub fn choose_files(
974        &mut self,
975        request: PickRequest,
976        then: impl FnOnce(&mut H, Vec<ChosenFile>, &mut Self) -> EffectAnswer + 'static,
977    ) -> Effected {
978        let chosen = request.send_contents();
979        self.wait_for(chosen, move |host, outcome, cx| match outcome {
980            Some(ContentsOutcome::Picked(files)) => {
981                then(host, files.into_iter().map(ChosenFile::from).collect(), cx)
982            }
983            Some(ContentsOutcome::Cancelled) => then(host, Vec::new(), cx),
984            Some(ContentsOutcome::Failed(reason)) => Err(reason),
985            None => Err(CHOOSER_GONE.into()),
986        })
987    }
988
989    /// Ask the person to choose a file with the host's chooser and run
990    /// `then` on the UI thread with its path on this device, or `None` when
991    /// they cancelled: for a file the app reads in place rather than into
992    /// memory (a disk image, a video). Only a host whose files have paths
993    /// answers; elsewhere use [`Self::choose_file`]. The effect must be
994    /// declared `job`.
995    pub fn choose_path(
996        &mut self,
997        request: PickRequest,
998        then: impl FnOnce(&mut H, Option<std::path::PathBuf>, &mut Self) -> EffectAnswer + 'static,
999    ) -> Effected {
1000        let chosen = request.send();
1001        self.wait_for(chosen, move |host, outcome, cx| match outcome {
1002            Some(PortalOutcome::Opened(path)) => then(host, Some(path), cx),
1003            Some(PortalOutcome::Cancelled) => then(host, None, cx),
1004            Some(PortalOutcome::Failed(reason)) => Err(reason),
1005            None => Err(CHOOSER_GONE.into()),
1006        })
1007    }
1008
1009    /// Ask the person where to save `bytes` with the host's chooser
1010    /// (`request` is a Save request suggesting a file name; a browser page
1011    /// downloads them under it), then run `then` on the UI thread with
1012    /// whether they were saved: `false` when the person cancelled. The
1013    /// effect must be declared `job`.
1014    pub fn save_file(
1015        &mut self,
1016        request: PickRequest,
1017        bytes: Vec<u8>,
1018        then: impl FnOnce(&mut H, bool, &mut Self) -> EffectAnswer + 'static,
1019    ) -> Effected {
1020        let saved = request.send_save_contents(bytes);
1021        self.wait_for(saved, move |host, outcome, cx| match outcome {
1022            Some(SaveOutcome::Saved) => then(host, true, cx),
1023            Some(SaveOutcome::Cancelled) => then(host, false, cx),
1024            Some(SaveOutcome::Failed(reason)) => Err(reason),
1025            None => Err(CHOOSER_GONE.into()),
1026        })
1027    }
1028
1029    /// Ask the host to show what a launch naming `arguments` opens in this
1030    /// window, in place of the record it shows ([`OpenHere::open`]), then
1031    /// run `then` on the UI thread with its answer: shown, or why not. The
1032    /// answer arrives even when the window shows the new record before this
1033    /// surface draws another frame. Cancelling the effect withdraws a
1034    /// request the host has not taken up yet. The effect must be declared
1035    /// `job`.
1036    pub fn open_here(
1037        &mut self,
1038        opener: &super::OpenHere,
1039        arguments: &[String],
1040        then: impl FnOnce(&mut H, Result<(), String>, &mut Self) -> EffectAnswer + 'static,
1041    ) -> Effected {
1042        /// Withdraws the request when the job stops unanswered.
1043        struct Asked(ui_core::application::ReplacementOutcome);
1044        impl Drop for Asked {
1045            fn drop(&mut self) {
1046                self.0.withdraw();
1047            }
1048        }
1049        let asked = Asked(opener.open(arguments));
1050        let progress = self.track(Some(Self::then(then)));
1051        if let Some(started) = self.started.as_mut() {
1052            started.core.source = Some(Box::new(move || {
1053                // The job's own channel stays open while it waits here.
1054                let _waiting = &progress;
1055                asked
1056                    .0
1057                    .get()
1058                    .map(|answer| Box::new(answer) as Box<dyn Any + Send>)
1059            }));
1060        }
1061        Effected::Pending
1062    }
1063}
1064
1065/// Work a view or surface starts on its own, with nobody invoking an
1066/// effect: a route fetched when the destination changes, a feed refreshed
1067/// when it opens. Each job runs under a key; starting another under a key
1068/// still running stops the earlier one, so the latest request wins. A job
1069/// is started like an effect's ([`EffectCx::thread`], [`EffectCx::spawn`])
1070/// and finishes the same way, on the UI thread, with ordinary actions; a
1071/// surface's completion gets `&mut` the surface. Jobs run only in a window
1072/// someone uses: a past version and a capture start none.
1073///
1074/// Get one from [`SurfaceCx::jobs`](super::SurfaceCx::jobs) or
1075/// [`ViewCx::jobs`](super::ViewCx::jobs). Clones share the jobs.
1076pub struct Jobs<R: Resource> {
1077    env: Option<EffectEnv<R>>,
1078    queue: Rc<RefCell<JobQueue>>,
1079}
1080
1081impl<R: Resource> Clone for Jobs<R> {
1082    fn clone(&self) -> Self {
1083        Self {
1084            env: self.env.clone(),
1085            queue: Rc::clone(&self.queue),
1086        }
1087    }
1088}
1089
1090/// Where a running job last said it is, as its [`Progress`] reported it.
1091#[derive(Clone, Debug, PartialEq, Eq)]
1092pub struct JobProgress {
1093    pub phase: String,
1094    pub completed: Option<u64>,
1095    pub total: Option<u64>,
1096}
1097
1098/// What the jobs' owner has yet to take up.
1099#[derive(Default)]
1100struct JobQueue {
1101    /// Jobs started and not yet tracked.
1102    started: Vec<(String, Job)>,
1103    cancelled: Vec<String>,
1104    running: std::collections::HashSet<String>,
1105    /// The last progress each running job reported.
1106    progress: HashMap<String, JobProgress>,
1107}
1108
1109impl<R: Resource> Jobs<R> {
1110    pub(crate) fn new(env: Option<EffectEnv<R>>) -> Self {
1111        Self {
1112            env,
1113            queue: Rc::default(),
1114        }
1115    }
1116
1117    /// Start the job `key` as `start` does it, through an [`EffectCx`] whose
1118    /// completions get `&mut H`: the surface itself for a surface's jobs
1119    /// (`jobs.start::<Self>(…)`), `()` for a view's. Work it finishes at
1120    /// once needs no job; a failure is logged, as nobody waits for it.
1121    pub fn start<H: 'static>(
1122        &self,
1123        key: impl Into<String>,
1124        start: impl FnOnce(&mut EffectCx<R, H>) -> EffectAnswer,
1125    ) {
1126        let Some(env) = &self.env else {
1127            return;
1128        };
1129        let mut cx = EffectCx::<R, H>::new(env.clone(), None);
1130        let answer = start(&mut cx);
1131        self.queue
1132            .borrow_mut()
1133            .started(key.into(), answer, cx.started.take(), R::KIND);
1134    }
1135
1136    /// Stop the job `key`; its result, if it comes, is dropped.
1137    pub fn cancel(&self, key: &str) {
1138        let mut queue = self.queue.borrow_mut();
1139        queue.started.retain(|(queued, _)| queued != key);
1140        queue.progress.remove(key);
1141        if queue.running.remove(key) {
1142            queue.cancelled.push(key.to_owned());
1143        }
1144    }
1145
1146    /// Whether the job `key` is still running.
1147    pub fn is_running(&self, key: &str) -> bool {
1148        self.queue.borrow().running.contains(key)
1149    }
1150
1151    /// Where the running job `key` last said it is, for the view to show
1152    /// and publish; `None` before its first report and once it ended.
1153    pub fn progress(&self, key: &str) -> Option<JobProgress> {
1154        self.queue.borrow().progress.get(key).cloned()
1155    }
1156}
1157
1158/// The jobs one owner runs, polled where its completions get `&mut H`.
1159pub(crate) struct OwnedJobs<R: Resource, H> {
1160    jobs: Jobs<R>,
1161    running: Running<String>,
1162    marker: std::marker::PhantomData<fn(&mut H)>,
1163}
1164
1165impl<R: Resource, H: 'static> OwnedJobs<R, H> {
1166    pub(crate) fn new(jobs: Jobs<R>) -> Self {
1167        Self {
1168            jobs,
1169            running: Running::default(),
1170            marker: std::marker::PhantomData,
1171        }
1172    }
1173
1174    /// Take up the jobs started since, stop the cancelled ones, and finish
1175    /// those that are done with `host`.
1176    pub(crate) fn poll(&mut self, host: &mut H) {
1177        let Some(env) = self.jobs.env.clone() else {
1178            return;
1179        };
1180        poll_owned(
1181            &self.jobs.queue,
1182            &mut self.running,
1183            std::any::TypeId::of::<Then<R, H>>(),
1184            &mut |then, value, custody, invocation| {
1185                complete::<R, H>(&env, host, then, value, custody, invocation)
1186            },
1187        );
1188    }
1189}
1190
1191/// [`OwnedJobs::poll`] of the jobs of `owner` in `queue`, running in
1192/// `running`, their completions run by `complete`.
1193fn poll_owned(
1194    queue: &RefCell<JobQueue>,
1195    running: &mut Running<String>,
1196    owner: std::any::TypeId,
1197    complete: &mut Complete<'_>,
1198) {
1199    {
1200        let mut queue = queue.borrow_mut();
1201        for key in std::mem::take(&mut queue.cancelled) {
1202            running.cancel(&key);
1203        }
1204        let mut others = Vec::new();
1205        for (key, started) in std::mem::take(&mut queue.started) {
1206            if started.owner == owner {
1207                running.insert(key, started);
1208            } else {
1209                others.push((key, started));
1210            }
1211        }
1212        queue.started = others;
1213    }
1214    for key in running.keys() {
1215        while let Some(polled) = running.poll(&key, complete, None) {
1216            if !queue.borrow_mut().note(&key, polled) {
1217                break;
1218            }
1219        }
1220    }
1221}
1222
1223impl JobQueue {
1224    /// Queue the job `key` a start answered `answer` for, when it runs on;
1225    /// stop what it started otherwise. A job still queued under `key` is
1226    /// replaced.
1227    fn started(&mut self, key: String, answer: EffectAnswer, started: Option<Job>, kind: &str) {
1228        match (answer, started) {
1229            (Ok(Effected::Pending), Some(started)) => {
1230                self.cancelled.retain(|cancelled| *cancelled != key);
1231                self.started.retain(|(queued, _)| *queued != key);
1232                self.running.insert(key.clone());
1233                self.progress.remove(&key);
1234                self.started.push((key, started));
1235            }
1236            (answer, started) => {
1237                if let Some(started) = started {
1238                    started.stop();
1239                }
1240                if let Err(error) = answer {
1241                    tracing::warn!(%error, kind, %key, "a job failed to start");
1242                }
1243            }
1244        }
1245    }
1246
1247    /// Note what the job `key` reported; whether to poll it again.
1248    fn note(&mut self, key: &str, polled: Polled) -> bool {
1249        match polled {
1250            Polled::Progress {
1251                phase,
1252                completed,
1253                total,
1254                ..
1255            } => {
1256                self.progress.insert(
1257                    key.to_owned(),
1258                    JobProgress {
1259                        phase,
1260                        completed,
1261                        total,
1262                    },
1263                );
1264                true
1265            }
1266            Polled::Finished(None) => false,
1267            Polled::Finished(Some(result)) => {
1268                if let Err(error) = result {
1269                    tracing::warn!(error = %error.message, %key, "a job failed");
1270                }
1271                self.running.remove(key);
1272                self.progress.remove(key);
1273                false
1274            }
1275        }
1276    }
1277}
1278
1279/// What an effect, or one of the resource's own actions, looks like to
1280/// callers right now, when it differs from its declaration: unavailable
1281/// while the engine is offline, labelled "Stop recording" while recording.
1282#[derive(Clone, Debug, PartialEq)]
1283pub struct EffectStatus {
1284    id: &'static str,
1285    availability: Option<Availability>,
1286    label: Option<String>,
1287}
1288
1289impl EffectStatus {
1290    /// `effect` cannot be invoked now: `code` names why for programs,
1291    /// `reason` for people.
1292    pub fn unavailable<E, A>(
1293        effect: ActionRef<E, A>,
1294        code: impl Into<String>,
1295        reason: impl Into<String>,
1296    ) -> Self {
1297        Self {
1298            id: effect.id,
1299            availability: Some(Availability::Unavailable {
1300                code: code.into(),
1301                reason: reason.into(),
1302            }),
1303            label: None,
1304        }
1305    }
1306
1307    /// `effect` is offered as `label` now.
1308    pub fn labelled<E, A>(effect: ActionRef<E, A>, label: impl Into<String>) -> Self {
1309        Self {
1310            id: effect.id,
1311            availability: None,
1312            label: Some(label.into()),
1313        }
1314    }
1315
1316    /// Offer the effect as `label` too.
1317    #[must_use]
1318    pub fn with_label(mut self, label: impl Into<String>) -> Self {
1319        self.label = Some(label.into());
1320        self
1321    }
1322
1323    /// The id of the action or effect this status is about.
1324    pub(crate) fn id(&self) -> &'static str {
1325        self.id
1326    }
1327
1328    /// Show `spec` as this status says it is right now.
1329    pub(crate) fn overlay(&self, spec: &mut ActionSpec) {
1330        if let Some(availability) = &self.availability {
1331            spec.contract.availability = availability.clone();
1332        }
1333        if let Some(label) = &self.label {
1334            spec.label.clone_from(label);
1335        }
1336    }
1337}
1338
1339/// The specs of `E`, as callers see them now.
1340pub(crate) fn published_specs<E: Effect>(statuses: &[EffectStatus]) -> Vec<ActionSpec> {
1341    E::specs()
1342        .into_iter()
1343        .map(|mut spec| {
1344            let id = spec.id.clone();
1345            for status in statuses.iter().filter(|status| status.id == id) {
1346                status.overlay(&mut spec);
1347            }
1348            spec
1349        })
1350        .collect()
1351}
1352
1353/// What a job reported when polled.
1354enum Polled {
1355    Progress {
1356        sequence: u64,
1357        phase: String,
1358        completed: Option<u64>,
1359        total: Option<u64>,
1360    },
1361    /// Finished, or chained into another job (`None`).
1362    Finished(Option<Result<ActionOutput, ActionFailure>>),
1363}
1364
1365/// The jobs one handler runs, keyed by what they answer.
1366pub(crate) struct Running<K = InvocationId> {
1367    running: HashMap<K, Job>,
1368}
1369
1370impl<K> Default for Running<K> {
1371    fn default() -> Self {
1372        Self {
1373            running: HashMap::new(),
1374        }
1375    }
1376}
1377
1378impl<K> Drop for Running<K> {
1379    fn drop(&mut self) {
1380        for (_, job) in self.running.drain() {
1381            job.stop();
1382        }
1383    }
1384}
1385
1386impl<K: Eq + Hash + Clone> Running<K> {
1387    fn insert(&mut self, key: K, started: Job) {
1388        if let Some(previous) = self.running.insert(key, started) {
1389            previous.stop();
1390        }
1391    }
1392
1393    fn contains(&self, key: &K) -> bool {
1394        self.running.contains_key(key)
1395    }
1396
1397    fn keys(&self) -> Vec<K> {
1398        self.running.keys().cloned().collect()
1399    }
1400
1401    /// Stop the job answering `key`; its result, if it comes, is dropped.
1402    fn cancel(&mut self, key: &K) -> bool {
1403        self.running.remove(key).map(Job::stop).is_some()
1404    }
1405
1406    /// The next thing the job `key` reports, running its completion on this
1407    /// thread when it finished.
1408    fn poll(
1409        &mut self,
1410        key: &K,
1411        complete: &mut Complete<'_>,
1412        invocation: Option<InvocationId>,
1413    ) -> Option<Polled> {
1414        let value = match self.running.get_mut(key)?.core.next()? {
1415            Next::Progress(progress) => return Some(progress),
1416            Next::Failed(error) => {
1417                self.cancel(key);
1418                return Some(Polled::Finished(Some(Err(error))));
1419            }
1420            Next::Finished(value) => value,
1421        };
1422        let job = self.running.remove(key)?;
1423        let Some(then) = job.then else {
1424            return Some(answered(value));
1425        };
1426        let (answer, next) = match complete(then, value, job.core.custody, invocation) {
1427            Ok(completed) => completed,
1428            Err(error) => return Some(Polled::Finished(Some(Err(failure(error))))),
1429        };
1430        let chained = matches!(answer, Ok(Effected::Pending)) && next.is_some();
1431        match next {
1432            Some(next) if chained => self.insert(key.clone(), next),
1433            Some(next) => next.stop(),
1434            None => {}
1435        }
1436        Some(Polled::Finished(completed(answer, chained)))
1437    }
1438}
1439
1440thread_local! {
1441    /// The effect invocation whose handler or completion runs on this
1442    /// thread now.
1443    static ACTING_FOR: Cell<Option<InvocationId>> = const { Cell::new(None) };
1444}
1445
1446/// The effect invocation whose handler or completion is running: what a
1447/// dispatcher queues now continues it, so the host applies it for the
1448/// effect's invoker.
1449pub(crate) fn acting_for() -> Option<InvocationId> {
1450    ACTING_FOR.get()
1451}
1452
1453/// Marks an effect invocation's handler or completion as running, until
1454/// dropped.
1455struct ActingFor(Option<InvocationId>);
1456
1457impl ActingFor {
1458    fn enter(invocation: Option<InvocationId>) -> Self {
1459        Self(ACTING_FOR.replace(invocation))
1460    }
1461}
1462
1463impl Drop for ActingFor {
1464    fn drop(&mut self) {
1465        ACTING_FOR.set(self.0);
1466    }
1467}
1468
1469/// An effect's failure told only by its message.
1470fn failure(message: String) -> ActionFailure {
1471    ActionFailure {
1472        code: "effect_failed".into(),
1473        message,
1474        details: None,
1475        retryable: false,
1476    }
1477}
1478
1479fn failed(invocation: InvocationId, error: ActionFailure) -> InvocationUpdate {
1480    InvocationUpdate::Failed { invocation, error }
1481}
1482
1483/// The handler of one effect vocabulary `E`, and the jobs it runs.
1484pub(crate) struct EffectRunner<R: Resource, H, E> {
1485    env: EffectEnv<R>,
1486    jobs: Running,
1487    marker: std::marker::PhantomData<fn(E, &mut H)>,
1488}
1489
1490impl<R: Resource, H: 'static, E: Effect> EffectRunner<R, H, E> {
1491    pub(crate) fn new(env: EffectEnv<R>) -> Self {
1492        Self {
1493            env,
1494            jobs: Running::default(),
1495            marker: std::marker::PhantomData,
1496        }
1497    }
1498
1499    /// Answer `op` when it is one of `E`'s effects: decode it, hand it to
1500    /// `handle`, and start what it started. `Ok(None)` for any other
1501    /// operation.
1502    ///
1503    /// # Errors
1504    /// Returns the handler's failure, or why its answer breaks the effect's
1505    /// declaration.
1506    pub(crate) fn start(
1507        &mut self,
1508        op: &ResourceOperation<'_>,
1509        host: &mut H,
1510        doc: Option<&tilo_resource::Doc>,
1511        handle: impl FnOnce(&mut H, E, &mut EffectCx<R, H>) -> EffectAnswer,
1512    ) -> Result<Option<ResourceOperationStart>, String> {
1513        let (request, custody) = op.with_custody();
1514        if request.type_id != E::OPERATION {
1515            return Ok(None);
1516        }
1517        op.check()?;
1518        let effect = E::from_payload(request.payload)?;
1519        let id = effect.id();
1520        let declaration = E::declaration(id);
1521        let place = declaration
1522            .as_ref()
1523            .and_then(|decl| place_of(decl, &effect));
1524        let job = admitted(declaration.as_ref(), id, request.kind)?;
1525        let mut cx = EffectCx::new(self.env.clone(), Some(request.invocation))
1526            .with_custody(owned_scope(custody))?;
1527        cx.target = op.target().cloned();
1528        cx.place.clone_from(&place);
1529        // Keep an immutable snapshot for this effect invocation, including any
1530        // pending edits. The effect may also read a historical version; neither
1531        // read borrows the author's document mutably.
1532        (cx.document, cx.heads) = lend(doc);
1533        let mut handling = Some((handle, effect));
1534        let answer = run_under(
1535            Some(request.invocation),
1536            custody,
1537            &mut || match handling.take() {
1538                Some((handle, effect)) => handle(host, effect, &mut cx),
1539                None => Err("an effect's handler ran twice".into()),
1540            },
1541        );
1542        cx.document = None;
1543        let live = &self.env.live;
1544        let started = settle_start(
1545            &mut self.jobs,
1546            Answered {
1547                id,
1548                invocation: request.invocation,
1549                job,
1550                answer,
1551                started: cx.started.take(),
1552            },
1553            &mut || {
1554                track_job(
1555                    &mut cx.started,
1556                    &cx.env.wake,
1557                    JobCustody::of(cx.worker_custody.as_ref(), cx.custody.as_ref()),
1558                    None,
1559                    std::any::TypeId::of::<Then<R, H>>(),
1560                )
1561            },
1562            &mut |participant| {
1563                if let Some(place) = &place {
1564                    live.land(participant, place.clone());
1565                }
1566            },
1567        );
1568        if let Some(ready) = cx.started.take() {
1569            self.jobs.insert(request.invocation, ready);
1570        }
1571        started.map(Some)
1572    }
1573
1574    /// The next update of the job answering `invocation`, if this runner
1575    /// runs it.
1576    pub(crate) fn poll(
1577        &mut self,
1578        invocation: InvocationId,
1579        cancel: bool,
1580        host: &mut H,
1581    ) -> Option<InvocationUpdate> {
1582        let env = &self.env;
1583        poll_invocation(
1584            &mut self.jobs,
1585            invocation,
1586            cancel,
1587            &mut |then, value, custody, invocation| {
1588                complete::<R, H>(env, host, then, value, custody, invocation)
1589            },
1590        )
1591    }
1592}
1593
1594/// The scope a worker runs under for `custody`, and the custody the
1595/// effect's dispatcher keeps.
1596fn custody_scopes(
1597    custody: Option<&ResourceInvocationScope>,
1598) -> Result<(Option<ResourceEffectScope>, Option<ResourceInvocationScope>), String> {
1599    let Some(custody) = custody else {
1600        return Ok((None, None));
1601    };
1602    Ok((Some(custody.effect_scope()?), Some(custody.clone())))
1603}
1604
1605/// A snapshot of `doc` to lend an effect's handler, and its heads.
1606fn lend(
1607    doc: Option<&tilo_resource::Doc>,
1608) -> (
1609    Option<Rc<RefCell<tilo_resource::Doc>>>,
1610    Vec<ui_core::automerge::ChangeHash>,
1611) {
1612    match doc {
1613        Some(doc) => (Some(Rc::new(RefCell::new(doc.clone()))), doc.base_heads()),
1614        None => (None, Vec::new()),
1615    }
1616}
1617
1618/// Whether an effect invoked as `kind` with `declaration` is admitted, and
1619/// whether it is a job.
1620///
1621/// # Errors
1622/// A view only changes what its caller sees and is admitted as one; an
1623/// effect invoked as a view would skip the checks an effect gets.
1624fn admitted(
1625    declaration: Option<&tilo_resource::effect::EffectDecl>,
1626    id: &str,
1627    kind: tilo_actions::OperationKind,
1628) -> Result<bool, String> {
1629    if declaration.map(|decl| decl.kind) != Some(kind) {
1630        return Err(format!("`{id}` was invoked as another kind of operation"));
1631    }
1632    Ok(declaration.is_some_and(tilo_resource::effect::EffectDecl::is_job))
1633}
1634
1635/// What an effect's handler answered.
1636struct Answered<'a> {
1637    id: &'a str,
1638    invocation: InvocationId,
1639    /// Whether the effect is declared a job.
1640    job: bool,
1641    answer: EffectAnswer,
1642    /// The job the handler started, if any.
1643    started: Option<Job>,
1644}
1645
1646/// Start what an effect's handler answered: its output at once, or the job
1647/// it started in `jobs`. `reply` tracks a job for an output a job's caller
1648/// learns as the job's; `land` moves the participant where the effect was
1649/// told to act once it succeeded.
1650///
1651/// # Errors
1652/// The handler's failure, or why its answer breaks the effect's
1653/// declaration.
1654fn settle_start(
1655    jobs: &mut Running,
1656    answered: Answered<'_>,
1657    reply: &mut dyn FnMut() -> Progress,
1658    land: &mut dyn FnMut(tilo_actions::ParticipantId),
1659) -> Result<ResourceOperationStart, String> {
1660    let Answered {
1661        id,
1662        invocation,
1663        job,
1664        answer,
1665        started,
1666    } = answered;
1667    let answer = match answer {
1668        Ok(answer) => answer,
1669        Err(error) => {
1670            if let Some(started) = started {
1671                started.stop();
1672            }
1673            return Err(error);
1674        }
1675    };
1676    // A participant told where to act is there now.
1677    if let Some(participant) = ui_core::invocation_scope::current_participant() {
1678        land(participant);
1679    }
1680    match (answer, started, job) {
1681        (Effected::Done(output), started, false) => {
1682            if let Some(started) = started {
1683                started.stop();
1684            }
1685            Ok(ResourceOperationStart::Completed(output))
1686        }
1687        // A job's caller learns its result as the job's, even when it
1688        // was ready at once.
1689        (Effected::Done(output), started, true) => {
1690            if let Some(started) = started {
1691                started.stop();
1692            }
1693            let reply = EffectReply {
1694                progress: reply(),
1695                completion: false,
1696            };
1697            reply.finish(Ok(output));
1698            Ok(ResourceOperationStart::Accepted)
1699        }
1700        (Effected::Pending, Some(started), true) => {
1701            jobs.insert(invocation, started);
1702            Ok(ResourceOperationStart::Accepted)
1703        }
1704        (Effected::Pending, Some(started), false) => {
1705            started.stop();
1706            Err(format!(
1707                "`{id}` is declared to answer at once; declare it `#[effect(job)]` to answer later"
1708            ))
1709        }
1710        (Effected::Pending, None, _) => Err(format!("`{id}` answered later but started no job")),
1711    }
1712}
1713
1714/// The next update of the job in `jobs` answering `invocation`, its
1715/// completion run by `complete`; `None` when it runs no such job or has
1716/// nothing new.
1717fn poll_invocation(
1718    jobs: &mut Running,
1719    invocation: InvocationId,
1720    cancel: bool,
1721    complete: &mut Complete<'_>,
1722) -> Option<InvocationUpdate> {
1723    if !jobs.contains(&invocation) {
1724        return None;
1725    }
1726    if cancel && jobs.cancel(&invocation) {
1727        return Some(InvocationUpdate::Cancelled {
1728            invocation,
1729            message: None,
1730        });
1731    }
1732    match jobs.poll(&invocation, complete, Some(invocation))? {
1733        Polled::Progress {
1734            sequence,
1735            phase,
1736            completed,
1737            total,
1738        } => Some(InvocationUpdate::Progress {
1739            invocation,
1740            sequence,
1741            phase,
1742            completed,
1743            total,
1744            message: None,
1745        }),
1746        Polled::Finished(None) => None,
1747        Polled::Finished(Some(Ok(output))) => {
1748            Some(InvocationUpdate::Succeeded { invocation, output })
1749        }
1750        Polled::Finished(Some(Err(error))) => Some(failed(invocation, error)),
1751    }
1752}
1753
1754/// One registered effect vocabulary: what the root publishes for it.
1755struct Published {
1756    alive: Weak<()>,
1757    operation: &'static str,
1758    specs: Box<dyn Fn() -> Vec<ActionSpec>>,
1759}
1760
1761/// A view's effect handler, type-erased for the root.
1762trait ViewHandler {
1763    fn start(
1764        &mut self,
1765        op: &ResourceOperation<'_>,
1766    ) -> Result<Option<ResourceOperationStart>, String>;
1767    fn poll(&mut self, invocation: InvocationId, cancel: bool) -> Option<InvocationUpdate>;
1768}
1769
1770type Handle<R, E> = Box<dyn FnMut(E, &mut EffectCx<R>) -> EffectAnswer>;
1771
1772struct Handler<R: Resource, E> {
1773    runner: EffectRunner<R, (), E>,
1774    handle: Handle<R, E>,
1775}
1776
1777impl<R: Resource, E: Effect> ViewHandler for Handler<R, E> {
1778    fn start(
1779        &mut self,
1780        op: &ResourceOperation<'_>,
1781    ) -> Result<Option<ResourceOperationStart>, String> {
1782        let handle = &mut self.handle;
1783        self.runner
1784            .start(op, &mut (), None, |(), effect, cx| handle(effect, cx))
1785    }
1786
1787    fn poll(&mut self, invocation: InvocationId, cancel: bool) -> Option<InvocationUpdate> {
1788        self.runner.poll(invocation, cancel, &mut ())
1789    }
1790}
1791
1792/// A registered projection drain ([`ViewCx::drain`](super::ViewCx::drain)),
1793/// as the view or surface that registered it holds it.
1794///
1795/// A drain looks at the document after every change. When what it compares
1796/// the document against lives outside — the configuration a service reports
1797/// back — tell it that changed with [`Self::outside_changed`], and on the
1798/// next frame it carries out again every entry it still lists.
1799#[derive(Clone, Default)]
1800pub struct Projection {
1801    /// `None` where drains never run: History, a capture.
1802    outside_changed: Option<Rc<Cell<bool>>>,
1803}
1804
1805impl Projection {
1806    /// The outside world the drain compares the document against changed:
1807    /// carry out what is still pending on the next frame, even what was
1808    /// carried out at this revision already.
1809    pub fn outside_changed(&self) {
1810        if let Some(flag) = &self.outside_changed {
1811            flag.set(true);
1812        }
1813    }
1814}
1815
1816impl std::fmt::Debug for Projection {
1817    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1818        f.debug_struct("Projection")
1819            .field("runs", &self.outside_changed.is_some())
1820            .finish()
1821    }
1822}
1823
1824/// A projection drain, type-erased for the root.
1825trait Drainer {
1826    /// Start what is pending in `state` (the resource's state) and not
1827    /// running or just settled.
1828    fn run(&mut self, state: &dyn Any, revision: u64);
1829    /// Settle finished jobs at `revision`; whether any did.
1830    fn poll(&mut self, revision: u64) -> bool;
1831    /// Forget which entries finished, so those still pending run again.
1832    fn forget_settled(&mut self);
1833}
1834
1835/// The pending entries a resource state (as `&dyn Any`) holds that this
1836/// device owns, each boxed; `None` for the state of another resource.
1837type OwnedEntries = Box<dyn Fn(&dyn Any) -> Option<Vec<(Id, Box<dyn Any>)>>>;
1838/// Run one entry (boxed) as an invocation under a custody: what it answered
1839/// and the job it started.
1840type StartEntry = Box<
1841    dyn FnMut(
1842        &Id,
1843        Box<dyn Any>,
1844        Option<InvocationId>,
1845        Option<&ResourceInvocationScope>,
1846    ) -> Result<(EffectAnswer, Option<Job>), String>,
1847>;
1848/// How an entry's handler answer settles it: the settle edit and what to
1849/// tell an invocation waiting on it, `None` when the outbox offers no settle
1850/// edit, or why the entry stays pending.
1851type SettleEntry = Box<
1852    dyn Fn(
1853        &Id,
1854        Result<ActionOutput, String>,
1855    ) -> Result<Option<(ApplySettlement, Result<ActionOutput, ActionFailure>)>, String>,
1856>;
1857
1858/// The drain of one [`Outbox`](tilo_resource::Outbox) on the device that
1859/// owns its entries ([`RecordEnv::outbox`](super::RecordEnv::outbox)). Only
1860/// reading, running and settling an entry know the resource and the entry's
1861/// types ([`Effects::outbox`] builds them); the bookkeeping is compiled once.
1862struct OutboxDrain {
1863    kind: &'static str,
1864    owned: OwnedEntries,
1865    start: StartEntry,
1866    settle: SettleEntry,
1867    /// Runs a job's completion, typed by the resource.
1868    complete: Box<Complete<'static>>,
1869    /// What an entry abandoned by its invocation settles with.
1870    abandoned: fn() -> Option<serde_json::Value>,
1871    jobs: Running<Id>,
1872    /// Entries whose run failed for now, and the revision it failed at: run
1873    /// again once the document moved on or the outside changed.
1874    retry: HashMap<Id, u64>,
1875    /// Entries this drain settled, never run again here while their
1876    /// settlement is on its way into the state.
1877    settled: std::collections::HashSet<Id>,
1878    /// Entries running for the local invocation whose action queued them,
1879    /// which completes once they settled.
1880    caused: HashMap<Id, (InvocationId, causal::Group)>,
1881    /// The revision the drain last looked at.
1882    revision: u64,
1883    /// Settlements to write in the next settle pass, shared with the root.
1884    settling: Settling,
1885}
1886
1887/// One outbox entry's settlement, written in the settle pass of the frame
1888/// that learned it: the settle edit, and the invocation that waits for it
1889/// with what to tell it once the edit is in the document.
1890struct Settlement {
1891    apply: ApplySettlement,
1892    waiting: Option<(causal::Group, Result<ActionOutput, ActionFailure>)>,
1893}
1894
1895type Settling = Rc<RefCell<Vec<Settlement>>>;
1896type ApplySettlement =
1897    Box<dyn FnOnce(&mut ui_core::widget::Edits<'_>) -> Result<(), tilo_actions::ActionError>>;
1898
1899/// Write the settlements in `settling`, then tell the invocations waiting on
1900/// them how they settled.
1901fn apply_settlements(settling: &Settling, edits: &mut ui_core::widget::Edits<'_>) {
1902    let settling = std::mem::take(&mut *settling.borrow_mut());
1903    for Settlement { apply, waiting } in settling {
1904        let applied = apply(edits);
1905        if let Some((group, told)) = waiting {
1906            group.borrow_mut().finished(match applied {
1907                Ok(()) => told,
1908                Err(error) => Err(failure(error.to_string())),
1909            });
1910        } else if let Err(error) = applied {
1911            tracing::warn!(%error, "an outbox settlement was refused");
1912        }
1913    }
1914}
1915
1916/// How a handler's answer settles its entry: a [`Settled`] it answered
1917/// ([`Effected::settled`]), the result `O` it answered, or nothing to report
1918/// (`O`'s default).
1919fn settlement<O>(output: &ActionOutput) -> Result<Settled<O>, String>
1920where
1921    O: serde::de::DeserializeOwned + Default,
1922{
1923    let Some(value) = &output.value else {
1924        return Ok(Settled::Done(O::default()));
1925    };
1926    serde_json::from_value::<Settled<O>>(value.clone())
1927        .or_else(|_| serde_json::from_value::<O>(value.clone()).map(Settled::Done))
1928        .map_err(|error| {
1929            format!("an outbox handler answered what its entry does not settle with: {error}")
1930        })
1931}
1932
1933/// What an outbox entry abandoned by whoever it was for settles with.
1934fn abandoned<O: serde::Serialize + Default>() -> Option<serde_json::Value> {
1935    serde_json::to_value(Settled::<O>::failed("cancelled")).ok()
1936}
1937
1938impl OutboxDrain {
1939    /// Record how the entry `id` settled, or leave it pending to run again;
1940    /// an invocation waiting on it learns which.
1941    fn finish(&mut self, id: &Id, answer: Result<ActionOutput, String>) -> bool {
1942        let group = self.caused.remove(id).map(|(_, group)| group);
1943        match (self.settle)(id, answer) {
1944            Ok(Some((apply, told))) => {
1945                self.settled.insert(id.clone());
1946                // The settlement is in the document before whoever waits on
1947                // the entry hears it settled: their next request sees it.
1948                self.settling.borrow_mut().push(Settlement {
1949                    apply,
1950                    waiting: group.map(|group| (group, told)),
1951                });
1952                true
1953            }
1954            Ok(None) => {
1955                self.settled.insert(id.clone());
1956                false
1957            }
1958            Err(error) => {
1959                tracing::warn!(%error, kind = self.kind, "an outbox entry stays pending");
1960                if let Some(group) = group {
1961                    group.borrow_mut().finished(Err(failure(error)));
1962                }
1963                self.retry.insert(id.clone(), self.revision);
1964                false
1965            }
1966        }
1967    }
1968
1969    /// The pending entries of `state` this device owns.
1970    fn owned(&self, state: &dyn Any) -> Option<Vec<(Id, Box<dyn Any>)>> {
1971        (self.owned)(state)
1972    }
1973
1974    /// Run `value` (the entry `id`) as `invocation`, under its custody when
1975    /// it has one. Whether it finished at once.
1976    fn start_entry(
1977        &mut self,
1978        id: Id,
1979        value: Box<dyn Any>,
1980        invocation: Option<InvocationId>,
1981        custody: Option<&ResourceInvocationScope>,
1982    ) -> Result<(), String> {
1983        let (answer, started) = (self.start)(&id, value, invocation, custody)?;
1984        match (answer, started) {
1985            (Ok(Effected::Pending), Some(started)) => self.jobs.insert(id, started),
1986            (answer, started) => {
1987                if let Some(started) = started {
1988                    started.stop();
1989                }
1990                let answer = answer.and_then(|effected| match effected {
1991                    Effected::Done(output) => Ok(output),
1992                    Effected::Pending => {
1993                        Err("an outbox handler answered later without starting a job".into())
1994                    }
1995                });
1996                self.finish(&id, answer);
1997            }
1998        }
1999        Ok(())
2000    }
2001}
2002
2003impl Drainer for OutboxDrain {
2004    fn run(&mut self, state: &dyn Any, revision: u64) {
2005        let Some(pending) = self.owned(state) else {
2006            return;
2007        };
2008        self.revision = revision;
2009        self.settled
2010            .retain(|id| pending.iter().any(|(entry, _)| entry == id));
2011        self.retry
2012            .retain(|id, _| pending.iter().any(|(entry, _)| entry == id));
2013        for (id, value) in pending {
2014            if self.jobs.contains(&id)
2015                || self.settled.contains(&id)
2016                || self.retry.get(&id) == Some(&revision)
2017            {
2018                continue;
2019            }
2020            if let Err(error) = self.start_entry(id, value, None, None) {
2021                tracing::warn!(%error, kind = self.kind, "an outbox entry did not start");
2022            }
2023        }
2024    }
2025
2026    fn poll(&mut self, revision: u64) -> bool {
2027        self.revision = revision;
2028        let mut finished = false;
2029        for id in self.jobs.keys() {
2030            let invocation = self.caused.get(&id).map(|(invocation, _)| *invocation);
2031            while let Some(polled) = self.jobs.poll(&id, &mut *self.complete, invocation) {
2032                let Polled::Finished(result) = polled else {
2033                    continue;
2034                };
2035                if let Some(result) = result {
2036                    self.finish(&id, result.map_err(|error| error.message));
2037                    finished = true;
2038                }
2039                break;
2040            }
2041        }
2042        finished
2043    }
2044
2045    fn forget_settled(&mut self) {
2046        self.retry.clear();
2047    }
2048}
2049
2050/// A drain shared by the drains that run what is pending and, for
2051/// [`RecordEnv::outbox_awaited`](super::RecordEnv::outbox_awaited), by those that
2052/// run what an invocation queued.
2053impl<D: Drainer> Drainer for Rc<RefCell<D>> {
2054    fn run(&mut self, state: &dyn Any, revision: u64) {
2055        self.borrow_mut().run(state, revision);
2056    }
2057
2058    fn poll(&mut self, revision: u64) -> bool {
2059        self.borrow_mut().poll(revision)
2060    }
2061
2062    fn forget_settled(&mut self) {
2063        self.borrow_mut().forget_settled();
2064    }
2065}
2066
2067struct Registered {
2068    alive: Weak<()>,
2069    operation: &'static str,
2070    handler: Box<dyn ViewHandler>,
2071}
2072
2073struct Drains {
2074    alive: Weak<()>,
2075    drain: Box<dyn Drainer>,
2076}
2077
2078#[derive(Default)]
2079struct Inner {
2080    causal: causal::CausalEffects,
2081    published: Vec<Published>,
2082    handlers: Vec<Registered>,
2083    drains: Vec<Drains>,
2084    /// The revision drains last ran at.
2085    drained: Option<u64>,
2086    /// Set when the outside world a drain compares against changed, so the
2087    /// drains look again although the document did not move.
2088    outside_changed: Rc<Cell<bool>>,
2089    /// Outbox settlements waiting for the settle pass.
2090    settling: Settling,
2091}
2092
2093/// Every effect handler and projection drain of one live resource root, and
2094/// what they publish. Clones share it.
2095pub(crate) struct Effects<R: Resource> {
2096    env: Option<EffectEnv<R>>,
2097    inner: Rc<RefCell<Inner>>,
2098    /// The jobs the views start on their own, polled with the drains.
2099    jobs: Rc<RefCell<OwnedJobs<R, ()>>>,
2100}
2101
2102impl<R: Resource> Clone for Effects<R> {
2103    fn clone(&self) -> Self {
2104        Self {
2105            env: self.env.clone(),
2106            inner: Rc::clone(&self.inner),
2107            jobs: Rc::clone(&self.jobs),
2108        }
2109    }
2110}
2111
2112impl<R: Resource> Effects<R> {
2113    /// Effects of the live app, which run.
2114    pub(crate) fn live(dispatch: Dispatcher<R>, live: Live<R>) -> Self {
2115        let env = EffectEnv {
2116            dispatch,
2117            live,
2118            wake: WakeHandle::new(),
2119        };
2120        Self {
2121            jobs: Rc::new(RefCell::new(OwnedJobs::new(Jobs::new(Some(env.clone()))))),
2122            env: Some(env),
2123            inner: Rc::default(),
2124        }
2125    }
2126
2127    /// Effects of a past version, which never run: History shows what the
2128    /// document was, it does not do again what was done.
2129    pub(crate) fn inert() -> Self {
2130        Self {
2131            env: None,
2132            inner: Rc::default(),
2133            jobs: Rc::new(RefCell::new(OwnedJobs::new(Jobs::new(None)))),
2134        }
2135    }
2136
2137    pub(crate) fn env(&self) -> Option<&EffectEnv<R>> {
2138        self.env.as_ref()
2139    }
2140
2141    /// The jobs the views start on their own.
2142    #[inline(never)]
2143    pub(crate) fn view_jobs(&self) -> Jobs<R> {
2144        self.jobs.borrow().jobs.clone()
2145    }
2146
2147    /// Jobs for one surface, whose completions get `&mut` the surface.
2148    #[inline(never)]
2149    pub(crate) fn surface_jobs(&self) -> Jobs<R> {
2150        Jobs::new(self.env.clone())
2151    }
2152
2153    /// Take up and finish the views' jobs.
2154    #[inline(never)]
2155    pub(crate) fn poll_jobs(&self) {
2156        self.jobs.borrow_mut().poll(&mut ());
2157        self.inner.borrow_mut().causal.poll();
2158    }
2159
2160    /// Publish vocabulary `E` while `alive` lives, as `specs` describes it
2161    /// at each publication.
2162    pub(crate) fn publish<E: Effect>(
2163        &self,
2164        alive: Weak<()>,
2165        specs: impl Fn() -> Vec<ActionSpec> + 'static,
2166    ) {
2167        self.inner.borrow_mut().published.push(Published {
2168            alive,
2169            operation: E::OPERATION,
2170            specs: Box::new(specs),
2171        });
2172    }
2173
2174    pub(crate) fn handle<E: Effect>(
2175        &self,
2176        alive: Weak<()>,
2177        handle: impl FnMut(E, &mut EffectCx<R>) -> EffectAnswer + 'static,
2178    ) {
2179        let Some(env) = self.env.clone() else {
2180            return;
2181        };
2182        self.inner.borrow_mut().handlers.push(Registered {
2183            alive,
2184            operation: E::OPERATION,
2185            handler: Box::new(Handler {
2186                runner: EffectRunner::<R, (), E>::new(env),
2187                handle: Box::new(handle),
2188            }),
2189        });
2190    }
2191
2192    pub(crate) fn outbox<T, O>(
2193        &self,
2194        alive: Weak<()>,
2195        outbox: Path<R, Outbox<T, O>>,
2196        owns: impl Fn(&R::State, &T) -> bool + 'static,
2197        run: impl FnMut(&Id, T, &mut EffectCx<R>) -> EffectAnswer + 'static,
2198        awaited: bool,
2199    ) -> Projection
2200    where
2201        R: Resource<State = R> + tilo_resource::Model + 'static,
2202        T: tilo_resource::Model + Clone + 'static,
2203        O: serde::Serialize + serde::de::DeserializeOwned + Default + 'static,
2204    {
2205        let Some(env) = self.env.clone() else {
2206            return Projection::default();
2207        };
2208        let reader = outbox.clone();
2209        let owned: OwnedEntries = Box::new(move |state: &dyn Any| {
2210            let state = state.downcast_ref::<R>()?;
2211            Some(reader.get(state).map_or_else(Vec::new, |outbox| {
2212                outbox
2213                    .pending()
2214                    .filter(|(_, value)| owns(state, value))
2215                    .map(|(id, value)| (id.clone(), Box::new(value.clone()) as Box<dyn Any>))
2216                    .collect()
2217            }))
2218        });
2219        let mut run = run;
2220        let starting = env.clone();
2221        let start: StartEntry = Box::new(move |id, value, invocation, custody| {
2222            let Ok(value) = value.downcast::<T>() else {
2223                return Err("an outbox entry is of another type".into());
2224            };
2225            let mut cx =
2226                EffectCx::new(starting.clone(), invocation).with_custody(owned_scope(custody))?;
2227            let mut value = Some(*value);
2228            let answer = run_under(invocation, custody, &mut || match value.take() {
2229                Some(value) => run(id, value, &mut cx),
2230                None => Err("an outbox entry ran twice".into()),
2231            });
2232            Ok((answer, cx.started.take()))
2233        });
2234        let dispatch = env.dispatch.clone();
2235        let settle: SettleEntry = Box::new(move |id, answer| {
2236            let settled = answer.and_then(|output| settlement::<O>(&output))?;
2237            let told = match &settled {
2238                Settled::Done(_) => Ok(ActionOutput::default()),
2239                Settled::Failed { reason, .. } => Err(failure(reason.clone())),
2240            };
2241            let Some(action) = R::edit_action(outbox.settle(id, &settled)) else {
2242                return Ok(None);
2243            };
2244            let dispatch = dispatch.clone();
2245            let apply: ApplySettlement = Box::new(move |edits| dispatch.apply_now(edits, &action));
2246            Ok(Some((apply, told)))
2247        });
2248        let completing = env;
2249        let complete: Box<Complete<'static>> = Box::new(move |then, value, custody, invocation| {
2250            complete::<R, ()>(&completing, &mut (), then, value, custody, invocation)
2251        });
2252        let drain = Rc::new(RefCell::new(OutboxDrain {
2253            kind: R::KIND,
2254            owned,
2255            start,
2256            settle,
2257            complete,
2258            abandoned: abandoned::<O>,
2259            jobs: Running::default(),
2260            retry: HashMap::new(),
2261            settled: std::collections::HashSet::new(),
2262            caused: HashMap::new(),
2263            revision: 0,
2264            settling: Rc::clone(&self.inner.borrow().settling),
2265        }));
2266        let mut inner = self.inner.borrow_mut();
2267        if awaited {
2268            inner
2269                .causal
2270                .register_drainer(alive.clone(), Box::new(Rc::clone(&drain)));
2271        }
2272        inner.drains.push(Drains {
2273            alive,
2274            drain: Box::new(drain),
2275        });
2276        inner.drained = None;
2277        Projection {
2278            outside_changed: Some(Rc::clone(&inner.outside_changed)),
2279        }
2280    }
2281
2282    /// The operation types of every published vocabulary.
2283    pub(crate) fn operations(&self) -> Vec<&'static str> {
2284        self.inner.borrow_mut().operations()
2285    }
2286
2287    /// Every published effect, as callers see it now.
2288    #[inline(never)]
2289    pub(crate) fn specs(&self) -> Vec<ActionSpec> {
2290        self.inner.borrow_mut().specs()
2291    }
2292
2293    /// Answer `op` with the view handler of its vocabulary.
2294    pub(crate) fn start(
2295        &self,
2296        op: &ResourceOperation<'_>,
2297    ) -> Result<Option<ResourceOperationStart>, String> {
2298        self.inner.borrow_mut().start(op)
2299    }
2300
2301    pub(crate) fn poll(&self, invocation: InvocationId, cancel: bool) -> Option<InvocationUpdate> {
2302        self.inner.borrow_mut().poll(invocation, cancel)
2303    }
2304
2305    pub(crate) fn has_causal_drains(&self) -> bool {
2306        self.inner.borrow().causal.registered()
2307    }
2308
2309    pub(crate) fn start_document_effects(
2310        &self,
2311        operation: &ResourceOperation<'_>,
2312        applied: &ui_core::widget::document_effects::AppliedDocument<'_>,
2313    ) -> Result<Option<ResourceOperationStart>, String> {
2314        self.inner
2315            .borrow_mut()
2316            .causal
2317            .start(operation, applied, read_state::<R>)
2318    }
2319
2320    /// Write the outbox settlements learned since the last settle pass, then
2321    /// tell the invocations waiting on them how they settled.
2322    #[inline(never)]
2323    pub(crate) fn apply_settlements(&self, edits: &mut ui_core::widget::Edits<'_>) {
2324        let settling = Rc::clone(&self.inner.borrow().settling);
2325        apply_settlements(&settling, edits);
2326    }
2327
2328    /// Settle finished projection jobs, then start what the document at
2329    /// `revision` lists and is not running yet.
2330    #[inline(never)]
2331    pub(crate) fn drain_projections(&self, state: &R::State, revision: u64) {
2332        self.inner.borrow_mut().drain_projections(state, revision);
2333    }
2334}
2335
2336/// The resource state `doc` holds, read as `R`'s.
2337fn read_state<R: Resource>(doc: &tilo_resource::Doc) -> Result<Box<dyn Any>, String> {
2338    R::read(doc)
2339        .map(|state| Box::new(state) as Box<dyn Any>)
2340        .map_err(|error| error.to_string())
2341}
2342
2343impl Inner {
2344    fn prune(&mut self) {
2345        self.published
2346            .retain(|entry| entry.alive.strong_count() > 0);
2347        self.handlers.retain(|entry| entry.alive.strong_count() > 0);
2348        self.drains.retain(|entry| entry.alive.strong_count() > 0);
2349    }
2350
2351    fn operations(&mut self) -> Vec<&'static str> {
2352        self.prune();
2353        self.published.iter().map(|entry| entry.operation).collect()
2354    }
2355
2356    fn specs(&mut self) -> Vec<ActionSpec> {
2357        self.prune();
2358        self.published
2359            .iter()
2360            .flat_map(|entry| (entry.specs)())
2361            .collect()
2362    }
2363
2364    fn start(
2365        &mut self,
2366        op: &ResourceOperation<'_>,
2367    ) -> Result<Option<ResourceOperationStart>, String> {
2368        self.prune();
2369        for entry in &mut self.handlers {
2370            if op.with_custody().0.type_id == entry.operation
2371                && let Some(started) = entry.handler.start(op)?
2372            {
2373                return Ok(Some(started));
2374            }
2375        }
2376        Ok(None)
2377    }
2378
2379    fn poll(&mut self, invocation: InvocationId, cancel: bool) -> Option<InvocationUpdate> {
2380        if let Some(update) = self.causal.poll_invocation(invocation, cancel) {
2381            return Some(update);
2382        }
2383        self.handlers
2384            .iter_mut()
2385            .find_map(|entry| entry.handler.poll(invocation, cancel))
2386    }
2387
2388    fn drain_projections(&mut self, state: &dyn Any, revision: u64) {
2389        if self.drains.is_empty() {
2390            return;
2391        }
2392        self.prune();
2393        let mut settled = false;
2394        for entry in &mut self.drains {
2395            settled |= entry.drain.poll(revision);
2396        }
2397        let outside_changed = self.outside_changed.replace(false);
2398        if !settled && !outside_changed && self.drained == Some(revision) {
2399            return;
2400        }
2401        self.drained = Some(revision);
2402        for entry in &mut self.drains {
2403            if outside_changed {
2404                entry.drain.forget_settled();
2405            }
2406            entry.drain.run(state, revision);
2407        }
2408    }
2409}
2410
2411#[cfg(test)]
2412mod tests {
2413    use super::*;
2414    use tilo_resource::{Model, Reader, resource};
2415
2416    #[derive(Model, Default, Debug, Clone)]
2417    pub struct Probe {
2418        pub count: i64,
2419    }
2420
2421    #[resource(kind = "test.probe", label = "Probe")]
2422    impl Probe {
2423        /// Set the count.
2424        #[action]
2425        pub fn set(&mut self, count: i64) {
2426            self.count = count;
2427        }
2428    }
2429
2430    #[derive(Clone, Debug, tilo_resource::Effect)]
2431    #[effect(operation = "test.probe.effects")]
2432    enum Work {
2433        /// Count until cancelled.
2434        #[effect(cancellable)]
2435        Count,
2436        /// Answer at once.
2437        Ping,
2438    }
2439
2440    fn runner() -> EffectRunner<Probe, (), Work> {
2441        runner_on(Dispatcher::new("test", Probe::KIND, false, None))
2442    }
2443
2444    fn runner_on(dispatch: Dispatcher<Probe>) -> EffectRunner<Probe, (), Work> {
2445        let mut doc = tilo_resource::Doc::new();
2446        Probe::initialize(&mut doc).unwrap();
2447        EffectRunner::new(EffectEnv {
2448            dispatch,
2449            live: Live::new(Reader::<Probe>::read(&doc).unwrap()),
2450            wake: WakeHandle::detached(),
2451        })
2452    }
2453
2454    #[test]
2455    fn what_an_effect_submits_continues_its_invocation() {
2456        let dispatch = Dispatcher::new("test", Probe::KIND, false, None);
2457        let mut runner = runner_on(dispatch.clone());
2458        let invocation = InvocationId::new();
2459        let payload = payload(&Work::Count);
2460        let op = ResourceOperation::local(
2461            invocation,
2462            tilo_actions::OperationKind::Effect,
2463            Work::OPERATION,
2464            &payload,
2465        );
2466        runner
2467            .start(&op, &mut (), None, |(), _, cx| {
2468                cx.dispatcher().effect(&Work::Ping);
2469                Ok(cx.thread(
2470                    |_| (),
2471                    |(), (), cx| {
2472                        cx.dispatcher().effect(&Work::Ping);
2473                        Ok(Effected::done())
2474                    },
2475                ))
2476            })
2477            .unwrap();
2478        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
2479        while !runner
2480            .poll(invocation, false, &mut ())
2481            .is_some_and(|update| update.is_terminal())
2482        {
2483            assert!(std::time::Instant::now() < deadline, "the job finishes");
2484            std::thread::yield_now();
2485        }
2486        dispatch.effect(&Work::Ping);
2487        let continues = std::iter::from_fn(|| dispatch.core().take_attributed())
2488            .map(|(_, on_behalf_of)| on_behalf_of)
2489            .collect::<Vec<_>>();
2490        assert_eq!(
2491            continues,
2492            [Some(invocation), Some(invocation), None],
2493            "the handler's and the completion's continue the invocation; \
2494             the app's own continues nothing"
2495        );
2496    }
2497
2498    fn payload(effect: &Work) -> Vec<u8> {
2499        Work::specs()
2500            .into_iter()
2501            .find(|spec| spec.id == effect.id())
2502            .unwrap()
2503            .build_json(&effect.arguments())
2504            .unwrap()
2505            .payload
2506    }
2507
2508    #[test]
2509    fn approved_check_reports_use_original_effect_progress_and_freeze_after_cancel() {
2510        let mut runner = runner();
2511        let invocation = InvocationId::new();
2512        let payload = payload(&Work::Count);
2513        let op = ResourceOperation::local(
2514            invocation,
2515            tilo_actions::OperationKind::Effect,
2516            Work::OPERATION,
2517            &payload,
2518        );
2519        let (forward, reports) = channel();
2520        let checked: ui_core::invocation_scope::ApprovedCheckReportedWork =
2521            Box::new(move |report| {
2522                forward.send(report).unwrap();
2523                Box::pin(std::future::pending())
2524            });
2525        let started = runner
2526            .start(&op, &mut (), None, |(), _, cx| {
2527                Ok(cx.spawn(
2528                    move |progress| attach_approved_check_reporter(checked, progress),
2529                    |(), _receipt, _| Ok(Effected::Done(ActionOutput::default())),
2530                ))
2531            })
2532            .unwrap();
2533        assert!(matches!(started, Some(ResourceOperationStart::Accepted)));
2534        let report = reports
2535            .recv_timeout(std::time::Duration::from_secs(5))
2536            .unwrap();
2537        for (sequence, phase) in [(1, "Synthesis"), (2, "Routing")] {
2538            report(
2539                ui_core::invocation_scope::ApprovedCheckReportObservation::Reported {
2540                    phase: phase.into(),
2541                    completed: Some(sequence),
2542                    total: Some(2),
2543                },
2544            )
2545            .unwrap();
2546            assert!(matches!(runner.poll(invocation, false, &mut ()),
2547                Some(InvocationUpdate::Progress { invocation: actual, sequence: observed,
2548                    phase: actual_phase, completed: Some(done), total: Some(2), message: None })
2549                if actual == invocation && observed == sequence && actual_phase == phase && done == sequence));
2550            assert!(runner.poll(invocation, false, &mut ()).is_none());
2551        }
2552        assert!(matches!(runner.poll(invocation, true, &mut ()),
2553            Some(InvocationUpdate::Cancelled { invocation: actual, .. }) if actual == invocation));
2554        assert!(
2555            report(
2556                ui_core::invocation_scope::ApprovedCheckReportObservation::Reported {
2557                    phase: "Late".into(),
2558                    completed: None,
2559                    total: None,
2560                }
2561            )
2562            .is_err()
2563        );
2564        assert!(runner.poll(invocation, false, &mut ()).is_none());
2565        drop(report);
2566        drop(reports);
2567        drop(runner);
2568    }
2569
2570    #[test]
2571    fn cancelling_a_job_stops_its_worker_and_drops_its_result() {
2572        let mut runner = runner();
2573        let invocation = InvocationId::new();
2574        let payload = payload(&Work::Count);
2575        let op = ResourceOperation::local(
2576            invocation,
2577            tilo_actions::OperationKind::Effect,
2578            Work::OPERATION,
2579            &payload,
2580        );
2581        let (stopped, observed) = channel();
2582        let started = runner
2583            .start(&op, &mut (), None, |(), _, cx| {
2584                Ok(cx.thread(
2585                    move |progress| {
2586                        while !progress.is_cancelled() {
2587                            std::thread::yield_now();
2588                        }
2589                        let _ = stopped.send(());
2590                        7
2591                    },
2592                    |(), count: i32, _| Ok(Effected::value(count)),
2593                ))
2594            })
2595            .unwrap();
2596        assert!(matches!(started, Some(ResourceOperationStart::Accepted)));
2597        assert!(runner.poll(invocation, false, &mut ()).is_none());
2598        assert!(matches!(
2599            runner.poll(invocation, true, &mut ()),
2600            Some(InvocationUpdate::Cancelled { .. })
2601        ));
2602        // The worker stops, or never starts when cancelled first.
2603        assert!(!matches!(
2604            observed.recv_timeout(std::time::Duration::from_secs(30)),
2605            Err(std::sync::mpsc::RecvTimeoutError::Timeout)
2606        ));
2607        assert!(
2608            runner.poll(invocation, false, &mut ()).is_none(),
2609            "a cancelled job's result is dropped"
2610        );
2611    }
2612
2613    #[test]
2614    fn a_failed_handler_cancels_the_job_it_already_started() {
2615        let mut runner = runner();
2616        let invocation = InvocationId::new();
2617        let payload = payload(&Work::Count);
2618        let op = ResourceOperation::local(
2619            invocation,
2620            tilo_actions::OperationKind::Effect,
2621            Work::OPERATION,
2622            &payload,
2623        );
2624        let mut progress = None;
2625        let result = runner.start(&op, &mut (), None, |(), _, cx| {
2626            let reply = cx.reply();
2627            progress = Some(reply.progress().clone());
2628            Err("handler refused after starting work".into())
2629        });
2630        assert!(result.is_err());
2631        assert!(progress.unwrap().is_cancelled());
2632        assert!(runner.poll(invocation, false, &mut ()).is_none());
2633    }
2634
2635    #[test]
2636    fn dropping_the_handler_cancels_its_retained_jobs() {
2637        let mut runner = runner();
2638        let payload = payload(&Work::Count);
2639        let op = ResourceOperation::local(
2640            InvocationId::new(),
2641            tilo_actions::OperationKind::Effect,
2642            Work::OPERATION,
2643            &payload,
2644        );
2645        let mut progress = None;
2646        assert!(matches!(
2647            runner
2648                .start(&op, &mut (), None, |(), _, cx| {
2649                    let reply = cx.reply();
2650                    progress = Some(reply.progress().clone());
2651                    Ok(Effected::Pending)
2652                })
2653                .unwrap(),
2654            Some(ResourceOperationStart::Accepted)
2655        ));
2656        let progress = progress.unwrap();
2657        assert!(!progress.is_cancelled());
2658        drop(runner);
2659        assert!(progress.is_cancelled());
2660        let mut work_ran = false;
2661        assert!(
2662            progress
2663                .with_effect(|| {
2664                    work_ran = true;
2665                    Ok(())
2666                })
2667                .is_err()
2668        );
2669        assert!(!work_ran, "retired work must not enter its effect callback");
2670    }
2671
2672    /// A handler reads the document it answers about, now and at a past
2673    /// version, and the document is back with its owner once it answered.
2674    #[test]
2675    fn a_handler_reads_the_document_now_and_at_a_version() {
2676        use ui_core::automerge::{ReadDoc, transaction::Transactable};
2677        let mut runner = runner();
2678        let mut doc = tilo_resource::Doc::new();
2679        doc.put(ui_core::automerge::ROOT, "count", 1_i64).unwrap();
2680        let first = tilo_resource::history::commit_stamped(&mut doc).unwrap();
2681        doc.put(ui_core::automerge::ROOT, "count", 2_i64).unwrap();
2682        tilo_resource::history::commit_stamped(&mut doc);
2683        let payload = payload(&Work::Ping);
2684        let op = ResourceOperation::local(
2685            InvocationId::new(),
2686            tilo_actions::OperationKind::Effect,
2687            Work::OPERATION,
2688            &payload,
2689        );
2690        let count = |doc: &tilo_resource::Doc| {
2691            doc.get(ui_core::automerge::ROOT, "count")
2692                .unwrap()
2693                .and_then(|(value, _)| value.as_i64())
2694        };
2695        let before = (doc.base_heads(), doc.pending_ops());
2696        let answered = runner
2697            .start(&op, &mut (), Some(&doc), |(), _, cx| {
2698                let now = count(&cx.document().expect("lent to the handler"));
2699                let past = cx.version(&first.to_string())?;
2700                assert_eq!(past.heads, vec![first]);
2701                Ok(Effected::value([now, count(&past.doc)]))
2702            })
2703            .unwrap();
2704        assert_eq!((doc.base_heads(), doc.pending_ops()), before);
2705        assert!(matches!(
2706            answered,
2707            Some(ResourceOperationStart::Completed(output))
2708                if output.value == Some(serde_json::json!([2, 1]))
2709        ));
2710        assert_eq!(count(&doc), Some(2), "the document is back with its owner");
2711        let refused = runner
2712            .start(&op, &mut (), None, |(), _, cx| {
2713                cx.version(&first.to_string()).map(|_| Effected::done())
2714            })
2715            .unwrap_err();
2716        assert!(refused.contains("no document"), "{refused}");
2717    }
2718
2719    /// An effect waiting to show another record here: the opener it asked,
2720    /// and the requests it made.
2721    fn opener() -> (
2722        super::super::OpenHere,
2723        Rc<RefCell<Vec<ui_core::application::ReplacementOutcome>>>,
2724    ) {
2725        let asked = Rc::new(RefCell::new(Vec::new()));
2726        let requests = Rc::clone(&asked);
2727        let opener = super::super::OpenHere::new(move |_| {
2728            let outcome = ui_core::application::ReplacementOutcome::default();
2729            requests.borrow_mut().push(outcome.clone());
2730            outcome
2731        });
2732        (opener, asked)
2733    }
2734
2735    fn open_here_job(
2736        runner: &mut EffectRunner<Probe, (), Work>,
2737        opener: &super::super::OpenHere,
2738    ) -> InvocationId {
2739        let invocation = InvocationId::new();
2740        let payload = payload(&Work::Count);
2741        let op = ResourceOperation::local(
2742            invocation,
2743            tilo_actions::OperationKind::Effect,
2744            Work::OPERATION,
2745            &payload,
2746        );
2747        let opener = opener.clone();
2748        runner
2749            .start(&op, &mut (), None, move |(), _, cx| {
2750                Ok(cx.open_here(&opener, &["b.pdf".into()], |(), answer, _| {
2751                    answer.map(|()| Effected::value("shown"))
2752                }))
2753            })
2754            .unwrap();
2755        invocation
2756    }
2757
2758    #[test]
2759    fn opening_here_answers_with_the_hosts_answer_or_withdraws_when_cancelled() {
2760        let (opener, asked) = opener();
2761        let mut runner = runner();
2762
2763        let shown = open_here_job(&mut runner, &opener);
2764        assert!(
2765            runner.poll(shown, false, &mut ()).is_none(),
2766            "waits for the host"
2767        );
2768        asked.borrow()[0].answer(Ok(()));
2769        assert!(matches!(
2770            runner.poll(shown, false, &mut ()),
2771            Some(InvocationUpdate::Succeeded { output, .. })
2772                if output.value == Some(serde_json::json!("shown"))
2773        ));
2774
2775        let refused = open_here_job(&mut runner, &opener);
2776        asked.borrow()[1].answer(Err("the file is gone".into()));
2777        assert!(matches!(
2778            runner.poll(refused, false, &mut ()),
2779            Some(InvocationUpdate::Failed { error, .. }) if error.message == "the file is gone"
2780        ));
2781
2782        let cancelled = open_here_job(&mut runner, &opener);
2783        assert!(matches!(
2784            runner.poll(cancelled, true, &mut ()),
2785            Some(InvocationUpdate::Cancelled { .. })
2786        ));
2787        assert!(
2788            !asked.borrow()[2].withdraw(),
2789            "the cancelled effect withdrew its request before the host took it"
2790        );
2791    }
2792
2793    #[test]
2794    fn an_effect_declared_immediate_cannot_answer_later() {
2795        let mut runner = runner();
2796        let payload = payload(&Work::Ping);
2797        let op = ResourceOperation::local(
2798            InvocationId::new(),
2799            tilo_actions::OperationKind::Effect,
2800            Work::OPERATION,
2801            &payload,
2802        );
2803        let refused = runner
2804            .start(&op, &mut (), None, |(), _, cx| {
2805                let _reply = cx.reply();
2806                Ok(Effected::Pending)
2807            })
2808            .unwrap_err();
2809        assert!(refused.contains("#[effect(job)]"), "{refused}");
2810        let answered = runner
2811            .start(&op, &mut (), None, |(), _, _| Ok(Effected::value("pong")))
2812            .unwrap();
2813        assert!(matches!(
2814            answered,
2815            Some(ResourceOperationStart::Completed(output))
2816                if output.value == Some(serde_json::json!("pong"))
2817        ));
2818    }
2819}