From 6fef056b11b2ac843bf82e43be0c70b686c5fbb5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 18 Oct 2026 12:33:47 +0000 Subject: [PATCH 1/3] fix: remove the worldwide token counter --- .cargo/config.toml | 2 +- README.md | 22 +- SECURITY.md | 2 - crates/tracedecay-api/src/configuration.rs | 19 +- crates/tracedecay-cli/src/cli.rs | 14 - crates/tracedecay-cli/src/cli/help.rs | 23 -- crates/tracedecay-cli/src/cloud.rs | 99 +----- crates/tracedecay-cli/src/commands.rs | 9 +- .../tracedecay-cli/src/commands/settings.rs | 86 ------ crates/tracedecay-cli/src/global.rs | 69 ----- crates/tracedecay-cli/src/main.rs | 90 +----- .../tracedecay-cli/src/native_root_tests.rs | 3 +- crates/tracedecay-cli/src/startup_tests.rs | 27 +- .../tests/core_cli_suite/tool_daemon_test.rs | 65 ++-- .../tests/core_cli_suite/user_config_test.rs | 6 +- .../sweep_outcomes.rs | 2 +- .../src/configuration/user_settings.rs | 28 +- crates/tracedecay-dashboard-api/src/cloud.rs | 18 +- .../src/settings_api.rs | 3 - crates/tracedecay-domain/src/configuration.rs | 11 +- crates/tracedecay-domain/src/lib.rs | 14 +- .../src/configuration/registry.rs | 17 +- .../src/configuration/resolver.rs | 6 +- .../src/configuration/store.rs | 30 +- .../configuration/store/tests/persistence.rs | 125 +++++++- crates/tracedecay-global-db/src/support.rs | 4 +- .../src/user_config.rs | 65 ++-- .../configuration_idempotency_journey_test.rs | 6 +- crates/tracedecay/src/doctor.rs | 220 ++------------ crates/tracedecay/src/doctor/tests.rs | 44 +-- crates/tracedecay/src/mcp/server.rs | 5 - .../tracedecay/src/mcp/server/connection.rs | 44 +-- crates/tracedecay/src/mcp/server/ledger.rs | 284 +++--------------- crates/tracedecay/src/mcp/server/requests.rs | 1 - .../tests/dashboard_api_test/settings.rs | 29 +- .../tests/mcp_suite/mcp_cli_serve_test.rs | 24 +- .../runtime_surface_acceptance.rs | 6 +- .../AnalyticsControls.dom.test.tsx | 131 ++------ .../observatory/AnalyticsControls.tsx | 60 +--- .../observatory/analyticsControls.test.ts | 12 +- .../observatory/analyticsControls.ts | 28 +- .../src/workspaces/observatory/evidence.ts | 2 +- .../settings/SettingsPage.dom.test.tsx | 1 - .../settings/settingsEditorMachine.ts | 1 - .../src/workspaces/settings/settingsModel.ts | 8 +- .../workspaces/settings/settingsRows.test.ts | 2 +- .../src/workspaces/settings/settingsRows.ts | 3 - dashboard/stories/fixtures/data.ts | 1 - docs/REBRAND-COMPATIBILITY-POLICY.md | 11 +- docs/USER-GUIDE.md | 57 ++-- evals/agent_adoption/run.sh | 1 - scripts/mcp-conformance-smoke.sh | 1 - 52 files changed, 437 insertions(+), 1404 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index c12efa73a9..28ad4ef952 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -31,7 +31,7 @@ CXXFLAGS_aarch64_pc_windows_msvc = { value = "-DNDEBUG /utf-8", force = false } # connections. Nothing reads those statistics or sets a heap limit. LIBSQLITE3_FLAGS = { value = "-DSQLITE_MAX_WORKER_THREADS=15 -DSQLITE_DEFAULT_MEMSTATUS=0", force = true } -# Global accounting (savings ledger, worldwide-counter flushes) is ON by +# Global accounting (the savings ledger) is ON by # default for installed binaries, but `cargo test` / `cargo run` would then # write temp-project rows into the developer's real ~/.tracedecay/global.db. # Keep cargo-launched processes hermetic; tests that exercise the ledger diff --git a/README.md b/README.md index fb0965ed87..e4417998e7 100644 --- a/README.md +++ b/README.md @@ -195,8 +195,6 @@ authorized, sanitized, classified records under their remote policy. Network-capable effects are separate from core retrieval: -- Worldwide counter: when opted in, sends the pending aggregate token-savings - amount, and `tracedecay doctor` reads the public total to check reachability. - GitHub release checks and updates: contact GitHub release endpoints. An explicitly configured private GitHub review source can use a read-only credential from the operating-system keyring; missing, ambiguous, @@ -212,19 +210,13 @@ Network-capable effects are separate from core retrieval: path fails closed with typed `unavailable`, `denied`, or `stale-peer` state. These services receive normal transport metadata, including the connection's -source address, in addition to any request payload. The counter request is an -aggregate amount, not repository content, but it is not an anonymity guarantee. -Use a network policy or firewall if a command must make no outbound connection. -An opt-out, denial, timeout, or unavailable remote service must remain visible -as that state; it is not evidence of a zero counter, an up-to-date release, or -current pricing. See [Security](SECURITY.md#network-access) for the complete -outbound-access and credential boundary. - -Disable the worldwide counter with: - -```bash -tracedecay disable-upload-counter -``` +source address, in addition to any request payload. Use a network policy or +firewall if a command must make no outbound connection. A denial, timeout, or +unavailable remote service must remain visible as that state; it is not +evidence of an up-to-date release or current pricing. TraceDecay no longer has +a worldwide token counter: token savings and costs stay local. See +[Security](SECURITY.md#network-access) for the complete outbound-access and +credential boundary. ## Troubleshooting diff --git a/SECURITY.md b/SECURITY.md index 2d0d2dce1d..80992d8d4b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -67,7 +67,6 @@ Outbound capabilities include: | Configured remote sources and authorities | Policy-authorized retrieval and remote operations | Configured peer authentication and disclosure policy | Admission or authorization failures are reported; review each peer before enabling | | `github.com` | Download binary during `tracedecay upgrade` | None (public releases) | Error shown to user | | `huggingface.co` and Hugging Face artifact hosts | Download missing, revision-pinned semantic-model artifacts when semantic auto-download is enabled | None | Semantic retrieval reports model acquisition state or failure; exact, lexical, and graph retrieval remain available | -| `tracedecay-counter.enzinol.workers.dev` | Aggregate token-savings counter | None | Silently ignored | Provider usage and pricing do not add an outbound connection. `tracedecay cost` reads immutable provider-native usage observations and the deterministic bundled @@ -169,7 +168,6 @@ an old count of unsafe sites is not a security guarantee. The following are **not** security issues: -- The aggregate token counter sending a count to the public Cloudflare Worker endpoint (this is documented behavior and contains no identifying information beyond an approximate country derived from IP by Cloudflare) - The database containing symbol names or file paths from your project (this is core functionality) - The MCP edit tools modifying files (this is opt-in functionality your AI agent invokes through its own tool-approval flow) - `tracedecay_run_affected_tests` compiling and running your project's own test suite (this is the tool's documented purpose) diff --git a/crates/tracedecay-api/src/configuration.rs b/crates/tracedecay-api/src/configuration.rs index 4817efb7a1..dc04cf01dc 100644 --- a/crates/tracedecay-api/src/configuration.rs +++ b/crates/tracedecay-api/src/configuration.rs @@ -72,8 +72,6 @@ pub struct UserSettingsPatch { pub expected_revision_id: String, pub idempotency_key: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub upload_enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub watcher_debounce: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub extraction_timeout_secs: Option, @@ -121,7 +119,6 @@ pub fn parse_code_index_worker_settings_patch( /// write that unsigned value and do not parse the wire string again. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AdmittedUserSettingsPatch { - pub upload_enabled: Option, pub watcher_debounce_ms: Option, pub extraction_timeout_secs: Option, } @@ -154,7 +151,6 @@ pub fn admit_user_settings_patch( } if errors.is_empty() { Ok(AdmittedUserSettingsPatch { - upload_enabled: patch.upload_enabled, watcher_debounce_ms, extraction_timeout_secs: patch.extraction_timeout_secs, }) @@ -332,7 +328,7 @@ mod tests { let result = parse_user_settings_patch(json!({ "expected_revision_id": "configuration.revision.project", "idempotency_key": "configuration.idempotency.fixture", - "upload_enabled": true, + "watcher_debounce": "2s", "code_index_workers": { "mode": "automatic" } })); @@ -340,4 +336,17 @@ mod tests { assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body["validation_errors"][0]["field"], "code_index_workers"); } + + #[test] + fn user_patch_refuses_the_retired_worldwide_counter_upload_field() { + let result = parse_user_settings_patch(json!({ + "expected_revision_id": "configuration.revision.profile", + "idempotency_key": "configuration.idempotency.fixture", + "upload_enabled": true + })); + + let (status, Json(body)) = result.expect_err("a retired field must be refused"); + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["validation_errors"][0]["field"], "upload_enabled"); + } } diff --git a/crates/tracedecay-cli/src/cli.rs b/crates/tracedecay-cli/src/cli.rs index 7eb5537359..eaa11bd830 100644 --- a/crates/tracedecay-cli/src/cli.rs +++ b/crates/tracedecay-cli/src/cli.rs @@ -578,20 +578,6 @@ pub enum Commands { #[arg(short, long)] path: Option, }, - /// Disable uploading token counts to the worldwide counter - #[command( - name = "disable-upload-counter", - long_about = DISABLE_UPLOAD_COUNTER_LONG_ABOUT, - after_help = DISABLE_UPLOAD_COUNTER_AFTER_HELP - )] - DisableUploadCounter, - /// Enable uploading token counts to the worldwide counter - #[command( - name = "enable-upload-counter", - long_about = ENABLE_UPLOAD_COUNTER_LONG_ABOUT, - after_help = ENABLE_UPLOAD_COUNTER_AFTER_HELP - )] - EnableUploadCounter, /// Check tracedecay installation, configuration, and agent integration #[command(long_about = DOCTOR_LONG_ABOUT, after_help = DOCTOR_AFTER_HELP)] Doctor { diff --git a/crates/tracedecay-cli/src/cli/help.rs b/crates/tracedecay-cli/src/cli/help.rs index 77bc4f4833..a0d8e2c722 100644 --- a/crates/tracedecay-cli/src/cli/help.rs +++ b/crates/tracedecay-cli/src/cli/help.rs @@ -446,29 +446,6 @@ Examples: Related: tracedecay current-counter, tracedecay gain."; -pub(crate) const DISABLE_UPLOAD_COUNTER_LONG_ABOUT: &str = "\ -Opts this machine out of contributing anonymous token-savings counts to the \ -public worldwide counter. Only aggregate numbers are ever uploaded, never \ -code, paths, or queries, but uploading is entirely optional."; - -pub(crate) const DISABLE_UPLOAD_COUNTER_AFTER_HELP: &str = "\ -Examples: - tracedecay disable-upload-counter Stop contributing counts - -Related: tracedecay enable-upload-counter, tracedecay gain (local ledger -is unaffected)."; - -pub(crate) const ENABLE_UPLOAD_COUNTER_LONG_ABOUT: &str = "\ -Re-enables contributing anonymous token-savings counts to the public \ -worldwide counter after a previous `disable-upload-counter`. Only aggregate \ -numbers are uploaded, never code, paths, or queries."; - -pub(crate) const ENABLE_UPLOAD_COUNTER_AFTER_HELP: &str = "\ -Examples: - tracedecay enable-upload-counter Resume contributing counts - -Related: tracedecay disable-upload-counter, tracedecay gain."; - pub(crate) const DOCTOR_LONG_ABOUT: &str = "\ Checks the binary, PATH, daemon service, project index, and every agent \ integration, printing actionable fixes for anything broken. The running \ diff --git a/crates/tracedecay-cli/src/cloud.rs b/crates/tracedecay-cli/src/cloud.rs index ced9744617..27618d8b48 100644 --- a/crates/tracedecay-cli/src/cloud.rs +++ b/crates/tracedecay-cli/src/cloud.rs @@ -1,7 +1,5 @@ -//! HTTP client for the worldwide counter Cloudflare Worker and GitHub release -//! version checking. +//! HTTP client for GitHub release version checking. //! -//! Counter operations are best-effort: failures surface as `None` / empty. //! Release lookups classify every failure as a [`ReleaseLookupError`]. All //! are synchronous `ureq` calls that block the calling thread for up to their //! own timeout, which an enclosing Tokio deadline cannot cut short. A caller @@ -14,20 +12,14 @@ use serde::de::DeserializeOwned; use tracedecay_application::http_agent::http_agent; use tracedecay_dashboard_api::cloud::ReleaseLookupError; -/// The Cloudflare Worker endpoint URL. -const WORKER_URL: &str = "https://tracedecay-counter.enzinol.workers.dev"; - /// Base URL of the GitHub REST API release lookups are issued against. pub(crate) const GITHUB_API_URL: &str = "https://api.github.com"; const GITHUB_OWNER: &str = "ScriptedAlchemy"; const GITHUB_REPOSITORY: &str = "tracedecay"; -/// Timeout for flush (upload) requests. -const FLUSH_TIMEOUT: Duration = Duration::from_secs(2); - -/// Timeout for opportunistic advisory reads (worldwide counter and the -/// background / `doctor` version probe). Keep this short so a stalled -/// GitHub or worker never blocks those paths. +/// Timeout for opportunistic advisory reads (the background / `doctor` +/// version probe). Keep this short so a stalled GitHub never blocks those +/// paths. const FETCH_TIMEOUT: Duration = Duration::from_secs(1); /// Bound for each GitHub release-metadata read of an explicit `tracedecay @@ -38,53 +30,6 @@ const FETCH_TIMEOUT: Duration = Duration::from_secs(1); /// keep [`FETCH_TIMEOUT`]. pub(crate) const UPGRADE_RELEASE_LOOKUP_TIMEOUT: Duration = Duration::from_secs(30); -/// Response from the worker's POST /increment and GET /total endpoints. -#[derive(serde::Deserialize)] -struct WorkerResponse { - total: u64, -} - -/// Creates a ureq agent with the given timeout. -pub fn agent_with_timeout(timeout: Duration) -> ureq::Agent { - http_agent( - ureq::Agent::config_builder() - .timeout_global(Some(timeout)) - .build(), - ) -} - -/// Uploads pending tokens to the worldwide counter. -/// Returns the new worldwide total on success, or None on any failure. -#[tracing::instrument(name = "cloud.flush_pending", level = "trace", skip_all)] -pub fn flush_pending(amount: u64) -> Option { - if amount == 0 { - return None; - } - let body = serde_json::json!({ "amount": amount }); - let agent = agent_with_timeout(FLUSH_TIMEOUT); - let parsed: WorkerResponse = agent - .post(&format!("{WORKER_URL}/increment")) - .send_json(&body) - .ok()? - .body_mut() - .read_json() - .ok()?; - Some(parsed.total) -} - -/// Fetches the current worldwide total from the worker. -/// Returns None on timeout, network error, or parse failure. -#[tracing::instrument(name = "cloud.fetch_worldwide_total", level = "trace", skip_all)] -pub fn fetch_worldwide_total() -> Option { - fetch_worldwide_total_from(&format!("{WORKER_URL}/total")) -} - -fn fetch_worldwide_total_from(url: &str) -> Option { - let agent = agent_with_timeout(FETCH_TIMEOUT); - let parsed: WorkerResponse = agent.get(url).call().ok()?.body_mut().read_json().ok()?; - Some(parsed.total) -} - /// Response from GitHub releases API (only the fields we need). #[derive(serde::Deserialize)] struct GitHubRelease { @@ -356,16 +301,15 @@ pub fn is_beta() -> bool { tracedecay_dashboard_api::cloud::is_beta(env!("CARGO_PKG_VERSION")) } -/// Admits these ureq implementations into the composition library so MCP -/// flush/version checks can run after the binary starts. Unregistered -/// lookups stay `None` (already best-effort). +/// Admits the ureq release lookup into the composition library so MCP version +/// checks can run after the binary starts. An unregistered lookup stays +/// `None` (already best-effort). pub fn admit_sync_probes() { - tracedecay_dashboard_api::cloud::admit_sync_cloud_probes(flush_pending, fetch_latest_version); + tracedecay_dashboard_api::cloud::admit_sync_cloud_probes(fetch_latest_version); } pub fn doctor_network_probes() -> tracedecay::doctor::AdmittedDoctorNetworkProbes { tracedecay::doctor::AdmittedDoctorNetworkProbes { - fetch_worldwide_total, fetch_latest_version, } } @@ -570,33 +514,6 @@ mod tests { ); } - #[test] - fn advisory_worldwide_total_still_times_out_at_the_one_second_budget() { - let (base, _heads) = stub_after( - Duration::from_millis(1500), - Some(respond( - "200 OK", - "Content-Type: application/json\r\n", - r#"{"total":42}"#, - )), - ); - let started = Instant::now(); - - assert!( - fetch_worldwide_total_from(&format!("{base}/total")).is_none(), - "advisory worldwide-total must keep the one-second budget" - ); - let elapsed = started.elapsed(); - assert!( - elapsed >= FETCH_TIMEOUT, - "advisory worldwide-total returned before its one-second budget: {elapsed:?}" - ); - assert!( - elapsed < Duration::from_millis(1500), - "advisory worldwide-total waited for the delayed answer: {elapsed:?}" - ); - } - fn respond(status: &str, headers: &str, body: &str) -> &'static str { format!( "HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", diff --git a/crates/tracedecay-cli/src/commands.rs b/crates/tracedecay-cli/src/commands.rs index d86586b06b..be8a552d0c 100644 --- a/crates/tracedecay-cli/src/commands.rs +++ b/crates/tracedecay-cli/src/commands.rs @@ -25,12 +25,11 @@ pub(crate) use index::{handle_init, handle_no_command, handle_sync}; pub(crate) use profile_storage::handle_profile_storage_action; pub(crate) use scope::resolve_project_scope; pub(crate) use settings::{ - canonical_upload_enabled, current_configuration_revision, current_project_setting, - handle_upload_counter, mutate_project_configuration, project_configuration_set, - repeated_query_hints_enabled, report_configuration_receipt, + current_configuration_revision, current_project_setting, mutate_project_configuration, + project_configuration_set, repeated_query_hints_enabled, report_configuration_receipt, }; pub(crate) use stale_store_reset::handle_wipe_stale; pub(crate) use storage::{ - ProfileOfflineAuthority, RestoreWording, annotate_reset_required, handle_list, handle_wipe, - join_outcome_and_restore, process_error_text, take_profile_offline, try_admit_profile_registry, + ProfileOfflineAuthority, RestoreWording, handle_list, handle_wipe, join_outcome_and_restore, + process_error_text, take_profile_offline, try_admit_profile_registry, }; diff --git a/crates/tracedecay-cli/src/commands/settings.rs b/crates/tracedecay-cli/src/commands/settings.rs index 435110761c..e203031ac6 100644 --- a/crates/tracedecay-cli/src/commands/settings.rs +++ b/crates/tracedecay-cli/src/commands/settings.rs @@ -17,7 +17,6 @@ use tracedecay_daemon_protocol::RequestedOutputFormat; use tracedecay_domain::configuration::{ ConfigurationIdempotencyKey, ConfigurationLayerIdV1, ConfigurationRevisionId, ConfigurationValueV1, SettingKey, USER_REPEATED_QUERY_HINTS_SETTING_KEY, - USER_UPLOAD_ENABLED_SETTING_KEY, UserProfileId, }; use tracedecay_domain::{ProjectId, UtcMicros, canonical_sha256}; use tracedecay_tool_catalog::ApplicationSurfaceOperation; @@ -43,25 +42,6 @@ fn cli_configuration_idempotency_key( .map_err(|error| config_error(format!("invalid configuration request key: {error}"))) } -fn cli_user_configuration_idempotency_key( - profile_id: &UserProfileId, - expected_revision: &ConfigurationRevisionId, - mutations: &[ConfigurationDirectMutationRequestV1], -) -> tracedecay_domain::errors::Result { - let digest = canonical_sha256(&( - "tracedecay.cli.user-configuration-mutation.v1", - profile_id, - expected_revision, - mutations, - )) - .map_err(|error| config_error(format!("invalid user configuration mutation: {error}")))?; - let suffix = digest - .hex_suffix() - .ok_or_else(|| config_error("user configuration mutation digest is malformed"))?; - ConfigurationIdempotencyKey::new(format!("configuration.idempotency.cli.user.{suffix}")) - .map_err(|error| config_error(format!("invalid user configuration request key: {error}"))) -} - fn configuration_deadline( operation: ApplicationSurfaceOperation, observed_at: UtcMicros, @@ -210,13 +190,6 @@ async fn resolved_setting( .map_err(|error| config_error(format!("invalid configuration setting: {error}"))) } -/// The profile's worldwide-counter upload setting, wherever the command runs. -pub(crate) async fn canonical_upload_enabled( - profile: &ProfileRoot, -) -> tracedecay_domain::errors::Result { - boolean_setting(&resolved_setting(profile, None, USER_UPLOAD_ENABLED_SETTING_KEY).await?) -} - /// The profile's experimental repeated-query hint setting for `serve`. pub(crate) async fn repeated_query_hints_enabled( profile: &ProfileRoot, @@ -280,31 +253,6 @@ pub(crate) async fn mutate_project_configuration( configuration_effect_receipt(envelope, &idempotency_key).map(Some) } -async fn mutate_user_configuration( - profile: &ProfileRoot, - profile_id: &UserProfileId, - expected_revision: ConfigurationRevisionId, - mutations: Vec, -) -> tracedecay_domain::errors::Result> { - if mutations.is_empty() { - return Ok(None); - } - let idempotency_key = - cli_user_configuration_idempotency_key(profile_id, &expected_revision, &mutations)?; - let envelope = invoke_configuration_surface( - profile, - None, - ApplicationSurfaceOperation::ConfigurationBatch, - ConfigurationWireRequestV1::Batch(ConfigurationBatchRequestV1 { - mutations, - expected_revision, - idempotency_key: idempotency_key.clone(), - }), - ) - .await?; - configuration_effect_receipt(envelope, &idempotency_key).map(Some) -} - fn configuration_effect_receipt( envelope: ApplicationEnvelope, idempotency_key: &ConfigurationIdempotencyKey, @@ -347,37 +295,3 @@ pub(crate) fn report_configuration_receipt(receipt: Option<&EffectReceipt>) { eprintln!("Receipt: {}", receipt.request_id.as_str()); } } - -#[tracing::instrument(name = "cli.settings.upload_counter", level = "trace", skip_all)] -pub(crate) async fn handle_upload_counter( - profile: &ProfileRoot, - enable: bool, -) -> tracedecay_domain::errors::Result<()> { - let current = resolved_setting(profile, None, USER_UPLOAD_ENABLED_SETTING_KEY).await?; - let profile_id = - tracedecay_daemon_identity::profile_identity::load_existing(profile.data_dir())? - .profile_id() - .clone(); - let mutations = if boolean_setting(¤t)? != enable { - vec![ConfigurationDirectMutationRequestV1::Set { - layer: ConfigurationLayerIdV1::UserProfile { - profile_id: profile_id.clone(), - }, - key: current.key, - value: Box::new(ConfigurationValueV1::Boolean(enable)), - }] - } else { - Vec::new() - }; - let receipt = - mutate_user_configuration(profile, &profile_id, current.revision_id, mutations).await?; - if enable { - eprintln!("Worldwide counter upload enabled."); - } else { - eprintln!( - "Worldwide counter upload disabled. You can re-enable with `tracedecay enable-upload-counter`." - ); - } - report_configuration_receipt(receipt.as_ref()); - Ok(()) -} diff --git a/crates/tracedecay-cli/src/global.rs b/crates/tracedecay-cli/src/global.rs index c96a487b65..5ea8e37a9c 100644 --- a/crates/tracedecay-cli/src/global.rs +++ b/crates/tracedecay-cli/src/global.rs @@ -32,55 +32,6 @@ pub(crate) async fn classify_project_storage_with_registry( .await } -/// Returns how many seconds have elapsed since a persisted timestamp. -/// -/// User config timestamps can land in the future because of clock skew, -/// manual edits, or state copied across machines. Clamp those cases to zero so -/// cooldown/version-cache logic degrades gracefully instead of getting stuck on -/// a negative delta. -fn elapsed_since(now: i64, recorded_at: i64) -> i64 { - if recorded_at >= now { - 0 - } else { - now - recorded_at - } -} - -/// Best-effort: try to flush pending tokens to the worldwide counter. -/// -/// `upload_enabled` is the already-resolved canonical user-profile setting; -/// `config` only carries the pending counter and cooldown timestamps. -/// `force` = true on status/sync commands (always attempt), false on others -/// (only flush if stale > 30s). -pub(crate) fn try_flush( - config: &mut tracedecay_session_memory::user_config::UserConfig, - force: bool, - upload_enabled: bool, -) { - if config.pending_upload == 0 || !upload_enabled { - return; - } - let now = tracedecay_runtime_core::tracedecay::current_timestamp(); - - // Cooldown: skip if last flush attempt failed less than 60s ago - if config.last_flush_attempt_at > config.last_upload_at - && elapsed_since(now, config.last_flush_attempt_at) < 60 - { - return; - } - - // Staleness check for non-force commands - if !force && elapsed_since(now, config.last_upload_at) < 30 { - return; - } - - config.last_flush_attempt_at = now; - if crate::cloud::flush_pending(config.pending_upload).is_some() { - config.pending_upload = 0; - config.last_upload_at = now; - } -} - /// Returns the project paths the `wipe` command should act on. /// /// `--all` returns every path tracked in the global DB (including stale rows). @@ -702,24 +653,4 @@ mod gather_tests { vec![std::path::PathBuf::from("/repos/a")] ); } - - #[test] - fn elapsed_since_clamps_future_timestamps() { - assert_eq!(elapsed_since(100, 40), 60); - assert_eq!(elapsed_since(100, 100), 0); - assert_eq!(elapsed_since(100, 140), 0); - } - - #[test] - fn canonical_upload_denial_leaves_pending_tokens_unflushed() { - let mut config = tracedecay_session_memory::user_config::UserConfig { - pending_upload: 42, - ..tracedecay_session_memory::user_config::UserConfig::default() - }; - - try_flush(&mut config, true, false); - - assert_eq!(config.pending_upload, 42); - assert_eq!(config.last_flush_attempt_at, 0); - } } diff --git a/crates/tracedecay-cli/src/main.rs b/crates/tracedecay-cli/src/main.rs index f31f3bf161..3e1280fb3e 100644 --- a/crates/tracedecay-cli/src/main.rs +++ b/crates/tracedecay-cli/src/main.rs @@ -733,7 +733,7 @@ async fn run(cli: Cli) -> tracedecay_domain::errors::Result { } }; - run_startup_preamble(&profile, &command).await; + run_startup_preamble(&profile, &command); dispatch_command(&profile, command, host_bundle).await } @@ -754,28 +754,9 @@ fn command_profile(command: Option<&Commands>) -> tracedecay_domain::errors::Res } #[tracing::instrument(name = "cli.startup.preamble", level = "trace", skip_all)] -async fn run_startup_preamble(profile: &ProfileRoot, command: &Commands) { +fn run_startup_preamble(profile: &ProfileRoot, command: &Commands) { let startup_policy = CommandStartupPolicy::for_command(command); - // Check first-run before any config save creates the file. - let profile_root = profile.data_dir(); - let is_first_run = !tracedecay_session_memory::user_config::UserConfig::exists(profile_root); - - let is_force_flush = matches!(command, Commands::Sync { .. } | Commands::Status { .. }); - match tracedecay_session_memory::user_config::UserConfig::load(profile_root) { - Ok(user_config) => { - flush_worldwide_counter(profile, command, is_force_flush, user_config).await; - } - Err(err) => eprintln!("warning: {err}"), - } - - if is_first_run && startup_policy.runs_startup_maintenance() { - eprintln!( - "note: tracedecay can optionally upload anonymous token savings counts to a worldwide counter.\n\ - \x20 Run `tracedecay enable-upload-counter` to opt in." - ); - } - if startup_policy.runs_agent_install_check() && let Some(home) = profile.home() { @@ -783,52 +764,6 @@ async fn run_startup_preamble(profile: &ProfileRoot, command: &Commands) { } } -async fn flush_worldwide_counter( - profile: &ProfileRoot, - command: &Commands, - is_force_flush: bool, - mut user_config: tracedecay_session_memory::user_config::UserConfig, -) { - let profile_root = profile.data_dir(); - // Skip the worldwide-counter flush on hot startup paths. `try_flush` - // makes a synchronous HTTP call which can add seconds to - // `tracedecay serve` startup on slow networks, long enough to blow the - // MCP client's 30 s `initialize` timeout. The canonical setting lookup is - // only consulted when there are pending tokens to flush: with nothing - // pending the setting cannot change behavior, and probing it on every - // command turned the daemon's transient "runtime still mounting" state - // into per-command stderr noise. A failed lookup on an ordinary command - // is deferred (the next command retries); the flush-bearing commands - // (`sync`, `status`) still surface it, so a persistent failure - // stays visible exactly where the flush is expected to happen. - // The setting belongs to the profile, so the flush does not depend on - // the current directory. - if runs_worldwide_counter_flush(command) && user_config.pending_upload > 0 { - match commands::canonical_upload_enabled(profile).await { - Ok(upload_enabled) => { - global::try_flush(&mut user_config, is_force_flush, upload_enabled); - } - Err(error) if is_force_flush => { - eprintln!( - "warning: canonical worldwide-counter upload setting is unavailable: {}", - commands::annotate_reset_required(error, None) - ); - } - Err(error) => { - tracing::debug!( - "worldwide-counter flush deferred: canonical upload setting unavailable: \ - {error}" - ); - } - } - } - if !is_local_install_command(command) - && let Err(err) = user_config.save_if_exists(profile_root) - { - eprintln!("warning: could not save tracedecay config: {err}"); - } -} - async fn resolve_registered_project_root( profile: &ProfileRoot, project_id: Option, @@ -975,10 +910,7 @@ impl CommandFamily { | Commands::PostUpdate { .. } | Commands::PackageHook { .. } | Commands::Channel { .. } => Self::Update, - Commands::CurrentCounter { .. } - | Commands::ResetCounter { .. } - | Commands::DisableUploadCounter - | Commands::EnableUploadCounter => Self::Configuration, + Commands::CurrentCounter { .. } | Commands::ResetCounter { .. } => Self::Configuration, Commands::Doctor { .. } | Commands::Cost { .. } | Commands::Gain { .. } @@ -1822,12 +1754,6 @@ async fn dispatch_configuration_command( } eprintln!("Local counter reset (was {prev})"); } - Commands::DisableUploadCounter => { - commands::handle_upload_counter(profile, false).await?; - } - Commands::EnableUploadCounter => { - commands::handle_upload_counter(profile, true).await?; - } _ => unreachable!("non-configuration command passed to configuration dispatcher"), } Ok(()) @@ -2023,6 +1949,7 @@ impl CommandStartupPolicy { } } + #[cfg(test)] fn runs_startup_maintenance(self) -> bool { !matches!(self, Self::SkipAll) } @@ -2032,11 +1959,6 @@ impl CommandStartupPolicy { } } -fn runs_worldwide_counter_flush(command: &Commands) -> bool { - !matches!(command, Commands::Init { .. }) - && CommandStartupPolicy::for_command(command).runs_startup_maintenance() -} - #[cfg(test)] fn should_skip_startup_maintenance(command: &Commands) -> bool { !CommandStartupPolicy::for_command(command).runs_startup_maintenance() @@ -2047,10 +1969,6 @@ fn should_skip_agent_install_check(command: &Commands) -> bool { !CommandStartupPolicy::for_command(command).runs_agent_install_check() } -fn is_local_install_command(command: &Commands) -> bool { - matches!(command, Commands::Install { local: true, .. }) -} - fn normalize_tool_reserved_global_flags(cli: &mut Cli) { if !cli.dry_run { return; diff --git a/crates/tracedecay-cli/src/native_root_tests.rs b/crates/tracedecay-cli/src/native_root_tests.rs index 88cfe50643..6f0681009d 100644 --- a/crates/tracedecay-cli/src/native_root_tests.rs +++ b/crates/tracedecay-cli/src/native_root_tests.rs @@ -31,7 +31,7 @@ fn run_on_native_root(profile: &ProfileRoot, args: &[&str]) -> Result (Option, V (output.status.code(), body) } -fn run_upload_counter_command(home: &Path, cwd: &Path, command: &str) { - let output = tracedecay_command_with_home(home) - .current_dir(cwd) - .arg(command) - .stdin(Stdio::null()) - .output() - .expect("upload counter command should run"); - assert!(output.status.success(), "{command} failed: {output:?}"); -} - /// A user-scoped setting belongs to the profile, so it resolves wherever the -/// command runs: a value written inside a project is the one doctor and -/// `configuration_get` report outside any project, and a write made outside a -/// project is the one the project then reads. A project-scoped key still needs -/// a project. +/// command runs: the same value is reported inside and outside any project. A +/// project-scoped key still needs a project, and the retired worldwide-counter +/// upload setting is no longer a registered key. #[test] fn user_settings_resolve_from_the_profile_outside_any_project() { let home = TempDir::new().unwrap(); @@ -4060,40 +4049,22 @@ fn user_settings_resolve_from_the_profile_outside_any_project() { init_committed_git_project_with_cli(&home, &project); let _daemon = spawn_tracedecay_daemon(&home); - run_upload_counter_command(&home, &project, "enable-upload-counter"); - - let doctor = tracedecay_command_with_home(&home) - .current_dir(&outside) - .arg("doctor") - .stdin(Stdio::null()) - .output() - .expect("doctor should run"); - let doctor_stderr = String::from_utf8_lossy(&doctor.stderr); - assert!( - doctor_stderr.contains("Worldwide counter upload enabled"), - "doctor outside a project must report the profile's upload setting:\n{doctor_stderr}" - ); - assert!( - !doctor_stderr.contains("upload setting unavailable") - && !doctor_stderr.contains("canonical configuration is unavailable"), - "doctor outside a project reported the user setting unavailable:\n{doctor_stderr}" - ); - - let (code, enabled) = configuration_get_from(&home, &outside, "user.upload_enabled.v1"); - assert_eq!(code, Some(0), "{enabled}"); - assert_eq!( - enabled["structuredContent"]["outcome"]["value"]["payload"]["effective_value"], - json!({ "kind": "boolean", "value": true }), - "{enabled}" - ); + for cwd in [&outside, &project] { + let (code, hints) = configuration_get_from(&home, cwd, "user.repeated_query_hints.v1"); + assert_eq!(code, Some(0), "{hints}"); + assert_eq!( + hints["structuredContent"]["outcome"]["value"]["payload"]["effective_value"], + json!({ "kind": "boolean", "value": false }), + "the profile setting must resolve from {}: {hints}", + cwd.display() + ); + } - run_upload_counter_command(&home, &outside, "disable-upload-counter"); - let (code, disabled) = configuration_get_from(&home, &project, "user.upload_enabled.v1"); - assert_eq!(code, Some(0), "{disabled}"); - assert_eq!( - disabled["structuredContent"]["outcome"]["value"]["payload"]["effective_value"], - json!({ "kind": "boolean", "value": false }), - "a write made outside a project must be the value the project reads: {disabled}" + let (code, retired) = configuration_get_from(&home, &outside, "user.upload_enabled.v1"); + assert_ne!( + code, + Some(0), + "the retired upload setting must be refused, not read: {retired}" ); let (code, refused) = configuration_get_from(&home, &outside, "index.max_file_size.v1"); diff --git a/crates/tracedecay-cli/tests/core_cli_suite/user_config_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/user_config_test.rs index 381ba9dd64..ce0365ebdd 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/user_config_test.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/user_config_test.rs @@ -2,9 +2,9 @@ use tracedecay_session_memory::user_config::UserConfig; #[test] fn missing_fields_use_defaults() { - let toml_str = "pending_upload = 5\n"; + let toml_str = "last_version_check_at = 5\n"; let parsed: UserConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(parsed.pending_upload, 5); - assert_eq!(parsed.last_upload_at, 0); + assert_eq!(parsed.last_version_check_at, 5); + assert_eq!(parsed.last_version_warning_at, 0); assert!(parsed.installed_agents.is_empty()); } diff --git a/crates/tracedecay-cli/tests/host_journeys_suite/host_lifecycle_cli_acceptance/sweep_outcomes.rs b/crates/tracedecay-cli/tests/host_journeys_suite/host_lifecycle_cli_acceptance/sweep_outcomes.rs index f61c947ef8..e447a37183 100644 --- a/crates/tracedecay-cli/tests/host_journeys_suite/host_lifecycle_cli_acceptance/sweep_outcomes.rs +++ b/crates/tracedecay-cli/tests/host_journeys_suite/host_lifecycle_cli_acceptance/sweep_outcomes.rs @@ -518,7 +518,7 @@ fn doctor_fails_a_corrupt_profile_config_with_its_repair() { let _daemon = ProfileDaemon::start(&cli); let config = cli.profile.join("config.toml"); let installed = fs::read_to_string(&config).unwrap(); - fs::write(&config, "pending_upload = 0\n true").unwrap(); + fs::write(&config, "last_version_check_at = 0\n true").unwrap(); let doctor = cli.run(&["doctor"]); let doctor_stderr = stderr(&doctor); diff --git a/crates/tracedecay-configuration/src/configuration/user_settings.rs b/crates/tracedecay-configuration/src/configuration/user_settings.rs index eb490cd0e2..5b3a8c6c10 100644 --- a/crates/tracedecay-configuration/src/configuration/user_settings.rs +++ b/crates/tracedecay-configuration/src/configuration/user_settings.rs @@ -12,8 +12,7 @@ use std::pin::Pin; use tracedecay_automation::config::AutomationConfig; use tracedecay_domain::configuration::{ ConfigurationLayerIdV1, ConfigurationValueV1, SettingKey, - USER_EXTRACTION_TIMEOUT_SECS_SETTING_KEY, USER_UPLOAD_ENABLED_SETTING_KEY, - USER_WATCHER_DEBOUNCE_MS_SETTING_KEY, UserProfileId, + USER_EXTRACTION_TIMEOUT_SECS_SETTING_KEY, USER_WATCHER_DEBOUNCE_MS_SETTING_KEY, UserProfileId, }; use tracedecay_contracts::now_micros; @@ -29,7 +28,6 @@ pub type UserSettingsFuture<'a, T> = pub struct UserSettingsSnapshotV1 { pub configuration_snapshot_id: String, pub configuration_revision_id: String, - pub upload_enabled: bool, pub watcher_debounce: String, pub watcher_debounce_ms: u64, pub extraction_timeout_secs: u64, @@ -40,7 +38,6 @@ pub struct UserSettingsSnapshotV1 { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct UserSettingsMutationV1 { - pub upload_enabled: Option, /// Positive milliseconds already parsed at the settings boundary. pub watcher_debounce_ms: Option, pub extraction_timeout_secs: Option, @@ -142,14 +139,12 @@ fn user_settings_snapshot( current: &ProfileConfigurationV1, metadata: UserMetadata, ) -> Result { - let upload_enabled = required_bool(current, USER_UPLOAD_ENABLED_SETTING_KEY)?; let watcher_debounce_ms = required_unsigned(current, USER_WATCHER_DEBOUNCE_MS_SETTING_KEY)?; let extraction_timeout_secs = required_unsigned(current, USER_EXTRACTION_TIMEOUT_SECS_SETTING_KEY)?; Ok(UserSettingsSnapshotV1 { configuration_snapshot_id: current.snapshot.snapshot_id.as_str().to_owned(), configuration_revision_id: current.revision_id.as_str().to_owned(), - upload_enabled, watcher_debounce: format_duration_millis(watcher_debounce_ms), watcher_debounce_ms, extraction_timeout_secs, @@ -173,13 +168,6 @@ pub fn plan_user_settings_mutation( let mut mutations = Vec::new(); let mut restart_recommended = false; - if let Some(upload_enabled) = mutation.upload_enabled { - mutations.push(set( - layer.clone(), - USER_UPLOAD_ENABLED_SETTING_KEY, - ConfigurationValueV1::Boolean(upload_enabled), - )?); - } if let Some(watcher_debounce_ms) = mutation.watcher_debounce_ms { mutations.push(set( layer.clone(), @@ -235,16 +223,6 @@ fn setting_key(raw_key: &str) -> Result .map_err(|error| unavailable(format!("registered user setting key: {error}"))) } -fn required_bool( - current: &ProfileConfigurationV1, - raw_key: &str, -) -> Result { - match current.value(raw_key) { - Ok(ConfigurationValueV1::Boolean(value)) => Ok(*value), - _ => Err(unavailable(format!("boolean user setting {raw_key}"))), - } -} - fn required_unsigned( current: &ProfileConfigurationV1, raw_key: &str, @@ -271,7 +249,6 @@ mod tests { UserSettingsSnapshotV1 { configuration_snapshot_id: "configuration.snapshot.fixture".to_owned(), configuration_revision_id: "configuration.revision.fixture".to_owned(), - upload_enabled: false, watcher_debounce: "2s".to_owned(), watcher_debounce_ms: 2_000, extraction_timeout_secs: 60, @@ -288,14 +265,13 @@ mod tests { &snapshot(), profile_id.clone(), UserSettingsMutationV1 { - upload_enabled: Some(true), watcher_debounce_ms: Some(15_000), extraction_timeout_secs: Some(60), }, ) .unwrap(); - assert_eq!(plan.mutations.len(), 3); + assert_eq!(plan.mutations.len(), 2); assert!(plan.restart_recommended); assert!(plan.mutations.iter().any(|mutation| matches!( mutation, diff --git a/crates/tracedecay-dashboard-api/src/cloud.rs b/crates/tracedecay-dashboard-api/src/cloud.rs index 598e6e4ab3..8f436a98e1 100644 --- a/crates/tracedecay-dashboard-api/src/cloud.rs +++ b/crates/tracedecay-dashboard-api/src/cloud.rs @@ -164,27 +164,17 @@ fn http_date(unix_seconds: u64) -> String { httpdate::fmt_http_date(UNIX_EPOCH + Duration::from_secs(unix_seconds)) } -type FlushPending = fn(u64) -> Option; type FetchLatestVersion = fn() -> Result; -static FLUSH_PENDING: OnceLock = OnceLock::new(); static FETCH_LATEST_VERSION: OnceLock = OnceLock::new(); -/// Admits the CLI binary's sync ureq implementations. A second admission is -/// ignored so tests that re-enter process start stay idempotent. Unregistered -/// lookups return `None`. -pub fn admit_sync_cloud_probes( - flush_pending: FlushPending, - fetch_latest_version: FetchLatestVersion, -) { - let _ = FLUSH_PENDING.set(flush_pending); +/// Admits the CLI binary's sync ureq release lookup. A second admission is +/// ignored so tests that re-enter process start stay idempotent. An +/// unregistered lookup returns `None`. +pub fn admit_sync_cloud_probes(fetch_latest_version: FetchLatestVersion) { let _ = FETCH_LATEST_VERSION.set(fetch_latest_version); } -pub fn flush_pending(amount: u64) -> Option { - FLUSH_PENDING.get().and_then(|flush| flush(amount)) -} - /// Set by test harnesses so no spawned CLI or daemon asks GitHub for the /// latest release; the advisory version check then has no answer. pub const NO_RELEASE_CHECK_ENV: &str = "TRACEDECAY_NO_RELEASE_CHECK"; diff --git a/crates/tracedecay-dashboard-api/src/settings_api.rs b/crates/tracedecay-dashboard-api/src/settings_api.rs index 5d69f5d0b1..4056a5a43d 100644 --- a/crates/tracedecay-dashboard-api/src/settings_api.rs +++ b/crates/tracedecay-dashboard-api/src/settings_api.rs @@ -212,7 +212,6 @@ struct UserSettingsPayloadV1 { /// user settings revision above. code_index_worker_configuration_snapshot_id: String, code_index_worker_configuration_revision_id: String, - upload_enabled: bool, code_index_workers: CodeIndexWorkerSelectionV1, /// The admitted process-wide plan. A persisted selection may differ until /// the daemon restarts, so unavailable is carried as `null` rather than @@ -431,7 +430,6 @@ pub async fn patch_user_settings( ¤t, profile_id, UserSettingsMutationV1 { - upload_enabled: admitted.upload_enabled, watcher_debounce_ms: admitted.watcher_debounce_ms, extraction_timeout_secs: admitted.extraction_timeout_secs, }, @@ -678,7 +676,6 @@ fn user_settings_payload( code_index_worker_configuration_revision_id: worker_configuration .configuration_revision_id .clone(), - upload_enabled: user.upload_enabled, code_index_workers: worker_configuration.code_index_workers, code_index_worker_status: tracedecay_code_index::parallelism::installed_worker_status(), watcher_debounce: user.watcher_debounce.clone(), diff --git a/crates/tracedecay-domain/src/configuration.rs b/crates/tracedecay-domain/src/configuration.rs index 8b771a20c6..304f2e80fd 100644 --- a/crates/tracedecay-domain/src/configuration.rs +++ b/crates/tracedecay-domain/src/configuration.rs @@ -61,13 +61,18 @@ pub const RETIRED_CORE_SETTING_KEYS_V1: &[&str] = &[ "index.git_ignore.v1", "semantic.runtime.v1", "sync.orphan_db_gc_days.v1", - USER_UPLOAD_ENABLED_SETTING_KEY, + "user.upload_enabled.v1", USER_WATCHER_DEBOUNCE_MS_SETTING_KEY, USER_EXTRACTION_TIMEOUT_SECS_SETTING_KEY, ]; +/// Profile setting keys that shipped and were then retired. A profile store +/// carrying one converges by dropping it on the next read; the value has no +/// registered owner anymore. `user.upload_enabled.v1` was the opt-in of the +/// worldwide token counter, removed without a final upload. +pub const RETIRED_PROFILE_SETTING_KEYS_V1: &[&str] = &["user.upload_enabled.v1"]; + /// Canonical user-profile settings. -pub const USER_UPLOAD_ENABLED_SETTING_KEY: &str = "user.upload_enabled.v1"; pub const USER_REPEATED_QUERY_HINTS_SETTING_KEY: &str = "user.repeated_query_hints.v1"; pub const USER_CODE_INDEX_WORKERS_SETTING_KEY: &str = "user.code_index_workers.v1"; pub const USER_WATCHER_DEBOUNCE_MS_SETTING_KEY: &str = "user.watcher_debounce_ms.v1"; @@ -80,7 +85,6 @@ pub const USER_WORK_EXPERTISE_CONSENT_SETTING_KEY: &str = "user.work_expertise_c /// Work read pins it together with the project's consent in one revision. pub const PROFILE_SETTING_KEYS_V1: &[&str] = &[ USER_CODE_INDEX_WORKERS_SETTING_KEY, - USER_UPLOAD_ENABLED_SETTING_KEY, USER_REPEATED_QUERY_HINTS_SETTING_KEY, USER_WATCHER_DEBOUNCE_MS_SETTING_KEY, USER_EXTRACTION_TIMEOUT_SECS_SETTING_KEY, @@ -129,7 +133,6 @@ pub const CONFIGURATION_SETTING_KEYS_V1: &[&str] = &[ AUTOMATION_SETTINGS_SETTING_KEY, LCM_SUMMARIZER_EXECUTABLES_SETTING_KEY, crate::feedback::PROXIMITY_RISK_THRESHOLD_SETTING_KEY_V1, - USER_UPLOAD_ENABLED_SETTING_KEY, USER_REPEATED_QUERY_HINTS_SETTING_KEY, USER_CODE_INDEX_WORKERS_SETTING_KEY, USER_WATCHER_DEBOUNCE_MS_SETTING_KEY, diff --git a/crates/tracedecay-domain/src/lib.rs b/crates/tracedecay-domain/src/lib.rs index 93fce9395a..3c73c73636 100644 --- a/crates/tracedecay-domain/src/lib.rs +++ b/crates/tracedecay-domain/src/lib.rs @@ -125,13 +125,13 @@ pub use configuration::{ TELEMETRY_TIMINGS_SETTING_KEY, TopologyConcurrencyPolicyV1, TopologyEscalationPolicyV1, TopologyGatePolicyV1, TopologyNotificationLevelV1, TopologyPolicyDigestV1, USER_CODE_INDEX_WORKERS_SETTING_KEY, USER_EXTRACTION_TIMEOUT_SECS_SETTING_KEY, - USER_REPEATED_QUERY_HINTS_SETTING_KEY, USER_UPLOAD_ENABLED_SETTING_KEY, - USER_WATCHER_DEBOUNCE_MS_SETTING_KEY, USER_WORK_EXPERTISE_CONSENT_SETTING_KEY, UserProfileId, - WORK_EXECUTABLE_BINDINGS_SETTING_KEY, WORK_TOPOLOGY_POLICY_SETTING_KEY, - WorkExecutableBindingV1, WorkExecutableCapabilityV1, WorkExpertiseCategoryV1, - WorkExpertiseConsentV1, WorkTopologyPolicyV1, WorktreeCleanlinessRequirementV1, - WorktreePlacementModeV1, WorktreePlacementRootId, WorktreeRetentionPolicyV1, - WorktreeRootPolicyV1, resolve_restrictive_capabilities, safe_work_topology_policy_v1, + USER_REPEATED_QUERY_HINTS_SETTING_KEY, USER_WATCHER_DEBOUNCE_MS_SETTING_KEY, + USER_WORK_EXPERTISE_CONSENT_SETTING_KEY, UserProfileId, WORK_EXECUTABLE_BINDINGS_SETTING_KEY, + WORK_TOPOLOGY_POLICY_SETTING_KEY, WorkExecutableBindingV1, WorkExecutableCapabilityV1, + WorkExpertiseCategoryV1, WorkExpertiseConsentV1, WorkTopologyPolicyV1, + WorktreeCleanlinessRequirementV1, WorktreePlacementModeV1, WorktreePlacementRootId, + WorktreeRetentionPolicyV1, WorktreeRootPolicyV1, resolve_restrictive_capabilities, + safe_work_topology_policy_v1, }; pub use cursor_binding::{ CURSOR_INVALID_CODE, CURSOR_PARAMETER_CHANGED_CODE, CursorBindingBuilderV1, diff --git a/crates/tracedecay-global-db/src/configuration/registry.rs b/crates/tracedecay-global-db/src/configuration/registry.rs index 6645a1aa37..e31e1ece16 100644 --- a/crates/tracedecay-global-db/src/configuration/registry.rs +++ b/crates/tracedecay-global-db/src/configuration/registry.rs @@ -23,10 +23,9 @@ use tracedecay_domain::configuration::{ SYNC_WATCH_MAX_DELAY_MS_SETTING_KEY, SYNC_WATCH_MAX_PROJECTS_SETTING_KEY, SettingDefinitionV1, SettingKey, SettingScopeV1, SettingSensitivityV1, TELEMETRY_TIMINGS_SETTING_KEY, USER_CODE_INDEX_WORKERS_SETTING_KEY, USER_EXTRACTION_TIMEOUT_SECS_SETTING_KEY, - USER_REPEATED_QUERY_HINTS_SETTING_KEY, USER_UPLOAD_ENABLED_SETTING_KEY, - USER_WATCHER_DEBOUNCE_MS_SETTING_KEY, USER_WORK_EXPERTISE_CONSENT_SETTING_KEY, - WORK_EXECUTABLE_BINDINGS_SETTING_KEY, WORK_TOPOLOGY_POLICY_SETTING_KEY, WorkExpertiseConsentV1, - safe_work_topology_policy_v1, + USER_REPEATED_QUERY_HINTS_SETTING_KEY, USER_WATCHER_DEBOUNCE_MS_SETTING_KEY, + USER_WORK_EXPERTISE_CONSENT_SETTING_KEY, WORK_EXECUTABLE_BINDINGS_SETTING_KEY, + WORK_TOPOLOGY_POLICY_SETTING_KEY, WorkExpertiseConsentV1, safe_work_topology_policy_v1, }; use tracedecay_domain::feedback::PROXIMITY_RISK_THRESHOLD_SETTING_KEY_V1; use tracedecay_domain::{DomainError, validate_index_path_patterns}; @@ -415,11 +414,6 @@ fn register_profile_scalar_settings( registry: &mut ConfigurationRegistry, ) -> Result<(), ConfigurationRegistryError> { for (key, default_value, restart_requirement) in [ - ( - USER_UPLOAD_ENABLED_SETTING_KEY, - ConfigurationValueV1::Boolean(false), - RestartRequirementV1::None, - ), ( USER_REPEATED_QUERY_HINTS_SETTING_KEY, ConfigurationValueV1::Boolean(false), @@ -862,11 +856,6 @@ mod user_profile_settings_tests { fn editable_profile_settings_are_registered_with_exact_scope_and_restart_semantics() { let registry = ConfigurationRegistry::profile().expect("registry"); for (raw_key, kind, restart) in [ - ( - USER_UPLOAD_ENABLED_SETTING_KEY, - ConfigurationValueKindV1::Boolean, - RestartRequirementV1::None, - ), ( USER_REPEATED_QUERY_HINTS_SETTING_KEY, ConfigurationValueKindV1::Boolean, diff --git a/crates/tracedecay-global-db/src/configuration/resolver.rs b/crates/tracedecay-global-db/src/configuration/resolver.rs index 233bb9b55d..2dffabd8ab 100644 --- a/crates/tracedecay-global-db/src/configuration/resolver.rs +++ b/crates/tracedecay-global-db/src/configuration/resolver.rs @@ -220,7 +220,7 @@ mod tests { }, revision_id: id("revision.project.1"), entries: BTreeMap::from([( - id("user.upload_enabled.v1"), + id("user.repeated_query_hints.v1"), ConfigurationValueV1::Boolean(true), )]), }; @@ -228,7 +228,7 @@ mod tests { resolve_configuration(®istry, &[layer]) .unwrap_err() .to_string(), - "setting user.upload_enabled.v1 cannot be placed in Project { project_id: ProjectId(\"project.fixture\") }" + "setting user.repeated_query_hints.v1 cannot be placed in Project { project_id: ProjectId(\"project.fixture\") }" ); let user_layer = ConfigurationLayerV1 { @@ -237,7 +237,7 @@ mod tests { }, revision_id: id("revision.profile.1"), entries: BTreeMap::from([( - id("user.upload_enabled.v1"), + id("user.repeated_query_hints.v1"), ConfigurationValueV1::Boolean(true), )]), }; diff --git a/crates/tracedecay-global-db/src/configuration/store.rs b/crates/tracedecay-global-db/src/configuration/store.rs index e8eed26376..b7a4948960 100644 --- a/crates/tracedecay-global-db/src/configuration/store.rs +++ b/crates/tracedecay-global-db/src/configuration/store.rs @@ -18,10 +18,10 @@ use tracedecay_domain::configuration::{ ConfigurationReceiptId, ConfigurationRevisionId, ConfigurationSnapshotV1, ConfigurationValueV1, INDEX_NATIVE_GRAPH_ACTIVATION_SETTING_KEY, LCM_SUMMARIZER_EXECUTABLES_SETTING_KEY, ProtectedChange, ProtectedChangePlan, ProtectedChangeSnapshotError, - RETIRED_CORE_SETTING_KEYS_V1, RedactedConfigurationChangeV1, RollbackModeV1, - SOURCE_BINDINGS_SETTING_KEY, SYNC_WATCH_LINKED_WORKTREES_SETTING_KEY, ScopeControlOperationV1, - SettingKey, USER_CODE_INDEX_WORKERS_SETTING_KEY, USER_EXTRACTION_TIMEOUT_SECS_SETTING_KEY, - USER_REPEATED_QUERY_HINTS_SETTING_KEY, USER_UPLOAD_ENABLED_SETTING_KEY, + RETIRED_CORE_SETTING_KEYS_V1, RETIRED_PROFILE_SETTING_KEYS_V1, RedactedConfigurationChangeV1, + RollbackModeV1, SOURCE_BINDINGS_SETTING_KEY, SYNC_WATCH_LINKED_WORKTREES_SETTING_KEY, + ScopeControlOperationV1, SettingKey, USER_CODE_INDEX_WORKERS_SETTING_KEY, + USER_EXTRACTION_TIMEOUT_SECS_SETTING_KEY, USER_REPEATED_QUERY_HINTS_SETTING_KEY, USER_WATCHER_DEBOUNCE_MS_SETTING_KEY, UserProfileId, WORK_TOPOLOGY_POLICY_SETTING_KEY, }; use tracedecay_domain::{AccessPolicyDigest, ActorId, ManifestDigest, UtcMicros, canonical_sha256}; @@ -666,7 +666,6 @@ pub struct ProfileCodeIndexWorkerCommitV1 { /// Profile settings registered after a profile store was first initialized. /// A store written before one existed converges by adding its default. const PROFILE_ADDITIVE_SETTING_KEYS: &[&str] = &[ - USER_UPLOAD_ENABLED_SETTING_KEY, USER_REPEATED_QUERY_HINTS_SETTING_KEY, USER_WATCHER_DEBOUNCE_MS_SETTING_KEY, USER_EXTRACTION_TIMEOUT_SECS_SETTING_KEY, @@ -725,12 +724,19 @@ impl<'db> ProfileConfigurationStore<'db> { } } let current = self.current_state().await?; - if registry.definitions().all(|definition| { - current - .snapshot - .effective_values - .contains_key(&definition.key) - }) { + let carries_retired_key = current.snapshot.effective_values.keys().any(|key| { + RETIRED_PROFILE_SETTING_KEYS_V1 + .iter() + .any(|retired| key.as_str() == *retired) + }); + if !carries_retired_key + && registry.definitions().all(|definition| { + current + .snapshot + .effective_values + .contains_key(&definition.key) + }) + { return Self::project(&self.profile_id, current, ®istry); } let converged = match self @@ -740,7 +746,7 @@ impl<'db> ProfileConfigurationStore<'db> { occurred_at, ®istry, PROFILE_ADDITIVE_SETTING_KEYS, - &[], + RETIRED_PROFILE_SETTING_KEYS_V1, ) .await { diff --git a/crates/tracedecay-global-db/src/configuration/store/tests/persistence.rs b/crates/tracedecay-global-db/src/configuration/store/tests/persistence.rs index 61ccf8c36a..a4913c6b31 100644 --- a/crates/tracedecay-global-db/src/configuration/store/tests/persistence.rs +++ b/crates/tracedecay-global-db/src/configuration/store/tests/persistence.rs @@ -18,8 +18,9 @@ use crate::configuration::registry::ConfigurationRegistry; use crate::configuration::resolver::resolve_configuration; use tracedecay_domain::configuration::{ CodeIndexWorkerSelectionV1, ConfigurationIdempotencyKey, ConfigurationLayerIdV1, - ConfigurationMutationOperationV1, SettingKey, USER_CODE_INDEX_WORKERS_SETTING_KEY, - USER_REPEATED_QUERY_HINTS_SETTING_KEY, USER_UPLOAD_ENABLED_SETTING_KEY, + ConfigurationMutationOperationV1, ConfigurationValueKindV1, DeprecationStateV1, + RestartRequirementV1, SettingDefinitionV1, SettingKey, SettingScopeV1, SettingSensitivityV1, + USER_CODE_INDEX_WORKERS_SETTING_KEY, USER_REPEATED_QUERY_HINTS_SETTING_KEY, }; #[asupersync::test] @@ -176,10 +177,6 @@ async fn worker_only_profile_store_converges_onto_the_profile_registry() { converged.code_index_workers().unwrap().selection, CodeIndexWorkerSelectionV1::Exact { workers: 8 } ); - assert_eq!( - converged.value(USER_UPLOAD_ENABLED_SETTING_KEY).unwrap(), - &ConfigurationValueV1::Boolean(false) - ); assert_eq!( converged .value(USER_REPEATED_QUERY_HINTS_SETTING_KEY) @@ -188,6 +185,122 @@ async fn worker_only_profile_store_converges_onto_the_profile_registry() { ); } +/// A profile store written while the worldwide counter shipped carries the +/// `user.upload_enabled.v1` opt-in, possibly enabled. It converges on the +/// first read: the retired key is dropped without any upload, the operator's +/// other values survive, and a second read finds nothing left to do. +#[asupersync::test] +async fn profile_store_carrying_the_retired_upload_setting_converges_and_keeps_other_values() { + let directory = tempfile::tempdir().unwrap(); + let profile_root = directory.path().join("profile"); + let runtime = crate::tests::harness::HostAdmissionTestRuntimeV1::profile(&profile_root) + .await + .unwrap(); + let db = runtime + .registered_database(HostAdmissionScope::Profile) + .unwrap(); + let profile_id = db.binding().shard_id.profile_id.clone(); + let upload_key = SettingKey::new("user.upload_enabled.v1").unwrap(); + let worker_key = SettingKey::new(USER_CODE_INDEX_WORKERS_SETTING_KEY).unwrap(); + assert!( + ConfigurationRegistry::profile() + .unwrap() + .definition(&upload_key) + .is_err(), + "the current profile registry must not register the retired key" + ); + let mut legacy_definitions = ConfigurationRegistry::profile() + .unwrap() + .definitions() + .cloned() + .collect::>(); + legacy_definitions.push(SettingDefinitionV1 { + key: upload_key.clone(), + schema_revision: crate::configuration::registry::CONFIGURATION_REGISTRY_SCHEMA_REVISION, + value_kind: ConfigurationValueKindV1::Boolean, + default_value: ConfigurationValueV1::Boolean(false), + sensitivity: SettingSensitivityV1::Public, + scope: SettingScopeV1::UserProfile, + restart_requirement: RestartRequirementV1::None, + deprecation: DeprecationStateV1::Active, + }); + let legacy = ConfigurationRegistry::from_definitions(legacy_definitions).unwrap(); + let initial_revision = id("configuration.profile.initial.v1"); + GlobalDbConfigurationControlStore::new_registered(db) + .initialize_canonical_with_registry( + &initial_revision, + &resolve_configuration(&legacy, &[]).unwrap(), + UtcMicros(1), + &legacy, + ) + .await + .unwrap(); + let layer = ConfigurationLayerIdV1::UserProfile { + profile_id: profile_id.clone(), + }; + let mut revision = initial_revision; + for (name, key, value) in [ + ( + "worker", + worker_key.clone(), + ConfigurationValueV1::CodeIndexWorkerSelection(CodeIndexWorkerSelectionV1::Exact { + workers: 8, + }), + ), + ( + "upload", + upload_key.clone(), + ConfigurationValueV1::Boolean(true), + ), + ] { + let authority = control_authority_with_key_for_layer( + ConfigurationMutationOperationV1::DirectMutation, + &revision, + Some( + ConfigurationIdempotencyKey::new(format!( + "configuration.idempotency.legacy-{name}" + )) + .unwrap(), + ), + layer.clone(), + ); + let transaction = db.begin_write_transaction().await.unwrap(); + let committed = super::super::mutation::commit_direct_in_transaction_with_registry( + &transaction, + &authority, + &DirectConfigurationMutation::Set { + layer: layer.clone(), + key, + value: Box::new(value), + }, + &revision, + &legacy, + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + revision = committed.current.revision_id; + } + + let store = super::super::ProfileConfigurationStore::new_registered(db, &profile_id).unwrap(); + let converged = store.read_or_initialize(UtcMicros(3)).await.unwrap(); + + assert_ne!(converged.revision_id, revision); + assert!( + !converged + .snapshot + .effective_values + .contains_key(&upload_key), + "the retired upload opt-in must be dropped, not carried" + ); + assert_eq!( + converged.code_index_workers().unwrap().selection, + CodeIndexWorkerSelectionV1::Exact { workers: 8 } + ); + let settled = store.read_or_initialize(UtcMicros(4)).await.unwrap(); + assert_eq!(settled.revision_id, converged.revision_id); +} + #[asupersync::test] async fn project_revision_store_rejects_profile_worker_snapshot() { let (_directory, runtime, root) = global_setup().await; diff --git a/crates/tracedecay-global-db/src/support.rs b/crates/tracedecay-global-db/src/support.rs index 6f35e1d369..7460e5a8ee 100644 --- a/crates/tracedecay-global-db/src/support.rs +++ b/crates/tracedecay-global-db/src/support.rs @@ -61,8 +61,8 @@ pub fn env_flag(name: &str) -> bool { std::env::var(name).is_ok_and(|value| env_value_truthy(&value)) } -/// Whether user-level global accounting (the cross-project `savings_ledger` -/// plus worldwide-counter flushes in the MCP server) is enabled. +/// Whether user-level global accounting (the cross-project `savings_ledger`) +/// is enabled. /// /// Enabled **by default**: every other writer of the user-level `global.db` /// (CLI sync, hooks, `tracedecay cost`, the dashboard) is ungated, and the diff --git a/crates/tracedecay-session-memory/src/user_config.rs b/crates/tracedecay-session-memory/src/user_config.rs index 82235f2b1b..9ca04d9d85 100644 --- a/crates/tracedecay-session-memory/src/user_config.rs +++ b/crates/tracedecay-session-memory/src/user_config.rs @@ -15,11 +15,16 @@ use tracedecay_domain::canonical_text::default_true; use tracedecay_automation::config::AutomationConfig; use tracedecay_runtime_core::storage::{append_lock_path, retry_transient_file_op}; -/// Keys canonical `user.*` configuration settings own, and caches of the -/// retired status worldwide/flag decoration. They are never read from this -/// file. -const RETIRED_KEYS: [&str; 7] = [ +/// Keys canonical `user.*` configuration settings own, and state of the +/// retired worldwide counter (its upload opt-in, pending amount and flush +/// timestamps) and status worldwide/flag decoration. They are never read from +/// this file; an older profile that still carries them loads normally and the +/// next write erases them. The pending amount is dropped without an upload. +const RETIRED_KEYS: [&str; 10] = [ "upload_enabled", + "pending_upload", + "last_upload_at", + "last_flush_attempt_at", "watcher_debounce", "extraction_timeout_secs", "last_worldwide_total", @@ -31,18 +36,6 @@ const RETIRED_KEYS: [&str; 7] = [ /// User-level tracedecay configuration. #[derive(Debug, Serialize, Deserialize)] pub struct UserConfig { - /// Tokens accumulated locally, not yet uploaded. - #[serde(default)] - pub pending_upload: u64, - - /// UNIX timestamp of last successful upload. - #[serde(default)] - pub last_upload_at: i64, - - /// UNIX timestamp of last flush attempt (success or failure). - #[serde(default)] - pub last_flush_attempt_at: i64, - /// Cached latest version from GitHub releases. #[serde(default)] pub cached_latest_version: String, @@ -102,9 +95,6 @@ pub struct UserConfig { impl Default for UserConfig { fn default() -> Self { Self { - pending_upload: 0, - last_upload_at: 0, - last_flush_attempt_at: 0, cached_latest_version: String::new(), last_version_check_at: 0, last_version_warning_at: 0, @@ -275,7 +265,7 @@ impl UserConfig { /// /// An unreadable or malformed file is an error, not defaults: those would /// silently turn a stored opt-out (dashboard, memory injection) back on - /// and drop the tracked hosts and pending counts. + /// and drop the tracked hosts. pub fn load(profile_root: &Path) -> std::result::Result { read_profile_config::(profile_root).map(Self::without_retired_keys) } @@ -441,7 +431,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); // The torn write seen in the wild: a valid line followed by a bare // " true" orphan with no key. - let torn = "pending_upload = 0\n true"; + let torn = "last_version_check_at = 0\n true"; std::fs::write(&path, torn).unwrap(); let entries = || { let mut names = std::fs::read_dir(path.parent().unwrap()) @@ -452,7 +442,7 @@ mod tests { names }; let config = UserConfig { - pending_upload: 7, + last_version_check_at: 7, ..UserConfig::default() }; @@ -471,9 +461,9 @@ mod tests { assert_eq!(load_error.to_string(), expected); assert_eq!(entries(), vec!["config.toml".to_owned()]); - std::fs::write(&path, "pending_upload = 0\n").unwrap(); + std::fs::write(&path, "last_version_check_at = 0\n").unwrap(); config.save(profile).expect("a parseable config saves"); - assert_eq!(UserConfig::load(profile).unwrap().pending_upload, 7); + assert_eq!(UserConfig::load(profile).unwrap().last_version_check_at, 7); } #[test] @@ -529,13 +519,13 @@ mod tests { let path = config_path(profile); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - let handles: Vec<_> = (0..8u64) + let handles: Vec<_> = (0..8i64) .map(|thread_idx| { let profile = profile.to_path_buf(); std::thread::spawn(move || { - for i in 0..20u64 { + for i in 0..20i64 { let config = UserConfig { - pending_upload: thread_idx * 100 + i, + last_version_check_at: thread_idx * 100 + i, ..UserConfig::default() }; // Every write must succeed and leave a parseable file. @@ -554,10 +544,10 @@ mod tests { let parsed = toml::from_str::(&saved) .expect("file must be parseable after concurrent saves"); assert_eq!( - parsed.pending_upload % 100, + parsed.last_version_check_at % 100, 19, "the surviving file is one writer's final save, got {}", - parsed.pending_upload + parsed.last_version_check_at ); } @@ -583,9 +573,9 @@ mod tests { } }); - for i in 0..150u64 { + for i in 0..150i64 { let config = UserConfig { - pending_upload: i, + last_version_check_at: i, ..UserConfig::default() }; config.save(profile).expect("writer save should succeed"); @@ -595,7 +585,7 @@ mod tests { assert_eq!( toml::from_str::(&saved) .expect("final config parses") - .pending_upload, + .last_version_check_at, 149 ); } @@ -608,7 +598,9 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "upload_enabled = true\nwatcher_debounce = \"9s\"\nextraction_timeout_secs = 5\n\ + "upload_enabled = true\npending_upload = 4200\nlast_upload_at = 1711375100\n\ + last_flush_attempt_at = 1711375150\n\ + watcher_debounce = \"9s\"\nextraction_timeout_secs = 5\n\ last_worldwide_total = 1000000\nlast_worldwide_fetch_at = 1711375200\n\ cached_country_flags = [\"🇺🇸\", \"🇩🇪\"]\nlast_flags_fetch_at = 1711375200\n\ future_key = \"keep-me\"\n[future_table]\nflag = true\n", @@ -616,7 +608,7 @@ mod tests { .unwrap(); let mut config = UserConfig::load(profile).unwrap(); - config.pending_upload = 3; + config.last_version_check_at = 3; config .save(profile) @@ -625,9 +617,12 @@ mod tests { assert!(saved.contains("future_key = \"keep-me\"")); assert!(saved.contains("[future_table]")); assert!(saved.contains("flag = true")); - assert!(saved.contains("pending_upload = 3")); + assert!(saved.contains("last_version_check_at = 3")); for retired in [ "upload_enabled", + "pending_upload", + "last_upload_at", + "last_flush_attempt_at", "watcher_debounce", "extraction_timeout_secs", "last_worldwide_total", diff --git a/crates/tracedecay/src/daemon/production_harness/configuration_idempotency_journey_test.rs b/crates/tracedecay/src/daemon/production_harness/configuration_idempotency_journey_test.rs index 73e5f96ac1..e35f722b0a 100644 --- a/crates/tracedecay/src/daemon/production_harness/configuration_idempotency_journey_test.rs +++ b/crates/tracedecay/src/daemon/production_harness/configuration_idempotency_journey_test.rs @@ -10,7 +10,7 @@ use tracedecay_contracts::{ use tracedecay_domain::configuration::{ ConfigurationIdempotencyKey, ConfigurationLayerIdV1, ConfigurationRevisionId, ConfigurationValueV1, SettingKey, TELEMETRY_TIMINGS_SETTING_KEY, - USER_UPLOAD_ENABLED_SETTING_KEY, UserProfileId, + USER_REPEATED_QUERY_HINTS_SETTING_KEY, UserProfileId, }; use tracedecay_sdk::client::{Client, ClientError, ConnectionMode}; use tracedecay_sdk::operations::{ApplicationConfigurationSet, TypedOperation}; @@ -330,7 +330,7 @@ async fn user_profile_configuration_batch_has_cli_dashboard_parity_after_restart layer: ConfigurationLayerIdV1::UserProfile { profile_id: profile_id.clone(), }, - key: SettingKey::new(USER_UPLOAD_ENABLED_SETTING_KEY).expect("user setting key"), + key: SettingKey::new(USER_REPEATED_QUERY_HINTS_SETTING_KEY).expect("user setting key"), value: Box::new(ConfigurationValueV1::Boolean(true)), }], expected_revision: expected_revision.clone(), @@ -390,7 +390,7 @@ async fn user_profile_configuration_batch_has_cli_dashboard_parity_after_restart let changed = ConfigurationBatchRequestV1 { mutations: vec![ConfigurationDirectMutationRequestV1::Set { layer: ConfigurationLayerIdV1::UserProfile { profile_id }, - key: SettingKey::new(USER_UPLOAD_ENABLED_SETTING_KEY).expect("user setting key"), + key: SettingKey::new(USER_REPEATED_QUERY_HINTS_SETTING_KEY).expect("user setting key"), value: Box::new(ConfigurationValueV1::Boolean(false)), }], ..request diff --git a/crates/tracedecay/src/doctor.rs b/crates/tracedecay/src/doctor.rs index 6b8fb5e20a..f7e5895b63 100644 --- a/crates/tracedecay/src/doctor.rs +++ b/crates/tracedecay/src/doctor.rs @@ -11,26 +11,13 @@ use tracedecay_contracts::project_open::{ use tracedecay_contracts::storage::{ SchemaConvergenceFindingV1, SchemaConvergenceProgressV1, SchemaConvergenceStateV1, }; -use tracedecay_contracts::{ApplicationOutcome, ResolvedSetting}; -use tracedecay_domain::configuration::{ - ConfigurationValueV1, SettingKey, USER_UPLOAD_ENABLED_SETTING_KEY, -}; use tracedecay_domain::errors::ProjectOpenFailureKind; -use tracedecay_tool_catalog::{ApplicationSurfaceOperation, BindingSurface}; use tracedecay_agent_hosts::agents::{self, DoctorCounters, HealthcheckContext}; use tracedecay_application::advisory::github_runtime; use tracedecay_automation_runtime::automation::effect_runtime::pending_automation_effect_resets_blocking; -use tracedecay_contracts::request_identity::{GlobalRequestSurface, mint_global_request_id}; -use tracedecay_contracts::{ConfigurationGetRequestV1, ConfigurationWireRequestV1}; -use tracedecay_daemon_protocol::ApplicationSurfaceRequest; -use tracedecay_daemon_protocol::RequestedOutputFormat; -use tracedecay_daemon_service::application_surface::{ - execute_application_surface, resolve_application_surface_dispatch, -}; #[cfg(unix)] use tracedecay_daemon_service::logging::recent_watcher_events; -use tracedecay_runtime_core::text::format_token_count; /// Opens an isolated daemon-registered profile database so Doctor tests can /// exercise the read-only session-temporal health adapter against the real @@ -96,7 +83,6 @@ impl DoctorTestRuntime { /// itself, the composition root cannot depend on the CLI crate. #[derive(Clone, Copy)] pub struct AdmittedDoctorNetworkProbes { - pub fetch_worldwide_total: fn() -> Option, pub fetch_latest_version: fn() -> Result, } @@ -173,14 +159,9 @@ pub async fn run_doctor( &mut pending_reset, )?; dc.replay_from(join_doctor_check(independent.watcher).await?); - let upload_enabled = join_doctor_check(independent.upload).await?; - check_user_config(&mut dc, profile.data_dir(), upload_enabled.as_ref()); - independent.network.admit_worldwide(upload_enabled.as_ref()); + check_user_config(&mut dc, profile.data_dir()); dc.replay_from(join_doctor_check(independent.hosts).await?); - independent - .network - .report(&mut dc, upload_enabled.as_ref()) - .await?; + independent.network.report(&mut dc).await?; print_summary(&dc); let completion = doctor_result(&dc, pending_reset); @@ -197,7 +178,6 @@ struct IndependentDoctorChecks { daemon_status: DoctorCheck>>>, watcher: DoctorCheck, - upload: DoctorCheck>, hosts: DoctorCheck, network: DoctorNetworkChecks, } @@ -220,7 +200,6 @@ fn launch_independent_doctor_checks( let project_for_hosts = project_path.to_path_buf(); let profile_for_status = profile.clone(); let project_for_status = project_path.to_path_buf(); - let profile_for_upload = profile.clone(); IndependentDoctorChecks { daemon_service: spawn_doctor_check(move || { @@ -248,15 +227,6 @@ fn launch_independent_doctor_checks( watcher: spawn_doctor_check(move || { collect_quiet(|dc| check_watcher(dc, &profile_for_watcher)) }), - upload: spawn_doctor_task(async move { - if daemon_listening { - configured_upload_enabled(&profile_for_upload) - .await - .map(UploadSetting::Resolved) - } else { - Ok(UploadSetting::DaemonUnavailable) - } - }), hosts: spawn_doctor_check(move || { collect_quiet(|dc| check_host_integrations(dc, &profile_for_hosts, &project_for_hosts)) }), @@ -463,7 +433,6 @@ struct ObservedDoctorFindingsV1 { } /// Typed state for a profile with no listening daemon. -const DAEMON_UNAVAILABLE: &str = "daemon_unavailable"; const DAEMON_UNAVAILABLE_STATEMENT: &str = "daemon_unavailable: no TraceDecay daemon is listening \ for this profile, so the daemon's canonical Doctor findings were not read and only \ binary-local checks ran. Pending operator action: start the daemon (`tracedecay daemon \ @@ -1216,135 +1185,24 @@ fn automation_effect_reset_findings( .collect()) } -#[tracing::instrument(name = "doctor.config.upload", level = "trace", skip_all)] -async fn configured_upload_enabled( - profile: &tracedecay_runtime_core::config::ProfileRoot, -) -> tracedecay_domain::errors::Result { - let setting = configured_setting(profile, USER_UPLOAD_ENABLED_SETTING_KEY).await?; - match setting.effective_value { - ConfigurationValueV1::Boolean(enabled) => Ok(enabled), - _ => Err(tracedecay_domain::errors::TraceDecayError::Config { - message: "worldwide counter upload setting is not boolean".to_owned(), - }), - } -} - -async fn configured_setting( - profile: &tracedecay_runtime_core::config::ProfileRoot, - key: &str, -) -> tracedecay_domain::errors::Result { - let operation = ApplicationSurfaceOperation::ConfigurationGet; - let key = SettingKey::new(key).map_err(|error| { - tracedecay_domain::errors::TraceDecayError::Config { - message: error.to_string(), - } - })?; - let request_id = - mint_global_request_id(GlobalRequestSurface::DaemonDoctor).map_err(|error| { - tracedecay_domain::errors::TraceDecayError::Config { - message: format!("could not create Doctor configuration request: {error}"), - } - })?; - // The setting belongs to the profile: the request names no project. - let handshake = crate::daemon::handshake_for_current_client(profile, None, None, false, false)?; - let client = crate::daemon::invocation_client_for_current(profile, handshake)?; - let dispatched = resolve_application_surface_dispatch( - BindingSurface::Cli, - operation, - request_id.clone(), - ApplicationSurfaceRequest::Configuration(ConfigurationWireRequestV1::Get( - ConfigurationGetRequestV1 { key: key.clone() }, - )), - RequestedOutputFormat::Json, - ) - .map_err(|error| tracedecay_domain::errors::TraceDecayError::Config { - message: error.to_string(), - })?; - let result = execute_application_surface(operation, dispatched, Some(&client)) - .await - .map_err(|error| tracedecay_domain::errors::TraceDecayError::Config { - message: error.to_string(), - })?; - let envelope = match result.result { - Ok(envelope) => envelope, - Err(problem) => { - return Err(tracedecay_domain::errors::TraceDecayError::Config { - message: problem.problem.summary(), - }); - } - }; - let ApplicationOutcome::Evidence(evidence) = envelope.outcome else { - return Err(tracedecay_domain::errors::TraceDecayError::Config { - message: "configuration get returned a non-evidence outcome".to_owned(), - }); - }; - let setting: ResolvedSetting = serde_json::from_value(evidence.payload.ok_or_else(|| { - tracedecay_domain::errors::TraceDecayError::Config { - message: "configuration get omitted its payload".to_owned(), - } - })?) - .map_err(|error| tracedecay_domain::errors::TraceDecayError::Config { - message: format!("configuration get returned an invalid setting: {error}"), - })?; - if setting.key != key { - return Err(tracedecay_domain::errors::TraceDecayError::Config { - message: "configuration get returned the wrong setting".to_owned(), - }); - } - Ok(setting) -} - -/// The worldwide-counter upload setting as the profile's configuration owner -/// answered it. -#[derive(Debug, PartialEq, Eq)] -enum UploadSetting { - Resolved(bool), - /// No daemon listens for this profile, so no configuration owner answered. - DaemonUnavailable, -} - -/// Check canonical user configuration and pending upload state. -fn check_user_config( - dc: &mut DoctorCounters, - profile_root: &Path, - upload_enabled: Result<&UploadSetting, &tracedecay_domain::errors::TraceDecayError>, -) { +/// Check the profile `config.toml` and the GitHub review sources it names. +fn check_user_config(dc: &mut DoctorCounters, profile_root: &Path) { dc.section("User config"); - match upload_enabled { - Ok(UploadSetting::Resolved(true)) => dc.pass("Worldwide counter upload enabled"), - Ok(UploadSetting::Resolved(false)) => { - dc.info("Worldwide counter upload disabled (default)"); - } - Ok(UploadSetting::DaemonUnavailable) => { - dc.info(&format!( - "Worldwide counter upload setting unread: {DAEMON_UNAVAILABLE}" - )); - } - Err(error) => dc.warn(&format!( - "Worldwide counter upload setting unavailable from canonical configuration: {error}" - )), - } match tracedecay_session_memory::user_config::UserConfig::load(profile_root) { - Ok(config) => { - if config.pending_upload > 0 { - dc.info(&format!("Pending upload: {} tokens", config.pending_upload)); - } - match github_runtime::check_configured_github_review_sources_v1(profile_root) { - Ok(findings) => { - for finding in findings { - dc.fail(&format!( - "GitHub review source {finding}, so it is not registered; fix or \ + Ok(_) => match github_runtime::check_configured_github_review_sources_v1(profile_root) { + Ok(findings) => { + for finding in findings { + dc.fail(&format!( + "GitHub review source {finding}, so it is not registered; fix or \ remove its github_review_sources entry in {}", - tracedecay_session_memory::user_config::config_path(profile_root) - .display() - )); - } + tracedecay_session_memory::user_config::config_path(profile_root).display() + )); } - Err(error) => dc.fail(&format!( - "GitHub review sources are unusable, none is registered: {error}" - )), } - } + Err(error) => dc.fail(&format!( + "GitHub review sources are unusable, none is registered: {error}" + )), + }, Err(error) => dc.fail(&format!("Profile config is unusable: {error}")), } } @@ -1464,72 +1322,28 @@ fn tracked_hosts(dc: &mut DoctorCounters, profile_root: &Path) -> Vec { struct DoctorNetworkChecks { latest: DoctorCheck>, - worldwide: Option>>, - fetch_worldwide_total: fn() -> Option, } impl DoctorNetworkChecks { fn start(network: AdmittedDoctorNetworkProbes) -> Self { Self { latest: spawn_doctor_check(move || (network.fetch_latest_version)()), - worldwide: None, - fetch_worldwide_total: network.fetch_worldwide_total, - } - } - - fn admit_worldwide( - &mut self, - upload_enabled: Result<&UploadSetting, &tracedecay_domain::errors::TraceDecayError>, - ) { - if matches!(upload_enabled, Ok(UploadSetting::Resolved(true))) { - self.worldwide = Some(spawn_doctor_check(self.fetch_worldwide_total)); } } #[tracing::instrument(name = "doctor.check.network", level = "trace", skip_all)] - async fn report( - self, - dc: &mut DoctorCounters, - upload_enabled: Result<&UploadSetting, &tracedecay_domain::errors::TraceDecayError>, - ) -> tracedecay_domain::errors::Result<()> { + async fn report(self, dc: &mut DoctorCounters) -> tracedecay_domain::errors::Result<()> { let latest = join_doctor_check(self.latest).await?; - let worldwide = match self.worldwide { - Some(handle) => Some(join_doctor_check(handle).await?), - None => None, - }; - render_network(dc, upload_enabled, worldwide, latest); + render_network(dc, latest); Ok(()) } } fn render_network( dc: &mut DoctorCounters, - upload_enabled: Result<&UploadSetting, &tracedecay_domain::errors::TraceDecayError>, - worldwide: Option>, latest: Result, ) { dc.section("Network"); - match upload_enabled { - Ok(UploadSetting::Resolved(true)) => { - if let Some(total) = worldwide.flatten() { - dc.pass(&format!( - "Worldwide counter reachable (total: {})", - format_token_count(total) - )); - } else { - dc.warn("Worldwide counter unreachable (offline or timeout)"); - } - } - Ok(UploadSetting::Resolved(false)) => { - dc.info("Worldwide counter skipped (upload disabled)"); - } - Ok(UploadSetting::DaemonUnavailable) => { - dc.info(&format!("Worldwide counter check skipped: {DAEMON_UNAVAILABLE}")); - } - Err(error) => dc.warn(&format!( - "Worldwide counter check skipped because canonical configuration is unavailable: {error}" - )), - } match latest { Ok(latest) => dc.pass(&format!("GitHub releases API reachable (latest v{latest})")), Err(error) => dc.warn(&format!("GitHub release lookup failed, {error}")), diff --git a/crates/tracedecay/src/doctor/tests.rs b/crates/tracedecay/src/doctor/tests.rs index 79c89692ff..a5a9cf4db3 100644 --- a/crates/tracedecay/src/doctor/tests.rs +++ b/crates/tracedecay/src/doctor/tests.rs @@ -746,50 +746,26 @@ async fn temporal_health_does_not_block_on_a_saturated_general_reader_lane() { drop(held); } -static NETWORK_PROBE_STARTED: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); - -fn rendezvous_network_probe() { - use std::sync::atomic::Ordering; - - NETWORK_PROBE_STARTED.fetch_add(1, Ordering::SeqCst); - while NETWORK_PROBE_STARTED.load(Ordering::SeqCst) < 2 { - std::thread::yield_now(); - } -} - #[tokio::test] -async fn independent_network_probes_overlap() { - use std::sync::atomic::Ordering; - - NETWORK_PROBE_STARTED.store(0, Ordering::SeqCst); +async fn network_section_probes_only_the_github_release_lookup() { let mut counters = DoctorCounters::quiet(); - let mut network = DoctorNetworkChecks::start(AdmittedDoctorNetworkProbes { - fetch_worldwide_total: || { - rendezvous_network_probe(); - Some(7) - }, - fetch_latest_version: || { - rendezvous_network_probe(); - Ok("1.2.3".to_owned()) - }, + let network = DoctorNetworkChecks::start(AdmittedDoctorNetworkProbes { + fetch_latest_version: || Ok("1.2.3".to_owned()), }); - network.admit_worldwide(Ok(&UploadSetting::Resolved(true))); - network - .report(&mut counters, Ok(&UploadSetting::Resolved(true))) - .await - .unwrap(); + network.report(&mut counters).await.unwrap(); assert!( counters.checks.iter().any(|check| check .message - .contains("Worldwide counter reachable (total: 7)")), + .contains("GitHub releases API reachable (latest v1.2.3)")), "{:?}", counters.checks ); assert!( - counters.checks.iter().any(|check| check - .message - .contains("GitHub releases API reachable (latest v1.2.3)")), - "{:?}", + !counters + .checks + .iter() + .any(|check| check.message.to_lowercase().contains("worldwide")), + "doctor must not probe or mention the retired worldwide counter: {:?}", counters.checks ); } diff --git a/crates/tracedecay/src/mcp/server.rs b/crates/tracedecay/src/mcp/server.rs index 0fe82fc0f2..0e509a912a 100644 --- a/crates/tracedecay/src/mcp/server.rs +++ b/crates/tracedecay/src/mcp/server.rs @@ -303,9 +303,6 @@ pub struct McpServer { /// clone and swap in the freshly synced map on completion. file_token_map: Arc>>, tokens_saved: Option, - last_flushed_tokens: Option, - /// UNIX timestamp of last worldwide flush (`0` = never). - last_flush_at: AtomicI64, /// User-level database tracking all projects (best-effort). Wrapped in /// `Arc` so spawned savings-recording tasks can hold a cheap clone of /// the handle instead of opening a new connection per call. @@ -1006,8 +1003,6 @@ impl McpServer { diagnostics_lsp, file_token_map: Arc::new(std::sync::Mutex::new(file_token_map)), tokens_saved: persisted_tokens_saved.map(AtomicU64::new), - last_flushed_tokens: persisted_tokens_saved.map(AtomicU64::new), - last_flush_at: AtomicI64::new(0), global_db, accounting_db, profile, diff --git a/crates/tracedecay/src/mcp/server/connection.rs b/crates/tracedecay/src/mcp/server/connection.rs index 69fcf2f938..81f6da9bd1 100644 --- a/crates/tracedecay/src/mcp/server/connection.rs +++ b/crates/tracedecay/src/mcp/server/connection.rs @@ -224,8 +224,8 @@ impl McpServer { } impl McpServer { - /// Persists the tokens-saved counter, flushes pending tokens to the - /// worldwide counter, checkpoints the WAL, and logs a session summary. + /// Persists the tokens-saved counter, checkpoints the WAL, and logs a + /// session summary. /// /// Idempotent, safe to call multiple times. `run` invokes it once when /// its main loop exits; callers (e.g. `main.rs`, tests) may invoke it @@ -279,46 +279,6 @@ impl McpServer { } gdb.checkpoint().await; } - - // Flush remaining delta to worldwide counter (what periodic flushes missed). - if let Some(last_flushed_tokens) = self.last_flushed_tokens.as_ref() { - let last_flushed = last_flushed_tokens.load(Ordering::Relaxed); - if (self.accounting_db.is_some() || self.global_db.is_some()) - && tokens_saved > last_flushed - { - let delta = tokens_saved - last_flushed; - match (self.canonical_upload_enabled().await, self.owner_profile()) { - (Err(error), _) => failures.push(format!( - "worldwide counter upload configuration unavailable: {error}" - )), - (Ok(_), None) => failures.push( - "worldwide counter upload skipped: server has no owning profile" - .to_owned(), - ), - (Ok(upload_enabled), Some(profile)) => { - // The upload is a blocking HTTP call; it stays off - // the native worker that runs shutdown. - let profile_root = profile.data_dir().to_path_buf(); - let saved = tracedecay_runtime_core::native_runtime::spawn_blocking( - move || { - super::ledger::persist_worldwide_delta( - &profile_root, - delta, - upload_enabled, - ) - .map_err(|error| error.to_string()) - }, - ) - .await - .and_then(std::convert::identity); - if let Err(error) = saved { - failures - .push(format!("worldwide counter delta not saved: {error}")); - } - } - } - } - } } // Checkpoint WAL to merge it into the main database file diff --git a/crates/tracedecay/src/mcp/server/ledger.rs b/crates/tracedecay/src/mcp/server/ledger.rs index d7231f1000..31a0070a02 100644 --- a/crates/tracedecay/src/mcp/server/ledger.rs +++ b/crates/tracedecay/src/mcp/server/ledger.rs @@ -55,15 +55,9 @@ fn derive_hook_span_git_context( Some((worktree, branch)) } -fn configuration_authority_unavailable(detail: impl std::fmt::Display) -> TraceDecayError { - TraceDecayError::Config { - message: format!("configuration authority unavailable: {detail}"), - } -} - -// Global accounting (savings ledger + worldwide-counter flushes) is enabled -// by default; see `tracedecay_global_db::global_accounting_mode` for the env -// override precedence. +// Global accounting (the savings ledger) is enabled by default; see +// `tracedecay_global_db::global_accounting_mode` for the env override +// precedence. /// Where this server's savings-ledger and analytics writes land. /// @@ -111,37 +105,6 @@ impl McpServer { matches!(self.ledger_sink(), LedgerSink::Mounted(_)) } - /// Reads the upload policy from the owning profile's configuration store. - /// There is deliberately no `config.toml` fallback: without the canonical - /// authority, the upload decision is unavailable. - #[tracing::instrument(name = "mcp.ledger.read_upload_policy", level = "trace", skip_all)] - pub(super) async fn canonical_upload_enabled(&self) -> Result { - let (Some(database), Some(identity)) = - (self.profile_session_db.clone(), self.profile_identity()) - else { - return Err(configuration_authority_unavailable( - "server has no registered profile configuration store", - )); - }; - let profile = tracedecay_project::config::read_or_initialize_profile_configuration( - database, - identity.profile_id(), - ) - .await - .map_err(|error| { - configuration_authority_unavailable(format!("cannot read upload setting: {error}")) - })?; - match profile.value(tracedecay_domain::configuration::USER_UPLOAD_ENABLED_SETTING_KEY) { - Ok(tracedecay_domain::configuration::ConfigurationValueV1::Boolean(enabled)) => { - Ok(*enabled) - } - Ok(_) => Err(configuration_authority_unavailable( - "upload setting is not boolean", - )), - Err(error) => Err(configuration_authority_unavailable(error)), - } - } - /// Estimates the raw-file token cost ("before") for the given file /// paths from the cached file-token map (indexed file bytes / 4). /// Pure lookup, persists nothing. @@ -311,74 +274,6 @@ impl McpServer { crate::mcp::server::requests::recover_lock(&self.file_token_map).clone() } - /// Admits one background worldwide-counter flush at each 30-second boundary. - /// - /// The complete flush runs on the observed ledger-write path so responses - /// never await configuration or cloud I/O and shutdown still drains it. - #[tracing::instrument(name = "mcp.ledger.flush_worldwide", level = "trace", skip_all)] - pub(crate) fn maybe_flush_worldwide(self: &Arc) { - let now = tracedecay_runtime_core::tracedecay::current_timestamp(); - let last = self.last_flush_at.load(Ordering::Relaxed); - if now - last < 30 { - return; - } - if !claim_worldwide_flush(&self.last_flush_at, last, now) { - return; - } - - let (Some(tokens_saved), Some(last_flushed_tokens)) = - (&self.tokens_saved, &self.last_flushed_tokens) - else { - return; - }; - let current = tokens_saved.load(Ordering::Relaxed); - let last_flushed = last_flushed_tokens.load(Ordering::Relaxed); - if current <= last_flushed { - return; - } - let delta = current - last_flushed; - - if !self.ledger_sink_is_mounted() { - return; - } - let Some(profile_root) = self - .owner_profile() - .map(|profile| profile.data_dir().to_path_buf()) - else { - tracing::warn!("worldwide counter flush skipped: server has no owning profile"); - return; - }; - - let server = Arc::clone(self); - self.spawn_observed_ledger_write(async move { - let upload_enabled = match server.canonical_upload_enabled().await { - Ok(enabled) => enabled, - Err(error) => { - tracing::warn!( - error = %error, - "worldwide counter upload skipped because configuration authority is unavailable" - ); - return; - } - }; - let saved = tracedecay_runtime_core::native_runtime::spawn_blocking(move || { - persist_worldwide_delta(&profile_root, delta, upload_enabled) - .inspect_err(|error| tracing::warn!(%error, "could not save upload config")) - .is_ok() - }) - .await - .unwrap_or_else(|error| { - tracing::warn!(%error, "worldwide counter flush task failed"); - false - }); - if saved - && let Some(last_flushed_tokens) = server.last_flushed_tokens.as_ref() - { - last_flushed_tokens.store(current, Ordering::Release); - } - }); - } - #[tracing::instrument(name = "mcp.ledger.record_error_analytics", level = "trace", skip_all)] pub(crate) fn record_mcp_tool_error_analytics( &self, @@ -602,163 +497,58 @@ impl McpServer { } } -/// Adds `delta` to the profile's pending worldwide-counter count, uploading it -/// when enabled, and saves the profile config. -pub(super) fn persist_worldwide_delta( - profile_root: &Path, - delta: u64, - upload_enabled: bool, -) -> std::result::Result<(), tracedecay_session_memory::user_config::ConfigSaveError> { - let mut config = tracedecay_session_memory::user_config::UserConfig::load(profile_root)?; - config.pending_upload = config.pending_upload.saturating_add(delta); - if upload_enabled - && tracedecay_dashboard_api::cloud::flush_pending(config.pending_upload).is_some() - { - config.pending_upload = 0; - config.last_upload_at = tracedecay_runtime_core::tracedecay::current_timestamp(); - } - config.save(profile_root) -} - -fn claim_worldwide_flush(last_flush_at: &AtomicI64, expected: i64, now: i64) -> bool { - last_flush_at - .compare_exchange(expected, now, Ordering::AcqRel, Ordering::Acquire) - .is_ok() -} - #[cfg(test)] mod tests { - use std::sync::atomic::AtomicU64; + use serde_json::json; + use tracedecay_mcp::transport::JsonRpcRequest; use super::*; - #[test] - fn concurrent_boundary_calls_claim_exactly_one_worldwide_flush() { - let last_flush_at = Arc::new(AtomicI64::new(10)); - let barrier = Arc::new(std::sync::Barrier::new(16)); - let claimed = Arc::new(AtomicU64::new(0)); - let workers = (0..16) - .map(|_| { - let last_flush_at = Arc::clone(&last_flush_at); - let barrier = Arc::clone(&barrier); - let claimed = Arc::clone(&claimed); - std::thread::spawn(move || { - barrier.wait(); - if claim_worldwide_flush(&last_flush_at, 10, 40) { - claimed.fetch_add(1, Ordering::AcqRel); - } - }) - }) - .collect::>(); - for worker in workers { - worker.join().expect("flush claimant"); - } - - assert_eq!(claimed.load(Ordering::Acquire), 1); - assert_eq!(last_flush_at.load(Ordering::Acquire), 40); - } - - #[test] - fn disabled_upload_records_each_delta_once_after_durable_save() { - let profile = tempfile::tempdir().expect("profile"); - let profile = profile.path(); - let mut config = tracedecay_session_memory::user_config::UserConfig::load(profile).unwrap(); - config.pending_upload = 0; - config - .save(profile) - .expect("initialize isolated user config"); - let current = 37_u64; - let last_flushed = AtomicU64::new(0); - - for _ in 0..2 { - let previous = last_flushed.load(Ordering::Acquire); - if current > previous - && persist_worldwide_delta(profile, current - previous, false).is_ok() - { - last_flushed.store(current, Ordering::Release); - } - } - - let persisted = tracedecay_session_memory::user_config::UserConfig::load(profile).unwrap(); - assert_eq!(persisted.pending_upload, current); - assert_eq!(last_flushed.load(Ordering::Acquire), current); - } - + /// A completed tool response persists its tokens-saved accounting locally + /// and leaves the profile `config.toml` alone. The retired worldwide + /// counter used to write its pending-upload amount there after a response. #[tokio::test] - async fn concurrent_response_boundary_admits_one_observed_background_flush() { + async fn completed_response_persists_local_accounting_without_counter_state() { let (cg, _project, authority) = super::super::writer_test_support::init_indexed_repo().await; + let config_path = + tracedecay_session_memory::user_config::config_path(authority.profile_root()); + let config_before = std::fs::read(&config_path).ok(); + let project_root = cg.project_root().to_path_buf(); let context = super::super::writer_test_support::registered_context(cg, &authority); let server = super::super::McpServer::new_with_context(context).await; let tokens_saved = server.tokens_saved.as_ref().expect("fixture token counter"); - let last_flushed = server - .last_flushed_tokens - .as_ref() - .expect("fixture flushed counter"); - let baseline = last_flushed.load(Ordering::Acquire); + let baseline = tokens_saved.load(Ordering::Acquire); tokens_saved.store(baseline + 41, Ordering::Release); - server.last_flush_at.store(0, Ordering::Release); - let started_before = server.ledger_writes_started.load(Ordering::SeqCst); - let barrier = Arc::new(tokio::sync::Barrier::new(16)); - let callers = (0..16) - .map(|_| { - let server = Arc::clone(&server); - let barrier = Arc::clone(&barrier); - tokio::spawn(async move { - barrier.wait().await; - server.maybe_flush_worldwide(); - }) - }) - .collect::>(); - for caller in callers { - caller.await.expect("boundary caller"); - } + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(json!(1)), + method: "tools/call".to_string(), + params: Some(json!({ + "name": "tracedecay_search", + "arguments": { "query": "main" } + })), + }; + server + .handle_request(&request) + .await + .expect("tool call should produce a response"); + server.ledger_writes_settled().await; assert_eq!( - server.ledger_writes_started.load(Ordering::SeqCst), - started_before + 1, - "concurrent completed responses must admit exactly one flush owner" + std::fs::read(&config_path).ok(), + config_before, + "a response must not write counter state into the profile config" ); + + let accounted = tokens_saved.load(Ordering::Acquire); + server.spawn_token_accounting_persist(&project_root, "tracedecay_search", 41, 100); server.ledger_writes_settled().await; assert_eq!( - last_flushed.load(Ordering::Acquire), - baseline + 41, - "shutdown-observed settlement must advance durable token accounting" + server.cg_snapshot().await.get_tokens_saved().await.unwrap(), + accounted + 41, + "local tokens-saved accounting must stay persisted" ); server.shutdown_background_tasks().await; } - - /// The observed ledger write runs where no Tokio runtime exists and still - /// advances durable accounting. - #[test] - fn worldwide_flush_settles_without_a_tokio_runtime() { - let hazards = crate::test_support::native_runtime_hazards(); - tracedecay_runtime_core::native_runtime::block_on(async { - assert!(tokio::runtime::Handle::try_current().is_err()); - let (cg, _project, authority) = - super::super::writer_test_support::init_indexed_repo().await; - let context = super::super::writer_test_support::registered_context(cg, &authority); - let server = super::super::McpServer::new_with_context(context).await; - let tokens_saved = server.tokens_saved.as_ref().expect("fixture token counter"); - let last_flushed = server - .last_flushed_tokens - .as_ref() - .expect("fixture flushed counter"); - let baseline = last_flushed.load(Ordering::Acquire); - tokens_saved.store(baseline + 7, Ordering::Release); - server.last_flush_at.store(0, Ordering::Release); - - server.maybe_flush_worldwide(); - - assert!( - server - .ledger_writes_settled_within(Duration::from_secs(30)) - .await - ); - assert_eq!(last_flushed.load(Ordering::Acquire), baseline + 7); - server.shutdown_background_tasks().await; - }) - .expect("native runtime"); - assert_eq!(crate::test_support::native_runtime_hazards(), hazards); - } } diff --git a/crates/tracedecay/src/mcp/server/requests.rs b/crates/tracedecay/src/mcp/server/requests.rs index d7b090349c..0dfcc44f9c 100644 --- a/crates/tracedecay/src/mcp/server/requests.rs +++ b/crates/tracedecay/src/mcp/server/requests.rs @@ -996,7 +996,6 @@ impl McpServer { accounting.net_saved_tokens(), accounting.raw_file_tokens, ); - self.maybe_flush_worldwide(); accounting } diff --git a/crates/tracedecay/tests/dashboard_api_test/settings.rs b/crates/tracedecay/tests/dashboard_api_test/settings.rs index e7957a8596..8b4fa7527d 100644 --- a/crates/tracedecay/tests/dashboard_api_test/settings.rs +++ b/crates/tracedecay/tests/dashboard_api_test/settings.rs @@ -86,7 +86,10 @@ fn settings_dashboard_api_aggregates_and_updates_config() { "settings must expose the profile worker snapshot: {settings}" ); - assert_eq!(settings["user"]["upload_enabled"], false); + assert!( + settings["user"].get("upload_enabled").is_none(), + "the retired worldwide-counter upload setting must not be served: {settings}" + ); assert_eq!( settings["user"]["code_index_workers"], json!({ "mode": "automatic" }), @@ -334,7 +337,7 @@ fn settings_dashboard_api_aggregates_and_updates_config() { &json!({ "expected_revision_id": user_revision, "idempotency_key": "configuration.idempotency.dashboard-user-unmounted", - "upload_enabled": false, + "extraction_timeout_secs": 90, "watcher_debounce": "15s" }), ); @@ -353,13 +356,31 @@ fn settings_dashboard_api_aggregates_and_updates_config() { &user_url, &json!({ "expected_revision_id": user_revision, - "upload_enabled": false + "extraction_timeout_secs": 90 }), ); assert_eq!( status, 400, "missing user idempotency must be rejected: {absent_user_idempotency}" ); + + let (status, retired_upload) = patch_json_body( + &agent, + &user_url, + &json!({ + "expected_revision_id": user_revision, + "idempotency_key": "configuration.idempotency.dashboard-user-retired-upload", + "upload_enabled": true + }), + ); + assert_eq!( + status, 400, + "the retired upload field must be refused, not silently accepted: {retired_upload}" + ); + assert_eq!( + retired_upload["validation_errors"][0]["field"], + "upload_enabled" + ); assert_eq!( absent_user_idempotency["validation_errors"][0]["field"], "idempotency_key" @@ -437,7 +458,7 @@ fn settings_dashboard_api_aggregates_and_updates_config() { assert_eq!(reloaded["project"]["config"]["max_file_size"], 1_048_576); assert_eq!(reloaded["project"]["configuration_revision_id"], revision); assert_eq!(reloaded["user"]["configuration_revision_id"], user_revision); - assert_eq!(reloaded["user"]["upload_enabled"], false); + assert!(reloaded["user"].get("upload_enabled").is_none()); assert_eq!(reloaded["user"]["watcher_debounce"], "2s"); }); } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_cli_serve_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_cli_serve_test.rs index a1a682ce91..9a2f7b770c 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_cli_serve_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_cli_serve_test.rs @@ -972,13 +972,6 @@ async fn projectless_session_serves_user_settings_and_refuses_project_keys() { let home = TempDir::new().unwrap(); let home = canonical_existing_path(home.path()); let _daemon = common::spawn_tracedecay_daemon(&home); - let enable = tracedecay_command_with_home(&home) - .current_dir(&home) - .arg("enable-upload-counter") - .stdin(Stdio::null()) - .output() - .expect("enable-upload-counter should run"); - assert!(enable.status.success(), "{enable:?}"); // The home directory is the ambient root, so the handshake names no project. let output = run_serve_requests( @@ -987,7 +980,7 @@ async fn projectless_session_serves_user_settings_and_refuses_project_keys() { None, &[ json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} }), - configuration_get_request(2, "user.upload_enabled.v1"), + configuration_get_request(2, "user.repeated_query_hints.v1"), json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/list" }), configuration_get_request(4, "index.max_file_size.v1"), ], @@ -1001,8 +994,8 @@ async fn projectless_session_serves_user_settings_and_refuses_project_keys() { &setting["outcome"]["value"]["payload"]["effective_value"], ), ( - &json!("user.upload_enabled.v1"), - &json!({ "kind": "boolean", "value": true }), + &json!("user.repeated_query_hints.v1"), + &json!({ "kind": "boolean", "value": false }), ), "{setting}" ); @@ -1053,13 +1046,6 @@ async fn unenrolled_directory_session_serves_user_settings() { let home = canonical_existing_path(home.path()); let unenrolled = TempDir::new().unwrap(); let _daemon = common::spawn_tracedecay_daemon(&home); - let enable = tracedecay_command_with_home(&home) - .current_dir(unenrolled.path()) - .arg("enable-upload-counter") - .stdin(Stdio::null()) - .output() - .expect("enable-upload-counter should run"); - assert!(enable.status.success(), "{enable:?}"); let output = run_serve_requests( &home, @@ -1067,7 +1053,7 @@ async fn unenrolled_directory_session_serves_user_settings() { None, &[ json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} }), - configuration_get_request(2, "user.upload_enabled.v1"), + configuration_get_request(2, "user.repeated_query_hints.v1"), configuration_get_request(3, "index.max_file_size.v1"), ], ); @@ -1075,7 +1061,7 @@ async fn unenrolled_directory_session_serves_user_settings() { let setting = json_rpc_tool_payload(&output.stdout, 2); assert_eq!( setting["outcome"]["value"]["payload"]["effective_value"], - json!({ "kind": "boolean", "value": true }), + json!({ "kind": "boolean", "value": false }), "{setting}" ); let refusal = json_rpc_response(&output.stdout, 3); diff --git a/crates/tracedecay/tests/runtime_acceptance_suite/runtime_surface_acceptance.rs b/crates/tracedecay/tests/runtime_acceptance_suite/runtime_surface_acceptance.rs index 0062c9c373..23cdd92dfb 100644 --- a/crates/tracedecay/tests/runtime_acceptance_suite/runtime_surface_acceptance.rs +++ b/crates/tracedecay/tests/runtime_acceptance_suite/runtime_surface_acceptance.rs @@ -1163,13 +1163,13 @@ async fn dashboard_user_settings_replay_through_application_restart() { let request = serde_json::json!({ "expected_revision_id": initial_revision.clone(), "idempotency_key": idempotency_key, - "upload_enabled": true, + "extraction_timeout_secs": 90, "watcher_debounce": "15s" }); let (status, applied) = patch_dashboard_json(&agent, &user_url, &request); assert_eq!(status, 200, "profile mutation failed: {applied}"); assert_eq!(applied["payload"]["restart_recommended"], true); - assert_eq!(applied["payload"]["user"]["upload_enabled"], true); + assert_eq!(applied["payload"]["user"]["extraction_timeout_secs"], 90); assert_eq!(applied["payload"]["user"]["watcher_debounce"], "15s"); let applied_revision = applied["payload"]["user"]["configuration_revision_id"] .as_str() @@ -1222,7 +1222,7 @@ async fn dashboard_user_settings_replay_through_application_restart() { let conflicting_request = serde_json::json!({ "expected_revision_id": initial_revision.clone(), "idempotency_key": idempotency_key, - "upload_enabled": false, + "extraction_timeout_secs": 30, "watcher_debounce": "15s" }); let (status, conflict) = diff --git a/dashboard/src/workspaces/observatory/AnalyticsControls.dom.test.tsx b/dashboard/src/workspaces/observatory/AnalyticsControls.dom.test.tsx index 1c19e01ba7..a2fc69ec56 100644 --- a/dashboard/src/workspaces/observatory/AnalyticsControls.dom.test.tsx +++ b/dashboard/src/workspaces/observatory/AnalyticsControls.dom.test.tsx @@ -11,12 +11,11 @@ import type { ObservatoryAccountingReads } from './accountingReads.ts'; /** * The Plan 26 `analytics-controls` view. * - * Two real reads back parts of it, `/api/settings` for the profile upload - * setting and `/api/doctor/findings?family=storage` for the typed retention-backlog status, - * and they are read independently. The assertions pin the two reassuring - * falsehoods this surface could most easily tell: that an unpublished - * collection mode is `Off`, and that an absent exporter means zero egress - * failures. + * `/api/doctor/findings?family=storage` backs the typed retention-backlog + * status and is read independently of the observatory projection. The + * assertions pin the two reassuring falsehoods this surface could most easily + * tell: that an unpublished collection mode is `Off`, and that an absent + * exporter means zero egress failures. */ const NOW_MICROS = 1_753_003_600_000_000; @@ -26,30 +25,29 @@ afterEach(() => { }); describe('Observatory analytics controls', () => { - - it('keeps a failed settings read from blanking the retention evidence', async () => { - // Independent sources: one refusing must not take the other down with it. - renderControls({ settingsStatus: 503 }); + it('states retention evidence and no longer reads or shows an upload setting', async () => { + const fetchMock = renderControls(); await screen.findByText('retention sweep observed 4 entries'); - const upload = screen.getByRole('region', { name: 'profile upload setting' }); - expect(upload.textContent).toContain('the profile settings read did not resolve'); - expect(upload.textContent).not.toContain('disabled'); + expect(screen.queryByRole('region', { name: 'profile upload setting' })).toBeNull(); + expect( + fetchMock.mock.calls.some(([input]) => String(input).includes('/api/settings')), + ).toBe(false); }); - it('keeps a failed findings read from blanking the profile setting', async () => { + it('keeps a failed findings read from blanking the collection mode', async () => { renderControls({ findingsStatus: 503 }); - await screen.findByText('user.upload_enabled.v1'); - const retention = screen.getByRole('region', { name: 'retention and deletion' }); - expect(retention.textContent).toContain('could not be read'); + const retention = await screen.findByRole('region', { name: 'retention and deletion' }); + await screen.findByText('could not be read', { exact: false }); expect(retention.textContent).not.toContain('retention sweep observed'); + const mode = screen.getByRole('region', { name: 'collection mode' }); + expect(mode.textContent).not.toContain('could not be read'); }); }); function renderControls( options: { - settingsStatus?: number; findingsStatus?: number; retentionStatuses?: unknown[]; } = {}, @@ -62,31 +60,24 @@ function renderControls( reason: 'retention sweep observed 4 entries', }, ]; - vi.stubGlobal( - 'fetch', - vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url.includes('/api/settings')) { - if (options.settingsStatus != null) { - return new Response('{}', { status: options.settingsStatus }); - } - return new Response(JSON.stringify(envelope(settingsPayload())), { status: 200 }); - } - if (url.includes('/api/doctor/findings?family=storage')) { - if (options.findingsStatus != null) { - return new Response('{}', { status: options.findingsStatus }); - } - return new Response(JSON.stringify(envelope(findingsPayload(statuses))), { status: 200 }); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/api/doctor/findings?family=storage')) { + if (options.findingsStatus != null) { + return new Response('{}', { status: options.findingsStatus }); } - return new Response('{}', { status: 404 }); - }), - ); + return new Response(JSON.stringify(envelope(findingsPayload(statuses))), { status: 200 }); + } + return new Response('{}', { status: 404 }); + }); + vi.stubGlobal('fetch', fetchMock); const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); render( , ); + return fetchMock; } function accountingReads(): ObservatoryAccountingReads { @@ -183,74 +174,6 @@ function findingsPayload(kindStatuses: unknown[]) { }; } -function settingsPayload() { - return { - automation: { - availability: { available: true, reason: null, required_authority: null }, - backend: null, - config_endpoint: '/api/plugins/holographic/curation/config', - enabled: false, - host_mode: null, - source_coverage: { effective: 'global', global: 'default', project: 'absent' }, - }, - environment: { - global_accounting_enabled: false, - global_accounting_mode: 'off', - pricing_offline: true, - variables: [], - }, - project: { - config: { - exclude: [], - extract_docstrings: true, - context_scout: false, - include: [], - max_file_size: 1_048_576, - sync: { auto_track_pr_branches: false, auto_track_pr_poll_secs: 300 }, - telemetry: { timings: false }, - track_call_sites: true, - }, - configuration_revision_id: 'revision-1', - configuration_snapshot_id: 'snapshot-1', - pr_autotrack: { tracked: [] }, - }, - restart_recommended: null, - resync_recommended: null, - storage: { - dashboard_root: '/store/dashboard', - graph_db: '/store/graph.db', - lcm_db: '/store/lcm.db', - lcm_scope: 'project', - memory_db: '/store/memory.db', - project_id: 'tracedecay', - project_root: '/repo', - savings_db: '/store/savings.db', - storage_mode: 'profile_sharded', - store_root: '/store', - }, - user: { - code_index_worker_status: { - available_logical_cpus: 4, - configured: { mode: 'automatic' }, - effective_workers: 4, - environment_override_workers: null, - limiting_reason: 'automatic_all_cores', - memory_safe_workers: 6, - }, - code_index_workers: { mode: 'automatic' }, - code_index_worker_configuration_revision_id: 'worker-revision-1', - code_index_worker_configuration_snapshot_id: 'worker-snapshot-1', - configuration_revision_id: 'revision-1', - configuration_snapshot_id: 'snapshot-1', - extraction_timeout_secs: 30, - installed_agents: ['claude'], - upload_enabled: false, - watcher_debounce: '500ms', - }, - version: { cached_latest_version: null, channel: 'stable', version: '0.0.0' }, - }; -} - function envelope(payload: unknown) { return { schema_revision: 1, diff --git a/dashboard/src/workspaces/observatory/AnalyticsControls.tsx b/dashboard/src/workspaces/observatory/AnalyticsControls.tsx index 0859ad798e..1324084eb2 100644 --- a/dashboard/src/workspaces/observatory/AnalyticsControls.tsx +++ b/dashboard/src/workspaces/observatory/AnalyticsControls.tsx @@ -3,19 +3,15 @@ * analytics: local mode, share staging age, retention/deletion, and egress * failures. * - * Two real reads back parts of this surface, `GET /api/settings` for the - * `user.upload_enabled.v1` profile setting, and `GET /api/doctor/findings?family=storage` for - * the typed `retention_backlog` status. They are read independently: a failed - * settings read must not blank the retention evidence, and neither may stand in - * for the analytics collection mode, which nothing publishes. + * `GET /api/doctor/findings?family=storage` backs the typed `retention_backlog` + * status. It is read independently of the observatory projection, and neither + * may stand in for the analytics collection mode, which nothing publishes. * * The rule this surface exists to hold is the one in `analyticsControls.ts`: an * unread mode is not `Off`, and an absent exporter is not zero egress failures. * Both would be reassuring, and both would be false. */ import type { ComponentProps, ReactNode } from 'react'; -import { SettingsPayloadV1Schema } from '../../contracts/generated.ts'; -import { useEnvelope } from '../../data/query/useEnvelope.ts'; import { useStorageFindings } from '../../data/query/storageFindings.ts'; import { envelopeReadState } from '../../ui/ReadSection.tsx'; import { StateChip } from '../../ui/StateChip'; @@ -26,20 +22,13 @@ import { egressFailureReading, retentionBacklogReading, shareStagingReading, - uploadSettingReading, type RetentionBacklogReading, - type UploadSettingReading, } from './analyticsControls.ts'; import type { ObservatoryAccountingReads } from './accountingReads.ts'; export function AnalyticsControls({ reads }: { reads: ObservatoryAccountingReads }) { - const settings = useEnvelope(['settings', 'analytics-controls'], '/api/settings', SettingsPayloadV1Schema); const findings = useStorageFindings(); - const settingsState = envelopeReadState(settings.isPending, settings.data, { - loading: 'requesting profile settings', - transport: 'profile settings could not be read', - }); const findingsState = envelopeReadState(findings.isPending, findings.data, { loading: 'requesting doctor storage findings', transport: 'doctor storage findings could not be read', @@ -175,26 +164,6 @@ export function AnalyticsControls({ reads }: { reads: ObservatoryAccountingReads : `${egress.failures.toLocaleString()} failures were recorded by the canonical projection.`}

