Skip to main content

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;