diff --git a/Cargo.lock b/Cargo.lock index 895c13c14..6be59d809 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3657,6 +3657,7 @@ dependencies = [ "anyhow", "chrono", "serde", + "serde_json", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d20afc0d4..e7d2928d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -242,7 +242,7 @@ mmdr-size-api = ["jcode-tui-mermaid/mmdr-size-api"] pdf = ["dep:jcode-pdf"] [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] } +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] } [target.'cfg(target_os = "macos")'.dependencies] global-hotkey = "0.7" diff --git a/crates/jcode-build-support/src/customizations.rs b/crates/jcode-build-support/src/customizations.rs new file mode 100644 index 000000000..8430436df --- /dev/null +++ b/crates/jcode-build-support/src/customizations.rs @@ -0,0 +1,167 @@ +use super::{ + SelfDevCustomizationOutcome, SelfDevCustomizationOutcomeStatus, SelfDevCustomizationRecord, + SelfDevCustomizationStatus, +}; +use anyhow::Result; +use chrono::Utc; +use jcode_storage as storage; +use std::path::PathBuf; + +fn customizations_dir() -> Result { + let dir = storage::jcode_dir()?.join("selfdev").join("customizations"); + storage::ensure_dir(&dir)?; + Ok(dir) +} + +pub fn customization_records_dir() -> Result { + let dir = customizations_dir()?.join("records"); + storage::ensure_dir(&dir)?; + Ok(dir) +} + +pub fn customization_patches_dir() -> Result { + let dir = customizations_dir()?.join("patches"); + storage::ensure_dir(&dir)?; + Ok(dir) +} + +pub fn customization_record_path(id: &str) -> Result { + Ok(customization_records_dir()?.join(format!("{}.json", sanitize_record_id(id)))) +} + +pub fn customization_patch_path(id: &str) -> Result { + Ok(customization_patches_dir()?.join(format!("{}.patch", sanitize_record_id(id)))) +} + +pub fn sanitize_record_id(id: &str) -> String { + let mut clean = String::with_capacity(id.len()); + for ch in id.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + clean.push(ch.to_ascii_lowercase()); + } else { + clean.push('-'); + } + } + let clean = clean.trim_matches('-'); + if clean.is_empty() { + format!("customization-{}", Utc::now().timestamp_millis()) + } else { + clean.chars().take(96).collect() + } +} + +pub fn create_customization_record( + mut record: SelfDevCustomizationRecord, + patch: Option<&str>, +) -> Result { + record.id = sanitize_record_id(&record.id); + let now = Utc::now(); + record.updated_at = now; + if record.created_at > now { + record.created_at = now; + } + + let record_path = customization_record_path(&record.id)?; + if record_path.exists() { + anyhow::bail!("customization record `{}` already exists", record.id); + } + + if let Some(patch) = patch.filter(|patch| !patch.trim().is_empty()) { + let patch_path = customization_patch_path(&record.id)?; + if patch_path.exists() { + anyhow::bail!("customization patch `{}` already exists", record.id); + } + storage::write_text_secret(&patch_path, patch)?; + record.provenance.patch_path = Some(patch_path); + } + + storage::write_json_secret(&record_path, &record)?; + Ok(record) +} + +pub fn save_customization_record(record: &SelfDevCustomizationRecord) -> Result<()> { + storage::write_json_secret(&customization_record_path(&record.id)?, record) +} + +pub fn load_customization_record(id: &str) -> Result> { + let path = customization_record_path(id)?; + if path.exists() { + Ok(Some(storage::read_json(&path)?)) + } else { + Ok(None) + } +} + +pub fn list_customization_records() -> Result> { + let dir = customization_records_dir()?; + let mut records = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let path = entry?.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + if let Ok(record) = storage::read_json::(&path) { + records.push(record); + } + } + records.sort_by(|a, b| { + b.updated_at + .cmp(&a.updated_at) + .then_with(|| a.id.cmp(&b.id)) + }); + Ok(records) +} + +pub fn list_active_customization_records() -> Result> { + Ok(list_customization_records()? + .into_iter() + .filter(SelfDevCustomizationRecord::is_active) + .collect()) +} + +pub fn disable_customization_record( + id: &str, + detail: Option, +) -> Result { + let mut record = load_customization_record(id)? + .ok_or_else(|| anyhow::anyhow!("customization record `{}` not found", id))?; + let now = Utc::now(); + record.status = SelfDevCustomizationStatus::Disabled; + record.disabled_at = Some(now); + record.updated_at = now; + record.outcomes.push(SelfDevCustomizationOutcome { + status: SelfDevCustomizationOutcomeStatus::Disabled, + timestamp: now, + detail, + validation_commands: Vec::new(), + }); + save_customization_record(&record)?; + Ok(record) +} + +pub fn append_customization_outcome( + id: &str, + outcome: SelfDevCustomizationOutcome, +) -> Result { + let mut record = load_customization_record(id)? + .ok_or_else(|| anyhow::anyhow!("customization record `{}` not found", id))?; + record.updated_at = Utc::now(); + record.outcomes.push(outcome); + save_customization_record(&record)?; + Ok(record) +} + +pub fn summarize_customization_update_state() -> Result> { + Ok(list_active_customization_records()? + .into_iter() + .map(|record| SelfDevCustomizationOutcome { + status: SelfDevCustomizationOutcomeStatus::NeedsReview, + timestamp: Utc::now(), + detail: Some(format!( + "Customization `{}` is active and should be reviewed against this update.", + record.id + )), + validation_commands: record.validation.commands, + }) + .collect()) +} diff --git a/crates/jcode-build-support/src/lib.rs b/crates/jcode-build-support/src/lib.rs index 993f63598..a49908416 100644 --- a/crates/jcode-build-support/src/lib.rs +++ b/crates/jcode-build-support/src/lib.rs @@ -1,8 +1,16 @@ +mod customizations; mod paths; mod platform_support; mod source_state; mod storage_helpers; +pub use customizations::{ + append_customization_outcome, create_customization_record, customization_patch_path, + customization_patches_dir, customization_record_path, customization_records_dir, + disable_customization_record, list_active_customization_records, list_customization_records, + load_customization_record, sanitize_record_id, save_customization_record, + summarize_customization_update_state, +}; pub use paths::{ SELFDEV_CARGO_PROFILE, binary_name, binary_stem, client_update_candidate, current_binary_build_time_string, current_binary_built_at, find_dev_binary, @@ -13,8 +21,9 @@ pub use paths::{ }; pub use source_state::{ current_build_info, current_git_diff, current_git_hash, current_git_hash_full, - current_source_state, ensure_source_state_matches, get_commit_message, is_working_tree_dirty, - repo_build_version, repo_scope_key, worktree_scope_key, + current_git_patch_with_untracked, current_source_state, ensure_source_state_matches, + get_commit_message, is_working_tree_dirty, repo_build_version, repo_scope_key, + worktree_scope_key, }; pub use storage_helpers::{ build_log_path, build_progress_path, builds_dir, canary_binary_path, clear_build_progress, @@ -37,7 +46,9 @@ use std::time::{Duration, Instant}; pub use jcode_selfdev_types::{ BinaryChoice, BinaryVersionReport, BuildInfo, CanaryStatus, CrashInfo, DevBinarySourceMetadata, MigrationContext, PendingActivation, PublishedBuild, SelfDevBuildCommand, SelfDevBuildTarget, - SourceState, + SelfDevCustomizationBuildMetadata, SelfDevCustomizationOutcome, + SelfDevCustomizationOutcomeStatus, SelfDevCustomizationProvenance, SelfDevCustomizationRecord, + SelfDevCustomizationStatus, SelfDevCustomizationValidation, SourceState, }; /// Manifest tracking build versions and their status diff --git a/crates/jcode-build-support/src/source_state.rs b/crates/jcode-build-support/src/source_state.rs index 1079caf40..e219ae56b 100644 --- a/crates/jcode-build-support/src/source_state.rs +++ b/crates/jcode-build-support/src/source_state.rs @@ -184,13 +184,62 @@ pub fn current_git_hash_full(repo_dir: &Path) -> Result { /// Get the git diff for uncommitted changes pub fn current_git_diff(repo_dir: &Path) -> Result { let output = Command::new("git") - .args(["diff", "HEAD"]) + .args(["diff", "--binary", "HEAD"]) .current_dir(repo_dir) .output()?; Ok(String::from_utf8_lossy(&output.stdout).to_string()) } +pub fn current_git_patch_with_untracked(repo_dir: &Path) -> Result { + let mut patch = current_git_diff(repo_dir)?; + let untracked = git_output_bytes( + repo_dir, + &["ls-files", "--others", "--exclude-standard", "-z"], + )?; + + for path in untracked + .split(|byte| *byte == 0) + .filter(|entry| !entry.is_empty()) + { + let relative = String::from_utf8_lossy(path); + let path = relative.as_ref(); + let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" }; + let output = Command::new("git") + .args(["diff", "--binary", "--no-index", "--", null_device, path]) + .current_dir(repo_dir) + .output()?; + + match output.status.code() { + Some(0) | Some(1) => {} + code => { + anyhow::bail!( + "git diff --no-index for untracked file {} failed with status {:?}", + path, + code + ); + } + } + + if !patch.is_empty() && !patch.ends_with('\n') { + patch.push('\n'); + } + patch.push_str(&String::from_utf8_lossy(&output.stdout)); + if !output.stderr.is_empty() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + anyhow::bail!( + "git diff --no-index for untracked file {} wrote stderr: {}", + path, + stderr.trim() + ); + } + } + } + + Ok(patch) +} + /// Check if working tree is dirty pub fn is_working_tree_dirty(repo_dir: &Path) -> Result { let output = Command::new("git") diff --git a/crates/jcode-build-support/src/tests.rs b/crates/jcode-build-support/src/tests.rs index 090cd570f..9de986881 100644 --- a/crates/jcode-build-support/src/tests.rs +++ b/crates/jcode-build-support/src/tests.rs @@ -71,6 +71,133 @@ fn source_state_fixture(short_hash: &str, fingerprint: &str) -> SourceState { } } +#[test] +fn customization_storage_round_trip_and_active_filter() { + with_temp_jcode_home(|| { + let mut record = SelfDevCustomizationRecord::new( + "custom/one", + "Keep a local self-dev customization", + "Agents can inspect the customization later", + ); + record + .validation + .commands + .push("cargo check -p jcode".to_string()); + + let stored = + create_customization_record(record, Some("diff --git a/src/main.rs b/src/main.rs\n")) + .expect("create customization"); + + assert_eq!(stored.id, "custom-one"); + assert!( + stored + .provenance + .patch_path + .as_ref() + .is_some_and(|p| p.exists()) + ); + + let loaded = load_customization_record("custom-one") + .expect("load customization") + .expect("record exists"); + assert_eq!(loaded.goal, "Keep a local self-dev customization"); + + let active = list_active_customization_records().expect("list active"); + assert_eq!(active.len(), 1); + assert_eq!(active[0].id, "custom-one"); + + disable_customization_record("custom-one", Some("no longer needed".to_string())) + .expect("disable customization"); + assert!( + list_active_customization_records() + .expect("list active after disable") + .is_empty() + ); + }); +} + +#[test] +fn customization_append_outcome_persists_history() { + with_temp_jcode_home(|| { + let record = SelfDevCustomizationRecord::new( + "custom-two", + "Track update review", + "Update flow records review outcomes", + ); + create_customization_record(record, None).expect("create customization"); + + append_customization_outcome( + "custom-two", + SelfDevCustomizationOutcome { + status: SelfDevCustomizationOutcomeStatus::NeedsReview, + timestamp: chrono::Utc::now(), + detail: Some("review this after pull".to_string()), + validation_commands: vec!["cargo test -p jcode".to_string()], + }, + ) + .expect("append outcome"); + + let loaded = load_customization_record("custom-two") + .expect("load customization") + .expect("record exists"); + assert_eq!(loaded.outcomes.len(), 1); + assert_eq!( + loaded.outcomes[0].status, + SelfDevCustomizationOutcomeStatus::NeedsReview + ); + }); +} + +#[test] +fn customization_patch_with_untracked_keeps_tracked_diff() { + let repo = create_git_repo_fixture(); + std::fs::write( + repo.path().join("Cargo.toml"), + "[package]\nname = \"jcode\"\nversion = \"0.0.1\"\n", + ) + .expect("modify tracked file"); + + let patch = current_git_patch_with_untracked(repo.path()).expect("patch"); + + assert!(patch.contains("diff --git a/Cargo.toml b/Cargo.toml")); + assert!(patch.contains("-version = \"0.0.0\"")); + assert!(patch.contains("+version = \"0.0.1\"")); +} + +#[test] +fn customization_patch_with_untracked_includes_new_file() { + let repo = create_git_repo_fixture(); + std::fs::write(repo.path().join("new_tool.rs"), "pub fn new_tool() {}\n") + .expect("write untracked file"); + + let patch = current_git_patch_with_untracked(repo.path()).expect("patch"); + + assert!(patch.contains("new_tool.rs")); + assert!(patch.contains("+pub fn new_tool() {}")); +} + +#[test] +fn customization_record_patch_file_includes_untracked_file() { + with_temp_jcode_home(|| { + let repo = create_git_repo_fixture(); + std::fs::write(repo.path().join("new_tool.rs"), "pub fn new_tool() {}\n") + .expect("write untracked file"); + let patch = current_git_patch_with_untracked(repo.path()).expect("patch"); + let record = SelfDevCustomizationRecord::new( + "custom-untracked", + "Capture new file", + "Patch provenance includes untracked files", + ); + + let stored = create_customization_record(record, Some(&patch)).expect("create record"); + let patch_path = stored.provenance.patch_path.expect("patch path"); + let patch_text = std::fs::read_to_string(patch_path).expect("read patch"); + + assert!(patch_text.contains("new_tool.rs")); + assert!(patch_text.contains("+pub fn new_tool() {}")); + }); +} + #[test] fn test_build_manifest_default() { let manifest = BuildManifest::default(); diff --git a/crates/jcode-selfdev-types/Cargo.toml b/crates/jcode-selfdev-types/Cargo.toml index 8c60fada0..626637151 100644 --- a/crates/jcode-selfdev-types/Cargo.toml +++ b/crates/jcode-selfdev-types/Cargo.toml @@ -8,3 +8,6 @@ publish = false anyhow = "1" chrono = { version = "0.4", features = ["serde"] } serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +serde_json = "1" diff --git a/crates/jcode-selfdev-types/src/lib.rs b/crates/jcode-selfdev-types/src/lib.rs index 80b648a3b..f142b4c44 100644 --- a/crates/jcode-selfdev-types/src/lib.rs +++ b/crates/jcode-selfdev-types/src/lib.rs @@ -69,6 +69,188 @@ pub struct SourceState { pub changed_paths: usize, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SelfDevCustomizationStatus { + #[default] + Active, + Disabled, + Superseded, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SelfDevCustomizationOutcomeStatus { + AppliedCleanly, + NeedsReview, + Disabled, + RepairedAutomatically, + ValidationPassed, + ValidationFailed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct SelfDevCustomizationBuildMetadata { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build_command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_fingerprint: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct SelfDevCustomizationProvenance { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default)] + pub touched_paths: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diff_stat: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub patch_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct SelfDevCustomizationValidation { + #[serde(default)] + pub commands: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_output: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_validated_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SelfDevCustomizationOutcome { + pub status: SelfDevCustomizationOutcomeStatus, + pub timestamp: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(default)] + pub validation_commands: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SelfDevCustomizationRecord { + pub id: String, + #[serde(default)] + pub status: SelfDevCustomizationStatus, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_at: Option>, + pub goal: String, + pub expected_behavior: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rationale: Option, + #[serde(default)] + pub update_hints: Vec, + #[serde(default)] + pub provenance: SelfDevCustomizationProvenance, + #[serde(default)] + pub validation: SelfDevCustomizationValidation, + #[serde(default)] + pub build: SelfDevCustomizationBuildMetadata, + #[serde(default)] + pub outcomes: Vec, +} + +impl SelfDevCustomizationRecord { + pub fn new( + id: impl Into, + goal: impl Into, + expected_behavior: impl Into, + ) -> Self { + let now = Utc::now(); + Self { + id: id.into(), + status: SelfDevCustomizationStatus::Active, + created_at: now, + updated_at: now, + disabled_at: None, + goal: goal.into(), + expected_behavior: expected_behavior.into(), + intent: None, + rationale: None, + update_hints: Vec::new(), + provenance: SelfDevCustomizationProvenance::default(), + validation: SelfDevCustomizationValidation::default(), + build: SelfDevCustomizationBuildMetadata::default(), + outcomes: Vec::new(), + } + } + + pub fn is_active(&self) -> bool { + self.status == SelfDevCustomizationStatus::Active + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn customization_record_minimal_json_defaults() { + let json = r#"{ + "id": "custom-1", + "created_at": "2026-05-10T00:00:00Z", + "updated_at": "2026-05-10T00:00:00Z", + "goal": "Keep self-dev behavior", + "expected_behavior": "Reload keeps the customization visible" + }"#; + + let record: SelfDevCustomizationRecord = serde_json::from_str(json).unwrap(); + + assert_eq!(record.status, SelfDevCustomizationStatus::Active); + assert!(record.is_active()); + assert!(record.provenance.touched_paths.is_empty()); + assert!(record.validation.commands.is_empty()); + assert!(record.outcomes.is_empty()); + } + + #[test] + fn customization_record_full_json_round_trips() { + let mut record = SelfDevCustomizationRecord::new( + "custom-2", + "Remember local customization", + "Status reports active records", + ); + record.intent = Some("self-dev memory".to_string()); + record.rationale = Some("Agents need persistent context".to_string()); + record.update_hints.push("Review after update".to_string()); + record + .provenance + .touched_paths + .push("src/tool/selfdev/mod.rs".to_string()); + record + .validation + .commands + .push("cargo check -p jcode".to_string()); + record.outcomes.push(SelfDevCustomizationOutcome { + status: SelfDevCustomizationOutcomeStatus::NeedsReview, + timestamp: Utc::now(), + detail: Some("active during update".to_string()), + validation_commands: record.validation.commands.clone(), + }); + + let json = serde_json::to_string(&record).unwrap(); + let loaded: SelfDevCustomizationRecord = serde_json::from_str(&json).unwrap(); + + assert_eq!(loaded, record); + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PublishedBuild { pub version: String, diff --git a/src/tool/selfdev/customization.rs b/src/tool/selfdev/customization.rs new file mode 100644 index 000000000..eb542a75a --- /dev/null +++ b/src/tool/selfdev/customization.rs @@ -0,0 +1,312 @@ +use super::*; +use crate::memory::{MemoryCategory, MemoryEntry, MemoryManager, TrustLevel}; +use std::collections::BTreeSet; + +const DIFF_STAT_LIMIT: usize = 4000; + +impl SelfDevTool { + pub(super) async fn do_record_customization( + &self, + params: SelfDevInput, + ctx: &ToolContext, + ) -> Result { + let goal = non_empty(params.goal, "goal")?; + let expected_behavior = non_empty(params.expected_behavior, "expected_behavior")?; + let repo_dir = Self::resolve_repo_dir(ctx.working_dir.as_deref()) + .ok_or_else(|| anyhow::anyhow!("Could not find jcode repository"))?; + let source = Self::requested_source_state(&repo_dir)?; + let id = params + .id + .unwrap_or_else(|| format!("customization-{}", uuid::Uuid::new_v4().simple())); + + let mut touched_paths = detected_touched_paths(&repo_dir)?; + if let Some(paths) = params.touched_paths { + touched_paths.extend(paths.into_iter().filter(|path| !path.trim().is_empty())); + } + + let diff = if Self::is_test_session() { + String::new() + } else { + build::current_git_patch_with_untracked(&repo_dir) + .context("Failed to capture customization patch")? + }; + let diff_stat = if Self::is_test_session() { + None + } else { + git_output_string(&repo_dir, &["diff", "--stat", "HEAD"]) + .ok() + .map(|value| truncate_chars(&value, DIFF_STAT_LIMIT)) + .filter(|value| !value.trim().is_empty()) + }; + + let mut record = build::SelfDevCustomizationRecord::new(id, goal, expected_behavior); + record.intent = params.customization_intent; + record.rationale = params.rationale; + record.update_hints = params.update_hints.unwrap_or_default(); + record.provenance = build::SelfDevCustomizationProvenance { + session_id: Some(ctx.session_id.clone()), + repo_dir: Some(repo_dir.clone()), + working_dir: ctx.working_dir.clone(), + source: Some(source.clone()), + touched_paths: touched_paths.into_iter().collect(), + diff_stat, + patch_path: None, + }; + record.validation.commands = params.validation_commands.unwrap_or_default(); + record.build.source_fingerprint = Some(source.fingerprint.clone()); + + let stored = build::create_customization_record(record, Some(&diff))?; + let memory_id = write_customization_memory(&stored, &repo_dir, &ctx.session_id).ok(); + + let mut output = format!( + "Recorded self-dev customization `{}`.\n\nGoal: {}\nExpected behavior: {}\nStatus: active", + stored.id, stored.goal, stored.expected_behavior + ); + if let Some(path) = stored.provenance.patch_path.as_ref() { + output.push_str(&format!("\nPatch: {}", path.display())); + } + if let Some(memory_id) = memory_id.as_deref() { + output.push_str(&format!("\nMemory: {}", memory_id)); + } + + Ok(ToolOutput::new(output).with_metadata(json!({ + "id": stored.id, + "status": stored.status, + "record_path": build::customization_record_path(&stored.id)?.to_string_lossy(), + "patch_path": stored.provenance.patch_path.as_ref().map(|p| p.to_string_lossy().to_string()), + "memory_id": memory_id, + }))) + } + + pub(super) async fn do_list_customizations(&self) -> Result { + let records = build::list_customization_records()?; + if records.is_empty() { + return Ok(ToolOutput::new("No self-dev customizations recorded.")); + } + + let mut output = String::from("## Self-Dev Customizations\n\n"); + for record in &records { + output.push_str(&format!( + "- `{}` — {:?}\n Goal: {}\n Updated: {}\n", + record.id, record.status, record.goal, record.updated_at + )); + if !record.provenance.touched_paths.is_empty() { + output.push_str(&format!( + " Paths: {}\n", + record.provenance.touched_paths.join(", ") + )); + } + } + + Ok(ToolOutput::new(output).with_metadata(json!({ "count": records.len() }))) + } + + pub(super) async fn do_inspect_customization(&self, id: Option) -> Result { + let id = non_empty(id, "id")?; + let Some(record) = build::load_customization_record(&id)? else { + return Ok(ToolOutput::new(format!( + "Self-dev customization `{}` was not found.", + id + ))); + }; + + let mut output = format!( + "## Self-Dev Customization `{}`\n\n**Status:** {:?}\n**Goal:** {}\n**Expected behavior:** {}\n**Created:** {}\n**Updated:** {}\n", + record.id, + record.status, + record.goal, + record.expected_behavior, + record.created_at, + record.updated_at + ); + if let Some(rationale) = record.rationale.as_deref() { + output.push_str(&format!("**Rationale:** {}\n", rationale)); + } + if !record.update_hints.is_empty() { + output.push_str(&format!( + "**Update hints:** {}\n", + record.update_hints.join("; ") + )); + } + if !record.validation.commands.is_empty() { + output.push_str(&format!( + "**Validation:** `{}`\n", + record.validation.commands.join("`, `") + )); + } + if let Some(status) = record.validation.last_status.as_ref() { + output.push_str(&format!("**Last validation:** {:?}\n", status)); + } + if !record.provenance.touched_paths.is_empty() { + output.push_str(&format!( + "\n**Touched paths:** {}\n", + record.provenance.touched_paths.join(", ") + )); + } + if let Some(source) = record.provenance.source.as_ref() { + output.push_str(&format!( + "**Source:** `{}` dirty={} changed_paths={}\n", + source.fingerprint, source.dirty, source.changed_paths + )); + } + if let Some(patch) = record.provenance.patch_path.as_ref() { + output.push_str(&format!("**Patch:** {}\n", patch.display())); + } + if !record.outcomes.is_empty() { + output.push_str("\n## Outcomes\n\n"); + for outcome in &record.outcomes { + output.push_str(&format!( + "- {:?} at {}{}\n", + outcome.status, + outcome.timestamp, + outcome + .detail + .as_deref() + .map(|detail| format!(" — {}", detail)) + .unwrap_or_default() + )); + } + } + + Ok(ToolOutput::new(output).with_metadata(json!({ "record": record }))) + } + + pub(super) async fn do_disable_customization( + &self, + id: Option, + reason: Option, + ctx: &ToolContext, + ) -> Result { + let id = non_empty(id, "id")?; + let record = build::disable_customization_record(&id, reason)?; + let memory_removed = forget_customization_memory(&record, ctx).unwrap_or(false); + Ok(ToolOutput::new(format!( + "Disabled self-dev customization `{}`.{}", + record.id, + if memory_removed { + " Removed compact memory entry." + } else { + "" + } + ))) + } +} + +fn customization_memory_id(record_id: &str) -> String { + format!("selfdev-customization-{}", record_id) +} + +fn write_customization_memory( + record: &build::SelfDevCustomizationRecord, + repo_dir: &Path, + session_id: &str, +) -> Result { + let paths = if record.provenance.touched_paths.is_empty() { + "no touched paths recorded".to_string() + } else { + record.provenance.touched_paths.join(", ") + }; + let validation = if record.validation.commands.is_empty() { + "no validation command recorded".to_string() + } else { + record.validation.commands.join("; ") + }; + let content = format!( + "Self-dev customization `{}` is active. Goal: {}. Expected behavior: {}. Paths: {}. Validation: {}.", + record.id, record.goal, record.expected_behavior, paths, validation + ); + let mut entry = MemoryEntry::new( + MemoryCategory::Custom("self_dev_customization".to_string()), + content, + ) + .with_source(session_id.to_string()) + .with_tags(memory_tags(record)); + entry.id = customization_memory_id(&record.id); + entry.trust = TrustLevel::High; + entry.refresh_search_text(); + + MemoryManager::new() + .with_project_dir(repo_dir) + .upsert_project_memory(entry) +} + +fn forget_customization_memory( + record: &build::SelfDevCustomizationRecord, + ctx: &ToolContext, +) -> Result { + let manager = record + .provenance + .repo_dir + .as_ref() + .cloned() + .or_else(|| SelfDevTool::resolve_repo_dir(ctx.working_dir.as_deref())) + .map(|dir| MemoryManager::new().with_project_dir(dir)) + .unwrap_or_else(MemoryManager::new); + manager.forget(&customization_memory_id(&record.id)) +} + +fn memory_tags(record: &build::SelfDevCustomizationRecord) -> Vec { + let mut tags = vec![ + "selfdev".to_string(), + format!("customization:{}", record.id), + "status:active".to_string(), + ]; + tags.extend( + record + .provenance + .touched_paths + .iter() + .take(8) + .map(|path| format!("path:{}", path)), + ); + tags +} + +fn non_empty(value: Option, field: &str) -> Result { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow::anyhow!("{} required", field)) +} + +fn detected_touched_paths(repo_dir: &Path) -> Result> { + if SelfDevTool::is_test_session() { + return Ok(BTreeSet::new()); + } + let status = git_output_string(repo_dir, &["status", "--porcelain=v1"])?; + let mut paths = BTreeSet::new(); + for line in status.lines() { + let raw = line + .get(3..) + .unwrap_or(line) + .trim() + .rsplit_once(" -> ") + .map(|(_, new_path)| new_path) + .unwrap_or_else(|| line.get(3..).unwrap_or(line).trim()); + if !raw.is_empty() { + paths.insert(raw.to_string()); + } + } + Ok(paths) +} + +fn git_output_string(repo_dir: &Path, args: &[&str]) -> Result { + let output = std::process::Command::new("git") + .args(args) + .current_dir(repo_dir) + .output()?; + if !output.status.success() { + anyhow::bail!("git {} failed", args.join(" ")); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + value.to_string() + } else { + let mut truncated: String = value.chars().take(max_chars).collect(); + truncated.push_str("\n..."); + truncated + } +} diff --git a/src/tool/selfdev/mod.rs b/src/tool/selfdev/mod.rs index 3355f385e..b1dc483fd 100644 --- a/src/tool/selfdev/mod.rs +++ b/src/tool/selfdev/mod.rs @@ -11,7 +11,7 @@ use crate::server; use crate::session; use crate::storage; use crate::tool::{Tool, ToolContext, ToolExecutionMode, ToolOutput}; -use anyhow::Result; +use anyhow::{Context, Result}; use async_trait::async_trait; use chrono::Utc; use serde::{Deserialize, Serialize}; @@ -21,6 +21,7 @@ use std::process::Stdio; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; mod build_queue; +mod customization; mod launch; mod reload; mod status; @@ -61,6 +62,30 @@ struct SelfDevInput { /// Background task id for actions like cancel-build. #[serde(default)] task_id: Option, + /// Customization record id for inspect/disable. + #[serde(default)] + id: Option, + /// Human-readable customization goal for record-customization. + #[serde(default)] + goal: Option, + /// Expected behavior for record-customization. + #[serde(default)] + expected_behavior: Option, + /// Optional customization intent label. + #[serde(default)] + customization_intent: Option, + /// Optional rationale for keeping the customization. + #[serde(default)] + rationale: Option, + /// Optional update hints to review during update/install flows. + #[serde(default)] + update_hints: Option>, + /// Optional validation commands for update/repair flows. + #[serde(default)] + validation_commands: Option>, + /// Optional touched paths if auto-detection is incomplete. + #[serde(default)] + touched_paths: Option>, } /// Context saved before reload, restored after restart @@ -390,6 +415,10 @@ impl Tool for SelfDevTool { "cancel-build", "reload", "status", + "record-customization", + "list-customizations", + "inspect-customization", + "disable-customization", "socket-info", "socket-help" ], @@ -408,7 +437,21 @@ impl Tool for SelfDevTool { "description": "Shell command for action=test. Runs under the selfdev worktree compile lock." }, "request_id": { "type": "string" }, - "task_id": { "type": "string" } + "task_id": { "type": "string" }, + "id": { "type": "string" }, + "goal": { + "type": "string", + "description": "Human-readable goal for action=record-customization." + }, + "expected_behavior": { + "type": "string", + "description": "Expected behavior for action=record-customization." + }, + "customization_intent": { "type": "string" }, + "rationale": { "type": "string" }, + "update_hints": { "type": "array", "items": { "type": "string" } }, + "validation_commands": { "type": "array", "items": { "type": "string" } }, + "touched_paths": { "type": "array", "items": { "type": "string" } } }, "required": ["action"] }) @@ -462,6 +505,13 @@ impl Tool for SelfDevTool { } } "status" => self.do_status().await, + "record-customization" => self.do_record_customization(params, &ctx).await, + "list-customizations" => self.do_list_customizations().await, + "inspect-customization" => self.do_inspect_customization(params.id).await, + "disable-customization" => { + self.do_disable_customization(params.id, params.reason, &ctx) + .await + } "socket-info" => { if !SelfDevTool::session_is_selfdev(&ctx.session_id) { Ok(ToolOutput::new( @@ -481,7 +531,7 @@ impl Tool for SelfDevTool { } } _ => Ok(ToolOutput::new(format!( - "Unknown action: {}. Use 'enter', 'build', 'test', 'cancel-build', 'reload', 'status', 'socket-info', or 'socket-help'.", + "Unknown action: {}. Use 'enter', 'build', 'test', 'cancel-build', 'reload', 'status', 'record-customization', 'list-customizations', 'inspect-customization', 'disable-customization', 'socket-info', or 'socket-help'.", action ))), }; diff --git a/src/tool/selfdev/status.rs b/src/tool/selfdev/status.rs index 13d1ead6d..d41c24d54 100644 --- a/src/tool/selfdev/status.rs +++ b/src/tool/selfdev/status.rs @@ -86,6 +86,35 @@ pub fn selfdev_status_output() -> Result { } } + let customizations = build::list_active_customization_records()?; + if !customizations.is_empty() { + status.push_str("\n## Active Customizations\n\n"); + for record in &customizations { + status.push_str(&format!( + "- `{}` — {}\n Expected: {}\n", + record.id, record.goal, record.expected_behavior + )); + if !record.provenance.touched_paths.is_empty() { + status.push_str(&format!( + " Paths: {}\n", + record.provenance.touched_paths.join(", ") + )); + } + if !record.validation.commands.is_empty() { + status.push_str(&format!( + " Validation: `{}`\n", + record.validation.commands.join("`, `") + )); + } + if let Some(validation_status) = record.validation.last_status.as_ref() { + status.push_str(&format!( + " Last validation: {}\n", + validation_status_label(validation_status) + )); + } + } + } + status.push_str("\n## Debug Socket\n\n"); status.push_str(&format!( "**Path:** {}\n", @@ -209,6 +238,17 @@ pub fn selfdev_status_output() -> Result { Ok(ToolOutput::new(status)) } +fn validation_status_label(status: &build::SelfDevCustomizationOutcomeStatus) -> &'static str { + match status { + build::SelfDevCustomizationOutcomeStatus::AppliedCleanly => "applied_cleanly", + build::SelfDevCustomizationOutcomeStatus::RepairedAutomatically => "repaired_automatically", + build::SelfDevCustomizationOutcomeStatus::NeedsReview => "needs_review", + build::SelfDevCustomizationOutcomeStatus::Disabled => "disabled", + build::SelfDevCustomizationOutcomeStatus::ValidationPassed => "validation_passed", + build::SelfDevCustomizationOutcomeStatus::ValidationFailed => "validation_failed", + } +} + impl SelfDevTool { pub(super) async fn do_status(&self) -> Result { selfdev_status_output() diff --git a/src/tool/selfdev/tests.rs b/src/tool/selfdev/tests.rs index 7256bf0ac..fcd8a44e2 100644 --- a/src/tool/selfdev/tests.rs +++ b/src/tool/selfdev/tests.rs @@ -93,6 +93,134 @@ async fn wait_for_task_completion(task_id: &str) -> background::TaskStatusFile { } } +#[tokio::test] +async fn record_customization_creates_record_and_list_output() { + let _storage_guard = crate::storage::lock_test_env(); + let _lock = lock_env(); + let temp_home = tempfile::TempDir::new().expect("temp home"); + let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); + let _test_session_guard = EnvVarGuard::set("JCODE_TEST_SESSION", "1"); + let repo = create_repo_fixture(); + let ctx = create_test_context("session-customization", Some(repo.path().to_path_buf())); + let tool = SelfDevTool::new(); + + let output = tool + .do_record_customization( + SelfDevInput { + action: "record-customization".to_string(), + prompt: None, + context: None, + reason: None, + target: None, + command: None, + notify: None, + wake: None, + request_id: None, + task_id: None, + id: Some("custom/test".to_string()), + goal: Some("Preserve local self-dev behavior".to_string()), + expected_behavior: Some("The customization is visible in status".to_string()), + customization_intent: Some("status-memory".to_string()), + rationale: Some("Agents need durable context".to_string()), + update_hints: Some(vec!["Review after source update".to_string()]), + validation_commands: Some(vec!["cargo check -p jcode".to_string()]), + touched_paths: Some(vec!["src/tool/selfdev/mod.rs".to_string()]), + }, + &ctx, + ) + .await + .expect("record customization"); + + assert!(output.output.contains("custom-test")); + + let loaded = build::load_customization_record("custom-test") + .expect("load customization") + .expect("record exists"); + assert_eq!(loaded.goal, "Preserve local self-dev behavior"); + assert_eq!( + loaded.provenance.touched_paths, + vec!["src/tool/selfdev/mod.rs".to_string()] + ); + + let list = tool + .do_list_customizations() + .await + .expect("list customizations"); + assert!(list.output.contains("custom-test")); +} + +#[tokio::test] +async fn disable_customization_removes_compact_memory_and_active_status() { + let _storage_guard = crate::storage::lock_test_env(); + let _lock = lock_env(); + let temp_home = tempfile::TempDir::new().expect("temp home"); + let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); + let _test_session_guard = EnvVarGuard::set("JCODE_TEST_SESSION", "1"); + let repo = create_repo_fixture(); + let ctx = create_test_context("session-customization", Some(repo.path().to_path_buf())); + let tool = SelfDevTool::new(); + + tool.do_record_customization( + SelfDevInput { + action: "record-customization".to_string(), + prompt: None, + context: None, + reason: None, + target: None, + command: None, + notify: None, + wake: None, + request_id: None, + task_id: None, + id: Some("custom/disable".to_string()), + goal: Some("Remove compact memory when disabled".to_string()), + expected_behavior: Some("Disabled customizations are not active memory".to_string()), + customization_intent: None, + rationale: None, + update_hints: None, + validation_commands: None, + touched_paths: Some(vec!["src/tool/selfdev/mod.rs".to_string()]), + }, + &ctx, + ) + .await + .expect("record customization"); + + let manager = crate::memory::MemoryManager::new().with_project_dir(repo.path()); + assert!( + manager + .list_all() + .expect("list memories") + .iter() + .any(|entry| entry.id == "selfdev-customization-custom-disable") + ); + + let output = tool + .do_disable_customization( + Some("custom-disable".to_string()), + Some("obsolete".to_string()), + &ctx, + ) + .await + .expect("disable customization"); + + assert!(output.output.contains("Removed compact memory entry")); + assert!( + !manager + .list_all() + .expect("list memories") + .iter() + .any(|entry| entry.id == "selfdev-customization-custom-disable") + ); + + let status = selfdev_status_output().expect("status"); + assert!(!status.output.contains("Active Customizations")); + let record = build::load_customization_record("custom-disable") + .expect("load record") + .expect("record exists"); + assert!(!record.is_active()); +} + #[test] fn test_reload_context_serialization() { // Create test context with task info diff --git a/src/update.rs b/src/update.rs index 2fbd9728b..22dedd45a 100644 --- a/src/update.rs +++ b/src/update.rs @@ -12,8 +12,10 @@ pub use jcode_update_core::{ }; use serde::{Deserialize, Serialize}; use std::fs; -use std::io::Read; +use std::io::{BufReader, Read}; use std::path::{Path, PathBuf}; +use std::process::{ExitStatus, Stdio}; +use std::thread; use std::time::{Duration, Instant, SystemTime}; const GITHUB_REPO: &str = "1jehuang/jcode"; @@ -21,6 +23,14 @@ const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(60); // minimum gap const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(5); const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120); const DOWNLOAD_PROGRESS_UPDATE_STEP: u64 = 1_048_576; +const CUSTOMIZATION_VALIDATION_OUTPUT_LIMIT: usize = 4000; +const CUSTOMIZATION_VALIDATION_COMMAND_TIMEOUT: Duration = Duration::from_secs(120); +const CUSTOMIZATION_VALIDATION_TERMINATION_GRACE: Duration = Duration::from_secs(2); +const CUSTOMIZATION_VALIDATION_STREAM_LIMIT: usize = CUSTOMIZATION_VALIDATION_OUTPUT_LIMIT; + +#[cfg(test)] +static CUSTOMIZATION_VALIDATION_TIMEOUT_OVERRIDE_MS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); pub fn print_centered(msg: &str) { let width = crossterm::terminal::size() @@ -318,6 +328,7 @@ fn install_main_source_update_blocking(latest_sha: &str) -> Result { metadata.installed_from = Some("source".to_string()); metadata.last_check = SystemTime::now(); metadata.save()?; + record_customization_update_reports(&channel_version); Ok(path) } @@ -895,10 +906,502 @@ pub fn download_and_install_blocking_with_progress( metadata.last_check = SystemTime::now(); metadata.save()?; record_release_update_duration(started.elapsed()); + record_customization_update_reports(&release.tag_name); Ok(versioned_path) } +fn record_customization_update_reports(version: &str) { + let active = match build::list_active_customization_records() { + Ok(active) => active, + Err(error) => { + crate::logging::warn(&format!( + "Failed to load active self-dev customization records for update reporting: {error}" + )); + return; + } + }; + if active.is_empty() { + return; + } + + for record in active { + let record_id = record.id.clone(); + let result = if record.validation.commands.is_empty() { + let outcome = build::SelfDevCustomizationOutcome { + status: build::SelfDevCustomizationOutcomeStatus::NeedsReview, + timestamp: chrono::Utc::now(), + detail: Some(format!( + "Update to {} installed; active customization should be reviewed before relying on it.", + version + )), + validation_commands: Vec::new(), + }; + build::append_customization_outcome(&record.id, outcome).map(|_| ()) + } else { + record_customization_validation_outcome(record, version) + }; + + if let Err(error) = result { + crate::logging::warn(&format!( + "Failed to append customization update report for {}: {}", + record_id, error + )); + } + } +} + +fn record_customization_validation_outcome( + mut record: build::SelfDevCustomizationRecord, + version: &str, +) -> Result<()> { + let commands = record.validation.commands.clone(); + let now = chrono::Utc::now(); + let (status, detail) = match record.provenance.repo_dir.as_ref() { + Some(repo_dir) if repo_dir.is_dir() => { + run_customization_validation_commands(repo_dir, &commands) + } + Some(repo_dir) => ( + build::SelfDevCustomizationOutcomeStatus::ValidationFailed, + format!( + "Validation failed for update {version}: repo dir unavailable ({})", + repo_dir.display() + ), + ), + None => ( + build::SelfDevCustomizationOutcomeStatus::ValidationFailed, + format!("Validation failed for update {version}: repo dir unavailable"), + ), + }; + + record.validation.last_status = Some(status); + record.validation.last_output = Some(detail.clone()); + record.validation.last_validated_at = Some(now); + record.updated_at = now; + record.outcomes.push(build::SelfDevCustomizationOutcome { + status, + timestamp: now, + detail: Some(detail), + validation_commands: commands, + }); + build::save_customization_record(&record)?; + Ok(()) +} + +fn run_customization_validation_commands( + repo_dir: &Path, + commands: &[String], +) -> (build::SelfDevCustomizationOutcomeStatus, String) { + let mut combined = String::new(); + for command in commands { + let output = match run_validation_command(repo_dir, command) { + Ok(output) => output, + Err(error) => { + return ( + build::SelfDevCustomizationOutcomeStatus::ValidationFailed, + truncate_with_tail( + &format!("Validation command `{command}` failed to start: {error}"), + CUSTOMIZATION_VALIDATION_OUTPUT_LIMIT, + ), + ); + } + }; + + let command_detail = validation_output_detail(command, &output); + append_validation_output(&mut combined, &command_detail); + if !output.status.success() { + return ( + build::SelfDevCustomizationOutcomeStatus::ValidationFailed, + truncate_failure_detail( + &combined, + &command_detail, + CUSTOMIZATION_VALIDATION_OUTPUT_LIMIT, + ), + ); + } + } + + ( + build::SelfDevCustomizationOutcomeStatus::ValidationPassed, + truncate_with_tail(&combined, CUSTOMIZATION_VALIDATION_OUTPUT_LIMIT), + ) +} + +struct ValidationCommandOutput { + status: ExitStatus, + stdout: Vec, + stderr: Vec, + timed_out: bool, +} + +fn run_validation_command(repo_dir: &Path, command: &str) -> Result { + let mut process = spawn_validation_command(repo_dir, command)?; + + let stdout = process.stdout.take().context("missing validation stdout")?; + let stderr = process.stderr.take().context("missing validation stderr")?; + let stdout_reader = + thread::spawn(move || read_limited(stdout, CUSTOMIZATION_VALIDATION_STREAM_LIMIT)); + let stderr_reader = + thread::spawn(move || read_limited(stderr, CUSTOMIZATION_VALIDATION_STREAM_LIMIT)); + + let started = Instant::now(); + let mut timed_out = false; + let timeout = validation_command_timeout(); + let status = loop { + if let Some(status) = process.try_wait()? { + break status; + } + if started.elapsed() >= timeout { + timed_out = true; + break terminate_validation_process_tree(&mut process)?; + } + thread::sleep(Duration::from_millis(100)); + }; + cleanup_validation_process_job(process.id()); + + let stdout = stdout_reader + .join() + .unwrap_or_else(|_| b"".to_vec()); + let stderr = stderr_reader + .join() + .unwrap_or_else(|_| b"".to_vec()); + + Ok(ValidationCommandOutput { + status, + stdout, + stderr, + timed_out, + }) +} + +fn spawn_validation_command(repo_dir: &Path, command: &str) -> Result { + if cfg!(windows) { + spawn_windows_validation_command(repo_dir, command) + } else { + spawn_unix_validation_command(repo_dir, command) + } +} + +#[cfg(unix)] +fn spawn_unix_validation_command(repo_dir: &Path, command: &str) -> Result { + use std::os::unix::process::CommandExt; + + let mut shell = std::process::Command::new("sh"); + shell + .args(["-c", command]) + .current_dir(repo_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + unsafe { + shell.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + Ok(shell.spawn()?) +} + +#[cfg(not(unix))] +fn spawn_unix_validation_command(_repo_dir: &Path, _command: &str) -> Result { + anyhow::bail!("Unix validation command spawning is unavailable on this platform") +} + +#[cfg(windows)] +fn spawn_windows_validation_command(repo_dir: &Path, command: &str) -> Result { + use std::os::windows::io::AsRawHandle; + use std::os::windows::process::CommandExt; + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, + }; + use windows_sys::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP; + + let mut process = std::process::Command::new("cmd") + .args(["/C", command]) + .current_dir(repo_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .creation_flags(CREATE_NEW_PROCESS_GROUP) + .spawn()?; + + unsafe { + let job = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if job.is_null() { + let _ = process.kill(); + anyhow::bail!( + "CreateJobObjectW failed: {}", + std::io::Error::last_os_error() + ); + } + + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let set_ok = SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + &info as *const _ as *const core::ffi::c_void, + std::mem::size_of::() as u32, + ); + let assign_ok = AssignProcessToJobObject(job, process.as_raw_handle() as HANDLE); + if set_ok == 0 || assign_ok == 0 { + let error = std::io::Error::last_os_error(); + let _ = process.kill(); + let _ = CloseHandle(job); + anyhow::bail!("failed to attach validation command to job object: {error}"); + } + WINDOWS_VALIDATION_JOBS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push((process.id(), job as isize)); + } + + Ok(process) +} + +#[cfg(not(windows))] +fn spawn_windows_validation_command( + _repo_dir: &Path, + _command: &str, +) -> Result { + anyhow::bail!("Windows validation command spawning is unavailable on this platform") +} + +#[cfg(windows)] +static WINDOWS_VALIDATION_JOBS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +fn terminate_validation_process_tree(process: &mut std::process::Child) -> Result { + terminate_validation_process_tree_inner(process)?; + Ok(process.wait()?) +} + +#[cfg(windows)] +fn cleanup_validation_process_job(pid: u32) { + use windows_sys::Win32::Foundation::CloseHandle; + + let job = { + let mut jobs = WINDOWS_VALIDATION_JOBS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + jobs.iter() + .position(|(job_pid, _)| *job_pid == pid) + .map(|index| jobs.remove(index).1) + }; + if let Some(job) = job { + unsafe { + let _ = CloseHandle(job as windows_sys::Win32::Foundation::HANDLE); + } + } +} + +#[cfg(unix)] +fn cleanup_validation_process_job(pid: u32) { + let _ = signal_validation_process_group(pid, libc::SIGTERM); + let _ = signal_validation_process_group(pid, libc::SIGKILL); +} + +#[cfg(not(any(unix, windows)))] +fn cleanup_validation_process_job(_pid: u32) {} + +#[cfg(unix)] +fn terminate_validation_process_tree_inner(process: &mut std::process::Child) -> Result<()> { + signal_validation_process_group(process.id(), libc::SIGTERM)?; + if wait_for_validation_exit(process, CUSTOMIZATION_VALIDATION_TERMINATION_GRACE)?.is_some() { + return Ok(()); + } + signal_validation_process_group(process.id(), libc::SIGKILL)?; + Ok(()) +} + +#[cfg(windows)] +fn terminate_validation_process_tree_inner(process: &mut std::process::Child) -> Result<()> { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::TerminateJobObject; + + let job = WINDOWS_VALIDATION_JOBS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .find_map(|(pid, job)| (*pid == process.id()).then_some(*job)); + if let Some(job) = job { + unsafe { + let job = job as windows_sys::Win32::Foundation::HANDLE; + let _ = TerminateJobObject(job, 1); + let _ = CloseHandle(job); + } + WINDOWS_VALIDATION_JOBS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .retain(|(pid, _)| *pid != process.id()); + Ok(()) + } else { + let _ = process.kill(); + Ok(()) + } +} + +#[cfg(not(any(unix, windows)))] +fn terminate_validation_process_tree_inner(process: &mut std::process::Child) -> Result<()> { + let _ = process.kill(); + Ok(()) +} + +#[cfg(unix)] +fn signal_validation_process_group(pid: u32, signal: i32) -> Result<()> { + let rc = unsafe { libc::kill(-(pid as i32), signal) }; + if rc == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + Ok(()) + } else { + Err(error.into()) + } +} + +fn wait_for_validation_exit( + process: &mut std::process::Child, + timeout: Duration, +) -> Result> { + let started = Instant::now(); + loop { + if let Some(status) = process.try_wait()? { + return Ok(Some(status)); + } + if started.elapsed() >= timeout { + return Ok(None); + } + thread::sleep(Duration::from_millis(50)); + } +} + +fn validation_command_timeout() -> Duration { + #[cfg(test)] + { + let override_ms = + CUSTOMIZATION_VALIDATION_TIMEOUT_OVERRIDE_MS.load(std::sync::atomic::Ordering::SeqCst); + if override_ms > 0 { + return Duration::from_millis(override_ms); + } + } + CUSTOMIZATION_VALIDATION_COMMAND_TIMEOUT +} + +fn read_limited(reader: R, limit: usize) -> Vec { + let mut reader = BufReader::new(reader); + let mut output = Vec::new(); + let mut buffer = [0_u8; 8192]; + let mut truncated = false; + + loop { + let read = match reader.read(&mut buffer) { + Ok(0) => break, + Ok(read) => read, + Err(_) => break, + }; + if output.len() < limit { + let remaining = limit - output.len(); + output.extend_from_slice(&buffer[..read.min(remaining)]); + if read > remaining { + truncated = true; + } + } else { + truncated = true; + } + } + + if truncated { + output.extend_from_slice(b"\n..."); + } + output +} + +fn validation_output_detail(command: &str, output: &ValidationCommandOutput) -> String { + let mut detail = format!( + "Command: `{}`\nStatus: {}\n", + command, + output.status.code().map_or_else( + || "terminated by signal".to_string(), + |code| code.to_string() + ) + ); + if output.timed_out { + detail.push_str(&format!( + "Timed out after {}\n", + format_validation_timeout(validation_command_timeout()) + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.trim().is_empty() { + detail.push_str("Stdout:\n"); + detail.push_str(stdout.trim()); + detail.push('\n'); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + detail.push_str("Stderr:\n"); + detail.push_str(stderr.trim()); + detail.push('\n'); + } + detail +} + +fn append_validation_output(combined: &mut String, detail: &str) { + if !combined.is_empty() { + combined.push_str("\n\n"); + } + combined.push_str(detail); +} + +fn truncate_failure_detail(history: &str, failing_detail: &str, max_chars: usize) -> String { + if history.chars().count() <= max_chars { + return history.to_string(); + } + + let marker = "\n... truncated earlier validation output ...\n\n"; + let marker_chars = marker.chars().count(); + let failing_chars = failing_detail.chars().count(); + if failing_chars + marker_chars < max_chars { + let head_chars = max_chars - failing_chars - marker_chars; + let head: String = history.chars().take(head_chars).collect(); + format!("{head}{marker}{failing_detail}") + } else { + truncate_with_tail(failing_detail, max_chars) + } +} + +fn truncate_with_tail(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + value.to_string() + } else { + let marker = "\n... truncated validation output ...\n"; + let marker_chars = marker.chars().count(); + if max_chars <= marker_chars + 2 { + return value.chars().take(max_chars).collect(); + } + let remaining = max_chars - marker_chars; + let head_chars = remaining / 2; + let tail_chars = remaining - head_chars; + let head: String = value.chars().take(head_chars).collect(); + let tail_start = value.chars().count().saturating_sub(tail_chars); + let tail: String = value.chars().skip(tail_start).collect(); + format!("{head}{marker}{tail}") + } +} + +fn format_validation_timeout(timeout: Duration) -> String { + if timeout.as_secs() > 0 { + format!("{} seconds", timeout.as_secs()) + } else { + format!("{} ms", timeout.as_millis()) + } +} + pub fn check_and_maybe_update(auto_install: bool) -> UpdateCheckResult { use crate::bus::{Bus, BusEvent, UpdateStatus}; @@ -1120,4 +1623,284 @@ mod tests { Duration::from_secs(123) ); } + + #[test] + fn test_record_customization_update_reports_without_commands_marks_needs_review() { + let _storage_guard = storage::lock_test_env(); + static ENV_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + let _env_guard = ENV_LOCK + .get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let temp_home = tempfile::tempdir().expect("temp home"); + struct EnvRestore(Option); + impl Drop for EnvRestore { + fn drop(&mut self) { + if let Some(previous) = self.0.as_ref() { + jcode_core::env::set_var("JCODE_HOME", previous); + } else { + jcode_core::env::remove_var("JCODE_HOME"); + } + } + } + let _restore = EnvRestore(std::env::var_os("JCODE_HOME")); + jcode_core::env::set_var("JCODE_HOME", temp_home.path()); + + let record = build::SelfDevCustomizationRecord::new( + "custom-update", + "Keep local self-dev behavior", + "Update reports make review visible", + ); + build::create_customization_record(record, None).expect("create customization"); + + record_customization_update_reports("v9.9.9"); + + let loaded = build::load_customization_record("custom-update") + .expect("load customization") + .expect("record exists"); + assert_eq!(loaded.outcomes.len(), 1); + assert_eq!( + loaded.outcomes[0].status, + build::SelfDevCustomizationOutcomeStatus::NeedsReview + ); + assert_eq!(loaded.outcomes[0].validation_commands, Vec::::new()); + } + + #[test] + fn test_record_customization_update_reports_validation_pass() { + let _storage_guard = storage::lock_test_env(); + static ENV_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + let _env_guard = ENV_LOCK + .get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let temp_home = tempfile::tempdir().expect("temp home"); + let repo = tempfile::tempdir().expect("repo dir"); + struct EnvRestore(Option); + impl Drop for EnvRestore { + fn drop(&mut self) { + if let Some(previous) = self.0.as_ref() { + jcode_core::env::set_var("JCODE_HOME", previous); + } else { + jcode_core::env::remove_var("JCODE_HOME"); + } + } + } + let _restore = EnvRestore(std::env::var_os("JCODE_HOME")); + jcode_core::env::set_var("JCODE_HOME", temp_home.path()); + + let mut record = build::SelfDevCustomizationRecord::new( + "custom-validation-pass", + "Validate customization", + "Passing validation is recorded", + ); + record.provenance.repo_dir = Some(repo.path().to_path_buf()); + record + .validation + .commands + .push("echo validation-ok".to_string()); + build::create_customization_record(record, None).expect("create customization"); + + record_customization_update_reports("v9.9.9"); + + let loaded = build::load_customization_record("custom-validation-pass") + .expect("load customization") + .expect("record exists"); + assert_eq!(loaded.outcomes.len(), 1); + assert_eq!( + loaded.outcomes[0].status, + build::SelfDevCustomizationOutcomeStatus::ValidationPassed + ); + assert_eq!( + loaded.validation.last_status, + Some(build::SelfDevCustomizationOutcomeStatus::ValidationPassed) + ); + assert!( + loaded + .validation + .last_output + .as_deref() + .unwrap_or_default() + .contains("validation-ok") + ); + } + + #[test] + fn test_record_customization_update_reports_validation_failure_is_report_only() { + let _storage_guard = storage::lock_test_env(); + static ENV_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + let _env_guard = ENV_LOCK + .get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let temp_home = tempfile::tempdir().expect("temp home"); + let repo = tempfile::tempdir().expect("repo dir"); + struct EnvRestore(Option); + impl Drop for EnvRestore { + fn drop(&mut self) { + if let Some(previous) = self.0.as_ref() { + jcode_core::env::set_var("JCODE_HOME", previous); + } else { + jcode_core::env::remove_var("JCODE_HOME"); + } + } + } + let _restore = EnvRestore(std::env::var_os("JCODE_HOME")); + jcode_core::env::set_var("JCODE_HOME", temp_home.path()); + + let mut record = build::SelfDevCustomizationRecord::new( + "custom-validation-fail", + "Validate customization", + "Failing validation is recorded", + ); + record.provenance.repo_dir = Some(repo.path().to_path_buf()); + record + .validation + .commands + .push("echo validation-failed && exit 7".to_string()); + build::create_customization_record(record, None).expect("create customization"); + + record_customization_update_reports("v9.9.9"); + + let loaded = build::load_customization_record("custom-validation-fail") + .expect("load customization") + .expect("record exists"); + assert_eq!(loaded.outcomes.len(), 1); + assert_eq!( + loaded.outcomes[0].status, + build::SelfDevCustomizationOutcomeStatus::ValidationFailed + ); + assert_eq!( + loaded.validation.last_status, + Some(build::SelfDevCustomizationOutcomeStatus::ValidationFailed) + ); + let output = loaded.validation.last_output.as_deref().unwrap_or_default(); + assert!(output.contains("validation-failed")); + assert!(output.contains("Status: 7")); + } + + #[test] + fn test_record_customization_update_reports_preserves_failing_command_when_truncated() { + let _storage_guard = storage::lock_test_env(); + static ENV_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + let _env_guard = ENV_LOCK + .get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let temp_home = tempfile::tempdir().expect("temp home"); + let repo = tempfile::tempdir().expect("repo dir"); + struct EnvRestore(Option); + impl Drop for EnvRestore { + fn drop(&mut self) { + if let Some(previous) = self.0.as_ref() { + jcode_core::env::set_var("JCODE_HOME", previous); + } else { + jcode_core::env::remove_var("JCODE_HOME"); + } + } + } + let _restore = EnvRestore(std::env::var_os("JCODE_HOME")); + jcode_core::env::set_var("JCODE_HOME", temp_home.path()); + + let noisy_command = if cfg!(windows) { + "for /L %i in (1,1,5000) do @echo noisy" + } else { + "yes noisy | head -c 8000" + }; + let failing_command = if cfg!(windows) { + "echo final-validation-failure && exit /B 7" + } else { + "echo final-validation-failure && exit 7" + }; + let mut record = build::SelfDevCustomizationRecord::new( + "custom-validation-truncated-fail", + "Validate customization", + "Failing validation remains visible after truncation", + ); + record.provenance.repo_dir = Some(repo.path().to_path_buf()); + record.validation.commands.push(noisy_command.to_string()); + record.validation.commands.push(failing_command.to_string()); + build::create_customization_record(record, None).expect("create customization"); + + record_customization_update_reports("v9.9.9"); + + let loaded = build::load_customization_record("custom-validation-truncated-fail") + .expect("load customization") + .expect("record exists"); + assert_eq!( + loaded.validation.last_status, + Some(build::SelfDevCustomizationOutcomeStatus::ValidationFailed) + ); + let output = loaded.validation.last_output.as_deref().unwrap_or_default(); + assert!(output.contains("truncated earlier validation output")); + assert!(output.contains("final-validation-failure")); + assert!(output.contains("Status: 7")); + assert!(output.chars().count() <= CUSTOMIZATION_VALIDATION_OUTPUT_LIMIT); + } + + #[test] + fn test_customization_validation_timeout_kills_child_process_tree() { + let _timeout_guard = ValidationTimeoutOverride::set(200); + let repo = tempfile::tempdir().expect("repo dir"); + let command = if cfg!(windows) { + "cmd /C \"ping -n 10 127.0.0.1 > nul\"" + } else { + "sh -c 'sleep 10'" + }; + + let started = Instant::now(); + let (status, detail) = + run_customization_validation_commands(repo.path(), &[command.to_string()]); + + assert_eq!( + status, + build::SelfDevCustomizationOutcomeStatus::ValidationFailed + ); + assert!(detail.contains("Timed out after")); + assert!( + started.elapsed() < Duration::from_secs(5), + "timeout should terminate the child process tree promptly" + ); + } + + #[test] + fn test_customization_validation_cleans_background_children_after_success() { + let repo = tempfile::tempdir().expect("repo dir"); + let command = if cfg!(windows) { + "start /B cmd /C \"ping -n 10 127.0.0.1 > nul\" && echo background-ok" + } else { + "sleep 10 & echo background-ok" + }; + + let started = Instant::now(); + let (status, detail) = + run_customization_validation_commands(repo.path(), &[command.to_string()]); + + assert_eq!( + status, + build::SelfDevCustomizationOutcomeStatus::ValidationPassed + ); + assert!(detail.contains("background-ok")); + assert!( + started.elapsed() < Duration::from_secs(5), + "background child pipe handles should not delay validation completion" + ); + } + + struct ValidationTimeoutOverride; + + impl ValidationTimeoutOverride { + fn set(timeout_ms: u64) -> Self { + CUSTOMIZATION_VALIDATION_TIMEOUT_OVERRIDE_MS + .store(timeout_ms, std::sync::atomic::Ordering::SeqCst); + Self + } + } + + impl Drop for ValidationTimeoutOverride { + fn drop(&mut self) { + CUSTOMIZATION_VALIDATION_TIMEOUT_OVERRIDE_MS + .store(0, std::sync::atomic::Ordering::SeqCst); + } + } }