From c10d2642e8ebe0c344eae8c90e1c3e951124b78c Mon Sep 17 00:00:00 2001 From: Colin Vaughn Date: Sun, 21 Jun 2026 03:05:39 -0400 Subject: [PATCH 01/10] feat(serve): auto-freshen graph for live-coded files Detect files added/changed/removed since the last extraction and run an incremental rebuild before answering a query, so files an agent writes mid-session are queryable without a separate watch/update. - synaptic-incremental: new `freshen` module (build-provenance manifest + on-disk change detection via the mtime fastpath); `rebuild_with_detect` reuses one scan so the serve catch-up walks the tree once. - serve: debounced on-query staleness gate (`needs_freshen`) + synchronous incremental catch-up (`apply_freshen`) under the rebuild lock, on both the stdio and HTTP transports. Opt-out via SYNAPTIC_SERVE_AUTOFRESH; tunable debounce (SYNAPTIC_SERVE_AUTOFRESH_DEBOUNCE_MS) and cap (SYNAPTIC_SERVE_AUTOFRESH_MAX_FILES). - rebuild: allow a bounded shrink on incremental rebuilds so symbol removals (e.g. deleting a method) propagate; the strict shrink guard is kept for full rebuilds. - extract/update persist the provenance manifest (reusing their existing scan). --- Cargo.lock | 1 + bin/synaptic/src/commands/extract.rs | 7 + bin/synaptic/src/commands/update.rs | 7 + crates/synaptic-incremental/src/freshen.rs | 272 ++++++++++++++++++++ crates/synaptic-incremental/src/lib.rs | 89 +++++-- crates/synaptic-server/Cargo.toml | 1 + crates/synaptic-server/src/http.rs | 9 + crates/synaptic-server/src/lib.rs | 273 ++++++++++++++++++++- 8 files changed, 635 insertions(+), 24 deletions(-) create mode 100644 crates/synaptic-incremental/src/freshen.rs diff --git a/Cargo.lock b/Cargo.lock index 5ec39ac..69e2611 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3325,6 +3325,7 @@ dependencies = [ "synaptic-core", "synaptic-graph", "synaptic-history", + "synaptic-incremental", "synaptic-predict", "synaptic-prs", "synaptic-query", diff --git a/bin/synaptic/src/commands/extract.rs b/bin/synaptic/src/commands/extract.rs index e748798..f1f11a2 100644 --- a/bin/synaptic/src/commands/extract.rs +++ b/bin/synaptic/src/commands/extract.rs @@ -431,6 +431,13 @@ pub(crate) fn run_extract( eprintln!("note: could not write change-detection manifest: {e}"); } } + // Serve catch-up provenance: a code+markdown manifest at a stable location + // (survives `cache clear`) that `synaptic serve` diffs against to learn which + // files an agent added/changed since this build. Distinct from the cache-dir + // manifest above, which only drives the "changes since last build" line. + if let Err(e) = synaptic_incremental::persist_manifest_with(&out_dir, &det) { + eprintln!("note: could not write serve provenance manifest: {e}"); + } println!("Wrote {}/{{{}}}", out_dir.display(), extras); Ok(()) } diff --git a/bin/synaptic/src/commands/update.rs b/bin/synaptic/src/commands/update.rs index bf08751..95476dd 100644 --- a/bin/synaptic/src/commands/update.rs +++ b/bin/synaptic/src/commands/update.rs @@ -83,6 +83,13 @@ pub(crate) fn run_update( let outcome = rebuild(&opts, &changes, existing.as_ref()) .map_err(|e| anyhow::anyhow!("rebuild failed: {e}"))?; + // Refresh the serve catch-up provenance regardless of topology change: a + // content edit that leaves the topology identical still advances the manifest + // so `serve` doesn't re-detect the same file forever. + if let Err(e) = synaptic_incremental::persist_manifest(&out_dir, &root) { + eprintln!("note: could not write serve provenance manifest: {e}"); + } + if !outcome.changed { println!( "No changes — graph is up to date ({} nodes).", diff --git a/crates/synaptic-incremental/src/freshen.rs b/crates/synaptic-incremental/src/freshen.rs new file mode 100644 index 0000000..d26e8a2 --- /dev/null +++ b/crates/synaptic-incremental/src/freshen.rs @@ -0,0 +1,272 @@ +//! Build provenance + on-disk change detection for the serve catch-up path. +//! +//! A build (full `extract`, incremental `update`/`watch`, or a server catch-up) +//! persists a [`Manifest`] under `synaptic-out/` recording the mtime + content +//! hash of every file that fed the graph. The serve process later diffs the +//! current on-disk state against that manifest to learn -- cheaply, with the +//! mtime fastpath -- exactly which files an agent added/changed/removed since the +//! graph was built, so it can run a minimal incremental rebuild before answering +//! a query. The detector deliberately enumerates the *same* input set as +//! [`rebuild`](crate::rebuild) (code + extractable markdown) so it never reports a +//! file the rebuild would ignore (which would otherwise churn forever). + +use std::path::{Path, PathBuf}; + +use synaptic_detect::{detect, DetectResult, FileType, Manifest, ManifestDiff}; + +/// The build manifest's name under the output dir. Hidden + inside +/// `synaptic-out/`, so the watcher's ignore rules already skip it (no +/// self-trigger), alongside `.rebuild.lock` / `.pending_changes`. +const MANIFEST_FILE: &str = ".manifest.json"; + +/// Path of the persisted build manifest under `out_dir`. +pub fn manifest_path(out_dir: &Path) -> PathBuf { + out_dir.join(MANIFEST_FILE) +} + +/// True for markdown files that get structural heading extraction, matching +/// [`rebuild`](crate::rebuild)'s markdown selection. Shared so the change +/// detector and the rebuild agree on exactly which markdown feeds the graph. +pub fn is_extractable_markdown(path: &Path) -> bool { + matches!( + path.extension().and_then(|e| e.to_str()), + Some("md") | Some("mdx") | Some("qmd") + ) +} + +/// The files that feed the graph from a detect result: code + extractable +/// markdown, matching `rebuild`'s `extract_set`. +fn inputs_of(det: &DetectResult) -> Vec { + let mut files: Vec = det.of(FileType::Code).to_vec(); + files.extend( + det.of(FileType::Document) + .iter() + .filter(|p| is_extractable_markdown(p)) + .cloned(), + ); + files +} + +/// The files that feed the graph for `root` (code + extractable markdown). +pub fn graph_input_files(root: &Path) -> Vec { + inputs_of(&detect(root)) +} + +/// Write a build manifest under `out_dir` reflecting the current on-disk state of +/// the graph-input files. Called after every successful build so the serve +/// catch-up has a baseline to diff against. +pub fn persist_manifest(out_dir: &Path, root: &Path) -> std::io::Result<()> { + persist_manifest_with(out_dir, &detect(root)) +} + +/// Like [`persist_manifest`] but reuses an existing detect result instead of +/// walking the tree again -- for callers (the serve catch-up, `extract`) that +/// already scanned. Builds against the prior manifest with the mtime fastpath, +/// so it only re-hashes files whose mtime moved. +pub fn persist_manifest_with(out_dir: &Path, det: &DetectResult) -> std::io::Result<()> { + let mpath = manifest_path(out_dir); + let prior = Manifest::load(&mpath); + let files = inputs_of(det); + let manifest = + Manifest::build_incremental(files.iter().map(PathBuf::as_path), &det.scan_root, &prior); + manifest.save(&mpath) +} + +/// What [`detect_changes`] found on disk since the last build. +#[derive(Debug)] +pub struct ChangeReport { + /// Added/changed/removed/unchanged file keys (repo-relative, POSIX). + pub diff: ManifestDiff, + /// The freshly built manifest, so the caller can persist it without a second + /// filesystem walk. + pub current: Manifest, + /// True when no prior manifest existed and this run only established the + /// baseline (so the diff is empty by construction, not because nothing + /// changed). + pub bootstrapped: bool, + /// The detect result that produced this report, so the caller can feed it + /// straight to `rebuild_with_detect` instead of walking the tree again. + pub det: DetectResult, +} + +impl ChangeReport { + /// No add/change/remove relative to the prior manifest. + pub fn is_empty(&self) -> bool { + self.diff.added.is_empty() && self.diff.changed.is_empty() && self.diff.removed.is_empty() + } + + /// The added + changed + removed paths as a rebuild change set (relative to + /// the repo root; removed paths no longer exist and are evicted by rebuild). + pub fn changed_paths(&self) -> Vec { + self.diff + .added + .iter() + .chain(&self.diff.changed) + .chain(&self.diff.removed) + .map(PathBuf::from) + .collect() + } +} + +/// Diff the current on-disk graph inputs against the manifest persisted under +/// `out_dir`. Uses the mtime fastpath, so unchanged files are stat-only. +/// +/// Bootstrap: when no prior manifest exists (e.g. a graph built by an older +/// binary), this builds and saves the baseline manifest and reports *no changes* +/// -- the loaded graph is assumed to match disk, avoiding a spurious full +/// rebuild on the first query. +pub fn detect_changes(out_dir: &Path, root: &Path) -> ChangeReport { + let mpath = manifest_path(out_dir); + let prior_exists = mpath.exists(); + let prior = Manifest::load(&mpath); + let det = detect(root); + let files = inputs_of(&det); + let root = det.scan_root.as_path(); + let current = if prior_exists { + Manifest::build_incremental(files.iter().map(PathBuf::as_path), root, &prior) + } else { + Manifest::build(files.iter().map(PathBuf::as_path), root) + }; + + if !prior_exists { + let _ = current.save(&mpath); + return ChangeReport { + diff: ManifestDiff::default(), + current, + bootstrapped: true, + det, + }; + } + + let diff = prior.diff(¤t); + ChangeReport { + diff, + current, + bootstrapped: false, + det, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn write(root: &Path, rel: &str, body: &str) { + let p = root.join(rel); + if let Some(parent) = p.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(p, body).unwrap(); + } + + #[test] + fn graph_inputs_include_code_and_markdown_only() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "src/a.py", "x = 1\n"); + write(root, "README.md", "# hi\n"); + write(root, "data.bin", "\x00\x01"); + write(root, "notes.txt", "plain\n"); + + let keys: std::collections::BTreeSet = graph_input_files(root) + .iter() + .map(|p| { + p.strip_prefix(root.canonicalize().unwrap()) + .unwrap_or(p) + .to_string_lossy() + .replace('\\', "/") + }) + .collect(); + assert!(keys.contains("src/a.py"), "code included: {keys:?}"); + assert!(keys.contains("README.md"), "markdown included: {keys:?}"); + assert!(!keys.contains("data.bin"), "binary excluded: {keys:?}"); + assert!(!keys.contains("notes.txt"), "plain text excluded: {keys:?}"); + } + + #[test] + fn detect_changes_bootstraps_when_no_prior_manifest() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let out = root.join("synaptic-out"); + write(root, "a.py", "x = 1\n"); + + let report = detect_changes(&out, root); + assert!(report.bootstrapped, "no prior manifest => bootstrap"); + assert!(report.is_empty(), "bootstrap reports no changes"); + assert!( + manifest_path(&out).exists(), + "bootstrap writes the baseline manifest" + ); + } + + #[test] + fn detect_changes_reports_added_file() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let out = root.join("synaptic-out"); + write(root, "a.py", "x = 1\n"); + persist_manifest(&out, root).unwrap(); + + // Agent writes a brand-new file after the build. + write(root, "b.py", "y = 2\n"); + let report = detect_changes(&out, root); + assert!(!report.is_empty(), "new file detected"); + assert_eq!(report.diff.added, vec!["b.py".to_string()]); + assert_eq!(report.changed_paths(), vec![PathBuf::from("b.py")]); + } + + #[test] + fn detect_changes_reports_changed_and_removed() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let out = root.join("synaptic-out"); + write(root, "a.py", "x = 1\n"); + write(root, "b.py", "y = 2\n"); + persist_manifest(&out, root).unwrap(); + + // a.py edited, b.py deleted. + write(root, "a.py", "x = 99\n"); + fs::remove_file(root.join("b.py")).unwrap(); + let report = detect_changes(&out, root); + assert_eq!(report.diff.changed, vec!["a.py".to_string()]); + assert_eq!(report.diff.removed, vec!["b.py".to_string()]); + } + + #[test] + fn persisting_current_manifest_stops_redetection() { + // After a change is detected and the report's fresh manifest is persisted + // (what the serve catch-up does), the next detect reports nothing -- so a + // content change never makes the server rebuild the same file forever. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let out = root.join("synaptic-out"); + write(root, "a.py", "x = 1\n"); + persist_manifest(&out, root).unwrap(); + + write(root, "b.py", "y = 2\n"); + let report = detect_changes(&out, root); + assert!(!report.is_empty(), "new file detected"); + report.current.save(&manifest_path(&out)).unwrap(); + + let again = detect_changes(&out, root); + assert!( + again.is_empty(), + "re-detection after persisting the manifest must be empty: {:?}", + again.diff + ); + } + + #[test] + fn detect_changes_empty_when_nothing_moved() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let out = root.join("synaptic-out"); + write(root, "a.py", "x = 1\n"); + persist_manifest(&out, root).unwrap(); + + let report = detect_changes(&out, root); + assert!(!report.bootstrapped, "prior manifest existed"); + assert!(report.is_empty(), "no edits => no changes"); + } +} diff --git a/crates/synaptic-incremental/src/lib.rs b/crates/synaptic-incremental/src/lib.rs index 1dc1673..b7d0c30 100644 --- a/crates/synaptic-incremental/src/lib.rs +++ b/crates/synaptic-incremental/src/lib.rs @@ -15,12 +15,17 @@ #![forbid(unsafe_code)] pub mod concurrency; +pub mod freshen; pub mod hooks; pub mod merge_driver; pub mod watch; pub use concurrency::{ drain_pending, merge_changed_paths, queue_pending, try_acquire_lock, RebuildLock, }; +pub use freshen::{ + detect_changes, graph_input_files, is_extractable_markdown, manifest_path, persist_manifest, + persist_manifest_with, ChangeReport, +}; pub use merge_driver::{run_merge_driver, union_graphs, MergeDriverError}; pub use watch::{should_ignore_path, ChangeBatch, DEBOUNCE_MS}; @@ -29,7 +34,7 @@ use std::path::{Path, PathBuf}; use rayon::prelude::*; use synaptic_core::{Edge, GraphData, Hyperedge, Node, NodeId}; -use synaptic_detect::{classify_file, detect, FileType}; +use synaptic_detect::{classify_file, detect, DetectResult, FileType}; use synaptic_extract::cached_extract_source; use synaptic_graph::{ apply_communities, build_from_parts, cluster, deduplicate_entities, guard_shrink, @@ -179,16 +184,25 @@ pub fn rebuild( changes: &ChangeSet, existing: Option<&GraphData>, ) -> Result { - // Canonicalize the root so `detect`'s yielded paths and our `root.join(rel)` - // share one representation (matters for the changed-path to code-file match, - // and keeps rel ids/source_files identical to a full `synaptic extract`). - let root = opts - .root - .canonicalize() - .unwrap_or_else(|_| opts.root.clone()); - let root = root.as_path(); + // `detect` canonicalizes the root internally and exposes it as `scan_root`. + let det = detect(&opts.root); + rebuild_with_detect(opts, changes, existing, &det) +} + +/// Like [`rebuild`] but reuses an existing detect result instead of walking the +/// tree again -- for the serve catch-up, which already scanned to discover the +/// change set. `det.scan_root` is the canonicalized root, so the produced graph +/// is identical to a fresh `rebuild` (the scan is the only thing reused). +pub fn rebuild_with_detect( + opts: &RebuildOptions, + changes: &ChangeSet, + existing: Option<&GraphData>, + det: &DetectResult, +) -> Result { + // The canonical root keeps `root.join(rel)` and rel ids/source_files + // identical to a full `synaptic extract` (matters for changed-path matching). + let root = det.scan_root.as_path(); let root_str = root.to_string_lossy().into_owned(); - let det = detect(root); let code_files: Vec = det.of(FileType::Code).to_vec(); // Markdown is a Document (not Code), but it gets structural heading // extraction too, so extract it alongside code in every rebuild path @@ -197,12 +211,7 @@ pub fn rebuild( let md_files: Vec = det .of(FileType::Document) .iter() - .filter(|p| { - matches!( - p.extension().and_then(|e| e.to_str()), - Some("md") | Some("mdx") | Some("qmd") - ) - }) + .filter(|p| freshen::is_extractable_markdown(p)) .cloned() .collect(); let extract_set: HashSet<&Path> = code_files @@ -391,8 +400,18 @@ pub fn rebuild( } // Refuse a silent shrink (unless forced or an explicit deletion happened). + // An incremental rebuild is scoped to explicitly-changed files, so any shrink + // is bounded to them and expected (e.g. an edit that removes a method), and + // is authorized here; the strict guard still protects full rebuilds, where a + // shrink signals a catastrophic empty extraction. + let incremental = matches!(changes, ChangeSet::Incremental(_)); let existing_n = existing.map(|g| g.nodes.len()).unwrap_or(0); - guard_shrink(kg.node_count(), existing_n, opts.force, had_deletions)?; + guard_shrink( + kg.node_count(), + existing_n, + opts.force, + had_deletions || incremental, + )?; // No-change short-circuit: identical topology means reuse the previous // community assignment, skip re-clustering, and tell the caller nothing needs @@ -617,6 +636,42 @@ mod tests { kg.nodes().map(|n| n.label.clone()).collect() } + #[test] + fn incremental_allows_removing_a_symbol_from_an_existing_file() { + // Editing a file to drop a whole symbol is a net node shrink, but it is + // bounded to that explicitly-changed file and expected, so an incremental + // rebuild must apply it. The shrink guard only protects full rebuilds + // (the catastrophic empty-extraction case). + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write( + root, + "a.py", + "def keep():\n return 1\n\n\ndef drop_me():\n return 2\n", + ); + let opts = RebuildOptions { + root: root.to_path_buf(), + directed: false, + force: false, + }; + let r1 = rebuild(&opts, &ChangeSet::Full, None).unwrap(); + let existing = r1.kg.to_graph_data(); + assert!(labels(&r1.kg).contains("drop_me()"), "symbol present at first"); + + // Remove drop_me() from the still-existing file. + write(root, "a.py", "def keep():\n return 1\n"); + let r2 = rebuild( + &opts, + &ChangeSet::Incremental(vec![PathBuf::from("a.py")]), + Some(&existing), + ) + .expect("incremental rebuild must apply a bounded shrink, not error"); + let l = labels(&r2.kg); + assert!(!l.contains("drop_me()"), "removed symbol is gone: {l:?}"); + assert!(l.contains("keep()"), "kept symbol survives: {l:?}"); + assert!(r2.changed, "topology changed"); + } + #[test] fn rebuild_full_then_incremental_preserves_and_evicts() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/synaptic-server/Cargo.toml b/crates/synaptic-server/Cargo.toml index c818d3a..3342d00 100644 --- a/crates/synaptic-server/Cargo.toml +++ b/crates/synaptic-server/Cargo.toml @@ -11,6 +11,7 @@ serde_json = { workspace = true } synaptic-core = { workspace = true } synaptic-graph = { workspace = true } synaptic-query = { workspace = true } +synaptic-incremental = { workspace = true } synaptic-prs = { workspace = true } synaptic-synql = { workspace = true } synaptic-history = { workspace = true } diff --git a/crates/synaptic-server/src/http.rs b/crates/synaptic-server/src/http.rs index ee5f4de..1b08f3b 100644 --- a/crates/synaptic-server/src/http.rs +++ b/crates/synaptic-server/src/http.rs @@ -263,6 +263,15 @@ async fn handle_post(State(st): State, headers: HeaderMap, body: Byte write_server(&server).maybe_reload(); reloaded = true; } + // On-query catch-up: detect files added/changed since the build (cheap, + // debounced, under the read lock) and incrementally rebuild under the + // write lock before dispatching, so live-coded files are queryable. + if needs_reload { + if let Some(report) = read_server(&server).needs_freshen() { + write_server(&server).apply_freshen(report); + reloaded = true; + } + } (reloaded, read_server(&server).dispatch_request(&req)) }) .await diff --git a/crates/synaptic-server/src/lib.rs b/crates/synaptic-server/src/lib.rs index d6a4c77..68d9f25 100644 --- a/crates/synaptic-server/src/lib.rs +++ b/crates/synaptic-server/src/lib.rs @@ -34,6 +34,8 @@ pub use session::{SessionStore, DEFAULT_SESSION_IDLE}; use std::collections::BTreeMap; use std::io::{BufRead, Write}; use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; use serde_json::{json, Value}; use synaptic_core::{sanitize_label, GraphData, NodeId}; @@ -122,6 +124,58 @@ pub struct Server { /// opt-in (`serve --allow-exec`). When off, `speculate` is neither advertised /// in tools/list nor runnable. allow_exec: bool, + /// On-query catch-up config (repo root, output dir, debounce, caps). `None` + /// disables auto-freshen (e.g. no source root, or no graph path). + freshen: Option, + /// Last time the catch-up staleness walk ran, for debouncing. Interior + /// mutability so the cheap gate can run under the HTTP shared read lock. + last_fresh_check: Mutex>, +} + +/// Configuration for the serve catch-up path: detect files an agent +/// added/changed since the graph was built and incrementally rebuild before +/// answering, so live-coded files are queryable without a separate `watch`. +#[derive(Debug, Clone)] +struct FreshenConfig { + /// Repo root scanned for source changes (the source root). + root: PathBuf, + /// Output dir holding `graph.json`, the manifest, and the rebuild lock. + out_dir: PathBuf, + /// Whether auto-freshen is on (env `SYNAPTIC_SERVE_AUTOFRESH`). + enabled: bool, + /// Minimum gap between staleness walks, so a burst of queries walks once. + debounce: Duration, + /// Skip auto-freshen when more than this many files changed (a branch switch + /// shouldn't block a query on a near-full rebuild); 0 = unlimited. + max_files: usize, +} + +impl FreshenConfig { + /// Derive config from the repo root + graph path, honoring env overrides. + /// Returns `None` (disabling auto-freshen) when there is no graph path to + /// locate the output dir. + fn from_env(root: PathBuf, graph_path: Option<&Path>) -> Option { + let out_dir = graph_path?.parent()?.to_path_buf(); + let enabled = std::env::var("SYNAPTIC_SERVE_AUTOFRESH") + .map(|v| !matches!(v.trim(), "0" | "false" | "no" | "off")) + .unwrap_or(true); + let debounce = std::env::var("SYNAPTIC_SERVE_AUTOFRESH_DEBOUNCE_MS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .map(Duration::from_millis) + .unwrap_or(Duration::from_millis(1000)); + let max_files = std::env::var("SYNAPTIC_SERVE_AUTOFRESH_MAX_FILES") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(500); + Some(FreshenConfig { + root, + out_dir, + enabled, + debounce, + max_files, + }) + } } fn reload_key_for(path: &Path) -> Option<(u64, u64)> { @@ -168,6 +222,8 @@ impl Server { log_path: query_log_path(), source_root: None, allow_exec: false, + freshen: None, + last_fresh_check: Mutex::new(None), } } @@ -188,6 +244,7 @@ impl Server { /// Set the trusted source root for `get_source` (and other code-reading /// tools). Stored as-is; resolution canonicalizes per request. pub fn with_source_root(mut self, root: PathBuf) -> Server { + self.freshen = FreshenConfig::from_env(root.clone(), self.graph_path.as_deref()); self.source_root = Some(root); self } @@ -222,18 +279,125 @@ impl Server { } if let Ok(bytes) = std::fs::read(&path) { if let Ok(gd) = serde_json::from_slice::(&bytes) { - self.kg = KnowledgeGraph::from_graph_data(gd); - self.communities = communities_of(&self.kg); - self.query_index = QueryIndex::build(&self.kg); - self.affected_index = - ReverseImpactIndex::build(&self.kg, DEFAULT_AFFECTED_RELATIONS); - self.stats = graph_stats(&self.kg); - self.god_nodes_all = god_nodes(&self.kg, usize::MAX); + self.reindex_from(KnowledgeGraph::from_graph_data(gd)); self.reload_key = Some(key); } } } + /// Swap in a new graph and rebuild every derived index (query/affected/stats/ + /// god-nodes). Shared by [`maybe_reload`](Server::maybe_reload) and the + /// catch-up path so both refresh the server's view identically. + fn reindex_from(&mut self, kg: KnowledgeGraph) { + self.kg = kg; + self.communities = communities_of(&self.kg); + self.query_index = QueryIndex::build(&self.kg); + self.affected_index = ReverseImpactIndex::build(&self.kg, DEFAULT_AFFECTED_RELATIONS); + self.stats = graph_stats(&self.kg); + self.god_nodes_all = god_nodes(&self.kg, usize::MAX); + } + + /// Cheap, read-lock-safe staleness gate for the catch-up path: debounced so a + /// burst of queries walks the tree at most once per window. Returns the + /// repo-relative paths an agent added/changed/removed since the graph was + /// built, or `None` when auto-freshen is off, within the debounce window, + /// nothing changed, or the change set is too large. + fn needs_freshen(&self) -> Option { + let cfg = self.freshen.as_ref()?; + if !cfg.enabled { + return None; + } + // Debounce: walk the tree at most once per window. Interior mutability so + // this gate runs under the HTTP shared read lock. + { + let mut last = self.last_fresh_check.lock().ok()?; + if let Some(t) = *last { + if t.elapsed() < cfg.debounce { + return None; + } + } + *last = Some(Instant::now()); + } + let report = synaptic_incremental::detect_changes(&cfg.out_dir, &cfg.root); + if report.is_empty() { + return None; + } + let changed = report.changed_paths().len(); + if cfg.max_files != 0 && changed > cfg.max_files { + eprintln!( + "[synaptic] {} files changed since the graph was built (> autofresh max {}); \ + run `synaptic update` to refresh -- serving the current graph.", + changed, cfg.max_files + ); + return None; + } + Some(report) + } + + /// Run a synchronous incremental rebuild under the rebuild lock, persist + /// `graph.json` + the provenance manifest, and refresh the in-memory indices. + /// Reuses the detect result and freshly built manifest from `report` so the + /// whole catch-up walks the tree only once. Best-effort: lock contention or a + /// rebuild error leaves the current graph in place. + fn apply_freshen(&mut self, report: synaptic_incremental::ChangeReport) { + let Some(cfg) = self.freshen.clone() else { + return; + }; + let Some(graph_path) = self.graph_path.clone() else { + return; + }; + // Serialize with `watch`/`update`: if another rebuild holds the lock, + // leave the current graph in place -- that rebuild rewrites graph.json and + // the mtime hot-reload picks it up on a later request. + let _lock = match synaptic_incremental::try_acquire_lock(&cfg.out_dir) { + Ok(Some(guard)) => guard, + Ok(None) => return, + Err(e) => { + eprintln!("[synaptic] auto-freshen: could not acquire rebuild lock: {e}"); + return; + } + }; + let existing = self.kg.to_graph_data(); + let opts = synaptic_incremental::RebuildOptions { + root: cfg.root.clone(), + directed: self.kg.directed, + force: false, + }; + let changes = synaptic_incremental::ChangeSet::Incremental(report.changed_paths()); + // Reuse the scan from detect_changes instead of walking the tree again. + let outcome = match synaptic_incremental::rebuild_with_detect( + &opts, + &changes, + Some(&existing), + &report.det, + ) { + Ok(o) => o, + Err(e) => { + eprintln!("[synaptic] auto-freshen: rebuild failed: {e}"); + return; + } + }; + // Advance provenance even when topology is unchanged (a comment-only edit + // still moves the manifest, so we don't re-detect it on every query). The + // manifest was already built by detect_changes; just persist it. + if let Err(e) = report + .current + .save(&synaptic_incremental::manifest_path(&cfg.out_dir)) + { + eprintln!("[synaptic] auto-freshen: could not write manifest: {e}"); + } + if outcome.changed { + // Persist graph.json so other processes and our own next mtime check + // agree, then update reload_key so that check is a no-op. + if let Ok(bytes) = serde_json::to_vec_pretty(&outcome.kg.to_graph_data()) { + if std::fs::write(&graph_path, &bytes).is_ok() { + self.reload_key = reload_key_for(&graph_path); + } + } + self.reindex_from(outcome.kg); + } + } + fn label_of(&self, id: &NodeId) -> String { self.kg .node(id) @@ -1566,6 +1730,9 @@ impl Server { let method = req.get("method").and_then(Value::as_str).unwrap_or(""); if request_needs_reload(method) { self.maybe_reload(); + if let Some(report) = self.needs_freshen() { + self.apply_freshen(report); + } } self.dispatch_request(req) } @@ -2736,6 +2903,98 @@ mod tests { .to_string() } + #[test] + fn autofresh_picks_up_a_new_file_on_query() { + use std::fs; + // A graph built from alpha.py only. After serving, an agent writes a new + // beta.py and queries it: the on-query catch-up must extract beta.py so + // the new symbol is queryable without any external watch/update. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + let out = root.join("synaptic-out"); + fs::create_dir_all(&out).unwrap(); + fs::write(root.join("alpha.py"), "def alpha_func():\n return 1\n").unwrap(); + + let opts = synaptic_incremental::RebuildOptions { + root: root.clone(), + directed: false, + force: false, + }; + let outcome = + synaptic_incremental::rebuild(&opts, &synaptic_incremental::ChangeSet::Full, None) + .unwrap(); + let graph_path = out.join("graph.json"); + fs::write( + &graph_path, + serde_json::to_vec(&outcome.kg.to_graph_data()).unwrap(), + ) + .unwrap(); + synaptic_incremental::persist_manifest(&out, &root).unwrap(); + + let mut server = Server::load(graph_path).unwrap().with_source_root(root.clone()); + assert!( + !server.kg.nodes().any(|n| n.label.contains("beta_func")), + "beta_func absent before the file is written" + ); + + fs::write(root.join("beta.py"), "def beta_func():\n return 2\n").unwrap(); + let text = call_tool(&mut server, "query_graph", json!({ "question": "beta_func" })); + assert!( + text.contains("beta_func"), + "new file's symbol must be queryable after auto-freshen: {text}" + ); + } + + #[test] + fn autofresh_applies_a_symbol_removal_on_query() { + use std::fs; + // Removing a method from a still-existing file is a bounded shrink; the + // on-query catch-up must apply it so the deleted symbol leaves the graph. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + let out = root.join("synaptic-out"); + fs::create_dir_all(&out).unwrap(); + fs::write( + root.join("m.py"), + "def keep_func():\n return 1\n\n\ndef gone_func():\n return 2\n", + ) + .unwrap(); + + let opts = synaptic_incremental::RebuildOptions { + root: root.clone(), + directed: false, + force: false, + }; + let outcome = + synaptic_incremental::rebuild(&opts, &synaptic_incremental::ChangeSet::Full, None) + .unwrap(); + let graph_path = out.join("graph.json"); + fs::write( + &graph_path, + serde_json::to_vec(&outcome.kg.to_graph_data()).unwrap(), + ) + .unwrap(); + synaptic_incremental::persist_manifest(&out, &root).unwrap(); + + let mut server = Server::load(graph_path).unwrap().with_source_root(root.clone()); + assert!( + server.kg.nodes().any(|n| n.label.contains("gone_func")), + "symbol present before the edit" + ); + + // Delete gone_func() from the file, then query. + fs::write(root.join("m.py"), "def keep_func():\n return 1\n").unwrap(); + let _ = call_tool(&mut server, "query_graph", json!({ "question": "keep_func" })); + assert!( + !server.kg.nodes().any(|n| n.label.contains("gone_func")), + "removed symbol must leave the graph after auto-freshen" + ); + assert!( + server.kg.nodes().any(|n| n.label.contains("keep_func")), + "kept symbol remains" + ); + } + #[test] fn every_tool_and_param_is_documented() { // Findings #1/#3: each tool needs a substantive description, and every From 3bd3e227485995288357132a5eb79570e1630b6a Mon Sep 17 00:00:00 2001 From: Colin Vaughn Date: Sun, 21 Jun 2026 03:09:37 -0400 Subject: [PATCH 02/10] style: apply rustfmt to auto-freshen tests Wrap long assert!/call_tool lines per rustfmt so `cargo fmt --all --check` (the lint CI job) passes. No behavior change. --- crates/synaptic-incremental/src/lib.rs | 5 ++++- crates/synaptic-server/src/lib.rs | 20 ++++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/synaptic-incremental/src/lib.rs b/crates/synaptic-incremental/src/lib.rs index b7d0c30..1eacd2e 100644 --- a/crates/synaptic-incremental/src/lib.rs +++ b/crates/synaptic-incremental/src/lib.rs @@ -656,7 +656,10 @@ mod tests { }; let r1 = rebuild(&opts, &ChangeSet::Full, None).unwrap(); let existing = r1.kg.to_graph_data(); - assert!(labels(&r1.kg).contains("drop_me()"), "symbol present at first"); + assert!( + labels(&r1.kg).contains("drop_me()"), + "symbol present at first" + ); // Remove drop_me() from the still-existing file. write(root, "a.py", "def keep():\n return 1\n"); diff --git a/crates/synaptic-server/src/lib.rs b/crates/synaptic-server/src/lib.rs index 68d9f25..e4c41c2 100644 --- a/crates/synaptic-server/src/lib.rs +++ b/crates/synaptic-server/src/lib.rs @@ -2931,14 +2931,20 @@ mod tests { .unwrap(); synaptic_incremental::persist_manifest(&out, &root).unwrap(); - let mut server = Server::load(graph_path).unwrap().with_source_root(root.clone()); + let mut server = Server::load(graph_path) + .unwrap() + .with_source_root(root.clone()); assert!( !server.kg.nodes().any(|n| n.label.contains("beta_func")), "beta_func absent before the file is written" ); fs::write(root.join("beta.py"), "def beta_func():\n return 2\n").unwrap(); - let text = call_tool(&mut server, "query_graph", json!({ "question": "beta_func" })); + let text = call_tool( + &mut server, + "query_graph", + json!({ "question": "beta_func" }), + ); assert!( text.contains("beta_func"), "new file's symbol must be queryable after auto-freshen: {text}" @@ -2976,7 +2982,9 @@ mod tests { .unwrap(); synaptic_incremental::persist_manifest(&out, &root).unwrap(); - let mut server = Server::load(graph_path).unwrap().with_source_root(root.clone()); + let mut server = Server::load(graph_path) + .unwrap() + .with_source_root(root.clone()); assert!( server.kg.nodes().any(|n| n.label.contains("gone_func")), "symbol present before the edit" @@ -2984,7 +2992,11 @@ mod tests { // Delete gone_func() from the file, then query. fs::write(root.join("m.py"), "def keep_func():\n return 1\n").unwrap(); - let _ = call_tool(&mut server, "query_graph", json!({ "question": "keep_func" })); + let _ = call_tool( + &mut server, + "query_graph", + json!({ "question": "keep_func" }), + ); assert!( !server.kg.nodes().any(|n| n.label.contains("gone_func")), "removed symbol must leave the graph after auto-freshen" From c6f972785ddf3ea97f4d5107cbf4b3a3b4c47a99 Mon Sep 17 00:00:00 2001 From: Colin Vaughn Date: Sun, 21 Jun 2026 09:18:42 -0400 Subject: [PATCH 03/10] docs(readme): lead with monorepo + federated multi-repo scale Surface cross-repo federation as a top-of-README selling point: the same workflow scales from a single small folder to a large monorepo or a fleet of separate repositories federated into one graph with real cross-repo edges. --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d4138a4..a7a361f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,10 @@ Turn any folder of code into a persistent, queryable **knowledge graph**, then work over that graph instead of re-reading the codebase. Synaptic extracts symbols and relationships across 30+ languages with [tree-sitter](https://tree-sitter.github.io/), clusters them into -communities, and surfaces the structurally important pieces. +communities, and surfaces the structurally important pieces. It scales with your codebase: +from a single small folder to a large **monorepo**, or a fleet of **separate repositories +federated into one graph** — with real cross-repo edge resolution that keeps architecture +visible across repo boundaries. On top of the graph it answers structural and architectural queries, traces reverse impact ("what would this change break?"), forecasts and speculatively runs a change before you make From 01e5a2aea1237218d90e233d77b7f1da06f20f15 Mon Sep 17 00:00:00 2001 From: Colin Vaughn Date: Sun, 21 Jun 2026 12:36:17 -0400 Subject: [PATCH 04/10] feat(update): synaptic-upgrade crate backing self-update --- Cargo.lock | 103 +++++++++- Cargo.toml | 6 + crates/synaptic-upgrade/Cargo.toml | 26 +++ crates/synaptic-upgrade/src/check.rs | 136 ++++++++++++++ crates/synaptic-upgrade/src/config.rs | 105 +++++++++++ crates/synaptic-upgrade/src/github.rs | 106 +++++++++++ crates/synaptic-upgrade/src/lib.rs | 26 +++ crates/synaptic-upgrade/src/target.rs | 69 +++++++ crates/synaptic-upgrade/src/updater.rs | 248 +++++++++++++++++++++++++ crates/synaptic-upgrade/src/version.rs | 38 ++++ 10 files changed, 861 insertions(+), 2 deletions(-) create mode 100644 crates/synaptic-upgrade/Cargo.toml create mode 100644 crates/synaptic-upgrade/src/check.rs create mode 100644 crates/synaptic-upgrade/src/config.rs create mode 100644 crates/synaptic-upgrade/src/github.rs create mode 100644 crates/synaptic-upgrade/src/lib.rs create mode 100644 crates/synaptic-upgrade/src/target.rs create mode 100644 crates/synaptic-upgrade/src/updater.rs create mode 100644 crates/synaptic-upgrade/src/version.rs diff --git a/Cargo.lock b/Cargo.lock index 69e2611..e8043cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,15 @@ dependencies = [ "object", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arcstr" version = "1.2.0" @@ -350,7 +359,7 @@ dependencies = [ "log", "quick-xml", "serde", - "zip", + "zip 7.2.0", ] [[package]] @@ -706,6 +715,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "difflib" version = "0.4.0" @@ -838,6 +858,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -856,6 +886,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ + "crc32fast", "miniz_oxide", "zlib-rs", ] @@ -2459,7 +2490,7 @@ version = "0.95.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f281b687352597d29efaad39701d1167d5c48aa76fb973e392bc13e9d44e7f36" dependencies = [ - "zip", + "zip 7.2.0", ] [[package]] @@ -2555,6 +2586,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + [[package]] name = "semver" version = "1.0.28" @@ -3378,6 +3420,25 @@ dependencies = [ "thiserror", ] +[[package]] +name = "synaptic-upgrade" +version = "0.3.0" +dependencies = [ + "anyhow", + "flate2", + "reqwest", + "self-replace", + "semver", + "serde", + "serde_json", + "sha2 0.11.0", + "tar", + "tempfile", + "thiserror", + "toml", + "zip 2.4.2", +] + [[package]] name = "synaptic-workspace" version = "0.3.0" @@ -3418,6 +3479,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4762,6 +4834,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xxhash-rust" version = "0.8.15" @@ -4871,6 +4953,23 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + [[package]] name = "zip" version = "7.2.0" diff --git a/Cargo.toml b/Cargo.toml index dc972c0..aae741a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ synaptic-predict = { path = "crates/synaptic-predict" } synaptic-sandbox = { path = "crates/synaptic-sandbox" } synaptic-eval = { path = "crates/synaptic-eval" } synaptic-sqlaudit = { path = "crates/synaptic-sqlaudit" } +synaptic-upgrade = { path = "crates/synaptic-upgrade" } clap = { version = "4", features = ["derive"] } anyhow = "1" rayon = "1" @@ -103,6 +104,11 @@ strsim = "0.11" tiktoken-rs = "0.12" sha2 = "0.11" hmac = "0.13" +semver = "1" +self-replace = "1" +zip = { version = "2", default-features = false, features = ["deflate"] } +flate2 = "1" +tar = "0.4" tempfile = "3" criterion = "0.8" proptest = "1" diff --git a/crates/synaptic-upgrade/Cargo.toml b/crates/synaptic-upgrade/Cargo.toml new file mode 100644 index 0000000..78321d7 --- /dev/null +++ b/crates/synaptic-upgrade/Cargo.toml @@ -0,0 +1,26 @@ +[package] +# Crate name avoids the substrings "update"/"setup"/"install"/"patch": the +# Windows installer-detection heuristic force-elevates (UAC) any executable whose +# file name contains them, which would break this crate's own test binary. The +# user-facing command is still `synaptic self-update`. +name = "synaptic-upgrade" +edition.workspace = true +version.workspace = true +license.workspace = true +rust-version.workspace = true +description = "Opt-in self-update for the Synaptic CLI (GitHub Releases)." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +sha2 = { workspace = true } +semver = { workspace = true } +reqwest = { workspace = true, features = ["blocking"] } +zip = { workspace = true } +flate2 = { workspace = true } +tar = { workspace = true } +self-replace = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/synaptic-upgrade/src/check.rs b/crates/synaptic-upgrade/src/check.rs new file mode 100644 index 0000000..26f24ef --- /dev/null +++ b/crates/synaptic-upgrade/src/check.rs @@ -0,0 +1,136 @@ +//! The opt-in, throttled background update check. + +use std::path::Path; + +use crate::config::{config_path, UpdateConfig}; +use crate::github::{latest_release, Release}; +use crate::version::version_is_newer; + +/// Current epoch seconds (lives here so deterministic code stays clock-free). +fn now_secs() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +/// Background check called once per CLI invocation. Returns a one-line notice to +/// print to stderr when an update is available, or `None`. Never blocks for long +/// and never errors: any network/parse failure is swallowed (returns `None`). +/// +/// Honors a hard override: `SYNAPTIC_UPDATE_CHECK=0` disables the check even when +/// the config is enabled (e.g. for CI). +pub fn maybe_notify(current_version: &str) -> Option { + if std::env::var("SYNAPTIC_UPDATE_CHECK").as_deref() == Ok("0") { + return None; + } + maybe_notify_with(&config_path(), current_version, now_secs(), || { + latest_release().ok() + }) +} + +/// Testable core: explicit config path, clock, and release fetcher. +pub fn maybe_notify_with( + path: &Path, + current_version: &str, + now: f64, + fetch: impl FnOnce() -> Option, +) -> Option { + let mut cfg = UpdateConfig::load(path).unwrap_or_default(); + if !cfg.enabled || !cfg.due(now) { + return None; + } + // Stamp before fetching so a flaky network does not retry every invocation. + cfg.last_check = Some(now); + let _ = cfg.save(path); + + let latest = fetch()?; + if version_is_newer(current_version, &latest.version) { + Some(format!( + "(note) Synaptic {} is available - run `synaptic self-update`", + latest.version.trim_start_matches('v') + )) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::UpdateConfig; + use crate::github::Release; + + fn rel(v: &str) -> Release { + Release { + version: v.into(), + notes: String::new(), + assets: vec![], + } + } + + #[test] + fn disabled_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("update.toml"); + UpdateConfig { + enabled: false, + last_check: None, + } + .save(&path) + .unwrap(); + let out = maybe_notify_with(&path, "0.3.0", 1000.0, || Some(rel("0.9.9"))); + assert!(out.is_none()); + } + + #[test] + fn throttled_returns_none_and_does_not_fetch() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("update.toml"); + UpdateConfig { + enabled: true, + last_check: Some(1000.0), + } + .save(&path) + .unwrap(); + let mut fetched = false; + let out = maybe_notify_with(&path, "0.3.0", 1000.0 + 60.0, || { + fetched = true; + Some(rel("9.9.9")) + }); + assert!(out.is_none()); + assert!(!fetched, "should not fetch within the throttle window"); + } + + #[test] + fn enabled_and_due_with_newer_returns_notice_and_stamps() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("update.toml"); + UpdateConfig { + enabled: true, + last_check: None, + } + .save(&path) + .unwrap(); + let out = maybe_notify_with(&path, "0.3.0", 5000.0, || Some(rel("v0.3.1"))); + assert!(out.unwrap().contains("0.3.1")); + // last_check advanced so the next call is throttled. + let cfg = UpdateConfig::load(&path).unwrap(); + assert_eq!(cfg.last_check, Some(5000.0)); + } + + #[test] + fn up_to_date_returns_none_but_still_stamps() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("update.toml"); + UpdateConfig { + enabled: true, + last_check: None, + } + .save(&path) + .unwrap(); + let out = maybe_notify_with(&path, "0.3.1", 5000.0, || Some(rel("0.3.1"))); + assert!(out.is_none()); + assert_eq!(UpdateConfig::load(&path).unwrap().last_check, Some(5000.0)); + } +} diff --git a/crates/synaptic-upgrade/src/config.rs b/crates/synaptic-upgrade/src/config.rs new file mode 100644 index 0000000..ef4680b --- /dev/null +++ b/crates/synaptic-upgrade/src/config.rs @@ -0,0 +1,105 @@ +//! The persisted opt-in config at `~/.synaptic/update.toml`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Seconds between background update checks (24h). +const CHECK_INTERVAL_SECS: f64 = 86_400.0; + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct UpdateConfig { + /// Whether the background update check runs. + #[serde(default)] + pub enabled: bool, + /// Epoch seconds of the last completed background check (f64 for parity with + /// the manifest `mtime` convention used elsewhere in the project). + #[serde(default)] + pub last_check: Option, +} + +impl UpdateConfig { + /// Load the config, returning the default (disabled) when the file is absent. + pub fn load(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => toml::from_str(&text).with_context(|| format!("parsing {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } + } + + /// Persist the config, creating the parent directory if needed. + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + let text = toml::to_string_pretty(self).context("serializing update config")?; + std::fs::write(path, text).with_context(|| format!("writing {}", path.display())) + } + + /// Whether a background check is due as of `now` (epoch seconds). + pub fn due(&self, now: f64) -> bool { + match self.last_check { + None => true, + Some(t) => now - t >= CHECK_INTERVAL_SECS, + } + } +} + +/// The default config path: `~/.synaptic/update.toml` +/// (`%USERPROFILE%\.synaptic\update.toml` on Windows). Falls back to +/// `.synaptic/update.toml` in the CWD when no home directory is set. Mirrors the +/// home resolution used by the global graph store. +pub fn config_path() -> PathBuf { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from); + let base = match home { + Some(h) => h.join(".synaptic"), + None => PathBuf::from(".synaptic"), + }; + base.join("update.toml") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_to_disabled_when_missing() { + let dir = tempfile::tempdir().unwrap(); + let cfg = UpdateConfig::load(&dir.path().join("update.toml")).unwrap(); + assert!(!cfg.enabled); + assert!(cfg.last_check.is_none()); + } + + #[test] + fn round_trips_through_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("update.toml"); + let cfg = UpdateConfig { + enabled: true, + last_check: Some(1_700_000_000.0), + }; + cfg.save(&path).unwrap(); + let back = UpdateConfig::load(&path).unwrap(); + assert_eq!(back, cfg); + } + + #[test] + fn due_respects_24h_window() { + let never = UpdateConfig { + enabled: true, + last_check: None, + }; + assert!(never.due(1_000.0)); + let fresh = UpdateConfig { + enabled: true, + last_check: Some(1_000.0), + }; + assert!(!fresh.due(1_000.0 + 3_600.0)); // 1h later: not due + assert!(fresh.due(1_000.0 + 86_400.0)); // exactly 24h later: due + } +} diff --git a/crates/synaptic-upgrade/src/github.rs b/crates/synaptic-upgrade/src/github.rs new file mode 100644 index 0000000..dccb889 --- /dev/null +++ b/crates/synaptic-upgrade/src/github.rs @@ -0,0 +1,106 @@ +//! Fetch the latest GitHub release metadata for the Synaptic repo. + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use crate::{REPO_NAME, REPO_OWNER}; + +/// One downloadable release asset. +#[derive(Debug, Clone, PartialEq)] +pub struct Asset { + pub name: String, + pub url: String, +} + +/// The subset of a GitHub release we use. +#[derive(Debug, Clone, PartialEq)] +pub struct Release { + /// The tag name, e.g. "v0.3.1". + pub version: String, + /// The release notes body (may be empty). + pub notes: String, + pub assets: Vec, +} + +// Wire shapes matching the GitHub REST response. +#[derive(Deserialize)] +struct WireRelease { + tag_name: String, + #[serde(default)] + body: Option, + #[serde(default)] + assets: Vec, +} + +#[derive(Deserialize)] +struct WireAsset { + name: String, + browser_download_url: String, +} + +/// Parse a `/releases/latest` JSON body into a [`Release`]. +pub fn parse_latest(json: &str) -> Result { + let w: WireRelease = serde_json::from_str(json).context("parsing release JSON")?; + Ok(Release { + version: w.tag_name, + notes: w.body.unwrap_or_default(), + assets: w + .assets + .into_iter() + .map(|a| Asset { + name: a.name, + url: a.browser_download_url, + }) + .collect(), + }) +} + +/// Fetch the latest release from GitHub. Short timeout; honors `GITHUB_TOKEN` +/// (optional, raises the anonymous rate limit). Network failures are surfaced as +/// errors for the caller to handle (the background check swallows them). +pub fn latest_release() -> Result { + let url = format!("https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest"); + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent(concat!("synaptic-update/", env!("CARGO_PKG_VERSION"))) + .build() + .context("building HTTP client")?; + let mut req = client + .get(&url) + .header("Accept", "application/vnd.github+json"); + if let Ok(token) = std::env::var("GITHUB_TOKEN") { + if !token.is_empty() { + req = req.header("Authorization", format!("Bearer {token}")); + } + } + let resp = req.send().context("requesting latest release")?; + let resp = resp.error_for_status().context("GitHub returned an error")?; + let body = resp.text().context("reading release body")?; + parse_latest(&body) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"{ + "tag_name": "v0.3.1", + "body": "Changes - fix things", + "assets": [ + {"name": "synaptic-x86_64-unknown-linux-gnu.tar.gz", + "browser_download_url": "https://example.com/a.tar.gz"}, + {"name": "synaptic-x86_64-unknown-linux-gnu.tar.gz.sha256", + "browser_download_url": "https://example.com/a.sha256"} + ] + }"#; + + #[test] + fn parses_release_metadata() { + let r = parse_latest(SAMPLE).unwrap(); + assert_eq!(r.version, "v0.3.1"); + assert_eq!(r.notes, "Changes - fix things"); + assert_eq!(r.assets.len(), 2); + let a = r.assets.iter().find(|a| a.name.ends_with(".tar.gz")).unwrap(); + assert_eq!(a.url, "https://example.com/a.tar.gz"); + } +} diff --git a/crates/synaptic-upgrade/src/lib.rs b/crates/synaptic-upgrade/src/lib.rs new file mode 100644 index 0000000..5f28556 --- /dev/null +++ b/crates/synaptic-upgrade/src/lib.rs @@ -0,0 +1,26 @@ +//! Opt-in self-update for the Synaptic CLI. +//! +//! Nothing here touches the network or the binary unless the user has opted in +//! (`self-update --enable` for the background check) or explicitly run the +//! update command. See the [`check`] module for the throttled background notice +//! and the [`updater`] module for the actual download/verify/replace pipeline. + +pub mod check; +pub mod config; +pub mod github; +pub mod target; +pub mod updater; +pub mod version; + +pub use config::{config_path, UpdateConfig}; +pub use github::{latest_release, Asset, Release}; +pub use version::version_is_newer; + +/// The repository self-update queries for releases. +pub const REPO_OWNER: &str = "ColinVaughn"; +pub const REPO_NAME: &str = "Synaptic"; + +/// Human-facing releases page, shown when no prebuilt asset fits the platform. +pub fn releases_url() -> String { + format!("https://github.com/{REPO_OWNER}/{REPO_NAME}/releases") +} diff --git a/crates/synaptic-upgrade/src/target.rs b/crates/synaptic-upgrade/src/target.rs new file mode 100644 index 0000000..28f66f4 --- /dev/null +++ b/crates/synaptic-upgrade/src/target.rs @@ -0,0 +1,69 @@ +//! Map the running platform to the release asset built by +//! `.github/workflows/release.yml`. + +/// The release archive file name for a target triple. +/// Windows targets ship a `.zip`; everything else ships a `.tar.gz`. +pub fn archive_name(triple: &str) -> String { + if triple.contains("windows") { + format!("synaptic-{triple}.zip") + } else { + format!("synaptic-{triple}.tar.gz") + } +} + +/// The checksum sidecar name for a target triple. +pub fn sha256_name(triple: &str) -> String { + format!("{}.sha256", archive_name(triple)) +} + +/// The bare binary file name inside the archive (`synaptic`/`synaptic.exe`). +pub fn binary_name(stem: &str) -> String { + if cfg!(windows) { + format!("{stem}.exe") + } else { + stem.to_string() + } +} + +/// The target triple for the running platform, or `None` when no prebuilt +/// release asset is published for it. The four arms match the release matrix. +pub fn current_target() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"), + ("macos", "aarch64") => Some("aarch64-apple-darwin"), + ("macos", "x86_64") => Some("x86_64-apple-darwin"), + ("windows", "x86_64") => Some("x86_64-pc-windows-msvc"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unix_triple_uses_tar_gz() { + assert_eq!( + archive_name("x86_64-unknown-linux-gnu"), + "synaptic-x86_64-unknown-linux-gnu.tar.gz" + ); + assert_eq!( + sha256_name("x86_64-unknown-linux-gnu"), + "synaptic-x86_64-unknown-linux-gnu.tar.gz.sha256" + ); + } + + #[test] + fn windows_triple_uses_zip() { + assert_eq!( + archive_name("x86_64-pc-windows-msvc"), + "synaptic-x86_64-pc-windows-msvc.zip" + ); + } + + #[test] + fn current_target_is_some_on_supported_hosts() { + // The CI matrix only runs on the four supported targets. + assert!(current_target().is_some()); + } +} diff --git a/crates/synaptic-upgrade/src/updater.rs b/crates/synaptic-upgrade/src/updater.rs new file mode 100644 index 0000000..2c7f897 --- /dev/null +++ b/crates/synaptic-upgrade/src/updater.rs @@ -0,0 +1,248 @@ +//! Download the matching release archive, verify its checksum, extract the +//! binaries, and atomically replace the running executable. + +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Context, Result}; +use sha2::{Digest, Sha256}; + +use crate::github::{Asset, Release}; +use crate::target::{archive_name, binary_name, sha256_name}; + +/// Find a release asset by exact file name. +pub fn find_asset<'a>(release: &'a Release, name: &str) -> Option<&'a Asset> { + release.assets.iter().find(|a| a.name == name) +} + +/// Verify `bytes` hashes to the hex digest at the start of `expected` +/// (sidecar files are often " "). Case-insensitive. +pub fn verify_sha256(bytes: &[u8], expected: &str) -> bool { + let want = match expected.split_whitespace().next() { + Some(t) => t.to_ascii_lowercase(), + None => return false, + }; + let mut h = Sha256::new(); + h.update(bytes); + let got = h.finalize(); + let got_hex: String = got.iter().map(|b| format!("{b:02x}")).collect(); + got_hex == want +} + +/// Download `url` into memory (blocking). +fn download(url: &str) -> Result> { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .user_agent(concat!("synaptic-update/", env!("CARGO_PKG_VERSION"))) + .build()?; + let resp = client.get(url).send().context("downloading asset")?; + let resp = resp.error_for_status().context("download failed")?; + Ok(resp.bytes().context("reading download body")?.to_vec()) +} + +/// Extract the binary named `stem` (e.g. "synaptic") from a release archive into +/// `dest_dir`, returning the written path. Dispatches on the archive extension. +pub fn extract_binary(archive: &Path, stem: &str, dest_dir: &Path) -> Result { + let want = binary_name(stem); + let name = archive.to_string_lossy(); + if name.ends_with(".zip") { + extract_from_zip(archive, &want, dest_dir) + } else { + extract_from_tar_gz(archive, &want, dest_dir) + } +} + +fn extract_from_tar_gz(archive: &Path, want: &str, dest_dir: &Path) -> Result { + let f = File::open(archive).with_context(|| format!("opening {}", archive.display()))?; + let mut ar = tar::Archive::new(flate2::read::GzDecoder::new(f)); + for entry in ar.entries().context("reading tar entries")? { + let mut e = entry.context("reading tar entry")?; + let path = e.path().context("entry path")?.into_owned(); + if path.file_name().and_then(|s| s.to_str()) == Some(want) { + let out = dest_dir.join(want); + e.unpack(&out).with_context(|| format!("unpacking {want}"))?; + return Ok(out); + } + } + bail!("{want} not found in {}", archive.display()) +} + +fn extract_from_zip(archive: &Path, want: &str, dest_dir: &Path) -> Result { + let f = File::open(archive).with_context(|| format!("opening {}", archive.display()))?; + let mut zip = zip::ZipArchive::new(f).context("opening zip")?; + for i in 0..zip.len() { + let mut file = zip.by_index(i).context("reading zip entry")?; + let name = file.enclosed_name(); + let matches = name + .as_ref() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + == Some(want); + if matches { + let out = dest_dir.join(want); + let mut buf = Vec::new(); + file.read_to_end(&mut buf).context("reading zip member")?; + std::fs::write(&out, &buf).with_context(|| format!("writing {want}"))?; + return Ok(out); + } + } + bail!("{want} not found in {}", archive.display()) +} + +#[cfg(unix)] +fn make_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms)?; + Ok(()) +} + +#[cfg(not(unix))] +fn make_executable(_path: &Path) -> Result<()> { + Ok(()) +} + +/// Run the full update: download + verify + extract + replace the running exe +/// (and best-effort the sibling alias). `triple` is the current platform target. +pub fn apply_update(release: &Release, triple: &str) -> Result<()> { + let archive_file = archive_name(triple); + let asset = find_asset(release, &archive_file) + .ok_or_else(|| anyhow!("release {} has no asset {archive_file}", release.version))?; + + println!("Downloading {archive_file} ..."); + let bytes = download(&asset.url)?; + + // Verify against the sha256 sidecar when present; warn (don't fail) if the + // release predates checksum publishing. + match find_asset(release, &sha256_name(triple)) { + Some(sidecar) => { + let sum = download(&sidecar.url)?; + let sum = String::from_utf8_lossy(&sum); + if !verify_sha256(&bytes, &sum) { + bail!("checksum mismatch for {archive_file} - aborting"); + } + println!("Checksum verified."); + } + None => { + eprintln!("warning: no checksum published for this release; skipping verification") + } + } + + let tmp = tempfile::tempdir().context("creating temp dir")?; + let archive_path = tmp.path().join(&archive_file); + std::fs::write(&archive_path, &bytes).context("writing archive to temp")?; + + // Replace the running executable (whatever its name) with the matching new + // binary, then best-effort replace the sibling alias. + let current = std::env::current_exe().context("resolving current exe")?; + let cur_stem = if current + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s == "syn") + .unwrap_or(false) + { + "syn" + } else { + "synaptic" + }; + let sibling_stem = if cur_stem == "syn" { "synaptic" } else { "syn" }; + + let new_self = extract_binary(&archive_path, cur_stem, tmp.path())?; + make_executable(&new_self)?; + self_replace::self_replace(&new_self).context("replacing running executable")?; + + if let Ok(new_sibling) = extract_binary(&archive_path, sibling_stem, tmp.path()) { + let _ = make_executable(&new_sibling); + let sibling_path = current.with_file_name(binary_name(sibling_stem)); + if let Err(e) = std::fs::copy(&new_sibling, &sibling_path) { + eprintln!( + "warning: updated {cur_stem} but could not update the {sibling_stem} alias at {}: {e}", + sibling_path.display() + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::github::{Asset, Release}; + + #[test] + fn verify_sha256_matches_and_rejects() { + let bytes = b"hello world"; + // sha256("hello world") + let good = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; + assert!(verify_sha256(bytes, good)); + // sidecar files often look like " filename" + assert!(verify_sha256(bytes, &format!("{good} synaptic.tar.gz"))); + assert!(!verify_sha256(bytes, "deadbeef")); + } + + #[test] + fn find_asset_by_name() { + let r = Release { + version: "v1".into(), + notes: String::new(), + assets: vec![ + Asset { + name: "a.zip".into(), + url: "u1".into(), + }, + Asset { + name: "b.tar.gz".into(), + url: "u2".into(), + }, + ], + }; + assert_eq!(find_asset(&r, "b.tar.gz").unwrap().url, "u2"); + assert!(find_asset(&r, "missing").is_none()); + } + + #[cfg(unix)] + #[test] + fn extracts_named_binary_from_tar_gz() { + use flate2::write::GzEncoder; + use flate2::Compression; + let dir = tempfile::tempdir().unwrap(); + let archive = dir.path().join("a.tar.gz"); + { + let f = std::fs::File::create(&archive).unwrap(); + let enc = GzEncoder::new(f, Compression::default()); + let mut tar = tar::Builder::new(enc); + let mut header = tar::Header::new_gnu(); + let data = b"#!/bin/true\n"; + header.set_size(data.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + tar.append_data(&mut header, "synaptic-x/synaptic", &data[..]) + .unwrap(); + tar.into_inner().unwrap().finish().unwrap(); + } + let out = extract_binary(&archive, "synaptic", dir.path()).unwrap(); + assert!(out.exists()); + assert_eq!(std::fs::read(&out).unwrap(), b"#!/bin/true\n"); + } + + #[cfg(windows)] + #[test] + fn extracts_named_binary_from_zip() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let archive = dir.path().join("a.zip"); + { + let f = std::fs::File::create(&archive).unwrap(); + let mut zip = zip::ZipWriter::new(f); + let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default(); + zip.start_file("synaptic-x/synaptic.exe", opts).unwrap(); + zip.write_all(b"MZ binary").unwrap(); + zip.finish().unwrap(); + } + let out = extract_binary(&archive, "synaptic", dir.path()).unwrap(); + assert!(out.exists()); + assert_eq!(std::fs::read(&out).unwrap(), b"MZ binary"); + } +} diff --git a/crates/synaptic-upgrade/src/version.rs b/crates/synaptic-upgrade/src/version.rs new file mode 100644 index 0000000..f51e282 --- /dev/null +++ b/crates/synaptic-upgrade/src/version.rs @@ -0,0 +1,38 @@ +//! Semver comparison tolerant of a leading `v` (release tags are `vX.Y.Z`). + +use semver::Version; + +fn parse(s: &str) -> Option { + Version::parse(s.trim().trim_start_matches('v')).ok() +} + +/// Whether `latest` is a strictly newer semantic version than `current`. +/// Returns `false` if either string fails to parse (never offers a bad update). +pub fn version_is_newer(current: &str, latest: &str) -> bool { + match (parse(current), parse(latest)) { + (Some(c), Some(l)) => l > c, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn newer_patch_is_newer() { + assert!(version_is_newer("0.3.0", "0.3.1")); + assert!(version_is_newer("0.3.0", "v0.3.1")); // leading v tolerated + } + + #[test] + fn same_or_older_is_not_newer() { + assert!(!version_is_newer("0.3.1", "0.3.1")); + assert!(!version_is_newer("0.3.2", "0.3.1")); + } + + #[test] + fn unparseable_is_not_newer() { + assert!(!version_is_newer("0.3.0", "not-a-version")); + } +} From e3d74454415d265eb339d349b147ba2689726061 Mon Sep 17 00:00:00 2001 From: Colin Vaughn Date: Sun, 21 Jun 2026 12:39:28 -0400 Subject: [PATCH 05/10] feat(update): self-update CLI command + opt-in background notice --- Cargo.lock | 1 + bin/synaptic/Cargo.toml | 1 + bin/synaptic/src/cli.rs | 19 +++++++ bin/synaptic/src/commands/mod.rs | 1 + bin/synaptic/src/commands/self_update.rs | 72 ++++++++++++++++++++++++ bin/synaptic/src/lib.rs | 15 +++++ bin/synaptic/tests/self_upgrade_cli.rs | 46 +++++++++++++++ 7 files changed, 155 insertions(+) create mode 100644 bin/synaptic/src/commands/self_update.rs create mode 100644 bin/synaptic/tests/self_upgrade_cli.rs diff --git a/Cargo.lock b/Cargo.lock index e8043cb..cc01a81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3074,6 +3074,7 @@ dependencies = [ "synaptic-skillgen", "synaptic-sqlaudit", "synaptic-synql", + "synaptic-upgrade", "synaptic-workspace", "tempfile", "tokio", diff --git a/bin/synaptic/Cargo.toml b/bin/synaptic/Cargo.toml index 6afe584..178c5ea 100644 --- a/bin/synaptic/Cargo.toml +++ b/bin/synaptic/Cargo.toml @@ -59,6 +59,7 @@ synaptic-predict = { workspace = true } synaptic-sandbox = { workspace = true } synaptic-eval = { workspace = true } synaptic-sqlaudit = { workspace = true } +synaptic-upgrade = { workspace = true } clap = { workspace = true } anyhow = { workspace = true } rayon = { workspace = true } diff --git a/bin/synaptic/src/cli.rs b/bin/synaptic/src/cli.rs index db3608e..a5daae3 100644 --- a/bin/synaptic/src/cli.rs +++ b/bin/synaptic/src/cli.rs @@ -475,6 +475,25 @@ pub(crate) enum Cmd { #[command(subcommand)] action: SqlAction, }, + /// Update the Synaptic binary to the latest GitHub release (opt-in). + /// Bare: check and, if newer, prompt to download + replace. `--enable` / + /// `--disable` toggle the background "update available" notice (off by + /// default; persisted to ~/.synaptic/update.toml). `--check` reports + /// availability without downloading. + SelfUpdate { + /// Enable the background update-available notice and exit. + #[arg(long, conflicts_with_all = ["disable", "check", "yes"])] + enable: bool, + /// Disable the background update-available notice and exit. + #[arg(long, conflicts_with_all = ["enable", "check", "yes"])] + disable: bool, + /// Report whether an update is available, then exit (no download). + #[arg(long)] + check: bool, + /// Skip the confirmation prompt before downloading and replacing. + #[arg(long, short = 'y')] + yes: bool, + }, } #[derive(Subcommand)] diff --git a/bin/synaptic/src/commands/mod.rs b/bin/synaptic/src/commands/mod.rs index a662724..baf7c21 100644 --- a/bin/synaptic/src/commands/mod.rs +++ b/bin/synaptic/src/commands/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod prs; pub(crate) mod query; pub(crate) mod refactor; pub(crate) mod search; +pub(crate) mod self_update; pub(crate) mod serve; pub(crate) mod skill; pub(crate) mod speculate; diff --git a/bin/synaptic/src/commands/self_update.rs b/bin/synaptic/src/commands/self_update.rs new file mode 100644 index 0000000..f71e438 --- /dev/null +++ b/bin/synaptic/src/commands/self_update.rs @@ -0,0 +1,72 @@ +//! `self-update` command: opt-in self-replacement from GitHub Releases. + +use std::io::Write; + +use anyhow::{Context, Result}; +use synaptic_upgrade::config::{config_path, UpdateConfig}; +use synaptic_upgrade::{github, releases_url, target, updater, version_is_newer}; + +const CURRENT: &str = env!("CARGO_PKG_VERSION"); + +pub(crate) fn run_self_update(enable: bool, disable: bool, check: bool, yes: bool) -> Result<()> { + if enable || disable { + let path = config_path(); + let mut cfg = UpdateConfig::load(&path).unwrap_or_default(); + cfg.enabled = enable; // exactly one of enable/disable is set (clap-enforced) + cfg.save(&path)?; + println!( + "Background update check {}.", + if enable { "enabled" } else { "disabled" } + ); + return Ok(()); + } + + let release = github::latest_release().context("checking for the latest release")?; + if !version_is_newer(CURRENT, &release.version) { + println!("Synaptic is up to date ({CURRENT})."); + return Ok(()); + } + + let latest = release.version.trim_start_matches('v'); + println!("Update available: {CURRENT} -> {latest}"); + if check { + return Ok(()); + } + + let triple = match target::current_target() { + Some(t) => t, + None => { + println!( + "No prebuilt binary is published for this platform.\n\ + Download or build manually from {}", + releases_url() + ); + return Ok(()); + } + }; + + if !release.notes.trim().is_empty() { + println!("\nRelease notes:\n{}\n", release.notes.trim()); + } + + if !yes && !confirm("Download and replace the current binary? [y/N] ")? { + println!("Aborted."); + return Ok(()); + } + + updater::apply_update(&release, triple)?; + println!("Updated to {latest}. Restart synaptic to use the new version."); + Ok(()) +} + +/// Prompt on stderr, read a line from stdin, return true only for y/yes. +fn confirm(prompt: &str) -> Result { + eprint!("{prompt}"); + std::io::stderr().flush().ok(); + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .context("reading confirmation")?; + let ans = line.trim().to_ascii_lowercase(); + Ok(ans == "y" || ans == "yes") +} diff --git a/bin/synaptic/src/lib.rs b/bin/synaptic/src/lib.rs index d194efa..6f2d3e6 100644 --- a/bin/synaptic/src/lib.rs +++ b/bin/synaptic/src/lib.rs @@ -24,6 +24,7 @@ use commands::prs::run_prs; use commands::query::{run_affected, run_explain, run_path, run_query}; use commands::refactor::run_refactor; use commands::search::run_search; +use commands::self_update::run_self_update; use commands::serve::run_serve; use commands::skill::run_skill; use commands::speculate::run_speculate; @@ -49,6 +50,14 @@ pub fn run_cli() -> Result<()> { fn run() -> Result<()> { let cli = Cli::parse(); + // Opt-in background update notice (off by default; throttled to once a day; + // swallows all errors). Skipped for `self-update` itself so the check can't + // nag mid-update. + if !matches!(cli.cmd, Cmd::SelfUpdate { .. }) { + if let Some(note) = synaptic_upgrade::check::maybe_notify(env!("CARGO_PKG_VERSION")) { + eprintln!("{note}"); + } + } match cli.cmd { Cmd::Extract { path, @@ -259,5 +268,11 @@ fn run() -> Result<()> { }), Cmd::Eval { action } => run_eval(action), Cmd::Sql { action } => commands::sql::run_sql(action), + Cmd::SelfUpdate { + enable, + disable, + check, + yes, + } => run_self_update(enable, disable, check, yes), } } diff --git a/bin/synaptic/tests/self_upgrade_cli.rs b/bin/synaptic/tests/self_upgrade_cli.rs new file mode 100644 index 0000000..548f51a --- /dev/null +++ b/bin/synaptic/tests/self_upgrade_cli.rs @@ -0,0 +1,46 @@ +//! CLI tests for `self-update` that do not touch the network. +//! +//! The file name avoids the substring "update": Windows force-elevates (UAC) any +//! executable whose name contains "update"/"setup"/"install"/"patch", which would +//! break the compiled test binary. + +use assert_cmd::Command; + +#[test] +fn enable_then_disable_writes_config() { + let home = tempfile::tempdir().unwrap(); + + Command::cargo_bin("synaptic") + .unwrap() + .env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .env("SYNAPTIC_UPDATE_CHECK", "0") + .args(["self-update", "--enable"]) + .assert() + .success(); + + let cfg = std::fs::read_to_string(home.path().join(".synaptic/update.toml")).unwrap(); + assert!(cfg.contains("enabled = true"), "config was: {cfg}"); + + Command::cargo_bin("synaptic") + .unwrap() + .env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .env("SYNAPTIC_UPDATE_CHECK", "0") + .args(["self-update", "--disable"]) + .assert() + .success(); + + let cfg = std::fs::read_to_string(home.path().join(".synaptic/update.toml")).unwrap(); + assert!(cfg.contains("enabled = false"), "config was: {cfg}"); +} + +#[test] +fn enable_and_disable_conflict() { + Command::cargo_bin("synaptic") + .unwrap() + .env("SYNAPTIC_UPDATE_CHECK", "0") + .args(["self-update", "--enable", "--disable"]) + .assert() + .failure(); +} From b365e9e13285e6dd9e431ececf5e14e604ca2cc8 Mon Sep 17 00:00:00 2001 From: Colin Vaughn Date: Sun, 21 Jun 2026 12:40:36 -0400 Subject: [PATCH 06/10] docs(update): document self-update; publish release checksums --- .github/workflows/release.yml | 4 ++++ CHANGELOG.md | 9 +++++++++ README.md | 8 ++++++++ 3 files changed, 21 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d107f44..4ed4ff2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,15 +47,19 @@ jobs: cp README.md LICENSE CHANGELOG.md "$dist/" if [ "${{ runner.os }}" = "Windows" ]; then 7z a "${dist}.zip" "$dist" + certutil -hashfile "${dist}.zip" SHA256 | sed -n '2p' | tr -d ' \r' > "${dist}.zip.sha256" else tar czf "${dist}.tar.gz" "$dist" + shasum -a 256 "${dist}.tar.gz" | awk '{print $1}' > "${dist}.tar.gz.sha256" fi - uses: actions/upload-artifact@v7 with: name: synaptic-${{ matrix.target }} path: | synaptic-${{ matrix.target }}.tar.gz + synaptic-${{ matrix.target }}.tar.gz.sha256 synaptic-${{ matrix.target }}.zip + synaptic-${{ matrix.target }}.zip.sha256 if-no-files-found: ignore release: diff --git a/CHANGELOG.md b/CHANGELOG.md index a1df3f3..4474824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ All notable changes to Synaptic are documented here. The format is based on ## [Unreleased] +### Added +- `self-update` command: opt-in self-replacement from the latest GitHub release, + with SHA-256 checksum verification and a confirmation prompt before the binary + is swapped (`--yes` to skip, `--check` to report availability only). An opt-in + background check (`self-update --enable`) prints a one-line "update available" + notice at most once per day; it is off by default, writes to + `~/.synaptic/update.toml`, and can be force-disabled with + `SYNAPTIC_UPDATE_CHECK=0`. Release archives now publish a `.sha256` sidecar. + ## [0.3.0] - 2026-06-21 ### Changed diff --git a/README.md b/README.md index a7a361f..f864ca8 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,13 @@ Neo4j/FalkorDB export), and `office` / `gws` / `media` (spreadsheet / Google-Wor audio-video ingest), e.g. `cargo install --path bin/synaptic --features pg,push`. See [Installation](https://github.com/ColinVaughn/Synaptic/wiki/Installation) and [Configuration](https://github.com/ColinVaughn/Synaptic/wiki/Configuration). +Once installed, update in place with `synaptic self-update` (verifies a SHA-256 +checksum and prompts before replacing the binary). Opt in to a background +"update available" notice with `synaptic self-update --enable` — off by default, +runs at most once a day, and never blocks normal commands. `cargo install` / +source builds can self-update too, but the swap installs the default-feature +prebuilt binary. + ## Quickstart ```sh @@ -311,6 +318,7 @@ A code-only corpus runs fully offline; the optional LLM semantic pass over docs | `hook ` | Manage git hooks + the `graph.json` merge driver | | `install` / `uninstall [platform]` | Install the Synaptic skill for a host assistant | | `cache ` | Maintain the on-disk extraction cache | +| `self-update` | Update the binary from the latest GitHub release (opt-in). Flags: `--enable`/`--disable` (background notice), `--check`, `--yes` | The full reference with every flag is in [Commands](https://github.com/ColinVaughn/Synaptic/wiki/Commands). Run `synaptic --help` for the flag list at the terminal. From 8dbf869efbaf0328709f4f987b29b60af53bff8c Mon Sep 17 00:00:00 2001 From: Colin Vaughn Date: Sun, 21 Jun 2026 12:41:52 -0400 Subject: [PATCH 07/10] style: cargo fmt for self-update --- crates/synaptic-upgrade/src/config.rs | 4 +++- crates/synaptic-upgrade/src/github.rs | 10 ++++++++-- crates/synaptic-upgrade/src/updater.rs | 3 ++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/synaptic-upgrade/src/config.rs b/crates/synaptic-upgrade/src/config.rs index ef4680b..8bcb227 100644 --- a/crates/synaptic-upgrade/src/config.rs +++ b/crates/synaptic-upgrade/src/config.rs @@ -23,7 +23,9 @@ impl UpdateConfig { /// Load the config, returning the default (disabled) when the file is absent. pub fn load(path: &Path) -> Result { match std::fs::read_to_string(path) { - Ok(text) => toml::from_str(&text).with_context(|| format!("parsing {}", path.display())), + Ok(text) => { + toml::from_str(&text).with_context(|| format!("parsing {}", path.display())) + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), } diff --git a/crates/synaptic-upgrade/src/github.rs b/crates/synaptic-upgrade/src/github.rs index dccb889..71bb41e 100644 --- a/crates/synaptic-upgrade/src/github.rs +++ b/crates/synaptic-upgrade/src/github.rs @@ -74,7 +74,9 @@ pub fn latest_release() -> Result { } } let resp = req.send().context("requesting latest release")?; - let resp = resp.error_for_status().context("GitHub returned an error")?; + let resp = resp + .error_for_status() + .context("GitHub returned an error")?; let body = resp.text().context("reading release body")?; parse_latest(&body) } @@ -100,7 +102,11 @@ mod tests { assert_eq!(r.version, "v0.3.1"); assert_eq!(r.notes, "Changes - fix things"); assert_eq!(r.assets.len(), 2); - let a = r.assets.iter().find(|a| a.name.ends_with(".tar.gz")).unwrap(); + let a = r + .assets + .iter() + .find(|a| a.name.ends_with(".tar.gz")) + .unwrap(); assert_eq!(a.url, "https://example.com/a.tar.gz"); } } diff --git a/crates/synaptic-upgrade/src/updater.rs b/crates/synaptic-upgrade/src/updater.rs index 2c7f897..509ab5d 100644 --- a/crates/synaptic-upgrade/src/updater.rs +++ b/crates/synaptic-upgrade/src/updater.rs @@ -61,7 +61,8 @@ fn extract_from_tar_gz(archive: &Path, want: &str, dest_dir: &Path) -> Result Date: Sun, 21 Jun 2026 14:50:38 -0400 Subject: [PATCH 08/10] docs(wiki): document the self-update system --- wiki/Commands.md | 35 +++++++++++ wiki/Configuration.md | 3 + wiki/Home.md | 8 ++- wiki/Installation.md | 20 ++++++ wiki/Updating.md | 143 ++++++++++++++++++++++++++++++++++++++++++ wiki/_Sidebar.md | 1 + 6 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 wiki/Updating.md diff --git a/wiki/Commands.md b/wiki/Commands.md index eab6d65..31b92f6 100644 --- a/wiki/Commands.md +++ b/wiki/Commands.md @@ -31,6 +31,7 @@ Most read commands operate on `synaptic-out/graph.json` by default; build it fir | [`global`](#global) | Manage the cross-repo global graph store (`~/.synaptic`). | | [`merge-graphs`](#merge-graphs) | Compose several `graph.json` files into one namespaced graph. | | [`cache`](#cache) | Maintain the on-disk extraction cache. | +| [`self-update`](#self-update) | Update the binary from the latest GitHub release (opt-in). | There is also an internal `merge-driver` command. It is hidden from `--help` and invoked by git, not users; see [`hook`](#hook). @@ -993,6 +994,40 @@ synaptic cache clear . --recursive See [Extraction](Extraction). +## self-update + +Update the `synaptic` binary in place from the latest [GitHub Release](../../releases). This is **opt-in**: Synaptic never checks for updates or replaces the binary unless you run this command or enable the background notice below. See [Updating](Updating) for the full walkthrough. + +Syntax: + +```sh +synaptic self-update [--enable | --disable] [--check] [--yes] +``` + +| Name | Default | Description | +| --- | --- | --- | +| `--enable` | off | Turn on the once-a-day "update available" notice and exit. Writes `~/.synaptic/update.toml`; no network. | +| `--disable` | off | Turn the background notice off and exit. | +| `--check` | off | Report whether a newer release exists, then exit without downloading. | +| `--yes` / `-y` | off | Skip the confirmation prompt before downloading and replacing. | + +`--enable` and `--disable` cannot be combined with each other or with `--check`/`--yes` — they only toggle the background notice and exit. + +With no flags, `self-update` queries the latest release and compares it to the running version. If it is not newer it prints `Synaptic is up to date ()` and exits. If it is newer it shows the version delta and release notes, prompts `Download and replace the current binary? [y/N]`, then (on confirmation, or with `--yes`) downloads the prebuilt archive for your platform, verifies its SHA-256 checksum when one is published, and atomically replaces the running binary plus its `syn` alias. The new version takes effect on the next invocation. + +If no prebuilt binary exists for your platform, `self-update` prints the releases URL and exits without changing anything. A source/`cargo install` build can self-update, but the swap installs the default-feature prebuilt binary (rebuild from source to keep extra Cargo features). + +Examples: + +```sh +synaptic self-update # interactive: check, confirm, replace +synaptic self-update --check # just report availability (scriptable) +synaptic self-update --yes # unattended update +synaptic self-update --enable # opt in to the daily reminder +``` + +The background notice is throttled to once per 24 hours, prints a single line to stderr, swallows network errors, and can be force-disabled with `SYNAPTIC_UPDATE_CHECK=0`. See [Updating](Updating) and [Configuration](Configuration). + ## merge-driver (internal) `synaptic merge-driver ` is a git merge driver for `graph.json`. It is hidden from `--help` and invoked by git as `%O %A %B`, not by users. It union-composes both sides into `CURRENT` so `graph.json` never conflicts. It is registered automatically by `synaptic hook install`. diff --git a/wiki/Configuration.md b/wiki/Configuration.md index 61a7363..0e69354 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -72,6 +72,8 @@ OpenAI, DeepSeek, Azure OpenAI, Bedrock, Ollama. Set `SYNAPTIC_BACKEND` to force |---|---| | `HOME` / `USERPROFILE` | Locate the global store `~/.synaptic` (falls back to `.synaptic` in the working directory) | | `SYNAPTIC_SKIP_HOOK` | Skip the installed git hook for one invocation (`1`) | +| `SYNAPTIC_UPDATE_CHECK` | Set to `0` to force the opt-in background update notice off, regardless of config. See [Updating](Updating) | +| `GITHUB_TOKEN` | Optional. Raises the GitHub API rate limit for the `self-update` release lookup | ## Config and output files @@ -84,6 +86,7 @@ OpenAI, DeepSeek, Azure OpenAI, Bedrock, Ollama. Set `SYNAPTIC_BACKEND` to force | `synaptic-out/cache/ast/` | written | Per-file AST cache, keyed by content; auto-invalidated when extractor logic or enabled languages change. Clear with `synaptic cache clear` | | `synaptic-out/cache/semantic/` | written | Semantic-pass response cache | | `~/.synaptic/` | read/written | Global cross-repo store (`global-graph.json`, `global-manifest.json`) | +| `~/.synaptic/update.toml` | read/written | Opt-in self-update state: `enabled` (background notice) and `last_check` (24h throttle). Written by `synaptic self-update --enable`/`--disable`. See [Updating](Updating) | | `.claude/settings.json` | read/written | `PreToolUse` hooks installed by `synaptic install` (Claude). See [Assistant Integration](Assistant-Integration) | | `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` and per-platform skill files | written | Assistant instruction sections written by `install` | | `.codex/config.toml` / `.codex/hooks.json` (+ `~/.codex/config.toml`) | read/written | Codex MCP server + `SessionStart` hook from `synaptic install codex` (project, or global with `--global`). See [Assistant Integration](Assistant-Integration) | diff --git a/wiki/Home.md b/wiki/Home.md index d72b5d2..307dc6b 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -68,7 +68,7 @@ get the binary. ## Documentation map -**Getting started:** [Installation](Installation) - [Quickstart](Quickstart) +**Getting started:** [Installation](Installation) - [Quickstart](Quickstart) - [Updating](Updating) **Concepts:** [Architecture](Architecture) - [Languages](Languages) - [Synaptic vs Other Tools](Synaptic-vs-Other-Tools) @@ -87,8 +87,10 @@ get the binary. ## Design principles -- **Offline by default.** A code-only corpus makes no network calls. Only the opt-in - `--semantic` pass over documents calls an LLM ([Semantic Analysis](Semantic-Analysis)). +- **Offline by default.** A code-only corpus makes no network calls. The only network + features are opt-in and explicit: the `--semantic` pass over documents calls an LLM + ([Semantic Analysis](Semantic-Analysis)), and `self-update` (with its optional daily + notice) contacts GitHub ([Updating](Updating)). - **Auditable.** Every edge carries a confidence level (`EXTRACTED`, `INFERRED`, `AMBIGUOUS`). - **Deterministic.** The same input produces the same graph; ids and community numbers are diff --git a/wiki/Installation.md b/wiki/Installation.md index 8f5f3c8..8a7fb72 100644 --- a/wiki/Installation.md +++ b/wiki/Installation.md @@ -25,6 +25,26 @@ Tagged releases attach prebuilt binaries for Linux (`x86_64`), macOS (`x86_64` a `aarch64`), and Windows (`x86_64`) to the [GitHub Releases](../../releases) page. Each archive bundles the `synaptic` binary plus the README, LICENSE, and CHANGELOG. +## Updating + +Once installed, update in place with: + +```sh +synaptic self-update +``` + +This checks the latest [GitHub Release](../../releases), and if it is newer, prompts you +before downloading the prebuilt archive for your platform, verifying its checksum, and +replacing the running binary (and its `syn` alias). Updating is **opt-in** — Synaptic never +checks or replaces itself on its own. To get a once-a-day "update available" reminder on +ordinary commands, opt in with `synaptic self-update --enable` (off by default, throttled, +and printed only to stderr). + +A `cargo install` / source build can self-update too, but the swap installs the +default-feature prebuilt binary; rebuild from source if you depend on extra features. See +[Updating](Updating) for the full walkthrough and [`self-update`](Commands#self-update) for +the flag reference. + ## Optional features Several integrations are gated behind Cargo features and are **off by default**, so the diff --git a/wiki/Updating.md b/wiki/Updating.md new file mode 100644 index 0000000..5de728f --- /dev/null +++ b/wiki/Updating.md @@ -0,0 +1,143 @@ +# Updating + +Synaptic can update itself in place from the latest [GitHub Release](../../releases). The +update system is **opt-in**: Synaptic never contacts the network or replaces the binary on +its own. You either run `synaptic self-update` explicitly, or enable a once-a-day +"update available" notice that only ever prints a one-line reminder. + +This page covers the whole system. For the bare command reference see +[`self-update`](Commands#self-update) in [Commands](Commands); for the files and environment +variables involved see [Configuration](Configuration). + +## At a glance + +```sh +synaptic self-update # check; if a newer release exists, prompt then replace +synaptic self-update --check # report whether an update is available, then exit +synaptic self-update --yes # update without the confirmation prompt +synaptic self-update --enable # turn on the daily "update available" notice +synaptic self-update --disable # turn the notice back off +``` + +Nothing here runs unless you ask for it. `--enable`/`--disable` only write a small config +file and need no network. + +## How an update works + +Running `synaptic self-update` performs these steps: + +1. **Check.** Query `releases/latest` for `ColinVaughn/Synaptic` and compare the tag to the + running version (a leading `v` is tolerated; the comparison is semantic-version aware). If + the latest release is not newer, it prints `Synaptic is up to date ().` and exits. +2. **Confirm.** If a newer release exists, it prints the version delta and the release notes, + then asks `Download and replace the current binary? [y/N]`. Answer `y`/`yes` to proceed. + `--yes` (or `-y`) skips this prompt for scripts. +3. **Download.** Fetch the prebuilt archive that matches your platform (see + [Platform support](#platform-support)). +4. **Verify.** If the release publishes a `.sha256` checksum next to the archive, the + download is verified against it; a mismatch **aborts** the update before anything is + replaced. Releases made before checksums were published have no sidecar, so verification + is skipped with a printed warning rather than failing. +5. **Replace.** Extract the binary and atomically replace the currently running executable. + The `syn` short alias next to it is updated too. + +The new version takes effect the next time you run Synaptic — the already-running process +keeps the old code in memory, so the command finishes with +`Updated to . Restart synaptic to use the new version.` + +If anything fails (network, checksum mismatch, write error), the existing binary is left +untouched: the download is verified before the swap, never overwritten in place first. + +## The opt-in background notice + +By default Synaptic never checks for updates. Turn the reminder on with: + +```sh +synaptic self-update --enable +``` + +Once enabled, ordinary commands occasionally check for a newer release in the background and, +if one exists, print a single line to **stderr** and then continue normally: + +``` +(note) Synaptic 0.3.1 is available - run `synaptic self-update` +``` + +Properties of the background check: + +- **Throttled.** It runs at most once every 24 hours. The timestamp of the last check is + stored in the config file, so a burst of commands triggers only one check. +- **Non-blocking and silent on failure.** It uses a short timeout, and any network or parse + error is swallowed — a flaky connection never slows down or breaks a normal command. The + timestamp still advances so a failed check is not retried on every invocation. +- **stderr only.** The notice goes to stderr, so it never corrupts machine-readable stdout + (for example a `--json` result or the `serve` MCP stream). +- **Never on `self-update` itself.** The check is skipped while you run the update command. + +Turn it back off at any time: + +```sh +synaptic self-update --disable +``` + +### Disabling the check without changing config + +Set `SYNAPTIC_UPDATE_CHECK=0` to force the background check off even when the config has it +enabled. This is useful in CI or any non-interactive environment: + +```sh +SYNAPTIC_UPDATE_CHECK=0 synaptic query "..." +``` + +The variable only affects the background notice; it does not change what `synaptic +self-update` does when you run it explicitly. + +## Checksum verification + +When a release publishes a `synaptic-.tar.gz.sha256` (or `.zip.sha256`) sidecar next +to its archive, `self-update` downloads it and verifies the SHA-256 of the downloaded archive +before replacing the binary. A mismatch aborts the update. Synaptic's release workflow +publishes these sidecars; releases predating that workflow have none, in which case +verification is skipped with a warning so updating still works against older releases. + +## Platform support + +`self-update` downloads the prebuilt archive matching your platform: + +| Platform | Release asset | +|---|---| +| Linux x86_64 | `synaptic-x86_64-unknown-linux-gnu.tar.gz` | +| macOS Apple Silicon | `synaptic-aarch64-apple-darwin.tar.gz` | +| macOS Intel | `synaptic-x86_64-apple-darwin.tar.gz` | +| Windows x86_64 | `synaptic-x86_64-pc-windows-msvc.zip` | + +On any other platform there is no prebuilt binary to install. `self-update` detects this, +prints the [Releases](../../releases) URL so you can download or build manually, and exits +without changing anything. + +## Updating a source build + +`self-update` replaces whatever binary is currently running, including one produced by +`cargo install --path bin/synaptic` or `cargo build`. Note that the replacement is the +**default-feature** prebuilt binary. If you built with extra Cargo features (for example +`pg`, `push`, `office`, `gws`, `media`, or `live-explain`; see [Installation](Installation) +and [Configuration](Configuration)), self-updating swaps in a binary that does not have them. +Rebuild from source with your features instead of self-updating in that case. + +## Rate limits and tokens + +The check uses GitHub's anonymous REST API, which is rate-limited per IP. If you hit the +limit (for example on a shared CI runner), set `GITHUB_TOKEN` to a token and Synaptic will +send it to raise the limit. The token is optional and only used to authenticate the release +lookup. + +## Files and variables + +| Path / variable | Role | +|---|---| +| `~/.synaptic/update.toml` | Stores `enabled` (the opt-in flag) and `last_check` (the throttle timestamp). Written by `--enable`/`--disable`. On Windows this is `%USERPROFILE%\.synaptic\update.toml`; with no home directory it falls back to `.synaptic/update.toml` in the working directory. | +| `SYNAPTIC_UPDATE_CHECK` | Set to `0` to force the background notice off regardless of config. | +| `GITHUB_TOKEN` | Optional. Raises the GitHub API rate limit for the release lookup. | + +See [Configuration](Configuration) for the full list of files and environment variables, and +[Commands](Commands#self-update) for the command reference. diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index df2fabd..1a87e04 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -4,6 +4,7 @@ - [Home](Home) - [Installation](Installation) - [Quickstart](Quickstart) +- [Updating](Updating) **Concepts** - [Architecture](Architecture) From f28206bdc9a6bfb4c657af6ea8aab1e714376771 Mon Sep 17 00:00:00 2001 From: Colin Vaughn Date: Sun, 21 Jun 2026 14:53:37 -0400 Subject: [PATCH 09/10] ci(release): sync wiki/ to the GitHub wiki on release --- .github/workflows/release.yml | 44 +++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ed4ff2..b6a0042 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,9 @@ name: release # Build prebuilt `synaptic` binaries for Linux/macOS/Windows and attach them to -# the GitHub Release when a `v*` tag is pushed. Run manually via workflow_dispatch -# to smoke-test the build matrix without cutting a release. +# the GitHub Release when a `v*` tag is pushed, and sync the `wiki/` directory to +# the GitHub wiki. Run manually via workflow_dispatch to smoke-test the build +# matrix (and re-sync the wiki) without cutting a release. on: push: tags: ["v*"] @@ -75,3 +76,42 @@ jobs: with: files: dist/* generate_release_notes: true + + publish-wiki: + # Sync wiki/ (as committed on the released ref) to the GitHub wiki repo, but + # only commit when it actually differs from what is already published. + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v7 + - name: Sync wiki/ to the GitHub wiki + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if [ ! -d wiki ]; then + echo "no wiki/ directory; nothing to publish" + exit 0 + fi + tmp="$(mktemp -d)" + # Build the authenticated clone URL without a literal user@host token. + auth="https://x-access-token:${GH_TOKEN}" + repo="${auth}@github.com/${GITHUB_REPOSITORY}.wiki.git" + if ! git clone --depth 1 "$repo" "$tmp"; then + echo "::error::wiki repo not found. Create the first page in the GitHub UI once, then re-run." + exit 1 + fi + # Mirror wiki/ into the wiki repo: delete pages removed from source, + # but never touch the wiki repo's own .git. + rsync -a --delete --exclude '.git' wiki/ "$tmp/" + cd "$tmp" + bot="github-actions[bot]" + git config user.name "$bot" + git config user.email "41898282+${bot}@users.noreply.github.com" + git add -A + if git diff --cached --quiet; then + echo "Wiki already up to date; nothing to publish." + else + git commit -m "docs(wiki): sync from ${GITHUB_REF_NAME} (${GITHUB_SHA})" + git push origin HEAD + fi From 26c4b1426cb7bd4d8a0169fad36809868fbc7407 Mon Sep 17 00:00:00 2001 From: Colin Vaughn Date: Sun, 21 Jun 2026 14:58:02 -0400 Subject: [PATCH 10/10] docs(update): escape angle brackets in verify_sha256 doc comment Fixes the docs CI job: rustdoc parsed ``/`` as HTML tags under RUSTDOCFLAGS=-D warnings. --- crates/synaptic-upgrade/src/updater.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/synaptic-upgrade/src/updater.rs b/crates/synaptic-upgrade/src/updater.rs index 509ab5d..ae25d13 100644 --- a/crates/synaptic-upgrade/src/updater.rs +++ b/crates/synaptic-upgrade/src/updater.rs @@ -17,7 +17,7 @@ pub fn find_asset<'a>(release: &'a Release, name: &str) -> Option<&'a Asset> { } /// Verify `bytes` hashes to the hex digest at the start of `expected` -/// (sidecar files are often " "). Case-insensitive. +/// (sidecar files are often `" "`). Case-insensitive. pub fn verify_sha256(bytes: &[u8], expected: &str) -> bool { let want = match expected.split_whitespace().next() { Some(t) => t.to_ascii_lowercase(),