diff --git a/core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs b/core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs index 09306950d..586bb49cb 100644 --- a/core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs +++ b/core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs @@ -44,6 +44,7 @@ fn main() -> Result<(), String> { let lookup_args = match surface { "sol-chat" => vec!["chat".to_string()], "sol-import" => vec!["import".to_string()], + "sol-notify" => vec!["notify".to_string()], _ => argv, }; let entry = resolve_surface_leaf(surface, &lookup_args); diff --git a/core/crates/solstone-core-sol-client-cli/src/help.rs b/core/crates/solstone-core-sol-client-cli/src/help.rs index e9dca4536..a283e387f 100644 --- a/core/crates/solstone-core-sol-client-cli/src/help.rs +++ b/core/crates/solstone-core-sol-client-cli/src/help.rs @@ -6,6 +6,7 @@ use solstone_core_sol_client::aggregate::{self, InventoryEntry}; use solstone_core_sol_client::command::CommandOutput; const ROOT_CONTRACT_JSON: &str = include_str!("../../../fixtures/native-sol/root-contract-v1.json"); +const NOTIFY_HELP: &str = "usage: sol notify [-h] [--title TITLE] [--icon ICON] [--event EVENT]\n [--action ACTION] [--facet FACET] [--app APP]\n [--badge BADGE] [--auto-dismiss AUTO_DISMISS] [--no-dismiss]\n [-v] [-d]\n message [message ...]\n\nSend a notification via callosum\n\npositional arguments:\n message notification message text\n\noptions:\n -h, --help show this help message and exit\n --title TITLE notification title\n --icon ICON emoji icon\n --event EVENT event name (default: show)\n --action ACTION URL path to open on click\n --facet FACET facet context\n --app APP source app name\n --badge BADGE badge text or number\n --auto-dismiss AUTO_DISMISS\n auto-dismiss after N milliseconds\n --no-dismiss make notification non-dismissible\n -v, --verbose Enable verbose output\n -d, --debug Enable debug logging\n"; pub struct RootHelpStatus<'a> { pub journal_path: Option<&'a str>, @@ -91,6 +92,10 @@ pub fn render_top_level_help(command: &str, args: &[String]) -> Option "sol-chat", "import" => "sol-import", @@ -354,13 +359,27 @@ mod tests { assert!(root_call_groups().contains(&"journal".to_string())); } + #[test] + fn notify_top_level_help_matches_argparse_bytes() { + let output = + render_top_level_help("notify", &["--help".to_string()]).expect("notify help output"); + + assert_eq!(output.stdout, NOTIFY_HELP); + assert_eq!(output.stderr, ""); + assert_eq!(output.exit, 0); + assert_eq!(output.stdout.len(), 992); + } + #[test] fn every_positive_inventory_leaf_renders_declared_metadata() { let mut seen = BTreeSet::new(); let mut scanned = 0; let mut secondary_scanned = 0; for entry in aggregate::entries() { - if !matches!(entry.surface, "sol-call" | "sol-chat" | "sol-import") { + if !matches!( + entry.surface, + "sol-call" | "sol-chat" | "sol-import" | "sol-notify" + ) { continue; } scanned += 1; diff --git a/core/crates/solstone-core-sol-client-cli/src/lib.rs b/core/crates/solstone-core-sol-client-cli/src/lib.rs index 659e56836..ce1b00353 100644 --- a/core/crates/solstone-core-sol-client-cli/src/lib.rs +++ b/core/crates/solstone-core-sol-client-cli/src/lib.rs @@ -8,7 +8,7 @@ use solstone_core_sol_client::aggregate; use solstone_core_sol_client::command::{CommandContext, CommandOutput}; use solstone_core_sol_client::seam::{ BuildIdentityProvider, ChatEventSource, ClientItemIdProvider, Clock, FileProvider, - HttpTransport, + HttpTransport, NotificationSink, }; pub mod help; @@ -18,6 +18,7 @@ pub enum Outcome { Migrated { path: Vec }, Chat { args: Vec }, Import { args: Vec }, + Notify { args: Vec }, MovedStub { name: OsString }, Unsupported { args: Vec }, } @@ -29,6 +30,7 @@ pub struct DispatchSeams<'a> { pub files: Option<&'a dyn FileProvider>, pub build_identity: Option<&'a dyn BuildIdentityProvider>, pub client_item_ids: Option<&'a dyn ClientItemIdProvider>, + pub notification_sink: Option<&'a dyn NotificationSink>, } #[must_use] @@ -55,6 +57,16 @@ pub fn evaluate_args(args: &[OsString]) -> Outcome { }, ) } + [command, rest @ ..] if command == OsStr::new("notify") => { + match_generated_surface_path("sol-notify", &[String::from("notify")]).map_or_else( + || Outcome::Unsupported { + args: args.to_vec(), + }, + |_entry| Outcome::Notify { + args: rest.to_vec(), + }, + ) + } _ => Outcome::Unsupported { args: args.to_vec(), }, @@ -84,6 +96,7 @@ pub fn dispatch_sol_chat_with_seams( files: seams.files, build_identity: seams.build_identity, client_item_ids: seams.client_item_ids, + notification_sink: None, }) } @@ -110,6 +123,34 @@ pub fn dispatch_sol_import_with_seams( files: seams.files, build_identity: seams.build_identity, client_item_ids: seams.client_item_ids, + notification_sink: None, + }) +} + +#[must_use] +pub fn dispatch_sol_notify_with_seams( + args: &[String], + env: &BTreeMap, + stdin: &str, + today: &str, + seams: DispatchSeams<'_>, +) -> CommandOutput { + let Some((_, handler)) = match_generated_surface_path("sol-notify", &[String::from("notify")]) + else { + return CommandOutput::failure("Unsupported native sol command.\n", 64); + }; + handler(CommandContext { + args, + env, + stdin, + today, + transport: seams.transport, + clock: seams.clock, + chat_events: None, + files: seams.files, + build_identity: seams.build_identity, + client_item_ids: seams.client_item_ids, + notification_sink: seams.notification_sink, }) } @@ -152,6 +193,7 @@ pub fn dispatch_sol_call( files: None, build_identity: None, client_item_ids: None, + notification_sink: None, }, ) } @@ -179,6 +221,7 @@ pub fn dispatch_sol_call_with_seams( files: seams.files, build_identity: seams.build_identity, client_item_ids: seams.client_item_ids, + notification_sink: None, }) } @@ -292,6 +335,16 @@ mod tests { ); } + #[test] + fn routes_top_level_notify_to_notify_shell() { + assert_eq!( + evaluate_args(&args(&["notify", "hello"])), + Outcome::Notify { + args: args(&["hello"]) + } + ); + } + #[test] fn classifies_unported_call_as_unsupported_without_spawn_path() { assert_eq!( diff --git a/core/crates/solstone-core-sol-client-cli/tests/parity.rs b/core/crates/solstone-core-sol-client-cli/tests/parity.rs index 4a3612289..917265f7b 100644 --- a/core/crates/solstone-core-sol-client-cli/tests/parity.rs +++ b/core/crates/solstone-core-sol-client-cli/tests/parity.rs @@ -8,7 +8,8 @@ use serde_json::{Value, json}; use solstone_core_sol_client::error::ClientError; use solstone_core_sol_client::seam::{ ChatInput, ExpectedHttpCall, FakeBuildIdentityProvider, FakeClientItemIdProvider, FakeClock, - FixtureFileProvider, RecordedHttpCall, ScriptedChatEventSource, ScriptedHttpTransport, + FixtureFileProvider, RecordedHttpCall, RecordingNotificationSink, ScriptedChatEventSource, + ScriptedHttpTransport, }; use solstone_core_sol_client::sse::iter_sse_events; use solstone_core_sol_client::transport::{ @@ -17,7 +18,7 @@ use solstone_core_sol_client::transport::{ }; use solstone_core_sol_client_cli::{ DispatchSeams, dispatch_sol_call_with_seams, dispatch_sol_chat_with_seams, - dispatch_sol_import_with_seams, + dispatch_sol_import_with_seams, dispatch_sol_notify_with_seams, }; const ACTIVITIES_VECTORS: &str = @@ -38,6 +39,7 @@ const IMPORT_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/i const LEDGER_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/ledger.jsonl"); const LINK_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/link.jsonl"); const MOVED_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/moved.jsonl"); +const NOTIFY_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/notify.jsonl"); const PROFILE_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/profile.jsonl"); const SETTINGS_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/settings.jsonl"); const SOL_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/sol.jsonl"); @@ -67,6 +69,7 @@ fn native_matches_sol_call_parity_vectors() { .chain(load_vectors(LEDGER_VECTORS)) .chain(load_vectors(LINK_VECTORS)) .chain(load_vectors(MOVED_VECTORS)) + .chain(load_vectors(NOTIFY_VECTORS)) .chain(load_vectors(PROFILE_VECTORS)) .chain(load_vectors(SETTINGS_VECTORS)) .chain(load_vectors(SOL_VECTORS)) @@ -105,6 +108,15 @@ fn run_vector(vector: &Value) { .and_then(Value::as_str) .unwrap_or("11111111111141118111111111111111"), ); + let notification_sink = if vector["notification_sink"] + .get("fail") + .and_then(Value::as_bool) + .unwrap_or(false) + { + RecordingNotificationSink::failing() + } else { + RecordingNotificationSink::new() + }; let output = if vector["surface"].as_str() == Some("sol-chat") { dispatch_sol_chat_with_seams( @@ -119,6 +131,7 @@ fn run_vector(vector: &Value) { files: Some(&files), build_identity: Some(&build_identity), client_item_ids: Some(&client_item_ids), + notification_sink: None, }, ) } else if vector["surface"].as_str() == Some("sol-import") { @@ -135,6 +148,23 @@ fn run_vector(vector: &Value) { files: Some(&files), build_identity: Some(&build_identity), client_item_ids: Some(&client_item_ids), + notification_sink: None, + }, + ) + } else if vector["surface"].as_str() == Some("sol-notify") { + dispatch_sol_notify_with_seams( + &argv, + &env, + stdin, + today, + DispatchSeams { + transport: &transport, + clock: Some(&clock), + chat_events: None, + files: Some(&files), + build_identity: Some(&build_identity), + client_item_ids: Some(&client_item_ids), + notification_sink: Some(¬ification_sink), }, ) } else { @@ -150,16 +180,21 @@ fn run_vector(vector: &Value) { files: Some(&files), build_identity: Some(&build_identity), client_item_ids: Some(&client_item_ids), + notification_sink: None, }, ) }; transport.assert_done(); - let actual = json!({ + let mut actual = json!({ "stdout": output.stdout, "stderr": output.stderr, "exit": output.exit, "requests": recorded_calls_to_json(transport.recorded()), }); + let notifications = notification_sink.recorded(); + if vector["expected"].get("notifications").is_some() || !notifications.is_empty() { + actual["notifications"] = recorded_notifications_to_json(notifications); + } let normalizations = normalization_array(vector); assert_eq!( normalize_result(actual, &normalizations), @@ -169,6 +204,10 @@ fn run_vector(vector: &Value) { ); } +fn recorded_notifications_to_json(lines: Vec) -> Value { + Value::Array(lines.into_iter().map(Value::String).collect()) +} + fn load_vectors(text: &str) -> Vec { text.lines() .filter(|line| !line.trim().is_empty()) diff --git a/core/crates/solstone-core-sol-client/src/command.rs b/core/crates/solstone-core-sol-client/src/command.rs index b78d82e96..9a390cda7 100644 --- a/core/crates/solstone-core-sol-client/src/command.rs +++ b/core/crates/solstone-core-sol-client/src/command.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use crate::seam::{ BuildIdentityProvider, ChatEventSource, ClientItemIdProvider, Clock, FileProvider, - HttpTransport, + HttpTransport, NotificationSink, }; #[derive(Clone, Copy)] @@ -20,6 +20,7 @@ pub struct CommandContext<'a> { pub files: Option<&'a dyn FileProvider>, pub build_identity: Option<&'a dyn BuildIdentityProvider>, pub client_item_ids: Option<&'a dyn ClientItemIdProvider>, + pub notification_sink: Option<&'a dyn NotificationSink>, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/core/crates/solstone-core-sol-client/src/generated/inventory.rs b/core/crates/solstone-core-sol-client/src/generated/inventory.rs index 87c8e827d..b3d18cef1 100644 --- a/core/crates/solstone-core-sol-client/src/generated/inventory.rs +++ b/core/crates/solstone-core-sol-client/src/generated/inventory.rs @@ -37,6 +37,8 @@ mod solstone_think_native_chat_command_rs; mod solstone_think_native_import_command_rs; #[path = "../../../../../solstone/think/native/moved/command.rs"] mod solstone_think_native_moved_command_rs; +#[path = "../../../../../solstone/think/native/notify/command.rs"] +mod solstone_think_native_notify_command_rs; #[path = "../../../../../solstone/think/tools/native/health/command.rs"] mod solstone_think_tools_native_health_command_rs; #[path = "../../../../../solstone/think/tools/native/ledger/command.rs"] @@ -2075,6 +2077,20 @@ pub const ENTRIES: &[InventoryEntry] = &[ contract_operation_id: None, handler: "navigate", }, + InventoryEntry { + surface: "sol-notify", + path: &["notify"], + kind: "top-level", + help: "Send a notification via callosum", + authority_path: "solstone/think/native/notify/authority.toml", + params_json: "[{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"argument\",\"multiple\":false,\"name\":\"message\",\"nargs\":-1,\"options\":[\"message\"],\"required\":true,\"secondary\":[],\"type\":\"text\"},{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"title\",\"nargs\":1,\"options\":[\"--title\"],\"required\":false,\"secondary\":[],\"type\":\"text\"},{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"icon\",\"nargs\":1,\"options\":[\"--icon\"],\"required\":false,\"secondary\":[],\"type\":\"text\"},{\"count\":false,\"default\":\"show\",\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"event\",\"nargs\":1,\"options\":[\"--event\"],\"required\":false,\"secondary\":[],\"type\":\"text\"},{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"action\",\"nargs\":1,\"options\":[\"--action\"],\"required\":false,\"secondary\":[],\"type\":\"text\"},{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"facet\",\"nargs\":1,\"options\":[\"--facet\"],\"required\":false,\"secondary\":[],\"type\":\"text\"},{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"app\",\"nargs\":1,\"options\":[\"--app\"],\"required\":false,\"secondary\":[],\"type\":\"text\"},{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"badge\",\"nargs\":1,\"options\":[\"--badge\"],\"required\":false,\"secondary\":[],\"type\":\"text\"},{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"auto_dismiss\",\"nargs\":1,\"options\":[\"--auto-dismiss\"],\"required\":false,\"secondary\":[],\"type\":\"integer\"},{\"count\":false,\"default\":false,\"flag_value\":true,\"hidden\":false,\"is_flag\":true,\"kind\":\"option\",\"multiple\":false,\"name\":\"no_dismiss\",\"nargs\":1,\"options\":[\"--no-dismiss\"],\"required\":false,\"secondary\":[],\"type\":\"boolean\"},{\"count\":false,\"default\":false,\"flag_value\":true,\"hidden\":false,\"is_flag\":true,\"kind\":\"option\",\"multiple\":false,\"name\":\"verbose\",\"nargs\":1,\"options\":[\"-v\",\"--verbose\"],\"required\":false,\"secondary\":[],\"type\":\"boolean\"},{\"count\":false,\"default\":false,\"flag_value\":true,\"hidden\":false,\"is_flag\":true,\"kind\":\"option\",\"multiple\":false,\"name\":\"debug\",\"nargs\":1,\"options\":[\"-d\",\"--debug\"],\"required\":false,\"secondary\":[],\"type\":\"boolean\"}]", + entry_type: "top-level-notify", + operation_id: "notify.top_level", + method: None, + route: None, + contract_operation_id: None, + handler: "notify", + }, InventoryEntry { surface: "sol-call", path: &["health", "summary"], @@ -2391,6 +2407,7 @@ pub const HANDLERS: &[Handler] = &[ solstone_think_native_import_command_rs::import_top_level, solstone_think_native_moved_command_rs::identity, solstone_think_native_moved_command_rs::navigate, + solstone_think_native_notify_command_rs::notify, solstone_think_tools_native_health_command_rs::summary, solstone_think_tools_native_health_command_rs::full, solstone_think_tools_native_health_command_rs::for_range, diff --git a/core/crates/solstone-core-sol-client/src/seam.rs b/core/crates/solstone-core-sol-client/src/seam.rs index 770557e56..395a2fdfe 100644 --- a/core/crates/solstone-core-sol-client/src/seam.rs +++ b/core/crates/solstone-core-sol-client/src/seam.rs @@ -57,6 +57,15 @@ pub trait ClientItemIdProvider { fn client_item_id(&self) -> String; } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotificationSinkError { + Unavailable, +} + +pub trait NotificationSink { + fn send_line(&self, line: &str) -> Result<(), NotificationSinkError>; +} + pub trait FileProvider { fn read(&self, path: &Path) -> IoResult>; fn read_to_string(&self, path: &Path) -> std::io::Result; @@ -123,6 +132,42 @@ pub struct ScriptedChatEventSource { inputs: RefCell>, } +#[derive(Debug, Default)] +pub struct RecordingNotificationSink { + recorded: RefCell>, + fail: bool, +} + +impl RecordingNotificationSink { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn failing() -> Self { + Self { + recorded: RefCell::new(Vec::new()), + fail: true, + } + } + + #[must_use] + pub fn recorded(&self) -> Vec { + self.recorded.borrow().clone() + } +} + +impl NotificationSink for RecordingNotificationSink { + fn send_line(&self, line: &str) -> Result<(), NotificationSinkError> { + self.recorded.borrow_mut().push(line.to_string()); + if self.fail { + return Err(NotificationSinkError::Unavailable); + } + Ok(()) + } +} + impl ScriptedChatEventSource { #[must_use] pub fn new(inputs: Vec) -> Self { diff --git a/core/crates/solstone-core-sol/src/lib.rs b/core/crates/solstone-core-sol/src/lib.rs index 3292ab5c2..2b3f6051c 100644 --- a/core/crates/solstone-core-sol/src/lib.rs +++ b/core/crates/solstone-core-sol/src/lib.rs @@ -3,7 +3,9 @@ use std::collections::BTreeMap; use std::ffi::{OsStr, OsString}; -use std::io::{IsTerminal, Read, Result as IoResult}; +use std::io::{IsTerminal, Read, Result as IoResult, Write}; +#[cfg(unix)] +use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; use std::sync::{Mutex, mpsc}; @@ -21,13 +23,13 @@ use solstone_core_sol_client::command::CommandOutput; use solstone_core_sol_client::port::read_convey_port; use solstone_core_sol_client::seam::{ BuildIdentityProvider, ChatEventSource, ChatInput, ClientItemIdProvider, Clock, FileProvider, - HttpTransport, ProcessOutput, ProcessSpawner, + HttpTransport, NotificationSink, NotificationSinkError, ProcessOutput, ProcessSpawner, }; use solstone_core_sol_client::sse::SseDecoder; use solstone_core_sol_client::transport::UreqHttpTransport; use solstone_core_sol_client_cli::{ DispatchSeams, Outcome, dispatch_sol_call_with_seams, dispatch_sol_chat_with_seams, - dispatch_sol_import_with_seams, evaluate_args, help, + dispatch_sol_import_with_seams, dispatch_sol_notify_with_seams, evaluate_args, help, }; mod generated; @@ -47,8 +49,7 @@ const COMPAT_SENTINEL_ARMED: &str = "armed"; const COMPAT_ARGV0_MARKER_PREFIX: &str = "__solstone_native_argv0="; const COMPAT_RECURSION_ERROR: &str = "sol: compatibility dispatch recursion detected. Reinstall solstone and solstone-core."; -const TOP_LEVEL_COMPAT_COMMANDS: &[&str] = - &["notify", "doctor", "check", "contract", "skills", "link"]; +const TOP_LEVEL_COMPAT_COMMANDS: &[&str] = &["doctor", "check", "skills", "link"]; pub fn run(public_argv0: &str, args: Vec) -> ExitCode { run_with_stdin_provider(public_argv0, args, &RealStdinProvider) @@ -88,6 +89,9 @@ fn run_with_stdin_provider( [command, rest @ ..] if command == OsStr::new("import") => { run_top_level_native(public_argv0, &args, "import", rest, stdin_provider) } + [command, rest @ ..] if command == OsStr::new("notify") => { + run_top_level_native(public_argv0, &args, "notify", rest, stdin_provider) + } [flag, ..] if flag.to_string_lossy().starts_with('-') => { render_output(usage_error_output()) } @@ -480,6 +484,7 @@ fn run_dispatched( let build_identity = RealBuildIdentityProvider; let client_item_ids = RealClientItemIdProvider; let chat_events = ChannelChatEventSource::default(); + let notification_sink = UnixNotificationSink::new(journal.path.join("health/callosum.sock")); let output = match outcome { Outcome::Migrated { .. } | Outcome::MovedStub { .. } => dispatch_sol_call_with_seams( @@ -494,6 +499,7 @@ fn run_dispatched( files: Some(&files), build_identity: Some(&build_identity), client_item_ids: Some(&client_item_ids), + notification_sink: None, }, ), Outcome::Chat { .. } => dispatch_sol_chat_with_seams( @@ -508,6 +514,7 @@ fn run_dispatched( files: Some(&files), build_identity: Some(&build_identity), client_item_ids: Some(&client_item_ids), + notification_sink: None, }, ), Outcome::Import { .. } => dispatch_sol_import_with_seams( @@ -522,6 +529,22 @@ fn run_dispatched( files: Some(&files), build_identity: Some(&build_identity), client_item_ids: Some(&client_item_ids), + notification_sink: None, + }, + ), + Outcome::Notify { .. } => dispatch_sol_notify_with_seams( + &args, + &env, + &stdin, + &today, + DispatchSeams { + transport: &transport, + clock: None, + chat_events: None, + files: Some(&files), + build_identity: Some(&build_identity), + client_item_ids: Some(&client_item_ids), + notification_sink: Some(¬ification_sink), }, ), Outcome::Unsupported { .. } => unsupported_output(), @@ -767,6 +790,43 @@ fn discover_binary_home() -> Result { discover_home(None, fallback.as_deref()) } +#[derive(Debug)] +struct UnixNotificationSink { + socket_path: PathBuf, +} + +impl UnixNotificationSink { + fn new(socket_path: PathBuf) -> Self { + Self { socket_path } + } +} + +impl NotificationSink for UnixNotificationSink { + fn send_line(&self, line: &str) -> Result<(), NotificationSinkError> { + #[cfg(unix)] + { + let timeout = Some(Duration::from_secs(2)); + let mut stream = UnixStream::connect(&self.socket_path) + .map_err(|_| NotificationSinkError::Unavailable)?; + stream + .set_write_timeout(timeout) + .map_err(|_| NotificationSinkError::Unavailable)?; + stream + .set_read_timeout(timeout) + .map_err(|_| NotificationSinkError::Unavailable)?; + stream + .write_all(line.as_bytes()) + .map_err(|_| NotificationSinkError::Unavailable)?; + Ok(()) + } + #[cfg(not(unix))] + { + let _ = line; + Err(NotificationSinkError::Unavailable) + } + } +} + #[derive(Debug)] struct SystemClock { started: Instant, @@ -1233,6 +1293,7 @@ mod tests { os_args(&["call", "activities", "list", "--help"]), os_args(&["chat", "--help"]), os_args(&["import", "--help"]), + os_args(&["notify", "--help"]), ] { let _ = run_with_stdin_provider("sol", args, &provider); } @@ -1259,6 +1320,7 @@ mod tests { files: None, build_identity: None, client_item_ids: None, + notification_sink: None, }, ); @@ -1422,4 +1484,89 @@ mod tests { &unknown_outcome )); } + + #[test] + fn retired_contract_is_unsupported_not_compat() { + assert!(!is_top_level_compat_command(OsStr::new("contract"))); + assert!(matches!( + evaluate_args(&os_args(&["contract"])), + Outcome::Unsupported { .. } + )); + } + + #[cfg(unix)] + #[test] + fn notify_no_socket_renders_send_failure_with_real_sink() { + let root = temp_path("notify-no-socket"); + fs::create_dir_all(&root).expect("create notify temp dir"); + let output = run_notify_with_real_sink( + &["hello"], + &UnixNotificationSink::new(root.join("callosum.sock")), + ); + + assert_eq!(output.stdout, ""); + assert_eq!( + output.stderr, + "Failed to send notification (is callosum running?)\n" + ); + assert_eq!(output.exit, 1); + fs::remove_dir_all(root).expect("cleanup notify no socket"); + } + + #[cfg(unix)] + #[test] + fn notify_accept_then_close_renders_send_failure_with_real_sink() { + use std::os::unix::net::UnixListener; + + let root = temp_path("notify-reject"); + fs::create_dir_all(&root).expect("create notify temp dir"); + let socket = root.join("callosum.sock"); + let listener = UnixListener::bind(&socket).expect("bind notify socket"); + let handle = thread::spawn(move || { + let (stream, _) = listener.accept().expect("accept notify socket"); + drop(stream); + }); + let message = "x".repeat(1024 * 1024); + let start = Instant::now(); + let output = + run_notify_with_real_sink(&[message.as_str()], &UnixNotificationSink::new(socket)); + + assert!( + start.elapsed() < Duration::from_secs(2), + "notify send failure exceeded two-second bound" + ); + assert_eq!(output.stdout, ""); + assert_eq!( + output.stderr, + "Failed to send notification (is callosum running?)\n" + ); + assert_eq!(output.exit, 1); + handle.join().expect("notify listener thread"); + fs::remove_dir_all(root).expect("cleanup notify reject"); + } + + #[cfg(unix)] + fn run_notify_with_real_sink(args: &[&str], sink: &UnixNotificationSink) -> CommandOutput { + let args = args + .iter() + .map(|value| (*value).to_string()) + .collect::>(); + let env = BTreeMap::new(); + let transport = ScriptedHttpTransport::new(vec![]); + dispatch_sol_notify_with_seams( + &args, + &env, + "", + "20260723", + DispatchSeams { + transport: &transport, + clock: None, + chat_events: None, + files: None, + build_identity: None, + client_item_ids: None, + notification_sink: Some(sink), + }, + ) + } } diff --git a/core/crates/solstone-core/tests/version.rs b/core/crates/solstone-core/tests/version.rs index b2a899cb2..f9091182f 100644 --- a/core/crates/solstone-core/tests/version.rs +++ b/core/crates/solstone-core/tests/version.rs @@ -53,6 +53,30 @@ fn sol_root_output(program: &Path, cwd: &Path, public_argv0: &str) -> Output { ) } +#[cfg(unix)] +fn compat_child_with_retry(program: &Path, public_argv0: &str) -> std::process::Child { + for _ in 0..100 { + match Command::new(program) + .arg(identity_arg(public_argv0)) + .args(["check", "message"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(child) => return child, + Err(error) if error.kind() == ErrorKind::ExecutableFileBusy => { + sleep(Duration::from_millis(20)); + } + Err(error) => panic!("solstone-core should spawn: {error:?}"), + } + } + panic!( + "fake solstone-core stayed busy after retries: {}", + program.display() + ) +} + #[test] fn version_writes_stdout_and_exits_zero() { let output = Command::new(bin()) @@ -357,7 +381,6 @@ fn sol_root_installed_layout_canonicalizes_lib64_alias_independent_of_cwd() { fn sol_and_solstone_identities_forward_compat_with_public_argv0_identity() { use std::io::Write; use std::os::unix::fs::PermissionsExt; - use std::process::Stdio; let env_root = temp_path("compat-sibling-python"); let bin_dir = env_root.join("bin"); @@ -388,14 +411,7 @@ fn sol_and_solstone_identities_forward_compat_with_public_argv0_identity() { ("sol", "__solstone_native_argv0=sol"), ("solstone", "__solstone_native_argv0=solstone"), ] { - let mut child = Command::new(&fake_solstone_core) - .arg(identity_arg(public_argv0)) - .args(["notify", "message"]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("solstone-core should spawn"); + let mut child = compat_child_with_retry(&fake_solstone_core, public_argv0); child .stdin .as_mut() @@ -408,7 +424,7 @@ fn sol_and_solstone_identities_forward_compat_with_public_argv0_identity() { assert_eq!( String::from_utf8(output.stdout).expect("stdout should be utf-8"), format!( - "sentinel=armed\npython_argv=<-P><-m><{expected_marker}>\nstdin=payload" + "sentinel=armed\npython_argv=<-P><-m><{expected_marker}>\nstdin=payload" ) ); assert_eq!( diff --git a/core/fixtures/native-sol/parity/notify.jsonl b/core/fixtures/native-sol/parity/notify.jsonl new file mode 100644 index 000000000..e30f155cf --- /dev/null +++ b/core/fixtures/native-sol/parity/notify.jsonl @@ -0,0 +1,5 @@ +{"id":"notify.help","surface":"sol-notify","argv":["--help"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723"},"transport":{"requests":[]},"expected":{"stdout":"usage: sol notify [-h] [--title TITLE] [--icon ICON] [--event EVENT]\n [--action ACTION] [--facet FACET] [--app APP]\n [--badge BADGE] [--auto-dismiss AUTO_DISMISS] [--no-dismiss]\n [-v] [-d]\n message [message ...]\n\nSend a notification via callosum\n\npositional arguments:\n message notification message text\n\noptions:\n -h, --help show this help message and exit\n --title TITLE notification title\n --icon ICON emoji icon\n --event EVENT event name (default: show)\n --action ACTION URL path to open on click\n --facet FACET facet context\n --app APP source app name\n --badge BADGE badge text or number\n --auto-dismiss AUTO_DISMISS\n auto-dismiss after N milliseconds\n --no-dismiss make notification non-dismissible\n -v, --verbose Enable verbose output\n -d, --debug Enable debug logging\n","stderr":"","exit":0,"requests":[]},"normalizations":[]} +{"id":"notify.success.message_only","surface":"sol-notify","argv":["hello","from","native"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723"},"transport":{"requests":[]},"expected":{"stdout":"","stderr":"Notification sent\n","exit":0,"requests":[],"notifications":["{\"tract\": \"notification\", \"event\": \"show\", \"message\": \"hello from native\"}\n"]},"normalizations":[]} +{"id":"notify.success.all_options","surface":"sol-notify","argv":["--title","Test","--icon","\ud83d\udd14","--event","custom","--action","/open","--facet","work","--app","alerts","--badge","7","--auto-dismiss","3000","--no-dismiss","-v","--verbose","-d","--debug","h\u00e9llo","world"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723"},"transport":{"requests":[]},"expected":{"stdout":"","stderr":"Notification sent\n","exit":0,"requests":[],"notifications":["{\"tract\": \"notification\", \"event\": \"custom\", \"message\": \"h\\u00e9llo world\", \"title\": \"Test\", \"icon\": \"\\ud83d\\udd14\", \"action\": \"/open\", \"facet\": \"work\", \"app\": \"alerts\", \"badge\": \"7\", \"autoDismiss\": 3000, \"dismissible\": false}\n"]},"normalizations":[]} +{"id":"notify.failure.sink","surface":"sol-notify","argv":["hello"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723"},"notification_sink":{"fail":true},"transport":{"requests":[]},"expected":{"stdout":"","stderr":"Failed to send notification (is callosum running?)\n","exit":1,"requests":[],"notifications":["{\"tract\": \"notification\", \"event\": \"show\", \"message\": \"hello\"}\n"]},"normalizations":[]} +{"id":"notify.error.bad_auto_dismiss","surface":"sol-notify","argv":["--auto-dismiss","nope","hello"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723"},"transport":{"requests":[]},"expected":{"stdout":"","stderr":"usage: sol notify [-h] [--title TITLE] [--icon ICON] [--event EVENT]\n [--action ACTION] [--facet FACET] [--app APP]\n [--badge BADGE] [--auto-dismiss AUTO_DISMISS] [--no-dismiss]\n [-v] [-d]\n message [message ...]\n\nSend a notification via callosum\n\npositional arguments:\n message notification message text\n\noptions:\n -h, --help show this help message and exit\n --title TITLE notification title\n --icon ICON emoji icon\n --event EVENT event name (default: show)\n --action ACTION URL path to open on click\n --facet FACET facet context\n --app APP source app name\n --badge BADGE badge text or number\n --auto-dismiss AUTO_DISMISS\n auto-dismiss after N milliseconds\n --no-dismiss make notification non-dismissible\n -v, --verbose Enable verbose output\n -d, --debug Enable debug logging\nsol notify: error: argument --auto-dismiss: invalid int value: 'nope'\n","exit":2,"requests":[]},"normalizations":[]} diff --git a/core/fixtures/native-sol/root-contract-v1.json b/core/fixtures/native-sol/root-contract-v1.json index 68040c66d..3edc7b309 100644 --- a/core/fixtures/native-sol/root-contract-v1.json +++ b/core/fixtures/native-sol/root-contract-v1.json @@ -24,8 +24,7 @@ { "commands": [ "skills", - "link", - "contract" + "link" ], "heading": "Tools" } @@ -53,7 +52,7 @@ "identity" ], "days_line": "Days: 2", - "expected_bare_sol_stdout": "sol - journal access CLI (solstone)\n\nJournal: ${JOURNAL}\nDays: 2\n\nUsage: sol [args...]\n\nConversation\n chat\n\nYour journal\n call\n import\n\nSee & diagnose\n notify\n doctor\n check\n\nTools\n skills\n link\n contract\n\nApps (sol call ):\n call activities\n call awareness\n call body\n call chat\n call entities\n call facets\n call import\n call link\n call settings\n call sol\n call speakers\n call support\n call thinking\n call transcripts\n call health\n call journal\n call ledger\n call navigate\n call profile\n call identity\n\n", + "expected_bare_sol_stdout": "sol - journal access CLI (solstone)\n\nJournal: ${JOURNAL}\nDays: 2\n\nUsage: sol [args...]\n\nConversation\n chat\n\nYour journal\n call\n import\n\nSee & diagnose\n notify\n doctor\n check\n\nTools\n skills\n link\n\nApps (sol call ):\n call activities\n call awareness\n call body\n call chat\n call entities\n call facets\n call import\n call link\n call settings\n call sol\n call speakers\n call support\n call thinking\n call transcripts\n call health\n call journal\n call ledger\n call navigate\n call profile\n call identity\n\n", "header": "sol - journal access CLI (solstone)", "journal_line": "Journal: ${JOURNAL}", "oracle": { diff --git a/docs/PORTING.md b/docs/PORTING.md index fc391a98b..84f00cb8c 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -253,8 +253,9 @@ Native sol client design records: `docs/design/native-sol-client/02-design.md`, `docs/design/native-sol-client/03-batch-prep.md`, `docs/design/native-sol-client/04-batch-design.md`, -`docs/design/native-sol-client/05-raw-body-parity.md`, and -`docs/design/native-sol-client/06-cutover-design.md`. +`docs/design/native-sol-client/05-raw-body-parity.md`, +`docs/design/native-sol-client/06-cutover-design.md`, and +`docs/design/native-sol-client/07-notify-contract-design.md`. ## Dual Paths And Shims diff --git a/docs/SOLCLI.md b/docs/SOLCLI.md index 9e564988c..00de3505b 100644 --- a/docs/SOLCLI.md +++ b/docs/SOLCLI.md @@ -400,7 +400,7 @@ CLI commands, a native `native/authority.toml` plus `native/command.rs`. | Group | Commands | |-------|----------| -| Think (processing) | `import`, `think`, `planner`, `indexer`, `supervisor`, `schedule`, `maintenance`, `top`, `health`, `notify`, `heartbeat` | +| Think (processing) | `import`, `think`, `planner`, `indexer`, `supervisor`, `schedule`, `maintenance`, `top`, `health`, `notify` (`sol notify`, native), `heartbeat` | | Service | `service` (+ aliases `up`, `down`, `start`), `navigate`, `identity`, `settings`, `install-provider` | | Observe (capture) | `transcribe`, `describe`, `sense`, `transfer`, `observer` | | Talent (AI agents) | `agents`, `cortex`, `talent`, `call`, `engage`, `providers` | diff --git a/docs/design/native-sol-client/07-notify-contract-design.md b/docs/design/native-sol-client/07-notify-contract-design.md new file mode 100644 index 000000000..cffac0739 --- /dev/null +++ b/docs/design/native-sol-client/07-notify-contract-design.md @@ -0,0 +1,466 @@ +# Native Sol Notify and Contract Retirement Design + +This records the W1.5a design for two coupled changes: + +- Port top-level `sol notify` from Python compatibility to native Rust. +- Retire `sol contract` from the public `sol` surface while leaving + `journal contract` and the contract tooling module intact. + +No frozen oracle blob is changed. Generated artifacts are regenerated from the +updated generators during implementation. + +## D0. Authority Entry Type + +The existing `local` entry type is not the right semantic fit for notify. +Today it is the `sol-call` placeholder entry +`solstone/apps/network/native/authority.toml:31-38`, path +`["link", "observer-pause"]`, operation `link.observer-pause`, handler +`observer_pause`. Its command implementation returns a local "not yet +available" message and has no top-level routing role. + +Decision: + +- Add a new surface `sol-notify`. +- Add a new entry type `top-level-notify`. +- Do not reuse `local`. + +Reason: notify is a first-class top-level command, like chat and import. The +inventory partition is keyed by `(surface, entry_type)`, so a new surface is +needed either way. `top-level-notify` keeps the top-level native surfaces +consistent and avoids overloading `local` with a second meaning. + +Authority sketch: + +- File: `solstone/think/native/notify/authority.toml` +- Source: `command.rs` +- `surface = "sol-notify"` +- `path = ["notify"]` +- `kind = "top-level"` +- `help = "Send a notification via callosum"` +- `operation_id = "notify.top_level"` +- `entry_type = "top-level-notify"` +- `handler = "notify"` +- No `method`, `route`, `contract_operation_id`, or + `backing_contract_operation_ids` +- Params: variadic positional `message`; string options `--title`, `--icon`, + `--event`, `--action`, `--facet`, `--app`, `--badge`; integer + `--auto-dismiss`; flag `--no-dismiss`; flags `-v/--verbose` and `-d/--debug` + +Conformance routes `top-level-notify` through `check_non_http_entry`, not +`check_top_level_backing_contracts`. That is satisfiable because the authority +must have no `method`, no `route`, no `contract_operation_id`, and no OpenAPI +backing contract. + +## D1. Notification Send Seam + +Add a notification-specific seam in +`core/crates/solstone-core-sol-client/src/seam.rs`: + +- Trait: `NotificationSink` +- Method: `send_line(&self, line: &str) -> Result<(), NotificationSinkError>` +- Error: a small typed `NotificationSinkError` whose public meaning is + "unavailable"; detailed I/O causes are deliberately not rendered + +The seam takes the fully built JSON line, including its trailing newline. This +keeps JSON byte construction in the native notify command and lets unit and +parity tests pin the exact wire string. + +Real implementation: + +- File: `core/crates/solstone-core-sol/src/lib.rs` +- Type: `UnixNotificationSink` +- State: `socket_path: PathBuf` +- Construction: in `run_dispatched`, after `resolve_process_journal_path()`, + using `journal.path.join("health").join("callosum.sock")` +- Send behavior: connect a `std::os::unix::net::UnixStream`, set write and read + timeouts to two seconds, then `write_all(line.as_bytes())` + +Timeout honesty: Python's `socket.settimeout(2.0)` covers connect and send. +Rust `UnixStream::connect` has no connect-timeout API. The native version will +set `set_write_timeout(Some(2s))` and `set_read_timeout(Some(2s))` after +connect. This bounds writes, but AF_UNIX connect can still block if the listener +backlog is saturated. The design records that as a narrow implementation +semantic difference. There is no retry, ack, read protocol, or second send +attempt. + +Fake implementation: + +- File: `core/crates/solstone-core-sol-client/src/seam.rs` +- Type: `RecordingNotificationSink` +- Behavior: records each line verbatim in insertion order; can be configured to + fail with `NotificationSinkError` + +Blast radius decision: put the sink on `CommandContext` as +`notification_sink: Option<&dyn NotificationSink>`, and thread it through +`DispatchSeams`. This touches the nine existing `CommandContext` construction +sites, but it preserves the generated handler contract +`for<'a> fn(CommandContext<'a>) -> CommandOutput` from +`core/crates/solstone-core-sol-client/src/aggregate.rs:23`. Keeping notify in +the generated inventory without `CommandContext` would require a second dispatch +path or an unused dummy handler. + +## D2. CLI Crate Threading + +In `core/crates/solstone-core-sol-client-cli/src/lib.rs`: + +- Add `Outcome::Notify`. +- Extend `evaluate_args` with a `notify` arm that resolves the `sol-notify` + authority. +- Add `dispatch_sol_notify_with_seams`, mirroring chat/import and constructing + a `CommandContext` with `notification_sink`. + +In `core/crates/solstone-core-sol/src/lib.rs`: + +- Add `[command, rest @ ..] if command == OsStr::new("notify")` to the + top-level argv match. +- Place it with `chat` and `import`, before the leading-dash arm and before the + compatibility arm. +- Add `notify` to `run_top_level_native` help resolution so + `sol notify --help` uses native help. +- In `run_dispatched`, build `UnixNotificationSink` from the already resolved + journal path and pass it through `DispatchSeams`. + +Argv convention: notify matches chat, not import. `run_top_level_native` passes +`command_args` to `run_dispatched`, so the notify dispatcher receives args +after `notify` only. Parity should therefore call `dispatch_sol_notify_with_seams` +with vector `argv` as args-only, with no `skip(1)`. + +## D3. Argument Parsing and Emission + +The native parser is hand-rolled like the existing native commands and must +match the Python argparse surface. + +Accepted syntax: + +- Positional `message`: one or more values, joined with single spaces. +- String options: `--title`, `--icon`, `--event`, `--action`, `--facet`, + `--app`, `--badge`. +- Value options support both `--name value` and `--name=value`, matching + argparse. +- `--auto-dismiss N`: parses as integer and emits JSON key `autoDismiss` as a + number. +- `--no-dismiss`: emits `dismissible: false`; native never emits + `dismissible: true`. +- `-v/--verbose` and `-d/--debug`: accepted and ignored. Python + `setup_cli()` only changes logging/setup behavior for this command; notify has + no observable verbose/debug output to preserve. +- `-h/--help`: stdout is the byte-exact P1 help text, exit 0, stderr empty. + +Absent options produce absent JSON keys, not nulls. + +Emission key order must reproduce Python's insertion order from +`solstone/think/notify_cli.py`: + +1. `tract` +2. `event` +3. `message` +4. `title` +5. `icon` +6. `action` +7. `facet` +8. `app` +9. `badge` +10. `autoDismiss` +11. `dismissible` + +`--event` defaults to `show` and is emitted as the second key. It is not part of +the optional fields map. + +JSON formatting must reproduce Python `json.dumps` defaults used by +`callosum_send`: comma-space separators, colon-space separators, ASCII escaping, +and one trailing newline. A compact `serde_json::to_string` output is not byte +compatible. + +Malformed args: + +- Missing message: exit 2. +- Unknown flag: exit 2. +- Non-integer `--auto-dismiss`: exit 2. + +The error shape follows native chat: stdout empty, stderr is HELP followed by +`sol notify: error: ...\n`. The message text should match argparse wording for +the pinned malformed vectors. + +## D4. Failure Collapse and CommandOutput + +Every sink send failure collapses to the same user-visible result: + +- stdout: empty +- stderr: `Failed to send notification (is callosum running?)\n` +- exit: 1 + +This includes no socket file, connection refused, timeout, partial write, and +any platform/path I/O failure. The real sink accepts a `PathBuf`, so non-UTF-8 +journal paths are not inherently errors on Unix. The sink itself does not +resolve the journal; journal resolution remains in `run_dispatched`. + +Successful send: + +- stdout: empty +- stderr: `Notification sent\n` +- exit: 0 + +`CommandOutput` supports this, but not via its helpers. The struct has explicit +`stdout`, `stderr`, and `exit` fields. `CommandOutput::success(stdout)` always +sets empty stderr and exit 0, while `CommandOutput::failure(stderr, exit)` sets +empty stdout. Notify success must therefore construct `CommandOutput` directly. + +## D5. `require_solstone()` Divergence + +Native notify deliberately does not reproduce Python's convey-port TCP probe. +This is a narrow behavior change. + +| State | Python today | Native after port | +| --- | --- | --- | +| Success | stdout empty; stderr `Notification sent\n`; exit 0 | same | +| Down stack before send | stdout empty; stderr `sol: solstone isn't running. Start it with 'journal up' and retry.\n`; exit 1 | stdout empty; stderr `Failed to send notification (is callosum running?)\n`; exit 1 | +| Supervisor-spawned down stack | stdout empty; stderr empty; exit 75 | stdout empty; stderr `Failed to send notification (is callosum running?)\n`; exit 1 if journal resolves and socket send fails | +| Unconfigured journal | Python setup/journal failure shape before probe | existing `run_dispatched` shape: stdout empty; stderr `native sol journal resolution failed: {error}\n`; exit 75 | + +This avoids a second availability check and treats callosum socket delivery as +the only runtime dependency for notify. + +## D6. Parity Coverage Strategy + +Use option (a): extend the parity harness. Coverage will add notify to +`required_dispatch`, so a non-HTTP parity path is required. + +Changes: + +- Add `sol-notify` branching to + `core/crates/solstone-core-sol-client-cli/tests/parity.rs`. +- Add `sol-notify => ["notify"]` mapping to + `core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs`. +- Add `core/fixtures/native-sol/parity/notify.jsonl`. +- Add `required_top_level_notify` to `scripts/check_native_sol_coverage.py`. + +Vector schema addition: + +- New optional key under `expected`: `notifications`. +- Shape: array of strings. +- Each string is one exact line passed to `NotificationSink::send_line`, + including the trailing `\n`. +- Existing vectors that omit `expected.notifications` mean an expected empty + notification list. The harness should not require editing all old vectors. +- If a vector omits `expected.notifications` but the fake records any lines, + the vector fails. + +Notify vectors to include: + +- Help vector: proves exact P1 help on stdout, exit 0, no send. +- Minimal success vector: proves stdout empty, stderr `Notification sent\n`, + exit 0, and one notification line with `tract = "notification"`, + `event = "show"`, message joined from positional words, one JSON object, and a + single trailing newline. +- Full-options success vector: proves optional key order, `autoDismiss` as a + number, and `dismissible` only as false. +- Failure vector: fake sink failure proves the collapsed stderr/exit. +- Malformed-args vector: proves exit 2 argparse-style errors. + +Coverage should treat notify as non-HTTP. It cannot use `requests` for +request-binding. Instead, notify coverage requires parity success/failure and at +least one non-empty `expected.notifications` binding for `notify.top_level`. + +## D7. Root-Contract Retirement Mechanism + +`scripts/build_native_sol_root_contract.py` needs the smallest explicit filter. + +Add module-level `RETIRED_ACCESS_COMMANDS = frozenset({"contract"})`. + +Add filtering after AST extraction of `ACCESS_HELP_GROUPS`, before +`render_stdout()` is used: + +- `access_groups()` still extracts the frozen oracle exactly as today. +- A new filter checks that every retired command is present in the extracted + oracle before filtering. +- If a retired command is absent, error. This prevents the filter from silently + rotting after the frozen oracle changes. +- Remove retired commands from their groups. +- If filtering empties a group, error rather than dropping the group. An empty + group would mean the oracle structure changed enough that the retire list + needs review. + +Slot in `build()`: after `groups = access_groups(tree, names)` and before +`apps = call_groups()` / `render_stdout(...)`. + +Do not touch: + +- Frozen commit/path/blob constants. +- Blob hash verification. +- The frozen blob itself. +- `core/fixtures/native-sol/root-contract-v1.json` by hand. + +After filtering `contract`, the Tools group still contains sibling-owned +`skills` and `link`, so the group survives. This remains a sibling-contention +area if the skills/link sessions land nearby changes. + +## D8. Contract Provenance String + +Use `python -m solstone.think.contract_cli build` as the replacement. It matches +`Makefile:826` and works from a packaged wheel without a repository root. + +Exact replacements: + +| File | Before | After | +| --- | --- | --- | +| `solstone/think/contract/journal.py:130` | `sol contract build` | `python -m solstone.think.contract_cli build` | +| `solstone/think/contract/journal.py:133` | ``regenerate with `sol contract build`.`` | ``regenerate with `python -m solstone.think.contract_cli build`.`` | +| `solstone/think/contract/journal.py:157` | stale bundle message ending ``run `sol contract build``` | same message ending ``run `python -m solstone.think.contract_cli build``` | +| `solstone/think/contract_cli.py:35` | missing bundle message ending ``run `sol contract build``` | same message ending ``run `python -m solstone.think.contract_cli build``` | + +`solstone/talent/journal/contract/bundle.json` is generated and should change +through `make contract`, not by hand. The historical design reference in +`docs/design/native-sol-client/06-cutover-design.md:287` stays unchanged. + +Do not touch the OpenAPI contract-route strings in `Makefile:561` or +`scripts/check_native_sol_contract_routes.py:140,145`; they are unrelated to the +retired `sol contract` command. + +## D9. Python Notify Deletion and Test Repointing + +Delete `solstone/think/notify_cli.py`. + +Remove notify and contract from the finite top-level compatibility sets: + +- `solstone/think/sol_compat_inventory.py` +- `core/crates/solstone-core-sol/src/lib.rs::TOP_LEVEL_COMPAT_COMMANDS` + +Do not add a third compat-set expression, and do not touch +`sol_compat_cli.py`, the sentinel, the recursion guard, or +`solstone/think/sol_cli.py`. + +Repoint tests to `check` as the surviving compatibility exemplar. Avoid +`skills` and `link` because sibling sessions own those areas. + +Exact repoints: + +- `core/crates/solstone-core/tests/version.rs:393`: change compat invocation + from `notify message` to `check message`. +- `core/crates/solstone-core/tests/version.rs:411`: update asserted argv from + `` to ``. +- `tests/test_check_access_imports_clean.py:27,30,31`: use + `solstone.think.check` and `sol check --help [solstone.think.check]`. +- `tests/test_sol_compat_cli.py:183`: use bare command string `check`. +- `tests/test_sol.py:286,290`: use existing module `solstone.think.check` for + the patched `import_module` failure path. + +Access-import cleanliness: + +- Add `("sol notify --help", ["sol", "notify", "--help"])` to + `NATIVE_CASES` in `scripts/check_access_imports_clean.py:68`. +- Delete the now-dead `contract` exclusion comment and filter at + `scripts/check_access_imports_clean.py:93-97`. + +`solstone.egg-info/SOURCES.txt` is not tracked in this worktree, so deleting +`notify_cli.py` has no tracked egg-info edit. + +## D10. Test Placement + +Native notify command tests: + +- File: `solstone/think/native/notify/command.rs` +- Assertions: parser behavior, exact help output, JSON key order and formatting, + absent optional keys, `autoDismiss` numeric emission, `dismissible: false`, + ignored verbose/debug flags, malformed arg exit 2, failing sink collapse, and + success stderr with exit 0. + +Parity tests: + +- File: `core/crates/solstone-core-sol-client-cli/tests/parity.rs` +- Fixtures: `core/fixtures/native-sol/parity/notify.jsonl` +- Assertions: help text, wire bytes, success/failure stdout/stderr/exit, and + malformed args. + +Real Unix socket tests: + +- File: `core/crates/solstone-core-sol/src/lib.rs`, next to + `UnixNotificationSink`. +- Cases: no socket file and accept-then-close/reject with a local + `UnixListener` under a temp directory. +- Use `#[cfg(unix)]` for the real socket tests. +- Use `std::thread::spawn(...)` if concurrency is needed for accept-then-close; + avoid `.spawn(` and avoid a helper named `output()`. + +These tests do not violate the no-Python-spawn patterns: notify command source +contains no process spawning, the real sink test is in the core sol shell crate, +and `std::thread::spawn(...)` is not the forbidden `.spawn(` process pattern. +They also fit the unit-test rail: AF_UNIX sockets under a temp dir are local IPC, +not live network or service dependencies. + +## D11. Implementation Order + +1. Add native notify authority and command skeleton, plus the + `NotificationSink` seam, `CommandContext` field, and dispatch threading. + Update all nine `CommandContext` construction sites with + `notification_sink: None` unless notify tests pass a fake. + +2. Extend native inventory gates for `sol-notify` / `top-level-notify`: + `ENTRY_TYPES`, allowed surfaces, final totals, top-level partition map, + conformance entry-type dispatch, architecture surface recognition if needed, + and coverage's required top-level notify set. + +3. Run `make build-native-sol-inventory` during implementation. Generated + artifact: `core/crates/solstone-core-sol-client/src/generated/inventory.rs`. + This is the point where inventory/conformance/architecture can be made + structurally green again. + +4. Wire native top-level `notify` in `core/crates/solstone-core-sol/src/lib.rs` + before the leading-dash and compatibility arms. Build the real sink from the + resolved journal path in `run_dispatched`. + +5. Extend parity for non-HTTP notification side effects: harness branch, + resolver mapping, `expected.notifications` schema, and + `core/fixtures/native-sol/parity/notify.jsonl`. This is where coverage for + `notify.top_level` re-greens. + +6. Add the root-contract retire filter and remove `contract` from the public + compat sets. Run `make build-native-sol-root-contract`. Generated artifact: + `core/fixtures/native-sol/root-contract-v1.json`. This re-greens the + root-contract oracle after `contract` disappears from bare `sol` output. + +7. Delete `solstone/think/notify_cli.py`, remove `notify` from the compat sets, + repoint the five test sites to `check`, add the native `sol notify --help` + access-import case, and remove the dead contract exclusion in + `scripts/check_access_imports_clean.py`. + +8. Replace the four contract provenance command strings with + `python -m solstone.think.contract_cli build`. + +9. Run `make contract`. Generated artifact: + `solstone/talent/journal/contract/bundle.json`. + +10. Final implementation validation should use the requested narrow gates, + including the native inventory, coverage, conformance, root-contract, + compat, no-python-spawn, architecture, contract, and access-import checks. + +## Risks and Open Questions + +- JSON byte parity is the highest-risk detail. Python `json.dumps` defaults are + not the same as `serde_json::to_string`; native notify needs a deliberate + serializer or formatting path. +- Rust AF_UNIX connect timeout cannot exactly match Python's socket timeout. + The documented native behavior bounds writes, not connect. +- Adding `notification_sink` to `CommandContext` is a nine-site mechanical edit, + but it keeps the generated inventory handler model intact. +- The root-contract Tools group still contains sibling-owned `skills` and + `link`; rebase and regenerate if either sibling session lands nearby + inventory/root-contract changes first. + +## C1 Implementation Note + +Argparse was checked with `prog = "sol notify"` and `COLUMNS=80` for malformed +arguments. Python emits the usage block only, not full help: + +- Missing message: stdout empty, stderr is the usage block followed by + `sol notify: error: the following arguments are required: message\n`, exit 2. +- Unknown flag `--bogus`: stdout empty, stderr is the usage block followed by + `sol notify: error: unrecognized arguments: --bogus\n`, exit 2. +- Bad `--auto-dismiss nope`: stdout empty, stderr is the usage block followed + by `sol notify: error: argument --auto-dismiss: invalid int value: 'nope'\n`, + exit 2. + +Native `sol chat` and `sol import` intentionally diverge from argparse here: +their `argparse_error()` helpers emit the full command `HELP` text followed by +`sol : error: ...\n`. Native notify follows that existing native +surface convention, and the parity malformed-argument vector pins the full-help +shape rather than Python's usage-only shape. diff --git a/scripts/build_native_sol_inventory.py b/scripts/build_native_sol_inventory.py index b23bb4d37..7c85dc1e7 100644 --- a/scripts/build_native_sol_inventory.py +++ b/scripts/build_native_sol_inventory.py @@ -36,7 +36,14 @@ PARAM_KEYS = { } PARAM_REQUIRED_KEYS = PARAM_KEYS - {"default", "flag_value"} ORACLE_PATH = REPO_ROOT / "core/fixtures/native-sol/sol-call-grammar-v1.json" -ENTRY_TYPES = {"http", "moved-stub", "top-level-chat", "top-level-import", "local"} +ENTRY_TYPES = { + "http", + "moved-stub", + "top-level-chat", + "top-level-import", + "top-level-notify", + "local", +} COMMAND_KINDS = {"command", "callback", "top-level"} HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"} FINAL_ORACLE_TOTAL = 178 @@ -44,6 +51,7 @@ FINAL_HTTP_TOTAL = 152 FINAL_JOURNAL_PYTHON_COMPAT_TOTAL = 23 FINAL_TOP_LEVEL_CHAT_TOTAL = 1 FINAL_TOP_LEVEL_IMPORT_TOTAL = 1 +FINAL_TOP_LEVEL_NOTIFY_TOTAL = 1 FINAL_STUB_COUNTS = {"moved-stub": 2, "local": 1} FINAL_HTTP_GROUP_COUNTS = { "activities": 6, @@ -146,7 +154,7 @@ def parse_entry( raise ValueError(f"{label}: path must be a non-empty string list") command_path = tuple(raw_path) surface = raw_entry.get("surface", "sol-call") - if surface not in {"sol-call", "sol-chat", "sol-import"}: + if surface not in {"sol-call", "sol-chat", "sol-import", "sol-notify"}: raise ValueError(f"{label}: unsupported surface {surface!r}") kind = require_string(raw_entry, "kind", Path(label)) if kind not in COMMAND_KINDS: @@ -477,6 +485,7 @@ def check_top_level_partition(entries: list[AuthorityEntry]) -> list[str]: expected = { ("sol-chat", "top-level-chat"): FINAL_TOP_LEVEL_CHAT_TOTAL, ("sol-import", "top-level-import"): FINAL_TOP_LEVEL_IMPORT_TOTAL, + ("sol-notify", "top-level-notify"): FINAL_TOP_LEVEL_NOTIFY_TOTAL, } actual: dict[tuple[str, str], int] = {} for entry in entries: diff --git a/scripts/build_native_sol_root_contract.py b/scripts/build_native_sol_root_contract.py index 78f07fffc..74b0698c9 100644 --- a/scripts/build_native_sol_root_contract.py +++ b/scripts/build_native_sol_root_contract.py @@ -20,6 +20,7 @@ ORACLE_BLOB = "a20570fc0994f6215a013e8c89ce7776ddec7d17" CALL_PATH = "solstone/think/call.py" JOURNAL_PLACEHOLDER = "${JOURNAL}" VERSION_PLACEHOLDER = "${VERSION}" +RETIRED_ACCESS_COMMANDS = frozenset({"contract"}) def git_bytes(*args: str) -> bytes: @@ -139,6 +140,35 @@ def access_groups(tree: ast.Module, names: dict[str, str]) -> list[dict[str, Any return groups +def filter_retired_access_commands( + groups: list[dict[str, Any]], +) -> list[dict[str, Any]]: + present = { + command + for group in groups + for command in group["commands"] + if command in RETIRED_ACCESS_COMMANDS + } + missing = sorted(RETIRED_ACCESS_COMMANDS - present) + if missing: + raise RuntimeError( + f"retired access command(s) absent from oracle: {', '.join(missing)}" + ) + filtered: list[dict[str, Any]] = [] + for group in groups: + commands = [ + command + for command in group["commands"] + if command not in RETIRED_ACCESS_COMMANDS + ] + if not commands: + raise RuntimeError( + f"retired access command filter emptied group {group['heading']!r}" + ) + filtered.append({"heading": group["heading"], "commands": commands}) + return filtered + + def call_overrides(call_tree: ast.Module) -> dict[str, str]: raw = assignment(call_tree, "CALL_NAME_OVERRIDES") if not isinstance(raw, ast.Dict): @@ -248,7 +278,7 @@ def build() -> dict[str, Any]: raise RuntimeError( "oracle no longer uses os.path.isdir(path) for Days omission" ) - groups = access_groups(tree, names) + groups = filter_retired_access_commands(access_groups(tree, names)) apps = call_groups() return { "schema": "native-sol-root-contract-v1", diff --git a/scripts/check_access_imports_clean.py b/scripts/check_access_imports_clean.py index 1f63eacf4..6ac1ed95f 100644 --- a/scripts/check_access_imports_clean.py +++ b/scripts/check_access_imports_clean.py @@ -80,6 +80,7 @@ NATIVE_CASES: tuple[tuple[str, list[str]], ...] = ( ("sol status", ["sol", "status"]), ("sol chat --help", ["sol", "chat", "--help"]), ("sol import --help", ["sol", "import", "--help"]), + ("sol notify --help", ["sol", "notify", "--help"]), ("sol call --help", ["sol", "call", "--help"]), ("sol call activities --help", ["sol", "call", "activities", "--help"]), ( @@ -90,11 +91,6 @@ NATIVE_CASES: tuple[tuple[str, list[str]], ...] = ( TOP_LEVEL_COMPAT_CASES: tuple[tuple[str, list[str], str], ...] = tuple( (f"sol {command} --help", ["sol", command, "--help"], module) for command, module in sorted(TOP_LEVEL_COMPAT_MODULES.items()) - # `contract` remains in the native compatibility allowlist, but it imports - # solstone.think.contract.journal -> jsonschema, which is owned by the - # journal-host extra. Routing to Python and thin-base importability are - # separate properties; do not grow the thin base to make this case pass. - if command != "contract" ) JOURNAL_COMPAT_CASES: tuple[tuple[str, list[str], str], ...] = ( ( diff --git a/scripts/check_native_sol_conformance.py b/scripts/check_native_sol_conformance.py index c701f7ced..9479e7f7d 100644 --- a/scripts/check_native_sol_conformance.py +++ b/scripts/check_native_sol_conformance.py @@ -101,6 +101,8 @@ def check_conformance( errors.append("native sol conformance discovered zero OpenAPI operations") if not any(authority.entry_type == "top-level-import" for authority in authorities): errors.append("native sol conformance missing top-level import authority") + if not any(authority.entry_type == "top-level-notify" for authority in authorities): + errors.append("native sol conformance missing top-level notify authority") for authority in sorted(authorities, key=lambda entry: entry.operation_id): raw_authority = raw_authority_by_operation.get(authority.operation_id) @@ -130,6 +132,8 @@ def check_conformance( "import", ) ) + elif authority.entry_type == "top-level-notify": + errors.extend(check_non_http_entry(authority, contract_by_operation)) else: errors.append( f"{authority.operation_id}: unsupported entry_type " diff --git a/scripts/check_native_sol_coverage.py b/scripts/check_native_sol_coverage.py index 6da27a658..bbabfc5ad 100644 --- a/scripts/check_native_sol_coverage.py +++ b/scripts/check_native_sol_coverage.py @@ -19,6 +19,7 @@ try: FINAL_STUB_COUNTS, FINAL_TOP_LEVEL_CHAT_TOTAL, FINAL_TOP_LEVEL_IMPORT_TOTAL, + FINAL_TOP_LEVEL_NOTIFY_TOTAL, REPO_ROOT, discover, ) @@ -28,6 +29,7 @@ except ModuleNotFoundError: # pragma: no cover - direct script execution path. FINAL_STUB_COUNTS, FINAL_TOP_LEVEL_CHAT_TOTAL, FINAL_TOP_LEVEL_IMPORT_TOTAL, + FINAL_TOP_LEVEL_NOTIFY_TOTAL, REPO_ROOT, discover, ) @@ -71,8 +73,17 @@ def check_coverage(root: Path = REPO_ROOT) -> list[str]: for entry in entries if entry.surface == "sol-import" and entry.entry_type == "top-level-import" } + required_top_level_notify = { + entry.operation_id + for entry in entries + if entry.surface == "sol-notify" and entry.entry_type == "top-level-notify" + } required_dispatch = ( - required | required_stubs | required_top_level_chat | required_top_level_import + required + | required_stubs + | required_top_level_chat + | required_top_level_import + | required_top_level_notify ) vectors = load_vectors(PARITY_DIR) resolved = resolve_vectors(PARITY_DIR, vectors) @@ -136,6 +147,11 @@ def check_coverage(root: Path = REPO_ROOT) -> list[str]: f"current top-level import authority count {len(required_top_level_import)} " f"!= {FINAL_TOP_LEVEL_IMPORT_TOTAL}" ) + if len(required_top_level_notify) != FINAL_TOP_LEVEL_NOTIFY_TOTAL: + errors.append( + f"current top-level notify authority count {len(required_top_level_notify)} " + f"!= {FINAL_TOP_LEVEL_NOTIFY_TOTAL}" + ) if not required_dispatch: errors.append("native dispatch authority set is empty") resolved_operations = { @@ -168,6 +184,21 @@ def check_coverage(root: Path = REPO_ROOT) -> list[str]: import_buckets[bucket_name], ) ) + notify_buckets = collect_buckets( + vectors, + resolved, + required_top_level_notify, + {"top-level-notify"}, + errors, + ) + for bucket_name in ("notification_binding", "success", "failure"): + errors.extend( + compare_sets( + f"top-level notify {bucket_name}", + required_top_level_notify, + notify_buckets[bucket_name], + ) + ) if not applicability_errors: errors.extend( @@ -300,6 +331,7 @@ def collect_buckets( ) -> dict[str, set[str]]: buckets: dict[str, set[str]] = { "request_binding": set(), + "notification_binding": set(), "success": set(), "failure": set(), } @@ -315,8 +347,13 @@ def collect_buckets( continue expected = vector.get("expected") or {} requests = expected.get("requests") if isinstance(expected, dict) else None + notifications = ( + expected.get("notifications") if isinstance(expected, dict) else None + ) if pins_request_shape(requests): buckets["request_binding"].add(operation_id) + if pins_notification_shape(notifications): + buckets["notification_binding"].add(operation_id) if is_failure_vector(vector, expected): buckets["failure"].add(operation_id) elif is_success_vector( @@ -326,6 +363,12 @@ def collect_buckets( return buckets +def pins_notification_shape(notifications: Any) -> bool: + return isinstance(notifications, list) and any( + isinstance(item, str) and item.endswith("\n") for item in notifications + ) + + def pins_request_shape(requests: Any) -> bool: if not isinstance(requests, list): return False diff --git a/solstone/apps/activities/native/command.rs b/solstone/apps/activities/native/command.rs index c63892a5b..891976f12 100644 --- a/solstone/apps/activities/native/command.rs +++ b/solstone/apps/activities/native/command.rs @@ -839,6 +839,7 @@ mod tests { files: None, build_identity: None, client_item_ids: None, + notification_sink: None, }); assert_eq!( diff --git a/solstone/apps/support/native/command.rs b/solstone/apps/support/native/command.rs index 83862904a..2569005c6 100644 --- a/solstone/apps/support/native/command.rs +++ b/solstone/apps/support/native/command.rs @@ -1326,6 +1326,7 @@ mod tests { files: None, build_identity: None, client_item_ids: None, + notification_sink: None, }); assert_eq!( diff --git a/solstone/talent/journal/contract/bundle.json b/solstone/talent/journal/contract/bundle.json index 92123fc00..2baa64865 100644 --- a/solstone/talent/journal/contract/bundle.json +++ b/solstone/talent/journal/contract/bundle.json @@ -1,8 +1,8 @@ { "contract": "solstone-journal-at-rest", "contract_version": 1, - "description": "Generated journal at-rest contract bundle. Do not hand-edit; regenerate with `sol contract build`.", - "generated_by": "sol contract build", + "description": "Generated journal at-rest contract bundle. Do not hand-edit; regenerate with `python -m solstone.think.contract_cli build`.", + "generated_by": "python -m solstone.think.contract_cli build", "layout": { "description": "Journal producer-facing layout lifecycle. Read paths remain broader than producer write paths for legacy compatibility.", "lifecycle": [ diff --git a/solstone/think/contract/journal.py b/solstone/think/contract/journal.py index efa5a7edc..a6024dfb3 100644 --- a/solstone/think/contract/journal.py +++ b/solstone/think/contract/journal.py @@ -127,10 +127,10 @@ def build_bundle(root: Path = ROOT) -> dict[str, Any]: return { "contract": "solstone-journal-at-rest", "contract_version": 1, - "generated_by": "sol contract build", + "generated_by": "python -m solstone.think.contract_cli build", "description": ( "Generated journal at-rest contract bundle. Do not hand-edit; " - "regenerate with `sol contract build`." + "regenerate with `python -m solstone.think.contract_cli build`." ), "layout": layout, "schemas": {key: schemas[key] for key in sorted(schemas)}, @@ -154,7 +154,10 @@ def check_artifact(path: Path = ARTIFACT_PATH) -> list[str]: current = "" if current == expected: return [] - return [f"{_repo_relative(path)} is stale; run `sol contract build`"] + return [ + f"{_repo_relative(path)} is stale; run " + "`python -m solstone.think.contract_cli build`" + ] def classify_breaking_changes( diff --git a/solstone/think/contract_cli.py b/solstone/think/contract_cli.py index 59512699c..8f5509f46 100644 --- a/solstone/think/contract_cli.py +++ b/solstone/think/contract_cli.py @@ -32,7 +32,8 @@ def _cmd_check(args: argparse.Namespace) -> int: committed = json.loads(journal.ARTIFACT_PATH.read_text(encoding="utf-8")) except FileNotFoundError: print( - "journal contract bundle is missing; run `sol contract build`", + "journal contract bundle is missing; run " + "`python -m solstone.think.contract_cli build`", file=sys.stderr, ) return 1 diff --git a/solstone/think/native/chat/command.rs b/solstone/think/native/chat/command.rs index de19c2849..8af68b3a8 100644 --- a/solstone/think/native/chat/command.rs +++ b/solstone/think/native/chat/command.rs @@ -706,6 +706,7 @@ mod tests { files: None, build_identity: None, client_item_ids: None, + notification_sink: None, }); assert_eq!( diff --git a/solstone/think/native/import/command.rs b/solstone/think/native/import/command.rs index 63f66c5fd..9d0334a72 100644 --- a/solstone/think/native/import/command.rs +++ b/solstone/think/native/import/command.rs @@ -603,6 +603,7 @@ mod tests { files: Some(files), build_identity: None, client_item_ids: Some(client_item_ids), + notification_sink: None, }) } diff --git a/solstone/think/native/notify/authority.toml b/solstone/think/native/notify/authority.toml new file mode 100644 index 000000000..18c715607 --- /dev/null +++ b/solstone/think/native/notify/authority.toml @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +schema = "native-sol-authority-v1" +source = "command.rs" + +[[entries]] +surface = "sol-notify" +path = ["notify"] +kind = "top-level" +help = "Send a notification via callosum" +operation_id = "notify.top_level" +entry_type = "top-level-notify" +handler = "notify" +params = [ + { name = "message", kind = "argument", type = "text", required = true, nargs = -1, multiple = false, options = ["message"], secondary = [], hidden = false, is_flag = false, count = false }, + { name = "title", kind = "option", type = "text", required = false, nargs = 1, multiple = false, options = ["--title"], secondary = [], hidden = false, is_flag = false, count = false }, + { name = "icon", kind = "option", type = "text", required = false, nargs = 1, multiple = false, options = ["--icon"], secondary = [], hidden = false, is_flag = false, count = false }, + { name = "event", kind = "option", type = "text", required = false, nargs = 1, multiple = false, default = "show", options = ["--event"], secondary = [], hidden = false, is_flag = false, count = false }, + { name = "action", kind = "option", type = "text", required = false, nargs = 1, multiple = false, options = ["--action"], secondary = [], hidden = false, is_flag = false, count = false }, + { name = "facet", kind = "option", type = "text", required = false, nargs = 1, multiple = false, options = ["--facet"], secondary = [], hidden = false, is_flag = false, count = false }, + { name = "app", kind = "option", type = "text", required = false, nargs = 1, multiple = false, options = ["--app"], secondary = [], hidden = false, is_flag = false, count = false }, + { name = "badge", kind = "option", type = "text", required = false, nargs = 1, multiple = false, options = ["--badge"], secondary = [], hidden = false, is_flag = false, count = false }, + { name = "auto_dismiss", kind = "option", type = "integer", required = false, nargs = 1, multiple = false, options = ["--auto-dismiss"], secondary = [], hidden = false, is_flag = false, count = false }, + { name = "no_dismiss", kind = "option", type = "boolean", required = false, nargs = 1, multiple = false, default = false, options = ["--no-dismiss"], secondary = [], hidden = false, is_flag = true, count = false, flag_value = true }, + { name = "verbose", kind = "option", type = "boolean", required = false, nargs = 1, multiple = false, default = false, options = ["-v", "--verbose"], secondary = [], hidden = false, is_flag = true, count = false, flag_value = true }, + { name = "debug", kind = "option", type = "boolean", required = false, nargs = 1, multiple = false, default = false, options = ["-d", "--debug"], secondary = [], hidden = false, is_flag = true, count = false, flag_value = true }, +] diff --git a/solstone/think/native/notify/command.rs b/solstone/think/native/notify/command.rs new file mode 100644 index 000000000..b58a863c3 --- /dev/null +++ b/solstone/think/native/notify/command.rs @@ -0,0 +1,435 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use std::fmt::Write as _; + +use crate::command::{CommandContext, CommandOutput}; + +const HELP: &str = "usage: sol notify [-h] [--title TITLE] [--icon ICON] [--event EVENT]\n [--action ACTION] [--facet FACET] [--app APP]\n [--badge BADGE] [--auto-dismiss AUTO_DISMISS] [--no-dismiss]\n [-v] [-d]\n message [message ...]\n\nSend a notification via callosum\n\npositional arguments:\n message notification message text\n\noptions:\n -h, --help show this help message and exit\n --title TITLE notification title\n --icon ICON emoji icon\n --event EVENT event name (default: show)\n --action ACTION URL path to open on click\n --facet FACET facet context\n --app APP source app name\n --badge BADGE badge text or number\n --auto-dismiss AUTO_DISMISS\n auto-dismiss after N milliseconds\n --no-dismiss make notification non-dismissible\n -v, --verbose Enable verbose output\n -d, --debug Enable debug logging\n"; +const FAILURE: &str = "Failed to send notification (is callosum running?)\n"; + +#[must_use] +pub fn notify(ctx: CommandContext<'_>) -> CommandOutput { + let parsed = match parse_args(ctx.args) { + Ok(parsed) => parsed, + Err(error) => return argparse_error(error), + }; + if parsed.help { + return CommandOutput::success(HELP); + } + if parsed.message.is_empty() { + return argparse_error("the following arguments are required: message".to_string()); + } + let Some(sink) = ctx.notification_sink else { + return send_failed(); + }; + let line = notification_line(&parsed); + if sink.send_line(&line).is_err() { + return send_failed(); + } + CommandOutput { + stdout: String::new(), + stderr: "Notification sent\n".to_string(), + exit: 0, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedArgs { + message: String, + title: Option, + icon: Option, + event: String, + action: Option, + facet: Option, + app: Option, + badge: Option, + auto_dismiss: Option, + no_dismiss: bool, + help: bool, +} + +impl Default for ParsedArgs { + fn default() -> Self { + Self { + message: String::new(), + title: None, + icon: None, + event: "show".to_string(), + action: None, + facet: None, + app: None, + badge: None, + auto_dismiss: None, + no_dismiss: false, + help: false, + } + } +} + +fn parse_args(args: &[String]) -> Result { + let mut parsed = ParsedArgs::default(); + let mut message = Vec::new(); + let mut index = 0; + while index < args.len() { + let token = &args[index]; + if token == "-h" || token == "--help" { + parsed.help = true; + } else if token == "-v" || token == "--verbose" || token == "-d" || token == "--debug" { + } else if token == "--no-dismiss" { + parsed.no_dismiss = true; + } else if token == "--" { + message.extend(args[index + 1..].iter().cloned()); + break; + } else if let Some(value) = token.strip_prefix("--title=") { + parsed.title = Some(value.to_string()); + } else if token == "--title" { + index += 1; + parsed.title = Some(take_value(args, index, "--title")?.to_string()); + } else if let Some(value) = token.strip_prefix("--icon=") { + parsed.icon = Some(value.to_string()); + } else if token == "--icon" { + index += 1; + parsed.icon = Some(take_value(args, index, "--icon")?.to_string()); + } else if let Some(value) = token.strip_prefix("--event=") { + parsed.event = value.to_string(); + } else if token == "--event" { + index += 1; + parsed.event = take_value(args, index, "--event")?.to_string(); + } else if let Some(value) = token.strip_prefix("--action=") { + parsed.action = Some(value.to_string()); + } else if token == "--action" { + index += 1; + parsed.action = Some(take_value(args, index, "--action")?.to_string()); + } else if let Some(value) = token.strip_prefix("--facet=") { + parsed.facet = Some(value.to_string()); + } else if token == "--facet" { + index += 1; + parsed.facet = Some(take_value(args, index, "--facet")?.to_string()); + } else if let Some(value) = token.strip_prefix("--app=") { + parsed.app = Some(value.to_string()); + } else if token == "--app" { + index += 1; + parsed.app = Some(take_value(args, index, "--app")?.to_string()); + } else if let Some(value) = token.strip_prefix("--badge=") { + parsed.badge = Some(value.to_string()); + } else if token == "--badge" { + index += 1; + parsed.badge = Some(take_value(args, index, "--badge")?.to_string()); + } else if let Some(value) = token.strip_prefix("--auto-dismiss=") { + parsed.auto_dismiss = Some(parse_auto_dismiss(value)?); + } else if token == "--auto-dismiss" { + index += 1; + let value = take_value(args, index, "--auto-dismiss")?; + parsed.auto_dismiss = Some(parse_auto_dismiss(value)?); + } else if token.starts_with('-') { + return Err(format!("unrecognized arguments: {token}")); + } else { + message.push(token.clone()); + } + index += 1; + } + parsed.message = message.join(" "); + Ok(parsed) +} + +fn take_value<'a>(args: &'a [String], index: usize, option: &str) -> Result<&'a str, String> { + args.get(index) + .map(String::as_str) + .ok_or_else(|| format!("argument {option}: expected one argument")) +} + +fn parse_auto_dismiss(value: &str) -> Result { + value + .parse::() + .map_err(|_| format!("argument --auto-dismiss: invalid int value: '{value}'")) +} + +fn argparse_error(error: String) -> CommandOutput { + CommandOutput::failure(format!("{HELP}sol notify: error: {error}\n"), 2) +} + +fn send_failed() -> CommandOutput { + CommandOutput::failure(FAILURE, 1) +} + +fn notification_line(parsed: &ParsedArgs) -> String { + let mut fields = vec![ + json_field("tract", JsonValue::String("notification")), + json_field("event", JsonValue::String(&parsed.event)), + json_field("message", JsonValue::String(&parsed.message)), + ]; + if let Some(value) = parsed.title.as_deref() { + fields.push(json_field("title", JsonValue::String(value))); + } + if let Some(value) = parsed.icon.as_deref() { + fields.push(json_field("icon", JsonValue::String(value))); + } + if let Some(value) = parsed.action.as_deref() { + fields.push(json_field("action", JsonValue::String(value))); + } + if let Some(value) = parsed.facet.as_deref() { + fields.push(json_field("facet", JsonValue::String(value))); + } + if let Some(value) = parsed.app.as_deref() { + fields.push(json_field("app", JsonValue::String(value))); + } + if let Some(value) = parsed.badge.as_deref() { + fields.push(json_field("badge", JsonValue::String(value))); + } + if let Some(value) = parsed.auto_dismiss { + fields.push(json_field("autoDismiss", JsonValue::Integer(value))); + } + if parsed.no_dismiss { + fields.push(json_field("dismissible", JsonValue::Bool(false))); + } + format!("{{{}}}\n", fields.join(", ")) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum JsonValue<'a> { + String(&'a str), + Integer(i64), + Bool(bool), +} + +fn json_field(key: &str, value: JsonValue<'_>) -> String { + format!("{}: {}", python_json_string(key), python_json_value(value)) +} + +fn python_json_value(value: JsonValue<'_>) -> String { + match value { + JsonValue::String(value) => python_json_string(value), + JsonValue::Integer(value) => value.to_string(), + JsonValue::Bool(value) => { + if value { + "true".to_string() + } else { + "false".to_string() + } + } + } +} + +fn python_json_string(value: &str) -> String { + let mut output = String::with_capacity(value.len() + 2); + output.push('"'); + for ch in value.chars() { + match ch { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\u{08}' => output.push_str("\\b"), + '\u{0c}' => output.push_str("\\f"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + ch if (ch as u32) < 0x20 => push_unicode_escape(&mut output, ch as u32), + ch if (ch as u32) < 0x80 => output.push(ch), + ch if (ch as u32) <= 0xffff => push_unicode_escape(&mut output, ch as u32), + ch => { + let value = ch as u32 - 0x1_0000; + push_unicode_escape(&mut output, 0xd800 + (value >> 10)); + push_unicode_escape(&mut output, 0xdc00 + (value & 0x03ff)); + } + } + } + output.push('"'); + output +} + +fn push_unicode_escape(output: &mut String, value: u32) { + write!(output, "\\u{value:04x}").expect("write to string"); +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::command::{CommandContext, CommandOutput}; + use crate::seam::{RecordingNotificationSink, ScriptedHttpTransport}; + + use super::*; + + fn string_args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + fn run_notify_case(args: &[&str], sink: Option<&RecordingNotificationSink>) -> CommandOutput { + let args = string_args(args); + let env = BTreeMap::new(); + let transport = ScriptedHttpTransport::new(vec![]); + notify(CommandContext { + args: &args, + env: &env, + stdin: "", + today: "20260723", + transport: &transport, + clock: None, + chat_events: None, + files: None, + build_identity: None, + client_item_ids: None, + notification_sink: sink.map(|sink| sink as &dyn crate::seam::NotificationSink), + }) + } + + #[test] + fn help_matches_argparse_bytes() { + let output = run_notify_case(&["--help"], None); + + assert_eq!(output, CommandOutput::success(HELP)); + assert_eq!(HELP.len(), 992); + assert!(HELP.ends_with('\n')); + } + + #[test] + fn message_only_emits_minimal_notification_line() { + let sink = RecordingNotificationSink::new(); + let output = run_notify_case(&["hello", "there"], Some(&sink)); + + assert_eq!( + output, + CommandOutput { + stdout: String::new(), + stderr: "Notification sent\n".to_string(), + exit: 0, + } + ); + assert_eq!( + sink.recorded(), + vec![ + "{\"tract\": \"notification\", \"event\": \"show\", \"message\": \"hello there\"}\n" + .to_string() + ] + ); + let line = &sink.recorded()[0]; + for absent in [ + "\"title\"", + "\"icon\"", + "\"action\"", + "\"facet\"", + "\"app\"", + "\"badge\"", + "\"autoDismiss\"", + "\"dismissible\"", + ] { + assert!(!line.contains(absent), "{absent} should be absent"); + } + } + + #[test] + fn all_options_emit_in_python_json_order() { + let sink = RecordingNotificationSink::new(); + let output = run_notify_case( + &[ + "--title", + "Test", + "--icon", + "\u{1f514}", + "--event", + "custom", + "--action", + "/open", + "--facet", + "work", + "--app", + "alerts", + "--badge", + "7", + "--auto-dismiss", + "3000", + "--no-dismiss", + "-v", + "-d", + "hello", + "world", + ], + Some(&sink), + ); + + assert_eq!(output.exit, 0); + assert_eq!( + sink.recorded(), + vec!["{\"tract\": \"notification\", \"event\": \"custom\", \"message\": \"hello world\", \"title\": \"Test\", \"icon\": \"\\ud83d\\udd14\", \"action\": \"/open\", \"facet\": \"work\", \"app\": \"alerts\", \"badge\": \"7\", \"autoDismiss\": 3000, \"dismissible\": false}\n".to_string()] + ); + let line = &sink.recorded()[0]; + assert!(line.contains("\"autoDismiss\": 3000")); + assert!(!line.contains("\"autoDismiss\": \"3000\"")); + assert!(line.contains("\"dismissible\": false")); + assert!(!line.contains("\"dismissible\": true")); + } + + #[test] + fn non_ascii_matches_python_json_dumps_ensure_ascii() { + let sink = RecordingNotificationSink::new(); + let output = run_notify_case(&["--icon", "\u{1f514}", "h\u{e9}llo"], Some(&sink)); + + assert_eq!(output.exit, 0); + assert_eq!( + sink.recorded(), + vec![ + "{\"tract\": \"notification\", \"event\": \"show\", \"message\": \"h\\u00e9llo\", \"icon\": \"\\ud83d\\udd14\"}\n" + .to_string() + ] + ); + } + + #[test] + fn no_sink_collapses_to_send_failure() { + let output = run_notify_case(&["hello"], None); + + assert_eq!( + output, + CommandOutput { + stdout: String::new(), + stderr: FAILURE.to_string(), + exit: 1, + } + ); + } + + #[test] + fn failing_sink_collapses_to_send_failure() { + let sink = RecordingNotificationSink::failing(); + let output = run_notify_case(&["hello"], Some(&sink)); + + assert_eq!( + output, + CommandOutput { + stdout: String::new(), + stderr: FAILURE.to_string(), + exit: 1, + } + ); + assert_eq!( + sink.recorded(), + vec![ + "{\"tract\": \"notification\", \"event\": \"show\", \"message\": \"hello\"}\n" + .to_string() + ] + ); + } + + #[test] + fn malformed_args_follow_native_full_help_error_shape() { + for (args, message) in [ + ( + Vec::<&str>::new(), + "the following arguments are required: message", + ), + (vec!["--bogus", "hello"], "unrecognized arguments: --bogus"), + ( + vec!["--auto-dismiss", "nope", "hello"], + "argument --auto-dismiss: invalid int value: 'nope'", + ), + ] { + let output = run_notify_case(&args, None); + assert_eq!(output.stdout, ""); + assert_eq!( + output.stderr, + format!("{HELP}sol notify: error: {message}\n") + ); + assert_eq!(output.exit, 2); + } + } +} diff --git a/solstone/think/notify_cli.py b/solstone/think/notify_cli.py deleted file mode 100644 index 227b30faf..000000000 --- a/solstone/think/notify_cli.py +++ /dev/null @@ -1,67 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import argparse -import sys - -from solstone.think.callosum import callosum_send -from solstone.think.utils import require_solstone, setup_cli - - -def main() -> None: - parser = argparse.ArgumentParser(description="Send a notification via callosum") - parser.add_argument("message", nargs="+", help="notification message text") - parser.add_argument("--title", help="notification title") - parser.add_argument("--icon", help="emoji icon") - parser.add_argument("--event", default="show", help="event name (default: show)") - parser.add_argument("--action", help="URL path to open on click") - parser.add_argument("--facet", help="facet context") - parser.add_argument("--app", help="source app name") - parser.add_argument("--badge", help="badge text or number") - parser.add_argument( - "--auto-dismiss", - type=int, - dest="auto_dismiss", - help="auto-dismiss after N milliseconds", - ) - parser.add_argument( - "--no-dismiss", - action="store_true", - dest="no_dismiss", - help="make notification non-dismissible", - ) - - args = setup_cli(parser) - require_solstone() - - message = " ".join(args.message) - kwargs = {"message": message} - - if args.title is not None: - kwargs["title"] = args.title - if args.icon is not None: - kwargs["icon"] = args.icon - if args.action is not None: - kwargs["action"] = args.action - if args.facet is not None: - kwargs["facet"] = args.facet - if args.app is not None: - kwargs["app"] = args.app - if args.badge is not None: - kwargs["badge"] = args.badge - if args.auto_dismiss is not None: - kwargs["autoDismiss"] = args.auto_dismiss - if args.no_dismiss: - kwargs["dismissible"] = False - - ok = callosum_send("notification", args.event, **kwargs) - if ok: - print("Notification sent", file=sys.stderr) - return - - print("Failed to send notification (is callosum running?)", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/solstone/think/sol_compat_inventory.py b/solstone/think/sol_compat_inventory.py index f97ca0c02..c8299cf79 100644 --- a/solstone/think/sol_compat_inventory.py +++ b/solstone/think/sol_compat_inventory.py @@ -23,10 +23,8 @@ EXIT_SOFTWARE = 70 PUBLIC_BINARIES = frozenset({"sol", "solstone"}) TOP_LEVEL_COMPAT_MODULES = { - "notify": "solstone.think.notify_cli", "doctor": "solstone.think.doctor", "check": "solstone.think.check", - "contract": "solstone.think.contract_cli", "skills": "solstone.think.skills_cli", "link": "solstone.think.link", } diff --git a/solstone/think/tools/native/health/command.rs b/solstone/think/tools/native/health/command.rs index 72e178443..363fcbbc0 100644 --- a/solstone/think/tools/native/health/command.rs +++ b/solstone/think/tools/native/health/command.rs @@ -447,6 +447,7 @@ mod tests { files: None, build_identity: None, client_item_ids: None, + notification_sink: None, }); assert_eq!( @@ -549,6 +550,7 @@ mod tests { files: None, build_identity: None, client_item_ids: None, + notification_sink: None, }); assert_eq!( diff --git a/tests/native_sol/test_parity_coverage.py b/tests/native_sol/test_parity_coverage.py index 5962628b9..be7909569 100644 --- a/tests/native_sol/test_parity_coverage.py +++ b/tests/native_sol/test_parity_coverage.py @@ -129,6 +129,8 @@ def vector_matches_entry( argv = list(vector.get("argv", [])) if entry.surface == "sol-chat": return tuple(entry.path) == ("chat",) + if entry.surface == "sol-notify": + return tuple(entry.path) == ("notify",) if entry.surface == "sol-import": return tuple(argv[: len(entry.path)]) == entry.path return tuple(argv[: len(entry.path)]) == entry.path @@ -139,7 +141,7 @@ def argv_tail( vector: dict[str, object], ) -> list[str]: argv = [str(arg) for arg in vector.get("argv", [])] - if entry.surface == "sol-chat": + if entry.surface in {"sol-chat", "sol-notify"}: return argv if entry.surface == "sol-import": return argv[1:] diff --git a/tests/test_check_access_imports_clean.py b/tests/test_check_access_imports_clean.py index 4074a1f3a..2b9c73981 100644 --- a/tests/test_check_access_imports_clean.py +++ b/tests/test_check_access_imports_clean.py @@ -24,9 +24,9 @@ def _run(*args: str) -> subprocess.CompletedProcess[str]: def test_injected_access_heavy_import_goes_red_and_names_offender() -> None: - result = _run("--inject-heavy-module", "solstone.think.notify_cli") + result = _run("--inject-heavy-module", "solstone.think.check") assert result.returncode == 1 - assert "sol notify --help [solstone.think.notify_cli]" in result.stderr - assert "solstone.think.notify_cli" in result.stderr + assert "sol check --help [solstone.think.check]" in result.stderr + assert "solstone.think.check" in result.stderr assert "numpy" in result.stderr diff --git a/tests/test_sol.py b/tests/test_sol.py index f6a4b29fd..0206edd0a 100644 --- a/tests/test_sol.py +++ b/tests/test_sol.py @@ -283,11 +283,11 @@ class TestRunCommand: def test_run_command_import_error_keeps_raw_error(self, capsys): missing = ModuleNotFoundError("No module named 'numpy'", name="numpy") with patch("importlib.import_module", side_effect=missing): - exit_code = sol.run_command("solstone.think.notify_cli") + exit_code = sol.run_command("solstone.think.check") captured = capsys.readouterr() assert exit_code == 1 - assert "Could not import module 'solstone.think.notify_cli'" in captured.err + assert "Could not import module 'solstone.think.check'" in captured.err assert "solstone[journal]" not in captured.err def test_run_command_no_main_function(self): diff --git a/tests/test_sol_compat_cli.py b/tests/test_sol_compat_cli.py index 20b3ff411..0fed55934 100644 --- a/tests/test_sol_compat_cli.py +++ b/tests/test_sol_compat_cli.py @@ -180,7 +180,7 @@ def test_compat_refuses_missing_active_or_unknown_sentinel( monkeypatch.setenv(SENTINEL, state) code = sol_compat_cli.main( - [marker_for_public_argv0("sol"), "notify"], + [marker_for_public_argv0("sol"), "check"], runner=runner, ) captured = capsys.readouterr()