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;