balls/tracker.rs
1//! The `tracker` plugin — balls' one remote-talker (§0/§12/§13).
2//!
3//! Base balls is local-only: it commits task-file changes to the balls branch
4//! and never touches a remote. Everything beyond that is a plugin, and the
5//! tracker is the plugin that owns the remote. It is a SEPARATE binary, invoked
6//! subprocess-uniform (`<bin> <op> <phase>`, §6) with the §7 wire on stdin and
7//! no return channel — in-repo only as a default capability + reference impl.
8//!
9//! Its whole job is git acts on the state branch:
10//! - [`remote_ops::sync`] — `sync/pre`: the reconcile over every unpublished
11//! seal — fetch, rebase the store checkout onto the remote tip, push (§13;
12//! bl-3616 §3). A same-ball conflict is the contention signal, named.
13//! - [`remote_ops::push`] — `*/post`: publish the sealed balls branch (§12); a
14//! non-ff reject runs the same reconcile once, over the one in-flight seal.
15//! - [`prime::prime`] — `prime/pre`: settle the store name, clone an established
16//! remote branch into a local ref, or stop SILENTLY when stealth (§12).
17//! - [`prime::prime_post`] — `prime/post`: settle content — fetch-ff an
18//! established remote then push, or found an absent branch by pushing (§12).
19//!
20//! The wire's [`Binding`] is everything it needs — `remote` + `tasks_branch`
21//! name the store upstream DIRECTLY, with no trail to walk (§12). When the binding
22//! carries no explicit `remote` (core resolves only `--remote`/XDG, the
23//! config tiers — §0 keeps it local-only), the tracker discovers the project-repo
24//! `origin` as its single fallback ([`effective_remote`], resolved once at the
25//! [`handle`] dispatch point). Each handler no-ops in a stealth repo — no explicit
26//! remote AND no discoverable origin, or a binding DECLARED stealth (the landing
27//! `task_remote` sentinel, written by `bl prime --stealth` and re-derived by core
28//! on every op, bl-9df0) — the structural opt-out (§12).
29
30mod drift;
31mod git;
32mod payload;
33mod prime;
34mod remote_ops;
35
36#[cfg(test)]
37mod fixtures;
38
39pub use payload::Binding;
40
41use serde::Serialize;
42use std::io::{self, Read, Write};
43use std::path::Path;
44
45/// The host-resolved environment the binary edge hands the tracker: the XDG
46/// roots that locate this checkout's clone bundle (§1), and the chain of store
47/// checkouts the `bl`s in this invocation tree hold open (`$BALLS_HELD_STORES`,
48/// §6). No env reads in the lib — the edge resolves them once (the bl-bfa8
49/// rule) and passes them in.
50pub struct Env {
51 pub xdg: crate::layout::Xdg,
52 /// `$BALLS_HELD_STORES` split like `$PATH`: every `bl` in the invocation
53 /// tree, outermost first, ending with the one that spawned this plugin —
54 /// see [`Env::nested`] for the read that turns it into a publish decision.
55 pub held: Vec<std::path::PathBuf>,
56}
57
58impl Env {
59 /// Assemble from the raw boundary values, so the chain PARSE lives in the
60 /// library where unit tests reach every branch (the bl-bfa8 rule, as
61 /// [`crate::edge::Edge::resolve`] does it for core). An absent variable is
62 /// an empty chain — it FAILS OPEN, publishing: a plugin run by hand, or by a
63 /// core too old to set the variable, must not silently stop federating.
64 #[must_use]
65 pub fn resolve(xdg: crate::layout::Xdg, held: Option<std::ffi::OsString>) -> Self {
66 Self { xdg, held: held.map(|h| std::env::split_paths(&h).collect()).unwrap_or_default() }
67 }
68
69 /// Whether an ENCLOSING `bl` holds `store` open — the §12 rung that decides
70 /// [`remote_ops::push`] (bl-1266), store-scoped since bl-aac7.
71 ///
72 /// The read, stated once: core exports the chain with its OWN store appended
73 /// LAST (`crate::plugin::held_chain`), so the final entry is always the
74 /// spawning op's — the wire's `binding.store` — and the enclosing set is
75 /// everything before it. `store` in that prefix reads exactly "an op above
76 /// the one that invoked me has this anvil open", which is the condition
77 /// under which its seal is not yet this op's to publish; a nested `bl -C`
78 /// on a store nobody above holds is NOT nested here and publishes (the
79 /// bl-1266 H1 fill). Depth is no longer consulted — it is the §6 recursion
80 /// cap alone.
81 #[must_use]
82 pub fn nested(&self, store: &str) -> bool {
83 let enclosing = &self.held[..self.held.len().saturating_sub(1)];
84 enclosing.iter().any(|h| h == Path::new(store))
85 }
86}
87
88/// The ops the tracker handles, for the §6 `protocol` self-description: the
89/// deliverable verbs — `comment` among them: an `update` specialization with
90/// its own hook key, so wiring `comment.post` must be admitted (bl-cca0) —
91/// plus `import` (it pushes on their `post` — imported records sync like any
92/// mutate, §16), `sync`/`prime`, `install` (it fetches
93/// the center's config on `install/pre`, §13), and the reads `show`/`list`
94/// (the drift render, bl-439d).
95const OPS: &[&str] = &[
96 "create", "claim", "unclaim", "update", "comment", "close", "import", "sync", "prime", "install", "show", "list",
97];
98
99/// The §6 self-description emitted by `tracker protocol`. balls never persists
100/// it; it is read at install time to validate a binding.
101#[derive(Serialize)]
102struct SelfDescription {
103 protocol: u32,
104 ops: &'static [&'static str],
105}
106
107/// The tracker entrypoint: dispatch `args` (`protocol`, or `<op> <phase>` with
108/// the §7 payload on `input`), returning the process exit code. A handler error
109/// is logged to stderr and becomes exit `1` — the §6 "non-zero aborts the op".
110pub fn run(args: &[String], input: &mut impl Read, out: &mut impl Write, env: &Env) -> i32 {
111 match dispatch(args, input, out, env) {
112 Ok(()) => 0,
113 Err(e) => {
114 eprintln!("tracker: {e}");
115 1
116 }
117 }
118}
119
120fn dispatch(args: &[String], input: &mut impl Read, out: &mut impl Write, env: &Env) -> io::Result<()> {
121 match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
122 // Beside `protocol` — a question about the binary, not about an op. A
123 // sibling states its own version and no other's ([`crate::version`]).
124 ["--version" | "-V"] => writeln!(out, "{}", crate::version::plugin_line("bl-tracker")),
125 ["protocol"] => protocol(out),
126 [op, phase] => handle(op, phase, input, out, env),
127 _ => Err(io::Error::other("usage: tracker --version | tracker protocol | tracker <op> <phase>")),
128 }
129}
130
131/// Emit the §6 `{ protocol, ops }` self-description as JSON.
132fn protocol(out: &mut impl Write) -> io::Result<()> {
133 let desc = SelfDescription { protocol: crate::message::PROTOCOL, ops: OPS };
134 serde_json::to_writer(&mut *out, &desc).map_err(io::Error::other)?;
135 out.write_all(b"\n")
136}
137
138/// Route one `<op> <phase>` to its handler. The tracker acts in six slots —
139/// `sync/pre`, `prime/pre` (settle name + clone-in), `prime/post` (settle content
140/// — the reconcile, bl-0a23/bl-21ab), `install/pre` (the §13 config fetch), any
141/// deliverable verb's `post` (the push, then the op-ball drift line on stderr,
142/// bl-439d), and the `show`/`list` reads (the drift line folded into the render)
143/// — and no-ops everywhere else. `prime/post` is matched out explicitly BEFORE
144/// the `sync`/`prime`/`install` catch-all so it reaches its own content handler;
145/// that catch-all then keeps `sync`/`install` from triggering the generic `post`
146/// push: in particular `install` adopts config INTO the local landing (a fetch),
147/// and must NEVER push the landing back out (publishing is a separate direction,
148/// §6/§13).
149fn handle(op: &str, phase: &str, input: &mut impl Read, out: &mut impl Write, env: &Env) -> io::Result<()> {
150 let payload::Input { mut binding, id } = payload::read_input(input)?;
151 binding.remote = effective_remote(&binding);
152 match (op, phase) {
153 ("sync", "pre") => remote_ops::sync(&binding, env),
154 ("prime", "pre") => prime::prime(&binding, env),
155 ("prime", "post") => prime::prime_post(&binding, env),
156 ("install", "pre") => remote_ops::fetch_config(&binding),
157 ("sync" | "prime" | "install", _) => Ok(()),
158 ("show" | "list", "read") => drift::render(op, &binding, id.as_deref(), out),
159 (_, "post") => {
160 remote_ops::push(&binding, env)?;
161 // The op's own drift, in the op's scope (bl-3616 Q5): quiet at zero
162 // (a mandatory push), a count when the seal stayed local (opt-in
163 // wiring, an unreachable remote), nothing in stealth or when an
164 // enclosing op will publish for this one (the count would be its).
165 if binding.remote.is_some() && !env.nested(&binding.store) {
166 if let Some(line) = drift::op_line(Path::new(&binding.store), id.as_deref()) {
167 eprintln!("tracker: {line}");
168 }
169 }
170 Ok(())
171 }
172 _ => Ok(()),
173 }
174}
175
176/// The effective store remote for this op (§12): the EXPLICIT remote core already
177/// resolved (`--remote`/XDG `remote`, on the binding), else the
178/// auto-discovered project-repo `origin`. Implicit `origin` discovery is the
179/// TRACKER's alone — core stays local-only (§0) and hands a `remote: None`
180/// binding when no explicit tier is set. Resolved ONCE here at the [`handle`]
181/// dispatch point and written back onto `binding.remote`, so every handler shares
182/// this one fallback and reads `binding.remote` as before — the stealth gate
183/// ("no remote ⇒ no-op") thus means "no explicit remote AND no discoverable
184/// origin", with no per-handler re-probe. A binding carrying `stealth` — the
185/// landing `task_remote` sentinel core derives on EVERY op (§12, bl-9df0) — is
186/// DECLARED stealth: resolve no
187/// remote at all, so even a discoverable `origin` is never founded or pushed.
188fn effective_remote(b: &Binding) -> Option<String> {
189 if b.stealth {
190 return None;
191 }
192 b.remote.clone().or_else(|| origin_of(Path::new(&b.invocation_path)))
193}
194
195/// The auto-discovered store remote — `git remote get-url origin` on the PROJECT
196/// repo (the `invocation_path`, the clone the user works in, whose `origin` is the
197/// real upstream the code rides and where `balls/tasks` sits alongside it). A
198/// LOCAL config read, no network. NEVER the landing: the landing is local-only
199/// (§2 install-transport, founded by a bare `git init`) and carries no origin.
200/// Absent origin (the stealth case, or a non-repo path) ⇒ `None`.
201fn origin_of(project: &Path) -> Option<String> {
202 git::git(project, &["remote", "get-url", "origin"]).ok()
203}
204
205#[cfg(test)]
206#[path = "tracker_tests.rs"]
207mod tests;