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/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/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/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/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/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/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/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/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 8304c6c..abb8d66 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,78 @@ 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, + ); + // 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); + 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); + crate::auth::notice::clear_sync_auth_blocked(); } fn flush_metrics(events: &[MetricEvent]) { @@ -552,11 +610,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; } @@ -567,32 +624,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; } }; @@ -708,7 +781,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 @@ -785,15 +858,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/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(()); } 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); } 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; 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;