Skip to main content

yog/app/
balls.rs

1//! What a frame asks of the live `bl` projection — the §3.5 join, the ops tail,
2//! and the two verb hooks that tell the worker a `bl`/`litany` action landed
3//! (DESIGN §5.1 #2/#7, §7.2, §4.2, §15 Y16).
4//!
5//! Y14 built the pure projection ([`crate::projects`]); Y16 held it live. Since
6//! bl-ee0a the *holding* is the worker's — the [`BlRunner`](crate::projects::runner::BlRunner)
7//! fetch cadence, the join rebuild and the ops re-read all run in
8//! [`Deriver`](super::Deriver) — and what is left here is reading the result and
9//! naming the root a dispatched verb changed, so the worker re-fetches ahead of
10//! the watch. The convergence the operator sees is unchanged; it arrives on the
11//! next pass instead of inside the click's own frame, which is what stops a
12//! `bl` listing from happening on the paint thread at all.
13
14use super::AppModel;
15use crate::cli_outbound::Cli;
16use crate::opslog::SurfaceFailure;
17use crate::projects::join;
18use crate::projects::runner;
19use std::path::{Path, PathBuf};
20
21/// The empty-project hint's two lines (STORIES S3-T5, bl-b491): elidable prose
22/// then the verbatim command. The split is the fix — see
23/// [`AppModel::empty_project_hint`].
24#[derive(Debug, PartialEq, Eq)]
25pub struct EmptyHint {
26    /// The prose that introduces the command; may elide harmlessly.
27    pub lead: String,
28    /// The command to type, verbatim — rendered alone so it never elides.
29    pub command: String,
30}
31
32impl AppModel {
33    /// The operator identity (§4.1): recorded `identity_last_used` else `$USER`
34    /// else empty. **Not** a claim stamp — Z3's start flow and Z4's close/release/
35    /// assign/move all stamp `--as <workspace name>` (§3.2 ownership line), never
36    /// the operator. Retained as the *author* identity for the standalone `bl
37    /// create`/`bl update` verbs (§8.2 New ball / Update ball), where the operator
38    /// — not a workspace — is the reporter.
39    pub fn identity(&self) -> String {
40        runner::identity(self.ui.identity_last_used(), self.identity_user.clone())
41    }
42
43    /// **One surface's** last-failure view-model (§5.3, §7.3): the most recent
44    /// ops row `origin` attributed to that surface, *iff* it is a rendered
45    /// failure ([`OpRow::failed`]), projected to its argv and stderr tail.
46    /// `None` when that surface's last attempted action succeeded, so the
47    /// surface clears its ichor-red banner. Reads the already-derived tail, so
48    /// the banner and the ops pane never diverge (both project the same durable
49    /// ops line, §4.2). The shell paints it; nothing is held.
50    ///
51    /// **The banner's lifetime now ends at an ack as well** (bl-c417): the query
52    /// runs over [`since_ack`](crate::opslog::since_ack)'s rows, so a dismissal
53    /// quiets it even though nothing was retried. It is still not a stored flag —
54    /// the watermark is the newest ack *line*, and a NEW failure of this origin
55    /// lands after it and banners again.
56    ///
57    /// The origin parameter is the whole fix for bl-48f8. Un-parameterised this
58    /// asked one global question — "did the *last* op fail?" — which three
59    /// surfaces then answered identically, so a failed ▶ Start painted itself on
60    /// the balls fold, the composer and the bootstrap box at once, and any one
61    /// surface's clean run wiped the other two's live banners. Per-origin it is
62    /// the same rule with the general input: the last row **of this surface**,
63    /// iff it failed. §6's retirement therefore stays per-surface too — a clean
64    /// re-run retires the banner it superseded and no one else's.
65    pub fn last_failure(&self, origin: crate::opslog::Origin) -> Option<SurfaceFailure> {
66        crate::opslog::since_ack(&self.snap.ops)
67            .iter()
68            .rev()
69            .find(|r| r.origin == origin)
70            .filter(|r| r.failed())
71            .map(SurfaceFailure::from)
72    }
73
74    /// The yog state root — where `ops.jsonl` lives, the verb-log target the
75    /// shell passes to [`crate::actions::verbs`] (§4.2).
76    pub(crate) fn state_root(&self) -> &Path {
77        &self.roots.yog_state
78    }
79
80    /// The empty-project roster hint (STORIES S3-T5) as its two rendered lines.
81    /// With **zero projects** in the
82    /// world — no clone lists cleanly and none is orphaned — the roster shows the
83    /// paved way to enter one, `yog exec bl prime` in a repo (v1 keeps `bl prime`
84    /// out of the UI, §8.3). Since bl-44a5/bl-2930 that gesture works with only
85    /// yog on `PATH`: the hatch seeds the world's shims and the embedded `bl`
86    /// runs `prime` with a plugin chain that is yog (§16.4). `None` once any
87    /// project is present (clean or orphaned) — a project surface then exists to
88    /// work with.
89    ///
90    /// Two lines, not one sentence (bl-b491): the roster truncates every row
91    /// rather than widening the panel (§11, bl-9669), and a single
92    /// "No projects yet — add one with: yog exec bl prime" lost the command to
93    /// the ellipsis at the default width — the one part of the hint that is the
94    /// hint. The prose leads on its own elidable line; the command follows
95    /// alone, so the width it must survive is its own.
96    pub fn empty_project_hint(&self) -> Option<EmptyHint> {
97        let has_project = !self.snap.balls_by_project.is_empty()
98            || self
99                .snap
100                .join_rows
101                .iter()
102                .any(|r| r.state == join::JoinState::OrphanedProject);
103        (!has_project).then(|| EmptyHint {
104            lead: "No projects yet — add one with:".to_owned(),
105            command: "yog exec bl prime".to_owned(),
106        })
107    }
108
109    /// A dispatched `bl` verb landed against `project` (§15 Y16): mark the
110    /// **project** dirty so the worker re-fetches its live *and* closed balls
111    /// (the delivered-row source, §5.1 #4), rebuilds the join and re-reads the
112    /// ops tail on its next pass — the immediate convergence the operator sees,
113    /// ahead of the watch.
114    ///
115    /// The root named is the project's own identity — its decoded invocation
116    /// path (§5.1 #1) — which is the vocabulary every other project surface
117    /// already speaks. yog never spells the percent-encoded clone dir: that
118    /// encoding is balls', and one fact has one owner.
119    pub fn after_bl_verb(&mut self, project: &Path) {
120        self.mark_dirty([project.to_path_buf()]);
121    }
122
123    /// A dispatched `litany` verb landed (message/stop/scan): it touches no
124    /// ball, so only the ops tail changes — the yog-state root's ordinary
125    /// routing (§7.1).
126    pub fn after_litany_verb(&mut self) {
127        self.mark_dirty([self.roots.yog_state.clone()]);
128    }
129
130    /// The balls state root — the parent of the per-project clones dir (balls
131    /// arch §1: `clones/` always lives under it); the start flow's
132    /// `work_worktree_path` derives from it (§3.3).
133    pub fn balls_state_root(&self) -> PathBuf {
134        let clones = &self.roots.balls_clones;
135        // `clones` is always nested under the state root, so it has a parent;
136        // the fallback (the clones dir itself) keeps this panic-free.
137        clones.parent().unwrap_or(clones).to_path_buf()
138    }
139
140    /// The boundary [`Deps`](crate::boundary::dispatch::Deps) this instance
141    /// answers with (§8.5): its roots, its composed world, its published
142    /// snapshot, its verb binaries.
143    ///
144    /// **No gesture executes through it any more** (REMOTE §9.8, bl-1747): the
145    /// window posts every act over the wire and the engine builds the `Deps`
146    /// the act runs in. What is left here is the §8.5 line's *query* arm, which
147    /// is answered in place, and the acceptance world's stand-in for the
148    /// transport — both reads.
149    ///
150    /// The world it carries is the **unlensed** one (§16.2). A §9 config
151    /// gesture folds brazen's destinations out of `deps.world` and those live
152    /// inside a workspace's own wall, so the wall is layered on where the
153    /// gesture names its workspace — at the consumer, which is the arm every
154    /// act crosses. It was lensed on the *focused* workspace while a window
155    /// held a focus in this process; a server holds none (REMOTE §7).
156    pub fn boundary_deps(&self, litany: &Cli, bl: &Cli) -> crate::boundary::dispatch::Deps {
157        crate::boundary::dispatch::Deps {
158            litany: litany.clone(),
159            bl: bl.clone(),
160            state_root: self.state_root().to_path_buf(),
161            yog_binary: crate::cli_outbound::self_exe().unwrap_or_default(),
162            world: self.roots.world.clone(),
163            home: self.roots.home.clone(),
164            yog_data_root: self.roots.yog_data.clone(),
165            balls_state_root: self.balls_state_root(),
166            snapshot: std::sync::Arc::clone(&self.snap),
167            caller: crate::boundary::dispatch::Caller::default(),
168        }
169    }
170}
171
172/// `pub(crate)` so a sibling test corpus shares this one `FakeBl` rather than
173/// standing up a second fake of the same runner.
174#[cfg(test)]
175pub(crate) mod tests;