Skip to main content

recall_server/
merge.rs

1//! Reconciles two versions of a memory file by shelling out to the local
2//! `claude` CLI.
3//!
4//! It shells out on purpose: no Anthropic API key appears anywhere in this
5//! codebase (see CLAUDE.md), so the merge rides whatever account is already
6//! logged into the CLI on the host. That is a real operational dependency,
7//! which is why every failure here degrades to last-write-wins rather than
8//! failing the sync — a not-yet-configured merge must never be able to take
9//! basic syncing down with it.
10
11use std::process::Stdio;
12use std::time::Duration;
13
14use serde::Deserialize;
15use tokio::io::AsyncWriteExt;
16use tokio::process::Command;
17use tokio::time::timeout;
18
19use crate::now;
20
21/// Replaces Claude Code's default agentic system prompt.
22///
23/// Together with `--exclude-dynamic-system-prompt-sections` and
24/// `--strict-mcp-config`, and running from a neutral working directory,
25/// this is what keeps a merge call cheap: measured live, the default prompt
26/// plus project context turned a trivial merge into roughly $0.19 of
27/// cache-creation tokens, versus about $0.01 with these. The task needs no
28/// tools and no project context — only text in, text out.
29pub const SYSTEM_PROMPT: &str = concat!(
30    "You are a precise text-merging assistant for a personal notes-sync tool. ",
31    "You merge two versions of a Claude Code auto-memory file that were edited independently on different machines and then synced through a central server. ",
32    "Rules: preserve every distinct fact from both versions; if both state the same fact in different words, keep it once, worded clearly (prefer the more complete wording); ",
33    "if they directly contradict each other, keep both and mark the conflict inline so a human can resolve it later; never invent information that isn't present in either version. ",
34    "Output ONLY the merged file content — no preamble, no explanation, no code fences, nothing else."
35);
36
37const DEFAULT_TIMEOUT: Duration = Duration::from_secs(45);
38const STATUS_TIMEOUT: Duration = Duration::from_secs(15);
39
40/// How much of a subprocess's output is worth quoting back in an error.
41const DETAIL_LIMIT: usize = 500;
42
43/// Why a merge could not produce a merged file. Every variant ends the same
44/// way at the call site: fall back to last-write-wins and keep the sync.
45#[derive(Debug, thiserror::Error)]
46pub enum Error {
47    /// The binary is missing, unspawnable, or not logged in.
48    #[error("claude CLI unavailable: {0}")]
49    Unavailable(String),
50    /// It ran, but not within `RECALL_MERGE_TIMEOUT_MS`.
51    #[error("claude merge timed out after {0:?}")]
52    TimedOut(Duration),
53    /// It exited non-zero.
54    #[error("claude failed: {0}")]
55    Failed(String),
56    /// Its output was not the JSON envelope this asked for.
57    #[error("claude returned non-JSON output: {0}")]
58    NonJson(String),
59    /// The envelope reported an error of its own.
60    #[error("claude merge failed: {0}")]
61    Rejected(String),
62    /// A well-formed success envelope carrying nothing, from two versions
63    /// that both had content.
64    ///
65    /// Treated as a failure precisely because it does not look like one:
66    /// stored as a result it would replace both machines' notes with `""`
67    /// and report `merged: true`.
68    #[error("claude returned an empty merge of two non-empty versions")]
69    EmptyResult,
70}
71
72/// Runs merges through a `claude` binary.
73#[derive(Debug, Clone)]
74pub struct Merger {
75    /// The binary to run. Resolved through `PATH` like any other command.
76    pub bin: String,
77    /// How long it may take before the merge is abandoned.
78    pub timeout: Duration,
79}
80
81impl Default for Merger {
82    fn default() -> Self {
83        Self {
84            bin: "claude".to_string(),
85            timeout: DEFAULT_TIMEOUT,
86        }
87    }
88}
89
90/// The local, zero-cost check of whether a merge could work at all: is the
91/// binary there, and is it logged in. Exposed via `/health` so a degraded
92/// deployment is visible without waiting for a real conflict.
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct Status {
95    /// When this check ran.
96    pub checked_at: String,
97    /// Whether the binary was found and runnable.
98    pub available: bool,
99    /// Whether it has a usable login.
100    pub logged_in: bool,
101    /// Why the check failed, when it did.
102    pub error: String,
103}
104
105/// The subset of `claude -p --output-format json` we read.
106#[derive(Debug, Deserialize)]
107struct CliResult {
108    #[serde(default)]
109    is_error: bool,
110    #[serde(default)]
111    result: String,
112}
113
114/// `claude auth status` speaks camelCase.
115#[derive(Debug, Deserialize)]
116struct AuthStatus {
117    #[serde(default, rename = "loggedIn")]
118    logged_in: bool,
119}
120
121/// Builds the user-side prompt for one merge.
122pub fn prompt(old_content: &str, new_content: &str) -> String {
123    format!("--- VERSION A (currently stored) ---\n{old_content}\n\n--- VERSION B (incoming) ---\n{new_content}")
124}
125
126impl Merger {
127    /// Builds a merger around one binary and one timeout.
128    pub fn new(bin: impl Into<String>, timeout: Duration) -> Self {
129        Self {
130            bin: bin.into(),
131            timeout: if timeout.is_zero() {
132                DEFAULT_TIMEOUT
133            } else {
134                timeout
135            },
136        }
137    }
138
139    /// Returns a single reconciled version of the two inputs.
140    pub async fn merge(&self, old_content: &str, new_content: &str) -> Result<String, Error> {
141        let limit = if self.timeout.is_zero() {
142            DEFAULT_TIMEOUT
143        } else {
144            self.timeout
145        };
146
147        let mut cmd = Command::new(&self.bin);
148        cmd.arg("-p")
149            .args(["--output-format", "json"])
150            .args(["--input-format", "text"])
151            .args(["--system-prompt", SYSTEM_PROMPT])
152            .arg("--exclude-dynamic-system-prompt-sections")
153            .arg("--strict-mcp-config")
154            // A neutral working directory: nothing here should read, or be
155            // influenced by, whatever project happens to be on disk. Along
156            // with the two flags above this is the difference between a
157            // ~$0.01 and a ~$0.19 merge call.
158            .current_dir(std::env::temp_dir())
159            .stdin(Stdio::piped())
160            .stdout(Stdio::piped())
161            .stderr(Stdio::piped())
162            // If the timeout below drops this future, the child must die
163            // with it rather than linger holding a session.
164            .kill_on_drop(true);
165
166        let mut child = cmd.spawn().map_err(|e| match e.kind() {
167            std::io::ErrorKind::NotFound => {
168                Error::Unavailable(format!("{} not found on PATH", self.bin))
169            }
170            _ => Error::Failed(e.to_string()),
171        })?;
172
173        let mut stdin = child.stdin.take().expect("stdin was piped");
174        let body = prompt(old_content, new_content);
175        // Writing and draining run together: a prompt larger than the pipe
176        // buffer would deadlock if we wrote it all before reading output.
177        let run = async {
178            let write = async move {
179                stdin.write_all(body.as_bytes()).await?;
180                stdin.shutdown().await
181            };
182            let (written, out) = tokio::join!(write, child.wait_with_output());
183            // A child that closed stdin early shows up here as a broken
184            // pipe; its exit status and stderr say more about why than the
185            // write error does, so defer to them.
186            if let Err(e) = written {
187                if e.kind() != std::io::ErrorKind::BrokenPipe {
188                    return Err(e);
189                }
190            }
191            out.map(|o| (o.status, o.stdout, o.stderr))
192        };
193
194        let (status, stdout, stderr) = match timeout(limit, run).await {
195            Err(_) => return Err(Error::TimedOut(limit)),
196            Ok(Err(e)) => return Err(Error::Failed(e.to_string())),
197            Ok(Ok(v)) => v,
198        };
199
200        if !status.success() {
201            let detail = String::from_utf8_lossy(&stderr).trim().to_string();
202            let detail = if detail.is_empty() {
203                "(no stderr)".to_string()
204            } else {
205                detail
206            };
207            return Err(Error::Failed(format!(
208                "exit {}: {}",
209                status
210                    .code()
211                    .map_or_else(|| "signal".into(), |c| c.to_string()),
212                truncate(&detail, DETAIL_LIMIT)
213            )));
214        }
215
216        let out = String::from_utf8_lossy(&stdout);
217        let parsed: CliResult = serde_json::from_str(&out)
218            .map_err(|_| Error::NonJson(truncate(&out, DETAIL_LIMIT).to_string()))?;
219        if parsed.is_error {
220            return Err(Error::Rejected(
221                truncate(&parsed.result, DETAIL_LIMIT).to_string(),
222            ));
223        }
224
225        // A merge that comes back empty while both inputs had content is a
226        // malfunction, not a result — the model refused, or emitted nothing,
227        // or the envelope carried `is_error: false` over an empty body.
228        //
229        // Storing it would be the worst outcome this server can produce:
230        // both machines' notes replaced by "", reported as `merged: true`,
231        // and then written out as an empty file everywhere on the next pull.
232        // Every other failure here degrades to last-write-wins, and so does
233        // this one.
234        if parsed.result.trim().is_empty()
235            && !(old_content.trim().is_empty() && new_content.trim().is_empty())
236        {
237            return Err(Error::EmptyResult);
238        }
239        Ok(parsed.result)
240    }
241
242    /// Runs `claude auth status`, which is a local check and costs no
243    /// tokens. A missing binary is reported as unavailable, never a panic.
244    pub async fn check_status(&self) -> Status {
245        let checked_at = now();
246        let run = Command::new(&self.bin)
247            .args(["auth", "status"])
248            .stdin(Stdio::null())
249            .kill_on_drop(true)
250            .output();
251
252        let out = match timeout(STATUS_TIMEOUT, run).await {
253            Err(_) => {
254                return Status {
255                    checked_at,
256                    error: format!("`{} auth status` timed out", self.bin),
257                    ..Status::default()
258                }
259            }
260            Ok(Err(e)) => {
261                let error = if e.kind() == std::io::ErrorKind::NotFound {
262                    "claude CLI not found on PATH".to_string()
263                } else {
264                    e.to_string()
265                };
266                return Status {
267                    checked_at,
268                    error,
269                    ..Status::default()
270                };
271            }
272            Ok(Ok(out)) => out,
273        };
274
275        if !out.status.success() {
276            let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
277            return Status {
278                checked_at,
279                available: true,
280                logged_in: false,
281                error: if detail.is_empty() {
282                    "`claude auth status` failed".to_string()
283                } else {
284                    truncate(&detail, DETAIL_LIMIT).to_string()
285                },
286            };
287        }
288
289        match serde_json::from_slice::<AuthStatus>(&out.stdout) {
290            Ok(a) => Status {
291                checked_at,
292                available: true,
293                logged_in: a.logged_in,
294                error: String::new(),
295            },
296            Err(_) => Status {
297                checked_at,
298                available: true,
299                logged_in: false,
300                error: "could not parse `claude auth status` output".to_string(),
301            },
302        }
303    }
304}
305
306fn truncate(s: &str, n: usize) -> &str {
307    if s.len() <= n {
308        return s;
309    }
310    let mut end = n;
311    while end > 0 && !s.is_char_boundary(end) {
312        end -= 1;
313    }
314    &s[..end]
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn prompt_labels_both_versions() {
323        let p = prompt("old", "new");
324        assert!(p.contains("--- VERSION A (currently stored) ---\nold"));
325        assert!(p.contains("--- VERSION B (incoming) ---\nnew"));
326    }
327
328    /// No API key may reach the CLI, and the cost-saving flags must stay.
329    #[test]
330    fn the_system_prompt_is_verbatim() {
331        assert!(SYSTEM_PROMPT.starts_with("You are a precise text-merging assistant"));
332        assert!(SYSTEM_PROMPT.ends_with("no code fences, nothing else."));
333    }
334
335    #[tokio::test]
336    async fn a_missing_binary_is_unavailable_not_a_panic() {
337        let m = Merger::new("definitely-not-a-real-binary", Duration::from_secs(1));
338        let st = m.check_status().await;
339        assert!(!st.available);
340        assert!(!st.logged_in);
341        assert!(!st.error.is_empty());
342        assert!(!st.checked_at.is_empty());
343
344        let err = m.merge("a", "b").await.unwrap_err();
345        assert!(matches!(err, Error::Unavailable(_)), "got {err:?}");
346    }
347
348    /// The envelope is what the CLI actually returns; `is_error` must be
349    /// treated as a failure even though the process exits 0.
350    #[tokio::test]
351    async fn parses_the_json_envelope_and_honors_is_error() {
352        let (_ok_dir, bin) = fake_claude(r#"{"is_error":false,"result":"merged!"}"#);
353        assert_eq!(
354            Merger::new(bin, Duration::from_secs(10))
355                .merge("a", "b")
356                .await
357                .unwrap(),
358            "merged!"
359        );
360
361        let (_bad_dir, bin) = fake_claude(r#"{"is_error":true,"result":"nope"}"#);
362        let err = Merger::new(bin, Duration::from_secs(10))
363            .merge("a", "b")
364            .await;
365        assert!(matches!(err, Err(Error::Rejected(_))), "got {err:?}");
366
367        let (_junk_dir, bin) = fake_claude("not json");
368        let err = Merger::new(bin, Duration::from_secs(10))
369            .merge("a", "b")
370            .await;
371        assert!(matches!(err, Err(Error::NonJson(_))), "got {err:?}");
372    }
373
374    #[tokio::test]
375    async fn reads_logged_in_from_auth_status() {
376        let (_dir, bin) = fake_claude(r#"{"loggedIn":true}"#);
377        let st = Merger::new(bin, Duration::from_secs(10))
378            .check_status()
379            .await;
380        assert!(st.available && st.logged_in, "got {st:?}");
381    }
382
383    /// A stand-in `claude` that drains stdin, ignores its arguments and
384    /// prints `out`. The TempDir comes back so the caller keeps the script
385    /// alive for as long as it needs it.
386    fn fake_claude(out: &str) -> (tempfile::TempDir, String) {
387        use std::io::Write;
388        use std::os::unix::fs::PermissionsExt;
389
390        let dir = tempfile::tempdir().unwrap();
391        let path = dir.path().join("claude");
392        let mut f = std::fs::File::create(&path).unwrap();
393        writeln!(f, "#!/bin/sh").unwrap();
394        writeln!(f, "cat > /dev/null").unwrap();
395        writeln!(f, "printf '%s' '{out}'").unwrap();
396        drop(f);
397        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
398        settle(&path);
399        let bin = path.to_str().unwrap().to_string();
400        (dir, bin)
401    }
402
403    /// Waits out `ETXTBSY` on a script this process just wrote.
404    ///
405    /// Writing an executable and then running it is racy in a multi-threaded
406    /// program: another thread's `fork` inherits the still-open write handle
407    /// to this file, and until that child reaches its own `exec` the kernel
408    /// refuses to run the image — `Text file busy`. The window is
409    /// microseconds and only exists between a `File::create` here and some
410    /// other test's spawn, but with a dozen tests spawning processes in
411    /// parallel it does hit, and did: `cargo test --workspace` failed roughly
412    /// one run in four.
413    ///
414    /// The retry belongs here rather than in [`Merger`] because nothing in
415    /// production writes a binary and then runs it. This is an artifact of
416    /// building the stand-in inside the test binary.
417    fn settle(path: &std::path::Path) {
418        use std::process::Stdio;
419
420        for _ in 0..100 {
421            match std::process::Command::new(path)
422                .stdin(Stdio::null())
423                .stdout(Stdio::null())
424                .stderr(Stdio::null())
425                .status()
426            {
427                // Once it execs cleanly, no writer holds the inode and it
428                // will keep exec'ing — nothing reopens it for writing.
429                Ok(_) => return,
430                Err(e) if e.raw_os_error() == Some(libc_etxtbsy()) => {
431                    std::thread::sleep(Duration::from_millis(5));
432                }
433                // Anything else is a real problem the assertions should see.
434                Err(_) => return,
435            }
436        }
437        panic!("{} stayed ETXTBSY for half a second", path.display());
438    }
439
440    /// `ETXTBSY`. Spelled out rather than pulling in `libc` for one integer.
441    fn libc_etxtbsy() -> i32 {
442        26
443    }
444}