From 0d2bbc8a5ff998133a9b91498ba8e45b226707f1 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Sat, 4 Jul 2026 22:13:54 +0530 Subject: [PATCH 1/4] fix: drain durable sync queues reliably and stop persisting ephemeral hook binary paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt transcripts and authorship notes could silently stop being stored through two independent failure modes: 1. The daemon's flush loop only ran flush_cas_queue()/flush_notes()/ flush_pending_to_cloud() when the in-memory telemetry buffer was non-empty, so durable queues could sit pending indefinitely on an otherwise idle daemon. The loop now flushes every tick; each durable drain first does a cheap local pending-count check so an empty queue never triggers an auth check (and its potential token-refresh network call). When pending work exists but auth is unavailable, a rate-limited WARN is logged (previously debug-level, invisible in daemon logs) and a 5-minute backoff prevents refresh spam. 2. install-hooks persisted std::env::current_exe() into agent hook configs. When run from a cargo build directory (e.g. a temporary worktree's target/debug binary), the recorded path dies as soon as that directory is removed, and every subsequent checkpoint hook fails silently — no sessions, no prompts, empty authorship notes. resolve_hook_binary_path() now prefers the stable installed binary (~/.autter/bin/autter) whenever the current executable is a cargo artifact. Generated-By: PostHog Code Task-Id: e5855956-9de8-49a0-836b-8a2c91373bef --- src/authorship/internal_db.rs | 12 +++ src/commands/install_hooks.rs | 12 +-- src/daemon/telemetry_worker.rs | 160 +++++++++++++++++++++++++-------- src/file_changes/db.rs | 11 +++ src/file_changes/mod.rs | 42 ++++++--- src/mdm/utils.rs | 64 +++++++++++++ src/notes/db.rs | 12 +++ tests/integration/main.rs | 2 +- 8 files changed, 257 insertions(+), 58 deletions(-) diff --git a/src/authorship/internal_db.rs b/src/authorship/internal_db.rs index 5b8b9e4..2344e3d 100644 --- a/src/authorship/internal_db.rs +++ b/src/authorship/internal_db.rs @@ -423,6 +423,18 @@ impl InternalDatabase { Ok(records) } + /// Number of CAS objects still waiting to be uploaded (excluding rows that + /// exhausted their retry budget). Cheap gate for the daemon flush loop so + /// an empty queue never triggers an auth check. + pub fn count_pending_cas(&self) -> Result { + let count = self.conn.query_row( + "SELECT COUNT(*) FROM cas_sync_queue WHERE attempts < 6", + [], + |row| row.get(0), + )?; + Ok(count) + } + /// Delete a CAS sync record (on successful sync) pub fn delete_cas_sync_record(&mut self, id: i64) -> Result<(), AutterError> { self.conn diff --git a/src/commands/install_hooks.rs b/src/commands/install_hooks.rs index 1f18245..73ca981 100644 --- a/src/commands/install_hooks.rs +++ b/src/commands/install_hooks.rs @@ -5,7 +5,7 @@ use crate::mdm::agents::get_all_installers; use crate::mdm::hook_installer::HookInstallerParams; use crate::mdm::skills_installer; use crate::mdm::spinner::{Spinner, print_diff}; -use crate::mdm::utils::get_current_binary_path; +use crate::mdm::utils::resolve_hook_binary_path; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -318,8 +318,9 @@ pub fn run(args: &[String]) -> Result, AutterError> { let _ = crate::daemon::telemetry_handle::init_daemon_telemetry_handle(); } - // Get absolute path to the current binary - let binary_path = get_current_binary_path()?; + // Hook configs need a binary path that outlives this process; prefer the + // stable installed binary when running from a cargo build directory. + let binary_path = resolve_hook_binary_path()?; persist_install_config(&binary_path, options.dry_run)?; let params = HookInstallerParams { binary_path }; @@ -448,8 +449,9 @@ pub fn run_uninstall(args: &[String]) -> Result, AutterE } } - // Get absolute path to the current binary - let binary_path = get_current_binary_path()?; + // Match the path resolution used at install time so uninstall targets the + // same hook entries. + let binary_path = resolve_hook_binary_path()?; let params = HookInstallerParams { binary_path }; // Run async operations with smol and convert result diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index 8304c6c..0809e24 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -58,14 +58,6 @@ impl TelemetryBuffer { } } - fn is_empty(&self) -> bool { - self.errors.is_empty() - && self.performances.is_empty() - && self.messages.is_empty() - && self.metrics.is_empty() - && self.cas_records.is_empty() - } - fn ingest_envelopes(&mut self, envelopes: Vec) { for envelope in envelopes { match envelope { @@ -264,11 +256,14 @@ async fn telemetry_flush_loop(buffer: Arc>) { loop { ticker.tick().await; + // Take whatever in-memory telemetry accumulated this tick (possibly + // nothing). The flush must still run on an empty buffer: the durable + // queues (CAS transcripts, authorship notes, file-change aggregates) + // are drained inside flush_telemetry_batch, and gating them on + // in-memory activity left them stranded whenever the daemon was + // otherwise idle. let snapshot = { let mut buf = buffer.lock().await; - if buf.is_empty() { - continue; - } buf.take() }; @@ -285,7 +280,6 @@ async fn telemetry_flush_loop(buffer: Arc>) { fn flush_telemetry_batch(batch: TelemetryBuffer) { let config = Config::get(); - let distinct_id = get_or_create_distinct_id(); // Flush metrics (always processed — uploaded or stored in SQLite) if !batch.metrics.is_empty() { @@ -297,6 +291,7 @@ fn flush_telemetry_batch(batch: TelemetryBuffer) { !batch.errors.is_empty() || !batch.performances.is_empty() || !batch.messages.is_empty(); if has_sentry_or_posthog { + let distinct_id = get_or_create_distinct_id(); flush_sentry_and_posthog( config, &distinct_id, @@ -311,15 +306,74 @@ fn flush_telemetry_batch(batch: TelemetryBuffer) { flush_cas(batch.cas_records); } - // Drain the durable CAS queue (the post-commit transcript bridge enqueues - // here). This reads directly from the internal DB, mirroring flush_notes. - flush_cas_queue(); + // Drain the durable queues. Skipped while the auth backoff is active so an + // expired login doesn't trigger a token-refresh network call on every tick. + if !durable_sync_auth_backoff_active() { + // Drain the durable CAS queue (the post-commit transcript bridge enqueues + // here). This reads directly from the internal DB, mirroring flush_notes. + flush_cas_queue(); - // Flush pending notes (reads directly from notes-db; no-op when kind != Http). - flush_notes(); + // Flush pending notes (reads directly from notes-db; no-op when kind != Http). + flush_notes(); + + // Flush pending file change aggregates to the org database. + crate::file_changes::flush_pending_to_cloud(); + } +} - // Flush pending file change aggregates to the org database. - crate::file_changes::flush_pending_to_cloud(); +// ----- Durable-queue auth backoff ------------------------------------------- +// +// The durable queues (CAS transcripts, authorship notes, file-change +// aggregates) are drained on every flush tick, even when no in-memory +// telemetry accumulated. Each drain attempt can trigger a token-refresh +// network call, so after an unauthenticated attempt we back off instead of +// retrying every 3 seconds — and emit a rate-limited warning so a stalled +// sync is visible in the daemon log. (This used to be a debug-level message, +// which let queued transcripts and notes sit pending for weeks unnoticed +// after a login expired.) + +/// Unix timestamp before which auth-gated durable-queue flushes are skipped. +static DURABLE_SYNC_AUTH_RETRY_AFTER: std::sync::atomic::AtomicI64 = + std::sync::atomic::AtomicI64::new(0); +/// Unix timestamp of the last "sync blocked" warning, to rate-limit it. +static DURABLE_SYNC_LAST_AUTH_WARN: std::sync::atomic::AtomicI64 = + std::sync::atomic::AtomicI64::new(0); +const DURABLE_SYNC_AUTH_RETRY_SECS: i64 = 300; +const DURABLE_SYNC_AUTH_WARN_SECS: i64 = 1800; + +fn unix_now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn durable_sync_auth_backoff_active() -> bool { + unix_now_secs() < DURABLE_SYNC_AUTH_RETRY_AFTER.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Record that a durable-queue flush found pending work but no valid auth. +/// Arms the retry backoff and emits a rate-limited warning. +fn note_durable_sync_unauthenticated(queue: &str, pending: i64) { + let now = unix_now_secs(); + DURABLE_SYNC_AUTH_RETRY_AFTER.store( + now + DURABLE_SYNC_AUTH_RETRY_SECS, + std::sync::atomic::Ordering::Relaxed, + ); + let last_warn = DURABLE_SYNC_LAST_AUTH_WARN.load(std::sync::atomic::Ordering::Relaxed); + if now - last_warn >= DURABLE_SYNC_AUTH_WARN_SECS { + DURABLE_SYNC_LAST_AUTH_WARN.store(now, std::sync::atomic::Ordering::Relaxed); + tracing::warn!( + queue, + pending, + "sync blocked: not authenticated; queued data will stay local until `autter login` succeeds" + ); + } +} + +/// Record a successful auth check so the backoff clears immediately. +fn note_durable_sync_authenticated() { + DURABLE_SYNC_AUTH_RETRY_AFTER.store(0, std::sync::atomic::Ordering::Relaxed); } fn flush_metrics(events: &[MetricEvent]) { @@ -567,32 +621,48 @@ pub fn flush_notes() { return; } }; + + // Cheap local check first: with nothing queued, skip building the API + // client entirely (constructing it can trigger a token refresh). + let notes_db = match crate::notes::db::NotesDatabase::global() { + Ok(db) => db, + Err(e) => { + tracing::warn!(%e, "notes: failed to get notes DB"); + return; + } + }; + let pending_count = { + let Ok(lock) = notes_db.lock() else { + tracing::warn!("notes: DB lock poisoned"); + return; + }; + lock.count_pending().unwrap_or(0) + }; + if pending_count == 0 { + return; + } + let context = ApiContext::new(Some(backend_url.clone())); let client = ApiClient::new(context); if !client.is_logged_in() && !client.has_api_key() { - tracing::debug!("notes: skipping flush, not authenticated"); + note_durable_sync_unauthenticated("notes", pending_count); return; } + note_durable_sync_authenticated(); // Dequeue up to 50 pending notes. - let pending = match crate::notes::db::NotesDatabase::global() { - Ok(db) => match db.lock() { - Ok(mut lock) => match lock.dequeue_pending(50) { - Ok(rows) => rows, - Err(e) => { - tracing::warn!(%e, "notes: failed to dequeue pending rows"); - return; - } - }, + let pending = { + let Ok(mut lock) = notes_db.lock() else { + tracing::warn!("notes: DB lock poisoned"); + return; + }; + match lock.dequeue_pending(50) { + Ok(rows) => rows, Err(e) => { - tracing::warn!("notes: DB lock poisoned: {}", e); + tracing::warn!(%e, "notes: failed to dequeue pending rows"); return; } - }, - Err(e) => { - tracing::warn!(%e, "notes: failed to get notes DB"); - return; } }; @@ -785,15 +855,29 @@ fn flush_cas(records: Vec) { /// that fail to upload stay locked as `processing` and are recovered to /// `pending` by `dequeue_cas_batch`'s stale-lock sweep on a later tick. fn flush_cas_queue() { + let Ok(db) = crate::authorship::internal_db::InternalDatabase::global() else { + return; + }; + + // Cheap local check first: with nothing queued, skip building the API + // client entirely (constructing it can trigger a token refresh). + let pending = { + let Ok(db_lock) = db.lock() else { + return; + }; + db_lock.count_pending_cas().unwrap_or(0) + }; + if pending == 0 { + return; + } + // Don't lock records as `processing` if we can't upload them anyway — that // would just churn the queue through the 10-minute stale-lock recovery. if !cas_client().1 { + note_durable_sync_unauthenticated("cas_transcripts", pending); return; } - - let Ok(db) = crate::authorship::internal_db::InternalDatabase::global() else { - return; - }; + note_durable_sync_authenticated(); // Bound the number of batches per tick so a large backlog can't monopolize // the flush loop; the remainder is picked up on subsequent ticks. diff --git a/src/file_changes/db.rs b/src/file_changes/db.rs index f22d23b..4663217 100644 --- a/src/file_changes/db.rs +++ b/src/file_changes/db.rs @@ -262,6 +262,17 @@ impl FileChangesDatabase { Ok(results) } + /// Number of rows still waiting to be uploaded. Cheap gate for the daemon + /// flush loop so an empty queue never triggers an auth check. + pub fn count_pending(&self) -> Result { + let count = self.conn.query_row( + "SELECT COUNT(*) FROM file_change_counts WHERE synced = 0", + [], + |row| row.get(0), + )?; + Ok(count) + } + pub fn dequeue_pending( &mut self, limit: usize, diff --git a/src/file_changes/mod.rs b/src/file_changes/mod.rs index 55e8b94..3190b36 100644 --- a/src/file_changes/mod.rs +++ b/src/file_changes/mod.rs @@ -66,6 +66,26 @@ pub fn flush_pending_to_cloud() { None => return, }; + // Cheap local check first: with nothing queued, skip building the API + // client entirely (constructing it can trigger a token refresh). + let db = match FileChangesDatabase::global() { + Ok(db) => db, + Err(e) => { + tracing::warn!(%e, "file-changes: failed to get DB"); + return; + } + }; + let pending_count = { + let Ok(lock) = db.lock() else { + tracing::warn!("file-changes: DB lock poisoned"); + return; + }; + lock.count_pending().unwrap_or(0) + }; + if pending_count == 0 { + return; + } + let default_client = ApiClient::new(crate::api::client::ApiContext::new(Some( backend_url.clone(), ))); @@ -73,23 +93,17 @@ pub fn flush_pending_to_cloud() { return; } - let pending = match FileChangesDatabase::global() { - Ok(db) => match db.lock() { - Ok(mut lock) => match lock.dequeue_pending(100) { - Ok(rows) => rows, - Err(e) => { - tracing::warn!(%e, "file-changes: failed to dequeue pending rows"); - return; - } - }, + let pending = { + let Ok(mut lock) = db.lock() else { + tracing::warn!("file-changes: DB lock poisoned"); + return; + }; + match lock.dequeue_pending(100) { + Ok(rows) => rows, Err(e) => { - tracing::warn!("file-changes: DB lock poisoned: {}", e); + tracing::warn!(%e, "file-changes: failed to dequeue pending rows"); return; } - }, - Err(e) => { - tracing::warn!(%e, "file-changes: failed to get DB"); - return; } }; diff --git a/src/mdm/utils.rs b/src/mdm/utils.rs index ce1c4dd..78325ea 100644 --- a/src/mdm/utils.rs +++ b/src/mdm/utils.rs @@ -798,6 +798,47 @@ pub fn get_current_binary_path() -> Result { Ok(clean_path(canonical)) } +/// Path where the installer places the stable autter binary, if present. +pub fn stable_install_binary_path() -> Option { + let name = if cfg!(windows) { + "autter.exe" + } else { + "autter" + }; + let path = home_dir().join(".autter").join("bin").join(name); + path.is_file().then_some(path) +} + +/// True when `path` points inside a cargo build directory +/// (`target/debug` or `target/release`). +pub fn is_cargo_build_artifact(path: &Path) -> bool { + let comps: Vec<&str> = path + .components() + .filter_map(|c| c.as_os_str().to_str()) + .collect(); + comps + .windows(2) + .any(|w| w[0] == "target" && (w[1] == "debug" || w[1] == "release")) +} + +/// Binary path to persist into agent hook configs. +/// +/// Hook configs outlive the process that wrote them, so a cargo build +/// artifact (e.g. a `target/debug` binary in a temporary worktree) must not +/// be written into them — once that directory is deleted, every hook +/// invocation silently fails and checkpoints stop being captured. When the +/// current executable is a cargo artifact and a stable installed binary +/// exists, prefer the stable one. +pub fn resolve_hook_binary_path() -> Result { + let current = get_current_binary_path()?; + if is_cargo_build_artifact(¤t) + && let Some(stable) = stable_install_binary_path() + { + return Ok(clean_path(stable)); + } + Ok(current) +} + /// Update VS Code chat hook settings in a settings.json/jsonc file. /// /// Ensures `"chat.useHooks"` is set to `true`. @@ -953,6 +994,29 @@ mod tests { assert!(!version_meets_requirement(old_claude, MIN_CLAUDE_VERSION)); } + #[test] + fn test_is_cargo_build_artifact() { + assert!(is_cargo_build_artifact(Path::new( + "/Users/dev/worktrees/foo/autter-cli/target/debug/autter" + ))); + assert!(is_cargo_build_artifact(Path::new( + "/home/dev/autter-cli/target/release/autter" + ))); + #[cfg(windows)] + assert!(is_cargo_build_artifact(Path::new( + r"C:\dev\autter-cli\target\debug\autter.exe" + ))); + + assert!(!is_cargo_build_artifact(Path::new( + "/Users/dev/.autter/bin/autter" + ))); + assert!(!is_cargo_build_artifact(Path::new("/usr/local/bin/autter"))); + // "target" not immediately followed by debug/release + assert!(!is_cargo_build_artifact(Path::new( + "/opt/target/tools/autter" + ))); + } + #[test] fn test_is_autter_checkpoint_command() { assert!(is_autter_checkpoint_command("autter checkpoint")); diff --git a/src/notes/db.rs b/src/notes/db.rs index 3dbd8a0..d6b8053 100644 --- a/src/notes/db.rs +++ b/src/notes/db.rs @@ -342,6 +342,18 @@ impl NotesDatabase { // ----- Queue operations ----- + /// Number of notes still waiting to be uploaded (excluding rows that + /// exhausted their retry budget). Cheap gate for the daemon flush loop so + /// an empty queue never triggers an auth check. + pub fn count_pending(&self) -> Result { + let count = self.conn.query_row( + "SELECT COUNT(*) FROM notes WHERE synced = 0 AND attempts < 6", + [], + |row| row.get(0), + )?; + Ok(count) + } + /// Lock and return a batch of pending notes for upload. /// /// Sets `processing_started_at` on selected rows so concurrent workers do not diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 863df02..39bfd13 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -16,7 +16,6 @@ mod amp; mod attribution_tracker_comprehensive; mod background_agent_attribution; mod bash_attribution; -mod blame_why; mod bash_tool_benchmark; mod bash_tool_conformance; mod bash_tool_provenance; @@ -24,6 +23,7 @@ mod bash_tool_timeouts; mod blame_comprehensive; mod blame_flags; mod blame_subdirectory; +mod blame_why; mod checkout_switch; mod checkpoint_debug_log; mod checkpoint_explicit_paths; From f8c63f902e78667bb5f1131c7a51df113724a55e Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Sat, 4 Jul 2026 22:20:28 +0530 Subject: [PATCH 2/4] feat: self-discover and maintain hooks in Claude Agent SDK harness config dirs Claude Code reads hooks from ~/.claude (or $CLAUDE_CONFIG_DIR), but CLIs built on the Claude Agent SDK run their sessions with CLAUDE_CONFIG_DIR pointing at their own config directory. Hooks installed there were invisible to `autter install-hooks` run from a terminal, so a stale hook in a harness config silently broke checkpointing for every session of that harness with no repair path. Each `autter checkpoint claude` invocation runs inside the harness's environment, so it now records any non-default CLAUDE_CONFIG_DIR in ~/.autter/internal/claude-config-dirs.json. The Claude hook installer then installs, repairs, and uninstalls hooks across the primary settings file and every registered harness directory on each install/update run. Harness entries are best-effort (one malformed harness config can't fail the whole install) and no vendor paths are hardcoded, so this works for any current or future Claude-based CLI. Generated-By: PostHog Code Task-Id: e5855956-9de8-49a0-836b-8a2c91373bef --- .../checkpoint_agent/presets/claude.rs | 6 ++ src/mdm/agents/claude_code.rs | 69 ++++++++++++- src/mdm/claude_config_registry.rs | 96 +++++++++++++++++++ src/mdm/mod.rs | 1 + 4 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 src/mdm/claude_config_registry.rs diff --git a/src/commands/checkpoint_agent/presets/claude.rs b/src/commands/checkpoint_agent/presets/claude.rs index 5d1bed6..5c00d29 100644 --- a/src/commands/checkpoint_agent/presets/claude.rs +++ b/src/commands/checkpoint_agent/presets/claude.rs @@ -48,6 +48,12 @@ impl AgentPreset for ClaudePreset { )); } + // This process runs inside the agent's environment: when a harness + // built on the Claude Agent SDK routes hooks through its own + // CLAUDE_CONFIG_DIR, record that directory so install/update runs + // keep its hooks maintained too. + crate::mdm::claude_config_registry::register_active_config_dir(); + let cwd = parse::required_str(&data, "cwd")?; let transcript_path = parse::required_str(&data, "transcript_path")?; diff --git a/src/mdm/agents/claude_code.rs b/src/mdm/agents/claude_code.rs index cd2afac..7cc42dc 100644 --- a/src/mdm/agents/claude_code.rs +++ b/src/mdm/agents/claude_code.rs @@ -21,6 +21,23 @@ impl ClaudeCodeInstaller { claude_config_dir().join("settings.json") } + /// The primary settings file plus the settings file of every registered + /// Claude-compatible harness config directory (CLIs built on the Claude + /// Agent SDK that run hooks through their own `CLAUDE_CONFIG_DIR`). + /// Keeping these in sync means a harness whose hook binary path went + /// stale gets repaired on the next install/update run instead of + /// silently dropping checkpoints forever. + fn all_settings_paths() -> Vec { + let mut paths = vec![Self::settings_path()]; + for dir in crate::mdm::claude_config_registry::registered_config_dirs() { + let candidate = dir.join("settings.json"); + if !paths.contains(&candidate) { + paths.push(candidate); + } + } + paths + } + /// Returns `(hooks_installed, hooks_up_to_date)` from a parsed settings value. /// `hooks_installed` = autter checkpoint command exists in ANY matcher block. /// `hooks_up_to_date` = autter checkpoint command exists in the `"*"` catch-all block. @@ -312,8 +329,11 @@ impl HookInstaller for ClaudeCodeInstaller { fn check_hooks(&self, _params: &HookInstallerParams) -> Result { let has_binary = binary_exists("claude"); let has_dotfiles = claude_config_dir().exists(); + // Harnesses built on the Claude Agent SDK count as an installation + // even without the `claude` binary or `~/.claude` present. + let has_harness = !crate::mdm::claude_config_registry::registered_config_dirs().is_empty(); - if !has_binary && !has_dotfiles { + if !has_binary && !has_dotfiles && !has_harness { return Ok(HookCheckResult { tool_installed: false, hooks_installed: false, @@ -361,7 +381,30 @@ impl HookInstaller for ClaudeCodeInstaller { params: &HookInstallerParams, dry_run: bool, ) -> Result, AutterError> { - Self::install_hooks_at(&Self::settings_path(), params, dry_run) + let mut diffs: Vec = Vec::new(); + for (idx, path) in Self::all_settings_paths().iter().enumerate() { + if idx == 0 { + // Primary settings file: errors are fatal, as before. + if let Some(diff) = Self::install_hooks_at(path, params, dry_run)? { + diffs.push(diff); + } + } else { + // Registered harness configs are best-effort: one harness's + // malformed settings must not fail the whole install. + match Self::install_hooks_at(path, params, dry_run) { + Ok(Some(diff)) => diffs.push(diff), + Ok(None) => {} + Err(e) => { + tracing::warn!(path = %path.display(), %e, "claude: failed to update harness hook config"); + } + } + } + } + if diffs.is_empty() { + Ok(None) + } else { + Ok(Some(diffs.join("\n"))) + } } fn uninstall_hooks( @@ -369,7 +412,27 @@ impl HookInstaller for ClaudeCodeInstaller { _params: &HookInstallerParams, dry_run: bool, ) -> Result, AutterError> { - Self::uninstall_hooks_at(&Self::settings_path(), dry_run) + let mut diffs: Vec = Vec::new(); + for (idx, path) in Self::all_settings_paths().iter().enumerate() { + if idx == 0 { + if let Some(diff) = Self::uninstall_hooks_at(path, dry_run)? { + diffs.push(diff); + } + } else { + match Self::uninstall_hooks_at(path, dry_run) { + Ok(Some(diff)) => diffs.push(diff), + Ok(None) => {} + Err(e) => { + tracing::warn!(path = %path.display(), %e, "claude: failed to clean harness hook config"); + } + } + } + } + if diffs.is_empty() { + Ok(None) + } else { + Ok(Some(diffs.join("\n"))) + } } } diff --git a/src/mdm/claude_config_registry.rs b/src/mdm/claude_config_registry.rs new file mode 100644 index 0000000..213d759 --- /dev/null +++ b/src/mdm/claude_config_registry.rs @@ -0,0 +1,96 @@ +//! Registry of Claude-Code-compatible configuration directories. +//! +//! Claude Code reads its hook configuration from `~/.claude` (or +//! `$CLAUDE_CONFIG_DIR`) — but CLIs built on the Claude Agent SDK (PostHog +//! Code, and any other harness that embeds Claude Code) run their sessions +//! with `CLAUDE_CONFIG_DIR` pointing at their own config directory. Hooks +//! installed there are invisible to a plain `autter install-hooks` run from a +//! terminal, so a hook that goes stale in a harness config (e.g. its binary +//! path no longer exists) would silently break checkpointing for every +//! session of that harness, with no repair path. +//! +//! To keep every harness maintained without hardcoding vendor paths, each +//! `autter checkpoint claude` invocation — which runs inside the harness's +//! environment — records the active non-default `CLAUDE_CONFIG_DIR` here. The +//! Claude hook installer then installs/updates hooks in every registered +//! directory on each install or update run. + +use std::path::PathBuf; + +use crate::mdm::utils::{clean_path, home_dir, write_atomic}; + +fn registry_path() -> PathBuf { + home_dir() + .join(".autter") + .join("internal") + .join("claude-config-dirs.json") +} + +/// All registered Claude-compatible config directories that still exist on +/// disk. Directories that have been removed are skipped (but stay in the +/// registry: harnesses like PostHog Code may be reinstalled later). +pub fn registered_config_dirs() -> Vec { + let Ok(raw) = std::fs::read_to_string(registry_path()) else { + return Vec::new(); + }; + let Ok(dirs) = serde_json::from_str::>(&raw) else { + return Vec::new(); + }; + dirs.into_iter() + .map(PathBuf::from) + .filter(|p| p.is_dir()) + .collect() +} + +/// Record the active `CLAUDE_CONFIG_DIR` when it points somewhere other than +/// the default `~/.claude`. Called from the Claude checkpoint path, which +/// runs inside the harness's environment. Best-effort: failures are ignored +/// so a checkpoint is never blocked by registry bookkeeping. +pub fn register_active_config_dir() { + let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") else { + return; + }; + if dir.trim().is_empty() { + return; + } + let dir = clean_path(PathBuf::from(dir)); + if dir == home_dir().join(".claude") || !dir.is_dir() { + return; + } + + let path = registry_path(); + let mut dirs: Vec = std::fs::read_to_string(&path) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default(); + + let dir_str = dir.to_string_lossy().to_string(); + if dirs.iter().any(|d| d == &dir_str) { + return; + } + dirs.push(dir_str); + + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(&dirs) { + let _ = write_atomic(&path, json.as_bytes()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registered_config_dirs_empty_when_registry_missing() { + // With a HOME that has no registry file, this must not error. + // (Integration tests run with an isolated HOME, so the registry file + // won't exist there.) + let dirs = registered_config_dirs(); + // Every returned entry must be an existing directory. + for dir in dirs { + assert!(dir.is_dir()); + } + } +} diff --git a/src/mdm/mod.rs b/src/mdm/mod.rs index 56bdb26..65b55ca 100644 --- a/src/mdm/mod.rs +++ b/src/mdm/mod.rs @@ -1,4 +1,5 @@ pub mod agents; +pub mod claude_config_registry; pub mod hook_installer; pub mod jetbrains; pub mod skills_installer; From ed8f6bd309b7d45aac4e471936a0d9ea6f8607c7 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Sat, 4 Jul 2026 22:45:52 +0530 Subject: [PATCH 3/4] feat: add 'both' notes backend for simultaneous local git notes + cloud sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authorship notes previously went to exactly one destination: git refs/notes/ai (git_notes) or the cloud data plane via notes-db (http). notes_backend.kind = "both" now writes every note to git refs/notes/ai AND queues it for cloud upload, so the full record of sessions, prompts, and tool calls travels with the repository (offline, team-shareable via the existing notes push/fetch hooks) while the cloud copy powers hosted features. Semantics in Both mode: - Writes: git note first (durable local copy), then best-effort cloud enqueue — a cloud failure never loses the git note and can be backfilled with `autter notes migrate`. - Reads: local git notes are authoritative; the notes-db cache and cloud API cover commits whose notes only exist remotely (e.g. cloud-only teammates). Blob-OID fast paths work again since notes live in git. - Sync: pre-push pushes refs/notes/ai as in git_notes mode; fetch warms the cloud cache in addition to fetching the notes ref; the daemon flushes pending notes-db rows as in http mode. Transcripts already store dual-destination in the default prompt storage mode (permanent local cas_cache + cloud CAS upload), so notes were the missing piece for full local+cloud coverage. Generated-By: PostHog Code Task-Id: e5855956-9de8-49a0-836b-8a2c91373bef --- src/authorship/cas_bridge.rs | 2 +- src/commands/config.rs | 3 +- src/commands/fetch_notes.rs | 11 +- src/commands/hooks/push_hooks.rs | 8 +- src/commands/log.rs | 7 +- src/commands/notes_migrate.rs | 8 +- src/config.rs | 20 +++- src/daemon/telemetry_worker.rs | 7 +- src/git/notes_api.rs | 172 ++++++++++++++++++++++++++++++- src/git/sync_authorship.rs | 9 +- 10 files changed, 219 insertions(+), 28 deletions(-) diff --git a/src/authorship/cas_bridge.rs b/src/authorship/cas_bridge.rs index bfaa1d2..8b59a33 100644 --- a/src/authorship/cas_bridge.rs +++ b/src/authorship/cas_bridge.rs @@ -178,7 +178,7 @@ pub fn resolve_cas_messages(messages_url: &str) -> Result>, } let cfg = config::Config::fresh(); - let dataplane_url = if cfg.notes_backend_kind() == config::NotesBackendKind::Http { + let dataplane_url = if cfg.notes_backend_kind().uses_http() { cfg.notes_backend_url().map(|s| s.to_string()) } else { None diff --git a/src/commands/config.rs b/src/commands/config.rs index c494766..7b3b9f0 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1357,8 +1357,9 @@ fn parse_notes_backend_kind(value: &str) -> Result { match value.trim().to_lowercase().as_str() { "git_notes" | "git-notes" => Ok(NotesBackendKind::GitNotes), "http" => Ok(NotesBackendKind::Http), + "both" => Ok(NotesBackendKind::Both), _ => Err(format!( - "Invalid notes_backend.kind '{}'. Expected 'git_notes' or 'http'", + "Invalid notes_backend.kind '{}'. Expected 'git_notes', 'http', or 'both'", value )), } diff --git a/src/commands/fetch_notes.rs b/src/commands/fetch_notes.rs index cd50dfa..e3031c3 100644 --- a/src/commands/fetch_notes.rs +++ b/src/commands/fetch_notes.rs @@ -93,8 +93,15 @@ pub fn handle_fetch_notes(args: &[String]) { let start = Instant::now(); // When the HTTP notes backend is enabled, warm the local notes-db cache - // from the HTTP backend instead of fetching refs/notes/ai. - if crate::config::Config::get().notes_backend_kind() == NotesBackendKind::Http { + // from the HTTP backend. For the pure Http backend this replaces the git + // fetch; for the Both backend it runs in addition to it. + let backend_kind = crate::config::Config::get().notes_backend_kind(); + if backend_kind == NotesBackendKind::Both + && let Err(e) = crate::git::notes_api::warm_cache_for_remote(&repo, &remote_name) + { + tracing::warn!(%e, "both backend: cloud cache warm failed; continuing with git fetch"); + } + if backend_kind == NotesBackendKind::Http { match crate::git::notes_api::warm_cache_for_remote(&repo, &remote_name) { Ok(()) => { let elapsed = start.elapsed(); diff --git a/src/commands/hooks/push_hooks.rs b/src/commands/hooks/push_hooks.rs index 89d68d3..c9547dc 100644 --- a/src/commands/hooks/push_hooks.rs +++ b/src/commands/hooks/push_hooks.rs @@ -1,5 +1,4 @@ use crate::commands::upgrade; -use crate::config::NotesBackendKind; use crate::git::cli_parser::{ParsedGitInvocation, is_dry_run}; use crate::git::repository::Repository; use crate::git::sync_authorship::push_authorship_notes; @@ -7,8 +6,11 @@ use crate::git::sync_authorship::push_authorship_notes; pub fn run_pre_push_hook_managed(parsed_args: &ParsedGitInvocation, repository: &Repository) { upgrade::maybe_schedule_background_update_check(); - // When using the HTTP notes backend, skip the git-notes push entirely. - if crate::config::Config::get().notes_backend_kind() == NotesBackendKind::Http { + // Skip the git-notes push when notes don't live in git refs at all. + if !crate::config::Config::get() + .notes_backend_kind() + .uses_git_notes() + { tracing::debug!("run_pre_push_hook_managed: skipping authorship push (Http backend)"); return; } diff --git a/src/commands/log.rs b/src/commands/log.rs index 59a80eb..a76a7f3 100644 --- a/src/commands/log.rs +++ b/src/commands/log.rs @@ -3,7 +3,7 @@ use crate::authorship::ignore::effective_ignore_patterns; use crate::authorship::stats::{ stats_for_commit_stats_with_parent_and_authorship, write_stats_to_terminal, }; -use crate::config::{Config, NotesBackendKind}; +use crate::config::Config; use crate::error::AutterError; use crate::git::repository::Repository; use crossterm::{ @@ -231,9 +231,10 @@ fn run_log(args: &[String]) -> Result { } fn run_plain_log(global_args: &[String], git_log_args: &[String]) -> Result { - if Config::get().notes_backend_kind() != NotesBackendKind::GitNotes { + if !Config::get().notes_backend_kind().uses_git_notes() { return Err(LogError::Message( - "plain git log --notes=ai only supports the git_notes backend".to_string(), + "plain git log --notes=ai only supports backends that store notes in git refs" + .to_string(), )); } diff --git a/src/commands/notes_migrate.rs b/src/commands/notes_migrate.rs index 325d584..7d1628c 100644 --- a/src/commands/notes_migrate.rs +++ b/src/commands/notes_migrate.rs @@ -10,7 +10,7 @@ use crate::api::client::{ApiClient, ApiContext}; use crate::api::types::{NoteEntry, NotesUploadRequest}; -use crate::config::{Config, NotesBackendKind}; +use crate::config::Config; use crate::error::AutterError; use crate::git::find_repository; use crate::notes::db::NotesDatabase; @@ -38,11 +38,11 @@ pub fn handle_notes_migrate(args: &[String]) { } } - // 1. Refuse to run unless notes_backend.kind == http. + // 1. Refuse to run unless the backend syncs notes over HTTP. let cfg = Config::fresh(); - if cfg.notes_backend_kind() != NotesBackendKind::Http { + if !cfg.notes_backend_kind().uses_http() { eprintln!( - "error: `autter notes migrate` requires notes_backend.kind = http.\n\ + "error: `autter notes migrate` requires notes_backend.kind = http (or both).\n\ Current backend: {}\n\ \n\ To enable cloud sync, run:\n\ diff --git a/src/config.rs b/src/config.rs index 55cf74c..ae9fecf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -32,6 +32,10 @@ pub enum NotesBackendKind { GitNotes, /// HTTP backend: queue writes to notes-db, flush via daemon, reads from cache Http, + /// Dual write: store notes in git refs/notes/ai AND sync them to the HTTP + /// backend. Local notes travel with the repo (offline, team-shareable via + /// push/fetch); the cloud copy powers hosted features. + Both, } impl NotesBackendKind { @@ -39,8 +43,19 @@ impl NotesBackendKind { match self { NotesBackendKind::GitNotes => "git_notes", NotesBackendKind::Http => "http", + NotesBackendKind::Both => "both", } } + + /// Notes are written to (and synced through) git refs/notes/ai. + pub fn uses_git_notes(&self) -> bool { + matches!(self, NotesBackendKind::GitNotes | NotesBackendKind::Both) + } + + /// Notes are queued to notes-db and uploaded to the HTTP backend. + pub fn uses_http(&self) -> bool { + matches!(self, NotesBackendKind::Http | NotesBackendKind::Both) + } } impl std::fmt::Display for NotesBackendKind { @@ -628,7 +643,7 @@ impl Config { if let Some(url) = self.notes_backend.backend_url.as_deref() { return Some(url); } - if self.notes_backend.kind == NotesBackendKind::Http { + if self.notes_backend.kind.uses_http() { return Some(DEFAULT_NOTES_BACKEND_URL); } None @@ -636,7 +651,7 @@ impl Config { /// Returns true when the HTTP notes backend is active. pub fn notes_backend_enabled(&self) -> bool { - matches!(self.notes_backend.kind, NotesBackendKind::Http) + self.notes_backend.kind.uses_http() } pub fn transcript_streaming_lookback_days(&self) -> Option { @@ -1143,6 +1158,7 @@ fn build_config() -> Config { .and_then(|s| match s.as_str() { "http" => Some(NotesBackendKind::Http), "git_notes" | "git-notes" => Some(NotesBackendKind::GitNotes), + "both" => Some(NotesBackendKind::Both), _ => None, }); let url_from_env = env::var("AUTTER_NOTES_BACKEND_URL").ok(); diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index 0809e24..344e584 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -606,11 +606,10 @@ fn flush_sentry_and_posthog( /// - Not authenticated (no API key and not logged in) pub fn flush_notes() { use crate::api::types::{NoteEntry, NotesUploadRequest}; - use crate::config::NotesBackendKind; let cfg = Config::fresh(); - if cfg.notes_backend_kind() != NotesBackendKind::Http { - tracing::debug!("notes: skipping flush, backend is not Http"); + if !cfg.notes_backend_kind().uses_http() { + tracing::debug!("notes: skipping flush, backend does not use Http"); return; } @@ -778,7 +777,7 @@ pub fn flush_notes() { /// otherwise fall back to the API base URL (legacy behavior). fn cas_client() -> (ApiClient, bool) { let cfg = Config::fresh(); - let dataplane_url = if cfg.notes_backend_kind() == crate::config::NotesBackendKind::Http { + let dataplane_url = if cfg.notes_backend_kind().uses_http() { cfg.notes_backend_url().map(|s| s.to_string()) } else { None diff --git a/src/git/notes_api.rs b/src/git/notes_api.rs index f742744..d04952c 100644 --- a/src/git/notes_api.rs +++ b/src/git/notes_api.rs @@ -19,12 +19,37 @@ pub use crate::git::refs::CommitAuthorship; // --- Writes --- pub fn write_note(repo: &Repository, commit_sha: &str, content: &str) -> Result<(), AutterError> { - match Config::get().notes_backend_kind() { + write_note_with_kind( + Config::get().notes_backend_kind(), + repo, + commit_sha, + content, + ) +} + +fn write_note_with_kind( + kind: NotesBackendKind, + repo: &Repository, + commit_sha: &str, + content: &str, +) -> Result<(), AutterError> { + match kind { NotesBackendKind::Http => { let repo_url = crate::repo_url::resolve_repo_url_from_repo(repo); http_write_note(commit_sha, content, repo_url.as_deref()) } NotesBackendKind::GitNotes => crate::git::refs::notes_add(repo, commit_sha, content), + NotesBackendKind::Both => { + // Git notes are the durable local copy; write them first. The + // HTTP enqueue is best-effort — a failure must not lose the git + // note, and the cloud copy can be backfilled with notes-migrate. + let git_result = crate::git::refs::notes_add(repo, commit_sha, content); + let repo_url = crate::repo_url::resolve_repo_url_from_repo(repo); + if let Err(e) = http_write_note(commit_sha, content, repo_url.as_deref()) { + tracing::warn!(%e, commit_sha, "both backend: cloud note enqueue failed"); + } + git_result + } } } @@ -41,6 +66,14 @@ pub fn write_notes_batch( http_write_batch(entries, repo_url.as_deref()) } NotesBackendKind::GitNotes => crate::git::refs::notes_add_batch(repo, entries), + NotesBackendKind::Both => { + let git_result = crate::git::refs::notes_add_batch(repo, entries); + let repo_url = crate::repo_url::resolve_repo_url_from_repo(repo); + if let Err(e) = http_write_batch(entries, repo_url.as_deref()) { + tracing::warn!(%e, count = entries.len(), "both backend: cloud notes enqueue failed"); + } + git_result + } } } @@ -51,6 +84,10 @@ pub fn read_note(repo: &Repository, commit_sha: &str) -> Option { NotesBackendKind::Http => http_read_note(commit_sha) .or_else(|| crate::git::refs::show_authorship_note(repo, commit_sha)), NotesBackendKind::GitNotes => crate::git::refs::show_authorship_note(repo, commit_sha), + // Local git notes are authoritative; the cloud cache covers commits + // whose notes only exist remotely (e.g. written by cloud-only teammates). + NotesBackendKind::Both => crate::git::refs::show_authorship_note(repo, commit_sha) + .or_else(|| http_read_note(commit_sha)), } } @@ -90,6 +127,31 @@ pub fn read_notes_batch( Ok(notes) } NotesBackendKind::GitNotes => crate::git::refs::notes_for_commits(repo, commit_shas), + NotesBackendKind::Both => { + // Local git notes first, then the cloud cache/API for commits + // whose notes only exist remotely. + let mut notes = crate::git::refs::notes_for_commits(repo, commit_shas)?; + + let missing_after_git: Vec = commit_shas + .iter() + .filter(|sha| !notes.contains_key(*sha)) + .cloned() + .collect(); + if !missing_after_git.is_empty() { + notes.extend(http_read_notes(&missing_after_git)); + } + + let missing_after_cache: Vec = commit_shas + .iter() + .filter(|sha| !notes.contains_key(*sha)) + .cloned() + .collect(); + if !missing_after_cache.is_empty() { + notes.extend(http_fetch_and_cache_notes(&missing_after_cache)); + } + + Ok(notes) + } } } @@ -106,6 +168,14 @@ pub fn read_authorship(repo: &Repository, commit_sha: &str) -> Option crate::git::refs::get_authorship(repo, commit_sha), + NotesBackendKind::Both => { + crate::git::refs::get_authorship(repo, commit_sha).or_else(|| { + let content = http_read_note(commit_sha)?; + AuthorshipLog::deserialize_from_string(&content) + .map_err(|e| tracing::debug!("notes deserialization error: {}", e)) + .ok() + }) + } } } @@ -126,6 +196,20 @@ pub fn read_authorship_v3( NotesBackendKind::GitNotes => { crate::git::refs::get_reference_as_authorship_log_v3(repo, commit_sha) } + NotesBackendKind::Both => { + match crate::git::refs::get_reference_as_authorship_log_v3(repo, commit_sha) { + Ok(log) => Ok(log), + Err(git_err) => { + if let Some(content) = http_read_note(commit_sha) { + AuthorshipLog::deserialize_from_string(&content).map_err(|e| { + AutterError::Generic(format!("notes deserialization error: {}", e)) + }) + } else { + Err(git_err) + } + } + } + } } } @@ -162,7 +246,10 @@ pub fn read_note_blob_oids( // For Http, notes are in notes-db not in git — no blob OIDs exist. // Return an empty map; callers handle this as "no notes in git". NotesBackendKind::Http => Ok(HashMap::new()), - NotesBackendKind::GitNotes => { + // For Both, notes are written to git refs, so real blob OIDs exist + // and the fast paths work. Commits without a local note degrade to + // slow-path reads, which consult the cloud cache. + NotesBackendKind::GitNotes | NotesBackendKind::Both => { crate::git::refs::note_blob_oids_for_commits(repo, commit_shas) } } @@ -191,6 +278,21 @@ pub fn commits_with_notes( NotesBackendKind::GitNotes => { crate::git::refs::commits_with_authorship_notes(repo, commit_shas) } + NotesBackendKind::Both => { + // Git notes first; the cloud cache covers commits whose notes + // only exist remotely. + let from_git = crate::git::refs::commits_with_authorship_notes(repo, commit_shas)?; + if from_git.len() == commit_shas.len() { + return Ok(from_git); + } + let missing: Vec = commit_shas + .iter() + .filter(|sha| !from_git.contains(*sha)) + .cloned() + .collect(); + let cached = http_check_exists(&missing); + Ok(from_git.into_iter().chain(cached).collect()) + } } } @@ -199,7 +301,7 @@ pub fn filter_commits_with_notes( commit_shas: &[String], ) -> Result, AutterError> { match Config::get().notes_backend_kind() { - NotesBackendKind::Http => { + NotesBackendKind::Http | NotesBackendKind::Both => { // `CommitAuthorship` requires a git_author that is only available from // `git rev-list`. Call the underlying git function which handles author // lookup, then patch in cache hits for commits whose `authorship_log` @@ -207,7 +309,8 @@ pub fn filter_commits_with_notes( // // The git function calls `get_authorship(repo, sha)` (refs.rs, not // notes_api), so for Http the results will be `CommitAuthorship::NoLog` - // for all commits. We promote any commit that has a cache entry to + // for all commits (and for Both, any commit without a local git + // note). We promote any commit that has a cache entry to // `CommitAuthorship::Log`. let cached_map = http_read_notes(commit_shas); @@ -718,7 +821,7 @@ mod tests { let kind = crate::config::Config::fresh().notes_backend_kind(); let result: Result, _> = match kind { crate::config::NotesBackendKind::Http => Ok(HashMap::new()), - crate::config::NotesBackendKind::GitNotes => { + crate::config::NotesBackendKind::GitNotes | crate::config::NotesBackendKind::Both => { crate::git::refs::note_blob_oids_for_commits( tmp.autter_repo(), &["abc".to_string()], @@ -798,6 +901,65 @@ mod tests { } } + /// Integration test: the `Both` backend writes the note to git refs AND + /// queues it in notes-db for cloud upload. + #[test] + #[serial_test::serial(notes_db_env)] + fn integration_both_write_note_goes_to_git_and_db() { + use crate::git::repository::exec_git; + use crate::git::test_utils::TmpRepo; + use std::env; + + // Isolated notes-db for this test. + let tmp_db = tempfile::NamedTempFile::new().expect("tmp db file"); + let db_path = tmp_db.path().to_str().unwrap().to_string(); + unsafe { + env::set_var("AUTTER_TEST_NOTES_DB_PATH", &db_path); + } + + let repo = TmpRepo::new().expect("TmpRepo::new"); + + repo.write_file("a.txt", "hello", false) + .expect("write file"); + let sha = repo.commit_all("msg").expect("commit"); + + write_note_with_kind( + NotesBackendKind::Both, + repo.autter_repo(), + &sha, + "dual-note-content", + ) + .expect("both write"); + + // Confirm it is queued in notes-db for cloud upload. + let db = crate::notes::db::NotesDatabase::global().expect("global db"); + let mut lock = db.lock().expect("lock"); + let note_in_db = lock.get_note(&sha).expect("get note"); + assert_eq!(note_in_db, Some("dual-note-content".to_string())); + let pending = lock.dequeue_pending(10).expect("dequeue"); + assert!( + pending.iter().any(|p| p.commit_sha == sha), + "note should be pending in notes-db for the Both backend" + ); + drop(lock); + + // Confirm the note ALSO exists in git refs/notes/ai. + let mut args = repo.autter_repo().global_args_for_exec(); + args.extend([ + "notes".to_string(), + "--ref=ai".to_string(), + "show".to_string(), + sha.clone(), + ]); + let output = exec_git(&args).expect("git notes show should succeed for Both backend"); + let shown = String::from_utf8_lossy(&output.stdout); + assert_eq!(shown.trim(), "dual-note-content"); + + unsafe { + env::remove_var("AUTTER_TEST_NOTES_DB_PATH"); + } + } + /// Integration test: `materialize_notes_for_display` writes notes from the /// notes-db cache into `refs/notes/ai-display` so that `git log --notes=ai-display` /// can show them. diff --git a/src/git/sync_authorship.rs b/src/git/sync_authorship.rs index 591bafb..3be8207 100644 --- a/src/git/sync_authorship.rs +++ b/src/git/sync_authorship.rs @@ -238,9 +238,12 @@ pub fn push_authorship_notes( repository: &Repository, remote_name: &str, ) -> Result<(), AutterError> { - // Belt-and-suspenders: when the HTTP backend is active, notes are not stored - // in refs/notes/ai so there is nothing to push. - if crate::config::Config::get().notes_backend_kind() == crate::config::NotesBackendKind::Http { + // Belt-and-suspenders: skip when notes are not stored in refs/notes/ai + // (pure HTTP backend), so there is nothing to push. + if !crate::config::Config::get() + .notes_backend_kind() + .uses_git_notes() + { tracing::debug!("push_authorship_notes: skipping refs/notes/ai push (Http backend active)"); return Ok(()); } From 83b33218a330807e73a0cc661d419a61b22244d7 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Sat, 4 Jul 2026 23:02:47 +0530 Subject: [PATCH 4/4] feat: remind users on interactive commands when their login has stopped working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud sync pauses silently when auth breaks — previously the only signal was a daemon-log warning nobody reads. Interactive autter commands and git-proxy commands now print a one-line stderr reminder with the pending queue counts and the fix (`autter login`). Detection covers both logged-out shapes: - refresh token past its expiry timestamp, and - a token that is valid by timestamp but rejected by the server (revoked or rotated) — the daemon flush loop persists a sync-auth-blocked stamp whenever pending work meets failed auth, and clears it on the first successful authenticated sync. The notice never fires for users who never logged in, is gated on an interactive stdout so scripts and pipes stay clean, fires at most once per process, and rate-limits to once per 24h across processes. Generated-By: PostHog Code Task-Id: e5855956-9de8-49a0-836b-8a2c91373bef --- src/auth/mod.rs | 1 + src/auth/notice.rs | 181 +++++++++++++++++++++++++++++++++ src/commands/git_handlers.rs | 3 + src/daemon/telemetry_worker.rs | 4 + src/main.rs | 12 +++ 5 files changed, 201 insertions(+) create mode 100644 src/auth/notice.rs diff --git a/src/auth/mod.rs b/src/auth/mod.rs index e960067..d6ff22a 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -2,6 +2,7 @@ pub mod client; pub mod credential_backend; pub mod credentials; pub mod identity; +pub mod notice; pub mod state; pub mod types; diff --git a/src/auth/notice.rs b/src/auth/notice.rs new file mode 100644 index 0000000..d9c80a9 --- /dev/null +++ b/src/auth/notice.rs @@ -0,0 +1,181 @@ +//! User-facing "you've been logged out" reminder. +//! +//! When a login expires (refresh token past its lifetime), cloud sync of +//! authorship notes and prompt transcripts silently pauses — queued data stays +//! local until the user logs in again. The daemon logs a warning, but users +//! don't read daemon logs, so this surfaces the state on interactive commands: +//! a short stderr notice with the pending queue counts and the fix. +//! +//! Fires only for users who WERE logged in (stored credentials whose refresh +//! token has expired) — never for users who never logged in. Gated on an +//! interactive stdout so it can't pollute scripts or piped output, emitted at +//! most once per process, and rate-limited to once per 24 hours across +//! processes via a timestamp file. + +use std::io::IsTerminal; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::auth::CredentialStore; + +/// Minimum seconds between notices across processes (24 hours). +const NOTICE_INTERVAL_SECS: i64 = 24 * 60 * 60; + +/// How long a daemon-reported auth failure stays "fresh" (48 hours). The +/// daemon re-records the stamp on every blocked flush attempt, so an active +/// problem keeps the stamp current; a stale stamp after a successful login +/// simply ages out even if the clear was missed. +const SYNC_BLOCKED_FRESH_SECS: i64 = 48 * 60 * 60; + +static NOTICE_EMITTED: AtomicBool = AtomicBool::new(false); + +fn notice_stamp_path() -> PathBuf { + crate::mdm::utils::home_dir() + .join(".autter") + .join("internal") + .join("logged-out-notice-at") +} + +fn sync_blocked_stamp_path() -> PathBuf { + crate::mdm::utils::home_dir() + .join(".autter") + .join("internal") + .join("sync-auth-blocked-at") +} + +/// Record that a sync attempt found pending work but no working auth. +/// Called by the daemon's flush loop; read back by [`maybe_warn_logged_out`]. +/// This catches the case a pure token-expiry check misses: a refresh token +/// that is valid by timestamp but rejected by the server (revoked, rotated +/// signing keys, etc.). +pub fn record_sync_auth_blocked() { + let path = sync_blocked_stamp_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, unix_now().to_string()); +} + +/// Clear the blocked stamp after a successful authenticated sync. +pub fn clear_sync_auth_blocked() { + let _ = std::fs::remove_file(sync_blocked_stamp_path()); +} + +/// True when the daemon recently reported auth-blocked sync attempts. +fn sync_auth_blocked_recently() -> bool { + let Ok(raw) = std::fs::read_to_string(sync_blocked_stamp_path()) else { + return false; + }; + let Ok(at) = raw.trim().parse::() else { + return false; + }; + unix_now() - at < SYNC_BLOCKED_FRESH_SECS +} + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// True when the notice was shown within the last [`NOTICE_INTERVAL_SECS`]. +fn recently_notified() -> bool { + let Ok(raw) = std::fs::read_to_string(notice_stamp_path()) else { + return false; + }; + let Ok(last) = raw.trim().parse::() else { + return false; + }; + unix_now() - last < NOTICE_INTERVAL_SECS +} + +fn record_notified() { + let path = notice_stamp_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, unix_now().to_string()); +} + +/// Count of locally queued items waiting for cloud upload: (notes, transcripts). +/// Best-effort — returns zeros when the databases are unavailable. +fn pending_upload_counts() -> (i64, i64) { + let notes = crate::notes::db::NotesDatabase::global() + .ok() + .and_then(|db| db.lock().ok().map(|lock| lock.count_pending().unwrap_or(0))) + .unwrap_or(0); + let transcripts = crate::authorship::internal_db::InternalDatabase::global() + .ok() + .and_then(|db| { + db.lock() + .ok() + .map(|lock| lock.count_pending_cas().unwrap_or(0)) + }) + .unwrap_or(0); + (notes, transcripts) +} + +/// Print a reminder to stderr when the user's login has expired. +/// +/// Call from interactive command paths (autter subcommands and the git proxy). +/// All gates are cheap and short-circuit: terminal check, per-process flag, +/// 24-hour stamp file, then the credentials read. +pub fn maybe_warn_logged_out() { + if !std::io::stdout().is_terminal() { + return; + } + if NOTICE_EMITTED.load(Ordering::Relaxed) || recently_notified() { + return; + } + + // Only warn when the user WAS logged in (stored credentials exist) and + // the session no longer works — either the refresh token is past its + // expiry, or the daemon reports that authenticated sync is failing (a + // token that is valid by timestamp but rejected by the server). + let Ok(Some(creds)) = CredentialStore::new().load() else { + return; + }; + if !creds.is_refresh_token_expired() && !sync_auth_blocked_recently() { + return; + } + + if NOTICE_EMITTED + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; + } + record_notified(); + + let (notes, transcripts) = pending_upload_counts(); + + eprintln!(); + eprintln!("\x1b[1;33m⚠ You've been logged out of autter — cloud sync is paused.\x1b[0m"); + if notes > 0 || transcripts > 0 { + eprintln!( + "\x1b[1;33m {} authorship note{} and {} transcript{} are stored locally and will upload once you're back in.\x1b[0m", + notes, + if notes == 1 { "" } else { "s" }, + transcripts, + if transcripts == 1 { "" } else { "s" }, + ); + } + eprintln!("\x1b[1;33m Run \x1b[1;36mautter login\x1b[0m\x1b[1;33m to reconnect.\x1b[0m"); + eprintln!(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn notice_interval_parse_roundtrip() { + // The stamp file format is a bare unix timestamp; make sure the + // parse used by recently_notified accepts what record_notified writes. + let now = unix_now(); + let parsed = now.to_string().trim().parse::().unwrap(); + assert_eq!(parsed, now); + assert!(now > 0); + } +} diff --git a/src/commands/git_handlers.rs b/src/commands/git_handlers.rs index 179f46a..e0d3e01 100644 --- a/src/commands/git_handlers.rs +++ b/src/commands/git_handlers.rs @@ -273,6 +273,9 @@ fn handle_git_inner(args: &[String]) { // Warn (loudly, once per process) if this CLI is below the platform's // minimum required version. Reads the cached releases payload — no network. crate::commands::upgrade::maybe_warn_below_min_version(); + + // Remind the user when their login has expired and cloud sync is paused. + crate::auth::notice::maybe_warn_logged_out(); }); exit_with_status(exit_status); diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index 344e584..abb8d66 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -360,6 +360,9 @@ fn note_durable_sync_unauthenticated(queue: &str, pending: i64) { now + DURABLE_SYNC_AUTH_RETRY_SECS, std::sync::atomic::Ordering::Relaxed, ); + // Persist the blocked state so interactive commands can remind the user + // (see auth::notice::maybe_warn_logged_out). + crate::auth::notice::record_sync_auth_blocked(); let last_warn = DURABLE_SYNC_LAST_AUTH_WARN.load(std::sync::atomic::Ordering::Relaxed); if now - last_warn >= DURABLE_SYNC_AUTH_WARN_SECS { DURABLE_SYNC_LAST_AUTH_WARN.store(now, std::sync::atomic::Ordering::Relaxed); @@ -374,6 +377,7 @@ fn note_durable_sync_unauthenticated(queue: &str, pending: i64) { /// Record a successful auth check so the backoff clears immediately. fn note_durable_sync_authenticated() { DURABLE_SYNC_AUTH_RETRY_AFTER.store(0, std::sync::atomic::Ordering::Relaxed); + crate::auth::notice::clear_sync_auth_blocked(); } fn flush_metrics(events: &[MetricEvent]) { diff --git a/src/main.rs b/src/main.rs index 4548a1a..451ab72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +use autter::auth; use autter::commands; use autter::utils::{SuperuserCheckResult, check_superuser_guard, print_superuser_warning}; use clap::Parser; @@ -94,6 +95,17 @@ fn main() { if !is_superuser_exempt_command(&cli.args) { commands::upgrade::maybe_warn_below_min_version(); } + // Remind the user when their login has expired (cloud sync paused), + // except on commands where it would be noise or redundant. + let first_arg = cli.args.first().map(String::as_str); + if !is_superuser_exempt_command(&cli.args) + && !matches!( + first_arg, + Some("login" | "logout" | "onboard" | "checkpoint") + ) + { + auth::notice::maybe_warn_logged_out(); + } commands::autter_handlers::handle_autter(&cli.args); std::process::exit(0); }