diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 1878a48bf..49dfa5d51 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -760,6 +760,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -1106,6 +1118,12 @@ dependencies = [ "serde", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "endi" version = "1.1.1" @@ -1886,6 +1904,19 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width 0.2.2", + "unit-prefix", + "web-time", +] + [[package]] name = "inout" version = "0.1.4" @@ -2658,6 +2689,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.5" @@ -3412,6 +3449,7 @@ dependencies = [ "clap_complete", "dirs", "hmac 0.13.0", + "indicatif", "inquire", "jsonschema", "keyring-core", @@ -4391,6 +4429,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 1ee161f4c..9b1ab2065 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -34,6 +34,7 @@ clap_complete = "4" dirs = "6" hmac = "0.13" inquire = "0.9" +indicatif = "0.18" jsonschema = "0.46" keyring-core = "1" owo-colors = { version = "4", features = ["supports-colors"] } diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index 1700ea4b7..695a9cc71 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -47,9 +47,11 @@ pub const COMPLETION_CLAP_ABOUT: &str = "Generate deterministic shell completion pub const COMPLETION_TOP_LEVEL_PURPOSE: &str = "Generate deterministic shell completion scripts"; pub const COMPLETION_SHOW_IN_TOP_LEVEL_HELP: bool = true; -pub const TRACE_CLAP_ABOUT: &str = "Inspect Agent Trace databases and recorded activity"; -pub const TRACE_TOP_LEVEL_PURPOSE: &str = "Inspect Agent Trace databases and recorded activity"; -pub const TRACE_SHOW_IN_TOP_LEVEL_HELP: bool = true; +pub const SYNC_CLAP_ABOUT: &str = + "Synchronize the current repository's Agent Trace database with the control plane"; +pub const SYNC_TOP_LEVEL_PURPOSE: &str = + "Synchronize the current repository's Agent Trace database"; +pub const SYNC_SHOW_IN_TOP_LEVEL_HELP: bool = true; pub const TOP_LEVEL_COMMANDS: &[TopLevelCommandMetadata] = &[ TopLevelCommandMetadata { @@ -93,9 +95,9 @@ pub const TOP_LEVEL_COMMANDS: &[TopLevelCommandMetadata] = &[ show_in_top_level_help: COMPLETION_SHOW_IN_TOP_LEVEL_HELP, }, TopLevelCommandMetadata { - name: crate::services::trace::NAME, - purpose: TRACE_TOP_LEVEL_PURPOSE, - show_in_top_level_help: TRACE_SHOW_IN_TOP_LEVEL_HELP, + name: crate::services::sync::NAME, + purpose: SYNC_TOP_LEVEL_PURPOSE, + show_in_top_level_help: SYNC_SHOW_IN_TOP_LEVEL_HELP, }, ]; @@ -230,54 +232,13 @@ pub enum Commands { shell: CompletionShell, }, - #[command(about = TRACE_CLAP_ABOUT, hide = !TRACE_SHOW_IN_TOP_LEVEL_HELP)] - Trace { - #[command(subcommand)] - subcommand: TraceSubcommand, - }, -} - -#[derive(Subcommand, Debug, Clone, PartialEq, Eq)] -pub enum TraceSubcommand { - #[command(about = "Inspect discovered Agent Trace databases")] - Db { - #[command(subcommand)] - subcommand: TraceDbSubcommand, - }, - - #[command(about = "Show Agent Trace activity for the current repository (or all with --all)")] - Status { - #[arg(long)] - all: bool, - - #[arg(long, value_enum, default_value_t = OutputFormat::Text)] - format: OutputFormat, - }, - - #[command( - about = "Synchronize the current repository's Agent Trace database with the control plane" - )] + #[command(about = SYNC_CLAP_ABOUT, hide = !SYNC_SHOW_IN_TOP_LEVEL_HELP)] Sync { #[arg(long, value_enum, default_value_t = OutputFormat::Text)] format: OutputFormat, }, } -#[derive(Subcommand, Debug, Clone, PartialEq, Eq)] -pub enum TraceDbSubcommand { - #[command(about = "List discovered Agent Trace databases with readiness")] - List { - #[arg(long, value_enum, default_value_t = OutputFormat::Text)] - format: OutputFormat, - }, - - #[command(about = "Open an embedded SQL shell for an Agent Trace database")] - Shell { - #[arg(value_name = "repository-id-or-alias")] - identifier: Option, - }, -} - #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] pub enum AuthSubcommand { #[command(about = "Start login flow and store credentials")] diff --git a/cli/src/services/agent_trace_sync/control_plane.rs b/cli/src/services/agent_trace_sync/control_plane.rs index b56b1c6ee..ed64e282a 100644 --- a/cli/src/services/agent_trace_sync/control_plane.rs +++ b/cli/src/services/agent_trace_sync/control_plane.rs @@ -139,7 +139,7 @@ impl fmt::Display for ControlPlaneError { match self { Self::MissingCredentials => write!( f, - "No stored WorkOS credentials were found. Try: run 'sce auth login' before running 'sce trace sync'." + "No stored WorkOS credentials were found. Try: run 'sce auth login' before running 'sce sync'." ), Self::AuthenticationFailed(reason) => write!( f, @@ -285,7 +285,7 @@ impl AuthenticatedControlPlaneClient { let outcome = run_with_retry( policy, "agent_trace_sync.ingestion_state", - "check network connectivity and control-plane availability, then rerun 'sce trace sync'", + "check network connectivity and control-plane availability, then rerun 'sce sync'", |_attempt| { let url = url.clone(); async move { diff --git a/cli/src/services/checkout/mod.rs b/cli/src/services/checkout/mod.rs index 0fc128917..70b59ed34 100644 --- a/cli/src/services/checkout/mod.rs +++ b/cli/src/services/checkout/mod.rs @@ -5,8 +5,7 @@ //! string, consistent with the existing `agent_trace_id` convention in this //! codebase. //! -//! Checkout databases are discovered via filesystem scan in `sce trace db list` -//! (see `cli/src/services/trace/`). There is no central registry file. +//! Checkout identity is repository metadata used by Agent Trace diagnostics. use std::path::{Path, PathBuf}; use std::process::Command; diff --git a/cli/src/services/command_registry.rs b/cli/src/services/command_registry.rs index cc4675aca..bff826015 100644 --- a/cli/src/services/command_registry.rs +++ b/cli/src/services/command_registry.rs @@ -14,7 +14,7 @@ const DEFAULT_COMMAND_NAMES: &[&str] = &[ services::hooks::NAME, services::bash_policy::NAME, services::setup::NAME, - services::trace::NAME, + services::sync::NAME, services::version::NAME, ]; @@ -34,7 +34,7 @@ pub enum RuntimeCommand { Policy(services::bash_policy::command::PolicyCommand), Version(services::version::command::VersionCommand), Completion(services::completion::command::CompletionCommand), - Trace(services::trace::command::TraceCommand), + Sync(services::sync::command::SyncCommand), } impl RuntimeCommand { @@ -50,7 +50,7 @@ impl RuntimeCommand { Self::Policy(_) => Cow::Borrowed(services::bash_policy::NAME), Self::Version(_) => Cow::Borrowed(services::version::NAME), Self::Completion(_) => Cow::Borrowed(services::completion::NAME), - Self::Trace(_) => Cow::Borrowed(services::trace::NAME), + Self::Sync(_) => Cow::Borrowed(services::sync::NAME), } } @@ -83,7 +83,7 @@ impl RuntimeCommand { Self::Policy(command) => command.execute(), Self::Version(command) => command.execute(context), Self::Completion(command) => Ok(command.execute(context)), - Self::Trace(command) => command.execute_with_stderr(context, stderr), + Self::Sync(command) => command.execute_with_stderr(context, stderr), } } } @@ -192,15 +192,11 @@ pub fn default_runtime_command(name: &str) -> Option { }, }, )), - services::trace::NAME => Some(RuntimeCommand::Trace( - services::trace::command::TraceCommand { - request: services::trace::TraceRequest { - subcommand: services::trace::TraceSubcommandRequest::DbList { - format: services::output_format::OutputFormat::Text, - }, - }, + services::sync::NAME => Some(RuntimeCommand::Sync(services::sync::command::SyncCommand { + request: services::sync::SyncRequest { + format: services::output_format::OutputFormat::Text, }, - )), + })), _ => None, } } @@ -224,7 +220,7 @@ mod tests { "hooks", "policy", "setup", - "trace", + "sync", "version" ] ); @@ -237,7 +233,7 @@ mod tests { for name in DEFAULT_COMMAND_NAMES { assert!(registry.contains(name)); } - assert!(!registry.contains("sync")); + assert!(registry.contains("sync")); } #[test] @@ -246,6 +242,6 @@ mod tests { let command = default_runtime_command(name).expect("command should exist"); assert_eq!(command.name(), *name); } - assert!(default_runtime_command("sync").is_none()); + assert!(default_runtime_command("sync").is_some()); } } diff --git a/cli/src/services/mod.rs b/cli/src/services/mod.rs index 731a351e6..e84ebdba9 100644 --- a/cli/src/services/mod.rs +++ b/cli/src/services/mod.rs @@ -36,6 +36,6 @@ pub mod security; pub mod setup; pub mod structured_patch; pub mod style; +pub mod sync; pub mod token_storage; -pub mod trace; pub mod version; diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index 45496476e..e041c1dc7 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -250,32 +250,14 @@ fn convert_clap_command(command: cli_schema::Commands) -> Result Ok(convert_trace_subcommand(subcommand)), + cli_schema::Commands::Sync { format } => { + Ok(RuntimeCommand::Sync(services::sync::command::SyncCommand { + request: services::sync::SyncRequest { format }, + })) + } } } -#[allow(clippy::needless_pass_by_value)] -fn convert_trace_subcommand(subcommand: cli_schema::TraceSubcommand) -> RuntimeCommand { - let request = match subcommand { - cli_schema::TraceSubcommand::Db { subcommand } => match subcommand { - cli_schema::TraceDbSubcommand::List { format } => services::trace::TraceRequest { - subcommand: services::trace::TraceSubcommandRequest::DbList { format }, - }, - cli_schema::TraceDbSubcommand::Shell { identifier } => services::trace::TraceRequest { - subcommand: services::trace::TraceSubcommandRequest::DbShell { identifier }, - }, - }, - cli_schema::TraceSubcommand::Status { all, format } => services::trace::TraceRequest { - subcommand: services::trace::TraceSubcommandRequest::Status { all, format }, - }, - cli_schema::TraceSubcommand::Sync { format } => services::trace::TraceRequest { - subcommand: services::trace::TraceSubcommandRequest::Sync { format }, - }, - }; - - RuntimeCommand::Trace(services::trace::command::TraceCommand { request }) -} - fn convert_doctor_command( fix: bool, format: services::output_format::OutputFormat, @@ -499,64 +481,44 @@ mod tests { } #[test] - fn trace_db_shell_parses_to_trace_shell_request() { - let command = parse(&["sce", "trace", "db", "shell", "agent_trace_0"]); + fn sync_parses_to_sync_request_with_default_text_format() { + let command = parse(&["sce", "sync"]); - let RuntimeCommand::Trace(command) = command else { - panic!("expected trace command"); + let RuntimeCommand::Sync(command) = command else { + panic!("expected sync command"); }; assert_eq!( - command.request.subcommand, - services::trace::TraceSubcommandRequest::DbShell { - identifier: Some(String::from("agent_trace_0")), - } + command.request.format, + services::output_format::OutputFormat::Text ); } #[test] - fn trace_sync_parses_to_trace_sync_request_with_default_text_format() { - let command = parse(&["sce", "trace", "sync"]); + fn sync_json_format_parses_to_sync_request() { + let command = parse(&["sce", "sync", "--format", "json"]); - let RuntimeCommand::Trace(command) = command else { - panic!("expected trace command"); + let RuntimeCommand::Sync(command) = command else { + panic!("expected sync command"); }; assert_eq!( - command.request.subcommand, - services::trace::TraceSubcommandRequest::Sync { - format: services::output_format::OutputFormat::Text, - } + command.request.format, + services::output_format::OutputFormat::Json ); } #[test] - fn trace_sync_json_format_parses_to_trace_sync_request() { - let command = parse(&["sce", "trace", "sync", "--format", "json"]); - - let RuntimeCommand::Trace(command) = command else { - panic!("expected trace command"); - }; - - assert_eq!( - command.request.subcommand, - services::trace::TraceSubcommandRequest::Sync { - format: services::output_format::OutputFormat::Json, - } + fn removed_trace_command_is_rejected() { + let result = parse_runtime_command( + ["sce", "trace", "sync"].into_iter().map(String::from), + &CommandRegistry::default(), + None, ); - } - - #[test] - fn trace_db_help_lists_shell_subcommand() { - let command = parse(&["sce", "trace", "db", "--help"]); - let RuntimeCommand::HelpText(command) = command else { - panic!("expected help text command"); - }; - - assert!(command.text.contains("shell")); - assert!(command - .text - .contains("Open an embedded SQL shell for an Agent Trace database")); + match result { + Ok(_) => panic!("trace should be unavailable"), + Err(error) => assert!(error.to_string().contains("Unknown command 'trace'")), + } } } diff --git a/cli/src/services/style.rs b/cli/src/services/style.rs index e0d2be4c8..a1496dc5a 100644 --- a/cli/src/services/style.rs +++ b/cli/src/services/style.rs @@ -46,6 +46,10 @@ where style_if(text, supports_color_stderr(), f) } +pub(crate) fn success_with_stderr_color_policy(text: &str, color_enabled: bool) -> String { + style_if(text, color_enabled, |s| s.green().bold().to_string()) +} + #[must_use] pub fn heading(text: &str) -> String { heading_with_color_policy(text, supports_color()) diff --git a/cli/src/services/sync/command.rs b/cli/src/services/sync/command.rs new file mode 100644 index 000000000..3ff9845e6 --- /dev/null +++ b/cli/src/services/sync/command.rs @@ -0,0 +1,93 @@ +use std::io::Write; + +use crate::app::ContextWithRepoRoot; +use crate::services::error::ClassifiedError; +use crate::services::sync::progress::{ + IndicatifProgressReporter, NoopProgressReporter, ProgressReporter, +}; +use crate::services::sync::render_sync; +use crate::services::sync::sync::{ + run_current_sync_with_progress_and_clock, SyncProgressClock, SystemSyncProgressClock, + TraceSyncError, +}; +use crate::services::sync::SyncRequest; + +pub struct SyncCommand { + pub request: SyncRequest, +} + +fn current_repo_root(context: &C) -> Result +where + C: ContextWithRepoRoot, +{ + if let Some(path) = context.repo_root() { + Ok(path.to_path_buf()) + } else { + std::env::current_dir().map_err(|err| { + ClassifiedError::runtime(format!("failed to determine current directory: {err}")) + }) + } +} + +#[allow(clippy::needless_pass_by_value)] +fn classify_sync_error(err: TraceSyncError) -> ClassifiedError { + ClassifiedError::runtime(format!("{err}")) +} + +impl SyncCommand { + #[allow(dead_code)] + pub fn execute(&self, context: &C) -> Result + where + C: ContextWithRepoRoot, + { + let mut stderr = std::io::sink(); + self.execute_with_stderr(context, &mut stderr) + } + + pub fn execute_with_stderr( + &self, + context: &C, + stderr: &mut W, + ) -> Result + where + C: ContextWithRepoRoot, + W: Write, + { + let clock = SystemSyncProgressClock; + self.execute_with_stderr_and_clock(context, stderr, &clock) + } + + fn execute_with_stderr_and_clock( + &self, + context: &C, + stderr: &mut W, + clock: &Clock, + ) -> Result + where + C: ContextWithRepoRoot, + W: Write, + Clock: SyncProgressClock, + { + let repo_root = current_repo_root(context)?; + + let report = match self.request.format { + crate::services::output_format::OutputFormat::Text => { + let mut progress = IndicatifProgressReporter::new(stderr); + let result = + run_current_sync_with_progress_and_clock(&repo_root, &mut progress, clock); + if result.is_ok() { + progress.finish_successfully(); + } + result + } + crate::services::output_format::OutputFormat::Json => { + let mut progress = NoopProgressReporter; + run_current_sync_with_progress_and_clock(&repo_root, &mut progress, clock) + } + } + .map_err(classify_sync_error)?; + + render_sync::render(&report, self.request.format) + .map_err(|error| ClassifiedError::runtime(format!("{error:#}"))) + } +} diff --git a/cli/src/services/sync/mod.rs b/cli/src/services/sync/mod.rs new file mode 100644 index 000000000..424107cd7 --- /dev/null +++ b/cli/src/services/sync/mod.rs @@ -0,0 +1,16 @@ +//! Top-level `sce sync` command and Agent Trace synchronization service. + +pub mod command; +pub mod progress; +pub mod render_sync; +#[allow(clippy::module_inception)] +pub mod sync; + +pub const NAME: &str = "sync"; + +use crate::services::output_format::OutputFormat; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SyncRequest { + pub format: OutputFormat, +} diff --git a/cli/src/services/sync/progress.rs b/cli/src/services/sync/progress.rs new file mode 100644 index 000000000..f6858dd4e --- /dev/null +++ b/cli/src/services/sync/progress.rs @@ -0,0 +1,307 @@ +//! Sync-owned progress reporting contracts and terminal presentation. + +use std::io::{self, IsTerminal, Write}; +use std::time::Duration; + +use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; + +use super::sync::SyncProgressEvent; + +/// Receives deterministic progress events and can finalize a successful run. +pub trait ProgressReporter { + fn report(&mut self, event: E); + + /// Finalizes a successfully completed report. + /// + /// Reporters that do not need a separate successful-finalization step can + /// keep the default no-op implementation. + fn finish_successfully(&mut self) {} +} + +impl ProgressReporter for F +where + F: FnMut(E), +{ + fn report(&mut self, event: E) { + self(event); + } +} + +/// Discards progress for callers that only need the final result. +#[derive(Clone, Copy, Debug, Default)] +pub struct NoopProgressReporter; + +impl ProgressReporter for NoopProgressReporter { + fn report(&mut self, _event: E) {} +} + +const STREAM_LABELS: [&str; 4] = ["messages", "parts", "diff_traces", "agent_traces"]; +const STREAM_LABEL_WIDTH: usize = 15; +const STEADY_TICK_INTERVAL: Duration = Duration::from_millis(100); + +pub struct IndicatifProgressReporter<'a, W> { + _progress: MultiProgress, + bars: [ProgressBar; STREAM_LABELS.len()], + writer: &'a mut W, + interactive: bool, + color_enabled: bool, + uploaded: [usize; STREAM_LABELS.len()], + completed: [bool; STREAM_LABELS.len()], +} + +impl<'a, W> IndicatifProgressReporter<'a, W> +where + W: Write, +{ + pub fn new(writer: &'a mut W) -> Self { + Self::with_policies( + writer, + io::stderr().is_terminal(), + crate::services::style::supports_color_stderr(), + ) + } + + pub(crate) fn with_policies(writer: &'a mut W, interactive: bool, color_enabled: bool) -> Self { + let target = if interactive { + ProgressDrawTarget::stderr_with_hz(20) + } else { + ProgressDrawTarget::hidden() + }; + let progress = MultiProgress::with_draw_target(target); + let style = progress_style(color_enabled); + let bars = STREAM_LABELS.map(|stream| { + let bar = progress.add(ProgressBar::new(0)); + bar.set_style(style.clone()); + bar.set_prefix(format!("{stream: Option<&ProgressBar> { + STREAM_LABELS + .iter() + .position(|candidate| *candidate == stream) + .map(|index| &self.bars[index]) + } + + fn stream_index(stream: &str) -> Option { + STREAM_LABELS + .iter() + .position(|candidate| *candidate == stream) + } + + fn report_batch(&mut self, stream: &str, uploaded: usize) { + if let Some(bar) = self.bar_for(stream) { + bar.set_position(uploaded as u64); + bar.set_message(uploaded_message(uploaded)); + } + if let Some(index) = Self::stream_index(stream) { + self.uploaded[index] = uploaded; + } + self.render_non_interactive(); + } + + fn report_completion(&mut self, stream: &str, uploaded: usize) { + if let Some(bar) = self.bar_for(stream) { + bar.finish_with_message(uploaded_message(uploaded)); + } + if let Some(index) = Self::stream_index(stream) { + self.uploaded[index] = uploaded; + self.completed[index] = true; + } + self.render_non_interactive(); + } + + fn render_non_interactive(&mut self) { + if self.interactive { + return; + } + + for (index, stream) in STREAM_LABELS.iter().enumerate() { + let marker = if self.completed[index] { + crate::services::style::success_with_stderr_color_policy("✓", self.color_enabled) + } else { + "⠋".to_string() + }; + let _ = writeln!( + self.writer, + "{marker} {stream: ProgressReporter for IndicatifProgressReporter<'_, W> +where + W: Write, +{ + fn report(&mut self, event: SyncProgressEvent) { + match event { + SyncProgressEvent::Started { .. } | SyncProgressEvent::Finished { .. } => { + self.render_non_interactive(); + } + SyncProgressEvent::BatchAccepted { + stream, uploaded, .. + } => self.report_batch(stream, uploaded), + SyncProgressEvent::StreamCompleted { + stream, uploaded, .. + } => self.report_completion(stream, uploaded), + } + } + + fn finish_successfully(&mut self) { + let line_breaks = if self.interactive { 2 } else { 1 }; + for _ in 0..line_breaks { + let _ = writeln!(self.writer); + } + let _ = self.writer.flush(); + } +} + +fn progress_style(color_enabled: bool) -> ProgressStyle { + let check = crate::services::style::success_with_stderr_color_policy("✓", color_enabled); + ProgressStyle::with_template("{spinner} {prefix} {msg}") + .expect("sync progress template is valid") + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", check.as_str()]) +} + +fn uploaded_message(uploaded: usize) -> String { + let row_label = if uploaded == 1 { "row" } else { "rows" }; + format!("{uploaded} {row_label} uploaded") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone, Debug, PartialEq, Eq)] + enum TestEvent { + Started, + Finished, + } + + #[derive(Default)] + struct CollectingReporter { + events: Vec, + finalized_successfully: bool, + } + + impl ProgressReporter for CollectingReporter { + fn report(&mut self, event: TestEvent) { + self.events.push(event); + } + + fn finish_successfully(&mut self) { + self.finalized_successfully = true; + } + } + + #[test] + fn collecting_reporter_observes_events_and_successful_finalization() { + let mut reporter = CollectingReporter::default(); + + reporter.report(TestEvent::Started); + reporter.report(TestEvent::Finished); + reporter.finish_successfully(); + + assert_eq!( + reporter.events, + vec![TestEvent::Started, TestEvent::Finished] + ); + assert!(reporter.finalized_successfully); + } + + #[test] + fn closure_reporter_supports_the_event_contract() { + let mut events = Vec::new(); + let mut reporter = |event: TestEvent| events.push(event); + + reporter.report(TestEvent::Started); + reporter.report(TestEvent::Finished); + reporter.finish_successfully(); + + assert_eq!(events, vec![TestEvent::Started, TestEvent::Finished]); + } + + #[test] + fn noop_reporter_ignores_events_and_finalization() { + let mut reporter = NoopProgressReporter; + reporter.report(TestEvent::Finished); + >::finish_successfully(&mut reporter); + } + + #[test] + fn progress_reporter_creates_aligned_rows_and_updates_only_the_matching_stream() { + let mut output = Vec::new(); + let mut reporter = IndicatifProgressReporter::with_policies(&mut output, false, false); + + reporter.report(SyncProgressEvent::Started { + timestamp: "2026-01-02T03:04:05Z".to_string(), + }); + reporter.report(SyncProgressEvent::BatchAccepted { + stream: "messages", + batch_rows: 500, + uploaded: 500, + cursor: 500, + }); + reporter.report(SyncProgressEvent::StreamCompleted { + stream: "parts", + uploaded: 0, + cursor: 12, + batches: 0, + }); + + let output = String::from_utf8(output).expect("progress output should be UTF-8"); + assert!(output.contains("messages ")); + assert!(output.contains("parts ")); + assert!(output.contains("diff_traces ")); + assert!(output.contains("agent_traces ")); + assert!(output.contains("messages 500 rows uploaded")); + assert!(output.contains("parts 0 rows uploaded")); + assert!(output.contains("✓ parts 0 rows uploaded")); + assert!(!output.contains("\x1b[")); + } + + #[test] + fn progress_reporter_finishes_rows_with_a_plain_check_when_color_is_disabled() { + let mut output = Vec::new(); + let mut reporter = IndicatifProgressReporter::with_policies(&mut output, false, false); + + reporter.report(SyncProgressEvent::StreamCompleted { + stream: "agent_traces", + uploaded: 7, + cursor: 12, + batches: 2, + }); + + let output = String::from_utf8(output).expect("progress output should be UTF-8"); + assert!(output.contains("✓ agent_traces 7 rows uploaded")); + assert!(!output.contains("\x1b[")); + } + + #[test] + fn progress_reporter_leaves_a_blank_line_after_completion() { + let mut output = Vec::new(); + let mut reporter = IndicatifProgressReporter::with_policies(&mut output, false, false); + + reporter.finish_successfully(); + + assert!(output.ends_with(b"\n\n")); + } +} diff --git a/cli/src/services/trace/render_sync.rs b/cli/src/services/sync/render_sync.rs similarity index 50% rename from cli/src/services/trace/render_sync.rs rename to cli/src/services/sync/render_sync.rs index 398d51c55..65b5b2585 100644 --- a/cli/src/services/trace/render_sync.rs +++ b/cli/src/services/sync/render_sync.rs @@ -1,18 +1,15 @@ -//! Renderers for `sce trace sync` (text and JSON). +//! Renderers for `sce sync` (text and JSON). use anyhow::{Context, Result}; use serde_json::json; use crate::services::output_format::OutputFormat; use crate::services::style; -use crate::services::trace::sync::{AgentTraceSyncReport, StreamSyncReport}; -use crate::services::trace::NAME; +use crate::services::sync::sync::{AgentTraceSyncReport, StreamSyncReport}; +use crate::services::sync::NAME; -const HEADING: &str = "Agent Trace sync complete."; - -const COL_STREAM: &str = "Stream"; -const COL_UPLOADED: &str = "Uploaded"; -const COL_FINAL_CURSOR: &str = "Final cursor"; +const COMPLETE_HEADING: &str = "Agent Trace sync complete."; +const ALREADY_SYNCED_HEADING: &str = "Agent Trace already synced."; pub fn render(report: &AgentTraceSyncReport, format: OutputFormat) -> Result { match format { @@ -22,63 +19,25 @@ pub fn render(report: &AgentTraceSyncReport, format: OutputFormat) -> Result String { - let mut lines = vec![style::heading(HEADING)]; - lines.push(format!("Repository ID: {}", report.repository_id)); - lines.push(format!("Source instance ID: {}", report.source_instance_id)); - lines.push(String::new()); - - let headers = [COL_STREAM, COL_UPLOADED, COL_FINAL_CURSOR]; - let rows: [[String; 3]; 4] = [ - format_row("messages", &report.streams.messages), - format_row("parts", &report.streams.parts), - format_row("diff_traces", &report.streams.diff_traces), - format_row("agent_traces", &report.streams.agent_traces), + let uploaded = [ + report.streams.messages.uploaded, + report.streams.parts.uploaded, + report.streams.diff_traces.uploaded, + report.streams.agent_traces.uploaded, ]; + let heading = if uploaded.iter().all(|count| *count == 0) { + ALREADY_SYNCED_HEADING + } else { + COMPLETE_HEADING + }; - let widths: Vec = (0..headers.len()) - .map(|col| { - rows.iter() - .map(|row| row[col].len()) - .max() - .unwrap_or(0) - .max(headers[col].len()) - }) - .collect(); - - lines.push(join_row(&headers.map(str::to_string), &widths)); - for row in &rows { - lines.push(join_row(row, &widths)); - } - - lines.join("\n") -} - -fn join_row(cells: &[String; N], widths: &[usize]) -> String { - cells - .iter() - .enumerate() - .map(|(i, cell)| format!("{cell:>() - .join(" ") - .trim_end() - .to_string() -} - -fn format_row(name: &str, stream: &StreamSyncReport) -> [String; 3] { - [ - name.to_string(), - stream.uploaded.to_string(), - stream.final_cursor.to_string(), - ] + style::heading(heading) } fn render_json(report: &AgentTraceSyncReport) -> Result { let payload = json!({ "status": "ok", "command": NAME, - "subcommand": "sync", - "repositoryId": report.repository_id, - "sourceInstanceId": report.source_instance_id, "streams": { "messages": stream_json(&report.streams.messages), "parts": stream_json(&report.streams.parts), @@ -87,7 +46,7 @@ fn render_json(report: &AgentTraceSyncReport) -> Result { }, }); - serde_json::to_string_pretty(&payload).context("failed to serialize trace sync report to JSON") + serde_json::to_string_pretty(&payload).context("failed to serialize sync report to JSON") } fn stream_json(stream: &StreamSyncReport) -> serde_json::Value { @@ -107,7 +66,7 @@ mod tests { AgentTraceSyncReport { repository_id: "repo-123".to_string(), source_instance_id: "source-abc".to_string(), - streams: crate::services::trace::sync::StreamSyncReports { + streams: crate::services::sync::sync::StreamSyncReports { messages: StreamSyncReport { uploaded: 3, initial_cursor: 10, @@ -136,40 +95,15 @@ mod tests { } } - #[test] - fn text_renders_concise_per_stream_layout_without_batches() { - let rendered = render_text(&sample_report()); - assert!(rendered.contains("Agent Trace sync complete.")); - assert!(rendered.contains("Repository ID: repo-123")); - assert!(rendered.contains("Source instance ID: source-abc")); - assert!(rendered.contains("messages")); - assert!(rendered.contains("parts")); - assert!(rendered.contains("diff_traces")); - assert!(rendered.contains("agent_traces")); - // Concise: no per-batch or per-row detail is printed. - assert!(!rendered.contains("batch")); - } - - #[test] - fn text_row_values_match_uploaded_and_final_cursor() { - let rendered = render_text(&sample_report()); - let messages_line = rendered - .lines() - .find(|line| line.trim_start().starts_with("messages")) - .expect("messages row present"); - assert!(messages_line.contains('3')); - assert!(messages_line.contains("13")); - } - #[test] fn json_shape_matches_contract() { let payload = render_json(&sample_report()).expect("json render"); let value: serde_json::Value = serde_json::from_str(&payload).expect("valid json"); assert_eq!(value["status"], "ok"); - assert_eq!(value["command"], "trace"); - assert_eq!(value["subcommand"], "sync"); - assert_eq!(value["repositoryId"], "repo-123"); - assert_eq!(value["sourceInstanceId"], "source-abc"); + assert_eq!(value["command"], "sync"); + assert!(value.get("subcommand").is_none()); + assert!(value.get("repositoryId").is_none()); + assert!(value.get("sourceInstanceId").is_none()); let messages = &value["streams"]["messages"]; assert_eq!(messages["uploaded"], 3); diff --git a/cli/src/services/trace/sync.rs b/cli/src/services/sync/sync.rs similarity index 96% rename from cli/src/services/trace/sync.rs rename to cli/src/services/sync/sync.rs index 28b1fe6e6..0cf23ab94 100644 --- a/cli/src/services/trace/sync.rs +++ b/cli/src/services/sync/sync.rs @@ -1,4 +1,4 @@ -//! `sce trace sync` orchestration: resolves the current repository's Agent +//! `sce sync` orchestration: resolves the current repository's Agent //! Trace storage, fetches authoritative control-plane cursors, then //! synchronizes each of the four independent capture streams in the fixed //! `messages -> parts -> diff_traces -> agent_traces` order. @@ -32,6 +32,7 @@ use crate::services::agent_trace_sync::{ }; use crate::services::auth; use crate::services::config; +use crate::services::sync::progress::{NoopProgressReporter, ProgressReporter}; static SYNC_RUNTIME: OnceLock = OnceLock::new(); @@ -60,28 +61,6 @@ pub struct StreamSyncReport { pub batches: usize, } -/// Supplies timestamps for one trace-sync invocation. -pub trait SyncProgressClock { - fn now(&self) -> DateTime; -} - -/// Uses the system UTC clock for production sync invocations. -#[derive(Clone, Copy, Debug, Default)] -pub struct SystemSyncProgressClock; - -impl SyncProgressClock for SystemSyncProgressClock { - fn now(&self) -> DateTime { - Utc::now() - } -} - -fn timestamp(clock: &C) -> String -where - C: SyncProgressClock, -{ - clock.now().to_rfc3339_opts(SecondsFormat::AutoSi, true) -} - /// Progress emitted while the four trace streams are synchronized. #[derive(Clone, Debug, PartialEq, Eq)] pub enum SyncProgressEvent { @@ -105,28 +84,29 @@ pub enum SyncProgressEvent { }, } -/// Receives deterministic sync progress events. -pub trait SyncProgressSink { - fn report(&mut self, event: SyncProgressEvent); +/// Supplies timestamps for one trace-sync invocation. +pub trait SyncProgressClock { + fn now(&self) -> DateTime; } -impl SyncProgressSink for F -where - F: FnMut(SyncProgressEvent), -{ - fn report(&mut self, event: SyncProgressEvent) { - self(event); +/// Uses the system UTC clock for production sync invocations. +#[derive(Clone, Copy, Debug, Default)] +pub struct SystemSyncProgressClock; + +impl SyncProgressClock for SystemSyncProgressClock { + fn now(&self) -> DateTime { + Utc::now() } } -/// Discards sync progress for callers that only need the final report. -pub struct NoopSyncProgressSink; - -impl SyncProgressSink for NoopSyncProgressSink { - fn report(&mut self, _event: SyncProgressEvent) {} +fn timestamp(clock: &C) -> String +where + C: SyncProgressClock, +{ + clock.now().to_rfc3339_opts(SecondsFormat::AutoSi, true) } -/// Terminal failure of `sce trace sync`. +/// Terminal failure of `sce sync`. #[derive(Debug)] pub enum TraceSyncError { /// Local repository/storage/config resolution failed. @@ -154,11 +134,11 @@ impl std::error::Error for TraceSyncError {} /// Resolves the current repository's Agent Trace storage (the same /// `ContextWithRepoRoot`/`AgentTraceStorageContext`/`resolve_agent_trace_storage` -/// path `sce trace status` uses) and control-plane configuration, then +/// path used by the sync command) and control-plane configuration, then /// synchronizes all four capture streams. #[allow(dead_code)] pub fn run_current_sync(repo_root: &Path) -> Result { - let mut progress = NoopSyncProgressSink; + let mut progress = NoopProgressReporter; run_current_sync_with_progress(repo_root, &mut progress) } @@ -168,7 +148,7 @@ pub fn run_current_sync_with_progress( progress: &mut S, ) -> Result where - S: SyncProgressSink, + S: ProgressReporter, { let clock = SystemSyncProgressClock; run_current_sync_with_progress_and_clock(repo_root, progress, &clock) @@ -181,7 +161,7 @@ pub fn run_current_sync_with_progress_and_clock( clock: &C, ) -> Result where - S: SyncProgressSink, + S: ProgressReporter, C: SyncProgressClock, { progress.report(SyncProgressEvent::Started { @@ -199,7 +179,7 @@ fn run_current_sync_without_progress( progress: &mut S, ) -> Result where - S: SyncProgressSink, + S: ProgressReporter, { let storage_config = config::resolve_agent_trace_storage_runtime_config(repo_root) .map_err(|error| TraceSyncError::Runtime(format!("{error:#}")))?; @@ -239,7 +219,7 @@ pub(crate) fn run_sync_against( db: &RepositoryAgentTraceDb, client: &AuthenticatedControlPlaneClient, ) -> Result { - let mut progress = NoopSyncProgressSink; + let mut progress = NoopProgressReporter; run_sync_against_with_progress(repository_id, source_instance_id, db, client, &mut progress) } @@ -252,7 +232,7 @@ pub(crate) fn run_sync_against_with_progress( progress: &mut S, ) -> Result where - S: SyncProgressSink, + S: ProgressReporter, { let clock = SystemSyncProgressClock; run_sync_against_with_progress_and_clock( @@ -275,7 +255,7 @@ pub(crate) fn run_sync_against_with_progress_and_clock( clock: &C, ) -> Result where - S: SyncProgressSink, + S: ProgressReporter, C: SyncProgressClock, { progress.report(SyncProgressEvent::Started { @@ -297,7 +277,7 @@ fn run_sync_against_without_progress( progress: &mut S, ) -> Result where - S: SyncProgressSink, + S: ProgressReporter, { let runtime = shared_runtime()?; let reader = AgentTraceExportReader::new(db); @@ -319,7 +299,7 @@ async fn run_sync_async<'a, S>( progress: &'a mut S, ) -> Result where - S: SyncProgressSink + 'a, + S: ProgressReporter + 'a, { let state_request = AgentTraceIngestionStateRequest { repository_id: repository_id.to_string(), @@ -379,22 +359,6 @@ where ) .await?; - for (stream, report) in [ - ("messages", messages), - ("parts", parts), - ("diff_traces", diff_traces), - ("agent_traces", agent_traces), - ] { - progress - .borrow_mut() - .report(SyncProgressEvent::StreamCompleted { - stream, - uploaded: report.uploaded, - cursor: report.final_cursor, - batches: report.batches, - }); - } - Ok(AgentTraceSyncReport { repository_id: repository_id.to_string(), source_instance_id: source_instance_id.to_string(), @@ -488,7 +452,7 @@ async fn sync_one_stream<'a, T, ReadFn, IngestFn, S>( ) -> Result where T: AgentTraceExportRow + Clone + 'a, - S: SyncProgressSink + 'a, + S: ProgressReporter + 'a, ReadFn: FnMut(i64, usize) -> anyhow::Result> + 'a, IngestFn: FnMut( AgentTraceIngestionBatchRequest, @@ -578,6 +542,15 @@ where source, })?; + progress + .borrow_mut() + .report(SyncProgressEvent::StreamCompleted { + stream: stream_label, + uploaded: outcome.uploaded, + cursor: outcome.final_cursor, + batches: outcome.batches, + }); + Ok(StreamSyncReport { uploaded: outcome.uploaded, initial_cursor: outcome.initial_cursor, @@ -622,7 +595,7 @@ fn shared_runtime() -> Result<&'static Runtime, TraceSyncError> { .enable_io() .enable_time() .build() - .context("failed to create trace sync command runtime") + .context("failed to create sync command runtime") .map_err(|error| TraceSyncError::Runtime(format!("{error:#}")))?; Ok(SYNC_RUNTIME.get_or_init(|| runtime)) @@ -859,6 +832,24 @@ mod tests { SyncProgressEvent::Started { timestamp: "2026-01-02T03:04:05Z".to_string(), }, + SyncProgressEvent::StreamCompleted { + stream: "parts", + uploaded: 0, + cursor: 0, + batches: 0, + }, + SyncProgressEvent::StreamCompleted { + stream: "diff_traces", + uploaded: 0, + cursor: 0, + batches: 0, + }, + SyncProgressEvent::StreamCompleted { + stream: "agent_traces", + uploaded: 0, + cursor: 0, + batches: 0, + }, SyncProgressEvent::BatchAccepted { stream: "messages", batch_rows: 100, @@ -883,24 +874,6 @@ mod tests { cursor: 201, batches: 3, }, - SyncProgressEvent::StreamCompleted { - stream: "parts", - uploaded: 0, - cursor: 0, - batches: 0, - }, - SyncProgressEvent::StreamCompleted { - stream: "diff_traces", - uploaded: 0, - cursor: 0, - batches: 0, - }, - SyncProgressEvent::StreamCompleted { - stream: "agent_traces", - uploaded: 0, - cursor: 0, - batches: 0, - }, SyncProgressEvent::Finished { timestamp: "2026-01-02T03:04:06Z".to_string(), }, diff --git a/cli/src/services/trace/command.rs b/cli/src/services/trace/command.rs deleted file mode 100644 index 97e207d70..000000000 --- a/cli/src/services/trace/command.rs +++ /dev/null @@ -1,248 +0,0 @@ -use std::io::Write; - -use crate::app::ContextWithRepoRoot; -use crate::services::error::ClassifiedError; -use crate::services::trace::discovery::discover_agent_trace_dbs; -use crate::services::trace::render_list; -use crate::services::trace::render_status; -use crate::services::trace::render_status_all; -use crate::services::trace::render_sync; -use crate::services::trace::shell::{run_agent_trace_db_shell, ShellTarget}; -use crate::services::trace::status::{resolve_current_status, StatusErrorOrRuntime}; -use crate::services::trace::status_all::aggregate_current_status_all; -use crate::services::trace::sync::{ - run_current_sync_with_progress_and_clock, NoopSyncProgressSink, SyncProgressClock, - SyncProgressEvent, SyncProgressSink, SystemSyncProgressClock, TraceSyncError, -}; -use crate::services::trace::{ - resolve_agent_trace_db_identifier, TraceRequest, TraceSubcommandRequest, -}; - -pub struct TraceCommand { - pub request: TraceRequest, -} - -fn current_repo_root(context: &C) -> Result -where - C: ContextWithRepoRoot, -{ - if let Some(path) = context.repo_root() { - Ok(path.to_path_buf()) - } else { - std::env::current_dir().map_err(|err| { - ClassifiedError::runtime(format!("failed to determine current directory: {err}")) - }) - } -} - -fn classify_status_error(err: StatusErrorOrRuntime) -> ClassifiedError { - match err { - StatusErrorOrRuntime::Runtime(runtime_err) => { - ClassifiedError::runtime(format!("{runtime_err:#}")) - } - } -} - -#[allow(clippy::needless_pass_by_value)] -fn classify_sync_error(err: TraceSyncError) -> ClassifiedError { - ClassifiedError::runtime(format!("{err}")) -} - -struct StderrSyncProgressReporter<'a, W> { - writer: &'a mut W, -} - -impl<'a, W> StderrSyncProgressReporter<'a, W> { - fn new(writer: &'a mut W) -> Self { - Self { writer } - } -} - -impl SyncProgressSink for StderrSyncProgressReporter<'_, W> -where - W: Write, -{ - fn report(&mut self, event: SyncProgressEvent) { - let _ = writeln!(self.writer, "{}", format_progress_event(&event)); - let _ = self.writer.flush(); - } -} - -fn format_progress_event(event: &SyncProgressEvent) -> String { - match event { - SyncProgressEvent::Started { timestamp } => { - format!("Starting Agent Trace sync at {timestamp}...") - } - SyncProgressEvent::BatchAccepted { - stream, - batch_rows, - uploaded, - cursor, - } => format!( - "{stream}: uploaded batch of {batch_rows} rows ({uploaded} total, cursor {cursor})" - ), - SyncProgressEvent::StreamCompleted { - stream, - uploaded, - cursor, - batches, - } if *batches == 0 => { - format!("{stream}: complete - no new rows uploaded (cursor {cursor})") - } - SyncProgressEvent::StreamCompleted { - stream, - uploaded, - cursor, - batches, - } => format!( - "{stream}: complete - {uploaded} rows uploaded in {batches} batches (cursor {cursor})" - ), - SyncProgressEvent::Finished { timestamp } => { - format!("Agent Trace sync finished at {timestamp}.") - } - } -} - -impl TraceCommand { - #[allow(dead_code)] - pub fn execute(&self, context: &C) -> Result - where - C: ContextWithRepoRoot, - { - let mut stderr = std::io::sink(); - self.execute_with_stderr(context, &mut stderr) - } - - pub fn execute_with_stderr( - &self, - context: &C, - stderr: &mut W, - ) -> Result - where - C: ContextWithRepoRoot, - W: Write, - { - let clock = SystemSyncProgressClock; - self.execute_with_stderr_and_clock(context, stderr, &clock) - } - - fn execute_with_stderr_and_clock( - &self, - context: &C, - stderr: &mut W, - clock: &Clock, - ) -> Result - where - C: ContextWithRepoRoot, - W: Write, - Clock: SyncProgressClock, - { - match &self.request.subcommand { - TraceSubcommandRequest::DbList { format } => { - let databases = discover_agent_trace_dbs() - .map_err(|error| ClassifiedError::runtime(format!("{error:#}")))?; - render_list::render(&databases, *format) - .map_err(|error| ClassifiedError::runtime(format!("{error:#}"))) - } - TraceSubcommandRequest::DbShell { identifier } => { - let target = if let Some(identifier) = identifier { - let databases = discover_agent_trace_dbs() - .map_err(|error| ClassifiedError::runtime(format!("{error:#}")))?; - let database = resolve_agent_trace_db_identifier(&databases, identifier) - .map_err(|error| ClassifiedError::validation(error.user_message()))?; - ShellTarget { - alias: database.alias, - scope: database.kind.label().to_string(), - identifier: database.kind.identifier().to_string(), - path: database.path, - } - } else { - let repo_root = current_repo_root(context)?; - let report = - resolve_current_status(&repo_root).map_err(classify_status_error)?; - ShellTarget { - alias: "current".to_string(), - scope: "repository".to_string(), - identifier: report - .repository_id - .unwrap_or_else(|| "unknown".to_string()), - path: report.database_path, - } - }; - - let stdin = std::io::stdin(); - let stdout = std::io::stdout(); - run_agent_trace_db_shell(&target, stdin.lock(), stdout.lock()) - .map_err(|error| ClassifiedError::runtime(format!("{error:#}")))?; - Ok(String::new()) - } - TraceSubcommandRequest::Status { all: true, format } => { - let report = aggregate_current_status_all() - .map_err(|error| ClassifiedError::runtime(format!("{error:#}")))?; - render_status_all::render(&report, *format) - .map_err(|error| ClassifiedError::runtime(format!("{error:#}"))) - } - TraceSubcommandRequest::Status { all: false, format } => { - let repo_root = current_repo_root(context)?; - - let report = resolve_current_status(&repo_root).map_err(classify_status_error)?; - - render_status::render(&report, *format) - .map_err(|error| ClassifiedError::runtime(format!("{error:#}"))) - } - TraceSubcommandRequest::Sync { format } => { - let repo_root = current_repo_root(context)?; - - let report = match format { - crate::services::output_format::OutputFormat::Text => { - let mut progress = StderrSyncProgressReporter::new(stderr); - run_current_sync_with_progress_and_clock(&repo_root, &mut progress, clock) - } - crate::services::output_format::OutputFormat::Json => { - let mut progress = NoopSyncProgressSink; - run_current_sync_with_progress_and_clock(&repo_root, &mut progress, clock) - } - } - .map_err(classify_sync_error)?; - - render_sync::render(&report, *format) - .map_err(|error| ClassifiedError::runtime(format!("{error:#}"))) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn progress_reporter_writes_deterministic_text_lines_and_flushes_each_event() { - let mut output = Vec::new(); - let mut reporter = StderrSyncProgressReporter::new(&mut output); - - reporter.report(SyncProgressEvent::Started { - timestamp: "2026-01-02T03:04:05Z".to_string(), - }); - reporter.report(SyncProgressEvent::BatchAccepted { - stream: "messages", - batch_rows: 500, - uploaded: 500, - cursor: 500, - }); - reporter.report(SyncProgressEvent::StreamCompleted { - stream: "parts", - uploaded: 0, - cursor: 12, - batches: 0, - }); - reporter.report(SyncProgressEvent::Finished { - timestamp: "2026-01-02T03:04:06Z".to_string(), - }); - - assert_eq!( - String::from_utf8(output).expect("progress output should be UTF-8"), - "Starting Agent Trace sync at 2026-01-02T03:04:05Z...\nmessages: uploaded batch of 500 rows (500 total, cursor 500)\nparts: complete - no new rows uploaded (cursor 12)\nAgent Trace sync finished at 2026-01-02T03:04:06Z.\n" - ); - } -} diff --git a/cli/src/services/trace/discovery.rs b/cli/src/services/trace/discovery.rs deleted file mode 100644 index 637428c6e..000000000 --- a/cli/src/services/trace/discovery.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! Deterministic discovery of Agent Trace databases. -//! -//! Discovery scans repository-scoped databases at -//! `/sce/repos//agent-trace.db`. - -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::SystemTime; - -use anyhow::{Context, Result}; - -use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; -use crate::services::default_paths::resolve_state_data_root; - -const LIST_GUIDANCE: &str = "Run `sce trace db list` to see available Agent Trace databases."; - -/// Tables that must exist for an Agent Trace DB to be considered `ready`. -/// -/// Order is significant: the first missing table is reported as the skip -/// reason. -const REQUIRED_TABLES: &[&str] = &[ - "diff_traces", - "post_commit_patch_intersections", - "agent_traces", - "messages", - "parts", -]; - -/// Schema-readiness verdict for a discovered Agent Trace DB. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Readiness { - Ready, - Skipped { missing_table: String }, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum DiscoveredAgentTraceDbKind { - Repository { repository_id: String }, -} - -impl DiscoveredAgentTraceDbKind { - pub fn identifier(&self) -> &str { - match self { - Self::Repository { repository_id } => repository_id, - } - } - - pub fn label(&self) -> &'static str { - match self { - Self::Repository { .. } => "repository", - } - } -} - -/// A discovered Agent Trace database with its readiness verdict. -#[derive(Clone, Debug)] -pub struct DiscoveredAgentTraceDb { - pub alias: String, - pub kind: DiscoveredAgentTraceDbKind, - pub path: PathBuf, - pub mtime: SystemTime, - pub readiness: Readiness, -} - -/// User-actionable failures while resolving an Agent Trace DB identifier. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum ResolveAgentTraceDbError { - UnknownIdentifier { - identifier: String, - }, - AmbiguousIdentifier { - identifier: String, - }, - SkippedDatabase { - identifier: String, - alias: String, - scope: String, - database_id: String, - missing_table: String, - }, -} - -impl ResolveAgentTraceDbError { - pub fn user_message(&self) -> String { - match self { - Self::UnknownIdentifier { identifier } => format!( - "sce trace db shell: no agent-trace database matches '{identifier}'. {LIST_GUIDANCE}" - ), - Self::AmbiguousIdentifier { identifier } => format!( - "sce trace db shell: identifier '{identifier}' matches more than one agent-trace database. {LIST_GUIDANCE}" - ), - Self::SkippedDatabase { - identifier, - alias, - scope, - database_id, - missing_table, - } => format!( - "sce trace db shell: database '{identifier}' ({alias}, {scope} {database_id}) is not schema-ready: missing table '{missing_table}'. Run `sce setup` or inspect `sce trace db list` before opening a shell." - ), - } - } -} - -impl std::fmt::Display for ResolveAgentTraceDbError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.user_message()) - } -} - -impl std::error::Error for ResolveAgentTraceDbError {} - -/// Resolve an alias or checkout ID to one ready discovered Agent Trace DB. -pub fn resolve_agent_trace_db_identifier( - databases: &[DiscoveredAgentTraceDb], - identifier: &str, -) -> Result { - let matches: Vec<&DiscoveredAgentTraceDb> = databases - .iter() - .filter(|db| db.alias == identifier || db.kind.identifier() == identifier) - .collect(); - - let db = match matches.as_slice() { - [] => { - return Err(ResolveAgentTraceDbError::UnknownIdentifier { - identifier: identifier.to_string(), - }); - } - [db] => *db, - _ => { - return Err(ResolveAgentTraceDbError::AmbiguousIdentifier { - identifier: identifier.to_string(), - }); - } - }; - - match &db.readiness { - Readiness::Ready => Ok(db.clone()), - Readiness::Skipped { missing_table } => Err(ResolveAgentTraceDbError::SkippedDatabase { - identifier: identifier.to_string(), - alias: db.alias.clone(), - scope: db.kind.label().to_string(), - database_id: db.kind.identifier().to_string(), - missing_table: missing_table.clone(), - }), - } -} - -/// Discover repository-scoped Agent Trace DBs under the resolved state-data root. -pub fn discover_agent_trace_dbs() -> Result> { - let state_root = resolve_state_data_root().context("failed to resolve state data root")?; - let sce_dir = state_root.join("sce"); - discover_repository_agent_trace_dbs_in(&sce_dir) -} - -/// Discover repository-scoped Agent Trace DBs in an explicit `sce` directory. -pub fn discover_repository_agent_trace_dbs_in( - sce_dir: &Path, -) -> Result> { - let repos_dir = sce_dir.join("repos"); - if !repos_dir.is_dir() { - return Ok(Vec::new()); - } - - let mut entries: Vec<(String, PathBuf, SystemTime)> = Vec::new(); - for entry in fs::read_dir(&repos_dir) - .with_context(|| format!("failed to read repos directory '{}'", repos_dir.display()))? - { - let entry = entry.with_context(|| { - format!( - "failed to read directory entry in '{}'", - repos_dir.display() - ) - })?; - let repository_id = entry.file_name().to_string_lossy().into_owned(); - if repository_id.is_empty() || !entry.metadata()?.is_dir() { - continue; - } - let path = entry.path().join("agent-trace.db"); - if !path.is_file() { - continue; - } - let mtime = path - .metadata() - .with_context(|| format!("failed to read metadata for '{}'", path.display()))? - .modified() - .with_context(|| format!("failed to read mtime for '{}'", path.display()))?; - entries.push((repository_id, path, mtime)); - } - - entries.sort_by(|left, right| right.2.cmp(&left.2).then_with(|| left.0.cmp(&right.0))); - discovered_from_entries(entries, |repository_id| { - DiscoveredAgentTraceDbKind::Repository { repository_id } - }) -} - -fn discovered_from_entries( - entries: Vec<(String, PathBuf, SystemTime)>, - kind_for_id: impl Fn(String) -> DiscoveredAgentTraceDbKind, -) -> Result> { - let mut discovered = Vec::with_capacity(entries.len()); - for (index, (id, path, mtime)) in entries.into_iter().enumerate() { - let readiness = probe_readiness(&path)?; - discovered.push(DiscoveredAgentTraceDb { - alias: format!("agent_trace_{index}"), - kind: kind_for_id(id), - path, - mtime, - readiness, - }); - } - - Ok(discovered) -} - -/// Probe an Agent Trace DB file for required schema readiness. -/// -/// Opens the database without running migrations and queries `sqlite_master` -/// for each required table in declared order. Returns `Skipped` with the first -/// missing table reported; otherwise `Ready`. -pub(super) fn probe_readiness(path: &Path) -> Result { - let db = RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(path) - .with_context(|| format!("failed to open agent trace DB '{}'", path.display()))?; - - for table in REQUIRED_TABLES { - let rows = db - .query_map( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1", - (*table,), - |row| row.get::(0).map_err(Into::into), - ) - .with_context(|| format!("failed to probe table '{table}' in '{}'", path.display()))?; - - if rows.is_empty() { - return Ok(Readiness::Skipped { - missing_table: (*table).to_string(), - }); - } - } - - Ok(Readiness::Ready) -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::time::UNIX_EPOCH; - - use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; - - fn unique_temp_dir(label: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after Unix epoch") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "sce-trace-discovery-{label}-{}-{nonce}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir - } - - fn create_repository_schema_db(path: &Path, repository_id: &str) { - let db = RepositoryAgentTraceDb::new_at(path).expect("repository DB should open"); - db.verify_or_initialize_repository_metadata(repository_id) - .expect("repository metadata"); - drop(db); - } - - #[test] - fn repository_schema_db_reports_ready_by_default() { - let dir = unique_temp_dir("repo-ready"); - let repository_id = "repo123"; - let db_path = dir.join("repos").join(repository_id).join("agent-trace.db"); - create_repository_schema_db(&db_path, repository_id); - - let discovered = discover_repository_agent_trace_dbs_in(&dir) - .expect("repository discovery should succeed"); - - assert_eq!(discovered.len(), 1); - assert_eq!(discovered[0].kind.identifier(), repository_id); - assert_eq!(discovered[0].alias, "agent_trace_0"); - assert_eq!(discovered[0].readiness, Readiness::Ready); - assert_eq!(discovered[0].path, db_path); - } -} diff --git a/cli/src/services/trace/mod.rs b/cli/src/services/trace/mod.rs deleted file mode 100644 index babc9fcc0..000000000 --- a/cli/src/services/trace/mod.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Agent Trace database discovery, readiness probing, and stats services. - -pub mod command; -pub mod discovery; -pub mod render_list; -pub mod render_status; -pub mod render_status_all; -pub mod render_sync; -pub mod shell; -pub mod stats; -pub mod status; -pub mod status_all; -pub mod sync; - -pub const NAME: &str = "trace"; - -#[allow(unused_imports)] -pub use discovery::{ - discover_agent_trace_dbs, resolve_agent_trace_db_identifier, DiscoveredAgentTraceDb, Readiness, - ResolveAgentTraceDbError, -}; - -use crate::services::output_format::OutputFormat; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum TraceSubcommandRequest { - DbList { format: OutputFormat }, - DbShell { identifier: Option }, - Status { all: bool, format: OutputFormat }, - Sync { format: OutputFormat }, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TraceRequest { - pub subcommand: TraceSubcommandRequest, -} diff --git a/cli/src/services/trace/render_list.rs b/cli/src/services/trace/render_list.rs deleted file mode 100644 index 73405b19d..000000000 --- a/cli/src/services/trace/render_list.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Renderers for `sce trace db list` (text and JSON). - -use std::time::SystemTime; - -use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use serde_json::json; - -use crate::services::output_format::OutputFormat; -use crate::services::style; -use crate::services::trace::discovery::{DiscoveredAgentTraceDb, Readiness}; -use crate::services::trace::NAME; - -const HEADING: &str = "SCE trace db list"; -const EMPTY_MESSAGE: &str = "no agent-trace databases discovered"; - -const COL_ALIAS: &str = "Alias"; -const COL_SCOPE: &str = "Scope"; -const COL_ID: &str = "ID"; -const COL_STATUS: &str = "Status"; -const COL_UPDATED_AT: &str = "Updated at"; -const COL_PATH: &str = "Path"; - -pub fn render(databases: &[DiscoveredAgentTraceDb], format: OutputFormat) -> Result { - match format { - OutputFormat::Text => Ok(render_text(databases)), - OutputFormat::Json => render_json(databases), - } -} - -fn render_text(databases: &[DiscoveredAgentTraceDb]) -> String { - let mut lines = vec![style::heading(HEADING)]; - - if databases.is_empty() { - lines.push(EMPTY_MESSAGE.to_string()); - return lines.join("\n"); - } - - let rows: Vec<(String, String, String, String, String, String)> = databases - .iter() - .map(|db| { - ( - db.alias.clone(), - db.kind.label().to_string(), - db.kind.identifier().to_string(), - status_label(&db.readiness), - mtime_to_human_readable(db.mtime), - db.path.display().to_string(), - ) - }) - .collect(); - - let alias_width = column_width(COL_ALIAS, rows.iter().map(|(a, _, _, _, _, _)| a.as_str())); - let scope_width = column_width(COL_SCOPE, rows.iter().map(|(_, s, _, _, _, _)| s.as_str())); - let id_width = column_width(COL_ID, rows.iter().map(|(_, _, id, _, _, _)| id.as_str())); - let status_width = column_width(COL_STATUS, rows.iter().map(|(_, _, _, s, _, _)| s.as_str())); - let updated_at_width = column_width( - COL_UPDATED_AT, - rows.iter().map(|(_, _, _, _, u, _)| u.as_str()), - ); - - lines.push(format!( - "{COL_ALIAS: Result { - let entries: Vec = databases - .iter() - .map(|db| { - let (status, skip_reason) = match &db.readiness { - Readiness::Ready => ("ready", None), - Readiness::Skipped { missing_table } => { - ("skipped", Some(format!("missing table: {missing_table}"))) - } - }; - let mut entry = json!({ - "alias": db.alias, - "scope": db.kind.label(), - "identifier": db.kind.identifier(), - "path": db.path.display().to_string(), - "status": status, - "updated_at": mtime_to_rfc3339(db.mtime), - }); - if let Some(reason) = skip_reason { - entry - .as_object_mut() - .expect("json object") - .insert("skip_reason".to_string(), json!(reason)); - } - entry - }) - .collect(); - - let payload = json!({ - "status": "ok", - "command": NAME, - "subcommand": "db.list", - "databases": entries, - }); - - serde_json::to_string_pretty(&payload) - .context("failed to serialize trace db list report to JSON") -} - -fn status_label(readiness: &Readiness) -> String { - match readiness { - Readiness::Ready => "ready".to_string(), - Readiness::Skipped { missing_table } => { - format!("skipped: missing table '{missing_table}'") - } - } -} - -fn mtime_to_rfc3339(mtime: SystemTime) -> String { - let dt: DateTime = mtime.into(); - dt.to_rfc3339() -} - -fn mtime_to_human_readable(mtime: SystemTime) -> String { - let dt: DateTime = mtime.into(); - dt.format("%Y-%m-%d %H:%M:%S UTC").to_string() -} - -fn column_width<'a, I: Iterator>(header: &str, values: I) -> usize { - values.map(str::len).max().unwrap_or(0).max(header.len()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_discovery_renders_empty_message_text() { - let rendered = render_text(&[]); - assert!(rendered.contains(EMPTY_MESSAGE)); - } - - #[test] - fn empty_discovery_renders_empty_databases_json() { - let payload = render_json(&[]).expect("json render"); - let value: serde_json::Value = serde_json::from_str(&payload).expect("valid json"); - assert_eq!(value["status"], "ok"); - assert_eq!(value["command"], "trace"); - assert_eq!(value["subcommand"], "db.list"); - assert_eq!(value["databases"].as_array().unwrap().len(), 0); - } -} diff --git a/cli/src/services/trace/render_status.rs b/cli/src/services/trace/render_status.rs deleted file mode 100644 index d34c44411..000000000 --- a/cli/src/services/trace/render_status.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Renderers for `sce trace status` (text and JSON). - -use anyhow::{Context, Result}; -use serde_json::json; - -use crate::services::output_format::OutputFormat; -use crate::services::style; -use crate::services::trace::status::{DbStatus, StatusReport}; -use crate::services::trace::NAME; - -const HEADING: &str = "SCE trace status"; - -pub fn render(report: &StatusReport, format: OutputFormat) -> Result { - match format { - OutputFormat::Text => Ok(render_text(report)), - OutputFormat::Json => render_json(report), - } -} - -fn render_text(report: &StatusReport) -> String { - let mut lines = vec![style::heading(HEADING)]; - if let Some(repository_id) = &report.repository_id { - lines.push(format!("Repository ID: {repository_id}")); - } - if let Some(source) = &report.repository_identity_source { - lines.push(format!("Repository identity source: {source}")); - } - if let Some(canonical_identity) = &report.canonical_identity { - lines.push(format!("Canonical identity: {canonical_identity}")); - } - if let Some(remote) = &report.configured_remote { - lines.push(format!("Configured remote: {remote}")); - } - lines.push(format!("Checkout ID: {}", report.checkout_id)); - lines.push(format!( - "Repository-scoped database: {}", - report.database_path.display() - )); - - match &report.db_status { - DbStatus::Ready { - stats, - last_activity, - } => { - lines.push(String::from("Status: ready")); - lines.push(format!("Diff traces: {}", stats.diff_traces)); - lines.push(format!("Messages: {}", stats.messages)); - lines.push(format!("Parts: {}", stats.parts)); - lines.push(format!("Agent traces: {}", stats.agent_traces)); - lines.push(format!( - "Post-commit intersections: {}", - stats.post_commit_patch_intersections - )); - lines.push(format!( - "Last activity: {}", - last_activity.map_or_else(|| String::from("never"), |dt| dt.to_rfc3339()) - )); - } - DbStatus::Skipped { missing_table } => { - lines.push(format!("Status: skipped: missing table '{missing_table}'")); - } - } - - lines.join("\n") -} - -fn render_json(report: &StatusReport) -> Result { - let mut payload = json!({ - "status": "ok", - "command": NAME, - "subcommand": "status", - "repository_id": report.repository_id, - "repository_identity_source": report.repository_identity_source, - "canonical_identity": report.canonical_identity, - "configured_remote": report.configured_remote, - "checkout_id": report.checkout_id, - "database_scope": "repository", - "database_path": report.database_path.display().to_string(), - }); - - let object = payload.as_object_mut().expect("payload is object"); - match &report.db_status { - DbStatus::Ready { - stats, - last_activity, - } => { - object.insert("db_status".to_string(), json!("ready")); - object.insert( - "stats".to_string(), - json!({ - "diff_traces": stats.diff_traces, - "messages": stats.messages, - "parts": stats.parts, - "agent_traces": stats.agent_traces, - "post_commit_patch_intersections": stats.post_commit_patch_intersections, - }), - ); - object.insert( - "last_activity".to_string(), - last_activity.map_or(serde_json::Value::Null, |dt| json!(dt.to_rfc3339())), - ); - } - DbStatus::Skipped { missing_table } => { - object.insert("db_status".to_string(), json!("skipped")); - object.insert( - "skip_reason".to_string(), - json!(format!("missing table: {missing_table}")), - ); - } - } - - serde_json::to_string_pretty(&payload) - .context("failed to serialize trace status report to JSON") -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::path::PathBuf; - - use chrono::{DateTime, Utc}; - - use crate::services::trace::stats::AgentTraceDbStats; - - fn ready_report() -> StatusReport { - let last = - DateTime::::from_timestamp_millis(1_782_650_096_789).expect("timestamp parses"); - StatusReport { - repository_id: Some(String::from("repo123")), - repository_identity_source: Some(String::from("remote_url")), - canonical_identity: Some(String::from("github.com/acme/widgets")), - configured_remote: Some(String::from("origin")), - checkout_id: String::from("01900000-0000-7000-8000-000000000abc"), - database_path: PathBuf::from("/tmp/sce/repos/repo123/agent-trace.db"), - db_status: DbStatus::Ready { - stats: AgentTraceDbStats { - diff_traces: 7, - messages: 4, - parts: 11, - agent_traces: 3, - post_commit_patch_intersections: 1, - last_activity: Some(last), - }, - last_activity: Some(last), - }, - } - } - - fn skipped_report() -> StatusReport { - StatusReport { - repository_id: Some(String::from("repo456")), - repository_identity_source: Some(String::from("remote_url")), - canonical_identity: Some(String::from("github.com/acme/gadgets")), - configured_remote: Some(String::from("origin")), - checkout_id: String::from("01900000-0000-7000-8000-000000000def"), - database_path: PathBuf::from("/tmp/sce/repos/repo456/agent-trace.db"), - db_status: DbStatus::Skipped { - missing_table: String::from("agent_traces"), - }, - } - } - - #[test] - fn ready_text_renders_all_counts_and_last_activity() { - let rendered = render_text(&ready_report()); - assert!(rendered.contains("SCE trace status")); - assert!(rendered.contains("Repository ID: repo123")); - assert!(rendered.contains("Repository identity source: remote_url")); - assert!(rendered.contains("Canonical identity: github.com/acme/widgets")); - assert!(rendered.contains("Configured remote: origin")); - assert!(rendered.contains("Checkout ID: 01900000-0000-7000-8000-000000000abc")); - assert!( - rendered.contains("Repository-scoped database: /tmp/sce/repos/repo123/agent-trace.db") - ); - assert!(rendered.contains("Status: ready")); - assert!(rendered.contains("Diff traces: 7")); - assert!(rendered.contains("Messages: 4")); - assert!(rendered.contains("Parts: 11")); - assert!(rendered.contains("Agent traces: 3")); - assert!(rendered.contains("Post-commit intersections: 1")); - assert!(rendered.contains("Last activity: 2026-06-28T")); - } - - #[test] - fn ready_text_renders_never_when_last_activity_absent() { - let mut report = ready_report(); - if let DbStatus::Ready { - ref mut stats, - ref mut last_activity, - } = report.db_status - { - stats.last_activity = None; - *last_activity = None; - } - let rendered = render_text(&report); - assert!(rendered.contains("Last activity: never")); - } - - #[test] - fn skipped_text_renders_skip_reason() { - let rendered = render_text(&skipped_report()); - assert!(rendered.contains("Status: skipped: missing table 'agent_traces'")); - assert!(!rendered.contains("Diff traces:")); - } - - #[test] - fn ready_json_shape_matches_contract() { - let payload = render_json(&ready_report()).expect("json render"); - let value: serde_json::Value = serde_json::from_str(&payload).expect("valid json"); - assert_eq!(value["status"], "ok"); - assert_eq!(value["command"], "trace"); - assert_eq!(value["subcommand"], "status"); - assert_eq!(value["db_status"], "ready"); - assert_eq!(value["database_scope"], "repository"); - assert_eq!(value["repository_identity_source"], "remote_url"); - assert_eq!(value["canonical_identity"], "github.com/acme/widgets"); - assert_eq!(value["configured_remote"], "origin"); - assert!(value.get("skip_reason").is_none()); - assert_eq!(value["stats"]["diff_traces"], 7); - assert_eq!(value["stats"]["messages"], 4); - assert_eq!(value["stats"]["parts"], 11); - assert_eq!(value["stats"]["agent_traces"], 3); - assert_eq!(value["stats"]["post_commit_patch_intersections"], 1); - assert!(value["last_activity"].is_string()); - } - - #[test] - fn skipped_json_shape_matches_contract() { - let payload = render_json(&skipped_report()).expect("json render"); - let value: serde_json::Value = serde_json::from_str(&payload).expect("valid json"); - assert_eq!(value["db_status"], "skipped"); - assert_eq!(value["database_scope"], "repository"); - assert_eq!(value["skip_reason"], "missing table: agent_traces"); - assert!(value.get("stats").is_none()); - assert!(value.get("last_activity").is_none()); - } -} diff --git a/cli/src/services/trace/render_status_all.rs b/cli/src/services/trace/render_status_all.rs deleted file mode 100644 index d4b0317e4..000000000 --- a/cli/src/services/trace/render_status_all.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! Renderers for `sce trace status --all` (text and JSON). - -use anyhow::{Context, Result}; -use serde_json::json; - -use crate::services::output_format::OutputFormat; -use crate::services::style; -use crate::services::trace::status_all::{DatabaseRow, DatabaseRowStatus, StatusAllReport}; -use crate::services::trace::NAME; - -const HEADING: &str = "SCE trace status (all)"; -const TOTALS_HEADING: &str = "Totals"; -const BY_DATABASE_HEADING: &str = "By database"; - -const COL_ALIAS: &str = "Alias"; -const COL_SCOPE: &str = "Scope"; -const COL_ID: &str = "ID"; -const COL_STATUS: &str = "Status"; -const COL_DIFFS: &str = "Diffs"; -const COL_MESSAGES: &str = "Messages"; -const COL_PARTS: &str = "Parts"; -const COL_TRACES: &str = "Traces"; -const COL_INTERSECTIONS: &str = "Intersections"; -const SKIPPED_PLACEHOLDER: &str = "-"; - -pub fn render(report: &StatusAllReport, format: OutputFormat) -> Result { - match format { - OutputFormat::Text => Ok(render_text(report)), - OutputFormat::Json => render_json(report), - } -} - -fn render_text(report: &StatusAllReport) -> String { - let mut lines = vec![style::heading(HEADING)]; - lines.push(format!( - "Databases: {} discovered, {} ready, {} skipped", - report.discovery.discovered, report.discovery.ready, report.discovery.skipped - )); - - lines.push(String::new()); - lines.push(style::heading(TOTALS_HEADING)); - lines.push(format!("Diff traces: {}", report.totals.diff_traces)); - lines.push(format!("Messages: {}", report.totals.messages)); - lines.push(format!("Parts: {}", report.totals.parts)); - lines.push(format!("Agent traces: {}", report.totals.agent_traces)); - lines.push(format!( - "Post-commit intersections: {}", - report.totals.post_commit_patch_intersections - )); - lines.push(format!( - "Last activity: {}", - report - .totals - .last_activity - .map_or_else(|| String::from("never"), |dt| dt.to_rfc3339()) - )); - - if !report.databases.is_empty() { - lines.push(String::new()); - lines.push(style::heading(BY_DATABASE_HEADING)); - - let headers = [ - COL_ALIAS, - COL_SCOPE, - COL_ID, - COL_STATUS, - COL_DIFFS, - COL_MESSAGES, - COL_PARTS, - COL_TRACES, - COL_INTERSECTIONS, - ]; - let rows: Vec<[String; 9]> = report.databases.iter().map(format_row).collect(); - - let widths: Vec = (0..headers.len()) - .map(|col| { - rows.iter() - .map(|row| row[col].len()) - .max() - .unwrap_or(0) - .max(headers[col].len()) - }) - .collect(); - - lines.push(join_row(&headers.map(str::to_string), &widths)); - for row in &rows { - lines.push(join_row(row, &widths)); - } - } - - lines.join("\n") -} - -fn join_row(cells: &[String; N], widths: &[usize]) -> String { - cells - .iter() - .enumerate() - .map(|(i, cell)| format!("{cell:>() - .join(" ") - .trim_end() - .to_string() -} - -fn format_row(row: &DatabaseRow) -> [String; 9] { - let scope = row.kind.label().to_string(); - let id = row.kind.identifier().to_string(); - - match &row.status { - DatabaseRowStatus::Ready { stats } => [ - row.alias.clone(), - scope, - id, - "ready".to_string(), - stats.diff_traces.to_string(), - stats.messages.to_string(), - stats.parts.to_string(), - stats.agent_traces.to_string(), - stats.post_commit_patch_intersections.to_string(), - ], - DatabaseRowStatus::Skipped { missing_table } => [ - row.alias.clone(), - scope, - id, - format!("skipped: missing '{missing_table}'"), - SKIPPED_PLACEHOLDER.to_string(), - SKIPPED_PLACEHOLDER.to_string(), - SKIPPED_PLACEHOLDER.to_string(), - SKIPPED_PLACEHOLDER.to_string(), - SKIPPED_PLACEHOLDER.to_string(), - ], - } -} - -fn render_json(report: &StatusAllReport) -> Result { - let databases: Vec = report - .databases - .iter() - .map(|row| match &row.status { - DatabaseRowStatus::Ready { stats } => json!({ - "alias": row.alias, - "scope": row.kind.label(), - "identifier": row.kind.identifier(), - "path": row.path.display().to_string(), - "status": "ready", - "diff_traces": stats.diff_traces, - "messages": stats.messages, - "parts": stats.parts, - "agent_traces": stats.agent_traces, - "post_commit_patch_intersections": stats.post_commit_patch_intersections, - "last_activity": stats - .last_activity - .map_or(serde_json::Value::Null, |dt| json!(dt.to_rfc3339())), - }), - DatabaseRowStatus::Skipped { missing_table } => json!({ - "alias": row.alias, - "scope": row.kind.label(), - "identifier": row.kind.identifier(), - "path": row.path.display().to_string(), - "status": "skipped", - "skip_reason": format!("missing table: {missing_table}"), - }), - }) - .collect(); - - let payload = json!({ - "status": "ok", - "command": NAME, - "subcommand": "status.all", - "discovery": { - "discovered": report.discovery.discovered, - "ready": report.discovery.ready, - "skipped": report.discovery.skipped, - }, - "totals": { - "diff_traces": report.totals.diff_traces, - "messages": report.totals.messages, - "parts": report.totals.parts, - "agent_traces": report.totals.agent_traces, - "post_commit_patch_intersections": report.totals.post_commit_patch_intersections, - "last_activity": report - .totals - .last_activity - .map_or(serde_json::Value::Null, |dt| json!(dt.to_rfc3339())), - }, - "databases": databases, - }); - - serde_json::to_string_pretty(&payload) - .context("failed to serialize trace status.all report to JSON") -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; - - use crate::services::trace::status_all::aggregate_status_all_in; - - fn unique_temp_dir(label: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after Unix epoch") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "sce-trace-render-status-all-{label}-{}-{nonce}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir - } - - #[test] - fn empty_renders_text_with_zeroed_summary_and_totals() { - let dir = unique_temp_dir("empty-text"); - let report = aggregate_status_all_in(&dir).expect("aggregate"); - let rendered = render_text(&report); - assert!(rendered.contains("Databases: 0 discovered, 0 ready, 0 skipped")); - assert!(rendered.contains("Diff traces: 0")); - assert!(rendered.contains("Last activity: never")); - assert!(!rendered.contains(BY_DATABASE_HEADING)); - } - - #[test] - fn empty_renders_json_with_zeroed_shape() { - let dir = unique_temp_dir("empty-json"); - let report = aggregate_status_all_in(&dir).expect("aggregate"); - let payload = render_json(&report).expect("json render"); - let value: serde_json::Value = serde_json::from_str(&payload).expect("valid json"); - assert_eq!(value["status"], "ok"); - assert_eq!(value["command"], "trace"); - assert_eq!(value["subcommand"], "status.all"); - assert_eq!(value["discovery"]["discovered"], 0); - assert_eq!(value["discovery"]["ready"], 0); - assert_eq!(value["discovery"]["skipped"], 0); - assert_eq!(value["totals"]["diff_traces"], 0); - assert!(value["totals"]["last_activity"].is_null()); - assert_eq!(value["databases"].as_array().unwrap().len(), 0); - } -} diff --git a/cli/src/services/trace/shell.rs b/cli/src/services/trace/shell.rs deleted file mode 100644 index b27299de5..000000000 --- a/cli/src/services/trace/shell.rs +++ /dev/null @@ -1,336 +0,0 @@ -//! Embedded Agent Trace DB SQL shell core. - -#![allow(dead_code)] - -use std::io::{BufRead, Write}; -use std::path::PathBuf; - -use anyhow::{Context, Result}; -use turso::Value as TursoValue; - -use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; -use crate::services::db::QueryRows; - -const HELP_TEXT: &str = "Commands:\n .help Show this help\n .tables List tables\n .exit Exit the shell\n .quit Exit the shell\nSQL statements execute against the resolved Agent Trace DB.\n"; -const TABLES_SQL: &str = "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ShellTarget { - pub alias: String, - pub scope: String, - pub identifier: String, - pub path: PathBuf, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ShellExit { - EndOfInput, - DotCommand, -} - -pub fn run_agent_trace_db_shell( - target: &ShellTarget, - input: impl BufRead, - mut output: impl Write, -) -> Result { - let db = RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(&target.path) - .with_context(|| format!("failed to open Agent Trace DB '{}'", target.path.display()))?; - db.ensure_schema_ready_for_hooks().with_context(|| { - format!( - "Agent Trace DB '{}' is not schema-ready", - target.path.display() - ) - })?; - - run_agent_trace_db_shell_with_db(&db, target, input, &mut output) -} - -pub fn run_agent_trace_db_shell_with_db( - db: &RepositoryAgentTraceDb, - target: &ShellTarget, - input: impl BufRead, - mut output: impl Write, -) -> Result { - writeln!(output, "Agent Trace DB shell")?; - writeln!(output, "alias: {}", target.alias)?; - writeln!(output, "scope: {}", target.scope)?; - writeln!(output, "identifier: {}", target.identifier)?; - writeln!(output, "path: {}", target.path.display())?; - writeln!(output, "Type .help for commands; .exit or .quit to exit.")?; - - for line in input.lines() { - let line = line.context("failed to read Agent Trace DB shell input")?; - let trimmed = line.trim(); - - if trimmed.is_empty() { - continue; - } - - if trimmed.starts_with('.') { - match trimmed { - ".help" => { - write!(output, "{HELP_TEXT}")?; - continue; - } - ".tables" => { - render_tables(db, &mut output)?; - continue; - } - ".exit" | ".quit" => return Ok(ShellExit::DotCommand), - _ => { - writeln!( - output, - "Unknown command: {trimmed}. Run .help for supported commands." - )?; - continue; - } - } - } - - for statement in split_sql_line(trimmed) { - if statement.is_empty() { - continue; - } - render_sql_result(db, statement, &mut output)?; - } - } - - Ok(ShellExit::EndOfInput) -} - -fn split_sql_line(line: &str) -> impl Iterator { - line.split(';').map(str::trim) -} - -fn render_sql_result( - db: &RepositoryAgentTraceDb, - sql: &str, - output: &mut impl Write, -) -> Result<()> { - match execute_sql(db, sql) { - Ok(ShellSqlResult::Query(rows)) => render_query_rows(&rows, output), - Ok(ShellSqlResult::Statement { rows_affected }) => { - writeln!(output, "OK ({rows_affected} rows affected)").map_err(Into::into) - } - Err(error) => writeln!(output, "SQL error: {error}").map_err(Into::into), - } -} - -fn render_tables(db: &RepositoryAgentTraceDb, output: &mut impl Write) -> Result<()> { - match db.query_values(TABLES_SQL, ()) { - Ok(rows) => { - for row in rows.rows { - if let Some(TursoValue::Text(name)) = row.first() { - writeln!(output, "{name}")?; - } - } - Ok(()) - } - Err(error) => { - writeln!(output, "SQL error: failed to list tables: {error}").map_err(Into::into) - } - } -} - -#[derive(Clone, Debug, PartialEq)] -enum ShellSqlResult { - Query(QueryRows), - Statement { rows_affected: u64 }, -} - -fn execute_sql(db: &RepositoryAgentTraceDb, sql: &str) -> Result { - if is_query_sql(sql) { - db.query_values(sql, ()) - .map(ShellSqlResult::Query) - .with_context(|| format!("failed to query SQL: {sql}")) - } else { - db.execute(sql, ()) - .map(|rows_affected| ShellSqlResult::Statement { rows_affected }) - .with_context(|| format!("failed to execute SQL: {sql}")) - } -} - -fn is_query_sql(sql: &str) -> bool { - let first_token = sql - .trim_start() - .split(|character: char| character.is_whitespace() || character == '(') - .next() - .unwrap_or_default(); - matches!( - first_token.to_ascii_uppercase().as_str(), - "SELECT" | "WITH" | "PRAGMA" | "EXPLAIN" - ) -} - -fn render_query_rows(rows: &QueryRows, output: &mut impl Write) -> Result<()> { - if rows.columns.is_empty() { - writeln!(output, "OK (0 rows affected)")?; - return Ok(()); - } - - writeln!(output, "{}", rows.columns.join(" | "))?; - writeln!( - output, - "{}", - rows.columns - .iter() - .map(|_| "---") - .collect::>() - .join(" | ") - )?; - - for row in &rows.rows { - let rendered = row.iter().map(render_value).collect::>().join(" | "); - writeln!(output, "{rendered}")?; - } - - writeln!(output, "({} rows)", rows.rows.len())?; - Ok(()) -} - -fn render_value(value: &TursoValue) -> String { - match value { - TursoValue::Null => String::from("NULL"), - TursoValue::Integer(value) => value.to_string(), - TursoValue::Real(value) => value.to_string(), - TursoValue::Text(value) => value.clone(), - TursoValue::Blob(value) => format!("x'{}'", encode_lower_hex(value)), - } -} - -fn encode_lower_hex(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::with_capacity(bytes.len() * 2); - for byte in bytes { - output.push(HEX[(byte >> 4) as usize] as char); - output.push(HEX[(byte & 0x0f) as usize] as char); - } - output -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::path::Path; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn unique_temp_db(label: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after Unix epoch") - .as_nanos(); - std::env::temp_dir().join(format!( - "sce-trace-shell-{label}-{}-{nonce}.db", - std::process::id() - )) - } - - fn shell_target(path: &Path) -> ShellTarget { - ShellTarget { - alias: String::from("agent_trace_0"), - scope: String::from("repository"), - identifier: String::from("018f2d7d-0000-7000-8000-000000000000"), - path: path.to_path_buf(), - } - } - - fn run_shell(input: &str) -> String { - let path = unique_temp_db("core"); - let db = RepositoryAgentTraceDb::new_at(&path).expect("test DB should open"); - let mut output = Vec::new(); - run_agent_trace_db_shell_with_db(&db, &shell_target(&path), input.as_bytes(), &mut output) - .expect("shell should run"); - String::from_utf8(output).expect("shell output should be UTF-8") - } - - fn output_after_startup(output: &str) -> &str { - output - .split_once("Type .help for commands; .exit or .quit to exit.\n") - .expect("shell startup prompt should be present") - .1 - } - - #[test] - fn shell_runs_query_and_exit_from_piped_input() { - let output = run_shell("SELECT COUNT(*) AS diff_trace_count FROM diff_traces;\n.exit\n"); - - assert!(output.contains("Agent Trace DB shell\n")); - assert!(output.contains("alias: agent_trace_0\n")); - assert!(output.contains("diff_trace_count\n---\n0\n(1 rows)\n")); - } - - #[test] - fn shell_renders_help_and_quit() { - let output = run_shell(".help\n.quit\n"); - - assert!(output.contains("Commands:\n .help Show this help\n .tables List tables\n .exit Exit the shell\n .quit Exit the shell\n")); - } - - #[test] - fn shell_tables_lists_table_names_in_deterministic_order() { - let output = - run_shell("CREATE TABLE IF NOT EXISTS z_shell_smoke (id INTEGER);\n.tables\n.exit\n"); - let table_lines = output_after_startup(&output) - .lines() - .skip_while(|line| !line.starts_with("OK (")) - .skip(1) - .collect::>(); - - assert!(table_lines.contains(&"__sce_migrations")); - assert!(table_lines.contains(&"diff_traces")); - assert!(table_lines.contains(&"z_shell_smoke")); - - let migrations_index = table_lines - .iter() - .position(|line| *line == "__sce_migrations") - .expect("migration table should be listed"); - let diff_traces_index = table_lines - .iter() - .position(|line| *line == "diff_traces") - .expect("diff traces table should be listed"); - let smoke_index = table_lines - .iter() - .position(|line| *line == "z_shell_smoke") - .expect("test table should be listed"); - - assert!(migrations_index < diff_traces_index); - assert!(diff_traces_index < smoke_index); - assert!(!table_lines.iter().any(|line| line.contains(" | "))); - assert!(!table_lines.iter().any(|line| line.starts_with('('))); - } - - #[test] - fn shell_renders_malformed_sql_diagnostic_and_continues() { - let output = run_shell("SELECT FROM;\nSELECT 1 AS ok;\n.exit\n"); - - assert!(output.contains("SQL error: failed to query SQL: SELECT FROM")); - assert!(output.contains("ok\n---\n1\n(1 rows)\n")); - } - - #[test] - fn shell_renders_non_query_statement_success() { - let output = run_shell("CREATE TABLE IF NOT EXISTS shell_smoke (id INTEGER);\nINSERT INTO shell_smoke (id) VALUES (7);\nSELECT id FROM shell_smoke;\n.exit\n"); - - assert!(output.contains("OK (0 rows affected)\n")); - assert!(output.contains("OK (1 rows affected)\n")); - assert!(output.contains("id\n---\n7\n(1 rows)\n")); - } - - #[test] - fn shell_opens_path_and_checks_schema_readiness() { - let path = unique_temp_db("open-path"); - let db = RepositoryAgentTraceDb::new_at(&path).expect("test DB should open"); - drop(db); - - let mut output = Vec::new(); - let exit = - run_agent_trace_db_shell(&shell_target(&path), ".exit\n".as_bytes(), &mut output) - .expect("shell should open ready DB"); - - assert_eq!(exit, ShellExit::DotCommand); - assert!(String::from_utf8(output) - .expect("shell output should be UTF-8") - .contains("path: ")); - } -} diff --git a/cli/src/services/trace/stats.rs b/cli/src/services/trace/stats.rs deleted file mode 100644 index 2a9750349..000000000 --- a/cli/src/services/trace/stats.rs +++ /dev/null @@ -1,284 +0,0 @@ -//! Agent Trace DB row-count and last-activity stats. -//! -//! Issues read-only `COUNT(*)` and `MAX(...)` queries against a single -//! Agent Trace DB and returns the aggregated counts plus the most recent -//! activity timestamp derived from `diff_traces.time_ms`, -//! `messages.updated_at`, and `agent_traces.created_at`. - -use std::path::Path; - -use anyhow::{Context, Result}; -use chrono::{DateTime, TimeZone, Utc}; - -use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; - -/// Aggregated Agent Trace DB row counts and last activity. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -#[allow(dead_code)] -pub struct AgentTraceDbStats { - pub diff_traces: u64, - pub messages: u64, - pub parts: u64, - pub agent_traces: u64, - pub post_commit_patch_intersections: u64, - pub last_activity: Option>, -} - -/// Collect row counts and last activity for a single Agent Trace DB. -/// -/// Opens the DB read-only (without running migrations) and issues one -/// `SELECT COUNT(*)` per required table plus three `MAX(...)` queries to -/// compute the last activity timestamp. The caller is expected to have -/// already verified schema readiness via `discover_agent_trace_dbs`. -#[allow(dead_code)] -pub fn collect_agent_trace_db_stats(path: &Path) -> Result { - let db = RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(path) - .with_context(|| format!("failed to open agent trace DB '{}'", path.display()))?; - - let diff_traces = count_rows(&db, "diff_traces", path)?; - let messages = count_rows(&db, "messages", path)?; - let parts = count_rows(&db, "parts", path)?; - let agent_traces = count_rows(&db, "agent_traces", path)?; - let post_commit_patch_intersections = count_rows(&db, "post_commit_patch_intersections", path)?; - - let diff_max_ms = query_optional_i64(&db, "SELECT MAX(time_ms) FROM diff_traces", path) - .context("failed to query MAX(diff_traces.time_ms)")?; - let messages_max_iso = query_optional_string(&db, "SELECT MAX(updated_at) FROM messages", path) - .context("failed to query MAX(messages.updated_at)")?; - let agent_traces_max_iso = - query_optional_string(&db, "SELECT MAX(created_at) FROM agent_traces", path) - .context("failed to query MAX(agent_traces.created_at)")?; - - let mut last_activity: Option> = None; - if let Some(ms) = diff_max_ms { - if let Some(dt) = DateTime::::from_timestamp_millis(ms) { - last_activity = Some(last_activity.map_or(dt, |prev| prev.max(dt))); - } - } - for iso in [messages_max_iso, agent_traces_max_iso] - .into_iter() - .flatten() - { - if let Some(dt) = parse_iso_millis(&iso) { - last_activity = Some(last_activity.map_or(dt, |prev| prev.max(dt))); - } - } - - Ok(AgentTraceDbStats { - diff_traces, - messages, - parts, - agent_traces, - post_commit_patch_intersections, - last_activity, - }) -} - -fn count_rows(db: &RepositoryAgentTraceDb, table: &str, path: &Path) -> Result { - let sql = format!("SELECT COUNT(*) FROM {table}"); - let rows = db - .query_map(sql.as_str(), (), |row| { - row.get::(0).map_err(Into::into) - }) - .with_context(|| { - format!( - "failed to count rows in '{table}' for agent trace DB '{}'", - path.display() - ) - })?; - let count = rows.into_iter().next().unwrap_or(0); - Ok(u64::try_from(count).unwrap_or(0)) -} - -fn query_optional_i64(db: &RepositoryAgentTraceDb, sql: &str, path: &Path) -> Result> { - let rows = db - .query_map(sql, (), |row| row.get::>(0).map_err(Into::into)) - .with_context(|| { - format!( - "failed to query '{sql}' on agent trace DB '{}'", - path.display() - ) - })?; - Ok(rows.into_iter().next().flatten()) -} - -fn query_optional_string( - db: &RepositoryAgentTraceDb, - sql: &str, - path: &Path, -) -> Result> { - let rows = db - .query_map(sql, (), |row| { - row.get::>(0).map_err(Into::into) - }) - .with_context(|| { - format!( - "failed to query '{sql}' on agent trace DB '{}'", - path.display() - ) - })?; - Ok(rows.into_iter().next().flatten()) -} - -/// Parse the `SQLite` `strftime('%Y-%m-%dT%H:%M:%fZ', ...)` format into UTC. -fn parse_iso_millis(text: &str) -> Option> { - if let Ok(dt) = DateTime::parse_from_rfc3339(text) { - return Some(dt.with_timezone(&Utc)); - } - // SQLite emits `YYYY-MM-DDTHH:MM:SS.sssZ`; fall back to a naive parse if - // the upstream format ever drops the timezone designator. - let naive = chrono::NaiveDateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S%.fZ").ok()?; - Some(Utc.from_utc_datetime(&naive)) -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; - - use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; - use crate::services::agent_trace_db::{ - AgentTraceInsert, DiffTraceInsert, InsertMessageInsert, InsertPartInsert, MessageRole, - PartType, PostCommitPatchIntersectionInsert, - }; - - fn unique_temp_dir(label: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after Unix epoch") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "sce-trace-stats-{label}-{}-{nonce}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir - } - - fn seed_db(path: &Path) -> i64 { - let db = RepositoryAgentTraceDb::new_at(path).expect("repository DB should open"); - - // 2 diff traces - db.insert_diff_trace(DiffTraceInsert { - time_ms: 1_000, - session_id: "s1", - patch: "diff1", - model_id: Some("m1"), - tool_name: "claude", - tool_version: Some("1"), - payload_type: "patch", - }) - .expect("diff trace 1"); - let latest_diff_ms = 2_500; - db.insert_diff_trace(DiffTraceInsert { - time_ms: latest_diff_ms, - session_id: "s1", - patch: "diff2", - model_id: Some("m1"), - tool_name: "claude", - tool_version: Some("1"), - payload_type: "patch", - }) - .expect("diff trace 2"); - - // 1 post_commit_patch_intersection - db.insert_post_commit_patch_intersection(PostCommitPatchIntersectionInsert { - commit_id: "c1", - post_commit_time_ms: 3_000, - recent_window_cutoff_ms: 0, - recent_window_end_ms: 3_000, - loaded_diff_trace_count: 2, - skipped_diff_trace_count: 0, - intersection_patch: "patch", - }) - .expect("intersection"); - - // 1 agent trace - db.insert_agent_trace(AgentTraceInsert { - commit_id: "c1", - commit_time_ms: 3_000, - trace_json: "{}", - agent_trace_id: "at1", - url: "https://example.test/at1", - remote_url: "https://example.test/repo", - }) - .expect("agent trace"); - - // 2 messages, 3 parts - db.insert_message(InsertMessageInsert { - session_id: "s1".into(), - message_id: "m1".into(), - role: MessageRole::User, - generated_at_unix_ms: 1_000, - }) - .expect("message 1"); - db.insert_message(InsertMessageInsert { - session_id: "s1".into(), - message_id: "m2".into(), - role: MessageRole::Assistant, - generated_at_unix_ms: 1_100, - }) - .expect("message 2"); - for (i, part_id) in ["p1", "p2", "p3"].iter().enumerate() { - db.insert_part(InsertPartInsert { - part_type: PartType::Text, - text: format!("part {part_id}"), - session_id: "s1".into(), - message_id: if i < 2 { "m1".into() } else { "m2".into() }, - generated_at_unix_ms: 1_000 + i64::try_from(i).expect("part index fits in i64"), - }) - .expect("part"); - } - - latest_diff_ms - } - - #[test] - fn collect_stats_returns_counts_and_last_activity() { - let dir = unique_temp_dir("counts"); - let db_path = dir.join("agent-trace-aaaa.db"); - let latest_diff_ms = seed_db(&db_path); - - let stats = - collect_agent_trace_db_stats(&db_path).expect("stats collection should succeed"); - - assert_eq!(stats.diff_traces, 2); - assert_eq!(stats.messages, 2); - assert_eq!(stats.parts, 3); - assert_eq!(stats.agent_traces, 1); - assert_eq!(stats.post_commit_patch_intersections, 1); - - let last = stats.last_activity.expect("last activity should be set"); - let diff_dt = DateTime::::from_timestamp_millis(latest_diff_ms) - .expect("latest diff time should convert"); - assert!( - last >= diff_dt, - "last_activity {last} should be >= latest diff trace {diff_dt}" - ); - } - - #[test] - fn collect_stats_on_empty_db_returns_zero_counts_and_no_activity() { - let dir = unique_temp_dir("empty"); - let db_path = dir.join("agent-trace-bbbb.db"); - drop(RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open")); - - let stats = - collect_agent_trace_db_stats(&db_path).expect("stats collection should succeed"); - - assert_eq!(stats.diff_traces, 0); - assert_eq!(stats.messages, 0); - assert_eq!(stats.parts, 0); - assert_eq!(stats.agent_traces, 0); - assert_eq!(stats.post_commit_patch_intersections, 0); - assert!(stats.last_activity.is_none()); - } - - #[test] - fn parse_iso_millis_handles_sqlite_strftime_output() { - let parsed = parse_iso_millis("2026-06-28T12:34:56.789Z").expect("parse strftime output"); - assert_eq!(parsed.timestamp_millis(), 1_782_650_096_789); - } -} diff --git a/cli/src/services/trace/status.rs b/cli/src/services/trace/status.rs deleted file mode 100644 index e8c7e6e42..000000000 --- a/cli/src/services/trace/status.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Repository-scoped `sce trace status` resolution. -//! -//! Resolves the current repository's active Agent Trace storage, probes schema -//! readiness, and (when ready) collects row counts plus the last-activity -//! timestamp. - -use std::path::{Path, PathBuf}; - -use anyhow::Result; -use chrono::{DateTime, Utc}; - -#[cfg(test)] -use crate::services::agent_trace_storage::resolve_agent_trace_storage_at_state_root; -use crate::services::agent_trace_storage::{resolve_agent_trace_storage, AgentTraceStorageContext}; -use crate::services::config; -use crate::services::repository_identity::resolve::RepositoryIdentitySource; -use crate::services::trace::discovery::{probe_readiness, Readiness}; -use crate::services::trace::stats::{collect_agent_trace_db_stats, AgentTraceDbStats}; - -/// Verdict for a resolved checkout's Agent Trace DB. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum DbStatus { - Ready { - stats: AgentTraceDbStats, - last_activity: Option>, - }, - Skipped { - missing_table: String, - }, -} - -/// Resolved status report ready for rendering. -#[derive(Clone, Debug)] -pub struct StatusReport { - pub repository_id: Option, - pub repository_identity_source: Option, - pub canonical_identity: Option, - pub configured_remote: Option, - pub checkout_id: String, - pub database_path: PathBuf, - pub db_status: DbStatus, -} - -/// Resolve `sce trace status` for the current working repository using the -/// default state-data root. -pub fn resolve_current_status(repo_root: &Path) -> Result { - let storage_config = config::resolve_agent_trace_storage_runtime_config(repo_root) - .map_err(StatusErrorOrRuntime::Runtime)?; - let context = AgentTraceStorageContext { - repository_root: repo_root, - explicit_repository_id: storage_config.repository_id.as_deref(), - repository_remote: &storage_config.repository_remote, - }; - let storage = resolve_agent_trace_storage(&context).map_err(StatusErrorOrRuntime::Runtime)?; - let repository_identity_source = identity_source_label(&storage.repository_identity.source); - let configured_remote = configured_remote_name(&storage.repository_identity.source); - status_report_from_path( - Some(storage.repository_identity.identity.repository_id), - Some(repository_identity_source), - Some(storage.repository_identity.identity.canonical_identity), - configured_remote, - storage.checkout_id, - storage.db_path, - ) -} - -/// Testable repository-scoped variant taking the state root explicitly. -#[cfg(test)] -#[allow(dead_code)] -pub fn resolve_current_status_at_state_root( - repo_root: &Path, - state_root: &Path, - explicit_repository_id: Option<&str>, - repository_remote: &str, -) -> Result { - let context = AgentTraceStorageContext { - repository_root: repo_root, - explicit_repository_id, - repository_remote, - }; - let storage = resolve_agent_trace_storage_at_state_root(&context, state_root) - .map_err(StatusErrorOrRuntime::Runtime)?; - let repository_identity_source = identity_source_label(&storage.repository_identity.source); - let configured_remote = configured_remote_name(&storage.repository_identity.source); - status_report_from_path( - Some(storage.repository_identity.identity.repository_id), - Some(repository_identity_source), - Some(storage.repository_identity.identity.canonical_identity), - configured_remote, - storage.checkout_id, - storage.db_path, - ) -} - -fn status_report_from_path( - repository_id: Option, - repository_identity_source: Option, - canonical_identity: Option, - configured_remote: Option, - checkout_id: String, - database_path: PathBuf, -) -> Result { - let readiness = probe_readiness(&database_path).map_err(StatusErrorOrRuntime::Runtime)?; - let db_status = match readiness { - Readiness::Ready => { - let stats = collect_agent_trace_db_stats(&database_path) - .map_err(StatusErrorOrRuntime::Runtime)?; - let last_activity = stats.last_activity; - DbStatus::Ready { - stats, - last_activity, - } - } - Readiness::Skipped { missing_table } => DbStatus::Skipped { missing_table }, - }; - - Ok(StatusReport { - repository_id, - repository_identity_source, - canonical_identity, - configured_remote, - checkout_id, - database_path, - db_status, - }) -} - -fn identity_source_label(source: &RepositoryIdentitySource) -> String { - match source { - RepositoryIdentitySource::ExplicitConfig => String::from("explicit_config"), - RepositoryIdentitySource::RemoteUrl { .. } => String::from("remote_url"), - } -} - -fn configured_remote_name(source: &RepositoryIdentitySource) -> Option { - match source { - RepositoryIdentitySource::ExplicitConfig => None, - RepositoryIdentitySource::RemoteUrl { remote_name } => Some(remote_name.clone()), - } -} - -/// Wraps internal runtime failures surfaced by `sce trace status` resolution. -#[derive(Debug)] -pub enum StatusErrorOrRuntime { - Runtime(anyhow::Error), -} - -impl std::fmt::Display for StatusErrorOrRuntime { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Runtime(err) => write!(f, "{err:#}"), - } - } -} - -impl std::error::Error for StatusErrorOrRuntime {} - -#[cfg(test)] -mod tests { - use super::*; - - use std::process::Command; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn unique_temp_dir(label: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after Unix epoch") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "sce-trace-status-{label}-{}-{nonce}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir - } - - fn init_git_repo(repo_root: &Path) { - let output = Command::new("git") - .args(["init", "-q"]) - .current_dir(repo_root) - .output() - .expect("git init"); - assert!(output.status.success(), "git init failed"); - } - - fn add_remote(repo_root: &Path, name: &str, url: &str) { - let output = Command::new("git") - .args(["remote", "add", name, url]) - .current_dir(repo_root) - .output() - .expect("git remote add"); - assert!(output.status.success(), "git remote add failed"); - } - - #[test] - fn repository_status_uses_safe_identity_metadata_for_credential_remote() { - let repo = unique_temp_dir("repo-safe-identity"); - init_git_repo(&repo); - add_remote( - &repo, - "origin", - "https://alice:s3cr3t@github.com/acme/widgets.git", - ); - let state_root = unique_temp_dir("repo-safe-identity-state"); - - let report = resolve_current_status_at_state_root(&repo, &state_root, None, "origin") - .expect("repository status should resolve"); - - assert_eq!( - report.repository_identity_source.as_deref(), - Some("remote_url") - ); - assert_eq!(report.configured_remote.as_deref(), Some("origin")); - assert_eq!( - report.canonical_identity.as_deref(), - Some("github.com/acme/widgets") - ); - assert!(!report.repository_id.unwrap().contains("s3cr3t")); - assert!(!report - .database_path - .display() - .to_string() - .contains("s3cr3t")); - } -} diff --git a/cli/src/services/trace/status_all.rs b/cli/src/services/trace/status_all.rs deleted file mode 100644 index 8003ac9e8..000000000 --- a/cli/src/services/trace/status_all.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! Aggregation for `sce trace status --all` across every discovered DB. -//! -//! Walks the `services::trace::discovery` output, runs -//! `collect_agent_trace_db_stats` over each `Ready` DB, and aggregates totals -//! plus a per-database breakdown for downstream renderers. `Skipped` DBs are -//! counted in the discovery summary but excluded from totals. - -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; - -use crate::services::default_paths::resolve_state_data_root; -use crate::services::trace::discovery::{ - discover_repository_agent_trace_dbs_in, DiscoveredAgentTraceDbKind, Readiness, -}; -use crate::services::trace::stats::{collect_agent_trace_db_stats, AgentTraceDbStats}; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DiscoverySummary { - pub discovered: usize, - pub ready: usize, - pub skipped: usize, -} - -/// Aggregated totals across all discovered Agent Trace DBs. -pub type Totals = AgentTraceDbStats; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum DatabaseRowStatus { - Ready { stats: AgentTraceDbStats }, - Skipped { missing_table: String }, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DatabaseRow { - pub alias: String, - pub kind: DiscoveredAgentTraceDbKind, - pub path: PathBuf, - pub status: DatabaseRowStatus, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StatusAllReport { - pub discovery: DiscoverySummary, - pub totals: Totals, - pub databases: Vec, -} - -/// Aggregate `sce trace status --all` using the default state-data root. -pub fn aggregate_current_status_all() -> Result { - let state_root = resolve_state_data_root().context("failed to resolve state data root")?; - let sce_dir = state_root.join("sce"); - aggregate_status_all_in(&sce_dir) -} - -/// Aggregate `sce trace status --all` against an explicit `sce` directory. -pub fn aggregate_status_all_in(sce_dir: &Path) -> Result { - let discovered = discover_repository_agent_trace_dbs_in(sce_dir)?; - - let mut discovery = DiscoverySummary { - discovered: discovered.len(), - ready: 0, - skipped: 0, - }; - let mut totals = Totals::default(); - let mut databases: Vec = Vec::with_capacity(discovered.len()); - - for db in discovered { - match db.readiness { - Readiness::Ready => { - discovery.ready += 1; - let stats = collect_agent_trace_db_stats(&db.path)?; - totals.diff_traces += stats.diff_traces; - totals.messages += stats.messages; - totals.parts += stats.parts; - totals.agent_traces += stats.agent_traces; - totals.post_commit_patch_intersections += stats.post_commit_patch_intersections; - if let Some(dt) = stats.last_activity { - totals.last_activity = - Some(totals.last_activity.map_or(dt, |prev| prev.max(dt))); - } - databases.push(DatabaseRow { - alias: db.alias, - kind: db.kind, - path: db.path, - status: DatabaseRowStatus::Ready { stats }, - }); - } - Readiness::Skipped { missing_table } => { - discovery.skipped += 1; - databases.push(DatabaseRow { - alias: db.alias, - kind: db.kind, - path: db.path, - status: DatabaseRowStatus::Skipped { missing_table }, - }); - } - } - } - - Ok(StatusAllReport { - discovery, - totals, - databases, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn unique_temp_dir(label: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after Unix epoch") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "sce-trace-status-all-{label}-{}-{nonce}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir - } - - #[test] - fn empty_sce_dir_reports_zero_discovery_and_totals() { - let dir = unique_temp_dir("empty"); - let report = aggregate_status_all_in(&dir).expect("empty aggregation should succeed"); - assert_eq!(report.discovery.discovered, 0); - assert_eq!(report.discovery.ready, 0); - assert_eq!(report.discovery.skipped, 0); - assert_eq!(report.totals, Totals::default()); - assert!(report.databases.is_empty()); - } -} diff --git a/context/architecture.md b/context/architecture.md index 0b206921a..400e27595 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -101,17 +101,17 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - The npm distribution implementation lives under `npm/`: `package.json` defines the `sce` package surface, `bin/sce.js` launches the package-local native binary, `lib/install.js` resolves the current package version against the release manifest, verifies `sce-v-release-manifest.json.sig` with the bundled public key before trusting manifest contents, and then installs the checksum-verified native archive for supported macOS/Linux targets, while `test/platform.test.js` and `test/install.test.js` cover platform selection plus signed-manifest installer behavior. - `cli/src/main.rs` is the executable entrypoint (`sce`) and delegates to `app::run`. -- `cli/src/cli_schema.rs` defines the clap-based CLI schema using derive macros for all top-level commands and subcommands, including the `trace db shell ` surface, and renders command-local help text for the `auth` command tree (`auth`, `auth login`, `auth logout`, `auth status`). +- `cli/src/cli_schema.rs` defines the clap-based CLI schema using derive macros for all top-level commands and subcommands, including the top-level `sync` command, and renders command-local help text for the `auth` command tree (`auth`, `auth login`, `auth logout`, `auth status`). - `cli/src/app.rs` provides the clap-based argument dispatch loop with deterministic help/setup execution, bare-command help routing for `sce auth` and `sce config`, centralized stream routing (`stdout` success payloads, `stderr` redacted diagnostics), stable class-based exit-code mapping (`2` parse, `3` validation, `4` runtime, `5` dependency), and stable class-based stderr diagnostic codes (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) with default `Try:` remediation injection when missing. - The app runtime now moves through explicit startup phases in `cli/src/app.rs`: dependency bootstrapping (`perform_dependency_check`), startup context construction (`build_startup_context`), runtime initialization (`initialize_runtime`), command parse/execute inside telemetry subscriber context (`run_command_lifecycle`, `parse_command_phase` plus `services::app_support::execute_command_phase`), and final output rendering through `services::app_support::render_run_outcome`. `AppRuntime` owns the concrete production logger, no-op telemetry runtime, filesystem ops, git ops, static `CommandRegistry`, and startup-diagnostic state across those phases; `RunOutcome` carries final render data with an optional generic logger implementing `services::observability::traits::Logger`, so render support can log classified errors without production-logger type coupling. If a telemetry implementation attempts to invoke the command action more than once, dispatch returns a runtime-classified error instead of panicking or reusing consumed arguments. - `AppContext` is the CLI's borrowed dependency view in `cli/src/app.rs`: it is generic over logger, telemetry, filesystem, and git capability implementations and stores references plus an optional `repo_root: Option` instead of owning `Arc` trait objects. Because it borrows from `AppRuntime`, `AppContext` is a lightweight, short-lived view and must not be stored long-term (e.g., in structs or across await points). Startup creates a context view over `AppRuntime`'s concrete production dependencies with `repo_root` set to `None`; command paths can derive repo-root-scoped context views through the `ContextWithRepoRoot` accessor trait / `AppContext::with_repo_root(...)`, which reuses the same borrowed dependencies while attaching the resolved root. Narrow accessor traits expose associated concrete capability types for logger, telemetry, fs, and git (`&Self::...`) plus repo-root access, so call sites can express capability requirements without erasing the borrowed dependencies back to trait objects; lifecycle providers consume the repo-root accessor rather than the full context type. -- Command parse-time conversion and run-time handling are separated by an internal static `RuntimeCommand` seam. `cli/src/services/command_registry.rs` defines the `RuntimeCommand` enum with variants for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, and trace, plus a deterministic `CommandRegistry` name catalog populated by `build_default_registry()`. `parse_command_phase` in `cli/src/app.rs` delegates clap-output conversion to `cli/src/services/parse/command_runtime.rs`, which owns clap error classification, help rendering bridges, and parsed-request-to-enum conversion while returning concrete enum values. Service-owned `command.rs` modules define command payload structs and generic execution methods with narrow context requirements: context-free commands accept any context, hooks requires logger access, setup/doctor require repo-root scoping, and central dispatch requires the union of logger plus repo-root-scoping capabilities. `services::app_support::execute_command_phase` emits lifecycle logs around `RuntimeCommand::execute_with_stderr(...)`; the enum performs the only central dispatch match and delegates business behavior to the service-owned command structs, with trace sync receiving the app-owned stderr writer for format-gated progress. +- Command parse-time conversion and run-time handling are separated by an internal static `RuntimeCommand` seam. `cli/src/services/command_registry.rs` defines the `RuntimeCommand` enum with variants for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, and sync, plus a deterministic `CommandRegistry` name catalog populated by `build_default_registry()`. `parse_command_phase` in `cli/src/app.rs` delegates clap-output conversion to `cli/src/services/parse/command_runtime.rs`, which owns clap error classification, help rendering bridges, and parsed-request-to-enum conversion while returning concrete enum values. Service-owned `command.rs` modules define command payload structs and generic execution methods with narrow context requirements: context-free commands accept any context, hooks requires logger access, setup/doctor require repo-root scoping, and central dispatch requires the union of logger plus repo-root-scoping capabilities. `services::app_support::execute_command_phase` emits lifecycle logs around `RuntimeCommand::execute_with_stderr(...)`; the enum performs the only central dispatch match and delegates business behavior to the service-owned command structs, with sync receiving the app-owned stderr writer for format-gated progress. - Startup observability bootstrapping in `cli/src/app.rs` still tolerates invalid default-discovered config files by continuing with degraded defaults plus `sce.config.invalid_config` warn-level logs, but the warning/logging work is now isolated behind the startup-context and runtime-initialization phases rather than one inline startup function. - `cli/src/services/observability.rs` provides deterministic runtime observability controls and rendering for app lifecycle logs, including shared config-resolved threshold/format and `log_dir` inputs with precedence `env > config file > defaults` for non-flag observability keys, stable event identifiers, severity filtering, the forced-emission warning path used for invalid discovered config startup diagnostics, stderr primary emission, redaction-safe emission through the shared security helper, and log-directory writes with bounded retention. Config resolution also carries a positive config-file/default-only `log_file_retention_limit` (`10` by default) into startup observability config and `sce config show`; the concrete logger stores that resolved value and threads it through primary and v2 cleanup. When `log_dir` resolves from `SCE_LOG_DIR`, config, or the `/sce/logs` default, each enabled or forced log operation selects `/sce-.log` or `/sce--.log` using the machine-local date and optional logger session context, with deterministic percent-encoding for unsafe session filename bytes; after successfully writing a newly created selected file, retention keeps the configured number of newest direct regular `*.log` files by mtime plus path/name tie-break and fails open on cleanup errors. Its `observability::traits` submodule exposes the current `Logger` API with `Option<&str>` session context plus object-safe `Telemetry` trait boundaries and `NoopLogger`; the concrete observability logger and telemetry runtime still own behavior and implement those traits. `services::app_support::render_run_outcome` consumes the logger through that trait boundary when logging classified errors and stdout-write failures. - `cli/src/services/observability.rs` no longer owns duplicate log enums or parsing helpers; it consumes the canonical primitive seam from `cli/src/services/config/mod.rs` and stays focused on logger and telemetry runtime behavior. -- `cli/src/cli_schema.rs` is now the canonical owner for top-level command metadata for the real clap-backed command set (`auth`, `config`, `setup`, `doctor`, `hooks`, `version`, `completion`), including the slim top-level help purpose text and per-command visibility on `sce`, `sce help`, and `sce --help`; `cli/src/command_surface.rs` remains the custom top-level help renderer and known-command classifier, adding the synthetic `help` row plus the ASCII banner while consuming that shared metadata instead of maintaining a parallel command catalog. +- `cli/src/cli_schema.rs` is now the canonical owner for top-level command metadata for the real clap-backed command set (`auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, `completion`), including the slim top-level help purpose text and per-command visibility on `sce`, `sce help`, and `sce --help`; `cli/src/command_surface.rs` remains the custom top-level help renderer and known-command classifier, adding the synthetic `help` row plus the ASCII banner while consuming that shared metadata instead of maintaining a parallel command catalog. - `cli/src/services/default_paths.rs` is the canonical production path catalog for the CLI: it resolves config/state/cache roots with platform-aware XDG or `dirs` fallbacks through an internal `roots` seam, exposes named default paths for current persisted artifacts and database/log files (global config, auth tokens, auth DB, local DB, default observability log directory, and the sole Agent Trace DB path helper `agent_trace_db_path_for_repository` under `repos//agent-trace.db`; the former global-sentinel and per-checkout Agent Trace path helpers were removed by the `retire-legacy-agent-trace-db` plan), and owns canonical repo-relative, embedded-asset, install, hook, and context-path accessors so non-test production path definitions have one shared owner. Compile-time generated payload paths are owned by `build.rs` under `OUT_DIR`, not by the default-path catalog. Current production consumers such as config discovery, observability config resolution, doctor reporting, setup/install flows, database adapters, checkout identity, Agent Trace storage resolution, and local hook runtime path resolution consume this shared catalog rather than defining owned path literals in their own modules. -- `cli/src/services/agent_trace.rs` is the Rust CLI owner for the SCE web base URL (`SCE_WEB_BASE_URL`) and exposes helpers for SCE-owned URL construction: Agent Trace conversation lookup URLs, persisted Agent Trace trace URLs, Agent Trace session URLs, and setup-created config schema URLs. Production Rust code should consume those helpers instead of repeating `sce.crocoder.dev` literals. The config resolver separately owns the `control_plane_base_url` runtime seam, whose baked `sce trace sync` default is `https://sce.crocoderlab.dev`; this control-plane host is not a web URL or schema owner. +- `cli/src/services/agent_trace.rs` is the Rust CLI owner for the SCE web base URL (`SCE_WEB_BASE_URL`) and exposes helpers for SCE-owned URL construction: Agent Trace conversation lookup URLs, persisted Agent Trace trace URLs, Agent Trace session URLs, and setup-created config schema URLs. Production Rust code should consume those helpers instead of repeating `sce.crocoder.dev` literals. The config resolver separately owns the `control_plane_base_url` runtime seam, whose baked `sce sync` default is `https://sce.crocoderlab.dev`; this control-plane host is not a web URL or schema owner. - `cli/src/services/config/mod.rs` is the config service facade and `sce config` orchestration surface (`show`, `validate`, `--help`), with bare `sce config` routed by `cli/src/app.rs` to the same help payload as `sce config --help`. Focused submodules own the implementation slices: `types.rs` owns shared config/runtime primitives, `schema.rs` owns generated schema embedding plus typed file parsing, `policy.rs` owns bash-policy semantic validation plus policy-specific formatting and runtime preset-catalog access for the Rust evaluator, `resolver.rs` owns deterministic config-file discovery, file-layer merging, explicit value precedence (`flags > env > config file > defaults` where flag-backed), shared auth-key resolution, observability-runtime resolution, attribution-hooks runtime gate resolution, database-retry config resolution and `DATABASE_RETRY_CONFIG` `OnceLock` initialization, default-discovered invalid-file degradation, and explicit-path fatal errors for `--config` / `SCE_CONFIG_FILE`, and private `render.rs` owns `sce config show` / `sce config validate` text and JSON output construction plus rendering-specific display-value helpers. The facade preserves existing `services::config` imports for startup/auth/hooks callers while delegating command execution to resolution plus rendering submodules. - `cli/src/services/output_format.rs` defines the canonical shared CLI output-format contract (`OutputFormat`) for supporting commands, with deterministic `text|json` parsing and command-scoped actionable invalid-value guidance. - `cli/src/services/config/types.rs` is the canonical owner for the shared runtime/config primitive seam used by the CLI: `LogLevel`, `LogFormat`, `SCE_LOG_LEVEL`, `SCE_LOG_FORMAT`, `SCE_LOG_DIR`, `DEFAULT_LOG_FILE_RETENTION_LIMIT`, and the shared bool parsing helpers used by both config resolution and observability bootstrap; `cli/src/services/config/mod.rs` re-exports those primitives through the facade. @@ -122,20 +122,21 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/db/mod.rs` provides the shared generic Turso infrastructure seam: `DbSpec` supplies a service-specific name, path, ordered embedded migrations, and config-file lookup key (`db_config_key()`), while `TursoDb` owns parent-directory creation, `Builder::new_local(...)` initialization (with `experimental_multiprocess_wal(true)` for safe concurrent access), Turso connection setup, tokio current-thread runtime bridging, retry-backed blocking `execute`/`query`/`query_values`/`query_map` wrappers, and generic migration execution with per-database `__sce_migrations` metadata. `TursoDb::new()` and `EncryptedTursoDb::new()` wrap only their local open/connect block in `run_with_retry_sync` using a config-driven connection-open policy resolved from the `DATABASE_RETRY_CONFIG` `OnceLock` with fallback to hardcoded defaults, while operation methods use a config-driven operation policy from the same source. `query_values()` returns fully fetched column names plus raw `turso::Value` rows for deterministic operator-facing rendering; `query_map()` retries the initial query and row-fetch loop, then applies caller row mapping after retry completion. Migration execution is not retried and uses batch execution so one migration file may contain multiple SQL statements while still recording one migration ID. The same module also provides `EncryptedTursoDb`, a structurally parallel encrypted adapter that resolves the encryption key through `encryption_key::get_or_create_encryption_key()`, enables Turso local encryption with strict `aegis256` cipher selection, and exposes retry-backed synchronous wrappers plus migration execution. `cli/src/services/db/encryption_key.rs` first derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text when present, otherwise falls back to keyring-backed credential-store get-or-create behavior; no plaintext auth DB fallback exists. - `cli/src/services/local_db/mod.rs` provides the concrete local DB spec and `LocalDb` type alias over the shared generic `TursoDb` adapter. `LocalDbSpec` resolves the deterministic persistent runtime DB target through the shared default-path seam and declares no local migrations; `TursoDb` supplies retry-backed blocking `execute`/`query`, parent-directory creation, Turso connection setup, tokio current-thread runtime bridging, and generic migration execution. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`. -- `cli/src/services/agent_trace_db/mod.rs` owns the shared Agent Trace insert payloads, SQL constants, and typed row helpers (diff-trace/intersection/Agent Trace/message/part) plus `ensure_schema_ready_for_hooks()` consumed by the repository adapter. `cli/src/services/agent_trace_db/repository.rs` defines the sole `RepositoryAgentTraceDb` adapter over `TursoDb` with one fresh `agent-trace-repository/001_repository_schema.sql` baseline for `diff_traces` (including `payload_type`), `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes, and triggers, `repository_metadata` validation, no trace-table `checkout_id` columns, `agent_traces.agent_trace_id NOT NULL UNIQUE`, and `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` using the inclusive chronological parser without checkout filtering. Active hook runtime, setup/lifecycle storage, and trace status/list/shell flows resolve through `agent_trace_storage` and use `RepositoryAgentTraceDb`. The checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, the 15-file `cli/migrations/agent-trace/` chain, and the `sce trace --legacy` surface were removed by the `retire-legacy-agent-trace-db` plan. +- `cli/src/services/agent_trace_db/mod.rs` owns the shared Agent Trace insert payloads, SQL constants, and typed row helpers (diff-trace/intersection/Agent Trace/message/part) plus `ensure_schema_ready_for_hooks()` consumed by the repository adapter. `cli/src/services/agent_trace_db/repository.rs` defines the sole `RepositoryAgentTraceDb` adapter over `TursoDb` with one fresh `agent-trace-repository/001_repository_schema.sql` baseline for `diff_traces` (including `payload_type`), `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes, and triggers, `repository_metadata` validation, no trace-table `checkout_id` columns, `agent_traces.agent_trace_id NOT NULL UNIQUE`, and `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` using the inclusive chronological parser without checkout filtering. Active hook runtime, setup/lifecycle storage, and `sce sync` resolve through `agent_trace_storage` and use `RepositoryAgentTraceDb`. The checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, the 15-file `cli/migrations/agent-trace/` chain, and the former `sce trace --legacy` surface were removed by the `retire-legacy-agent-trace-db` plan. - `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator (`install_assets_for_concrete_target_with_rename`) installs each embedded asset individually: it stages the asset's content next to its final destination and swaps it into place by renaming the staging file directly over the destination — never unlinking the destination first, since `fs::rename` already replaces an existing file atomically — with deterministic recovery guidance naming the failing asset's path on swap failure (the pre-existing destination content, if any, is untouched) and no backup artifact creation; it never removes an integration target directory as a whole, so files a repository owns inside `.opencode`/`.claude`/`.pi` — including nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run. For the two assets that are merge targets — the Claude target's `settings.json`, detected by `is_claude_settings_merge_target`, and the OpenCode target's `opencode.json`, detected by `is_opencode_config_merge_target` — the content staged is not the embedded asset's bytes but the result of `cli/src/services/setup/config_merge.rs::merge_or_create_claude_settings`/`merge_or_create_opencode_config(existing_bytes, generated_bytes, source_path)`: each returns the generated document verbatim when no file exists yet, otherwise parses the existing file as JSON (a parse failure is a hard error naming `source_path`, and nothing is written) and merges it with a pure per-shape function that copies `$schema` from the generated document and preserves every other key from the existing file untouched. `merge_claude_settings` replaces, per hook event key the generated document declares, only the entries whose command contains the ownership marker `run-sce-or-show-install-guidance.sh`, preserving every event key and non-SCE hook entry from the existing file. `merge_opencode_config` merges the `plugin` array as a set: existing entries whose path starts with the ownership marker `./plugins/sce-` are dropped structurally — so a stale plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it — and the generated document's `plugin` entries are appended after the surviving entries. After that per-asset install loop, `prune_stale_assets_for_concrete_target` deletes every path the full embedded catalog for the concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), and `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, stopping at the target root or at a directory that still holds something such as a user file. It formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`, backed by the rename-injectable `install_required_git_hooks_with_rename`) is a third content-computation seam alongside the two JSON merge targets: `install_single_required_hook_with_rename` computes the bytes to stage with `cli/src/services/setup/hook_merge.rs::merge_or_create_hook(existing_bytes, canonical_bytes, hook_name)` rather than writing `hook_asset.bytes` verbatim — a foreign hook (no SCE managed block, no legacy guidance-URL marker) is kept as an exact byte prefix with the canonical block appended after it, an SCE-owned hook has only its block spliced in place or left unchanged, and a legacy pre-marker hook is replaced wholesale — then follows the same per-file stage/atomic-swap choreography as config-asset install: the staging file is renamed directly over the existing hook without unlinking it first, so a rename failure leaves the prior hook's bytes and executable bit intact, with deterministic recovery guidance on swap failure. `Installed`/`Updated`/`Skipped` are decided against the merged bytes plus the executable bit rather than the canonical asset's raw bytes, so an already-current foreign-plus-block hook reports `Skipped`; `RequiredHookInstallResult.unreachable_block_advisory` is set, and rendered as a named advisory line in setup's hook output, when an appended block follows a foreign hook's zero-indent `exec`/`exit` and so would never run. After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. - `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization, staging/swap install behavior, required-hook installation, and filesystem safety guards, while the inline `prompt` module owns interactive target selection and prompt styling. - `cli/src/services/security.rs` provides shared security utilities for deterministic secret redaction (`redact_sensitive_text`) and directory write-permission probes (`ensure_directory_is_writable`) used by app/setup/observability surfaces. -- `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Checkout DB discovery no longer lives in `doctor`; it moved to the `trace` group (`sce trace db list`) in `cli/src/services/trace/`. Report fact collection preserves environment/repository/hook/integration display data and checkout identity facts, while service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. +- `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Agent Trace database inspection is no longer a doctor-adjacent command surface; doctor owns repository-scoped DB health and checkout identity facts, while `sce sync` owns control-plane synchronization. Report fact collection preserves environment/repository/hook/integration display data, while service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/services/completion/mod.rs` defines completion parser/rendering contract (`parse_completion_request`, `render_completion`) with deterministic Bash/Zsh/Fish script output aligned to current parser-valid command/flag surfaces; `cli/src/services/completion/command.rs` owns the `CompletionCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent repository-level `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists result to `post_commit_patch_intersections`, then persists built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake extracts direct model metadata from top-level or nested `model` fields and normalizes it with the `claude/` prefix when present. `session-model` is no longer a supported hook route. - Generated Claude settings no longer register `SessionStart` for Agent Trace model attribution, and `sce hooks session-model` is no longer a supported hook command. The `session_models` table/API and diff-trace fallback lookup were removed in T02 of the `remove-session-models-direct-claude-model-id` plan; `diff-trace` now uses only direct payload `model_id` and `tool_version` values. - `cli/src/services/resilience.rs` defines bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) for transient operation hardening with deterministic failure messaging and retry observability. -- `sce trace sync [--format text|json]` is implemented: `cli/src/services/trace/sync.rs` resolves repository-scoped Agent Trace storage the same way `sce trace status` does, authenticates against the control plane with stored WorkOS credentials, uses the config-resolved `control_plane_base_url` with baked default `https://sce.crocoderlab.dev`, calls the ingestion `/state` endpoint once, then starts the `messages`/`parts`/`diff_traces`/`agent_traces` capture-stream state machines concurrently via `AgentTraceExportReader` and a shared per-stream reconciliation engine. Batches and cursor refreshes remain sequential within each stream, while fixed stream order is retained for final and stream-completion reporting; `cli/src/services/trace/render_sync.rs` renders the converged `AgentTraceSyncReport` as concise per-stream text or `camelCase` JSON (see `context/cli/trace-command.md`). Local DB bootstrap and setup-time repository-scoped Agent Trace DB initialization otherwise still flow through lifecycle providers aggregated by setup, while repository-scoped DB health/repair flows through the doctor surface. There is no checkout-scoped DB discovery or `sce trace --legacy` surface (removed by the `retire-legacy-agent-trace-db` plan). +- `cli/src/services/sync/progress.rs` owns the sync-local, consumer-typed progress seam: generic `ProgressReporter` supports event delivery plus explicit successful finalization, closure-based collectors, and a no-op implementation alongside the fixed `indicatif` stderr presentation adapter. `cli/src/services/sync/sync.rs` owns `SyncProgressEvent` and its four-stream payload semantics, while `sync/command.rs` selects the terminal adapter for text and the no-op reporter for JSON. There is no top-level `cli/src/services/progress/` module; sync orchestration depends only on its sync-owned contract, so terminal-library details stay at the sync presentation boundary. +- `sce sync [--format text|json]` is implemented: `cli/src/services/sync/sync.rs` resolves repository-scoped Agent Trace storage, authenticates against the control plane with stored WorkOS credentials, uses the config-resolved `control_plane_base_url` with baked default `https://sce.crocoderlab.dev`, calls the ingestion `/state` endpoint once, then starts the `messages`/`parts`/`diff_traces`/`agent_traces` capture-stream state machines concurrently via `AgentTraceExportReader` and a shared per-stream reconciliation engine. Batches and cursor refreshes remain sequential within each stream, while fixed stream order is retained for final and stream-completion reporting; `cli/src/services/sync/render_sync.rs` renders the converged `AgentTraceSyncReport` as concise per-stream text or `camelCase` JSON without a nested subcommand field (see `context/cli/sync-command.md`). Local DB bootstrap and setup-time repository-scoped Agent Trace DB initialization otherwise still flow through lifecycle providers aggregated by setup, while repository-scoped DB health/repair flows through the doctor surface. The former trace database inspection and nested sync surfaces are unavailable. - `cli/src/services/patch.rs` defines the standalone patch domain model (`ParsedPatch`, `PatchFileChange`, `FileChangeKind`, `PatchHunk`, `TouchedLine`, `TouchedLineKind`) for in-memory parsed unified-diff representation, capturing only touched lines (added/removed) plus minimal per-file/per-hunk metadata while excluding non-hunk headers and unchanged context lines. All types are `serde`-serializable/deserializable with `snake_case` JSON field naming. The module also provides `parse_patch`, a public parser function that converts raw unified-diff text (both `Index:` SVN-style and `diff --git` git-style formats) into `ParsedPatch` structs, with `ParseError` for actionable malformed-input diagnostics. Storage-agnostic JSON load helpers (`load_patch_from_json` for string input, `load_patch_from_json_bytes` for byte input) reconstruct `ParsedPatch` from serialized JSON content with `PatchLoadError` for actionable deserialization diagnostics. Its patch-set operations now include deterministic ordered combination plus target-shaped intersection that prefers exact touched-line matches and falls back to historical `kind`+`content` matching when incremental diffs and canonical post-commit diffs have drifted line numbers; `parse_patch`, `combine_patches`, and `intersect_patches` are consumed by the active post-commit hook runtime. - `cli/src/services/structured_patch.rs` defines the synchronous structured editor-hook derivation seam. It derives Claude `PostToolUse` `Write` structured-update hunks, `Write` `tool_input.content` create fallback, and `Edit` structured-patch payloads into canonical `ParsedPatch` values plus Claude session/tool metadata, returning deterministic skip reasons for unsupported events/tools/payload shapes. The module is pure and side-effect-free. It is wired into `sce hooks diff-trace` for Claude payload classification at intake (T04) and into `RepositoryAgentTraceDb::recent_diff_trace_patches` for post-commit structured payload parsing dispatch at read time (T05). -- `cli/src/services/` contains module boundaries for command_registry, lifecycle, auth_command, config, setup, doctor, hooks, checkout identity, bash_policy, version, completion, help, patch, SCE web URL helpers, shared database infrastructure, local DB adapters, encrypted auth DB adapters, and Agent Trace DB adapters with explicit trait seams for future implementations. `cli/src/services/checkout/` owns checkout ID file infrastructure (the per-checkout Agent Trace DB opener/path helper was removed by the `retire-legacy-agent-trace-db` plan); active setup/hooks use `agent_trace_storage` to establish checkout identity as diagnostics and initialize/open the repository-scoped DB, while `sce doctor` surfaces checkout identity facts plus credential-safe repository Agent Trace DB metadata. There is no checkout-scoped discovery or `sce trace --legacy` surface; any pre-migration `agent-trace-*.db` files on disk are never touched and no longer inspectable via the CLI. `cli/src/services/bash_policy.rs` owns both the CLI-agnostic evaluator logic and the hidden `sce policy bash` command adapter used by OpenCode, Claude, and Pi callers. `cli/src/services/command_registry.rs` defines the static `RuntimeCommand` enum, deterministic `CommandRegistry` name catalog, and `build_default_registry()` function for command dispatch metadata. Service-owned command modules own the runtime command payload structs for help/help-text, version, completion, auth, config, setup, doctor, hooks, and policy. +- `cli/src/services/` contains module boundaries for command_registry, lifecycle, auth_command, config, setup, doctor, hooks, checkout identity, bash_policy, version, completion, help, patch, sync, SCE web URL helpers, shared database infrastructure, local DB adapters, encrypted auth DB adapters, and Agent Trace DB adapters with explicit trait seams for future implementations. `cli/src/services/checkout/` owns checkout ID file infrastructure (the per-checkout Agent Trace DB opener/path helper was removed by the `retire-legacy-agent-trace-db` plan); active setup/hooks use `agent_trace_storage` to establish checkout identity as diagnostics and initialize/open the repository-scoped DB, while `sce doctor` surfaces checkout identity facts plus credential-safe repository Agent Trace DB metadata. There is no checkout-scoped discovery or former trace inspection surface; any pre-migration `agent-trace-*.db` files on disk are never touched and no longer inspectable via the CLI. `cli/src/services/bash_policy.rs` owns both the CLI-agnostic evaluator logic and the hidden `sce policy bash` command adapter used by OpenCode, Claude, and Pi callers. `cli/src/services/command_registry.rs` defines the static `RuntimeCommand` enum, deterministic `CommandRegistry` name catalog, and `build_default_registry()` function for command dispatch metadata. Service-owned command modules own the runtime command payload structs for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, and sync. - `cli/README.md` is the crate-local onboarding and usage source of truth for placeholder behavior, safety limitations, and roadmap mapping back to service contracts. - `flake.nix` applies `rust-overlay` (`oxalica/rust-overlay`) to nixpkgs, pins `rust-bin.stable.1.95.0.default` with `rustfmt` + `clippy`, and reads package/check version from repo-root `.version`. Its `cliGeneratedInput` derivation invokes the shared generated-input producer from a declarative source containing the producer and canonical inputs, then publishes the producer-validated handoff as its store output. Native, release, test, and Clippy Cargo derivations receive that same store path through `SCE_CLI_GENERATED_INPUT_DIR`; their Cargo environments exclude Pkl and assert that it is unavailable. Repository-mode `cli/build.rs` validates the handoff before copying it into `OUT_DIR`; published crates use the validated packaging-only fallback. The build script stages SQL under `OUT_DIR/static/migrations` and writes `OUT_DIR/generated_migrations.rs` with deterministic migration constants sorted by numeric filename prefix. - Crane dependency-only derivations and `cli-fmt` intentionally do not receive `SCE_CLI_GENERATED_INPUT_DIR`, so canonical generation changes invalidate the producer and compiling derivations while preserving host/musl dependency artifacts and formatting. The root flake runs `cli-tests`, `cli-clippy`, and `cli-fmt` through Crane-backed paths and exposes `cli-generated-input` as the focused payload/inventory integrity check. It also exposes directory-scoped JS validation derivations for `npm/` and `config/lib/`, while `pkl-generated` uses a narrow canonical-input source set plus maybe-missing forbidden paths so reintroduced generated repository artifacts fail the check. @@ -183,7 +184,7 @@ Investigations T08 (`turso default-features = false`) and T09 (isolating the with rationale in the benchmark doc. Final after-change numbers and remaining bottlenecks are captured in T11. -This phase establishes compile-safe extension seams with a dependency baseline (`anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, `uuid`, plus target-specific keyring backends); no CLI dev-dependencies are currently declared. Per-user local Turso DB and Agent Trace DB bootstrap/health coverage now exist through setup/doctor flows; a user-invocable `sce trace sync` command is now fully implemented including rendering (see above), and broader runtime integrations remain deferred. +This phase establishes compile-safe extension seams with a dependency baseline (`anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, `uuid`, plus target-specific keyring backends); no CLI dev-dependencies are currently declared. Per-user local Turso DB and Agent Trace DB bootstrap/health coverage now exist through setup/doctor flows; the user-invocable `sce sync` command is now fully implemented including rendering (see above), and broader runtime integrations remain deferred. ## SCE plan/code role boundary diff --git a/context/cli/agent-trace-storage.md b/context/cli/agent-trace-storage.md index d999d0425..5973a7fd8 100644 --- a/context/cli/agent-trace-storage.md +++ b/context/cli/agent-trace-storage.md @@ -22,10 +22,10 @@ Module at `cli/src/services/agent_trace_storage/` (T04 of the `repository-scoped ## Never-touch on-disk boundary -The resolver never selects, creates, or touches pre-migration checkout-scoped `/sce/agent-trace-.db` files or the pre-migration global `/sce/agent-trace.db`; tests assert neither appears after resolution. Active hook/runtime, lifecycle setup call sites, and `sce trace` status/shell resolution use this resolver. There is no longer a checkout-scoped resolver: the `retire-legacy-agent-trace-db` plan removed `checkout::resolve_or_create_agent_trace_db_for_checkout` and the `sce trace --legacy` inspection surface. Any pre-migration checkout/global DB files left on disk are never migrated, imported, renamed, or deleted, and are no longer inspectable through the CLI. +The resolver never selects, creates, or touches pre-migration checkout-scoped `/sce/agent-trace-.db` files or the pre-migration global `/sce/agent-trace.db`; tests assert neither appears after resolution. Active hook/runtime, lifecycle setup, and `sce sync` use this resolver. There is no longer a checkout-scoped resolver: the `retire-legacy-agent-trace-db` plan removed `checkout::resolve_or_create_agent_trace_db_for_checkout` and the former trace inspection surface. Any pre-migration checkout/global DB files left on disk are never migrated, imported, renamed, or deleted, and are no longer inspectable through the CLI. ## Status -Registered in `cli/src/services/mod.rs` and consumed by hook runtime, Agent Trace lifecycle setup, and current-repository trace status/shell flows. T05 changed the resolved DB handle to the repository-scoped adapter and validates the stored `repository_metadata.repository_id` before returning storage; T08 wired hooks/lifecycle to pass resolved config values into this context; T09 wired trace UX to repository-scoped storage (the checkout-scoped `sce trace --legacy` surface was later removed by the `retire-legacy-agent-trace-db` plan). The `agent-trace-source-instance-id` plan's T03 split hook-runtime resolution into its own no-migration entrypoint, switching `open_agent_trace_db_for_hook_runtime` in `cli/src/services/hooks/mod.rs` off the setup/lifecycle resolver so hook runtime never runs migration `002` (or any migration). Covered by in-module tests: repository separation, SSH/HTTPS clone consolidation, linked-worktree consolidation, explicit-ID override, idempotent re-resolution, missing-identity guidance, path-segment validation, pre-migration checkout DB byte preservation/non-selection, empty fresh repository DB state, repository-level row sharing across equivalent clone checkouts, credential-safe remote canonicalization, concurrent first-open convergence, and hook-runtime resolution (fails before setup on a missing DB, fails before setup on a baseline-only pre-`002` schema without recording migration `002`, and matches setup's `RepositoryMetadata` once setup has run) (`nix build .#checks..cli-tests`). +Registered in `cli/src/services/mod.rs` and consumed by hook runtime, Agent Trace lifecycle setup, and `sce sync`. T05 changed the resolved DB handle to the repository-scoped adapter and validates the stored `repository_metadata.repository_id` before returning storage; T08 wired hooks/lifecycle to pass resolved config values into this context; the former trace UX was later removed by the `retire-legacy-agent-trace-db` plan. The `agent-trace-source-instance-id` plan's T03 split hook-runtime resolution into its own no-migration entrypoint, switching `open_agent_trace_db_for_hook_runtime` in `cli/src/services/hooks/mod.rs` off the setup/lifecycle resolver so hook runtime never runs migration `002` (or any migration). Covered by in-module tests: repository separation, SSH/HTTPS clone consolidation, linked-worktree consolidation, explicit-ID override, idempotent re-resolution, missing-identity guidance, path-segment validation, pre-migration checkout DB byte preservation/non-selection, empty fresh repository DB state, repository-level row sharing across equivalent clone checkouts, credential-safe remote canonicalization, concurrent first-open convergence, and hook-runtime resolution (fails before setup on a missing DB, fails before setup on a baseline-only pre-`002` schema without recording migration `002`, and matches setup's `RepositoryMetadata` once setup has run) (`nix build .#checks..cli-tests`). See also: [repository-identity.md](repository-identity.md), [checkout-identity.md](checkout-identity.md), [default-path-catalog.md](default-path-catalog.md), [../sce/agent-trace-db.md](../sce/agent-trace-db.md) diff --git a/context/cli/agent-trace-sync-command.md b/context/cli/agent-trace-sync-command.md index 852e97453..4545f7bde 100644 --- a/context/cli/agent-trace-sync-command.md +++ b/context/cli/agent-trace-sync-command.md @@ -1,16 +1,16 @@ # Agent Trace sync architecture -`sce trace sync` is the composition step that synchronizes a repository's local Agent Trace capture database with the control-plane Agent Trace ingestion API. It composes already-shipped infrastructure — repository/source identity, the read-only export readers, and the existing WorkOS auth/token-storage stack — into one command; it does not redesign the local database, source identity, export readers, or control-plane storage model. +`sce sync` is the composition step that synchronizes a repository's local Agent Trace capture database with the control-plane Agent Trace ingestion API. It composes already-shipped infrastructure — repository/source identity, the read-only export readers, and the existing WorkOS auth/token-storage stack — into one command; it does not redesign the local database, source identity, export readers, or control-plane storage model. ## User flow ``` sce auth login # obtain and store WorkOS credentials cd # any directory inside the target Git repository -sce trace sync # synchronize this repository's Agent Trace DB +sce sync # synchronize this repository's Agent Trace DB ``` -`sce trace sync --format json` produces the same synchronization with machine-readable output; see [trace-command.md](trace-command.md) for the exact rendering contracts. +`sce sync --format json` produces the same synchronization with machine-readable output; see [sync-command.md](sync-command.md) for the exact rendering contracts. ## Composed data flow @@ -18,17 +18,17 @@ sce trace sync # synchronize this repository's Agent Trace DB flowchart LR A[hooks / plugins] --> B[repository Agent Trace DB] B --> C[AgentTraceExportReader] - C --> D[sce trace sync] + C --> D[sce sync] D -- "HTTPS + WorkOS Bearer" --> E[control plane
sce.crocoderlab.dev by default] ``` - **hooks/plugins** write local capture rows (`messages`, `parts`, `diff_traces`, `agent_traces`) into the current repository's `RepositoryAgentTraceDb` during normal Git/editor activity — this is unchanged by sync. - **`AgentTraceExportReader`** (PR #198) is the read-only local export boundary sync uses to read rows after a cursor; sync never queries the repository DB directly. -- **`sce trace sync`** resolves repository storage through the same `agent_trace_storage` path `sce trace status` uses (not the hook-runtime resolver), builds an `AuthenticatedControlPlaneClient` from stored WorkOS credentials and the resolved `control_plane_base_url`, and drives one authoritative `/state` call before starting four concurrent stream state machines. Each stream keeps its own batches and reconciliation refreshes sequential and cursor-safe; the bounded per-stream reconciliation loop remains independent. With no environment or config override, that base is `https://sce.crocoderlab.dev`; it is distinct from the `https://sce.crocoder.dev` SCE web and config-schema URL owner. +- **`sce sync`** resolves repository storage through `agent_trace_storage` (not the hook-runtime resolver), builds an `AuthenticatedControlPlaneClient` from stored WorkOS credentials and the resolved `control_plane_base_url`, and drives one authoritative `/state` call before starting four concurrent stream state machines. Each stream keeps its own batches and reconciliation refreshes sequential and cursor-safe; the bounded per-stream reconciliation loop remains independent. With no environment or config override, that base is `https://sce.crocoderlab.dev`; it is distinct from the `https://sce.crocoder.dev` SCE web and config-schema URL owner. - **Credential runtime boundary:** `AuthenticatedControlPlaneClient` keeps the synchronous `CredentialStore` behind an `Arc` and runs every token-storage `load`/`save` through `tokio::task::spawn_blocking`. The underlying encrypted auth DB and Linux Secret Service/zbus APIs are blocking and may create their own Tokio runtime, so they must never execute directly inside the async control-plane request future. Token refresh and HTTP requests remain asynchronous; only credential persistence crosses the blocking boundary. The client owns a refresh single-flight guard: expired-token callers re-check credentials after acquiring it, and callers retrying the same rejected access token reuse a token saved by an earlier refresh; valid-token resolution does not acquire the guard. - **control plane** is the sole source of cursor truth: every invocation starts from `POST /agent-trace/ingestion/state`, uploads via `POST /agent-trace/ingestion/batch`, and advances a stream's cursor only from a validated batch response (`accepted == rows.len()` and `cursor == rows.last().sourceRowId`), never by inferring `cursor + rows.len()`. -The four streams (`messages`, `parts`, `diff_traces`, `agent_traces`) start concurrently after the single authoritative state response; batches and cursor-refresh calls remain sequential within each stream. Fixed stream order applies to the final report and stream-completion reporting, while accepted-batch progress may arrive as requests complete. Text mode reports a UTC RFC3339 start timestamp before the first control-plane request, each validated accepted batch's size, cumulative uploaded rows, and current cursor, stream completion, and a terminal UTC RFC3339 end timestamp after success or failure through deterministic newline-delimited flushed lines on `stderr`; an empty stream reports that no new rows were uploaded. The timestamps come from an injectable clock for deterministic tests and do not alter the sync protocol or error classification. JSON mode uses a no-op progress sink and retains its JSON-only output contract without progress or lifecycle timestamps. +The four streams (`messages`, `parts`, `diff_traces`, `agent_traces`) start concurrently after the single authoritative state response; batches and cursor-refresh calls remain sequential within each stream. Accepted-batch progress and stream-completion events may arrive as their respective futures complete. Text mode creates four aligned `indicatif` rows immediately on `stderr`, before any accepted batch can be reported, each with a 15-column stream label, a steady spinner, and a `0 rows uploaded` starting message. Accepted batches update only their stream's cumulative count; each stream changes its spinner to a shared-policy styled `✓` with its final count as soon as its own future completes, including an empty stream with zero uploaded rows. After the progress rows, the final text report contains only a status heading: `Agent Trace already synced.` when all four streams uploaded zero rows, otherwise `Agent Trace sync complete.`. Non-TTY output uses stable aligned plain snapshots without ANSI or terminal-control sequences, and `NO_COLOR` disables completion styling. JSON mode uses a no-op progress sink and retains its JSON-only stdout contract without human progress on `stderr`. ## No-local-persistence invariants @@ -39,7 +39,7 @@ Sync creates no local sync state anywhere on disk: - No Turso Sync and no direct Turso credentials in SCE. - No `BridgeLock` or local data-warehouse (DWH). -Because every invocation starts from the control plane's authoritative `/state` cursors instead of local progress, restarts, conflicts, and ambiguous network failures are all recoverable without any client-side persisted state, and running `sce trace sync` twice in a row is naturally incremental — the second run's `/state` reflects the first run's uploads and only unsynced rows are re-read. +Because every invocation starts from the control plane's authoritative `/state` cursors instead of local progress, restarts, conflicts, and ambiguous network failures are all recoverable without any client-side persisted state, and running `sce sync` twice in a row is naturally incremental — the second run's `/state` reflects the first run's uploads and only unsynced rows are re-read. ## Recovery semantics @@ -54,8 +54,8 @@ Because every invocation starts from the control plane's authoritative `/state` ## Related context -- [trace-command.md](trace-command.md) — the full `sce trace` command group, including `sync` request/response rendering (text and JSON shapes). -- [agent-trace-storage.md](agent-trace-storage.md) — the repository-scoped storage resolver sync reuses from `sce trace status`. +- [sync-command.md](sync-command.md) — the `sce sync` command and its text/JSON rendering contract. +- [agent-trace-storage.md](agent-trace-storage.md) — the repository-scoped storage resolver used by sync. - [agent-trace-export-readers.md](../sce/agent-trace-export-readers.md) — the read-only local export boundary sync reads through. - [auth-db.md](../sce/auth-db.md) — encrypted WorkOS credential storage sync authenticates through. - [Trace-sync progress stream contract](../decisions/2026-08-13-trace-sync-progress-stream-contract.md) — stderr progress/timestamps and stdout/JSON compatibility boundary. diff --git a/context/cli/checkout-identity.md b/context/cli/checkout-identity.md index 995176c6c..4716f37af 100644 --- a/context/cli/checkout-identity.md +++ b/context/cli/checkout-identity.md @@ -22,7 +22,7 @@ During setup and hook runtime: - The active DB path is `/sce/repos//agent-trace.db`. - `RepositoryAgentTraceDb` opens through the repository fast-path-then-migrate flow and validates `repository_metadata.repository_id`. -`sce doctor` still displays checkout identity where available. `sce trace` list/status/status-all/shell UX operates only on repository-scoped DBs. Any pre-migration `/sce/agent-trace-*.db` checkout-scoped files left on disk are never touched by SCE and are no longer inspectable through the CLI. +`sce doctor` still displays checkout identity where available. The former Agent Trace list/status/status-all/shell UX is no longer available; `sce sync` operates on the current repository-scoped DB. Any pre-migration `/sce/agent-trace-*.db` checkout-scoped files left on disk are never touched by SCE and are no longer inspectable through the CLI. ## Testing boundary diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index c2fac6458..a8bf2c7fb 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -11,7 +11,7 @@ Operator onboarding currently comes from `sce --help`, command-local `--help` ou - Top-level command metadata catalog: `cli/src/cli_schema.rs` - Custom top-level help renderer and known-command classifier: `cli/src/command_surface.rs` - Turso adapters: `cli/src/services/auth_db/mod.rs`, `cli/src/services/local_db/mod.rs`, `cli/src/services/agent_trace_db/mod.rs`, and shared infrastructure in `cli/src/services/db/mod.rs` -- Service domains: `cli/src/services/{agent_trace,agent_trace_db,auth,auth_command,auth_db,checkout,completion,config,db,default_paths,hooks,local_db,observability,output_format,resilience,security,setup,style,token_storage,version}` plus the split doctor module at `cli/src/services/doctor/{mod,command,inspect,render,fixes,types}.rs`; service-owned `command.rs` files own command payload structs for help/version/completion/auth/config/setup/doctor/hooks, and `cli/src/services/command_registry.rs` owns the static `RuntimeCommand` enum that dispatches them +- Service domains: `cli/src/services/{agent_trace,agent_trace_db,auth,auth_command,auth_db,checkout,completion,config,db,default_paths,hooks,local_db,observability,output_format,resilience,security,setup,style,sync,token_storage,version}` plus the split doctor module at `cli/src/services/doctor/{mod,command,inspect,render,fixes,types}.rs`; service-owned `command.rs` files own command payload structs for help/version/completion/auth/config/setup/doctor/hooks/policy/sync, and `cli/src/services/command_registry.rs` owns the static `RuntimeCommand` enum that dispatches them - Service lifecycle: `cli/src/services/lifecycle.rs` defines lifecycle-owned health/setup result types, the `ServiceLifecycle` trait with `diagnose`, `fix`, and `setup` methods for concrete providers, and the static `LifecycleProvider` enum used by `doctor`/`setup` to dispatch across `config`, `local_db`, `auth_db`, `agent_trace_db`, and `hooks` without boxed provider aggregation ## Onboarding documentation @@ -46,7 +46,7 @@ Operator onboarding currently comes from `sce --help`, command-local `--help` ou - the banner uses a per-column right-to-left color gradient (cyan on the right, magenta on the left) when stdout color is enabled, and renders as plain ASCII when color is disabled (non-TTY or `NO_COLOR`) - the banner is rendered by `command_surface::help_text()` calling `style::banner_with_gradient(SCE_BANNER_LINES)` before the heading - the visible real-command rows are sourced from `cli_schema::TOP_LEVEL_COMMANDS`, so top-level purpose text and help visibility are defined once for both help rendering and known-command classification -- the visible command list is `help`, `config`, `setup`, `doctor`, `trace`, `version`, and `completion` +- the visible command list is `help`, `config`, `setup`, `doctor`, `sync`, `version`, and `completion` - top-level help omits implemented/placeholder labels - top-level examples cover setup plus doctor/version machine-readable or repair-intent flows (`doctor --format json`, `doctor --fix`, `version --format json`) and use the shared example-command styling when stdout color is enabled - `auth` and `hooks` stay parser-valid and directly invocable, but are hidden from those top-level help surfaces @@ -60,12 +60,12 @@ Deferred or gated command surfaces currently avoid claiming unimplemented behavi `setup` accepts a repeatable `--workflow ` flag selecting which optional workflows to install (currently only `brownfield`). Passing it makes the listed slugs the exact selection for that run; omitting it reuses the persisted `integrations.optional_workflows`, so a repeat run preserves an earlier opt-in. Unknown slugs fail request resolution with a validation error naming the embedded catalog's available slugs and write no files, and `--workflow` is rejected alongside `--bootstrap-context` or on a hooks-only run because neither installs target assets. The resolved selection filters the installed assets and is persisted; see [config precedence contract](config-precedence-contract.md) and [setup local bootstrap](../sce/setup-repo-local-config-bootstrap.md). An interactive `setup` run instead resolves the selection through an `inquire` multi-select shown after the target prompt, titled `Select optional workflows` with one `{title} — {description}` row per optional workflow using the shared prompt styling. Rows are unchecked when nothing is persisted and pre-checked from `integrations.optional_workflows` otherwise (a supplied `--workflow` list seeds them instead); the answered prompt is the run's exact selection. Cancelling either prompt yields the existing `Setup cancelled. No files were changed.` outcome, a non-TTY run keeps the existing actionable guidance, and the prompt is skipped when the catalog has no optional workflow. `auth` now emits auth-local guidance for bare `sce auth` and `sce auth --help`, listing `login`, `logout`, and `status` plus copy-ready next steps. -`setup`, `doctor`, `hooks`, `version`, and `completion` all support command-local `--help`/`-h` usage output via top-level parser routing in `cli/src/app.rs`. +`setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion` all support command-local `--help`/`-h` usage output via top-level parser routing in `cli/src/app.rs`. `setup` now also exposes compile-time embedded config assets for OpenCode/Claude/Pi targets, sourced from the generated `config/.opencode/**`, `config/.claude/**`, and `config/.pi/**` trees via `cli/build.rs` with normalized forward-slash relative paths and target-scoped iteration APIs; the embedded asset set includes the OpenCode bash-policy plugin wrapper plus Claude settings `PreToolUse` Bash policy hook, both delegating to the Rust `sce policy bash` path. `setup` additionally includes a repository-root install engine (`install_embedded_setup_assets`) that installs each embedded asset individually into `.opencode/`/`.claude/`/`.pi/` — stage next to the final destination, remove only that destination file if present, swap into place, with deterministic recovery guidance naming the failing asset's path on swap failure — never removing an integration target directory as a whole, while treating bash-policy enforcement files as first-class SCE-managed assets. See [setup non-destructive per-asset install policy](../sce/setup-no-backup-policy-seam.md) for the full contract, including the pending pruning gap for deselected/stale assets. `setup` now executes end-to-end and prints deterministic completion details including selected target(s) and per-target install count. -`doctor` now executes end-to-end with explicit diagnosis and repair-intent surfaces: `sce doctor` stays read-only and `sce doctor --fix` selects repair-intent mode. Agent Trace DB checkout discovery has moved out of `doctor`; `sce trace db list`, `sce trace status`, and `sce trace status --all` operate only on repository-scoped DBs (the checkout-scoped `--legacy` surface was removed by the `retire-legacy-agent-trace-db` plan); see [trace-command.md](trace-command.md). The current `doctor` runtime aggregates `ServiceLifecycle::diagnose` and `ServiceLifecycle::fix` calls across all registered service providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`) plus integration checks, covering state-root resolution, global and repo-local `sce/config.json` readability/schema validation, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, the repo hook rollout slice when a repository target is detected, and repo-root installed OpenCode, Claude, and Pi integration presence/content health for their embedded setup assets. Fix mode delegates to each provider's `fix` implementation, which reuses the canonical setup hook install flow to repair missing/stale/non-executable required hooks and missing hooks directories, and it can bootstrap missing canonical database parent directories when the resolved paths match canonical owned locations. -`sce trace sync [--format text|json]` is the implemented user-invocable sync command: it synchronizes the current repository's Agent Trace DB with the control-plane ingestion API; local DB and Agent Trace DB bootstrap continue to happen through `setup`, and DB health/repair continues to happen through `doctor`. See [agent-trace-sync-command.md](agent-trace-sync-command.md) and [trace-command.md](trace-command.md). +`doctor` now executes end-to-end with explicit diagnosis and repair-intent surfaces: `sce doctor` stays read-only and `sce doctor --fix` selects repair-intent mode. The former Agent Trace database inspection routes are unavailable; doctor owns repository-scoped Agent Trace DB health and checkout-identity diagnostics. The current `doctor` runtime aggregates `ServiceLifecycle::diagnose` and `ServiceLifecycle::fix` calls across all registered service providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`) plus integration checks, covering state-root resolution, global and repo-local `sce/config.json` readability/schema validation, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, the repo hook rollout slice when a repository target is detected, and repo-root installed OpenCode, Claude, and Pi integration presence/content health for their embedded setup assets. Fix mode delegates to each provider's `fix` implementation, which reuses the canonical setup hook install flow to repair missing/stale/non-executable required hooks and missing hooks directories, and it can bootstrap missing canonical database parent directories when the resolved paths match canonical owned locations. +`sce sync [--format text|json]` is the implemented user-invocable synchronization command: it synchronizes the current repository's Agent Trace DB with the control-plane ingestion API; local DB and Agent Trace DB bootstrap continue to happen through `setup`, and DB health/repair continues to happen through `doctor`. See [agent-trace-sync-command.md](agent-trace-sync-command.md) and [sync-command.md](sync-command.md). ## Command loop and error model @@ -89,17 +89,17 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/setup/mod.rs` defines setup parsing/selection contracts, additive `bootstrap_context_baseline`, and runtime install orchestration (`run_setup_for_mode`) over the embedded asset install engine; `cli/src/services/setup/command.rs` owns the setup runtime command handler. After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes aggregate `ServiceLifecycle::setup` calls across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`) in order, using a `ContextWithRepoRoot`-scoped context with resolved repository root. - `cli/src/services/setup/mod.rs` now keeps its larger internal responsibilities behind focused inline support modules: `install` owns repository canonicalization, staging/swap install flows, required-hook installation, and repo/writeability guards, while `prompt` owns interactive target selection and styled prompt labels. - `cli/src/services/config/mod.rs` defines config parser/runtime contracts (`show`, `validate`, `--help`), strict config-file key/type validation, deterministic text/JSON rendering, repo-configured bash-policy preset/custom validation and reporting under `policies.bash`, and shared auth-key metadata that declares env key, config-file key, and optional baked-default eligibility for supported auth runtime values starting with `workos_client_id` (`WORKOS_CLIENT_ID` vs `workos_client_id`); auth-key provenance/preference metadata stays on `show`, while `validate` stays trimmed to validation status plus issues/warnings. `cli/src/services/config/lifecycle.rs` implements `ServiceLifecycle` for config health checks and setup (global/local config validation and repo-local config bootstrap). -- `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorAction`, `DoctorMode`, `run_doctor`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, checkout-database discovery, stable text/JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, repo-root installed OpenCode, Claude, and Pi integration inventory derived from embedded setup asset catalogs, shared-style bracketed human status token rendering (`[PASS]`, `[FAIL]`, `[MISS]`) with simplified `label (path)` text rows, and repair-mode delegation to service-owned fix implementations. Claude grouping is path-based: `settings.json`/`hooks/**` as `ClaudeCode plugins` (including `.claude/hooks/run-sce-or-show-install-guidance.sh`), plus `ClaudeCode agents`, `ClaudeCode commands`, and `ClaudeCode skills`; Pi grouping is path-based: `prompts/**` as `Pi prompts` and `skills/**` as `Pi skills`. +- `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorAction`, `DoctorMode`, `run_doctor`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, repository-scoped Agent Trace DB health, stable text/JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, repo-root installed OpenCode, Claude, and Pi integration inventory derived from embedded setup asset catalogs, shared-style bracketed human status token rendering (`[PASS]`, `[FAIL]`, `[MISS]`) with simplified `label (path)` text rows, and repair-mode delegation to service-owned fix implementations. Claude grouping is path-based: `settings.json`/`hooks/**` as `ClaudeCode plugins` (including `.claude/hooks/run-sce-or-show-install-guidance.sh`), plus `ClaudeCode agents`, `ClaudeCode commands`, and `ClaudeCode skills`; Pi grouping is path-based: `prompts/**` as `Pi prompts` and `skills/**` as `Pi skills`. - `cli/src/services/version/mod.rs` defines the version parser/output contract (`parse_version_request`, `render_version`) with deterministic text/JSON output modes; `cli/src/services/version/command.rs` owns the version runtime command handler. - `cli/src/services/completion/mod.rs` defines the completion output contract (`render_completion`) using clap_complete to generate deterministic shell scripts for Bash, Zsh, and Fish; `cli/src/services/completion/command.rs` owns the completion runtime command handler. - `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `conversation-trace`; `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the enabled-by-default attribution gate with explicit opt-out controls; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path; `diff-trace` performs STDIN JSON intake, required-field validation, and best-effort AgentTraceDb insertion with tool-prefixed stored `session_id` values plus direct nullable `model_id` / `tool_version` attribution. Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `session-model` is no longer a supported hooks route. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). - `cli/src/services/resilience.rs` defines shared bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) with deterministic failure messaging and retry observability hooks. -- `cli/src/services/trace/sync.rs` implements `sce trace sync` orchestration (control-plane authentication, per-stream reconciliation, and report assembly); local DB initialization and health ownership remain split between setup and doctor. See [agent-trace-sync-command.md](agent-trace-sync-command.md). +- `cli/src/services/sync/sync.rs` implements `sce sync` orchestration (control-plane authentication, per-stream reconciliation, and report assembly); local DB initialization and health ownership remain split between setup and doctor. `cli/src/services/sync/command.rs` owns format-gated stderr progress and `cli/src/services/sync/render_sync.rs` owns text/JSON report rendering. See [agent-trace-sync-command.md](agent-trace-sync-command.md). - `cli/src/services/default_paths.rs` defines the canonical per-user persisted-location seam for config/state/cache roots plus named default file paths for current persisted artifacts (`global config`, `auth tokens`, `local DB`, `agent trace DB`) used by config discovery, token storage, database adapters, and doctor diagnostics; its internal `roots` seam now owns the platform-aware root-directory resolution so non-test production modules consume shared path accessors instead of resolving owned roots directly. - `cli/src/services/agent_trace.rs` defines the canonical Rust SCE web base URL and helpers for Agent Trace conversation URLs, persisted Agent Trace trace URLs, Agent Trace session URLs, and setup-created repo-local config schema URLs. - `cli/src/services/token_storage.rs` defines WorkOS token persistence (`save_tokens`, `load_tokens`, `delete_tokens`) via the encrypted `AuthDb` `auth_credentials` table using a `OnceLock` lazy singleton with constant integer row ID `1`. `token_file_path()` returns the auth DB path. `TokenStorageError` exposes `PathResolution` and `Database` variants. No JSON file I/O remains. - `cli/src/services/auth_command/mod.rs` defines the auth command orchestration surface (`AuthRequest`, `AuthSubcommand`, `run_auth_subcommand`) for `login`, `renew`, `logout`, and `status`, including shared text/JSON rendering, token refresh/forced renewal handling for `sce auth renew`, token-storage-backed logout deletion with path-aware remediation guidance, expiry-aware status reporting, canonical credentials-file path reporting sourced from the shared default-path seam, precedence-aware client-ID guidance sourced from the shared auth-runtime resolver instead of env-only assumptions, and a lazily initialized current-thread Tokio runtime with both I/O and time enabled so the auth flows can drive the WorkOS device/refresh paths without the prior I/O-disabled panic; `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. -- `cli/src/app.rs` parses `auth`, `config`, `setup`, `doctor`, `hooks`, `version`, and `completion` into service-owned runtime command handlers so runtime messages are sourced from domain modules instead of inline strings. +- `cli/src/app.rs` parses `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion` into service-owned runtime command handlers so runtime messages are sourced from domain modules instead of inline strings. ## Local and Agent Trace Turso adapter behavior @@ -108,7 +108,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/agent_trace_db/repository.rs` provides the sole `RepositoryAgentTraceDb = TursoDb` adapter plus `DiffTraceInsert<'_>`/`insert_diff_trace()` for parameterized writes to `diff_traces`, `AgentTraceInsert<'_>`/`insert_agent_trace()` for built `agent_traces`, and typed message/part insert helpers for conversation traces; the checkout-scoped `AgentTraceDb` adapter was removed by the `retire-legacy-agent-trace-db` plan. - Active hook runtime resolves repository-scoped DB files through config-backed repository identity and `default_paths::agent_trace_db_path_for_repository(repository_id)` via `agent_trace_storage`; checkout ID is created/reused only as diagnostic metadata. - `cli/src/services/local_db/lifecycle.rs` implements `ServiceLifecycle` for local DB health checks and setup (DB path/health validation and DB bootstrap). -- `cli/src/services/agent_trace_db/lifecycle.rs` implements `ServiceLifecycle` for Agent Trace repository storage setup, setup-time repository DB initialization, repository/global-sentinel DB path-health validation, and parent bootstrap. +- `cli/src/services/agent_trace_db/lifecycle.rs` implements `ServiceLifecycle` for Agent Trace repository storage setup, setup-time repository DB initialization, repository-scoped DB path-health validation, and parent bootstrap. - `sce setup` aggregates `ServiceLifecycle::setup` calls, which includes `LocalDbLifecycle::setup()` and `AgentTraceDbLifecycle::setup()` for DB initialization as part of local prerequisite bootstrap. - `sce doctor` aggregates `ServiceLifecycle::diagnose` and `ServiceLifecycle::fix` calls, which includes both DB lifecycle providers for DB path/health validation and can bootstrap missing canonical parent directories when repair mode is appropriate. @@ -146,4 +146,4 @@ An interactive `setup` run instead resolves the selection through an `inquire` m ## Scope boundary for this phase - This slice establishes compile-safe crate/module boundaries with implemented setup orchestration and deterministic messaging. -- Local Turso DB bootstrap and health coverage are implemented through `setup` and `doctor`; `sce trace sync` command wiring is implemented and documented in [agent-trace-sync-command.md](agent-trace-sync-command.md). +- Local Turso DB bootstrap and health coverage are implemented through `setup` and `doctor`; `sce sync` command wiring is implemented and documented in [agent-trace-sync-command.md](agent-trace-sync-command.md). diff --git a/context/cli/config-precedence-contract.md b/context/cli/config-precedence-contract.md index 59ef766ee..887ea693c 100644 --- a/context/cli/config-precedence-contract.md +++ b/context/cli/config-precedence-contract.md @@ -142,7 +142,7 @@ When a default-discovered global or repo-local config file exists but fails JSON - Auth failure guidance for migrated auth keys no longer assumes env-only configuration. - Missing-client-id guidance for `workos_client_id` describes the full allowed chain for this key: `WORKOS_CLIENT_ID`, config-file key `workos_client_id`, or fallback to the baked default when no higher-precedence invalid override blocks it. - Auth login runtime guidance refers to the resolved source chain generically (`WORKOS_CLIENT_ID`, config file, or baked default for `workos_client_id`) instead of env-only wording. -- `control_plane_base_url` resolves through the same shared auth-adjacent key path but has no dedicated auth failure guidance of its own; it is consumed by the Agent Trace control-plane client (`sce trace sync`). +- `control_plane_base_url` resolves through the same shared auth-adjacent key path but has no dedicated auth failure guidance of its own; it is consumed by the Agent Trace control-plane client (`sce sync`). ## Related files diff --git a/context/cli/styling-service.md b/context/cli/styling-service.md index eadb978e0..903157609 100644 --- a/context/cli/styling-service.md +++ b/context/cli/styling-service.md @@ -5,7 +5,6 @@ The CLI styling service in `cli/src/services/style.rs` provides deterministic te ## Dependencies - `owo-colors` - Color styling with automatic TTY detection -- `comfy-table` - Table rendering for tabular output ## API @@ -14,15 +13,11 @@ The CLI styling service in `cli/src/services/style.rs` provides deterministic te - `supports_color() -> bool` - Returns `true` if stdout is a TTY and `NO_COLOR` is not set - `supports_color_stderr() -> bool` - Returns `true` if stderr is a TTY and `NO_COLOR` is not set -### Table Rendering - -- `table() -> Table` - Creates a new `comfy_table::Table` instance for tabular output -- `create_table(headers: &[&str]) -> Table` - Creates a styled table with compact preset (no borders), applies cyan/bold header styling when color is enabled, and returns a table ready for row additions - ### Conditional Styling - `style_if_enabled(text: &str, f: F) -> String` - Applies styling function only when colors are enabled - `style_if_enabled_stderr(text: &str, f: F) -> String` - Applies styling function only when stderr colors are enabled +- `success_with_stderr_color_policy(text: &str, color_enabled: bool) -> String` - Internal helper for applying the shared green/bold stderr success policy when a caller already resolved the color decision ### Help Output Styling @@ -61,15 +56,27 @@ The CLI styling service in `cli/src/services/style.rs` provides deterministic te - Error diagnostics use `supports_color_stderr()` for stderr TTY detection - Top-level app diagnostics and observability log-file write failures both render through the shared stderr styling helpers when stderr color is enabled. +## Sync progress styling + +The sync-owned `cli/src/services/sync/progress.rs` presentation consumer keeps human progress on +`stderr` and uses `supports_color_stderr()` plus `NO_COLOR` for its completion +marker. The presentation adapter owns the `indicatif` multi-progress rows; +this service owns the shared green/bold completion-marker policy through +`success_with_stderr_color_policy(...)`. Spinner rows use plain text when +stderr is redirected or color is disabled; non-TTY snapshots never emit ANSI +or terminal-control sequences. JSON output remains silent on the +human-progress channel. +The generic reporter contract and its no-op implementation remain owned by the +sync progress module; this styling service owns only the shared color policy. + ## Re-exports - `pub use owo_colors::OwoColorize` - Trait for color styling methods on strings -- `pub use comfy_table::Table` - Table type for tabular output ## Usage ```rust -use crate::services::style::{heading, command_name, error_code, error_text, success, label, value, prompt_label, prompt_value, create_table, supports_color}; +use crate::services::style::{heading, command_name, error_code, error_text, success, label, value, prompt_label, prompt_value, supports_color}; // Help output styling println!("{}", heading("Usage:")); @@ -86,12 +93,6 @@ println!("{} {}", label("Repository root:"), value("'/path/to/repo'")); println!("{} {}", prompt_label("Open in browser:"), prompt_value("https://example.com")); println!("{} {}", prompt_label("Code:"), prompt_value("ABCD-EFGH")); -// Table output styling -let mut table = create_table(&["Command", "Status", "Purpose"]); -table.add_row(vec!["setup", "implemented", "Prepare local repository prerequisites"]); -table.add_row(vec!["sync", "placeholder", "Coordinate future cloud sync workflows"]); -println!("{}", table); - // Conditional styling if supports_color() { println!("{}", "Success".green()); diff --git a/context/cli/sync-command.md b/context/cli/sync-command.md new file mode 100644 index 000000000..5f5070cba --- /dev/null +++ b/context/cli/sync-command.md @@ -0,0 +1,101 @@ +# sce sync command + +`sce sync [--format text|json]` is the only user-invocable synchronization +command. It synchronizes the current repository's Agent Trace database with the +control-plane ingestion API. The former `sce trace` command group and its +database discovery, shell, list, status, and nested sync invocations are no +longer available; no compatibility alias is retained. + +The Clap surface is defined in `cli/src/cli_schema.rs` and dispatched through +the static `RuntimeCommand::Sync` variant. The sync-owned command boundary lives +under `cli/src/services/sync/`; shared storage, export, authentication, and +control-plane protocol infrastructure remains in their existing services. +Sync orchestration owns its `SyncProgressEvent` lifecycle, batch, and +stream-completion payloads and publishes them through the consumer-typed, +library-independent `services::sync::progress::ProgressReporter` contract. +Reporters may collect events or discard them with the no-op implementation; +the same sync-owned module supplies terminal presentation through that contract +rather than the synchronization algorithm importing terminal-library details. +There is no top-level `services::progress` module or cross-command progress +framework. +Text execution explicitly finalizes the reporter only after a successful sync; +failure paths retain their existing termination behavior, and the final sync +report remains owned by `render_sync` rather than the progress adapter. + +## User flow + +``` +sce auth login # obtain and store WorkOS credentials +cd # any directory inside the target Git repository +sce sync # synchronize this repository's Agent Trace DB +``` + +`sce sync --format json` produces the same synchronization with a machine- +readable stdout payload. Text mode creates the aligned multi-progress display +on stderr before stream batches begin; JSON mode emits no human progress or +lifecycle text. + +## Composed data flow + +```mermaid +flowchart LR + A[hooks / plugins] --> B[repository Agent Trace DB] + B --> C[AgentTraceExportReader] + C --> D[sce sync] + D -- "HTTPS + WorkOS Bearer" --> E[control plane
sce.crocoderlab.dev by default] +``` + +The command resolves repository storage through `agent_trace_storage`, builds an +`AuthenticatedControlPlaneClient` from stored WorkOS credentials and the +resolved `control_plane_base_url`, then performs one authoritative `/state` +request before starting the four concurrent stream state machines: +`messages`, `parts`, `diff_traces`, and `agent_traces`. Batches and cursor +refreshes remain sequential within each stream, and final reporting retains the +fixed stream order. + +The control plane is the sole cursor authority. Sync creates no local cursor, +`agent-trace-sync.db`, Turso Sync state, `BridgeLock`, or local data warehouse. +Repeated invocations are naturally incremental because each run starts from the +authoritative control-plane cursors. + +## Output contract + +The final text report contains only a completion heading after the progress +display. It says `Agent Trace already synced.` when all four streams uploaded +zero rows; otherwise it says `Agent Trace sync complete.`. During text mode, +four progress rows are created immediately +in the fixed order +`messages`, `parts`, `diff_traces`, `agent_traces`. Each row uses a 15-column +stream-label field, starts at `0 rows uploaded`, and has its own steady spinner. +Accepted batches update only their stream's cumulative count. A stream replaces +its spinner with a styled `✓` and its final count as soon as that stream's +sync future completes. Redirected/non-TTY stderr uses stable aligned plain +snapshots without ANSI or terminal-control sequences, while `NO_COLOR` also +disables the completion styling. JSON mode uses the no-op human-progress +sink, emits no progress on stderr, and emits this JSON-only stdout shape: + +```json +{ + "status": "ok", + "command": "sync", + "streams": { + "messages": {"uploaded": 0, "initialCursor": 0, "finalCursor": 0, "batches": 0}, + "parts": {"uploaded": 0, "initialCursor": 0, "finalCursor": 0, "batches": 0}, + "diffTraces": {"uploaded": 0, "initialCursor": 0, "finalCursor": 0, "batches": 0}, + "agentTraces": {"uploaded": 0, "initialCursor": 0, "finalCursor": 0, "batches": 0} + } +} +``` + +Authentication refresh, conflict reconciliation, ambiguous batch recovery, +terminal protocol failures, ownership rejection, and sanitized control-plane +errors remain owned by `services::agent_trace_sync` and its control-plane +client. The command change does not alter those semantics. + +## Related context + +- [Agent Trace sync architecture](agent-trace-sync-command.md) +- [Agent Trace storage](agent-trace-storage.md) +- [Agent Trace export readers](../sce/agent-trace-export-readers.md) +- [CLI stdout/stderr contract](../sce/cli-stdout-stderr-contract.md) +- [Trace-sync progress stream contract](../decisions/2026-08-13-trace-sync-progress-stream-contract.md) diff --git a/context/cli/trace-command.md b/context/cli/trace-command.md deleted file mode 100644 index dfad706c0..000000000 --- a/context/cli/trace-command.md +++ /dev/null @@ -1,95 +0,0 @@ -# sce trace command - -Top-level CLI command group exposing Agent Trace database visibility for operators. - -Lives under `cli/src/services/trace/` with these subcommands: - -- `sce trace db list` — discover repository-scoped Agent Trace DBs under `/sce/repos//agent-trace.db`. -- `sce trace db shell [repository-id-or-alias]` — open an embedded in-process SQL shell for the current repository DB by default, or a discovered repository DB by alias/repository ID. -- `sce trace status` — render counts and last-activity for the current repository-scoped DB. -- `sce trace status --all` — aggregate counts across every discovered repository DB. -- `sce trace sync [--format text|json]` — synchronize the current repository's Agent Trace DB with the control-plane ingestion API (see [Sync — `services::trace::sync`](#sync--servicestracesync) below). - -`sce trace` operates only on repository-scoped DBs; there is no `--legacy` flag. The `retire-legacy-agent-trace-db` plan removed checkout-scoped discovery/status/shell access. Any pre-migration `/sce/agent-trace-*.db` files left on disk are never touched by SCE and are no longer inspectable through the CLI. - -The list/status subcommands declare `--format text|json` via `services::output_format::OutputFormat`; `db shell` is interactive and uses standard input/output directly after successful resolution. Clap surface is defined in `cli/src/cli_schema.rs` (`Commands::Trace`, `TraceSubcommand`, `TraceDbSubcommand`) and dispatched through `services::command_registry` to `services::trace::command::TraceCommand`. - -## Implemented behavior - -### Discovery — `services::trace::discovery` - -`discover_agent_trace_dbs()` scans `/sce/repos/*/agent-trace.db`, sorts by file mtime descending (ties broken by repository ID ascending), and assigns positional `agent_trace_{N}` aliases. Each entry carries an mtime-derived `SystemTime`, a `DiscoveredAgentTraceDbKind::Repository { repository_id }`, and a `Readiness` verdict (`Ready` or `Skipped { missing_table }`). There is no checkout-scoped discovery kind or scanner; the `retire-legacy-agent-trace-db` plan removed `DiscoveredAgentTraceDbKind::LegacyCheckout` and `discover_legacy_agent_trace_dbs*`. SCE never migrates, imports, renames, deletes, or backfills any pre-migration checkout-scoped files into repository-scoped databases. - -Readiness is probed read-only via the shared Agent Trace DB open-without-migrations path and a `sqlite_master` lookup for each required table in declared order: - -``` -diff_traces -post_commit_patch_intersections -agent_traces -messages -parts -``` - -The first missing table is reported as the skip reason. Discovery returns an empty Vec when the scanned directory does not exist. - -`resolve_agent_trace_db_identifier(databases, identifier)` accepts either an `agent_trace_N` alias or the discovered database's repository ID, returns a cloned ready `DiscoveredAgentTraceDb`, rejects unknown/ambiguous identifiers with guidance to run `sce trace db list`, and rejects skipped databases with the stored missing-table readiness reason. - -### Embedded shell core — `services::trace::shell` - -`run_agent_trace_db_shell(target, input, output)` opens the resolved repository-scoped Agent Trace DB path in-process without running migrations (via `RepositoryAgentTraceDb::open_for_hooks_without_migrations_at`), verifies schema readiness, prints alias, scope (`repository`), identifier, and database path, then runs a minimal SQL shell over caller-provided `BufRead`/`Write` streams. The core supports `.help`, `.tables`, `.exit`, and `.quit`, splits single-line input on semicolons, executes query statements through `TursoDb::query_values`, executes non-query statements through `execute`, and renders deterministic text rows. - -Default `sce trace db shell` resolves the current repository-scoped DB through the same storage context used by hook runtime. `sce trace db shell ` resolves a discovered repository DB by alias or repository ID. The shell is embedded-only and never shells out to `turso`, `sqlite3`, or another external database CLI. - -### `sce trace db list` rendering — `services::trace::render_list` - -Text output is `services::style::heading("SCE trace db list")` followed by a padded table with `Alias`, `Scope`, `ID`, `Status`, `Updated at`, and `Path`. Empty-state output is the heading plus `no agent-trace databases discovered`. - -JSON output shape: - -```json -{ - "status": "ok", - "command": "trace", - "subcommand": "db.list", - "databases": [ - { - "alias": "agent_trace_0", - "scope": "repository", - "identifier": "", - "path": "/.../repos//agent-trace.db", - "status": "ready", - "updated_at": "2026-06-27T12:34:56+00:00" - } - ] -} -``` - -`skip_reason` is omitted when `status == "ready"`. Text `Updated at` is rendered as `YYYY-MM-DD HH:MM:SS UTC`; JSON `updated_at` is RFC3339. - -### `sce trace status` resolution/rendering — `services::trace::status`, `render_status` - -`resolve_current_status(repo_root)` resolves config-backed Agent Trace storage (`agent_trace.repository_id` or configured remote, default `origin`) through `agent_trace_storage`, creating/reusing checkout identity for diagnostics and selecting `/sce/repos//agent-trace.db`. It probes schema readiness and, when ready, collects row counts and last-activity via `services::trace::stats::collect_agent_trace_db_stats`. - -Text output includes `Repository: `, then checkout ID, database path, readiness, row counts, and last activity. JSON includes `repository_id`, `checkout_id`, `database_path`, `db_status`, `stats` for ready DBs, and `skip_reason` for skipped DBs. - -### `sce trace status --all` aggregation/rendering — `services::trace::status_all`, `render_status_all` - -`aggregate_current_status_all()` resolves `/sce/` and delegates to repository discovery. It runs `collect_agent_trace_db_stats` on each ready DB and accumulates totals for `diff_traces`, `messages`, `parts`, `agent_traces`, `post_commit_patch_intersections`, and max `last_activity`. Skipped DBs are excluded from totals but included in discovery summary and breakdown rows. - -Text rendering shows discovery summary, totals, and a `By database` table with `Alias`, `Scope`, `ID`, `Status`, and count columns. JSON entries use `scope` (`repository`) and `identifier`. - -### Sync — `services::trace::sync`, `render_sync` - -`run_current_sync(repo_root)` resolves the current repository's Agent Trace storage through the same `agent_trace_storage` path `sce trace status` uses (not the hook-runtime resolver), builds an `AuthenticatedControlPlaneClient` from the resolved `control_plane_base_url`/`workos_client_id` config, and uses `https://sce.crocoderlab.dev` as the baked control-plane base when no override is configured. This control-plane host is separate from the SCE web/schema URL owned by `SCE_WEB_BASE_URL`. Sync calls the control-plane `/agent-trace/ingestion/state` endpoint once, then starts four independent capture-stream state machines concurrently (`messages`, `parts`, `diff_traces`, `agent_traces`) via the local `AgentTraceExportReader` and the shared per-stream reconciliation engine. Batches and cursor-refresh calls remain sequential within each stream; fixed stream order applies to final reports and stream-completion reporting, not network execution. The authenticated client coalesces concurrent refreshes for the same expired or rejected access token while allowing valid-token requests to proceed without refresh coordination. A genuinely ambiguous batch outcome (`5xx`, transport failure, invalid response) reconciles by refetching `/state`; a terminal control-plane failure (missing/invalid credentials, `400`, `403`) fails the stream immediately without an extra network call, so a `403` never mutates local repository metadata or retries. No local sync cursor, cursor file, or database is created — every invocation starts from the authoritative `/state` cursors, so repeated runs are naturally incremental. - -`render_sync::render(report, format)` renders the converged `AgentTraceSyncReport`. In text mode, sync first emits deterministic live progress lines to `stderr`: a start line, one line for each accepted batch with the stream, batch size, cumulative uploaded rows, and server cursor, then one completion line per stream; an empty stream reports that no new rows were uploaded. These lines are newline-delimited and flushed as events arrive, so a slow upload is observable before the final report. The start line includes an injected-clock UTC RFC3339 timestamp and is emitted before the initial `/state` request; a terminal end line is emitted after successful completion or failure, preserving the classified error returned to the app. The final text output remains a `style::heading("Agent Trace sync complete.")` line, `Repository ID:`/`Source instance ID:` lines, then a padded table with one row per stream (`Stream`, `Uploaded`, `Final cursor`) in the fixed `messages → parts → diff_traces → agent_traces` order — no per-batch or per-row detail is added to the `stdout` payload. JSON mode uses a no-op progress sink and emits no human progress or lifecycle timestamps; its `stdout` output carries `status`, `command`, `subcommand`, `repositoryId`, `sourceInstanceId`, and `streams.{messages,parts,diffTraces,agentTraces}`, each with `uploaded`/`initialCursor`/`finalCursor`/`batches`; the JSON stream keys are camelCase (`diffTraces`/`agentTraces`) even though the internal `StreamSyncReports` struct fields are `diff_traces`/`agent_traces`. `TraceCommand::execute` dispatches `TraceSubcommandRequest::Sync { format }` through the format-specific progress sink and then to `render_sync::render`, completing the command surface end to end. - -## Related context - -- [agent-trace-sync-command.md](agent-trace-sync-command.md) — composed local-to-control-plane sync architecture, user flow, no-local-persistence invariants, and recovery semantics. -- [agent-trace-storage.md](agent-trace-storage.md) — repository-scoped storage resolver and active DB path contract. -- [checkout-identity.md](checkout-identity.md) — checkout identity diagnostics and never-touch on-disk handling of pre-migration DB files. -- [default-path-catalog.md](default-path-catalog.md) — Agent Trace DB path ownership. -- [styling-service.md](styling-service.md) — heading helper used by text renderers. -- [Trace-sync progress stream contract](../decisions/2026-08-13-trace-sync-progress-stream-contract.md) — stderr progress/timestamps and stdout/JSON compatibility boundary. -- [../sce/agent-trace-db.md](../sce/agent-trace-db.md) — Agent Trace DB schema and migration ownership. diff --git a/context/context-map.md b/context/context-map.md index b3c9b772b..3f40802bf 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -9,16 +9,16 @@ Primary context files: Feature/domain context: -- `context/cli/cli-command-surface.md` (CLI command surface including top-level help with ASCII art banner and gradient rendering, setup install flow with the repeatable `sce setup --workflow ` optional-workflow selection and its interactive post-target multi-select, WorkOS device authorization flow + token storage behavior, attribution-only hook routing with validated post-commit `--remote-url` plumbing plus DB-backed `diff-trace` dual persistence and post-commit Agent Trace payload persistence including range `content_hash`, setup-owned local DB + repository-scoped Agent Trace DB bootstrap plus doctor DB health coverage with credential-safe repository identity diagnostics, centralized Rust SCE web URL helpers in `services::agent_trace`, nested flake release package/app installability, Cargo local install + crates.io readiness policy, and hidden `sce policy bash` command adapter for bash-policy hook callers; implemented `sce trace sync` command wiring synchronizing the current repository's Agent Trace DB with the control plane; static `RuntimeCommand` enum dispatch lives in `services/command_registry.rs`, command payload structs for help/version/completion/auth/config/setup/doctor/hooks/policy are owned by their respective `services/{name}/command.rs` files, and clap-to-runtime conversion lives in `services/parse/command_runtime.rs`) +- `context/cli/cli-command-surface.md` (CLI command surface including top-level help with ASCII art banner and gradient rendering, setup install flow with the repeatable `sce setup --workflow ` optional-workflow selection and its interactive post-target multi-select, WorkOS device authorization flow + token storage behavior, attribution-only hook routing with validated post-commit `--remote-url` plumbing plus DB-backed `diff-trace` dual persistence and post-commit Agent Trace payload persistence including range `content_hash`, setup-owned local DB + repository-scoped Agent Trace DB bootstrap plus doctor DB health coverage with credential-safe repository identity diagnostics, centralized Rust SCE web URL helpers in `services::agent_trace`, nested flake release package/app installability, Cargo local install + crates.io readiness policy, hidden `sce policy bash` command adapter for bash-policy hook callers, and top-level `sce sync` command wiring for current-repository Agent Trace synchronization; static `RuntimeCommand` enum dispatch lives in `services/command_registry.rs`, command payload structs for help/version/completion/auth/config/setup/doctor/hooks/policy/sync are owned by their respective `services/{name}/command.rs` files, and clap-to-runtime conversion lives in `services/parse/command_runtime.rs`) - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted auth/config files, named DB paths for auth/local/repository-scoped Agent Trace databases, the default observability log-dir accessor consumed by config resolution with Linux `${XDG_STATE_HOME:-~/.local/state}/sce/logs` fallback semantics, repo-relative, embedded-asset, install, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/repository-identity.md` (repository identity module in `cli/src/services/repository_identity/`: pure scheme-neutral `host[:port]/path` canonicalization for SCP/`ssh://`/HTTPS/`git://` remote URLs with credential stripping, hostname lowercasing, default-port removal, and query/fragment/trailing-`.git` cleanup, trim-only explicit-identity handling, `sha256("sce-repository-id-v1\0" + canonical_identity)` repository IDs, credential-safe fieldless errors, plus the `resolve` runtime submodule applying explicit-config-then-configured-remote precedence with `git config --get remote..url` lookup, `RepositoryIdentitySource` provenance, and `.sce/config.json`-guidance resolution errors that never echo URLs; consumed by the T04 `agent_trace_storage` resolver) - `context/cli/agent-trace-storage.md` (repository-scoped Agent Trace storage resolver in `cli/src/services/agent_trace_storage/`: `AgentTraceStorageContext` inputs mirroring the `agent_trace.*` config keys, `ResolvedAgentTraceStorage` carrying repository identity + checkout ID + `/sce/repos//agent-trace.db` path + open `RepositoryAgentTraceDb` + typed `RepositoryMetadata`, `resolve_agent_trace_storage{,_at_state_root}` setup/lifecycle entrypoints with idempotent concurrent-safe first open via bounded fast-path-then-migrate retry plus narrow one-file schema migration-metadata repair and repository metadata validation, a separate no-migration `resolve_agent_trace_storage_for_hook_runtime{,_at_state_root}` pair that high-frequency hook callers use exclusively and that never runs migration `002` or any migration, path-unsafe repository ID rejection in `default_paths::agent_trace_db_path_for_repository{,_at}`, strict never-touch boundary for any pre-migration checkout-scoped/global DB files, and active hook/runtime plus Agent Trace lifecycle setup call-site consumption after T08) - `context/cli/checkout-identity.md` (current checkout identity infrastructure in `cli/src/services/checkout/`, including `/sce/checkout-id` UUIDv7 storage, setup/hook integration that creates/reuses checkout identity as repository-scoped Agent Trace diagnostic metadata, the removed per-checkout DB opener/path helper, `sce doctor` checkout identity display, and the never-touch on-disk handling of pre-migration checkout-scoped DB files that are no longer inspectable via the CLI) - `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, and set operations in `cli/src/services/patch.rs` for in-memory parsed unified-diff representation, capturing only touched lines plus minimal per-file/per-hunk metadata, supporting both `Index:` SVN-style and `diff --git` git-style formats, with `ParseError` for actionable malformed-input diagnostics, `PatchLoadError`/`load_patch_from_json`/`load_patch_from_json_bytes` for storage-agnostic JSON reconstruction, `intersect_patches` for target-shaped overlap with exact-match-first and historical `kind`+`content` fallback semantics plus matched-constructed-line `session_id` and matched-constructed-hunk `model_id` provenance inheritance, and `combine_patches` for ordered patch combination with later-wins conflict resolution plus winning-hunk `model_id` provenance inheritance; `parse_patch`, `intersect_patches`, and `combine_patches` are consumed by the active post-commit hook runtime) - `context/cli/structured-patch-service.md` (Claude structured editor-hook derivation in `cli/src/services/structured_patch.rs`, including `Write` structured-update hunks, `Write` `tool_input.content` create fallback, `Edit` structured patches, deterministic skip reasons, `ParsedPatch` output semantics, and Rust golden fixture coverage) -- `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors` and `comfy-table`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces, and per-column right-to-left RGB gradient banner rendering) -- `context/cli/trace-command.md` (`sce trace` command group: repository-scoped-only discovery of `/sce/repos//agent-trace.db` with mtime-desc + repository-id tiebreak alias assignment and required-table readiness probing, no `--legacy` flag or checkout-scoped access, implemented `sce trace db shell` current-repository opening plus alias/repository-ID resolution without external `turso`, implemented `sce trace db list` text + JSON rendering using `services::style::heading` with scope/identifier fields, implemented repository-scoped `sce trace status` with checkout ID diagnostics, implemented `sce trace status --all` aggregation across discovered repository DBs, implemented `sce trace sync` control-plane synchronization orchestration with deterministic text-mode `stderr` progress and unchanged JSON/text rendering contracts, and the completed removal of `sce doctor dbs` whose discovery scan/rendering moved into `services::trace`) -- `context/cli/agent-trace-sync-command.md` (composed local-to-control-plane `sce trace sync` architecture: the `hooks/plugins → repository Agent Trace DB → AgentTraceExportReader → sce trace sync → HTTPS + WorkOS Bearer → control plane` data flow, the `sce auth login` / `cd ` / `sce trace sync` user flow, the no-local-cursor/no-`agent-trace-sync.db`/no-Turso-Sync/no-`BridgeLock`/no-local-DWH invariants, and `401`/`409`/ambiguous-batch-failure recovery semantics) +- `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces including sync completion markers, and per-column right-to-left RGB gradient banner rendering) +- `context/cli/sync-command.md` (the top-level `sce sync` command: repository-scoped Agent Trace storage resolution, WorkOS-authenticated four-stream control-plane synchronization through the sync-owned consumer-typed `services::sync::progress` reporter contract with sync-owned events, its generic/no-op contract and `indicatif` presentation adapter for aligned stderr progress with independent stream completion, explicit successful finalization, JSON stdout silence, and rejection of the removed `sce trace` command group) +- `context/cli/agent-trace-sync-command.md` (composed local-to-control-plane `sce sync` architecture: the `hooks/plugins → repository Agent Trace DB → AgentTraceExportReader → sce sync → HTTPS + WorkOS Bearer → control plane` data flow, the `sce auth login` / `cd ` / `sce sync` user flow, the no-local-cursor/no-`agent-trace-sync.db`/no-Turso-Sync/no-`BridgeLock`/no-local-DWH invariants, and `401`/`409`/ambiguous-batch-failure recovery semantics) - `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, focused `config/render.rs` ownership for `show`/`validate` text+JSON output construction, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys including `log_dir` / `SCE_LOG_DIR` with `/sce/logs` defaulting plus config-file/default-only positive `log_file_retention_limit`, config-file-only `agent_trace.repository_id`/`agent_trace.repository_remote` repository-identity keys with default remote `origin`, the catalog-derived `integrations.optional_workflows` optional-workflow selection key, JSON-pointer-prefixed schema-validation errors, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract including `policies.attribution_hooks.enabled` default-true/explicit-false opt-out metadata, config-file selection order, `show` provenance output, and trimmed `validate` output contract) - `context/cli/capability-traits.md` (current broad CLI capability seam in `cli/src/services/capabilities.rs`, including `FsOps`/`StdFsOps`, `GitOps`/`ProcessGitOps`, git root/hooks resolution behavior, compile-time-typed borrowed AppContext wiring with associated-type narrow capability accessors plus `ContextWithRepoRoot` repo-root-scoped context derivation, generic command execution bounds, and test-only unimplemented stubs; current service internals do not consume fs/git traits until later lifecycle migration tasks) - `context/cli/service-lifecycle.md` (current compile-safe lifecycle seam in `cli/src/services/lifecycle.rs`, including default no-op `ServiceLifecycle` diagnose/fix/setup methods against narrow `HasRepoRoot`, lifecycle-owned health/fix/setup result types with generic setup messages, doctor/setup adapter boundaries, the static `LifecycleProvider` enum catalog/dispatcher, hook/config/local_db/auth_db/agent_trace_db lifecycle providers including setup-time repository-scoped Agent Trace DB initialization plus checkout identity diagnostics, implemented doctor aggregation over diagnose/fix providers, and implemented setup aggregation over `setup` providers in order config → local_db → auth_db → agent_trace_db → hooks when requested) @@ -120,4 +120,6 @@ Recent decision records: - `context/decisions/2026-08-10-agent-trace-source-instance-id.md` (adds `source_instance_id` as a physical-database-lineage identity on `repository_metadata`, independent of and never derived from `repository_id`; concurrency-safe atomic claim; local storage identity only, no remote-ingestion architecture designed) - `context/decisions/2026-08-11-separate-control-plane-and-sce-web-urls.md` (separates the dedicated control-plane sync default from the SCE web and config-schema URL owner while preserving the existing override seam and ingestion contract) - `context/decisions/2026-08-13-trace-sync-progress-stream-contract.md` (keeps trace-sync progress and lifecycle timestamps on stderr while preserving stdout payload and JSON silence) +- `context/decisions/2026-08-18-consumer-typed-progress-reporter-boundary.md` (keeps the reusable reporter contract generic over consumer event types while sync owns `SyncProgressEvent`) +- `context/decisions/2026-08-18-sync-owned-progress-reporter-contract.md` (makes `services::sync::progress` the sole owner of the generic progress contract, no-op reporter, sync adapter, and focused tests; no top-level progress service remains) - `context/decisions/2026-08-07-git-hook-managed-block-cooperation.md` (SCE-installed git hooks are a bounded in-place editor, not an exclusive owner: hook ownership is decided structurally by the SCE managed-block marker pair or a legacy guidance-URL marker, a foreign hook's bytes are preserved as an exact prefix with the block appended after them, and coexistence with third-party hook managers is cooperative, not authoritative) diff --git a/context/glossary.md b/context/glossary.md index ebd917802..b932e9e7a 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -39,9 +39,9 @@ - `checkout identity`: Stable UUIDv7 identifier assigned to a cloned repository or linked Git worktree, stored in `/sce/checkout-id` (never committed) and resolved via `git rev-parse --git-dir`. The identity is created or reused by `sce setup` through `AgentTraceDbLifecycle::setup()` and also auto-created by hook runtime when `sce setup` has not been run. Checkout identity is now diagnostic metadata for repository-scoped Agent Trace storage; it does not select the active DB and is not stored on Agent Trace rows. Any pre-migration per-checkout DB files at `/sce/agent-trace-{checkout_id}.db` are never touched by SCE and are no longer inspectable via the CLI (the checkout-scoped DB surface was removed by the `retire-legacy-agent-trace-db` plan). See `context/cli/checkout-identity.md`. - `repository identity`: Stable identity of a logical Git repository used to select the active repository-scoped Agent Trace DB path `/sce/repos//agent-trace.db` through the `agent_trace_storage` resolver. Resolved by `cli/src/services/repository_identity/` with precedence: explicit `agent_trace.repository_id` config value, then the URL of the configured Git remote (`agent_trace.repository_remote`, default `origin`), otherwise an actionable `.sce/config.json` error. Remote URLs canonicalize to a scheme-neutral, credential-free `host[:port]/path` form so equivalent SSH/SCP/HTTPS remotes converge, and the repository ID is `sha256("sce-repository-id-v1\0" + canonical_identity)` hex. Distinct from `checkout identity`, which stays per clone/worktree for diagnostics. See `context/cli/repository-identity.md`. - `refresh single-flight guard`: Client-owned async coordination for Agent Trace control-plane authentication. Concurrent callers whose stored access token is expired, or whose request rejected the same token, serialize only refresh-and-save work, re-check credentials after acquiring the guard, and reuse the token saved by the first refresher; valid-token requests do not acquire the guard. -- `repository-scoped Agent Trace DB`: Active Agent Trace storage shape where one logical Git repository maps to `/sce/repos//agent-trace.db`. The current seam is `RepositoryAgentTraceDb = TursoDb` in `cli/src/services/agent_trace_db/repository.rs`, backed by the fresh multi-statement `001_repository_schema` baseline plus the additive `002_repository_source_instance_id` migration, with `repository_metadata` (`repository_id` plus `source_instance_id`) plus repository-level trace tables, no `checkout_id` columns, and typed repository-level insert helpers for diff traces, post-commit intersections, Agent Trace rows, messages, and parts. Hook runtime, Agent Trace setup/lifecycle, and `sce trace` status/list/shell flows resolve repository-scoped storage through `agent_trace_storage`. This is the sole Agent Trace DB adapter; the checkout-scoped adapter and the `sce trace --legacy` inspection surface were removed by the `retire-legacy-agent-trace-db` plan. +- `repository-scoped Agent Trace DB`: Active Agent Trace storage shape where one logical Git repository maps to `/sce/repos//agent-trace.db`. The current seam is `RepositoryAgentTraceDb = TursoDb` in `cli/src/services/agent_trace_db/repository.rs`, backed by the fresh multi-statement `001_repository_schema` baseline plus the additive `002_repository_source_instance_id` migration, with `repository_metadata` (`repository_id` plus `source_instance_id`) plus repository-level trace tables, no `checkout_id` columns, and typed repository-level insert helpers for diff traces, post-commit intersections, Agent Trace rows, messages, and parts. Hook runtime, Agent Trace setup/lifecycle, and `sce sync` resolve repository-scoped storage through `agent_trace_storage`. This is the sole Agent Trace DB adapter; the checkout-scoped adapter and former trace inspection surface were removed by the `retire-legacy-agent-trace-db` plan. - `source_instance_id`: Physical-database identity column on `repository_metadata`, independent of the logical `repository_id`. Added by the additive `002_repository_source_instance_id` migration (existing/placeholder rows default to an empty string); generated once per physical `agent-trace.db` by application code (`generate_source_instance_id()`, UUID v4 today) and validated with `is_valid_source_instance_id()` (non-empty once trimmed) — never generated in SQL and never derived from `repository_id`, remote URL, checkout ID, filesystem path, hostname, or user/workspace identity. `RepositoryAgentTraceDb::verify_or_initialize_repository_metadata` claims it with a concurrency-safe `UPDATE ... WHERE source_instance_id = ''`, so concurrent first opens of the same physical database converge on one winner and an already-valid value is never overwritten; the value stays stable across reopen and repeated `sce setup` runs. Two independently created databases for the same logical repository (for example two clones) get different `source_instance_id` values. See `context/sce/agent-trace-db.md`. -- `checkout registry` (removed): The central JSON registry at `/sce/checkout-registry.json` was removed in the `remove-checkout-registry` plan. `sce trace db list` now discovers checkouts by scanning `/sce/agent-trace-*.db` files on disk. `checkout_id`, `database_path`, and `last_seen` (from file mtime) are derived from the filesystem; `path` and `remote_url` are no longer rendered. See `context/cli/checkout-identity.md`. +- `checkout registry` (removed): The central JSON registry at `/sce/checkout-registry.json` was removed in the `remove-checkout-registry` plan. The later trace inspection surface was also removed; current checkout IDs are diagnostic metadata only and are not discovered as command targets. Any pre-migration `/sce/agent-trace-*.db` files remain untouched. See `context/cli/checkout-identity.md`. - `generated OpenCode plugin registration contract`: Current generated-config contract where `config/.opencode/opencode.json` serializes the OpenCode `plugin` field from canonical Pkl sources for SCE-managed plugins only; the current registered paths are `./plugins/sce-bash-policy.ts` and `./plugins/sce-agent-trace.ts`. Claude does not use an OpenCode-style plugin manifest; Claude bash-policy enforcement is registered through generated `.claude/settings.json` as a `PreToolUse` `Bash` command hook running `sce policy bash`. - `root Biome contract`: Repository-root formatting/linting contract owned by `biome.json`, currently scoped only to `npm/**` and the shared `config/lib/**` plugin package root with package-local `node_modules/**` excluded; the canonical execution path is the root Nix dev shell (`nix develop -c biome ...`). - `cli flake checks`: Check derivations in root `flake.nix` (`checks..cli-tests`, `cli-clippy`, `cli-fmt`), plus `pkl-generated`, `workflow-actionlint`, split npm/config-lib JS checks, and Linux-only Flatpak checks; invoked via `nix flake check` at repo root. @@ -65,12 +65,13 @@ - `cli cargo install contract`: Supported Cargo install surface for the `shared-context-engineering` crate, which installs the `sce` binary: crates.io (`cargo install shared-context-engineering --locked`) and local checkout (`./scripts/run-cli-cargo.sh install --path cli --locked`). Direct `cargo install --git` is unsupported because it has no repository pre-Cargo generation boundary. - `cli crates.io publication posture`: Current Cargo package posture in `cli/Cargo.toml` where crates.io-facing metadata is publication-ready for the `shared-context-engineering` crate, with crate-facing install guidance owned by `cli/README.md`. - `Nix performance recommendations`: Repo-local operator guidance in `AGENTS.md` covering optional user-level `~/.config/nix/nix.conf` tuning (`max-jobs = auto`, `cores = 0`) and the explicit root/admin-only boundary for `/etc/nix/nix.conf` `auto-optimise-store = true`. -- `sce` (CLI foundation): Rust binary crate at `cli/` with implemented auth command flows (`auth login|renew|logout|status`) plus auth-local bare-command guidance (`sce auth`, `sce auth --help`), implemented setup installation flow including lifecycle-aggregated local DB and Agent Trace DB bootstrap, implemented attribution-only `hooks` subcommand routing/validation entrypoints, and a fully implemented `sce trace sync` command that synchronizes the current repository's Agent Trace DB with the control plane and renders the documented text/JSON output (see `context/cli/trace-command.md`). -- `command surface contract`: The current top-level command/help catalog split where `cli/src/cli_schema.rs` owns the real clap-backed command metadata (top-level purpose text plus help visibility for `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `version`, and `completion`) and `cli/src/command_surface.rs` consumes that catalog for the custom banner/help surface plus known-command classification, while still adding the synthetic `help` row. +- `sce` (CLI foundation): Rust binary crate at `cli/` with implemented auth command flows (`auth login|renew|logout|status`) plus auth-local bare-command guidance (`sce auth`, `sce auth --help`), implemented setup installation flow including lifecycle-aggregated local DB and Agent Trace DB bootstrap, implemented attribution-only `hooks` subcommand routing/validation entrypoints, and a fully implemented top-level `sce sync` command that synchronizes the current repository's Agent Trace DB with the control plane and renders the documented text/JSON output (see `context/cli/sync-command.md`). +- `command surface contract`: The current top-level command/help catalog split where `cli/src/cli_schema.rs` owns the real clap-backed command metadata (top-level purpose text plus help visibility for `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion`) and `cli/src/command_surface.rs` consumes that catalog for the custom banner/help surface plus known-command classification, while still adding the synthetic `help` row. - `top-level help visibility metadata`: Per-command `show_in_top_level_help` metadata in `cli/src/cli_schema.rs` that controls whether a known command appears in `sce`, `sce help`, and `sce --help` without affecting direct invocation; the current hidden top-level commands are `auth`, `hooks`, and `policy`, and `cli/src/command_surface.rs` renders the curated top-level help list from that shared metadata. -- `command loop`: The `clap` derive-based parser + dispatcher in `cli/src/cli_schema.rs`, `cli/src/services/parse/command_runtime.rs`, and `cli/src/app.rs` that routes `help`, `config`, `setup`, `doctor`, `auth`, `hooks`, `policy`, `version`, and `completion`, executes implemented command flows, emits command-local help payloads for supported subcommand trees, and returns deterministic actionable errors for invalid invocation. +- `command loop`: The `clap` derive-based parser + dispatcher in `cli/src/cli_schema.rs`, `cli/src/services/parse/command_runtime.rs`, and `cli/src/app.rs` that routes `help`, `config`, `setup`, `doctor`, `auth`, `hooks`, `policy`, `sync`, `version`, and `completion`, executes implemented command flows, emits command-local help payloads for supported subcommand trees, and returns deterministic actionable errors for invalid invocation. - `RuntimeCommand seam`: Internal static command-execution abstraction in `cli/src/services/command_registry.rs` where clap-parsed commands are represented as a `RuntimeCommand` enum with variants for `Help`, `HelpText`, `Version`, `Completion`, `Auth`, `Config`, `Setup`, `Doctor`, `Hooks`, and `Policy`. The enum owns `name()` and `execute(...)` dispatch, delegating behavior to service-owned command payload structs while avoiding boxed `dyn RuntimeCommand` handles. Parsed request construction lives in `cli/src/services/parse/command_runtime.rs` when user-provided options or subcommands are required. -- `sce dependency baseline`: Current crate dependency set declared in `cli/Cargo.toml` (`anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, `uuid`, plus target-specific keyring backends). No CLI dev-dependencies are currently declared, and the baseline is validated through normal compile/test coverage. +- `sce dependency baseline`: Current crate dependency set declared in `cli/Cargo.toml` (`anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `indicatif`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, `uuid`, plus target-specific keyring backends). No CLI dev-dependencies are currently declared, and the baseline is validated through normal compile/test coverage. +- `progress reporter contract`: Library-independent, consumer-typed sync seam in `cli/src/services/sync/progress.rs` where `ProgressReporter` carries an arbitrary consumer event type, supports explicit successful finalization and closure-based collectors, and provides `NoopProgressReporter` for callers without human progress output. The sync-owned `SyncProgressEvent` in `cli/src/services/sync/sync.rs` carries lifecycle, accepted-batch, and stream-completion payloads for the four concurrent Agent Trace streams; `services::sync::progress` also owns the fixed `indicatif` terminal adapter and focused contract tests, while `sync/command.rs` selects text versus JSON behavior. No top-level `services::progress` module exists. - `local Turso adapter`: Module in `cli/src/services/local_db/mod.rs` that defines `LocalDbSpec` and exposes `LocalDb` as a `TursoDb` alias. It resolves the canonical local DB path with `local_db_path()`, currently declares zero migrations, and inherits retry-backed `new()`, `execute()`, `query()`, and `query_map()` behavior from the shared generic adapter. - `encrypted Turso adapter`: Generic adapter seam in `cli/src/services/db/mod.rs` exposed as `EncryptedTursoDb`, structurally parallel to `TursoDb` (connection, tokio runtime bridge, spec typing). Its constructor resolves the encryption key via `encryption_key::get_or_create_encryption_key(&db_path, db_name)`, which derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text before falling back to OS credential-store keyring get-or-create behavior; credential-store default registration is guarded by stable `OnceLock` plus an atomic in-progress flag so errors or panics leave initialization retryable without mutex poisoning. The adapter enables Turso local encryption with strict `aegis256` cipher selection through `turso::EncryptionOpts`, wraps encrypted local open/connect in the default DB connection-open retry policy, and runs embedded migrations after retry has produced a connection; the adapter also exposes retry-backed synchronous `execute`, `query`, `query_map`, and `run_migrations` helpers with `__sce_migrations` tracking parity. - `auth DB adapter`: Module in `cli/src/services/auth_db/mod.rs` that defines `AuthDbSpec` and exposes `AuthDb` as an `EncryptedTursoDb` alias. It resolves the canonical `/sce/auth.db` path with `auth_db_path()`, keeps encryption mandatory with `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret precedence before OS keyring fallback and no plaintext mode, and embeds ordered auth migrations where baseline SQL creates `auth_credentials` without `user_id`, with `updated_at`, and a trigger that auto-refreshes `updated_at` on row updates. Auth runtime token-storage is now wired through `cli/src/services/token_storage.rs`, which persists tokens via the `auth_credentials` table in the encrypted auth DB instead of a JSON file. @@ -94,7 +95,7 @@ - `DB query retry policy`: Retry policy used by `TursoDb::execute()`, `TursoDb::query()`, `TursoDb::query_map()`, `EncryptedTursoDb::execute()`, `EncryptedTursoDb::query()`, and `EncryptedTursoDb::query_map()` for local Turso operation retry, resolved from `policies.database_retry..query` via the `DATABASE_RETRY_CONFIG` `OnceLock` with fallback to hardcoded defaults (`5` attempts, `200ms` elapsed-attempt timeout, `25ms` initial backoff, `100ms` max backoff; default worst-case failure budget `<= 2_000ms`) through `run_with_retry_sync`. `query_map()` retries the initial query and row-fetch loop, then runs caller row mapping outside retry. - `__sce_migrations`: Per-database migration metadata table created by the shared `TursoConnectionCore` migration path behind public adapter `run_migrations()` methods; records applied migration IDs after successful execution so later setup/lifecycle initialization applies only migrations not yet recorded, while existing metadata-less DBs are brought forward by re-applying the current idempotent migration set and recording each ID. - `CLI generated migration manifest`: Build-time Rust source at `OUT_DIR/generated_migrations.rs` written by `cli/build.rs` from immediate `cli/migrations//*.sql` directories after staging SQL under `OUT_DIR/static/migrations`; constants are named from the database directory (for example `AGENT_TRACE_REPOSITORY_MIGRATIONS`, `AUTH_MIGRATIONS`), sorted by the numeric filename prefix before `_`, and embed staged SQL via `include_str!`. -- `sync command deferral` (historical): Former plan/state note that a user-invocable sync command was deferred to `0.4.0`; superseded now that `sce trace sync` is wired (see `context/cli/trace-command.md`). Local DB bootstrap and setup-time repository-scoped Agent Trace DB initialization still flow through lifecycle providers aggregated by the setup command, hook runtime still keeps a lazy repository Agent Trace DB fallback for repositories where setup has not run or schema metadata is incomplete, and DB health/repair still flows through the doctor surface. +- `sync command deferral` (historical): Former plan/state note that a user-invocable sync command was deferred to `0.4.0`; superseded first by nested `sce trace sync` and now by top-level `sce sync` (see `context/cli/sync-command.md`). Local DB bootstrap and setup-time repository-scoped Agent Trace DB initialization still flow through lifecycle providers aggregated by the setup command, hook runtime still keeps a lazy repository Agent Trace DB fallback for repositories where setup has not run or schema metadata is incomplete, and DB health/repair still flows through the doctor surface. - `CLI bounded resilience wrapper`: Shared policy in `cli/src/services/resilience.rs` (`RetryPolicy`, async `run_with_retry`, sync `run_with_retry_sync`) that applies deterministic retries/timeouts/capped backoff to transient operations, emits retry observability events, and returns actionable terminal failure guidance. The sync helper is currently wired into shared database constructors for local open/connect retry and into `TursoDb`/`EncryptedTursoDb` operation retry for `execute()`/`query()`/`query_map()`. - `setup service orchestration`: Setup execution logic in `cli/src/services/setup/command.rs` that resolves the repository root, always ensures the durable-context baseline, then for normal modes derives a repo-root-scoped `AppContext` from the runtime command context, dispatches `setup` through the static lifecycle provider catalog (config → local_db → auth_db → agent_trace_db → hooks when requested), handles interactive target selection for config asset installation, and emits deterministic success messaging per target. - `setup target flags`: Mutually-exclusive `sce setup` target selectors (`--opencode`, `--claude`, `--pi`, `--all`) that force non-interactive mode for automation; `--all` expands to opencode+claude+pi and replaced the removed `--both` flag. @@ -117,7 +118,7 @@ - `sce stderr error-code taxonomy`: Stable user-facing diagnostic code classes emitted by `cli/src/app.rs` (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) via `Error []: ...` stderr formatting. - `class-default Try guidance injection`: `cli/src/app.rs` diagnostic behavior that appends `Try:` remediation text by failure class only when an error message does not already include `Try:` guidance. - `sce observability baseline`: App-runtime logging contract in `cli/src/services/observability.rs` and `cli/src/app.rs` with config-resolved observability inputs, deterministic env-over-config-file-over-default precedence for non-flag logging keys, default-backed `log_dir` / `SCE_LOG_DIR` file routing by machine-local date plus optional session filename partitioning, creation-triggered retention of direct regular `*.log` files to 10 entries, stable lifecycle `event_id` values, and stderr primary emission. -- `sce stdout/stderr contract`: App-level stream routing contract in `cli/src/app.rs` where command success payloads are emitted on stdout only, while redacted user-facing diagnostics and text-mode `sce trace sync` progress are emitted on stderr; JSON sync emits no human progress. +- `sce stdout/stderr contract`: App-level stream routing contract in `cli/src/app.rs` where command success payloads are emitted on stdout only, while redacted user-facing diagnostics and text-mode `sce sync` progress are emitted on stderr; JSON sync emits no human progress. - `SCE_LOG_LEVEL`: Optional runtime env key for `sce` observability threshold; allowed values are `error`, `warn`, `info`, and `debug`, defaulting to `error` when unset. - `SCE_LOG_FORMAT`: Optional runtime env key for `sce` observability record format; allowed values are `text` and `json`, defaulting to `text` when unset. - `SCE_LOG_FILE`: Optional runtime env key for `sce` observability file sink path; when set, rendered observability lines are mirrored to this file path with parent-directory auto-create behavior. @@ -129,7 +130,7 @@ - `app startup phases`: Current `cli/src/app.rs` execution model that separates dependency checking, startup-context construction, runtime initialization, command parse/execute, and output rendering into named helpers while preserving the CLI's existing exit-code, stderr-diagnostic, and degraded-startup behavior; output rendering and execution-phase logging helpers live in `cli/src/services/app_support.rs`. - `RunOutcome`: Generic final render payload in `cli/src/services/app_support.rs` (`RunOutcome`) carrying a command result, optional startup diagnostic, and optional logger implementing the logger trait boundary. Production construction in `cli/src/app.rs` uses the concrete observability logger, while rendering is not hardcoded to that production type. - `AppContext`: Generic borrowed dependency view in `cli/src/app.rs` passed through static command dispatch. `AppRuntime` owns the concrete production logger, telemetry, filesystem, and git implementations; `AppContext` stores references to those dependencies plus an optional `repo_root: Option`, not owned `Arc` trait objects. Because it borrows from `AppRuntime`, `AppContext` is a lightweight, short-lived view and must not be stored long-term (e.g., in structs or across await points). The `repo_root` field is `None` at startup and command paths can derive a repo-root-scoped context with `AppContext::with_repo_root(...)` / `ContextWithRepoRoot`, preserving the borrowed runtime dependencies while attaching the resolved root. Narrow accessor traits (`HasLogger`, `HasTelemetry`, `HasFs`, `HasGit`, `HasRepoRoot`) let command and lifecycle call sites express capability requirements without depending on the full production context type; logger/telemetry/fs/git accessors use associated concrete capability types and return `&Self::{Capability}` rather than object-erased `&dyn ...` values. -- `CommandRegistry`: Static command-name catalog in `cli/src/services/command_registry.rs`; populated by `build_default_registry()` and carried by `AppRuntime` during command dispatch. It exposes deterministic command-name membership for the current top-level command catalog (`help`, `auth`, `config`, `setup`, `doctor`, `hooks`, `version`, and `completion`) while actual parsed command payloads are represented by the `RuntimeCommand` enum rather than zero-arg boxed constructors. +- `CommandRegistry`: Static command-name catalog in `cli/src/services/command_registry.rs`; populated by `build_default_registry()` and carried by `AppRuntime` during command dispatch. It exposes deterministic command-name membership for the current top-level command catalog (`help`, `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion`) while actual parsed command payloads are represented by the `RuntimeCommand` enum rather than zero-arg boxed constructors. - `ServiceLifecycle`: Compile-safe lifecycle trait seam in `cli/src/services/lifecycle.rs` with default no-op generic `diagnose`, `fix`, and `setup` methods over `C: HasRepoRoot`; it exposes lifecycle-owned health, fix, and setup result types, while doctor/setup adapt those records at orchestration boundaries before rendering command-owned output. The hooks service has `HooksLifecycle` for hook rollout diagnosis/fix/setup, the config service has `ConfigLifecycle` for global/repo-local config validation plus repo-local config bootstrap, local_db has `LocalDbLifecycle` for canonical local DB path health/bootstrap/setup, auth_db has `AuthDbLifecycle` for canonical auth DB path health/bootstrap/setup, and agent_trace_db has `AgentTraceDbLifecycle` for repository identity resolution, checkout identity setup for diagnostics, setup-time repository-scoped Agent Trace DB initialization, and repository Agent Trace DB path health/bootstrap, returning an actionable "requires a Git repository" diagnostic outside repository context (the former global parent fallback was removed by the `retire-legacy-agent-trace-db` plan). Doctor runtime aggregates the static provider catalog for `diagnose` and `fix`; setup command aggregates providers for `setup` in order (config → local_db → auth_db → agent_trace_db → hooks when requested). - `lifecycle provider catalog`: Shared factory in `cli/src/services/lifecycle.rs` (`lifecycle_providers(include_hooks)`) that returns static `LifecycleProvider` enum values in deterministic config → local_db → auth_db → agent_trace_db → hooks order, used by doctor with hooks included and by setup with hooks included only when requested. The enum owns concrete-provider dispatch for `id`, `diagnose`, `fix`, and `setup` without boxed provider trait objects or `&dyn HasRepoRoot` lifecycle context erasure. - `sce config command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/config/mod.rs` (with schema/file-parsing delegated to `cli/src/services/config/schema.rs`, runtime precedence resolution delegated to `cli/src/services/config/resolver.rs`, and text/JSON output construction delegated to `cli/src/services/config/render.rs`), exposing `show`, `validate`, and `--help` for deterministic runtime config inspection and validation; `show` reports resolved flat logging observability values with provenance, while `validate` reports pass/fail plus validation issues and warnings only. @@ -139,7 +140,7 @@ - `sce shared output-format contract`: Canonical parser contract in `cli/src/services/output_format.rs` (`OutputFormat`) that centralizes supported `--format` values (`text`, `json`) and emits command-specific actionable invalid-value guidance (`Run ' --help' ...`) for commands wired to dual-output rendering. - `sce shell completion contract`: Deterministic CLI completion contract where `sce completion --shell ` emits parser-aligned Bash/Zsh/Fish completion scripts for current top-level commands and supported options/subcommands. - `CLI default path catalog`: Canonical production path-ownership contract in `cli/src/services/default_paths.rs`; it is the shared owner for non-test production CLI path definitions, covering per-user persisted paths plus repo-relative, install, hook, and context-path definitions used by production CLI code; build-time payload paths are `OUT_DIR`-owned and excluded. -- `SCE web URL owner`: Rust service module at `cli/src/services/agent_trace.rs`; owns `SCE_WEB_BASE_URL = "https://sce.crocoder.dev"` and helper functions for SCE-owned URL construction, including Agent Trace conversation URLs, persisted Agent Trace trace URLs, Agent Trace session URLs, and setup-created config schema URLs. It does not own the `control_plane_base_url` used by `sce trace sync`, whose baked default is `https://sce.crocoderlab.dev`. +- `SCE web URL owner`: Rust service module at `cli/src/services/agent_trace.rs`; owns `SCE_WEB_BASE_URL = "https://sce.crocoder.dev"` and helper functions for SCE-owned URL construction, including Agent Trace conversation URLs, persisted Agent Trace trace URLs, Agent Trace session URLs, and setup-created config schema URLs. It does not own the `control_plane_base_url` used by `sce sync`, whose baked default is `https://sce.crocoderlab.dev`. - `CLI capability traits`: Broad capability seam in `cli/src/services/capabilities.rs` consumed by the borrowed, compile-time-typed `AppContext`. `FsOps`/`StdFsOps` wrap filesystem operations and `GitOps`/`ProcessGitOps` wrap git process execution plus repository-root and hooks-directory resolution; current service internals do not consume them directly yet. - `FsOps`: Filesystem capability trait in `cli/src/services/capabilities.rs` with `read_file`, `write_file`, `metadata`, and `exists`, implemented in production by `StdFsOps`. - `GitOps`: Git capability trait in `cli/src/services/capabilities.rs` with `run_command`, `resolve_repository_root`, `resolve_hooks_directory`, and `is_available`, implemented in production by `ProcessGitOps`. @@ -169,7 +170,7 @@ - `setup atomic-swap`: Per-file replacement choreography in `cli/src/services/setup/mod.rs` where staged content is renamed directly over an existing destination file, without ever unlinking that destination first — `fs::rename` replaces it atomically on both Unix and Windows, so a rename failure leaves the prior destination content and permissions untouched; on swap failure, the engine cleans that file's staging artifact and returns deterministic recovery guidance naming its path (recover from version control). No backup artifacts are created. Config install (`install_embedded_setup_assets`) applies this at individual-asset granularity and never removes an integration target directory as a whole; required-hook install applies it per hook file. Formerly called "setup remove-and-replace"; that name predates the removal of the pre-swap unlink step. - `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, and `conversation-trace` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id` values plus direct `model_id` and `tool_version` values (session-model fallback was removed in the `remove-session-models-direct-claude-model-id` plan). - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Claude/OpenCode editor runtimes; `session-model` is also removed from the supported hook command surface. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. -- `deferred sync command` (historical): There is no top-level `sce sync` command or `cli/src/services/sync.rs` module; the implemented sync surface is `sce trace sync` under the `trace` command group (see `context/cli/trace-command.md` and `context/cli/agent-trace-sync-command.md`). Local DB initialization and health ownership are still split between setup and doctor. +- `deferred sync command` (historical): An earlier implementation note deferred a user-invocable sync command; it was superseded first by nested `sce trace sync` and now by the top-level `sce sync` command (see `context/cli/sync-command.md` and `context/cli/agent-trace-sync-command.md`). Local DB initialization and health ownership remain split between setup and doctor. - `sce CLI onboarding guide`: Crate-local documentation at `cli/README.md` that defines runnable placeholder commands, non-goals/safety limits, and roadmap mapping to service modules. - `plan/code overlap map`: Context artifact at `context/sce/plan-code-overlap-map.md` that classifies the thin OpenCode Plan/Code agents, `/change-to-plan`, `/next-task`, `/validate`, and their phase skills into routing, orchestration, and behavior ownership boundaries. - `SCE dedup ownership table`: Context artifact at `context/sce/dedup-ownership-table.md` that assigns one canonical owner per shared behavior domain, lists reference-only consumers, and labels each overlap as `intentional/keep` or `dedup/remove`. diff --git a/context/overview.md b/context/overview.md index 09764ea43..a14350372 100644 --- a/context/overview.md +++ b/context/overview.md @@ -2,7 +2,7 @@ This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 107-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. -It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, agent-trace hooks, bash-policy evaluation, and trace database inspection. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. +It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. The generated `/next-task` workflow persists task-level context-synchronization lifecycle state in each plan (`pending`, `synced`, or `blocked`) so unresolved task synchronization debt survives a session boundary and gates new implementation. Successful `/next-task` execution hands task synchronization an explicit, pre-edit-Git-baseline-relative changed-file list plus implementation, verification, done-check, plan-update, and context-impact evidence, recorded directly on the completed task (`Completed`, `Files changed`, `Result`, `Verify`, `Context impact`, `Context synchronization`); the five-file root context pass remains mandatory. A later-session sync-debt retry reads that same completed task record directly from the plan by plan path and task ID, with no separate persisted synchronization handoff. `/validate` is validation-only: it runs final checks, writes the Validation Report, and reports `validated`, `failed`, or `blocked` without plan-level context synchronization. @@ -10,18 +10,18 @@ The generated `/next-task` workflow persists task-level context-synchronization - **Exit codes:** `2` parse, `3` validation, `4` runtime, `5` dependency failure (see `context/sce/cli-exit-code-contract.md`). - **Stderr diagnostics:** stable `SCE-ERR-{PARSE,VALIDATION,RUNTIME,DEPENDENCY}` codes with class-default `Try:` remediation (see `context/sce/cli-error-code-taxonomy.md`). -- **Stdout/stderr:** command payloads on stdout only; redacted diagnostics and text-mode `sce trace sync` progress on stderr, while JSON sync remains silent (see `context/sce/cli-stdout-stderr-contract.md`). +- **Stdout/stderr:** command payloads on stdout only; redacted diagnostics and text-mode `sce sync` progress on stderr, while JSON sync remains silent (see `context/sce/cli-stdout-stderr-contract.md`). - **Observability:** config-resolved logging to stderr, optional dated/session-partitioned `log_dir` / `SCE_LOG_DIR` files with retention (see `context/sce/cli-observability-contract.md`). - **Config precedence:** `flags > env > config file > defaults` (see `context/cli/config-precedence-contract.md`). - **Attribution hooks:** enabled by default, gated by staged-diff AI-overlap preflight; `SCE_ATTRIBUTION_HOOKS_DISABLED` opt-out (see `context/sce/agent-trace-commit-msg-coauthor-policy.md`). - **Install channels:** repo-flake Nix, Cargo, npm, and source-built Flatpak (`dev.crocoder.sce`); Homebrew deferred (see `context/sce/cli-first-install-channels-contract.md`). -The CLI crate currently depends on `anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, and `uuid`, with target-specific keyring backend dependencies for Linux/FreeBSD, macOS, and Windows. No CLI dev-dependencies are currently declared. +The CLI crate currently depends on `anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `indicatif`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, and `uuid`, with target-specific keyring backend dependencies for Linux/FreeBSD, macOS, and Windows. No CLI dev-dependencies are currently declared. Its command loop is implemented with `clap` derive-based argument parsing and `anyhow` error handling. Top-level help now displays an ASCII art "SCE" banner with a per-column right-to-left color gradient (cyan→magenta when color is enabled, plain ASCII when disabled) above a slim command list without implemented/placeholder labels, and hides `auth` and `hooks` from `sce`, `sce help`, and `sce --help`, while those commands remain directly invocable. The real top-level command catalog/help-visibility contract is now centralized in `cli/src/cli_schema.rs` and consumed by `cli/src/command_surface.rs` for custom banner/help rendering plus known-command classification. The runtime includes implemented auth flows (`auth login|logout|status`) plus auth-local guidance for bare `sce auth` / `sce auth --help`, implemented config inspection/validation (`config show`/`config validate`) with bare `sce config` routing to the same help payload as `sce config --help`, real setup orchestration, implemented `doctor` diagnosis-vs-fix CLI surface and stable output-shape scaffolding (`sce doctor`, `sce doctor --fix`, `--format text|json`) plus current installed-CLI/global-state diagnostics for state-root resolution, global config validation, local DB and Agent Trace DB path + health, writable DB-parent-path checks, git availability/repository targeting, bare-repo refusal, effective hook-path source detection, an intentionally empty repo-scoped SCE database section for the active repository, required-hook presence/executable/content-drift checks against canonical embedded SCE-managed hook assets, repair-mode reuse of canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories, and doctor-owned bootstrap repair for missing canonical DB parent directories, implemented attribution-only `hooks` subcommand routing/validation entrypoints with commit-msg-only behavior behind an enabled-by-default gate with explicit opt-out controls, implemented machine-readable runtime identification (`version`), implemented shell completion script generation via `clap_complete` (`completion --shell `), and placeholder dispatch for deferred commands (`sync`) through explicit service contracts. Parse-time command conversion plus run-time command handling now flow through an internal `RuntimeCommand` seam in `cli/src/app.rs`, so top-level app orchestration no longer owns one monolithic dispatch `match` for every command. The command loop now enforces a stable exit-code contract in `cli/src/app.rs`: `2` parse failures, `3` invocation validation failures, `4` runtime failures, and `5` dependency startup failures. The same runtime also emits stable user-facing stderr error classes (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) using deterministic `Error []: ...` diagnostics with class-default `Try:` remediation appended when missing. The app runtime now also includes a structured observability baseline in `cli/src/services/observability.rs`: deterministic env-controlled log threshold/format (`SCE_LOG_LEVEL` defaults to `error`; `SCE_LOG_FORMAT` defaults to `text`), default-backed log-directory routing (`SCE_LOG_DIR` / config-file `log_dir` / `/sce/logs`) with per-operation machine-local dated file selection, optional session filename partitioning, and creation-triggered retention of direct regular `*.log` files to 10 entries, stable lifecycle event IDs, stderr primary emission so stdout command payloads remain pipe-safe, and `observability::traits` boundaries for logger and telemetry behavior. -The app command dispatcher now enforces a centralized stdout/stderr stream contract in `cli/src/app.rs`: command success payloads are emitted on stdout only, while redacted user-facing diagnostics and text-mode trace-sync progress are emitted on stderr; JSON trace sync remains silent. `cli/src/app.rs` also now runs through explicit startup phases — dependency check, observability config resolution, runtime initialization, command parse/execute, and output rendering — with the app runtime carrying logger/telemetry plus static command-catalog state across those phases while preserving the existing exit-code and degraded-startup contracts. Within that lifecycle, `parse_command_phase` delegates clap-to-runtime conversion to `cli/src/services/parse/command_runtime.rs`, which returns a static `RuntimeCommand` enum, `services::app_support::execute_command_phase` logs around enum-owned `execute_with_stderr(...)` dispatch, and generic `RunOutcome` rendering logs classified errors through the logger trait boundary without coupling render support to the production logger type. Command payload structs for `help`, `version`, `completion`, `auth`, `config`, `setup`, `doctor`, and `hooks` live in service-owned `command.rs` files; `cli/src/services/command_registry.rs` owns the deterministic static command-name catalog and enum variants instead of boxed command trait objects. +The app command dispatcher now enforces a centralized stdout/stderr stream contract in `cli/src/app.rs`: command success payloads are emitted on stdout only, while redacted user-facing diagnostics and text-mode sync progress are emitted on stderr; JSON sync remains silent. `cli/src/app.rs` also now runs through explicit startup phases — dependency check, observability config resolution, runtime initialization, command parse/execute, and output rendering — with the app runtime carrying logger/telemetry plus static command-catalog state across those phases while preserving the existing exit-code and degraded-startup contracts. Within that lifecycle, `parse_command_phase` delegates clap-to-runtime conversion to `cli/src/services/parse/command_runtime.rs`, which returns a static `RuntimeCommand` enum, `services::app_support::execute_command_phase` logs around enum-owned `execute_with_stderr(...)` dispatch, and generic `RunOutcome` rendering logs classified errors through the logger trait boundary without coupling render support to the production logger type. Command payload structs for `help`, `version`, `completion`, `auth`, `config`, `setup`, `doctor`, `hooks`, and `sync` live in service-owned `command.rs` files; `cli/src/services/command_registry.rs` owns the deterministic static command-name catalog and enum variants instead of boxed command trait objects. The CLI now also enforces a shared output-format parser contract in `cli/src/services/output_format.rs`, with canonical `--format ` parsing and command-specific actionable invalid-value guidance reused by `config` and `version` services. A compile-safe service lifecycle seam also exists in `cli/src/services/lifecycle.rs`: `ServiceLifecycle` exposes default no-op `diagnose`, `fix`, and `setup` methods against the narrow `HasRepoRoot` accessor, uses lifecycle-owned health/fix/setup result types, and owns the shared static `LifecycleProvider` enum catalog/factory with deterministic config → local_db → auth_db → agent_trace_db → hooks ordering and no boxed provider aggregation. Hooks has a `services/hooks/lifecycle.rs` provider for hook rollout diagnosis/fix/setup, config has a `services/config/lifecycle.rs` provider for global/repo-local config validation plus repo-local config bootstrap, local_db has a `services/local_db/lifecycle.rs` provider for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup, auth_db has a `services/auth_db/lifecycle.rs` provider for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup, and agent_trace_db has a `services/agent_trace_db/lifecycle.rs` provider for repository-scoped Agent Trace DB setup and repository DB path health/parent readiness from resolved repository identity, returning an actionable "requires a Git repository" diagnostic outside repository context (no global/checkout fallback path). Doctor runtime aggregates the full shared provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor-owned output records; setup command aggregates the shared provider catalog for `setup` with hooks included only when requested and adapts lifecycle setup outcomes before rendering setup-owned messages. Agent Trace lifecycle setup now resolves repository storage, creates/reuses checkout identity for diagnostics, and initializes `/sce/repos//agent-trace.db` via `RepositoryAgentTraceDb`; hook runtime lazy initialization uses the same repository storage resolver when setup has not prepared the DB or schema metadata is incomplete. The CLI now also includes a shared text styling service in `cli/src/services/style.rs` that provides deterministic color enablement via `owo-colors`, automatic TTY detection, and `NO_COLOR` compliance for human-facing text output; stdout help/text surfaces, stderr diagnostics, and interactive prompt-adjacent text now reuse that shared styling policy while JSON, completion, and other non-interactive/machine-readable flows remain unstyled. The service exports color-detection, conditional styling, help/diagnostic/label/prompt styling, and `banner_with_gradient()` helpers for use across command surfaces while preserving pipe-safe output for non-interactive environments. @@ -29,16 +29,18 @@ The `setup` command includes an `inquire`-backed target-selection flow: default For repository generation consumers, `config/pkl/generator-inputs.txt` declares the canonical Pkl/plugin input set and `scripts/produce-cli-generated-input.sh` owns its discovery, two-pass `config/pkl/generate.pkl` evaluation, determinism comparison, payload/input inventories, in-flight input-mutation rejection, atomic handoff publication, and staging cleanup. `scripts/run-cli-cargo.sh` creates a fresh temporary destination, delegates generation to that producer, invokes the requested Cargo workflow with `SCE_CLI_GENERATED_INPUT_DIR`, and removes the handoff after Cargo success, failure, or handled signals. `config/pkl/check-generated.sh` delegates the same production mechanics while retaining contract and path assertions. `scripts/prepare-cli-generated-assets.sh` moves the producer-validated Pkl payload and checksums into the unchanged package fallback, adds hooks, migrations, and the Agent Trace schema, and appends only those static checksums to the combined inventory. The root flake's pre-Cargo `cliGeneratedInput` derivation invokes the same producer from a declarative source containing the producer plus its declared inputs. `cli/build.rs` rejects missing, incomplete, modified, or stale repository handoffs, copies the validated payload into Cargo `OUT_DIR/pkl-generated`, stages static inputs under `OUT_DIR/static`, and writes setup-asset, optional-workflow-catalog, and migration Rust manifests into `OUT_DIR`; it never invokes Pkl. Published crates carry the ignored packaging-only fallback, and unpacked downstream builds validate and copy it into their own `OUT_DIR` without requiring Pkl or parent repository paths. The setup service also provides repository-root install orchestration: it resolves the repository root, ensures the additive durable-context baseline, then for normal modes derives a repo-root-scoped `AppContext` from the runtime command context, aggregates `ServiceLifecycle::setup` calls across lifecycle providers (config → local_db → auth_db → agent_trace_db → hooks when requested), handles interactive or flag-based target selection for config asset installation, and reports deterministic completion details (selected target(s) and installed file counts). Setup installs config assets (`.opencode`/`.claude`/`.pi`) per file: each embedded asset is staged and swapped into its own destination path, creating parent directories as needed, without removing or recreating the target directory as a whole, so files a repository owns inside an SCE-managed target directory survive a setup run untouched. Two assets are merge targets rather than verbatim writes: Claude's `.claude/settings.json` and OpenCode's `.opencode/opencode.json`. For each, setup JSON-merges the generated document into the user's existing file rather than overwriting it, and fails deterministically without writing if the existing file is not valid JSON; a missing file is still created from the generated document verbatim. Claude's merge replaces only SCE-owned hook entries (identified by a command containing `run-sce-or-show-install-guidance.sh`) and the `$schema` key while preserving every other key and hook entry untouched. OpenCode's merge replaces the `$schema` key and merges the `plugin` array as a set: any entry shaped like an SCE plugin path (`./plugins/sce-*`) is dropped, structurally, so a path an older or renamed catalog once installed is still recognized and pruned, and the generated document's canonical plugin entries are appended after the surviving user entries. Required-hook install uses the same per-file stage/atomic-swap choreography as config-asset install — the staging file is renamed directly over an existing hook without unlinking it first, so a rename failure leaves the prior hook untouched. Both flows return deterministic recovery guidance (recover from version control) on swap failure, without creating backup artifacts. After installing, config install prunes stale SCE-owned assets: it deletes every path the full embedded catalog for the target claims but the current selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then removes any parent directory left empty by that deletion, leaving a directory intact if a user file still lives inside it. The setup command gates all modes on an existing git repository before any writes. Internally, `cli/src/services/setup/mod.rs` now separates install-flow logic from interactive prompt logic through focused support seams. The CLI now also applies baseline security hardening for reliability-driven automation: diagnostics/logging paths use deterministic secret redaction, `sce setup --hooks --repo ` canonicalizes and validates repository paths before execution, and setup write flows run explicit directory write-permission probes before staging/swap operations. -The config service now provides deterministic runtime config resolution with explicit precedence (`flags > env > config file > defaults`), strict config-file validation (`$schema`, `log_level`, `log_format`, `log_dir`, `timeout_ms`, `workos_client_id`, and nested `policies.bash`, `policies.attribution_hooks.enabled`, plus `policies.database_retry` with per-DB `connection_open`/`query` retry policy specs), deterministic default discovery/merge of global+local config files (`${config_root}/sce/config.json` then `.sce/config.json` with local override, where `config_root` comes from the shared default-path seam with XDG/`dirs::config_dir()` config-root resolution), defaults for the resolved observability value set (`log_level=error`, `log_format=text`, `log_dir=/sce/logs`), shared auth-key resolution with optional baked defaults starting at `workos_client_id`, first-class bash-policy preset/custom parsing with deterministic conflict and duplicate-prefix validation, custom-policy `satisfied_by` wrapper exemption (a policy does not fire when the matched command was unwrapped from a declared wrapper such as `nix shell nixpkgs#ripgrep`), and a canonical Pkl-authored `sce/config.json` JSON Schema generated beneath Cargo `OUT_DIR` and embedded by `cli/src/services/config/mod.rs` for both `sce config validate` and doctor-time config checks. Runtime startup config loading keeps parity with that schema by accepting its `$schema` declaration in repo-local and global config files, so startup commands such as `sce version` no longer fail before dispatch on that field; the canonical declaration is `"https://sce.crocoder.dev/config.json"`; this schema URL is separate from the `https://sce.crocoderlab.dev` baked default used by `sce trace sync` for control-plane ingestion. App-runtime observability now consumes flat logging keys through the shared resolver, so env values still override config-file values while config files provide deterministic fallback for `log_dir`; positive-integer `log_file_retention_limit` uses config-file/default precedence, defaults to `10`, and controls creation-triggered cleanup for primary and v2 log files; `sce config show` reports resolved observability/auth/policy values with provenance, while `sce config validate` is now a trimmed validation surface that reports only pass/fail plus validation errors or warnings in text and JSON modes. The canonical preset catalog and matching contract live in `config/pkl/base/bash-policy-presets.pkl` and `context/sce/bash-tool-policy-enforcement-contract.md`. +The config service now provides deterministic runtime config resolution with explicit precedence (`flags > env > config file > defaults`), strict config-file validation (`$schema`, `log_level`, `log_format`, `log_dir`, `timeout_ms`, `workos_client_id`, and nested `policies.bash`, `policies.attribution_hooks.enabled`, plus `policies.database_retry` with per-DB `connection_open`/`query` retry policy specs), deterministic default discovery/merge of global+local config files (`${config_root}/sce/config.json` then `.sce/config.json` with local override, where `config_root` comes from the shared default-path seam with XDG/`dirs::config_dir()` config-root resolution), defaults for the resolved observability value set (`log_level=error`, `log_format=text`, `log_dir=/sce/logs`), shared auth-key resolution with optional baked defaults starting at `workos_client_id`, first-class bash-policy preset/custom parsing with deterministic conflict and duplicate-prefix validation, custom-policy `satisfied_by` wrapper exemption (a policy does not fire when the matched command was unwrapped from a declared wrapper such as `nix shell nixpkgs#ripgrep`), and a canonical Pkl-authored `sce/config.json` JSON Schema generated beneath Cargo `OUT_DIR` and embedded by `cli/src/services/config/mod.rs` for both `sce config validate` and doctor-time config checks. Runtime startup config loading keeps parity with that schema by accepting its `$schema` declaration in repo-local and global config files, so startup commands such as `sce version` no longer fail before dispatch on that field; the canonical declaration is `"https://sce.crocoder.dev/config.json"`; this schema URL is separate from the `https://sce.crocoderlab.dev` baked default used by `sce sync` for control-plane ingestion. App-runtime observability now consumes flat logging keys through the shared resolver, so env values still override config-file values while config files provide deterministic fallback for `log_dir`; positive-integer `log_file_retention_limit` uses config-file/default precedence, defaults to `10`, and controls creation-triggered cleanup for primary and v2 log files; `sce config show` reports resolved observability/auth/policy values with provenance, while `sce config validate` is now a trimmed validation surface that reports only pass/fail plus validation errors or warnings in text and JSON modes. The canonical preset catalog and matching contract live in `config/pkl/base/bash-policy-presets.pkl` and `context/sce/bash-tool-policy-enforcement-contract.md`. Invalid default-discovered config files now also degrade gracefully at startup: `sce` keeps running with degraded observability defaults, logs `sce.config.invalid_config` warnings, and reserves hard failures for explicit `--config` / `SCE_CONFIG_FILE` targets or other truly invalid runtime observability inputs. `cli/src/services/config/mod.rs` is now a module facade that declares focused config submodules (`types`, `schema`, `policy`, `resolver`, private `render`, `command`, and `lifecycle`), re-exporting `pub use types::*` and `pub(crate) use schema::validate_config_file`. Shared config primitive ownership is delegated to `cli/src/services/config/types.rs`; schema loading and file parsing to `cli/src/services/config/schema.rs`; bash-policy semantic validation and policy-specific formatting to `cli/src/services/config/policy.rs`; runtime discovery/precedence to `cli/src/services/config/resolver.rs`; and `sce config show` / `sce config validate` text+JSON output construction to `cli/src/services/config/render.rs`. Downstream modules continue importing through `services::config` unchanged. The CLI now has a generic borrowed `AppContext` dependency view in `cli/src/app.rs`; `AppRuntime` owns concrete production logger/telemetry/fs/git dependencies, and command execution receives context views that borrow those dependencies plus an optional `repo_root: Option`. `AppContext::with_repo_root(...)` / `ContextWithRepoRoot` derives repo-root-scoped views while preserving the borrowed runtime dependencies, and command execution is generic over associated-type narrow accessor traits where practical. The broad capability seam lives in `cli/src/services/capabilities.rs`, where `FsOps`/`StdFsOps` wrap filesystem operations and `GitOps`/`ProcessGitOps` wrap git process execution plus repository-root/hooks-directory resolution. The shared default path service in `cli/src/services/default_paths.rs` is now the canonical owner for production CLI path definitions. It resolves per-user config/state/cache roots through a dedicated internal `roots` seam, exposes the current persisted-artifact inventory (global config and auth tokens), and also defines named DB paths (auth DB, local DB, Agent Trace DB) plus the repo-relative, install, hook, and context-path accessors consumed across current CLI production code. Non-test production modules should consume this shared catalog instead of hardcoding owned path literals. No default cache-backed persisted artifact currently exists, so cache-root resolution remains available without speculative cache-path features and no legacy default-path fallback is supported. The Rust CLI also centralizes SCE-owned web URI construction in `cli/src/services/agent_trace.rs`, with `SCE_WEB_BASE_URL` as the single Rust owner for `https://sce.crocoder.dev` and helpers consumed by Agent Trace conversation URLs, Agent Trace persisted trace URLs, Agent Trace session URLs, and setup-created repo-local config schema URLs. The config resolver separately owns `control_plane_base_url` and its `https://sce.crocoderlab.dev` baked sync default; the two URL owners must not be conflated. +The current user-facing synchronization entrypoint is `sce sync`; references to the former nested spelling in historical records do not describe an available command. +Sync owns the complete progress boundary in `cli/src/services/sync/progress.rs`: the consumer-typed `ProgressReporter` contract, no-op reporter, focused contract tests, and fixed `indicatif` terminal adapter. `SyncProgressEvent` remains owned by `cli/src/services/sync/sync.rs`; `sync/command.rs` selects the adapter or no-op implementation by output format, there is no top-level `cli/src/services/progress/` module, and JSON callers use the sync-owned no-op reporter. The same config resolver now also owns the attribution-hooks gate used by local hook runtime: opt-out env `SCE_ATTRIBUTION_HOOKS_DISABLED` overrides `policies.attribution_hooks.enabled` with inverted semantics, and the gate defaults to enabled unless explicitly disabled. The config service split now includes `cli/src/services/config/resolver.rs` as the focused owner for config-file discovery, file-layer merging, env/flag/default precedence, auth-key resolution, observability resolution, attribution-hooks resolution, and default-discovered invalid-file degradation; `cli/src/services/config/mod.rs` remains the facade/rendering orchestration surface while preserving existing `services::config` imports. Generated config now includes repo-local OpenCode plugin assets: `sce-bash-policy.ts` plus `sce-agent-trace.ts` are emitted under `config/.opencode/plugins/`; the OpenCode agent-trace plugin extracts `{ sessionID, diff, time, model_id }` from user `message.updated` events with diffs, tracks per-session OpenCode client version from `session.created`/`session.updated`, and sends payloads to `sce hooks diff-trace` with `tool_name="opencode"` plus optional `tool_version`. Claude generated config now routes supported `PostToolUse Write|Edit|MultiEdit|NotebookEdit` events directly to `sce hooks diff-trace`; it no longer registers a `SessionStart` hook or calls `sce hooks session-model`. Rust handles extraction, validation, and persistence without a TypeScript intermediary; the former `config/.claude/plugins/sce-agent-trace.ts` Bun runtime was removed in T07 of the `claude-rust-diff-trace` plan. The Rust hook validates required fields, uses only direct payload `model_id` and `tool_version` (no longer resolves from `session_models`), and persists tool-prefixed `session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), `model_id`, `tool_name`, and nullable `tool_version` into `diff_traces` through AgentTraceDb. Bash-policy now delegates OpenCode enforcement to the Rust `sce policy bash` command: the generated OpenCode plugin at `config/.opencode/plugins/sce-bash-policy.ts` is a thin wrapper that calls `sce policy bash --input normalized --output json` via `spawnSync` and throws on deny decisions; it no longer contains independent TypeScript policy logic. The former `bash-policy/runtime.ts` TypeScript runtime has been removed. Preset... Claude bash-policy enforcement is also generated through `.claude/settings.json` as a `PreToolUse` `Bash` command hook running `sce policy bash`, so Claude and OpenCode both delegate to the Rust policy evaluator without a Claude TypeScript runtime. Pi bash-policy enforcement is delegated the same way through a project-local Pi extension (`config/lib/pi-plugin/sce-pi-extension.ts`, emitted to `config/.pi/extensions/sce/index.ts`) whose `tool_call` handler blocks denied bash commands via `sce policy bash` and fails open when the policy check cannot run (see `context/sce/pi-extension-runtime.md`). -Local database bootstrap is now owned by `LocalDbLifecycle::setup` and `AgentTraceDbLifecycle::setup` aggregated by the setup command. Agent Trace setup creates/reuses the current checkout ID for diagnostics and initializes the repository-scoped `/sce/repos//agent-trace.db` with the repository schema; hook runtime lazily creates or upgrades that repository DB when setup has not run or schema metadata is incomplete. Doctor validates the repository-scoped DB path/health and can bootstrap missing parent directories; outside a Git repository it reports an actionable "requires a Git repository" diagnostic instead of probing a sentinel path. The `sce trace` group operates only on repository-scoped DBs for list/status/status-all/shell UX; the checkout-scoped `--legacy` surface was removed by the `retire-legacy-agent-trace-db` plan (see `context/decisions/2026-07-17-retire-legacy-agent-trace-db.md`). `sce trace sync` is now fully implemented: it resolves repository-scoped storage, authenticates against the control plane with stored WorkOS credentials, fetches authoritative cursors once, synchronizes the four Agent Trace capture streams concurrently while preserving sequential batches within each stream, and renders the documented concise text/JSON output (see `context/cli/trace-command.md`). +Local database bootstrap is now owned by `LocalDbLifecycle::setup` and `AgentTraceDbLifecycle::setup` aggregated by the setup command. Agent Trace setup creates/reuses the current checkout ID for diagnostics and initializes the repository-scoped `/sce/repos//agent-trace.db` with the repository schema; hook runtime lazily creates or upgrades that repository DB when setup has not run or schema metadata is incomplete. Doctor validates the repository-scoped DB path/health and can bootstrap missing parent directories; outside a Git repository it reports an actionable "requires a Git repository" diagnostic instead of probing a sentinel path. `sce sync` is fully implemented: it resolves repository-scoped storage, authenticates against the control plane with stored WorkOS credentials, fetches authoritative cursors once, synchronizes the four Agent Trace capture streams concurrently while preserving sequential batches within each stream, and renders the documented concise text/JSON output (see `context/cli/sync-command.md`). The former `sce trace` command group and its database inspection surfaces are unavailable. The repository-root flake (`flake.nix`) applies a Rust overlay-backed stable toolchain pinned to `1.95.0` (with `rustfmt` and `clippy`), reads package/check version from the repo-root `.version` file, and builds `packages.sce` through a Crane `buildDepsOnly` + `buildPackage` pipeline. One deterministic pre-Cargo Nix derivation invokes the shared generated-input producer and supplies its validated `SCE_CLI_GENERATED_INPUT_DIR` store path to native, release, test, and Clippy Cargo derivations. Pkl is absent from those Cargo environments; dependency-only and format derivations do not receive the handoff, so canonical generation changes invalidate compiling outputs without invalidating dependency artifacts or formatting. `cli-tests`, `cli-clippy`, and `cli-fmt` remain Crane-backed check derivations. The root flake splits native and release outputs: `packages.sce` and `packages.default` build the **native** development binary (`scePackage`), while `packages.sce-release` builds the release binary (`sceReleasePackage`: static musl on Linux, native on Darwin). So `nix build .#sce` / `.#default`, `nix run . -- --help`, `nix run .#sce -- --help`, and `nix profile install github:crocoder-dev/shared-context-engineering` target the native binary, and `nix build .#sce-release` / `nix run .#sce-release -- ...` (plus `nix run .#release-artifacts`, which builds `.#sce-release`) target the release binary. On Linux the native and release outputs are distinct store paths, and the release output passes the native portability audit. `packages..ci-checks` is the explicit long-running validation tier: `nix build .#ci-checks` builds the `.#sce-release` package and, on Linux, audits the real release binary for forbidden `/nix/store/` references, so the expensive work stays out of `nix flake check` (which never builds `.#sce-release`). Git-commit embedding is **release-only**: `SCE_GIT_COMMIT` is injected via a `releaseCommitArgs` fragment applied only to the release derivations (`scePackageMusl` on Linux, `sceReleasePackageNative` on Darwin), not to `commonCargoArgs`. So native `.#sce`/`.#default` and every `nix flake check` derivation (`cli-tests`, `cli-clippy`, `cli-fmt`) build without the commit in their inputs and stay cache-reusable across commits (native `sce version` reports `unknown`), while `.#sce-release` still reports the real commit via `sce version`. `cli/build.rs` `emit_git_commit` emits `SCE_GIT_COMMIT` only when the env var is explicitly set — no `git rev-parse` fallback and no `.git/HEAD`/`.git/packed-refs` rerun watches. On Darwin the release now uses a distinct native-toolchain derivation (native toolchain + commit), so it diverges from `.#sce` to carry the commit while native stays commit-independent. diff --git a/context/patterns.md b/context/patterns.md index 0eee1c55f..4b59fa2c4 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -120,7 +120,7 @@ - Keep wrapper-only help rows or banner rendering logic outside the clap catalog, but do not duplicate the real command visibility/purpose metadata in those renderers. - Keep placeholder or deferred state explicit in runtime responses and command-local docs rather than relying on top-level help status badges. - Parse CLI args with `clap` derive macros, classify top-level failures into stable exit-code classes (`parse`, `validation`, `runtime`, `dependency`), and keep user-facing failures deterministic/actionable. -- Keep command payload structs and execution methods in service-owned `command.rs` modules; keep the static `RuntimeCommand` enum and deterministic command-name catalog in `services/command_registry.rs`; keep clap-to-runtime conversion in `services/parse/command_runtime.rs`; `app.rs` should stay focused on startup lifecycle and thin parse/execute/render orchestration rather than owning command-specific runtime handlers or parse conversion details. Interactive commands such as `sce trace db shell` may perform scoped direct stdio handoff inside the service command, returning an empty payload so the app renderer preserves stdout ownership without duplicating transcript output. +- Keep command payload structs and execution methods in service-owned `command.rs` modules; keep the static `RuntimeCommand` enum and deterministic command-name catalog in `services/command_registry.rs`; keep clap-to-runtime conversion in `services/parse/command_runtime.rs`; `app.rs` should stay focused on startup lifecycle and thin parse/execute/render orchestration rather than owning command-specific runtime handlers or parse conversion details. The top-level `sce sync` command keeps its format-gated stderr progress adapter and report rendering inside the sync-owned service boundary. - Emit user-facing CLI diagnostics with stable class-based error IDs (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) using deterministic `Error []: ...` stderr formatting, and auto-append class-default `Try:` remediation only when the message does not already provide one. - Keep CLI observability separate from command payloads: emit deterministic lifecycle logs to `stderr` only with stable `event_id` values, and preserve `stdout` for command result payloads. - For baseline runtime observability controls, resolve logging settings through the shared config resolver first, preserving deterministic precedence (`flags > env > config file > defaults`) and fail-fast validation on invalid env/config inputs. diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index fd9eaec9b..d429f80c2 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -40,7 +40,7 @@ This adapter has no canonical `DbSpec::db_path()`; callers must resolve `/sce/repos//agent-trace.db` with the repository schema, validates `repository_metadata.repository_id`, and emits setup messaging with the repository ID, checkout ID, `source_instance_id` (`RepositoryMetadata` from the same resolution call), and initialized DB path. Hook runtime lazy initialization remains available for repositories where setup has not run or schema metadata is incomplete. - `sce doctor` surfaces checkout identity facts where available and lifecycle-owned repository Agent Trace DB health in the `Configuration` section, with `[PASS]`/`[FAIL]`/`[MISS]` status tokens. Outside repository context the lifecycle reports the actionable "requires a Git repository" diagnostic instead of probing a sentinel path. JSON output includes `checkout_identity` when available plus the resolved `agent_trace_db` field. -- `sce trace db list` discovers repository DBs under `/sce/repos//agent-trace.db`, reporting text or JSON sorted by mtime descending. See [context/cli/trace-command.md](../cli/trace-command.md). +- `sce sync` reads repository DB rows through the read-only export boundary before sending them to the control plane. See [context/cli/sync-command.md](../cli/sync-command.md). ## Runtime writers diff --git a/context/sce/agent-trace-export-readers.md b/context/sce/agent-trace-export-readers.md index f59a1e247..12907ac6f 100644 --- a/context/sce/agent-trace-export-readers.md +++ b/context/sce/agent-trace-export-readers.md @@ -7,12 +7,12 @@ ```mermaid flowchart LR A["SCE local source DB\n(RepositoryAgentTraceDb)"] --> B["Incremental export reader\n(AgentTraceExportReader)"] - B --> C["Control-plane client\n(sce trace sync)"] + B --> C["Control-plane client\n(sce sync)"] ``` - **SCE local source DB** — the existing repository-scoped `RepositoryAgentTraceDb` (see [agent-trace-db.md](agent-trace-db.md)), written by the hook/lifecycle paths already documented there. This reader does not change its writer, schema, or migrations. - **Incremental export reader** — `AgentTraceExportReader<'a>`, described below. Read-only, stateless across calls, no network. -- **Control-plane client** — `sce trace sync` (see [agent-trace-sync-command.md](../cli/agent-trace-sync-command.md)) composes this reader with the authenticated control-plane HTTP client; the reader itself remains unaware of that caller. +- **Control-plane client** — `sce sync` (see [agent-trace-sync-command.md](../cli/agent-trace-sync-command.md)) composes this reader with the authenticated control-plane HTTP client; the reader itself remains unaware of that caller. ## Composition point @@ -56,7 +56,7 @@ Each stream has an owned `serde::Serialize` export-row DTO (`AgentTraceMessageEx This reader introduces no local sync state and no outbound transport: -- No local sync cursor is stored anywhere; the caller (`sce trace sync`) derives cursors from the control plane's `/state` response on every invocation, entirely outside this module. +- No local sync cursor is stored anywhere; the caller (`sce sync`) derives cursors from the control plane's `/state` response on every invocation, entirely outside this module. - No `agent-trace-sync.db` or any other new database or table exists. - No Turso Sync, no ETL pipeline, and no data-warehouse (DWH) integration exists. - No network call, no HTTP client, and no auth/WorkOS code exists in this module. diff --git a/context/sce/cli-shell-completion-contract.md b/context/sce/cli-shell-completion-contract.md index 8a301099a..0652cab37 100644 --- a/context/sce/cli-shell-completion-contract.md +++ b/context/sce/cli-shell-completion-contract.md @@ -28,7 +28,7 @@ Defines the implemented `sce completion` contract for deterministic shell comple - Output is a shell script payload emitted on stdout for redirection/eval. - Scripts are deterministic for identical binary + input shell. - Generated scripts encode current parser-valid command/flag/subcommand surfaces for: -- top-level commands: `help`, `config`, `setup`, `doctor`, `auth`, `hooks`, `trace`, `sync`, `version`, `completion` +- top-level commands: `help`, `config`, `setup`, `doctor`, `auth`, `hooks`, `sync`, `version`, `completion` - completion-specific flags and values: `--shell` with `bash|zsh|fish` ## Implementation ownership diff --git a/context/sce/cli-stdout-stderr-contract.md b/context/sce/cli-stdout-stderr-contract.md index 9459fbd4c..4c810737d 100644 --- a/context/sce/cli-stdout-stderr-contract.md +++ b/context/sce/cli-stdout-stderr-contract.md @@ -26,6 +26,6 @@ See also: `context/sce/cli-error-code-taxonomy.md` for the canonical error-code - Stream routing is centralized in one app-level path to avoid per-command stream drift. - Exit code class mapping remains unchanged (`parse`, `validation`, `runtime`, `dependency`). - Observability lifecycle logs remain on `stderr` by contract and are independent from command payload output. -- Text-mode `sce trace sync` emits deterministic live start, cumulative accepted-batch, and stream-completion progress lines on `stderr`; the start line carries a UTC RFC3339 timestamp before the first sync request, and a terminal end timestamp follows success or failure. Empty streams report no new rows. JSON-mode sync emits no progress or lifecycle timestamp text and keeps its existing JSON-only payload on `stdout`. +- Text-mode `sce sync` emits its aligned four-row `indicatif` progress display on `stderr` before accepted batches begin: rows start at zero with independent steady spinners, accepted batches update only the corresponding cumulative count, and each stream receives a styled completion check at its own future boundary. Redirected/non-TTY output stays plain and free of terminal-control sequences, while `NO_COLOR` disables styling. The final text report remains the command result without repository or source-instance identifiers; JSON-mode sync emits no human progress text and keeps its JSON-only payload on `stdout`, also without those identifiers. The durable trace-sync stream choice is recorded in [Trace-sync progress stream contract](../decisions/2026-08-13-trace-sync-progress-stream-contract.md). diff --git a/nix/flatpak/cargo-sources.nix b/nix/flatpak/cargo-sources.nix index afd3002fd..9347e2fdd 100644 --- a/nix/flatpak/cargo-sources.nix +++ b/nix/flatpak/cargo-sources.nix @@ -33,7 +33,7 @@ let outputHashMode = "flat"; outputHashAlgo = "sha256"; - outputHash = "sha256-7TSA1WMFykJIg6khqRIOHOFb2M1bqotGP79LMHoLYIo="; + outputHash = "sha256-NGzWSCuHntwcdSl6qwSZYzazEbFspqs5c96xjE47AmA="; }; regenerateApp = pkgs.writeShellApplication { diff --git a/packaging/flatpak/cargo-sources.json b/packaging/flatpak/cargo-sources.json index cda508d25..ad7ab552b 100644 --- a/packaging/flatpak/cargo-sources.json +++ b/packaging/flatpak/cargo-sources.json @@ -1000,6 +1000,19 @@ "dest": "cargo/vendor/concurrent-queue-2.5.0", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/console/console-0.16.4.crate", + "sha256": "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c", + "dest": "cargo/vendor/console-0.16.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c\", \"files\": {}}", + "dest": "cargo/vendor/console-0.16.4", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -1494,6 +1507,19 @@ "dest": "cargo/vendor/email_address-0.2.9", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/encode_unicode/encode_unicode-1.0.0.crate", + "sha256": "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0", + "dest": "cargo/vendor/encode_unicode-1.0.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0\", \"files\": {}}", + "dest": "cargo/vendor/encode_unicode-1.0.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -2508,6 +2534,19 @@ "dest": "cargo/vendor/indexmap-2.14.0", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/indicatif/indicatif-0.18.6.crate", + "sha256": "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c", + "dest": "cargo/vendor/indicatif-0.18.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c\", \"files\": {}}", + "dest": "cargo/vendor/indicatif-0.18.6", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -3652,6 +3691,19 @@ "dest": "cargo/vendor/polyval-0.6.2", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/portable-atomic/portable-atomic-1.15.0.crate", + "sha256": "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85", + "dest": "cargo/vendor/portable-atomic-1.15.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85\", \"files\": {}}", + "dest": "cargo/vendor/portable-atomic-1.15.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -5641,6 +5693,19 @@ "dest": "cargo/vendor/unicode-width-0.2.2", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unit-prefix/unit-prefix-0.5.2.crate", + "sha256": "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3", + "dest": "cargo/vendor/unit-prefix-0.5.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3\", \"files\": {}}", + "dest": "cargo/vendor/unit-prefix-0.5.2", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip",