From 5daf1caaac21e839159e47477e552c9647ed9e8a Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:26:18 -0500 Subject: [PATCH] feat(core): add hosted review foundation --- docs/advanced.md | 24 ++- docs/configuration.md | 30 ++++ src-rust/crates/cli/src/main.rs | 23 ++- src-rust/crates/commands/src/stats.rs | 2 + src-rust/crates/core/src/auth_store.rs | 5 +- src-rust/crates/core/src/claudemd.rs | 139 +++++++++++++++--- src-rust/crates/core/src/hosted_review.rs | 64 ++++++++ src-rust/crates/core/src/lib.rs | 155 ++++++++++++++++++-- src-rust/crates/core/src/memdir.rs | 82 +++++++++++ src-rust/crates/core/src/session_storage.rs | 133 +++++++++++++++++ src-rust/crates/tui/src/app.rs | 6 +- 11 files changed, 624 insertions(+), 39 deletions(-) create mode 100644 src-rust/crates/core/src/hosted_review.rs diff --git a/docs/advanced.md b/docs/advanced.md index db840efe..5c0c5713 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -518,11 +518,25 @@ Files can `@include` other files using a frontmatter include directive. Included The `InstructionsLoaded` hook event fires for every file that is loaded, with the `load_reason` field indicating why (e.g. `session_start`, `nested_traversal`, `path_glob_match`, `include`, `compact`). -The `/memory` command opens the memory management UI for viewing, editing, and organising instruction files. - ---- - -## Security and permissions +The `/memory` command opens the memory management UI for viewing, editing, and organising instruction files. + +### Hosted review memory isolation + +Local-personal mode is the default. It loads managed, user, project, and local +instruction scopes so a developer's personal `~/.coven-code/AGENTS.md` can +shape their own sessions. + +Hosted review mode is enabled with `--hosted-review`, +`COVEN_CODE_HOSTED_REVIEW=1`, or `config.hostedReview.enabled`. In this mode, +Coven Code does not load user-scope memory by default. The prompt records that +hosted review mode is active and lists the AGENTS.md scopes that were loaded. +Durable hosted memory and transcript namespaces are separated under a hosted +review path and require tenant scope plus a canonical repository identity before +they can be resolved for persistence. + +--- + +## Security and permissions ### Permission modes diff --git a/docs/configuration.md b/docs/configuration.md index d0e5dfef..7bbe5c9a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -102,6 +102,36 @@ See [Permission Modes](#permission-modes) for a full description of each value. | `custom_system_prompt` | string \| null | null | Replace the default Coven Code system prompt entirely with this text. | | `append_system_prompt` | string \| null | null | Append this text to the end of the assembled system prompt (after AGENTS.md content). | +### Hosted review mode + +Hosted review mode is for service-hosted code review runs where the session must +not inherit the operator's personal global memory. + +Enable it with any of: + +```bash +coven-code --hosted-review +COVEN_CODE_HOSTED_REVIEW=1 coven-code +``` + +Or in `settings.json`: + +```json +{ + "config": { + "hostedReview": { + "enabled": true + } + } +} +``` + +When hosted review mode is active, Coven Code skips user-scope memory +(`~/.coven-code/AGENTS.md` and `~/.coven-code/CLAUDE.md`) by default, marks +new session artifacts as hosted review artifacts, and requires a tenant plus +canonical repository identity before resolving hosted durable memory paths. +Local-personal mode remains the default and continues to load user memory. + ### Tool access | Key | Type | Default | Description | diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index 1a0fa4ac..4e057a52 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -247,6 +247,10 @@ struct Cli { #[arg(long = "bare", action = ArgAction::SetTrue)] bare: bool, + /// Run with hosted review memory isolation + #[arg(long = "hosted-review", env = "COVEN_CODE_HOSTED_REVIEW", action = ArgAction::SetTrue)] + hosted_review: bool, + /// Billing workload tag #[arg(long = "workload", value_name = "TAG")] workload: Option, @@ -807,6 +811,9 @@ async fn main() -> anyhow::Result<()> { config.verbose = cli.verbose; config.output_format = cli.output_format.into(); config.disable_claude_mds = cli.no_claude_md; + if cli.hosted_review { + config.hosted_review.enabled = true; + } if let Some(sp) = cli.system_prompt.clone() { config.custom_system_prompt = Some(sp); } @@ -852,7 +859,11 @@ async fn main() -> anyhow::Result<()> { // --dump-system-prompt fast path if cli.dump_system_prompt { - let ctx = ContextBuilder::new(cwd.clone()).disable_claude_mds(config.disable_claude_mds); + let ctx = ContextBuilder::new(cwd.clone()) + .disable_claude_mds(config.disable_claude_mds) + .memory_load_options(claurst_core::claudemd::MemoryLoadOptions::from_mode( + config.runtime_mode(), + )); let sys = ctx.build_system_context().await; let user = ctx.build_user_context().await; println!("{}\n\n{}", sys, user); @@ -860,8 +871,11 @@ async fn main() -> anyhow::Result<()> { } // Build context - let ctx_builder = - ContextBuilder::new(cwd.clone()).disable_claude_mds(config.disable_claude_mds); + let ctx_builder = ContextBuilder::new(cwd.clone()) + .disable_claude_mds(config.disable_claude_mds) + .memory_load_options(claurst_core::claudemd::MemoryLoadOptions::from_mode( + config.runtime_mode(), + )); let system_ctx = ctx_builder.build_system_context().await; let user_ctx = ctx_builder.build_user_context().await; @@ -2163,6 +2177,9 @@ async fn run_interactive( session.working_dir = Some(tool_ctx.working_dir.display().to_string()); session }; + if config.hosted_review_enabled() { + session.hosted_review = true; + } let initial_messages = session.messages.clone(); let mut base_query_config = query_config; let mut live_config = config.clone(); diff --git a/src-rust/crates/commands/src/stats.rs b/src-rust/crates/commands/src/stats.rs index a8a5475a..6b6ba469 100644 --- a/src-rust/crates/commands/src/stats.rs +++ b/src-rust/crates/commands/src/stats.rs @@ -1247,6 +1247,7 @@ mod tests { git_branch: None, agent_role: None, managed_session_id: None, + hosted_review: false, extra: Default::default(), }) } @@ -1265,6 +1266,7 @@ mod tests { git_branch: None, agent_role: None, managed_session_id: None, + hosted_review: false, extra: Default::default(), }) } diff --git a/src-rust/crates/core/src/auth_store.rs b/src-rust/crates/core/src/auth_store.rs index a022963b..d5f64fc5 100644 --- a/src-rust/crates/core/src/auth_store.rs +++ b/src-rust/crates/core/src/auth_store.rs @@ -160,7 +160,10 @@ mod tests { }, ); - assert_eq!(store.api_key_for("anthropic").as_deref(), Some("sk-ant-key")); + assert_eq!( + store.api_key_for("anthropic").as_deref(), + Some("sk-ant-key") + ); } /// Atomic save: a concurrent racer that creates the file mid-flight diff --git a/src-rust/crates/core/src/claudemd.rs b/src-rust/crates/core/src/claudemd.rs index 21b57ada..9459b3cc 100644 --- a/src-rust/crates/core/src/claudemd.rs +++ b/src-rust/crates/core/src/claudemd.rs @@ -9,6 +9,8 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::SystemTime; +use crate::hosted_review::RuntimeMode; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -48,6 +50,39 @@ pub struct MemoryFileInfo { pub mtime: Option, } +/// Controls which memory scopes are loaded for the current runtime mode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MemoryLoadOptions { + pub mode: RuntimeMode, + pub allow_user_memory: bool, + pub allow_managed_rules: bool, +} + +impl MemoryLoadOptions { + pub fn local() -> Self { + Self { + mode: RuntimeMode::Local, + allow_user_memory: true, + allow_managed_rules: true, + } + } + + pub fn hosted_review() -> Self { + Self { + mode: RuntimeMode::HostedReview, + allow_user_memory: false, + allow_managed_rules: false, + } + } + + pub fn from_mode(mode: RuntimeMode) -> Self { + match mode { + RuntimeMode::Local => Self::local(), + RuntimeMode::HostedReview => Self::hosted_review(), + } + } +} + // --------------------------------------------------------------------------- // Cache // --------------------------------------------------------------------------- @@ -238,33 +273,45 @@ fn load_scope_files(dir: &Path, scope: MemoryScope, files: &mut Vec Vec { + load_all_memory_files_with_options(project_root, &MemoryLoadOptions::local()) +} + +/// Load all memory files for the given project root using explicit scope gates. +pub fn load_all_memory_files_with_options( + project_root: &Path, + options: &MemoryLoadOptions, +) -> Vec { let mut files = Vec::new(); // 1. Managed: ~/.coven-code/rules/*.md if let Some(home) = dirs::home_dir() { - let rules_dir = home.join(".coven-code/rules"); - if let Ok(entries) = std::fs::read_dir(&rules_dir) { - let mut paths: Vec = entries - .flatten() - .filter_map(|e| { - let p = e.path(); - if p.extension().is_some_and(|x| x == "md") { - Some(p) - } else { - None + if options.allow_managed_rules { + let rules_dir = home.join(".coven-code/rules"); + if let Ok(entries) = std::fs::read_dir(&rules_dir) { + let mut paths: Vec = entries + .flatten() + .filter_map(|e| { + let p = e.path(); + if p.extension().is_some_and(|x| x == "md") { + Some(p) + } else { + None + } + }) + .collect(); + paths.sort(); + for p in paths { + if let Some(f) = load_memory_file(&p, MemoryScope::Managed) { + files.push(f); } - }) - .collect(); - paths.sort(); - for p in paths { - if let Some(f) = load_memory_file(&p, MemoryScope::Managed) { - files.push(f); } } } // 2. User: ~/.coven-code/AGENTS.md then ~/.coven-code/CLAUDE.md - load_scope_files(&home.join(".coven-code"), MemoryScope::User, &mut files); + if options.allow_user_memory { + load_scope_files(&home.join(".coven-code"), MemoryScope::User, &mut files); + } } // 3. Project: {project_root}/AGENTS.md then {project_root}/CLAUDE.md @@ -352,6 +399,64 @@ mod tests { assert!(project[0].path.ends_with("CLAUDE.md")); } + #[test] + fn hosted_review_excludes_user_memory_by_default() { + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("AGENTS.md"), "project memory").unwrap(); + + let home = tempfile::tempdir().unwrap(); + let coven_code = home.path().join(".coven-code"); + std::fs::create_dir_all(&coven_code).unwrap(); + std::fs::write(coven_code.join("AGENTS.md"), "user memory").unwrap(); + + let _lock = crate::coven_shared::COVEN_HOME_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let original_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", home.path()); + + let files = + load_all_memory_files_with_options(project.path(), &MemoryLoadOptions::hosted_review()); + + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + + assert!(files.iter().all(|file| file.scope != MemoryScope::User)); + assert!(files.iter().any(|file| { + file.scope == MemoryScope::Project && file.content.contains("project memory") + })); + } + + #[test] + fn local_memory_load_still_includes_user_memory() { + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("AGENTS.md"), "project memory").unwrap(); + + let home = tempfile::tempdir().unwrap(); + let coven_code = home.path().join(".coven-code"); + std::fs::create_dir_all(&coven_code).unwrap(); + std::fs::write(coven_code.join("AGENTS.md"), "user memory").unwrap(); + + let _lock = crate::coven_shared::COVEN_HOME_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let original_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", home.path()); + + let files = load_all_memory_files_with_options(project.path(), &MemoryLoadOptions::local()); + + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + + assert!(files + .iter() + .any(|file| file.scope == MemoryScope::User && file.content.contains("user memory"))); + } + #[test] fn expand_includes_circular() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src-rust/crates/core/src/hosted_review.rs b/src-rust/crates/core/src/hosted_review.rs new file mode 100644 index 00000000..5d689567 --- /dev/null +++ b/src-rust/crates/core/src/hosted_review.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; + +/// Runtime isolation mode for a Coven Code session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum RuntimeMode { + #[default] + Local, + HostedReview, +} + +impl RuntimeMode { + pub fn is_hosted_review(self) -> bool { + matches!(self, Self::HostedReview) + } +} + +/// Settings-backed hosted review configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct HostedReviewConfig { + #[serde(default, skip_serializing_if = "is_false")] + pub enabled: bool, +} + +impl HostedReviewConfig { + pub fn is_default(&self) -> bool { + !self.enabled + } +} + +/// Tenant/repository identity required before hosted mode may persist +/// durable memory or transcript artifacts into hosted namespaces. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostedReviewScope { + pub tenant_id: String, + pub canonical_repo_identity: String, +} + +impl HostedReviewScope { + pub fn new(tenant_id: String, canonical_repo_identity: String) -> Self { + Self { + tenant_id, + canonical_repo_identity, + } + } +} + +pub fn env_enables_hosted_review() -> bool { + std::env::var("COVEN_CODE_HOSTED_REVIEW") + .map(|value| is_truthy(&value)) + .unwrap_or(false) +} + +fn is_truthy(value: &str) -> bool { + !matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "0" | "false" | "no" | "off" + ) +} + +fn is_false(value: &bool) -> bool { + !*value +} diff --git a/src-rust/crates/core/src/lib.rs b/src-rust/crates/core/src/lib.rs index de911ac4..94f4b852 100644 --- a/src-rust/crates/core/src/lib.rs +++ b/src-rust/crates/core/src/lib.rs @@ -47,6 +47,9 @@ pub mod remote_session; // AGENTS.md hierarchical memory loading (T4-1). pub mod claudemd; +// Hosted review runtime isolation model. +pub mod hosted_review; + // Message manipulation utilities (T4-2). pub mod message_utils; @@ -877,6 +880,7 @@ pub mod config { /// Top-level configuration values, merged from CLI args + settings file + env. #[derive(Debug, Clone, Serialize, Deserialize, Default)] + #[serde(default)] pub struct Config { pub api_key: Option, pub model: Option, @@ -899,6 +903,14 @@ pub mod config { pub custom_system_prompt: Option, pub append_system_prompt: Option, pub disable_claude_mds: bool, + /// Hosted review isolation mode. Enabled by settings, CLI, or + /// COVEN_CODE_HOSTED_REVIEW=1. + #[serde( + default, + rename = "hostedReview", + skip_serializing_if = "crate::hosted_review::HostedReviewConfig::is_default" + )] + pub hosted_review: crate::hosted_review::HostedReviewConfig, pub project_dir: Option, #[serde(default)] pub workspace_paths: Vec, @@ -1264,6 +1276,18 @@ pub mod config { .unwrap_or("anthropic") } + pub fn runtime_mode(&self) -> crate::hosted_review::RuntimeMode { + if self.hosted_review_enabled() { + crate::hosted_review::RuntimeMode::HostedReview + } else { + crate::hosted_review::RuntimeMode::Local + } + } + + pub fn hosted_review_enabled(&self) -> bool { + self.hosted_review.enabled || crate::hosted_review::env_enables_hosted_review() + } + /// Resolve the effective model, falling back to a provider-appropriate default. /// /// When a non-Anthropic provider is active and no model is explicitly set, @@ -1692,6 +1716,11 @@ pub mod config { .or(base.config.append_system_prompt), disable_claude_mds: over.config.disable_claude_mds || base.config.disable_claude_mds, + hosted_review: if over.config.hosted_review.enabled { + over.config.hosted_review + } else { + base.config.hosted_review + }, project_dir: over.config.project_dir.or(base.config.project_dir), workspace_paths: { let mut v = base.config.workspace_paths; @@ -2009,6 +2038,30 @@ pub mod config { "trusted-project" ); } + + #[test] + fn hosted_review_config_deserializes_from_camel_case() { + let settings: Settings = + serde_json::from_str(r#"{"config":{"hostedReview":{"enabled":true}}}"#).unwrap(); + + assert!(settings.effective_config().hosted_review_enabled()); + } + + #[test] + fn hosted_review_env_enables_config() { + let _lock = crate::coven_shared::COVEN_HOME_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let original = std::env::var("COVEN_CODE_HOSTED_REVIEW").ok(); + std::env::set_var("COVEN_CODE_HOSTED_REVIEW", "1"); + + assert!(Config::default().hosted_review_enabled()); + + match original { + Some(value) => std::env::set_var("COVEN_CODE_HOSTED_REVIEW", value), + None => std::env::remove_var("COVEN_CODE_HOSTED_REVIEW"), + } + } } } @@ -2116,6 +2169,7 @@ pub mod context { pub struct ContextBuilder { cwd: PathBuf, disable_claude_mds: bool, + memory_load_options: crate::claudemd::MemoryLoadOptions, } impl ContextBuilder { @@ -2123,6 +2177,7 @@ pub mod context { Self { cwd, disable_claude_mds: false, + memory_load_options: crate::claudemd::MemoryLoadOptions::local(), } } @@ -2131,6 +2186,11 @@ pub mod context { self } + pub fn memory_load_options(mut self, options: crate::claudemd::MemoryLoadOptions) -> Self { + self.memory_load_options = options; + self + } + /// System context (git status, platform, IDE, etc.) pub async fn build_system_context(&self) -> String { let mut parts = vec![]; @@ -2203,19 +2263,23 @@ pub mod context { /// Walk up from cwd looking for AGENTS.md files and the global one. async fn find_and_read_claude_md(&self) -> Option { let mut claude_mds = vec![]; + let mut loaded_scopes: Vec<&str> = Vec::new(); // Global ~/.coven-code/AGENTS.md - if let Some(home) = dirs::home_dir() { - let global_claude_md = home - .join(".coven-code") - .join(crate::constants::CLAUDE_MD_FILENAME); - if global_claude_md.exists() { - if let Ok(content) = tokio::fs::read_to_string(&global_claude_md).await { - claude_mds.push(format!( - "# Memory (from {})\n{}", - global_claude_md.display(), - content - )); + if self.memory_load_options.allow_user_memory { + if let Some(home) = dirs::home_dir() { + let global_claude_md = home + .join(".coven-code") + .join(crate::constants::CLAUDE_MD_FILENAME); + if global_claude_md.exists() { + if let Ok(content) = tokio::fs::read_to_string(&global_claude_md).await { + loaded_scopes.push("user"); + claude_mds.push(format!( + "# Memory (from {})\n{}", + global_claude_md.display(), + content + )); + } } } } @@ -2227,6 +2291,7 @@ pub mod context { let candidate = d.join(crate::constants::CLAUDE_MD_FILENAME); if candidate.exists() { if let Ok(content) = tokio::fs::read_to_string(&candidate).await { + loaded_scopes.push("project"); project_mds.push(format!( "# Project Memory (from {})\n{}", candidate.display(), @@ -2240,6 +2305,22 @@ pub mod context { project_mds.reverse(); claude_mds.extend(project_mds); + if self.memory_load_options.mode.is_hosted_review() { + loaded_scopes.sort_unstable(); + loaded_scopes.dedup(); + let scopes = if loaded_scopes.is_empty() { + "none".to_string() + } else { + loaded_scopes.join(", ") + }; + claude_mds.insert( + 0, + format!( + "# Hosted Review Mode\nMode: hosted-review\nLoaded AGENTS.md scopes: {scopes}" + ), + ); + } + if claude_mds.is_empty() { None } else { @@ -2247,6 +2328,49 @@ pub mod context { } } } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn hosted_review_context_skips_global_user_memory() { + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("AGENTS.md"), "project instructions").unwrap(); + + let home = tempfile::tempdir().unwrap(); + let coven_code = home.path().join(".coven-code"); + std::fs::create_dir_all(&coven_code).unwrap(); + std::fs::write(coven_code.join("AGENTS.md"), "private user instructions").unwrap(); + + let _lock = crate::coven_shared::COVEN_HOME_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let original_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", home.path()); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let context = runtime.block_on(async { + ContextBuilder::new(project.path().to_path_buf()) + .memory_load_options(crate::claudemd::MemoryLoadOptions::hosted_review()) + .build_user_context() + .await + }); + + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + + assert!(context.contains("Mode: hosted-review")); + assert!(context.contains("Loaded AGENTS.md scopes: project")); + assert!(context.contains("project instructions")); + assert!(!context.contains("private user instructions")); + } + } } // --------------------------------------------------------------------------- @@ -3220,6 +3344,9 @@ pub mod history { /// Remote bridge URL if this session is mirrored to a remote endpoint. #[serde(skip_serializing_if = "Option::is_none")] pub remote_session_url: Option, + /// Marker for sessions produced under hosted review isolation. + #[serde(default, rename = "hostedReview", skip_serializing_if = "is_false")] + pub hosted_review: bool, /// Accumulated USD cost for this session. #[serde(default)] pub total_cost: f64, @@ -3252,6 +3379,7 @@ pub mod history { branch_from: None, branch_at_message: None, remote_session_url: None, + hosted_review: false, total_cost: 0.0, total_tokens: 0, checkpoints: vec![], @@ -3427,6 +3555,7 @@ pub mod history { branch_from: Some(source_id.to_string()), branch_at_message: Some(clamped_idx), remote_session_url: None, + hosted_review: source.hosted_review, total_cost: 0.0, total_tokens: 0, checkpoints: vec![], @@ -3462,6 +3591,10 @@ pub mod history { .collect() } + fn is_false(value: &bool) -> bool { + !*value + } + #[cfg(test)] mod session_meta_golden_tests { //! TDD anchor for SP-2.2 (PERF-MEM-2). `list_sessions`/`search_sessions` diff --git a/src-rust/crates/core/src/memdir.rs b/src-rust/crates/core/src/memdir.rs index 2d082ea1..335e51b0 100644 --- a/src-rust/crates/core/src/memdir.rs +++ b/src-rust/crates/core/src/memdir.rs @@ -11,6 +11,8 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; +use crate::hosted_review::{HostedReviewScope, RuntimeMode}; + // --------------------------------------------------------------------------- // Memory type taxonomy // --------------------------------------------------------------------------- @@ -361,6 +363,49 @@ pub fn auto_memory_path(project_root: &Path) -> PathBuf { memory_base.join("projects").join(sanitized).join("memory") } +/// Compute the auto-memory directory for a runtime mode. +/// +/// Hosted review mode requires a tenant and canonical repository identity +/// before it may resolve a durable memory namespace. +pub fn auto_memory_path_for_mode( + project_root: &Path, + mode: RuntimeMode, + scope: Option<&HostedReviewScope>, +) -> crate::Result { + match mode { + RuntimeMode::Local => Ok(auto_memory_path(project_root)), + RuntimeMode::HostedReview => { + let scope = scope.ok_or_else(|| { + crate::ClaudeError::Config( + "hosted review memory persistence requires tenant scope and canonical repo identity" + .to_string(), + ) + })?; + Ok(hosted_memory_path(scope)) + } + } +} + +pub fn ensure_auto_memory_dir_exists_for_mode( + project_root: &Path, + mode: RuntimeMode, + scope: Option<&HostedReviewScope>, +) -> crate::Result { + let path = auto_memory_path_for_mode(project_root, mode, scope)?; + ensure_memory_dir_exists(&path); + Ok(path) +} + +fn hosted_memory_path(scope: &HostedReviewScope) -> PathBuf { + crate::config::Settings::config_dir() + .join("hosted-review") + .join("tenants") + .join(sanitize_path_component(&scope.tenant_id)) + .join("repos") + .join(sanitize_path_component(&scope.canonical_repo_identity)) + .join("memory") +} + /// Sanitize an arbitrary string into a directory-name-safe component. /// Matches `sanitizePath` used inside `getAutoMemPath` in `paths.ts`. pub fn sanitize_path_component(s: &str) -> String { @@ -892,4 +937,41 @@ mod tests { // (The full env-var paths are integration-tested separately.) let _ = is_auto_memory_enabled(Some(false)); } + + #[test] + fn hosted_memory_path_requires_scope() { + let project = PathBuf::from("/tmp/repo"); + let err = auto_memory_path_for_mode( + &project, + crate::hosted_review::RuntimeMode::HostedReview, + None, + ) + .unwrap_err(); + + assert!(err.to_string().contains("tenant scope")); + } + + #[test] + fn hosted_memory_path_uses_separate_namespace() { + let project = PathBuf::from("/tmp/repo"); + let scope = crate::hosted_review::HostedReviewScope::new( + "tenant-a".to_string(), + "github.com/OpenCoven/coven-code".to_string(), + ); + + let local = auto_memory_path(&project); + let hosted = auto_memory_path_for_mode( + &project, + crate::hosted_review::RuntimeMode::HostedReview, + Some(&scope), + ) + .unwrap(); + + assert_ne!(hosted, local); + assert!(hosted.to_string_lossy().contains("hosted-review")); + assert!(hosted.to_string_lossy().contains("tenant-a")); + assert!(hosted + .to_string_lossy() + .contains("github.com_OpenCoven_coven-code")); + } } diff --git a/src-rust/crates/core/src/session_storage.rs b/src-rust/crates/core/src/session_storage.rs index ff78a39e..d5df6f93 100644 --- a/src-rust/crates/core/src/session_storage.rs +++ b/src-rust/crates/core/src/session_storage.rs @@ -18,6 +18,7 @@ use base64::Engine; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::hosted_review::{HostedReviewScope, RuntimeMode}; use crate::types::Message; // --------------------------------------------------------------------------- @@ -146,6 +147,10 @@ pub struct TranscriptMessage { #[serde(default)] pub managed_session_id: Option, + /// Marker for transcripts produced by hosted review mode. + #[serde(default, skip_serializing_if = "is_false")] + pub hosted_review: bool, + /// Catch-all for any other fields written by the TS CLI that we don't /// need to inspect. #[serde(flatten)] @@ -156,6 +161,10 @@ fn default_user_type() -> String { "external".to_string() } +fn is_false(value: &bool) -> bool { + !*value +} + // --------------------------------------------------------------------------- // Metadata-only entry types // --------------------------------------------------------------------------- @@ -234,11 +243,47 @@ pub fn transcript_dir(project_root: &Path) -> PathBuf { projects_dir().join(encoded) } +pub fn transcript_dir_for_mode( + project_root: &Path, + mode: RuntimeMode, + scope: Option<&HostedReviewScope>, +) -> crate::Result { + match mode { + RuntimeMode::Local => Ok(transcript_dir(project_root)), + RuntimeMode::HostedReview => { + let scope = scope.ok_or_else(|| { + crate::ClaudeError::Config( + "hosted review transcript persistence requires tenant scope and canonical repo identity" + .to_string(), + ) + })?; + Ok(projects_dir() + .join("hosted-review") + .join("tenants") + .join(crate::memdir::sanitize_path_component(&scope.tenant_id)) + .join("repos") + .join(crate::memdir::sanitize_path_component( + &scope.canonical_repo_identity, + )) + .join("transcripts")) + } + } +} + /// Returns the full path to a session's JSONL transcript file. pub fn transcript_path(project_root: &Path, session_id: &str) -> PathBuf { transcript_dir(project_root).join(format!("{}.jsonl", session_id)) } +pub fn transcript_path_for_mode( + project_root: &Path, + session_id: &str, + mode: RuntimeMode, + scope: Option<&HostedReviewScope>, +) -> crate::Result { + Ok(transcript_dir_for_mode(project_root, mode, scope)?.join(format!("{session_id}.jsonl"))) +} + // --------------------------------------------------------------------------- // Core I/O operations // --------------------------------------------------------------------------- @@ -549,6 +594,24 @@ pub fn make_user_entry( parent_uuid: Option<&str>, session_id: &str, cwd: &str, +) -> TranscriptEntry { + make_user_entry_for_mode( + message, + uuid, + parent_uuid, + session_id, + cwd, + RuntimeMode::Local, + ) +} + +pub fn make_user_entry_for_mode( + message: Message, + uuid: &str, + parent_uuid: Option<&str>, + session_id: &str, + cwd: &str, + mode: RuntimeMode, ) -> TranscriptEntry { TranscriptEntry::User(TranscriptMessage { uuid: Some(uuid.to_string()), @@ -563,6 +626,7 @@ pub fn make_user_entry( git_branch: None, agent_role: None, managed_session_id: None, + hosted_review: mode.is_hosted_review(), extra: Default::default(), }) } @@ -574,6 +638,24 @@ pub fn make_assistant_entry( parent_uuid: Option<&str>, session_id: &str, cwd: &str, +) -> TranscriptEntry { + make_assistant_entry_for_mode( + message, + uuid, + parent_uuid, + session_id, + cwd, + RuntimeMode::Local, + ) +} + +pub fn make_assistant_entry_for_mode( + message: Message, + uuid: &str, + parent_uuid: Option<&str>, + session_id: &str, + cwd: &str, + mode: RuntimeMode, ) -> TranscriptEntry { TranscriptEntry::Assistant(TranscriptMessage { uuid: Some(uuid.to_string()), @@ -588,6 +670,7 @@ pub fn make_assistant_entry( git_branch: None, agent_role: None, managed_session_id: None, + hosted_review: mode.is_hosted_review(), extra: Default::default(), }) } @@ -731,4 +814,54 @@ mod tests { let decoded = URL_SAFE_NO_PAD.decode(encoded_dir).unwrap(); assert_eq!(String::from_utf8(decoded).unwrap(), root.to_str().unwrap()); } + + #[test] + fn hosted_transcript_path_requires_scope() { + let err = transcript_path_for_mode( + Path::new("/tmp/repo"), + "sess-1", + crate::hosted_review::RuntimeMode::HostedReview, + None, + ) + .unwrap_err(); + + assert!(err.to_string().contains("tenant scope")); + } + + #[test] + fn hosted_transcript_path_uses_separate_namespace() { + let scope = crate::hosted_review::HostedReviewScope::new( + "tenant-a".to_string(), + "github.com/OpenCoven/coven-code".to_string(), + ); + let local = transcript_path(Path::new("/tmp/repo"), "sess-1"); + let hosted = transcript_path_for_mode( + Path::new("/tmp/repo"), + "sess-1", + crate::hosted_review::RuntimeMode::HostedReview, + Some(&scope), + ) + .unwrap(); + + assert_ne!(hosted, local); + assert!(hosted.to_string_lossy().contains("hosted-review")); + assert!(hosted.to_string_lossy().contains("tenant-a")); + } + + #[test] + fn hosted_transcript_entries_are_marked() { + let msg = make_msg(Role::User); + let uuid = uuid::Uuid::new_v4().to_string(); + let entry = make_user_entry_for_mode( + msg, + &uuid, + None, + "sess-1", + "/repo", + crate::hosted_review::RuntimeMode::HostedReview, + ); + let json = serde_json::to_string(&entry).unwrap(); + + assert!(json.contains("\"hostedReview\":true")); + } } diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 71045d04..072c5b0f 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -119,7 +119,10 @@ pub const PROMPT_SLASH_COMMANDS: &[(&str, &str)] = &[ ), ("sandbox", "Toggle sandboxed shell execution"), ("search", "Search the codebase by natural language or regex"), - ("splash", "Show, hide, or toggle the empty-session splash screen"), + ( + "splash", + "Show, hide, or toggle the empty-session splash screen", + ), ("session", "Browse, rename, fork, branch, and tag sessions"), ( "settings", @@ -3462,7 +3465,6 @@ impl App { return false; } - // Connect-a-provider dialog (/connect command) if self.connect_dialog.visible { match key.code {