shep 0.24.2

The shep binary: a process manager that keeps a flock of long-running processes alive on macOS, Linux and Windows, with logs, watch and cron restarts, and webhook alerts
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
//! What arrives at the reducer and what leaves it.

use std::sync::Arc;

use super::*;
use crate::commands::dogs::{Job, Prompt, Step};

/// Whether this lookout may act on a sheep.
///
/// Turned on by `--allow-control` or `lookout.allow_control` in the KV store.
/// A fat-finger catch, not a security boundary: anyone who can run lookout can
/// run `shep stop`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Control {
    /// Actions refuse. Asked for by `--read-only`, or by
    /// `lookout.allow_control` being `false`.
    ReadOnly,
    /// Actions are permitted, and the default: `x`, `R` and `L` arm a
    /// confirm, Enter sends it. The apply menu is the one door that sends on
    /// the press, since it names its keys on screen.
    Allowed,
}

/// Which keymap is in force.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
    /// The ordinary dashboard keys.
    Normal,
    /// The filter box is open and every printable key is text.
    Text,
    /// A dog's status tab is open: a plain letter other than the reserved
    /// ones is [`KeyPress::TextChar`], for the dog's action letters, and every
    /// other key means what it does in `Normal`.
    Pane,
}

/// The keys lookout binds, named by meaning rather than by keystroke.
///
/// `crate::lookout::input::map_key` builds these at the edge, so this module
/// never touches a terminal crate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyPress {
    /// `q` in normal mode, or `Ctrl-C` in either.
    Quit,
    /// `Esc` in normal mode: cancels an armed confirm, else clears the filter,
    /// else quits. The reducer decides; the keymap sees none of those states.
    Escape,
    /// `k` or `Up`: the selection moves up one row.
    SelectUp,
    /// `j` or `Down`: the selection moves down one row.
    SelectDown,
    /// `g` or `Home`: the first sheep in the flock.
    SelectFirst,
    /// `G` or `End`: the last one.
    SelectLast,
    /// `r`: poll now.
    Refresh,
    /// `x`, `R` or `L`: arms a confirm for the verb, or refuses and says why.
    Action(ActionVerb),
    /// `Enter` in normal mode. Sends an armed confirm; does nothing
    /// otherwise.
    Confirm,
    /// `/`: open the filter box, carrying whatever query is already set.
    FilterStart,
    /// One printable character typed into whichever text field is open.
    TextChar(char),
    /// `Backspace` in whichever text field is open.
    TextBackspace,
    /// A cursor move or deletion in whichever text field is open: the arrows,
    /// `Home`/`End`, `Delete`, the word moves and the kill keys. See
    /// [`TextEdit`] for which key is which.
    TextEdit(TextEdit),
    /// `Enter` in whichever text field is open: apply and leave.
    TextApply,
    /// `Esc` in whichever text field is open: abandon the edit and leave.
    TextAbandon,
    /// `s`: opens the settings screen, or closes it from inside.
    Settings,
    /// `i`: opens the dog catalogue. Ignored on every other screen.
    Catalogue,
    /// `S`: opens the secrets pane, or closes it from inside.
    ///
    /// Picked rather than found. The design named `g`, which is
    /// [`Self::SelectFirst`]; `s` is the settings screen and `S` is its
    /// neighbour, both change-screens over the shepherd's own
    /// configuration.
    Secrets,
    /// `v`: shows the selected secret's value for [`REVEAL_HOLDS`], in the
    /// secrets pane. Refuses when `[secrets] allow_read` is off, naming the
    /// gate. Ignored on every other screen.
    Reveal,
    /// `y`: sends the already-revealed value to the terminal's clipboard
    /// over OSC 52, in the secrets pane. A reveal by another route, so it
    /// takes [`Self::Reveal`]'s own `[secrets] allow_read` gate rather than
    /// a second one, and copies what [`Reveal`] already holds on screen
    /// rather than reading the store afresh. Ignored on every other screen.
    Copy,
    /// `Left`: the previous environment tab, in the secrets pane. Stops at
    /// the first rather than wrapping. Ignored elsewhere.
    TabPrev,
    /// `Right`: the next one, stopping at the last.
    TabNext,
    /// `space`: cycles the value under the settings screen's cursor. Nothing on
    /// the dashboard; refuses like an action key when the gate is closed.
    Cycle,
    /// `e`: open the config pane for the selected sheep on the dashboard.
    /// Inside the pane or its env sub-screen, edits the row under the
    /// cursor or sends an armed edit, the same job `Confirm` already does
    /// there: an operator should not have to remember two keys for one
    /// job. `Escape` is the only key that closes a pane or a sub-screen.
    Edit,
    /// `h` or `?`: opens the keymap overlay, from any body. Pressing either
    /// again, or `Escape`, closes it. Refused while a close dialog is up, and
    /// consumed as a cancel while an action is armed, like every other key.
    ///
    /// The config pane's field help draws unconditionally, so no key is
    /// needed for it: that is why `h` is free to mean this here.
    Help,
    /// `d`: arms the removal of the element under the cursor, on the config
    /// pane's list sub-screen. On a config field, restores the default by
    /// removing the operator's override so the default shows through. One
    /// verb, both places.
    Remove,
    /// `K`. What a step means is the body's to decide: a config pane
    /// reorders the list element under the cursor, the sheep pane steps to
    /// the previous sheep the flock table would show without leaving the
    /// pane, and every other body routes it to [`Effect::None`].
    StepUp,
    /// `J`, the twin of [`Self::StepUp`].
    StepDown,
    /// `F`: toggles the flock table between the flat list and grouping by
    /// fold.
    FoldView,
    /// `z`: hides the selected fold's members, leaving its header behind.
    /// Pressed again on the same fold, or anywhere while collapsed, it
    /// shows them again. Does nothing when the selection is not a
    /// [`RowKey::Fold`] header.
    Collapse,
    /// `b`: opens the full-screen bleats pane on the selected sheep. Does
    /// nothing with no sheep selected, the way `e` does.
    Bleats,
    /// `o`: cycles the bleats pane's stream axis, `None` (both) → `Out` →
    /// `Err` → `None`. A global binding, so the dashboard sees it too, and
    /// ignores it: there is no stream axis outside the pane.
    StreamCycle,
    /// `m`: cycles the bleats pane's minimum-level axis through every
    /// [`Level`] in ascending order, then back
    /// to `None`. Chosen over a design-named key because the status bar's own
    /// list names none for this axis; a global binding, ignored on the
    /// dashboard the same way [`Self::StreamCycle`] is.
    LevelCycle,
    /// `Ctrl-d`: scrolls the bleats pane forward (toward the newest line) by
    /// a body height. A global binding, ignored on the dashboard the same
    /// way [`Self::StreamCycle`] is.
    PageDown,
    /// `Ctrl-u`: scrolls the bleats pane backward (toward the oldest
    /// surviving line) by a body height, and stops it following the tail,
    /// the same way [`Self::SelectUp`] does. Ignored on the dashboard.
    PageUp,
    /// `f`: toggles whether the bleats pane follows the tail. Ignored on the
    /// dashboard.
    FollowToggle,
    /// `w`: toggles whether the bleats pane wraps long lines rather than
    /// truncating them. Ignored on the dashboard.
    WrapToggle,
    /// `n`: steps the bleats pane toward the newest line that matches the
    /// match axis. Does nothing with no match axis set, rather than
    /// quietly becoming a line-movement key. Ignored on the dashboard.
    MatchNext,
    /// `N`: the same, toward the oldest matching line. Ignored on the
    /// dashboard.
    MatchPrev,
    /// `D`: arms the removal of the key the cursor is on, in the secrets pane
    /// and on a sheep's env rows. `Enter` confirms and every other key cancels.
    ///
    /// Capital, and `d` is left alone: `d` is already
    /// [`Self::Remove`], and `map_key` dispatches on mode rather than
    /// pane, so the two cannot share a key. One key in both panes, because
    /// both panes delete the same kind of thing.
    ArmDelete,
    /// `Tab`: moves the config pane's focus to the next group.
    ///
    /// In a key/value editor it steps to the next field instead, the way
    /// [`Self::NextField`] below; `map_key` dispatches on mode, so the two
    /// never meet.
    NextGroup,
    /// `Shift+Tab`: moves the config pane's focus to the previous group.
    PrevGroup,
    /// `Tab` in a key/value editor: the cursor moves to the other field,
    /// which is where the KEY field's own grammar gets its turn. Read only
    /// by the env rows and the secrets pane.
    NextField,
    /// `Shift+Tab` in a key/value editor: the same step back, which is how
    /// the key a row is under is reached from its value.
    PrevField,
    /// `1` through `9`: jumps the config pane's focus straight to that
    /// group, numbered in the order the pane lists them (`GROUP_ORDER`,
    /// nine of them). A digit past the last group is ignored.
    Group(u8),
    /// `u`: undoes the config pane's last change.
    Undo,
    /// `c` in the close dialog: write the set and leave the sheep running.
    /// Bound nowhere else, the way [`Self::Undo`] is read only by the
    /// config pane.
    Continue,
}

