frame-cli 0.4.0

CLI for Frame — six intention-verbs over one application: frame new scaffolds it, frame run serves it, frame dev hot-reloads it against the running node, frame test proves it (real browser included), frame check verifies it statically, frame doctor walks the prerequisites
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
//! The R2 burst-coalescing state machine: relevant filesystem events open
//! an edit burst; one resettable semantic quiet deadline defines the
//! burst; expiry starts exactly one build of the latest content snapshot;
//! edits during a build make the completing candidate stale (last write
//! wins) and schedule exactly one follow-up build.
//!
//! The machine is PURE: it owns no thread, no channel, and no timer. Time
//! enters only as `now` arguments (the injected clock R6's determinism
//! observations require), and every effect leaves as a [`Directive`] the
//! caller executes. The ONE place a real timer may be armed is the
//! caller's handling of [`Directive::ArmQuietDeadline`] — the named
//! debounce choke point the Ruling B tripwire admits — and expiry starts
//! already-known dirty work; it never inspects the filesystem to discover
//! whether work exists.

use std::time::{Duration, Instant};

/// What the caller must do after feeding the machine one input.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Directive {
    /// Arm (or re-arm) THE quiet deadline to fire at this instant. This is
    /// the single timer-admission choke point of the whole dev loop.
    ArmQuietDeadline(Instant),
    /// Start one build of the latest content snapshot, labeled with this
    /// build generation.
    StartBuild(BuildGeneration),
    /// The build that just completed is stale (newer edits exist); discard
    /// its candidate without activation.
    DiscardStale(BuildGeneration),
    /// The build that just completed is the latest content; hand its
    /// candidate to activation.
    PromoteCandidate(BuildGeneration),
    /// Nothing to do.
    None,
}

/// Monotonic label for one build of one content snapshot. Stale build
/// completions are recognized by generation, never by timing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct BuildGeneration(pub u64);

/// Where the loop is between builds and bursts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
    /// No dirty work, no build in flight. The idle state arms nothing —
    /// zero-edit idle admits zero timer work.
    Idle,
    /// An edit burst is open; THE quiet deadline is armed for `deadline`.
    Burst { deadline: Instant },
    /// A build is in flight and no edit has arrived since it started.
    Building { generation: BuildGeneration },
    /// A build is in flight and edits arrived after its snapshot was
    /// taken: the burst deadline coalesces the follow-up. `ripe` records
    /// that the follow-up's quiet deadline already expired while the
    /// build still ran — the follow-up starts at completion, not before
    /// (one build at a time).
    BuildingDirty {
        generation: BuildGeneration,
        deadline: Instant,
        ripe: bool,
    },
}

/// The coalescing machine. See the module doc for the laws it encodes.
#[derive(Debug)]
pub struct Coalescer {
    quiet: Duration,
    state: State,
    next_generation: u64,
}

impl Coalescer {
    /// A machine with the given semantic quiet window (the named CLI
    /// parameter; 100 ms is the declared, visible default at the CLI).
    #[must_use]
    pub fn new(quiet: Duration) -> Self {
        Self {
            quiet,
            state: State::Idle,
            next_generation: 1,
        }
    }

    /// One relevant filesystem event at `now`: opens a burst or resets THE
    /// deadline of the open one; during a build it marks the newer
    /// generation dirty.
    pub fn relevant_event(&mut self, now: Instant) -> Directive {
        let deadline = now + self.quiet;
        match self.state {
            State::Idle | State::Burst { .. } => {
                self.state = State::Burst { deadline };
                Directive::ArmQuietDeadline(deadline)
            }
            State::Building { generation } | State::BuildingDirty { generation, .. } => {
                self.state = State::BuildingDirty {
                    generation,
                    deadline,
                    ripe: false,
                };
                Directive::ArmQuietDeadline(deadline)
            }
        }
    }

    /// THE quiet deadline fired at `now`. A stale firing (the deadline was
    /// re-armed after this firing was scheduled) is recognized by time and
    /// ignored; a ripe firing starts the build the burst already made
    /// dirty — expiry never discovers work, it starts known work.
    pub fn quiet_deadline_elapsed(&mut self, now: Instant) -> Directive {
        match self.state {
            State::Idle | State::Building { .. } => Directive::None,
            State::Burst { deadline } => {
                if now < deadline {
                    // A firing from a deadline that has since been pushed
                    // out by a newer event: benign, the re-armed deadline
                    // is still pending.
                    return Directive::None;
                }
                let generation = self.mint_generation();
                self.state = State::Building { generation };
                Directive::StartBuild(generation)
            }
            State::BuildingDirty {
                generation,
                deadline,
                ripe,
            } => {
                if now < deadline {
                    return Directive::None;
                }
                // The follow-up burst went quiet while the build still
                // runs: record ripeness; the follow-up starts at
                // completion (one build at a time).
                let _ = ripe;
                self.state = State::BuildingDirty {
                    generation,
                    deadline,
                    ripe: true,
                };
                Directive::None
            }
        }
    }