- - - {settingsState.kind === 'ready' ? ( - - ) : ( -

- the profile settings read did not resolve, so the setting is not stated -

- )} -
); @@ -258,26 +227,3 @@ function RetentionBacklog({ reading }: { reading: RetentionBacklogReading }) { ); } - -function UploadSetting({ reading }: { reading: UploadSettingReading }) { - return ( - <> -
-
-
setting
-
{reading.settingKey}
-
-
-
value
-
- {reading.enabled == null ? 'not reported' : reading.enabled ? 'enabled' : 'disabled'} -
-
-
-

{reading.disclaimer}

- - ); -} diff --git a/dashboard/src/workspaces/observatory/analyticsControls.test.ts b/dashboard/src/workspaces/observatory/analyticsControls.test.ts index c90a60e70d..db09173a6b 100644 --- a/dashboard/src/workspaces/observatory/analyticsControls.test.ts +++ b/dashboard/src/workspaces/observatory/analyticsControls.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { AnalyticsModeReadModelV1, MetricValueV1, StorageFindingKindStatusV1 } from '../../contracts/generated.ts'; -import { analyticsModeReading, egressFailureReading, retentionBacklogReading, shareStagingReading, uploadSettingReading } from './analyticsControls.ts'; +import { analyticsModeReading, egressFailureReading, retentionBacklogReading, shareStagingReading } from './analyticsControls.ts'; describe('analyticsModeReading', () => { it('reports an unavailable mode as unknown and explicitly not Off', () => { @@ -78,16 +78,6 @@ describe('retentionBacklogReading', () => { }); }); -describe('uploadSettingReading', () => { - - it('reports an unread setting as unknown rather than as disabled', () => { - const reading = uploadSettingReading(undefined); - expect(reading.enabled).toBeNull(); - expect(reading.enabled).not.toBe(false); - expect(reading.state).toBe('unknown'); - }); -}); - function status( kind: StorageFindingKindStatusV1['kind'], state: StorageFindingKindStatusV1['state'], diff --git a/dashboard/src/workspaces/observatory/analyticsControls.ts b/dashboard/src/workspaces/observatory/analyticsControls.ts index 79f30d6c29..f2df3bc539 100644 --- a/dashboard/src/workspaces/observatory/analyticsControls.ts +++ b/dashboard/src/workspaces/observatory/analyticsControls.ts @@ -1,9 +1,8 @@ -/** Plan 26 analytics controls over canonical settings and Observatory reads. */ +/** Plan 26 analytics controls over Observatory reads. */ import type { AnalyticsModeV1, AnalyticsModeReadModelV1, MetricValueV1, - SettingsPayloadV1, StorageFindingKindStatusV1, } from '../../contracts/generated.ts'; import type { DomainStateKind } from '../../ui/StateChip.tsx'; @@ -230,28 +229,3 @@ export function shareStagingReading(metrics: readonly MetricValueV1[]): ShareSta } return { ageSeconds: metric.value, state: 'ready', reason: '' }; } - -export interface UploadSettingReading { - /** The profile setting value, or `null` when the settings read carried none. */ - enabled: boolean | null; - settingKey: string; - state: DomainStateKind; - /** The one sentence that keeps this from being mistaken for the analytics - * collection mode. */ - disclaimer: string; -} - -/** The `user.upload_enabled.v1` profile setting from `/api/settings`. Real, - * wire-published, and explicitly not `AnalyticsModeV1`. */ -export function uploadSettingReading( - settings: SettingsPayloadV1 | undefined, -): UploadSettingReading { - const enabled = settings?.user.upload_enabled ?? null; - return { - enabled, - settingKey: 'user.upload_enabled.v1', - state: enabled == null ? 'unknown' : 'ready', - disclaimer: - 'This is the profile upload setting, not the Plan 26 analytics collection mode. It does not select Off, LocalOnly, or AggregateShare and does not govern adoption analytics.', - }; -} diff --git a/dashboard/src/workspaces/observatory/evidence.ts b/dashboard/src/workspaces/observatory/evidence.ts index 0239ee618f..c77adf2779 100644 --- a/dashboard/src/workspaces/observatory/evidence.ts +++ b/dashboard/src/workspaces/observatory/evidence.ts @@ -209,7 +209,7 @@ export const SOURCE_IDENTITY: Record = { id: 'analytics', title: 'Analytics controls', route: '/api/observatory', - authority: 'canonical analytics mode, retention lifecycle, egress and upload settings', + authority: 'canonical analytics mode, retention lifecycle and egress', }, telemetry: { id: 'telemetry', diff --git a/dashboard/src/workspaces/settings/SettingsPage.dom.test.tsx b/dashboard/src/workspaces/settings/SettingsPage.dom.test.tsx index 370012bae5..084c53beb7 100644 --- a/dashboard/src/workspaces/settings/SettingsPage.dom.test.tsx +++ b/dashboard/src/workspaces/settings/SettingsPage.dom.test.tsx @@ -51,7 +51,6 @@ const FIXTURE_SETTINGS: ReadonlyArray = [ 'user.configuration_snapshot_id', 'user.extraction_timeout_secs', 'user.installed_agents', - 'user.upload_enabled', 'user.watcher_debounce', ], ], diff --git a/dashboard/src/workspaces/settings/settingsEditorMachine.ts b/dashboard/src/workspaces/settings/settingsEditorMachine.ts index c893afde88..f48313af5b 100644 --- a/dashboard/src/workspaces/settings/settingsEditorMachine.ts +++ b/dashboard/src/workspaces/settings/settingsEditorMachine.ts @@ -728,7 +728,6 @@ function sameProjectValues( function sameUserValues(left: UserSettingsValues, right: UserSettingsValues): boolean { return ( - left.upload_enabled === right.upload_enabled && left.watcher_debounce === right.watcher_debounce && left.extraction_timeout_secs === right.extraction_timeout_secs ); diff --git a/dashboard/src/workspaces/settings/settingsModel.ts b/dashboard/src/workspaces/settings/settingsModel.ts index 19cc9f3337..752e2e47dc 100644 --- a/dashboard/src/workspaces/settings/settingsModel.ts +++ b/dashboard/src/workspaces/settings/settingsModel.ts @@ -12,7 +12,7 @@ * The daemon really does merge two layers there, and then ships only the * result. The winning layer is not on the wire. * - `user` and `project.config` do not even address the same keys - * (`upload_enabled`/`watcher_debounce` vs `include`/`exclude`/…), so they + * (`watcher_debounce`/`extraction_timeout_secs` vs `include`/`exclude`/…), so they * are not competing layers over a shared namespace. * * Therefore this model does NOT rank groups into a resolution stack and does @@ -150,7 +150,6 @@ export interface ProjectSettingsValues { } export interface UserSettingsValues { - readonly upload_enabled: boolean; readonly watcher_debounce: string; readonly extraction_timeout_secs: string; } @@ -205,7 +204,6 @@ export interface ProjectSettingsChangeSet { /** The user-scope counterpart of `ProjectSettingsChangeSet`. */ export interface UserSettingsChangeSet { - upload_enabled?: boolean; watcher_debounce?: string; extraction_timeout_secs?: number; } @@ -443,7 +441,6 @@ export function buildSettingsEditor(payload: SettingsPayloadV1): SettingsEditor auto_track_pr_poll_secs: pollSecs, }, user: { - upload_enabled: payload.user.upload_enabled, watcher_debounce: payload.user.watcher_debounce, extraction_timeout_secs: extractionTimeout, }, @@ -551,9 +548,6 @@ export function planUserChangeAgainst( if (errors.length > 0) return { outcome: 'invalid', errors }; const patch: UserSettingsChangeSet = {}; - if (values.upload_enabled !== current.user.upload_enabled) { - patch.upload_enabled = values.upload_enabled; - } if (values.watcher_debounce !== current.user.watcher_debounce) { patch.watcher_debounce = values.watcher_debounce; } diff --git a/dashboard/src/workspaces/settings/settingsRows.test.ts b/dashboard/src/workspaces/settings/settingsRows.test.ts index 3288aa2625..9a3b80e00c 100644 --- a/dashboard/src/workspaces/settings/settingsRows.test.ts +++ b/dashboard/src/workspaces/settings/settingsRows.test.ts @@ -162,7 +162,7 @@ describe('write bindings', () => { expect(applyRequirement(bindingFor('project.config.track_call_sites')!)).toEqual({ kind: 'reported_on_apply', }); - expect(applyRequirement(bindingFor('user.upload_enabled')!)).toEqual({ + expect(applyRequirement(bindingFor('user.watcher_debounce')!)).toEqual({ kind: 'reported_on_apply', }); }); diff --git a/dashboard/src/workspaces/settings/settingsRows.ts b/dashboard/src/workspaces/settings/settingsRows.ts index c96383879c..44d9336aaf 100644 --- a/dashboard/src/workspaces/settings/settingsRows.ts +++ b/dashboard/src/workspaces/settings/settingsRows.ts @@ -114,7 +114,6 @@ const BINDINGS: Readonly> = { field: 'auto_track_pr_poll_secs', input: 'integer', }, - 'user.upload_enabled': { scope: 'user', field: 'upload_enabled', input: 'boolean' }, 'user.watcher_debounce': { scope: 'user', field: 'watcher_debounce', input: 'duration' }, 'user.extraction_timeout_secs': { scope: 'user', @@ -360,8 +359,6 @@ function withUserField( value: unknown, ): UserSettingsValues { switch (field) { - case 'upload_enabled': - return typeof value === 'boolean' ? { ...values, [field]: value } : values; case 'watcher_debounce': case 'extraction_timeout_secs': return typeof value === 'string' ? { ...values, [field]: value } : values; diff --git a/dashboard/stories/fixtures/data.ts b/dashboard/stories/fixtures/data.ts index f0a00736f1..05390f0ddd 100644 --- a/dashboard/stories/fixtures/data.ts +++ b/dashboard/stories/fixtures/data.ts @@ -3242,7 +3242,6 @@ const settingsPayload: Record = { configuration_revision_id: 'user-rev-7', code_index_worker_configuration_snapshot_id: 'profile-worker-snap-7', code_index_worker_configuration_revision_id: 'profile-worker-rev-7', - upload_enabled: false, code_index_workers: { mode: 'automatic' }, code_index_worker_status: { configured: { mode: 'automatic' }, diff --git a/docs/REBRAND-COMPATIBILITY-POLICY.md b/docs/REBRAND-COMPATIBILITY-POLICY.md index c082d932b7..2c8c8e9e30 100644 --- a/docs/REBRAND-COMPATIBILITY-POLICY.md +++ b/docs/REBRAND-COMPATIBILITY-POLICY.md @@ -71,11 +71,12 @@ benchmark reports, and daemon-removal instructions should remain unless it is factually wrong. These references should be labeled as historical when helpful, not mechanically rewritten. -### A5. Worldwide counter endpoint +### A5. Worldwide counter endpoint (retired) -The existing counter endpoint name may remain `tracedecay-counter` while the service -continues to be best-effort and documented. A future endpoint rename is allowed only -when the replacement worker is deployed and continuity/failure behavior is preserved. +The worldwide counter was removed from the client (issue #3619); no TraceDecay +binary calls the `tracedecay-counter` Cloudflare Worker anymore. Shutting the +hosted Worker and its Upstash Redis store down is a maintainer action outside +this repository, see the worldwide token counter note in `docs/USER-GUIDE.md`. ## Category B, automatically migrated or cleaned up @@ -285,7 +286,7 @@ Every audited surface maps to exactly one policy category below. | Antigravity legacy CLI plugin path | B | Remove generated `tracedecay.json` on uninstall/reconcile. | | Claude legacy hook/config migration | B | Repair/remove known generated legacy hook and config shapes. | | `tracedecay-large-treesitters` package/dependency name | E | Keep exact upstream name until upstream rename or explicit maintained-fork policy. | -| Worldwide counter endpoint `tracedecay-counter` | A | Retain until replacement endpoint is deployed with preserved behavior. | +| Worldwide counter endpoint `tracedecay-counter` | A | Retired from the client; the hosted Worker is decommissioned by a maintainer. | | Legacy release asset names for explicit requested versions | B | Probe after current asset names for explicit old-version installs/upgrades. | | Latest-version filtering of legacy-only releases | D | Reject legacy-only/pre-reset releases as automatic latest candidates. | | Extracted binary name `tracedecay` in old archives | B | Accept only as fallback while extracting explicit old archives. | diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index a148361d5f..64dc8bf90c 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -825,11 +825,10 @@ It verifies: exact worktree/ref/commit/generation, freshness, coverage, and typed authority state - **Global registry**, daemon-owned project/profile enrollment and availability -- **User config**, `~/.tracedecay/config.toml` and upload settings +- **User config**, `~/.tracedecay/config.toml` and the GitHub review sources it names - **Agent integrations**. MCP server registration, hook installation, tool permissions, prompt rules -- **Network**, the configured worldwide counter and GitHub releases API; each - reports its own available or unavailable state, and a failed release lookup - names its outcome (see [Updating TraceDecay](#updating-tracedecay)) +- **Network**, the GitHub releases API; it reports its own available or + unavailable state, and a failed release lookup names its outcome (see [Updating TraceDecay](#updating-tracedecay)) If any tool permissions are missing after an upgrade, Doctor reports the missing capability and the supported install/update operation. Doctor only reports @@ -1123,33 +1122,25 @@ Network effects are separate from local indexing and retrieval. They can be disabled, unavailable, or denied without turning those states into successful local results. The available effects are described below. -### Worldwide token counter +### Worldwide token counter (retired) -TraceDecay tracks how many tokens it has saved locally. If you opt in, the -daemon's token-savings status path uploads that aggregate count to the worldwide -counter. Repository content, file names, and project names are not part of the -counter payload. The counter service still receives ordinary transport metadata -such as the source IP and may derive aggregate geography from it; submitting an -aggregate count is not an anonymity guarantee. +TraceDecay no longer has a worldwide token counter and makes no counter network +call. Token savings, costs, and usage stay local: `tracedecay current-counter`, +`tracedecay reset-counter`, `tracedecay gain`, `tracedecay cost`, and the +dashboard are unchanged. -`tracedecay status` does not display the counter. When you opt in, `tracedecay doctor` -reads the public total only to report whether the counter service is reachable. +`tracedecay enable-upload-counter` and `tracedecay disable-upload-counter` no +longer exist and fail as unknown subcommands. The `user.upload_enabled.v1` +setting and the dashboard's `upload_enabled` field are gone, so a configuration +read or patch that names them is refused. An older profile that still stores the +opt-in, a pending upload amount, or flush timestamps loads normally: the next +write erases them, and the pending amount is never uploaded. -**To opt in:** - -```bash -tracedecay enable-upload-counter -``` - -Disable it again at any time: - -```bash -tracedecay disable-upload-counter -``` - -The setting belongs to your profile, not to a project: it applies everywhere -and can be changed or read (`tracedecay tool configuration_get --args -'{"key":"user.upload_enabled.v1"}'`) from any directory. +The hosted counter service was a Cloudflare Worker named `tracedecay-counter`, +backed by Upstash Redis. Nothing in this repository deploys or operates it, so +removing the client does not shut it down. Decommissioning it is a separate +maintainer action: delete the Worker and its Redis store from the Cloudflare +and Upstash accounts that host them. Its status is tracked on issue #3619. ### Version check @@ -1370,7 +1361,7 @@ tracedecay memory status --path /path/to/project --json Created in your home directory. Contains: -- `config.toml`, user preferences (cached version info, pending upload count) +- `config.toml`, user preferences (cached version info, installed agents) - `global.db`, daemon-owned registry/usage metadata for enrolled projects; it is not a fact authority and clients never open it directly - `projects//`, daemon-owned project authority data when profile storage is enabled @@ -1378,13 +1369,11 @@ Created in your home directory. Contains: The `config.toml` is plain TOML and fully transparent: ```toml -pending_upload = 4823 # tokens waiting to be uploaded -last_upload_at = 1711375200 # last successful upload timestamp +cached_latest_version = "1.2.3" # latest release seen by the version check +last_version_check_at = 1711375200 # last version check timestamp +installed_agents = ["claude"] # hosts TraceDecay has integrated ``` -The upload opt-in is the `user.upload_enabled.v1` setting, not a key in this -file. - #### Mandatory structured sanitization of ingested transcripts Agent transcripts can contain credentials or other sensitive values. TraceDecay diff --git a/evals/agent_adoption/run.sh b/evals/agent_adoption/run.sh index 05f725f593..c7a074ed32 100755 --- a/evals/agent_adoption/run.sh +++ b/evals/agent_adoption/run.sh @@ -358,7 +358,6 @@ echo "query hints: $REPEATED_QUERY_HINTS" if [[ -n "$LEARNED_SKILL_WORKFLOW" ]]; then echo "learned: $LEARNED_SKILL_WORKFLOW variants=[${ACTIVE_VARIANTS# }]" fi -"$TD" disable-upload-counter >/dev/null 2>&1 || true # ---- build fixtures -------------------------------------------------------- # gitc() { git -C "$1" -c user.email=eval@tracedecay -c user.name=eval "${@:2}"; } diff --git a/scripts/mcp-conformance-smoke.sh b/scripts/mcp-conformance-smoke.sh index 22205c6de5..187bdc7ae4 100755 --- a/scripts/mcp-conformance-smoke.sh +++ b/scripts/mcp-conformance-smoke.sh @@ -116,7 +116,6 @@ NODE echo "error: tracedecay init failed" >&2 return 1 fi - "$TRACEDECAY_BIN" disable-upload-counter >/dev/null 2>&1 || true # Resolve and cache the inspector package before any timed call: on a cold # runner `npx` spends its first invocation downloading the package, and From b9475c69c5428a2f19f1c94b7b4056430c9692db Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 18 Oct 2026 16:10:04 +0000 Subject: [PATCH 2/3] fix: drop unused mut and Debug-free parse assertion --- crates/tracedecay-cli/src/startup_tests.rs | 3 ++- crates/tracedecay/src/doctor.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-cli/src/startup_tests.rs b/crates/tracedecay-cli/src/startup_tests.rs index 7d030ee651..e7ab1a9d38 100644 --- a/crates/tracedecay-cli/src/startup_tests.rs +++ b/crates/tracedecay-cli/src/startup_tests.rs @@ -698,7 +698,8 @@ fn post_update_full_reinstall_advances_both_version_markers() { fn retired_worldwide_counter_commands_are_refused_by_the_parser() { for retired in ["enable-upload-counter", "disable-upload-counter"] { let error = Cli::try_parse_from(["tracedecay", retired]) - .expect_err("a retired subcommand must not parse"); + .err() + .expect("a retired subcommand must not parse"); assert_eq!(error.kind(), clap::error::ErrorKind::InvalidSubcommand); } } diff --git a/crates/tracedecay/src/doctor.rs b/crates/tracedecay/src/doctor.rs index f7e5895b63..3b98ef2c49 100644 --- a/crates/tracedecay/src/doctor.rs +++ b/crates/tracedecay/src/doctor.rs @@ -136,7 +136,7 @@ pub async fn run_doctor( let project_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let daemon_listening = tracedecay_daemon_control::daemon_socket_connectable(profile); - let mut independent = launch_independent_doctor_checks( + let independent = launch_independent_doctor_checks( profile, build_version, &project_path, From 6ce5353a6038c908f4daf40d64b5e18361eed87a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 18 Oct 2026 16:17:58 +0000 Subject: [PATCH 3/3] chore: regenerate dashboard contracts without upload_enabled --- .../codegen/schemas/dashboard-contracts.schema.json | 10 ---------- dashboard/src/contracts/decoders.ts | 4 ---- dashboard/src/contracts/types.ts | 2 -- 3 files changed, 16 deletions(-) diff --git a/dashboard/codegen/schemas/dashboard-contracts.schema.json b/dashboard/codegen/schemas/dashboard-contracts.schema.json index e65724f847..ee47a131a9 100644 --- a/dashboard/codegen/schemas/dashboard-contracts.schema.json +++ b/dashboard/codegen/schemas/dashboard-contracts.schema.json @@ -26581,12 +26581,6 @@ "idempotency_key": { "type": "string" }, - "upload_enabled": { - "type": [ - "boolean", - "null" - ] - }, "watcher_debounce": { "type": [ "string", @@ -26640,9 +26634,6 @@ }, "type": "array" }, - "upload_enabled": { - "type": "boolean" - }, "watcher_debounce": { "type": "string" } @@ -26652,7 +26643,6 @@ "configuration_revision_id", "code_index_worker_configuration_snapshot_id", "code_index_worker_configuration_revision_id", - "upload_enabled", "code_index_workers", "code_index_worker_status", "watcher_debounce", diff --git a/dashboard/src/contracts/decoders.ts b/dashboard/src/contracts/decoders.ts index 67b2c136d4..38737d6a67 100644 --- a/dashboard/src/contracts/decoders.ts +++ b/dashboard/src/contracts/decoders.ts @@ -9455,13 +9455,11 @@ export const UserSettingsPatchSchema: z.ZodObject<{ expected_revision_id: z.ZodType; extraction_timeout_secs: z.ZodOptional>; idempotency_key: z.ZodType; - upload_enabled: z.ZodOptional>; watcher_debounce: z.ZodOptional>; }, z.core.$strict> = z.object({ expected_revision_id: z.string(), extraction_timeout_secs: z.number().int().min(0).nullable().optional(), idempotency_key: z.string(), - upload_enabled: z.boolean().nullable().optional(), watcher_debounce: z.string().nullable().optional(), }).strict(); @@ -9474,7 +9472,6 @@ export const UserSettingsPayloadV1Schema: z.ZodObject<{ configuration_snapshot_id: z.ZodType; extraction_timeout_secs: z.ZodType; installed_agents: z.ZodType, unknown>; - upload_enabled: z.ZodType; watcher_debounce: z.ZodType; }> = z.object({ code_index_worker_configuration_revision_id: z.string(), @@ -9485,7 +9482,6 @@ export const UserSettingsPayloadV1Schema: z.ZodObject<{ configuration_snapshot_id: z.string(), extraction_timeout_secs: z.number().int().min(0), installed_agents: z.array(z.string()), - upload_enabled: z.boolean(), watcher_debounce: z.string(), }); diff --git a/dashboard/src/contracts/types.ts b/dashboard/src/contracts/types.ts index 7eceeb6733..b9e3fcaa73 100644 --- a/dashboard/src/contracts/types.ts +++ b/dashboard/src/contracts/types.ts @@ -5423,7 +5423,6 @@ export type UserSettingsPatch = { expected_revision_id: string; extraction_timeout_secs?: number | null | undefined; idempotency_key: string; - upload_enabled?: boolean | null | undefined; watcher_debounce?: string | null | undefined; }; @@ -5436,7 +5435,6 @@ export type UserSettingsPayloadV1 = { configuration_snapshot_id: string; extraction_timeout_secs: number; installed_agents: Array; - upload_enabled: boolean; watcher_debounce: string; };