From 58dd881202dc35d68d248d4f442933eb24cd87e7 Mon Sep 17 00:00:00 2001 From: stefanskoricdev Date: Fri, 21 Aug 2026 13:59:00 +0200 Subject: [PATCH] runtime: Add independent log_to_file configuration Allow file logging to be enabled or disabled independently from log_dir, defaulting to enabled. Resolve and validate the setting through the config schema, expose provenance in config output, and suppress duplicate stderr error records when file logging is active. Co-authored-by: SCE --- cli/src/services/config/render.rs | 10 +++ cli/src/services/config/resolver.rs | 72 ++++++++++++++++++++ cli/src/services/config/schema.rs | 28 +++++++- cli/src/services/config/types.rs | 1 + cli/src/services/observability.rs | 83 ++++++++++++++++++++++- config/pkl/base/sce-config-schema.pkl | 5 ++ context/architecture.md | 2 +- context/cli/config-precedence-contract.md | 13 ++-- context/context-map.md | 4 +- context/glossary.md | 4 +- context/overview.md | 6 +- context/sce/cli-observability-contract.md | 16 +++-- 12 files changed, 222 insertions(+), 22 deletions(-) diff --git a/cli/src/services/config/render.rs b/cli/src/services/config/render.rs index 10a1dae0..c8d764a7 100644 --- a/cli/src/services/config/render.rs +++ b/cli/src/services/config/render.rs @@ -10,6 +10,7 @@ use super::resolver::{ use super::types::DatabaseRetryConfig; use super::{ConfigPathSource, ReportFormat, ResolvedOptionalValue, ValueSource}; +#[allow(clippy::too_many_lines)] pub(super) fn format_show_output(runtime: &RuntimeConfig, report_format: ReportFormat) -> String { let warnings = build_show_warnings(runtime); match report_format { @@ -76,6 +77,10 @@ pub(super) fn format_show_output(runtime: &RuntimeConfig, report_format: ReportF runtime.log_format.value.as_str(), runtime.log_format.source, ), + "log_to_file": format_resolved_value_json( + runtime.log_to_file.value, + runtime.log_to_file.source, + ), "log_dir": format_optional_resolved_value_json(&runtime.log_dir), "log_file_retention_limit": format_resolved_value_json( runtime.log_file_retention_limit.value, @@ -235,6 +240,11 @@ fn format_observability_text_lines(runtime: &RuntimeConfig) -> Vec { runtime.log_format.value.as_str(), runtime.log_format.source, ), + format_resolved_value_text( + "log_to_file", + &runtime.log_to_file.value.to_string(), + runtime.log_to_file.source, + ), format_optional_resolved_value_text("log_dir", &runtime.log_dir), format_resolved_value_text( "log_file_retention_limit", diff --git a/cli/src/services/config/resolver.rs b/cli/src/services/config/resolver.rs index 79dbba7a..fb5a2581 100644 --- a/cli/src/services/config/resolver.rs +++ b/cli/src/services/config/resolver.rs @@ -70,6 +70,7 @@ pub(super) struct RuntimeConfig { pub(super) loaded_config_paths: Vec, pub(super) log_level: ResolvedValue, pub(super) log_format: ResolvedValue, + pub(super) log_to_file: ResolvedValue, pub(super) log_dir: ResolvedOptionalValue, pub(super) log_file_retention_limit: ResolvedValue, pub(super) timeout_ms: ResolvedValue, @@ -257,6 +258,7 @@ where Ok(ResolvedObservabilityRuntimeConfig { log_level: runtime.log_level.value, log_format: runtime.log_format.value, + log_to_file: runtime.log_to_file.value, log_dir: runtime.log_dir.value, log_file_retention_limit: runtime.log_file_retention_limit.value, loaded_config_paths: runtime.loaded_config_paths, @@ -335,6 +337,7 @@ where let mut file_config = schema::FileConfig { log_level: None, log_format: None, + log_to_file: None, log_dir: None, log_file_retention_limit: None, timeout_ms: None, @@ -366,6 +369,9 @@ where if let Some(log_format) = layer.log_format { file_config.log_format = Some(log_format); } + if let Some(log_to_file) = layer.log_to_file { + file_config.log_to_file = Some(log_to_file); + } if let Some(log_dir) = layer.log_dir { file_config.log_dir = Some(log_dir); } @@ -447,6 +453,17 @@ where }; } + let resolved_log_to_file = match file_config.log_to_file { + Some(value) => ResolvedValue { + value: value.value, + source: ValueSource::ConfigFile(value.source), + }, + None => ResolvedValue { + value: true, + source: ValueSource::Default, + }, + }; + let resolved_log_dir = if let Some(raw) = env_lookup(ENV_LOG_DIR) { ResolvedOptionalValue { value: Some(raw), @@ -575,6 +592,7 @@ where loaded_config_paths, log_level: resolved_log_level, log_format: resolved_log_format, + log_to_file: resolved_log_to_file, log_dir: resolved_log_dir, log_file_retention_limit: resolved_log_file_retention_limit, timeout_ms: resolved_timeout_ms, @@ -847,6 +865,60 @@ mod tests { assert_eq!(runtime.agent_trace_repository_id.source, None); } + #[test] + fn log_to_file_defaults_to_true() { + let runtime = resolve_runtime_with_config(None).unwrap(); + + assert!(runtime.log_to_file.value); + assert_eq!(runtime.log_to_file.source, ValueSource::Default); + } + + #[test] + fn log_to_file_and_log_dir_resolve_independently() { + let enabled_without_log_dir = + resolve_runtime_with_config(Some(r#"{"log_to_file":true}"#)).unwrap(); + let log_dir_without_log_to_file = + resolve_runtime_with_config(Some(r#"{"log_dir":"/tmp/sce-logs"}"#)).unwrap(); + let disabled_without_log_dir = + resolve_runtime_with_config(Some(r#"{"log_to_file":false}"#)).unwrap(); + + assert!(enabled_without_log_dir.log_to_file.value); + assert!(enabled_without_log_dir.log_dir.value.is_some()); + assert!(enabled_without_log_dir.validation_errors.is_empty()); + assert_eq!( + log_dir_without_log_to_file.log_dir.value.as_deref(), + Some("/tmp/sce-logs") + ); + assert!(log_dir_without_log_to_file.log_to_file.value); + assert!(disabled_without_log_dir.log_dir.value.is_some()); + assert!(disabled_without_log_dir.validation_errors.is_empty()); + assert_eq!( + enabled_without_log_dir.log_to_file.source, + ValueSource::ConfigFile(ConfigPathSource::Flag) + ); + assert_eq!( + disabled_without_log_dir.log_to_file.source, + ValueSource::ConfigFile(ConfigPathSource::Flag) + ); + } + + #[test] + fn log_to_file_resolves_both_explicit_boolean_values() { + let enabled = resolve_runtime_with_config(Some(r#"{"log_to_file":true}"#)).unwrap(); + let disabled = resolve_runtime_with_config(Some(r#"{"log_to_file":false}"#)).unwrap(); + + assert!(enabled.log_to_file.value); + assert!(!disabled.log_to_file.value); + assert_eq!( + enabled.log_to_file.source, + ValueSource::ConfigFile(ConfigPathSource::Flag) + ); + assert_eq!( + disabled.log_to_file.source, + ValueSource::ConfigFile(ConfigPathSource::Flag) + ); + } + #[test] fn agent_trace_auto_sync_defaults_to_true() { let runtime = resolve_runtime_with_config(None).unwrap(); diff --git a/cli/src/services/config/schema.rs b/cli/src/services/config/schema.rs index 15496424..016b0ff4 100644 --- a/cli/src/services/config/schema.rs +++ b/cli/src/services/config/schema.rs @@ -34,6 +34,7 @@ pub(crate) const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ CONFIG_SCHEMA_DECLARATION_KEY, "log_level", "log_format", + "log_to_file", "log_dir", "log_file_retention_limit", "timeout_ms", @@ -45,7 +46,7 @@ pub(crate) const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ ]; pub(crate) const TOP_LEVEL_CONFIG_KEYS_DESCRIPTION: &str = - "$schema, log_level, log_format, timeout_ms, workos_client_id, control_plane_base_url, agent_trace, policies, integrations, log_dir, log_file_retention_limit"; + "$schema, log_level, log_format, log_to_file, timeout_ms, workos_client_id, control_plane_base_url, agent_trace, policies, integrations, log_dir, log_file_retention_limit"; static CONFIG_SCHEMA_VALIDATOR: OnceLock = OnceLock::new(); @@ -71,6 +72,7 @@ pub(crate) struct ParsedFileConfigDocument { pub(crate) _schema: Option, pub(crate) log_level: Option, pub(crate) log_format: Option, + pub(crate) log_to_file: Option, pub(crate) log_dir: Option, pub(crate) log_file_retention_limit: Option, pub(crate) timeout_ms: Option, @@ -158,6 +160,7 @@ pub(crate) struct FileConfigValue { pub(crate) struct FileConfig { pub(crate) log_level: Option>, pub(crate) log_format: Option>, + pub(crate) log_to_file: Option>, pub(crate) log_dir: Option>, pub(crate) log_file_retention_limit: Option>, pub(crate) timeout_ms: Option>, @@ -302,6 +305,9 @@ pub(crate) fn parse_file_config( }) }) .transpose()?; + let log_to_file = typed + .log_to_file + .map(|value| FileConfigValue { value, source }); let log_dir = typed.log_dir.map(|value| FileConfigValue { value, source }); let log_file_retention_limit = typed .log_file_retention_limit @@ -324,6 +330,7 @@ pub(crate) fn parse_file_config( Ok(FileConfig { log_level, log_format, + log_to_file, log_dir, log_file_retention_limit, timeout_ms, @@ -761,6 +768,25 @@ mod agent_trace_config_tests { ); } + #[test] + fn parses_log_to_file_boolean() { + let config = parse(r#"{"log_to_file":false}"#).unwrap(); + + assert_eq!( + config.log_to_file.as_ref().map(|value| value.value), + Some(false) + ); + } + + #[test] + fn rejects_empty_log_dir_when_file_logging_is_enabled() { + let error = parse(r#"{"log_to_file":true,"log_dir":""}"#) + .unwrap_err() + .to_string(); + + assert!(error.contains("failed schema validation"), "{error}"); + } + #[test] fn omitted_agent_trace_block_parses_as_unset() { let config = parse("{}").unwrap(); diff --git a/cli/src/services/config/types.rs b/cli/src/services/config/types.rs index 9ef5e1aa..8e73e6da 100644 --- a/cli/src/services/config/types.rs +++ b/cli/src/services/config/types.rs @@ -198,6 +198,7 @@ pub(crate) struct ResolvedAuthRuntimeConfig { pub(crate) struct ResolvedObservabilityRuntimeConfig { pub(crate) log_level: LogLevel, pub(crate) log_format: LogFormat, + pub(crate) log_to_file: bool, pub(crate) log_dir: Option, pub(crate) log_file_retention_limit: usize, pub(crate) loaded_config_paths: Vec, diff --git a/cli/src/services/observability.rs b/cli/src/services/observability.rs index 2edc386b..7ef4ffaa 100644 --- a/cli/src/services/observability.rs +++ b/cli/src/services/observability.rs @@ -31,6 +31,7 @@ const EMPTY_SESSION_ID_TOKEN: &str = "%EMPTY"; pub struct ObservabilityConfig { pub level: LogLevel, pub format: LogFormat, + pub log_to_file: bool, } impl Default for ObservabilityConfig { @@ -38,6 +39,7 @@ impl Default for ObservabilityConfig { Self { level: LogLevel::Error, format: LogFormat::Text, + log_to_file: true, } } } @@ -61,8 +63,13 @@ impl Logger { config: ObservabilityConfig { level: config.log_level, format: config.log_format, + log_to_file: config.log_to_file, }, - log_dir: config.log_dir.as_deref().map(PathBuf::from), + log_dir: config + .log_to_file + .then_some(config.log_dir.as_deref()) + .flatten() + .map(PathBuf::from), log_file_retention_limit: config.log_file_retention_limit, }) } @@ -181,7 +188,9 @@ impl Logger { let line = self.render_line(level, event_id, message, fields); let redacted_line = redact_sensitive_text(&line); - emit_stderr_line(&redacted_line); + if should_emit_to_stderr(level, self.config.log_to_file) { + emit_stderr_line(&redacted_line); + } if let Err(error) = self.write_log_line(&redacted_line, session_id) { let diagnostic = redact_sensitive_text(&format!( @@ -192,6 +201,10 @@ impl Logger { } fn write_log_line(&self, redacted_line: &str, session_id: Option<&str>) -> Result<()> { + if !self.config.log_to_file { + return Ok(()); + } + let Some(log_dir) = self.log_dir.as_deref() else { return Ok(()); }; @@ -292,6 +305,10 @@ fn cli_error_fields(error: &CliError) -> Vec<(&'static str, String)> { fields } +fn should_emit_to_stderr(level: LogLevel, log_to_file: bool) -> bool { + level != LogLevel::Error || !log_to_file +} + fn validate_log_dir(value: &str) -> Result<()> { if value.is_empty() { bail!("Invalid {ENV_LOG_DIR} ''. Try: set it to a directory path or unset {ENV_LOG_DIR}."); @@ -728,6 +745,68 @@ mod tests { assert_eq!(field_value(&fields, "error_source"), None); } + #[test] + fn error_records_route_to_stderr_only_when_file_logging_is_disabled() { + assert!(!should_emit_to_stderr(LogLevel::Error, true)); + assert!(should_emit_to_stderr(LogLevel::Error, false)); + } + + #[test] + fn non_error_records_remain_on_stderr_when_file_logging_is_enabled() { + assert!(should_emit_to_stderr(LogLevel::Warn, true)); + assert!(should_emit_to_stderr(LogLevel::Info, true)); + assert!(should_emit_to_stderr(LogLevel::Debug, true)); + } + + #[test] + fn enabled_file_logging_writes_error_records_without_stderr_routing() { + let log_dir = + std::env::temp_dir().join(format!("sce-observability-test-{}", std::process::id())); + let _ = fs::remove_dir_all(&log_dir); + let logger = Logger { + config: ObservabilityConfig { + level: LogLevel::Error, + format: LogFormat::Text, + log_to_file: true, + }, + log_dir: Some(log_dir.clone()), + log_file_retention_limit: 10, + }; + + logger.error("sce.test.error", "test error", &[], None); + + let entries = fs::read_dir(&log_dir) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(entries.len(), 1); + let contents = fs::read_to_string(entries[0].path()).unwrap(); + assert!(contents.contains("event_id=sce.test.error")); + let _ = fs::remove_dir_all(log_dir); + } + + #[test] + fn disabled_file_logging_keeps_error_records_off_disk() { + let log_dir = std::env::temp_dir().join(format!( + "sce-observability-disabled-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&log_dir); + let logger = Logger { + config: ObservabilityConfig { + level: LogLevel::Error, + format: LogFormat::Text, + log_to_file: false, + }, + log_dir: Some(log_dir.clone()), + log_file_retention_limit: 10, + }; + + logger.error("sce.test.error", "test error", &[], None); + + assert!(!log_dir.exists()); + } + #[test] fn observability_fields_for_internal_error_carry_surface_and_full_source_chain() { let source = anyhow::anyhow!("root cause").context("failed to do the thing"); diff --git a/config/pkl/base/sce-config-schema.pkl b/config/pkl/base/sce-config-schema.pkl index da454f53..21c1ad7a 100644 --- a/config/pkl/base/sce-config-schema.pkl +++ b/config/pkl/base/sce-config-schema.pkl @@ -77,6 +77,11 @@ local sceConfigSchema = new JsonSchema { type = "string" enum = new { "text"; "json" } } + ["log_to_file"] = new JsonSchema { + type = "boolean" + description = "Write log records to the configured log directory. Defaults to true." + default = true + } ["log_dir"] = new JsonSchema { type = "string" minLength = 1 diff --git a/context/architecture.md b/context/architecture.md index d9487596..a8bfb359 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -107,7 +107,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `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 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` provides deterministic runtime observability controls and rendering for app lifecycle logs, including shared config-resolved threshold/format, explicit config-file/default `log_to_file`, 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, error-specific stderr suppression when file logging is enabled while non-error records and file-write diagnostics remain on stderr, 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`, `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. diff --git a/context/cli/config-precedence-contract.md b/context/cli/config-precedence-contract.md index 91e92a12..b0f1b95e 100644 --- a/context/cli/config-precedence-contract.md +++ b/context/cli/config-precedence-contract.md @@ -4,7 +4,7 @@ This contract documents the implemented `sce config` command behavior, runtime resolver, renderer, and canonical Pkl-authored `sce/config.json` schema. The schema is emitted to payload-relative `config/schema/sce-config.schema.json` under Cargo `OUT_DIR` or packaging fallbacks and embedded by `cli/src/services/config/schema.rs` as `SCE_CONFIG_SCHEMA_JSON`; no generated schema is committed. -The current implementation resolves flat logging keys and Agent Trace runtime keys with deterministic precedence and source metadata, exposes resolved-value inspection through `sce config show`, and keeps `sce config validate` focused on validation status plus errors/warnings. Threshold, format, directory, and `log_file_retention_limit` values are consumed by runtime logging; the concrete logger uses the retention value for primary and v2 creation-triggered cleanup. The default-enabled `agent_trace.auto_sync` value is consumed by the post-commit trigger boundary and by doctor readiness reporting, and can be disabled explicitly. +The current implementation resolves flat logging keys and Agent Trace runtime keys with deterministic precedence and source metadata, exposes resolved-value inspection through `sce config show`, and keeps `sce config validate` focused on validation status plus errors/warnings. File logging is explicitly controlled by the config-file/default `log_to_file` boolean, which defaults to `true`; `log_to_file` and `log_dir` resolve independently, with omitted `log_dir` falling back to the default location. Threshold, format, directory, and `log_file_retention_limit` values are consumed by runtime logging; the concrete logger uses the retention value for primary and v2 creation-triggered cleanup. The default-enabled `agent_trace.auto_sync` value is consumed by the post-commit trigger boundary and by doctor readiness reporting, and can be disabled explicitly. ## Command surface @@ -34,8 +34,10 @@ Agent Trace repository identity keys are also config-file only with per-key `glo Resolved observability values that currently have no CLI flag layer follow the same lower-precedence chain without a flag step: 1. environment values (`SCE_LOG_FORMAT`, `SCE_LOG_DIR`) -2. config file values (`log_format`, `log_dir`) -3. defaults (`log_format=text`; `log_dir=/sce/logs` through `default_paths::observability_log_dir()`, resolving on Linux to `$XDG_STATE_HOME/sce/logs` or `~/.local/state/sce/logs` when `XDG_STATE_HOME` is unset) +2. config file values (`log_format`, `log_to_file`, `log_dir`) +3. defaults (`log_format=text`, `log_to_file=true`; `log_dir=/sce/logs` through `default_paths::observability_log_dir()`, resolving on Linux to `$XDG_STATE_HOME/sce/logs` or `~/.local/state/sce/logs` when `XDG_STATE_HOME` is unset) + +`log_to_file` is config-file/default only; unlike `log_dir`, it has no environment variable or CLI flag. Omitting either property does not produce a cross-property validation error: `log_to_file` defaults to `true`, and omitted `log_dir` resolves to the default location. An explicit empty config value for `log_dir` is rejected by the generated schema. `log_file_retention_limit` intentionally has no environment or CLI-flag layer: @@ -80,8 +82,9 @@ When a default-discovered global or repo-local config file exists but fails JSON - Startup/runtime config resolution now degrades gracefully only for default-discovered files: invalid discovered files are skipped and reported via collected `validation_errors`, while explicit `--config` / `SCE_CONFIG_FILE` targets still fail immediately on the same parse or validation errors. - Config file content must be valid JSON with a top-level object. -- Allowed keys: `$schema`, `log_level`, `log_format`, `log_dir`, `log_file_retention_limit`, `timeout_ms`, `workos_client_id`, `control_plane_base_url`, `agent_trace`, `policies`, `integrations`. +- Allowed keys: `$schema`, `log_level`, `log_format`, `log_to_file`, `log_dir`, `log_file_retention_limit`, `timeout_ms`, `workos_client_id`, `control_plane_base_url`, `agent_trace`, `policies`, `integrations`. - Unknown keys fail validation. +- `log_to_file` must be a boolean when present and defaults to `true`; it is independent of `log_dir`. - `log_level` must be one of `error|warn|info|debug`. - `log_format` must be `text` or `json` when present. - `log_dir` must be a non-empty string when present. @@ -121,7 +124,7 @@ When a default-discovered global or repo-local config file exists but fails JSON - `show` reports discovered config files as `config_paths` (JSON) / `Config files:` (text). - Resolved values in `show` continue to report `source`; when source is `config_file`, output also reports a deterministic `config_source` value (`flag`, `env`, `default_discovered_global`, `default_discovered_local`). - `show` includes migrated supported auth keys in `result.resolved`. -- `show` includes resolved observability values directly in `result.resolved`, preserving flat logging keys (`log_level`, `log_format`, `log_dir`, `log_file_retention_limit`). +- `show` includes resolved observability values directly in `result.resolved`, preserving flat logging keys (`log_level`, `log_format`, `log_to_file`, `log_dir`, `log_file_retention_limit`) and their source metadata. - `validate` text output is limited to `SCE config validation`, `Validation issues`, and `Validation warnings` lines. - `validate` JSON output is limited to `result.command`, `result.valid`, `result.issues`, and `result.warnings`. - `show` includes resolved Agent Trace configuration under `result.resolved.agent_trace` (JSON: `repository_id` optional-value shape, `repository_remote` and `auto_sync` resolved-value shapes) and as per-key text lines, reporting `(unset)` for a missing `repository_id`, `source: default` for the `origin` remote fallback, and `source: default` for omitted `auto_sync`. diff --git a/context/context-map.md b/context/context-map.md index 60b3d560..b7a4895b 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -20,7 +20,7 @@ Feature/domain context: - `context/cli/agent-trace-auto-sync.md` (default-enabled post-commit Agent Trace synchronization with explicit-false opt-out plus doctor readiness reporting: the existing `sce sync` command launched once through the current executable after local persistence, detached null-standard-stream behavior, fail-open startup, canonical managed-block proof of hook readiness, stable text/JSON enabled/disabled/not-ready/not-applicable states, no daemon/queue/high-frequency trigger, and retryability through manual sync and control-plane cursor authority) - `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`, default-enabled `agent_trace.auto_sync` boolean resolution with explicit-false opt-out for the post-commit trigger boundary, 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/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 config-file/default `log_to_file`, `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`, default-enabled `agent_trace.auto_sync` boolean resolution with explicit-false opt-out for the post-commit trigger boundary, 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) - `context/sce/cli-exit-code-contract.md` (stable class-based `sce` process exit-code contract in `cli/src/app.rs`, so automation can branch on failure category without parsing error text) @@ -30,7 +30,7 @@ Feature/domain context: - `context/sce/cli-version-command-contract.md` (implemented `sce version` contract for deterministic human and machine-readable runtime identification) - `context/sce/cli-shell-completion-contract.md` (implemented `sce completion` contract for deterministic Bash/Zsh/Fish completion script generation) - `context/sce/claude-raw-hook-capture.md` (removed feature: the former `sce hooks claude-capture` raw-capture route and its supporting types, replaced by the active `diff-trace` and `conversation-trace` intakes) -- `context/sce/cli-observability-contract.md` (implemented config-backed runtime observability contract for the flat logging config-file shape with `log_dir` / `SCE_LOG_DIR` env-over-config-over-`/sce/logs` fallback, append-only local-date/session log file routing with a one-time complete-record `-v2.log` fallback on primary open/append/flush failure, creation-triggered retention of direct regular `*.log` files to 10 entries, reliable producer-native diff-trace/conversation-trace session routing and hook-specific non-duplicated Agent Trace DB-open error events, deterministic session filename sanitization, concrete logger/telemetry runtime behavior plus logger and object-safe telemetry trait boundaries, AppContext observability wiring, generic `RunOutcome` final rendering, runtime-classified repeated telemetry action protection, operator-facing `sce config show` observability reporting, and the trimmed `sce config validate` status-only validation surface) +- `context/sce/cli-observability-contract.md` (implemented config-backed runtime observability contract for the flat logging config-file shape with explicit config-file/default `log_to_file`, `log_dir` / `SCE_LOG_DIR` env-over-config-over-`/sce/logs` fallback, append-only local-date/session log file routing with a one-time complete-record `-v2.log` fallback on primary open/append/flush failure, creation-triggered retention of direct regular `*.log` files to 10 entries, reliable producer-native diff-trace/conversation-trace session routing and hook-specific non-duplicated Agent Trace DB-open error events, deterministic session filename sanitization, concrete logger/telemetry runtime behavior plus logger and object-safe telemetry trait boundaries, AppContext observability wiring, generic `RunOutcome` final rendering, runtime-classified repeated telemetry action protection, operator-facing `sce config show` observability reporting, and the trimmed `sce config validate` status-only validation surface) - `context/sce/shared-context-code-workflow.md` (canonical `/next-task` task-synchronization lifecycle and validation-only `/validate` lifecycle, package-local phase references with single-skill control flow, and the task-synchronization-scoped `sce-decision` sibling invocation with ADR reuse/blocker propagation) - `context/sce/shared-context-plan-workflow.md` (canonical `/change-to-plan` workflow, package-local context-load/plan-authoring/template references, clarification/readiness gate contract, and one-task/one-atomic-commit task slicing) - [Context workflow rules](sce/context-workflow-rules.md) (canonical bootstrap, ongoing context maintenance, task synchronization, hygiene, discoverability, and feature-existence rules) diff --git a/context/glossary.md b/context/glossary.md index bafda407..b999dc8a 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -65,6 +65,7 @@ - `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`. +- `log_to_file`: Flat SCE config-file boolean controlling file-log emission independently of stderr and tracing. It defaults to `true`, is surfaced with source metadata by `sce config show`, and resolves independently from `log_dir`; an omitted `log_dir` uses the default location, while an explicitly empty config value remains invalid. Set `log_to_file` to `false` to disable file logging without changing other logger destinations. See [CLI observability contract](sce/cli-observability-contract.md). - `sce` (CLI foundation): Rust binary crate at `cli/` with implemented auth command flows (`auth login|logout|whoami`) plus auth-local bare-command guidance (`sce auth`, `sce auth --help`), Control Plane `/me`-backed whoami profile output using flat email/name/role/permissions/organization labels, exact logged-out login guidance, 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`). - `auth login stored-credential renewal`: The `sce auth login` behavior that first validates every stored credential through the existing non-forced token path, preserves valid credentials, refreshes expired credentials, and falls back to device authorization when credentials are absent or renewal fails. Renewal reports retain `login` labels in text and JSON; credential renewal is not exposed as a public subcommand. - `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. @@ -118,14 +119,13 @@ - `sce exit-code class contract`: Stable top-level process exit-code mapping owned by `cli/src/app.rs` (`0` success, `2` parse failure, `3` validation failure, `4` runtime failure, `5` dependency failure) so automation can branch on failure class without parsing text errors. - `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 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, tracing for all emitted records, and error-specific stderr suppression when file logging is enabled. - `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. - `SCE_LOG_FILE_MODE`: Optional runtime env key controlling `SCE_LOG_FILE` write policy; allowed values are `truncate` and `append`, defaults to `truncate`, and requires `SCE_LOG_FILE`. - `SCE_LOG_DIR`: Optional runtime env key for `sce` observability log-directory configuration; when set, it overrides config-file `log_dir` and the `/sce/logs` default and must be non-empty. - - `logger trait boundary`: `services::observability::traits::Logger` mirrors the current observability logger API (`info`, `debug`, `warn`, `error`, `log_cli_error`) for generic command/runtime bounds and tests, with each method accepting `Option<&str>` session context for file routing; the concrete `services::observability::Logger` implements it and `NoopLogger` remains available for side-effect-free tests. - `telemetry trait boundary`: `services::observability::traits::Telemetry` mirrors the current telemetry subscriber API (`with_default_subscriber`) for generic app-runtime bounds and tests, with the concrete `services::observability::TelemetryRuntime` implementing it by delegating to the existing inherent method. - `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`. diff --git a/context/overview.md b/context/overview.md index a3c91969..90a0e831 100644 --- a/context/overview.md +++ b/context/overview.md @@ -11,7 +11,7 @@ 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 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`). +- **Observability:** config-resolved logging with tracing, explicit config-file/default `log_to_file` control, error-specific stderr suppression when file logging is enabled, and 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`); the config-file-only `agent_trace.auto_sync` setting defaults to `true` and is resolved with source metadata for the post-commit trigger and doctor readiness boundaries. Its asynchronous post-commit behavior and doctor capability reporting are documented in `context/cli/agent-trace-auto-sync.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`). @@ -21,7 +21,7 @@ Its command loop is implemented with `clap` derive-based argument parsing and `a The current doctor presentation contract supersedes the earlier output-shape scaffolding wording above: human text uses the compact Environment/Repository/Integrations hierarchy with healthy rows collapsed and unhealthy branches expanded, while JSON retains complete path, identity, problem, and fix-result detail. See `context/sce/doctor-human-text-contract.md`. 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 command boundary's former flat, string-only `ClassifiedError` has been replaced by typed `CliError` in `cli/src/services/error.rs`: `CliError::User` carries a closed `UserError` catalog (currently only `NotAuthenticated`) for expected, deliberately-explained failures rendered as a friendly sentence with no `Try:` suffix, while `CliError::Internal` carries a live `anyhow::Error` source rendered as the real error chain with class-default remediation; `app_support` is the sole owner of turning either into the final styled stderr diagnostic, and `sce sync` is the first command to classify a failure (authentication) into `CliError::User`. See `context/sce/cli-error-code-taxonomy.md` for the full contract. -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 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, error-specific stderr suppression while preserving stderr for non-error records and file-write diagnostics 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 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. @@ -30,7 +30,7 @@ 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 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_to_file`, `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. diff --git a/context/sce/cli-observability-contract.md b/context/sce/cli-observability-contract.md index 02b44afd..ff8eebd5 100644 --- a/context/sce/cli-observability-contract.md +++ b/context/sce/cli-observability-contract.md @@ -12,7 +12,8 @@ Runtime observability consumes the shared resolved observability config from `cl - `SCE_LOG_LEVEL` selects log threshold with allowed values `error`, `warn`, `info`, `debug`. - `SCE_LOG_FORMAT` selects log format with allowed values `text`, `json`. - `SCE_LOG_DIR` configures the log-directory value used by the logger configuration surface and overrides config/default values. -- Defaults are deterministic: `log_level=error`, `log_format=text`, and `log_dir=/sce/logs` when higher-precedence env/config inputs are unset. +- `log_to_file` is a flat config-file boolean that defaults to `true`; it explicitly controls whether records are written to the configured log directory while tracing and stderr routing remain separate concerns for the current logger. It resolves independently from `log_dir`. +- Defaults are deterministic: `log_level=error`, `log_format=text`, `log_to_file=true`, and `log_dir=/sce/logs` when higher-precedence env/config inputs are unset. Omitting either file-logging property is valid. - `log_file_retention_limit` is a flat config-file/default-only value with minimum `1` and default `10`; it has no environment variable or CLI flag, merges local over global, and appears in `sce config show` with provenance. - The default `log_dir` is resolved by `cli/src/services/default_paths.rs` through `observability_log_dir()`; on Linux this is `$XDG_STATE_HOME/sce/logs`, or `~/.local/state/sce/logs` when `XDG_STATE_HOME` is unset. - Invalid observability env values still fail invocation validation with actionable error text. @@ -26,13 +27,14 @@ Runtime observability consumes the shared resolved observability config from `cl ## Emission contract -- Log output is always emitted to `stderr`; command result payloads remain on `stdout`. -- Each enabled or forced log operation appends the redacted rendered record to a file selected at emit time from the resolved `log_dir`, machine-local date, and optional caller-provided session ID. +- Command result payloads remain on `stdout`; non-error log records and file-write diagnostics are emitted to `stderr`. +- Error records are emitted to tracing in all cases. When `log_to_file=true`, they are written to the configured log file and their logger emission is suppressed on `stderr` to avoid duplicate output; when `log_to_file=false`, error records remain on `stderr` and are not written to a file. +- Each enabled or forced log operation appends the redacted rendered record to a file selected at emit time from the resolved `log_dir`, machine-local date, and optional caller-provided session ID, except when file logging is disabled. - Sessionless file logs route to `/sce-.log`; session-aware file logs route to `/sce--.log`. - Session filename sanitization preserves ASCII letters, digits, `-`, and `_`; percent-encodes every other UTF-8 byte as uppercase `%HH`; and represents an explicitly empty `Some("")` session ID with the reserved `%EMPTY` token. - `sce hooks diff-trace` and `conversation-trace` pass producer-native session context into this existing routing argument when available. Diff-trace logging never uses the AgentTraceDb-only `oc_`/`cc_`/`pi_` prefix; skipped conversation items use their own session; batch-wide conversation insert failures use the first valid insert's session. Agent Trace DB open failures use hook-specific error events (`sce.hooks.diff_trace.agent_trace_db_open_failed` and `sce.hooks.conversation_trace.agent_trace_db_open_failed`) and do not also emit their broader write/intake events for the same failure. Session IDs remain absent from rendered record fields unless separately supplied as fields. - File routing creates the configured directory when needed, uses owner-only create permissions on Unix, and serializes writes independently per path. -- If the selected primary file cannot be opened, appended to, or flushed, the logger retries the complete rendered record exactly once at a sibling path with `-v2` inserted before `.log`: `sce--v2.log` or `sce---v2.log`. Existing v2 files use the same create-or-append and per-path serialization behavior. +- If file logging is enabled and the selected primary file cannot be opened, appended to, or flushed, the logger retries the complete rendered record exactly once at a sibling path with `-v2` inserted before `.log`: `sce--v2.log` or `sce---v2.log`. Existing v2 files use the same create-or-append and per-path serialization behavior. - Successful v2 persistence suppresses the terminal `Failed to write SCE log file` diagnostic and leaves the CLI command result unchanged. If both persistence attempts fail, the logger emits one redacted terminal file-write diagnostic to stderr and continues fail-open; a partial primary append may therefore coexist with the complete fallback record. - Directory creation, primary lock acquisition, and retention cleanup failures do not trigger alternate-name generation. The fallback attempt is non-recursive: no v3, timestamped, random, or unbounded variants are tried. - After a successful write to a newly created primary or v2 SCE log file, the logger runs one best-effort retention pass over direct regular `*.log` children of `log_dir`. Existing-file appends do not scan or delete files. Cleanup keeps the resolved `log_file_retention_limit` newest files (default `10`). Files are ordered newest-first by filesystem modification time with path/name ordering as the deterministic tie-break, and older `.log` files are removed regardless of whether their names are SCE-owned. @@ -76,13 +78,15 @@ Runtime observability consumes the shared resolved observability config from `cl ## Log directory config safety contract -- `log_dir` config-file values are schema-validated as non-empty strings. +- `log_to_file` is resolved from the config file or its backward-compatible `true` default and is surfaced with provenance by `sce config show`. +- `log_to_file` and `log_dir` are independent configuration properties. Omitting `log_dir` uses the default location, and omitting `log_to_file` defaults to enabled file logging; neither omission creates a cross-property validation error. +- `log_dir` config-file values are schema-validated as non-empty strings; an explicitly empty config value fails schema validation before runtime startup. Setting `log_to_file` to `false` disables file logging without changing `log_dir` resolution. - `SCE_LOG_DIR` env values are resolved with env-over-config-over-default precedence and rejected when explicitly empty. - Logger construction validates resolved `log_dir` as non-empty without opening files; directory creation, per-operation local-date file selection, append writes, Unix owner-only create permissions, and creation-triggered retention happen at log emission time. ## Ownership and verification -- `cli/src/services/config/resolver.rs` owns shared observability value resolution, config-file discovery/merge, env-over-config/default precedence for supported runtime inputs, default `log_dir` resolution through `default_paths::observability_log_dir()`, and config-file/default-only `log_file_retention_limit` resolution. +- `cli/src/services/config/resolver.rs` owns shared observability value resolution, including independent config-file/default `log_to_file` and `log_dir` resolution, config-file discovery/merge, env-over-config/default precedence for supported runtime inputs, default `log_dir` resolution through `default_paths::observability_log_dir()`, and config-file/default-only `log_file_retention_limit` resolution. - `cli/src/services/observability.rs` owns runtime logger construction from resolved values, storage and application of `log_file_retention_limit`, `log_dir` non-empty validation, level filtering, tracing-event enablement checks, record rendering, local-date/session file-name selection, session filename sanitization, primary append plus one-time v2 fallback persistence, and best-effort `.log` retention; `cli/src/services/observability/traits.rs` owns the logger and telemetry trait boundaries plus the no-op logger implementation. - `cli/src/app.rs` owns lifecycle event emission around parse/dispatch success and failure paths, resolves observability config before command dispatch, emits startup invalid-config warning events for skipped discovered config files, wraps dispatch inside the observability subscriber context, and guards the single-use command-dispatch action against repeated telemetry invocation with a runtime-classified error. `cli/src/services/app_support.rs` owns final stdout/stderr rendering and generic logger-backed classified-error logging. - Retention-specific validation uses packaged CLI smoke checks for config/schema behavior and direct review of the primary/v2 logger cleanup plumbing. The root flake check suite validates the build, lint, formatting, generated parity, and remaining repository tests; no retention-specific Rust test module is currently kept in `observability.rs`, `config/resolver.rs`, or `config/schema.rs`.