    /// The in-flight build for `generation` completed (successfully or
    /// not — candidate handling is the caller's; this machine only decides
    /// staleness and follow-up scheduling).
    pub fn build_completed(&mut self, completed: BuildGeneration, now: Instant) -> [Directive; 2] {
        match self.state {
            State::Building { generation } if generation == completed => {
                self.state = State::Idle;
                [Directive::PromoteCandidate(completed), Directive::None]
            }
            State::BuildingDirty {
                generation,
                deadline,
                ripe,
            } if generation == completed => {
                // Last write wins: edits arrived after this build's
                // snapshot, so its candidate is stale regardless of its
                // own verdict.
                if ripe || now >= deadline {
                    let follow_up = self.mint_generation();
                    self.state = State::Building {
                        generation: follow_up,
                    };
                    [
                        Directive::DiscardStale(completed),
                        Directive::StartBuild(follow_up),
                    ]
                } else {
                    self.state = State::Burst { deadline };
                    // The deadline for the follow-up burst is already
                    // armed (events arm it as they arrive); nothing to
                    // re-arm here.
                    [Directive::DiscardStale(completed), Directive::None]
                }
            }
            // A completion for a generation this machine no longer tracks
            // (never minted here, or superseded by a state the caller
            // drove differently) cannot activate anything.
            State::Idle
            | State::Burst { .. }
            | State::Building { .. }
            | State::BuildingDirty { .. } => [Directive::DiscardStale(completed), Directive::None],
        }
    }

    /// The watcher reported loss (overflow/desynchronization): the backend
    /// no longer promises completeness, so the caller takes ONE full
    /// content snapshot and rebuilds it — immediately, not debounced; the
    /// loss already coalesced everything it swallowed. During a build the
    /// snapshot rebuild becomes the (ripe) follow-up.
    pub fn watcher_desynchronized(&mut self, now: Instant) -> Directive {
        match self.state {
            State::Idle | State::Burst { .. } => {
                let generation = self.mint_generation();
                self.state = State::Building { generation };
                Directive::StartBuild(generation)
            }
            State::Building { generation } | State::BuildingDirty { generation, .. } => {
                self.state = State::BuildingDirty {
                    generation,
                    deadline: now,
                    ripe: true,
                };
                Directive::None
            }
        }
    }

    fn mint_generation(&mut self) -> BuildGeneration {
        let generation = BuildGeneration(self.next_generation);
        self.next_generation += 1;
        generation
    }
}

#[cfg(test)]
mod tests {
    use super::{BuildGeneration, Coalescer, Directive};
    use std::time::{Duration, Instant};

    const QUIET: Duration = Duration::from_millis(100);

    fn machine() -> (Coalescer, Instant) {
        (Coalescer::new(QUIET), Instant::now())
    }

    /// One event → one armed deadline → one build. The baseline law.
    #[test]
    fn one_event_one_deadline_one_build() {
        let (mut m, t0) = machine();
        assert_eq!(
            m.relevant_event(t0),
            Directive::ArmQuietDeadline(t0 + QUIET)
        );
        assert_eq!(
            m.quiet_deadline_elapsed(t0 + QUIET),
            Directive::StartBuild(BuildGeneration(1))
        );
        assert_eq!(
            m.build_completed(BuildGeneration(1), t0 + QUIET * 2),
            [
                Directive::PromoteCandidate(BuildGeneration(1)),
                Directive::None
            ]
        );
    }

    /// A rapid burst re-arms THE one deadline; a stale firing from a
    /// superseded deadline is ignored by time; exactly one build follows.
    #[test]
    fn rapid_burst_coalesces_to_one_build() {
        let (mut m, t0) = machine();
        for i in 0..10 {
            let at = t0 + Duration::from_millis(i * 10);
            assert_eq!(
                m.relevant_event(at),
                Directive::ArmQuietDeadline(at + QUIET),
                "every event re-arms the same single deadline"
            );
        }
        let last_event = t0 + Duration::from_millis(90);
        // The firing scheduled by the FIRST event is stale by the time it
        // would run: the deadline moved.
        assert_eq!(m.quiet_deadline_elapsed(t0 + QUIET), Directive::None);
        // The true quiet point starts exactly one build.
        assert_eq!(
            m.quiet_deadline_elapsed(last_event + QUIET),
            Directive::StartBuild(BuildGeneration(1))
        );
        // And nothing further is armed or started.
        assert_eq!(
            m.build_completed(BuildGeneration(1), last_event + QUIET * 2),
            [
                Directive::PromoteCandidate(BuildGeneration(1)),
                Directive::None
            ]
        );
    }

    /// Edits during a build: the completing candidate is stale even
    /// though its build succeeded, and exactly one follow-up build of the
    /// final content starts at completion (the follow-up's own quiet
    /// deadline having expired mid-build).
    #[test]
    fn edit_during_build_discards_stale_and_rebuilds_once() {
        let (mut m, t0) = machine();
        m.relevant_event(t0);
        assert_eq!(
            m.quiet_deadline_elapsed(t0 + QUIET),
            Directive::StartBuild(BuildGeneration(1))
        );
        // Edit lands while generation 1 builds.
        let edit = t0 + QUIET + Duration::from_millis(20);
        assert_eq!(
            m.relevant_event(edit),
            Directive::ArmQuietDeadline(edit + QUIET)
        );
        // Its quiet deadline expires while the build still runs: no
        // second concurrent build starts.
        assert_eq!(m.quiet_deadline_elapsed(edit + QUIET), Directive::None);
        // Completion of the stale build discards it and starts exactly
        // one follow-up.
        assert_eq!(
            m.build_completed(BuildGeneration(1), edit + QUIET * 2),
            [
                Directive::DiscardStale(BuildGeneration(1)),
                Directive::StartBuild(BuildGeneration(2))
            ]
        );
        assert_eq!(
            m.build_completed(BuildGeneration(2), edit + QUIET * 3),
            [
                Directive::PromoteCandidate(BuildGeneration(2)),
                Directive::None
            ]
        );
    }

    /// Edit-during-build whose burst is still OPEN at completion: the
    /// stale candidate is discarded and the follow-up waits for the
    /// already-armed quiet deadline — no build of a still-moving tree.
    #[test]
    fn open_burst_at_completion_waits_for_quiet() {
        let (mut m, t0) = machine();
        m.relevant_event(t0);
        m.quiet_deadline_elapsed(t0 + QUIET);
        let edit = t0 + QUIET + Duration::from_millis(20);
        m.relevant_event(edit);
        // Build completes 10ms after the edit — inside the edit's quiet
        // window.
        let completion = edit + Duration::from_millis(10);
        assert_eq!(
            m.build_completed(BuildGeneration(1), completion),
            [Directive::DiscardStale(BuildGeneration(1)), Directive::None]
        );
        // The burst goes quiet → exactly one follow-up build.
        assert_eq!(
            m.quiet_deadline_elapsed(edit + QUIET),
            Directive::StartBuild(BuildGeneration(2))
        );
    }

    /// Zero-edit idle admits zero timer work: no event, no directive ever
    /// asks the caller to arm anything. (The live counter witness rides
    /// the choke point in the loop; this is the machine's half.)
    #[test]
    fn idle_arms_nothing() {
        let (mut m, t0) = machine();
        assert_eq!(m.quiet_deadline_elapsed(t0 + QUIET), Directive::None);
        assert_eq!(m.quiet_deadline_elapsed(t0 + QUIET * 100), Directive::None);
    }

    /// Determinism (R8/constraint 8): the same event sequence and build
    /// outcomes produce the same generations and directives, run twice.
    #[test]
    fn same_sequence_same_generations() {
        let script = |m: &mut Coalescer, t0: Instant| {
            let mut log = vec![
                m.relevant_event(t0),
                m.quiet_deadline_elapsed(t0 + QUIET),
                m.relevant_event(t0 + QUIET + Duration::from_millis(5)),
                m.quiet_deadline_elapsed(t0 + QUIET * 2 + Duration::from_millis(5)),
            ];
            for d in m.build_completed(BuildGeneration(1), t0 + QUIET * 3) {
                log.push(d);
            }
            for d in m.build_completed(BuildGeneration(2), t0 + QUIET * 4) {
                log.push(d);
            }
            log
        };
        let t0 = Instant::now();
        let (mut a, mut b) = (Coalescer::new(QUIET), Coalescer::new(QUIET));
        assert_eq!(script(&mut a, t0), script(&mut b, t0));
    }

    /// Watcher loss forces one immediate full-snapshot rebuild — the loss
    /// already coalesced whatever it swallowed; waiting on a quiet
    /// deadline would wait on events the backend just admitted losing.
    #[test]
    fn desync_rebuilds_immediately_when_not_building() {
        let (mut m, t0) = machine();
        assert_eq!(
            m.watcher_desynchronized(t0),
            Directive::StartBuild(BuildGeneration(1))
        );
    }

    /// Watcher loss during a build marks the follow-up ripe: the running
    /// build's candidate is stale (its snapshot predates the loss) and
    /// the snapshot rebuild starts at completion.
    #[test]
    fn desync_during_build_rebuilds_at_completion() {
        let (mut m, t0) = machine();
        m.relevant_event(t0);
        m.quiet_deadline_elapsed(t0 + QUIET);
        assert_eq!(
            m.watcher_desynchronized(t0 + QUIET + Duration::from_millis(1)),
            Directive::None
        );
        assert_eq!(
            m.build_completed(BuildGeneration(1), t0 + QUIET * 2),
            [
                Directive::DiscardStale(BuildGeneration(1)),
                Directive::StartBuild(BuildGeneration(2))
            ]
        );
    }

    /// A completion for a generation the machine is not tracking never
    /// promotes: stale completions are recognized by generation.
    #[test]
    fn unknown_generation_completion_never_promotes() {
        let (mut m, t0) = machine();
        assert_eq!(
            m.build_completed(BuildGeneration(7), t0),
            [Directive::DiscardStale(BuildGeneration(7)), Directive::None]
        );
    }
}