/// The text of a bracketed paste.
///
/// `Debug` is redacted (IR-41): the paste may be a secret headed for the
/// secrets pane's writer, and `Msg` derives `Debug`.
#[derive(Clone)]
pub struct Pasted(String);

impl Pasted {
    /// Wraps the text a terminal delivered.
    #[must_use]
    pub fn new(text: String) -> Self {
        Self(text)
    }

    /// The pasted text.
    pub(super) fn as_str(&self) -> &str {
        &self.0
    }
}

impl core::fmt::Debug for Pasted {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        write!(f, "Pasted(<{} bytes>)", self.0.len())
    }
}

/// Everything that can change the dashboard.
#[derive(Debug, Clone)]
pub enum Msg {
    /// A `Request::ListFlock` reply landed. `at` is when it was received, and
    /// becomes every row's uptime anchor.
    Snapshot {
        /// The flock as the shepherd reported it.
        rows: Vec<ProcessInfo>,
        /// When the reply was received.
        at: Instant,
    },
    /// One frame off the bus.
    Event(BusEvent),
    /// This client's own receiver fell behind and discarded frames.
    /// [`BusEvent::Dropped`] is the shepherd's queue instead.
    BusLagged {
        /// How many frames this process lost.
        count: u64,
    },
    /// The link task is re-dialling; `attempt` is 1-based.
    Retrying {
        /// Which attempt is in flight.
        attempt: u32,
    },
    /// The link task reconnected and re-subscribed.
    Relinked,
    /// The reconnect ladder is exhausted. Everything on screen is now frozen.
    Frozen {
        /// When the link was declared lost, already rendered for display.
        at_local: String,
        /// The last dial's own words, from
        /// [`LinkError`](crate::lookout::source::LinkError)'s `Display`.
        /// Carried rather than re-derived so the link panel quotes the
        /// failure instead of guessing at one.
        why: String,
    },
    /// One key.
    Key(KeyPress),
    /// A bracketed paste. Inserted at the cursor of the open text box, with
    /// newlines and other control characters dropped; ignored while no box
    /// is open.
    Paste(Pasted),
    /// The 1s heartbeat. `now` is what advances every running sheep's uptime.
    Tick {
        /// The current instant, read by the caller.
        now: Instant,
    },
    /// The terminal changed size; nothing to update but the frame is stale.
    Resize,
    /// One reading of the machine this lookout runs on, off the 1s heartbeat.
    /// `None` means `sysinfo` does not support this platform. Refused once the
    /// link is lost.
    Host {
        /// What the sampler saw, or `None` on an unsupported platform.
        sample: Option<crate::lookout::source::HostSample>,
    },
    /// The hint `shep flock` would print under this flock, read from this
    /// machine every 30 seconds. `None` means no hint applies. Refused once
    /// the link is lost. Always yields [`Effect::None`].
    Hint {
        /// The one line, or `None` when nothing applies.
        line: Option<String>,
    },
    /// How big the selected sheep's two log files are on disk, read on the
    /// same cadence as the lamb walk. Always yields [`Effect::None`].
    LogSize {
        /// The sheep the read was taken for.
        id: u32,
        /// `out` plus `err`, or `None` when either could not be read.
        total_bytes: Option<u64>,
    },
    /// One refresh of the selected sheep's log files, answering an
    /// [`Effect::RefreshFeed`]. Always yields [`Effect::None`].
    Bleats {
        /// What the read found, including what it could not show.
        tail: crate::lookout::tail::Tail,
    },
    /// A request this dashboard asked for came back. `sent` is the echo tag.
    Replied {
        /// What was asked.
        sent: Sent,
        /// What the shepherd said, or why it could not be asked.
        result: Result<Response, RequestError>,
    },
    /// A request the caller could not hand to the link task.
    ///
    /// The reducer is already in the in-flight state when `run_ui` tries to
    /// send, so a failed `try_send` has to come back, or the
    /// one-action-at-a-time guard refuses everything from then on.
    Unsent {
        /// What could not be sent.
        sent: Sent,
    },
    /// An [`Effect::SendAll`] batch finished going out to the link task.
    ///
    /// `None` means every entry reached the channel; `send` only ever fails
    /// there once the channel is closed, so the first casualty stands for
    /// the rest of that batch, the same way a lone [`Effect::Send`] reports
    /// only the one entry it carries.
    BatchSent {
        /// The first entry the channel refused, once closed.
        unsent: Option<Sent>,
    },
    /// The settings screen's read of `shep.toml` landed, answering an
    /// [`Effect::LoadSettings`]. A `String` error, since this reducer holds no
    /// error types from `commands`.
    Settings {
        /// The rendered snapshot, or why it could not be read.
        result: Result<SettingsSnapshot, String>,
    },
    /// An [`Effect::WriteSetting`] has landed.
    SettingWritten {
        /// The edit that was sent, echoed back: the cursor can have moved on
        /// while the write was in flight.
        edit: SettingEdit,
        /// The ticket it went out with, echoed back: the screen can have
        /// armed a second edit, or closed and reopened, while it was in
        /// flight.
        ticket: u64,
        /// Whether the write landed, or why it did not.
        result: Result<(), String>,
    },
    /// An [`Effect::LoadDogPane`]'s schema probe has answered.
    ///
    /// The schema half only: the section arrives separately over the wire as
    /// [`Sent::DogSection`], since it comes from the shepherd rather than the
    /// dog's own binary. `Result<_, String>`, like [`Self::Settings`]: this
    /// reducer holds no error types from `commands`.
    DogPane {
        /// The dog.
        name: String,
        /// The adopted binary, or [`None`] for a built-in, echoed back so
        /// the pane records what it probed.
        adopted_path: Option<PathBuf>,
        /// The key names of the `env` its `shep.toml` entry sets, read in the
        /// same blocking half as the probe. Names only: a value is a secret,
        /// and the pane that takes these never holds one.
        env_keys: Vec<String>,
        /// The dog's schema, or why there is no pane for it.
        result: Result<serde_json::Value, String>,
    },
    /// An [`Effect::ReadDogPane`] has read the dog's cached manifest.
    ///
    /// Dropped unless the dog's config pane is still the one open.
    DogDeclaresPane {
        /// The dog.
        name: String,
        /// The opening the read was raised under.
        opening: u64,
        /// What the manifest declares of a pane, or [`None`] when it declares
        /// none or no manifest is cached.
        declared: Option<DeclaredPane>,
    },
    /// An [`Effect::ListSheepDogs`] has answered: every dog this shepherd
    /// knows, a built-in with its per-sheep schema where it publishes one
    /// and an adopted dog by name alone. Dropped unless the pane that asked
    /// is still up on that sheep.
    SheepDogNames {
        /// The sheep whose `dogs` tab asked.
        sheep: String,
        /// The [`Effect::ListSheepDogs`] this answers.
        ask: u64,
        /// One entry per dog, in no particular order.
        dogs: Vec<ListedDog>,
    },
    /// An [`Effect::LoadSheepDog`] probe has answered. Dropped unless the
    /// pane that asked is still up on that sheep, and lists the dog.
    SheepDog {
        /// The sheep whose `dogs` tab asked.
        sheep: String,
        /// What the dog answered.
        entry: SheepDogEntry,
    },
    /// An [`Effect::RefreshSheepDogSections`] has answered: each standard
    /// dog's entry, read again. Dropped unless the pane that asked is still
    /// up on that sheep.
    SheepDogSections {
        /// The sheep whose `dogs` tab asked.
        sheep: String,
        /// One entry per dog asked, in no particular order.
        entries: Vec<SheepDogEntry>,
    },
    /// An [`Effect::WriteDog`] has landed. `Ok` carries the [`DogEntry`] the
    /// write resolved, which [`Sent::Dog`] then rides to the shepherd, so the
    /// request cannot disagree with the file.
    DogWritten {
        /// The toggle that was sent, echoed back.
        edit: DogEdit,
        /// The ticket it went out with, echoed back for
        /// [`Self::SettingWritten`]'s reason.
        ticket: u64,
        /// Whether the write landed and what it resolved to, or why it did
        /// not.
        result: Result<DogEntry, String>,
    },
    /// An [`Effect::WriteDogEnv`] has landed.
    DogEnvWritten {
        /// The dog, echoed back for the sentence this reports.
        name: String,
        /// The ticket the file half went out with, echoed back for
        /// [`Self::SettingWritten`]'s reason.
        ticket: u64,
        /// The requests the same close built, held until the file half
        /// answered. A refused write sends none of them.
        then: Vec<Sent>,
        /// Whether the write landed, or why it did not.
        result: Result<(), String>,
    },
    /// An [`Effect::WriteDogTune`] has landed. `Ok` carries what the file now
    /// says, which for a grant is the [`DogEntry`] the record describes and
    /// which [`Sent::GrantDog`] then rides to the shepherd, so the request
    /// cannot disagree with the file.
    DogTuneWritten {
        /// The change that was sent, echoed back: the cursor can have moved on
        /// while the write was in flight.
        tune: DogTune,
        /// The ticket it went out with, echoed back for
        /// [`Self::SettingWritten`]'s reason.
        ticket: u64,
        /// What the file now says, or why it says the same thing as before.
        result: Result<DogTuneLanded, String>,
    },
    /// An [`Effect::LoadSecrets`] has answered.
    ///
    /// `Result<_, String>`, like [`Self::Settings`]: `secrets::model` cannot
    /// fail, but the `spawn_blocking` it runs on can. Dropped when no pane is
    /// open, the way a late config reply is. `environment` is the tab this
    /// read was built for, echoed back because the pane's model is empty on
    /// the first load.
    Secrets {
        /// The environment [`crate::lookout::secrets::model`] was built for.
        environment: String,
        /// The model, boxed: it is much larger than every other variant.
        result: Result<Box<SecretsModel>, String>,
    },
    /// An [`Effect::RevealSecret`]'s read has answered.
    ///
    /// Nothing here reaches the screen on its own: the pane draws the value
    /// only while every reason it was asked for still holds, which is what
    /// the two echoes are for.
    Revealed {
        /// The key that was read, echoed back: the selection can have moved
        /// to another row while the read was in flight.
        key: String,
        /// The environment tab it was read for, echoed back for
        /// [`Self::Secrets`]'s reason.
        environment: String,
        /// The value, or `None` when the slot no longer resolves or the
        /// store would not read.
        value: Option<RevealedValue>,
    },
    /// An [`Effect::WriteSecret`] has landed.
    SecretWritten {
        /// What the write returned, its error already rendered: a
        /// `SecretError` names a key, never a value, but the pane has no
        /// use for the type.
        ///
        /// `Ok(true)` means the store changed. `Ok(false)` is `secrets::unset`
        /// reporting that there was no slot to remove, which a delete must
        /// say out loud rather than report as a success.
        result: Result<bool, String>,
    },
    /// An [`Effect::LoadCatalogue`] or an [`Effect::FetchCatalogue`] has
    /// answered.
    ///
    /// Kept whichever screen is open. An `Err` leaves the rows as they were.
    Catalogue {
        /// The rows and whether a fetch is still due, or why the load failed.
        result: Result<Box<CatalogueRead>, String>,
    },
    /// A dog job reached a step. Always yields [`Effect::None`].
    DogStep {
        /// The dog the job is for, echoed back: a late step for a job that
        /// has ended describes nothing.
        name: String,
        /// The step it reached.
        step: Step,
    },
    /// A dog job's session is asking its grant question, and is blocked on
    /// the answer. Always yields [`Effect::None`].
    DogGrant {
        /// The dog the job is for, echoed back.
        name: String,
        /// What the verb would have printed, from the same function.
        prompt: Prompt,
        /// Where the answer goes, held against the tag of the modal it opens.
        reply: Arc<GrantReply>,
    },
    /// A dog job ended.
    DogJobDone {
        /// The dog.
        name: String,
        /// Whether the verb would have exited successfully.
        ok: bool,
        /// Everything the verb printed, capped by the modal that shows it.
        output: String,
    },
}

