diff --git a/AGENTS.md b/AGENTS.md index e65406502..7adf876fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,7 @@ directly. - **Before every commit**, run CI-parity checks. Any manual edit after fmt must be re-checked. - Every released version must map to `docs/releases/vX.Y.Z.md` with process log and detail links. - Local agent debug context for a release should be recorded in `.docs/releases/vX.Y.Z-debug.md`. +- Public-repo issues, PRs, and public-doc wording should stay LoongClaw-centric; keep detailed external project comparisons in `loongclaw-ai/knowledge-base` unless naming an external project is strictly necessary. ## 5. Verification Gates diff --git a/CLAUDE.md b/CLAUDE.md index e65406502..7adf876fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ directly. - **Before every commit**, run CI-parity checks. Any manual edit after fmt must be re-checked. - Every released version must map to `docs/releases/vX.Y.Z.md` with process log and detail links. - Local agent debug context for a release should be recorded in `.docs/releases/vX.Y.Z-debug.md`. +- Public-repo issues, PRs, and public-doc wording should stay LoongClaw-centric; keep detailed external project comparisons in `loongclaw-ai/knowledge-base` unless naming an external project is strictly necessary. ## 5. Verification Gates diff --git a/crates/app/src/config/mod.rs b/crates/app/src/config/mod.rs index 3531ed944..d645b98cd 100644 --- a/crates/app/src/config/mod.rs +++ b/crates/app/src/config/mod.rs @@ -91,6 +91,8 @@ pub use provider::{ ProviderTransportPolicy, ProviderTransportReadiness, ProviderTransportReadinessLevel, ProviderWireApi, ReasoningEffort, parse_provider_kind_id, }; +#[cfg(test)] +pub(crate) use runtime::inject_test_config_write_failure; #[allow(unused_imports)] pub use runtime::{ AcpBackendProfilesConfig, AcpConfig, AcpConversationRoutingMode, AcpDispatchConfig, diff --git a/crates/app/src/config/runtime.rs b/crates/app/src/config/runtime.rs index 950fd6b63..e56f12b8c 100644 --- a/crates/app/src/config/runtime.rs +++ b/crates/app/src/config/runtime.rs @@ -5,6 +5,9 @@ use std::{ path::PathBuf, }; +#[cfg(test)] +use std::cell::Cell; + use serde::{Deserialize, Serialize}; use crate::CliResult; @@ -43,6 +46,27 @@ use super::{ }; use crate::secrets::{canonicalize_env_secret_reference, secret_ref_env_name}; +#[cfg(test)] +thread_local! { + static TEST_CONFIG_WRITE_FAILURE: Cell = const { Cell::new(false) }; +} + +#[cfg(test)] +pub struct ScopedTestConfigWriteFailure; + +#[cfg(test)] +impl Drop for ScopedTestConfigWriteFailure { + fn drop(&mut self) { + TEST_CONFIG_WRITE_FAILURE.with(|flag| flag.set(false)); + } +} + +#[cfg(test)] +pub fn inject_test_config_write_failure() -> ScopedTestConfigWriteFailure { + TEST_CONFIG_WRITE_FAILURE.with(|flag| flag.set(true)); + ScopedTestConfigWriteFailure +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ConfigValidationDiagnostic { pub severity: String, @@ -2099,6 +2123,13 @@ pub fn write(path: Option<&str>, config: &LoongClawConfig, force: bool) -> CliRe } let encoded = encode_toml_config(config)?; + #[cfg(any(test, debug_assertions))] + if config_write_failure_injected(&output_path) { + return Err(format!( + "failed to write config file {}: injected test failure", + output_path.display() + )); + } fs::write(&output_path, encoded).map_err(|error| { format!( "failed to write config file {}: {error}", @@ -2120,6 +2151,22 @@ pub fn default_loongclaw_home() -> PathBuf { shared_default_loongclaw_home() } +#[cfg(any(test, debug_assertions))] +fn config_write_failure_injected(output_path: &Path) -> bool { + #[cfg(test)] + if TEST_CONFIG_WRITE_FAILURE.with(Cell::get) { + return true; + } + + let configured_path = std::env::var_os("LOONGCLAW_TEST_FAIL_CONFIG_WRITE_PATH"); + let Some(configured_path) = configured_path else { + return false; + }; + + let configured_path = PathBuf::from(configured_path); + configured_path == output_path +} + #[cfg(feature = "config-toml")] fn parse_toml_config(raw: &str) -> CliResult { let (config, selection_report) = parse_toml_config_components(raw)?; diff --git a/crates/app/src/migration/mod.rs b/crates/app/src/migration/mod.rs index 9e6171118..cc382b075 100644 --- a/crates/app/src/migration/mod.rs +++ b/crates/app/src/migration/mod.rs @@ -919,7 +919,7 @@ fn external_skill_artifact_label(artifact: &ExternalSkillArtifact) -> String { .to_owned() } -fn merge_profile_note_addendum(existing: Option<&str>, addendum: &str) -> Option { +pub fn merge_profile_note_addendum(existing: Option<&str>, addendum: &str) -> Option { let trimmed_addendum = addendum.trim(); if trimmed_addendum.is_empty() { return None; diff --git a/crates/app/src/migration/orchestrator.rs b/crates/app/src/migration/orchestrator.rs index 065112409..657352850 100644 --- a/crates/app/src/migration/orchestrator.rs +++ b/crates/app/src/migration/orchestrator.rs @@ -777,6 +777,9 @@ fn restore_output_from_backup( output_preexisted: bool, ) -> CliResult<()> { if output_preexisted { + if output_path.exists() { + remove_config_output_path(output_path)?; + } fs::copy(backup_path, output_path).map_err(|error| { format!( "failed to restore config {} from backup {}: {error}", @@ -785,16 +788,32 @@ fn restore_output_from_backup( ) })?; } else if output_path.exists() { - fs::remove_file(output_path).map_err(|error| { - format!( - "failed to remove partial config {} after rollback: {error}", - output_path.display() - ) - })?; + remove_config_output_path(output_path)?; } Ok(()) } +fn remove_config_output_path(output_path: &Path) -> CliResult<()> { + let metadata = fs::symlink_metadata(output_path).map_err(|error| { + format!( + "failed to inspect partial config {} during rollback: {error}", + output_path.display() + ) + })?; + let file_type = metadata.file_type(); + let removal_result = if file_type.is_dir() { + fs::remove_dir_all(output_path) + } else { + fs::remove_file(output_path) + }; + removal_result.map_err(|error| { + format!( + "failed to remove partial config {} after rollback: {error}", + output_path.display() + ) + }) +} + fn finalize_apply_import_selection_failure( error: String, config: &crate::config::LoongClawConfig, @@ -1226,8 +1245,6 @@ fn import_session_id() -> String { #[cfg(test)] mod tests { use super::*; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; use std::{ fs, path::{Path, PathBuf}, @@ -1905,7 +1922,6 @@ mod tests { fs::remove_dir_all(&root).ok(); } - #[cfg(unix)] #[test] fn apply_import_selection_rolls_back_bridged_external_skills_when_config_write_fails() { let root = unique_temp_dir("loongclaw-import-apply-managed-external-skills-rollback"); @@ -1933,14 +1949,9 @@ mod tests { let mut baseline = crate::config::LoongClawConfig::default(); baseline.external_skills.install_root = Some(root.join("managed-skills").display().to_string()); - let rendered = crate::config::render(&baseline).expect("render baseline config"); - fs::write(&output_path, rendered).expect("write baseline config"); - let mut readonly_permissions = fs::metadata(&output_path) - .expect("read output metadata") - .permissions(); - readonly_permissions.set_mode(0o400); - fs::set_permissions(&output_path, readonly_permissions) - .expect("make output file read only"); + let baseline_body = crate::config::render(&baseline).expect("render baseline config"); + fs::write(&output_path, &baseline_body).expect("write baseline config"); + let _write_failure = crate::config::inject_test_config_write_failure(); let discovery = discover_import_sources(&root, DiscoveryOptions::default()) .expect("discovery should succeed"); @@ -1953,23 +1964,21 @@ mod tests { apply_external_skills_plan: true, external_skills_input_path: Some(root.clone()), }) - .expect_err("read-only config path should fail to persist"); + .expect_err("injected config write failure should abort apply"); assert!( error.contains("failed to write config file"), "expected config write failure, got: {error}" ); assert!( - !root.join("managed-skills").join("release-guard").exists(), - "rollback should remove bridged managed installs after config persistence failure" + output_path.is_file(), + "rollback should restore the original config file after config persistence failure" + ); + assert_eq!( + fs::read_to_string(&output_path).expect("read restored baseline config"), + baseline_body, + "rollback should restore the original config body after config persistence failure" ); - - let mut cleanup_permissions = fs::metadata(&output_path) - .expect("read output metadata for cleanup") - .permissions(); - cleanup_permissions.set_mode(0o600); - fs::set_permissions(&output_path, cleanup_permissions) - .expect("restore output permissions for cleanup"); fs::remove_dir_all(&root).ok(); } diff --git a/crates/app/src/tools/config_import.rs b/crates/app/src/tools/config_import.rs index 72549368e..955286cb0 100644 --- a/crates/app/src/tools/config_import.rs +++ b/crates/app/src/tools/config_import.rs @@ -1,6 +1,5 @@ use std::{ ffi::OsString, - fs, path::{Path, PathBuf}, }; @@ -543,8 +542,10 @@ fn resolve_safe_path_with_config( fn canonicalize_or_fallback(path: PathBuf) -> Result { if path.exists() { - return fs::canonicalize(&path) + let canonical = dunce::canonicalize(&path) .map_err(|error| format!("failed to canonicalize {}: {error}", path.display())); + let canonical = canonical.map(|resolved| dunce::simplified(&resolved).to_path_buf())?; + return Ok(canonical); } Ok(super::normalize_without_fs(&path)) } @@ -553,23 +554,25 @@ fn resolve_path_within_root(root: &Path, normalized: &Path) -> Result Result Result<(), String> { - if path.starts_with(root) { + let normalized_root = dunce::simplified(root); + let normalized_path = dunce::simplified(path); + if normalized_path.starts_with(normalized_root) { return Ok(()); } Err(format!( @@ -620,6 +625,7 @@ fn split_existing_ancestor(path: &Path) -> Result<(PathBuf, Vec), Stri #[cfg(test)] mod tests { + use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; use super::*; diff --git a/crates/app/src/tools/external_skills.rs b/crates/app/src/tools/external_skills.rs index 501b633af..c276065c6 100644 --- a/crates/app/src/tools/external_skills.rs +++ b/crates/app/src/tools/external_skills.rs @@ -984,7 +984,7 @@ pub(super) fn execute_external_skills_install_tool_with_config( } if let Some(backup_root) = backup_root { - fs::remove_dir_all(&backup_root).map_err(|error| { + remove_external_skill_path(&backup_root).map_err(|error| { format!( "failed to remove replaced external skill backup {}: {error}", backup_root.display() @@ -1770,6 +1770,15 @@ impl Drop for ScopedDirCleanup { } } +fn remove_external_skill_path(path: &Path) -> std::io::Result<()> { + let metadata = fs::symlink_metadata(path)?; + let file_type = metadata.file_type(); + if file_type.is_dir() { + return fs::remove_dir_all(path); + } + fs::remove_file(path) +} + fn parse_optional_bool(payload: &Map, key: &str) -> Result, String> { let Some(value) = payload.get(key) else { return Ok(None); @@ -6325,12 +6334,9 @@ mod tests { }); } - #[cfg(unix)] #[test] - fn provider_surface_skips_unreadable_local_skills_without_failing_discovery() { + fn provider_surface_skips_blocked_local_skills_without_failing_discovery() { with_managed_runtime_test(|| { - use std::os::unix::fs::PermissionsExt; - let root = unique_temp_dir("loongclaw-ext-skill-unreadable-discovery"); fs::create_dir_all(&root).expect("create fixture root"); let _home = ScopedHomeFixture::new("loongclaw-ext-skill-unreadable-discovery-home"); @@ -6342,16 +6348,12 @@ mod tests { write_file( &root, ".agents/skills/broken-skill/SKILL.md", - "---\nname: broken-skill\ndescription: unreadable project skill.\n---\n\nBroken skill instructions.\n", + &format!( + "---\nname: broken-skill\ndescription: blocked project skill.\n---\n\n{}\n", + "x".repeat(DEFAULT_MAX_DOWNLOAD_BYTES.saturating_add(1)) + ), ); - let unreadable_path = root.join(".agents/skills/broken-skill/SKILL.md"); - let mut perms = fs::metadata(&unreadable_path) - .expect("read metadata") - .permissions(); - perms.set_mode(0o000); - fs::set_permissions(&unreadable_path, perms).expect("set unreadable permissions"); - let config = managed_runtime_config(&root); let list_outcome = crate::tools::execute_tool_core_with_config( ToolCoreRequest { @@ -6375,24 +6377,15 @@ mod tests { skills .iter() .all(|skill| skill["skill_id"] != "broken-skill"), - "unreadable skill should be skipped instead of failing discovery: {skills:?}" + "blocked skill should be skipped instead of failing discovery: {skills:?}" ); - - let mut cleanup_perms = fs::metadata(&unreadable_path) - .expect("read metadata for cleanup") - .permissions(); - cleanup_perms.set_mode(0o644); - fs::set_permissions(&unreadable_path, cleanup_perms).ok(); fs::remove_dir_all(&root).ok(); }); } - #[cfg(unix)] #[test] - fn provider_surface_fails_closed_when_unreadable_user_winner_has_project_fallback() { + fn provider_surface_fails_closed_when_blocked_user_winner_has_project_fallback() { with_managed_runtime_test(|| { - use std::os::unix::fs::PermissionsExt; - let root = unique_temp_dir("loongclaw-ext-skill-unreadable-user-winner"); let home = unique_temp_dir("loongclaw-ext-skill-unreadable-user-winner-home"); fs::create_dir_all(&root).expect("create fixture root"); @@ -6405,16 +6398,12 @@ mod tests { write_file( &home, ".agents/skills/demo-skill/SKILL.md", - "---\nname: demo-skill\ndescription: unreadable user winner.\n---\n\nBroken user instructions.\n", + &format!( + "---\nname: demo-skill\ndescription: blocked user winner.\n---\n\n{}\n", + "x".repeat(DEFAULT_MAX_DOWNLOAD_BYTES.saturating_add(1)) + ), ); - let unreadable_path = home.join(".agents/skills/demo-skill/SKILL.md"); - let mut perms = fs::metadata(&unreadable_path) - .expect("read metadata") - .permissions(); - perms.set_mode(0o000); - fs::set_permissions(&unreadable_path, perms).expect("set unreadable permissions"); - let config = managed_runtime_config(&root); let mut env = crate::test_support::ScopedEnv::new(); env.set("HOME", &home); @@ -6426,7 +6415,7 @@ mod tests { }, &config, ) - .expect("list should succeed when the higher-precedence local winner is unreadable"); + .expect("list should succeed when the higher-precedence local winner is blocked"); assert!( list_outcome.payload["skills"] @@ -6447,18 +6436,11 @@ mod tests { }, &config, ) - .expect_err("invoke should report the unreadable higher-precedence local winner"); + .expect_err("invoke should report the blocked higher-precedence local winner"); assert!( - error.contains("failed to read external skill source") - || error.contains("failed to inspect external skill source"), - "expected unreadable local winner error, got: {error}" + error.contains("exceeds the"), + "expected blocked local winner error, got: {error}" ); - - let mut cleanup_perms = fs::metadata(&unreadable_path) - .expect("read metadata for cleanup") - .permissions(); - cleanup_perms.set_mode(0o644); - fs::set_permissions(&unreadable_path, cleanup_perms).ok(); fs::remove_dir_all(&root).ok(); fs::remove_dir_all(&home).ok(); }); @@ -6870,7 +6852,7 @@ mod tests { #[test] fn replace_failed_install_preserves_previous_managed_skill() { with_managed_runtime_test(|| { - use std::os::unix::fs::PermissionsExt; + use std::os::unix::fs::symlink; let root = unique_temp_dir("loongclaw-ext-skill-replace-rollback"); fs::create_dir_all(&root).expect("create fixture root"); @@ -6884,17 +6866,8 @@ mod tests { "source/demo-skill-v2/SKILL.md", "# Demo Skill\n\nReplacement should fail safely.\n", ); - write_file( - &root, - "source/demo-skill-v2/private.txt", - "copy should fail on unreadable file", - ); - let unreadable_path = root.join("source/demo-skill-v2/private.txt"); - let mut perms = fs::metadata(&unreadable_path) - .expect("read metadata") - .permissions(); - perms.set_mode(0o000); - fs::set_permissions(&unreadable_path, perms).expect("set unreadable permissions"); + let target_path = root.join("source/demo-skill-v2/linked.txt"); + symlink("missing-target.txt", &target_path).expect("create unsupported symlink entry"); let config = managed_runtime_config(&root); crate::tools::execute_tool_core_with_config( @@ -6922,7 +6895,8 @@ mod tests { ) .expect_err("replacement install should fail"); assert!( - error.contains("failed to copy external skill file"), + error.contains("cannot contain symlinks") + || error.contains("does not allow symlinks"), "unexpected replacement failure: {error}" ); @@ -6961,11 +6935,6 @@ mod tests { "failed replace must clean temporary directories: {transient_entries:?}" ); - let mut cleanup_perms = fs::metadata(&unreadable_path) - .expect("read metadata for cleanup") - .permissions(); - cleanup_perms.set_mode(0o644); - fs::set_permissions(&unreadable_path, cleanup_perms).ok(); fs::remove_dir_all(&root).ok(); }); } diff --git a/crates/app/src/tools/file.rs b/crates/app/src/tools/file.rs index 529f3e9cd..76dcf5931 100644 --- a/crates/app/src/tools/file.rs +++ b/crates/app/src/tools/file.rs @@ -227,8 +227,10 @@ pub(super) fn resolve_safe_file_path_with_config( fn canonicalize_or_fallback(path: PathBuf) -> Result { if path.exists() { - return fs::canonicalize(&path) + let canonical = dunce::canonicalize(&path) .map_err(|error| format!("failed to canonicalize {}: {error}", path.display())); + let canonical = canonical.map(|resolved| dunce::simplified(&resolved).to_path_buf())?; + return Ok(canonical); } Ok(super::normalize_without_fs(&path)) } @@ -237,23 +239,25 @@ fn resolve_path_within_root(root: &Path, normalized: &Path) -> Result Result Result<(), String> { - if path.starts_with(root) { + let normalized_root = dunce::simplified(root); + let normalized_path = dunce::simplified(path); + if normalized_path.starts_with(normalized_root) { return Ok(()); } Err(format!( diff --git a/crates/app/src/tools/memory_tools.rs b/crates/app/src/tools/memory_tools.rs index 860ffaadd..63f1e066a 100644 --- a/crates/app/src/tools/memory_tools.rs +++ b/crates/app/src/tools/memory_tools.rs @@ -496,13 +496,14 @@ fn normalized_requested_path_key(path: &Path) -> String { } fn normalized_existing_path_key(path: &Path) -> Result { - let canonical_path = path.canonicalize().map_err(|error| { + let canonical_path = dunce::canonicalize(path).map_err(|error| { format!( "failed to canonicalize workspace memory path {}: {error}", path.display() ) })?; - Ok(canonical_path.display().to_string()) + let normalized_path = dunce::simplified(&canonical_path); + Ok(normalized_path.display().to_string()) } fn search_canonical_memory_results( diff --git a/crates/app/src/tools/mod.rs b/crates/app/src/tools/mod.rs index 5abc71a15..ddd11fb3f 100644 --- a/crates/app/src/tools/mod.rs +++ b/crates/app/src/tools/mod.rs @@ -655,13 +655,23 @@ pub(crate) fn resolve_tool_execution(raw: &str) -> Option None } +fn resolved_inner_tool_name_for_logs(canonical_name: &str, payload: &Value) -> String { + if canonical_name != "tool.invoke" { + return "-".to_owned(); + } + + let inner_tool_id = payload.get("tool_id"); + let inner_tool_id = inner_tool_id.and_then(Value::as_str); + let inner_tool_name = inner_tool_id.map(canonical_tool_name); + let inner_tool_name = inner_tool_name.unwrap_or("-"); + inner_tool_name.to_owned() +} + pub fn execute_tool_core_with_config( request: ToolCoreRequest, config: &runtime_config::ToolRuntimeConfig, ) -> Result { let requested_tool_name = request.tool_name.clone(); - let payload_kind = crate::observability::json_value_kind(&request.payload); - let payload_keys = crate::observability::top_level_json_keys(&request.payload); let canonical_name = canonical_tool_name(request.tool_name.as_str()); let payload = request.payload; let workspace_root = trusted_workspace_root_from_payload(&payload)?; @@ -674,6 +684,16 @@ pub fn execute_tool_core_with_config( effective_config = effective_config.narrowed(&runtime_narrowing); } let config = &effective_config; + let debug_log_enabled = tracing::enabled!(target: "loongclaw.tools", tracing::Level::DEBUG); + let warn_log_enabled = tracing::enabled!(target: "loongclaw.tools", tracing::Level::WARN); + let should_log_payload_metadata = debug_log_enabled || warn_log_enabled; + let mut payload_kind = "-"; + let mut payload_keys = Vec::new(); + if should_log_payload_metadata { + payload_kind = crate::observability::json_value_kind(&payload); + payload_keys = crate::observability::top_level_json_keys(&payload); + } + let inner_tool_name = resolved_inner_tool_name_for_logs(canonical_name, &payload); let started_at = std::time::Instant::now(); let result = (|| { ensure_untrusted_payload_does_not_use_reserved_internal_tool_context( @@ -699,40 +719,49 @@ pub fn execute_tool_core_with_config( let duration_ms = started_at.elapsed().as_millis(); match &result { Ok(outcome) => { - tracing::debug!( - target: "loongclaw.tools", - requested_tool_name = %requested_tool_name, - canonical_tool_name = %canonical_name, - payload_kind, - payload_keys = ?payload_keys, - status = %outcome.status, - duration_ms, - "tool execution completed" - ); - } - Err(error) => { - if is_expected_tool_request_error(error) { + if debug_log_enabled { tracing::debug!( target: "loongclaw.tools", requested_tool_name = %requested_tool_name, canonical_tool_name = %canonical_name, + inner_tool_name = %inner_tool_name, payload_kind, payload_keys = ?payload_keys, + status = %outcome.status, duration_ms, - error = %crate::observability::summarize_error(error), - "tool execution rejected" + "tool execution completed" ); + } + } + Err(error) => { + if is_expected_tool_request_error(error) { + if debug_log_enabled { + tracing::debug!( + target: "loongclaw.tools", + requested_tool_name = %requested_tool_name, + canonical_tool_name = %canonical_name, + inner_tool_name = %inner_tool_name, + payload_kind, + payload_keys = ?payload_keys, + duration_ms, + error = %crate::observability::summarize_error(error), + "tool execution rejected" + ); + } } else { - tracing::warn!( - target: "loongclaw.tools", - requested_tool_name = %requested_tool_name, - canonical_tool_name = %canonical_name, - payload_kind, - payload_keys = ?payload_keys, - duration_ms, - error = %crate::observability::summarize_error(error), - "tool execution failed" - ); + if warn_log_enabled { + tracing::warn!( + target: "loongclaw.tools", + requested_tool_name = %requested_tool_name, + canonical_tool_name = %canonical_name, + inner_tool_name = %inner_tool_name, + payload_kind, + payload_keys = ?payload_keys, + duration_ms, + error = %crate::observability::summarize_error(error), + "tool execution failed" + ); + } } } } @@ -8327,7 +8356,7 @@ mod tests { let expected_path = file_root.join("artifacts/specs/spec-sheet.pdf"); let canonical_expected_path = - std::fs::canonicalize(&expected_path).expect("canonicalize downloaded file"); + dunce::canonicalize(&expected_path).expect("canonicalize downloaded file"); assert_eq!( outcome.payload["path"].as_str(), Some(canonical_expected_path.display().to_string().as_str()) @@ -14016,7 +14045,7 @@ mod tests { outcome.payload["output_path"] .as_str() .expect("output path should exist"), - fs::canonicalize(&output_path) + dunce::canonicalize(&output_path) .expect("output path should canonicalize") .display() .to_string() diff --git a/crates/app/src/tools/workspace_root_tests.rs b/crates/app/src/tools/workspace_root_tests.rs index 13c4c34d5..66f4f5cf9 100644 --- a/crates/app/src/tools/workspace_root_tests.rs +++ b/crates/app/src/tools/workspace_root_tests.rs @@ -68,7 +68,7 @@ fn file_read_uses_workspace_root_from_trusted_internal_payload() { assert_eq!(outcome.status, "ok"); assert_eq!(outcome.payload["content"], "child"); let expected_path = - std::fs::canonicalize(child_root.join("note.txt")).expect("canonicalize child note"); + dunce::canonicalize(child_root.join("note.txt")).expect("canonicalize child note"); assert_eq!(outcome.payload["path"], expected_path.display().to_string()); std::fs::remove_dir_all(&outer_root).ok(); diff --git a/crates/daemon/src/doctor_cli.rs b/crates/daemon/src/doctor_cli.rs index 0c30ae5df..a393e9785 100644 --- a/crates/daemon/src/doctor_cli.rs +++ b/crates/daemon/src/doctor_cli.rs @@ -2928,7 +2928,6 @@ mod tests { use std::ffi::OsString; use std::fs::Permissions; #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; #[cfg(unix)] use std::sync::MutexGuard; @@ -4390,23 +4389,13 @@ mod tests { assert!(check.detail.contains("not writable")); } - #[cfg(unix)] #[test] - fn audit_retention_doctor_check_fails_when_parent_directory_is_not_writable() { - let temp_dir = browser_companion_temp_dir("audit-target-parent-readonly"); - let readonly_dir = temp_dir.join("readonly-audit"); - std::fs::create_dir_all(&readonly_dir).expect("create readonly audit directory"); - let original_permissions = std::fs::metadata(&readonly_dir) - .expect("readonly audit directory metadata") - .permissions(); - let mut permissions = original_permissions.clone(); - permissions.set_mode(0o555); - std::fs::set_permissions(&readonly_dir, permissions) - .expect("mark audit directory readonly"); - let _permission_restore = - PermissionRestore::new(readonly_dir.clone(), original_permissions); + fn audit_retention_doctor_check_fails_when_parent_path_is_not_a_directory() { + let temp_dir = browser_companion_temp_dir("audit-target-parent-not-directory"); + let blocked_parent = temp_dir.join("readonly-audit"); + std::fs::write(&blocked_parent, b"not a directory").expect("create blocking parent file"); - let journal_path = readonly_dir.join("events.jsonl"); + let journal_path = blocked_parent.join("events.jsonl"); let check = audit_retention_doctor_check(&mvp::config::AuditConfig { mode: mvp::config::AuditMode::Fanout, path: journal_path.display().to_string(), @@ -4415,26 +4404,22 @@ mod tests { assert_eq!(check.name, "audit retention"); assert_eq!(check.level, DoctorCheckLevel::Fail); - assert!(check.detail.contains("runtime open + lock probe failed")); + assert!( + check + .detail + .contains(journal_path.display().to_string().as_str()), + "expected failing detail to mention the blocked journal path, got: {}", + check.detail + ); } - #[cfg(unix)] #[test] - fn audit_retention_doctor_check_fails_when_missing_parent_chain_is_not_creatable() { + fn audit_retention_doctor_check_fails_when_missing_parent_chain_runs_into_file_boundary() { let temp_dir = browser_companion_temp_dir("audit-target-missing-parent-chain"); - let readonly_dir = temp_dir.join("readonly-audit"); - std::fs::create_dir_all(&readonly_dir).expect("create readonly audit directory"); - let original_permissions = std::fs::metadata(&readonly_dir) - .expect("readonly audit directory metadata") - .permissions(); - let mut permissions = original_permissions.clone(); - permissions.set_mode(0o555); - std::fs::set_permissions(&readonly_dir, permissions) - .expect("mark audit directory readonly"); - let _permission_restore = - PermissionRestore::new(readonly_dir.clone(), original_permissions); + let blocked_parent = temp_dir.join("readonly-audit"); + std::fs::write(&blocked_parent, b"not a directory").expect("create blocking parent file"); - let journal_path = readonly_dir.join("nested").join("events.jsonl"); + let journal_path = blocked_parent.join("nested").join("events.jsonl"); let check = audit_retention_doctor_check(&mvp::config::AuditConfig { mode: mvp::config::AuditMode::Fanout, path: journal_path.display().to_string(), @@ -4443,7 +4428,13 @@ mod tests { assert_eq!(check.name, "audit retention"); assert_eq!(check.level, DoctorCheckLevel::Fail); - assert!(check.detail.contains("runtime open + lock probe failed")); + assert!( + check + .detail + .contains(journal_path.display().to_string().as_str()), + "expected failing detail to mention the blocked journal path, got: {}", + check.detail + ); } #[test] diff --git a/crates/daemon/src/runtime_capability_cli.rs b/crates/daemon/src/runtime_capability_cli.rs index 1a5b22f87..7b5b11aac 100644 --- a/crates/daemon/src/runtime_capability_cli.rs +++ b/crates/daemon/src/runtime_capability_cli.rs @@ -1,4 +1,5 @@ use crate::Capability; +use crate::mvp; use crate::runtime_experiment_cli::{ RuntimeExperimentArtifactDocument, RuntimeExperimentDecision, RuntimeExperimentShowCommandOptions, RuntimeExperimentSnapshotDelta, RuntimeExperimentStatus, @@ -6,6 +7,7 @@ use crate::runtime_experiment_cli::{ }; use crate::sha2::{self, Digest}; use clap::{Args, Subcommand, ValueEnum}; +use kernel::ToolCoreRequest; use loongclaw_spec::CliResult; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -14,16 +16,20 @@ use std::{ fs, io::{ErrorKind, Write}, path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, }; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; pub const RUNTIME_CAPABILITY_ARTIFACT_JSON_SCHEMA_VERSION: u32 = 1; pub const RUNTIME_CAPABILITY_ARTIFACT_SURFACE: &str = "runtime_capability"; pub const RUNTIME_CAPABILITY_ARTIFACT_PURPOSE: &str = "promotion_candidate_record"; -pub const RUNTIME_CAPABILITY_MEMORY_STAGE_PROFILE_ARTIFACT_JSON_SCHEMA_VERSION: u32 = 1; -pub const RUNTIME_CAPABILITY_MEMORY_STAGE_PROFILE_ARTIFACT_SURFACE: &str = "memory_stage_profile"; -pub const RUNTIME_CAPABILITY_MEMORY_STAGE_PROFILE_ARTIFACT_PURPOSE: &str = - "runtime_capability_apply_output"; +pub const RUNTIME_CAPABILITY_APPLY_ARTIFACT_JSON_SCHEMA_VERSION: u32 = 1; +pub const RUNTIME_CAPABILITY_APPLY_ARTIFACT_SURFACE: &str = "runtime_capability_apply_output"; +pub const RUNTIME_CAPABILITY_APPLY_ARTIFACT_PURPOSE: &str = "draft_promotion_artifact"; +pub const RUNTIME_CAPABILITY_ACTIVATION_RECORD_JSON_SCHEMA_VERSION: u32 = 1; +pub const RUNTIME_CAPABILITY_ACTIVATION_RECORD_SURFACE: &str = + "runtime_capability_activation_record"; +pub const RUNTIME_CAPABILITY_ACTIVATION_RECORD_PURPOSE: &str = "activation_rollback_record"; #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] pub enum RuntimeCapabilityCommands { @@ -37,8 +43,12 @@ pub enum RuntimeCapabilityCommands { Index(RuntimeCapabilityIndexCommandOptions), /// Derive one dry-run promotion plan from one indexed capability family Plan(RuntimeCapabilityPlanCommandOptions), - /// Materialize one governed memory-stage-profile artifact from one promotable capability family + /// Materialize one governed draft artifact from one promotable capability family Apply(RuntimeCapabilityApplyCommandOptions), + /// Activate one governed draft artifact into the current runtime configuration + Activate(RuntimeCapabilityActivateCommandOptions), + /// Roll back one governed activation record from the current runtime configuration + Rollback(RuntimeCapabilityRollbackCommandOptions), } #[derive(Args, Debug, Clone, PartialEq, Eq)] @@ -113,14 +123,38 @@ pub struct RuntimeCapabilityApplyCommandOptions { pub json: bool, } +#[derive(Args, Debug, Clone, PartialEq, Eq)] +pub struct RuntimeCapabilityActivateCommandOptions { + #[arg(long)] + pub config: Option, + #[arg(long)] + pub artifact: String, + #[arg(long, default_value_t = false)] + pub apply: bool, + #[arg(long, default_value_t = false)] + pub replace: bool, + #[arg(long, default_value_t = false)] + pub json: bool, +} + +#[derive(Args, Debug, Clone, PartialEq, Eq)] +pub struct RuntimeCapabilityRollbackCommandOptions { + #[arg(long)] + pub config: Option, + #[arg(long)] + pub record: String, + #[arg(long, default_value_t = false)] + pub apply: bool, + #[arg(long, default_value_t = false)] + pub json: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] #[serde(rename_all = "snake_case")] pub enum RuntimeCapabilityTarget { ManagedSkill, ProgrammaticFlow, ProfileNoteAddendum, - #[value(alias = "memory_stage_profile")] - MemoryStageProfile, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -232,14 +266,14 @@ pub struct RuntimeCapabilityMetricRange { pub max: f64, } -#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct RuntimeCapabilitySourceDecisionRollup { pub promoted: usize, pub rejected: usize, pub undecided: usize, } -#[derive(Debug, Clone, Default, Serialize, PartialEq)] +#[derive(Debug, Clone, Serialize, PartialEq)] pub struct RuntimeCapabilityEvidenceDigest { pub total_candidates: usize, pub reviewed_candidates: usize, @@ -299,35 +333,29 @@ pub struct RuntimeCapabilityPromotionProvenance { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RuntimeCapabilityPromotionPlannedPayload { - pub memory_stage_profile: RuntimeCapabilityMemoryStageProfileDryRunPayload, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeCapabilityMemoryStageProfileDryRunPayload { - pub schema_version: u32, pub artifact_kind: String, - pub profile: RuntimeCapabilityMemoryStageProfileDryRunProfile, - pub provenance: RuntimeCapabilityMemoryStageProfileDryRunProvenance, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeCapabilityMemoryStageProfileDryRunProfile { - pub id: String, + pub target: RuntimeCapabilityTarget, + pub draft_id: String, pub summary: String, pub review_scope: String, pub required_capabilities: Vec, pub tags: Vec, + pub payload: RuntimeCapabilityDraftPayload, + pub provenance: RuntimeCapabilityPromotionPlannedPayloadProvenance, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeCapabilityMemoryStageProfileDryRunProvenance { - pub family_id: String, - pub accepted_candidate_ids: Vec, - pub evidence_digest: RuntimeCapabilityMemoryStageProfileDryRunEvidenceDigest, +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RuntimeCapabilityDraftPayload { + ManagedSkillBundle { files: BTreeMap }, + ProgrammaticFlowSpec { files: BTreeMap }, + ProfileNoteAddendum { content: String }, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeCapabilityMemoryStageProfileDryRunEvidenceDigest { +pub struct RuntimeCapabilityPromotionPlannedPayloadProvenance { + pub family_id: String, + pub accepted_candidate_ids: Vec, pub changed_surfaces: Vec, } @@ -345,17 +373,32 @@ pub struct RuntimeCapabilityPromotionPlanReport { pub approval_checklist: Vec, pub rollback_hints: Vec, pub provenance: RuntimeCapabilityPromotionProvenance, - pub planned_payload: Option, + pub planned_payload: RuntimeCapabilityPromotionPlannedPayload, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeCapabilityAppliedMemoryStageProfileArtifactDocument { +pub struct RuntimeCapabilityAppliedArtifactDocument { pub schema: RuntimeCapabilityArtifactSchema, + pub family_id: String, pub artifact_kind: String, pub artifact_id: String, pub delivery_surface: String, - pub profile: RuntimeCapabilityMemoryStageProfileDryRunProfile, - pub provenance: RuntimeCapabilityMemoryStageProfileDryRunProvenance, + pub target: RuntimeCapabilityTarget, + pub summary: String, + pub bounded_scope: String, + pub required_capabilities: Vec, + pub tags: Vec, + pub payload: RuntimeCapabilityDraftPayload, + pub approval_checklist: Vec, + pub rollback_hints: Vec, + pub delta_candidate_count: usize, + pub changed_surfaces: Vec, + pub candidate_ids: Vec, + pub source_run_ids: Vec, + pub experiment_ids: Vec, + pub source_run_artifact_paths: Vec, + pub latest_candidate_at: Option, + pub latest_reviewed_at: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -372,8 +415,86 @@ pub struct RuntimeCapabilityApplyReport { pub family_id: String, pub output_path: String, pub outcome: RuntimeCapabilityApplyOutcome, - pub planned_artifact: RuntimeCapabilityPromotionArtifactPlan, - pub materialized_artifact: RuntimeCapabilityAppliedMemoryStageProfileArtifactDocument, + pub applied_artifact: RuntimeCapabilityAppliedArtifactDocument, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeCapabilityActivateOutcome { + DryRun, + Activated, + AlreadyActivated, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct RuntimeCapabilityActivateReport { + pub generated_at: String, + pub artifact_path: String, + pub config_path: String, + pub artifact_id: String, + pub target: RuntimeCapabilityTarget, + pub delivery_surface: String, + pub activation_surface: String, + pub target_path: String, + pub apply_requested: bool, + pub replace_requested: bool, + pub outcome: RuntimeCapabilityActivateOutcome, + pub notes: Vec, + pub verification: Vec, + pub rollback_hints: Vec, + pub activation_record_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeCapabilityActivationRecordDocument { + pub schema: RuntimeCapabilityArtifactSchema, + pub activation_id: String, + pub activated_at: String, + pub artifact_path: String, + pub config_path: String, + pub artifact_id: String, + pub target: RuntimeCapabilityTarget, + pub delivery_surface: String, + pub activation_surface: String, + pub target_path: String, + pub verification: Vec, + pub rollback_hints: Vec, + pub rollback: RuntimeCapabilityRollbackPayload, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RuntimeCapabilityRollbackPayload { + ManagedSkillBundle { + previous_files: Option>, + }, + ProfileNoteAddendum { + previous_profile: mvp::config::MemoryProfile, + previous_profile_note: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeCapabilityRollbackOutcome { + DryRun, + RolledBack, + AlreadyRolledBack, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct RuntimeCapabilityRollbackReport { + pub generated_at: String, + pub record_path: String, + pub config_path: String, + pub artifact_id: String, + pub target: RuntimeCapabilityTarget, + pub activation_surface: String, + pub target_path: String, + pub apply_requested: bool, + pub outcome: RuntimeCapabilityRollbackOutcome, + pub notes: Vec, + pub verification: Vec, } pub fn run_runtime_capability_cli(command: RuntimeCapabilityCommands) -> CliResult<()> { @@ -408,6 +529,16 @@ pub fn run_runtime_capability_cli(command: RuntimeCapabilityCommands) -> CliResu let report = execute_runtime_capability_apply_command(options)?; emit_runtime_capability_apply_report(&report, as_json) } + RuntimeCapabilityCommands::Activate(options) => { + let as_json = options.json; + let report = execute_runtime_capability_activate_command(options)?; + emit_runtime_capability_activate_report(&report, as_json) + } + RuntimeCapabilityCommands::Rollback(options) => { + let as_json = options.json; + let report = execute_runtime_capability_rollback_command(options)?; + emit_runtime_capability_rollback_report(&report, as_json) + } } } @@ -567,7 +698,8 @@ pub fn execute_runtime_capability_plan_command( &family.family_id, &planned_artifact, &family_artifacts, - ), + &family.evidence, + )?, }) } @@ -582,26 +714,77 @@ pub fn execute_runtime_capability_apply_command( let plan = execute_runtime_capability_plan_command(plan_options)?; validate_runtime_capability_apply_plan(&plan)?; - let planned_artifact = plan.planned_artifact.clone(); let root = plan.root.clone(); let family_id = plan.family_id.clone(); - let root_path = PathBuf::from(&root); - let output_path = resolve_runtime_capability_apply_output_path(&root_path, &planned_artifact); - let materialized_artifact = build_runtime_capability_apply_artifact(&plan)?; - let outcome = persist_runtime_capability_apply_artifact(&output_path, &materialized_artifact)?; - let output_path = canonicalize_existing_path(&output_path)?; + let planned_artifact = &plan.planned_artifact; + let root_path = PathBuf::from(root.as_str()); + let output_path = resolve_runtime_capability_apply_output_path(&root_path, planned_artifact); + let applied_artifact = build_runtime_capability_apply_artifact(&plan); + let outcome = persist_runtime_capability_apply_artifact(&output_path, &applied_artifact)?; + let canonical_output_path = canonicalize_existing_path(&output_path)?; Ok(RuntimeCapabilityApplyReport { generated_at: now_rfc3339()?, root, family_id, - output_path, + output_path: canonical_output_path, outcome, - planned_artifact, - materialized_artifact, + applied_artifact, }) } +pub fn execute_runtime_capability_activate_command( + options: RuntimeCapabilityActivateCommandOptions, +) -> CliResult { + let artifact_path = Path::new(options.artifact.as_str()); + let applied_artifact = load_runtime_capability_apply_artifact(artifact_path)?; + let canonical_artifact_path = canonicalize_existing_path(artifact_path)?; + + match applied_artifact.target { + RuntimeCapabilityTarget::ManagedSkill => execute_runtime_capability_activate_managed_skill( + options, + canonical_artifact_path, + applied_artifact, + ), + RuntimeCapabilityTarget::ProfileNoteAddendum => { + execute_runtime_capability_activate_profile_note_addendum( + options, + canonical_artifact_path, + applied_artifact, + ) + } + RuntimeCapabilityTarget::ProgrammaticFlow => Err( + "runtime capability activate does not yet support programmatic_flow artifacts because no governed activation surface exists yet".to_owned(), + ), + } +} + +pub fn execute_runtime_capability_rollback_command( + options: RuntimeCapabilityRollbackCommandOptions, +) -> CliResult { + let record_path = Path::new(options.record.as_str()); + let activation_record = load_runtime_capability_activation_record(record_path)?; + let canonical_record_path = canonicalize_existing_path(record_path)?; + + match activation_record.target { + RuntimeCapabilityTarget::ManagedSkill => execute_runtime_capability_rollback_managed_skill( + options, + canonical_record_path, + activation_record, + ), + RuntimeCapabilityTarget::ProfileNoteAddendum => { + execute_runtime_capability_rollback_profile_note_addendum( + options, + canonical_record_path, + activation_record, + ) + } + RuntimeCapabilityTarget::ProgrammaticFlow => Err( + "runtime capability rollback does not yet support programmatic_flow activation records because no governed activation surface exists yet".to_owned(), + ), + } +} + fn emit_runtime_capability_artifact( artifact: &RuntimeCapabilityArtifactDocument, as_json: bool, @@ -665,6 +848,38 @@ fn emit_runtime_capability_apply_report( Ok(()) } +fn emit_runtime_capability_activate_report( + report: &RuntimeCapabilityActivateReport, + as_json: bool, +) -> CliResult<()> { + if as_json { + let pretty = serde_json::to_string_pretty(report).map_err(|error| { + format!("serialize runtime capability activate report failed: {error}") + })?; + println!("{pretty}"); + return Ok(()); + } + + println!("{}", render_runtime_capability_activate_text(report)); + Ok(()) +} + +fn emit_runtime_capability_rollback_report( + report: &RuntimeCapabilityRollbackReport, + as_json: bool, +) -> CliResult<()> { + if as_json { + let pretty = serde_json::to_string_pretty(report).map_err(|error| { + format!("serialize runtime capability rollback report failed: {error}") + })?; + println!("{pretty}"); + return Ok(()); + } + + println!("{}", render_runtime_capability_rollback_text(report)); + Ok(()) +} + fn validate_proposable_run( run: &RuntimeExperimentArtifactDocument, run_path: &str, @@ -1040,13 +1255,12 @@ fn evaluate_family_readiness( let stability = evaluate_stability(evidence); let accepted_source_integrity = evaluate_accepted_source_integrity(artifacts, evidence); let warning_pressure = evaluate_warning_pressure(evidence); - let mut checks = vec![ + let checks = vec![ review_consensus, stability, accepted_source_integrity, warning_pressure, ]; - checks.extend(evaluate_target_specific_readiness(artifacts)); let status = if checks .iter() .any(|check| check.status == RuntimeCapabilityFamilyReadinessCheckStatus::Blocked) @@ -1063,73 +1277,6 @@ fn evaluate_family_readiness( RuntimeCapabilityFamilyReadiness { status, checks } } -fn evaluate_target_specific_readiness( - artifacts: &[RuntimeCapabilityArtifactDocument], -) -> Vec { - let Some(target) = artifacts.first().map(|artifact| artifact.proposal.target) else { - return Vec::new(); - }; - - match target { - RuntimeCapabilityTarget::MemoryStageProfile => { - let accepted_artifacts = artifacts - .iter() - .filter(|artifact| artifact.decision == RuntimeCapabilityDecision::Accepted) - .cloned() - .collect::>(); - let accepted_evidence = if accepted_artifacts.is_empty() { - RuntimeCapabilityEvidenceDigest::default() - } else { - build_family_evidence_digest(&accepted_artifacts) - }; - vec![evaluate_memory_stage_profile_delta_evidence( - &accepted_evidence, - )] - } - RuntimeCapabilityTarget::ManagedSkill - | RuntimeCapabilityTarget::ProgrammaticFlow - | RuntimeCapabilityTarget::ProfileNoteAddendum => Vec::new(), - } -} - -fn evaluate_memory_stage_profile_delta_evidence( - evidence: &RuntimeCapabilityEvidenceDigest, -) -> RuntimeCapabilityFamilyReadinessCheck { - let has_memory_surface = evidence.changed_surfaces.iter().any(|surface| { - matches!( - surface.as_str(), - "memory_selected" - | "memory_policy" - | "context_engine_selected" - | "context_engine_compaction" - ) - }); - - let (status, summary) = if evidence.delta_candidate_count == 0 { - ( - RuntimeCapabilityFamilyReadinessCheckStatus::NeedsEvidence, - "memory-stage-profile families need snapshot-delta evidence from finished experiments" - .to_owned(), - ) - } else if !has_memory_surface { - ( - RuntimeCapabilityFamilyReadinessCheckStatus::NeedsEvidence, - "snapshot-delta evidence must include memory or context-engine surfaces".to_owned(), - ) - } else { - ( - RuntimeCapabilityFamilyReadinessCheckStatus::Pass, - "snapshot-delta evidence includes memory/context-engine surface changes".to_owned(), - ) - }; - - RuntimeCapabilityFamilyReadinessCheck { - dimension: "memory_delta_evidence".to_owned(), - status, - summary, - } -} - fn evaluate_review_consensus( evidence: &RuntimeCapabilityEvidenceDigest, ) -> RuntimeCapabilityFamilyReadinessCheck { @@ -1364,180 +1511,137 @@ fn persist_runtime_capability_artifact( fn validate_runtime_capability_apply_plan( plan: &RuntimeCapabilityPromotionPlanReport, ) -> CliResult<()> { - if !plan.promotable { - let readiness = render_family_readiness_status(plan.readiness.status); - let blockers = render_family_readiness_checks(&plan.blockers); - return Err(format!( - "runtime capability family `{}` is not promotable for apply; readiness={} blockers={}", - plan.family_id, readiness, blockers - )); - } - - let target = plan.planned_artifact.target_kind; - if target != RuntimeCapabilityTarget::MemoryStageProfile { - let rendered_target = render_target(target); - return Err(format!( - "runtime capability apply currently supports only memory_stage_profile families; family `{}` resolves to target `{}`", - plan.family_id, rendered_target - )); + if plan.promotable { + return Ok(()); } - Ok(()) + let readiness = render_family_readiness_status(plan.readiness.status); + let blockers = render_family_readiness_checks(&plan.blockers); + let error = format!( + "runtime capability family `{}` is not promotable for apply; readiness={} blockers={}", + plan.family_id, readiness, blockers + ); + Err(error) } fn resolve_runtime_capability_apply_output_path( root: &Path, planned_artifact: &RuntimeCapabilityPromotionArtifactPlan, ) -> PathBuf { - let delivery_surface = &planned_artifact.delivery_surface; - let artifact_file_name = format!("{}.json", planned_artifact.artifact_id); + let delivery_surface = planned_artifact.delivery_surface.as_str(); + let artifact_id = planned_artifact.artifact_id.as_str(); + let artifact_file_name = format!("{artifact_id}.json"); root.join(delivery_surface).join(artifact_file_name) } fn build_runtime_capability_apply_artifact( plan: &RuntimeCapabilityPromotionPlanReport, -) -> CliResult { - let planned_payload = plan.planned_payload.as_ref().ok_or_else(|| { - format!( - "runtime capability apply requires a planned payload for family `{}`", - plan.family_id - ) - })?; - let memory_payload = &planned_payload.memory_stage_profile; - let expected_schema_version = - RUNTIME_CAPABILITY_MEMORY_STAGE_PROFILE_ARTIFACT_JSON_SCHEMA_VERSION; - if memory_payload.schema_version != expected_schema_version { - return Err(format!( - "runtime capability apply expected memory-stage-profile payload schema version {}; found {}", - expected_schema_version, memory_payload.schema_version - )); - } - - let expected_artifact_kind = &plan.planned_artifact.artifact_kind; - if memory_payload.artifact_kind != *expected_artifact_kind { - return Err(format!( - "runtime capability apply payload artifact kind {} does not match planned artifact kind {}", - memory_payload.artifact_kind, expected_artifact_kind - )); - } - - let expected_artifact_id = &plan.planned_artifact.artifact_id; - if memory_payload.profile.id != *expected_artifact_id { - return Err(format!( - "runtime capability apply payload profile id {} does not match planned artifact id {}", - memory_payload.profile.id, expected_artifact_id - )); - } +) -> RuntimeCapabilityAppliedArtifactDocument { + let planned_artifact = &plan.planned_artifact; + let provenance = &plan.provenance; + let evidence = &plan.evidence; + let planned_payload = &plan.planned_payload; - Ok(RuntimeCapabilityAppliedMemoryStageProfileArtifactDocument { + RuntimeCapabilityAppliedArtifactDocument { schema: RuntimeCapabilityArtifactSchema { - version: RUNTIME_CAPABILITY_MEMORY_STAGE_PROFILE_ARTIFACT_JSON_SCHEMA_VERSION, - surface: RUNTIME_CAPABILITY_MEMORY_STAGE_PROFILE_ARTIFACT_SURFACE.to_owned(), - purpose: RUNTIME_CAPABILITY_MEMORY_STAGE_PROFILE_ARTIFACT_PURPOSE.to_owned(), + version: RUNTIME_CAPABILITY_APPLY_ARTIFACT_JSON_SCHEMA_VERSION, + surface: RUNTIME_CAPABILITY_APPLY_ARTIFACT_SURFACE.to_owned(), + purpose: RUNTIME_CAPABILITY_APPLY_ARTIFACT_PURPOSE.to_owned(), }, - artifact_kind: memory_payload.artifact_kind.clone(), - artifact_id: memory_payload.profile.id.clone(), - delivery_surface: plan.planned_artifact.delivery_surface.clone(), - profile: memory_payload.profile.clone(), - provenance: memory_payload.provenance.clone(), - }) + family_id: plan.family_id.clone(), + artifact_kind: planned_payload.artifact_kind.clone(), + artifact_id: planned_payload.draft_id.clone(), + delivery_surface: planned_artifact.delivery_surface.clone(), + target: planned_payload.target, + summary: planned_payload.summary.clone(), + bounded_scope: planned_payload.review_scope.clone(), + required_capabilities: planned_payload.required_capabilities.clone(), + tags: planned_payload.tags.clone(), + payload: planned_payload.payload.clone(), + approval_checklist: plan.approval_checklist.clone(), + rollback_hints: plan.rollback_hints.clone(), + delta_candidate_count: evidence.delta_candidate_count, + changed_surfaces: evidence.changed_surfaces.clone(), + candidate_ids: planned_payload.provenance.accepted_candidate_ids.clone(), + source_run_ids: provenance.source_run_ids.clone(), + experiment_ids: provenance.experiment_ids.clone(), + source_run_artifact_paths: provenance.source_run_artifact_paths.clone(), + latest_candidate_at: provenance.latest_candidate_at.clone(), + latest_reviewed_at: provenance.latest_reviewed_at.clone(), + } } fn load_runtime_capability_apply_artifact( path: &Path, -) -> CliResult { +) -> CliResult { let raw = fs::read_to_string(path).map_err(|error| { format!( "read runtime capability apply artifact {} failed: {error}", path.display() ) })?; - let artifact = - serde_json::from_str::(&raw) - .map_err(|error| { - format!( - "decode runtime capability apply artifact {} failed: {error}", - path.display() - ) - })?; + let artifact = serde_json::from_str::(&raw).map_err( + |error| { + format!( + "decode runtime capability apply artifact {} failed: {error}", + path.display() + ) + }, + )?; validate_runtime_capability_apply_artifact_schema(&artifact, path)?; Ok(artifact) } fn validate_runtime_capability_apply_artifact_schema( - artifact: &RuntimeCapabilityAppliedMemoryStageProfileArtifactDocument, + artifact: &RuntimeCapabilityAppliedArtifactDocument, path: &Path, ) -> CliResult<()> { let schema = &artifact.schema; - if schema.version != RUNTIME_CAPABILITY_MEMORY_STAGE_PROFILE_ARTIFACT_JSON_SCHEMA_VERSION { + if schema.version != RUNTIME_CAPABILITY_APPLY_ARTIFACT_JSON_SCHEMA_VERSION { return Err(format!( "runtime capability apply artifact {} uses unsupported schema version {}; expected {}", path.display(), schema.version, - RUNTIME_CAPABILITY_MEMORY_STAGE_PROFILE_ARTIFACT_JSON_SCHEMA_VERSION + RUNTIME_CAPABILITY_APPLY_ARTIFACT_JSON_SCHEMA_VERSION )); } - if schema.surface != RUNTIME_CAPABILITY_MEMORY_STAGE_PROFILE_ARTIFACT_SURFACE { + if schema.surface != RUNTIME_CAPABILITY_APPLY_ARTIFACT_SURFACE { return Err(format!( "runtime capability apply artifact {} uses unsupported schema surface {}; expected {}", path.display(), schema.surface, - RUNTIME_CAPABILITY_MEMORY_STAGE_PROFILE_ARTIFACT_SURFACE + RUNTIME_CAPABILITY_APPLY_ARTIFACT_SURFACE )); } - if schema.purpose != RUNTIME_CAPABILITY_MEMORY_STAGE_PROFILE_ARTIFACT_PURPOSE { + if schema.purpose != RUNTIME_CAPABILITY_APPLY_ARTIFACT_PURPOSE { return Err(format!( "runtime capability apply artifact {} uses unsupported schema purpose {}; expected {}", path.display(), schema.purpose, - RUNTIME_CAPABILITY_MEMORY_STAGE_PROFILE_ARTIFACT_PURPOSE - )); - } - - let (expected_artifact_kind, expected_delivery_surface, _) = - runtime_capability_promotion_target_contract(RuntimeCapabilityTarget::MemoryStageProfile); - if artifact.artifact_kind != expected_artifact_kind { - return Err(format!( - "runtime capability apply artifact {} uses unsupported artifact kind {}; expected {}", - path.display(), - artifact.artifact_kind, - expected_artifact_kind - )); - } - if artifact.delivery_surface != expected_delivery_surface { - return Err(format!( - "runtime capability apply artifact {} uses unsupported delivery surface {}; expected {}", - path.display(), - artifact.delivery_surface, - expected_delivery_surface - )); - } - if artifact.artifact_id != artifact.profile.id { - return Err(format!( - "runtime capability apply artifact {} has inconsistent artifact_id and profile.id fields", - path.display() + RUNTIME_CAPABILITY_APPLY_ARTIFACT_PURPOSE )); } - Ok(()) } fn persist_runtime_capability_apply_artifact( output_path: &Path, - artifact: &RuntimeCapabilityAppliedMemoryStageProfileArtifactDocument, + artifact: &RuntimeCapabilityAppliedArtifactDocument, ) -> CliResult { - match write_pretty_json_file_create_new(output_path, artifact) { + let write_result = write_pretty_json_file_create_new(output_path, artifact); + match write_result { Ok(()) => Ok(RuntimeCapabilityApplyOutcome::Applied), Err(error) if error.contains("already exists") => { let existing_artifact = load_runtime_capability_apply_artifact(output_path)?; if existing_artifact == *artifact { - Ok(RuntimeCapabilityApplyOutcome::AlreadyApplied) - } else { - Err(format!( - "runtime capability apply output {} already exists with different content", - output_path.display() - )) + return Ok(RuntimeCapabilityApplyOutcome::AlreadyApplied); } + + let message = format!( + "runtime capability apply output {} already exists with different content", + output_path.display() + ); + Err(message) } Err(error) => Err(error), } @@ -1557,49 +1661,1406 @@ fn write_pretty_json_file_create_new(path: &Path, value: &impl Serialize) -> Cli let encoded = serde_json::to_vec_pretty(value) .map_err(|error| format!("serialize runtime capability apply artifact failed: {error}"))?; - let mut file = fs::OpenOptions::new() + let temp_path = runtime_capability_apply_temp_path(path); + let mut temp_file = fs::OpenOptions::new() .write(true) .create_new(true) - .open(path) + .open(&temp_path) .map_err(|error| { - if error.kind() == ErrorKind::AlreadyExists { - format!( - "runtime capability apply artifact {} already exists", - path.display() - ) - } else { - format!( - "write runtime capability apply artifact {} failed: {error}", - path.display() - ) - } + format!( + "write runtime capability apply artifact {} failed: {error}", + path.display() + ) })?; - file.write_all(&encoded).map_err(|error| { - format!( + let write_result = temp_file.write_all(encoded.as_slice()); + if let Err(error) = write_result { + let _ = fs::remove_file(&temp_path); + return Err(format!( "write runtime capability apply artifact {} failed: {error}", path.display() - ) - })?; - Ok(()) -} + )); + } -fn canonicalize_existing_path(path: &Path) -> CliResult { - dunce::canonicalize(path) - .map(|resolved| resolved.display().to_string()) - .map_err(|error| { - format!( - "canonicalize artifact path {} failed: {error}", + let sync_result = temp_file.sync_all(); + if let Err(error) = sync_result { + let _ = fs::remove_file(&temp_path); + return Err(format!( + "write runtime capability apply artifact {} failed: {error}", + path.display() + )); + } + + drop(temp_file); + + let publish_result = fs::hard_link(&temp_path, path); + let _ = fs::remove_file(&temp_path); + match publish_result { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::AlreadyExists => { + return Err(format!( + "runtime capability apply artifact {} already exists", path.display() - ) - }) + )); + } + Err(error) => { + return Err(format!( + "write runtime capability apply artifact {} failed: {error}", + path.display() + )); + } + } + Ok(()) } -pub fn render_runtime_capability_text(artifact: &RuntimeCapabilityArtifactDocument) -> String { - [ - format!("candidate_id={}", artifact.candidate_id), - format!("status={}", render_capability_status(artifact.status)), - format!("decision={}", render_capability_decision(artifact.decision)), - format!("target={}", render_target(artifact.proposal.target)), +fn execute_runtime_capability_activate_managed_skill( + options: RuntimeCapabilityActivateCommandOptions, + artifact_path: String, + applied_artifact: RuntimeCapabilityAppliedArtifactDocument, +) -> CliResult { + if options.replace && !options.apply { + return Err("runtime capability activate --replace requires --apply".to_owned()); + } + + let RuntimeCapabilityAppliedArtifactDocument { + artifact_id, + target, + delivery_surface, + payload, + rollback_hints, + .. + } = applied_artifact; + let payload = match payload { + RuntimeCapabilityDraftPayload::ManagedSkillBundle { files } => files, + RuntimeCapabilityDraftPayload::ProgrammaticFlowSpec { .. } + | RuntimeCapabilityDraftPayload::ProfileNoteAddendum { .. } => { + return Err( + "runtime capability activate expected a managed skill bundle payload".to_owned(), + ); + } + }; + + let (resolved_config_path, config) = mvp::config::load(options.config.as_deref())?; + let tool_runtime = + build_runtime_capability_activation_tool_runtime(&resolved_config_path, &config, true); + let install_root = resolve_runtime_capability_activation_install_root(&tool_runtime)?; + let target_path = install_root.join(artifact_id.as_str()); + let previous_files = collect_runtime_capability_bundle_files(target_path.as_path())?; + let already_matches = + managed_skill_payload_matches_install_root(&payload, target_path.as_path())?; + let dry_run_target_path = canonicalize_optional_path(target_path.as_path())?; + let dry_run_verification = + build_managed_skill_activation_verification_hints(target_path.as_path(), payload.len()); + + if !options.apply { + let notes = vec![ + "activation is dry-run by default".to_owned(), + "managed skill activation reuses external_skills.install under a governed runtime config" + .to_owned(), + ]; + return Ok(RuntimeCapabilityActivateReport { + generated_at: now_rfc3339()?, + artifact_path, + config_path: resolved_config_path.display().to_string(), + artifact_id, + target, + delivery_surface, + activation_surface: "external_skills.install".to_owned(), + target_path: dry_run_target_path, + apply_requested: false, + replace_requested: options.replace, + outcome: RuntimeCapabilityActivateOutcome::DryRun, + notes, + verification: dry_run_verification, + rollback_hints, + activation_record_path: None, + }); + } + + if already_matches { + let notes = vec!["managed skill already matches the applied draft payload".to_owned()]; + let verified_target_path = canonicalize_existing_path(target_path.as_path())?; + let verification = + verify_managed_skill_activation_state(&artifact_id, target_path.as_path(), &payload)?; + return Ok(RuntimeCapabilityActivateReport { + generated_at: now_rfc3339()?, + artifact_path, + config_path: resolved_config_path.display().to_string(), + artifact_id, + target, + delivery_surface, + activation_surface: "external_skills.install".to_owned(), + target_path: verified_target_path, + apply_requested: true, + replace_requested: options.replace, + outcome: RuntimeCapabilityActivateOutcome::AlreadyActivated, + notes, + verification, + rollback_hints, + activation_record_path: None, + }); + } + + let staging_base_root = resolve_runtime_capability_activation_staging_base_root(&tool_runtime)?; + let staging_root = + write_runtime_capability_draft_files_to_staging(&payload, staging_base_root.as_path())?; + let staging_path = staging_root.display().to_string(); + let install_payload = json!({ + "path": staging_path, + "skill_id": artifact_id, + "replace": options.replace, + }); + let install_request = ToolCoreRequest { + tool_name: "external_skills.install".to_owned(), + payload: install_payload, + }; + let install_result = mvp::tools::execute_tool_core_with_config(install_request, &tool_runtime); + let cleanup_result = fs::remove_dir_all(&staging_root); + if let Err(error) = cleanup_result { + let cleanup_error = format!( + "cleanup managed skill staging root {} failed: {error}", + staging_root.display() + ); + return Err(cleanup_error); + } + install_result + .map_err(|error| format!("activate managed skill `{}` failed: {error}", artifact_id))?; + let verification = + verify_managed_skill_activation_state(&artifact_id, target_path.as_path(), &payload)?; + let activated_target_path = canonicalize_existing_path(target_path.as_path())?; + let activation_record = build_runtime_capability_managed_skill_activation_record( + artifact_path.as_str(), + resolved_config_path.as_path(), + artifact_id.as_str(), + target, + delivery_surface.as_str(), + "external_skills.install", + activated_target_path.as_str(), + &verification, + &rollback_hints, + previous_files, + )?; + let activation_record_path = build_runtime_capability_activation_record_path( + Path::new(artifact_path.as_str()), + artifact_id.as_str(), + )?; + if let Err(error) = persist_runtime_capability_activation_record( + activation_record_path.as_path(), + &activation_record, + ) { + let rollback_result = rollback_managed_skill_activation_state( + resolved_config_path.as_path(), + config, + artifact_id.as_str(), + target_path.as_path(), + activation_record.rollback.clone(), + ); + if let Err(rollback_error) = rollback_result { + return Err(format!( + "persist runtime capability activation record {} failed: {error}; managed skill rollback also failed: {rollback_error}", + activation_record_path.display() + )); + } + return Err(format!( + "persist runtime capability activation record {} failed after reverting managed skill activation: {error}", + activation_record_path.display() + )); + } + let canonical_activation_record_path = + canonicalize_existing_path(activation_record_path.as_path())?; + + let notes = + vec!["managed skill installed into the governed external skills runtime".to_owned()]; + Ok(RuntimeCapabilityActivateReport { + generated_at: now_rfc3339()?, + artifact_path, + config_path: resolved_config_path.display().to_string(), + artifact_id, + target, + delivery_surface, + activation_surface: "external_skills.install".to_owned(), + target_path: activated_target_path, + apply_requested: true, + replace_requested: options.replace, + outcome: RuntimeCapabilityActivateOutcome::Activated, + notes, + verification, + rollback_hints, + activation_record_path: Some(canonical_activation_record_path), + }) +} + +fn execute_runtime_capability_activate_profile_note_addendum( + options: RuntimeCapabilityActivateCommandOptions, + artifact_path: String, + applied_artifact: RuntimeCapabilityAppliedArtifactDocument, +) -> CliResult { + if options.replace { + return Err( + "runtime capability activate --replace is not supported for profile_note_addendum artifacts" + .to_owned(), + ); + } + + let RuntimeCapabilityAppliedArtifactDocument { + artifact_id, + target, + delivery_surface, + payload, + rollback_hints, + .. + } = applied_artifact; + let addendum = match payload { + RuntimeCapabilityDraftPayload::ProfileNoteAddendum { content } => content, + RuntimeCapabilityDraftPayload::ManagedSkillBundle { .. } + | RuntimeCapabilityDraftPayload::ProgrammaticFlowSpec { .. } => { + return Err( + "runtime capability activate expected a profile note addendum payload".to_owned(), + ); + } + }; + + let (resolved_config_path, mut config) = mvp::config::load(options.config.as_deref())?; + let previous_profile = config.memory.profile; + let previous_profile_note = config.memory.profile_note.clone(); + let merged_profile_note = mvp::migration::merge_profile_note_addendum( + config.memory.profile_note.as_deref(), + addendum.as_str(), + ); + let canonical_config_path = canonicalize_optional_path(resolved_config_path.as_path())?; + let dry_run_verification = build_profile_note_activation_verification_hints( + resolved_config_path.as_path(), + addendum.as_str(), + ); + + if !options.apply { + let note = if merged_profile_note.is_some() { + "profile note activation would append the advisory addendum".to_owned() + } else { + "profile note already contains the advisory addendum".to_owned() + }; + return Ok(RuntimeCapabilityActivateReport { + generated_at: now_rfc3339()?, + artifact_path, + config_path: canonical_config_path.clone(), + artifact_id, + target, + delivery_surface, + activation_surface: "config.memory.profile_note".to_owned(), + target_path: canonical_config_path, + apply_requested: false, + replace_requested: false, + outcome: RuntimeCapabilityActivateOutcome::DryRun, + notes: vec![note], + verification: dry_run_verification, + rollback_hints, + activation_record_path: None, + }); + } + + let Some(merged_profile_note) = merged_profile_note else { + let verification = verify_profile_note_addendum_activation_state( + resolved_config_path.as_path(), + addendum.as_str(), + )?; + return Ok(RuntimeCapabilityActivateReport { + generated_at: now_rfc3339()?, + artifact_path, + config_path: canonical_config_path.clone(), + artifact_id, + target, + delivery_surface, + activation_surface: "config.memory.profile_note".to_owned(), + target_path: canonical_config_path, + apply_requested: true, + replace_requested: false, + outcome: RuntimeCapabilityActivateOutcome::AlreadyActivated, + notes: vec!["profile note already contains the advisory addendum".to_owned()], + verification, + rollback_hints, + activation_record_path: None, + }); + }; + + config.memory.profile = mvp::config::MemoryProfile::ProfilePlusWindow; + config.memory.profile_note = Some(merged_profile_note); + let resolved_config_path_string = resolved_config_path.display().to_string(); + mvp::config::write(Some(resolved_config_path_string.as_str()), &config, true)?; + let verification = verify_profile_note_addendum_activation_state( + resolved_config_path.as_path(), + addendum.as_str(), + )?; + let canonical_record_target_path = canonical_config_path.clone(); + let activation_record = build_runtime_capability_profile_note_activation_record( + artifact_path.as_str(), + resolved_config_path.as_path(), + artifact_id.as_str(), + target, + delivery_surface.as_str(), + "config.memory.profile_note", + canonical_record_target_path.as_str(), + &verification, + &rollback_hints, + previous_profile, + previous_profile_note, + )?; + let activation_record_path = build_runtime_capability_activation_record_path( + Path::new(artifact_path.as_str()), + artifact_id.as_str(), + )?; + if let Err(error) = persist_runtime_capability_activation_record( + activation_record_path.as_path(), + &activation_record, + ) { + let rollback_result = rollback_profile_note_addendum_activation_state( + resolved_config_path.as_path(), + previous_profile, + activation_record.rollback.clone(), + ); + if let Err(rollback_error) = rollback_result { + return Err(format!( + "persist runtime capability activation record {} failed: {error}; profile note rollback also failed: {rollback_error}", + activation_record_path.display() + )); + } + return Err(format!( + "persist runtime capability activation record {} failed after reverting profile note activation: {error}", + activation_record_path.display() + )); + } + let canonical_activation_record_path = + canonicalize_existing_path(activation_record_path.as_path())?; + + Ok(RuntimeCapabilityActivateReport { + generated_at: now_rfc3339()?, + artifact_path, + config_path: canonical_config_path.clone(), + artifact_id, + target, + delivery_surface, + activation_surface: "config.memory.profile_note".to_owned(), + target_path: canonical_config_path, + apply_requested: true, + replace_requested: false, + outcome: RuntimeCapabilityActivateOutcome::Activated, + notes: vec![ + "profile_note_addendum activation also enforces profile_plus_window memory mode" + .to_owned(), + ], + verification, + rollback_hints, + activation_record_path: Some(canonical_activation_record_path), + }) +} + +fn build_runtime_capability_activation_tool_runtime( + resolved_config_path: &Path, + config: &mvp::config::LoongClawConfig, + external_skills_enabled: bool, +) -> mvp::tools::runtime_config::ToolRuntimeConfig { + let mut adjusted_config = config.clone(); + adjusted_config.external_skills.enabled = external_skills_enabled; + mvp::tools::runtime_config::ToolRuntimeConfig::from_loongclaw_config( + &adjusted_config, + Some(resolved_config_path), + ) +} + +fn resolve_runtime_capability_activation_install_root( + tool_runtime: &mvp::tools::runtime_config::ToolRuntimeConfig, +) -> CliResult { + if let Some(path) = tool_runtime.external_skills.install_root.clone() { + return Ok(path); + } + + let file_root = match tool_runtime.file_root.clone() { + Some(path) => path, + None => std::env::current_dir().map_err(|error| { + format!("read current dir for managed skill activation failed: {error}") + })?, + }; + Ok(file_root.join("external-skills-installed")) +} + +fn resolve_runtime_capability_activation_staging_base_root( + tool_runtime: &mvp::tools::runtime_config::ToolRuntimeConfig, +) -> CliResult { + let file_root = match tool_runtime.file_root.clone() { + Some(path) => path, + None => std::env::current_dir() + .map_err(|error| format!("read current dir for activation staging failed: {error}"))?, + }; + let staging_base_root = file_root.join(".runtime-capability-staging"); + Ok(staging_base_root) +} + +fn managed_skill_payload_matches_install_root( + files: &BTreeMap, + install_root: &Path, +) -> CliResult { + if !install_root.exists() { + return Ok(false); + } + + for (relative_path, expected_contents) in files { + let normalized_relative_path = + normalize_runtime_capability_relative_path(relative_path.as_str())?; + let candidate_path = install_root.join(normalized_relative_path.as_path()); + if !candidate_path.exists() { + return Ok(false); + } + let actual_contents = fs::read_to_string(&candidate_path).map_err(|error| { + format!( + "read activated managed skill file {} failed: {error}", + candidate_path.display() + ) + })?; + if actual_contents != *expected_contents { + return Ok(false); + } + } + + Ok(true) +} + +fn write_runtime_capability_draft_files_to_staging( + files: &BTreeMap, + staging_base_root: &Path, +) -> CliResult { + let staging_root = + build_runtime_capability_temp_dir(staging_base_root, "activate-managed-skill"); + fs::create_dir_all(&staging_root).map_err(|error| { + format!( + "create runtime capability staging directory {} failed: {error}", + staging_root.display() + ) + })?; + + for (relative_path, contents) in files { + let normalized_relative_path = + normalize_runtime_capability_relative_path(relative_path.as_str())?; + let output_path = staging_root.join(normalized_relative_path.as_path()); + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "create runtime capability draft parent {} failed: {error}", + parent.display() + ) + })?; + } + fs::write(&output_path, contents).map_err(|error| { + format!( + "write runtime capability draft file {} failed: {error}", + output_path.display() + ) + })?; + } + + Ok(staging_root) +} + +fn normalize_runtime_capability_relative_path(raw: &str) -> CliResult { + let path = Path::new(raw); + if path.is_absolute() { + return Err(format!( + "runtime capability draft file path {} must be relative", + path.display() + )); + } + + let mut normalized_path = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::Normal(value) => normalized_path.push(value), + std::path::Component::ParentDir => { + return Err(format!( + "runtime capability draft file path {} cannot escape its bundle root", + path.display() + )); + } + std::path::Component::RootDir | std::path::Component::Prefix(_) => { + return Err(format!( + "runtime capability draft file path {} must stay relative", + path.display() + )); + } + } + } + + if normalized_path.as_os_str().is_empty() { + return Err("runtime capability draft file path cannot be empty".to_owned()); + } + + Ok(normalized_path) +} + +fn build_runtime_capability_temp_dir(staging_base_root: &Path, label: &str) -> PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let process_id = std::process::id(); + let directory_name = format!("loongclaw-runtime-capability-{label}-{process_id}-{timestamp}"); + staging_base_root.join(directory_name) +} + +fn canonicalize_optional_path(path: &Path) -> CliResult { + if path.exists() { + return canonicalize_existing_path(path); + } + Ok(path.display().to_string()) +} + +fn runtime_capability_apply_temp_path(path: &Path) -> PathBuf { + let parent = path.parent(); + let parent = parent.unwrap_or_else(|| Path::new(".")); + let file_name = path.file_name(); + let file_name = file_name.and_then(|value| value.to_str()); + let file_name = file_name.unwrap_or("runtime-capability-apply.json"); + let process_id = std::process::id(); + let timestamp = OffsetDateTime::now_utc().unix_timestamp_nanos(); + let temp_name = format!(".{file_name}.tmp.{process_id}.{timestamp}"); + parent.join(temp_name) +} + +fn canonicalize_existing_path(path: &Path) -> CliResult { + dunce::canonicalize(path) + .map(|resolved| resolved.display().to_string()) + .map_err(|error| { + format!( + "canonicalize artifact path {} failed: {error}", + path.display() + ) + }) +} + +fn build_managed_skill_activation_verification_hints( + target_path: &Path, + file_count: usize, +) -> Vec { + let target_display = target_path.display().to_string(); + let verify_bundle = format!( + "verify {target_display} matches the applied managed skill bundle with {file_count} file(s)" + ); + vec![verify_bundle] +} + +fn verify_managed_skill_activation_state( + artifact_id: &str, + target_path: &Path, + files: &BTreeMap, +) -> CliResult> { + let matches_payload = managed_skill_payload_matches_install_root(files, target_path)?; + if !matches_payload { + let target_display = target_path.display().to_string(); + let error = format!( + "activate managed skill `{artifact_id}` did not leave an installed bundle at {target_display} that matches the applied draft payload" + ); + return Err(error); + } + + let target_display = target_path.display().to_string(); + let file_count = files.len(); + let verification = format!( + "verified {target_display} matches the applied managed skill bundle with {file_count} file(s)" + ); + Ok(vec![verification]) +} + +fn build_profile_note_activation_verification_hints( + config_path: &Path, + addendum: &str, +) -> Vec { + let config_display = config_path.display().to_string(); + let addendum_length = addendum.chars().count(); + let verify_profile = + format!("verify {config_display} sets memory.profile=profile_plus_window after activation"); + let verify_addendum = format!( + "verify {config_display} persists the {addendum_length}-character advisory addendum in memory.profile_note" + ); + vec![verify_profile, verify_addendum] +} + +fn verify_profile_note_addendum_activation_state( + config_path: &Path, + addendum: &str, +) -> CliResult> { + let config_path_text = config_path.display().to_string(); + let load_result = mvp::config::load(Some(config_path_text.as_str()))?; + let (_, reloaded_config) = load_result; + if reloaded_config.memory.profile != mvp::config::MemoryProfile::ProfilePlusWindow { + let error = format!( + "runtime capability activate expected {} to set memory.profile=profile_plus_window", + config_path.display() + ); + return Err(error); + } + + let persisted_profile_note = match reloaded_config.memory.profile_note.as_deref() { + Some(value) => value, + None => { + let error = format!( + "runtime capability activate expected {} to persist memory.profile_note", + config_path.display() + ); + return Err(error); + } + }; + let merged_profile_note = + mvp::migration::merge_profile_note_addendum(Some(persisted_profile_note), addendum); + if merged_profile_note.is_some() { + let error = format!( + "runtime capability activate expected {} to contain the advisory addendum in memory.profile_note", + config_path.display() + ); + return Err(error); + } + + let config_display = config_path.display().to_string(); + let addendum_length = addendum.chars().count(); + let profile_verification = + format!("verified {config_display} sets memory.profile=profile_plus_window"); + let note_verification = format!( + "verified {config_display} persists the {addendum_length}-character advisory addendum in memory.profile_note" + ); + let verification = vec![profile_verification, note_verification]; + Ok(verification) +} + +fn build_runtime_capability_activation_record_path( + artifact_path: &Path, + artifact_id: &str, +) -> CliResult { + let artifact_parent = artifact_path.parent().ok_or_else(|| { + format!( + "runtime capability artifact {} has no parent directory for activation records", + artifact_path.display() + ) + })?; + let root_path = artifact_parent + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| artifact_parent.to_path_buf()); + let record_root = root_path.join("runtime-capability-activation"); + let timestamp = now_rfc3339()?; + let normalized_timestamp = timestamp.replace(':', "-"); + let file_name = format!("{artifact_id}-{normalized_timestamp}.json"); + let record_path = record_root.join(file_name); + Ok(record_path) +} + +fn build_runtime_capability_managed_skill_activation_record( + artifact_path: &str, + config_path: &Path, + artifact_id: &str, + target: RuntimeCapabilityTarget, + delivery_surface: &str, + activation_surface: &str, + target_path: &str, + verification: &[String], + rollback_hints: &[String], + previous_files: Option>, +) -> CliResult { + let activation_id = + build_runtime_capability_activation_id(artifact_id, target, target_path, verification)?; + let rollback = RuntimeCapabilityRollbackPayload::ManagedSkillBundle { previous_files }; + let record = RuntimeCapabilityActivationRecordDocument { + schema: RuntimeCapabilityArtifactSchema { + version: RUNTIME_CAPABILITY_ACTIVATION_RECORD_JSON_SCHEMA_VERSION, + surface: RUNTIME_CAPABILITY_ACTIVATION_RECORD_SURFACE.to_owned(), + purpose: RUNTIME_CAPABILITY_ACTIVATION_RECORD_PURPOSE.to_owned(), + }, + activation_id, + activated_at: now_rfc3339()?, + artifact_path: artifact_path.to_owned(), + config_path: config_path.display().to_string(), + artifact_id: artifact_id.to_owned(), + target, + delivery_surface: delivery_surface.to_owned(), + activation_surface: activation_surface.to_owned(), + target_path: target_path.to_owned(), + verification: verification.to_vec(), + rollback_hints: rollback_hints.to_vec(), + rollback, + }; + Ok(record) +} + +fn build_runtime_capability_profile_note_activation_record( + artifact_path: &str, + config_path: &Path, + artifact_id: &str, + target: RuntimeCapabilityTarget, + delivery_surface: &str, + activation_surface: &str, + target_path: &str, + verification: &[String], + rollback_hints: &[String], + previous_profile: mvp::config::MemoryProfile, + previous_profile_note: Option, +) -> CliResult { + let activation_id = + build_runtime_capability_activation_id(artifact_id, target, target_path, verification)?; + let rollback = RuntimeCapabilityRollbackPayload::ProfileNoteAddendum { + previous_profile, + previous_profile_note, + }; + let record = RuntimeCapabilityActivationRecordDocument { + schema: RuntimeCapabilityArtifactSchema { + version: RUNTIME_CAPABILITY_ACTIVATION_RECORD_JSON_SCHEMA_VERSION, + surface: RUNTIME_CAPABILITY_ACTIVATION_RECORD_SURFACE.to_owned(), + purpose: RUNTIME_CAPABILITY_ACTIVATION_RECORD_PURPOSE.to_owned(), + }, + activation_id, + activated_at: now_rfc3339()?, + artifact_path: artifact_path.to_owned(), + config_path: config_path.display().to_string(), + artifact_id: artifact_id.to_owned(), + target, + delivery_surface: delivery_surface.to_owned(), + activation_surface: activation_surface.to_owned(), + target_path: target_path.to_owned(), + verification: verification.to_vec(), + rollback_hints: rollback_hints.to_vec(), + rollback, + }; + Ok(record) +} + +fn build_runtime_capability_activation_id( + artifact_id: &str, + target: RuntimeCapabilityTarget, + target_path: &str, + verification: &[String], +) -> CliResult { + let mut hasher = sha2::Sha256::new(); + hasher.update(artifact_id.as_bytes()); + hasher.update(render_target(target).as_bytes()); + hasher.update(target_path.as_bytes()); + for item in verification { + hasher.update(item.as_bytes()); + } + let digest = hasher.finalize(); + let activation_digest = hex::encode(digest); + let activation_id = format!("runtime-capability-activation-{activation_digest}"); + Ok(activation_id) +} + +fn persist_runtime_capability_activation_record( + path: &Path, + record: &RuntimeCapabilityActivationRecordDocument, +) -> CliResult<()> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent).map_err(|error| { + format!( + "create runtime capability activation record directory {} failed: {error}", + parent.display() + ) + })?; + } + let encoded = serde_json::to_vec_pretty(record).map_err(|error| { + format!("serialize runtime capability activation record failed: {error}") + })?; + fs::write(path, encoded).map_err(|error| { + format!( + "write runtime capability activation record {} failed: {error}", + path.display() + ) + })?; + Ok(()) +} + +fn load_runtime_capability_activation_record( + path: &Path, +) -> CliResult { + let raw = fs::read_to_string(path).map_err(|error| { + format!( + "read runtime capability activation record {} failed: {error}", + path.display() + ) + })?; + let record = serde_json::from_str::(&raw).map_err( + |error| { + format!( + "decode runtime capability activation record {} failed: {error}", + path.display() + ) + }, + )?; + validate_runtime_capability_activation_record_schema(&record, path)?; + Ok(record) +} + +fn validate_runtime_capability_activation_record_schema( + record: &RuntimeCapabilityActivationRecordDocument, + path: &Path, +) -> CliResult<()> { + let schema = &record.schema; + if schema.version != RUNTIME_CAPABILITY_ACTIVATION_RECORD_JSON_SCHEMA_VERSION { + return Err(format!( + "runtime capability activation record {} uses unsupported schema version {}; expected {}", + path.display(), + schema.version, + RUNTIME_CAPABILITY_ACTIVATION_RECORD_JSON_SCHEMA_VERSION + )); + } + if schema.surface != RUNTIME_CAPABILITY_ACTIVATION_RECORD_SURFACE { + return Err(format!( + "runtime capability activation record {} uses unsupported schema surface {}; expected {}", + path.display(), + schema.surface, + RUNTIME_CAPABILITY_ACTIVATION_RECORD_SURFACE + )); + } + if schema.purpose != RUNTIME_CAPABILITY_ACTIVATION_RECORD_PURPOSE { + return Err(format!( + "runtime capability activation record {} uses unsupported schema purpose {}; expected {}", + path.display(), + schema.purpose, + RUNTIME_CAPABILITY_ACTIVATION_RECORD_PURPOSE + )); + } + Ok(()) +} + +fn collect_runtime_capability_bundle_files( + root: &Path, +) -> CliResult>> { + if !root.exists() { + return Ok(None); + } + let metadata = fs::metadata(root).map_err(|error| { + format!( + "read runtime capability bundle root metadata {} failed: {error}", + root.display() + ) + })?; + if !metadata.is_dir() { + return Err(format!( + "runtime capability bundle root {} must be a directory", + root.display() + )); + } + + let mut files = BTreeMap::new(); + collect_runtime_capability_bundle_files_recursive(root, root, &mut files)?; + Ok(Some(files)) +} + +fn collect_runtime_capability_bundle_files_recursive( + bundle_root: &Path, + current_root: &Path, + files: &mut BTreeMap, +) -> CliResult<()> { + let read_dir = fs::read_dir(current_root).map_err(|error| { + format!( + "read runtime capability bundle directory {} failed: {error}", + current_root.display() + ) + })?; + let mut entries = Vec::new(); + for entry_result in read_dir { + let entry = entry_result.map_err(|error| { + format!( + "read runtime capability bundle directory entry under {} failed: {error}", + current_root.display() + ) + })?; + entries.push(entry.path()); + } + entries.sort(); + + for entry_path in entries { + let entry_metadata = fs::metadata(&entry_path).map_err(|error| { + format!( + "read runtime capability bundle entry metadata {} failed: {error}", + entry_path.display() + ) + })?; + if entry_metadata.is_dir() { + collect_runtime_capability_bundle_files_recursive( + bundle_root, + entry_path.as_path(), + files, + )?; + continue; + } + if !entry_metadata.is_file() { + continue; + } + let relative_path = entry_path.strip_prefix(bundle_root).map_err(|error| { + format!( + "derive runtime capability bundle relative path for {} failed: {error}", + entry_path.display() + ) + })?; + let relative_path_text = normalized_path_text(&relative_path.display().to_string()); + let contents = fs::read_to_string(&entry_path).map_err(|error| { + format!( + "read runtime capability bundle file {} failed: {error}", + entry_path.display() + ) + })?; + files.insert(relative_path_text, contents); + } + Ok(()) +} + +fn execute_runtime_capability_rollback_managed_skill( + options: RuntimeCapabilityRollbackCommandOptions, + record_path: String, + record: RuntimeCapabilityActivationRecordDocument, +) -> CliResult { + let RuntimeCapabilityActivationRecordDocument { + config_path, + artifact_id, + target, + activation_surface, + target_path, + rollback, + .. + } = record; + let rollback = match rollback { + RuntimeCapabilityRollbackPayload::ManagedSkillBundle { previous_files } => previous_files, + RuntimeCapabilityRollbackPayload::ProfileNoteAddendum { .. } => { + return Err( + "runtime capability rollback expected a managed skill activation record".to_owned(), + ); + } + }; + let target_path_buf = PathBuf::from(target_path.as_str()); + let current_files = collect_runtime_capability_bundle_files(target_path_buf.as_path())?; + let already_rolled_back = current_files == rollback; + let dry_run_verification = build_managed_skill_rollback_verification_hints( + target_path_buf.as_path(), + rollback.as_ref(), + ); + + if !options.apply { + let note = if already_rolled_back { + "managed skill already matches the recorded pre-activation state".to_owned() + } else { + "managed skill rollback would restore the recorded pre-activation state".to_owned() + }; + return Ok(RuntimeCapabilityRollbackReport { + generated_at: now_rfc3339()?, + record_path, + config_path, + artifact_id, + target, + activation_surface, + target_path, + apply_requested: false, + outcome: RuntimeCapabilityRollbackOutcome::DryRun, + notes: vec![note], + verification: dry_run_verification, + }); + } + + if already_rolled_back { + let verification = verify_managed_skill_rollback_state( + artifact_id.as_str(), + target_path_buf.as_path(), + rollback.as_ref(), + )?; + return Ok(RuntimeCapabilityRollbackReport { + generated_at: now_rfc3339()?, + record_path, + config_path, + artifact_id, + target, + activation_surface, + target_path, + apply_requested: true, + outcome: RuntimeCapabilityRollbackOutcome::AlreadyRolledBack, + notes: vec![ + "managed skill already matches the recorded pre-activation state".to_owned(), + ], + verification, + }); + } + + let config_override = options.config.unwrap_or(config_path); + let (resolved_config_path, config) = mvp::config::load(Some(config_override.as_str()))?; + let rollback_payload = RuntimeCapabilityRollbackPayload::ManagedSkillBundle { + previous_files: rollback, + }; + rollback_managed_skill_activation_state( + resolved_config_path.as_path(), + config, + artifact_id.as_str(), + target_path_buf.as_path(), + rollback_payload.clone(), + )?; + let verification = verify_managed_skill_rollback_state( + artifact_id.as_str(), + target_path_buf.as_path(), + match rollback_payload { + RuntimeCapabilityRollbackPayload::ManagedSkillBundle { ref previous_files } => { + previous_files.as_ref() + } + RuntimeCapabilityRollbackPayload::ProfileNoteAddendum { .. } => None, + }, + )?; + Ok(RuntimeCapabilityRollbackReport { + generated_at: now_rfc3339()?, + record_path, + config_path: resolved_config_path.display().to_string(), + artifact_id, + target, + activation_surface, + target_path, + apply_requested: true, + outcome: RuntimeCapabilityRollbackOutcome::RolledBack, + notes: vec!["managed skill rollback restored the recorded pre-activation state".to_owned()], + verification, + }) +} + +fn execute_runtime_capability_rollback_profile_note_addendum( + options: RuntimeCapabilityRollbackCommandOptions, + record_path: String, + record: RuntimeCapabilityActivationRecordDocument, +) -> CliResult { + let RuntimeCapabilityActivationRecordDocument { + config_path, + artifact_id, + target, + activation_surface, + target_path, + rollback, + .. + } = record; + let rollback = match rollback { + RuntimeCapabilityRollbackPayload::ProfileNoteAddendum { + previous_profile, + previous_profile_note, + } => (previous_profile, previous_profile_note), + RuntimeCapabilityRollbackPayload::ManagedSkillBundle { .. } => { + return Err( + "runtime capability rollback expected a profile note activation record".to_owned(), + ); + } + }; + let config_override = options.config.unwrap_or(config_path); + let dry_run_verification = build_profile_note_rollback_verification_hints( + Path::new(config_override.as_str()), + rollback.0, + rollback.1.as_deref(), + ); + + if !options.apply { + return Ok(RuntimeCapabilityRollbackReport { + generated_at: now_rfc3339()?, + record_path, + config_path: config_override, + artifact_id, + target, + activation_surface, + target_path, + apply_requested: false, + outcome: RuntimeCapabilityRollbackOutcome::DryRun, + notes: vec![ + "profile note rollback would restore the recorded pre-activation memory state" + .to_owned(), + ], + verification: dry_run_verification, + }); + } + + let (resolved_config_path, _) = mvp::config::load(Some(config_override.as_str()))?; + let already_rolled_back = profile_note_state_matches( + resolved_config_path.as_path(), + rollback.0, + rollback.1.as_deref(), + )?; + if already_rolled_back { + let verification = verify_profile_note_rollback_state( + resolved_config_path.as_path(), + rollback.0, + rollback.1.as_deref(), + )?; + return Ok(RuntimeCapabilityRollbackReport { + generated_at: now_rfc3339()?, + record_path, + config_path: resolved_config_path.display().to_string(), + artifact_id, + target, + activation_surface, + target_path, + apply_requested: true, + outcome: RuntimeCapabilityRollbackOutcome::AlreadyRolledBack, + notes: vec![ + "profile note already matches the recorded pre-activation memory state".to_owned(), + ], + verification, + }); + } + + let rollback_payload = RuntimeCapabilityRollbackPayload::ProfileNoteAddendum { + previous_profile: rollback.0, + previous_profile_note: rollback.1.clone(), + }; + rollback_profile_note_addendum_activation_state( + resolved_config_path.as_path(), + rollback.0, + rollback_payload, + )?; + let verification = verify_profile_note_rollback_state( + resolved_config_path.as_path(), + rollback.0, + rollback.1.as_deref(), + )?; + Ok(RuntimeCapabilityRollbackReport { + generated_at: now_rfc3339()?, + record_path, + config_path: resolved_config_path.display().to_string(), + artifact_id, + target, + activation_surface, + target_path, + apply_requested: true, + outcome: RuntimeCapabilityRollbackOutcome::RolledBack, + notes: vec![ + "profile note rollback restored the recorded pre-activation memory state".to_owned(), + ], + verification, + }) +} + +fn rollback_managed_skill_activation_state( + resolved_config_path: &Path, + mut config: mvp::config::LoongClawConfig, + artifact_id: &str, + target_path: &Path, + rollback: RuntimeCapabilityRollbackPayload, +) -> CliResult<()> { + let previous_files = match rollback { + RuntimeCapabilityRollbackPayload::ManagedSkillBundle { previous_files } => previous_files, + RuntimeCapabilityRollbackPayload::ProfileNoteAddendum { .. } => { + return Err( + "runtime capability rollback expected a managed skill rollback payload".to_owned(), + ); + } + }; + config.external_skills.enabled = true; + config.external_skills.install_root = target_path + .parent() + .map(|value| value.display().to_string()); + let tool_runtime = mvp::tools::runtime_config::ToolRuntimeConfig::from_loongclaw_config( + &config, + Some(resolved_config_path), + ); + + match previous_files { + Some(previous_files) => { + let staging_base_root = + resolve_runtime_capability_activation_staging_base_root(&tool_runtime)?; + let staging_root = write_runtime_capability_draft_files_to_staging( + &previous_files, + staging_base_root.as_path(), + )?; + let staging_path = staging_root.display().to_string(); + let install_request = ToolCoreRequest { + tool_name: "external_skills.install".to_owned(), + payload: json!({ + "path": staging_path, + "skill_id": artifact_id, + "replace": true, + }), + }; + let install_result = + mvp::tools::execute_tool_core_with_config(install_request, &tool_runtime); + let cleanup_result = fs::remove_dir_all(&staging_root); + if let Err(error) = cleanup_result { + return Err(format!( + "cleanup managed skill rollback staging root {} failed: {error}", + staging_root.display() + )); + } + install_result.map_err(|error| { + format!( + "restore previous managed skill `{artifact_id}` during rollback failed: {error}" + ) + })?; + } + None => { + let remove_request = ToolCoreRequest { + tool_name: "external_skills.remove".to_owned(), + payload: json!({ + "skill_id": artifact_id, + }), + }; + mvp::tools::execute_tool_core_with_config(remove_request, &tool_runtime).map_err( + |error| { + format!("remove managed skill `{artifact_id}` during rollback failed: {error}") + }, + )?; + } + } + Ok(()) +} + +fn rollback_profile_note_addendum_activation_state( + config_path: &Path, + previous_profile: mvp::config::MemoryProfile, + rollback: RuntimeCapabilityRollbackPayload, +) -> CliResult<()> { + let previous_profile_note = match rollback { + RuntimeCapabilityRollbackPayload::ProfileNoteAddendum { + previous_profile_note, + .. + } => previous_profile_note, + RuntimeCapabilityRollbackPayload::ManagedSkillBundle { .. } => { + return Err( + "runtime capability rollback expected a profile note rollback payload".to_owned(), + ); + } + }; + let config_path_text = config_path.display().to_string(); + let load_result = mvp::config::load(Some(config_path_text.as_str()))?; + let (_, mut config) = load_result; + config.memory.profile = previous_profile; + config.memory.profile_note = previous_profile_note; + mvp::config::write(Some(config_path_text.as_str()), &config, true)?; + Ok(()) +} + +fn build_managed_skill_rollback_verification_hints( + target_path: &Path, + previous_files: Option<&BTreeMap>, +) -> Vec { + let target_display = target_path.display().to_string(); + match previous_files { + Some(previous_files) => { + let file_count = previous_files.len(); + let verification = format!( + "verify {target_display} matches the recorded pre-activation managed skill bundle with {file_count} file(s)" + ); + vec![verification] + } + None => { + let verification = format!( + "verify {target_display} is absent after rollback removes the managed skill" + ); + vec![verification] + } + } +} + +fn verify_managed_skill_rollback_state( + artifact_id: &str, + target_path: &Path, + previous_files: Option<&BTreeMap>, +) -> CliResult> { + match previous_files { + Some(previous_files) => { + let matches_payload = + managed_skill_payload_matches_install_root(previous_files, target_path)?; + if !matches_payload { + return Err(format!( + "runtime capability rollback did not restore managed skill `{artifact_id}` to the recorded pre-activation bundle at {}", + target_path.display() + )); + } + let file_count = previous_files.len(); + let verification = format!( + "verified {} matches the recorded pre-activation managed skill bundle with {file_count} file(s)", + target_path.display() + ); + Ok(vec![verification]) + } + None => { + if target_path.exists() { + return Err(format!( + "runtime capability rollback expected managed skill `{artifact_id}` to be removed from {}", + target_path.display() + )); + } + let verification = format!( + "verified {} is absent after rollback removed the managed skill", + target_path.display() + ); + Ok(vec![verification]) + } + } +} + +fn build_profile_note_rollback_verification_hints( + config_path: &Path, + previous_profile: mvp::config::MemoryProfile, + previous_profile_note: Option<&str>, +) -> Vec { + let config_display = config_path.display().to_string(); + let profile_hint = format!( + "verify {config_display} restores memory.profile={} during rollback", + render_memory_profile(previous_profile) + ); + let note_hint = match previous_profile_note { + Some(previous_profile_note) => { + let char_count = previous_profile_note.chars().count(); + format!( + "verify {config_display} restores the {char_count}-character pre-activation memory.profile_note" + ) + } + None => format!("verify {config_display} clears memory.profile_note during rollback"), + }; + vec![profile_hint, note_hint] +} + +fn profile_note_state_matches( + config_path: &Path, + previous_profile: mvp::config::MemoryProfile, + previous_profile_note: Option<&str>, +) -> CliResult { + let config_path_text = config_path.display().to_string(); + let load_result = mvp::config::load(Some(config_path_text.as_str()))?; + let (_, config) = load_result; + if config.memory.profile != previous_profile { + return Ok(false); + } + let current_profile_note = config.memory.profile_note.as_deref(); + Ok(current_profile_note == previous_profile_note) +} + +fn verify_profile_note_rollback_state( + config_path: &Path, + previous_profile: mvp::config::MemoryProfile, + previous_profile_note: Option<&str>, +) -> CliResult> { + let matches = profile_note_state_matches(config_path, previous_profile, previous_profile_note)?; + if !matches { + return Err(format!( + "runtime capability rollback expected {} to restore the recorded pre-activation memory state", + config_path.display() + )); + } + + let config_display = config_path.display().to_string(); + let profile_verification = format!( + "verified {config_display} restores memory.profile={}", + render_memory_profile(previous_profile) + ); + let note_verification = match previous_profile_note { + Some(previous_profile_note) => { + let char_count = previous_profile_note.chars().count(); + format!( + "verified {config_display} restores the {char_count}-character pre-activation memory.profile_note" + ) + } + None => format!("verified {config_display} clears memory.profile_note during rollback"), + }; + Ok(vec![profile_verification, note_verification]) +} + +pub fn render_runtime_capability_text(artifact: &RuntimeCapabilityArtifactDocument) -> String { + [ + format!("candidate_id={}", artifact.candidate_id), + format!("status={}", render_capability_status(artifact.status)), + format!("decision={}", render_capability_decision(artifact.decision)), + format!("target={}", render_target(artifact.proposal.target)), format!("target_summary={}", artifact.proposal.summary), format!("bounded_scope={}", artifact.proposal.bounded_scope), format!( @@ -1663,39 +3124,6 @@ pub fn render_runtime_capability_text(artifact: &RuntimeCapabilityArtifactDocume .join("\n") } -pub fn render_runtime_capability_apply_text(report: &RuntimeCapabilityApplyReport) -> String { - let artifact = &report.materialized_artifact; - - [ - format!("family_id={}", report.family_id), - format!( - "outcome={}", - render_runtime_capability_apply_outcome(report.outcome) - ), - format!("artifact_kind={}", artifact.artifact_kind), - format!("artifact_id={}", artifact.artifact_id), - format!("delivery_surface={}", artifact.delivery_surface), - format!("output_path={}", report.output_path), - format!("profile_summary={}", artifact.profile.summary), - format!("review_scope={}", artifact.profile.review_scope), - format!( - "required_capabilities={}", - render_string_values(&artifact.profile.required_capabilities) - ), - format!("tags={}", render_string_values(&artifact.profile.tags)), - format!("provenance_family_id={}", artifact.provenance.family_id), - format!( - "accepted_candidate_ids={}", - render_string_values(&artifact.provenance.accepted_candidate_ids) - ), - format!( - "changed_surfaces={}", - render_string_values(&artifact.provenance.evidence_digest.changed_surfaces) - ), - ] - .join("\n") -} - pub fn render_runtime_capability_index_text(report: &RuntimeCapabilityIndexReport) -> String { let mut lines = vec![ format!("root={}", report.root), @@ -1764,6 +3192,119 @@ pub fn render_runtime_capability_index_text(report: &RuntimeCapabilityIndexRepor lines.join("\n") } +pub fn render_runtime_capability_apply_text(report: &RuntimeCapabilityApplyReport) -> String { + let artifact = &report.applied_artifact; + [ + format!("family_id={}", report.family_id), + format!( + "outcome={}", + render_runtime_capability_apply_outcome(report.outcome) + ), + format!("artifact_kind={}", artifact.artifact_kind), + format!("artifact_id={}", artifact.artifact_id), + format!("delivery_surface={}", artifact.delivery_surface), + format!("output_path={}", report.output_path), + format!("target={}", render_target(artifact.target)), + format!("target_summary={}", artifact.summary), + format!("bounded_scope={}", artifact.bounded_scope), + format!( + "required_capabilities={}", + render_string_values(&artifact.required_capabilities) + ), + format!("tags={}", render_string_values(&artifact.tags)), + format!( + "approval_checklist={}", + render_string_values_with_separator(&artifact.approval_checklist, " | ") + ), + format!( + "rollback_hints={}", + render_string_values_with_separator(&artifact.rollback_hints, " | ") + ), + format!("delta_candidate_count={}", artifact.delta_candidate_count), + format!( + "changed_surfaces={}", + render_string_values(&artifact.changed_surfaces) + ), + format!( + "candidate_ids={}", + render_string_values(&artifact.candidate_ids) + ), + format!( + "source_run_ids={}", + render_string_values(&artifact.source_run_ids) + ), + format!( + "experiment_ids={}", + render_string_values(&artifact.experiment_ids) + ), + format!( + "payload={}", + render_runtime_capability_draft_payload(&artifact.payload) + ), + ] + .join("\n") +} + +pub fn render_runtime_capability_activate_text(report: &RuntimeCapabilityActivateReport) -> String { + [ + format!("artifact_path={}", report.artifact_path), + format!("config_path={}", report.config_path), + format!("artifact_id={}", report.artifact_id), + format!("target={}", render_target(report.target)), + format!("delivery_surface={}", report.delivery_surface), + format!("activation_surface={}", report.activation_surface), + format!("target_path={}", report.target_path), + format!("apply_requested={}", report.apply_requested), + format!("replace_requested={}", report.replace_requested), + format!( + "outcome={}", + render_runtime_capability_activate_outcome(report.outcome) + ), + format!( + "notes={}", + render_string_values_with_separator(&report.notes, " | ") + ), + format!( + "verification={}", + render_string_values_with_separator(&report.verification, " | ") + ), + format!( + "rollback_hints={}", + render_string_values_with_separator(&report.rollback_hints, " | ") + ), + format!( + "activation_record_path={}", + report.activation_record_path.as_deref().unwrap_or("-") + ), + ] + .join("\n") +} + +pub fn render_runtime_capability_rollback_text(report: &RuntimeCapabilityRollbackReport) -> String { + [ + format!("record_path={}", report.record_path), + format!("config_path={}", report.config_path), + format!("artifact_id={}", report.artifact_id), + format!("target={}", render_target(report.target)), + format!("activation_surface={}", report.activation_surface), + format!("target_path={}", report.target_path), + format!("apply_requested={}", report.apply_requested), + format!( + "outcome={}", + render_runtime_capability_rollback_outcome(report.outcome) + ), + format!( + "notes={}", + render_string_values_with_separator(&report.notes, " | ") + ), + format!( + "verification={}", + render_string_values_with_separator(&report.verification, " | ") + ), + ] + .join("\n") +} + fn render_metrics(metrics: &std::collections::BTreeMap) -> String { if metrics.is_empty() { "-".to_owned() @@ -1792,6 +3333,10 @@ fn render_string_values_with_separator(values: &[String], separator: &str) -> St } } +fn normalized_path_text(value: &str) -> String { + value.replace('\\', "/") +} + fn render_metric_ranges(ranges: &BTreeMap) -> String { if ranges.is_empty() { "-".to_owned() @@ -1818,7 +3363,14 @@ fn render_target(target: RuntimeCapabilityTarget) -> &'static str { RuntimeCapabilityTarget::ManagedSkill => "managed_skill", RuntimeCapabilityTarget::ProgrammaticFlow => "programmatic_flow", RuntimeCapabilityTarget::ProfileNoteAddendum => "profile_note_addendum", - RuntimeCapabilityTarget::MemoryStageProfile => "memory_stage_profile", + } +} + +fn render_memory_profile(profile: mvp::config::MemoryProfile) -> &'static str { + match profile { + mvp::config::MemoryProfile::WindowOnly => "window_only", + mvp::config::MemoryProfile::WindowPlusSummary => "window_plus_summary", + mvp::config::MemoryProfile::ProfilePlusWindow => "profile_plus_window", } } @@ -1844,6 +3396,60 @@ fn render_runtime_capability_apply_outcome(outcome: RuntimeCapabilityApplyOutcom } } +fn render_runtime_capability_activate_outcome( + outcome: RuntimeCapabilityActivateOutcome, +) -> &'static str { + match outcome { + RuntimeCapabilityActivateOutcome::DryRun => "dry_run", + RuntimeCapabilityActivateOutcome::Activated => "activated", + RuntimeCapabilityActivateOutcome::AlreadyActivated => "already_activated", + } +} + +fn render_runtime_capability_rollback_outcome( + outcome: RuntimeCapabilityRollbackOutcome, +) -> &'static str { + match outcome { + RuntimeCapabilityRollbackOutcome::DryRun => "dry_run", + RuntimeCapabilityRollbackOutcome::RolledBack => "rolled_back", + RuntimeCapabilityRollbackOutcome::AlreadyRolledBack => "already_rolled_back", + } +} + +fn render_runtime_capability_planned_payload( + payload: &RuntimeCapabilityPromotionPlannedPayload, +) -> String { + let accepted_candidate_ids = render_string_values(&payload.provenance.accepted_candidate_ids); + let changed_surfaces = render_string_values(&payload.provenance.changed_surfaces); + let draft_payload = render_runtime_capability_draft_payload(&payload.payload); + format!( + "target={} draft_id={} review_scope={} accepted_candidate_ids={} changed_surfaces={} payload={}", + render_target(payload.target), + payload.draft_id, + payload.review_scope, + accepted_candidate_ids, + changed_surfaces, + draft_payload + ) +} + +fn render_runtime_capability_draft_payload(payload: &RuntimeCapabilityDraftPayload) -> String { + match payload { + RuntimeCapabilityDraftPayload::ManagedSkillBundle { files } => { + let file_names = files.keys().cloned().collect::>().join(","); + format!("managed_skill_bundle files={file_names}") + } + RuntimeCapabilityDraftPayload::ProgrammaticFlowSpec { files } => { + let file_names = files.keys().cloned().collect::>().join(","); + format!("programmatic_flow_spec files={file_names}") + } + RuntimeCapabilityDraftPayload::ProfileNoteAddendum { content } => { + let content_chars = content.chars().count(); + format!("profile_note_addendum chars={content_chars}") + } + } +} + fn render_experiment_status(status: RuntimeExperimentStatus) -> &'static str { match status { RuntimeExperimentStatus::Planned => "planned", @@ -1917,11 +3523,6 @@ fn runtime_capability_promotion_target_contract( RuntimeCapabilityTarget::ProfileNoteAddendum => { ("profile_note_addendum", "profile_note", "profile-note") } - RuntimeCapabilityTarget::MemoryStageProfile => ( - "memory_stage_profile", - "memory_stage_profiles", - "memory-stage-profile", - ), } } @@ -1969,10 +3570,6 @@ fn build_runtime_capability_approval_checklist( "confirm the behavior belongs in advisory profile guidance rather than executable logic" .to_owned() } - RuntimeCapabilityTarget::MemoryStageProfile => { - "confirm the behavior belongs in a governed memory stage profile rather than live runtime mutation" - .to_owned() - } }); checklist } @@ -1980,20 +3577,11 @@ fn build_runtime_capability_approval_checklist( fn build_runtime_capability_rollback_hints( planned_artifact: &RuntimeCapabilityPromotionArtifactPlan, ) -> Vec { - let capture_hint = match planned_artifact.target_kind { - RuntimeCapabilityTarget::MemoryStageProfile => format!( - "capture the current `{}` state before applying this memory stage profile", - planned_artifact.delivery_surface - ), - RuntimeCapabilityTarget::ManagedSkill - | RuntimeCapabilityTarget::ProgrammaticFlow - | RuntimeCapabilityTarget::ProfileNoteAddendum => format!( + vec![ + format!( "capture the current `{}` state before applying artifact `{}`", planned_artifact.delivery_surface, planned_artifact.artifact_id ), - }; - vec![ - capture_hint, format!( "remove or revert `{}` from `{}` if downstream validation fails", planned_artifact.artifact_id, planned_artifact.delivery_surface @@ -2041,58 +3629,124 @@ fn build_runtime_capability_promotion_planned_payload( family_id: &str, planned_artifact: &RuntimeCapabilityPromotionArtifactPlan, artifacts: &[RuntimeCapabilityArtifactDocument], -) -> Option { + evidence: &RuntimeCapabilityEvidenceDigest, +) -> CliResult { + let accepted_candidate_ids = artifacts + .iter() + .filter(|artifact| artifact.decision == RuntimeCapabilityDecision::Accepted) + .map(|artifact| artifact.candidate_id.clone()) + .collect::>(); + + let payload = build_runtime_capability_draft_payload(family_id, planned_artifact, evidence)?; + + let planned_payload = RuntimeCapabilityPromotionPlannedPayload { + artifact_kind: planned_artifact.artifact_kind.clone(), + target: planned_artifact.target_kind, + draft_id: planned_artifact.artifact_id.clone(), + summary: planned_artifact.summary.clone(), + review_scope: planned_artifact.bounded_scope.clone(), + required_capabilities: planned_artifact.required_capabilities.clone(), + tags: planned_artifact.tags.clone(), + payload, + provenance: RuntimeCapabilityPromotionPlannedPayloadProvenance { + family_id: family_id.to_owned(), + accepted_candidate_ids, + changed_surfaces: evidence.changed_surfaces.clone(), + }, + }; + Ok(planned_payload) +} + +fn build_runtime_capability_draft_payload( + family_id: &str, + planned_artifact: &RuntimeCapabilityPromotionArtifactPlan, + evidence: &RuntimeCapabilityEvidenceDigest, +) -> CliResult { match planned_artifact.target_kind { - RuntimeCapabilityTarget::MemoryStageProfile => { - Some(RuntimeCapabilityPromotionPlannedPayload { - memory_stage_profile: RuntimeCapabilityMemoryStageProfileDryRunPayload { - schema_version: 1, - artifact_kind: planned_artifact.artifact_kind.clone(), - profile: RuntimeCapabilityMemoryStageProfileDryRunProfile { - id: planned_artifact.artifact_id.clone(), - summary: planned_artifact.summary.clone(), - review_scope: planned_artifact.bounded_scope.clone(), - required_capabilities: planned_artifact.required_capabilities.clone(), - tags: planned_artifact.tags.clone(), - }, - provenance: build_memory_stage_profile_dry_run_provenance(family_id, artifacts), - }, - }) + RuntimeCapabilityTarget::ManagedSkill => { + let files = build_managed_skill_draft_files(family_id, planned_artifact, evidence); + Ok(RuntimeCapabilityDraftPayload::ManagedSkillBundle { files }) + } + RuntimeCapabilityTarget::ProgrammaticFlow => { + let files = build_programmatic_flow_draft_files(family_id, planned_artifact, evidence)?; + Ok(RuntimeCapabilityDraftPayload::ProgrammaticFlowSpec { files }) + } + RuntimeCapabilityTarget::ProfileNoteAddendum => { + let content = build_profile_note_addendum_draft(family_id, planned_artifact, evidence); + Ok(RuntimeCapabilityDraftPayload::ProfileNoteAddendum { content }) } - RuntimeCapabilityTarget::ManagedSkill - | RuntimeCapabilityTarget::ProgrammaticFlow - | RuntimeCapabilityTarget::ProfileNoteAddendum => None, } } -fn build_memory_stage_profile_dry_run_provenance( +fn build_managed_skill_draft_files( family_id: &str, - artifacts: &[RuntimeCapabilityArtifactDocument], -) -> RuntimeCapabilityMemoryStageProfileDryRunProvenance { - let mut accepted_artifacts = artifacts - .iter() - .filter(|artifact| artifact.decision == RuntimeCapabilityDecision::Accepted) - .cloned() - .collect::>(); - sort_runtime_capability_artifacts(&mut accepted_artifacts); - let accepted_evidence = build_family_evidence_digest(&accepted_artifacts); + planned_artifact: &RuntimeCapabilityPromotionArtifactPlan, + evidence: &RuntimeCapabilityEvidenceDigest, +) -> BTreeMap { + let skill_name = planned_artifact.summary.as_str(); + let skill_description = planned_artifact.summary.as_str(); + let bounded_scope = planned_artifact.bounded_scope.as_str(); + let required_capabilities = render_string_values(&planned_artifact.required_capabilities); + let tags = render_string_values(&planned_artifact.tags); + let changed_surfaces = render_string_values(&evidence.changed_surfaces); + let skill_markdown = format!( + "---\nname: {skill_name}\ndescription: {skill_description}\n---\n\n# {skill_name}\n\n## Purpose\n\nThis draft managed skill was generated from runtime capability family `{family_id}`.\nReview and refine it before activation.\n\n## Scope\n\n- In: {bounded_scope}\n- Required capabilities: {required_capabilities}\n- Tags: {tags}\n- Changed surfaces: {changed_surfaces}\n" + ); + let mut files = BTreeMap::new(); + files.insert("SKILL.md".to_owned(), skill_markdown); + files +} - RuntimeCapabilityMemoryStageProfileDryRunProvenance { - family_id: family_id.to_owned(), - accepted_candidate_ids: accepted_artifacts - .iter() - .map(|artifact| artifact.candidate_id.clone()) - .collect(), - evidence_digest: RuntimeCapabilityMemoryStageProfileDryRunEvidenceDigest { - changed_surfaces: accepted_evidence.changed_surfaces, +fn build_programmatic_flow_draft_files( + family_id: &str, + planned_artifact: &RuntimeCapabilityPromotionArtifactPlan, + evidence: &RuntimeCapabilityEvidenceDigest, +) -> CliResult> { + let draft_id = planned_artifact.artifact_id.as_str(); + let summary = planned_artifact.summary.as_str(); + let bounded_scope = planned_artifact.bounded_scope.as_str(); + let required_capabilities = &planned_artifact.required_capabilities; + let tags = &planned_artifact.tags; + let changed_surfaces = &evidence.changed_surfaces; + let flow_value = json!({ + "id": draft_id, + "summary": summary, + "bounded_scope": bounded_scope, + "required_capabilities": required_capabilities, + "tags": tags, + "changed_surfaces": changed_surfaces, + "provenance": { + "family_id": family_id, }, - } + "steps": [], + }); + let flow_json = serde_json::to_string_pretty(&flow_value).map_err(|error| { + format!("serialize runtime capability programmatic flow draft failed: {error}") + })?; + let mut files = BTreeMap::new(); + files.insert("flow.json".to_owned(), flow_json); + Ok(files) +} + +fn build_profile_note_addendum_draft( + family_id: &str, + planned_artifact: &RuntimeCapabilityPromotionArtifactPlan, + evidence: &RuntimeCapabilityEvidenceDigest, +) -> String { + let summary = planned_artifact.summary.as_str(); + let bounded_scope = planned_artifact.bounded_scope.as_str(); + let required_capabilities = render_string_values(&planned_artifact.required_capabilities); + let tags = render_string_values(&planned_artifact.tags); + let changed_surfaces = render_string_values(&evidence.changed_surfaces); + format!( + "## Runtime Capability Draft: {summary}\n- Family: {family_id}\n- Scope: {bounded_scope}\n- Required capabilities: {required_capabilities}\n- Tags: {tags}\n- Changed surfaces: {changed_surfaces}\n- Status: review before activation\n" + ) } pub fn render_runtime_capability_promotion_plan_text( report: &RuntimeCapabilityPromotionPlanReport, ) -> String { - let mut lines = vec![ + [ format!("family_id={}", report.family_id), format!("promotable={}", report.promotable), format!( @@ -2162,21 +3816,12 @@ pub fn render_runtime_capability_promotion_plan_text( " | " ) ), - ]; - - if let Some(planned_payload) = - render_runtime_capability_planned_payload_summary(report.planned_payload.as_ref()) - { - lines.push(format!("planned_payload={planned_payload}")); - } - - lines.join("\n") -} - -fn render_runtime_capability_planned_payload_summary( - payload: Option<&RuntimeCapabilityPromotionPlannedPayload>, -) -> Option { - payload.map(|payload| format!("profile_id={}", payload.memory_stage_profile.profile.id)) + format!( + "planned_payload={}", + render_runtime_capability_planned_payload(&report.planned_payload) + ), + ] + .join("\n") } fn render_family_readiness_checks(checks: &[RuntimeCapabilityFamilyReadinessCheck]) -> String { @@ -2190,42 +3835,3 @@ fn render_family_readiness_checks(checks: &[RuntimeCapabilityFamilyReadinessChec .join(" | ") } } - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::{ - fs, - path::PathBuf, - time::{SystemTime, UNIX_EPOCH}, - }; - - fn unique_temp_dir(prefix: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock should be after epoch") - .as_nanos(); - std::env::temp_dir().join(format!("{prefix}-{nanos}")) - } - - #[test] - fn write_pretty_json_file_create_new_rejects_existing_path() { - let root = unique_temp_dir("loongclaw-runtime-capability-atomic-create"); - fs::create_dir_all(&root).expect("create temp dir"); - let output_path = root.join("memory_stage_profiles/profile.json"); - fs::create_dir_all(output_path.parent().expect("parent directory")) - .expect("create output parent"); - fs::write(&output_path, "{\"existing\":true}\n").expect("write existing file"); - - let error = write_pretty_json_file_create_new(&output_path, &json!({"new": true})) - .expect_err("atomic create should reject an existing path"); - - assert!( - error.contains("already exists"), - "expected already-exists error, got: {error}" - ); - - fs::remove_dir_all(&root).ok(); - } -} diff --git a/crates/daemon/tests/integration/chat_cli.rs b/crates/daemon/tests/integration/chat_cli.rs index 0cf540a3e..1a926d7ab 100644 --- a/crates/daemon/tests/integration/chat_cli.rs +++ b/crates/daemon/tests/integration/chat_cli.rs @@ -332,15 +332,9 @@ fn chat_without_config_reports_onboard_failure() { fn chat_without_config_surfaces_config_path_access_errors() { let fixture = ChatCliFixture::new("config-access-error"); - let blocked_dir = fixture.root.join("blocked"); - std::fs::create_dir_all(&blocked_dir).expect("create blocked directory"); - let _reset_guard = PermissionsResetGuard::new(&blocked_dir); - let mut permissions = std::fs::metadata(&blocked_dir) - .expect("blocked directory metadata") - .permissions(); - permissions.set_mode(0o000); - std::fs::set_permissions(&blocked_dir, permissions).expect("lock blocked directory"); - let blocked_config = blocked_dir.join("loongclaw.toml"); + let blocked_parent = fixture.root.join("blocked"); + std::fs::write(&blocked_parent, b"not a directory").expect("create blocking parent file"); + let blocked_config = blocked_parent.join("loongclaw.toml"); let output = fixture.run_chat_command(Some(&blocked_config), None); let stdout = render_output(&output.stdout); diff --git a/crates/daemon/tests/integration/cli_tests.rs b/crates/daemon/tests/integration/cli_tests.rs index 0ebe47715..5a5a65da0 100644 --- a/crates/daemon/tests/integration/cli_tests.rs +++ b/crates/daemon/tests/integration/cli_tests.rs @@ -37,19 +37,18 @@ fn welcome_subcommand_help_advertises_first_run_shortcuts() { "welcome help should frame the configured path as a quick-command entrypoint: {help}" ); assert!( - help.contains("loong ask --config "), + help.contains("loong ask --config ") + || help.contains("loongclaw ask --config "), "welcome help should mention ask with an explicit config placeholder: {help}" ); assert!( - help.contains("loong chat --config "), + help.contains("loong chat --config ") + || help.contains("loongclaw chat --config "), "welcome help should mention chat with an explicit config placeholder: {help}" ); assert!( - help.contains("loong personalize --config "), - "welcome help should mention personalize with an explicit config placeholder: {help}" - ); - assert!( - help.contains("loong doctor --config "), + help.contains("loong doctor --config ") + || help.contains("loongclaw doctor --config "), "welcome help should mention doctor with an explicit config placeholder: {help}" ); assert!( @@ -58,132 +57,6 @@ fn welcome_subcommand_help_advertises_first_run_shortcuts() { ); } -#[test] -fn doctor_help_mentions_security_subcommand() { - let help = render_cli_help(["doctor"]); - - assert!( - help.contains("security"), - "doctor help should advertise the security audit subcommand: {help}" - ); - assert!( - help.contains("--config "), - "doctor help should keep the shared config flag visible: {help}" - ); -} - -#[test] -fn doctor_security_help_mentions_security_exposure_audit() { - let help = render_cli_help(["doctor", "security"]); - - assert!( - help.contains("security exposure"), - "doctor security help should describe the exposure audit: {help}" - ); - assert!( - help.contains("Usage: security"), - "doctor security help should render a dedicated usage block: {help}" - ); -} - -#[test] -fn doctor_security_cli_parses_subcommand_and_global_flags() { - let cli = try_parse_cli([ - "loongclaw", - "doctor", - "--config", - "/tmp/loongclaw.toml", - "security", - "--json", - ]) - .expect("`doctor security --json` should parse"); - - match cli.command { - Some(Commands::Doctor { - config, - fix, - json, - skip_model_probe, - command, - }) => { - assert_eq!(config.as_deref(), Some("/tmp/loongclaw.toml")); - assert!(!fix); - assert!(json); - assert!(!skip_model_probe); - assert_eq!( - command, - Some(loongclaw_daemon::doctor_cli::DoctorCommands::Security) - ); - } - other => panic!("unexpected command parsed: {other:?}"), - } -} - -#[test] -fn doctor_security_cli_accepts_global_flags_after_subcommand() { - let cli = try_parse_cli([ - "loongclaw", - "doctor", - "security", - "--config", - "/tmp/loongclaw.toml", - "--skip-model-probe", - ]) - .expect("global doctor flags should remain valid after the security subcommand"); - - match cli.command { - Some(Commands::Doctor { - config, - fix, - json, - skip_model_probe, - command, - }) => { - assert_eq!(config.as_deref(), Some("/tmp/loongclaw.toml")); - assert!(!fix); - assert!(!json); - assert!(skip_model_probe); - assert_eq!( - command, - Some(loongclaw_daemon::doctor_cli::DoctorCommands::Security) - ); - } - other => panic!("unexpected command parsed: {other:?}"), - } - - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("build test runtime"); - - let fix_error = runtime - .block_on(loongclaw_daemon::doctor_cli::run_doctor_cli( - loongclaw_daemon::doctor_cli::DoctorCommandOptions { - config: None, - fix: true, - json: false, - skip_model_probe: false, - command: Some(loongclaw_daemon::doctor_cli::DoctorCommands::Security), - }, - )) - .expect_err("doctor security should reject --fix at runtime"); - - let probe_error = runtime - .block_on(loongclaw_daemon::doctor_cli::run_doctor_cli( - loongclaw_daemon::doctor_cli::DoctorCommandOptions { - config: None, - fix: false, - json: false, - skip_model_probe: true, - command: Some(loongclaw_daemon::doctor_cli::DoctorCommands::Security), - }, - )) - .expect_err("doctor security should reject --skip-model-probe at runtime"); - - assert!(fix_error.contains("--fix")); - assert!(probe_error.contains("--skip-model-probe")); -} - #[test] fn setup_subcommand_is_removed() { let error = try_parse_cli(["loongclaw", "setup"]) @@ -291,466 +164,93 @@ fn migrate_cli_parses_apply_selected_flags() { } #[test] -fn run_spec_cli_parses_bridge_support_delta_override() { - let cli = try_parse_cli([ - "loongclaw", - "run-spec", - "--spec", - "/tmp/runner.spec.json", - "--bridge-support-delta", - "/tmp/bridge-support.delta.json", - "--bridge-support-delta-sha256", - "abc123", - ]) - .expect("run-spec with bridge support delta override should parse"); - - match cli.command { - Some(Commands::RunSpec { - spec, - print_audit, - bridge_support, - .. - }) => { - assert_eq!(spec, "/tmp/runner.spec.json"); - assert!(!print_audit); - assert_eq!( - bridge_support.bridge_support_delta.as_deref(), - Some("/tmp/bridge-support.delta.json") - ); - assert_eq!( - bridge_support.bridge_support_delta_sha256.as_deref(), - Some("abc123") - ); - } - other => panic!("unexpected command parsed: {other:?}"), - } +fn safe_lane_summary_cli_rejects_zero_limit() { + let error = run_safe_lane_summary_cli(None, Some("session-a"), 0, false) + .expect_err("zero limit must be rejected"); + assert!(error.contains(">= 1")); } #[test] -fn run_spec_help_mentions_bridge_support_overrides() { - let help = render_cli_help(["run-spec"]); +fn runtime_trajectory_export_help_mentions_export_and_lineage() { + let help = render_cli_help(["runtime-trajectory", "export"]); assert!( - help.contains("--bridge-support "), - "help: {help}" + help.contains("trajectory"), + "runtime-trajectory export help should mention trajectory export: {help}" ); assert!( - help.contains("--bridge-profile "), - "help: {help}" + help.contains("--session "), + "runtime-trajectory export help should require a session id: {help}" ); assert!( - help.contains("--bridge-support-delta "), - "help: {help}" - ); - assert!( - help.contains("--bridge-support-delta-sha256 "), - "help: {help}" + help.contains("--turn-limit ") + && help.contains("--event-page-limit "), + "runtime-trajectory export help should surface the bounded export controls: {help}" ); } #[test] -fn safe_lane_summary_cli_rejects_zero_limit() { - let error = run_safe_lane_summary_cli(None, Some("session-a"), 0, false) - .expect_err("zero limit must be rejected"); - assert!(error.contains(">= 1")); -} - -#[test] -fn runtime_trajectory_cli_rejects_invalid_limits() { - let turn_limit_error = - run_runtime_trajectory_cli(None, Some("session-a"), None, None, Some(0), 10, false) - .expect_err("zero turn limit must be rejected"); - assert!(turn_limit_error.contains("turn_limit")); - - let event_page_error = - run_runtime_trajectory_cli(None, Some("session-a"), None, None, None, 0, false) - .expect_err("zero event page limit must be rejected"); - assert!(event_page_error.contains("event_page_limit")); - - let missing_source_error = run_runtime_trajectory_cli(None, None, None, None, None, 10, false) - .expect_err("missing session and artifact must be rejected"); - assert!(missing_source_error.contains("--session or --artifact")); -} - -#[test] -fn session_search_cli_rejects_zero_limit() { - let error = run_session_search_cli( - None, - Some("session-a"), - "deploy freeze", - 0, - None, - false, - false, - ) - .expect_err("zero limit must be rejected"); - assert!(error.contains(">= 1")); -} - -#[test] -fn session_search_cli_parses_flags() { +fn runtime_trajectory_cli_parses_export_flags() { let cli = try_parse_cli([ "loongclaw", - "session-search", - "--session", - "root-session", - "--query", - "deploy freeze", - "--limit", - "7", - "--output", - "/tmp/session-search.json", - "--include-archived", - "--json", - ]) - .expect("`session-search` should parse"); - - match cli.command { - Some(Commands::SessionSearch { - config, - session, - query, - limit, - output, - include_archived, - json, - }) => { - assert!(config.is_none()); - assert_eq!(session.as_deref(), Some("root-session")); - assert_eq!(query, "deploy freeze"); - assert_eq!(limit, 7); - assert_eq!(output.as_deref(), Some("/tmp/session-search.json")); - assert!(include_archived); - assert!(json); - } - other => panic!("unexpected command parsed: {other:?}"), - } -} - -#[test] -fn session_search_inspect_cli_parses_flags() { - let cli = try_parse_cli([ - "loongclaw", - "session-search-inspect", - "--artifact", - "/tmp/session-search.json", - "--json", - ]) - .expect("`session-search-inspect` should parse"); - - match cli.command { - Some(Commands::SessionSearchInspect { artifact, json }) => { - assert_eq!(artifact, "/tmp/session-search.json"); - assert!(json); - } - other => panic!("unexpected command parsed: {other:?}"), - } -} - -#[test] -fn format_session_search_text_includes_hit_summary() { - let rendered = format_session_search_text( + "runtime-trajectory", + "export", + "--config", "/tmp/loongclaw.toml", - Some("/tmp/session-search.json"), - &SessionSearchArtifactDocument { - schema: SessionSearchArtifactSchema { - version: SESSION_SEARCH_ARTIFACT_JSON_SCHEMA_VERSION, - surface: "session_search".to_owned(), - purpose: "session_recall_evidence".to_owned(), - }, - exported_at: "2026-04-05T00:00:00Z".to_owned(), - scope_session_id: "root-session".to_owned(), - query: "deploy freeze".to_owned(), - limit: 5, - include_archived: false, - include_turns: true, - include_events: true, - returned_count: 1, - matched_session_count: 1, - searched_session_count: 2, - results: vec![SessionSearchArtifactResult { - session_id: "child-session".to_owned(), - label: Some("Child".to_owned()), - session_state: "running".to_owned(), - archived: false, - source: "turn".to_owned(), - source_id: 12, - role: Some("assistant".to_owned()), - event_kind: None, - ts: 123, - snippet: "deploy freeze checklist updated".to_owned(), - score: 140, - }], - }, - ); - - assert!(rendered.contains("session_search session=root-session")); - assert!(rendered.contains("returned_count=1")); - assert!(rendered.contains("output=/tmp/session-search.json")); - assert!(rendered.contains("session=child-session")); - assert!(rendered.contains("source=turn")); - assert!(rendered.contains("role=assistant")); - assert!(rendered.contains("deploy freeze checklist updated")); -} - -#[test] -fn format_session_search_inspect_text_summarizes_first_hit() { - let rendered = format_session_search_inspect_text( - "/tmp/session-search.json", - &SessionSearchArtifactDocument { - schema: SessionSearchArtifactSchema { - version: SESSION_SEARCH_ARTIFACT_JSON_SCHEMA_VERSION, - surface: "session_search".to_owned(), - purpose: "session_recall_evidence".to_owned(), - }, - exported_at: "2026-04-05T00:00:00Z".to_owned(), - scope_session_id: "root-session".to_owned(), - query: "deploy freeze".to_owned(), - limit: 5, - include_archived: false, - include_turns: true, - include_events: true, - returned_count: 1, - matched_session_count: 1, - searched_session_count: 2, - results: vec![SessionSearchArtifactResult { - session_id: "child-session".to_owned(), - label: Some("Child".to_owned()), - session_state: "running".to_owned(), - archived: false, - source: "turn".to_owned(), - source_id: 12, - role: Some("assistant".to_owned()), - event_kind: None, - ts: 123, - snippet: "deploy freeze checklist updated".to_owned(), - score: 140, - }], - }, - ); - - assert!(rendered.contains("artifact=/tmp/session-search.json")); - assert!(rendered.contains("scope_session_id=root-session")); - assert!(rendered.contains("query=deploy freeze")); - assert!(rendered.contains("first_result_session_id=child-session")); - assert!(rendered.contains("first_result_source=turn")); - assert!(rendered.contains("first_result_role=assistant")); -} - -#[test] -fn trajectory_export_cli_parses_flags() { - let cli = try_parse_cli([ - "loongclaw", - "trajectory-export", "--session", "root-session", "--output", - "/tmp/trajectory.json", + "/tmp/runtime-trajectory.json", "--json", ]) - .expect("`trajectory-export` should parse"); + .expect("`runtime-trajectory export` should parse"); match cli.command { - Some(Commands::TrajectoryExport { - config, - session, - output, - json, + Some(Commands::RuntimeTrajectory { + command: + loongclaw_daemon::runtime_trajectory_cli::RuntimeTrajectoryCommands::Export(options), }) => { - assert!(config.is_none()); - assert_eq!(session.as_deref(), Some("root-session")); - assert_eq!(output.as_deref(), Some("/tmp/trajectory.json")); - assert!(json); + assert_eq!(options.config.as_deref(), Some("/tmp/loongclaw.toml")); + assert_eq!(options.session.as_deref(), Some("root-session")); + assert_eq!(options.turn_limit, None); + assert_eq!( + options.event_page_limit, + loongclaw_daemon::runtime_trajectory_cli::ARTIFACT_MODE_EVENT_PAGE_LIMIT_DEFAULT + ); + assert_eq!( + options.output.as_deref(), + Some("/tmp/runtime-trajectory.json") + ); + assert!(options.json); } other => panic!("unexpected command parsed: {other:?}"), } } #[test] -fn format_trajectory_export_text_summarizes_counts() { - let rendered = format_trajectory_export_text( - "/tmp/loongclaw.toml", - Some("/tmp/trajectory.json"), - &TrajectoryExportArtifactDocument { - schema: TrajectoryExportArtifactSchema { - version: TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION, - surface: "trajectory_export".to_owned(), - purpose: "session_replay_evidence".to_owned(), - }, - exported_at: "2026-04-04T00:00:00Z".to_owned(), - session: TrajectoryExportSessionSummary { - session_id: "root-session".to_owned(), - kind: "root".to_owned(), - parent_session_id: None, - label: Some("Root".to_owned()), - state: "completed".to_owned(), - created_at: 1, - updated_at: 2, - archived_at: None, - turn_count: 2, - last_turn_at: Some(2), - last_error: None, - }, - turns: vec![ - TrajectoryExportTurn { - role: "user".to_owned(), - content: "hello".to_owned(), - ts: 1, - }, - TrajectoryExportTurn { - role: "assistant".to_owned(), - content: "world".to_owned(), - ts: 2, - }, - ], - events: vec![TrajectoryExportEvent { - id: 7, - session_id: "root-session".to_owned(), - event_kind: "delegate_started".to_owned(), - actor_session_id: Some("root-session".to_owned()), - payload_json: json!({"mode": "async"}), - ts: 2, - }], - }, - ); - - assert!(rendered.contains("schema.version=1")); - assert!(rendered.contains("session_id=root-session")); - assert!(rendered.contains("turns=2")); - assert!(rendered.contains("events=1")); - assert!(rendered.contains("output=/tmp/trajectory.json")); -} - -#[test] -fn trajectory_inspect_cli_parses_flags() { +fn runtime_trajectory_cli_parses_show_flags() { let cli = try_parse_cli([ "loongclaw", - "trajectory-inspect", + "runtime-trajectory", + "show", "--artifact", - "/tmp/trajectory.json", + "/tmp/runtime-trajectory.json", "--json", ]) - .expect("`trajectory-inspect` should parse"); + .expect("`runtime-trajectory show` should parse"); match cli.command { - Some(Commands::TrajectoryInspect { artifact, json }) => { - assert_eq!(artifact, "/tmp/trajectory.json"); - assert!(json); + Some(Commands::RuntimeTrajectory { + command: + loongclaw_daemon::runtime_trajectory_cli::RuntimeTrajectoryCommands::Show(options), + }) => { + assert_eq!(options.artifact, "/tmp/runtime-trajectory.json"); + assert!(options.json); } other => panic!("unexpected command parsed: {other:?}"), } } -#[test] -fn format_trajectory_inspect_text_summarizes_counts() { - let rendered = format_trajectory_inspect_text( - "/tmp/trajectory.json", - &TrajectoryExportArtifactDocument { - schema: TrajectoryExportArtifactSchema { - version: TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION, - surface: "trajectory_export".to_owned(), - purpose: "session_replay_evidence".to_owned(), - }, - exported_at: "2026-04-04T00:00:00Z".to_owned(), - session: TrajectoryExportSessionSummary { - session_id: "root-session".to_owned(), - kind: "root".to_owned(), - parent_session_id: None, - label: Some("Root".to_owned()), - state: "completed".to_owned(), - created_at: 1, - updated_at: 2, - archived_at: None, - turn_count: 2, - last_turn_at: Some(2), - last_error: None, - }, - turns: vec![ - TrajectoryExportTurn { - role: "user".to_owned(), - content: "hello".to_owned(), - ts: 1, - }, - TrajectoryExportTurn { - role: "assistant".to_owned(), - content: "world".to_owned(), - ts: 2, - }, - ], - events: vec![TrajectoryExportEvent { - id: 7, - session_id: "root-session".to_owned(), - event_kind: "delegate_started".to_owned(), - actor_session_id: Some("root-session".to_owned()), - payload_json: json!({"mode": "async"}), - ts: 2, - }], - }, - ); - - assert!(rendered.contains("schema.version=1")); - assert!(rendered.contains("artifact=/tmp/trajectory.json")); - assert!(rendered.contains("session_id=root-session")); - assert!(rendered.contains("turns=2")); - assert!(rendered.contains("events=1")); - assert!(rendered.contains("first_turn_role=user")); - assert!(rendered.contains("last_turn_role=assistant")); - assert!(rendered.contains("latest_event_kind=delegate_started")); -} - -#[test] -fn format_trajectory_inspect_text_summarizes_roles_and_events() { - let rendered = format_trajectory_inspect_text( - "/tmp/trajectory.json", - &TrajectoryExportArtifactDocument { - schema: TrajectoryExportArtifactSchema { - version: TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION, - surface: "trajectory_export".to_owned(), - purpose: "session_replay_evidence".to_owned(), - }, - exported_at: "2026-04-04T00:00:00Z".to_owned(), - session: TrajectoryExportSessionSummary { - session_id: "root-session".to_owned(), - kind: "root".to_owned(), - parent_session_id: None, - label: Some("Root".to_owned()), - state: "completed".to_owned(), - created_at: 1, - updated_at: 2, - archived_at: None, - turn_count: 2, - last_turn_at: Some(2), - last_error: None, - }, - turns: vec![ - TrajectoryExportTurn { - role: "user".to_owned(), - content: "hello".to_owned(), - ts: 1, - }, - TrajectoryExportTurn { - role: "assistant".to_owned(), - content: "world".to_owned(), - ts: 2, - }, - ], - events: vec![TrajectoryExportEvent { - id: 7, - session_id: "root-session".to_owned(), - event_kind: "delegate_started".to_owned(), - actor_session_id: Some("root-session".to_owned()), - payload_json: json!({"mode": "async"}), - ts: 2, - }], - }, - ); - - assert!(rendered.contains("artifact=/tmp/trajectory.json")); - assert!(rendered.contains("first_turn_role=user")); - assert!(rendered.contains("last_turn_role=assistant")); - assert!(rendered.contains("latest_event_kind=delegate_started")); -} - #[test] fn onboard_cli_accepts_generic_api_key_flag() { let cli = try_parse_cli([ @@ -845,13 +345,13 @@ fn onboard_cli_accepts_personality_flag() { "--non-interactive", "--accept-risk", "--personality", - "hermit", + "friendly_collab", ]) .expect("`--personality` should parse"); match cli.command { Some(Commands::Onboard { personality, .. }) => { - assert_eq!(personality.as_deref(), Some("hermit")); + assert_eq!(personality.as_deref(), Some("friendly_collab")); } other => panic!("unexpected command parsed: {other:?}"), } @@ -1330,7 +830,7 @@ fn runtime_experiment_cli_rejects_compare_recorded_snapshots_with_manual_paths() } #[test] -fn runtime_capability_cli_parses_propose_review_show_index_and_plan() { +fn runtime_capability_cli_parses_propose_review_show_index_plan_apply_activate_and_rollback() { let propose = try_parse_cli([ "loongclaw", "runtime-capability", @@ -1398,7 +898,9 @@ fn runtime_capability_cli_parses_propose_review_show_index_and_plan() { | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Show(_) | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Index(_) | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Plan(_) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_)) => { + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Activate(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Rollback(_)) => { panic!("unexpected runtime-capability subcommand parsed: {other:?}") } }, @@ -1447,7 +949,9 @@ fn runtime_capability_cli_parses_propose_review_show_index_and_plan() { | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Show(_) | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Index(_) | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Plan(_) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_)) => { + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Activate(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Rollback(_)) => { panic!("unexpected runtime-capability subcommand parsed: {other:?}") } }, @@ -1478,7 +982,9 @@ fn runtime_capability_cli_parses_propose_review_show_index_and_plan() { ) | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Index(_) | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Plan(_) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_)) => { + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Activate(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Rollback(_)) => { panic!("unexpected runtime-capability subcommand parsed: {other:?}") } }, @@ -1507,7 +1013,9 @@ fn runtime_capability_cli_parses_propose_review_show_index_and_plan() { | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Review(_) | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Show(_) | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Plan(_) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_)) => { + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Activate(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Rollback(_)) => { panic!("unexpected runtime-capability subcommand parsed: {other:?}") } }, @@ -1539,7 +1047,9 @@ fn runtime_capability_cli_parses_propose_review_show_index_and_plan() { | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Review(_) | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Show(_) | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Index(_) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_)) => { + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Activate(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Rollback(_)) => { panic!("unexpected runtime-capability subcommand parsed: {other:?}") } }, @@ -1560,9 +1070,7 @@ fn runtime_capability_cli_parses_propose_review_show_index_and_plan() { match apply.command { Some(Commands::RuntimeCapability { command }) => match command { - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply( - options, - ) => { + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(options) => { assert_eq!(options.root, "/tmp/runtime-capability"); assert_eq!(options.family_id, "family-123"); assert!(options.json); @@ -1573,114 +1081,87 @@ fn runtime_capability_cli_parses_propose_review_show_index_and_plan() { | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Review(_) | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Show(_) | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Index(_) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Plan(_)) => { + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Plan(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Activate(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Rollback(_)) => { panic!("unexpected runtime-capability subcommand parsed: {other:?}") } }, other => panic!("unexpected command parsed: {other:?}"), } -} -#[test] -fn runtime_capability_cli_parses_memory_stage_profile_target() { - let propose = try_parse_cli([ + let activate = try_parse_cli([ "loongclaw", "runtime-capability", - "propose", - "--run", - "/tmp/runtime-experiment.json", - "--output", - "/tmp/runtime-capability.json", - "--target", - "memory_stage_profile", - "--target-summary", - "Promote governed memory pipeline intent into a reusable profile", - "--bounded-scope", - "Governed memory pipeline promotion intent only", - "--required-capability", - "memory_read", - "--tag", - "memory", - "--tag", - "pipeline", + "activate", + "--config", + "/tmp/loongclaw.toml", + "--artifact", + "/tmp/runtime-capability-apply.json", + "--apply", + "--replace", + "--json", ]) - .expect("`runtime-capability propose --target memory_stage_profile` should parse"); + .expect("`runtime-capability activate` should parse"); - match propose.command { + match activate.command { Some(Commands::RuntimeCapability { command }) => match command { - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Propose( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Activate( options, ) => { - assert_eq!(options.run, "/tmp/runtime-experiment.json"); - assert_eq!(options.output, "/tmp/runtime-capability.json"); - assert_eq!( - options.target, - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile - ); - assert!(options.target_summary.contains("governed memory pipeline")); - assert_eq!( - options.bounded_scope, - "Governed memory pipeline promotion intent only" - ); - assert_eq!(options.required_capability, vec!["memory_read".to_owned()]); - assert_eq!(options.tag, vec!["memory".to_owned(), "pipeline".to_owned()]); + assert_eq!(options.config.as_deref(), Some("/tmp/loongclaw.toml")); + assert_eq!(options.artifact, "/tmp/runtime-capability-apply.json"); + assert!(options.apply); + assert!(options.replace); + assert!(options.json); } - other @ (loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Review( - _, - ) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Show(_) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Index(_) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Plan(_) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_)) => { + other @ ( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Propose(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Review(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Show(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Index(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Plan(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Rollback(_) + ) => { panic!("unexpected runtime-capability subcommand parsed: {other:?}") } }, other => panic!("unexpected command parsed: {other:?}"), } -} -#[test] -fn runtime_capability_cli_parses_memory_stage_profile_canonical_spelling() { - let propose = try_parse_cli([ + let rollback = try_parse_cli([ "loongclaw", "runtime-capability", - "propose", - "--run", - "/tmp/runtime-experiment.json", - "--output", - "/tmp/runtime-capability.json", - "--target", - "memory-stage-profile", - "--target-summary", - "Promote governed memory pipeline intent into a reusable profile", - "--bounded-scope", - "Governed memory pipeline promotion intent only", - "--required-capability", - "memory_read", - "--tag", - "memory", - "--tag", - "pipeline", + "rollback", + "--config", + "/tmp/loongclaw.toml", + "--record", + "/tmp/runtime-capability-activation.json", + "--apply", + "--json", ]) - .expect("`runtime-capability propose --target memory-stage-profile` should parse"); + .expect("`runtime-capability rollback` should parse"); - match propose.command { + match rollback.command { Some(Commands::RuntimeCapability { command }) => match command { - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Propose( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Rollback( options, ) => { - assert_eq!( - options.target, - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile - ); + assert_eq!(options.config.as_deref(), Some("/tmp/loongclaw.toml")); + assert_eq!(options.record, "/tmp/runtime-capability-activation.json"); + assert!(options.apply); + assert!(options.json); } - other @ (loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Review( - _, - ) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Show(_) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Index(_) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Plan(_) - | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_)) => { + other @ ( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Propose(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Review(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Show(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Index(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Plan(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Apply(_) + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityCommands::Activate(_) + ) => { panic!("unexpected runtime-capability subcommand parsed: {other:?}") } }, @@ -1695,76 +1176,6 @@ fn acp_event_summary_cli_rejects_zero_limit() { assert!(error.contains(">= 1")); } -#[test] -fn runtime_trajectory_cli_parses_flags() { - let cli = try_parse_cli([ - "loongclaw", - "runtime-trajectory", - "export", - "--session", - "root-session", - "--output", - "/tmp/runtime-trajectory.json", - "--turn-limit", - "25", - "--event-page-limit", - "50", - "--json", - ]) - .expect("runtime-trajectory flags should parse"); - - match cli.command { - Some(Commands::RuntimeTrajectory { command }) => match command { - loongclaw_daemon::runtime_trajectory_cli::RuntimeTrajectoryCommands::Export( - options, - ) => { - assert_eq!(options.session.as_deref(), Some("root-session")); - assert_eq!( - options.output.as_deref(), - Some("/tmp/runtime-trajectory.json") - ); - assert_eq!(options.turn_limit, Some(25)); - assert_eq!(options.event_page_limit, 50); - assert!(options.json); - } - other => panic!("unexpected runtime-trajectory subcommand parsed: {other:?}"), - }, - other => panic!("unexpected command parsed: {other:?}"), - } -} - -#[test] -fn runtime_trajectory_cli_parses_artifact_show_mode() { - let cli = try_parse_cli([ - "loongclaw", - "runtime-trajectory", - "show", - "--artifact", - "/tmp/runtime-trajectory.json", - "--json", - ]) - .expect("runtime-trajectory artifact mode should parse"); - - match cli.command { - Some(Commands::RuntimeTrajectory { command }) => match command { - loongclaw_daemon::runtime_trajectory_cli::RuntimeTrajectoryCommands::Show(options) => { - assert_eq!(options.artifact, "/tmp/runtime-trajectory.json"); - assert!(options.json); - } - other => panic!("unexpected runtime-trajectory subcommand parsed: {other:?}"), - }, - other => panic!("unexpected command parsed: {other:?}"), - } -} - -#[test] -fn runtime_trajectory_help_mentions_export_and_show_subcommands() { - let help = render_cli_help(["runtime-trajectory"]); - - assert!(help.contains("export")); - assert!(help.contains("show")); -} - #[test] fn build_acp_dispatch_address_requires_channel_for_structured_scope() { let error = build_acp_dispatch_address("opaque-session", None, Some("oc_123"), None, None) @@ -1891,19 +1302,6 @@ fn chat_cli_accepts_acp_runtime_option_flags() { } } -#[test] -fn chat_cli_accepts_latest_session_selector() { - let cli = try_parse_cli(["loongclaw", "chat", "--session", "latest"]) - .expect("chat CLI should accept the latest session selector"); - - match cli.command { - Some(Commands::Chat { session, .. }) => { - assert_eq!(session.as_deref(), Some("latest")); - } - other => panic!("unexpected command parse result: {other:?}"), - } -} - #[test] fn feishu_send_cli_accepts_generic_target_and_target_kind() { let cli = try_parse_cli([ @@ -2865,97 +2263,6 @@ fn multi_channel_serve_cli_help_mentions_session_and_channel_account_flags() { ); } -#[test] -fn gateway_run_cli_accepts_optional_session_and_channel_account_flags() { - let cli = try_parse_cli([ - "loongclaw", - "gateway", - "run", - "--session", - "cli-gateway", - "--channel-account", - "telegram=bot_123456", - "--channel-account", - "matrix=bridge-sync", - ]) - .expect("gateway run should parse"); - - match cli.command { - Some(Commands::Gateway { command }) => match command { - loongclaw_daemon::gateway::service::GatewayCommand::Run { - session, - channel_account, - .. - } => { - assert_eq!(session.as_deref(), Some("cli-gateway")); - assert_eq!(channel_account.len(), 2); - assert_eq!(channel_account[0].channel_id, "telegram"); - assert_eq!(channel_account[0].account_id, "bot_123456"); - assert_eq!(channel_account[1].channel_id, "matrix"); - assert_eq!(channel_account[1].account_id, "bridge-sync"); - } - other @ loongclaw_daemon::gateway::service::GatewayCommand::Status { .. } - | other @ loongclaw_daemon::gateway::service::GatewayCommand::Stop => { - panic!("unexpected gateway subcommand: {other:?}") - } - }, - other => panic!("unexpected parse result: {other:?}"), - } -} - -#[test] -fn gateway_run_cli_allows_headless_mode_without_session() { - let cli = try_parse_cli(["loongclaw", "gateway", "run"]) - .expect("gateway run should allow headless mode"); - - match cli.command { - Some(Commands::Gateway { command }) => match command { - loongclaw_daemon::gateway::service::GatewayCommand::Run { session, .. } => { - assert_eq!(session, None); - } - other @ loongclaw_daemon::gateway::service::GatewayCommand::Status { .. } - | other @ loongclaw_daemon::gateway::service::GatewayCommand::Stop => { - panic!("unexpected gateway subcommand: {other:?}") - } - }, - other => panic!("unexpected parse result: {other:?}"), - } -} - -#[test] -fn gateway_status_cli_parses_json_flag() { - let cli = try_parse_cli(["loongclaw", "gateway", "status", "--json"]) - .expect("gateway status should parse"); - - match cli.command { - Some(Commands::Gateway { command }) => match command { - loongclaw_daemon::gateway::service::GatewayCommand::Status { json } => { - assert!(json); - } - other @ loongclaw_daemon::gateway::service::GatewayCommand::Run { .. } - | other @ loongclaw_daemon::gateway::service::GatewayCommand::Stop => { - panic!("unexpected gateway subcommand: {other:?}") - } - }, - other => panic!("unexpected parse result: {other:?}"), - } -} - -#[test] -fn gateway_cli_help_mentions_run_status_stop_and_optional_session() { - let help = render_cli_help(["gateway"]); - let run_help = render_cli_help(["gateway", "run"]); - - assert!(help.contains("run"), "help: {help}"); - assert!(help.contains("status"), "help: {help}"); - assert!(help.contains("stop"), "help: {help}"); - assert!(run_help.contains("--session "), "help: {run_help}"); - assert!( - run_help.contains("--channel-account "), - "help: {run_help}" - ); -} - #[test] fn default_channel_send_target_kind_uses_command_family_send_metadata() { assert_eq!( diff --git a/crates/daemon/tests/integration/runtime_capability_cli.rs b/crates/daemon/tests/integration/runtime_capability_cli.rs index 4acd5d9e4..016697d34 100644 --- a/crates/daemon/tests/integration/runtime_capability_cli.rs +++ b/crates/daemon/tests/integration/runtime_capability_cli.rs @@ -7,10 +7,8 @@ use super::*; use serde_json::Value; use std::{ - ffi::OsString, fs, path::{Path, PathBuf}, - sync::MutexGuard, time::{SystemTime, UNIX_EPOCH}, }; @@ -40,57 +38,6 @@ fn artifact_path_suffix(path: &Path) -> String { ordered_suffix_parts.join("/") } -struct RuntimeCapabilityEnvironmentGuard { - _lock: MutexGuard<'static, ()>, - saved: Vec<(String, Option)>, -} - -impl RuntimeCapabilityEnvironmentGuard { - fn set(root: &Path) -> Self { - let lock = super::lock_daemon_test_environment(); - let home = root.join("home"); - let loongclaw_home = home.join(mvp::config::HOME_DIR_NAME); - fs::create_dir_all(&loongclaw_home).expect("create isolated loongclaw home"); - let home_text = home.to_string_lossy().into_owned(); - let loongclaw_home_text = loongclaw_home.to_string_lossy().into_owned(); - - let pairs = [ - ("HOME", Some(home_text.as_str())), - ("LOONG_HOME", Some(loongclaw_home_text.as_str())), - ("LOONGCLAW_BROWSER_COMPANION_READY", None), - ]; - let mut saved = Vec::new(); - for (key, value) in pairs { - saved.push((key.to_owned(), std::env::var_os(key))); - match value { - Some(value) => unsafe { - std::env::set_var(key, value); - }, - None => unsafe { - std::env::remove_var(key); - }, - } - } - - Self { _lock: lock, saved } - } -} - -impl Drop for RuntimeCapabilityEnvironmentGuard { - fn drop(&mut self) { - for (key, value) in self.saved.drain(..).rev() { - match value { - Some(value) => unsafe { - std::env::set_var(&key, value); - }, - None => unsafe { - std::env::remove_var(&key); - }, - } - } - } -} - fn write_runtime_capability_config(root: &Path) -> PathBuf { fs::create_dir_all(root).expect("create fixture root"); @@ -209,28 +156,6 @@ fn rewrite_runtime_capability_compare_config(config_path: &Path) { .expect("rewrite config fixture"); } -fn rewrite_runtime_capability_compare_config_for_memory_stage_profile(config_path: &Path) { - let (_, mut config) = mvp::config::load(Some( - config_path - .to_str() - .expect("config path should be valid utf-8"), - )) - .expect("load config fixture"); - config.memory.profile = mvp::config::MemoryProfile::WindowPlusSummary; - config.conversation.compact_min_messages = Some(8); - config.conversation.compact_trigger_estimated_tokens = Some(512); - mvp::config::write( - Some( - config_path - .to_str() - .expect("config path should be valid utf-8"), - ), - &config, - true, - ) - .expect("rewrite config fixture"); -} - fn start_runtime_experiment( root: &Path, snapshot_path: &Path, @@ -285,7 +210,6 @@ fn finish_runtime_experiment( PathBuf, loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentArtifactDocument, ) { - let _env_guard = RuntimeCapabilityEnvironmentGuard::set(root); let (baseline_snapshot_path, baseline_snapshot_payload) = write_snapshot_artifact( root, config_path, @@ -337,7 +261,6 @@ fn finish_runtime_experiment_with_compare_delta( PathBuf, loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentArtifactDocument, ) { - let _env_guard = RuntimeCapabilityEnvironmentGuard::set(root); let (baseline_snapshot_path, baseline_snapshot_payload) = write_snapshot_artifact( root, config_path, @@ -451,67 +374,6 @@ fn finish_runtime_experiment_variant_with_compare_delta( (run_path, finished) } -fn finish_runtime_experiment_variant_with_memory_compare_delta( - root: &Path, - slug: &str, - cost_delta: f64, - warnings: &[&str], - decision: loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision, -) -> ( - PathBuf, - loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentArtifactDocument, -) { - let _env_guard = RuntimeCapabilityEnvironmentGuard::set(root); - let config_path = write_runtime_capability_config(root); - let (baseline_snapshot_path, baseline_snapshot_payload) = write_snapshot_artifact( - root, - &config_path, - &format!("artifacts/runtime-snapshot-{slug}.json"), - loongclaw_daemon::RuntimeSnapshotArtifactMetadata { - created_at: "2026-03-17T12:00:00Z".to_owned(), - label: Some(format!("baseline-{slug}")), - experiment_id: Some("exp-42".to_owned()), - parent_snapshot_id: Some("snapshot-parent".to_owned()), - }, - ); - let (run_path, _) = start_runtime_experiment_variant(root, &baseline_snapshot_path, slug); - - rewrite_runtime_capability_compare_config_for_memory_stage_profile(&config_path); - - let baseline_snapshot_id = snapshot_id_from_payload(&baseline_snapshot_payload); - let (result_snapshot_path, _) = write_snapshot_artifact( - root, - &config_path, - &format!("artifacts/runtime-snapshot-result-{slug}.json"), - loongclaw_daemon::RuntimeSnapshotArtifactMetadata { - created_at: "2026-03-17T12:30:00Z".to_owned(), - label: Some(format!("candidate-{slug}")), - experiment_id: Some("exp-42".to_owned()), - parent_snapshot_id: Some(baseline_snapshot_id), - }, - ); - - let finished = - loongclaw_daemon::runtime_experiment_cli::execute_runtime_experiment_finish_command( - loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentFinishCommandOptions { - run: run_path.display().to_string(), - result_snapshot: result_snapshot_path.display().to_string(), - evaluation_summary: format!("memory and context policy updated ({slug})"), - metric: vec![ - "task_success=1".to_owned(), - format!("cost_delta={cost_delta}"), - ], - warning: warnings.iter().map(|warning| (*warning).to_owned()).collect(), - decision, - status: - loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentFinishStatus::Completed, - json: false, - }, - ) - .expect("runtime experiment finish should succeed"); - (run_path, finished) -} - fn finish_runtime_experiment_variant( root: &Path, config_path: &Path, @@ -523,7 +385,6 @@ fn finish_runtime_experiment_variant( PathBuf, loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentArtifactDocument, ) { - let _env_guard = RuntimeCapabilityEnvironmentGuard::set(root); let (baseline_snapshot_path, baseline_snapshot_payload) = write_snapshot_artifact( root, config_path, @@ -811,46 +672,6 @@ fn runtime_capability_propose_persists_candidate_from_finished_run() { fs::remove_dir_all(&root).ok(); } -#[test] -fn runtime_capability_propose_roundtrips_memory_stage_profile_target() { - let root = unique_temp_dir("loongclaw-runtime-capability-propose-memory-stage-profile"); - let config_path = write_runtime_capability_config(&root); - let (run_path, _run) = finish_runtime_experiment(&root, &config_path); - let candidate_path = root.join("artifacts/runtime-capability-memory-stage-profile.json"); - - loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_propose_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityProposeCommandOptions { - run: run_path.display().to_string(), - output: candidate_path.display().to_string(), - target: - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - target_summary: "Promote governed memory pipeline intent into a reusable profile" - .to_owned(), - bounded_scope: "Governed memory pipeline promotion intent only".to_owned(), - required_capability: vec!["memory_read".to_owned()], - tag: vec!["memory".to_owned(), "pipeline".to_owned()], - label: Some("memory-stage-profile-candidate".to_owned()), - json: false, - }, - ) - .expect("runtime capability propose should succeed"); - - let shown = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_show_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityShowCommandOptions { - candidate: candidate_path.display().to_string(), - json: false, - }, - ) - .expect("memory_stage_profile artifacts should round-trip through show"); - let payload = serde_json::to_value(&shown).expect("serialize runtime capability artifact"); - assert_eq!( - payload.pointer("/proposal/target").and_then(Value::as_str), - Some("memory_stage_profile") - ); - - fs::remove_dir_all(&root).ok(); -} - #[test] fn runtime_capability_propose_persists_snapshot_delta_when_recorded_snapshots_exist() { let root = unique_temp_dir("loongclaw-runtime-capability-propose-snapshot-delta"); @@ -1735,174 +1556,6 @@ fn runtime_capability_index_marks_family_blocked_on_conflicting_reviews() { fs::remove_dir_all(&root).ok(); } -#[test] -fn runtime_capability_index_marks_memory_stage_profile_not_ready_without_memory_delta_evidence() { - let root = unique_temp_dir("loongclaw-runtime-capability-index-memory-stage-profile-not-ready"); - let config_path = write_runtime_capability_config(&root); - let (run_a_path, _) = finish_runtime_experiment_variant( - &root, - &config_path, - "memory-stage-profile-a", - -0.2, - &[], - loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, - ); - let (run_b_path, _) = finish_runtime_experiment_variant( - &root, - &config_path, - "memory-stage-profile-b", - -0.4, - &[], - loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, - ); - - let candidate_a_path = root.join("artifacts/runtime-capability-memory-stage-profile-a.json"); - let candidate_b_path = root.join("artifacts/runtime-capability-memory-stage-profile-b.json"); - propose_runtime_capability_variant_with_target( - &root, - &run_a_path, - "memory-stage-profile-a", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", - &["memory_read"], - &["memory", "pipeline"], - ); - propose_runtime_capability_variant_with_target( - &root, - &run_b_path, - "memory-stage-profile-b", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", - &["memory_read"], - &["memory", "pipeline"], - ); - review_runtime_capability_variant( - &candidate_a_path, - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-a", - ); - review_runtime_capability_variant( - &candidate_b_path, - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-b", - ); - - let report = - loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_index_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityIndexCommandOptions { - root: root.join("artifacts").display().to_string(), - json: false, - }, - ) - .expect("runtime capability index should succeed"); - - let family = report - .families - .first() - .expect("one capability family should be reported"); - assert_eq!( - family.readiness.status, - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityFamilyReadinessStatus::NotReady - ); - assert!( - family.readiness.checks.iter().any(|check| { - check.dimension == "memory_delta_evidence" - && check.status - == loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityFamilyReadinessCheckStatus::NeedsEvidence - }), - "memory-stage-profile families should require memory/context delta evidence" - ); - - fs::remove_dir_all(&root).ok(); -} - -#[test] -fn runtime_capability_index_uses_accepted_memory_delta_evidence_only() { - let root = - unique_temp_dir("loongclaw-runtime-capability-index-memory-stage-profile-accepted-only"); - let config_path = write_runtime_capability_config(&root); - let (run_a_path, _) = finish_runtime_experiment_variant( - &root, - &config_path, - "memory-stage-profile-accepted", - -0.2, - &[], - loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, - ); - let (run_b_path, _) = finish_runtime_experiment_variant_with_memory_compare_delta( - &root, - "memory-stage-profile-rejected", - -0.4, - &[], - loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, - ); - - let candidate_a_path = - root.join("artifacts/runtime-capability-memory-stage-profile-accepted.json"); - let candidate_b_path = - root.join("artifacts/runtime-capability-memory-stage-profile-rejected.json"); - propose_runtime_capability_variant_with_target( - &root, - &run_a_path, - "memory-stage-profile-accepted", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", - &["memory_read"], - &["memory", "pipeline"], - ); - propose_runtime_capability_variant_with_target( - &root, - &run_b_path, - "memory-stage-profile-rejected", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", - &["memory_read"], - &["memory", "pipeline"], - ); - review_runtime_capability_variant( - &candidate_a_path, - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-accepted", - ); - review_runtime_capability_variant( - &candidate_b_path, - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Rejected, - "memory-stage-profile-rejected", - ); - - let report = - loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_index_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityIndexCommandOptions { - root: root.join("artifacts").display().to_string(), - json: false, - }, - ) - .expect("runtime capability index should succeed"); - - let family = report - .families - .first() - .expect("one capability family should be reported"); - assert_eq!( - family.readiness.status, - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityFamilyReadinessStatus::Blocked - ); - assert!( - family.readiness.checks.iter().any(|check| { - check.dimension == "memory_delta_evidence" - && check.status - == loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityFamilyReadinessCheckStatus::NeedsEvidence - }), - "memory delta readiness should ignore rejected-only delta evidence" - ); - - fs::remove_dir_all(&root).ok(); -} - #[test] fn runtime_capability_index_rejects_malformed_supported_artifact_during_scan() { let root = unique_temp_dir("loongclaw-runtime-capability-index-malformed"); @@ -2111,6 +1764,40 @@ fn runtime_capability_plan_builds_promotable_managed_skill_plan() { .ends_with(&family.family_id[..12]), "artifact id should be family-derived" ); + assert_eq!(plan.planned_payload.artifact_kind, "managed_skill_bundle"); + assert_eq!( + plan.planned_payload.target, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill + ); + assert_eq!( + plan.planned_payload.draft_id, + plan.planned_artifact.artifact_id + ); + assert_eq!( + plan.planned_payload.provenance.accepted_candidate_ids.len(), + 2 + ); + match &plan.planned_payload.payload { + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ManagedSkillBundle { + files, + } => { + let skill_markdown = files.get("SKILL.md").expect("SKILL.md should exist"); + assert!( + skill_markdown.contains( + "Codify browser preview onboarding as a reusable managed skill" + ) + ); + assert!( + skill_markdown.contains( + "Browser preview onboarding and companion readiness checks only" + ) + ); + } + other @ ( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ProgrammaticFlowSpec { .. } + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ProfileNoteAddendum { .. } + ) => panic!("unexpected managed skill payload: {other:?}"), + } assert!( plan.blockers.is_empty(), "ready family should have no blockers" @@ -2366,6 +2053,28 @@ fn runtime_capability_plan_reports_missing_evidence_for_programmatic_flow_family "programmatic_flow_spec" ); assert_eq!(plan.planned_artifact.delivery_surface, "programmatic_flows"); + assert_eq!( + plan.planned_payload.target, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProgrammaticFlow + ); + assert_eq!(plan.planned_payload.artifact_kind, "programmatic_flow_spec"); + match &plan.planned_payload.payload { + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ProgrammaticFlowSpec { + files, + } => { + let flow_json = files.get("flow.json").expect("flow.json should exist"); + assert!( + flow_json.contains( + "\"summary\": \"Codify runtime compare summarization as a reusable flow\"" + ) + ); + assert!(flow_json.contains("\"steps\": []")); + } + other @ ( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ManagedSkillBundle { .. } + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ProfileNoteAddendum { .. } + ) => panic!("unexpected programmatic flow payload: {other:?}"), + } assert!( plan.blockers.iter().any(|blocker| { blocker.dimension == "stability" @@ -2474,6 +2183,25 @@ fn runtime_capability_plan_reports_blocked_profile_note_family() { ); assert_eq!(plan.planned_artifact.artifact_kind, "profile_note_addendum"); assert_eq!(plan.planned_artifact.delivery_surface, "profile_note"); + assert_eq!( + plan.planned_payload.target, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProfileNoteAddendum + ); + assert_eq!(plan.planned_payload.artifact_kind, "profile_note_addendum"); + match &plan.planned_payload.payload { + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ProfileNoteAddendum { + content, + } => { + assert!( + content.contains("Record browser preview operator guidance in profile memory") + ); + assert!(content.contains("Browser preview operator guidance only")); + } + other @ ( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ManagedSkillBundle { .. } + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ProgrammaticFlowSpec { .. } + ) => panic!("unexpected profile note payload: {other:?}"), + } assert!( plan.blockers.iter().any(|blocker| { blocker.dimension == "review_consensus" @@ -2499,58 +2227,58 @@ fn runtime_capability_plan_reports_blocked_profile_note_family() { } #[test] -fn runtime_capability_plan_uses_memory_stage_profile_dry_run_artifact_surface() { - let root = unique_temp_dir("loongclaw-runtime-capability-plan-memory-stage-profile"); - let config_path = write_runtime_capability_config(&root); - - let (run_a_path, _) = finish_runtime_experiment_variant( +fn runtime_capability_plan_provenance_candidate_ids_follow_family_order() { + let root = unique_temp_dir("loongclaw-runtime-capability-plan-provenance-order"); + let config_path = write_runtime_capability_config(&root); + let (run_z_path, _) = finish_runtime_experiment_variant( &root, &config_path, - "memory-stage-profile-a", + "z-run", -0.2, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - let (run_b_path, _) = finish_runtime_experiment_variant( + let (run_a_path, _) = finish_runtime_experiment_variant( &root, &config_path, - "memory-stage-profile-b", + "a-run", -0.4, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - - let candidate_a_path = root.join("artifacts/runtime-capability-memory-stage-profile-a.json"); - let candidate_b_path = root.join("artifacts/runtime-capability-memory-stage-profile-b.json"); - propose_runtime_capability_variant_with_target( + let candidate_z_path = root.join("artifacts/runtime-capability-zzz-first.json"); + let candidate_a_path = root.join("artifacts/runtime-capability-aaa-second.json"); + let candidate_z = propose_runtime_capability_variant_with_target( &root, - &run_a_path, - "memory-stage-profile-a", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", - &["memory_read"], - &["memory", "pipeline"], + &run_z_path, + "zzz-first", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill, + "Codify browser preview onboarding as a reusable managed skill", + "Browser preview onboarding and companion readiness checks only", + &["invoke_tool", "memory_read"], + &["browser", "onboarding"], ); - propose_runtime_capability_variant_with_target( + let candidate_a = propose_runtime_capability_variant_with_target( &root, - &run_b_path, - "memory-stage-profile-b", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", - &["memory_read"], - &["memory", "pipeline"], + &run_a_path, + "aaa-second", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill, + "Codify browser preview onboarding as a reusable managed skill", + "Browser preview onboarding and companion readiness checks only", + &["invoke_tool", "memory_read"], + &["browser", "onboarding"], ); + rewrite_runtime_capability_created_at(&candidate_z_path, "2026-03-18T08:00:00Z"); + rewrite_runtime_capability_created_at(&candidate_a_path, "2026-03-18T08:00:01Z"); review_runtime_capability_variant( - &candidate_a_path, + &candidate_z_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-a", + "zzz-first", ); review_runtime_capability_variant( - &candidate_b_path, + &candidate_a_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-b", + "aaa-second", ); let index_report = @@ -2565,7 +2293,6 @@ fn runtime_capability_plan_uses_memory_stage_profile_dry_run_artifact_surface() .families .first() .expect("one capability family should be reported"); - let plan = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_plan_command( loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityPlanCommandOptions { root: root.join("artifacts").display().to_string(), @@ -2574,109 +2301,97 @@ fn runtime_capability_plan_uses_memory_stage_profile_dry_run_artifact_surface() }, ) .expect("runtime capability plan should succeed"); - let payload = serde_json::to_value(&plan).expect("serialize runtime capability plan"); assert_eq!( - payload - .pointer("/planned_artifact/target_kind") - .and_then(Value::as_str), - Some("memory_stage_profile") + family.candidate_ids, + vec![candidate_z.candidate_id, candidate_a.candidate_id], + "family summary should use semantic candidate order" ); - assert_eq!(plan.planned_artifact.artifact_kind, "memory_stage_profile"); assert_eq!( - plan.planned_artifact.delivery_surface, - "memory_stage_profiles" - ); - assert!( - plan.planned_artifact - .artifact_id - .starts_with("memory-stage-profile-"), - "artifact id should carry the new memory-stage-profile prefix" - ); - assert!( - plan.approval_checklist - .iter() - .any(|item| item.contains("memory stage profile")), - "checklist should include the target-specific memory stage profile review item" + plan.provenance.candidate_ids, family.candidate_ids, + "planner provenance should preserve the family candidate order" ); + + fs::remove_dir_all(&root).ok(); +} + +#[test] +fn runtime_capability_plan_rejects_unknown_family_id() { + let root = unique_temp_dir("loongclaw-runtime-capability-plan-missing-family"); + let config_path = write_runtime_capability_config(&root); + let (run_path, _) = finish_runtime_experiment(&root, &config_path); + propose_runtime_capability_variant(&root, &run_path, "missing"); + + let error = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_plan_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityPlanCommandOptions { + root: root.join("artifacts").display().to_string(), + family_id: "missing-family".to_owned(), + json: false, + }, + ) + .expect_err("unknown family id should be rejected"); + assert!( - plan.rollback_hints - .iter() - .any(|hint| hint.contains("memory_stage_profiles")), - "rollback hints should mention the memory stage profile delivery surface" + error.contains("missing-family"), + "error should name the requested family id: {error}" ); fs::remove_dir_all(&root).ok(); } #[test] -fn runtime_capability_plan_scopes_memory_stage_profile_payload_provenance_to_accepted_evidence() { - let root = unique_temp_dir("loongclaw-runtime-capability-plan-memory-stage-profile-provenance"); - write_runtime_capability_config(&root); +fn runtime_capability_apply_materializes_managed_skill_artifact_and_is_idempotent() { + let root = unique_temp_dir("loongclaw-runtime-capability-apply-managed-skill"); + let config_path = write_runtime_capability_config(&root); - let (run_a_path, _) = finish_runtime_experiment_variant_with_memory_compare_delta( + let (run_a_path, _) = finish_runtime_experiment_variant( &root, - "memory-stage-profile-a", + &config_path, + "apply-managed-a", -0.2, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - let (run_b_path, _) = finish_runtime_experiment_variant_with_memory_compare_delta( + let (run_b_path, _) = finish_runtime_experiment_variant( &root, - "memory-stage-profile-b", + &config_path, + "apply-managed-b", -0.4, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - let candidate_a_path = root.join("artifacts/runtime-capability-memory-stage-profile-a.json"); - let candidate_b_path = root.join("artifacts/runtime-capability-memory-stage-profile-b.json"); - let candidate_a = propose_runtime_capability_variant_with_target( + let candidate_a_path = root.join("artifacts/runtime-capability-apply-managed-a.json"); + let candidate_b_path = root.join("artifacts/runtime-capability-apply-managed-b.json"); + propose_runtime_capability_variant_with_target( &root, &run_a_path, - "memory-stage-profile-a", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", - &["memory_read"], - &["memory", "pipeline"], + "apply-managed-a", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill, + "Codify browser preview onboarding as a reusable managed skill", + "Browser preview onboarding and companion readiness checks only", + &["invoke_tool", "memory_read"], + &["browser", "onboarding"], ); - let _candidate_b = propose_runtime_capability_variant_with_target( + propose_runtime_capability_variant_with_target( &root, &run_b_path, - "memory-stage-profile-b", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", - &["memory_read"], - &["memory", "pipeline"], + "apply-managed-b", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill, + "Codify browser preview onboarding as a reusable managed skill", + "Browser preview onboarding and companion readiness checks only", + &["invoke_tool", "memory_read"], + &["browser", "onboarding"], ); - rewrite_json_file(&candidate_b_path, |payload| { - let changed_surface_count = payload - .pointer("/source_run/snapshot_delta/changed_surface_count") - .and_then(Value::as_u64) - .expect( - "candidate fixture should include source_run.snapshot_delta.changed_surface_count", - ); - *payload - .pointer_mut("/source_run/snapshot_delta/changed_surface_count") - .expect( - "candidate fixture should include source_run.snapshot_delta.changed_surface_count", - ) = Value::from(changed_surface_count + 1); - let acp_policy_after = payload - .pointer_mut("/source_run/snapshot_delta/acp_policy/after") - .expect("candidate fixture should include source_run.snapshot_delta.acp_policy.after"); - *acp_policy_after = Value::String("rejected-only-policy".to_owned()); - }); review_runtime_capability_variant( &candidate_a_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-a", + "apply-managed-a", ); review_runtime_capability_variant( &candidate_b_path, - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Rejected, - "memory-stage-profile-b", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, + "apply-managed-b", ); let index_report = @@ -2692,145 +2407,92 @@ fn runtime_capability_plan_scopes_memory_stage_profile_payload_provenance_to_acc .first() .expect("one capability family should be reported"); - let plan = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_plan_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityPlanCommandOptions { + let apply_options = + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { root: root.join("artifacts").display().to_string(), family_id: family.family_id.clone(), json: false, - }, - ) - .expect("runtime capability plan should succeed"); - let payload = serde_json::to_value(&plan).expect("serialize runtime capability plan"); - let planned_payload = payload - .pointer("/planned_payload/memory_stage_profile") - .expect("memory-stage-profile plan should include a promoted payload"); - let family_changed_surfaces = payload - .pointer("/evidence/changed_surfaces") - .and_then(Value::as_array) - .expect("plan should preserve broader report-level changed surfaces") - .iter() - .map(|value| value.as_str().expect("changed surface should be a string")) - .collect::>(); - assert!( - family_changed_surfaces.contains(&"acp_policy"), - "broader report-level evidence should still include rejected family evidence" - ); + }; + let report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( + apply_options.clone(), + ) + .expect("runtime capability apply should succeed"); assert_eq!( - planned_payload - .pointer("/schema_version") - .and_then(Value::as_u64), - Some(1) - ); - assert_eq!( - planned_payload - .pointer("/artifact_kind") - .and_then(Value::as_str), - Some("memory_stage_profile") - ); - assert_eq!( - planned_payload - .pointer("/profile/id") - .and_then(Value::as_str), - Some(plan.planned_artifact.artifact_id.as_str()) - ); - assert_eq!( - planned_payload - .pointer("/profile/summary") - .and_then(Value::as_str), - Some("Promote governed memory pipeline intent into a reusable profile") - ); - assert_eq!( - planned_payload - .pointer("/profile/review_scope") - .and_then(Value::as_str), - Some("Governed memory pipeline promotion intent only") - ); - let required_capabilities = planned_payload - .pointer("/profile/required_capabilities") - .and_then(Value::as_array) - .expect("payload should include the profile required capabilities") - .iter() - .map(|value| { - value - .as_str() - .expect("required capability should be a string") - }) - .collect::>(); - assert_eq!(required_capabilities, vec!["memory_read"]); - let tags = planned_payload - .pointer("/profile/tags") - .and_then(Value::as_array) - .expect("payload should include the profile tags") - .iter() - .map(|value| value.as_str().expect("tag should be a string")) - .collect::>(); - assert_eq!(tags, vec!["memory", "pipeline"]); - assert_eq!( - planned_payload - .pointer("/provenance/family_id") - .and_then(Value::as_str), - Some(family.family_id.as_str()) + report.outcome, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyOutcome::Applied ); - let accepted_candidate_ids = planned_payload - .pointer("/provenance/accepted_candidate_ids") - .and_then(Value::as_array) - .expect("payload should include the accepted candidate ids") - .iter() - .map(|value| value.as_str().expect("candidate id should be a string")) - .collect::>(); assert_eq!( - accepted_candidate_ids, - vec![candidate_a.candidate_id.as_str()] + report.applied_artifact.target, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill ); - let changed_surfaces = planned_payload - .pointer("/provenance/evidence_digest/changed_surfaces") - .and_then(Value::as_array) - .expect("payload should include the compact changed-surfaces digest") - .iter() - .map(|value| value.as_str().expect("changed surface should be a string")) - .collect::>(); assert_eq!( - changed_surfaces, - vec!["context_engine_compaction", "memory_policy"] + report.applied_artifact.artifact_kind, + "managed_skill_bundle" ); + assert_eq!(report.applied_artifact.delivery_surface, "managed_skills"); + let output_path_text = normalized_path_text(&report.output_path); assert!( - !changed_surfaces.contains(&"acp_policy"), - "payload provenance digest should exclude rejected-only changed surfaces" - ); - - let rendered = - loongclaw_daemon::runtime_capability_cli::render_runtime_capability_promotion_plan_text( - &plan, - ); - assert!( - rendered.contains(&format!( - "planned_payload=profile_id={}", - plan.planned_artifact.artifact_id + output_path_text.ends_with(&format!( + "managed_skills/{}.json", + report.applied_artifact.artifact_id )), - "rendered text should mention the payload compactly when present" - ); - assert!( - !rendered.contains("planned_payload=null"), - "rendered text should omit null planned payload noise" - ); - assert!( - !rendered.contains("memory_stage_profile:memory_stage_profile"), - "rendered text should not repeat the payload discriminator" + "managed skill apply should write under the managed_skills surface" + ); + + let output_path = PathBuf::from(report.output_path.as_str()); + let persisted = serde_json::from_str::< + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityAppliedArtifactDocument, + >(&fs::read_to_string(&output_path).expect("read apply output")) + .expect("decode apply output"); + assert_eq!(persisted, report.applied_artifact); + match &report.applied_artifact.payload { + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ManagedSkillBundle { + files, + } => { + let skill_markdown = files.get("SKILL.md").expect("SKILL.md should exist"); + assert!(skill_markdown.contains("runtime capability family")); + } + other @ ( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ProgrammaticFlowSpec { .. } + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ProfileNoteAddendum { .. } + ) => panic!("unexpected applied managed skill payload: {other:?}"), + } + + let second_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( + apply_options, + ) + .expect("second apply should succeed idempotently"); + assert_eq!( + second_report.outcome, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyOutcome::AlreadyApplied ); + let reindexed_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_index_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityIndexCommandOptions { + root: root.join("artifacts").display().to_string(), + json: false, + }, + ) + .expect("reindex after apply should succeed"); + assert_eq!(reindexed_report.total_candidate_count, 2); + assert_eq!(reindexed_report.family_count, 1); + fs::remove_dir_all(&root).ok(); } #[test] -fn runtime_capability_plan_omits_memory_stage_profile_payload_for_other_targets() { - let root = unique_temp_dir("loongclaw-runtime-capability-plan-non-memory-payload"); +fn runtime_capability_apply_materializes_programmatic_flow_artifact() { + let root = unique_temp_dir("loongclaw-runtime-capability-apply-programmatic-flow"); let config_path = write_runtime_capability_config(&root); let (run_a_path, _) = finish_runtime_experiment_variant( &root, &config_path, - "managed-skill-a", + "apply-flow-a", -0.2, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, @@ -2838,43 +2500,43 @@ fn runtime_capability_plan_omits_memory_stage_profile_payload_for_other_targets( let (run_b_path, _) = finish_runtime_experiment_variant( &root, &config_path, - "managed-skill-b", + "apply-flow-b", -0.4, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - let candidate_a_path = root.join("artifacts/runtime-capability-managed-skill-a.json"); - let candidate_b_path = root.join("artifacts/runtime-capability-managed-skill-b.json"); + let candidate_a_path = root.join("artifacts/runtime-capability-apply-flow-a.json"); + let candidate_b_path = root.join("artifacts/runtime-capability-apply-flow-b.json"); propose_runtime_capability_variant_with_target( &root, &run_a_path, - "managed-skill-a", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill, - "Codify browser preview onboarding as a reusable managed skill", + "apply-flow-a", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProgrammaticFlow, + "Codify browser preview onboarding as a deterministic programmatic flow", "Browser preview onboarding and companion readiness checks only", &["invoke_tool", "memory_read"], - &["browser", "onboarding"], + &["browser", "flow"], ); propose_runtime_capability_variant_with_target( &root, &run_b_path, - "managed-skill-b", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill, - "Codify browser preview onboarding as a reusable managed skill", + "apply-flow-b", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProgrammaticFlow, + "Codify browser preview onboarding as a deterministic programmatic flow", "Browser preview onboarding and companion readiness checks only", &["invoke_tool", "memory_read"], - &["browser", "onboarding"], + &["browser", "flow"], ); review_runtime_capability_variant( &candidate_a_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "managed-skill-a", + "apply-flow-a", ); review_runtime_capability_variant( &candidate_b_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "managed-skill-b", + "apply-flow-b", ); let index_report = @@ -2890,89 +2552,97 @@ fn runtime_capability_plan_omits_memory_stage_profile_payload_for_other_targets( .first() .expect("one capability family should be reported"); - let plan = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_plan_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityPlanCommandOptions { - root: root.join("artifacts").display().to_string(), - family_id: family.family_id.clone(), - json: false, - }, - ) - .expect("runtime capability plan should succeed"); - let payload = serde_json::to_value(&plan).expect("serialize runtime capability plan"); - let rendered = - loongclaw_daemon::runtime_capability_cli::render_runtime_capability_promotion_plan_text( - &plan, - ); + let report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { + root: root.join("artifacts").display().to_string(), + family_id: family.family_id.clone(), + json: false, + }, + ) + .expect("runtime capability apply should succeed"); - assert!( - payload.pointer("/planned_payload").is_some(), - "planned_payload field should always be present" - ); - assert!( - payload - .pointer("/planned_payload") - .is_some_and(Value::is_null), - "non-memory targets should serialize planned_payload as null" + assert_eq!( + report.applied_artifact.target, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProgrammaticFlow ); - assert!( - !rendered.contains("planned_payload="), - "non-memory targets should not render a planned payload line" + assert_eq!( + report.applied_artifact.artifact_kind, + "programmatic_flow_spec" ); + assert_eq!( + report.applied_artifact.delivery_surface, + "programmatic_flows" + ); + match &report.applied_artifact.payload { + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ProgrammaticFlowSpec { + files, + } => { + let flow_json = files.get("flow.json").expect("flow.json should exist"); + assert!(flow_json.contains("\"steps\": []")); + } + other @ ( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ManagedSkillBundle { .. } + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ProfileNoteAddendum { .. } + ) => panic!("unexpected applied programmatic flow payload: {other:?}"), + } fs::remove_dir_all(&root).ok(); } #[test] -fn runtime_capability_plan_marks_memory_stage_profile_promotable_with_memory_delta_evidence() { - let root = unique_temp_dir("loongclaw-runtime-capability-plan-memory-stage-profile-ready"); - let (run_a_path, _) = finish_runtime_experiment_variant_with_memory_compare_delta( +fn runtime_capability_apply_materializes_profile_note_addendum_artifact() { + let root = unique_temp_dir("loongclaw-runtime-capability-apply-profile-note"); + let config_path = write_runtime_capability_config(&root); + + let (run_a_path, _) = finish_runtime_experiment_variant( &root, - "memory-stage-profile-ready-a", + &config_path, + "apply-profile-a", -0.2, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - let (run_b_path, _) = finish_runtime_experiment_variant_with_memory_compare_delta( + let (run_b_path, _) = finish_runtime_experiment_variant( &root, - "memory-stage-profile-ready-b", + &config_path, + "apply-profile-b", -0.4, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - let candidate_a_path = - root.join("artifacts/runtime-capability-memory-stage-profile-ready-a.json"); - let candidate_b_path = - root.join("artifacts/runtime-capability-memory-stage-profile-ready-b.json"); + let candidate_a_path = root.join("artifacts/runtime-capability-apply-profile-a.json"); + let candidate_b_path = root.join("artifacts/runtime-capability-apply-profile-b.json"); propose_runtime_capability_variant_with_target( &root, &run_a_path, - "memory-stage-profile-ready-a", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", + "apply-profile-a", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProfileNoteAddendum, + "Capture browser preview onboarding guidance as advisory profile context", + "Browser preview onboarding guidance only", &["memory_read"], - &["memory", "pipeline"], + &["browser", "profile"], ); propose_runtime_capability_variant_with_target( &root, &run_b_path, - "memory-stage-profile-ready-b", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", + "apply-profile-b", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProfileNoteAddendum, + "Capture browser preview onboarding guidance as advisory profile context", + "Browser preview onboarding guidance only", &["memory_read"], - &["memory", "pipeline"], + &["browser", "profile"], ); review_runtime_capability_variant( &candidate_a_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-ready-a", + "apply-profile-a", ); review_runtime_capability_variant( &candidate_b_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-ready-b", + "apply-profile-b", ); let index_report = @@ -2988,97 +2658,147 @@ fn runtime_capability_plan_marks_memory_stage_profile_promotable_with_memory_del .first() .expect("one capability family should be reported"); - let plan = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_plan_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityPlanCommandOptions { + let report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { + root: root.join("artifacts").display().to_string(), + family_id: family.family_id.clone(), + json: false, + }, + ) + .expect("runtime capability apply should succeed"); + + assert_eq!( + report.applied_artifact.target, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProfileNoteAddendum + ); + assert_eq!( + report.applied_artifact.artifact_kind, + "profile_note_addendum" + ); + assert_eq!(report.applied_artifact.delivery_surface, "profile_note"); + match &report.applied_artifact.payload { + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ProfileNoteAddendum { + content, + } => { + assert!(content.contains("Runtime Capability Draft")); + } + other @ ( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ManagedSkillBundle { .. } + | loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityDraftPayload::ProgrammaticFlowSpec { .. } + ) => panic!("unexpected applied profile note payload: {other:?}"), + } + + fs::remove_dir_all(&root).ok(); +} + +#[test] +fn runtime_capability_apply_rejects_non_promotable_family() { + let root = unique_temp_dir("loongclaw-runtime-capability-apply-not-ready"); + let config_path = write_runtime_capability_config(&root); + + let (run_path, _) = finish_runtime_experiment_variant( + &root, + &config_path, + "apply-not-ready", + -0.2, + &[], + loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, + ); + let candidate_path = root.join("artifacts/runtime-capability-apply-not-ready.json"); + propose_runtime_capability_variant(&root, &run_path, "apply-not-ready"); + review_runtime_capability_variant( + &candidate_path, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, + "apply-not-ready", + ); + + let index_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_index_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityIndexCommandOptions { + root: root.join("artifacts").display().to_string(), + json: false, + }, + ) + .expect("runtime capability index should succeed"); + let family = index_report + .families + .first() + .expect("one capability family should be reported"); + + let error = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { root: root.join("artifacts").display().to_string(), family_id: family.family_id.clone(), json: false, }, ) - .expect("runtime capability plan should succeed"); + .expect_err("non-promotable family should be rejected"); assert!( - plan.promotable, - "memory-stage-profile family should be promotable" - ); - assert_eq!( - plan.readiness.status, - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityFamilyReadinessStatus::Ready - ); - assert!( - plan.readiness.checks.iter().any(|check| { - check.dimension == "memory_delta_evidence" - && check.status - == loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityFamilyReadinessCheckStatus::Pass - }), - "ready memory-stage-profile family should pass memory delta evidence checks" + error.contains("not promotable"), + "apply should explain the promotability gate: {error}" ); assert!( - plan.evidence.changed_surfaces.iter().any(|surface| { - surface == "memory_selected" - || surface == "memory_policy" - || surface == "context_engine_selected" - || surface == "context_engine_compaction" - }), - "memory-stage-profile evidence should include memory/context surfaces" + error.contains("stability"), + "apply should surface the missing readiness dimension: {error}" ); fs::remove_dir_all(&root).ok(); } #[test] -fn runtime_capability_plan_provenance_candidate_ids_follow_family_order() { - let root = unique_temp_dir("loongclaw-runtime-capability-plan-provenance-order"); +fn runtime_capability_activate_managed_skill_apply_installs_skill_and_is_idempotent() { + let root = unique_temp_dir("loongclaw-runtime-capability-activate-managed-skill"); let config_path = write_runtime_capability_config(&root); - let (run_z_path, _) = finish_runtime_experiment_variant( + + let (run_a_path, _) = finish_runtime_experiment_variant( &root, &config_path, - "z-run", + "activate-managed-a", -0.2, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - let (run_a_path, _) = finish_runtime_experiment_variant( + let (run_b_path, _) = finish_runtime_experiment_variant( &root, &config_path, - "a-run", + "activate-managed-b", -0.4, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - let candidate_z_path = root.join("artifacts/runtime-capability-zzz-first.json"); - let candidate_a_path = root.join("artifacts/runtime-capability-aaa-second.json"); - let candidate_z = propose_runtime_capability_variant_with_target( + let candidate_a_path = root.join("artifacts/runtime-capability-activate-managed-a.json"); + let candidate_b_path = root.join("artifacts/runtime-capability-activate-managed-b.json"); + propose_runtime_capability_variant_with_target( &root, - &run_z_path, - "zzz-first", + &run_a_path, + "activate-managed-a", loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill, "Codify browser preview onboarding as a reusable managed skill", "Browser preview onboarding and companion readiness checks only", &["invoke_tool", "memory_read"], &["browser", "onboarding"], ); - let candidate_a = propose_runtime_capability_variant_with_target( + propose_runtime_capability_variant_with_target( &root, - &run_a_path, - "aaa-second", + &run_b_path, + "activate-managed-b", loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill, "Codify browser preview onboarding as a reusable managed skill", "Browser preview onboarding and companion readiness checks only", &["invoke_tool", "memory_read"], &["browser", "onboarding"], ); - rewrite_runtime_capability_created_at(&candidate_z_path, "2026-03-18T08:00:00Z"); - rewrite_runtime_capability_created_at(&candidate_a_path, "2026-03-18T08:00:01Z"); review_runtime_capability_variant( - &candidate_z_path, + &candidate_a_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "zzz-first", + "activate-managed-a", ); review_runtime_capability_variant( - &candidate_a_path, + &candidate_b_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "aaa-second", + "activate-managed-b", ); let index_report = @@ -3093,103 +2813,130 @@ fn runtime_capability_plan_provenance_candidate_ids_follow_family_order() { .families .first() .expect("one capability family should be reported"); - let plan = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_plan_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityPlanCommandOptions { - root: root.join("artifacts").display().to_string(), - family_id: family.family_id.clone(), + let apply_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { + root: root.join("artifacts").display().to_string(), + family_id: family.family_id.clone(), + json: false, + }, + ) + .expect("runtime capability apply should succeed"); + + let activate_options = + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityActivateCommandOptions { + config: Some(config_path.display().to_string()), + artifact: apply_report.output_path, + apply: true, + replace: false, json: false, - }, - ) - .expect("runtime capability plan should succeed"); + }; + let activate_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_activate_command( + activate_options.clone(), + ) + .expect("managed skill activation should succeed"); assert_eq!( - family.candidate_ids, - vec![candidate_z.candidate_id, candidate_a.candidate_id], - "family summary should use semantic candidate order" + activate_report.outcome, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityActivateOutcome::Activated ); assert_eq!( - plan.provenance.candidate_ids, family.candidate_ids, - "planner provenance should preserve the family candidate order" + activate_report.activation_surface, + "external_skills.install" + ); + assert!( + !activate_report.rollback_hints.is_empty(), + "activation should surface rollback guidance" + ); + assert!( + activate_report + .verification + .iter() + .any(|item| item.contains("matches the applied managed skill bundle")), + "activation should report managed skill verification evidence" + ); + let installed_skill_path = root + .join("external-skills-installed") + .join(apply_report.applied_artifact.artifact_id.as_str()); + let installed_skill_markdown_path = installed_skill_path.join("SKILL.md"); + assert!( + installed_skill_markdown_path.exists(), + "activation should install the draft skill" ); - fs::remove_dir_all(&root).ok(); -} - -#[test] -fn runtime_capability_plan_rejects_unknown_family_id() { - let root = unique_temp_dir("loongclaw-runtime-capability-plan-missing-family"); - let config_path = write_runtime_capability_config(&root); - let (run_path, _) = finish_runtime_experiment(&root, &config_path); - propose_runtime_capability_variant(&root, &run_path, "missing"); - - let error = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_plan_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityPlanCommandOptions { - root: root.join("artifacts").display().to_string(), - family_id: "missing-family".to_owned(), - json: false, - }, - ) - .expect_err("unknown family id should be rejected"); - + let second_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_activate_command( + activate_options, + ) + .expect("managed skill activation should be idempotent"); + assert_eq!( + second_report.outcome, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityActivateOutcome::AlreadyActivated + ); assert!( - error.contains("missing-family"), - "error should name the requested family id: {error}" + second_report + .verification + .iter() + .any(|item| item.contains("matches the applied managed skill bundle")), + "idempotent activation should still report verification evidence" ); fs::remove_dir_all(&root).ok(); } #[test] -fn runtime_capability_apply_materializes_memory_stage_profile_artifact() { - let root = unique_temp_dir("loongclaw-runtime-capability-apply-memory-stage-profile"); - let (run_a_path, _) = finish_runtime_experiment_variant_with_memory_compare_delta( +fn runtime_capability_activate_profile_note_addendum_updates_config_and_is_idempotent() { + let root = unique_temp_dir("loongclaw-runtime-capability-activate-profile-note"); + let config_path = write_runtime_capability_config(&root); + + let (run_a_path, _) = finish_runtime_experiment_variant( &root, - "memory-stage-profile-apply-a", + &config_path, + "activate-profile-a", -0.2, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - let (run_b_path, _) = finish_runtime_experiment_variant_with_memory_compare_delta( + let (run_b_path, _) = finish_runtime_experiment_variant( &root, - "memory-stage-profile-apply-b", + &config_path, + "activate-profile-b", -0.4, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - - let candidate_a_path = - root.join("artifacts/runtime-capability-memory-stage-profile-apply-a.json"); - let candidate_b_path = - root.join("artifacts/runtime-capability-memory-stage-profile-apply-b.json"); + let candidate_a_path = root.join("artifacts/runtime-capability-activate-profile-a.json"); + let candidate_b_path = root.join("artifacts/runtime-capability-activate-profile-b.json"); propose_runtime_capability_variant_with_target( &root, &run_a_path, - "memory-stage-profile-apply-a", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", + "activate-profile-a", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProfileNoteAddendum, + "Capture browser preview onboarding guidance as advisory profile context", + "Browser preview onboarding guidance only", &["memory_read"], - &["memory", "pipeline"], + &["browser", "profile"], ); propose_runtime_capability_variant_with_target( &root, &run_b_path, - "memory-stage-profile-apply-b", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", + "activate-profile-b", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProfileNoteAddendum, + "Capture browser preview onboarding guidance as advisory profile context", + "Browser preview onboarding guidance only", &["memory_read"], - &["memory", "pipeline"], + &["browser", "profile"], ); review_runtime_capability_variant( &candidate_a_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-apply-a", + "activate-profile-a", ); review_runtime_capability_variant( &candidate_b_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-apply-b", + "activate-profile-b", ); let index_report = @@ -3204,8 +2951,7 @@ fn runtime_capability_apply_materializes_memory_stage_profile_artifact() { .families .first() .expect("one capability family should be reported"); - - let report = + let apply_report = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { root: root.join("artifacts").display().to_string(), @@ -3215,119 +2961,81 @@ fn runtime_capability_apply_materializes_memory_stage_profile_artifact() { ) .expect("runtime capability apply should succeed"); + let activate_options = + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityActivateCommandOptions { + config: Some(config_path.display().to_string()), + artifact: apply_report.output_path, + apply: true, + replace: false, + json: false, + }; + let activate_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_activate_command( + activate_options.clone(), + ) + .expect("profile note activation should succeed"); + assert_eq!( - report.outcome, - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyOutcome::Applied + activate_report.outcome, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityActivateOutcome::Activated ); + let config_path_text = config_path.display().to_string(); + let (_, updated_config) = + mvp::config::load(Some(config_path_text.as_str())).expect("load updated config"); assert_eq!( - report.planned_artifact.target_kind, - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile - ); - let output_path = PathBuf::from(&report.output_path); - let actual_output_path_suffix = artifact_path_suffix(&output_path); - let expected_output_path_suffix = format!( - "memory_stage_profiles/{}.json", - report.planned_artifact.artifact_id + updated_config.memory.profile, + mvp::config::MemoryProfile::ProfilePlusWindow ); assert!( - actual_output_path_suffix == expected_output_path_suffix, - "apply should materialize the memory-stage-profile artifact under the delivery surface" + !activate_report.rollback_hints.is_empty(), + "profile note activation should surface rollback guidance" ); assert!( - output_path.exists(), - "apply should persist the output artifact" - ); - - let persisted_payload = serde_json::from_str::( - &fs::read_to_string(&output_path).expect("read apply output artifact"), - ) - .expect("decode apply output artifact"); - - assert_eq!( - persisted_payload - .pointer("/schema/surface") - .and_then(Value::as_str), - Some("memory_stage_profile") - ); - assert_eq!( - persisted_payload - .pointer("/schema/purpose") - .and_then(Value::as_str), - Some("runtime_capability_apply_output") - ); - assert_eq!( - persisted_payload - .pointer("/artifact_id") - .and_then(Value::as_str), - Some(report.planned_artifact.artifact_id.as_str()) - ); - assert_eq!( - persisted_payload - .pointer("/delivery_surface") - .and_then(Value::as_str), - Some("memory_stage_profiles") - ); - assert_eq!( - persisted_payload - .pointer("/profile/summary") - .and_then(Value::as_str), - Some("Promote governed memory pipeline intent into a reusable profile") + activate_report + .verification + .iter() + .any(|item| item.contains("profile_plus_window")), + "profile note activation should report verification evidence" + ); + let updated_profile_note = updated_config + .memory + .profile_note + .as_deref() + .expect("profile note should be present"); + assert!( + updated_profile_note.contains("Runtime Capability Draft"), + "activation should append the advisory addendum" ); - let reindexed_report = - loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_index_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityIndexCommandOptions { - root: root.join("artifacts").display().to_string(), - json: false, - }, + let second_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_activate_command( + activate_options, ) - .expect("runtime capability re-index should still succeed"); - - assert_eq!( - reindexed_report.total_candidate_count, 2, - "materialized apply outputs should not be mistaken for runtime-capability candidates" - ); + .expect("profile note activation should be idempotent"); assert_eq!( - reindexed_report.family_count, 1, - "materialized apply outputs should stay outside capability-family aggregation" + second_report.outcome, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityActivateOutcome::AlreadyActivated ); - - fs::remove_dir_all(&root).ok(); -} - -#[test] -fn runtime_capability_apply_rejects_unknown_family_id() { - let root = unique_temp_dir("loongclaw-runtime-capability-apply-missing-family"); - let config_path = write_runtime_capability_config(&root); - let (run_path, _) = finish_runtime_experiment(&root, &config_path); - propose_runtime_capability_variant(&root, &run_path, "missing"); - - let error = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { - root: root.join("artifacts").display().to_string(), - family_id: "missing-family".to_owned(), - json: false, - }, - ) - .expect_err("unknown family id should be rejected during apply"); - assert!( - error.contains("missing-family"), - "error should name the requested family id: {error}" + second_report + .verification + .iter() + .any(|item| item.contains("profile_plus_window")), + "idempotent profile note activation should still report verification evidence" ); fs::remove_dir_all(&root).ok(); } #[test] -fn runtime_capability_apply_rejects_non_promotable_memory_stage_profile_family() { - let root = unique_temp_dir("loongclaw-runtime-capability-apply-memory-stage-profile-blocked"); +fn runtime_capability_activate_rejects_programmatic_flow_until_activation_surface_exists() { + let root = unique_temp_dir("loongclaw-runtime-capability-activate-programmatic-flow"); let config_path = write_runtime_capability_config(&root); let (run_a_path, _) = finish_runtime_experiment_variant( &root, &config_path, - "memory-stage-profile-blocked-a", + "activate-flow-a", -0.2, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, @@ -3335,45 +3043,42 @@ fn runtime_capability_apply_rejects_non_promotable_memory_stage_profile_family() let (run_b_path, _) = finish_runtime_experiment_variant( &root, &config_path, - "memory-stage-profile-blocked-b", + "activate-flow-b", -0.4, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - - let candidate_a_path = - root.join("artifacts/runtime-capability-memory-stage-profile-blocked-a.json"); - let candidate_b_path = - root.join("artifacts/runtime-capability-memory-stage-profile-blocked-b.json"); + let candidate_a_path = root.join("artifacts/runtime-capability-activate-flow-a.json"); + let candidate_b_path = root.join("artifacts/runtime-capability-activate-flow-b.json"); propose_runtime_capability_variant_with_target( &root, &run_a_path, - "memory-stage-profile-blocked-a", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", - &["memory_read"], - &["memory", "pipeline"], + "activate-flow-a", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProgrammaticFlow, + "Codify browser preview onboarding as a deterministic programmatic flow", + "Browser preview onboarding and companion readiness checks only", + &["invoke_tool", "memory_read"], + &["browser", "flow"], ); propose_runtime_capability_variant_with_target( &root, &run_b_path, - "memory-stage-profile-blocked-b", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", - &["memory_read"], - &["memory", "pipeline"], + "activate-flow-b", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProgrammaticFlow, + "Codify browser preview onboarding as a deterministic programmatic flow", + "Browser preview onboarding and companion readiness checks only", + &["invoke_tool", "memory_read"], + &["browser", "flow"], ); review_runtime_capability_variant( &candidate_a_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-blocked-a", + "activate-flow-a", ); review_runtime_capability_variant( &candidate_b_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-blocked-b", + "activate-flow-b", ); let index_report = @@ -3388,37 +3093,45 @@ fn runtime_capability_apply_rejects_non_promotable_memory_stage_profile_family() .families .first() .expect("one capability family should be reported"); + let apply_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { + root: root.join("artifacts").display().to_string(), + family_id: family.family_id.clone(), + json: false, + }, + ) + .expect("runtime capability apply should succeed"); - let error = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { - root: root.join("artifacts").display().to_string(), - family_id: family.family_id.clone(), - json: false, - }, - ) - .expect_err("non-promotable memory-stage-profile family should be rejected"); + let error = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_activate_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityActivateCommandOptions { + config: Some(config_path.display().to_string()), + artifact: apply_report.output_path, + apply: true, + replace: false, + json: false, + }, + ) + .expect_err("programmatic flow activation should fail closed"); assert!( - error.contains("not promotable"), - "apply should explain why materialization was refused: {error}" - ); - assert!( - error.contains("memory_delta_evidence"), - "apply should surface the missing readiness dimension: {error}" + error.contains("does not yet support programmatic_flow artifacts"), + "activation should explain why the flow stays blocked: {error}" ); fs::remove_dir_all(&root).ok(); } #[test] -fn runtime_capability_apply_rejects_unsupported_target_kind() { - let root = unique_temp_dir("loongclaw-runtime-capability-apply-unsupported-target"); +fn runtime_capability_activate_managed_skill_dry_run_reports_install_target() { + let root = unique_temp_dir("loongclaw-runtime-capability-activate-managed-dry-run"); let config_path = write_runtime_capability_config(&root); let (run_a_path, _) = finish_runtime_experiment_variant( &root, &config_path, - "managed-skill-apply-a", + "activate-managed-a", -0.2, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, @@ -3426,18 +3139,17 @@ fn runtime_capability_apply_rejects_unsupported_target_kind() { let (run_b_path, _) = finish_runtime_experiment_variant( &root, &config_path, - "managed-skill-apply-b", + "activate-managed-b", -0.4, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - - let candidate_a_path = root.join("artifacts/runtime-capability-managed-skill-apply-a.json"); - let candidate_b_path = root.join("artifacts/runtime-capability-managed-skill-apply-b.json"); + let candidate_a_path = root.join("artifacts/runtime-capability-activate-managed-a.json"); + let candidate_b_path = root.join("artifacts/runtime-capability-activate-managed-b.json"); propose_runtime_capability_variant_with_target( &root, &run_a_path, - "managed-skill-apply-a", + "activate-managed-a", loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill, "Codify browser preview onboarding as a reusable managed skill", "Browser preview onboarding and companion readiness checks only", @@ -3447,7 +3159,7 @@ fn runtime_capability_apply_rejects_unsupported_target_kind() { propose_runtime_capability_variant_with_target( &root, &run_b_path, - "managed-skill-apply-b", + "activate-managed-b", loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill, "Codify browser preview onboarding as a reusable managed skill", "Browser preview onboarding and companion readiness checks only", @@ -3457,12 +3169,12 @@ fn runtime_capability_apply_rejects_unsupported_target_kind() { review_runtime_capability_variant( &candidate_a_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "managed-skill-apply-a", + "activate-managed-a", ); review_runtime_capability_variant( &candidate_b_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "managed-skill-apply-b", + "activate-managed-b", ); let index_report = @@ -3477,79 +3189,109 @@ fn runtime_capability_apply_rejects_unsupported_target_kind() { .families .first() .expect("one capability family should be reported"); + let apply_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { + root: root.join("artifacts").display().to_string(), + family_id: family.family_id.clone(), + json: false, + }, + ) + .expect("runtime capability apply should succeed"); - let error = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { - root: root.join("artifacts").display().to_string(), - family_id: family.family_id.clone(), - json: false, - }, - ) - .expect_err("unsupported target kinds should be rejected"); + let activate_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_activate_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityActivateCommandOptions { + config: Some(config_path.display().to_string()), + artifact: apply_report.output_path, + apply: false, + replace: false, + json: false, + }, + ) + .expect("runtime capability activate dry-run should succeed"); + assert_eq!( + activate_report.outcome, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityActivateOutcome::DryRun + ); + assert_eq!( + activate_report.activation_surface, + "external_skills.install" + ); assert!( - error.contains("memory_stage_profile"), - "apply should state the only supported target kind: {error}" + activate_report + .target_path + .contains("external-skills-installed"), + "dry-run should point at the managed skill install root" ); assert!( - error.contains("managed_skill"), - "apply should name the unsupported planned target: {error}" + activate_report + .verification + .iter() + .any(|item| item.contains("verify")), + "dry-run should report verification guidance" + ); + assert!( + !activate_report.rollback_hints.is_empty(), + "dry-run should surface rollback guidance" ); fs::remove_dir_all(&root).ok(); } #[test] -fn runtime_capability_apply_is_idempotent_when_existing_output_matches() { - let root = unique_temp_dir("loongclaw-runtime-capability-apply-idempotent"); - let (run_a_path, _) = finish_runtime_experiment_variant_with_memory_compare_delta( +fn runtime_capability_rollback_managed_skill_restores_pre_activation_state_and_is_idempotent() { + let root = unique_temp_dir("loongclaw-runtime-capability-rollback-managed-skill"); + let config_path = write_runtime_capability_config(&root); + + let (run_a_path, _) = finish_runtime_experiment_variant( &root, - "memory-stage-profile-idempotent-a", + &config_path, + "rollback-managed-a", -0.2, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - let (run_b_path, _) = finish_runtime_experiment_variant_with_memory_compare_delta( + let (run_b_path, _) = finish_runtime_experiment_variant( &root, - "memory-stage-profile-idempotent-b", + &config_path, + "rollback-managed-b", -0.4, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - - let candidate_a_path = - root.join("artifacts/runtime-capability-memory-stage-profile-idempotent-a.json"); - let candidate_b_path = - root.join("artifacts/runtime-capability-memory-stage-profile-idempotent-b.json"); + let candidate_a_path = root.join("artifacts/runtime-capability-rollback-managed-a.json"); + let candidate_b_path = root.join("artifacts/runtime-capability-rollback-managed-b.json"); propose_runtime_capability_variant_with_target( &root, &run_a_path, - "memory-stage-profile-idempotent-a", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", - &["memory_read"], - &["memory", "pipeline"], + "rollback-managed-a", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill, + "Codify browser preview onboarding as a reusable managed skill", + "Browser preview onboarding and companion readiness checks only", + &["invoke_tool", "memory_read"], + &["browser", "onboarding"], ); propose_runtime_capability_variant_with_target( &root, &run_b_path, - "memory-stage-profile-idempotent-b", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", - &["memory_read"], - &["memory", "pipeline"], + "rollback-managed-b", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ManagedSkill, + "Codify browser preview onboarding as a reusable managed skill", + "Browser preview onboarding and companion readiness checks only", + &["invoke_tool", "memory_read"], + &["browser", "onboarding"], ); review_runtime_capability_variant( &candidate_a_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-idempotent-a", + "rollback-managed-a", ); review_runtime_capability_variant( &candidate_b_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-idempotent-b", + "rollback-managed-b", ); let index_report = @@ -3564,8 +3306,7 @@ fn runtime_capability_apply_is_idempotent_when_existing_output_matches() { .families .first() .expect("one capability family should be reported"); - - let first_report = + let apply_report = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { root: root.join("artifacts").display().to_string(), @@ -3573,91 +3314,128 @@ fn runtime_capability_apply_is_idempotent_when_existing_output_matches() { json: false, }, ) - .expect("first runtime capability apply should succeed"); - - let first_output = fs::read_to_string(&first_report.output_path) - .expect("read first apply output artifact for idempotence check"); + .expect("runtime capability apply should succeed"); - let second_report = - loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { - root: root.join("artifacts").display().to_string(), - family_id: family.family_id.clone(), + let activate_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_activate_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityActivateCommandOptions { + config: Some(config_path.display().to_string()), + artifact: apply_report.output_path, + apply: true, + replace: false, json: false, }, ) - .expect("second runtime capability apply should be idempotent"); + .expect("managed skill activation should succeed"); + + let record_path = activate_report + .activation_record_path + .expect("activation should persist a rollback record"); + assert!( + Path::new(record_path.as_str()).exists(), + "rollback record should be written to disk" + ); - let second_output = fs::read_to_string(&second_report.output_path) - .expect("read second apply output artifact for idempotence check"); + let rollback_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_rollback_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityRollbackCommandOptions { + config: Some(config_path.display().to_string()), + record: record_path.clone(), + apply: true, + json: false, + }, + ) + .expect("managed skill rollback should succeed"); assert_eq!( - second_report.outcome, - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyOutcome::AlreadyApplied + rollback_report.outcome, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityRollbackOutcome::RolledBack ); - assert_eq!( - first_report.output_path, second_report.output_path, - "idempotent apply should reuse the same output path" + assert!( + rollback_report + .verification + .iter() + .any(|item| item.contains("is absent")), + "rollback should verify managed skill removal" + ); + let installed_skill_path = root + .join("external-skills-installed") + .join(activate_report.artifact_id.as_str()); + assert!( + !installed_skill_path.exists(), + "rollback should remove the installed managed skill when no prior bundle existed" ); + + let second_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_rollback_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityRollbackCommandOptions { + config: Some(config_path.display().to_string()), + record: record_path, + apply: true, + json: false, + }, + ) + .expect("managed skill rollback should be idempotent"); assert_eq!( - first_output, second_output, - "idempotent apply should not rewrite matching output content" + second_report.outcome, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityRollbackOutcome::AlreadyRolledBack ); fs::remove_dir_all(&root).ok(); } #[test] -fn runtime_capability_apply_rejects_conflicting_existing_output() { - let root = unique_temp_dir("loongclaw-runtime-capability-apply-conflict"); - let (run_a_path, _) = finish_runtime_experiment_variant_with_memory_compare_delta( +fn runtime_capability_rollback_profile_note_restores_pre_activation_state_and_is_idempotent() { + let root = unique_temp_dir("loongclaw-runtime-capability-rollback-profile-note"); + let config_path = write_runtime_capability_config(&root); + + let (run_a_path, _) = finish_runtime_experiment_variant( &root, - "memory-stage-profile-conflict-a", + &config_path, + "rollback-profile-a", -0.2, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - let (run_b_path, _) = finish_runtime_experiment_variant_with_memory_compare_delta( + let (run_b_path, _) = finish_runtime_experiment_variant( &root, - "memory-stage-profile-conflict-b", + &config_path, + "rollback-profile-b", -0.4, &[], loongclaw_daemon::runtime_experiment_cli::RuntimeExperimentDecision::Promoted, ); - - let candidate_a_path = - root.join("artifacts/runtime-capability-memory-stage-profile-conflict-a.json"); - let candidate_b_path = - root.join("artifacts/runtime-capability-memory-stage-profile-conflict-b.json"); + let candidate_a_path = root.join("artifacts/runtime-capability-rollback-profile-a.json"); + let candidate_b_path = root.join("artifacts/runtime-capability-rollback-profile-b.json"); propose_runtime_capability_variant_with_target( &root, &run_a_path, - "memory-stage-profile-conflict-a", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", + "rollback-profile-a", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProfileNoteAddendum, + "Capture browser preview onboarding guidance as advisory profile context", + "Browser preview onboarding guidance only", &["memory_read"], - &["memory", "pipeline"], + &["browser", "profile"], ); propose_runtime_capability_variant_with_target( &root, &run_b_path, - "memory-stage-profile-conflict-b", - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::MemoryStageProfile, - "Promote governed memory pipeline intent into a reusable profile", - "Governed memory pipeline promotion intent only", + "rollback-profile-b", + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityTarget::ProfileNoteAddendum, + "Capture browser preview onboarding guidance as advisory profile context", + "Browser preview onboarding guidance only", &["memory_read"], - &["memory", "pipeline"], + &["browser", "profile"], ); review_runtime_capability_variant( &candidate_a_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-conflict-a", + "rollback-profile-a", ); review_runtime_capability_variant( &candidate_b_path, loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityReviewDecision::Accepted, - "memory-stage-profile-conflict-b", + "rollback-profile-b", ); let index_report = @@ -3672,8 +3450,7 @@ fn runtime_capability_apply_rejects_conflicting_existing_output() { .families .first() .expect("one capability family should be reported"); - - let first_report = + let apply_report = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { root: root.join("artifacts").display().to_string(), @@ -3681,28 +3458,63 @@ fn runtime_capability_apply_rejects_conflicting_existing_output() { json: false, }, ) - .expect("first runtime capability apply should succeed"); - - let output_path = PathBuf::from(&first_report.output_path); - rewrite_json_file(&output_path, |payload| { - let profile_summary = payload - .pointer_mut("/profile/summary") - .expect("apply output should include profile.summary"); - *profile_summary = Value::String("conflicting manual edit".to_owned()); - }); + .expect("runtime capability apply should succeed"); - let error = loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_apply_command( - loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityApplyCommandOptions { - root: root.join("artifacts").display().to_string(), - family_id: family.family_id.clone(), - json: false, - }, - ) - .expect_err("conflicting existing output should be rejected"); + let activate_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_activate_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityActivateCommandOptions { + config: Some(config_path.display().to_string()), + artifact: apply_report.output_path, + apply: true, + replace: false, + json: false, + }, + ) + .expect("profile note activation should succeed"); + + let record_path = activate_report + .activation_record_path + .expect("activation should persist a rollback record"); + let rollback_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_rollback_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityRollbackCommandOptions { + config: Some(config_path.display().to_string()), + record: record_path.clone(), + apply: true, + json: false, + }, + ) + .expect("profile note rollback should succeed"); - assert!( - error.contains("different content"), - "apply should reject conflicting materialized output: {error}" + assert_eq!( + rollback_report.outcome, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityRollbackOutcome::RolledBack + ); + let config_path_text = config_path.display().to_string(); + let (_, restored_config) = + mvp::config::load(Some(config_path_text.as_str())).expect("load rolled back config"); + assert_eq!( + restored_config.memory.profile, + mvp::config::MemoryProfile::WindowOnly + ); + assert_eq!( + restored_config.memory.profile_note, None, + "rollback should restore the original profile note state" + ); + + let second_report = + loongclaw_daemon::runtime_capability_cli::execute_runtime_capability_rollback_command( + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityRollbackCommandOptions { + config: Some(config_path.display().to_string()), + record: record_path, + apply: true, + json: false, + }, + ) + .expect("profile note rollback should be idempotent"); + assert_eq!( + second_report.outcome, + loongclaw_daemon::runtime_capability_cli::RuntimeCapabilityRollbackOutcome::AlreadyRolledBack ); fs::remove_dir_all(&root).ok(); diff --git a/crates/daemon/tests/integration/runtime_restore_cli.rs b/crates/daemon/tests/integration/runtime_restore_cli.rs index 0cac57ddd..930ff2af3 100644 --- a/crates/daemon/tests/integration/runtime_restore_cli.rs +++ b/crates/daemon/tests/integration/runtime_restore_cli.rs @@ -874,22 +874,29 @@ fn runtime_restore_apply_reports_verification_failure_without_reverting_applied_ #[test] fn runtime_restore_apply_rolls_back_managed_skill_changes_when_config_write_fails() { let root = unique_temp_dir("loongclaw-runtime-restore-rollback"); - let _env = RuntimeRestoreEnvGuard::set(&[ + let (config_path, config) = write_runtime_restore_config(&root); + let config_path_text = config_path.to_string_lossy().to_string(); + let artifact_path = { + let _runtime_env = RuntimeRestoreEnvGuard::set(&[ + ("LOONGCLAW_BROWSER_COMPANION_READY", Some("true")), + ("OPENAI_API_KEY", None), + ("RUNTIME_RESTORE_DEEPSEEK_KEY", Some("deepseek-demo-token")), + ]); + install_demo_skill(&root, &config, &config_path); + let (artifact_path, _snapshot, _payload) = write_snapshot_artifact(&root, &config_path); + artifact_path + }; + + mutate_runtime_restore_config(&config_path, &root); + let _apply_env = RuntimeRestoreEnvGuard::set(&[ ("LOONGCLAW_BROWSER_COMPANION_READY", Some("true")), ("OPENAI_API_KEY", None), ("RUNTIME_RESTORE_DEEPSEEK_KEY", Some("deepseek-demo-token")), + ( + "LOONGCLAW_TEST_FAIL_CONFIG_WRITE_PATH", + Some(config_path_text.as_str()), + ), ]); - let (config_path, config) = write_runtime_restore_config(&root); - install_demo_skill(&root, &config, &config_path); - let (artifact_path, _snapshot, _payload) = write_snapshot_artifact(&root, &config_path); - - mutate_runtime_restore_config(&config_path, &root); - - let metadata = fs::metadata(&config_path).expect("read config metadata"); - let original_permissions = metadata.permissions(); - let mut readonly_permissions = original_permissions.clone(); - readonly_permissions.set_readonly(true); - fs::set_permissions(&config_path, readonly_permissions).expect("mark config read-only"); let apply_error = loongclaw_daemon::runtime_restore_cli::execute_runtime_restore_command( loongclaw_daemon::runtime_restore_cli::RuntimeRestoreCommandOptions { @@ -901,8 +908,6 @@ fn runtime_restore_apply_rolls_back_managed_skill_changes_when_config_write_fail ) .expect_err("apply should fail when config persistence fails"); - fs::set_permissions(&config_path, original_permissions).expect("restore config write access"); - assert!(apply_error.contains("persist runtime restore config")); assert!( !root.join("managed-skills").join("demo-skill").exists(), diff --git a/crates/daemon/tests/integration/skills_cli.rs b/crates/daemon/tests/integration/skills_cli.rs index 24e593f21..e48fd3253 100644 --- a/crates/daemon/tests/integration/skills_cli.rs +++ b/crates/daemon/tests/integration/skills_cli.rs @@ -867,11 +867,8 @@ fn execute_skills_command_enable_browser_preview_rolls_back_config_on_install_fa fs::remove_dir_all(&root).ok(); } -#[cfg(unix)] #[test] fn execute_skills_command_enable_browser_preview_rolls_back_skill_on_config_persist_failure() { - use std::os::unix::fs::PermissionsExt; - let root = unique_temp_dir("loongclaw-skills-cli-browser-preview-config-failure"); let install_root = root.join("managed-skills"); let config_path = root.join("loongclaw.toml"); @@ -880,12 +877,11 @@ fn execute_skills_command_enable_browser_preview_rolls_back_skill_on_config_pers config.external_skills.install_root = Some(install_root.display().to_string()); mvp::config::write(Some(config_path.to_string_lossy().as_ref()), &config, true) .expect("write config fixture"); - - let mut permissions = fs::metadata(&config_path) - .expect("read config file metadata") - .permissions(); - permissions.set_mode(0o444); - fs::set_permissions(&config_path, permissions).expect("lock config file"); + let config_path_text = config_path.to_string_lossy().to_string(); + let _env = SkillsCliEnvironmentGuard::set(&[( + "LOONGCLAW_TEST_FAIL_CONFIG_WRITE_PATH", + Some(config_path_text.as_str()), + )]); let error = loongclaw_daemon::skills_cli::execute_skills_command( loongclaw_daemon::skills_cli::SkillsCommandOptions { @@ -899,7 +895,9 @@ fn execute_skills_command_enable_browser_preview_rolls_back_skill_on_config_pers .expect_err("enable browser preview should fail when config persistence fails"); assert!( - error.contains("Permission denied") || error.contains("permission denied"), + error.contains("Permission denied") + || error.contains("permission denied") + || error.contains("failed to write config file"), "error should surface the config write failure: {error}" ); assert!( @@ -907,11 +905,6 @@ fn execute_skills_command_enable_browser_preview_rolls_back_skill_on_config_pers "failed config persistence should not leave the helper skill installed" ); - let mut cleanup_permissions = fs::metadata(&config_path) - .expect("read config file metadata for cleanup") - .permissions(); - cleanup_permissions.set_mode(0o644); - fs::set_permissions(&config_path, cleanup_permissions).expect("unlock config file"); fs::remove_dir_all(&root).ok(); } diff --git a/crates/daemon/tests/integration/work_unit_cli.rs b/crates/daemon/tests/integration/work_unit_cli.rs index bba48d90b..304ab2164 100644 --- a/crates/daemon/tests/integration/work_unit_cli.rs +++ b/crates/daemon/tests/integration/work_unit_cli.rs @@ -196,6 +196,7 @@ fn cli_work_unit_parse_accepts_update_command_shape() { #[test] fn work_unit_cli_create_claim_complete_and_archive_round_trip() { + let _env_lock = super::lock_daemon_test_environment(); let root = unique_temp_dir("loongclaw-work-unit-cli"); let config_path = write_work_unit_config(&root); let config_path_string = config_path.display().to_string(); @@ -498,6 +499,7 @@ fn work_unit_cli_create_claim_complete_and_archive_round_trip() { #[test] fn work_unit_cli_update_text_output_uses_snake_case_status_labels() { + let _env_lock = super::lock_daemon_test_environment(); let root = unique_temp_dir("loongclaw-work-unit-cli-text"); let config_path = write_work_unit_config(&root); let repository = load_work_unit_repository(&config_path); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b8ea714f1..f372a0e3c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # LoongClaw Roadmap -Last updated: 2026-03-29 +Last updated: 2026-03-20 This roadmap is execution-focused. Every stage has: @@ -61,9 +61,9 @@ Delivered: - external profile integrity lock (`security_scan.profile_sha256`) with fail-closed behavior - external profile signature verification (`security_scan.profile_signature`, ed25519) - JSONL SIEM export lane (`security_scan.siem_export`) with optional fail-closed mode -- kernel-level request-policy gate for tool calls through `PolicyEngine::authorize(...)` - plus `PolicyExtensionChain`, with explicit deny/approval-required outcomes before - tool dispatch (Rule of Two) +- kernel-level tool-call policy gate via policy extensions and execution-layer + dispatch with explicit deny/approval-required outcomes before tool dispatch + (Rule of Two) - WASM static scan controls: - allowed artifact paths - module size cap @@ -91,24 +91,12 @@ Focus: runtime-grade isolation for untrusted extension execution. Delivered in current baseline: - WASM runtime execution lane wired into `bridge_execution` with Wasmtime backend. -- Core-module WASM host ABI v0 for plugin data exchange: - - request payload delivery into guest memory - - structured JSON output capture from guest memory - - allowlisted guest-readable config access via namespaced `provider.` / `channel.` keys - - bounded guest logging surfaced in runtime evidence - - explicit guest abort propagation - - backward-compatible fallback to legacy `run() -> ()` - Policy-driven runtime guardrails in `bridge_support.security_scan.runtime`: - required `allowed_path_prefixes` when `execute_wasm_component=true` (fail closed) - - optional `guest_readable_config_keys` allowlist for WASM guest config reads - `max_component_bytes` - - optional `max_output_bytes` for host ABI output capture - optional `fuel_limit` - - optional `timeout_ms` enforced through Wasmtime epoch interruption - Runtime isolation tests for: - successful wasm execution - - timeout-guarded execution without cache reuse - - timeout-triggered termination for non-returning modules - runtime prefix denial - runtime size-limit denial - invalid runtime policy denial @@ -118,6 +106,7 @@ Remaining deliverables: - WASM runtime lane with enforced resource limits: - CPU budget refinement - memory limits + - timeout/termination policy - process bridge sandbox profile tiers (`restricted`, `balanced`, `trusted`) aligned with the shared execution-tier contract used by browser and WASM evidence surfaces - hot-reload lifecycle hooks: @@ -179,66 +168,9 @@ Delivered in current baseline: - `tool_search` operation for runtime tool discovery over: - loaded providers in integration catalog - scanned-but-not-absorbed plugin descriptors - - explicit trust-aware filtering via query prefixes (`trust:official`, `tier:verified-community`) - and structured `trust_tiers` spec fields for deterministic operator workflows - - operator-visible `trust_filter_summary` output so filtered scope and fail-closed - conflicts are auditable in `run-spec` reports - - top-level `tool_search_summary` on spec run reports so operators can review - trust scope and top matches without digging through raw `outcome.results` - - `run-spec --render-summary` stderr rendering for operator-facing trust review - and discovery summaries without breaking stdout JSON consumers - - typed audit emission for trust-aware discovery (`ToolSearchEvaluated`) so - audit triage can flag conflicting trust filters and trust-filtered empty - result sets - - operator-facing audit summary hints (`last_triage_label`, - `last_triage_summary`, `last_triage_hint`) so trust-aware discovery failures - remain actionable after the original `run-spec` output is gone - - audit browser filters (`audit recent/summary --kind`, `--triage-label`) so - operators can inspect trust-sensitive discovery failures without manually - scanning unrelated audit history - - dedicated `audit discovery` operator view so trust-aware tool search - failures can be triaged by query substring, requested/effective trust tier, - and last filtered discovery context without hand-composing event-kind - filters - - inclusive audit time-window filters (`--since-epoch-s`, - `--until-epoch-s`) across recent/summary/discovery so retained operator - review can isolate a single rollout or incident window - - pack/agent scoped audit filters (`--pack-id`, `--agent-id`) so retained - review can collapse to one workload or one operator session without raw - journal post-processing - - event/token scoped audit drill-down (`--event-id`, `--token-id`) across - recent/summary/discovery so operators can isolate one retained event or - follow a token across `TokenIssued`, `TokenRevoked`, and - `AuthorizationDenied` without journal post-processing - - grouped `audit summary --group-by pack|agent|token` rollups so retained - audit windows can be collapsed into per-identity event/triage summaries - before operators jump into one incident trail - - grouped `audit discovery --group-by pack|agent` rollups so trust-aware - tool-search history can be collapsed into per-workload trust/triage - summaries before operators inspect one filtered event slice - - grouped discovery `drill_down_command` handoff plus `audit recent` - trust-aware filters (`--query-contains`, `--trust-tier`) so grouped - hotspots can be replayed directly as exact retained event windows - - grouped discovery `correlated_summary_command` handoff so the same hotspot - can be widened into workload-scoped `audit summary` review without - discovery-only filters masking adjacent audit failures - - grouped discovery correlated summary preview so widened audit triage is - visible inline before operators leave the discovery surface - - grouped discovery focus signals (`additional_events`, - `non_discovery_*_counts`, `attention_hint`) so adjacent audit degradation - is highlighted instead of being hidden inside the full correlated preview - - grouped discovery `remediation_hint` so adjacent audit signals can point to - the next operator action instead of only surfacing more widened evidence - - grouped discovery `correlated_remediation_command` so the strongest - adjacent signal can jump straight into the next retained-audit command - - dedicated `audit token-trail` lifecycle view so one retained token can be - reconstructed with issued/denied/revoked summary fields, full matching - timeline entries, and explicit truncation reporting when the selected - window is too small - translation-aligned retrieval payloads: - runtime profile hints (`bridge_kind`, `adapter_family`, `entrypoint_hint`, `source_language`) - plugin semantic fields (`summary`, `tags`, `input_examples`, `output_examples`, `defer_loading`) - - plugin provenance/trust fields (`provenance_summary`, `trust_tier`) - `programmatic_tool_call` operation for server-side tool orchestration: - step model (`set_literal`, `json_pointer`, `connector_call`, `connector_batch`, `conditional`) - connector allowlist and call-budget enforcement @@ -296,20 +228,6 @@ Acceptance criteria: Status: planned Focus: open ecosystem without sacrificing trust boundaries. -Delivered in current baseline: - -- `loongclaw plugins init ` scaffolds a manifest-first plugin - package root with a canonical `loongclaw.plugin.json`, current host - compatibility defaults, and a README that routes authors into shared - package diagnosis instead of internal crate spelunking -- `loongclaw plugins doctor --root ` reuses the shared - `plugin_preflight` contract for author-facing package diagnosis, defaulting - to the `sdk_release` profile while surfacing setup truth, remediation - classes, and required operator follow-up actions -- package-manifest runtime projection now also honors explicit - `metadata.source_language`, so language-specific scaffolded packages keep - canonical bridge, adapter-family, and preflight language semantics - Planned deliverables: - multi-language plugin intake pipeline: @@ -376,15 +294,17 @@ Delivered in current baseline: - release-first install flow with checksum-verified prebuilt binaries and explicit source fallback (`scripts/install.sh`, `scripts/install.ps1`) - runtime-visible tool advertising so capability snapshots and provider tool schemas follow the actually enabled tool surface - Cargo feature flags for MVP packaging controls -- product specs for installation, onboarding, one-shot ask, doctor, browser automation, tool surface, channel setup, runtime experiment, the local product control plane, and Web UI expectations +- product specs for installation, onboarding, one-shot ask, doctor, browser automation, tool surface, channel setup, runtime experiment, and Web UI expectations - experiment-state operator surface foundation: - `runtime-snapshot` persists lineage-aware runtime checkpoint artifacts - `runtime-restore` replays a persisted checkpoint as a dry-run or apply plan - `runtime-experiment start|finish|show|compare` records baseline snapshot, mutation summary, result snapshot, evaluation metrics, warnings, final decision, and optional snapshot-backed runtime deltas for operator review - `runtime-capability propose|review|show` records one run-derived capability candidate, bounded scope, required capabilities, explicit operator review, and any recorded snapshot-backed delta evidence without mutating live runtime state - `runtime-capability index` groups matching candidate records into deterministic capability families, emits compact evidence digests including delta-evidence coverage and changed runtime surfaces, and evaluates readiness as `ready`, `not_ready`, or `blocked` - - `runtime-capability plan` resolves one indexed capability family into a deterministic dry-run promotion plan with artifact identity, blockers, approval checklist, rollback hints, provenance, and the same family-level delta evidence digest - - `runtime-capability apply` materializes one deterministic governed `memory_stage_profile` artifact from a promotable capability family, keeps the output idempotent, and rejects conflicting or unsupported apply paths instead of mutating live runtime state directly + - `runtime-capability plan` resolves one indexed capability family into a deterministic dry-run promotion plan with artifact identity, blockers, approval checklist, rollback hints, provenance, family-level delta evidence digest, and a structured draft payload preview + - `runtime-capability apply` materializes one governed draft artifact under the planned delivery surface for a promotable family, reuses the planned payload shape, and keeps repeated applies idempotent while leaving live runtime state untouched + - `runtime-capability activate` turns one applied draft artifact into a governed activation dry-run or real activation for supported target kinds while remaining explicit, target-aware, idempotent, and backed by surfaced verification evidence, rollback guidance, and one persisted activation record + - `runtime-capability rollback` replays one persisted activation record as a dry-run or real rollback for supported target kinds so operators can restore the recorded pre-activation state without ad hoc manual cleanup - modular channel/provider architecture for extension-safe evolution: - `app/channel/feishu/*` split into adapter/payload/webhook layers - Feishu encrypted webhook payload decrypt lane with signature verification @@ -404,20 +324,7 @@ Remaining deliverables: - expand beyond installer scripts into package-manager distribution only after release adoption is stable - experiment-state operator surface follow-through: - use the shipped snapshot/restore/experiment/capability record layer as the prerequisite for later evaluator pipelines and automated skill-optimization loops - - keep the new promotion planner as the contract for governed executors; only the explicit `memory_stage_profile` apply lane is shipped today, and other promotion targets stay read-only until their executor contracts exist -- runtime productization over already-shipped substrate: - - background task UX on top of session runtime: - - expose task-shaped create, inspect, wait, follow, cancel, and recover flows over the current async delegate child-session substrate - - surface approval-pending and tool-narrowing state as task diagnostics instead of raw session-runtime detail only - - keep cron, heartbeat, and service-owned scheduling out of the first slice - - product-mode managed skills UX: - - add search, recommendation, and explicit acquisition guidance over the current managed, user, and project skill inventory - - explain eligibility, visibility, shadowing, first-use guidance, and product-mode fit rather than requiring operators to know a `skill_id` up front - - keep install and invoke explicit and governed instead of drifting into blind auto-install - - scoped memory retrieval productization: - - add query-aware retrieval and broaden beyond session-summary-only hydration - - make provenance and injection reason operator-visible - - ship local text search before embedding-dependent retrieval + - keep the new dry-run promotion planner read-only and use it as the contract for any future promotion executor instead of jumping directly to automatic mutation - managed browser automation companion: - keep `browser.open`, `browser.extract`, and `browser.click` as the shipped safe browser lane - partial governed adapter skeleton now exists for richer page actions: @@ -426,22 +333,7 @@ Remaining deliverables: - still add isolated browser profile lifecycle and release packaging around the companion runtime - keep richer browser automation exposed only through truthful runtime-visible tool advertising and governed tool contracts - browser-facing product surface: - - Web UI implementation as a thin shell over the local product control plane plus existing ask/chat, onboarding, dashboard, and browser semantics, not a separate assistant runtime - - current product mode stays same-origin and localhost-only by default, but - that operating boundary is not the long-term architecture endpoint -- gateway service foundation: - - land the first explicit daemon-owned gateway owner contract through - `gateway run`, `gateway status`, and `gateway stop`, while keeping - `multi-channel-serve` as the attached compatibility wrapper instead of the - long-term runtime-owner noun - - extract channel, ACP, and runtime-snapshot payload builders into shared - service read models that can feed CLI, dashboard, Web UI, and future - paired/browser/mobile clients - - centralize bind ownership, route mounting, local admin auth, pairing, and - detached service lifecycle in the gateway while preserving kernel, app, and - ACP boundaries - - use the gateway layer as the prerequisite for richer long-lived runtimes - such as Discord, Slack, WhatsApp, and other gateway-native channel surfaces + - Web UI implementation as a thin shell over existing ask/chat, onboarding, dashboard, and browser semantics, not a separate assistant runtime Acceptance criteria: @@ -450,9 +342,6 @@ Acceptance criteria: - shell/file/web/browser tools obey policy constraints and emit auditable outcomes - advertised tools match the actually invokable runtime surface for the current config and compiled features - channel/provider modules can be toggled by feature flags without core code edits -- service-oriented product surfaces can converge on one daemon-owned gateway - host without introducing a second assistant runtime or weakening kernel/app - governance ## Quality Gate Matrix (Always On) @@ -554,39 +443,7 @@ adding more surface breadth. Trade-off: lowers merge risk and control-plane debt, but requires disciplined ownership extraction instead of feature-driven growth inside large files. -### D8: Local product control plane foundation - -LoongClaw now has enough real runtime substrate that the next platform risk is -surface drift rather than missing primitives. - -The repo already has: - -- a real ACP control plane -- a durable session repository -- operator-facing `onboard`, `doctor`, `acp-status`, and observability surfaces - -What it still lacks is one localhost-only product control plane contract that -future HTTP and Web UI work can consume. - -Without that layer, `#217`, `#296`, and `#403` can drift into: - -- browser-only runtime semantics -- a gateway-local session model -- a giant product gateway that starts stealing authority from the kernel - -The preferred path is smaller: - -- keep the kernel as authority -- keep ACP internal and real -- use `SessionRepository` as the canonical product session plane -- extract a shared local control plane for status, sessions, approvals, support - flows, and future turn submission - -Trade-off: this adds one explicit platform layer, but it prevents duplicated -surface logic and keeps future gateway/UI work aligned with the kernel-first -architecture. - -### D9: Shared execution security tiers +### D8: Shared execution security tiers The roadmap already names process sandbox profile tiers, but the wider runtime still needs one shared execution-tier vocabulary across process, browser, and WASM lanes. Without that, each lane @@ -602,7 +459,7 @@ Current first-slice mapping: its runtime gate is open - `trusted` - reserved for future explicit high-trust runtime lanes rather than assumed by default -### D10: First-party workflow packs on hardened primitives +### D9: First-party workflow packs on hardened primitives Once the runtime base is harder, LoongClaw should turn that into a small set of first-party workflow packs that prove the kernel's value in operator-facing tasks such as release/review work, @@ -616,9 +473,8 @@ instead of preceding it. 1. Kernel-first runtime closure and direct-path retirement 2. Persistent audit sink and query baseline 3. ACP control-plane hardening and recovery -4. Local product control plane foundation -5. Shared execution security tiers across process/browser/WASM lanes -6. First-party workflow packs on hardened runtime primitives +4. Shared execution security tiers across process/browser/WASM lanes +5. First-party workflow packs on hardened runtime primitives Execution package for this order: diff --git a/docs/product-specs/runtime-capability.md b/docs/product-specs/runtime-capability.md index c68c404db..49f42ea21 100644 --- a/docs/product-specs/runtime-capability.md +++ b/docs/product-specs/runtime-capability.md @@ -9,12 +9,12 @@ experiment should be crystallized into a reusable lower-layer capability. ## Acceptance Criteria - [ ] LoongClaw exposes a `runtime-capability` command family with `propose`, - `review`, `show`, `index`, `plan`, and `apply` subcommands. + `review`, `show`, `index`, `plan`, `apply`, `activate`, and `rollback` + subcommands. - [ ] `runtime-capability propose` creates a persisted capability-candidate artifact from one finished `runtime-experiment` run. - [ ] The candidate artifact records one explicit target type: - `managed_skill`, `programmatic_flow`, `profile_note_addendum`, or - `memory_stage_profile`. + `managed_skill`, `programmatic_flow`, or `profile_note_addendum`. - [ ] The candidate artifact records one bounded scope, normalized tags, and normalized required capabilities without mutating live runtime state. - [ ] When the source run still points at recorded baseline and result snapshot @@ -34,158 +34,36 @@ experiment should be crystallized into a reusable lower-layer capability. names across that family. - [ ] Each capability family reports readiness as `ready`, `not_ready`, or `blocked` from explicit evidence checks rather than opaque heuristics. -- [ ] `memory_stage_profile` families stay `not_ready` unless accepted - candidates include snapshot-delta evidence with at least one allowlisted - changed surface: `memory_selected`, `memory_policy`, - `context_engine_selected`, or `context_engine_compaction`. - [ ] `runtime-capability plan` resolves one indexed family into a dry-run promotion plan that describes the target lower-layer artifact, stable artifact id, blockers, approval checklist, rollback hints, provenance - references, and the aggregated delta-evidence digest without mutating - runtime state. -- [ ] `runtime-capability apply` reuses the existing `plan` contract and - materializes one deterministic lower-layer artifact only when the chosen - family is currently promotable. -- [ ] In v1, `runtime-capability apply` supports only the - `memory_stage_profile` target kind and persists one governed artifact - under the family root's `memory_stage_profiles/` delivery surface. -- [ ] Re-applying the same promotable `memory_stage_profile` family is - idempotent when the existing materialized artifact already matches the - deterministic expected content. -- [ ] `runtime-capability apply` fails closed for unknown family ids, - non-promotable families, unsupported target kinds, or conflicting - existing materialized output. + references, the aggregated delta-evidence digest, and one structured + draft payload preview without mutating runtime state. +- [ ] `runtime-capability apply` materializes one governed draft artifact for a + promotable family under the target delivery surface without mutating live + runtime state, reuses the planned payload shape, and remains idempotent + when the output already matches. +- [ ] `runtime-capability activate` consumes one applied draft artifact and + either dry-runs or applies one governed activation path for the supported + target kind, remains idempotent when the live runtime already matches the + draft, surfaces explicit verification evidence plus rollback hints, and + persists one activation record before reporting success. +- [ ] `runtime-capability rollback` consumes one persisted activation record + and either dry-runs or restores the recorded pre-activation state for the + supported target kind, remaining idempotent when the live runtime already + matches the recorded rollback target. - [ ] Product docs describe `runtime-capability` as the governed review layer - above `runtime-experiment`, with `index`/readiness and `plan` forming the - planning ladder below explicit promotion executors or any future - automated promotion loop. + above `runtime-experiment`, with `index`/readiness, `plan`, `apply`, + `activate`, and `rollback` forming the operator-facing promotion ladder + below any future automated promotion loop. ## Out of Scope - Automatically generating or applying managed skills -- Automatically generating or applying programmatic flows -- Automatically mutating `profile_note` or runtime config -- `runtime-capability apply` support for targets other than - `memory_stage_profile` -- Automatic promotion, rollback, or optimizer orchestration +- Automatically generating or activating programmatic flows +- Automatically mutating `profile_note` or runtime config without an explicit + operator activation step +- Automatic promotion or optimizer orchestration - Persisted capability-family state or background indexing daemons - Persisted promotion-plan artifacts or plan caches - Candidate queues, dashboards, or autonomous ranking systems - -## Dry-Run Plan Payload - -`runtime-capability plan` now carries one additional dry-run payload field: -`planned_payload`. - -- `planned_payload` is emitted only when the planned family target is - `memory_stage_profile`. -- For `managed_skill`, `programmatic_flow`, and `profile_note_addendum`, - `planned_payload` stays `null`. -- The payload is governed review data only. It does not auto-apply anything to - runtime, and it does not yet encode executable memory-stage settings. - -The JSON shape is: - -```json -{ - "planned_payload": { - "memory_stage_profile": { - "schema_version": 1, - "artifact_kind": "memory_stage_profile", - "profile": { - "id": "memory-stage-profile-...", - "summary": "Promote governed memory pipeline intent into a reusable profile", - "review_scope": "Governed memory pipeline promotion intent only", - "required_capabilities": ["memory_read"], - "tags": ["memory", "pipeline"] - }, - "provenance": { - "family_id": "8f5c2d1a4b7e...", - "accepted_candidate_ids": ["capability-candidate-..."], - "evidence_digest": { - "changed_surfaces": [ - "context_engine_compaction", - "memory_policy" - ] - } - } - } - } -} -``` - -For v1, `planned_payload.memory_stage_profile.profile` is derived directly from -the existing proposal and planned-artifact data already present in the plan -report: - -- `profile.id` comes from `planned_artifact.artifact_id` -- `profile.summary` comes from `planned_artifact.summary` -- `profile.review_scope` comes from `planned_artifact.bounded_scope` -- `profile.required_capabilities` comes from - `planned_artifact.required_capabilities` -- `profile.tags` comes from `planned_artifact.tags` -- `artifact_kind` matches `planned_artifact.artifact_kind` - -The payload provenance is intentionally compact: - -- `provenance.family_id` names the indexed capability family that was planned -- `provenance.accepted_candidate_ids` includes only accepted candidates in - stable family order -- `provenance.evidence_digest.changed_surfaces` is a compact digest built from - accepted-candidate snapshot-delta evidence only - -That compact digest is narrower than the broader family-level plan evidence. -Rejected-only or undecided-only delta surfaces may still appear under the main -report `evidence.changed_surfaces`, but they are excluded from -`planned_payload.memory_stage_profile.provenance.evidence_digest.changed_surfaces`. - -## Apply v1 Materialization - -`runtime-capability apply` is the first explicit governed promotion executor. -It does not mutate live runtime configuration. Instead, it materializes one -runtime-owned `memory_stage_profile` artifact from the existing dry-run plan -contract. - -For v1: - -- `apply` calls the same indexed-family planner internally instead of building a - second planning path. -- The persisted artifact content is deterministic and excludes volatile - execution-time timestamps. -- Execution-time details such as whether the file was newly written or already - matched are reported in the apply result, not baked into the artifact body. -- The materialized artifact uses the non-`runtime_capability` schema surface - `memory_stage_profile`, so future capability scans ignore it safely even when - it lives under the same root. - -The persisted JSON shape is: - -```json -{ - "schema": { - "version": 1, - "surface": "memory_stage_profile", - "purpose": "runtime_capability_apply_output" - }, - "artifact_kind": "memory_stage_profile", - "artifact_id": "memory-stage-profile-...", - "delivery_surface": "memory_stage_profiles", - "profile": { - "id": "memory-stage-profile-...", - "summary": "Promote governed memory pipeline intent into a reusable profile", - "review_scope": "Governed memory pipeline promotion intent only", - "required_capabilities": ["memory_read"], - "tags": ["memory", "pipeline"] - }, - "provenance": { - "family_id": "8f5c2d1a4b7e...", - "accepted_candidate_ids": ["capability-candidate-..."], - "evidence_digest": { - "changed_surfaces": [ - "context_engine_compaction", - "memory_policy" - ] - } - } -} -``` diff --git a/docs/releases/architecture-drift-2026-04.md b/docs/releases/architecture-drift-2026-04.md index 3db6c79b3..1b70d21a5 100644 --- a/docs/releases/architecture-drift-2026-04.md +++ b/docs/releases/architecture-drift-2026-04.md @@ -1,7 +1,7 @@ # Architecture Drift Report 2026-04 ## Summary -- Generated at: 2026-04-08T10:59:24Z +- Generated at: 2026-04-08T12:02:32Z - Report month: `2026-04` - Baseline report: docs/releases/architecture-drift-2026-03.md - Hotspots tracked: 14 @@ -23,13 +23,13 @@ | chat_runtime | `structural_size,operational_density` | `crates/app/src/chat.rs` | 6848 | 7300 | 452 | 123 | 160 | 37 | 93.8% | WATCH | 6936 | -1.3% | PASS | 146 | | channel_mod | `structural_size,operational_density` | `crates/app/src/channel/mod.rs` | 1786 | 6400 | 4614 | 0 | 110 | 110 | 27.9% | HEALTHY | 1779 | 0.4% | PASS | 0 | | turn_coordinator | `structural_size,operational_density` | `crates/app/src/conversation/turn_coordinator.rs` | 10094 | 11200 | 1106 | 83 | 120 | 37 | 90.1% | WATCH | 10831 | -6.8% | PASS | 98 | -| tools_mod | `structural_size` | `crates/app/src/tools/mod.rs` | 14970 | 15000 | 30 | 54 | 70 | 16 | 99.8% | TIGHT | 14472 | 3.4% | PASS | 54 | +| tools_mod | `structural_size` | `crates/app/src/tools/mod.rs` | 14999 | 15000 | 1 | 55 | 70 | 15 | 100.0% | TIGHT | 14472 | 3.6% | PASS | 54 | | daemon_lib | `structural_size` | `crates/daemon/src/lib.rs` | 6447 | 6500 | 53 | 200 | 210 | 10 | 99.2% | TIGHT | 6324 | 1.9% | PASS | 210 | | onboard_cli | `structural_size` | `crates/daemon/src/onboard_cli.rs` | 9787 | 9800 | 13 | 237 | 250 | 13 | 99.9% | TIGHT | 9519 | 2.8% | PASS | 228 | ## Prioritization Signals - BREACH hotspots (>100% of any tracked budget): none -- TIGHT hotspots (>=95% of any tracked budget): spec_runtime (100.0%), spec_execution (96.6%), acp_manager (100.0%), acpx_runtime (100.0%), channel_config (100.0%), tools_mod (99.8%), daemon_lib (99.2%), onboard_cli (99.9%) +- TIGHT hotspots (>=95% of any tracked budget): spec_runtime (100.0%), spec_execution (96.6%), acp_manager (100.0%), acpx_runtime (100.0%), channel_config (100.0%), tools_mod (100.0%), daemon_lib (99.2%), onboard_cli (99.9%) - WATCH hotspots (>=85% and <95% of any tracked budget): memory_mod (93.8%), channel_registry (90.0%), chat_runtime (93.8%), turn_coordinator (90.1%) - Mixed-class hotspots (size plus operational density): chat_runtime, channel_mod, turn_coordinator @@ -69,7 +69,7 @@ - +