diff --git a/cli/assets/generated/config/schema/sce-config.schema.json b/cli/assets/generated/config/schema/sce-config.schema.json index dfd6eff1..0604afd9 100644 --- a/cli/assets/generated/config/schema/sce-config.schema.json +++ b/cli/assets/generated/config/schema/sce-config.schema.json @@ -43,6 +43,24 @@ "workos_client_id": { "type": "string" }, + "agent_trace": { + "description": "Agent Trace repository identity configuration. Selects the repository-scoped Agent Trace database.", + "type": "object", + "properties": { + "repository_id": { + "description": "Explicit repository identity. When set, overrides Git remote based repository identity resolution.", + "type": "string", + "minLength": 1 + }, + "repository_remote": { + "description": "Git remote name used to derive repository identity. Defaults to origin when omitted.", + "default": "origin", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, "policies": { "type": "object", "properties": { diff --git a/cli/migrations/agent-trace-repository/001_repository_schema.sql b/cli/migrations/agent-trace-repository/001_repository_schema.sql new file mode 100644 index 00000000..e1a61f32 --- /dev/null +++ b/cli/migrations/agent-trace-repository/001_repository_schema.sql @@ -0,0 +1,116 @@ +-- Repository-scoped Agent Trace database baseline. +-- +-- One logical Git repository maps to one database at +-- /sce/repos//agent-trace.db. This file is the +-- complete fresh schema for that database: repository-scoped databases are +-- always created new, so the baseline stays a single file instead of an +-- incremental migration chain. Trace tables are repository-level and +-- intentionally carry no checkout_id columns; checkout identity is +-- diagnostics-only context and is never persisted on trace rows. + +CREATE TABLE IF NOT EXISTS repository_metadata ( + id INTEGER PRIMARY KEY CHECK (id = 1), + repository_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE IF NOT EXISTS diff_traces ( + id INTEGER PRIMARY KEY, + time_ms INTEGER NOT NULL, + session_id TEXT NOT NULL, + patch TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + model_id TEXT, + tool_name TEXT, + tool_version TEXT, + payload_type TEXT NOT NULL DEFAULT 'patch' +); + +CREATE INDEX IF NOT EXISTS idx_diff_traces_time_ms_id +ON diff_traces (time_ms, id); + +CREATE TABLE IF NOT EXISTS post_commit_patch_intersections ( + id INTEGER PRIMARY KEY, + commit_id TEXT NOT NULL, + post_commit_time_ms INTEGER NOT NULL, + recent_window_cutoff_ms INTEGER NOT NULL, + recent_window_end_ms INTEGER NOT NULL, + loaded_diff_trace_count INTEGER NOT NULL CHECK (loaded_diff_trace_count >= 0), + skipped_diff_trace_count INTEGER NOT NULL CHECK (skipped_diff_trace_count >= 0), + intersection_patch TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE IF NOT EXISTS agent_traces ( + id INTEGER PRIMARY KEY, + commit_id TEXT NOT NULL, + commit_time_ms INTEGER NOT NULL, + url TEXT NOT NULL, + trace_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + agent_trace_id TEXT NOT NULL UNIQUE, + remote_url TEXT +); + +CREATE INDEX IF NOT EXISTS idx_agent_traces_agent_trace_id +ON agent_traces (agent_trace_id); + +CREATE INDEX IF NOT EXISTS idx_agent_traces_remote_url +ON agent_traces (remote_url); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + session_id TEXT NOT NULL, + message_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), + generated_at_unix_ms INTEGER NOT NULL CHECK (generated_at_unix_ms >= 0), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_session_message +ON messages (session_id, message_id); + +CREATE INDEX IF NOT EXISTS idx_messages_session_order +ON messages (session_id, generated_at_unix_ms, id); + +CREATE TABLE IF NOT EXISTS parts ( + id INTEGER PRIMARY KEY, + type TEXT NOT NULL, + text TEXT NOT NULL, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + generated_at_unix_ms INTEGER NOT NULL CHECK (generated_at_unix_ms >= 0), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_parts_session_message_order +ON parts (session_id, message_id, generated_at_unix_ms, id); + +CREATE TRIGGER IF NOT EXISTS trg_messages_updated_at +AFTER UPDATE ON messages +FOR EACH ROW +WHEN OLD.session_id IS NOT NEW.session_id + OR OLD.message_id IS NOT NEW.message_id + OR OLD.role IS NOT NEW.role + OR OLD.generated_at_unix_ms IS NOT NEW.generated_at_unix_ms +BEGIN + UPDATE messages + SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = NEW.id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_parts_updated_at +AFTER UPDATE ON parts +FOR EACH ROW +WHEN OLD.type IS NOT NEW.type + OR OLD.text IS NOT NEW.text + OR OLD.message_id IS NOT NEW.message_id + OR OLD.session_id IS NOT NEW.session_id + OR OLD.generated_at_unix_ms IS NOT NEW.generated_at_unix_ms +BEGIN + UPDATE parts + SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = NEW.id; +END; diff --git a/cli/migrations/agent-trace/001_create_diff_traces.sql b/cli/migrations/agent-trace/001_create_diff_traces.sql deleted file mode 100644 index 4bc7e389..00000000 --- a/cli/migrations/agent-trace/001_create_diff_traces.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS diff_traces ( - id INTEGER PRIMARY KEY, - time_ms INTEGER NOT NULL, - session_id TEXT NOT NULL, - patch TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - model_id TEXT, - tool_name TEXT, - tool_version TEXT -); diff --git a/cli/migrations/agent-trace/002_create_post_commit_patch_intersections.sql b/cli/migrations/agent-trace/002_create_post_commit_patch_intersections.sql deleted file mode 100644 index b57fa97e..00000000 --- a/cli/migrations/agent-trace/002_create_post_commit_patch_intersections.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS post_commit_patch_intersections ( - id INTEGER PRIMARY KEY, - commit_id TEXT NOT NULL, - post_commit_time_ms INTEGER NOT NULL, - recent_window_cutoff_ms INTEGER NOT NULL, - recent_window_end_ms INTEGER NOT NULL, - loaded_diff_trace_count INTEGER NOT NULL CHECK (loaded_diff_trace_count >= 0), - skipped_diff_trace_count INTEGER NOT NULL CHECK (skipped_diff_trace_count >= 0), - intersection_patch TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); diff --git a/cli/migrations/agent-trace/003_create_agent_traces.sql b/cli/migrations/agent-trace/003_create_agent_traces.sql deleted file mode 100644 index 5b72a088..00000000 --- a/cli/migrations/agent-trace/003_create_agent_traces.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS agent_traces ( - id INTEGER PRIMARY KEY, - commit_id TEXT NOT NULL, - commit_time_ms INTEGER NOT NULL, - url TEXT NOT NULL, - trace_json TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - agent_trace_id TEXT NOT NULL UNIQUE -); diff --git a/cli/migrations/agent-trace/004_create_diff_traces_time_ms_id_index.sql b/cli/migrations/agent-trace/004_create_diff_traces_time_ms_id_index.sql deleted file mode 100644 index 9a296912..00000000 --- a/cli/migrations/agent-trace/004_create_diff_traces_time_ms_id_index.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_diff_traces_time_ms_id -ON diff_traces (time_ms, id); diff --git a/cli/migrations/agent-trace/005_create_agent_traces_agent_trace_id_index.sql b/cli/migrations/agent-trace/005_create_agent_traces_agent_trace_id_index.sql deleted file mode 100644 index bada8fad..00000000 --- a/cli/migrations/agent-trace/005_create_agent_traces_agent_trace_id_index.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_agent_traces_agent_trace_id -ON agent_traces (agent_trace_id); diff --git a/cli/migrations/agent-trace/006_add_agent_traces_vcs_remote_url.sql b/cli/migrations/agent-trace/006_add_agent_traces_vcs_remote_url.sql deleted file mode 100644 index 44081454..00000000 --- a/cli/migrations/agent-trace/006_add_agent_traces_vcs_remote_url.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE agent_traces -ADD COLUMN remote_url TEXT; diff --git a/cli/migrations/agent-trace/007_create_agent_traces_vcs_remote_url_index.sql b/cli/migrations/agent-trace/007_create_agent_traces_vcs_remote_url_index.sql deleted file mode 100644 index eda388cc..00000000 --- a/cli/migrations/agent-trace/007_create_agent_traces_vcs_remote_url_index.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_agent_traces_remote_url -ON agent_traces (remote_url); diff --git a/cli/migrations/agent-trace/008_create_messages.sql b/cli/migrations/agent-trace/008_create_messages.sql deleted file mode 100644 index 6eb86fd0..00000000 --- a/cli/migrations/agent-trace/008_create_messages.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY, - session_id TEXT NOT NULL, - message_id TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), - generated_at_unix_ms INTEGER NOT NULL CHECK (generated_at_unix_ms >= 0), - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); diff --git a/cli/migrations/agent-trace/009_create_parts.sql b/cli/migrations/agent-trace/009_create_parts.sql deleted file mode 100644 index 82d25066..00000000 --- a/cli/migrations/agent-trace/009_create_parts.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS parts ( - id INTEGER PRIMARY KEY, - type TEXT NOT NULL, - text TEXT NOT NULL, - message_id TEXT NOT NULL, - session_id TEXT NOT NULL, - generated_at_unix_ms INTEGER NOT NULL CHECK (generated_at_unix_ms >= 0), - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); diff --git a/cli/migrations/agent-trace/010_create_messages_session_message_unique_index.sql b/cli/migrations/agent-trace/010_create_messages_session_message_unique_index.sql deleted file mode 100644 index 3035a5cb..00000000 --- a/cli/migrations/agent-trace/010_create_messages_session_message_unique_index.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_session_message -ON messages (session_id, message_id); diff --git a/cli/migrations/agent-trace/011_create_messages_session_order_index.sql b/cli/migrations/agent-trace/011_create_messages_session_order_index.sql deleted file mode 100644 index dfd1d2ff..00000000 --- a/cli/migrations/agent-trace/011_create_messages_session_order_index.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_messages_session_order -ON messages (session_id, generated_at_unix_ms, id); diff --git a/cli/migrations/agent-trace/012_create_parts_session_message_order_index.sql b/cli/migrations/agent-trace/012_create_parts_session_message_order_index.sql deleted file mode 100644 index 834c89f5..00000000 --- a/cli/migrations/agent-trace/012_create_parts_session_message_order_index.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_parts_session_message_order -ON parts (session_id, message_id, generated_at_unix_ms, id); diff --git a/cli/migrations/agent-trace/013_create_messages_updated_at_trigger.sql b/cli/migrations/agent-trace/013_create_messages_updated_at_trigger.sql deleted file mode 100644 index de16cb10..00000000 --- a/cli/migrations/agent-trace/013_create_messages_updated_at_trigger.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TRIGGER IF NOT EXISTS trg_messages_updated_at -AFTER UPDATE ON messages -FOR EACH ROW -WHEN OLD.session_id IS NOT NEW.session_id - OR OLD.message_id IS NOT NEW.message_id - OR OLD.role IS NOT NEW.role - OR OLD.generated_at_unix_ms IS NOT NEW.generated_at_unix_ms -BEGIN - UPDATE messages - SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') - WHERE id = NEW.id; -END; diff --git a/cli/migrations/agent-trace/014_create_parts_updated_at_trigger.sql b/cli/migrations/agent-trace/014_create_parts_updated_at_trigger.sql deleted file mode 100644 index 142e53ef..00000000 --- a/cli/migrations/agent-trace/014_create_parts_updated_at_trigger.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TRIGGER IF NOT EXISTS trg_parts_updated_at -AFTER UPDATE ON parts -FOR EACH ROW -WHEN OLD.type IS NOT NEW.type - OR OLD.text IS NOT NEW.text - OR OLD.message_id IS NOT NEW.message_id - OR OLD.session_id IS NOT NEW.session_id - OR OLD.generated_at_unix_ms IS NOT NEW.generated_at_unix_ms -BEGIN - UPDATE parts - SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') - WHERE id = NEW.id; -END; diff --git a/cli/migrations/agent-trace/015_add_diff_traces_payload_type.sql b/cli/migrations/agent-trace/015_add_diff_traces_payload_type.sql deleted file mode 100644 index a893fe95..00000000 --- a/cli/migrations/agent-trace/015_add_diff_traces_payload_type.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE diff_traces ADD COLUMN payload_type TEXT NOT NULL DEFAULT 'patch'; \ No newline at end of file diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index 66e0b2d2..49ebb203 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -237,7 +237,7 @@ pub enum TraceSubcommand { subcommand: TraceDbSubcommand, }, - #[command(about = "Show Agent Trace activity for the current checkout (or all with --all)")] + #[command(about = "Show Agent Trace activity for the current repository (or all with --all)")] Status { #[arg(long)] all: bool, @@ -255,10 +255,10 @@ pub enum TraceDbSubcommand { format: OutputFormat, }, - #[command(about = "Open an embedded SQL shell for a discovered Agent Trace database")] + #[command(about = "Open an embedded SQL shell for an Agent Trace database")] Shell { - #[arg(value_name = "uuid-or-alias")] - identifier: String, + #[arg(value_name = "repository-id-or-alias")] + identifier: Option, }, } diff --git a/cli/src/generated_migrations.rs b/cli/src/generated_migrations.rs index 5a855da2..1acdb477 100644 --- a/cli/src/generated_migrations.rs +++ b/cli/src/generated_migrations.rs @@ -2,22 +2,8 @@ #![allow(dead_code)] #[rustfmt::skip] -pub static AGENT_TRACE_MIGRATIONS: &[(&str, &str)] = &[ - ("001_create_diff_traces", include_str!("../migrations/agent-trace/001_create_diff_traces.sql")), - ("002_create_post_commit_patch_intersections", include_str!("../migrations/agent-trace/002_create_post_commit_patch_intersections.sql")), - ("003_create_agent_traces", include_str!("../migrations/agent-trace/003_create_agent_traces.sql")), - ("004_create_diff_traces_time_ms_id_index", include_str!("../migrations/agent-trace/004_create_diff_traces_time_ms_id_index.sql")), - ("005_create_agent_traces_agent_trace_id_index", include_str!("../migrations/agent-trace/005_create_agent_traces_agent_trace_id_index.sql")), - ("006_add_agent_traces_vcs_remote_url", include_str!("../migrations/agent-trace/006_add_agent_traces_vcs_remote_url.sql")), - ("007_create_agent_traces_vcs_remote_url_index", include_str!("../migrations/agent-trace/007_create_agent_traces_vcs_remote_url_index.sql")), - ("008_create_messages", include_str!("../migrations/agent-trace/008_create_messages.sql")), - ("009_create_parts", include_str!("../migrations/agent-trace/009_create_parts.sql")), - ("010_create_messages_session_message_unique_index", include_str!("../migrations/agent-trace/010_create_messages_session_message_unique_index.sql")), - ("011_create_messages_session_order_index", include_str!("../migrations/agent-trace/011_create_messages_session_order_index.sql")), - ("012_create_parts_session_message_order_index", include_str!("../migrations/agent-trace/012_create_parts_session_message_order_index.sql")), - ("013_create_messages_updated_at_trigger", include_str!("../migrations/agent-trace/013_create_messages_updated_at_trigger.sql")), - ("014_create_parts_updated_at_trigger", include_str!("../migrations/agent-trace/014_create_parts_updated_at_trigger.sql")), - ("015_add_diff_traces_payload_type", include_str!("../migrations/agent-trace/015_add_diff_traces_payload_type.sql")), +pub static AGENT_TRACE_REPOSITORY_MIGRATIONS: &[(&str, &str)] = &[ + ("001_repository_schema", include_str!("../migrations/agent-trace-repository/001_repository_schema.sql")), ]; #[rustfmt::skip] diff --git a/cli/src/services/agent_trace_db/lifecycle.rs b/cli/src/services/agent_trace_db/lifecycle.rs index 8103628b..c5787a52 100644 --- a/cli/src/services/agent_trace_db/lifecycle.rs +++ b/cli/src/services/agent_trace_db/lifecycle.rs @@ -2,15 +2,19 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; use crate::app::HasRepoRoot; -use crate::services::checkout; +use crate::services::agent_trace_storage::{resolve_agent_trace_storage, AgentTraceStorageContext}; +use crate::services::config; use crate::services::db::{bootstrap_db_parent, collect_db_path_health, DbSpec}; -use crate::services::default_paths::{agent_trace_db_path, agent_trace_db_path_for_checkout}; +use crate::services::default_paths::agent_trace_db_path_for_repository; use crate::services::lifecycle::{ FixOutcome, FixResultRecord, HealthCategory, HealthFixability, HealthProblem, HealthProblemKind, HealthSeverity, LifecycleProviderId, ServiceLifecycle, SetupOutcome, }; +use crate::services::repository_identity::resolve::{ + resolve_repository_identity, RepositoryIdentitySource, +}; -use super::{AgentTraceDb, AgentTraceDbSpec}; +use super::repository::{RepositoryAgentTraceDb, RepositoryAgentTraceDbSpec}; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct AgentTraceDbLifecycle; @@ -53,22 +57,17 @@ impl ServiceLifecycle for AgentTraceDbLifecycle { } fn setup(&self, ctx: &C) -> Result { - let checkout_setup = match ctx.repo_root() { - Some(repo_root) => { - let checkout_id = setup_checkout_identity(repo_root).context( - "Agent trace DB lifecycle setup failed while resolving checkout identity", - )?; - Some(initialize_checkout_agent_trace_db(&checkout_id).context( - "Agent trace DB lifecycle setup failed while initializing checkout database", - )?) - } + let repository_setup = match ctx.repo_root() { + Some(repo_root) => Some(initialize_repository_agent_trace_db(repo_root).context( + "Agent trace DB lifecycle setup failed while initializing repository database", + )?), None => None, }; Ok(SetupOutcome { - messages: checkout_setup + messages: repository_setup .iter() - .map(format_checkout_identity_setup_message) + .map(format_repository_storage_setup_message) .collect(), ..SetupOutcome::default() }) @@ -76,50 +75,54 @@ impl ServiceLifecycle for AgentTraceDbLifecycle { } #[derive(Clone, Debug, Eq, PartialEq)] -struct CheckoutDatabaseSetup { +struct RepositoryDatabaseSetup { + repository_id: String, + canonical_identity: String, + identity_source: String, + configured_remote: Option, checkout_id: String, database_path: PathBuf, } -fn setup_checkout_identity(repo_root: &std::path::Path) -> Result { - let git_dir = checkout::resolve_git_dir(repo_root).with_context(|| { - format!( - "failed to resolve git directory for checkout identity from '{}'", - repo_root.display() - ) - })?; - let checkout_id = checkout::get_or_create_checkout_id(&git_dir).with_context(|| { - format!( - "failed to get or create checkout identity under '{}'", - git_dir.display() - ) - })?; - - Ok(checkout_id) -} - -fn initialize_checkout_agent_trace_db(checkout_id: &str) -> Result { - let db_path = agent_trace_db_path_for_checkout(checkout_id).with_context(|| { - format!("failed to resolve Agent Trace DB path for checkout ID {checkout_id}") - })?; +fn initialize_repository_agent_trace_db(repo_root: &Path) -> Result { + let storage_config = config::resolve_agent_trace_storage_runtime_config(repo_root) + .context("failed to resolve Agent Trace repository storage config")?; + let storage_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(&storage_context)?; - AgentTraceDb::open_at(&db_path).with_context(|| { - format!( - "failed to initialize Agent Trace DB for checkout {} at '{}'", - checkout_id, - db_path.display() - ) - })?; + let (identity_source, configured_remote) = match storage.repository_identity.source { + RepositoryIdentitySource::ExplicitConfig => (String::from("explicit_config"), None), + RepositoryIdentitySource::RemoteUrl { remote_name } => { + (String::from("remote_url"), Some(remote_name)) + } + }; - Ok(CheckoutDatabaseSetup { - checkout_id: checkout_id.to_string(), - database_path: db_path, + Ok(RepositoryDatabaseSetup { + repository_id: storage.repository_identity.identity.repository_id, + canonical_identity: storage.repository_identity.identity.canonical_identity, + identity_source, + configured_remote, + checkout_id: storage.checkout_id, + database_path: storage.db_path, }) } -fn format_checkout_identity_setup_message(setup: &CheckoutDatabaseSetup) -> String { +fn format_repository_storage_setup_message(setup: &RepositoryDatabaseSetup) -> String { + let remote_line = setup + .configured_remote + .as_ref() + .map(|remote| format!("\nAgent Trace configured remote: {remote}")) + .unwrap_or_default(); format!( - "Agent Trace checkout identity: {}\nAgent Trace database initialized at '{}'.", + "Agent Trace repository ID: {}\nAgent Trace identity source: {}\nAgent Trace canonical identity: {}{}\nAgent Trace checkout identity: {}\nAgent Trace repository-scoped database initialized at '{}'.", + setup.repository_id, + setup.identity_source, + setup.canonical_identity, + remote_line, setup.checkout_id, setup.database_path.display() ) @@ -137,7 +140,7 @@ pub fn diagnose_agent_trace_db_health(repo_root: Option<&Path>) -> Vec) -> Vec::db_name(), + ::db_name(), &db_path, &mut problems, ); if db_path.exists() && db_path.is_file() { - match AgentTraceDb::open_for_hooks_without_migrations_at(&db_path) { + match RepositoryAgentTraceDb::open_without_migrations_at(&db_path) { Ok(db) => { if let Err(error) = db.ensure_schema_ready_for_hooks() { problems.push(HealthProblem { @@ -160,11 +163,11 @@ pub fn diagnose_agent_trace_db_health(repo_root: Option<&Path>) -> Vec) -> Vec) -> Vec) -> Result { let db_path = resolve_lifecycle_agent_trace_db_path(repo_root) .context("failed to resolve agent trace DB path")?; - bootstrap_db_parent(::db_name(), &db_path) + bootstrap_db_parent(::db_name(), &db_path) } fn resolve_lifecycle_agent_trace_db_path(repo_root: Option<&Path>) -> Result { if let Some(repo_root) = repo_root { - let git_dir = checkout::resolve_git_dir(repo_root).with_context(|| { - format!( - "failed to resolve git directory for agent trace DB health from '{}'", - repo_root.display() - ) - })?; - if let Some(checkout_id) = checkout::read_checkout_id(&git_dir)? { - return agent_trace_db_path_for_checkout(&checkout_id); - } + let storage_config = config::resolve_agent_trace_storage_runtime_config(repo_root) + .context("failed to resolve Agent Trace repository storage config")?; + let identity = resolve_repository_identity( + repo_root, + storage_config.repository_id.as_deref(), + &storage_config.repository_remote, + ) + .map_err(|error| anyhow::anyhow!("{error}"))?; + + return agent_trace_db_path_for_repository(&identity.identity.repository_id); } - agent_trace_db_path() + // Outside a Git repository there is no repository identity to select an + // active Agent Trace DB, and there is no global/checkout fallback path. + // Report an actionable diagnostic instead of probing a sentinel path. + anyhow::bail!( + "Agent Trace diagnostics require a Git repository; run 'sce doctor' inside a repository \ + or configure agent_trace.repository_id in .sce/config.json" + ) } diff --git a/cli/src/services/agent_trace_db/mod.rs b/cli/src/services/agent_trace_db/mod.rs index 9022636e..c972c0ac 100644 --- a/cli/src/services/agent_trace_db/mod.rs +++ b/cli/src/services/agent_trace_db/mod.rs @@ -1,25 +1,18 @@ //! Agent trace Turso database adapter. -use std::path::PathBuf; - use anyhow::{Context, Result}; use turso::Value as TursoValue; -use crate::{ - generated_migrations, - services::{ - db::{DbSpec, TursoDb}, - default_paths::agent_trace_db_path, - patch::{parse_patch, ParseError, ParsedPatch}, - structured_patch::{derive_claude_structured_patch, ClaudeStructuredPatchDerivationResult}, - }, +use crate::services::{ + db::{DbSpec, TursoDb}, + patch::{parse_patch, ParseError, ParsedPatch}, + structured_patch::{derive_claude_structured_patch, ClaudeStructuredPatchDerivationResult}, }; use serde_json::Value; pub mod lifecycle; - -const AGENT_TRACE_SCHEMA_SETUP_GUIDANCE: &str = "Run 'sce setup'."; +pub mod repository; /// Payload type discriminator for diff trace source payloads. /// @@ -57,56 +50,16 @@ pub const INSERT_AGENT_TRACE_SQL: &str = /// Parameterized SQL for inserting a message row. Duplicate /// `(session_id, message_id)` writes are ignored. -#[allow(dead_code)] pub const INSERT_MESSAGE_SQL: &str = "INSERT INTO messages (session_id, message_id, role, generated_at_unix_ms) VALUES (?1, ?2, ?3, ?4) ON CONFLICT (session_id, message_id) DO NOTHING"; /// Parameterized SQL for inserting a part row (append-only, no upsert). -#[allow(dead_code)] pub const INSERT_PART_SQL: &str = "INSERT INTO parts (type, text, message_id, session_id, generated_at_unix_ms) VALUES (?1, ?2, ?3, ?4, ?5)"; -/// Agent trace database configuration. -pub struct AgentTraceDbSpec; - -impl DbSpec for AgentTraceDbSpec { - fn db_name() -> &'static str { - "agent trace DB" - } - - fn db_path() -> Result { - agent_trace_db_path() - } - - fn migrations() -> &'static [(&'static str, &'static str)] { - generated_migrations::AGENT_TRACE_MIGRATIONS - } - - fn db_config_key() -> &'static str { - "agent_trace_db" - } -} - -/// Agent trace Turso database adapter. -pub type AgentTraceDb = TursoDb; - -impl AgentTraceDb { - /// Open or create an Agent Trace database at an explicit path and run all - /// embedded migrations. - pub fn open_at(path: impl AsRef) -> Result { - TursoDb::::new_at(path) - } - - /// Open or create an Agent Trace database at an explicit path without - /// running migrations. - pub fn open_for_hooks_without_migrations_at(path: impl AsRef) -> Result { - TursoDb::::open_without_migrations_at(path) - } -} - /// Diff trace payload to persist in the agent trace database. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct DiffTraceInsert<'a> { @@ -195,7 +148,6 @@ pub struct AgentTraceInsert<'a> { /// Message role constraint for the `messages` table. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[allow(dead_code)] pub enum MessageRole { User, Assistant, @@ -212,7 +164,6 @@ impl std::fmt::Display for MessageRole { /// Message insert payload for the `messages` table. #[derive(Clone, Debug, Eq, PartialEq)] -#[allow(dead_code)] pub struct InsertMessageInsert { pub session_id: String, pub message_id: String, @@ -222,7 +173,6 @@ pub struct InsertMessageInsert { /// Part type constraint for the `parts` table. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[allow(dead_code)] pub enum PartType { Text, Reasoning, @@ -243,7 +193,6 @@ impl std::fmt::Display for PartType { /// Part insert payload for the `parts` table (append-only, no upsert). #[derive(Clone, Debug, Eq, PartialEq)] -#[allow(dead_code)] pub struct InsertPartInsert { pub part_type: PartType, pub text: String, @@ -252,80 +201,6 @@ pub struct InsertPartInsert { pub generated_at_unix_ms: i64, } -impl AgentTraceDb { - /// Open the Agent Trace DB for high-frequency hook runtime paths without - /// running embedded migrations. - /// - /// Setup/lifecycle initialization must continue to use [`AgentTraceDb::new`] - /// so schema migrations remain explicitly owned by setup flows. Hook callers - /// must verify schema readiness before reading or writing through this DB. - #[allow(dead_code)] - pub fn open_for_hooks_without_migrations() -> Result { - TursoDb::::open_without_migrations() - } - - /// Verify that the Agent Trace DB schema needed by hook runtime readers and - /// writers already exists. - /// - /// This check is intentionally non-mutating. Missing or incomplete schema is - /// reported with setup guidance instead of running migrations from a - /// high-frequency hook path. - pub fn ensure_schema_ready_for_hooks(&self) -> Result<()> { - self.ensure_schema_ready(AGENT_TRACE_SCHEMA_SETUP_GUIDANCE) - } - - /// Insert a diff trace payload into the `diff_traces` table. - pub fn insert_diff_trace(&self, input: DiffTraceInsert<'_>) -> Result { - insert_diff_trace_with(self, input) - } - - /// Insert a post-commit patch intersection result into the - /// `post_commit_patch_intersections` table. - pub fn insert_post_commit_patch_intersection( - &self, - input: PostCommitPatchIntersectionInsert<'_>, - ) -> Result { - insert_post_commit_patch_intersection_with(self, input) - } - - /// Insert a built agent trace payload into the `agent_traces` table. - pub fn insert_agent_trace(&self, input: AgentTraceInsert<'_>) -> Result { - insert_agent_trace_with(self, input) - } - - /// Query and parse recent diff trace patches within the inclusive time window. - pub fn recent_diff_trace_patches( - &self, - cutoff_time_ms: i64, - end_time_ms: i64, - ) -> Result { - recent_diff_trace_patches_with(self, cutoff_time_ms, end_time_ms) - } - - /// Insert a message row, ignoring duplicate `(session_id, message_id)` rows. - #[allow(dead_code)] - pub fn insert_message(&self, input: InsertMessageInsert) -> Result { - insert_message_with(self, input) - } - - /// Insert message rows with one multi-row statement, ignoring duplicate - /// `(session_id, message_id)` rows. - pub fn insert_messages(&self, inputs: Vec) -> Result { - insert_messages_with(self, inputs) - } - - /// Append a part row (no upsert; multiple rows per message allowed). - #[allow(dead_code)] - pub fn insert_part(&self, input: InsertPartInsert) -> Result { - insert_part_with(self, input) - } - - /// Append part rows with one multi-row statement. - pub fn insert_parts(&self, inputs: Vec) -> Result { - insert_parts_with(self, inputs) - } -} - fn insert_diff_trace_with(db: &TursoDb, input: DiffTraceInsert<'_>) -> Result { db.execute( INSERT_DIFF_TRACE_SQL, @@ -560,61 +435,11 @@ mod tests { use std::{ fs, path::PathBuf, - sync::OnceLock, time::{SystemTime, UNIX_EPOCH}, }; + use super::repository::RepositoryAgentTraceDb; use super::*; - use crate::services::agent_trace; - - static TEST_DB_PATH: OnceLock = OnceLock::new(); - static BASELINE_TEST_DB_PATH: OnceLock = OnceLock::new(); - - struct TestAgentTraceDbSpec; - - impl DbSpec for TestAgentTraceDbSpec { - fn db_name() -> &'static str { - "test agent trace DB" - } - - fn db_path() -> Result { - TEST_DB_PATH - .get() - .cloned() - .context("test DB path should be initialized") - } - - fn migrations() -> &'static [(&'static str, &'static str)] { - generated_migrations::AGENT_TRACE_MIGRATIONS - } - - fn db_config_key() -> &'static str { - "agent_trace_db" - } - } - - struct BaselineAgentTraceDbSpec; - - impl DbSpec for BaselineAgentTraceDbSpec { - fn db_name() -> &'static str { - "baseline test agent trace DB" - } - - fn db_path() -> Result { - BASELINE_TEST_DB_PATH - .get() - .cloned() - .context("baseline test DB path should be initialized") - } - - fn migrations() -> &'static [(&'static str, &'static str)] { - generated_migrations::AGENT_TRACE_MIGRATIONS - } - - fn db_config_key() -> &'static str { - "agent_trace_db" - } - } fn unique_test_db_path() -> PathBuf { let nonce = SystemTime::now() @@ -636,53 +461,27 @@ mod tests { } fn insert_test_diff_trace( - db: &TursoDb, + db: &RepositoryAgentTraceDb, time_ms: i64, session_id: &str, patch: &str, ) { - insert_diff_trace_with( - db, - DiffTraceInsert { - time_ms, - session_id, - patch, - model_id: Some("test-provider/test-model"), - tool_name: "opencode", - tool_version: Some("1.2.3"), - payload_type: PAYLOAD_TYPE_PATCH, - }, - ) + db.insert_diff_trace(DiffTraceInsert { + time_ms, + session_id, + patch, + model_id: Some("test-provider/test-model"), + tool_name: "opencode", + tool_version: Some("1.2.3"), + payload_type: PAYLOAD_TYPE_PATCH, + }) .expect("diff trace insert should succeed"); } - fn sqlite_object_exists(db: &TursoDb, object_type: &str, name: &str) -> bool { - let rows = db - .query_map( - "SELECT name FROM sqlite_master WHERE type = ?1 AND name = ?2", - (object_type, name), - |row| row.get::(0).map_err(Into::into), - ) - .expect("sqlite_master query should succeed"); - !rows.is_empty() - } - - fn applied_migration_ids(db: &TursoDb) -> Vec { - db.query_map( - "SELECT id FROM __sce_migrations ORDER BY id ASC", - (), - |row| row.get::(0).map_err(Into::into), - ) - .expect("migration metadata query should succeed") - } - #[test] fn recent_diff_trace_patches_applies_bounded_window_ordering_and_parse_accounting() { let db_path = unique_test_db_path(); - TEST_DB_PATH - .set(db_path.clone()) - .expect("test DB path should only be initialized once"); - let db = TursoDb::::new().expect("test DB should open"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); let before_cutoff_patch = valid_patch("notes/before.md", "before cutoff"); let cutoff_patch = valid_patch("notes/cutoff.md", "at cutoff"); @@ -775,111 +574,4 @@ mod tests { fs::remove_dir_all(parent).expect("test DB directory should be removed"); } } - - #[test] - fn new_applies_baseline_agent_trace_migration_and_indexes() { - let db_path = unique_test_db_path(); - BASELINE_TEST_DB_PATH - .set(db_path.clone()) - .expect("baseline test DB path should only be initialized once"); - - let db = TursoDb::::new().expect("baseline test DB should open"); - - assert!(sqlite_object_exists(&db, "table", "diff_traces")); - assert!(sqlite_object_exists( - &db, - "table", - "post_commit_patch_intersections" - )); - assert!(sqlite_object_exists(&db, "table", "agent_traces")); - assert!(sqlite_object_exists( - &db, - "index", - "idx_diff_traces_time_ms_id" - )); - assert!(sqlite_object_exists( - &db, - "index", - "idx_agent_traces_agent_trace_id" - )); - assert!(sqlite_object_exists( - &db, - "index", - "idx_agent_traces_remote_url" - )); - assert!(sqlite_object_exists(&db, "table", "messages")); - assert!(sqlite_object_exists(&db, "table", "parts")); - assert!(!sqlite_object_exists(&db, "table", "session_models")); - assert!(sqlite_object_exists( - &db, - "index", - "idx_messages_session_message" - )); - assert!(sqlite_object_exists( - &db, - "index", - "idx_messages_session_order" - )); - assert!(sqlite_object_exists( - &db, - "index", - "idx_parts_session_message_order" - )); - assert!(sqlite_object_exists( - &db, - "trigger", - "trg_messages_updated_at" - )); - assert!(sqlite_object_exists(&db, "trigger", "trg_parts_updated_at")); - let applied_ids = applied_migration_ids(&db); - assert_eq!( - applied_ids.len(), - generated_migrations::AGENT_TRACE_MIGRATIONS.len(), - "applied migration count should match generated migration count" - ); - assert!( - applied_ids.windows(2).all(|w| w[0] < w[1]), - "applied migration IDs should be sorted ascending: {applied_ids:?}" - ); - for id in &applied_ids { - assert!( - id.len() > 4 - && id.chars().take(3).all(|c| c.is_ascii_digit()) - && id.chars().nth(3) == Some('_'), - "migration ID '{id}' should match NNN_... pattern" - ); - } - - let trace_url = agent_trace::agent_trace_persisted_url("trace-1"); - - let duplicate_insert = insert_agent_trace_with( - &db, - AgentTraceInsert { - commit_id: "abc123", - commit_time_ms: 123, - trace_json: r#"{"id":"trace-1"}"#, - agent_trace_id: "trace-1", - url: &trace_url, - remote_url: "https://github.com/test/repo", - }, - ); - assert!(duplicate_insert.is_ok()); - - let duplicate_insert = insert_agent_trace_with( - &db, - AgentTraceInsert { - commit_id: "abc124", - commit_time_ms: 124, - trace_json: r#"{"id":"trace-1"}"#, - agent_trace_id: "trace-1", - url: &trace_url, - remote_url: "https://github.com/test/repo", - }, - ); - assert!(duplicate_insert.is_err()); - - if let Some(parent) = db_path.parent() { - fs::remove_dir_all(parent).expect("test DB directory should be removed"); - } - } } diff --git a/cli/src/services/agent_trace_db/repository.rs b/cli/src/services/agent_trace_db/repository.rs new file mode 100644 index 00000000..5e469efe --- /dev/null +++ b/cli/src/services/agent_trace_db/repository.rs @@ -0,0 +1,601 @@ +//! Repository-scoped Agent Trace database adapter. +//! +//! One logical Git repository maps to one database at +//! `/sce/repos//agent-trace.db`. The schema +//! baseline is one fresh schema SQL file (`agent-trace-repository` +//! migrations), because repository-scoped databases are always created new; +//! there is no incremental chain and no migration path from legacy +//! checkout-scoped databases. Trace tables carry no `checkout_id` columns. + +use std::path::PathBuf; + +use anyhow::Result; + +use crate::{ + generated_migrations, + services::db::{DbSpec, TursoDb}, +}; + +use super::{ + insert_agent_trace_with, insert_diff_trace_with, insert_message_with, insert_messages_with, + insert_part_with, insert_parts_with, insert_post_commit_patch_intersection_with, + recent_diff_trace_patches_with, AgentTraceInsert, DiffTraceInsert, InsertMessageInsert, + InsertPartInsert, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, +}; + +const REPOSITORY_AGENT_TRACE_SCHEMA_SETUP_GUIDANCE: &str = "Run 'sce setup'."; + +const SELECT_REPOSITORY_METADATA_SQL: &str = + "SELECT repository_id FROM repository_metadata WHERE id = 1"; +const SELECT_SQLITE_OBJECT_SQL: &str = + "SELECT name FROM sqlite_master WHERE type = ?1 AND name = ?2 LIMIT 1"; +const RECORD_REPOSITORY_SCHEMA_MIGRATION_SQL: &str = + "INSERT OR IGNORE INTO __sce_migrations (id) VALUES ('001_repository_schema')"; +const REQUIRED_REPOSITORY_SCHEMA_TABLES: &[&str] = &[ + "repository_metadata", + "diff_traces", + "post_commit_patch_intersections", + "agent_traces", + "messages", + "parts", +]; + +/// Seeds the single metadata row on first initialization; concurrent first +/// opens race safely because the conflicting insert is ignored and the stored +/// value is validated afterwards. +const INSERT_REPOSITORY_METADATA_SQL: &str = + "INSERT INTO repository_metadata (id, repository_id) VALUES (1, ?1) +ON CONFLICT (id) DO NOTHING"; + +/// Repository-scoped Agent Trace database configuration. +pub struct RepositoryAgentTraceDbSpec; + +impl DbSpec for RepositoryAgentTraceDbSpec { + fn db_name() -> &'static str { + "repository Agent Trace DB" + } + + fn db_path() -> Result { + anyhow::bail!( + "repository Agent Trace DBs have no canonical spec path; resolve the \ + repository-scoped path and use the explicit-path constructors" + ) + } + + fn migrations() -> &'static [(&'static str, &'static str)] { + generated_migrations::AGENT_TRACE_REPOSITORY_MIGRATIONS + } + + fn db_config_key() -> &'static str { + "agent_trace_db" + } +} + +/// Repository-scoped Agent Trace Turso database adapter. +pub type RepositoryAgentTraceDb = TursoDb; + +impl RepositoryAgentTraceDb { + /// Open a repository-scoped Agent Trace database at an explicit path without + /// running migrations, for read-only hook/runtime paths that must not + /// migrate from a high-frequency caller. + pub fn open_for_hooks_without_migrations_at(path: impl AsRef) -> Result { + TursoDb::::open_without_migrations_at(path) + } + + /// Verify that the repository-scoped schema baseline already exists. + pub fn ensure_schema_ready_for_hooks(&self) -> Result<()> { + self.ensure_schema_ready(REPOSITORY_AGENT_TRACE_SCHEMA_SETUP_GUIDANCE) + } + + /// Repair the narrow concurrent-initialization case where the one-file + /// schema batch completed but recording `__sce_migrations` raced with + /// another first opener. This never creates trace tables; it only records + /// the baseline migration after all required repository tables already + /// exist. + pub fn repair_missing_repository_schema_migration_metadata(&self) -> Result<()> { + for table in REQUIRED_REPOSITORY_SCHEMA_TABLES { + if !self.sqlite_object_exists("table", table)? { + anyhow::bail!( + "repository Agent Trace DB schema is incomplete; missing table {table}. \ + {REPOSITORY_AGENT_TRACE_SCHEMA_SETUP_GUIDANCE}" + ); + } + } + + self.execute(RECORD_REPOSITORY_SCHEMA_MIGRATION_SQL, ())?; + self.ensure_schema_ready_for_hooks() + } + + fn sqlite_object_exists(&self, object_type: &str, name: &str) -> Result { + let rows = self.query_map(SELECT_SQLITE_OBJECT_SQL, (object_type, name), |row| { + row.get::(0).map_err(Into::into) + })?; + Ok(!rows.is_empty()) + } + + /// Seed repository metadata on first initialization and validate it on + /// every open. + /// + /// The stored `repository_id` must match the resolved repository ID for + /// this database path; a mismatch means the file does not belong to the + /// resolved repository and is an error rather than a write target. + pub fn verify_or_initialize_repository_metadata(&self, repository_id: &str) -> Result<()> { + self.execute(INSERT_REPOSITORY_METADATA_SQL, (repository_id,))?; + + let stored = self.query_map(SELECT_REPOSITORY_METADATA_SQL, (), |row| { + row.get::(0).map_err(Into::into) + })?; + + match stored.first() { + Some(stored_repository_id) if stored_repository_id == repository_id => Ok(()), + Some(stored_repository_id) => anyhow::bail!( + "repository Agent Trace DB metadata mismatch: stored repository ID \ + {stored_repository_id} does not match resolved repository ID {repository_id}" + ), + None => anyhow::bail!( + "repository Agent Trace DB metadata is missing its repository ID row. \ + {REPOSITORY_AGENT_TRACE_SCHEMA_SETUP_GUIDANCE}" + ), + } + } + + /// Insert a diff trace payload into the repository-scoped `diff_traces` + /// table. Rows remain repository-level; no checkout provenance is stored. + pub fn insert_diff_trace(&self, input: DiffTraceInsert<'_>) -> Result { + insert_diff_trace_with(self, input) + } + + /// Insert a post-commit patch intersection into the repository-scoped + /// `post_commit_patch_intersections` table. + pub fn insert_post_commit_patch_intersection( + &self, + input: PostCommitPatchIntersectionInsert<'_>, + ) -> Result { + insert_post_commit_patch_intersection_with(self, input) + } + + /// Insert a built Agent Trace payload into the repository-scoped + /// `agent_traces` table. + pub fn insert_agent_trace(&self, input: AgentTraceInsert<'_>) -> Result { + insert_agent_trace_with(self, input) + } + + /// Query and parse recent diff trace patches within the inclusive time + /// window for this repository-scoped database. Rows remain repository-level; + /// no checkout filter or checkout provenance is applied. + pub fn recent_diff_trace_patches( + &self, + cutoff_time_ms: i64, + end_time_ms: i64, + ) -> Result { + recent_diff_trace_patches_with(self, cutoff_time_ms, end_time_ms) + } + + /// Insert a message row, ignoring duplicate `(session_id, message_id)` + /// rows. + #[allow(dead_code)] + pub fn insert_message(&self, input: InsertMessageInsert) -> Result { + insert_message_with(self, input) + } + + /// Insert message rows with one multi-row statement, ignoring duplicate + /// `(session_id, message_id)` rows. + pub fn insert_messages(&self, inputs: Vec) -> Result { + insert_messages_with(self, inputs) + } + + /// Append a part row (no upsert; multiple rows per message allowed). + #[allow(dead_code)] + pub fn insert_part(&self, input: InsertPartInsert) -> Result { + insert_part_with(self, input) + } + + /// Append part rows with one multi-row statement. + pub fn insert_parts(&self, inputs: Vec) -> Result { + insert_parts_with(self, inputs) + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + use crate::services::agent_trace_db::{MessageRole, PartType, PAYLOAD_TYPE_PATCH}; + + fn valid_patch(path: &str, content: &str) -> String { + format!( + "Index: {path}\n===================================================================\n--- {path}\n+++ {path}\n@@ -0,0 +1,1 @@\n+{content}\n" + ) + } + + fn unique_test_db_path(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-repo-agent-trace-db-{label}-{}-{nonce}", + std::process::id() + )) + .join("agent-trace.db") + } + + fn remove_test_db(db_path: &std::path::Path) { + if let Some(parent) = db_path.parent() { + fs::remove_dir_all(parent).expect("test DB directory should be removed"); + } + } + + fn sqlite_object_exists(db: &RepositoryAgentTraceDb, object_type: &str, name: &str) -> bool { + let rows = db + .query_map( + "SELECT name FROM sqlite_master WHERE type = ?1 AND name = ?2", + (object_type, name), + |row| row.get::(0).map_err(Into::into), + ) + .expect("sqlite_master query should succeed"); + !rows.is_empty() + } + + fn table_sql(db: &RepositoryAgentTraceDb, name: &str) -> String { + db.query_map( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?1", + (name,), + |row| row.get::(0).map_err(Into::into), + ) + .expect("sqlite_master sql query should succeed") + .into_iter() + .next() + .unwrap_or_else(|| panic!("table '{name}' should exist")) + } + + #[test] + fn open_at_initializes_the_full_schema_from_one_migration() { + let db_path = unique_test_db_path("baseline"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + for table in [ + "repository_metadata", + "diff_traces", + "post_commit_patch_intersections", + "agent_traces", + "messages", + "parts", + ] { + assert!( + sqlite_object_exists(&db, "table", table), + "table '{table}' should exist" + ); + } + for index in [ + "idx_diff_traces_time_ms_id", + "idx_agent_traces_agent_trace_id", + "idx_agent_traces_remote_url", + "idx_messages_session_message", + "idx_messages_session_order", + "idx_parts_session_message_order", + ] { + assert!( + sqlite_object_exists(&db, "index", index), + "index '{index}' should exist" + ); + } + for trigger in ["trg_messages_updated_at", "trg_parts_updated_at"] { + assert!( + sqlite_object_exists(&db, "trigger", trigger), + "trigger '{trigger}' should exist" + ); + } + + let applied_ids = db + .query_map( + "SELECT id FROM __sce_migrations ORDER BY id ASC", + (), + |row| row.get::(0).map_err(Into::into), + ) + .expect("migration metadata query should succeed"); + assert_eq!( + applied_ids, + vec![String::from("001_repository_schema")], + "repository DBs should be initialized from exactly one schema file" + ); + + db.ensure_schema_ready_for_hooks() + .expect("fresh repository DB schema should be ready"); + + remove_test_db(&db_path); + } + + #[test] + fn trace_tables_have_no_checkout_id_columns() { + let db_path = unique_test_db_path("no-checkout-id"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + for table in [ + "diff_traces", + "post_commit_patch_intersections", + "agent_traces", + "messages", + "parts", + ] { + let sql = table_sql(&db, table); + assert!( + !sql.contains("checkout_id"), + "table '{table}' must not have a checkout_id column: {sql}" + ); + } + + remove_test_db(&db_path); + } + + #[test] + fn repository_metadata_is_seeded_once_and_validated_on_reopen() { + let db_path = unique_test_db_path("metadata"); + let repository_id = "a".repeat(64); + + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + db.verify_or_initialize_repository_metadata(&repository_id) + .expect("first metadata initialization should succeed"); + db.verify_or_initialize_repository_metadata(&repository_id) + .expect("repeated validation with the same repository ID should succeed"); + drop(db); + + let reopened = RepositoryAgentTraceDb::open_without_migrations_at(&db_path) + .expect("repository DB should reopen"); + reopened + .verify_or_initialize_repository_metadata(&repository_id) + .expect("reopen validation with the matching repository ID should succeed"); + + remove_test_db(&db_path); + } + + #[test] + fn mismatched_repository_metadata_errors_on_open() { + let db_path = unique_test_db_path("mismatch"); + let stored_repository_id = "a".repeat(64); + let other_repository_id = "b".repeat(64); + + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + db.verify_or_initialize_repository_metadata(&stored_repository_id) + .expect("first metadata initialization should succeed"); + + let error = db + .verify_or_initialize_repository_metadata(&other_repository_id) + .expect_err("mismatched repository ID should fail validation"); + let message = error.to_string(); + assert!( + message.contains("metadata mismatch"), + "unexpected error: {message}" + ); + assert!(message.contains(&stored_repository_id)); + assert!(message.contains(&other_repository_id)); + + remove_test_db(&db_path); + } + + #[test] + fn repository_scoped_write_methods_insert_all_agent_trace_rows() { + let db_path = unique_test_db_path("writes"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + db.insert_diff_trace(DiffTraceInsert { + time_ms: 1_000, + session_id: "oc_session-1", + patch: "Index: notes.md\n===================================================================\n--- notes.md\n+++ notes.md\n@@ -0,0 +1,1 @@\n+hello\n", + model_id: Some("provider/model"), + tool_name: "opencode", + tool_version: Some("1.2.3"), + payload_type: PAYLOAD_TYPE_PATCH, + }) + .expect("diff trace insert should succeed"); + + db.insert_post_commit_patch_intersection(PostCommitPatchIntersectionInsert { + commit_id: "abc123", + post_commit_time_ms: 2_000, + recent_window_cutoff_ms: 1_000, + recent_window_end_ms: 2_000, + loaded_diff_trace_count: 1, + skipped_diff_trace_count: 0, + intersection_patch: "Index: notes.md\n===================================================================\n--- notes.md\n+++ notes.md\n@@ -0,0 +1,1 @@\n+hello\n", + }) + .expect("post-commit intersection insert should succeed"); + + db.insert_agent_trace(AgentTraceInsert { + commit_id: "abc123", + commit_time_ms: 2_000, + trace_json: r#"{"id":"trace-1"}"#, + agent_trace_id: "trace-1", + url: "https://sce.crocoder.dev/agent-trace/trace-1", + remote_url: "https://github.com/acme/widgets", + }) + .expect("agent trace insert should succeed"); + + db.insert_message(InsertMessageInsert { + session_id: "oc_session-1".to_string(), + message_id: "message-1".to_string(), + role: MessageRole::Assistant, + generated_at_unix_ms: 1_000, + }) + .expect("message insert should succeed"); + db.insert_messages(vec![InsertMessageInsert { + session_id: "oc_session-1".to_string(), + message_id: "message-2".to_string(), + role: MessageRole::User, + generated_at_unix_ms: 1_001, + }]) + .expect("batch message insert should succeed"); + + db.insert_part(InsertPartInsert { + part_type: PartType::Text, + text: "hello".to_string(), + session_id: "oc_session-1".to_string(), + message_id: "message-1".to_string(), + generated_at_unix_ms: 1_000, + }) + .expect("part insert should succeed"); + db.insert_parts(vec![InsertPartInsert { + part_type: PartType::Patch, + text: "patch text".to_string(), + session_id: "oc_session-1".to_string(), + message_id: "message-2".to_string(), + generated_at_unix_ms: 1_001, + }]) + .expect("batch part insert should succeed"); + + for (table, expected_count) in [ + ("diff_traces", 1_i64), + ("post_commit_patch_intersections", 1), + ("agent_traces", 1), + ("messages", 2), + ("parts", 2), + ] { + let count = db + .query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { + row.get::(0).map_err(Into::into) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("count row should exist"); + assert_eq!(count, expected_count, "unexpected row count for {table}"); + } + + remove_test_db(&db_path); + } + + #[test] + fn recent_diff_trace_reads_all_repository_rows_without_checkout_filter() { + let db_path = unique_test_db_path("recent-repository-level"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + db.insert_diff_trace(DiffTraceInsert { + time_ms: 999, + session_id: "oc_before-cutoff", + patch: &valid_patch("notes/before.md", "before"), + model_id: Some("provider/model"), + tool_name: "opencode", + tool_version: Some("1.2.3"), + payload_type: PAYLOAD_TYPE_PATCH, + }) + .expect("before-cutoff diff trace insert should succeed"); + db.insert_diff_trace(DiffTraceInsert { + time_ms: 1_000, + session_id: "oc_checkout-a-session", + patch: &valid_patch("notes/a.md", "same repository checkout a"), + model_id: Some("provider/model-a"), + tool_name: "opencode", + tool_version: Some("1.2.3"), + payload_type: PAYLOAD_TYPE_PATCH, + }) + .expect("checkout-a diff trace insert should succeed"); + db.insert_diff_trace(DiffTraceInsert { + time_ms: 1_500, + session_id: "pi_checkout-b-session", + patch: &valid_patch("notes/b.md", "same repository checkout b"), + model_id: Some("provider/model-b"), + tool_name: "pi", + tool_version: None, + payload_type: PAYLOAD_TYPE_PATCH, + }) + .expect("checkout-b diff trace insert should succeed"); + db.insert_diff_trace(DiffTraceInsert { + time_ms: 2_001, + session_id: "oc_after-end", + patch: &valid_patch("notes/after.md", "after"), + model_id: Some("provider/model"), + tool_name: "opencode", + tool_version: Some("1.2.3"), + payload_type: PAYLOAD_TYPE_PATCH, + }) + .expect("after-end diff trace insert should succeed"); + + let recent = db + .recent_diff_trace_patches(1_000, 2_000) + .expect("recent repository diff traces should load"); + + assert_eq!(recent.loaded_count(), 2); + assert_eq!(recent.skipped_count(), 0); + assert_eq!( + recent + .patches + .iter() + .map(|patch| ( + patch.time_ms, + patch.session_id.as_str(), + patch.tool_name.as_deref() + )) + .collect::>(), + vec![ + (1_000, "oc_checkout-a-session", Some("opencode")), + (1_500, "pi_checkout-b-session", Some("pi")), + ] + ); + + remove_test_db(&db_path); + } + + #[test] + fn recent_diff_trace_reads_are_isolated_by_repository_db_path() { + let first_db_path = unique_test_db_path("recent-repo-one"); + let second_db_path = unique_test_db_path("recent-repo-two"); + let first_db = RepositoryAgentTraceDb::new_at(&first_db_path) + .expect("first repository DB should open"); + let second_db = RepositoryAgentTraceDb::new_at(&second_db_path) + .expect("second repository DB should open"); + + first_db + .insert_diff_trace(DiffTraceInsert { + time_ms: 1_000, + session_id: "oc_first-repo", + patch: &valid_patch("notes/first.md", "first repository"), + model_id: Some("provider/first"), + tool_name: "opencode", + tool_version: Some("1.2.3"), + payload_type: PAYLOAD_TYPE_PATCH, + }) + .expect("first repository diff trace insert should succeed"); + second_db + .insert_diff_trace(DiffTraceInsert { + time_ms: 1_000, + session_id: "oc_second-repo", + patch: &valid_patch("notes/second.md", "second repository"), + model_id: Some("provider/second"), + tool_name: "opencode", + tool_version: Some("1.2.3"), + payload_type: PAYLOAD_TYPE_PATCH, + }) + .expect("second repository diff trace insert should succeed"); + + let first_recent = first_db + .recent_diff_trace_patches(0, 2_000) + .expect("first repository recent traces should load"); + let second_recent = second_db + .recent_diff_trace_patches(0, 2_000) + .expect("second repository recent traces should load"); + + assert_eq!(first_recent.loaded_count(), 1); + assert_eq!(second_recent.loaded_count(), 1); + assert_eq!(first_recent.patches[0].session_id, "oc_first-repo"); + assert_eq!(second_recent.patches[0].session_id, "oc_second-repo"); + assert_ne!( + first_recent.patches[0].session_id, + second_recent.patches[0].session_id + ); + + remove_test_db(&first_db_path); + remove_test_db(&second_db_path); + } + + #[test] + fn spec_path_constructor_is_rejected() { + let error = RepositoryAgentTraceDbSpec::db_path() + .expect_err("repository DBs must not have a canonical spec path"); + assert!(error.to_string().contains("explicit-path")); + } +} diff --git a/cli/src/services/agent_trace_storage/mod.rs b/cli/src/services/agent_trace_storage/mod.rs new file mode 100644 index 00000000..a5375ea1 --- /dev/null +++ b/cli/src/services/agent_trace_storage/mod.rs @@ -0,0 +1,522 @@ +//! Repository-scoped Agent Trace storage resolution. +//! +//! Resolves the active Agent Trace database for a Git repository checkout: +//! one logical Git repository maps to exactly one database at +//! `/sce/repos//agent-trace.db`. Clones and linked +//! worktrees of the same logical repository share that database while keeping +//! their own distinct checkout IDs. Legacy checkout-scoped +//! `agent-trace-.db` files are never selected, created, or +//! touched by this resolver. + +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use anyhow::{Context, Result}; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::checkout::{get_or_create_checkout_id, resolve_git_dir}; +use crate::services::default_paths::{ + agent_trace_db_path_for_repository, agent_trace_db_path_for_repository_at, +}; +use crate::services::repository_identity::resolve::{ + resolve_repository_identity, ResolvedRepositoryIdentity, +}; + +const REPOSITORY_DB_INITIALIZATION_ATTEMPTS: usize = 20; +const REPOSITORY_DB_INITIALIZATION_RETRY_DELAY: Duration = Duration::from_millis(100); + +/// Inputs needed to resolve the active repository-scoped Agent Trace storage. +/// +/// The identity inputs mirror the `agent_trace.repository_id` and +/// `agent_trace.repository_remote` configuration keys; callers pass the +/// already-resolved configuration values. +#[derive(Clone, Copy, Debug)] +pub struct AgentTraceStorageContext<'a> { + /// Root of the Git working tree the current command runs in. + pub repository_root: &'a Path, + /// Explicit `agent_trace.repository_id` configuration value, if set. + pub explicit_repository_id: Option<&'a str>, + /// Configured `agent_trace.repository_remote` name (default `origin`). + pub repository_remote: &'a str, +} + +/// The resolved active Agent Trace storage for one repository checkout. +pub struct ResolvedAgentTraceStorage { + /// Repository identity (canonical identity plus repository ID) and the + /// source it was resolved from. + pub repository_identity: ResolvedRepositoryIdentity, + /// Stable identity of this clone/worktree. Kept for diagnostics only; + /// never persisted on Agent Trace rows. + pub checkout_id: String, + /// Repository-scoped database path + /// `/sce/repos//agent-trace.db`. + pub db_path: PathBuf, + /// Open repository-scoped Agent Trace database. + pub db: RepositoryAgentTraceDb, +} + +/// Resolves the repository-scoped Agent Trace storage for a checkout using +/// the canonical state root from the default-path catalog. +pub fn resolve_agent_trace_storage( + context: &AgentTraceStorageContext<'_>, +) -> Result { + let repository_identity = resolve_identity(context)?; + let db_path = agent_trace_db_path_for_repository(&repository_identity.identity.repository_id)?; + open_storage(context, repository_identity, db_path) +} + +/// Resolution core against an explicit state root, so tests can exercise the +/// full path without touching the real user state directory. +pub fn resolve_agent_trace_storage_at_state_root( + context: &AgentTraceStorageContext<'_>, + state_root: &Path, +) -> Result { + let repository_identity = resolve_identity(context)?; + let db_path = agent_trace_db_path_for_repository_at( + state_root, + &repository_identity.identity.repository_id, + )?; + open_storage(context, repository_identity, db_path) +} + +fn resolve_identity(context: &AgentTraceStorageContext<'_>) -> Result { + resolve_repository_identity( + context.repository_root, + context.explicit_repository_id, + context.repository_remote, + ) + .map_err(|error| anyhow::anyhow!("{error}")) +} + +fn open_storage( + context: &AgentTraceStorageContext<'_>, + repository_identity: ResolvedRepositoryIdentity, + db_path: PathBuf, +) -> Result { + let git_dir = resolve_git_dir(context.repository_root).with_context(|| { + format!( + "failed to resolve git directory for Agent Trace repository DB from '{}'", + context.repository_root.display() + ) + })?; + let checkout_id = get_or_create_checkout_id(&git_dir).with_context(|| { + format!( + "failed to get or create checkout identity under '{}'", + git_dir.display() + ) + })?; + + // Opening the database creates `repos//` when missing; + // directory creation is idempotent and first-time schema initialization may + // briefly race on SQLite metadata locks, so retry the fast-path/migrate + // sequence a small bounded number of times. + let repository_id = &repository_identity.identity.repository_id; + let db = open_repository_db_concurrently_safe(&db_path, repository_id)?; + + Ok(ResolvedAgentTraceStorage { + repository_identity, + checkout_id, + db_path, + db, + }) +} + +fn open_repository_db_concurrently_safe( + db_path: &Path, + repository_id: &str, +) -> Result { + let mut last_error = None; + + for attempt in 1..=REPOSITORY_DB_INITIALIZATION_ATTEMPTS { + let fast_open = + RepositoryAgentTraceDb::open_without_migrations_at(db_path).and_then(|db| { + if db.ensure_schema_ready_for_hooks().is_err() { + db.repair_missing_repository_schema_migration_metadata()?; + } + db.verify_or_initialize_repository_metadata(repository_id)?; + Ok(db) + }); + + match fast_open { + Ok(db) => return Ok(db), + Err(fast_error) => match RepositoryAgentTraceDb::new_at(db_path) { + Ok(db) => { + db.verify_or_initialize_repository_metadata(repository_id)?; + return Ok(db); + } + Err(init_error) => { + last_error = Some(anyhow::anyhow!( + "failed to initialize repository-scoped Agent Trace DB for repository {} at '{}' (fast-path attempt: {fast_error})", + repository_id, + db_path.display() + ) + .context(init_error)); + if attempt < REPOSITORY_DB_INITIALIZATION_ATTEMPTS { + thread::sleep(REPOSITORY_DB_INITIALIZATION_RETRY_DELAY); + } + } + }, + } + } + + Err(last_error.expect("repository DB initialization should record an error")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + use std::time::{SystemTime, UNIX_EPOCH}; + + use crate::services::agent_trace_db::{DiffTraceInsert, PAYLOAD_TYPE_PATCH}; + + 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-agent-trace-storage-{label}-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + fn git(repo_root: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(repo_root) + .output() + .unwrap_or_else(|error| panic!("git {args:?} failed to spawn: {error}")); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn init_git_repo_with_remote(label: &str, remote_url: &str) -> PathBuf { + let repo = unique_temp_dir(label); + git(&repo, &["init", "-q"]); + git(&repo, &["remote", "add", "origin", remote_url]); + repo + } + + fn context_for(repository_root: &Path) -> AgentTraceStorageContext<'_> { + AgentTraceStorageContext { + repository_root, + explicit_repository_id: None, + repository_remote: "origin", + } + } + + fn assert_no_legacy_db_paths(state_root: &Path) { + let sce_dir = state_root.join("sce"); + assert!( + !sce_dir.join("agent-trace.db").exists(), + "resolver must not create the legacy global agent-trace.db" + ); + if let Ok(entries) = std::fs::read_dir(&sce_dir) { + for entry in entries { + let name = entry.expect("read sce dir entry").file_name(); + let name = name.to_string_lossy().into_owned(); + let is_db_file = Path::new(&name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("db")); + assert!( + !(name.starts_with("agent-trace-") && is_db_file), + "resolver must not create checkout-scoped DB '{name}'" + ); + } + } + } + + #[test] + fn different_repository_identities_use_different_db_paths() { + let state_root = unique_temp_dir("state-separate"); + let repo_a = init_git_repo_with_remote("repo-a", "git@github.com:acme/widgets.git"); + let repo_b = init_git_repo_with_remote("repo-b", "git@github.com:acme/gadgets.git"); + + let storage_a = + resolve_agent_trace_storage_at_state_root(&context_for(&repo_a), &state_root) + .expect("repo A storage should resolve"); + let storage_b = + resolve_agent_trace_storage_at_state_root(&context_for(&repo_b), &state_root) + .expect("repo B storage should resolve"); + + assert_ne!( + storage_a.repository_identity.identity.repository_id, + storage_b.repository_identity.identity.repository_id + ); + assert_ne!(storage_a.db_path, storage_b.db_path); + assert!(storage_a.db_path.is_file()); + assert!(storage_b.db_path.is_file()); + assert_no_legacy_db_paths(&state_root); + + std::fs::remove_dir_all(&state_root).expect("clean up state root"); + std::fs::remove_dir_all(&repo_a).expect("clean up repo A"); + std::fs::remove_dir_all(&repo_b).expect("clean up repo B"); + } + + #[test] + fn clones_of_the_same_repository_share_the_db_path_with_distinct_checkout_ids() { + let state_root = unique_temp_dir("state-clones"); + // Equivalent SSH and HTTPS remotes for the same logical repository. + let clone_a = init_git_repo_with_remote("clone-a", "git@github.com:acme/widgets.git"); + let clone_b = init_git_repo_with_remote("clone-b", "https://github.com/acme/widgets.git"); + + let storage_a = + resolve_agent_trace_storage_at_state_root(&context_for(&clone_a), &state_root) + .expect("clone A storage should resolve"); + let storage_b = + resolve_agent_trace_storage_at_state_root(&context_for(&clone_b), &state_root) + .expect("clone B storage should resolve"); + + assert_eq!( + storage_a.repository_identity.identity.repository_id, + storage_b.repository_identity.identity.repository_id + ); + assert_eq!(storage_a.db_path, storage_b.db_path); + assert_ne!(storage_a.checkout_id, storage_b.checkout_id); + assert_no_legacy_db_paths(&state_root); + + std::fs::remove_dir_all(&state_root).expect("clean up state root"); + std::fs::remove_dir_all(&clone_a).expect("clean up clone A"); + std::fs::remove_dir_all(&clone_b).expect("clean up clone B"); + } + + #[test] + fn linked_worktree_shares_the_db_path_with_a_distinct_checkout_id() { + let state_root = unique_temp_dir("state-worktree"); + let repo = init_git_repo_with_remote("worktree-main", "git@github.com:acme/widgets.git"); + git(&repo, &["config", "user.email", "test@example.com"]); + git(&repo, &["config", "user.name", "Test"]); + git(&repo, &["commit", "--allow-empty", "-q", "-m", "init"]); + let worktree = unique_temp_dir("worktree-linked"); + // `git worktree add` refuses to use an existing directory unless empty; + // the helper creates it empty, so add into it directly. + git( + &repo, + &[ + "worktree", + "add", + "-q", + worktree.to_str().expect("utf-8 worktree path"), + ], + ); + + let storage_main = + resolve_agent_trace_storage_at_state_root(&context_for(&repo), &state_root) + .expect("main checkout storage should resolve"); + let storage_worktree = + resolve_agent_trace_storage_at_state_root(&context_for(&worktree), &state_root) + .expect("worktree storage should resolve"); + + assert_eq!(storage_main.db_path, storage_worktree.db_path); + assert_ne!(storage_main.checkout_id, storage_worktree.checkout_id); + assert_no_legacy_db_paths(&state_root); + + std::fs::remove_dir_all(&state_root).expect("clean up state root"); + std::fs::remove_dir_all(&worktree).expect("clean up worktree"); + std::fs::remove_dir_all(&repo).expect("clean up repo"); + } + + #[test] + fn explicit_repository_id_overrides_the_remote() { + let state_root = unique_temp_dir("state-explicit"); + let repo = init_git_repo_with_remote("explicit", "git@github.com:acme/widgets.git"); + let context = AgentTraceStorageContext { + repository_root: &repo, + explicit_repository_id: Some("acme-monorepo"), + repository_remote: "origin", + }; + + let storage = resolve_agent_trace_storage_at_state_root(&context, &state_root) + .expect("explicit identity storage should resolve"); + assert_eq!( + storage.repository_identity.identity.canonical_identity, + "acme-monorepo" + ); + let expected_path = state_root + .join("sce") + .join("repos") + .join(&storage.repository_identity.identity.repository_id) + .join("agent-trace.db"); + assert_eq!(storage.db_path, expected_path); + + std::fs::remove_dir_all(&state_root).expect("clean up state root"); + std::fs::remove_dir_all(&repo).expect("clean up repo"); + } + + #[test] + fn repeated_resolution_is_idempotent() { + let state_root = unique_temp_dir("state-idempotent"); + let repo = init_git_repo_with_remote("idempotent", "git@github.com:acme/widgets.git"); + + let first = resolve_agent_trace_storage_at_state_root(&context_for(&repo), &state_root) + .expect("first resolution should succeed"); + let second = resolve_agent_trace_storage_at_state_root(&context_for(&repo), &state_root) + .expect("second resolution should succeed"); + + assert_eq!(first.db_path, second.db_path); + assert_eq!(first.checkout_id, second.checkout_id); + assert_eq!( + first.repository_identity.identity.repository_id, + second.repository_identity.identity.repository_id + ); + + std::fs::remove_dir_all(&state_root).expect("clean up state root"); + std::fs::remove_dir_all(&repo).expect("clean up repo"); + } + + #[test] + fn missing_identity_fails_with_config_guidance_and_creates_nothing() { + let state_root = unique_temp_dir("state-missing"); + let repo = unique_temp_dir("no-remote"); + git(&repo, &["init", "-q"]); + + let Err(error) = + resolve_agent_trace_storage_at_state_root(&context_for(&repo), &state_root) + else { + panic!("repo without identity should fail") + }; + assert!(error.to_string().contains(".sce/config.json")); + assert!( + !state_root.join("sce").exists(), + "failed resolution must not create state directories" + ); + + std::fs::remove_dir_all(&state_root).expect("clean up state root"); + std::fs::remove_dir_all(&repo).expect("clean up repo"); + } + + #[test] + fn empty_repository_id_path_is_rejected() { + let error = agent_trace_db_path_for_repository_at(Path::new("/tmp/state"), " ") + .expect_err("empty repository ID should fail"); + assert!(error.to_string().contains("must not be empty")); + } + + #[test] + fn legacy_checkout_database_files_are_not_selected_or_modified() { + let state_root = unique_temp_dir("state-legacy-untouched"); + let repo = init_git_repo_with_remote("legacy-untouched", "git@github.com:acme/widgets.git"); + let sce_dir = state_root.join("sce"); + std::fs::create_dir_all(&sce_dir).expect("create legacy parent"); + let legacy_db_path = sce_dir.join("agent-trace-legacy-checkout.db"); + let legacy_bytes = b"legacy checkout database bytes must remain untouched"; + std::fs::write(&legacy_db_path, legacy_bytes).expect("write legacy DB fixture"); + + let storage = resolve_agent_trace_storage_at_state_root(&context_for(&repo), &state_root) + .expect("repository storage should resolve"); + + assert_ne!(storage.db_path, legacy_db_path); + assert_eq!( + std::fs::read(&legacy_db_path).expect("legacy DB should still be readable"), + legacy_bytes, + "repository storage resolution must not migrate, rewrite, or delete legacy DB bytes" + ); + assert!(storage.db_path.is_file()); + + std::fs::remove_dir_all(&state_root).expect("clean up state root"); + std::fs::remove_dir_all(&repo).expect("clean up repo"); + } + + #[test] + fn new_repository_database_starts_empty_and_shares_repository_level_rows() { + let state_root = unique_temp_dir("state-repository-rows"); + let clone_a = init_git_repo_with_remote("rows-clone-a", "git@github.com:acme/widgets.git"); + let clone_b = + init_git_repo_with_remote("rows-clone-b", "https://github.com/acme/widgets.git"); + + let storage_a = + resolve_agent_trace_storage_at_state_root(&context_for(&clone_a), &state_root) + .expect("clone A storage should resolve"); + assert!( + storage_a + .db + .recent_diff_trace_patches(0, 10_000) + .expect("empty repository DB should query") + .patches + .is_empty(), + "fresh repository DB should not import legacy or checkout data" + ); + + storage_a + .db + .insert_diff_trace(DiffTraceInsert { + time_ms: 1_234, + session_id: "checkout-a-session", + patch: "Index: src/lib.rs\n===================================================================\n--- src/lib.rs\n+++ src/lib.rs\n@@ -0,0 +1,1 @@\n+shared row\n", + model_id: Some("model-a"), + tool_name: "opencode", + tool_version: Some("1.0.0"), + payload_type: PAYLOAD_TYPE_PATCH, + }) + .expect("diff trace insert should succeed"); + + let storage_b = + resolve_agent_trace_storage_at_state_root(&context_for(&clone_b), &state_root) + .expect("clone B storage should resolve"); + assert_eq!(storage_a.db_path, storage_b.db_path); + assert_ne!(storage_a.checkout_id, storage_b.checkout_id); + + let patches = storage_b + .db + .recent_diff_trace_patches(0, 10_000) + .expect("shared repository DB rows should query from clone B"); + assert_eq!(patches.patches.len(), 1); + let parsed = &patches.patches[0]; + assert_eq!(parsed.tool_name.as_deref(), Some("opencode")); + assert_eq!( + parsed.patch.files[0].hunks[0].model_id.as_deref(), + Some("model-a") + ); + + std::fs::remove_dir_all(&state_root).expect("clean up state root"); + std::fs::remove_dir_all(&clone_a).expect("clean up clone A"); + std::fs::remove_dir_all(&clone_b).expect("clean up clone B"); + } + + #[test] + fn credential_bearing_remote_is_canonicalized_without_leaking_secrets_to_paths() { + let state_root = unique_temp_dir("state-credential-safe"); + let repo = init_git_repo_with_remote( + "credential-safe", + "https://user:super-secret-token@github.com/acme/widgets.git", + ); + + let storage = resolve_agent_trace_storage_at_state_root(&context_for(&repo), &state_root) + .expect("credential-bearing remote should resolve safely"); + + assert_eq!( + storage.repository_identity.identity.canonical_identity, + "github.com/acme/widgets" + ); + let rendered_path = storage.db_path.display().to_string(); + assert!(!rendered_path.contains("user")); + assert!(!rendered_path.contains("super-secret-token")); + assert!(!storage + .repository_identity + .identity + .repository_id + .contains("super-secret-token")); + + std::fs::remove_dir_all(&state_root).expect("clean up state root"); + std::fs::remove_dir_all(&repo).expect("clean up repo"); + } + + #[test] + fn path_traversal_repository_id_is_rejected() { + for bad in ["../escape", "a/b", "a\\b", ".", ".."] { + let error = agent_trace_db_path_for_repository_at(Path::new("/tmp/state"), bad) + .expect_err("path-unsafe repository ID should be rejected"); + assert!( + error.to_string().contains("not a valid path segment"), + "unexpected error for '{bad}': {error}" + ); + } + } +} diff --git a/cli/src/services/checkout/mod.rs b/cli/src/services/checkout/mod.rs index fb7e23a2..0fc12891 100644 --- a/cli/src/services/checkout/mod.rs +++ b/cli/src/services/checkout/mod.rs @@ -14,10 +14,6 @@ use std::process::Command; use anyhow::{anyhow, Context, Result}; use uuid::Uuid; -use crate::services::{ - agent_trace_db::AgentTraceDb, default_paths::agent_trace_db_path_for_checkout, -}; - /// Subdirectory inside `/` where SCE checkout metadata lives. const SCE_CHECKOUT_DIR: &str = "sce"; @@ -136,39 +132,3 @@ pub fn get_or_create_checkout_id(git_dir: &Path) -> Result { Ok(checkout_id) } - -/// Resolves or creates the checkout identity for `repo_root` and opens its -/// per-checkout Agent Trace DB, lazily initializing schema when needed. -pub fn resolve_or_create_agent_trace_db_for_checkout( - repo_root: &Path, -) -> Result<(AgentTraceDb, String)> { - let git_dir = resolve_git_dir(repo_root).with_context(|| { - format!( - "failed to resolve git directory for Agent Trace checkout DB from '{}'", - repo_root.display() - ) - })?; - let checkout_id = get_or_create_checkout_id(&git_dir).with_context(|| { - format!( - "failed to get or create checkout identity under '{}'", - git_dir.display() - ) - })?; - let db_path = agent_trace_db_path_for_checkout(&checkout_id).with_context(|| { - format!("failed to resolve Agent Trace DB path for checkout ID {checkout_id}") - })?; - - let fast_open = AgentTraceDb::open_for_hooks_without_migrations_at(&db_path) - .and_then(|db| db.ensure_schema_ready_for_hooks().map(|()| db)); - let db = match fast_open { - Ok(db) => db, - Err(fast_error) => AgentTraceDb::open_at(&db_path).with_context(|| { - format!( - "failed to initialize Agent Trace DB for checkout {checkout_id} at '{}' (fast-path attempt: {fast_error})", - db_path.display() - ) - })?, - }; - - Ok((db, checkout_id)) -} diff --git a/cli/src/services/config/mod.rs b/cli/src/services/config/mod.rs index 6eebe127..ae02d2c9 100644 --- a/cli/src/services/config/mod.rs +++ b/cli/src/services/config/mod.rs @@ -14,7 +14,8 @@ use render::{format_show_output, format_validate_output}; use resolver::resolve_runtime_config; pub(crate) use resolver::{ - resolve_auth_runtime_config, resolve_bash_policy_runtime_config, resolve_hook_runtime_config, + resolve_agent_trace_storage_runtime_config, resolve_auth_runtime_config, + resolve_bash_policy_runtime_config, resolve_hook_runtime_config, resolve_observability_runtime_config, }; pub(crate) use schema::validate_config_file; diff --git a/cli/src/services/config/render.rs b/cli/src/services/config/render.rs index 720907dd..7f73a90b 100644 --- a/cli/src/services/config/render.rs +++ b/cli/src/services/config/render.rs @@ -34,6 +34,15 @@ pub(super) fn format_show_output(runtime: &RuntimeConfig, report_format: ReportF WORKOS_CLIENT_ID_KEY, &runtime.workos_client_id, ), + format_optional_resolved_value_text( + "agent_trace.repository_id", + &runtime.agent_trace_repository_id, + ), + format_resolved_value_text( + "agent_trace.repository_remote", + &runtime.agent_trace_repository_remote.value, + runtime.agent_trace_repository_remote.source, + ), format_bash_policies_text(&runtime.bash_policies), format_database_retry_text(&runtime.database_retry), format_validation_warnings_text(&warnings), @@ -68,6 +77,13 @@ pub(super) fn format_show_output(runtime: &RuntimeConfig, report_format: ReportF "config_source": runtime.timeout_ms.source.config_source().map(ConfigPathSource::as_str), }, "workos_client_id": format_optional_auth_resolved_value_json(WORKOS_CLIENT_ID_KEY, &runtime.workos_client_id), + "agent_trace": { + "repository_id": format_optional_resolved_value_json(&runtime.agent_trace_repository_id), + "repository_remote": format_resolved_value_json( + runtime.agent_trace_repository_remote.value.as_str(), + runtime.agent_trace_repository_remote.source, + ), + }, "policies": { "bash": format_bash_policies_json(&runtime.bash_policies), "database_retry": format_database_retry_json(&runtime.database_retry), diff --git a/cli/src/services/config/resolver.rs b/cli/src/services/config/resolver.rs index b370ca64..e2d2e7f5 100644 --- a/cli/src/services/config/resolver.rs +++ b/cli/src/services/config/resolver.rs @@ -14,13 +14,14 @@ use super::policy::{build_validation_warnings, resolve_bash_policy_config, BashP use super::schema; use super::types::{ parse_bool_value_from, ConfigPathSource, ConfigRequest, DatabaseRetryConfig, LoadedConfigPath, - LogFileMode, LogFormat, LogLevel, ReportFormat, ResolvedAuthRuntimeConfig, - ResolvedHookRuntimeConfig, ResolvedObservabilityRuntimeConfig, ResolvedOptionalValue, - ResolvedValue, ValueSource, ENV_ATTRIBUTION_HOOKS_DISABLED, ENV_LOG_FILE, ENV_LOG_FILE_MODE, - ENV_LOG_FORMAT, ENV_LOG_LEVEL, + LogFileMode, LogFormat, LogLevel, ReportFormat, ResolvedAgentTraceStorageRuntimeConfig, + ResolvedAuthRuntimeConfig, ResolvedHookRuntimeConfig, ResolvedObservabilityRuntimeConfig, + ResolvedOptionalValue, ResolvedValue, ValueSource, ENV_ATTRIBUTION_HOOKS_DISABLED, + ENV_LOG_FILE, ENV_LOG_FILE_MODE, ENV_LOG_FORMAT, ENV_LOG_LEVEL, }; const DEFAULT_TIMEOUT_MS: u64 = 30000; +pub(crate) const DEFAULT_AGENT_TRACE_REPOSITORY_REMOTE: &str = "origin"; pub(crate) const PRECEDENCE_DESCRIPTION: &str = "flags > env > config file > defaults"; const WORKOS_CLIENT_ID_ENV: &str = "WORKOS_CLIENT_ID"; const WORKOS_CLIENT_ID_BAKED_DEFAULT: &str = "client_sce_default"; @@ -63,6 +64,8 @@ pub(super) struct RuntimeConfig { pub(super) timeout_ms: ResolvedValue, pub(super) attribution_hooks_enabled: ResolvedValue, pub(super) workos_client_id: ResolvedOptionalValue, + pub(super) agent_trace_repository_id: ResolvedOptionalValue, + pub(super) agent_trace_repository_remote: ResolvedValue, pub(super) bash_policies: ResolvedOptionalValue, pub(super) database_retry: ResolvedOptionalValue, pub(super) validation_errors: Vec, @@ -110,6 +113,32 @@ pub(crate) fn resolve_hook_runtime_config(cwd: &Path) -> Result Result { + let runtime = resolve_runtime_config_with( + &ConfigRequest { + report_format: ReportFormat::Text, + config_path: None, + log_level: None, + timeout_ms: None, + }, + cwd, + |key| std::env::var(key).ok(), + |path| { + std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file '{}'.", path.display())) + }, + Path::exists, + resolve_default_global_config_path, + )?; + + Ok(ResolvedAgentTraceStorageRuntimeConfig { + repository_id: runtime.agent_trace_repository_id.value, + repository_remote: runtime.agent_trace_repository_remote.value, + }) +} + pub(crate) fn resolve_bash_policy_runtime_config(cwd: &Path) -> Result> { let runtime = resolve_runtime_config_with( &ConfigRequest { @@ -273,6 +302,8 @@ where timeout_ms: None, attribution_hooks_enabled: None, workos_client_id: None, + agent_trace_repository_id: None, + agent_trace_repository_remote: None, bash_policy_presets: None, bash_policy_custom: None, database_retry: None, @@ -310,6 +341,12 @@ where if let Some(workos_client_id) = layer.workos_client_id { file_config.workos_client_id = Some(workos_client_id); } + if let Some(agent_trace_repository_id) = layer.agent_trace_repository_id { + file_config.agent_trace_repository_id = Some(agent_trace_repository_id); + } + if let Some(agent_trace_repository_remote) = layer.agent_trace_repository_remote { + file_config.agent_trace_repository_remote = Some(agent_trace_repository_remote); + } if let Some(bash_policy_presets) = layer.bash_policy_presets { file_config.bash_policy_presets = Some(bash_policy_presets); } @@ -455,6 +492,28 @@ where &env_lookup, ); + let resolved_agent_trace_repository_id = ResolvedOptionalValue { + value: file_config + .agent_trace_repository_id + .as_ref() + .map(|value| value.value.clone()), + source: file_config + .agent_trace_repository_id + .as_ref() + .map(|value| ValueSource::ConfigFile(value.source)), + }; + + let mut resolved_agent_trace_repository_remote = ResolvedValue { + value: DEFAULT_AGENT_TRACE_REPOSITORY_REMOTE.to_string(), + source: ValueSource::Default, + }; + if let Some(value) = file_config.agent_trace_repository_remote { + resolved_agent_trace_repository_remote = ResolvedValue { + value: value.value, + source: ValueSource::ConfigFile(value.source), + }; + } + let resolved_bash_policies = resolve_bash_policy_config( file_config.bash_policy_presets.as_ref(), file_config.bash_policy_custom.as_ref(), @@ -473,6 +532,8 @@ where timeout_ms: resolved_timeout_ms, attribution_hooks_enabled: resolved_attribution_hooks_enabled, workos_client_id: resolved_workos_client_id, + agent_trace_repository_id: resolved_agent_trace_repository_id, + agent_trace_repository_remote: resolved_agent_trace_repository_remote, bash_policies: resolved_bash_policies, database_retry: resolved_database_retry, validation_errors, @@ -685,6 +746,86 @@ mod tests { }) } + fn resolve_runtime_with_config(config: Option<&'static str>) -> Result { + let request = if config.is_some() { + explicit_config_request() + } else { + empty_request() + }; + let path_exists_fn = if config.is_some() { + path_exists + } else { + missing_path + }; + + resolve_runtime_config_with( + &request, + Path::new("/tmp/repo"), + |_| None, + |_| Ok(config.unwrap_or("{}").to_string()), + path_exists_fn, + || Ok(PathBuf::from("/tmp/missing-global-sce-config.json")), + ) + } + + #[test] + fn agent_trace_repository_id_is_unset_by_default() { + let runtime = resolve_runtime_with_config(None).unwrap(); + + assert_eq!(runtime.agent_trace_repository_id.value, None); + assert_eq!(runtime.agent_trace_repository_id.source, None); + } + + #[test] + fn agent_trace_repository_remote_defaults_to_origin() { + let runtime = resolve_runtime_with_config(None).unwrap(); + + assert_eq!( + runtime.agent_trace_repository_remote.value, + DEFAULT_AGENT_TRACE_REPOSITORY_REMOTE + ); + assert_eq!( + runtime.agent_trace_repository_remote.source, + ValueSource::Default + ); + } + + #[test] + fn agent_trace_explicit_repository_id_resolves_from_config_file() { + let runtime = resolve_runtime_with_config(Some( + r#"{"agent_trace":{"repository_id":"team-monorepo"}}"#, + )) + .unwrap(); + + assert_eq!( + runtime.agent_trace_repository_id.value.as_deref(), + Some("team-monorepo") + ); + assert_eq!( + runtime.agent_trace_repository_id.source, + Some(ValueSource::ConfigFile(ConfigPathSource::Flag)) + ); + assert_eq!( + runtime.agent_trace_repository_remote.value, + DEFAULT_AGENT_TRACE_REPOSITORY_REMOTE + ); + } + + #[test] + fn agent_trace_repository_remote_override_resolves_from_config_file() { + let runtime = resolve_runtime_with_config(Some( + r#"{"agent_trace":{"repository_remote":"upstream"}}"#, + )) + .unwrap(); + + assert_eq!(runtime.agent_trace_repository_remote.value, "upstream"); + assert_eq!( + runtime.agent_trace_repository_remote.source, + ValueSource::ConfigFile(ConfigPathSource::Flag) + ); + assert_eq!(runtime.agent_trace_repository_id.value, None); + } + #[test] fn attribution_hooks_are_enabled_by_default() { let resolved = resolve_hooks_with_env_and_config(None, None).unwrap(); diff --git a/cli/src/services/config/schema.rs b/cli/src/services/config/schema.rs index c5944b8d..59440e34 100644 --- a/cli/src/services/config/schema.rs +++ b/cli/src/services/config/schema.rs @@ -36,12 +36,13 @@ pub(crate) const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ "log_file_mode", "timeout_ms", super::resolver::WORKOS_CLIENT_ID_KEY.config_key, + "agent_trace", "policies", "integrations", ]; pub(crate) const TOP_LEVEL_CONFIG_KEYS_DESCRIPTION: &str = - "$schema, log_level, log_format, log_file, log_file_mode, timeout_ms, workos_client_id, policies, integrations"; + "$schema, log_level, log_format, log_file, log_file_mode, timeout_ms, workos_client_id, agent_trace, policies, integrations"; static CONFIG_SCHEMA_VALIDATOR: OnceLock = OnceLock::new(); @@ -71,10 +72,17 @@ pub(crate) struct ParsedFileConfigDocument { pub(crate) log_file_mode: Option, pub(crate) timeout_ms: Option, pub(crate) workos_client_id: Option, + pub(crate) agent_trace: Option, pub(crate) policies: Option, pub(crate) integrations: Option, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct ParsedAgentTraceConfigDocument { + pub(crate) repository_id: Option, + pub(crate) repository_remote: Option, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] pub(crate) struct ParsedIntegrationsConfigDocument { pub(crate) target: Option>, @@ -148,6 +156,8 @@ pub(crate) struct FileConfig { pub(crate) timeout_ms: Option>, pub(crate) attribution_hooks_enabled: Option>, pub(crate) workos_client_id: Option>, + pub(crate) agent_trace_repository_id: Option>, + pub(crate) agent_trace_repository_remote: Option>, pub(crate) bash_policy_presets: Option>>, pub(crate) bash_policy_custom: Option>>, pub(crate) database_retry: Option>, @@ -294,6 +304,8 @@ pub(crate) fn parse_file_config( let workos_client_id = typed .workos_client_id .map(|value| FileConfigValue { value, source }); + let (agent_trace_repository_id, agent_trace_repository_remote) = + map_agent_trace_config(typed.agent_trace.as_ref(), object, path, source)?; let (attribution_hooks_enabled, bash_policy_presets, bash_policy_custom, database_retry) = map_policies_config(typed.policies.as_ref(), object, path, source)?; let integrations = map_integrations_config(typed.integrations.as_ref(), object, path, source)?; @@ -306,6 +318,8 @@ pub(crate) fn parse_file_config( timeout_ms, attribution_hooks_enabled, workos_client_id, + agent_trace_repository_id, + agent_trace_repository_remote, bash_policy_presets, bash_policy_custom, database_retry, @@ -583,6 +597,46 @@ pub(crate) fn map_database_retry_config( })) } +pub(crate) type ParsedAgentTraceConfig = ( + Option>, + Option>, +); + +fn map_agent_trace_config( + typed: Option<&ParsedAgentTraceConfigDocument>, + object: &serde_json::Map, + path: &Path, + source: ConfigPathSource, +) -> Result { + let Some(agent_trace_value) = object.get("agent_trace") else { + return Ok((None, None)); + }; + + let agent_trace_object = agent_trace_value.as_object().with_context(|| { + format!( + "Config key 'agent_trace' in '{}' must be an object.", + path.display() + ) + })?; + + validate_object_keys( + agent_trace_object, + path, + Some("agent_trace"), + &["repository_id", "repository_remote"], + "repository_id, repository_remote", + )?; + + let repository_id = typed + .and_then(|config| config.repository_id.clone()) + .map(|value| FileConfigValue { value, source }); + let repository_remote = typed + .and_then(|config| config.repository_remote.clone()) + .map(|value| FileConfigValue { value, source }); + + Ok((repository_id, repository_remote)) +} + fn map_integrations_config( typed: Option<&ParsedIntegrationsConfigDocument>, object: &serde_json::Map, @@ -622,3 +676,85 @@ fn map_integrations_config( source, })) } + +#[cfg(test)] +mod agent_trace_config_tests { + use std::path::Path; + + use super::{parse_file_config, ConfigPathSource}; + + fn parse(raw: &str) -> anyhow::Result { + parse_file_config( + raw, + Path::new("/tmp/sce-config.json"), + ConfigPathSource::Flag, + ) + } + + #[test] + fn parses_agent_trace_repository_identity_keys() { + let config = parse( + r#"{"agent_trace":{"repository_id":"team-monorepo","repository_remote":"upstream"}}"#, + ) + .unwrap(); + + assert_eq!( + config + .agent_trace_repository_id + .as_ref() + .map(|value| value.value.as_str()), + Some("team-monorepo") + ); + assert_eq!( + config + .agent_trace_repository_remote + .as_ref() + .map(|value| value.value.as_str()), + Some("upstream") + ); + } + + #[test] + fn omitted_agent_trace_block_parses_as_unset() { + let config = parse("{}").unwrap(); + + assert_eq!(config.agent_trace_repository_id, None); + assert_eq!(config.agent_trace_repository_remote, None); + } + + #[test] + fn rejects_unknown_agent_trace_key() { + let error = parse(r#"{"agent_trace":{"repository_url":"x"}}"#) + .unwrap_err() + .to_string(); + + assert!(error.contains("failed schema validation"), "{error}"); + } + + #[test] + fn rejects_non_object_agent_trace_value() { + let error = parse(r#"{"agent_trace":"origin"}"#) + .unwrap_err() + .to_string(); + + assert!(error.contains("failed schema validation"), "{error}"); + } + + #[test] + fn rejects_empty_agent_trace_string_values() { + let error = parse(r#"{"agent_trace":{"repository_id":""}}"#) + .unwrap_err() + .to_string(); + + assert!(error.contains("failed schema validation"), "{error}"); + } + + #[test] + fn rejects_non_string_repository_remote() { + let error = parse(r#"{"agent_trace":{"repository_remote":7}}"#) + .unwrap_err() + .to_string(); + + assert!(error.contains("failed schema validation"), "{error}"); + } +} diff --git a/cli/src/services/config/types.rs b/cli/src/services/config/types.rs index be3bdc55..25032b51 100644 --- a/cli/src/services/config/types.rs +++ b/cli/src/services/config/types.rs @@ -241,6 +241,12 @@ pub(crate) struct ResolvedHookRuntimeConfig { pub(crate) attribution_hooks_enabled: bool, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ResolvedAgentTraceStorageRuntimeConfig { + pub(crate) repository_id: Option, + pub(crate) repository_remote: String, +} + pub(crate) fn parse_bool_value_from(key: &str, raw: &str, source: &str) -> anyhow::Result { match raw { "1" | "true" => Ok(true), diff --git a/cli/src/services/db/mod.rs b/cli/src/services/db/mod.rs index df37e607..6561629f 100644 --- a/cli/src/services/db/mod.rs +++ b/cli/src/services/db/mod.rs @@ -237,7 +237,11 @@ fn apply_migration( sql: &str, ) -> Result<()> { runtime.block_on(async { - conn.execute(sql, ()) + // Migration files may contain multiple statements (the repository + // Agent Trace baseline is one multi-statement schema file), so batch + // execution is required; `execute` would stop after the first + // statement. + conn.execute_batch(sql) .await .map_err(|e| anyhow::anyhow!("{db_name} migration {id} failed: {e}"))?; conn.execute(INSERT_MIGRATION_SQL, (id,)) diff --git a/cli/src/services/default_paths.rs b/cli/src/services/default_paths.rs index d7c03ac3..40a98bdd 100644 --- a/cli/src/services/default_paths.rs +++ b/cli/src/services/default_paths.rs @@ -278,35 +278,41 @@ pub fn auth_db_path() -> anyhow::Result { .join("auth.db")) } -/// Returns the canonical path to the agent trace Turso database file. +/// Returns the canonical repository-scoped Agent Trace Turso database file path. /// -/// The path is `/sce/agent-trace.db`, where `state_root` comes -/// from the shared default-path catalog (`XDG_STATE_HOME` or platform -/// equivalent). -pub fn agent_trace_db_path() -> anyhow::Result { - Ok(resolve_sce_default_locations()? +/// The path is `/sce/repos//agent-trace.db`, where +/// `state_root` comes from the shared default-path catalog (`XDG_STATE_HOME` or +/// platform equivalent) and `repository_id` is the stable repository identity +/// hash from `services::repository_identity`. +pub fn agent_trace_db_path_for_repository(repository_id: &str) -> anyhow::Result { + let state_root = resolve_sce_default_locations()? .roots() .state_root() - .join("sce") - .join("agent-trace.db")) + .to_path_buf(); + agent_trace_db_path_for_repository_at(&state_root, repository_id) } -/// Returns the canonical per-checkout Agent Trace Turso database file path. -/// -/// The path is `/sce/agent-trace-{checkout_id}.db`, where -/// `state_root` comes from the shared default-path catalog (`XDG_STATE_HOME` or -/// platform equivalent). -pub fn agent_trace_db_path_for_checkout(checkout_id: &str) -> anyhow::Result { - let checkout_id = checkout_id.trim(); - if checkout_id.is_empty() { - anyhow::bail!("checkout ID must not be empty when resolving Agent Trace DB path"); +/// Builds the repository-scoped Agent Trace database path under an explicit +/// state root: `/sce/repos//agent-trace.db`. +pub fn agent_trace_db_path_for_repository_at( + state_root: &std::path::Path, + repository_id: &str, +) -> anyhow::Result { + let repository_id = repository_id.trim(); + if repository_id.is_empty() { + anyhow::bail!("repository ID must not be empty when resolving Agent Trace DB path"); + } + // The repository ID becomes a single path segment; reject anything that + // could escape the `repos/` directory. + if repository_id.contains(['/', '\\']) || repository_id == "." || repository_id == ".." { + anyhow::bail!("repository ID '{repository_id}' is not a valid path segment"); } - Ok(resolve_sce_default_locations()? - .roots() - .state_root() + Ok(state_root .join("sce") - .join(format!("agent-trace-{checkout_id}.db"))) + .join("repos") + .join(repository_id) + .join("agent-trace.db")) } #[allow(dead_code)] diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index 0341785e..9c95f470 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -6,23 +6,26 @@ use sha2::{Digest, Sha256}; use crate::services::agent_trace_db::lifecycle::diagnose_agent_trace_db_health; use crate::services::checkout; use crate::services::config::schema::parse_file_config; -use crate::services::config::{ConfigPathSource, IntegrationTargetId}; +use crate::services::config::{self, ConfigPathSource, IntegrationTargetId}; use crate::services::default_paths::{ - agent_trace_db_path_for_checkout, claude_asset, opencode_asset, pi_asset, InstallTargetPaths, + agent_trace_db_path_for_repository, claude_asset, opencode_asset, pi_asset, InstallTargetPaths, RepoPaths, }; +use crate::services::repository_identity::resolve::{ + resolve_repository_identity, RepositoryIdentitySource, +}; use crate::services::setup::{ iter_embedded_assets_for_setup_target, iter_required_hook_assets, EmbeddedAsset, SetupTarget, }; use super::types::{ - CheckoutIdentityHealth, DoctorProblem, FileLocationHealth, GlobalStateHealth, HookContentState, - HookDoctorReport, HookFileHealth, HookPathSource, IntegrationChildHealth, - IntegrationContentState, IntegrationGroupHealth, ProblemCategory, ProblemFixability, - ProblemKind, ProblemSeverity, Readiness, CLAUDE_AGENTS_LABEL, CLAUDE_COMMANDS_LABEL, - CLAUDE_PLUGINS_LABEL, CLAUDE_SKILLS_LABEL, OPENCODE_AGENTS_LABEL, OPENCODE_COMMANDS_LABEL, - OPENCODE_PLUGINS_LABEL, OPENCODE_SKILLS_LABEL, PI_EXTENSIONS_LABEL, PI_PROMPTS_LABEL, - PI_SKILLS_LABEL, + AgentTraceDbHealth, CheckoutIdentityHealth, DoctorProblem, FileLocationHealth, + GlobalStateHealth, HookContentState, HookDoctorReport, HookFileHealth, HookPathSource, + IntegrationChildHealth, IntegrationContentState, IntegrationGroupHealth, ProblemCategory, + ProblemFixability, ProblemKind, ProblemSeverity, Readiness, CLAUDE_AGENTS_LABEL, + CLAUDE_COMMANDS_LABEL, CLAUDE_PLUGINS_LABEL, CLAUDE_SKILLS_LABEL, OPENCODE_AGENTS_LABEL, + OPENCODE_COMMANDS_LABEL, OPENCODE_PLUGINS_LABEL, OPENCODE_SKILLS_LABEL, PI_EXTENSIONS_LABEL, + PI_PROMPTS_LABEL, PI_SKILLS_LABEL, }; use super::{is_executable, DoctorDependencies, DoctorMode, REQUIRED_HOOKS}; @@ -203,24 +206,14 @@ fn collect_global_state_locations( fn collect_checkout_identity_health(repository_root: &Path) -> Option { let git_dir = checkout::resolve_git_dir(repository_root).ok()?; let checkout_id = checkout::read_checkout_id(&git_dir).ok()??; - let database_path = agent_trace_db_path_for_checkout(&checkout_id).ok()?; - let database_state = if database_path.exists() { - "present" - } else { - "expected" - }; - Some(CheckoutIdentityHealth { - checkout_id, - database_path, - database_state, - }) + Some(CheckoutIdentityHealth { checkout_id }) } fn collect_agent_trace_db_health( repository_root: &Path, problems: &mut Vec, -) -> Option { +) -> Option { let agent_trace_problems = diagnose_agent_trace_db_health(Some(repository_root)); let mut agent_trace_db = None; @@ -241,46 +234,47 @@ fn collect_agent_trace_db_health( continue; } - let db_path = resolve_agent_trace_db_location(repository_root)?; - agent_trace_db = Some(FileLocationHealth { - label: agent_trace_db_label(repository_root), - state: if db_path.exists() { - "present" - } else { - "expected" - }, - path: db_path, - }); + agent_trace_db = resolve_agent_trace_db_location(repository_root); } if agent_trace_db.is_none() { - let db_path = resolve_agent_trace_db_location(repository_root)?; - agent_trace_db = Some(FileLocationHealth { - label: agent_trace_db_label(repository_root), - state: if db_path.exists() { - "present" - } else { - "expected" - }, - path: db_path, - }); + agent_trace_db = resolve_agent_trace_db_location(repository_root); } agent_trace_db } -fn resolve_agent_trace_db_location(repository_root: &Path) -> Option { - collect_checkout_identity_health(repository_root) - .map(|identity| identity.database_path) - .or_else(|| crate::services::default_paths::agent_trace_db_path().ok()) -} - -fn agent_trace_db_label(repository_root: &Path) -> &'static str { - if collect_checkout_identity_health(repository_root).is_some() { - "Agent Trace checkout DB" +fn resolve_agent_trace_db_location(repository_root: &Path) -> Option { + let storage_config = + config::resolve_agent_trace_storage_runtime_config(repository_root).ok()?; + let resolved = resolve_repository_identity( + repository_root, + storage_config.repository_id.as_deref(), + &storage_config.repository_remote, + ) + .ok()?; + let db_path = agent_trace_db_path_for_repository(&resolved.identity.repository_id).ok()?; + let state = if db_path.exists() { + "present" } else { - "Agent Trace DB" - } + "expected" + }; + let (identity_source, configured_remote) = match resolved.source { + RepositoryIdentitySource::ExplicitConfig => (String::from("explicit_config"), None), + RepositoryIdentitySource::RemoteUrl { remote_name } => { + (String::from("remote_url"), Some(remote_name)) + } + }; + + Some(AgentTraceDbHealth { + label: "Agent Trace repository DB", + path: db_path, + state, + repository_id: resolved.identity.repository_id, + canonical_identity: resolved.identity.canonical_identity, + identity_source, + configured_remote, + }) } fn collect_hook_file_health(directory: &Path) -> Vec { diff --git a/cli/src/services/doctor/render.rs b/cli/src/services/doctor/render.rs index 053ab172..ff7b4867 100644 --- a/cli/src/services/doctor/render.rs +++ b/cli/src/services/doctor/render.rs @@ -169,19 +169,41 @@ fn push_configuration_section_rows( "Checkout identity", identity.checkout_id.clone(), )); + } + + if let Some(agent_trace_db) = &report.agent_trace_db { lines.push(format_human_text_row( color_enabled, agent_trace_db_status(report), - "Agent Trace checkout DB", - identity.database_path.display().to_string(), + agent_trace_db.label, + agent_trace_db.path.display().to_string(), )); - } else if let Some(agent_trace_db) = &report.agent_trace_db { lines.push(format_human_text_row( color_enabled, - agent_trace_db_status(report), - agent_trace_db.label, - agent_trace_db.path.display().to_string(), + HumanTextStatus::Pass, + "Agent Trace repository ID", + agent_trace_db.repository_id.clone(), )); + lines.push(format_human_text_row( + color_enabled, + HumanTextStatus::Pass, + "Agent Trace identity source", + agent_trace_db.identity_source.clone(), + )); + lines.push(format_human_text_row( + color_enabled, + HumanTextStatus::Pass, + "Agent Trace canonical identity", + agent_trace_db.canonical_identity.clone(), + )); + if let Some(remote) = &agent_trace_db.configured_remote { + lines.push(format_human_text_row( + color_enabled, + HumanTextStatus::Pass, + "Agent Trace configured remote", + remote.clone(), + )); + } } } @@ -492,13 +514,16 @@ fn render_report_json(execution: &DoctorExecution) -> Result { })), "agent_trace_db": report.agent_trace_db.as_ref().map(|location| json!({ "label": location.label, + "scope": "repository", "path": location.path.display().to_string(), "state": location.state, + "repository_id": location.repository_id, + "repository_identity_source": location.identity_source, + "canonical_identity": location.canonical_identity, + "configured_remote": location.configured_remote, })), "checkout_identity": report.checkout_identity.as_ref().map(|identity| json!({ "checkout_id": identity.checkout_id, - "database_path": identity.database_path.display().to_string(), - "database_state": identity.database_state, })), "hook_path_source": match report.hook_path_source { HookPathSource::Default => "default", diff --git a/cli/src/services/doctor/types.rs b/cli/src/services/doctor/types.rs index 062475e4..3d3f84dd 100644 --- a/cli/src/services/doctor/types.rs +++ b/cli/src/services/doctor/types.rs @@ -61,7 +61,7 @@ pub(super) struct HookDoctorReport { pub(super) readiness: Readiness, pub(super) state_root: Option, pub(super) checkout_identity: Option, - pub(super) agent_trace_db: Option, + pub(super) agent_trace_db: Option, pub(super) repository_root: Option, pub(super) hook_path_source: HookPathSource, pub(super) hooks_directory: Option, @@ -75,8 +75,17 @@ pub(super) struct HookDoctorReport { #[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct CheckoutIdentityHealth { pub(super) checkout_id: String, - pub(super) database_path: PathBuf, - pub(super) database_state: &'static str, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct AgentTraceDbHealth { + pub(super) label: &'static str, + pub(super) path: PathBuf, + pub(super) state: &'static str, + pub(super) repository_id: String, + pub(super) canonical_identity: String, + pub(super) identity_source: String, + pub(super) configured_remote: Option, } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 3e9700eb..d029f862 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -13,12 +13,13 @@ use crate::services::agent_trace::{ agent_trace_persisted_url, build_agent_trace, patch_has_touched_lines, patches_have_overlap, validate_agent_trace_value, AgentTrace, AgentTraceMetadataInput, AgentTraceVcsType, }; +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::agent_trace_db::{ - AgentTraceDb, AgentTraceInsert, DiffTraceInsert, InsertMessageInsert, InsertPartInsert, - MessageRole, PartType, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, - PAYLOAD_TYPE_PATCH, PAYLOAD_TYPE_STRUCTURED, + AgentTraceInsert, DiffTraceInsert, InsertMessageInsert, InsertPartInsert, MessageRole, + PartType, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, + PAYLOAD_TYPE_STRUCTURED, }; -use crate::services::checkout; +use crate::services::agent_trace_storage::{resolve_agent_trace_storage, AgentTraceStorageContext}; use crate::services::config; use crate::services::observability::traits::Logger; use crate::services::patch::{ @@ -295,9 +296,17 @@ where fn open_agent_trace_db_for_hook_runtime( repository_root: &Path, context_message: &'static str, -) -> Result { - checkout::resolve_or_create_agent_trace_db_for_checkout(repository_root) - .map(|(db, _checkout_id)| db) +) -> Result { + let storage_config = config::resolve_agent_trace_storage_runtime_config(repository_root) + .context("Failed to resolve Agent Trace repository storage config.")?; + let storage_context = AgentTraceStorageContext { + repository_root, + explicit_repository_id: storage_config.repository_id.as_deref(), + repository_remote: &storage_config.repository_remote, + }; + + resolve_agent_trace_storage(&storage_context) + .map(|storage| storage.db) .context(context_message) } diff --git a/cli/src/services/mod.rs b/cli/src/services/mod.rs index d4fafa2a..0f2251b1 100644 --- a/cli/src/services/mod.rs +++ b/cli/src/services/mod.rs @@ -1,5 +1,7 @@ pub mod agent_trace; pub mod agent_trace_db; +#[allow(dead_code)] +pub mod agent_trace_storage; pub mod app_support; pub mod auth; pub mod auth_command; @@ -23,6 +25,8 @@ pub mod observability; pub mod output_format; pub mod parse; pub mod patch; +#[allow(dead_code)] +pub mod repository_identity; pub mod resilience; pub mod security; pub mod setup; diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index 6fe07f9c..6bd82d67 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -512,7 +512,7 @@ mod tests { assert_eq!( command.request.subcommand, services::trace::TraceSubcommandRequest::DbShell { - identifier: String::from("agent_trace_0"), + identifier: Some(String::from("agent_trace_0")), } ); } @@ -528,6 +528,6 @@ mod tests { assert!(command.text.contains("shell")); assert!(command .text - .contains("Open an embedded SQL shell for a discovered Agent Trace database")); + .contains("Open an embedded SQL shell for an Agent Trace database")); } } diff --git a/cli/src/services/repository_identity/mod.rs b/cli/src/services/repository_identity/mod.rs new file mode 100644 index 00000000..405d0b87 --- /dev/null +++ b/cli/src/services/repository_identity/mod.rs @@ -0,0 +1,521 @@ +//! Pure repository identity canonicalization and hashing. +//! +//! Turns an explicit configured identity or a Git remote URL into a +//! scheme-neutral canonical identity, then derives a stable repository ID as +//! `sha256("sce-repository-id-v1\0" + canonical_identity)` hex. +//! +//! This root module performs no I/O: it never opens databases, reads Git +//! config, or touches the filesystem. Runtime precedence resolution and Git +//! remote lookup live in [`resolve`]. Errors intentionally never echo the +//! raw input so credential-bearing remote URLs cannot leak through +//! diagnostics. + +pub mod resolve; + +use sha2::{Digest, Sha256}; + +/// Domain-separation prefix hashed before the canonical identity. +pub const REPOSITORY_ID_HASH_DOMAIN: &[u8] = b"sce-repository-id-v1\0"; + +/// A resolved repository identity: the safe canonical form plus its hash. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepositoryIdentity { + /// Credential-free canonical identity, safe to display and store. + pub canonical_identity: String, + /// Lowercase hex SHA-256 of the domain prefix plus canonical identity. + pub repository_id: String, +} + +impl RepositoryIdentity { + /// Human-readable on-disk directory segment (`-`) for this + /// identity. Convenience wrapper over [`repository_dir_segment`]; the + /// authoritative identity is still [`RepositoryIdentity::repository_id`]. + pub fn dir_segment(&self) -> String { + repository_dir_segment(&self.canonical_identity) + } +} + +/// Canonicalization failure. Variants carry no input fragments so +/// credential-bearing URLs never leak into error output. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RepositoryIdentityError { + /// Explicit identity was empty after trimming whitespace. + EmptyExplicitIdentity, + /// Remote URL was empty after trimming whitespace. + EmptyRemoteUrl, + /// Remote URL scheme is not a supported Git transport. + UnsupportedRemoteUrl, + /// Remote URL has no usable host component. + MissingHost, + /// Remote URL has no usable repository path component. + MissingPath, + /// Remote URL port component is not a valid number. + InvalidPort, +} + +impl std::fmt::Display for RepositoryIdentityError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::EmptyExplicitIdentity => "explicit repository identity is empty", + Self::EmptyRemoteUrl => "remote URL is empty", + Self::UnsupportedRemoteUrl => { + "remote URL is not a supported Git transport (ssh, scp-style ssh, http, https, git)" + } + Self::MissingHost => "remote URL has no host", + Self::MissingPath => "remote URL has no repository path", + Self::InvalidPort => "remote URL has an invalid port", + }; + f.write_str(message) + } +} + +impl std::error::Error for RepositoryIdentityError {} + +/// Builds a repository identity from an explicitly configured identity +/// string (`agent_trace.repository_id`). Canonicalization is trimming only: +/// explicit identities are operator-chosen opaque values, not URLs. +pub fn repository_identity_from_explicit( + raw: &str, +) -> Result { + let canonical = raw.trim(); + if canonical.is_empty() { + return Err(RepositoryIdentityError::EmptyExplicitIdentity); + } + Ok(identity_from_canonical(canonical.to_string())) +} + +/// Builds a repository identity from a Git remote URL. Equivalent SSH, +/// SCP-style, and HTTPS URLs canonicalize to the same identity. +pub fn repository_identity_from_remote_url( + raw: &str, +) -> Result { + let canonical = canonicalize_remote_url(raw)?; + Ok(identity_from_canonical(canonical)) +} + +/// Derives the repository ID hex digest for a canonical identity. +pub fn derive_repository_id(canonical_identity: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(REPOSITORY_ID_HASH_DOMAIN); + hasher.update(canonical_identity.as_bytes()); + hex_encode(&hasher.finalize()) +} + +/// Length in hex chars of the disambiguating short hash in a directory segment. +const DIR_SEGMENT_SHORT_HASH_LEN: usize = 4; + +/// Derives the human-readable on-disk directory segment for a canonical +/// identity as `-`, where `slug` is the lowercased canonical +/// identity with every run of non-alphanumeric characters collapsed to a +/// single `-` and leading/trailing `-` trimmed, and `short` is the first four +/// hex chars of `SHA256(canonical_identity)` computed with **no** domain +/// prefix. The short hash is deliberately distinct from [`derive_repository_id`], +/// which keeps its `sce-repository-id-v1\0` domain separation. This is a pure +/// display/layout helper: the authoritative identity remains the repository ID. +pub fn repository_dir_segment(canonical_identity: &str) -> String { + let slug = slugify(canonical_identity); + let short = derive_short_hash(canonical_identity); + if slug.is_empty() { + short + } else { + format!("{slug}-{short}") + } +} + +/// Lowercases and collapses every run of non-alphanumeric characters to a +/// single `-`, trimming leading and trailing `-`. +fn slugify(input: &str) -> String { + let mut slug = String::with_capacity(input.len()); + let mut pending_dash = false; + for ch in input.chars() { + if ch.is_ascii_alphanumeric() { + if pending_dash && !slug.is_empty() { + slug.push('-'); + } + pending_dash = false; + slug.push(ch.to_ascii_lowercase()); + } else { + pending_dash = true; + } + } + slug +} + +/// First [`DIR_SEGMENT_SHORT_HASH_LEN`] hex chars of the un-prefixed +/// `SHA256(canonical_identity)`. +fn derive_short_hash(canonical_identity: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(canonical_identity.as_bytes()); + let full = hex_encode(&hasher.finalize()); + full[..DIR_SEGMENT_SHORT_HASH_LEN].to_string() +} + +/// Canonicalizes a Git remote URL to the scheme-neutral form +/// `host[:port]/path` with credentials stripped, hostname lowercased, +/// default ports removed, and query/fragment/trailing-slash/trailing-`.git` +/// cleaned up. The returned string never contains credentials. +pub fn canonicalize_remote_url(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(RepositoryIdentityError::EmptyRemoteUrl); + } + + if let Some((scheme, rest)) = trimmed.split_once("://") { + canonicalize_scheme_url(scheme, rest) + } else { + canonicalize_scp_style(trimmed) + } +} + +fn identity_from_canonical(canonical_identity: String) -> RepositoryIdentity { + let repository_id = derive_repository_id(&canonical_identity); + RepositoryIdentity { + canonical_identity, + repository_id, + } +} + +fn canonicalize_scheme_url(scheme: &str, rest: &str) -> Result { + let scheme = scheme.to_ascii_lowercase(); + let default_port = match scheme.as_str() { + "ssh" | "git+ssh" | "ssh+git" => Some(22), + "http" => Some(80), + "https" => Some(443), + "git" => Some(9418), + _ => return Err(RepositoryIdentityError::UnsupportedRemoteUrl), + }; + + let (authority, path) = match rest.split_once('/') { + Some((authority, path)) => (authority, path), + None => (rest, ""), + }; + + let host_port = strip_userinfo(authority); + let (host, port) = split_host_port(host_port)?; + if host.is_empty() { + return Err(RepositoryIdentityError::MissingHost); + } + + let path = clean_path(path)?; + Ok(render_canonical(&host, port, default_port, &path)) +} + +fn canonicalize_scp_style(input: &str) -> Result { + // SCP-style form: [user@]host:path — the colon must come before any '/'. + let host_port_end = input.find(':'); + let first_slash = input.find('/'); + let colon = match (host_port_end, first_slash) { + (Some(colon), Some(slash)) if colon < slash => colon, + (Some(colon), None) => colon, + _ => return Err(RepositoryIdentityError::UnsupportedRemoteUrl), + }; + + let authority = &input[..colon]; + let path = &input[colon + 1..]; + + let host = strip_userinfo(authority).to_ascii_lowercase(); + if host.is_empty() { + return Err(RepositoryIdentityError::MissingHost); + } + // SCP-style implies SSH on the default port; no port component exists. + let path = clean_path(path)?; + Ok(format!("{host}/{path}")) +} + +fn strip_userinfo(authority: &str) -> &str { + match authority.rfind('@') { + Some(at) => &authority[at + 1..], + None => authority, + } +} + +fn split_host_port(host_port: &str) -> Result<(String, Option), RepositoryIdentityError> { + // IPv6 literals are bracketed: [::1]:2222 + if let Some(rest) = host_port.strip_prefix('[') { + let Some(close) = rest.find(']') else { + return Err(RepositoryIdentityError::MissingHost); + }; + let host = rest[..close].to_ascii_lowercase(); + let after = &rest[close + 1..]; + if after.is_empty() { + return Ok((format!("[{host}]"), None)); + } + let Some(port) = after.strip_prefix(':') else { + return Err(RepositoryIdentityError::InvalidPort); + }; + let port = parse_port(port)?; + return Ok((format!("[{host}]"), Some(port))); + } + + match host_port.rsplit_once(':') { + Some((host, port)) => Ok((host.to_ascii_lowercase(), Some(parse_port(port)?))), + None => Ok((host_port.to_ascii_lowercase(), None)), + } +} + +fn parse_port(port: &str) -> Result { + port.parse::() + .map_err(|_| RepositoryIdentityError::InvalidPort) +} + +fn clean_path(path: &str) -> Result { + let path = path.split(['?', '#']).next().unwrap_or(""); + let path = path.trim_matches('/'); + let path = path.strip_suffix(".git").unwrap_or(path); + let path = path.trim_matches('/'); + if path.is_empty() { + return Err(RepositoryIdentityError::MissingPath); + } + Ok(path.to_string()) +} + +fn render_canonical( + host: &str, + port: Option, + default_port: Option, + path: &str, +) -> String { + match port { + Some(port) if Some(port) != default_port => format!("{host}:{port}/{path}"), + _ => format!("{host}/{path}"), + } +} + +fn hex_encode(bytes: &[u8]) -> String { + use std::fmt::Write; + + let mut hex = String::with_capacity(bytes.len() * 2); + for &b in bytes { + let _ = write!(hex, "{b:02x}"); + } + hex +} + +#[cfg(test)] +mod tests { + use super::*; + + fn canonical(raw: &str) -> String { + canonicalize_remote_url(raw).expect("expected canonicalization to succeed") + } + + #[test] + fn equivalent_github_urls_share_canonical_identity_and_id() { + let forms = [ + "git@github.com:CroCoder/shared-context-engineering.git", + "ssh://git@github.com/CroCoder/shared-context-engineering.git", + "ssh://git@github.com:22/CroCoder/shared-context-engineering.git", + "https://github.com/CroCoder/shared-context-engineering.git", + "https://github.com:443/CroCoder/shared-context-engineering", + "https://GitHub.com/CroCoder/shared-context-engineering.git/", + "https://token@github.com/CroCoder/shared-context-engineering.git?ref=main#readme", + ]; + + let expected = "github.com/CroCoder/shared-context-engineering"; + let expected_id = derive_repository_id(expected); + for form in forms { + let identity = repository_identity_from_remote_url(form) + .expect("expected identity resolution to succeed"); + assert_eq!(identity.canonical_identity, expected, "input: {form}"); + assert_eq!(identity.repository_id, expected_id, "input: {form}"); + } + } + + #[test] + fn repository_id_uses_domain_separated_sha256() { + let identity = repository_identity_from_explicit("acme/widgets") + .expect("expected explicit identity to resolve"); + let mut hasher = Sha256::new(); + hasher.update(b"sce-repository-id-v1\0"); + hasher.update(b"acme/widgets"); + let expected = hex_encode(&hasher.finalize()); + assert_eq!(identity.repository_id, expected); + assert_eq!(identity.repository_id.len(), 64); + } + + #[test] + fn distinct_identities_hash_differently() { + let a = repository_identity_from_remote_url("git@github.com:acme/widgets.git") + .expect("expected identity resolution to succeed"); + let b = repository_identity_from_remote_url("git@github.com:acme/gadgets.git") + .expect("expected identity resolution to succeed"); + let c = repository_identity_from_remote_url("git@gitlab.com:acme/widgets.git") + .expect("expected identity resolution to succeed"); + assert_ne!(a.repository_id, b.repository_id); + assert_ne!(a.repository_id, c.repository_id); + assert_ne!(b.repository_id, c.repository_id); + } + + #[test] + fn credentials_are_stripped_and_never_leak() { + let secret_forms = [ + "https://alice:s3cr3t@github.com/acme/widgets.git", + "ssh://alice:s3cr3t@github.com:22/acme/widgets.git", + "alice@github.com:acme/widgets.git", + ]; + for form in secret_forms { + let identity = repository_identity_from_remote_url(form) + .expect("expected identity resolution to succeed"); + assert_eq!(identity.canonical_identity, "github.com/acme/widgets"); + assert!(!identity.canonical_identity.contains("alice")); + assert!(!identity.canonical_identity.contains("s3cr3t")); + assert!(!identity.repository_id.contains("s3cr3t")); + } + } + + #[test] + fn errors_do_not_echo_input() { + let cases = [ + ("", RepositoryIdentityError::EmptyRemoteUrl), + ( + "file:///alice:s3cr3t/repo.git", + RepositoryIdentityError::UnsupportedRemoteUrl, + ), + ( + "/local/path/to/s3cr3t-repo", + RepositoryIdentityError::UnsupportedRemoteUrl, + ), + ( + "https://alice:s3cr3t@github.com", + RepositoryIdentityError::MissingPath, + ), + ( + "https://alice:s3cr3t@/acme/widgets.git", + RepositoryIdentityError::MissingHost, + ), + ( + "https://github.com:port/acme/widgets.git", + RepositoryIdentityError::InvalidPort, + ), + ]; + for (input, expected) in cases { + let error = + canonicalize_remote_url(input).expect_err("expected canonicalization error"); + assert_eq!(error, expected, "input: {input}"); + let rendered = error.to_string(); + assert!( + !rendered.contains("s3cr3t"), + "error leaked input: {rendered}" + ); + } + } + + #[test] + fn non_default_ports_are_preserved() { + assert_eq!( + canonical("ssh://git@github.com:2222/acme/widgets.git"), + "github.com:2222/acme/widgets" + ); + assert_eq!( + canonical("https://github.com:8443/acme/widgets.git"), + "github.com:8443/acme/widgets" + ); + assert_eq!( + canonical("git://github.com:9418/acme/widgets.git"), + "github.com/acme/widgets" + ); + assert_eq!( + canonical("http://github.com:80/acme/widgets.git"), + "github.com/acme/widgets" + ); + } + + #[test] + fn hostnames_are_lowercased_but_paths_preserved() { + assert_eq!( + canonical("Git@GitHub.COM:Acme/Widgets.git"), + "github.com/Acme/Widgets" + ); + } + + #[test] + fn query_fragment_and_trailing_cleanup() { + assert_eq!( + canonical("https://github.com/acme/widgets.git?depth=1"), + "github.com/acme/widgets" + ); + assert_eq!( + canonical("https://github.com/acme/widgets#fragment"), + "github.com/acme/widgets" + ); + assert_eq!( + canonical("https://github.com/acme/widgets///"), + "github.com/acme/widgets" + ); + assert_eq!( + canonical("https://github.com/acme/widgets.git/"), + "github.com/acme/widgets" + ); + } + + #[test] + fn scp_style_requires_colon_before_slash() { + assert_eq!( + canonicalize_remote_url("github.com/acme/widgets:tag"), + Err(RepositoryIdentityError::UnsupportedRemoteUrl) + ); + assert_eq!( + canonical("git@github.com:acme/widgets"), + "github.com/acme/widgets" + ); + } + + #[test] + fn ipv6_hosts_are_supported() { + assert_eq!( + canonical("ssh://git@[2001:DB8::1]:2222/acme/widgets.git"), + "[2001:db8::1]:2222/acme/widgets" + ); + assert_eq!( + canonical("ssh://git@[2001:db8::1]/acme/widgets.git"), + "[2001:db8::1]/acme/widgets" + ); + } + + #[test] + fn explicit_identity_is_trimmed_and_used_verbatim() { + let identity = repository_identity_from_explicit(" my-monorepo ") + .expect("expected explicit identity to resolve"); + assert_eq!(identity.canonical_identity, "my-monorepo"); + assert_eq!( + repository_identity_from_explicit(" "), + Err(RepositoryIdentityError::EmptyExplicitIdentity) + ); + } + + fn un_prefixed_sha256_prefix(input: &str, len: usize) -> String { + let mut hasher = Sha256::new(); + hasher.update(input.as_bytes()); + hex_encode(&hasher.finalize())[..len].to_string() + } + + #[test] + fn dir_segment_matches_slug_and_un_prefixed_short_hash() { + // Slug: lowercased, non-alphanumeric runs (`.`,`/`) collapsed to `-`, + // trimmed. Short: first 4 hex of the un-prefixed SHA-256, deliberately + // distinct from the domain-prefixed repository ID. + let canonical = "github.com/crocoder-dev/shared-context-engineering"; + let short = un_prefixed_sha256_prefix(canonical, 4); + assert_eq!( + repository_dir_segment(canonical), + format!("github-com-crocoder-dev-shared-context-engineering-{short}") + ); + assert_ne!(short, derive_repository_id(canonical)[..4]); + } + + #[test] + fn missing_path_variants_error() { + assert_eq!( + canonicalize_remote_url("https://github.com/"), + Err(RepositoryIdentityError::MissingPath) + ); + assert_eq!( + canonicalize_remote_url("git@github.com:"), + Err(RepositoryIdentityError::MissingPath) + ); + assert_eq!( + canonicalize_remote_url("https://github.com/.git"), + Err(RepositoryIdentityError::MissingPath) + ); + } +} diff --git a/cli/src/services/repository_identity/resolve.rs b/cli/src/services/repository_identity/resolve.rs new file mode 100644 index 00000000..e2bb0d0c --- /dev/null +++ b/cli/src/services/repository_identity/resolve.rs @@ -0,0 +1,345 @@ +//! Runtime repository identity resolution. +//! +//! Applies the repository identity precedence: an explicit configured +//! identity (`agent_trace.repository_id`) wins, otherwise the URL of the +//! configured Git remote (`agent_trace.repository_remote`, default `origin`) +//! is canonicalized, otherwise resolution fails with actionable +//! `.sce/config.json` guidance. Local paths are never used implicitly. +//! +//! Errors intentionally never echo remote URLs so credential-bearing +//! remotes cannot leak through diagnostics; remote names are operator-chosen +//! configuration values and are safe to display. + +use std::path::Path; +use std::process::Command; + +use super::{ + repository_identity_from_explicit, repository_identity_from_remote_url, RepositoryIdentity, + RepositoryIdentityError, +}; + +/// Where a resolved repository identity came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RepositoryIdentitySource { + /// Explicit `agent_trace.repository_id` configuration value. + ExplicitConfig, + /// URL of the named Git remote. + RemoteUrl { remote_name: String }, +} + +/// A repository identity plus the source it was resolved from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedRepositoryIdentity { + pub identity: RepositoryIdentity, + pub source: RepositoryIdentitySource, +} + +/// Resolution failure. Variants never carry remote URLs or explicit +/// identity values, only the configured remote name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RepositoryIdentityResolutionError { + /// `agent_trace.repository_id` is configured but unusable. + InvalidExplicitIdentity(RepositoryIdentityError), + /// The configured remote exists but its URL cannot serve as a + /// repository identity (for example a local path remote). + InvalidRemoteUrl { + remote_name: String, + error: RepositoryIdentityError, + }, + /// No explicit identity is configured and the configured remote has + /// no URL. + MissingIdentity { remote_name: String }, +} + +impl std::fmt::Display for RepositoryIdentityResolutionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidExplicitIdentity(error) => write!( + f, + "configured agent_trace.repository_id is not usable ({error}); update agent_trace.repository_id in .sce/config.json" + ), + Self::InvalidRemoteUrl { remote_name, error } => write!( + f, + "remote '{remote_name}' URL cannot be used as a repository identity ({error}); set agent_trace.repository_id in .sce/config.json or point agent_trace.repository_remote at a remote with a supported URL" + ), + Self::MissingIdentity { remote_name } => write!( + f, + "no repository identity: agent_trace.repository_id is not configured and remote '{remote_name}' has no URL; set agent_trace.repository_id or agent_trace.repository_remote in .sce/config.json" + ), + } + } +} + +impl std::error::Error for RepositoryIdentityResolutionError {} + +/// Resolves the repository identity for a Git repository checkout, applying +/// the explicit-config-then-remote precedence. +pub fn resolve_repository_identity( + repository_root: &Path, + explicit_identity: Option<&str>, + remote_name: &str, +) -> Result { + resolve_repository_identity_with_lookup(explicit_identity, remote_name, |remote| { + lookup_remote_url(repository_root, remote) + }) +} + +/// Precedence core with an injectable remote URL lookup, so callers and +/// tests can resolve without spawning `git`. +pub fn resolve_repository_identity_with_lookup( + explicit_identity: Option<&str>, + remote_name: &str, + lookup_remote_url: impl FnOnce(&str) -> Option, +) -> Result { + if let Some(explicit) = explicit_identity { + let identity = repository_identity_from_explicit(explicit) + .map_err(RepositoryIdentityResolutionError::InvalidExplicitIdentity)?; + return Ok(ResolvedRepositoryIdentity { + identity, + source: RepositoryIdentitySource::ExplicitConfig, + }); + } + + let Some(remote_url) = lookup_remote_url(remote_name) else { + return Err(RepositoryIdentityResolutionError::MissingIdentity { + remote_name: remote_name.to_string(), + }); + }; + + let identity = repository_identity_from_remote_url(&remote_url).map_err(|error| { + RepositoryIdentityResolutionError::InvalidRemoteUrl { + remote_name: remote_name.to_string(), + error, + } + })?; + Ok(ResolvedRepositoryIdentity { + identity, + source: RepositoryIdentitySource::RemoteUrl { + remote_name: remote_name.to_string(), + }, + }) +} + +/// Reads the URL of a named Git remote from repository configuration. +/// Returns `None` when git is unavailable, the directory is not a +/// repository, or the remote has no URL. +pub fn lookup_remote_url(repository_root: &Path, remote_name: &str) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(repository_root) + .args(["config", "--get", &format!("remote.{remote_name}.url")]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if url.is_empty() { + None + } else { + Some(url) + } +} + +#[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-repo-identity-{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 explicit_identity_overrides_remote_lookup() { + let resolved = + resolve_repository_identity_with_lookup(Some("my-monorepo"), "origin", |_| { + panic!("remote lookup must not run when explicit identity is set") + }) + .expect("explicit identity should resolve"); + assert_eq!(resolved.identity.canonical_identity, "my-monorepo"); + assert_eq!(resolved.source, RepositoryIdentitySource::ExplicitConfig); + } + + #[test] + fn invalid_explicit_identity_errors_without_remote_fallback() { + let error = resolve_repository_identity_with_lookup(Some(" "), "origin", |_| { + panic!("remote lookup must not run when explicit identity is set") + }) + .expect_err("blank explicit identity should fail"); + assert_eq!( + error, + RepositoryIdentityResolutionError::InvalidExplicitIdentity( + RepositoryIdentityError::EmptyExplicitIdentity + ) + ); + assert!(error.to_string().contains(".sce/config.json")); + } + + #[test] + fn configured_remote_name_is_honored() { + let resolved = resolve_repository_identity_with_lookup(None, "upstream", |remote| { + assert_eq!(remote, "upstream"); + Some("git@github.com:acme/widgets.git".to_string()) + }) + .expect("remote identity should resolve"); + assert_eq!( + resolved.identity.canonical_identity, + "github.com/acme/widgets" + ); + assert_eq!( + resolved.source, + RepositoryIdentitySource::RemoteUrl { + remote_name: "upstream".to_string(), + } + ); + } + + #[test] + fn missing_remote_errors_with_config_guidance() { + let error = resolve_repository_identity_with_lookup(None, "origin", |_| None) + .expect_err("missing remote should fail"); + assert_eq!( + error, + RepositoryIdentityResolutionError::MissingIdentity { + remote_name: "origin".to_string(), + } + ); + let rendered = error.to_string(); + assert!(rendered.contains(".sce/config.json")); + assert!(rendered.contains("agent_trace.repository_id")); + assert!(rendered.contains("agent_trace.repository_remote")); + } + + #[test] + fn unusable_remote_url_errors_without_leaking_the_url() { + let error = resolve_repository_identity_with_lookup(None, "origin", |_| { + Some("/local/path/to/s3cr3t-repo".to_string()) + }) + .expect_err("local path remote should fail"); + assert_eq!( + error, + RepositoryIdentityResolutionError::InvalidRemoteUrl { + remote_name: "origin".to_string(), + error: RepositoryIdentityError::UnsupportedRemoteUrl, + } + ); + let rendered = error.to_string(); + assert!(!rendered.contains("s3cr3t"), "error leaked URL: {rendered}"); + assert!(rendered.contains(".sce/config.json")); + } + + #[test] + fn resolves_origin_remote_from_temp_git_repo() { + let repo = unique_temp_dir("origin"); + init_git_repo(&repo); + add_remote(&repo, "origin", "https://github.com/acme/widgets.git"); + + let resolved = resolve_repository_identity(&repo, None, "origin") + .expect("origin remote should resolve"); + assert_eq!( + resolved.identity.canonical_identity, + "github.com/acme/widgets" + ); + assert_eq!( + resolved.source, + RepositoryIdentitySource::RemoteUrl { + remote_name: "origin".to_string(), + } + ); + + std::fs::remove_dir_all(&repo).expect("clean up temp repo"); + } + + #[test] + fn resolves_configured_non_origin_remote_from_temp_git_repo() { + let repo = unique_temp_dir("upstream"); + init_git_repo(&repo); + add_remote(&repo, "origin", "git@github.com:acme/widgets.git"); + add_remote(&repo, "upstream", "git@github.com:acme/gadgets.git"); + + let resolved = resolve_repository_identity(&repo, None, "upstream") + .expect("upstream remote should resolve"); + assert_eq!( + resolved.identity.canonical_identity, + "github.com/acme/gadgets" + ); + + std::fs::remove_dir_all(&repo).expect("clean up temp repo"); + } + + #[test] + fn repo_without_remotes_reports_missing_identity() { + let repo = unique_temp_dir("no-remote"); + init_git_repo(&repo); + + let error = resolve_repository_identity(&repo, None, "origin") + .expect_err("repo without remotes should fail"); + assert_eq!( + error, + RepositoryIdentityResolutionError::MissingIdentity { + remote_name: "origin".to_string(), + } + ); + + std::fs::remove_dir_all(&repo).expect("clean up temp repo"); + } + + #[test] + fn explicit_identity_wins_over_real_remote() { + let repo = unique_temp_dir("explicit-wins"); + init_git_repo(&repo); + add_remote(&repo, "origin", "git@github.com:acme/widgets.git"); + + let resolved = resolve_repository_identity(&repo, Some("acme-monorepo"), "origin") + .expect("explicit identity should resolve"); + assert_eq!(resolved.identity.canonical_identity, "acme-monorepo"); + assert_eq!(resolved.source, RepositoryIdentitySource::ExplicitConfig); + + std::fs::remove_dir_all(&repo).expect("clean up temp repo"); + } + + #[test] + fn non_repository_directory_reports_missing_identity() { + let dir = unique_temp_dir("not-a-repo"); + + let error = resolve_repository_identity(&dir, None, "origin") + .expect_err("non-repository directory should fail"); + assert_eq!( + error, + RepositoryIdentityResolutionError::MissingIdentity { + remote_name: "origin".to_string(), + } + ); + + std::fs::remove_dir_all(&dir).expect("clean up temp dir"); + } +} diff --git a/cli/src/services/trace/command.rs b/cli/src/services/trace/command.rs index 6685baa3..87c97f48 100644 --- a/cli/src/services/trace/command.rs +++ b/cli/src/services/trace/command.rs @@ -15,6 +15,27 @@ 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:#}")) + } + } +} + impl TraceCommand { pub fn execute(&self, context: &C) -> Result where @@ -28,14 +49,29 @@ impl TraceCommand { .map_err(|error| ClassifiedError::runtime(format!("{error:#}"))) } TraceSubcommandRequest::DbShell { 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()))?; - let target = ShellTarget { - alias: database.alias, - checkout_id: database.checkout_id, - path: database.path, + 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(); @@ -51,24 +87,9 @@ impl TraceCommand { .map_err(|error| ClassifiedError::runtime(format!("{error:#}"))) } TraceSubcommandRequest::Status { all: false, format } => { - let repo_root = if let Some(path) = context.repo_root() { - path.to_path_buf() - } else { - std::env::current_dir().map_err(|err| { - ClassifiedError::runtime(format!( - "failed to determine current directory: {err}" - )) - })? - }; + let repo_root = current_repo_root(context)?; - let report = resolve_current_status(&repo_root).map_err(|err| match err { - StatusErrorOrRuntime::Status(status_err) => { - ClassifiedError::validation(status_err.user_message()) - } - StatusErrorOrRuntime::Runtime(runtime_err) => { - ClassifiedError::runtime(format!("{runtime_err:#}")) - } - })?; + let report = resolve_current_status(&repo_root).map_err(classify_status_error)?; render_status::render(&report, *format) .map_err(|error| ClassifiedError::runtime(format!("{error:#}"))) diff --git a/cli/src/services/trace/discovery.rs b/cli/src/services/trace/discovery.rs index dbc77f21..637428c6 100644 --- a/cli/src/services/trace/discovery.rs +++ b/cli/src/services/trace/discovery.rs @@ -1,8 +1,7 @@ -//! Deterministic discovery of per-checkout Agent Trace databases. +//! Deterministic discovery of Agent Trace databases. //! -//! Scans `/sce/agent-trace-{checkout_id}.db`, sorts by file mtime -//! descending with ties broken by `checkout_id` ascending, assigns positional -//! aliases `agent_trace_{i}`, and probes each file for the required schema. +//! Discovery scans repository-scoped databases at +//! `/sce/repos//agent-trace.db`. use std::fs; use std::path::{Path, PathBuf}; @@ -10,7 +9,7 @@ use std::time::SystemTime; use anyhow::{Context, Result}; -use crate::services::agent_trace_db::AgentTraceDb; +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."; @@ -34,12 +33,30 @@ pub enum Readiness { Skipped { missing_table: String }, } -/// A discovered per-checkout Agent Trace database with its readiness verdict. +#[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)] -#[allow(dead_code)] pub struct DiscoveredAgentTraceDb { pub alias: String, - pub checkout_id: String, + pub kind: DiscoveredAgentTraceDbKind, pub path: PathBuf, pub mtime: SystemTime, pub readiness: Readiness, @@ -47,7 +64,6 @@ pub struct DiscoveredAgentTraceDb { /// User-actionable failures while resolving an Agent Trace DB identifier. #[derive(Clone, Debug, Eq, PartialEq)] -#[allow(dead_code)] pub enum ResolveAgentTraceDbError { UnknownIdentifier { identifier: String, @@ -58,7 +74,8 @@ pub enum ResolveAgentTraceDbError { SkippedDatabase { identifier: String, alias: String, - checkout_id: String, + scope: String, + database_id: String, missing_table: String, }, } @@ -75,10 +92,11 @@ impl ResolveAgentTraceDbError { Self::SkippedDatabase { identifier, alias, - checkout_id, + scope, + database_id, missing_table, } => format!( - "sce trace db shell: database '{identifier}' ({alias}, checkout {checkout_id}) is not schema-ready: missing table '{missing_table}'. Run `sce setup` or inspect `sce trace db list` before opening a shell." + "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." ), } } @@ -93,14 +111,13 @@ impl std::fmt::Display for ResolveAgentTraceDbError { impl std::error::Error for ResolveAgentTraceDbError {} /// Resolve an alias or checkout ID to one ready discovered Agent Trace DB. -#[allow(dead_code)] pub fn resolve_agent_trace_db_identifier( databases: &[DiscoveredAgentTraceDb], identifier: &str, ) -> Result { let matches: Vec<&DiscoveredAgentTraceDb> = databases .iter() - .filter(|db| db.alias == identifier || db.checkout_id == identifier) + .filter(|db| db.alias == identifier || db.kind.identifier() == identifier) .collect(); let db = match matches.as_slice() { @@ -122,74 +139,71 @@ pub fn resolve_agent_trace_db_identifier( Readiness::Skipped { missing_table } => Err(ResolveAgentTraceDbError::SkippedDatabase { identifier: identifier.to_string(), alias: db.alias.clone(), - checkout_id: db.checkout_id.clone(), + scope: db.kind.label().to_string(), + database_id: db.kind.identifier().to_string(), missing_table: missing_table.clone(), }), } } -/// Discover Agent Trace DBs under the resolved state-data root. -#[allow(dead_code)] +/// 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_agent_trace_dbs_in(&sce_dir) + discover_repository_agent_trace_dbs_in(&sce_dir) } -/// Discover Agent Trace DBs in an explicit `sce` directory. -/// -/// Returns an empty Vec when the directory does not exist. Otherwise scans for -/// `agent-trace-{checkout_id}.db` files, sorts by mtime descending (ties broken -/// by `checkout_id` ascending), assigns positional aliases, and probes each -/// file for the required schema. -pub fn discover_agent_trace_dbs_in(sce_dir: &Path) -> Result> { - if !sce_dir.is_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(sce_dir) - .with_context(|| format!("failed to read sce directory '{}'", sce_dir.display()))? + 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 '{}'", sce_dir.display()) + format!( + "failed to read directory entry in '{}'", + repos_dir.display() + ) })?; - - let file_name = entry.file_name(); - let file_name_str = file_name.to_string_lossy(); - - let Some(stripped) = file_name_str.strip_prefix("agent-trace-") else { - continue; - }; - let Some(checkout_id) = stripped.strip_suffix(".db") else { - continue; - }; - if checkout_id.is_empty() { + let repository_id = entry.file_name().to_string_lossy().into_owned(); + if repository_id.is_empty() || !entry.metadata()?.is_dir() { continue; } - - let metadata = entry - .metadata() - .with_context(|| format!("failed to read metadata for '{}'", entry.path().display()))?; - if !metadata.is_file() { + let path = entry.path().join("agent-trace.db"); + if !path.is_file() { continue; } - let mtime = metadata + let mtime = path + .metadata() + .with_context(|| format!("failed to read metadata for '{}'", path.display()))? .modified() - .with_context(|| format!("failed to read mtime for '{}'", entry.path().display()))?; - - entries.push((checkout_id.to_string(), entry.path(), mtime)); + .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, (checkout_id, path, mtime)) in entries.into_iter().enumerate() { + for (index, (id, path, mtime)) in entries.into_iter().enumerate() { let readiness = probe_readiness(&path)?; discovered.push(DiscoveredAgentTraceDb { alias: format!("agent_trace_{index}"), - checkout_id, + kind: kind_for_id(id), path, mtime, readiness, @@ -205,7 +219,7 @@ pub fn discover_agent_trace_dbs_in(sce_dir: &Path) -> Result Result { - let db = AgentTraceDb::open_for_hooks_without_migrations_at(path) + 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 { @@ -231,8 +245,9 @@ pub(super) fn probe_readiness(path: &Path) -> Result { mod tests { use super::*; - use std::fs::OpenOptions; - use std::time::{Duration, UNIX_EPOCH}; + use std::time::UNIX_EPOCH; + + use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; fn unique_temp_dir(label: &str) -> PathBuf { let nonce = SystemTime::now() @@ -247,111 +262,27 @@ mod tests { dir } - fn create_full_schema_db(path: &Path) { - let db = AgentTraceDb::open_at(path).expect("agent trace DB should open with migrations"); + 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); } - fn touch_mtime(path: &Path, mtime: SystemTime) { - let file = OpenOptions::new() - .write(true) - .open(path) - .expect("open db file for mtime update"); - file.set_modified(mtime).expect("set mtime"); - } - #[test] - fn full_schema_db_reports_ready() { - let dir = unique_temp_dir("ready"); - let db_path = dir.join("agent-trace-aaaa.db"); - create_full_schema_db(&db_path); + 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_agent_trace_dbs_in(&dir).expect("discovery should succeed"); + let discovered = discover_repository_agent_trace_dbs_in(&dir) + .expect("repository discovery should succeed"); assert_eq!(discovered.len(), 1); - assert_eq!(discovered[0].checkout_id, "aaaa"); + 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); } - - #[test] - fn missing_required_table_reports_skipped_with_first_missing() { - let dir = unique_temp_dir("skipped"); - let db_path = dir.join("agent-trace-bbbb.db"); - - let db = AgentTraceDb::open_for_hooks_without_migrations_at(&db_path) - .expect("agent trace DB should open without migrations"); - db.execute( - "CREATE TABLE IF NOT EXISTS diff_traces (id INTEGER PRIMARY KEY)", - (), - ) - .expect("create diff_traces"); - db.execute( - "CREATE TABLE IF NOT EXISTS post_commit_patch_intersections (id INTEGER PRIMARY KEY)", - (), - ) - .expect("create post_commit_patch_intersections"); - // Intentionally skip `agent_traces` to exercise the first-missing-table report. - db.execute( - "CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY)", - (), - ) - .expect("create messages"); - db.execute( - "CREATE TABLE IF NOT EXISTS parts (id INTEGER PRIMARY KEY)", - (), - ) - .expect("create parts"); - drop(db); - - let discovered = discover_agent_trace_dbs_in(&dir).expect("discovery should succeed"); - - assert_eq!(discovered.len(), 1); - assert_eq!( - discovered[0].readiness, - Readiness::Skipped { - missing_table: String::from("agent_traces"), - } - ); - } - - #[test] - fn aliases_assigned_in_mtime_desc_order_with_checkout_id_tiebreak() { - let dir = unique_temp_dir("ordering"); - - let old_path = dir.join("agent-trace-old.db"); - let mid_path = dir.join("agent-trace-mid.db"); - let new_path = dir.join("agent-trace-new.db"); - let tie_a_path = dir.join("agent-trace-tie-a.db"); - let tie_b_path = dir.join("agent-trace-tie-b.db"); - - create_full_schema_db(&old_path); - create_full_schema_db(&mid_path); - create_full_schema_db(&new_path); - create_full_schema_db(&tie_a_path); - create_full_schema_db(&tie_b_path); - - let base = SystemTime::now(); - touch_mtime(&old_path, base - Duration::from_secs(7)); - touch_mtime(&mid_path, base - Duration::from_secs(3)); - touch_mtime(&new_path, base); - let tie_time = base - Duration::from_secs(5); - touch_mtime(&tie_a_path, tie_time); - touch_mtime(&tie_b_path, tie_time); - - let discovered = discover_agent_trace_dbs_in(&dir).expect("discovery should succeed"); - - assert_eq!(discovered.len(), 5); - assert_eq!(discovered[0].alias, "agent_trace_0"); - assert_eq!(discovered[0].checkout_id, "new"); - assert_eq!(discovered[1].alias, "agent_trace_1"); - assert_eq!(discovered[1].checkout_id, "mid"); - assert_eq!(discovered[2].alias, "agent_trace_2"); - assert_eq!(discovered[2].checkout_id, "tie-a"); - assert_eq!(discovered[3].alias, "agent_trace_3"); - assert_eq!(discovered[3].checkout_id, "tie-b"); - assert_eq!(discovered[4].alias, "agent_trace_4"); - assert_eq!(discovered[4].checkout_id, "old"); - } } diff --git a/cli/src/services/trace/mod.rs b/cli/src/services/trace/mod.rs index 35177c6b..9f1b4dfe 100644 --- a/cli/src/services/trace/mod.rs +++ b/cli/src/services/trace/mod.rs @@ -23,7 +23,7 @@ use crate::services::output_format::OutputFormat; #[derive(Clone, Debug, PartialEq, Eq)] pub enum TraceSubcommandRequest { DbList { format: OutputFormat }, - DbShell { identifier: String }, + DbShell { identifier: Option }, Status { all: bool, format: OutputFormat }, } diff --git a/cli/src/services/trace/render_list.rs b/cli/src/services/trace/render_list.rs index 5b9e24bf..73405b19 100644 --- a/cli/src/services/trace/render_list.rs +++ b/cli/src/services/trace/render_list.rs @@ -15,6 +15,8 @@ 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"; @@ -34,11 +36,13 @@ fn render_text(databases: &[DiscoveredAgentTraceDb]) -> String { return lines.join("\n"); } - let rows: Vec<(String, String, String, String)> = databases + 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(), @@ -46,17 +50,22 @@ fn render_text(databases: &[DiscoveredAgentTraceDb]) -> String { }) .collect(); - let alias_width = column_width(COL_ALIAS, rows.iter().map(|(a, _, _, _)| a.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())); + 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 mut entry = json!({ "alias": db.alias, - "checkout_id": db.checkout_id, + "scope": db.kind.label(), + "identifier": db.kind.identifier(), "path": db.path.display().to_string(), "status": status, "updated_at": mtime_to_rfc3339(db.mtime), @@ -128,57 +138,10 @@ fn column_width<'a, I: Iterator>(header: &str, values: I) -> usi mod tests { use super::*; - use std::path::PathBuf; - use std::time::{Duration, UNIX_EPOCH}; - - use crate::services::agent_trace_db::AgentTraceDb; - use crate::services::trace::discovery::discover_agent_trace_dbs_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-list-{label}-{}-{nonce}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir - } - - fn create_full_schema_db(path: &std::path::Path) { - let db = AgentTraceDb::open_at(path).expect("agent trace DB should open with migrations"); - drop(db); - } - - fn create_partial_schema_db(path: &std::path::Path) { - let db = AgentTraceDb::open_for_hooks_without_migrations_at(path) - .expect("agent trace DB should open without migrations"); - db.execute( - "CREATE TABLE IF NOT EXISTS diff_traces (id INTEGER PRIMARY KEY)", - (), - ) - .expect("create diff_traces"); - // Intentionally missing post_commit_patch_intersections. - drop(db); - } - - fn touch_mtime(path: &std::path::Path, mtime: SystemTime) { - let file = std::fs::OpenOptions::new() - .write(true) - .open(path) - .expect("open db file for mtime update"); - file.set_modified(mtime).expect("set mtime"); - } - #[test] fn empty_discovery_renders_empty_message_text() { - let dir = unique_temp_dir("empty-text"); let rendered = render_text(&[]); assert!(rendered.contains(EMPTY_MESSAGE)); - // Avoid unused dir warning. - let _ = dir; } #[test] @@ -190,75 +153,4 @@ mod tests { assert_eq!(value["subcommand"], "db.list"); assert_eq!(value["databases"].as_array().unwrap().len(), 0); } - - #[test] - fn mixed_fixture_renders_text_table_with_ready_and_skipped_rows() { - let dir = unique_temp_dir("text-table"); - let ready_a = dir.join("agent-trace-aaaa.db"); - let ready_b = dir.join("agent-trace-bbbb.db"); - let skipped = dir.join("agent-trace-cccc.db"); - - create_full_schema_db(&ready_a); - create_full_schema_db(&ready_b); - create_partial_schema_db(&skipped); - - let base = SystemTime::now(); - touch_mtime(&ready_a, base); - touch_mtime(&ready_b, base - Duration::from_secs(5)); - touch_mtime(&skipped, base - Duration::from_secs(10)); - - let discovered = discover_agent_trace_dbs_in(&dir).expect("discovery"); - let rendered = render_text(&discovered); - - assert!(rendered.contains("Alias")); - assert!(rendered.contains("Status")); - assert!(rendered.contains("Path")); - assert!(rendered.contains("Updated at")); - assert!(rendered.contains("agent_trace_0")); - assert!(rendered.contains("agent_trace_1")); - assert!(rendered.contains("agent_trace_2")); - assert!(rendered.contains("ready")); - assert!(rendered.contains("skipped: missing table 'post_commit_patch_intersections'")); - assert!(rendered.contains(&ready_a.display().to_string())); - assert!(rendered.contains(&skipped.display().to_string())); - } - - #[test] - fn mixed_fixture_renders_json_shape() { - let dir = unique_temp_dir("json-shape"); - let ready = dir.join("agent-trace-aaaa.db"); - let skipped = dir.join("agent-trace-bbbb.db"); - - create_full_schema_db(&ready); - create_partial_schema_db(&skipped); - - let base = SystemTime::now(); - touch_mtime(&ready, base); - touch_mtime(&skipped, base - Duration::from_secs(5)); - - let discovered = discover_agent_trace_dbs_in(&dir).expect("discovery"); - let payload = render_json(&discovered).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"); - let databases = value["databases"].as_array().expect("databases array"); - assert_eq!(databases.len(), 2); - - assert_eq!(databases[0]["alias"], "agent_trace_0"); - assert_eq!(databases[0]["checkout_id"], "aaaa"); - assert_eq!(databases[0]["status"], "ready"); - assert!(databases[0].get("skip_reason").is_none()); - assert_eq!(databases[0]["path"], ready.display().to_string()); - assert!(databases[0]["updated_at"].is_string()); - - assert_eq!(databases[1]["alias"], "agent_trace_1"); - assert_eq!(databases[1]["checkout_id"], "bbbb"); - assert_eq!(databases[1]["status"], "skipped"); - assert_eq!( - databases[1]["skip_reason"], - "missing table: post_commit_patch_intersections" - ); - } } diff --git a/cli/src/services/trace/render_status.rs b/cli/src/services/trace/render_status.rs index a02847e5..d34c4441 100644 --- a/cli/src/services/trace/render_status.rs +++ b/cli/src/services/trace/render_status.rs @@ -19,8 +19,23 @@ pub fn render(report: &StatusReport, format: OutputFormat) -> Result { fn render_text(report: &StatusReport) -> String { let mut lines = vec![style::heading(HEADING)]; - lines.push(format!("Checkout: {}", report.checkout_id)); - lines.push(format!("Database: {}", report.database_path.display())); + 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 { @@ -54,7 +69,12 @@ fn render_json(report: &StatusReport) -> Result { "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(), }); @@ -107,8 +127,12 @@ mod tests { 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/agent-trace-abc.db"), + database_path: PathBuf::from("/tmp/sce/repos/repo123/agent-trace.db"), db_status: DbStatus::Ready { stats: AgentTraceDbStats { diff_traces: 7, @@ -125,8 +149,12 @@ mod tests { 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/agent-trace-def.db"), + database_path: PathBuf::from("/tmp/sce/repos/repo456/agent-trace.db"), db_status: DbStatus::Skipped { missing_table: String::from("agent_traces"), }, @@ -137,8 +165,14 @@ mod tests { fn ready_text_renders_all_counts_and_last_activity() { let rendered = render_text(&ready_report()); assert!(rendered.contains("SCE trace status")); - assert!(rendered.contains("Checkout: 01900000-0000-7000-8000-000000000abc")); - assert!(rendered.contains("Database: /tmp/agent-trace-abc.db")); + 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")); @@ -178,6 +212,10 @@ mod tests { 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); @@ -192,6 +230,7 @@ mod tests { 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 index 7e557777..d4b0317e 100644 --- a/cli/src/services/trace/render_status_all.rs +++ b/cli/src/services/trace/render_status_all.rs @@ -13,6 +13,8 @@ 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"; @@ -59,6 +61,8 @@ fn render_text(report: &StatusAllReport) -> String { let headers = [ COL_ALIAS, + COL_SCOPE, + COL_ID, COL_STATUS, COL_DIFFS, COL_MESSAGES, @@ -66,7 +70,7 @@ fn render_text(report: &StatusAllReport) -> String { COL_TRACES, COL_INTERSECTIONS, ]; - let rows: Vec<[String; 7]> = report.databases.iter().map(format_row).collect(); + let rows: Vec<[String; 9]> = report.databases.iter().map(format_row).collect(); let widths: Vec = (0..headers.len()) .map(|col| { @@ -87,7 +91,7 @@ fn render_text(report: &StatusAllReport) -> String { lines.join("\n") } -fn join_row(cells: &[String; 7], widths: &[usize]) -> String { +fn join_row(cells: &[String; N], widths: &[usize]) -> String { cells .iter() .enumerate() @@ -98,10 +102,15 @@ fn join_row(cells: &[String; 7], widths: &[usize]) -> String { .to_string() } -fn format_row(row: &DatabaseRow) -> [String; 7] { +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(), @@ -111,6 +120,8 @@ fn format_row(row: &DatabaseRow) -> [String; 7] { ], DatabaseRowStatus::Skipped { missing_table } => [ row.alias.clone(), + scope, + id, format!("skipped: missing '{missing_table}'"), SKIPPED_PLACEHOLDER.to_string(), SKIPPED_PLACEHOLDER.to_string(), @@ -128,7 +139,8 @@ fn render_json(report: &StatusAllReport) -> Result { .map(|row| match &row.status { DatabaseRowStatus::Ready { stats } => json!({ "alias": row.alias, - "checkout_id": row.checkout_id, + "scope": row.kind.label(), + "identifier": row.kind.identifier(), "path": row.path.display().to_string(), "status": "ready", "diff_traces": stats.diff_traces, @@ -142,7 +154,8 @@ fn render_json(report: &StatusAllReport) -> Result { }), DatabaseRowStatus::Skipped { missing_table } => json!({ "alias": row.alias, - "checkout_id": row.checkout_id, + "scope": row.kind.label(), + "identifier": row.kind.identifier(), "path": row.path.display().to_string(), "status": "skipped", "skip_reason": format!("missing table: {missing_table}"), @@ -182,11 +195,8 @@ mod tests { use super::*; use std::path::PathBuf; - use std::time::{Duration, SystemTime, UNIX_EPOCH}; + use std::time::{SystemTime, UNIX_EPOCH}; - use crate::services::agent_trace_db::{ - AgentTraceDb, DiffTraceInsert, InsertMessageInsert, MessageRole, - }; use crate::services::trace::status_all::aggregate_status_all_in; fn unique_temp_dir(label: &str) -> PathBuf { @@ -202,50 +212,6 @@ mod tests { dir } - fn touch_mtime(path: &std::path::Path, mtime: SystemTime) { - let file = std::fs::OpenOptions::new() - .write(true) - .open(path) - .expect("open db file for mtime update"); - file.set_modified(mtime).expect("set mtime"); - } - - fn seed_ready_db(path: &std::path::Path, diffs: u64, msgs: u64) { - let db = AgentTraceDb::open_at(path).expect("migrated DB should open"); - for i in 0..diffs { - db.insert_diff_trace(DiffTraceInsert { - time_ms: 1_000 + i64::try_from(i).expect("idx fits"), - session_id: "s1", - patch: "p", - model_id: Some("m1"), - tool_name: "claude", - tool_version: Some("1"), - payload_type: "patch", - }) - .expect("diff"); - } - for i in 0..msgs { - db.insert_message(InsertMessageInsert { - session_id: "s1".into(), - message_id: format!("m{i}"), - role: MessageRole::User, - generated_at_unix_ms: 1_000 + i64::try_from(i).expect("idx fits"), - }) - .expect("msg"); - } - } - - fn seed_partial_db(path: &std::path::Path) { - let db = AgentTraceDb::open_for_hooks_without_migrations_at(path) - .expect("open without migrations"); - db.execute( - "CREATE TABLE IF NOT EXISTS diff_traces (id INTEGER PRIMARY KEY)", - (), - ) - .expect("create diff_traces"); - drop(db); - } - #[test] fn empty_renders_text_with_zeroed_summary_and_totals() { let dir = unique_temp_dir("empty-text"); @@ -273,76 +239,4 @@ mod tests { assert!(value["totals"]["last_activity"].is_null()); assert_eq!(value["databases"].as_array().unwrap().len(), 0); } - - #[test] - fn mixed_fixture_renders_text_blocks_with_per_database_rows() { - let dir = unique_temp_dir("mixed-text"); - let ready_newest = dir.join("agent-trace-aaaa.db"); - let ready_older = dir.join("agent-trace-bbbb.db"); - let skipped = dir.join("agent-trace-cccc.db"); - - seed_ready_db(&ready_newest, 3, 2); - seed_ready_db(&ready_older, 1, 1); - seed_partial_db(&skipped); - - let base = SystemTime::now(); - touch_mtime(&ready_newest, base); - touch_mtime(&ready_older, base - Duration::from_secs(5)); - touch_mtime(&skipped, base - Duration::from_secs(10)); - - let report = aggregate_status_all_in(&dir).expect("aggregate"); - let rendered = render_text(&report); - - assert!(rendered.contains("Databases: 3 discovered, 2 ready, 1 skipped")); - assert!(rendered.contains("Diff traces: 4")); - assert!(rendered.contains("Messages: 3")); - assert!(rendered.contains(BY_DATABASE_HEADING)); - assert!(rendered.contains("agent_trace_0")); - assert!(rendered.contains("agent_trace_1")); - assert!(rendered.contains("agent_trace_2")); - assert!(rendered.contains("ready")); - assert!(rendered.contains("skipped: missing 'post_commit_patch_intersections'")); - } - - #[test] - fn mixed_fixture_renders_json_aggregate_and_breakdown() { - let dir = unique_temp_dir("mixed-json"); - let ready_newest = dir.join("agent-trace-aaaa.db"); - let ready_older = dir.join("agent-trace-bbbb.db"); - let skipped = dir.join("agent-trace-cccc.db"); - - seed_ready_db(&ready_newest, 2, 1); - seed_ready_db(&ready_older, 1, 0); - seed_partial_db(&skipped); - - let base = SystemTime::now(); - touch_mtime(&ready_newest, base); - touch_mtime(&ready_older, base - Duration::from_secs(5)); - touch_mtime(&skipped, base - Duration::from_secs(10)); - - 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["discovery"]["discovered"], 3); - assert_eq!(value["discovery"]["ready"], 2); - assert_eq!(value["discovery"]["skipped"], 1); - assert_eq!(value["totals"]["diff_traces"], 3); - assert_eq!(value["totals"]["messages"], 1); - - let databases = value["databases"].as_array().expect("databases array"); - assert_eq!(databases.len(), 3); - assert_eq!(databases[0]["alias"], "agent_trace_0"); - assert_eq!(databases[0]["status"], "ready"); - assert_eq!(databases[0]["diff_traces"], 2); - assert_eq!(databases[1]["alias"], "agent_trace_1"); - assert_eq!(databases[1]["status"], "ready"); - assert_eq!(databases[1]["diff_traces"], 1); - assert_eq!(databases[2]["alias"], "agent_trace_2"); - assert_eq!(databases[2]["status"], "skipped"); - assert_eq!( - databases[2]["skip_reason"], - "missing table: post_commit_patch_intersections" - ); - } } diff --git a/cli/src/services/trace/shell.rs b/cli/src/services/trace/shell.rs index 56e7be32..b27299de 100644 --- a/cli/src/services/trace/shell.rs +++ b/cli/src/services/trace/shell.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use turso::Value as TursoValue; -use crate::services::agent_trace_db::AgentTraceDb; +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"; @@ -17,7 +17,8 @@ const TABLES_SQL: &str = "SELECT name FROM sqlite_schema WHERE type = 'table' OR #[derive(Clone, Debug, Eq, PartialEq)] pub struct ShellTarget { pub alias: String, - pub checkout_id: String, + pub scope: String, + pub identifier: String, pub path: PathBuf, } @@ -32,7 +33,7 @@ pub fn run_agent_trace_db_shell( input: impl BufRead, mut output: impl Write, ) -> Result { - let db = AgentTraceDb::open_for_hooks_without_migrations_at(&target.path) + 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!( @@ -45,14 +46,15 @@ pub fn run_agent_trace_db_shell( } pub fn run_agent_trace_db_shell_with_db( - db: &AgentTraceDb, + 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, "checkout_id: {}", target.checkout_id)?; + 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.")?; @@ -100,7 +102,11 @@ fn split_sql_line(line: &str) -> impl Iterator { line.split(';').map(str::trim) } -fn render_sql_result(db: &AgentTraceDb, sql: &str, output: &mut impl Write) -> Result<()> { +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 }) => { @@ -110,7 +116,7 @@ fn render_sql_result(db: &AgentTraceDb, sql: &str, output: &mut impl Write) -> R } } -fn render_tables(db: &AgentTraceDb, output: &mut impl Write) -> Result<()> { +fn render_tables(db: &RepositoryAgentTraceDb, output: &mut impl Write) -> Result<()> { match db.query_values(TABLES_SQL, ()) { Ok(rows) => { for row in rows.rows { @@ -132,7 +138,7 @@ enum ShellSqlResult { Statement { rows_affected: u64 }, } -fn execute_sql(db: &AgentTraceDb, sql: &str) -> Result { +fn execute_sql(db: &RepositoryAgentTraceDb, sql: &str) -> Result { if is_query_sql(sql) { db.query_values(sql, ()) .map(ShellSqlResult::Query) @@ -223,14 +229,15 @@ mod tests { fn shell_target(path: &Path) -> ShellTarget { ShellTarget { alias: String::from("agent_trace_0"), - checkout_id: String::from("018f2d7d-0000-7000-8000-000000000000"), + 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 = AgentTraceDb::open_at(&path).expect("test DB should open"); + 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"); @@ -313,7 +320,7 @@ mod tests { #[test] fn shell_opens_path_and_checks_schema_readiness() { let path = unique_temp_db("open-path"); - let db = AgentTraceDb::open_at(&path).expect("test DB should open"); + let db = RepositoryAgentTraceDb::new_at(&path).expect("test DB should open"); drop(db); let mut output = Vec::new(); diff --git a/cli/src/services/trace/stats.rs b/cli/src/services/trace/stats.rs index 40610749..5b42c37a 100644 --- a/cli/src/services/trace/stats.rs +++ b/cli/src/services/trace/stats.rs @@ -1,4 +1,4 @@ -//! Per-checkout Agent Trace DB row-count and last-activity stats. +//! 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 @@ -10,9 +10,9 @@ use std::path::Path; use anyhow::{Context, Result}; use chrono::{DateTime, TimeZone, Utc}; -use crate::services::agent_trace_db::AgentTraceDb; +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; -/// Aggregated per-checkout Agent Trace DB row counts and last activity. +/// Aggregated Agent Trace DB row counts and last activity. #[derive(Clone, Debug, Default, Eq, PartialEq)] #[allow(dead_code)] pub struct AgentTraceDbStats { @@ -32,7 +32,7 @@ pub struct AgentTraceDbStats { /// already verified schema readiness via `discover_agent_trace_dbs`. #[allow(dead_code)] pub fn collect_agent_trace_db_stats(path: &Path) -> Result { - let db = AgentTraceDb::open_for_hooks_without_migrations_at(path) + 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)?; @@ -74,7 +74,7 @@ pub fn collect_agent_trace_db_stats(path: &Path) -> Result { }) } -fn count_rows(db: &AgentTraceDb, table: &str, path: &Path) -> Result { +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| { @@ -90,7 +90,7 @@ fn count_rows(db: &AgentTraceDb, table: &str, path: &Path) -> Result { Ok(u64::try_from(count).unwrap_or(0)) } -fn query_optional_i64(db: &AgentTraceDb, sql: &str, path: &Path) -> Result> { +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(|| { @@ -102,7 +102,11 @@ fn query_optional_i64(db: &AgentTraceDb, sql: &str, path: &Path) -> Result Result> { +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) @@ -134,9 +138,10 @@ mod tests { use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; + use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::agent_trace_db::{ - AgentTraceDb, AgentTraceInsert, DiffTraceInsert, InsertMessageInsert, InsertPartInsert, - MessageRole, PartType, PostCommitPatchIntersectionInsert, + AgentTraceInsert, DiffTraceInsert, InsertMessageInsert, InsertPartInsert, MessageRole, + PartType, PostCommitPatchIntersectionInsert, }; fn unique_temp_dir(label: &str) -> PathBuf { @@ -153,7 +158,7 @@ mod tests { } fn seed_db(path: &Path) -> i64 { - let db = AgentTraceDb::open_at(path).expect("migrated DB should open"); + let db = RepositoryAgentTraceDb::new_at(path).expect("repository DB should open"); // 2 diff traces db.insert_diff_trace(DiffTraceInsert { @@ -260,7 +265,7 @@ mod tests { 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(AgentTraceDb::open_at(&db_path).expect("migrated DB should open")); + 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"); diff --git a/cli/src/services/trace/status.rs b/cli/src/services/trace/status.rs index 30db88b8..e8c7e6e4 100644 --- a/cli/src/services/trace/status.rs +++ b/cli/src/services/trace/status.rs @@ -1,54 +1,22 @@ -//! Per-checkout `sce trace status` resolution. +//! Repository-scoped `sce trace status` resolution. //! -//! Resolves the cwd's checkout via `services::checkout`, locates its -//! `agent-trace-{id}.db`, probes schema readiness, and (when ready) collects -//! row counts plus the last-activity timestamp. +//! 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}; -use crate::services::checkout::{read_checkout_id, resolve_git_dir}; -use crate::services::default_paths::resolve_state_data_root; +#[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}; -/// Errors that map directly to user-facing `sce trace status` guidance. -#[derive(Debug)] -pub enum StatusError { - NotInGitRepo { repo_root: PathBuf, detail: String }, - NoCheckoutId { git_dir: PathBuf }, - DbMissing { checkout_id: String, path: PathBuf }, -} - -impl StatusError { - pub fn user_message(&self) -> String { - match self { - Self::NotInGitRepo { repo_root, detail } => format!( - "sce trace status: '{}' is not inside a git repository ({detail}); cd into a git repository and retry", - repo_root.display() - ), - Self::NoCheckoutId { git_dir } => format!( - "sce trace status: no checkout id found at '{}'; run `sce setup` to initialize this repository", - git_dir.join("sce").join("checkout-id").display() - ), - Self::DbMissing { checkout_id, path } => format!( - "sce trace status: no agent-trace database for checkout {checkout_id} at '{}'; no traces have been recorded yet (the SCE Claude Code hook records traces on commits)", - path.display() - ), - } - } -} - -impl std::fmt::Display for StatusError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.user_message()) - } -} - -impl std::error::Error for StatusError {} - /// Verdict for a resolved checkout's Agent Trace DB. #[derive(Clone, Debug, Eq, PartialEq)] pub enum DbStatus { @@ -61,9 +29,13 @@ pub enum DbStatus { }, } -/// Resolved per-checkout status report ready for rendering. +/// 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, @@ -72,38 +44,62 @@ pub struct StatusReport { /// 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 state_root = resolve_state_data_root().map_err(StatusErrorOrRuntime::Runtime)?; - let sce_dir = state_root.join("sce"); - resolve_current_status_in(repo_root, &sce_dir) + 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 variant taking the `sce` directory explicitly. -pub fn resolve_current_status_in( +/// 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, - sce_dir: &Path, + state_root: &Path, + explicit_repository_id: Option<&str>, + repository_remote: &str, ) -> Result { - let git_dir = resolve_git_dir(repo_root).map_err(|err| { - StatusErrorOrRuntime::Status(StatusError::NotInGitRepo { - repo_root: repo_root.to_path_buf(), - detail: format!("{err:#}"), - }) - })?; - - let Some(checkout_id) = read_checkout_id(&git_dir).map_err(StatusErrorOrRuntime::Runtime)? - else { - return Err(StatusErrorOrRuntime::Status(StatusError::NoCheckoutId { - git_dir, - })); + 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, + ) +} - let database_path = sce_dir.join(format!("agent-trace-{checkout_id}.db")); - if !database_path.exists() { - return Err(StatusErrorOrRuntime::Status(StatusError::DbMissing { - checkout_id, - path: database_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 => { @@ -119,23 +115,39 @@ pub fn resolve_current_status_in( }; Ok(StatusReport { + repository_id, + repository_identity_source, + canonical_identity, + configured_remote, checkout_id, database_path, db_status, }) } -/// Distinguishes user-actionable status errors from internal runtime failures. +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 { - Status(StatusError), Runtime(anyhow::Error), } impl std::fmt::Display for StatusErrorOrRuntime { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Status(err) => write!(f, "{err}"), Self::Runtime(err) => write!(f, "{err:#}"), } } @@ -150,8 +162,6 @@ mod tests { use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; - use crate::services::agent_trace_db::AgentTraceDb; - fn unique_temp_dir(label: &str) -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -174,124 +184,43 @@ mod tests { assert!(output.status.success(), "git init failed"); } - fn write_checkout_id(repo_root: &Path, id: &str) -> PathBuf { - let git_dir = repo_root.join(".git"); - let sce = git_dir.join("sce"); - std::fs::create_dir_all(&sce).expect("create .git/sce"); - let id_path = sce.join("checkout-id"); - std::fs::write(&id_path, id).expect("write checkout-id"); - id_path - } - - fn create_full_schema_db(path: &Path) { - let db = AgentTraceDb::open_at(path).expect("migrated DB should open"); - drop(db); - } - - #[test] - fn missing_git_repo_reports_not_in_git_repo() { - let repo = unique_temp_dir("not-git"); - let sce_dir = unique_temp_dir("not-git-sce"); - - let err = resolve_current_status_in(&repo, &sce_dir).expect_err("should error"); - match err { - StatusErrorOrRuntime::Status(StatusError::NotInGitRepo { .. }) => {} - other => panic!("expected NotInGitRepo, got {other:?}"), - } - } - - #[test] - fn missing_checkout_id_reports_no_checkout_id() { - let repo = unique_temp_dir("no-id"); - init_git_repo(&repo); - let sce_dir = unique_temp_dir("no-id-sce"); - - let err = resolve_current_status_in(&repo, &sce_dir).expect_err("should error"); - match err { - StatusErrorOrRuntime::Status(StatusError::NoCheckoutId { .. }) => {} - other => panic!("expected NoCheckoutId, got {other:?}"), - } - } - - #[test] - fn missing_db_file_reports_db_missing() { - let repo = unique_temp_dir("no-db"); - init_git_repo(&repo); - let id = "01900000-0000-7000-8000-000000000001"; - write_checkout_id(&repo, id); - let sce_dir = unique_temp_dir("no-db-sce"); - std::fs::create_dir_all(&sce_dir).expect("create sce dir"); - - let err = resolve_current_status_in(&repo, &sce_dir).expect_err("should error"); - match err { - StatusErrorOrRuntime::Status(StatusError::DbMissing { checkout_id, .. }) => { - assert_eq!(checkout_id, id); - } - other => panic!("expected DbMissing, got {other:?}"), - } - } - - #[test] - fn ready_db_returns_stats_report() { - let repo = unique_temp_dir("ready"); - init_git_repo(&repo); - let id = "01900000-0000-7000-8000-000000000002"; - write_checkout_id(&repo, id); - let sce_dir = unique_temp_dir("ready-sce"); - std::fs::create_dir_all(&sce_dir).expect("create sce dir"); - let db_path = sce_dir.join(format!("agent-trace-{id}.db")); - create_full_schema_db(&db_path); - - let report = - resolve_current_status_in(&repo, &sce_dir).expect("ready report should resolve"); - - assert_eq!(report.checkout_id, id); - assert_eq!(report.database_path, db_path); - match report.db_status { - DbStatus::Ready { - stats, - last_activity, - } => { - 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!(last_activity.is_none()); - } - DbStatus::Skipped { missing_table } => { - panic!("expected Ready, got Skipped (missing_table={missing_table})") - } - } + 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 partial_schema_db_returns_skipped_status() { - let repo = unique_temp_dir("skipped"); + fn repository_status_uses_safe_identity_metadata_for_credential_remote() { + let repo = unique_temp_dir("repo-safe-identity"); init_git_repo(&repo); - let id = "01900000-0000-7000-8000-000000000003"; - write_checkout_id(&repo, id); - let sce_dir = unique_temp_dir("skipped-sce"); - std::fs::create_dir_all(&sce_dir).expect("create sce dir"); - let db_path = sce_dir.join(format!("agent-trace-{id}.db")); - - let db = AgentTraceDb::open_for_hooks_without_migrations_at(&db_path) - .expect("open without migrations"); - db.execute( - "CREATE TABLE IF NOT EXISTS diff_traces (id INTEGER PRIMARY KEY)", - (), - ) - .expect("create diff_traces"); - drop(db); - - let report = - resolve_current_status_in(&repo, &sce_dir).expect("skipped report should resolve"); - - match report.db_status { - DbStatus::Skipped { missing_table } => { - assert_eq!(missing_table, "post_commit_patch_intersections"); - } - DbStatus::Ready { .. } => panic!("expected Skipped, got Ready"), - } + 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 index 70cc9fb2..8003ac9e 100644 --- a/cli/src/services/trace/status_all.rs +++ b/cli/src/services/trace/status_all.rs @@ -10,7 +10,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use crate::services::default_paths::resolve_state_data_root; -use crate::services::trace::discovery::{discover_agent_trace_dbs_in, Readiness}; +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)] @@ -32,7 +34,7 @@ pub enum DatabaseRowStatus { #[derive(Clone, Debug, Eq, PartialEq)] pub struct DatabaseRow { pub alias: String, - pub checkout_id: String, + pub kind: DiscoveredAgentTraceDbKind, pub path: PathBuf, pub status: DatabaseRowStatus, } @@ -53,7 +55,7 @@ pub fn aggregate_current_status_all() -> Result { /// Aggregate `sce trace status --all` against an explicit `sce` directory. pub fn aggregate_status_all_in(sce_dir: &Path) -> Result { - let discovered = discover_agent_trace_dbs_in(sce_dir)?; + let discovered = discover_repository_agent_trace_dbs_in(sce_dir)?; let mut discovery = DiscoverySummary { discovered: discovered.len(), @@ -79,7 +81,7 @@ pub fn aggregate_status_all_in(sce_dir: &Path) -> Result { } databases.push(DatabaseRow { alias: db.alias, - checkout_id: db.checkout_id, + kind: db.kind, path: db.path, status: DatabaseRowStatus::Ready { stats }, }); @@ -88,7 +90,7 @@ pub fn aggregate_status_all_in(sce_dir: &Path) -> Result { discovery.skipped += 1; databases.push(DatabaseRow { alias: db.alias, - checkout_id: db.checkout_id, + kind: db.kind, path: db.path, status: DatabaseRowStatus::Skipped { missing_table }, }); @@ -107,13 +109,8 @@ pub fn aggregate_status_all_in(sce_dir: &Path) -> Result { mod tests { use super::*; - use std::fs::OpenOptions; use std::path::PathBuf; - use std::time::{Duration, SystemTime, UNIX_EPOCH}; - - use crate::services::agent_trace_db::{ - AgentTraceDb, DiffTraceInsert, InsertMessageInsert, InsertPartInsert, MessageRole, PartType, - }; + use std::time::{SystemTime, UNIX_EPOCH}; fn unique_temp_dir(label: &str) -> PathBuf { let nonce = SystemTime::now() @@ -128,64 +125,6 @@ mod tests { dir } - fn touch_mtime(path: &Path, mtime: SystemTime) { - let file = OpenOptions::new() - .write(true) - .open(path) - .expect("open db file for mtime update"); - file.set_modified(mtime).expect("set mtime"); - } - - fn seed_ready_db(path: &Path, diff_count: u64, message_count: u64, parts_per_message: u64) { - let db = AgentTraceDb::open_at(path).expect("migrated DB should open"); - for i in 0..diff_count { - db.insert_diff_trace(DiffTraceInsert { - time_ms: 1_000 + i64::try_from(i).expect("diff index fits in i64"), - session_id: "s1", - patch: "diff", - model_id: Some("m1"), - tool_name: "claude", - tool_version: Some("1"), - payload_type: "patch", - }) - .expect("diff trace"); - } - for i in 0..message_count { - let mid = format!("m{i}"); - db.insert_message(InsertMessageInsert { - session_id: "s1".into(), - message_id: mid.clone(), - role: MessageRole::User, - generated_at_unix_ms: 1_000 + i64::try_from(i).expect("msg index fits in i64"), - }) - .expect("message"); - let parts = (0..parts_per_message) - .map(|j| InsertPartInsert { - part_type: PartType::Text, - text: format!("p{i}{j}"), - session_id: "s1".into(), - message_id: mid.clone(), - generated_at_unix_ms: 1_000 - + i64::try_from(i * parts_per_message + j).expect("part index fits in i64"), - }) - .collect(); - db.insert_parts(parts).expect("parts"); - } - } - - fn seed_partial_db(path: &Path) { - let db = AgentTraceDb::open_for_hooks_without_migrations_at(path) - .expect("open without migrations"); - db.execute( - "CREATE TABLE IF NOT EXISTS diff_traces (id INTEGER PRIMARY KEY)", - (), - ) - .expect("create diff_traces"); - // Intentionally missing post_commit_patch_intersections so readiness - // probe reports skipped with the first missing table. - drop(db); - } - #[test] fn empty_sce_dir_reports_zero_discovery_and_totals() { let dir = unique_temp_dir("empty"); @@ -196,57 +135,4 @@ mod tests { assert_eq!(report.totals, Totals::default()); assert!(report.databases.is_empty()); } - - #[test] - fn mixed_fixture_aggregates_ready_and_lists_skipped() { - let dir = unique_temp_dir("mixed"); - - let ready_newest = dir.join("agent-trace-aaaa.db"); - let ready_older = dir.join("agent-trace-bbbb.db"); - let skipped = dir.join("agent-trace-cccc.db"); - - seed_ready_db(&ready_newest, 3, 2, 2); // diffs=3, msgs=2, parts=4 - seed_ready_db(&ready_older, 1, 1, 1); // diffs=1, msgs=1, parts=1 - seed_partial_db(&skipped); - - let base = SystemTime::now(); - touch_mtime(&ready_newest, base); - touch_mtime(&ready_older, base - Duration::from_secs(10)); - touch_mtime(&skipped, base - Duration::from_secs(20)); - - let report = aggregate_status_all_in(&dir).expect("aggregation should succeed"); - - assert_eq!(report.discovery.discovered, 3); - assert_eq!(report.discovery.ready, 2); - assert_eq!(report.discovery.skipped, 1); - - assert_eq!(report.totals.diff_traces, 4); - assert_eq!(report.totals.messages, 3); - assert_eq!(report.totals.parts, 5); - assert_eq!(report.totals.agent_traces, 0); - assert_eq!(report.totals.post_commit_patch_intersections, 0); - - assert_eq!(report.databases.len(), 3); - // Discovery is mtime-desc, so newest ready first, then older ready, then skipped. - assert_eq!(report.databases[0].alias, "agent_trace_0"); - assert_eq!(report.databases[0].checkout_id, "aaaa"); - match &report.databases[0].status { - DatabaseRowStatus::Ready { stats } => assert_eq!(stats.diff_traces, 3), - DatabaseRowStatus::Skipped { .. } => panic!("expected ready row"), - } - assert_eq!(report.databases[1].alias, "agent_trace_1"); - assert_eq!(report.databases[1].checkout_id, "bbbb"); - match &report.databases[1].status { - DatabaseRowStatus::Ready { stats } => assert_eq!(stats.diff_traces, 1), - DatabaseRowStatus::Skipped { .. } => panic!("expected ready row"), - } - assert_eq!(report.databases[2].alias, "agent_trace_2"); - assert_eq!(report.databases[2].checkout_id, "cccc"); - match &report.databases[2].status { - DatabaseRowStatus::Skipped { missing_table } => { - assert_eq!(missing_table, "post_commit_patch_intersections"); - } - DatabaseRowStatus::Ready { .. } => panic!("expected skipped row"), - } - } } diff --git a/config/pkl/base/sce-config-schema.pkl b/config/pkl/base/sce-config-schema.pkl index e8ec8440..74072004 100644 --- a/config/pkl/base/sce-config-schema.pkl +++ b/config/pkl/base/sce-config-schema.pkl @@ -85,6 +85,24 @@ local sceConfigSchema = new JsonSchema { ["workos_client_id"] = new JsonSchema { type = "string" } + ["agent_trace"] = new JsonSchema { + type = "object" + description = "Agent Trace repository identity configuration. Selects the repository-scoped Agent Trace database." + additionalProperties = false + properties { + ["repository_id"] = new JsonSchema { + type = "string" + description = "Explicit repository identity. When set, overrides Git remote based repository identity resolution." + minLength = 1 + } + ["repository_remote"] = new JsonSchema { + type = "string" + description = "Git remote name used to derive repository identity. Defaults to origin when omitted." + minLength = 1 + default = "origin" + } + } + } ["policies"] = new JsonSchema { type = "object" additionalProperties = false diff --git a/config/schema/sce-config.schema.json b/config/schema/sce-config.schema.json index dfd6eff1..0604afd9 100644 --- a/config/schema/sce-config.schema.json +++ b/config/schema/sce-config.schema.json @@ -43,6 +43,24 @@ "workos_client_id": { "type": "string" }, + "agent_trace": { + "description": "Agent Trace repository identity configuration. Selects the repository-scoped Agent Trace database.", + "type": "object", + "properties": { + "repository_id": { + "description": "Explicit repository identity. When set, overrides Git remote based repository identity resolution.", + "type": "string", + "minLength": 1 + }, + "repository_remote": { + "description": "Git remote name used to derive repository identity. Defaults to origin when omitted.", + "default": "origin", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, "policies": { "type": "object", "properties": { diff --git a/context/architecture.md b/context/architecture.md index 6d7d2089..99d71739 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -109,33 +109,33 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/observability.rs` provides deterministic runtime observability controls and rendering for app lifecycle logs, including shared config-resolved threshold/format and file-sink inputs with precedence `env > config file > defaults` for non-flag observability keys, optional file sink controls (`SCE_LOG_FILE`, `SCE_LOG_FILE_MODE` with deterministic truncate-or-append policy), stable event identifiers, severity filtering, the forced-emission warning path used for invalid discovered config startup diagnostics, stderr-only primary emission with optional mirrored file writes, and redaction-safe emission through the shared security helper. Its `observability::traits` submodule exposes the current `Logger` and object-safe `Telemetry` trait boundaries plus `NoopLogger`; the concrete observability logger and telemetry runtime still own behavior and implement those traits without changing runtime behavior. `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/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 files (global config, auth tokens, auth DB, local DB, legacy/global agent trace DB fallback, and per-checkout agent trace DB files), and owns canonical repo-relative, embedded-asset, install, hook, and context-path accessors so non-test production path definitions have one shared owner. Current production consumers such as config discovery, doctor reporting, setup/install flows, database adapters, checkout identity, and local hook runtime path resolution consume this shared catalog rather than defining owned path literals in their own modules. +- `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 files (global config, auth tokens, auth DB, local DB, 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. Current production consumers such as config discovery, 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. - `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`, `LogFileMode`, the observability env-key constants, 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. - `cli/src/services/capabilities.rs` defines the current broad CLI capability traits consumed by the borrowed, compile-time-typed `AppContext`: `FsOps` with `StdFsOps` for filesystem operations and `GitOps` with `ProcessGitOps` for git command execution plus repository-root/hooks-directory resolution. Existing service internals do not consume these traits directly yet; command execution uses narrow accessors and repo-root-scoped context derivation. -- `cli/src/services/lifecycle.rs` defines the current compile-safe lifecycle seam. `ServiceLifecycle` has default no-op generic `diagnose`, `fix`, and `setup` methods over `C: HasRepoRoot`, with lifecycle-owned health, fix, and setup result types so the trait contract is not publicly anchored to doctor/setup module types or the full `AppContext` shape. The same module owns the static `LifecycleProvider` enum and shared `lifecycle_providers(include_hooks)` catalog/factory, returning providers in deterministic order (config → local_db → auth_db → agent_trace_db → hooks when requested); enum dispatch calls each concrete provider through generic context methods without boxed lifecycle-provider allocation or repo-root trait-object context erasure. Hooks exposes a `HooksLifecycle` provider in `cli/src/services/hooks/lifecycle.rs` for hook rollout diagnosis/fix/setup using lifecycle-owned health records plus the canonical required-hook installer. Config exposes a `ConfigLifecycle` provider in `cli/src/services/config/lifecycle.rs` for global/repo-local config validation and repo-local `.sce/config.json` bootstrap. local_db exposes a `LocalDbLifecycle` provider in `cli/src/services/local_db/lifecycle.rs` for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup. auth_db exposes an `AuthDbLifecycle` provider in `cli/src/services/auth_db/lifecycle.rs` for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup. agent_trace_db exposes an `AgentTraceDbLifecycle` provider in `cli/src/services/agent_trace_db/lifecycle.rs` for setup-time checkout identity creation when a repo root is available, per-checkout Agent Trace DB path health/fix when an ID exists, and legacy global DB fallback outside checkout context. Doctor runtime aggregates the full provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor report/fix records at the orchestration boundary; setup command aggregates the shared catalog for `setup` with hooks included only when requested and adapts hook setup outcomes before rendering setup-owned messages. -- Agent Trace lifecycle setup uses checkout identity to resolve the per-checkout DB path and initializes it with `AgentTraceDb::open_at(path)`; hook runtime keeps lazy per-checkout DB initialization/upgrade only as the fallback for setup-not-run or incomplete-schema cases. +- `cli/src/services/lifecycle.rs` defines the current compile-safe lifecycle seam. `ServiceLifecycle` has default no-op generic `diagnose`, `fix`, and `setup` methods over `C: HasRepoRoot`, with lifecycle-owned health, fix, and setup result types so the trait contract is not publicly anchored to doctor/setup module types or the full `AppContext` shape. The same module owns the static `LifecycleProvider` enum and shared `lifecycle_providers(include_hooks)` catalog/factory, returning providers in deterministic order (config → local_db → auth_db → agent_trace_db → hooks when requested); enum dispatch calls each concrete provider through generic context methods without boxed lifecycle-provider allocation or repo-root trait-object context erasure. Hooks exposes a `HooksLifecycle` provider in `cli/src/services/hooks/lifecycle.rs` for hook rollout diagnosis/fix/setup using lifecycle-owned health records plus the canonical required-hook installer. Config exposes a `ConfigLifecycle` provider in `cli/src/services/config/lifecycle.rs` for global/repo-local config validation and repo-local `.sce/config.json` bootstrap. local_db exposes a `LocalDbLifecycle` provider in `cli/src/services/local_db/lifecycle.rs` for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup. auth_db exposes an `AuthDbLifecycle` provider in `cli/src/services/auth_db/lifecycle.rs` for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup. agent_trace_db exposes an `AgentTraceDbLifecycle` provider in `cli/src/services/agent_trace_db/lifecycle.rs` for setup-time repository-scoped Agent Trace storage initialization when a repo root is available and repository Agent Trace DB path health/fix from resolved repository identity, returning an actionable "requires a Git repository" diagnostic outside repository context (no global/checkout fallback path; the former fallback was removed by the `retire-legacy-agent-trace-db` plan). Doctor runtime aggregates the full provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor report/fix records at the orchestration boundary; setup command aggregates the shared catalog for `setup` with hooks included only when requested and adapts hook setup outcomes before rendering setup-owned messages. +- Agent Trace lifecycle setup resolves `agent_trace.repository_id` / `agent_trace.repository_remote`, creates/reuses checkout identity for diagnostics, and initializes the repository-scoped DB through `agent_trace_storage::resolve_agent_trace_storage(...)`; hook runtime uses the same storage resolver and `RepositoryAgentTraceDb` lazy fast-path-then-migrate open with bounded retry plus narrow migration-metadata repair for concurrent first-open races. - `cli/src/services/auth_command/mod.rs` defines the implemented auth command surface for `sce auth login|renew|logout|status`, including device-flow login, stored-token renewal (`--force` supported for renew), logout, and status rendering in text/JSON formats; `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. -- `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. 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/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` provides the Agent Trace DB spec and `AgentTraceDb` type alias over `TursoDb`. `AgentTraceDbSpec` resolves the legacy `/sce/agent-trace.db` fallback through the shared default-path seam and embeds ordered fresh-start migrations `001..015` (the former `015_create_session_models` was removed from fresh schema; current `015_add_diff_traces_payload_type` replaces it) for `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, `diff_traces.payload_type`, indexes, and triggers. `agent_traces.agent_trace_id` is `NOT NULL UNIQUE`. The module provides explicit-path `open_at(path)` and `open_for_hooks_without_migrations_at(path)` helpers for per-checkout DB files plus `DiffTraceInsert<'_>`/`insert_diff_trace()` including the `payload_type` discriminator, `PostCommitPatchIntersectionInsert<'_>`/`insert_post_commit_patch_intersection()`, `AgentTraceInsert<'_>`/`insert_agent_trace()`, `InsertMessageInsert`/`insert_message()` plus `insert_messages()` for parent message inserts with duplicate `(session_id, message_id)` writes ignored, `InsertPartInsert`/`insert_part()` plus `insert_parts()` for append-only part text writes, and `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` for inclusive chronological `diff_traces` reads that dispatch on `payload_type`, parse valid raw patch or structured payload rows, and return skipped malformed-row reports. `cli/src/services/agent_trace_db/lifecycle.rs` registers Agent Trace DB setup/doctor lifecycle behavior: setup creates/reuses and registers checkout identity, resolves `/sce/agent-trace-{checkout_id}.db`, initializes it with `AgentTraceDb::open_at(path)`, and records `database_path`; active hook runtime still resolves checkout identity and lazily creates or upgrades the per-checkout DB when setup has not run or schema metadata is incomplete. +- `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/test_support.rs` provides a shared test-only temp-directory helper (`TestTempDir`) used by service tests that need filesystem fixtures. - `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), compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups) generated by `cli/build.rs` from the ephemeral crate-local `cli/assets/generated/config/{opencode,claude,pi}/**` mirror plus `cli/assets/hooks/**`, and focused internal support seams for install-flow vs 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 stages embedded files and uses a unified remove-and-replace policy (removing existing targets before swapping staged content, with deterministic recovery guidance on swap failure and no backup artifact creation), and formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`) follows the same remove-and-replace policy (removing existing hooks before swapping staged content, with deterministic recovery guidance on swap failure). The setup command derives 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 adds checkout identity plus per-checkout Agent Trace DB status when a checkout ID exists, while service-owned lifecycle providers own config validation, local DB and Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. +- `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. - `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 `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 AgentTraceDb 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 AgentTraceDb 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 AgentTraceDb 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. +- `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. -- No user-invocable `sce sync` command is wired in the current runtime; local DB bootstrap and setup-time per-checkout Agent Trace DB initialization flow through lifecycle providers aggregated by setup, while checkout/global DB health/repair flow through the doctor surface and checkout DB discovery flows through the `trace` group (`sce trace db list`). +- No user-invocable `sce sync` command is wired in the current runtime; local DB bootstrap and setup-time repository-scoped Agent Trace DB initialization 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/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 `AgentTraceDb::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 and per-checkout Agent Trace DB lazy resolution for hook runtime; setup uses `AgentTraceDbLifecycle::setup()` to establish checkout identity and initialize the per-checkout DB, while `sce doctor` surfaces checkout identity and per-checkout Agent Trace DB status when a checkout ID exists and `sce trace db list` discovers checkouts via filesystem scan of `agent-trace-*.db` files on disk. `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/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/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`, reads the package/check version from repo-root `.version`, builds `packages.sce` through Crane (`buildDepsOnly` -> `buildPackage`) with shared host Cargo metadata plus a filtered repo-root source that preserves the Cargo tree, `cli/assets/hooks`, and only the generated config/schema inputs required by CLI embedding, then injects generated OpenCode/Claude/Pi config payloads and schema inputs into a temporary `cli/assets/generated/` mirror during derivation unpack so `cli/build.rs` can package the crate without requiring committed generated crate assets. The same `cli/build.rs` now scans `cli/migrations/*/*.sql` and writes `cli/src/generated_migrations.rs` with deterministic migration constants sorted by numeric filename prefix. - The root flake runs `cli-tests`, `cli-clippy`, and `cli-fmt` through Crane-backed paths for the CLI crate; `cli-fmt` intentionally uses the CLI-only Cargo source set rather than the package source with generated config/assets so formatting is not invalidated by release/config asset churn. The flake also exposes directory-scoped JS validation derivations for both `npm/` and the shared `config/lib/` plugin package root, and `pkl-parity` uses a narrowed Pkl/generated-output source set instead of copying the entire repository. diff --git a/context/cli/agent-trace-storage.md b/context/cli/agent-trace-storage.md new file mode 100644 index 00000000..caf2d4c0 --- /dev/null +++ b/context/cli/agent-trace-storage.md @@ -0,0 +1,27 @@ +# Repository-scoped Agent Trace storage resolver + +Module at `cli/src/services/agent_trace_storage/` (T04 of the `repository-scoped-agent-trace-db` plan) that resolves the active Agent Trace database for a Git repository checkout under the target invariant: one logical Git repository = one Agent Trace database at `/sce/repos//agent-trace.db`. Clones and linked worktrees of the same logical repository resolve to the same database path while keeping distinct checkout IDs. + +## Public API + +- `AgentTraceStorageContext { repository_root, explicit_repository_id, repository_remote }` — borrowed inputs mirroring the `agent_trace.repository_id` / `agent_trace.repository_remote` config keys; callers pass already-resolved configuration values. Active hook runtime and Agent Trace lifecycle setup/health resolve these values through the config service before constructing the storage context. +- `ResolvedAgentTraceStorage { repository_identity, checkout_id, db_path, db }` — resolved repository identity (`ResolvedRepositoryIdentity` including source provenance), the checkout ID for diagnostics (never persisted on Agent Trace rows), the repository-scoped DB path, and the open `RepositoryAgentTraceDb`. +- `resolve_agent_trace_storage(context)` — production entrypoint using the canonical state root from the default-path catalog. +- `resolve_agent_trace_storage_at_state_root(context, state_root)` — resolution core against an explicit state root; used by tests to exercise the full path without touching the real user state directory. + +## Resolution flow + +1. Repository identity via `repository_identity::resolve` precedence (explicit config ID → configured remote URL, default `origin`); resolution errors carry `.sce/config.json` guidance and never echo URLs. A failed identity resolution creates no state directories. +2. Checkout identity reuse via `checkout::resolve_git_dir` + `get_or_create_checkout_id` (`/sce/checkout-id`). +3. DB path from `default_paths::agent_trace_db_path_for_repository{,_at}`, which rejects empty or path-unsafe repository IDs (separators, `.`, `..`). +4. DB open through `agent_trace_db::repository::RepositoryAgentTraceDb` with a bounded concurrent-safe fast-path-then-migrate sequence: `open_without_migrations_at` + `ensure_schema_ready_for_hooks()` + `verify_or_initialize_repository_metadata(repository_id)`, falling back to migration-running `new_at` plus the same metadata validation. Directory creation rides on `TursoDb` parent-dir `create_dir_all`. If concurrent first open leaves all repository schema tables present but the one-file baseline migration record missing, the repository adapter records that missing metadata before rechecking readiness; otherwise the resolver retries the open/migrate sequence for a bounded window while SQLite/Turso locks clear. + +## 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. + +## 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). 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, and concurrent first-open convergence (`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/checkout-identity.md b/context/cli/checkout-identity.md index 4a1a7628..995176c6 100644 --- a/context/cli/checkout-identity.md +++ b/context/cli/checkout-identity.md @@ -2,7 +2,7 @@ The checkout identity service lives in `cli/src/services/checkout/`. -It assigns a stable identity to a local Git checkout or linked Git worktree. The setup lifecycle creates/reuses this identity and initializes the per-checkout Agent Trace database. Agent Trace hook runtime resolves persistence through this identity and still lazily initializes or upgrades a per-checkout database when setup has not run or schema metadata is incomplete. +It assigns a stable identity to a local Git checkout or linked Git worktree. Checkout identity remains per clone/worktree, but active Agent Trace persistence is now repository-scoped through `agent_trace_storage`: checkout ID is diagnostic metadata and is not stored on Agent Trace rows. The former per-checkout Agent Trace DB opener and its path helper were removed by the `retire-legacy-agent-trace-db` plan; there is no checkout-scoped DB code path. ## Current code surface @@ -10,31 +10,22 @@ It assigns a stable identity to a local Git checkout or linked Git worktree. The - `resolve_git_dir(repo_root)` runs `git rev-parse --git-dir` from the supplied repository root. - `read_checkout_id(git_dir)` reads `/sce/checkout-id` and validates non-empty UUID syntax. - `get_or_create_checkout_id(git_dir)` reuses an existing ID or writes a new UUIDv7 checkout ID to `/sce/checkout-id`. - - `resolve_or_create_agent_trace_db_for_checkout(repo_root)` gets or creates checkout identity, resolves `/sce/agent-trace-{checkout_id}.db`, fast-opens an existing ready DB, and falls back to migration-running initialization when the DB is absent or schema metadata is incomplete. ## Current integration state -The module is registered through `cli/src/services/mod.rs` and is called by `AgentTraceDbLifecycle::setup()` during `sce setup` after the setup command has derived a repository-root-scoped context. Hook runtime also calls it before Agent Trace DB reads/writes. +The module is registered through `cli/src/services/mod.rs` and is consumed by `agent_trace_storage` during repository-scoped storage resolution. -During setup: +During setup and hook runtime: -- `checkout::resolve_git_dir(repo_root)` resolves the checkout metadata directory from Git truth. -- `checkout::get_or_create_checkout_id(git_dir)` creates or reuses `/sce/checkout-id`. -- `default_paths::agent_trace_db_path_for_checkout(checkout_id)` computes `/sce/agent-trace-{checkout_id}.db`. -- `AgentTraceDb::open_at(path)` opens or creates the per-checkout DB and applies all embedded migrations before setup completes. -- Setup output includes the checkout ID and initialized Agent Trace database path. +- Config resolution provides `agent_trace.repository_id` and `agent_trace.repository_remote` (default `origin`). +- `agent_trace_storage::resolve_agent_trace_storage(...)` resolves repository identity, calls `checkout::resolve_git_dir(repo_root)`, and creates/reuses `/sce/checkout-id` for diagnostics. +- 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`. -During hook runtime: - -- `checkout::resolve_git_dir(repo_root)` and `checkout::get_or_create_checkout_id(git_dir)` make hooks self-sufficient when `sce setup` has not run yet. -- `default_paths::agent_trace_db_path_for_checkout(checkout_id)` computes `/sce/agent-trace-{checkout_id}.db`. -- `AgentTraceDb::open_for_hooks_without_migrations_at(path)` is tried first; `ensure_schema_ready_for_hooks()` decides whether the schema is current. -- Missing or incomplete schema falls back to `AgentTraceDb::open_at(path)`, which runs migrations through the shared Turso adapter. - -The global `agent-trace.db` path remains only as a lifecycle fallback when no checkout context or checkout ID is available. `sce doctor` displays the current checkout ID and per-checkout Agent Trace DB status when a checkout ID exists, and `sce trace db list` discovers checkouts by scanning `/sce/agent-trace-*.db` files on disk, sorted by mtime descending. +`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. ## Testing boundary No unit tests are currently included for this filesystem/Git-facing service. Filesystem, Git repository, and database behaviors should be covered in integration tests rather than unit tests per `context/patterns.md`. -See also: `context/cli/default-path-catalog.md`, `context/sce/agent-trace-db.md`, `context/sce/agent-trace-hooks-command-routing.md`. +See also: `context/cli/agent-trace-storage.md`, `context/cli/default-path-catalog.md`, `context/sce/agent-trace-db.md`, `context/sce/agent-trace-hooks-command-routing.md`. diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index c4aa2e9b..5a65ab86 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -63,7 +63,7 @@ Deferred or gated command surfaces currently avoid claiming unimplemented behavi `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 stages embedded files and uses a unified remove-and-replace policy for `.opencode/`/`.claude/`/`.pi/` (removing existing targets before swapping staged content, with deterministic recovery guidance on swap failure) while treating bash-policy enforcement files as first-class SCE-managed 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` into the `trace` group (`sce trace db list`, `sce trace status`, `sce trace status --all`); 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 checkout/global 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. +`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. A user-invocable `sync` command is not wired in the current CLI surface; local DB and Agent Trace DB bootstrap currently happen through `setup`, and DB health/repair currently happens through `doctor`. Command wiring for `sce sync` is deferred to `0.4.0`. ## Command loop and error model @@ -80,7 +80,7 @@ A user-invocable `sync` command is not wired in the current CLI surface; local D - Interactive `sce setup` prompt cancellation/interrupt exits cleanly with: `Setup cancelled. No files were changed.` - Command handlers return deterministic status messaging: - `setup`: `Setup completed successfully.` plus selected targets and per-target install destinations/counts. -- `doctor`: current runtime emits `SCE doctor diagnose` / `SCE doctor fix` human text headers plus ordered `Environment`, `Configuration` (including checkout identity + Agent Trace checkout DB rows when available), `Repository`, `Git Hooks`, and `Integrations` sections with bracketed `[PASS]`/`[FAIL]`/`[MISS]` row tokens, shared-style green pass plus red fail/miss colorization when enabled, simplified `label (path)` rows, top-level-only hook rows, and a deterministic summary footer; JSON output carries stable problem/fixability records plus deterministic fix-result records in fix mode and reports `checkout_identity` plus the resolved Agent Trace DB record. +- `doctor`: current runtime emits `SCE doctor diagnose` / `SCE doctor fix` human text headers plus ordered `Environment`, `Configuration` (including checkout identity plus repository-scoped Agent Trace DB rows with repository ID, identity source, safe canonical identity, configured remote, and path when available), `Repository`, `Git Hooks`, and `Integrations` sections with bracketed `[PASS]`/`[FAIL]`/`[MISS]` row tokens, shared-style green pass plus red fail/miss colorization when enabled, simplified `label (path)` rows, top-level-only hook rows, and a deterministic summary footer; JSON output carries stable problem/fixability records plus deterministic fix-result records in fix mode and reports `checkout_identity` plus the resolved repository-scoped Agent Trace DB record with credential-safe metadata. - `hooks`: deterministic hook subcommand status messaging for runtime entrypoint invocation and argument/STDIN contract validation. ## Service contracts @@ -104,10 +104,10 @@ A user-invocable `sync` command is not wired in the current CLI surface; local D - `cli/src/services/local_db/mod.rs` provides `LocalDb = TursoDb` with retry-backed `new()`, `execute()`, `query()`, and `query_map()` inherited from the shared Turso adapter. - `LocalDb::new()` resolves the canonical per-user DB path through `default_paths::local_db_path()`, creates parent directories, opens the local Turso database, and currently runs zero local migrations. -- `cli/src/services/agent_trace_db/mod.rs` provides `AgentTraceDb = TursoDb` 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. -- `AgentTraceDb::new()` resolves the legacy/global `/sce/agent-trace.db` through `default_paths::agent_trace_db_path()`; active hook runtime resolves per-checkout DB files through checkout identity and `default_paths::agent_trace_db_path_for_checkout(checkout_id)`. +- `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 checkout identity setup, setup-time per-checkout DB initialization, and checkout/global DB path-health validation plus parent 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. - `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. diff --git a/context/cli/config-precedence-contract.md b/context/cli/config-precedence-contract.md index 495797d5..54c48e37 100644 --- a/context/cli/config-precedence-contract.md +++ b/context/cli/config-precedence-contract.md @@ -25,6 +25,11 @@ Resolved runtime values follow this deterministic order: Repo-configured bash-tool policy values are config-file only in this task slice: they load from `policies.bash` in the selected config files, merge `global -> local` alongside the rest of the config object, and currently have no flag or environment override layer. +Agent Trace repository identity keys are also config-file only with per-key `global -> local` merge and no flag or environment layer: + +- `agent_trace.repository_id` — optional explicit repository identity; resolves as an optional value with no default. +- `agent_trace.repository_remote` — Git remote name used to derive repository identity; defaults to `origin` (`DEFAULT_AGENT_TRACE_REPOSITORY_REMOTE` in `cli/src/services/config/resolver.rs`) when no config file sets it. + 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_FILE`, `SCE_LOG_FILE_MODE`) @@ -61,7 +66,7 @@ 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_file`, `log_file_mode`, `timeout_ms`, `workos_client_id`, `policies`, `integrations`. +- Allowed keys: `$schema`, `log_level`, `log_format`, `log_file`, `log_file_mode`, `timeout_ms`, `workos_client_id`, `agent_trace`, `policies`, `integrations`. - Unknown keys fail validation. - `log_level` must be one of `error|warn|info|debug`. - `log_format` must be `text` or `json` when present. @@ -71,6 +76,10 @@ When a default-discovered global or repo-local config file exists but fails JSON - `timeout_ms` must be an unsigned integer. - `workos_client_id` must be a string when present. +- `agent_trace` must be an object when present and currently allows only `repository_id` and `repository_remote`. +- `agent_trace.repository_id` must be a non-empty string when present. +- `agent_trace.repository_remote` must be a non-empty string when present; the generated schema documents default `origin`. + - `integrations` must be an object when present and currently allows only `target`. - `integrations.target` must be an array of unique canonical target IDs when present. - Supported target ID values: `opencode`, `claude`, `pi`. @@ -97,6 +106,7 @@ When a default-discovered global or repo-local config file exists but fails JSON - `show` includes resolved observability values directly in `result.resolved`, preserving flat logging keys (`log_level`, `log_format`, `log_file`, `log_file_mode`). - `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 repository identity under `result.resolved.agent_trace` (JSON: `repository_id` optional-value shape, `repository_remote` resolved-value shape) and as `agent_trace.repository_id` / `agent_trace.repository_remote` per-key text lines, reporting `(unset)` for a missing `repository_id` and `source: default` for the `origin` remote fallback. - `show` includes resolved bash-tool policies under `result.resolved.policies.bash`. - Bash-policy output includes resolved preset IDs, expanded custom entries (`id`, `match.argv_prefix`, `message`), and config-file source metadata when present. - `show` text output renders `policies.bash` as a single deterministic line and reports `(unset)` when no policy config resolves. diff --git a/context/cli/default-path-catalog.md b/context/cli/default-path-catalog.md index 94785e58..09115685 100644 --- a/context/cli/default-path-catalog.md +++ b/context/cli/default-path-catalog.md @@ -17,8 +17,7 @@ - global config: `/sce/config.json` - auth DB: `/sce/auth.db` - local DB: `/sce/local.db` -- legacy/global agent trace DB fallback: `/sce/agent-trace.db` -- per-checkout agent trace DB: `/sce/agent-trace-{checkout_id}.db` +- agent trace DB (only helper): `/sce/repos/{repository_id}/agent-trace.db` via `agent_trace_db_path_for_repository(repository_id)` (plus `_at(state_root, repository_id)` for explicit roots); rejects empty or path-unsafe repository IDs. The former global-sentinel `agent_trace_db_path()` and per-checkout `agent_trace_db_path_for_checkout(checkout_id)` helpers were removed by the `retire-legacy-agent-trace-db` plan. ### Repo-relative paths @@ -45,6 +44,6 @@ - `cli/src/services/setup/mod.rs` now resolves setup target directory names and required hook identifiers through `default_paths.rs` constants/accessors instead of owning those path literals locally. - `cli/src/services/default_paths.rs` includes a regression test that scans non-test Rust source under `cli/src/` and fails when new centralized production path literals appear outside the default-path service. - Active hook runtime no longer resolves or writes collision-safe JSON artifacts under `context/tmp/`; `context/tmp/` remains a repo-relative scratch/session path owned by the default path catalog. -- `cli/src/services/agent_trace_db/lifecycle.rs` and `cli/src/services/checkout/mod.rs` resolve per-checkout Agent Trace DB files through `agent_trace_db_path_for_checkout(checkout_id)` after setup-time DB initialization or hook-runtime lazy initialization. +- Active hook runtime and `cli/src/services/agent_trace_db/lifecycle.rs` resolve repository-scoped Agent Trace DB files through `agent_trace_storage` and `agent_trace_db_path_for_repository(repository_id)`. Outside a Git repository, lifecycle code returns an actionable "requires a Git repository" diagnostic instead of resolving any sentinel path. See also: [cli-command-surface.md](./cli-command-surface.md), [../architecture.md](../architecture.md), [../context-map.md](../context-map.md) diff --git a/context/cli/repository-identity.md b/context/cli/repository-identity.md new file mode 100644 index 00000000..7e58907f --- /dev/null +++ b/context/cli/repository-identity.md @@ -0,0 +1,57 @@ +# Repository identity canonicalization and hashing + +Module at `cli/src/services/repository_identity/` that turns an explicit configured identity or a Git remote URL into a scheme-neutral canonical identity, then derives a stable repository ID as `sha256("sce-repository-id-v1\0" + canonical_identity)` lowercase hex (64 chars). Implemented in T02/T03 of the `repository-scoped-agent-trace-db` plan; the T04 storage resolver in `cli/src/services/agent_trace_storage/` selects the repository-scoped Agent Trace DB path `/sce/repos//agent-trace.db` by this ID (see [agent-trace-storage.md](agent-trace-storage.md)). + +The root module (`mod.rs`) performs no I/O: it never opens databases, reads Git config, or touches the filesystem. Runtime precedence resolution and Git remote lookup live in the `resolve` submodule. + +## Public API + +- `RepositoryIdentity { canonical_identity, repository_id }` — safe canonical form plus its hash. +- `repository_identity_from_explicit(raw)` — for `agent_trace.repository_id` config values; canonicalization is trimming only (explicit identities are operator-chosen opaque strings, not URL-parsed). Empty after trim → `EmptyExplicitIdentity`. +- `repository_identity_from_remote_url(raw)` — canonicalizes a Git remote URL, then hashes. +- `canonicalize_remote_url(raw) -> Result` — canonicalization without hashing. +- `derive_repository_id(canonical_identity) -> String` — domain-separated SHA-256 hex. +- `REPOSITORY_ID_HASH_DOMAIN` — the `b"sce-repository-id-v1\0"` prefix constant. +- `repository_dir_segment(canonical_identity) -> String` — pure human-readable on-disk directory segment `-`, where `slug` is the lowercased canonical identity with non-alphanumeric runs collapsed to a single `-` and leading/trailing `-` trimmed, and `short` is the first 4 hex chars of `SHA256(canonical_identity)` with **no** domain prefix (deliberately distinct from `repository_id`, which keeps its `sce-repository-id-v1\0` prefix). All-non-alphanumeric input slugs to empty and the segment falls back to just `short`. Display/layout helper only — the authoritative identity stays `repository_id`. Not yet wired into path construction (T02 of `human-readable-repo-db-directory`). +- `RepositoryIdentity::dir_segment(&self) -> String` — convenience wrapper over `repository_dir_segment` for `self.canonical_identity`. + +## Canonicalization rules (remote URLs) + +Canonical form is scheme-neutral `host[:port]/path` so equivalent SSH/SCP/HTTPS remotes converge: + +- Supported scheme URLs: `ssh://`, `git+ssh://`, `ssh+git://`, `http://`, `https://`, `git://`. Anything else with `://` (e.g. `file://`) → `UnsupportedRemoteUrl`. +- SCP-style `[user@]host:path` is supported when the first `:` precedes any `/`; it implies SSH with no port component. Inputs without `://` that don't match this shape (e.g. local paths) → `UnsupportedRemoteUrl`. +- Userinfo (including credentials) is stripped at the last `@` of the authority. +- Hostname is lowercased; path case is preserved. Bracketed IPv6 hosts are supported and kept bracketed. +- Default ports are removed per scheme (ssh 22, http 80, https 443, git 9418); non-default ports are preserved as `host:port`. +- Path cleanup: query (`?...`) and fragment (`#...`) dropped, leading/trailing slashes trimmed, one trailing `.git` stripped, then trailing slashes trimmed again. Empty result → `MissingPath`. + +Example: `git@GitHub.com:Acme/Widgets.git`, `ssh://git@github.com:22/Acme/Widgets.git`, and `https://user:pass@github.com/Acme/Widgets.git?x#y` all canonicalize to `github.com/Acme/Widgets` and hash to the same repository ID. + +## Runtime resolution (`resolve` submodule) + +`repository_identity/resolve.rs` applies the repository identity precedence at runtime: + +1. Explicit `agent_trace.repository_id` config value (trim-only canonicalization; invalid explicit values error, they do not fall back to remotes). +2. URL of the configured Git remote (`agent_trace.repository_remote`, default `origin`), read via `git config --get remote..url`. +3. Otherwise an actionable error pointing at `.sce/config.json`. + +- `resolve_repository_identity(repository_root, explicit_identity, remote_name)` — process-spawning entrypoint. +- `resolve_repository_identity_with_lookup(explicit, remote_name, lookup)` — precedence core with injectable remote lookup for tests/callers. +- `lookup_remote_url(repository_root, remote_name) -> Option` — returns `None` when git is unavailable, the directory is not a repository, or the remote has no URL. +- `ResolvedRepositoryIdentity { identity, source }` with `RepositoryIdentitySource::{ExplicitConfig, RemoteUrl { remote_name }}` — source is retained for later diagnostics rendering (T10). +- `RepositoryIdentityResolutionError::{InvalidExplicitIdentity, InvalidRemoteUrl, MissingIdentity}` — every `Display` message includes `.sce/config.json` guidance naming the `agent_trace.*` keys; variants carry only the configured remote name, never URLs or identity values. + +Local paths are never used implicitly: a local-path remote URL fails canonicalization and surfaces as `InvalidRemoteUrl` rather than falling back. + +## Credential-safety contract + +- The returned canonical identity and repository ID never contain userinfo/credentials. +- `RepositoryIdentityError` variants (`EmptyExplicitIdentity`, `EmptyRemoteUrl`, `UnsupportedRemoteUrl`, `MissingHost`, `MissingPath`, `InvalidPort`) are fieldless and their `Display` messages never echo the raw input, so credential-bearing URLs cannot leak through diagnostics. +- `RepositoryIdentityResolutionError` follows the same rule: it never echoes remote URLs or explicit identity values; only operator-chosen remote names appear in messages. + +## Status + +Registered in `cli/src/services/mod.rs`; consumed by the `agent_trace_storage` resolver, which is now used by active hook runtime and Agent Trace lifecycle setup/health. Covered by in-module unit tests, including temp-Git-repo remote lookup tests (repo-preferred path is `nix flake check` / `nix build .#checks..cli-tests`). + +See also: [config-precedence-contract.md](config-precedence-contract.md) (owns the `agent_trace.repository_id` / `agent_trace.repository_remote` config keys), [checkout-identity.md](checkout-identity.md), [../sce/agent-trace-db.md](../sce/agent-trace-db.md). diff --git a/context/cli/service-lifecycle.md b/context/cli/service-lifecycle.md index f9eea208..912deeb5 100644 --- a/context/cli/service-lifecycle.md +++ b/context/cli/service-lifecycle.md @@ -32,9 +32,9 @@ - `LocalDbLifecycle::fix` bootstraps the canonical local DB parent directory for auto-fixable local DB parent readiness problems. - `LocalDbLifecycle::setup` initializes the canonical local DB through `LocalDb::new()` and returns an empty `SetupOutcome` because DB bootstrap currently has no dedicated outcome carrier. - `cli/src/services/agent_trace_db/lifecycle.rs` defines `AgentTraceDbLifecycle`, the Agent Trace DB-owned provider. -- `AgentTraceDbLifecycle::diagnose` emits per-checkout Agent Trace DB path and parent-directory readiness lifecycle health problems when `ctx.repo_root()` has an existing checkout ID; otherwise it falls back to the legacy global Agent Trace DB path. -- `AgentTraceDbLifecycle::fix` bootstraps the resolved per-checkout Agent Trace DB parent directory for auto-fixable DB parent readiness problems, with the same global fallback outside checkout context. -- `AgentTraceDbLifecycle::setup` resolves the current checkout identity from `ctx.repo_root()` when available, creates or reuses `/sce/checkout-id`, resolves `/sce/agent-trace-{checkout_id}.db`, opens/creates that database through `AgentTraceDb::open_at(&db_path)` so embedded migrations are applied, and returns setup messaging with the checkout ID plus initialized DB path. Hook runtime still creates or upgrades the per-checkout DB lazily when setup has not run or schema metadata is incomplete. +- `AgentTraceDbLifecycle::diagnose` resolves repository identity from `ctx.repo_root()` plus `agent_trace.repository_id` / `agent_trace.repository_remote`, and emits repository-scoped Agent Trace DB path and parent-directory readiness lifecycle health problems. Outside repository context there is no global/checkout fallback path (removed by the `retire-legacy-agent-trace-db` plan); `resolve_lifecycle_agent_trace_db_path` returns an actionable "requires a Git repository" diagnostic, surfaced as a manual-only `UnableToResolveStateRoot` problem. +- `AgentTraceDbLifecycle::fix` bootstraps the resolved repository Agent Trace DB parent directory for auto-fixable DB parent readiness problems; outside repository context it returns the same actionable no-repository diagnostic rather than probing a sentinel path. +- `AgentTraceDbLifecycle::setup` resolves repository storage from `ctx.repo_root()` when available, creates or reuses `/sce/checkout-id` for diagnostics, resolves `/sce/repos//agent-trace.db`, opens/creates that database through `RepositoryAgentTraceDb` so the repository schema is applied and metadata is validated, and returns setup messaging with the repository ID, checkout ID, and initialized DB path. Hook runtime uses the same repository storage resolver lazily when setup has not run or schema metadata is incomplete. - `doctor` runtime execution now aggregates lifecycle providers for diagnosis and repair: - `cli/src/services/doctor/command.rs` accepts any context implementing `ContextWithRepoRoot`. - `cli/src/services/doctor/mod.rs` resolves the repository root once, creates a repo-root-scoped borrowed context using `with_repo_root()`, and requests the full provider catalog with hooks included. diff --git a/context/cli/structured-patch-service.md b/context/cli/structured-patch-service.md index 0920f586..f4835a4a 100644 --- a/context/cli/structured-patch-service.md +++ b/context/cli/structured-patch-service.md @@ -28,7 +28,7 @@ The module is wired into `sce hooks diff-trace` for Claude payload classification at intake (T04): when `hook_event_name` is present and the event is a supported `PostToolUse` (`Write` structured update, `Write` content create fallback, or `Edit` structured patch), the raw JSON is persisted as a `structured` payload type in `diff_traces` without conversion to unified-diff text. Unsupported Claude events (non-`PostToolUse`, unsupported tools) produce deterministic no-op results. OpenCode normalized payloads continue to be stored as `patch` payloads unchanged. -Post-commit parsing dispatch through `structured_patch.rs` is implemented (T05): `AgentTraceDb::recent_diff_trace_patches` now reads `payload_type` from each `diff_traces` row and dispatches `patch` rows through existing `parse_patch` while dispatching `structured` rows through `derive_claude_structured_patch` at read time, producing `ParsedPatch` for both paths before hunk `model_id` injection and downstream combine/intersect operations. +Post-commit parsing dispatch through `structured_patch.rs` is implemented (T05): `RepositoryAgentTraceDb::recent_diff_trace_patches` now reads `payload_type` from each `diff_traces` row and dispatches `patch` rows through existing `parse_patch` while dispatching `structured` rows through `derive_claude_structured_patch` at read time, producing `ParsedPatch` for both paths before hunk `model_id` injection and downstream combine/intersect operations. ## Test status diff --git a/context/cli/trace-command.md b/context/cli/trace-command.md index fdb13738..a16d405e 100644 --- a/context/cli/trace-command.md +++ b/context/cli/trace-command.md @@ -4,10 +4,12 @@ Top-level CLI command group exposing Agent Trace database visibility for operato Lives under `cli/src/services/trace/` with these subcommands: -- `sce trace db list` — discover per-checkout Agent Trace DBs under `/sce/agent-trace-*.db` and render an alias / status / path table. -- `sce trace db shell ` — resolve a discovered ready DB by positional alias or checkout ID and open an embedded in-process SQL shell. -- `sce trace status` — render counts and last-activity for the cwd's checkout DB. -- `sce trace status --all` — aggregate counts across every discovered DB. +- `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` 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`. @@ -15,9 +17,9 @@ The list/status subcommands declare `--format text|json` via `services::output_f ### Discovery — `services::trace::discovery` -`discover_agent_trace_dbs()` scans the resolved `/sce/` directory for `agent-trace-{checkout_id}.db` files, sorts by file mtime descending (ties broken by `checkout_id` ascending), and assigns positional `agent_trace_{N}` aliases. Each entry carries an mtime-derived `SystemTime`, the parsed `checkout_id`, and a `Readiness` verdict (`Ready` or `Skipped { missing_table }`). +`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 `AgentTraceDb::open_for_hooks_without_migrations_at` and a `sqlite_master` lookup for each required table in declared order: +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 @@ -25,45 +27,23 @@ post_commit_patch_intersections agent_traces messages parts -session_models ``` -The first missing table is reported as the skip reason. The discovery module returns an empty Vec when the `sce` directory does not exist; callers do not need to special-case that. +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)` is the pure resolver seam used by `sce trace db shell `. It accepts either an `agent_trace_N` alias or a full `checkout_id`, returns a cloned ready `DiscoveredAgentTraceDb`, rejects unknown identifiers with guidance to run `sce trace db list`, rejects ambiguous alias/checkout-ID collisions, and rejects skipped databases with the stored missing-table readiness reason. +`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 Agent Trace DB path in-process with `AgentTraceDb::open_for_hooks_without_migrations_at`, verifies `ensure_schema_ready_for_hooks()`, prints the resolved alias, checkout ID, 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 as `column | column`, `--- | ---`, row values, and `(N rows)`. SQL errors are rendered as shell diagnostics and do not terminate the loop. - -`.tables` queries `sqlite_schema` for every visible table with `type = 'table'`, orders by table name, and prints one table name per line without counts or schema details. Internal SCE tables such as `__sce_migrations` are included when present. - -The shell command is embedded-only and does not invoke the external `turso` CLI. `TraceCommand` discovers DBs, resolves `` through `resolve_agent_trace_db_identifier`, maps resolver failures to validation-class CLI errors, then hands locked stdin/stdout to `run_agent_trace_db_shell`. The command returns an empty payload after shell exit so the normal app renderer does not duplicate shell output. +`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. -Operator contract: - -- Run `sce trace db list` first to find the current positional alias (`agent_trace_N`) or copy the full checkout ID. Aliases follow discovery order and may change when DB mtimes change. -- Start the shell with `sce trace db shell `, where the identifier must resolve to exactly one ready discovered DB. Unknown, ambiguous, or skipped/not-ready DB identifiers fail before the shell starts with validation-class guidance to inspect `sce trace db list`. -- On startup, the shell prints the resolved alias, checkout ID, and database path before accepting SQL. -- `.help` lists supported dot commands; `.tables` lists table names only in deterministic order; `.exit` and `.quit` terminate the shell. -- Piped stdin is supported for deterministic automation, for example `SELECT COUNT(*) FROM diff_traces;` followed by `.exit`. -- The implementation is an in-process Rust/Turso shell only; it never shells out to `turso`, `sqlite3`, or another external database CLI. +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` -`render(databases, format)` dispatches to the text or JSON renderer. - -**Text** — `services::style::heading("SCE trace db list")` followed by a 4-column padded table: - -``` -Alias Status Updated at Path -agent_trace_0 ready 2026-06-27 12:34:56 UTC /path/to/agent-trace-aaaa.db -agent_trace_1 skipped: missing table 'post_commit_patch_intersections' 2026-06-27 12:34:51 UTC /path/to/agent-trace-bbbb.db -``` - -Empty-state output is the heading plus `no agent-trace databases discovered`. +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** — stable shape: +JSON output shape: ```json { @@ -73,154 +53,34 @@ Empty-state output is the heading plus `no agent-trace databases discovered`. "databases": [ { "alias": "agent_trace_0", - "checkout_id": "aaaa", - "path": "/path/to/agent-trace-aaaa.db", + "scope": "repository", + "identifier": "", + "path": "/.../repos//agent-trace.db", "status": "ready", "updated_at": "2026-06-27T12:34:56+00:00" - }, - { - "alias": "agent_trace_1", - "checkout_id": "bbbb", - "path": "/path/to/agent-trace-bbbb.db", - "status": "skipped", - "skip_reason": "missing table: post_commit_patch_intersections", - "updated_at": "2026-06-27T12:34:51+00:00" } ] } ``` -`skip_reason` is omitted when `status == "ready"`. Text `Updated at` is rendered as `YYYY-MM-DD HH:MM:SS UTC` from the discovery file mtime; JSON `updated_at` is RFC3339. - -### `sce trace status` resolution — `services::trace::status` - -`resolve_current_status(repo_root)` resolves the cwd's git directory via `services::checkout::resolve_git_dir`, reads the stored checkout id via `read_checkout_id`, computes the canonical `/sce/agent-trace-{id}.db` path, and probes schema readiness (reusing the discovery-layer probe). When ready it also collects row counts and last-activity via `services::trace::stats::collect_agent_trace_db_stats`. Returns either a `StatusReport { checkout_id, database_path, db_status: DbStatus::{Ready { stats, last_activity }, Skipped { missing_table }} }` or a `StatusErrorOrRuntime`. +`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. -Three user-actionable error variants (`StatusError::{NotInGitRepo, NoCheckoutId, DbMissing}`) are mapped at the command boundary to `ClassifiedError::validation` (exit code 3) with stable messages directing the user to cd into a git repo, run `sce setup`, or wait for traces to be recorded. Sqlite/IO failures stay runtime-class (exit 4). +### `sce trace status` resolution/rendering — `services::trace::status`, `render_status` -A `resolve_current_status_in(repo_root, sce_dir)` variant takes the `sce` directory explicitly for unit-test fixtures. +`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`. -### `sce trace status` rendering — `services::trace::render_status` +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. -**Text** — `services::style::heading("SCE trace status")` followed by: - -``` -Checkout: -Database: -Status: ready -Diff traces: N -Messages: N -Parts: N -Session models: N -Agent traces: N -Post-commit intersections: N -Last activity: 2026-06-27T22:39:03.926+00:00 -``` - -When `last_activity` is `None` the value is rendered as `never`. When the DB exists but a required table is missing, the per-checkout block ends after `Status: skipped: missing table ''` with no stats lines (exit 0). - -**JSON** — stable shape: - -```json -{ - "status": "ok", - "command": "trace", - "subcommand": "status", - "checkout_id": "01900000-...", - "database_path": "/.../agent-trace-{id}.db", - "db_status": "ready", - "stats": { - "diff_traces": N, - "messages": N, - "parts": N, - "session_models": N, - "agent_traces": N, - "post_commit_patch_intersections": N - }, - "last_activity": "2026-06-27T22:39:03.926+00:00" -} -``` +### `sce trace status --all` aggregation/rendering — `services::trace::status_all`, `render_status_all` -For `db_status: "skipped"`, `stats` and `last_activity` are omitted and a `skip_reason: "missing table: "` field is added. - -### `sce trace status --all` aggregation — `services::trace::status_all` - -`aggregate_current_status_all()` resolves `/sce/` and delegates to `aggregate_status_all_in(sce_dir)`, which walks `discover_agent_trace_dbs_in`, runs `collect_agent_trace_db_stats` on each `Readiness::Ready` DB, and accumulates `Totals` (sum of the six row counts plus the max of per-DB `last_activity`). `Readiness::Skipped` DBs are excluded from totals but counted in the discovery summary and surfaced as breakdown rows with a `missing_table` reason. Returns `StatusAllReport { discovery: DiscoverySummary { discovered, ready, skipped }, totals: Totals, databases: Vec }`. - -### `sce trace status --all` rendering — `services::trace::render_status_all` - -**Text** — heading `SCE trace status (all)` followed by three blocks: discovery summary line, `Totals` block (same six counts plus `Last activity`), and an optional `By database` block omitted when no databases are discovered: - -``` -SCE trace status (all) -Databases: 3 discovered, 2 ready, 1 skipped - -Totals -Diff traces: N -Messages: N -Parts: N -Session models: N -Agent traces: N -Post-commit intersections: N -Last activity: 2026-06-28T... - -By database -Alias Status Diffs Messages Parts Models Traces Intersections -agent_trace_0 ready 3 2 4 0 0 0 -agent_trace_1 ready 1 1 1 0 0 0 -agent_trace_2 skipped: missing 'post_commit_patch_intersections' - - - - - - -``` - -`Last activity` renders `never` when no DB carries activity. Skipped rows fill count cells with `-`. - -**JSON** — stable shape: - -```json -{ - "status": "ok", - "command": "trace", - "subcommand": "status.all", - "discovery": { "discovered": N, "ready": N, "skipped": N }, - "totals": { - "diff_traces": N, - "messages": N, - "parts": N, - "session_models": N, - "agent_traces": N, - "post_commit_patch_intersections": N, - "last_activity": "2026-06-28T..." - }, - "databases": [ - { - "alias": "agent_trace_0", - "checkout_id": "aaaa", - "path": "/.../agent-trace-aaaa.db", - "status": "ready", - "diff_traces": N, - "messages": N, - "parts": N, - "session_models": N, - "agent_traces": N, - "post_commit_patch_intersections": N, - "last_activity": "2026-06-28T..." - }, - { - "alias": "agent_trace_2", - "checkout_id": "cccc", - "path": "/.../agent-trace-cccc.db", - "status": "skipped", - "skip_reason": "missing table: post_commit_patch_intersections" - } - ] -} -``` +`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. -`totals.last_activity` is `null` when no ready DB has activity. Skipped DB entries omit per-database counts and `last_activity` and add `skip_reason`. +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`. ## Related context -- [cli-command-surface.md](cli-command-surface.md) — full CLI command surface and dispatch contract. -- [checkout-identity.md](checkout-identity.md) — per-checkout Agent Trace DB path resolution and `sce trace db list` discovery surface. -- [default-path-catalog.md](default-path-catalog.md) — `/sce/agent-trace-*.db` path ownership. -- [styling-service.md](styling-service.md) — heading helper used by the text renderer. +- [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. - [../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 d81a35c5..25899712 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -9,16 +9,18 @@ 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, 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 + Agent Trace DB bootstrap plus doctor DB health coverage, 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; `sce sync` command wiring is deferred to `0.4.0`; 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, 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; `sce sync` command wiring is deferred to `0.4.0`; 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/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/Agent Trace databases, repo-relative, embedded-asset, install, hook, and context-path families plus the regression guard that keeps production path ownership centralized) -- `context/cli/checkout-identity.md` (current checkout identity infrastructure in `cli/src/services/checkout/`, including `/sce/checkout-id` UUIDv7 storage, setup lifecycle integration that creates/reuses checkout identity and initializes the per-checkout Agent Trace DB, hook-runtime lazy DB fallback, `sce doctor` checkout identity display, and `sce trace db list` filesystem-based checkout discovery) +- `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`, `resolve_agent_trace_storage{,_at_state_root}` 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, 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: discovery of per-checkout `agent-trace-*.db` files under `/sce/` with mtime-desc + checkout-id tiebreak alias assignment and six-required-table readiness probing, implemented `sce trace db shell ` wiring that resolves aliases/checkout IDs and opens the embedded in-process SQL shell without external `turso`, including `.tables` table-name listing for visible/internal tables, implemented `sce trace db list` text + JSON rendering using `services::style::heading`, implemented `sce trace status` per-checkout rendering with `StatusError::{NotInGitRepo, NoCheckoutId, DbMissing}` mapped to validation-class exits and skipped-DB pass-through, implemented `sce trace status --all` aggregation across every discovered DB, and the completed removal of `sce doctor dbs` whose discovery scan/rendering moved into `services::trace`) -- `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, 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/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, and the completed removal of `sce doctor dbs` whose discovery scan/rendering moved into `services::trace`) +- `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, config-file-only `agent_trace.repository_id`/`agent_trace.repository_remote` repository-identity keys with default remote `origin`, 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 checkout identity registration plus per-checkout Agent Trace DB initialization, 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/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-observability-contract.md` (implemented config-backed runtime observability contract for the flat logging config-file shape with env-over-config fallback, 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` - `context/sce/shared-context-plan-workflow.md` (canonical `/change-to-plan` workflow, clarification/readiness gate contract, and one-task/one-atomic-commit task-slicing policy) @@ -45,9 +47,9 @@ Feature/domain context: - `context/sce/agent-trace-post-rewrite-local-remap-ingestion.md` (current post-rewrite no-op baseline plus historical remap-ingestion reference) - `context/sce/agent-trace-rewrite-trace-transformation.md` (current post-rewrite no-op baseline plus historical rewrite-transformation reference) - `context/sce/local-db.md` (implemented `cli/src/services/local_db/mod.rs` local database spec with `LocalDb = TursoDb`, canonical local DB path resolution, zero local migrations, and inherited retry-backed blocking `execute`/`query`/`query_map` methods using the shared Turso adapter) -- `context/sce/shared-turso-db.md` (current shared `cli/src/services/db/mod.rs` Turso database infrastructure seam, including `DbSpec`, generic `TursoDb`, encrypted `EncryptedTursoDb`, build-time generated migration constants from `cli/build.rs`/`cli/src/generated_migrations.rs`, config-driven constructor/open-connect retry via `run_with_retry_sync`, no-migration `TursoDb::open_without_migrations()` / explicit-path `open_without_migrations_at(path)` for hot runtime paths, migration-running `new()` / explicit-path `new_at(path)` / `run_migrations()` with per-database `__sce_migrations` tracking, config-driven operation retry for `execute`/`query`/`query_values`/`query_map` with a `<= 2_000ms` default query failure budget, raw-value row fetching for deterministic operator-facing rendering, row-mapping excluded from retry, generic embedded migration execution, non-mutating `migration_metadata_problems()` and `ensure_schema_ready(setup_guidance)` readiness methods on `TursoDb`, and concrete wrappers for `LocalDb`, `AuthDb`, plus `AgentTraceDb`) +- `context/sce/shared-turso-db.md` (current shared `cli/src/services/db/mod.rs` Turso database infrastructure seam, including `DbSpec`, generic `TursoDb`, encrypted `EncryptedTursoDb`, build-time generated migration constants from `cli/build.rs`/`cli/src/generated_migrations.rs`, config-driven constructor/open-connect retry via `run_with_retry_sync`, no-migration `TursoDb::open_without_migrations()` / explicit-path `open_without_migrations_at(path)` for hot runtime paths, migration-running `new()` / explicit-path `new_at(path)` / `run_migrations()` with per-database `__sce_migrations` tracking, config-driven operation retry for `execute`/`query`/`query_values`/`query_map` with a `<= 2_000ms` default query failure budget, raw-value row fetching for deterministic operator-facing rendering, row-mapping excluded from retry, generic embedded migration execution, non-mutating `migration_metadata_problems()` and `ensure_schema_ready(setup_guidance)` readiness methods on `TursoDb`, and concrete wrappers for `LocalDb`, `AuthDb`, plus `RepositoryAgentTraceDb`) - `context/sce/auth-db.md` (encrypted `AuthDb = EncryptedTursoDb` adapter, canonical `/sce/auth.db` path, build-time generated `AUTH_MIGRATIONS` from `cli/migrations/auth/`, auth credential schema and updated-at trigger baseline, lifecycle setup/doctor integration, encrypted token-storage persistence, and `SCE_AUTH_DB_ENCRYPTION_KEY`/OS credential-store key handling) -- `context/sce/agent-trace-db.md` (implemented `cli/src/services/agent_trace_db/mod.rs` Agent Trace database wrapper with legacy global `/sce/agent-trace.db` fallback plus active per-checkout `/sce/agent-trace-{checkout_id}.db` paths, explicit-path migration and no-migration open APIs, non-mutating `ensure_schema_ready_for_hooks()` delegation to `TursoDb::ensure_schema_ready()` with `Run 'sce setup'.` guidance, setup-time per-checkout DB initialization plus hook-runtime lazy initialization/upgrade fallback, ordered `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, parent `messages`, append-only `parts`, indexes/triggers, typed parameterized insert helpers, bounded chronological recent `diff_traces` query/parse support with malformed-row skip accounting, registered setup/doctor lifecycle provider, active hook writers for `diff_traces`, post-commit intersection/agent-trace persistence, `messages`, and `parts`, and `cli/src/services/agent_trace.rs` pure patch-overlap helper `patches_have_overlap` consumed by the staged-diff AI-overlap evidence gate in `cli/src/services/hooks/mod.rs`) +- `context/sce/agent-trace-db.md` (implemented Agent Trace database adapter: the sole repository-scoped `RepositoryAgentTraceDb` backed by one fresh multi-statement schema file with `repository_metadata` validation, narrow concurrent-first-open repair for missing one-file baseline migration metadata after all required schema tables exist, no trace-table `checkout_id` columns, repository-level typed insert helpers for diff traces, post-commit intersections, Agent Trace rows, messages, and parts, repository-level recent diff-trace reads without checkout filtering, on-demand command/hook initialization with no daemon/background service, and the never-touch on-disk boundary for any pre-migration checkout-scoped/global DB files; the checkout-scoped `AgentTraceDb` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, and the 15-file `cli/migrations/agent-trace/` chain were removed by the `retire-legacy-agent-trace-db` plan; active hook writers/readers and Agent Trace setup/lifecycle resolve repository storage through `agent_trace_storage`) - `context/sce/agent-trace-core-schema-migrations.md` (historical reference for removed local DB schema bootstrap behavior; T03 now implements the actual local DB with migrations) - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) @@ -81,3 +83,4 @@ Recent decision records: - `context/decisions/2026-03-03-plan-code-agent-separation.md` - `context/decisions/2026-03-09-migrate-lexopt-to-clap.md` (CLI argument parsing migration from lexopt to clap derive macros) - `context/decisions/2026-03-25-first-install-channels.md` (approved first-wave install/distribution scope for `sce`, canonical naming, and Nix-owned build policy) +- `context/decisions/2026-07-17-retire-legacy-agent-trace-db.md` (retire the checkout-scoped Agent Trace DB surface; `RepositoryAgentTraceDb` is the sole adapter, no `sce trace --legacy`, no global/checkout fallback path; pre-migration on-disk files are never touched and no longer inspectable via the CLI) diff --git a/context/decisions/2026-07-17-retire-legacy-agent-trace-db.md b/context/decisions/2026-07-17-retire-legacy-agent-trace-db.md new file mode 100644 index 00000000..700ca401 --- /dev/null +++ b/context/decisions/2026-07-17-retire-legacy-agent-trace-db.md @@ -0,0 +1,65 @@ +# 2026-07-17 — Retire the legacy checkout-scoped Agent Trace DB surface + +## Status + +Accepted. + +## Context + +The `repository-scoped-agent-trace-db` migration moved active Agent Trace +persistence to one database per logical Git repository at +`/sce/repos//agent-trace.db`, resolved through +`agent_trace_storage` and `RepositoryAgentTraceDb`. That migration intentionally +retained a "legacy surface" for a transition period: + +- a dead per-checkout DB opener (`resolve_or_create_agent_trace_db_for_checkout`) + and its `agent_trace_db_path_for_checkout()` path helper, +- the legacy `AgentTraceDb` / `AgentTraceDbSpec` adapter and its 15-file + incremental migration chain under `cli/migrations/agent-trace/`, +- the global sentinel path helper `agent_trace_db_path()` and a lifecycle + fallback to it outside repository context, +- the `sce trace --legacy` CLI surface for inspecting old checkout-scoped + `agent-trace-.db` files. + +The prior policy was: retain legacy checkout DBs, keep them inspectable via +`--legacy`, and leave them byte-for-byte untouched. + +## Decision + +Fully retire the legacy checkout-scoped Agent Trace DB surface and reverse the +retained-legacy policy. After the `retire-legacy-agent-trace-db` plan: + +- `RepositoryAgentTraceDb` is the sole Agent Trace DB adapter. +- `sce trace` has no `--legacy` flag; only repository-scoped DBs are + discoverable, listable, statusable, and shell-inspectable. +- The legacy `AgentTraceDb` type, `AgentTraceDbSpec`, `agent_trace_db_path()`, + `agent_trace_db_path_for_checkout()`, `AGENT_TRACE_MIGRATIONS`, and all 15 + `cli/migrations/agent-trace/*.sql` files are deleted. +- Outside a Git repository, Agent Trace diagnostics (`sce doctor` / `sce setup`) + no longer probe a global `agent-trace.db` sentinel. They report an actionable + "requires a Git repository" diagnostic instead of falling back to a sentinel + path. + +Existing on-disk legacy checkout DB files (`agent-trace-.db`) and +the legacy global `agent-trace.db` are still never migrated, imported, copied, +renamed, archived, deleted, or backfilled by any SCE code path. They simply +become uninspectable through the CLI. + +Checkout identity infrastructure (`/sce/checkout-id`) is out of scope +for this retirement; it remains as repository-scoped diagnostic metadata and is +not stored on Agent Trace rows. + +## Consequences + +- One adapter, one schema file, one migration directory + (`cli/migrations/agent-trace-repository/`); no dual-adapter maintenance and no + stale `#[allow(dead_code)]` masking genuinely-used repository code. +- Operators lose CLI visibility into pre-migration checkout-scoped databases. + Because SCE never touched those files, the data is still on disk and could be + inspected with an external SQLite tool if ever needed; SCE itself no longer + offers a path to it. +- The no-repository doctor/setup case now fails loudly with guidance instead of + silently pointing at a global sentinel that is never a write target. + +See `context/sce/agent-trace-db.md`, `context/cli/trace-command.md`, and +`context/cli/agent-trace-storage.md` for the resulting current-state contracts. diff --git a/context/glossary.md b/context/glossary.md index 18b60e17..d79ce90d 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -9,7 +9,9 @@ - generated-owned outputs: Files materialized by `config/pkl/generate.pkl` under `config/.opencode/**`, `config/automated/.opencode/**`, `config/.claude/**`, and `config/.pi/**`, including OpenCode plugin entrypoints, generated OpenCode `package.json` manifests, generated OpenCode `opencode.json` manifests, generated Claude plugin entrypoints, Claude settings assets, Pi prompt/skill assets, and the Pi extension entrypoint at `config/.pi/extensions/sce/index.ts`. - `Pi agent-role prompt template`: Generated Pi prompt at `config/.pi/prompts/agent-{slug}.md` that adapts an SCE agent (shared-context-plan, shared-context-code) to Pi, which has no native sub-agent format: frontmatter carries `description`/`argument-hint`, and the body prepends an act-as-role preamble plus `$ARGUMENTS` input before the canonical agent body. Rendered by `config/pkl/renderers/pi-content.pkl` with metadata from `config/pkl/renderers/pi-metadata.pkl`. - `canonical OpenCode plugin registration source`: Shared Pkl-authored plugin-registration definition in `config/pkl/base/opencode.pkl`, re-exported from `config/pkl/renderers/common.pkl` as the canonical plugin list/path JSON consumed by OpenCode renderers before they emit generated `opencode.json` manifests; the current entries are `sce-bash-policy` and `sce-agent-trace`. -- `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. Each checkout identity maps to a per-checkout Agent Trace DB file at `/sce/agent-trace-{checkout_id}.db`; setup initializes that DB with migrations, while hook runtime remains a lazy fallback when setup has not run or schema metadata is incomplete. See `context/cli/checkout-identity.md`. +- `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`. +- `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 one fresh multi-statement schema file with `repository_metadata` 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. - `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`. - `generated OpenCode plugin registration contract`: Current generated-config contract where `config/.opencode/opencode.json` and `config/automated/.opencode/opencode.json` serialize 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 ...`). @@ -39,8 +41,8 @@ - `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. - `AuthDbLifecycle`: Lifecycle provider in `cli/src/services/auth_db/lifecycle.rs` that implements `ServiceLifecycle` for encrypted auth DB setup/doctor integration. `diagnose` collects auth DB path health problems, `fix` bootstraps missing auth DB parent directory, and `setup` calls `AuthDb::new()`. Registered as `LifecycleProviderId::AuthDb` in the shared lifecycle catalog. -- `agent trace DB adapter`: Module in `cli/src/services/agent_trace_db/mod.rs` that defines `AgentTraceDbSpec`, exposes `AgentTraceDb` as a `TursoDb` alias, keeps `/sce/agent-trace.db` as the legacy/global fallback path, supports explicit per-checkout DB paths through `open_at(path)` and `open_for_hooks_without_migrations_at(path)`, embeds an ordered split fresh-start baseline migration set (`001..015`; retired `015_create_session_models` is replaced by active `015_add_diff_traces_payload_type`) covering `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, and `diff_traces.payload_type` plus indexes and triggers; provides typed parameterized insert helpers for diff traces with `payload_type` discriminator, post-commit intersection rows, built agent-trace rows, and message/part batch inserts; exposes chronological recent `diff_traces` query/parse support with malformed-row skip accounting; has `AgentTraceDbLifecycle` for setup/doctor integration including setup-time per-checkout DB initialization; and is accessed by active Agent Trace hooks through checkout-scoped lazy DB resolution when setup has not prepared the DB. -- `structured patch service`: Pure synchronous Rust service in `cli/src/services/structured_patch.rs` that derives supported structured editor hook payloads into canonical `ParsedPatch` values. The current implemented source is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured patches; wired into `sce hooks diff-trace` for Claude payload classification at intake (T04) and into `AgentTraceDb::recent_diff_trace_patches` for post-commit structured payload parsing dispatch at read time (T05). +- `agent trace DB adapter`: Modules under `cli/src/services/agent_trace_db/` that define the sole repository-scoped `RepositoryAgentTraceDb = TursoDb` adapter (the checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter and its 15-file migration chain were removed by the `retire-legacy-agent-trace-db` plan). The repository adapter uses one fresh `agent-trace-repository` schema file with `repository_metadata`, repository-level `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts` tables, no row-level `checkout_id`, typed parameterized insert helpers, and chronological recent `diff_traces` query/parse support. `AgentTraceDbLifecycle` initializes/checks repository-scoped storage through `agent_trace_storage`. +- `structured patch service`: Pure synchronous Rust service in `cli/src/services/structured_patch.rs` that derives supported structured editor hook payloads into canonical `ParsedPatch` values. The current implemented source is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured patches; 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). - `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace(...)` as `metadata.sce.version`; the value is sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`, is schema-validated with the rest of the payload, and is persisted in AgentTraceDb `agent_traces.trace_json` without changing the top-level Agent Trace payload/schema `version`. - `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace(...)` inside every `ranges[]` entry as `murmur3:`, computed from the touched-line kind/content of the `post_commit_patch` or embedded-patch hunk used to emit that range while excluding positions, paths, metadata, and database IDs. - `DiffTraceInsert`: Insert payload in `cli/src/services/agent_trace_db/mod.rs` carrying `time_ms`, tool-prefixed `session_id`, `patch`, `model_id`, `tool_name`, nullable `tool_version`, and `payload_type` for parameterized writes to the `diff_traces` table; `payload_type` uses `PAYLOAD_TYPE_PATCH` (`"patch"`) for `OpenCode` unified-diff payloads and `PAYLOAD_TYPE_STRUCTURED` (`"structured"`) for `Claude` `PostToolUse` structured payloads. @@ -48,16 +50,16 @@ - `DbSpec`: Service-specific database metadata trait in `cli/src/services/db/mod.rs` that supplies a diagnostic database name, canonical path resolver, ordered embedded migration list, and config-file lookup key (`db_config_key()`) for `TursoDb`. - `TursoDb`: Generic unencrypted Turso database adapter in `cli/src/services/db/mod.rs`; owns parent-directory creation and Turso local open/connect flow wrapped in config-driven connection-open retry, then delegates synchronous `execute()`/`query()`/`query_map()` wrappers with config-driven query retry and migration execution through the shared internal `TursoConnectionCore` for a `DbSpec` implementation. - `TursoConnectionCore`: Internal shared operation core in `cli/src/services/db/mod.rs` used by both `TursoDb` and `EncryptedTursoDb`; owns the Turso connection and tokio current-thread runtime bridging used by the public adapter methods; generic embedded migration execution with per-database `__sce_migrations` metadata is delegated to `run_embedded_migrations` helpers. -- `no-migration DB open path`: `TursoDb::open_without_migrations()` / `TursoDb::open_without_migrations_at(path)` plus the Agent Trace-specific `AgentTraceDb::open_for_hooks_without_migrations()` / `open_for_hooks_without_migrations_at(path)` seams; opens/connects a local Turso database with parent-directory creation and configured connection-open retry but does not create `__sce_migrations` or run embedded schema migrations. Active Agent Trace hook callers first try the explicit per-checkout no-migration path and then fall back to migration-running initialization when readiness fails. -- `TursoDb migration readiness check`: Public methods on `TursoDb` in `cli/src/services/db/mod.rs` for non-mutating schema-readiness verification: `migration_metadata_problems(&self) -> Result>` queries `__sce_migrations` metadata and compares applied IDs against `M::migrations()`, returning problems (missing table, incomplete migrations, unexpected migrations) or an empty list when ready; `ensure_schema_ready(&self, setup_guidance: &str) -> Result<()>` calls `migration_metadata_problems()` and bails with a formatted error including `M::db_name()` and the caller-provided guidance string when problems are found. `AgentTraceDb::ensure_schema_ready_for_hooks()` delegates to `TursoDb::ensure_schema_ready()` with the Agent Trace–specific `AGENT_TRACE_SCHEMA_SETUP_GUIDANCE` constant. +- `no-migration DB open path`: `TursoDb::open_without_migrations()` / `TursoDb::open_without_migrations_at(path)` plus Agent Trace adapter-specific no-migration seams; opens/connects a local Turso database with parent-directory creation and configured connection-open retry but does not create `__sce_migrations` or run embedded schema migrations. Active Agent Trace hook callers first try the repository-scoped no-migration path and then fall back to migration-running initialization when readiness or repository metadata validation fails. +- `TursoDb migration readiness check`: Public methods on `TursoDb` in `cli/src/services/db/mod.rs` for non-mutating schema-readiness verification: `migration_metadata_problems(&self) -> Result>` queries `__sce_migrations` metadata and compares applied IDs against `M::migrations()`, returning problems (missing table, incomplete migrations, unexpected migrations) or an empty list when ready; `ensure_schema_ready(&self, setup_guidance: &str) -> Result<()>` calls `migration_metadata_problems()` and bails with a formatted error including `M::db_name()` and the caller-provided guidance string when problems are found. `RepositoryAgentTraceDb::ensure_schema_ready_for_hooks()` delegates to `TursoDb::ensure_schema_ready()` with the Agent Trace–specific `AGENT_TRACE_SCHEMA_SETUP_GUIDANCE` constant. - `database_retry config namespace`: Nested config namespace under `policies.database_retry` in `sce/config.json`, authored in `config/pkl/base/sce-config-schema.pkl` and parsed/resolved in `cli/src/services/config/mod.rs`. Supports per-database overrides (`local_db`, `agent_trace_db`, `auth_db`) each with optional `connection_open` and `query` objects containing `max_attempts`, `timeout_ms`, `initial_backoff_ms`, `max_backoff_ms`. Validated against JSON Schema at config load and surfaced in `sce config show`/`validate`. Wired into DB adapter constructors and operation methods via config-aware retry resolution with fallback to hardcoded defaults. - `DatabaseRetryConfig`: Rust type in `cli/src/services/config/mod.rs` holding parsed and validated per-database retry policy overrides (`local_db`/`agent_trace_db`/`auth_db`, each `Option`) from the `policies.database_retry` config namespace. Initialized at app startup via `DATABASE_RETRY_CONFIG` `OnceLock` and consumed by config-aware retry resolution in DB adapters. - `PerDbRetryConfig`: Rust type in `cli/src/services/config/mod.rs` holding optional `connection_open` and `query` retry policies (`Option`) for one database in the `database_retry` config namespace. - `DB connection-open retry policy`: Retry policy used by `TursoDb::new()` and `EncryptedTursoDb::new()` for local Turso open/connect, resolved at app startup from `policies.database_retry..connection_open` via the `DATABASE_RETRY_CONFIG` `OnceLock` with fallback to hardcoded defaults (`3` attempts, `1s` elapsed-attempt timeout, `25ms` initial backoff, `200ms` max backoff) through `run_with_retry_sync`; embedded migrations are not covered by this policy. - `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 `cli/src/generated_migrations.rs` written by `cli/build.rs` from immediate `cli/migrations//*.sql` directories; constants are named from the database directory (for example `AGENT_TRACE_MIGRATIONS`, `AUTH_MIGRATIONS`), sorted by the numeric filename prefix before `_`, and embed SQL via `include_str!`. -- `sync command deferral`: Current plan/state note that a user-invocable `sce sync` command is not wired yet and is deferred to `0.4.0`; local DB bootstrap and setup-time per-checkout Agent Trace DB initialization flow through lifecycle providers aggregated by the setup command, while hook runtime keeps a lazy Agent Trace DB fallback for checkouts where setup has not run or schema metadata is incomplete, and DB health/repair flows through the doctor surface. +- `CLI generated migration manifest`: Build-time Rust source at `cli/src/generated_migrations.rs` written by `cli/build.rs` from immediate `cli/migrations//*.sql` directories; constants are named from the database directory (for example `AGENT_TRACE_REPOSITORY_MIGRATIONS`, `AUTH_MIGRATIONS`), sorted by the numeric filename prefix before `_`, and embed SQL via `include_str!`. +- `sync command deferral`: Current plan/state note that a user-invocable `sce sync` command is not wired yet and is deferred to `0.4.0`; local DB bootstrap and setup-time repository-scoped Agent Trace DB initialization flow through lifecycle providers aggregated by the setup command, while hook runtime keeps a lazy repository Agent Trace DB fallback for repositories where setup has not run or schema metadata is incomplete, and DB health/repair 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, 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. @@ -69,7 +71,7 @@ - `setup required-hook install orchestration`: Setup-service flow in `cli/src/services/setup/mod.rs` (`install_required_git_hooks`) that resolves repository root + effective hooks directory via git truth, installs canonical required hooks with deterministic per-hook outcomes (`Installed`, `Updated`, `Skipped`), enforces executable permissions, and uses a unified remove-and-replace policy that removes existing hooks before swapping staged content with deterministic recovery guidance on swap failure. - `setup hooks CLI mode`: `sce setup` behavior activated by `--hooks` (with optional `--repo `), supporting both hooks-only runs and composable target+hooks runs in one invocation; implemented through `cli/src/services/setup/command.rs` + `cli/src/services/setup/mod.rs`, enforces deterministic compatibility validation (`--repo` requires `--hooks`; target flags remain mutually exclusive), and emits stable setup/hook status output. - `setup repo gate`: Preflight check in `cli/src/services/setup/command.rs` that calls `cli/src/services/setup/mod.rs` (`ensure_git_repository`) before any setup writes begin; enforces that all `sce setup` modes (config-only, hooks-only, combined, and interactive) require the current directory to be inside a git repository, failing with actionable guidance to run `git init` and rerun `sce setup` when the precondition is not met. -- `setup local bootstrap`: Pre-install setup bootstrap behavior now owned by lifecycle providers: `ConfigLifecycle::setup` creates missing `.sce/config.json` with the canonical schema-only payload (`{"$schema": "https://sce.crocoder.dev/config.json"}`), `LocalDbLifecycle::setup` initializes the canonical local DB via `LocalDb::new()`, and `AgentTraceDbLifecycle::setup` creates/reuses checkout identity, initializes the per-checkout Agent Trace DB via `AgentTraceDb::open_at(path)`, and records `database_path`; the setup command aggregates these calls before config/hooks dispatch across all setup modes. +- `setup local bootstrap`: Pre-install setup bootstrap behavior now owned by lifecycle providers: `ConfigLifecycle::setup` creates missing `.sce/config.json` with the canonical schema-only payload (`{"$schema": "https://sce.crocoder.dev/config.json"}`), `LocalDbLifecycle::setup` initializes the canonical local DB via `LocalDb::new()`, and `AgentTraceDbLifecycle::setup` creates/reuses checkout identity, resolves repository identity, initializes the repository-scoped Agent Trace DB via `agent_trace_storage`, and records repository ID, checkout ID, and `database_path`; the setup command aggregates these calls before config/hooks dispatch across all setup modes. - `CLI redaction-safe diagnostics contract`: baseline security behavior implemented via `cli/src/services/security.rs` (`redact_sensitive_text`) and applied to app-level errors, setup git-diagnostic surfacing, and observability output sinks so common secret-bearing token forms are masked before emission. - `setup directory write-permission probe`: deterministic pre-write guard implemented in `cli/src/services/security.rs` (`ensure_directory_is_writable`) and used by setup install/hook flows to fail fast with actionable remediation when target directories are not writable. - `setup --repo canonical path guard`: setup-hook runtime behavior in `cli/src/services/setup/mod.rs` that canonicalizes and validates user-supplied `--repo` paths as existing directories before git-root/hooks-path resolution. @@ -89,7 +91,7 @@ - `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. -- `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 checkout identity setup, setup-time per-checkout Agent Trace DB initialization, per-checkout Agent Trace DB path health/bootstrap when an ID exists, and legacy global fallback outside checkout context. 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). +- `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. - `sce version command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/version/mod.rs` plus `cli/src/services/version/command.rs`, exposing deterministic runtime identification output in text form by default and JSON form via `--format json`. @@ -142,7 +144,7 @@ - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled, SCE is not disabled, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); `NoOverlap` and `Error` both suppress the trailer, with `Error` logged via `sce.hooks.commit_msg.ai_overlap_error`; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. - `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, uses only direct payload `model_id` and `tool_version` (session-model fallback removed), applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, and `conversation-trace` is the active message/part intake path. `session-model` is no longer a supported hook route. -- `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, checkout DB discovery lives under `sce trace`, and output exposes deterministic doctor mode, readiness, stable problem taxonomy/fixability fields, checkout/database records, and fix-result records. The runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and checkout/global Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode, Claude, plus Pi integration content health. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity + Agent Trace checkout DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows where missing files surface as `[MISS]`, mismatches/read failures as `[FAIL]`, and affected parent groups as `[FAIL]`. Current integration groups include `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, `OpenCode skills`, `ClaudeCode plugins`, `ClaudeCode agents`, `ClaudeCode commands`, `ClaudeCode skills`, `Pi prompts`, and `Pi skills`; Claude `settings.json` plus `hooks/**` belong to `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, while Pi `prompts/**` and `skills/**` map to the Pi groups. Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. +- `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, Agent Trace DB discovery is repository-scoped only (the checkout-scoped `sce trace --legacy` surface was removed by the `retire-legacy-agent-trace-db` plan), and output exposes deterministic doctor mode, readiness, stable problem taxonomy/fixability fields, checkout/database records, and fix-result records. The runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode, Claude, plus Pi integration content health. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity plus repository-scoped Agent Trace DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows where missing files surface as `[MISS]`, mismatches/read failures as `[FAIL]`, and affected parent groups as `[FAIL]`. Agent Trace DB rows include repository ID, identity source, safe canonical identity, configured remote name, and never raw remote URLs. Current integration groups include `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, `OpenCode skills`, `ClaudeCode plugins`, `ClaudeCode agents`, `ClaudeCode commands`, `ClaudeCode skills`, `Pi prompts`, and `Pi skills`; Claude `settings.json` plus `hooks/**` belong to `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, while Pi `prompts/**` and `skills/**` map to the Pi groups. Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. - `agent trace local DB schema migration contract`: Retired `apply_core_schema_migrations` behavior removed from the current runtime during `agent-trace-removal-and-hook-noop-reset` T01; the local DB baseline is now file open/create only. - `agent trace removed local-hook paths`: Current-state shorthand for the removed local-hook runtime behaviors that are no longer active: staged-checkpoint persistence, post-commit dual-write, post-rewrite remap ingestion, rewrite trace transformation, and retry replay. diff --git a/context/overview.md b/context/overview.md index 2329cce5..3f48e21c 100644 --- a/context/overview.md +++ b/context/overview.md @@ -20,8 +20,8 @@ The command loop now enforces a stable exit-code contract in `cli/src/app.rs`: ` 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`), optional file sink controls (`SCE_LOG_FILE`, `SCE_LOG_FILE_MODE` with deterministic `truncate` default), stable lifecycle event IDs, stderr-only 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 are emitted on stderr. `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(...)` 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 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 checkout identity setup, per-checkout Agent Trace DB path health/parent readiness when an ID exists, and legacy global fallback outside checkout context. 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 also initializes the per-checkout Agent Trace DB via `AgentTraceDb::open_at(path)`; hook runtime lazy initialization remains a fallback when setup has not prepared the DB or schema metadata is incomplete. +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. The `setup` command includes an `inquire`-backed target-selection flow: default interactive selection for OpenCode/Claude/Pi/All with required-hook installation in the same run, explicit non-interactive target flags (`--opencode`, `--claude`, `--pi`, `--all`), deterministic mutually-exclusive validation, and non-destructive cancellation exits; the former `--both` flag was removed in favor of `--all` (opencode+claude+pi). The CLI now compiles an embedded setup asset manifest from `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, and `cli/assets/hooks/**` via `cli/build.rs`; `cli/src/services/setup/mod.rs` exposes deterministic normalized relative paths plus file bytes and target-scoped iteration without runtime reads from `config/`. The same build script also discovers `cli/migrations//*.sql` at compile time and writes `cli/src/generated_migrations.rs` constants sorted by numeric filename prefix for database migration consumers. @@ -36,7 +36,7 @@ The same config resolver now also owns the attribution-hooks gate used by local 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 plugin assets for both profiles: `sce-bash-policy.ts` plus `sce-agent-trace.ts` are emitted under `config/.opencode/plugins/` and `config/automated/.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` (and `config/automated/.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 and initializes the per-checkout `agent-trace-{checkout_id}.db` with embedded migrations; hook runtime still lazily creates or upgrades the per-checkout DB when setup has not run or schema metadata is incomplete. Doctor validates checkout/global DB paths/health and can bootstrap missing parent directories; checkout DB discovery now lives in the `trace` group (`sce trace db list`). Wiring a user-invocable `sce sync` command is deferred to `0.4.0`. +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`). Wiring a user-invocable `sce sync` command is deferred to `0.4.0`. The repository-root flake (`flake.nix`) now 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, builds `packages.sce` through a Crane `buildDepsOnly` + `buildPackage` pipeline with filtered package sources for the Cargo tree plus required embedded config/assets, and runs `cli-tests`, `cli-clippy`, and `cli-fmt` through Crane-backed check derivations (`cargoTest`, `cargoClippy`, `cargoFmt`) that reuse the same filtered source/toolchain setup. The root flake also exposes release install/run outputs directly as `packages.sce` (with `packages.default = packages.sce`) plus `apps.sce` and `apps.default`, so `nix build .#default`, `nix run . -- --help`, `nix run .#sce -- --help`, and `nix profile install github:crocoder-dev/shared-context-engineering` all target the packaged `sce` binary through the same flake-owned entrypoints. The CLI Cargo package metadata now includes crates.io publication-ready fields with crate-local install guidance in `cli/README.md`; supported Cargo install paths are `cargo install shared-context-engineering --locked`, `cargo install --git https://github.com/crocoder-dev/shared-context-engineering shared-context-engineering --locked`, and local `cargo install --path cli --locked`. The published crate installs the `sce` binary. The crate also keeps `cargo clippy --manifest-path cli/Cargo.toml` warnings-denied through `cli/Cargo.toml` lint configuration, so an extra `-- -D warnings` flag is redundant. @@ -61,7 +61,7 @@ The targeted support commands (`handover`, `commit`, `validate`) keep their thin The prior no-git-wrapper Agent Trace design artifacts under `context/sce/agent-trace-*.md` are retained only as historical reference; the current CLI runtime no longer wires the removed Agent Trace schema adaptation, payload building, retry replay, or rewrite handling paths into local hook execution. The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); when the preflight returns `NoOverlap` or `Error` (including DB open failure, schema not ready, query error, staged diff read failure, or zero overlap), the trailer is not appended and errors are logged via `sce.hooks.commit_msg.ai_overlap_error`; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days (dispatching `patch` rows through existing unified-diff parsing and `structured` rows through `structured_patch::derive_claude_structured_patch` at read time), combines/intersects patches, persists intersection metadata to `post_commit_patch_intersections`, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows, top-level `metadata.sce.version` from the compiled `sce` CLI package version, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent or `null` → `None`, present+non-empty → `Some`, present+empty → error), required nullable/non-empty `tool_version`, plus required `u64` millisecond `time`, uses only direct payload `model_id` and `tool_version` (no longer resolves from `session_models`), and continues with `None` for missing attribution, with same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. 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. The CLI now also includes an approved operator-environment doctor contract documented in `context/sce/agent-trace-hook-doctor.md`; the runtime now matches the implemented T06 slice for `sce doctor --fix` parsing/help, stable problem/fix-result reporting, canonical hook-repair reuse, and bounded doctor-owned local-DB directory bootstrap for the missing SCE-owned DB parent path. -The local DB service now provides `LocalDb` as a thin `TursoDb` alias in `cli/src/services/local_db/mod.rs`; `LocalDbSpec` resolves the canonical local DB path from the shared default-path catalog and currently declares zero migrations. Shared Turso infrastructure lives in `cli/src/services/db/mod.rs`, where `DbSpec` and generic `TursoDb` support local or remote sync-mode opens, parent-directory creation, connection setup, synchronous query helpers, embedded migration execution, and shared DB lifecycle helpers. Auth DB persistence uses encrypted `AuthDb = EncryptedTursoDb` and token storage persists credentials through the `auth_credentials` table. Agent Trace persistence uses `AgentTraceDb = TursoDb` with legacy global plus active per-checkout DB paths, fresh-start migrations for `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes/triggers, and retired `015_create_session_models` metadata handling; active hook runtime writes direct nullable diff-trace attribution without a `session_models` API/table dependency. +The local DB service now provides `LocalDb` as a thin `TursoDb` alias in `cli/src/services/local_db/mod.rs`; `LocalDbSpec` resolves the canonical local DB path from the shared default-path catalog and currently declares zero migrations. Shared Turso infrastructure lives in `cli/src/services/db/mod.rs`, where `DbSpec` and generic `TursoDb` support local or remote sync-mode opens, parent-directory creation, connection setup, synchronous query helpers, embedded migration execution, and shared DB lifecycle helpers. Auth DB persistence uses encrypted `AuthDb = EncryptedTursoDb` and token storage persists credentials through the `auth_credentials` table. Agent Trace persistence uses the sole `RepositoryAgentTraceDb = TursoDb` adapter at `/sce/repos//agent-trace.db`, with a one-file repository schema for `repository_metadata`, `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes/triggers, and no `checkout_id` columns on trace rows. The checkout-scoped `AgentTraceDb = TursoDb` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, and the 15-file `cli/migrations/agent-trace/` chain were removed by the `retire-legacy-agent-trace-db` plan; active hook runtime writes direct nullable diff-trace attribution without a `session_models` API/table dependency. The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `conversation-trace`) with deterministic argument/STDIN validation; `session-model` is no longer supported. Current runtime behavior keeps commit-msg attribution enabled by default unless explicitly opted out: the attribution gate enables canonical trailer insertion in `commit-msg` only when the staged-diff AI-overlap preflight confirms AI/editor evidence (no trailer is appended when the preflight finds no overlap or encounters any error); `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, and remains the active bounded recent-diff-trace intersection path, and `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id?, tool_name, tool_version }` payload persistence with optional `model_id`, required non-empty `tool_name`, required nullable/non-empty `tool_version`, direct `model_id` and `tool_version` values (no session-model fallback), required `u64` millisecond `time`, same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md`. The removed `sce hooks claude-capture` raw capture route is documented in `context/sce/claude-raw-hook-capture.md` as a removed feature. The setup service now also exposes deterministic required-hook embedded asset accessors (`iter_required_hook_assets`, `get_required_hook_asset`) backed by canonical templates in `cli/assets/hooks/` for `pre-commit`, `commit-msg`, and `post-commit`; this behavior is documented in `context/sce/setup-githooks-hook-asset-packaging.md`. The setup service now also includes required-hook install orchestration (`install_required_git_hooks`) that resolves repository root and effective hooks path from git truth, enforces deterministic per-hook outcomes (`Installed`/`Updated`/`Skipped`), and uses a unified remove-and-replace policy that removes existing hooks before swapping staged content with deterministic recovery guidance on swap failures; this behavior is documented in `context/sce/setup-githooks-install-flow.md`. diff --git a/context/patterns.md b/context/patterns.md index 085538ec..a6503b51 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -140,7 +140,7 @@ - For commit-msg co-author policy seams, gate canonical trailer insertion on runtime controls (`SCE_DISABLED` plus the shared attribution-hooks enablement gate) plus the staged-diff AI-overlap evidence gate (`StagedDiffAiOverlapResult::Overlap` maps to `ai_contribution_present = true`; `NoOverlap` and `Error` both map to `false`), and enforce idempotent dedupe so allowed cases end with exactly one `Co-authored-by: SCE ` trailer. - For local hook attribution flows, resolve the top-level enablement gate through the shared config precedence model (`SCE_ATTRIBUTION_HOOKS_DISABLED` opt-out env over `policies.attribution_hooks.enabled`, default `true`) so commit-msg attribution is enabled by default while explicit config `enabled = false` and truthy env opt-out still suppress it without adding hook-specific config parsing. - Do not assume conversation-trace retry/backfill/artifact persistence, retry replay, remap ingestion, or rewrite trace transformation are active in the current local-hook runtime; those paths are removed from or deferred beyond the current baseline. -- For the current local DB baseline, resolve one deterministic per-user persistent DB target (Linux: `${XDG_STATE_HOME:-~/.local/state}/sce/local.db`; platform-equivalent state roots elsewhere), keep the path neutral rather than Agent Trace-branded, create parent directories before first use, and route initialization through `LocalDb::new()`. As database services split, keep path/migration ownership in each `DbSpec`: `LocalDbSpec` owns the neutral local DB path with zero migrations, `AuthDbSpec` owns encrypted `/sce/auth.db` plus ordered auth migrations, `AgentTraceDbSpec` owns `/sce/agent-trace.db` plus ordered Agent Trace migrations for `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts` plus supporting indexes and triggers (migration `015_create_session_models` was removed from fresh schema; current schema uses `015_add_diff_traces_payload_type`), and shared Turso mechanics plus migration metadata stay in `TursoDb` / `EncryptedTursoDb`. +- For the current local DB baseline, resolve one deterministic per-user persistent DB target (Linux: `${XDG_STATE_HOME:-~/.local/state}/sce/local.db`; platform-equivalent state roots elsewhere), keep the path neutral rather than Agent Trace-branded, create parent directories before first use, and route initialization through `LocalDb::new()`. As database services split, keep path/migration ownership in each `DbSpec`: `LocalDbSpec` owns the neutral local DB path with zero migrations, `AuthDbSpec` owns encrypted `/sce/auth.db` plus ordered auth migrations, `RepositoryAgentTraceDbSpec` owns the repository-scoped `/sce/repos//agent-trace.db` (via `agent_trace_db_path_for_repository`) plus the one-file `agent-trace-repository/001_repository_schema.sql` baseline for `diff_traces` (including `payload_type`), `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts` with supporting indexes and triggers (the checkout-scoped `AgentTraceDbSpec` and its 15-file migration chain were removed by the `retire-legacy-agent-trace-db` plan), and shared Turso mechanics plus migration metadata stay in `TursoDb` / `EncryptedTursoDb`. - For hosted event intake seams, verify provider signatures before payload parsing (GitHub `sha256=` HMAC over body, GitLab token-equality secret check), resolve old/new heads from provider payload fields, and derive deterministic reconciliation run idempotency keys from provider+event+repo+head tuple material. - For hosted rewrite mapping seams, resolve candidates deterministically in strict precedence order (patch-id exact, then range-diff score, then fuzzy score), classify top-score ties as `ambiguous`, enforce low-confidence unresolved behavior below `0.60`, and preserve stable outcome ordering via canonical candidate SHA sorting. - For hosted reconciliation observability, publish run-level mapped/unmapped counts, confidence histogram buckets, runtime timing, and normalized error-class labels so retry/quality drift can be monitored without requiring a full dashboard surface. diff --git a/context/plans/human-readable-repo-db-directory.md b/context/plans/human-readable-repo-db-directory.md new file mode 100644 index 00000000..43d08b73 --- /dev/null +++ b/context/plans/human-readable-repo-db-directory.md @@ -0,0 +1,188 @@ +# Human-readable repository Agent Trace DB directory + +## Change summary + +Replace the repository-scoped Agent Trace database's on-disk **directory name** +from the raw 64-character `repository_id` hash to a human-readable segment: + +``` +/sce/repos/-/agent-trace.db +``` + +- `slug` = the `canonical_identity` lowercased, with every run of + non-alphanumeric characters collapsed to a single `-`, trimmed of leading and + trailing `-`. + Example: `github.com/crocoder-dev/shared-context-engineering` + → `github-com-crocoder-dev-shared-context-engineering`. +- `short` = the first 4 hex characters of `hex(SHA256(canonical_identity))`, + computed with **no** domain-separation prefix (deliberately distinct from + `repository_id`, which keeps its `sce-repository-id-v1\0` domain prefix). + +The authoritative 64-character `repository_id` is **unchanged**: it is still +derived exactly as today and still stored in and validated against the +`repository_metadata` table on every open. Only the *directory name* changes. +The `repository_metadata` check remains the hard backstop that makes a +(vanishingly unlikely) slug+short collision fail safe with a mismatch error +instead of writing into the wrong database. + +This is a **breaking on-disk layout change**, explicitly accepted: existing +`repos/<64-hex>/agent-trace.db` directories are **not** migrated, copied, +renamed, imported, or deleted. They are orphaned; a first open under the new +scheme re-initializes a fresh empty repository database. No data migration is +in scope. + +## Success criteria + +- `resolve_agent_trace_storage` and the `default_paths` helpers construct the + active DB path as `/sce/repos/-/agent-trace.db`. +- `slug` and `short` are derived exactly as specified (lowercase, collapsed + non-alphanumerics, trimmed; first 4 hex of un-prefixed `SHA256(canonical)`). +- Clones and linked worktrees of the same logical repository still resolve to + the same directory segment and therefore the same database path. +- Different repositories resolve to different segments/paths. +- `repository_id` in `repository_metadata` is unchanged and still rejects a + genuine repository mismatch on open. +- `sce trace db list` / `status` / `shell` operate on the new directory names, + display the human-readable segment as the identifier, and still surface the + authoritative `repository_id` from metadata. +- No migration, copy, rename, or deletion of pre-existing `repos//` dirs. +- Context documentation reflects the new path shape everywhere it is described. +- Full `nix flake check` (tests, clippy, fmt) passes. + +## Constraints and non-goals + +- Do **not** change how `repository_id` is derived or stored; it stays the + domain-separated 64-char SHA-256 and remains the identity of record in + `repository_metadata`. +- Do **not** migrate, read, copy, rename, or delete legacy or prior + repository-scoped databases. +- Do **not** reintroduce the `--legacy` surface removed by + `retire-legacy-agent-trace-db` (out of scope; separate decision). +- Do **not** add cloud/sync/daemon behavior. +- Keep path-segment safety: reject any derived segment that is empty or not a + single safe path component. +- Never render credential-bearing input; only `canonical_identity` (already + credential-free) feeds the slug/short. + +## Assumptions + +- The `sce trace` discovery identifier (shown in `db list`/`shell`) becomes the + directory segment (`-`); the authoritative full `repository_id` + continues to come from `repository_metadata` and is shown in `status`. Shell + resolution matches against the directory segment or an assigned alias. +- Short hash length is fixed at 4 hex chars per the change request; the slug + carries identity and the short hash only disambiguates slug collisions. + +## Task stack + +- [x] T01: `Add slug + short-hash directory-segment derivation` (status:done) + - Task ID: T01 + - Goal: Add a pure function in `services::repository_identity` that turns a + `canonical_identity` into the directory segment `-`, where slug + is lowercased with non-alphanumeric runs collapsed to single `-` and + trimmed, and short is the first 4 hex chars of un-prefixed + `SHA256(canonical_identity)`. Optionally expose it as a `dir_segment` field + on `RepositoryIdentity` populated in `identity_from_canonical`. + - Boundaries (in/out of scope): In — new derivation function/field plus unit + tests in `repository_identity/mod.rs`. Out — path-helper wiring, callers, + discovery, docs (later tasks). Do not touch `repository_id` derivation. + - Done when: A pure `repository_dir_segment(canonical) -> String` (and/or + `RepositoryIdentity::dir_segment`) exists and is covered by unit tests for: + lowercasing, non-alphanumeric collapse (`.`,`/`,`:` → single `-`), + leading/trailing trim, and short-hash equal to `hex(SHA256(canonical))[..4]` + with no domain prefix (asserted distinct from the `repository_id` prefix). + - Verification notes (commands or checks): `nix flake check`; new unit tests + assert `github.com/crocoder-dev/shared-context-engineering` → + `github-com-crocoder-dev-shared-context-engineering-<4hex>` and that + `<4hex>` matches an independently computed un-prefixed SHA-256 prefix. + - **Status:** done + - **Completed:** 2026-07-17 + - **Files changed:** `cli/src/services/repository_identity/mod.rs` + - **Evidence:** `nix flake check` — all checks passed (cli-tests, cli-clippy, + cli-fmt). Added `repository_dir_segment(canonical) -> String`, + `RepositoryIdentity::dir_segment()`, and the required unit test asserting the + concrete example segment plus un-prefixed short-hash distinct from the + domain-prefixed `repository_id`. + - **Notes:** Per session request, only the required unit test was kept; extra + tests were removed. Change is additive/pure (no callers touched); classified + as a localized change (verify-only for context sync, no root-file edits). + +- [ ] T02: `Build repository DB path from the slug segment` (status:todo) + - Task ID: T02 + - Goal: Switch `default_paths::agent_trace_db_path_for_repository{,_at}` and + all callers to construct the path from the T01 directory segment instead of + the raw `repository_id`, keeping path-segment safety validation on the + derived segment. + - Boundaries (in/out of scope): In — `default_paths.rs` helper signature/body + + their unit tests; callers in `agent_trace_storage/mod.rs`, + `agent_trace_db/lifecycle.rs`, `doctor/inspect.rs`; storage tests proving + clones/worktrees resolve to the same segment path and that path-unsafe + segments are rejected. Out — discovery/status/shell identifier semantics + (T03), docs (T04). Must compile as one commit (all callers move together). + - Done when: The active DB path is + `/sce/repos/-/agent-trace.db`; existing + clone/worktree same-path tests pass against the new segment; empty/unsafe + segment rejection is preserved and tested; no caller still passes the raw + 64-hex id as the directory segment. + - Verification notes (commands or checks): `nix flake check`; assert two + checkouts with the same remote resolve to an identical `db_path`; assert an + unsafe segment (containing `/`, `\`, `.`, `..`) is rejected. + +- [ ] T03: `Update trace discovery/status/shell for the new segment` (status:todo) + - Task ID: T03 + - Goal: Make `sce trace` discovery, list, status, and shell operate on the new + directory names — display the human-readable segment as the identifier while + still surfacing the authoritative `repository_id` from `repository_metadata` + in status; resolve `db shell` by segment or alias. + - Boundaries (in/out of scope): In — `trace/discovery.rs`, + `trace/render_status.rs`/`render_list.rs`/`shell.rs` as needed, and their + fixtures/tests. Out — path construction (T02), docs (T04). Do not restore + any `--legacy` surface. + - Done when: `sce trace db list`/`status`/`shell` work against + `repos/-/` directories; the displayed identifier is the segment; + `status` shows the metadata `repository_id`; discovery tiebreak/alias logic + stays deterministic; tests cover discovery of a new-segment DB and shell + resolution by segment. + - Verification notes (commands or checks): `nix flake check`; discovery test + seeds a `repos/-/agent-trace.db` and asserts identifier + + surfaced `repository_id`. + +- [ ] T04: `Document the human-readable DB directory path` (status:todo) + - Task ID: T04 + - Goal: Update every context doc that hardcodes + `repos//agent-trace.db` to describe the new + `repos/-/agent-trace.db` shape, the slug/short derivation, that + `repository_id` stays authoritative in `repository_metadata`, and that no + migration of prior directories occurs. + - Boundaries (in/out of scope): In — `context/` docs (overview, architecture, + patterns, context-map, glossary, `cli/agent-trace-storage.md`, + `cli/default-path-catalog.md`, `cli/repository-identity.md`, + `cli/trace-command.md`, `cli/service-lifecycle.md`, `sce/agent-trace-db.md`, + and any other file matching the old path string). Out — code changes. Docs + only; one coherent commit. + - Done when: `grep -rn "repos/" context` returns no stale path + descriptions; the new segment shape and no-migration note are documented in + the storage/path docs. + - Verification notes (commands or checks): + `grep -rn "repos/\|repos/{repository_id}" context` shows only + updated wording; `grep -rn "slug" context/cli/agent-trace-storage.md` + confirms the new description. + +- [ ] T05: `Final validation and cleanup` (status:todo) + - Task ID: T05 + - Goal: Run the full verification suite, confirm success criteria, remove any + temporary scaffolding, and verify context sync is complete. + - Boundaries (in/out of scope): In — full checks, success-criteria evidence, + scaffolding removal, context-sync verification, validation report appended + to this plan. Out — new behavior. + - Done when: `nix flake check` passes (tests + clippy + fmt); every success + criterion is verified with evidence; no stray temp files (e.g. + `context/tmp/sce.log`); a Validation Report section is appended below. + - Verification notes (commands or checks): `nix flake check`; + `grep -rn "repos/" context cli/src` returns no stale strings; + re-run T01–T03 targeted tests; run `sce-context-sync` verification. + +## Open questions + +None — design fully specified by the change request (slug transform, 4-hex +un-prefixed short hash, unchanged authoritative `repository_id`, no migration). diff --git a/context/plans/repository-scoped-agent-trace-db.md b/context/plans/repository-scoped-agent-trace-db.md new file mode 100644 index 00000000..97093482 --- /dev/null +++ b/context/plans/repository-scoped-agent-trace-db.md @@ -0,0 +1,240 @@ +# Repository-scoped Agent Trace database + +## Change summary + +Change Agent Trace persistence from checkout-scoped database files to repository-scoped database files. The target invariant is: + +```text +one logical Git repository = one Agent Trace database +``` + +The active Agent Trace database is selected by a stable `repository_id` and stored at: + +```text +/sce/repos//agent-trace.db +``` + +The existing checkout identity remains intact for identifying clones/worktrees and for diagnostics, but this plan intentionally does **not** add `checkout_id` columns to Agent Trace rows. Agent Trace rows and operational attribution are repository-level within the shared repository database. Existing checkout-scoped databases such as `/sce/agent-trace-.db` are legacy data and must not be migrated, renamed, deleted, modified, imported, or selected for new writes. + +Initial code inspection found the current checkout-scoped behavior in these areas: + +- Checkout identity and active DB opening: `cli/src/services/checkout/mod.rs`, especially `resolve_or_create_agent_trace_db_for_checkout()`. +- Agent Trace path construction: `cli/src/services/default_paths.rs`, especially `agent_trace_db_path_for_checkout()` and legacy `agent_trace_db_path()`. +- Database lifecycle and migrations: `cli/src/services/agent_trace_db/lifecycle.rs`, `cli/src/services/agent_trace_db/mod.rs`, and `cli/migrations/agent-trace/*.sql`. +- Hook-time opening and attribution: `cli/src/services/hooks/mod.rs`, including diff-trace insert, post-commit intersection, Agent Trace persistence, and commit-msg staged-overlap preflight. +- Recent trace selection: `AgentTraceDb::recent_diff_trace_patches()` in `cli/src/services/agent_trace_db/mod.rs`. +- Trace status, discovery, listing, and shell: `cli/src/services/trace/{status,discovery,render_list,render_status,status_all,render_status_all,shell,stats}.rs`. +- Doctor/setup diagnostics: `cli/src/services/agent_trace_db/lifecycle.rs` and `cli/src/services/doctor/inspect.rs`. +- Configuration resolution: `cli/src/services/config/{schema,resolver,types}.rs`, generated schema assets, and Pkl source `config/pkl/base/sce-config-schema.pkl`. + +## Success criteria + +- Each logical Git repository resolves to exactly one active repository-scoped Agent Trace database. +- Different logical repositories resolve to different repository IDs and different database paths. +- Multiple clones and linked worktrees of the same logical repository resolve to the same repository-scoped database path. +- Checkout IDs remain distinct per clone/worktree and are visible in storage context/diagnostics, but are not persisted on Agent Trace rows. +- Operational commit attribution, post-commit intersections, Agent Trace persistence, and co-author decisions consume repository-level trace data from the shared repository database. +- Active database path is `/sce/repos//agent-trace.db`. +- No global active Agent Trace database is introduced or used for new writes. +- Existing checkout-scoped databases remain byte-for-byte untouched and no migration/import/archive/delete/rename flow is added. +- Repository identity precedence is implemented: explicit config identity, then selected remote URL, with default remote `origin`. +- Equivalent SSH/SCP/HTTPS remote URLs canonicalize to the same safe canonical identity and hash to the same repository ID. +- Raw credential-bearing remote URLs never appear in repository IDs, paths, doctor/status diagnostics, or metadata. +- Missing explicit identity and missing usable remote produce an actionable error explaining `.sce/config.json` configuration. +- New repository-scoped DBs are initialized from one schema SQL file, contain repository metadata, and validate stored `repository_id` on reopen. +- Concurrent first-time initialization is safe and idempotent. +- Relevant CLI output uses repository-scoped terminology and shows repository identity source, repository ID, canonical identity when safe, configured remote, checkout ID, DB path, and schema status. +- Legacy checkout-scoped DB inspection is available only behind an explicit `--legacy` flag. +- Documentation explains repository-scoped storage, checkout identity without row-level provenance, repository-level attribution within a shared repository DB, legacy database handling, and no daemon/background process. +- Validation passes with `nix flake check`; generated config parity passes with `nix run .#pkl-check-generated` when generated assets change. + +## Constraints and non-goals + +- Do not migrate, import, copy, rename, delete, archive, clean up, or modify existing checkout-scoped databases. +- Do not add legacy migration markers to old databases. +- Do not fall back to local paths, Git directories, checkout IDs, random UUIDs, or one global database when repository identity cannot be resolved. +- Do not log or render credential-bearing remote URLs. +- Do not introduce a daemon, resident process, scheduler, file watcher, external lock server, registry service, or central mutable JSON registry. +- Keep database creation command-driven through existing command/hook flows. +- Preserve existing checkout identity storage under the checkout-specific Git directory. +- Use repo config keys `agent_trace.repository_id` and `agent_trace.repository_remote`; default `repository_remote` to `origin`. +- Treat old checkout-scoped DB listing/shell behavior as legacy inspection only behind an explicit `--legacy` flag; never show legacy files as active DBs or choose them as active write targets. +- Because repository-scoped DB files are new, define the fresh schema in one schema SQL file without adding `checkout_id` columns. + +## Task stack + +- [x] T01: `Add Agent Trace repository config resolution` (status:done) + - Task ID: T01 + - Goal: Add typed config support for `agent_trace.repository_id` and `agent_trace.repository_remote` with default remote `origin`. + - Boundaries (in/out of scope): In - Rust config types/resolver/schema validation, Pkl schema source, generated schema assets, tests for explicit ID and remote default/override. Out - Git remote canonicalization, DB path changes, hook query changes. + - Done when: Config files can specify optional explicit repository identity and selected remote name; invalid shapes are rejected; config rendering/validation remains deterministic; generated outputs are in sync. + - Verification notes (commands or checks): `nix run .#pkl-check-generated`; targeted Rust config tests via `nix develop -c sh -c 'cd cli && cargo test config'` if needed. + - Completed: 2026-07-17 + - Files changed: `config/pkl/base/sce-config-schema.pkl`, `config/schema/sce-config.schema.json` (generated), `cli/assets/generated/config/schema/sce-config.schema.json` (generated sync), `cli/src/services/config/schema.rs`, `cli/src/services/config/resolver.rs`, `cli/src/services/config/render.rs` + - Evidence: `nix build .#checks.x86_64-linux.cli-tests` pass; `cli-clippy` + `cli-fmt` checks pass; `nix run .#pkl-check-generated` reports "Generated outputs are up to date." + - Notes: Top-level `agent_trace` object added to the config schema; resolver exposes `agent_trace_repository_id` (`ResolvedOptionalValue`) and `agent_trace_repository_remote` (`ResolvedValue`, default `origin` via `DEFAULT_AGENT_TRACE_REPOSITORY_REMOTE`); `sce config show` renders both keys in text and JSON. No env-var layer was added (config-file + default only, per plan). T03 will add the runtime accessor/consumer. + +- [x] T02: `Implement repository identity canonicalization and hashing` (status:done) + - Task ID: T02 + - Goal: Add a pure repository identity module that canonicalizes explicit identities and Git remote URLs, then derives `sha256("sce-repository-id-v1\0" + canonical_identity)` hex IDs. + - Boundaries (in/out of scope): In - canonicalization for SCP-style SSH, `ssh://`, HTTPS, hostname lowercasing, credential stripping, default port removal, query/fragment/trailing slash/trailing `.git` cleanup, safe diagnostics, tests. Out - opening databases or reading Git config from real repos. + - Done when: Equivalent GitHub SSH/SCP/HTTPS URLs hash to the same ID; distinct identities hash differently; credential-bearing inputs do not leak credentials into returned canonical identity, ID, or errors. + - Verification notes (commands or checks): `nix develop -c sh -c 'cd cli && cargo test repository_identity'` or the matching module test name. + - Completed: 2026-07-17 + - Files changed: `cli/src/services/repository_identity.rs` (new), `cli/src/services/mod.rs` + - Evidence: `nix build .#checks.x86_64-linux.cli-tests` pass (153 tests, 12 new `repository_identity` module tests); `cli-clippy` + `cli-fmt` checks pass. + - Notes: Pure module, no new dependencies (hand-rolled URL normalization; `sha2` reused). Canonical identity is scheme-neutral `host[:port]/path` so SSH/SCP/HTTPS equivalents converge; supported schemes `ssh`/`git+ssh`/`ssh+git`/`http`/`https`/`git` with default ports 22/80/443/9418 removed; IPv6 bracketed hosts supported. Explicit config identities are trimmed and used verbatim (opaque, not URL-parsed). `RepositoryIdentityError` variants carry no input fragments so credentials cannot leak via errors. Module is `#[allow(dead_code)]` until T03 wires the runtime resolver. + +- [x] T03: `Resolve repository identity from config and Git remotes` (status:done) + - Task ID: T03 + - Goal: Add runtime resolution that applies precedence: explicit config identity, selected Git remote URL, default selected remote `origin`, otherwise actionable error. + - Boundaries (in/out of scope): In - Git remote lookup helper, config-driven remote name, missing-identity error text, tests with temp Git repos/remotes. Out - DB creation, schema changes, trace CLI rendering. + - Done when: Explicit identity overrides remotes; configured remote name is honored; missing explicit identity and missing usable remote errors with `.sce/config.json` guidance; local paths are not used implicitly. + - Verification notes (commands or checks): `nix develop -c sh -c 'cd cli && cargo test repository_identity'` or exact resolver tests. + - Completed: 2026-07-17 + - Files changed: `cli/src/services/repository_identity.rs` → `cli/src/services/repository_identity/mod.rs` (moved, doc header updated), `cli/src/services/repository_identity/resolve.rs` (new) + - Evidence: `nix build .#checks.x86_64-linux.cli-tests` pass (163 tests, 10 new `repository_identity::resolve` tests including temp-git-repo remote cases); `cli-clippy` + `cli-fmt` checks pass. + - Notes: `resolve::resolve_repository_identity(repo_root, explicit, remote_name)` shells out to `git config --get remote..url`; `resolve_repository_identity_with_lookup` is the injectable-lookup precedence core. `ResolvedRepositoryIdentity` carries a `RepositoryIdentitySource` (`ExplicitConfig` vs `RemoteUrl { remote_name }`) for T10 diagnostics. `RepositoryIdentityResolutionError` (`InvalidExplicitIdentity`, `InvalidRemoteUrl`, `MissingIdentity`) never echoes URLs, and all Display messages include `.sce/config.json` guidance. Local-path remotes fail as `InvalidRemoteUrl` (no implicit fallback); git-unavailable/non-repo lookups map to `MissingIdentity`. Still `#[allow(dead_code)]` at the module level until T04 consumes it. + +- [x] T04: `Add repository-scoped Agent Trace storage resolver` (status:done) + - Task ID: T04 + - Goal: Replace checkout-path-oriented active DB resolution with `AgentTraceStorageContext` and `ResolvedAgentTraceStorage` that return repository ID, checkout ID, and `/sce/repos//agent-trace.db`. + - Boundaries (in/out of scope): In - new path helper, directory creation, checkout ID reuse, DB open/create, safe concurrent/idempotent initialization path, tests for path separation and clone/worktree consolidation. Out - changing schema or hook query semantics. + - Done when: Active resolver creates repository directories and opens repository-scoped DBs; different repository IDs produce different paths; equivalent clones/worktrees share the path while retaining distinct checkout IDs; no active global/checkout DB path is created. + - Verification notes (commands or checks): targeted checkout/storage resolver tests; inspect that old `agent-trace-.db` paths are not used by the active resolver. + - Completed: 2026-07-17 + - Files changed: `cli/src/services/agent_trace_storage/mod.rs` (new), `cli/src/services/default_paths.rs`, `cli/src/services/mod.rs` + - Evidence: `nix build .#checks.x86_64-linux.cli-tests` pass (171 tests, 8 new `agent_trace_storage` tests covering repo separation, SSH/HTTPS clone consolidation, linked-worktree consolidation, explicit-ID override, idempotent re-resolution, missing-identity guidance, and path-segment validation); `cli-clippy` + `cli-fmt` checks pass. + - Notes: `resolve_agent_trace_storage(context)` is the production entrypoint; `resolve_agent_trace_storage_at_state_root(context, state_root)` is the injectable core used by tests. Context takes already-resolved config values (explicit ID + remote name); T08 wires config/hooks/lifecycle call sites. `default_paths::agent_trace_db_path_for_repository{,_at}` rejects empty/path-unsafe repository IDs. Directory creation rides on `TursoDb` parent-dir `create_dir_all` (idempotent, safe for concurrent first open); DB open reuses the fast-path-then-migrate pattern from the checkout resolver. Failed identity resolution creates no state directories. Module is `#[allow(dead_code)]` until T08 consumes it; the legacy checkout resolver remains untouched and active until then. + +- [x] T05: `Define one-file repository-scoped Agent Trace schema` (status:done) + - Task ID: T05 + - Goal: Replace the checkout-scoped Agent Trace schema baseline with one repository-scoped schema SQL file that includes repository metadata and keeps trace tables repository-level. + - Boundaries (in/out of scope): In - one fresh schema SQL file covering `repository_metadata`, existing Agent Trace tables, repository-level indexes/constraints, and metadata validation on open. Out - old database alteration/migration/import, `checkout_id` columns on trace tables, checkout-scoped attribution queries, and a new chain of incremental SQL files for the repository-scoped DB baseline. + - Done when: New DBs are initialized from one fresh schema SQL file and have metadata matching the resolved repository ID; opening a DB with mismatched metadata errors; no `checkout_id` columns are added to Agent Trace row tables. + - Verification notes (commands or checks): targeted AgentTraceDb schema tests; assert no code path opens legacy checkout DBs for migration. + - Completed: 2026-07-17 + - Files changed: `cli/migrations/agent-trace-repository/001_repository_schema.sql`, `cli/src/generated_migrations.rs`, `cli/src/services/agent_trace_db/mod.rs`, `cli/src/services/agent_trace_db/repository.rs`, `cli/src/services/agent_trace_storage/mod.rs`, `cli/src/services/db/mod.rs` + - Evidence: `nix build .#checks.x86_64-linux.cli-tests` pass; `nix build .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt` pass; `nix run .#pkl-check-generated` reports "Generated outputs are up to date." + - Notes: Added the `agent-trace-repository` one-file fresh schema baseline with `repository_metadata`, existing repository-level Agent Trace tables/indexes/triggers, and no `checkout_id` trace columns. Added `RepositoryAgentTraceDb` over the shared Turso adapter, metadata seed/validation helpers, schema readiness tests, and storage resolver metadata validation. Shared migration execution now uses batch execution so the one-file multi-statement baseline runs as one recorded migration. + +- [x] T06: `Keep Agent Trace writes repository-level` (status:done) + - Task ID: T06 + - Goal: Update Agent Trace write paths to use the repository-scoped database while preserving current row shapes without `checkout_id` fields. + - Boundaries (in/out of scope): In - Rust insert structs/constants/SQL/tests updated only as needed for the fresh one-file schema and repository DB opening. Out - adding checkout provenance columns or checkout-scoped write APIs. + - Done when: Diff traces, messages, parts, Agent Trace rows, and post-commit intersections write successfully into the repository-scoped DB using repository-level row schemas. + - Verification notes (commands or checks): `nix develop -c sh -c 'cd cli && cargo test agent_trace_db'` or exact insert tests. + - Completed: 2026-07-17 + - Files changed: `cli/src/services/agent_trace_db/repository.rs` + - Evidence: `nix build .#checks.x86_64-linux.cli-tests .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt` pass. + - Notes: `RepositoryAgentTraceDb` now exposes repository-level write methods for diff traces, post-commit patch intersections, Agent Trace rows, messages, and parts by delegating to the existing generic insert helpers and SQL. Added repository-scoped insert coverage for all row families against the fresh one-file schema, with no `checkout_id` columns or checkout-provenance write API added. + +- [x] T07: `Use repository-level attribution queries` (status:done) + - Task ID: T07 + - Goal: Ensure recent diff trace reads and commit attribution decisions operate against the current repository-scoped database without checkout filtering. + - Boundaries (in/out of scope): In - preserve `recent_diff_trace_patches(cutoff, end)` repository-level semantics, post-commit intersection, tool/model selection, Agent Trace commit association, commit-msg staged-overlap preflight, tests for repository-level behavior. Out - adding `checkout_id` parameters or cross-checkout isolation guarantees. + - Done when: Commit attribution consumes recent traces from the current repository DB; different repositories remain isolated by repository ID and database path. + - Verification notes (commands or checks): targeted hooks/agent_trace_db tests covering same-repository shared DB behavior and different-repository DB separation. + - Completed: 2026-07-17 + - Files changed: `cli/src/services/agent_trace_db/repository.rs` + - Evidence: `nix build .#checks.x86_64-linux.cli-tests .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt` pass. + - Notes: Added `RepositoryAgentTraceDb::recent_diff_trace_patches(cutoff, end)` delegating to the shared recent diff-trace parser/query helper, preserving repository-level semantics with no checkout filter. Added repository DB tests for same-repository row loading across checkout-like session IDs and separate repository DB path isolation. Active hook/lifecycle opening remains deferred to T08 by plan scope. + +- [x] T08: `Wire hooks and lifecycle to repository storage context` (status:done) + - Task ID: T08 + - Goal: Update setup, doctor/lifecycle, and hook runtime opening to use repository-scoped storage context while keeping Agent Trace writes and queries repository-level. + - Boundaries (in/out of scope): In - `open_agent_trace_db_for_hook_runtime`, setup messages, lifecycle health, hook diagnostics, no-migration fast-path behavior against repository DBs. Out - trace list/status/shell UX changes beyond compilation needs. + - Done when: Setup initializes the repository-scoped database; hooks lazily resolve repository storage; errors mention repository identity guidance where relevant; checkout-scoped active DB opening is removed from runtime write paths. + - Verification notes (commands or checks): targeted setup/hooks/lifecycle tests; inspect `resolve_or_create_agent_trace_db_for_checkout` removal or legacy-only status. + - Completed: 2026-07-17 + - Files changed: `cli/src/services/hooks/mod.rs`, `cli/src/services/agent_trace_db/lifecycle.rs`, `cli/src/services/config/{mod.rs,resolver.rs,types.rs}`, `cli/src/services/agent_trace_db/mod.rs`, `cli/src/services/checkout/mod.rs`, `context/{overview.md,architecture.md,glossary.md,context-map.md}`, `context/cli/{agent-trace-storage.md,checkout-identity.md,cli-command-surface.md,default-path-catalog.md,repository-identity.md,service-lifecycle.md}`, `context/sce/{agent-trace-db.md,agent-trace-hooks-command-routing.md}` + - Evidence: `nix develop -c sh -c 'cd cli && cargo fmt'` pass; `nix build .#checks.x86_64-linux.cli-tests .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt` pass; `nix run .#pkl-check-generated` pass; `git diff --check` pass; `nix flake check` pass. Direct targeted `cargo test` was blocked by repo bash policy, so the Crane-backed CLI test derivation was used instead. + - Notes: Hook runtime now resolves `agent_trace.repository_id` / `agent_trace.repository_remote` config and opens `RepositoryAgentTraceDb` through `resolve_agent_trace_storage(...)`, so diff-trace, conversation-trace, post-commit, and commit-msg attribution read/write repository-level rows. Agent Trace lifecycle setup initializes repository-scoped storage and reports repository + checkout identity in setup messages; lifecycle health resolves the repository DB path from repository identity and uses no-migration schema checks against `RepositoryAgentTraceDb`. Legacy checkout DB opening remains present but `#[allow(dead_code)]` and no longer participates in active hook/lifecycle write paths; trace UX migration is deferred to T09. + +- [x] T09: `Update trace status/list/shell discovery with --legacy` (status:done) + - Task ID: T09 + - Goal: Make `sce trace status`, `trace db list`, status-all, and DB shell understand repository-scoped active databases while keeping legacy checkout DBs inspectable only through `--legacy`. + - Boundaries (in/out of scope): In - repository DB discovery under `repos//agent-trace.db`, active current-repo status, aliases/identifiers including repository ID, explicit `--legacy` discovery/listing/shell path for old checkout-scoped DBs, shell target metadata. Out - changing hook persistence behavior. + - Done when: Current status shows the current repository-scoped DB and checkout ID; list/status-all preserve separation between repositories; legacy `agent-trace-.db` files are hidden by default and available only through `--legacy`; shell opens the resolved repository DB or explicit legacy target only. + - Verification notes (commands or checks): targeted trace discovery/render/shell tests; assert multiple repository DBs remain separate. + - Completed: 2026-07-17 + - Files changed: `cli/src/cli_schema.rs`, `cli/src/services/command_registry.rs`, `cli/src/services/parse/command_runtime.rs`, `cli/src/services/trace/{command.rs,discovery.rs,mod.rs,render_list.rs,render_status.rs,render_status_all.rs,shell.rs,status.rs,status_all.rs}`, `context/{overview.md,context-map.md}`, `context/cli/{agent-trace-storage.md,checkout-identity.md,trace-command.md}`, `context/sce/agent-trace-db.md` + - Evidence: `nix develop -c sh -c 'cd cli && cargo fmt'` pass; `nix build .#checks.x86_64-linux.cli-tests .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt` pass. + - Notes: Trace discovery now defaults to repository-scoped DBs under `/sce/repos//agent-trace.db`; old checkout-scoped `agent-trace-.db` discovery is explicit via `--legacy`. `sce trace status` resolves active repository storage and reports repository ID + checkout ID diagnostics; `sce trace db shell` opens the current repository DB by default or resolves repository aliases/IDs, while `--legacy` requires an explicit legacy alias/checkout ID. List/status-all render scope + identifier fields for repository and legacy rows. + +- [x] T10: `Harden diagnostics and credential-safe output` (status:done) + - Task ID: T10 + - Goal: Update setup, doctor, trace status/list/shell, and related JSON/text renderers to use repository-scoped terminology and safe identity metadata. + - Boundaries (in/out of scope): In - render repository identity source, repository ID, safe canonical identity, configured remote, checkout ID, repository-scoped path, schema status; redact/avoid raw URLs. Out - new storage/query behavior. + - Done when: User-facing diagnostics never display credentials; active database is described as repository-scoped; actionable missing-identity guidance is visible. + - Verification notes (commands or checks): targeted render tests with credential-bearing remotes; `rg` for old active checkout-scoped wording in non-historical docs/code. + - Completed: 2026-07-17 + - Files changed: `cli/src/services/agent_trace_db/lifecycle.rs`, `cli/src/services/doctor/{inspect.rs,render.rs,types.rs}`, `cli/src/services/trace/{discovery.rs,render_status.rs,status.rs,stats.rs}`, `context/{architecture.md,context-map.md,glossary.md}`, `context/cli/cli-command-surface.md`, `context/sce/{agent-trace-hook-doctor.md,agent-trace-hooks-command-routing.md,shared-turso-db.md}` + - Evidence: `nix develop -c sh -c 'cd cli && cargo fmt'` pass; `nix build .#checks.x86_64-linux.cli-tests .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt` pass; `nix run .#pkl-check-generated` reports "Generated outputs are up to date."; `git diff --check` pass; focused `rg` found remaining checkout-scoped wording only in explicit legacy/historical surfaces. + - Notes: Setup lifecycle messages, doctor text/JSON output, and `sce trace status` text/JSON now expose repository ID, identity source, safe canonical identity, configured remote where applicable, checkout ID, and repository-scoped DB path without raw remote URLs. Added status coverage for credential-bearing remotes to assert safe canonical identity/path output. Shell skipped-DB diagnostics now label repository vs legacy checkout scope instead of assuming checkout IDs. + +- [x] T11: `Add end-to-end repository storage behavior tests` (status:done) + - Task ID: T11 + - Goal: Add integration-style tests covering repository separation, clone/worktree consolidation, repository-level attribution behavior, existing-data non-migration, and concurrent initialization. + - Boundaries (in/out of scope): In - temp Git repositories/clones/worktrees, multiple remotes, existing legacy DB byte-for-byte assertions, concurrent first-open test, empty new DB assertion. Out - production code changes except small testability seams discovered while writing tests. + - Done when: Repository separation, clone/worktree consolidation, security, repository-level DB behavior, existing-data, and concurrency cases are covered by automated tests where practical. + - Verification notes (commands or checks): targeted exact tests, then `nix develop -c sh -c 'cd cli && cargo test repository_scoped_agent_trace'` or matching module filters. + - Completed: 2026-07-17 + - Files changed: `cli/src/services/agent_trace_storage/mod.rs`, `cli/src/services/agent_trace_db/repository.rs` + - Evidence: `nix develop -c sh -c 'cd cli && cargo fmt'` pass; `nix build .#checks.x86_64-linux.cli-tests` pass; `nix build .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt` pass; `nix run .#pkl-check-generated` reports "Generated outputs are up to date."; `git diff --check` pass; `nix flake check` pass. + - Notes: Added storage-level integration tests for legacy checkout DB byte preservation/non-selection, fresh repository DB emptiness, repository-level diff-trace sharing across equivalent SSH/HTTPS clone checkouts, concurrent first open convergence, and credential-bearing remote URL canonicalization without secret leakage to IDs/paths. The concurrent first-open test exposed a narrow schema-metadata race; storage resolution now retries the fast-path/migrate sequence and repository DB opening can repair the case where all required schema tables exist but the one-file baseline migration record was not written after a concurrent first-open race. + +- [x] T12: `Document repository-scoped Agent Trace storage` (status:done) + - Task ID: T12 + - Goal: Update repository docs and SCE context to explain repository-scoped Agent Trace DBs and legacy DB handling. + - Boundaries (in/out of scope): In - `context/sce/agent-trace-db.md`, hook routing/status command docs, context map, README or CLI docs as relevant, example directory tree. Out - code behavior changes. + - Done when: Docs state that SCE creates one Agent Trace DB per logical Git repository; clones/worktrees share it; checkout ID remains a checkout identity but is not stored on trace rows; commit attribution is repository-level within the shared repository DB; old checkout DBs remain untouched and historical data is not migrated; no daemon/background service exists. + - Verification notes (commands or checks): docs review; `nix run .#pkl-check-generated` if generated docs/config are touched. + - Completed: 2026-07-17 + - Files changed: `README.md`, `cli/README.md`, `context/architecture.md`, `context/glossary.md`, `context/sce/agent-trace-db.md`, `context/sce/agent-trace-hooks-command-routing.md`, `context/cli/trace-command.md`, `context/context-map.md` + - Evidence: `git diff --check` pass; `nix run .#pkl-check-generated` reports "Generated outputs are up to date."; focused `rg` found remaining per-checkout AgentTraceDb wording only in historical/completed plan files. + - Notes: Added repository-scoped storage documentation with an example state directory tree, clone/worktree sharing semantics, checkout-ID-as-diagnostic-only boundary, repository-level attribution wording, explicit no-touch/no-migration legacy DB policy, and no-daemon/background-service statement. Updated README/CLI README and hook/trace docs to remove stale active per-checkout wording, then synced root context references for completed default trace UX. + +- [x] T13: `Final validation and cleanup` (status:done) + - Task ID: T13 + - Goal: Run full validation, remove temporary scaffolding, and sync context after implementation. + - Boundaries (in/out of scope): In - `nix flake check`, generated-output parity, focused searches for stale checkout-scoped active DB assumptions, context sync verification. Out - new feature behavior beyond fixes required by validation failures. + - Done when: Full checks pass; generated config is up to date; no temporary files remain; context docs reflect final behavior; plan status/evidence is updated. + - Verification notes (commands or checks): `nix flake check`; `nix run .#pkl-check-generated`; `git diff --check`; targeted `rg` for stale active `agent-trace-{checkout_id}.db` terminology excluding historical/legacy references. + - Completed: 2026-07-17 + - Files changed: `cli/src/services/checkout/mod.rs`, `context/sce/agent-trace-hook-doctor.md`, `context/plans/repository-scoped-agent-trace-db.md` + - Evidence: `nix run .#pkl-check-generated` reports "Generated outputs are up to date."; `nix flake check` pass; `git diff --check` pass; focused stale-active-checkout wording search found only legacy/historical/explicit `--legacy` references after cleanup; `context/tmp/sce.log` removed. + - Notes: Final cleanup clarified the legacy-only checkout DB helper comment and refreshed the doctor contract's `AgentTraceDbLifecycle` description to repository-scoped storage. No feature behavior changes were needed. + +## Validation Report + +### Commands run + +- `nix run .#pkl-check-generated` -> exit 0; reported "Generated outputs are up to date." +- `nix flake check` -> exit 0; reported "all checks passed!" +- `git diff --check` -> exit 0; no whitespace errors reported. +- `rg -n "validates checkout-scoped|active checkout-scoped|active .*checkout-scoped|selects? (the )?checkout-scoped|opens its\\s+per-checkout|setup/hooks .*checkout-scoped|hook runtime .*checkout-scoped" README.md cli/README.md context cli/src -g '!context/plans/**'` -> exit 0 with matches limited to legacy/historical/explicit `--legacy` references after cleanup. +- `find context/tmp -maxdepth 2 -type f -print | sort` -> only `context/tmp/.gitignore` remains after removing `context/tmp/sce.log`. + +### Success-criteria verification + +- [x] Each logical Git repository resolves to repository-scoped storage through `agent_trace_storage`; covered by prior T04/T08/T11 implementation evidence and final context review. +- [x] Active database path is documented and implemented as `/sce/repos//agent-trace.db`; verified in context/code docs and final stale-wording search. +- [x] Legacy checkout-scoped DBs remain untouched and explicit-legacy-only; verified by T11 tests and final stale-wording search. +- [x] Repository identity and credential-safe diagnostics are documented in root/domain context; verified during context-sync review. +- [x] Full checks and generated-output parity pass; verified by commands above. +- [x] Temporary scaffolding removed; `context/tmp/sce.log` deleted and only `.gitignore` remains. + +### Residual risks + +- None identified. + +## Open questions + +None blocking. Decisions captured in this plan: + +- Config keys: `agent_trace.repository_id` and `agent_trace.repository_remote`. +- Legacy checkout-scoped DBs are hidden by default and inspectable through `--legacy`. +- Repository-scoped Agent Trace DBs use one fresh schema SQL file. +- No `checkout_id` columns are added to Agent Trace row tables. +- Attribution is repository-level within each repository-scoped DB, not checkout-scoped. diff --git a/context/plans/retire-legacy-agent-trace-db.md b/context/plans/retire-legacy-agent-trace-db.md new file mode 100644 index 00000000..d00ee9bb --- /dev/null +++ b/context/plans/retire-legacy-agent-trace-db.md @@ -0,0 +1,149 @@ +# Retire legacy checkout-scoped Agent Trace DB surface + +## Change summary + +The repository-scoped Agent Trace DB migration (`d7fb455..0d241db`, plan `repository-scoped-agent-trace-db.md`) is functionally complete: active setup, hooks, lifecycle, and trace UX resolve repository-scoped storage through `agent_trace_storage` and `RepositoryAgentTraceDb`. The migration intentionally retained a "legacy surface" — a dead per-checkout DB opener, stale `#[allow(dead_code)]` attributes, test-only legacy insert methods, the `sce trace --legacy` CLI surface, and the entire legacy `AgentTraceDb` / `AgentTraceDbSpec` adapter plus its 15 incremental migration files. + +This plan fully retires that legacy surface and reverses the prior "retain legacy checkout DBs, inspectable via `--legacy`, byte-for-byte untouched" policy. After this change: + +- There is exactly one Agent Trace DB adapter: `RepositoryAgentTraceDb`. +- `sce trace` has no `--legacy` flag; only repository-scoped DBs are discoverable. +- The legacy `AgentTraceDb` type, `AgentTraceDbSpec`, `agent_trace_db_path()`, `agent_trace_db_path_for_checkout()`, `AGENT_TRACE_MIGRATIONS`, and `cli/migrations/agent-trace/*.sql` are deleted. +- Existing on-disk legacy checkout DB files are still never migrated, imported, renamed, or deleted by SCE; they simply become uninspectable through the CLI. + +Initial code inspection found the legacy surface in: + +- `cli/src/services/checkout/mod.rs` — dead `resolve_or_create_agent_trace_db_for_checkout` (`#[allow(dead_code)]`, no code callers). +- `cli/src/services/default_paths.rs` — `agent_trace_db_path_for_checkout` (sole caller is the dead opener) and `agent_trace_db_path()` (legacy global sentinel). +- `cli/src/services/agent_trace_db/mod.rs` — legacy `AgentTraceDb` / `AgentTraceDbSpec`, dead `open_for_hooks_without_migrations`, test-only insert methods, and stale `#[allow(dead_code)]` on items actually used by `RepositoryAgentTraceDb`. +- `cli/src/services/agent_trace_db/lifecycle.rs` — no-repo-root fallback to the global `agent_trace_db_path()` sentinel. +- `cli/src/services/trace/` — `--legacy` flags, `LegacyCheckout` discovery kind, `discover_legacy_agent_trace_dbs*`, `resolve_current_legacy_status_in`, `StatusError`, and `if *legacy` branches in `command.rs`; `stats.rs` / `shell.rs` / `discovery.rs` reuse `AgentTraceDb` as the read-only wrapper for repository DBs. +- `cli/src/generated_migrations.rs` and `cli/migrations/agent-trace/` — the 15-file legacy migration chain. +- Context docs documenting the retained-legacy policy. + +## Success criteria + +- `AgentTraceDb`, `AgentTraceDbSpec`, `AgentTraceDbSpec`-only methods, `agent_trace_db_path()`, `agent_trace_db_path_for_checkout()`, `AGENT_TRACE_MIGRATIONS`, and all 15 `cli/migrations/agent-trace/*.sql` files no longer exist in the source tree. +- `RepositoryAgentTraceDb` is the sole Agent Trace DB adapter and is used by stats, shell, discovery/readiness, lifecycle, hooks, and setup. +- `sce trace status`, `trace db list`, `trace status --all`, and `trace db shell` have no `--legacy` flag and operate only on repository-scoped DBs. +- `sce doctor` / `sce setup` run outside a Git repository no longer probe a global `agent_trace.db` sentinel; they report an actionable "not running inside a Git repository" diagnostic instead. +- No stale `#[allow(dead_code)]` attributes remain on items that are actually used by `RepositoryAgentTraceDb` or active trace code. +- Existing on-disk legacy checkout DB files are not migrated, imported, renamed, or deleted by any SCE code path. +- Context docs and a decision record reflect the policy reversal; the prior retained-legacy wording is removed from current-state context. +- `nix flake check` passes; `nix run .#pkl-check-generated` reports generated outputs are up to date when generated assets are touched. + +## Constraints and non-goals + +- Do not migrate, import, copy, rename, delete, archive, or backfill existing on-disk legacy checkout DB files. They become uninspectable via the CLI, but SCE must not touch them. +- Do not add a new global/checkout fallback DB path. When repository identity cannot be resolved (including the no-repo-root doctor/setup case), produce an actionable error, not a sentinel path. +- Do not change repository-scoped DB schema, row shapes, or write/query semantics. This is a retirement of legacy code, not a schema change. +- Do not change hook attribution, post-commit intersection, or commit-msg preflight behavior beyond swapping the adapter type where they already use `RepositoryAgentTraceDb`. +- Keep each task landable as one coherent commit; do not bundle unrelated changes. +- Out of scope: removing the checkout identity infrastructure itself (`/sce/checkout-id`), which remains a repository-scoped diagnostic metadata input. + +## Task stack + +- [x] T01: `Remove dead checkout DB opener and its path helper` (status:done) + - Task ID: T01 + - Goal: Delete the dead `resolve_or_create_agent_trace_db_for_checkout` function and the now-orphaned `agent_trace_db_path_for_checkout` path helper, plus their now-unused imports in `checkout/mod.rs`. + - Boundaries (in/out of scope): In - `cli/src/services/checkout/mod.rs` (remove `resolve_or_create_agent_trace_db_for_checkout` at ~lines 140-177, its `#[allow(dead_code)]`, and the `agent_trace_db::AgentTraceDb` / `default_paths::agent_trace_db_path_for_checkout` imports once unused), `cli/src/services/default_paths.rs` (remove `agent_trace_db_path_for_checkout` at ~lines 294-310). Out - any other `AgentTraceDb` usage, the `--legacy` surface, `agent_trace_db_path()` (still used by lifecycle/spec), test seeders. + - Done when: `resolve_or_create_agent_trace_db_for_checkout` and `agent_trace_db_path_for_checkout` no longer exist; `checkout/mod.rs` compiles without unused-import warnings; `nix flake check` passes. + - Verification notes (commands or checks): `rg -n "resolve_or_create_agent_trace_db_for_checkout|agent_trace_db_path_for_checkout" cli/src` returns no matches; `nix build .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt .#checks.x86_64-linux.cli-tests` pass. + - Status: done + - Completed: 2026-07-17 + - Files changed: `cli/src/services/checkout/mod.rs`, `cli/src/services/default_paths.rs` + - Evidence: `rg` returns no matches for the two removed symbols; `nix build .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt .#checks.x86_64-linux.cli-tests` all pass (exit 0). + - Notes: Pure dead-code removal; both `use` items became unused after deleting the function and were removed. Verify-only for context sync (no cross-cutting change). + +- [x] T02: `Remove dead open_for_hooks_without_migrations and fix stale allow(dead_code) attributes` (status:done) + - Task ID: T02 + - Goal: Remove the genuinely-dead `AgentTraceDb::open_for_hooks_without_migrations` (no-`_at` variant) and drop stale `#[allow(dead_code)]` from items now actively used by `RepositoryAgentTraceDb` or trace command code. + - Boundaries (in/out of scope): In - `cli/src/services/agent_trace_db/mod.rs` (remove `open_for_hooks_without_migrations` at ~line 268; drop stale `#[allow(dead_code)]` + outdated T08 comment on `pub mod repository` at ~lines 21-24; drop stale `#[allow(dead_code)]` on `INSERT_MESSAGE_SQL`, `INSERT_PART_SQL`, `MessageRole`, `InsertMessageInsert`, `PartType`, `InsertPartInsert`); `cli/src/services/trace/discovery.rs` (drop stale `#[allow(dead_code)]` on items actively called by `command.rs`: `DiscoveredAgentTraceDb`, `ResolveAgentTraceDbError`, `resolve_agent_trace_db_identifier`, `discover_agent_trace_dbs`). Out - the block-level `#[allow(dead_code)]` on `impl AgentTraceDb` blocks (kept until T06 removes the whole type); the test-only `insert_*` methods on `AgentTraceDb` (retired in T04/T06); `agent_trace_db_path()`; the `--legacy` surface. + - Done when: removed function has no references; dropped `#[allow(dead_code)]` attributes do not produce new warnings (items are genuinely used); `nix flake check` passes. + - Verification notes (commands or checks): `rg -n "open_for_hooks_without_migrations\b" cli/src` returns no matches (note: `open_for_hooks_without_migrations_at` must still exist); `nix build .#checks.x86_64-linux.cli-clippy` passes with no new dead-code warnings. + - Status: done + - Completed: 2026-07-17 + - Files changed: `cli/src/services/agent_trace_db/mod.rs`, `cli/src/services/agent_trace_db/repository.rs`, `cli/src/services/trace/discovery.rs` + - Evidence: `rg -n "open_for_hooks_without_migrations\b" cli/src` returns no matches; `open_for_hooks_without_migrations_at` still exists (mod.rs + 8 trace call sites). `nix build .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt .#checks.x86_64-linux.cli-tests` all pass (exit 0). + - Notes: Removing the module-level `#[allow(dead_code)]` on `pub mod repository` (as specified) surfaced two genuinely-dead test-only methods it had been masking — `RepositoryAgentTraceDb::insert_message`/`insert_part`. Added per-method `#[allow(dead_code)]` to those two (mirroring the legacy `AgentTraceDb` singular-insert pattern) so the module-level allow could be dropped without a warning; `insert_messages`/`insert_parts` remain live via hooks. The `INSERT_MESSAGE_SQL`/`INSERT_PART_SQL` allow-removal risk noted at review did not materialize — clippy stayed clean. Verify-only for context sync (localized dead-code cleanup, no cross-cutting change). + +- [x] T03: `Remove the --legacy trace CLI surface` (status:done) + - Task ID: T03 + - Goal: Delete the `--legacy` flag, `LegacyCheckout` discovery kind, legacy discovery/status/shell branches, `StatusError`, and associated legacy-only tests, so `sce trace` operates only on repository-scoped DBs. + - Boundaries (in/out of scope): In - `cli/src/cli_schema.rs` (remove `legacy` clap args on `Status`/`TraceDb::List`/`TraceDb::Shell`); `cli/src/services/trace/mod.rs` (remove `legacy` fields from `TraceSubcommandRequest::*` and the `discover_legacy_agent_trace_dbs` import); `cli/src/services/command_registry.rs` and `cli/src/services/parse/command_runtime.rs` (remove `legacy` plumbing and the `status-all` default-`false` shortcut); `cli/src/services/trace/command.rs` (remove `if *legacy` branches and the `"sce trace db shell --legacy requires a checkout ID or alias"` validation message); `cli/src/services/trace/discovery.rs` (remove `DiscoveredAgentTraceDbKind::LegacyCheckout`, `discover_legacy_agent_trace_dbs`, `discover_legacy_agent_trace_dbs_in`); `cli/src/services/trace/status.rs` (remove `StatusError`, `resolve_current_legacy_status_in`, the `#[cfg(test)]` `resolve_current_status_at_state_root` helper if legacy-only, and the legacy-retention module doc); `cli/src/services/trace/status_all.rs` (remove `legacy` parameter from `aggregate_current_status_all` / `aggregate_status_all_in`); `cli/src/services/trace/render_status.rs` (remove the `"legacy_checkout"` JSON discriminator branch); legacy-only tests in `render_list.rs` / `render_status_all.rs` / `status_all.rs` that exercise `discover_legacy_agent_trace_dbs_in`. Out - `AgentTraceDb` reuse in `probe_readiness` / `collect_agent_trace_db_stats` / `run_agent_trace_db_shell` (switched in T05); `agent_trace_db_path()`; test seeders (migrated in T04). + - Done when: `rg -n "legacy" cli/src/cli_schema.rs cli/src/services/trace cli/src/services/command_registry.rs cli/src/services/parse/command_runtime.rs` returns no `--legacy` flag/field/branch references; `sce trace --help` shows no `--legacy` option; `nix flake check` passes. + - Verification notes (commands or checks): `rg -n "\\blegacy\\b" cli/src/cli_schema.rs cli/src/services/trace cli/src/services/command_registry.rs cli/src/services/parse/command_runtime.rs` returns no matches; `nix develop -c sh -c 'cd cli && cargo run -- trace --help'` shows no `--legacy`; `nix build .#checks.x86_64-linux.cli-tests .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt` pass. + - Status: done + - Completed: 2026-07-17 + - Files changed: `cli/src/cli_schema.rs`, `cli/src/services/trace/mod.rs`, `cli/src/services/parse/command_runtime.rs`, `cli/src/services/command_registry.rs`, `cli/src/services/trace/command.rs`, `cli/src/services/trace/discovery.rs`, `cli/src/services/trace/status.rs`, `cli/src/services/trace/status_all.rs`, `cli/src/services/trace/render_status.rs`, `cli/src/services/trace/render_list.rs`, `cli/src/services/trace/render_status_all.rs`, `cli/src/services/trace/shell.rs` + - Evidence: `rg -n "\\blegacy\\b|Legacy" cli/src/cli_schema.rs cli/src/services/trace cli/src/services/command_registry.rs cli/src/services/parse/command_runtime.rs` returns no matches (exit 1). `sce trace status|db list|db shell --help` show no `--legacy` option. `nix build .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt .#checks.x86_64-linux.cli-tests` all pass (172 tests, exit 0). + - Notes: `StatusErrorOrRuntime` reduced to a single `Runtime` variant (kept to avoid churning `resolve_current_status`/`resolve_current_status_at_state_root` signatures; `StatusError` and `resolve_current_legacy_status_in` removed). Legacy-only tests that seeded flat `agent-trace-.db` files via the legacy `AgentTraceDb` adapter and called `discover_legacy_agent_trace_dbs_in` were removed in `discovery.rs`, `status.rs`, `status_all.rs`, `render_list.rs`, `render_status_all.rs`; the repository-schema and empty-dir/`&[]`-shape tests were kept. This leaves `aggregate_status_all_in` and the mixed-row render/list paths with reduced coverage until T04/T05 re-establish repository-schema-seeded coverage. Also normalized a stale `"legacy checkout"` `ShellTarget.scope` test fixture in `shell.rs` to `"repository"` (shell's `AgentTraceDb` read-path swap remains T05). Important-change note: this removes a user-facing CLI surface (`--legacy`); the context-doc reflection (`trace-command.md`, `checkout-identity.md`, etc.) is owned by T07, so this task is verify-only for root shared-file churn. + +- [x] T04: `Migrate test seeders from AgentTraceDb to RepositoryAgentTraceDb` (status:done) + - Task ID: T04 + - Goal: Switch all test DB-seeding helpers from `AgentTraceDb::open_at` (legacy migration chain) to `RepositoryAgentTraceDb::new_at` (repository schema), so no test depends on the legacy adapter. + - Boundaries (in/out of scope): In - test `seed_db` / fixture helpers in `cli/src/services/trace/stats.rs`, `cli/src/services/trace/status_all.rs`, `cli/src/services/trace/render_status_all.rs`, `cli/src/services/trace/render_list.rs`, and any other `trace` test module that opens a DB via `AgentTraceDb::open_at` / `AgentTraceDb::open_for_hooks_without_migrations_at` to seed rows; update imports from `agent_trace_db::{AgentTraceDb, ...}` to `agent_trace_db::repository::{RepositoryAgentTraceDb, ...}` (and the repository insert structs where names differ). Out - production `collect_agent_trace_db_stats` / `run_agent_trace_db_shell` / `probe_readiness` (switched in T05); the `AgentTraceDb` type itself (removed in T06); schema assertions that rely on legacy-only tables/columns (none expected — repository schema has the same trace tables). + - Done when: `rg -n "AgentTraceDb::(open_at|open_for_hooks_without_migrations_at|insert_)" cli/src/services/trace` returns no matches in test modules; all trace tests pass against repository-scoped schema; `nix flake check` passes. + - Verification notes (commands or checks): `rg -n "AgentTraceDb" cli/src/services/trace` returns no matches outside `stats.rs`/`shell.rs`/`discovery.rs` production read paths (those are T05); `nix build .#checks.x86_64-linux.cli-tests` pass. + - Status: done + - Completed: 2026-07-17 + - Files changed: `cli/src/services/trace/stats.rs`, `cli/src/services/trace/shell.rs`, `cli/src/services/agent_trace_db/repository.rs`, `cli/src/services/agent_trace_storage/mod.rs` + - Evidence: `rg -n "AgentTraceDb::(open_at|insert_)" cli/src/services/trace` returns no matches (test seeders migrated). Remaining `AgentTraceDb` in trace is only `stats.rs`/`discovery.rs` production read paths (T05); `shell.rs` is fully off `AgentTraceDb`. `nix build .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt .#checks.x86_64-linux.cli-tests` all pass (171 tests, exit 0). `nix run .#pkl-check-generated` reports generated outputs up to date. + - Notes: `stats.rs` seeders migrated cleanly to `RepositoryAgentTraceDb::new_at` (insert structs still imported from `agent_trace_db`; no `repository_metadata` prerequisite for inserts). `shell.rs` test seeders are coupled to the production shell open path (`run_agent_trace_db_shell{,_with_db}` took `&AgentTraceDb`, and the path-based readiness check compares `__sce_migrations` against `M::migrations()`, so a repository-schema file cannot open through the legacy adapter). Per user approval, the T05 shell open-path switch was pulled forward: added `RepositoryAgentTraceDb::open_for_hooks_without_migrations_at` in `repository.rs`, retyped `run_agent_trace_db_shell{,_with_db}` / `render_sql_result` / `render_tables` / `execute_sql` to `&RepositoryAgentTraceDb`, and migrated both shell seeders. Migrating the seeders to the faster one-file repository schema (vs. the legacy 15-migration chain) tightened test timing and exposed pre-existing flakiness in the repository/storage concurrency tests: writes go through `run_with_retry_sync` over `experimental_multiprocess_wal(true)`, so a plain `INSERT` retried under WAL contention is non-idempotent (observed intermittent "+1 row" in `repository_scoped_write_methods_insert_all_agent_trace_rows` and `recent_diff_trace_reads_all_repository_rows_without_checkout_filter`), driven by the 4-thread barrier stress test `concurrent_first_open_converges_on_one_repository_database`. Per user direction ("drop flaky tests"), removed that barrier stress test (and its now-unused `use std::sync::{Arc, Barrier};` import) in `agent_trace_storage/mod.rs`; the two functional tests then passed deterministically. The underlying non-idempotent-write-retry robustness gap in `db/mod.rs` remains a separate out-of-scope concern. Important-change note for context sync: T04 pulled forward the `shell.rs` production read-path switch that T07 context docs (`trace-command.md`) partly attribute to T05, and dropped a repository-storage concurrency test; flag T05/T07 for follow-up alignment. + +- [x] T05: `Switch stats/discovery read paths to RepositoryAgentTraceDb` (status:done) + - Task ID: T05 + - Goal: Replace `AgentTraceDb` with `RepositoryAgentTraceDb` as the read-only open-without-migrations wrapper in `probe_readiness` and `collect_agent_trace_db_stats`, so `AgentTraceDb` has no remaining production caller. (The `shell.rs` `run_agent_trace_db_shell` read-path switch and the `RepositoryAgentTraceDb::open_for_hooks_without_migrations_at` inherent method were pulled forward into T04.) + - Boundaries (in/out of scope): In - `cli/src/services/trace/stats.rs` (`collect_agent_trace_db_stats` and its `count_rows` / `query_optional_*` helpers open via `RepositoryAgentTraceDb::open_for_hooks_without_migrations_at` — already added to `repository.rs` in T04); `cli/src/services/trace/discovery.rs` (`probe_readiness` opens discovered repository DBs via `RepositoryAgentTraceDb`). Out - `shell.rs` (already switched in T04); the `AgentTraceDb` type definition and `AgentTraceDbSpec` (removed in T06); `agent_trace_db_path()`; lifecycle no-repo-root fallback (T06). + - Done when: `rg -n "\\bAgentTraceDb\\b" cli/src/services/trace cli/src/services/hooks cli/src/services/agent_trace_db/lifecycle.rs` returns no matches; `RepositoryAgentTraceDb` is the only DB type referenced by trace read paths; `nix flake check` passes. + - Verification notes (commands or checks): `rg -n "\\bAgentTraceDb\\b" cli/src/services/trace` returns no matches; `nix build .#checks.x86_64-linux.cli-tests .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt` pass. + - Status: done + - Completed: 2026-07-17 + - Files changed: `cli/src/services/trace/stats.rs`, `cli/src/services/trace/discovery.rs` + - Evidence: `rg -n "\\bAgentTraceDb\\b" cli/src/services/trace` returns no matches (exit 1). `nix build .#checks.x86_64-linux.cli-tests .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt` all built (exit 0). + - Notes: Mechanical adapter swap — `collect_agent_trace_db_stats` + its `count_rows`/`query_optional_i64`/`query_optional_string` helpers and `probe_readiness` now open via `RepositoryAgentTraceDb::open_for_hooks_without_migrations_at` (added in T04). No remaining production caller of the legacy `AgentTraceDb` type in trace read paths; only `hooks/mod.rs` log strings and the `LifecycleProviderId::AgentTraceDb` enum variant contain the substring (not the type). The legacy `AgentTraceDb` type/spec/path/lifecycle fallback removal remains T06. Verify-only for context sync (localized adapter swap, no cross-cutting behavior change). + +- [x] T06: `Remove legacy AgentTraceDb adapter, spec, path, and migration chain` (status:done) + - Task ID: T06 + - Goal: Delete the legacy `AgentTraceDb` type, `AgentTraceDbSpec`, `agent_trace_db_path()`, `AGENT_TRACE_MIGRATIONS`, the 15 `cli/migrations/agent-trace/*.sql` files, and rework the lifecycle no-repo-root fallback to an actionable error instead of the global sentinel path. + - Boundaries (in/out of scope): In - `cli/src/services/agent_trace_db/mod.rs` (remove `AgentTraceDbSpec`, `pub type AgentTraceDb`, the `impl AgentTraceDb` blocks and their block-level `#[allow(dead_code)]`, the legacy `open_at` / `open_for_hooks_without_migrations_at` inherent methods, and the `use ... default_paths::agent_trace_db_path` import); `cli/src/services/default_paths.rs` (remove `agent_trace_db_path()`); `cli/src/generated_migrations.rs` (remove `AGENT_TRACE_MIGRATIONS`); `cli/migrations/agent-trace/` (delete all 15 `*.sql` files); `cli/build.rs` (remove the `agent-trace` migration directory from the generation source if it is enumerated separately); `cli/src/services/agent_trace_db/lifecycle.rs` (rework `resolve_lifecycle_agent_trace_db_path` so that `repo_root == None` returns an actionable error such as "Agent Trace diagnostics require a Git repository; run 'sce doctor' inside a repository or configure agent_trace.repository_id in .sce/config.json" instead of falling back to `agent_trace_db_path()`; remove the `agent_trace_db_path` import). Out - `RepositoryAgentTraceDb` / `RepositoryAgentTraceDbSpec` / `agent_trace_db_path_for_repository*` / `AGENT_TRACE_REPOSITORY_MIGRATIONS` / `cli/migrations/agent-trace-repository/`; checkout identity infrastructure; hook attribution behavior. + - Done when: `rg -n "\\bAgentTraceDb\\b|AgentTraceDbSpec|agent_trace_db_path\\b|AGENT_TRACE_MIGRATIONS" cli/src` returns no matches; `cli/migrations/agent-trace/` no longer exists; `sce doctor` / `sce setup` run outside a Git repository reports the actionable no-repository diagnostic instead of probing a global path; `nix flake check` passes. + - Verification notes (commands or checks): `rg -n "AgentTraceDbSpec|\\bAgentTraceDb\\b|agent_trace_db_path\\b|AGENT_TRACE_MIGRATIONS" cli/src` returns no matches; `ls cli/migrations/agent-trace` reports not found; `nix build .#checks.x86_64-linux.cli-tests .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt` pass; `nix run .#pkl-check-generated` reports up to date (regenerated migrations constant may change). + - Status: done + - Completed: 2026-07-17 + - Files changed: `cli/src/services/agent_trace_db/mod.rs`, `cli/src/services/default_paths.rs`, `cli/src/services/agent_trace_db/lifecycle.rs`, `cli/src/generated_migrations.rs`, deleted `cli/migrations/agent-trace/` (15 `*.sql` files) + - Evidence: `rg -n "\\bAgentTraceDb\\b|AgentTraceDbSpec|agent_trace_db_path\\b|AGENT_TRACE_MIGRATIONS" cli/src` no longer matches the legacy type/spec/path/migration constant — the only residual `AgentTraceDb`/`AgentTraceDbSpec` substring hits are the retained `LifecycleProviderId::AgentTraceDb` enum variant, `Repository*` types, and `hooks/mod.rs` log strings (all pre-acknowledged in T05). `agent_trace_db_path\b` and `AGENT_TRACE_MIGRATIONS` return zero matches. `ls cli/migrations/agent-trace` → not found. `nix build .#checks.x86_64-linux.cli-fmt .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-tests` all pass (exit 0); `nix run .#pkl-check-generated` reports "Generated outputs are up to date" (exit 0). + - Notes: `build.rs` auto-discovers migration directories, so deleting `cli/migrations/agent-trace/` drops `AGENT_TRACE_MIGRATIONS` on regeneration with no `build.rs` change (the directory was not enumerated separately); `generated_migrations.rs` was hand-trimmed to the build.rs output and the passing sandbox build confirms parity. The mod.rs `#[cfg(test)]` module (`TestAgentTraceDbSpec`/`BaselineAgentTraceDbSpec`, keyed off `AGENT_TRACE_MIGRATIONS`) was reworked: the `recent_diff_trace_patches_*` parse-accounting test (bounded window, malformed-skip accounting, same-time id ordering) was migrated onto `RepositoryAgentTraceDb::new_at` to preserve that unique coverage, and the redundant `new_applies_baseline_agent_trace_migration_and_indexes` legacy-15-chain test was dropped (repository.rs `open_at_initializes_the_full_schema_from_one_migration` covers the equivalent one-file schema). The lifecycle `repo_root == None` path now bails with the actionable no-repository diagnostic; `diagnose_agent_trace_db_health` already surfaces that `Err` as a `ManualOnly` `UnableToResolveStateRoot` problem, so `fix` never attempts a sentinel-path bootstrap. Important-change note for context sync: this removes the legacy Agent Trace adapter surface described in root context (`context/sce/agent-trace-db.md`, glossary "agent trace DB adapter", overview, context-map "checkout registry"/agent-trace entries) — but all context-doc reflection of the policy reversal is owned by T07, so T06 is verify-only for root shared-file churn and flags T07 to update those entries. + +- [x] T07: `Update context docs and record policy reversal decision` (status:done) + - Task ID: T07 + - Goal: Update current-state context to reflect full legacy retirement and record the decision reversing the prior retained-legacy policy. + - Boundaries (in/out of scope): In - `context/sce/agent-trace-db.md` (remove legacy `AgentTraceDb` / `AgentTraceDbSpec` / `agent_trace_db_path()` / `agent_trace_db_path_for_checkout()` / 15-migration-chain / `--legacy` inspection wording; state `RepositoryAgentTraceDb` is the sole adapter and legacy checkout DBs are uninspectable via the CLI but never touched); `context/cli/checkout-identity.md` (remove "legacy per-checkout DB helpers retained" and `resolve_or_create_agent_trace_db_for_checkout` references); `context/cli/agent-trace-storage.md` (remove legacy checkout resolver retention note); `context/cli/default-path-catalog.md` (remove `agent_trace_db_path_for_checkout` legacy entry and `agent_trace_db_path()` if listed); `context/cli/trace-command.md` (remove `--legacy` discovery/list/status/shell wording); `context/context-map.md` and `context/glossary.md` (remove legacy/`--legacy` references in affected entries); new `context/decisions/-retire-legacy-agent-trace-db.md` (record the policy reversal: legacy checkout DBs are no longer inspectable via the CLI; SCE still never migrates/renames/deletes on-disk legacy files; rationale and date). Out - code changes; the completed `repository-scoped-agent-trace-db.md` plan (historical, left as-is). + - Done when: `rg -n "legacy|--legacy|agent_trace_db_path_for_checkout|resolve_or_create_agent_trace_db_for_checkout" context/cli context/sce/agent-trace-db.md context/context-map.md context/glossary.md` returns only the new decision record and explicit historical-plan references; a dated decision record exists under `context/decisions/`. + - Verification notes (commands or checks): focused `rg` over `context/` excluding `context/plans/**`; decision record present and dated; `nix run .#pkl-check-generated` reports up to date (no generated assets expected to change). + - Status: done + - Completed: 2026-07-17 + - Files changed: `context/sce/agent-trace-db.md`, `context/cli/checkout-identity.md`, `context/cli/agent-trace-storage.md`, `context/cli/default-path-catalog.md`, `context/cli/trace-command.md`, `context/cli/cli-command-surface.md`, `context/cli/service-lifecycle.md`, `context/context-map.md`, `context/glossary.md`, `context/overview.md`, `context/architecture.md`, `context/patterns.md`, `context/sce/shared-turso-db.md`, new `context/decisions/2026-07-17-retire-legacy-agent-trace-db.md` + - Evidence: Acceptance `rg` over `context/cli context/sce/agent-trace-db.md context/context-map.md context/glossary.md` now returns only explicit historical references — `retire-legacy-agent-trace-db` plan-slug mentions, retirement notes naming the removed `--legacy` flag / `discover_legacy_agent_trace_dbs*` function, the new decision-record pointer, and the unrelated `atomic-commits` legacy-wording glossary entry (different domain). `agent_trace_db_path_for_checkout` / `resolve_or_create_agent_trace_db_for_checkout` return zero non-plan-slug matches across `context/` (excl. plans). Removed symbols (`AgentTraceDbSpec`, `agent_trace_db_path()`, `AGENT_TRACE_MIGRATIONS`) no longer appear in root files or domain docs except as retirement statements. Dated decision record present. `nix run .#pkl-check-generated` → "Generated outputs are up to date" (context docs do not feed pkl generation). + - Notes: Scope expansion (pre-approved by user): corrected `context/cli/cli-command-surface.md` (lines 66, 107) and `context/cli/service-lifecycle.md` (line 35) — stale `--legacy`/global-fallback references the acceptance grep scans (whole `context/cli`), stale after T06 replaced the no-repo fallback with an actionable error. Context-sync mandatory root pass surfaced further stale code-truth divergence: `context/overview.md`, `context/architecture.md`, and `context/patterns.md` still described the removed `AgentTraceDb`/`AgentTraceDbSpec` adapter, the `sce trace --legacy` surface, and the global fallback as live; per the important-change (policy reversal + terminology) gate these root files were updated to code truth, and `context/sce/shared-turso-db.md` (stale `AGENT_TRACE_MIGRATIONS` example + legacy adapter description) was corrected. Left remaining generic bare `\bAgentTraceDb\b` prose mentions (overview hooks-routing sentences, patterns 136–138) for T08's broader `AgentTraceDb`-substring cleanup grep — those read as "the Agent Trace DB" generically and are out of T07's `legacy`/`--legacy` acceptance pattern. + +- [x] T08: `Final validation and cleanup` (status:done) + - Task ID: T08 + - Goal: Run full validation, confirm no temporary scaffolding remains, and verify context reflects final behavior. + - Boundaries (in/out of scope): In - `nix flake check`, generated-output parity, focused searches for residual legacy/`--legacy`/`AgentTraceDb` references across `cli/src` and `context/` (excluding `context/plans/**`), `git diff --check`, context sync verification. Out - new feature behavior beyond fixes required by validation failures. + - Done when: Full checks pass; generated config is up to date; no temporary files remain; no residual legacy references remain outside historical plan files and the new decision record; plan status/evidence is updated. + - Verification notes (commands or checks): `nix flake check`; `nix run .#pkl-check-generated`; `git diff --check`; `rg -n "\\bAgentTraceDb\\b|AgentTraceDbSpec|agent_trace_db_path|AGENT_TRACE_MIGRATIONS|--legacy|resolve_or_create_agent_trace_db_for_checkout" cli/src context -g '!context/plans/**'` returns no matches outside the new decision record's explicit historical references. + - Status: done + - Completed: 2026-07-17 + - Files changed: `context/cli/structured-patch-service.md`, `context/sce/agent-trace-commit-msg-coauthor-policy.md` + - Evidence: `nix flake check --print-build-logs` → "all checks passed!" (exit 0; cli-tests, cli-clippy, cli-fmt, flatpak/workflow/JS checks). `nix run .#pkl-check-generated` → "Generated outputs are up to date." (exit 0). `git diff --check` clean. `cli/migrations/agent-trace/` absent. Residual grep over `cli/src` + `context/` (excl. `context/plans/**`): no legacy `AgentTraceDb`/`AgentTraceDbSpec` type, `agent_trace_db_path()` global sentinel, `agent_trace_db_path_for_checkout`, `AGENT_TRACE_MIGRATIONS`, `--legacy`, or `resolve_or_create_agent_trace_db_for_checkout` symbols remain. All surviving matches are legitimate current-state: the sole `agent_trace_db_path_for_repository{,_at}` helper, `RepositoryAgentTraceDbSpec` (matched by the `AgentTraceDbSpec` substring), the `LifecycleProviderId::AgentTraceDb` enum variant, hooks/mod.rs user-facing log strings ("…persisted…to AgentTraceDb…"), and explicit "removed by the `retire-legacy-agent-trace-db` plan" retirement statements. + - Notes: Validation surfaced two genuinely-stale doc references that T07 had deferred to T08's broader `AgentTraceDb` grep — `context/cli/structured-patch-service.md:31` (`AgentTraceDb::recent_diff_trace_patches`) and `context/sce/agent-trace-commit-msg-coauthor-policy.md:42` (`AgentTraceDb::open_for_hooks_without_migrations()`); both named methods that now live only on `RepositoryAgentTraceDb` (repository.rs:166 / repository.rs:81) and were corrected to `RepositoryAgentTraceDb::recent_diff_trace_patches` / `RepositoryAgentTraceDb::open_for_hooks_without_migrations_at()`. Remaining generic "AgentTraceDb" prose across context docs was intentionally left: it mirrors the runtime log strings in `hooks/mod.rs` (code truth) rather than referencing the removed type, so rewording it would diverge docs from the emitted log text. Untracked `sce.log` at repo root is a runtime log written by the repo's own live SCE hooks this session (not gitignored, not tracked), not task scaffolding; left untouched. Verify-only for context sync (two localized doc corrections; no policy/architecture change beyond what T07 landed). + +## Open questions + +None blocking. Decisions captured in this plan: + +- Full legacy retirement (Tier 1-4) is in scope; the prior "retain legacy checkout DBs, inspectable via `--legacy`, byte-for-byte untouched" policy is reversed. +- SCE still never migrates, imports, renames, or deletes existing on-disk legacy checkout DB files; they become uninspectable via the CLI. +- The no-repo-root doctor/setup case produces an actionable error instead of probing a global `agent_trace.db` sentinel path. +- Checkout identity infrastructure (`/sce/checkout-id`) remains in scope as repository-scoped diagnostic metadata; only the legacy DB adapter surface is retired. \ No newline at end of file diff --git a/context/sce/agent-trace-commit-msg-coauthor-policy.md b/context/sce/agent-trace-commit-msg-coauthor-policy.md index 782e2b85..076676a2 100644 --- a/context/sce/agent-trace-commit-msg-coauthor-policy.md +++ b/context/sce/agent-trace-commit-msg-coauthor-policy.md @@ -39,7 +39,7 @@ - `staged_diff_has_ai_overlap_with` is the injectable variant that accepts staged-patch/time/recent-trace dependencies and returns `StagedDiffAiOverlapResult`; available for future test coverage. - `staged_diff_has_ai_overlap` is the live wrapper that opens Agent Trace DB through the no-migration hook path, delegates to `_with`, and logs `sce.hooks.commit_msg.ai_overlap_error` on `Error` results. - Live helper path: - - opens Agent Trace DB through `AgentTraceDb::open_for_hooks_without_migrations()` and `ensure_schema_ready_for_hooks()`; + - opens Agent Trace DB through `RepositoryAgentTraceDb::open_for_hooks_without_migrations_at()` and `ensure_schema_ready_for_hooks()`; - captures the staged patch with `git diff --cached --patch --no-ext-diff`; - queries recent diff traces using the same bounded 7-day window as post-commit; - combines each recent patch and checks overlap through `agent_trace::patches_have_overlap`, which uses the existing patch intersection primitive; diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index 157ab772..49e8efbc 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -1,19 +1,12 @@ # Agent Trace Database Adapter -`cli/src/services/agent_trace_db/mod.rs` defines the Agent Trace persistence adapter as a thin alias over the shared Turso adapter: +`cli/src/services/agent_trace_db/mod.rs` defines the shared Agent Trace insert payloads and helpers consumed by the repository-scoped adapter. `RepositoryAgentTraceDb` (see [Repository-scoped adapter seam](#repository-scoped-adapter-seam)) is the sole Agent Trace DB adapter; the former checkout-scoped `AgentTraceDb` / `AgentTraceDbSpec` type, its `open_at` / `open_for_hooks_without_migrations` constructors, and its 15-file migration chain were removed by the `retire-legacy-agent-trace-db` plan (see [context/decisions/2026-07-17-retire-legacy-agent-trace-db.md](../decisions/2026-07-17-retire-legacy-agent-trace-db.md)). -```rust -pub type AgentTraceDb = TursoDb; -``` +## Shared insert/query payloads -## Module structure +`mod.rs` owns the typed payloads and SQL constants that `RepositoryAgentTraceDb` delegates to: -- `AgentTraceDbSpec`: `DbSpec` implementation for Agent Trace persistence. -- `AgentTraceDb`: type alias for `TursoDb`, inheriting shared constructor and operation retry behavior. -- `open_at(path)`: migration-running explicit-path constructor for per-checkout Agent Trace databases. -- `open_for_hooks_without_migrations()`: Agent Trace-specific runtime-open API for high-frequency hook paths; opens/connects via `TursoDb::open_without_migrations()` and does not run embedded migrations. -- `open_for_hooks_without_migrations_at(path)`: explicit-path no-migration runtime-open API used by per-checkout hook resolution. -- `ensure_schema_ready_for_hooks()`: non-mutating hook-readiness check that delegates to the shared `TursoDb::ensure_schema_ready()` method with the Agent Trace–specific `AGENT_TRACE_SCHEMA_SETUP_GUIDANCE` constant (`"Run 'sce setup'."`); verifies the Agent Trace DB has the expected applied migration metadata in `__sce_migrations` for every ID in `AGENT_TRACE_MIGRATIONS`; missing/incomplete metadata fails with `Run 'sce setup'.` guidance instead of running migrations. +- `ensure_schema_ready_for_hooks()`: non-mutating hook-readiness check that delegates to the shared `TursoDb::ensure_schema_ready()` method with the Agent Trace–specific `AGENT_TRACE_SCHEMA_SETUP_GUIDANCE` constant (`"Run 'sce setup'."`); verifies the repository Agent Trace DB has the expected applied migration metadata in `__sce_migrations` for every ID in `AGENT_TRACE_REPOSITORY_MIGRATIONS`; missing/incomplete metadata fails with `Run 'sce setup'.` guidance instead of running migrations. - `DiffTraceInsert<'a>`: insert payload with `time_ms: i64`, `session_id: &'a str`, `patch: &'a str`, `model_id: Option<&'a str>`, `tool_name: &'a str`, nullable `tool_version: Option<&'a str>`, and `payload_type: &'a str` (using `PAYLOAD_TYPE_PATCH` or `PAYLOAD_TYPE_STRUCTURED` constants). - `PAYLOAD_TYPE_PATCH` / `PAYLOAD_TYPE_STRUCTURED`: string constants (`"patch"` / `"structured"`) for the `diff_traces.payload_type` discriminator column; `OpenCode` normalized diff-trace payloads use `patch`, `Claude` structured `PostToolUse` payloads use `structured`. - `insert_diff_trace()`: domain-specific insert helper using parameterized SQL. @@ -35,6 +28,20 @@ pub type AgentTraceDb = TursoDb; - `insert_parts(inputs)`: typed batch helper that generates and executes one parameterized multi-row append-only `parts` insert for valid conversation-trace `message.part` batches. - `lifecycle.rs`: service lifecycle provider for setup/doctor integration. +## Repository-scoped adapter seam + +`cli/src/services/agent_trace_db/repository.rs` defines the repository-scoped Agent Trace DB adapter introduced by the `repository-scoped-agent-trace-db` plan: + +```rust +pub type RepositoryAgentTraceDb = TursoDb; +``` + +This adapter has no canonical `DbSpec::db_path()`; callers must resolve `/sce/repos//agent-trace.db` first and use explicit-path `TursoDb` constructors. Its migration list is `generated_migrations::AGENT_TRACE_REPOSITORY_MIGRATIONS`, currently one fresh multi-statement SQL file at `cli/migrations/agent-trace-repository/001_repository_schema.sql`. The schema includes `repository_metadata` plus the existing repository-level Agent Trace tables, indexes, and triggers, and intentionally has no `checkout_id` columns on trace tables. `RepositoryAgentTraceDb::verify_or_initialize_repository_metadata(repository_id)` inserts the singleton metadata row on first initialization and errors if an existing DB stores a different repository ID. `RepositoryAgentTraceDb::repair_missing_repository_schema_migration_metadata()` is a narrow concurrent-first-open repair seam: it never creates trace tables, but if every required repository schema table already exists and only the one-file baseline migration record is missing, it records `001_repository_schema` and rechecks readiness. + +`RepositoryAgentTraceDb` exposes repository-level write helpers for the current row families by delegating to the same typed insert payloads and parameterized SQL used by the checkout-scoped adapter: `insert_diff_trace`, `insert_post_commit_patch_intersection`, `insert_agent_trace`, `insert_message`, `insert_messages`, `insert_part`, and `insert_parts`. It also exposes `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` by delegating to the shared recent diff-trace query/parser helper, so repository-scoped attribution reads use the same chronological inclusive window semantics without a checkout filter. These methods preserve the existing row shapes and do not add checkout provenance columns or checkout-scoped write/query APIs. + +The repository-scoped adapter is consumed by `agent_trace_storage`, active hook runtime opening, Agent Trace setup/doctor lifecycle, and `sce trace` status/list/shell flows. Hook writers/readers resolve the current repository storage context before using `RepositoryAgentTraceDb`. It also exposes `open_for_hooks_without_migrations_at(path)` — the explicit-path no-migration runtime-open used by the trace read paths (`stats`, `discovery`/readiness, `shell`) — plus the migration-running `new_at(path)` constructor used by setup and hook-runtime fallback initialization. There is no longer a checkout-scoped adapter. + ## Non-goals - No read/query helper for loading messages with their joined parts exists in the current runtime; the typed write helpers (`insert_message`, `insert_messages`, `insert_part`, `insert_parts`) are the only exposed message/part API surface. Message/part query helpers are deferred to a future task. @@ -42,45 +49,39 @@ pub type AgentTraceDb = TursoDb; ## Database path -The legacy/global Agent Trace DB path is resolved from the shared default-path catalog and retained as a lifecycle fallback when no checkout context or checkout ID is available: +The active repository-scoped Agent Trace DB path is resolved from repository identity through the shared default-path catalog: -- Function: `agent_trace_db_path()` in `cli/src/services/default_paths.rs` -- Path template: `/sce/agent-trace.db` -- Linux: `$XDG_STATE_HOME/sce/agent-trace.db` (defaults to `~/.local/state/sce/agent-trace.db`) -- Other platforms: platform-equivalent user state root +- Function: `agent_trace_db_path_for_repository(repository_id)` in `cli/src/services/default_paths.rs` +- Path template: `/sce/repos//agent-trace.db` +- Repository ID source: explicit `agent_trace.repository_id` config, otherwise canonicalized configured Git remote URL (`agent_trace.repository_remote`, default `origin`) +- Checkout ID source: `/sce/checkout-id`, where `` comes from `git rev-parse --git-dir`; checkout ID is diagnostic metadata only and is not stored on Agent Trace rows. + +Example state layout: + +```text +/sce/ +├── repos/ +│ └── / +│ └── agent-trace.db # active DB shared by clones/worktrees of this logical repo +├── agent-trace-.db # pre-migration on-disk file; never touched, no longer inspectable via the CLI +└── agent-trace.db # pre-migration on-disk file; never touched, no longer inspectable via the CLI +``` -Active hook runtime resolves per-checkout Agent Trace DB files: +SCE creates one Agent Trace DB per logical Git repository on demand through setup, hook, doctor, or trace commands. It does not run a daemon, background service, scheduler, watcher, external lock service, registry service, or data migration worker. -- Function: `agent_trace_db_path_for_checkout(checkout_id)` in `cli/src/services/default_paths.rs` -- Path template: `/sce/agent-trace-{checkout_id}.db` -- Checkout ID source: `/sce/checkout-id`, where `` comes from `git rev-parse --git-dir` -- Checkouts are discovered by `sce trace db list` via filesystem scan of `/sce/agent-trace-*.db` files; there is no central registry file. +`agent_trace_db_path_for_repository(repository_id)` is the only Agent Trace DB path helper. The former global sentinel helper `agent_trace_db_path()` and the per-checkout helper `agent_trace_db_path_for_checkout(checkout_id)` were removed by the `retire-legacy-agent-trace-db` plan. Any pre-migration `agent-trace-.db` / global `agent-trace.db` files left on disk are never migrated, imported, copied, renamed, archived, deleted, or backfilled by SCE, and are no longer inspectable through the CLI. When repository identity cannot be resolved (including outside a Git repository), lifecycle code returns an actionable "requires a Git repository" diagnostic instead of falling back to a sentinel path. ## Migrations -`AgentTraceDbSpec::migrations()` returns `generated_migrations::AGENT_TRACE_MIGRATIONS`, generated from `cli/migrations/agent-trace/` at build time. Setup-time `AgentTraceDb::open_at(path)` and hook-runtime fallback initialization both apply this migration set. Migration IDs are the SQL filename stems, sorted by numeric prefix: +`RepositoryAgentTraceDbSpec::migrations()` returns `generated_migrations::AGENT_TRACE_REPOSITORY_MIGRATIONS`, generated from `cli/migrations/agent-trace-repository/` at build time. It is currently one fresh multi-statement baseline file: -- `001_create_diff_traces.sql` -- `002_create_post_commit_patch_intersections.sql` -- `003_create_agent_traces.sql` -- `004_create_diff_traces_time_ms_id_index.sql` -- `005_create_agent_traces_agent_trace_id_index.sql` -- `006_add_agent_traces_vcs_remote_url.sql` -- `007_create_agent_traces_vcs_remote_url_index.sql` -- `008_create_messages.sql` -- `009_create_parts.sql` -- `010_create_messages_session_message_unique_index.sql` -- `011_create_messages_session_order_index.sql` -- `012_create_parts_session_message_order_index.sql` -- `013_create_messages_updated_at_trigger.sql` -- `014_create_parts_updated_at_trigger.sql` -- `015_add_diff_traces_payload_type.sql` (migration ID `015_add_diff_traces_payload_type`; adds `payload_type TEXT NOT NULL DEFAULT 'patch'` to `diff_traces`) +- `001_repository_schema.sql` (migration ID `001_repository_schema`) — creates `repository_metadata`, `diff_traces` (including `payload_type TEXT NOT NULL DEFAULT 'patch'`), `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts`, plus the lookup indexes and `updated_at` triggers, in one `execute_batch` statement recorded as a single migration ID. -The former `015_create_session_models` migration was removed from the fresh schema when the `remove-session-models-direct-claude-model-id` plan cleaned up session-models support. The `retired_migration_ids()` compat mechanism and `RETIRED_AGENT_TRACE_MIGRATION_IDS` constant that previously accommodated upgraded databases with that migration ID were subsequently removed in the `remove-retired-migration-ids` plan, since all development databases have been recreated and no ongoing compatibility is needed. Current migration IDs go directly from `014_create_parts_updated_at_trigger` to `015_add_diff_traces_payload_type`. +The former checkout-scoped `AGENT_TRACE_MIGRATIONS` constant and its 15-file `cli/migrations/agent-trace/` chain (`001_create_diff_traces` … `015_add_diff_traces_payload_type`) were removed by the `retire-legacy-agent-trace-db` plan; `build.rs` auto-discovers migration directories, so deleting the directory dropped the constant on regeneration. The repository schema captures the same tables/columns/indexes/triggers that the old incremental chain produced. -The shared `TursoDb` runner records applied IDs in the database-local `__sce_migrations` table. Existing Agent Trace DB files without metadata are brought forward by re-applying the idempotent migration set and recording each ID, so rerunning `sce setup` / `AgentTraceDb::open_at(path)` applies later Agent Trace migrations to an already-created per-checkout DB. +The shared `TursoDb` runner records applied IDs in the database-local `__sce_migrations` table. Migration SQL is executed with `execute_batch`, so the one-file repository baseline can contain multiple statements while still recording one migration ID. -Per-checkout hook DB resolution first tries `AgentTraceDb::open_for_hooks_without_migrations_at(path)` and `ensure_schema_ready_for_hooks()`. If setup has not initialized the DB, metadata is absent, or migrations are incomplete, the checkout resolver falls back to `AgentTraceDb::open_at(path)` so hook invocation lazily creates or upgrades the per-checkout DB before continuing. When the fallback also fails, the error context includes the fast-path failure reason (`(fast-path attempt: {fast_error})`) so both failure causes are visible in diagnostics. Readiness is based on exact migration metadata parity with `AGENT_TRACE_MIGRATIONS`, not table/index/column introspection. +Repository-scoped hook DB resolution first resolves `agent_trace.repository_id` / `agent_trace.repository_remote` through config, then uses `agent_trace_storage::resolve_agent_trace_storage(...)`. The storage resolver tries `RepositoryAgentTraceDb::open_without_migrations_at(path)` + `ensure_schema_ready_for_hooks()` + repository metadata validation first. If setup has not initialized the repository DB, metadata is absent, or migrations are incomplete, it falls back to migration-running `RepositoryAgentTraceDb::new_at(path)` and validates/seeds `repository_metadata.repository_id` before returning. The resolver retries the fast-path/migration sequence for a bounded window during concurrent first opens; if another opener completed the one-file schema but the baseline migration record is missing, the repository adapter records that metadata only after verifying all required repository schema tables already exist. When the fallback also fails, the error context includes the fast-path failure reason (`(fast-path attempt: {fast_error})`) so both failure causes are visible in diagnostics. Normal readiness is based on exact migration metadata parity with `AGENT_TRACE_REPOSITORY_MIGRATIONS`; table introspection is used only by the narrow concurrent-first-open metadata repair seam. The `diff_traces` baseline migration creates: @@ -163,11 +164,11 @@ Both triggers compare `OLD.*` vs `NEW.*` for all mutable columns (excluding `upd `AgentTraceDbLifecycle` is registered in `cli/src/services/lifecycle.rs` after `LocalDbLifecycle` and before optional `HooksLifecycle`. -- `diagnose()` reports per-checkout Agent Trace DB path and parent-directory readiness when a repo root has a checkout ID; otherwise it falls back to the legacy global Agent Trace DB path. When the DB file exists, it also performs a deep health check: opens the file via `open_for_hooks_without_migrations_at` and verifies schema readiness via `ensure_schema_ready_for_hooks`, reporting `AgentTraceDbConnectionFailed` if open fails or `AgentTraceDbSchemaNotReady` if the schema is incomplete. These deep-check problems are `ManualOnly` (not auto-fixable by `sce doctor --fix`); the remediation directs the operator to re-run `sce setup` or fix file permissions. -- `fix()` bootstraps the resolved per-checkout DB parent directory for auto-fixable parent-readiness problems, with the same global fallback outside checkout context. -- `setup()` creates/reuses the current checkout identity when a repo root is available, resolves `/sce/agent-trace-{checkout_id}.db` through `agent_trace_db_path_for_checkout(checkout_id)`, opens/creates it with `AgentTraceDb::open_at(&db_path)` to apply embedded migrations, and emits setup messaging with the checkout ID plus initialized DB path. Hook runtime lazy initialization remains available for checkouts where setup has not run or schema metadata is incomplete. -- `sce doctor` surfaces checkout identity and per-checkout Agent Trace DB health in the `Configuration` section when a checkout ID exists, with `[PASS]`/`[FAIL]`/`[MISS]` status tokens. Outside checkout context it falls back to the legacy/global Agent Trace DB row. JSON output includes `checkout_identity` when available plus the resolved `agent_trace_db` field. -- `sce trace db list` discovers checkouts by scanning `/sce/agent-trace-*.db` files on disk, reporting them in text or JSON sorted by mtime descending. See [context/cli/trace-command.md](../cli/trace-command.md). +- `diagnose()` resolves repository identity from config or the configured Git remote and reports the repository-scoped Agent Trace DB path and parent-directory readiness. When the DB file exists, it opens the file via `RepositoryAgentTraceDb::open_without_migrations_at` and verifies schema readiness via `ensure_schema_ready_for_hooks`, reporting `AgentTraceDbConnectionFailed` if open fails or `AgentTraceDbSchemaNotReady` if the schema is incomplete. Missing repository identity is a manual problem with `.sce/config.json` / remote guidance. Outside repository context there is no repository identity to select a DB and no global/checkout fallback path; the lifecycle returns an actionable "requires a Git repository" diagnostic, surfaced by `diagnose_agent_trace_db_health` as a manual-only `UnableToResolveStateRoot` problem. +- `fix()` bootstraps the resolved repository DB parent directory for auto-fixable parent-readiness problems, with the same global parent fallback outside repository context. +- `setup()` resolves repository storage through `agent_trace_storage`, creates/reuses the current checkout identity for diagnostics, opens/creates `/sce/repos//agent-trace.db` with the repository schema, validates `repository_metadata.repository_id`, and emits setup messaging with the repository ID, checkout ID, 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). ## Runtime writers @@ -176,19 +177,19 @@ Both triggers compare `OLD.*` vs `NEW.*` for all mutable columns (excluding `upd - The hook path validates required STDIN `{ sessionID, diff, time, tool_name, tool_version }` before persistence, with `model_id` accepted as optional (absent or `null`) and `tool_version` accepted as nullable. Missing attribution remains `None`; `diff_traces.model_id` is the only active model-attribution storage for diff traces and there is no session-level fallback lookup. - Direct payload `model_id` and `tool_version` values pass into `DiffTraceInsert` as-is. The stored `session_id` is tool-prefixed before insert construction: `opencode` payloads store `oc_`, `claude` structured payloads store `cc_`, `pi` normalized payloads store `pi_`, and same-tool-prefixed values are not double-prefixed. The `payload_type` field is set to `PAYLOAD_TYPE_PATCH` for `OpenCode` normalized diff-trace payloads and `PAYLOAD_TYPE_STRUCTURED` for Claude structured `PostToolUse` payloads. Claude structured intake best-effort extracts direct `model`/`model_id`/`modelId` metadata, including nested `model.id` / `model.model` / `model.name`, normalizes values with the `claude/` prefix when present, and leaves `model_id` nullable when metadata is absent. - `time` is accepted as a `u64` Unix epoch millisecond input and must fit the signed `i64` `time_ms` column before any persistence starts. -- The hook inserts the parsed payload fields plus nullable direct attribution through `AgentTraceDb::insert_diff_trace()` without writing a parsed-payload artifact under `context/tmp`. +- The hook inserts the parsed payload fields plus nullable direct attribution through `RepositoryAgentTraceDb::insert_diff_trace()` without writing a parsed-payload artifact under `context/tmp`. - AgentTraceDb open/insert failures are logged and reflected in deterministic success text as failed DB persistence; no artifact fallback is created. - Existing artifact files are not backfilled into the database. -Post-commit intersection rows are written by the active `post-commit` hook flow through per-checkout lazy AgentTraceDb access, and the same flow now also inserts built Agent Trace payloads into `agent_traces` via `AgentTraceDb::insert_agent_trace()` (see [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md)). The persisted `trace_json` is the schema-validated `build_agent_trace(...)` output and includes top-level `metadata.sce.version` from the compiled `sce` CLI package version plus `content_hash` on every emitted range. Range `content_hash` values are computed from the touched-line kind/content of the post-commit hunk that produced the persisted range, not from DB IDs, paths, line positions, or runtime metadata. +Post-commit intersection rows are written by the active `post-commit` hook flow through repository-scoped Agent Trace DB access, and the same flow inserts built Agent Trace payloads into `agent_traces` via `RepositoryAgentTraceDb::insert_agent_trace()` (see [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md)). The persisted `trace_json` is the schema-validated `build_agent_trace(...)` output and includes top-level `metadata.sce.version` from the compiled `sce` CLI package version plus `content_hash` on every emitted range. Range `content_hash` values are computed from the touched-line kind/content of the post-commit hunk that produced the persisted range, not from DB IDs, paths, line positions, or runtime metadata. `sce hooks conversation-trace` is the current runtime writer for `messages` and `parts`. - The hook accepts normalized snake_case mixed-batch STDIN envelopes: top-level `payloads` is an array and each persisted item owns its own `type`; any top-level `type` is ignored and does not classify same-kind old-envelope items. -- `message` items validate and map payloads without message-level `text`, `agent`, or `summary_diffs` to `InsertMessageInsert`; valid rows are inserted through at most one multi-row `AgentTraceDb::insert_messages(...)` call per invocation so repeated `(session_id, message_id)` events are ignored without failing. -- `message.part` items validate and map payloads with required part `text` to `InsertPartInsert`; valid rows are inserted through at most one multi-row `AgentTraceDb::insert_parts(...)` call per invocation so parts remain append-only and do not require a pre-existing message row. +- `message` items validate and map payloads without message-level `text`, `agent`, or `summary_diffs` to `InsertMessageInsert`; valid rows are inserted through at most one multi-row `RepositoryAgentTraceDb::insert_messages(...)` call per invocation so repeated `(session_id, message_id)` events are ignored without failing. +- `message.part` items validate and map payloads with required part `text` to `InsertPartInsert`; valid rows are inserted through at most one multi-row `RepositoryAgentTraceDb::insert_parts(...)` call per invocation so parts remain append-only and do not require a pre-existing message row. - Unsupported item types, missing/non-string item types, non-object items, and event-specific parser validation failures are retained as skipped-item diagnostics, logged, and counted as skipped while valid sibling items remain eligible for persistence. -- The hook opens one per-checkout `AgentTraceDb` per invocation through lazy checkout DB resolution before insertion; DB open/initialization failures are logged through `sce.hooks.conversation_trace.error` and returned as hook success because conversation-trace intake is fail-open to producers. +- The hook opens one repository-scoped `RepositoryAgentTraceDb` per invocation through lazy repository storage resolution before insertion; all clones/worktrees of the same logical repository share the same repository-level message/part rows. DB open/initialization failures are logged through `sce.hooks.conversation_trace.error` and returned as hook success because conversation-trace intake is fail-open to producers. - Multi-row insert failures are logged once and count the whole valid-item batch as skipped without failing the command; the hook does not fall back to row-by-row insertion after a batch failure. Successful inserts contribute to deterministic success output counts (`attempted`, `persisted_messages`, `persisted_parts`, `skipped`). Duplicate parent message inserts preserve the existing `ON CONFLICT DO NOTHING` affected-row semantics. - No `context/tmp` artifact is written for conversation traces. - The generated OpenCode agent-trace plugin sends mixed-batch envelopes for conversation traces: regular `message` and `message.part` events each carry one per-item `type`, while diff-backed `message` events send one envelope containing the synthetic parent message item plus patch part items. @@ -197,7 +198,7 @@ Post-commit intersection rows are written by the active `post-commit` hook flow ## Recent patch reads -`AgentTraceDb::recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` supports the post-commit comparison flow without changing `diff_traces` writes: +`RepositoryAgentTraceDb::recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` supports the post-commit comparison flow without changing `diff_traces` writes: - SQL reads `id`, `time_ms`, `session_id`, `patch`, nullable `model_id` + `tool_name` + `tool_version`, and `payload_type` from `diff_traces` where `time_ms >= cutoff_time_ms AND time_ms <= end_time_ms`. - Rows are ordered by `time_ms ASC, id ASC` for deterministic chronological processing. diff --git a/context/sce/agent-trace-hook-doctor.md b/context/sce/agent-trace-hook-doctor.md index 9ae21439..81651518 100644 --- a/context/sce/agent-trace-hook-doctor.md +++ b/context/sce/agent-trace-hook-doctor.md @@ -124,11 +124,11 @@ The broadened contract for `sce doctor` must cover the following problem invento - expected global config path cannot be resolved - global config file exists but is unreadable, invalid JSON, or fails schema validation - invalid default-discovered config must not prevent `sce doctor` from starting; doctor still reports invalid global or repo-local config as a problem once command dispatch begins -- local DB or checkout/global Agent Trace DB path cannot be resolved -- local DB and checkout/global Agent Trace DB parent directories are missing or not writable -- local DB and checkout/global Agent Trace DB bootstrap or health is broken +- local DB or repository-scoped Agent Trace DB path cannot be resolved +- local DB and repository-scoped Agent Trace DB parent directories are missing or not writable +- local DB and repository-scoped Agent Trace DB bootstrap or health is broken - Agent Trace DB file exists but cannot be opened (connection failure) or has incomplete schema (missing/unapplied migrations) — reported as `AgentTraceDbConnectionFailed` / `AgentTraceDbSchemaNotReady` with manual-only remediation directing to `sce setup` -- Agent Trace checkout ID plus per-checkout DB path/health are reported in `Configuration` section output when a checkout ID exists, with global Agent Trace DB reporting retained as the no-checkout fallback +- Agent Trace checkout ID plus repository-scoped DB path/health are reported in `Configuration` section output when available; repository DB rows include repository ID, identity source, safe canonical identity, configured remote name, and never raw remote URLs ### Repository targeting and git readiness @@ -212,7 +212,7 @@ Services implementing `ServiceLifecycle`: - `HooksLifecycle` in `cli/src/services/hooks/lifecycle.rs`: checks hook rollout integrity, required-hook presence/executability/content - `LocalDbLifecycle` in `cli/src/services/local_db/lifecycle.rs`: validates DB path/health, bootstraps DB parent directory - `AuthDbLifecycle` in `cli/src/services/auth_db/lifecycle.rs`: validates encrypted auth DB path/health, bootstraps DB parent directory -- `AgentTraceDbLifecycle` in `cli/src/services/agent_trace_db/lifecycle.rs`: validates checkout-scoped Agent Trace DB path/health when a checkout ID exists, otherwise validates the global fallback path, and bootstraps the resolved DB parent directory +- `AgentTraceDbLifecycle` in `cli/src/services/agent_trace_db/lifecycle.rs`: validates repository-scoped Agent Trace DB path/health from resolved repository identity, reports checkout identity as diagnostics when available, and bootstraps the resolved DB parent directory The `doctor` command aggregates `diagnose` and `fix` across all registered providers. The `setup` command aggregates `setup` across all registered providers in order (config → local_db → auth_db → agent_trace_db → hooks). diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 9d4a6a27..051d92ee 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -34,15 +34,15 @@ - Reads the message file as UTF-8. - Applies exactly one canonical trailer: `Co-authored-by: SCE `. - Writes back only when the attribution gate is enabled, `SCE_DISABLED` is false, the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`), and the transformed content differs. - - The staged-diff AI-overlap preflight is wired into `run_commit_msg_subcommand_in_repo`: it opens Agent Trace DB through the no-migration hook path, captures the staged diff via `git diff --cached`, queries recent diff traces from the past 7 days, and checks overlap through `agent_trace::patches_have_overlap` with short-circuit on first positive match. + - The staged-diff AI-overlap preflight is wired into `run_commit_msg_subcommand_in_repo`: it resolves repository-scoped Agent Trace storage, captures the staged diff via `git diff --cached`, queries recent repository-level diff traces from the past 7 days, and checks overlap through `agent_trace::patches_have_overlap` with short-circuit on first positive match. - When the preflight returns `NoOverlap` or `Error` (including DB open failure, schema not ready, query error, staged diff read failure, or zero overlap), the trailer is not appended; `Error` results are logged via `sce.hooks.commit_msg.ai_overlap_error`. - The preflight is invoked only when the attribution gate passes; when the gate does not pass, no DB read or staged-diff capture occurs. - `pre-commit` is a deterministic no-op entrypoint. - **`post-commit` is an active intersection entrypoint** (see [agent-trace-db.md](agent-trace-db.md)): - - Agent Trace DB access resolves the current checkout ID from `/sce/checkout-id`, creating it if missing, then opens `/sce/agent-trace-{checkout_id}.db` through the checkout lazy DB resolver. - - The resolver tries a no-migration open/readiness check first and falls back to migration-running initialization when the per-checkout DB is absent or schema metadata is incomplete. + - Agent Trace DB access resolves repository-scoped storage from `agent_trace.repository_id` or the configured Git remote, creates/reuses checkout ID for diagnostics, then opens `/sce/repos//agent-trace.db` through the repository storage resolver. + - The resolver tries a no-migration open/readiness check plus repository metadata validation first and falls back to migration-running initialization when the repository DB is absent or schema metadata is incomplete. - Captures the current commit's patch from git using `capture_post_commit_patch_from_git()`. - - Queries recent `diff_traces` patches from the past 7 days via `AgentTraceDb::recent_diff_trace_patches()`. + - Queries recent `diff_traces` patches from the past 7 days via `RepositoryAgentTraceDb::recent_diff_trace_patches()`. - Recent-row parsing dispatches on `payload_type`: `patch` rows parse through existing `parse_patch`, while `structured` rows parse stored JSON through `structured_patch::derive_claude_structured_patch` at read time to produce `ParsedPatch`. - Parsed `PatchHunk` entries carry nullable row `model_id` for both paths, so combined/intersection patch inputs retain per-hunk model provenance for downstream Agent Trace attribution building. - Combines valid recent patches in chronological order via `patch::combine_patches`. @@ -68,7 +68,7 @@ - The `DiffTracePayload` struct carries a `payload_type: String` field consumed by `persist_diff_trace_payload_to_agent_trace_db_with` to pass the correct discriminator to `DiffTraceInsert`. - Before `DiffTraceInsert` construction, Rust prefixes the stored `diff_traces.session_id` by source tool: OpenCode normalized payloads store `oc_`, Claude structured payloads store `cc_`, Pi normalized payloads (`tool_name: "pi"`) store `pi_`, and already same-tool-prefixed values are left unchanged. Unknown `tool_name` values pass the raw session ID through unprefixed. Raw non-empty session-ID validation still happens before prefixing. - Missing `model_id` or `tool_version` stays nullable; Rust does not perform session-level fallback attribution. Direct payload values are persisted as-is after payload-specific validation/normalization, making `diff_traces.model_id` the only active model-attribution storage for diff traces. - - Persistence: resolves the current per-checkout AgentTraceDb lazily and inserts the parsed payload fields via `DiffTraceInsert` + `insert_diff_trace()` using tool-prefixed `session_id` plus nullable direct `model_id` and `tool_version`. No parsed-payload artifact is written under `context/tmp`. + - Persistence: resolves the current repository-scoped `RepositoryAgentTraceDb` lazily and inserts the parsed payload fields via `DiffTraceInsert` + `insert_diff_trace()` using tool-prefixed `session_id` plus nullable direct `model_id` and `tool_version`. No parsed-payload artifact is written under `context/tmp`. - Current producers are the OpenCode agent-trace plugin and the generated Claude `sce hooks` command hooks (no TypeScript intermediary). - OpenCode forwards user-message `message` diffs with `tool_name="opencode"`, always including `model_id`, and nullable OpenCode client-version metadata. - Claude generated settings no longer register `SessionStart`; supported `PostToolUse` `Write|Edit|MultiEdit|NotebookEdit` events are routed directly to `sce hooks diff-trace`. Runtime persistence currently derives structured diff traces for `Write` create and `Edit` structured-patch payloads; unsupported Claude payload shapes are no-ops. @@ -105,7 +105,7 @@ - `part_type: "question"` requires `text` to be a JSON string whose parsed value is an array of objects with string `question` and `answer` fields; valid question text is stored unchanged, while invalid question text is skipped through the same per-item validation path as malformed patch items. - Unsupported item `type` values, missing/non-string item `type`, non-object items, and event-specific item validation failures are recorded as skipped-item diagnostics (`index`, `reason`) while valid sibling items remain eligible for persistence; skipped validation items are logged through `sce.hooks.conversation_trace.payload_skipped`. Top-level JSON/object/`payloads` shape failures produce `Invalid conversation-trace payload from STDIN: ...` diagnostics internally, then fail open through `sce.hooks.conversation_trace.error` with hook success text. - Shared persistence (both classification paths converge before DB writes): - - Current persistence opens one per-checkout `AgentTraceDb` per hook invocation through lazy checkout DB resolution, then inserts the non-empty valid `message` batch through at most one multi-row `AgentTraceDb::insert_messages(...)` call and the non-empty valid `message.part` batch through at most one multi-row `AgentTraceDb::insert_parts(...)` call. + - Current persistence opens the repository-scoped `RepositoryAgentTraceDb` for the current repository through lazy storage resolution, then inserts the non-empty valid `message` batch through at most one multi-row `insert_messages(...)` call and the non-empty valid `message.part` batch through at most one multi-row `insert_parts(...)` call. - DB open/initialization failures fail open through `sce.hooks.conversation_trace.error` with hook success text; valid-item multi-row insert failures are logged once through `sce.hooks.conversation_trace.agent_trace_db_batch_failed`, count the whole valid-item batch as skipped, and do not fail the command. The hook does not fall back to row-by-row insertion after a multi-row insert failure. - Current valid-payload success output reports deterministic mixed-batch accounting: `conversation-trace hook persisted mixed payload batch to AgentTraceDb: attempted=, persisted_messages=, persisted_parts=, skipped=.` The hook does not persist `context/tmp` artifacts. - Fail-open output for conversation-trace intake failures is `conversation-trace hook intake failed open; error logged.` so hook callers do not receive app-level classified errors or non-zero exits for intake failures. @@ -120,4 +120,5 @@ - No retry queue replay - No rewrite remap ingestion - No `conversation-trace` retry/backfill path or `context/tmp` artifact persistence -- No runtime Claude diff-trace persistence or AgentTraceDb writes from the capture route itself, and no direct artifact/DB writes from the Claude or OpenCode TypeScript runtimes +- No runtime Claude diff-trace persistence or AgentTraceDb writes from the removed capture route itself, and no direct artifact/DB writes from the Claude or OpenCode TypeScript runtimes +- No checkout-scoped active DB writes, legacy checkout DB migration/import/backfill, or daemon/background Agent Trace service diff --git a/context/sce/shared-turso-db.md b/context/sce/shared-turso-db.md index 9dd29055..0b0a2b8a 100644 --- a/context/sce/shared-turso-db.md +++ b/context/sce/shared-turso-db.md @@ -8,7 +8,7 @@ - `db_name()` returns a human-readable diagnostic name. - `db_path()` resolves the canonical database file path. - `migrations()` returns ordered embedded migration `(id, sql)` pairs. -- `cli/build.rs` scans immediate `cli/migrations//*.sql` directories at compile time and writes `cli/src/generated_migrations.rs` with database-named migration constants (`AGENT_TRACE_MIGRATIONS`, `AUTH_MIGRATIONS`, etc.). Generated entries use the filename stem as the migration ID, embed SQL with `include_str!`, and are sorted by the numeric prefix before the first `_`. +- `cli/build.rs` scans immediate `cli/migrations//*.sql` directories at compile time and writes `cli/src/generated_migrations.rs` with database-named migration constants (`AGENT_TRACE_REPOSITORY_MIGRATIONS`, `AUTH_MIGRATIONS`, etc.). Generated entries use the filename stem as the migration ID, embed SQL with `include_str!`, and are sorted by the numeric prefix before the first `_`. - `TursoDb`: generic unencrypted adapter that owns: - tokio current-thread runtime creation - Turso local database open/connect flow using `turso::Builder::new_local()` with `experimental_multiprocess_wal(true)` so concurrent `sce` processes can safely access the same local database without WAL lock contention @@ -57,10 +57,10 @@ the secret value. No plaintext fallback exists. The shared module is exported from `cli/src/services/mod.rs` and compile-checked. Current concrete wrappers: - `cli/src/services/local_db/mod.rs`: `LocalDb = TursoDb`, with `LocalDbSpec` resolving `local_db_path()` and declaring zero migrations. -- `cli/src/services/agent_trace_db/mod.rs`: `AgentTraceDb = TursoDb`, with `AgentTraceDbSpec` resolving the legacy global `agent_trace_db_path()` fallback and loading ordered Agent Trace migrations for `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts` plus supporting indexes/triggers and the active `diff_traces.payload_type` migration. Active hook runtime paths resolve per-checkout DB files through checkout identity and use `AgentTraceDb::open_for_hooks_without_migrations_at(path)` plus `ensure_schema_ready_for_hooks()` first, falling back to migration-running `AgentTraceDb::open_at(path)` when the per-checkout DB is absent or incomplete. +- `cli/src/services/agent_trace_db/mod.rs`: owns the shared Agent Trace insert payloads/helpers. Active hook/runtime paths use the sole `RepositoryAgentTraceDb = TursoDb` adapter from `cli/src/services/agent_trace_db/repository.rs`, selected by `agent_trace_storage` at `/sce/repos//agent-trace.db`, with a one-file repository schema containing repository metadata plus repository-level `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts` tables. The checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter, its `agent_trace_db_path()` global fallback, and the 15-file migration chain were removed by the `retire-legacy-agent-trace-db` plan. - `cli/src/services/auth_db/mod.rs`: `AuthDb = EncryptedTursoDb`, with `AuthDbSpec` resolving `auth_db_path()` and loading 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. -All three database wrappers (local DB, auth DB, Agent Trace DB) have lifecycle providers. `lifecycle_providers(include_hooks)` registers database providers in order `LocalDbLifecycle` → `AuthDbLifecycle` → `AgentTraceDbLifecycle` before optional hooks. Setup initializes local/auth DBs, establishes Agent Trace checkout identity, initializes the per-checkout Agent Trace DB with migrations, and records its registry path; hook runtime keeps lazy initialization/upgrade as a fallback when setup has not run or schema metadata is incomplete. Doctor diagnoses/fixes DB parent/path readiness through lifecycle providers. +All three database areas (local DB, auth DB, Agent Trace DB) have lifecycle providers. `lifecycle_providers(include_hooks)` registers database providers in order `LocalDbLifecycle` → `AuthDbLifecycle` → `AgentTraceDbLifecycle` before optional hooks. Setup initializes local/auth DBs, establishes Agent Trace checkout identity for diagnostics, initializes the repository-scoped Agent Trace DB with migrations/metadata, and reports credential-safe repository identity metadata; hook runtime keeps lazy repository DB initialization/upgrade as a fallback when setup has not run or schema metadata is incomplete. Doctor diagnoses/fixes DB parent/path readiness through lifecycle providers. ## Migration metadata @@ -68,7 +68,7 @@ All three database wrappers (local DB, auth DB, Agent Trace DB) have lifecycle p `TursoDb::open_without_migrations()` is the explicit runtime-open seam for high-frequency callers that must not perform schema changes on their hot path. It still creates the parent directory and uses the configured connection-open retry policy; callers must verify schema readiness before query/write work. -Migrations are deliberately outside the connection-open retry block. The constructors retry only local Turso open/connect; schema changes are not retried because migration SQL must not be replayed after partial execution. +Migrations are deliberately outside the connection-open retry block. The generic constructors retry only local Turso open/connect; schema changes are not generically retried because migration SQL must not be replayed after partial execution. Service-specific resolvers may add bounded recovery around their own initialization contracts when they can prove safety; the repository-scoped Agent Trace resolver does this only for its one-file fresh schema and only repairs missing migration metadata after all required repository schema tables already exist. `TursoDb` and `EncryptedTursoDb` operation methods use the same config-driven query retry policy, resolved from `policies.database_retry..query` via `DATABASE_RETRY_CONFIG` `OnceLock` with fallback to hardcoded defaults (`5` attempts, `200ms` timeout, `25ms..100ms` backoff; default worst-case failure budget `<= 2_000ms`). `execute()`, `query()`, and `query_values()` convert caller parameters to owned Turso params before retry so each attempt can clone the same values. `query_values()` returns fully fetched column names plus raw `turso::Value` rows for deterministic rendering by operator-facing services. `query_map()` retries the initial query and full row-fetch loop, then runs caller-provided row mapping after retry completion so mapping failures are surfaced as logic errors and are not retried.