/// The one gate on writing `shep.toml` from the settings screen.
///
/// [`WriteAuthority`]'s field is private, so [`WriteAuthority::granted`] is the
/// only way to build one, and it hands back [`None`] under
/// [`Control::ReadOnly`]. [`Effect::WriteSetting`] and [`Effect::WriteDog`]
/// each carry one, so a handler cannot name a write without the check.
mod authority {
    use super::App;

    /// Proof that `--allow-control` was on when a settings write was built.
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub struct WriteAuthority(());

    impl WriteAuthority {
        /// A token when `app`'s [`Control`](super::Control) permits writing,
        /// [`None`] otherwise.
        ///
        /// The sole constructor, taking `&App` rather than a `Control` so the
        /// answer can only be the app's own gate.
        #[must_use]
        pub fn granted(app: &App) -> Option<Self> {
            match app.control {
                super::Control::ReadOnly => None,
                super::Control::Allowed => Some(Self(())),
            }
        }
    }
}

pub use authority::WriteAuthority;

/// What the caller has to do after an update.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
    /// Nothing.
    None,
    /// Ask the link task for a `ListFlock` now, rather than at the next tick.
    PollNow,
    /// Re-read the selected sheep's log files and hand the result back as
    /// [`Msg::Bleats`]. The feed has no timer of its own; it rides this.
    RefreshFeed,
    /// Re-read the selected sheep's log files and ask the shepherd for its
    /// lambs. Raised only when the selection moved: a snapshot refreshes the
    /// feed alone, since it fires every two seconds.
    RefreshSelected,
    /// Send a request to the shepherd. Raised by [`App::confirm`] once an
    /// armed action's Enter lands; `crate::lookout::run_ui` sends it.
    Send(Sent),
    /// Send several requests, in the order given. Raised when a config pane
    /// closes: nothing it edited has gone out yet, so the whole set leaves on
    /// that one keypress.
    ///
    /// Its own variant rather than a `Vec` on [`Self::Send`], because every
    /// other sender raises exactly one request and would have to wrap it.
    SendAll(Vec<Sent>),
    /// Leave.
    Quit,
    /// The open modal was answered. `tag` is the one its owner gave
    /// [`Modal`], and the owner acts on `yes`.
    ///
    /// Raised for a grant or a confirm only: an output modal closes with
    /// [`Self::None`].
    ModalAnswered {
        /// The owner's tag from the modal.
        tag: u64,
        /// Whether the answer was yes.
        yes: bool,
    },
    /// Read `shep.toml`'s settings snapshot; the result lands as
    /// [`Msg::Settings`]. Raised by the dashboard's `s`; once the screen is
    /// open, `s` closes it instead.
    LoadSettings,
    /// Apply one edit to `shep.toml`; the result lands as
    /// [`Msg::SettingWritten`].
    ///
    /// Must run on `spawn_blocking`: `ConfigLock::acquire` blocks with no
    /// deadline, and the UI task's redraw, tick and bus drain would block with
    /// the write.
    WriteSetting {
        /// The edit to apply.
        edit: SettingEdit,
        /// Which write this is, so its reply can only resolve the prompt it
        /// belongs to. Minted per send, so no two are ever equal.
        ticket: u64,
        /// Proof the control gate was open.
        authority: WriteAuthority,
    },
    /// Probe one dog for its config schema; the answer lands as
    /// [`Msg::DogPane`].
    ///
    /// A dog's schema is not persisted anywhere, so the pane asks at open. An
    /// adopted dog is spawned with the schema flag, which is why
    /// `crate::lookout::run_ui` runs this on `spawn_blocking`. No
    /// [`WriteAuthority`]: this reads.
    LoadDogPane {
        /// The dog.
        name: String,
        /// The adopted binary, or [`None`] for a built-in.
        adopted_path: Option<PathBuf>,
    },
    /// Read an adopted dog's cached manifest for whether it declares `pane`;
    /// the answer lands as [`Msg::DogDeclaresPane`]. Raised when the dog's
    /// config pane opens, never over `--host`: the cache is this machine's
    /// and the remote dog's binary is not here.
    ///
    /// `spawn_blocking`, for [`Self::LoadDogPane`]'s reason. Reads the cache
    /// only and spawns no dog.
    ReadDogPane {
        /// The dog.
        name: String,
        /// The opening of the pane that asked, echoed back on the answer.
        opening: u64,
        /// The adopted binary the manifest was cached under.
        adopted_path: PathBuf,
    },
    /// List the dogs that can hold a table on a sheep; the answer lands as
    /// [`Msg::SheepDogNames`]. Raised the first time a sheep pane's `dogs`
    /// tab comes up. Spawns no dog.
    ///
    /// `spawn_blocking`, for [`Self::LoadDogPane`]'s reason.
    ListSheepDogs {
        /// The sheep whose `dogs` tab asked.
        sheep: String,
        /// Which ask this is, carried back on the answer.
        ask: u64,
    },
    /// Probe one dog for its per-sheep schema; the answer lands as
    /// [`Msg::SheepDog`]. Raised by opening that dog's row, once per row.
    ///
    /// `spawn_blocking`, for [`Self::LoadDogPane`]'s reason. Never stored,
    /// like a dog's own schema.
    LoadSheepDog {
        /// The sheep whose `dogs` tab asked.
        sheep: String,
        /// The dog.
        dog: String,
        /// The adopted binary, or [`None`] for a built-in.
        adopted_path: Option<PathBuf>,
    },
    /// Read each of a sheep's standard dogs again, so a re-read of the sheep
    /// pane shows `dogs.toml` as it is now; the answer lands as
    /// [`Msg::SheepDogSections`]. Raised when a re-read rebuilds a pane whose
    /// `dogs` tab is already listed.
    ///
    /// `spawn_blocking`, for [`Self::LoadDogPane`]'s reason.
    RefreshSheepDogSections {
        /// The sheep whose `dogs` tab asked.
        sheep: String,
        /// Each dog, with its adopted binary or [`None`] for a built-in.
        dogs: Vec<(String, Option<PathBuf>)>,
    },
    /// Apply one dog's file half; the result lands as [`Msg::DogWritten`].
    ///
    /// Its own effect, not [`Self::WriteSetting`]: it ends in a request to the
    /// shepherd ([`Sent::Dog`]) where a scalar write ends in a notice.
    /// `spawn_blocking`, for [`Self::WriteSetting`]'s reason.
    WriteDog {
        /// The toggle to apply.
        edit: DogEdit,
        /// Which write this is, for [`Self::WriteSetting`]'s reason. It
        /// rides on to [`Sent::Dog`], so both halves of one toggle answer
        /// under the same ticket.
        ticket: u64,
        /// Proof the control gate was open.
        authority: WriteAuthority,
    },
    /// Apply one dog pane's `env` edits to `shep.toml`; the result lands as
    /// [`Msg::DogEnvWritten`].
    ///
    /// Its own effect, not [`Self::WriteSetting`]: a dog's `env` sits beside
    /// its path in `[daemon] adopted_dogs`, and one key of it is written
    /// through [`crate::commands::shep_toml::ShepToml::set_adopted_dog_env`],
    /// which keeps the path. `spawn_blocking`, for [`Self::WriteSetting`]'s reason.
    WriteDogEnv {
        /// The dog, and every key its close filed, with the value or [`None`]
        /// for a removal.
        write: DogEnvWrite,
        /// The requests the same close built, sent once the file half lands:
        /// a dog pane can hold a section edit and an env edit at once, and a
        /// section must not be written over a file the env write refused.
        then: Vec<Sent>,
    },
    /// Apply one of a dog's other `[daemon] keys; the result lands as
    /// [`Msg::DogTuneWritten`].
    ///
    /// Its own effect, not [`Self::WriteSetting`]: a grant is a read-modify-
    /// write of the record `shep dogs grant` writes, with the manifest check
    /// in front of it. Neither is a value at a path.
    WriteDogTune {
        /// The change to apply.
        tune: DogTune,
        /// Which write this is, for [`Self::WriteSetting`]'s reason. It rides
        /// on to [`Sent::GrantDog`], so both halves of one grant answer under
        /// the same ticket.
        ticket: u64,
        /// Proof the control gate was open.
        authority: WriteAuthority,
    },
    /// Read the secret store, the provider cache and the roll; the result
    /// lands as [`Msg::Secrets`].
    ///
    /// Runs on `spawn_blocking` for [`Self::WriteSetting`]'s reason: the
    /// store's own lock acquires with no deadline.
    LoadSecrets,
    /// Read the cached dog index and build the catalogue's rows; the answer
    /// lands as [`Msg::Catalogue`].
    ///
    /// Runs on `spawn_blocking` as [`Self::LoadSettings`] does. It reads the
    /// cache only. With `fetch` set the answer says whether a fetch is due,
    /// and the reducer raises [`Self::FetchCatalogue`] for it. With `fetch`
    /// unset nothing ever touches the network.
    LoadCatalogue {
        /// Whether a missing or stale cache is to be fetched.
        fetch: bool,
    },
    /// Fetch the dog index through its cache and rebuild the catalogue's
    /// rows; the answer lands as [`Msg::Catalogue`].
    FetchCatalogue,
    /// Runs `job` through [`run`](crate::commands::dogs::run), the same code
    /// `shep install`, `shep upgrade` and `shep delete` run, on its own thread
    /// with its own current-thread runtime.
    ///
    /// Not `spawn_blocking`: the grant callback blocks on a modal answer, and
    /// the run starts `cargo`, which needs a runtime of its own. Progress and
    /// the result land as [`Msg::DogStep`] and [`Msg::DogJobDone`].
    StartDogJob {
        /// Which job.
        job: Job,
        /// Proof the control gate was open: an install, upgrade or delete writes `shep.toml`.
        authority: WriteAuthority,
    },
    /// Read the one value behind `row`; the answer lands as [`Msg::Revealed`].
    ///
    /// Runs on `spawn_blocking` for [`Self::LoadSettings`]'s reason: the read
    /// takes no lock, and a synchronous file read on the UI task stalls the
    /// redraw, the tick and the bus drain together.
    ///
    /// No [`WriteAuthority`]: the gate on this one is `[secrets] allow_read`.
    RevealSecret {
        /// The operator store to read an
        /// [`Operator`](crate::lookout::secrets::Source::Operator) row from.
        store: PathBuf,
        /// The provider cache to read a namespaced row from.
        provider_cache: PathBuf,
        /// The row, which names the key, the store and the slot.
        row: SecretRow,
        /// The environment tab it is being read for, echoed back by
        /// [`Msg::Revealed`].
        environment: String,
    },
    /// Apply one change to `secrets.json`; the result lands as
    /// [`Msg::SecretWritten`].
    ///
    /// Runs on `spawn_blocking` for [`Self::WriteSetting`]'s reason: the
    /// store's lock acquires with no deadline. The [`WriteAuthority`] is not
    /// decoration: this variant cannot be named without having passed the gate.
    WriteSecret(SecretEdit, WriteAuthority),
    /// Apply several changes to `secrets.json` in one task; the result lands
    /// as one [`Msg::SecretWritten`].
    ///
    /// Raised by a rename, which is two changes to the store. One task rather
    /// than two effects so one `Enter` gets one answer, and so the half that
    /// can lose a value goes last. `spawn_blocking` and [`WriteAuthority`]
    /// for the same reasons as [`Self::WriteSecret`].
    WriteSecrets(Vec<SecretEdit>, WriteAuthority),
    /// Sends the carried value to the terminal's clipboard over OSC 52, by
    /// [`KeyPress::Copy`]. `crate::lookout::run_ui` writes it straight to the
    /// same stdout handle `crate::lookout::term` uses, never through
    /// `Terminal<B>` and never through `tracing`: the value must not reach a
    /// log.
    ///
    /// No [`WriteAuthority`]: this never touches `shep.toml`.
    CopyToClipboard(ClipboardValue),
}