cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit f8b493c8c5bbdb66d3c59eaaf13dfb5c83117072
parent a7fe9539cc2961bef0b3f592cafd81ad258c1558
Author: triesap <tyson@radroots.org>
Date:   Sat, 23 May 2026 03:12:50 +0000

local_events: add cli local work records

- add shared local events dependency and runtime append helper
- record farm create, update, and rebind writes after durable config saves
- record listing create and rebind writes with actor and listing identity
- cover dry-run non-mutation and shared local work records in CLI tests

Diffstat:
MCargo.lock | 11+++++++++++
MCargo.toml | 1+
Msrc/runtime/farm.rs | 51++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/listing.rs | 33+++++++++++++++++++++++++++++++++
Asrc/runtime/local_events.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/mod.rs | 4++++
Mtests/support/mod.rs | 20++++++++++++++++++++
Mtests/target_cli.rs | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 429 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3526,6 +3526,7 @@ dependencies = [ "radroots_events", "radroots_events_codec", "radroots_identity", + "radroots_local_events", "radroots_log", "radroots_nostr", "radroots_nostr_accounts", @@ -3600,6 +3601,16 @@ dependencies = [ ] [[package]] +name = "radroots_local_events" +version = "0.1.0-alpha.2" +dependencies = [ + "radroots_sql_core", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] name = "radroots_log" version = "0.1.0-alpha.2" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -28,6 +28,7 @@ radroots_core = { path = "../lib/crates/core", features = ["std", "serde"] } radroots_events = { path = "../lib/crates/events" } radroots_events_codec = { path = "../lib/crates/events_codec", features = ["nostr", "serde_json"] } radroots_identity = { path = "../lib/crates/identity" } +radroots_local_events = { path = "../lib/crates/local_events" } radroots_log = { path = "../lib/crates/log" } radroots_nostr_accounts = { path = "../lib/crates/nostr_accounts", features = ["os-keyring"] } radroots_nostr = { path = "../lib/crates/nostr", features = ["client", "events"] } diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -34,6 +34,7 @@ use crate::runtime::farm_config::{ self, FarmConfigDocument, FarmConfigScope, FarmConfigSelection, FarmListingDefaults, FarmMissingField, FarmPublicationStatus, ResolvedFarmConfig, SUPPORTED_FARM_CONFIG_VERSION, }; +use crate::runtime::local_events::append_local_work; use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; use crate::runtime_args::{ FarmCreateArgs, FarmFieldArg, FarmPublishArgs, FarmRebindArgs, FarmScopeArg, FarmScopedArgs, @@ -166,7 +167,15 @@ fn rebind_inner( let written_path = if dry_run { resolved.path.clone() } else { - farm_config::write(&config.paths, resolved.scope, &document)? + let written_path = farm_config::write(&config.paths, resolved.scope, &document)?; + append_farm_local_work( + config, + resolved.scope, + written_path.display().to_string(), + &document, + Some(to_seller_pubkey.as_str()), + )?; + written_path }; let state = if dry_run { "dry_run" } else { "rebound" }; @@ -247,6 +256,13 @@ pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView, let account_pubkey = configured_account .as_ref() .map(|account| account.record.public_identity.public_key_hex.as_str()); + append_farm_local_work( + config, + resolved.scope, + written_path.display().to_string(), + &resolved.document, + account_pubkey, + )?; let reason = if configured_account.is_none() { Some(missing_farm_bound_seller_reason( resolved.document.selection.account.as_str(), @@ -1622,6 +1638,13 @@ fn save_draft_view( config: &RuntimeConfig, ) -> Result<FarmSetupView, RuntimeError> { let written_path = farm_config::write(&config.paths, scope, document)?; + append_farm_local_work( + config, + scope, + written_path.display().to_string(), + document, + Some(account.record.public_identity.public_key_hex.as_str()), + )?; Ok(FarmSetupView { state: state.to_owned(), source: FARM_CONFIG_SOURCE.to_owned(), @@ -1636,6 +1659,32 @@ fn save_draft_view( }) } +fn append_farm_local_work( + config: &RuntimeConfig, + scope: FarmConfigScope, + path: String, + document: &FarmConfigDocument, + owner_pubkey: Option<&str>, +) -> Result<(), RuntimeError> { + let payload = json!({ + "record_kind": "farm_config_v1", + "scope": scope.as_str(), + "path": path, + "document": document, + }); + let subject = format!("farm:{}", document.selection.farm_d_tag); + append_local_work( + config, + subject.as_str(), + Some(document.selection.account.clone()), + owner_pubkey.map(str::to_owned), + Some(document.selection.farm_d_tag.clone()), + None, + payload, + )?; + Ok(()) +} + fn farm_update_actions( config: &RuntimeConfig, document: &FarmConfigDocument, diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -45,6 +45,7 @@ use crate::runtime::direct_relay::{ publish_parts_with_identity, }; use crate::runtime::farm_config; +use crate::runtime::local_events::append_local_work; use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; use crate::runtime::sync::{ RelayIngestScope, freshness_for_scope_from_executor, market_refresh, missing_freshness, @@ -264,6 +265,7 @@ pub fn scaffold( let (draft, defaults) = build_listing_draft(config, args)?; let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?; write_listing_draft(&output_path, &draft, false)?; + append_listing_local_work(config, output_path.as_path(), &draft)?; let mut actions = vec![format!( "radroots listing validate {}", @@ -453,6 +455,36 @@ fn write_listing_draft( Ok(()) } +fn append_listing_local_work( + config: &RuntimeConfig, + path: &Path, + draft: &ListingDraftDocument, +) -> Result<(), RuntimeError> { + let listing_id = draft.listing.d_tag.trim(); + let seller_pubkey = draft.seller_actor.pubkey.trim(); + let listing_addr = if seller_pubkey.is_empty() || listing_id.is_empty() { + None + } else { + Some(listing_addr(seller_pubkey, listing_id)) + }; + let payload = json!({ + "record_kind": DRAFT_KIND, + "path": path.display().to_string(), + "document": draft, + }); + let subject = format!("listing:{}", draft.listing.d_tag); + append_local_work( + config, + subject.as_str(), + non_empty(draft.seller_actor.account_id.clone()), + non_empty(draft.seller_actor.pubkey.clone()), + non_empty(draft.listing.farm_d_tag.clone()), + listing_addr, + payload, + )?; + Ok(()) +} + fn validate_listing_output_target(output_path: &Path) -> Result<(), RuntimeError> { if output_path.exists() { return Err(RuntimeError::Config(format!( @@ -695,6 +727,7 @@ fn rebind_inner( if !dry_run { write_listing_draft(args.file.as_path(), &draft, true)?; + append_listing_local_work(config, args.file.as_path(), &draft)?; } Ok(ListingRebindView { diff --git a/src/runtime/local_events.rs b/src/runtime/local_events.rs @@ -0,0 +1,86 @@ +use std::fs; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use radroots_local_events::{ + LocalEventRecord, LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, + LocalRecordStatus, PublishOutboxStatus, SourceRuntime, +}; +use radroots_sql_core::SqliteExecutor; +use serde_json::Value; + +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; + +const SHARED_LOCAL_EVENTS_DIR: &str = "local_events"; +const SHARED_LOCAL_EVENTS_DB_FILE: &str = "local_events.sqlite"; + +static RECORD_COUNTER: AtomicU64 = AtomicU64::new(0); + +pub fn append_local_work( + config: &RuntimeConfig, + subject: &str, + owner_account_id: Option<String>, + owner_pubkey: Option<String>, + farm_id: Option<String>, + listing_addr: Option<String>, + payload: Value, +) -> Result<LocalEventRecord, RuntimeError> { + let timestamp = current_time_ms()?; + let sequence = RECORD_COUNTER.fetch_add(1, Ordering::Relaxed); + let input = LocalEventRecordInput { + record_id: format!("cli:local_work:{subject}:{timestamp}:{sequence}"), + family: LocalRecordFamily::LocalWork, + status: LocalRecordStatus::LocalSaved, + source_runtime: SourceRuntime::Cli, + created_at_ms: timestamp, + inserted_at_ms: timestamp, + owner_account_id, + owner_pubkey, + farm_id, + listing_addr, + local_work_json: Some(payload), + event_id: None, + event_kind: None, + event_pubkey: None, + event_created_at: None, + event_tags_json: None, + event_content: None, + event_sig: None, + raw_event_json: None, + outbox_status: PublishOutboxStatus::None, + relay_set_fingerprint: None, + relay_delivery_json: None, + }; + let store = open_store(config)?; + Ok(store.append_record(&input)?) +} + +fn open_store(config: &RuntimeConfig) -> Result<LocalEventsStore<SqliteExecutor>, RuntimeError> { + let root = shared_local_events_root(config)?; + fs::create_dir_all(&root)?; + let executor = SqliteExecutor::open(root.join(SHARED_LOCAL_EVENTS_DB_FILE))?; + let store = LocalEventsStore::new(executor); + store.migrate_up()?; + Ok(store) +} + +fn shared_local_events_root(config: &RuntimeConfig) -> Result<std::path::PathBuf, RuntimeError> { + let Some(shared_data_root) = config.paths.shared_accounts_data_root.parent() else { + return Err(RuntimeError::Config(format!( + "shared accounts data root {} has no parent directory", + config.paths.shared_accounts_data_root.display() + ))); + }; + Ok(shared_data_root.join(SHARED_LOCAL_EVENTS_DIR)) +} + +fn current_time_ms() -> Result<i64, RuntimeError> { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| { + RuntimeError::Config(format!("system clock is before unix epoch: {error}")) + })?; + i64::try_from(duration.as_millis()) + .map_err(|_| RuntimeError::Config("current timestamp exceeds i64 milliseconds".to_owned())) +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -7,6 +7,7 @@ pub mod find; pub mod hyf; pub mod listing; pub mod local; +pub mod local_events; pub mod logging; pub mod network; pub mod order; @@ -32,6 +33,8 @@ pub enum RuntimeError { Sql(#[from] radroots_replica_db::SqlError), #[error("replica sync error: {0}")] ReplicaSync(#[from] radroots_replica_sync::RadrootsReplicaEventsError), + #[error("local events error: {0}")] + LocalEvents(#[from] radroots_local_events::LocalEventsError), #[error("network error: {0}")] Network(String), #[error("failed to serialize json output: {0}")] @@ -48,6 +51,7 @@ impl RuntimeError { | Self::Accounts(_) | Self::Sql(_) | Self::ReplicaSync(_) + | Self::LocalEvents(_) | Self::Network(_) | Self::Json(_) | Self::Io(_) => ExitCode::from(1), diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -10,6 +10,7 @@ use radroots_events::RadrootsNostrEvent; use radroots_events::kinds::{KIND_FARM, KIND_LISTING}; use radroots_events_codec::trade::RadrootsTradeListingAddress; use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; +use radroots_local_events::{LocalEventRecord, LocalEventsStore}; use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event}; use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use serde_json::Value; @@ -111,6 +112,25 @@ impl RadrootsCliSandbox { .join("data/apps/cli/replica/replica.sqlite") } + pub fn local_events_db_path(&self) -> PathBuf { + self.root + .path() + .join("data/shared/local_events/local_events.sqlite") + } + + pub fn local_event_records(&self) -> Vec<LocalEventRecord> { + let path = self.local_events_db_path(); + if !path.exists() { + return Vec::new(); + } + let executor = SqliteExecutor::open(path).expect("open local events db"); + let store = LocalEventsStore::new(executor); + store.migrate_up().expect("migrate local events db"); + store + .list_records_after(0, 200) + .expect("list local event records") + } + #[cfg(unix)] pub fn write_fake_myc(&self, name: &str, body: &str) -> PathBuf { let path = self.root.path().join("bin").join(name); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -12,6 +12,9 @@ use radroots_events::trade::{ RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, }; use radroots_events_codec::trade::active_trade_order_request_event_build; +use radroots_local_events::{ + LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, SourceRuntime, +}; use radroots_nostr::prelude::{RadrootsNostrEvent, radroots_nostr_build_event}; use radroots_replica_db::{farm, farm_member_claim, migrations}; use radroots_replica_db_schema::farm::IFarmFields; @@ -3149,6 +3152,227 @@ fn seller_dry_runs_preflight_without_mutating_farm_or_listing_files() { } #[test] +fn seller_dry_runs_do_not_write_shared_local_work_records() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + + sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "farm", + "create", + "--name", + "Dry Run Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + assert!(sandbox.local_event_records().is_empty()); + + let listing_path = sandbox.root().join("dry-run-local-work.toml"); + let listing_path_arg = listing_path.to_string_lossy(); + sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "listing", + "create", + "--output", + listing_path_arg.as_ref(), + "--key", + "dry-run-eggs", + "--title", + "Eggs", + "--category", + "eggs", + "--summary", + "Fresh eggs", + "--bin-id", + "bin-1", + "--quantity-amount", + "1", + "--quantity-unit", + "each", + "--price-amount", + "6", + "--price-currency", + "USD", + "--price-per-amount", + "1", + "--price-per-unit", + "each", + "--available", + "10", + ]); + assert!(sandbox.local_event_records().is_empty()); +} + +#[test] +fn seller_local_writes_append_shared_local_work_records() { + let sandbox = RadrootsCliSandbox::new(); + let account = sandbox.json_success(&["--format", "json", "account", "create"]); + let account_id = account["result"]["account"]["id"] + .as_str() + .expect("account id"); + let farm = sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Green Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let farm_config = &farm["result"]["config"]; + let farm_d_tag = farm_config["farm_d_tag"].as_str().expect("farm d tag"); + let seller_pubkey = farm_config["seller_pubkey"] + .as_str() + .expect("seller pubkey"); + let listing_file = create_listing_draft(&sandbox, "shared-local-eggs"); + + let records = sandbox.local_event_records(); + assert_eq!(records.len(), 2); + + let farm_record = records + .iter() + .find(|record| { + record + .local_work_json + .as_ref() + .and_then(|payload| payload["record_kind"].as_str()) + == Some("farm_config_v1") + }) + .expect("farm local work record"); + assert_eq!(farm_record.family, LocalRecordFamily::LocalWork); + assert_eq!(farm_record.status, LocalRecordStatus::LocalSaved); + assert_eq!(farm_record.source_runtime, SourceRuntime::Cli); + assert_eq!(farm_record.outbox_status, PublishOutboxStatus::None); + assert_eq!(farm_record.owner_account_id.as_deref(), Some(account_id)); + assert_eq!(farm_record.owner_pubkey.as_deref(), Some(seller_pubkey)); + assert_eq!(farm_record.farm_id.as_deref(), Some(farm_d_tag)); + assert_eq!(farm_record.listing_addr, None); + let farm_payload = farm_record + .local_work_json + .as_ref() + .expect("farm local work payload"); + assert_eq!(farm_payload["scope"], "workspace"); + assert_eq!(farm_payload["document"]["farm"]["d_tag"], farm_d_tag); + + let listing_record = records + .iter() + .find(|record| { + record + .local_work_json + .as_ref() + .and_then(|payload| payload["record_kind"].as_str()) + == Some("listing_draft_v1") + }) + .expect("listing local work record"); + assert_eq!(listing_record.family, LocalRecordFamily::LocalWork); + assert_eq!(listing_record.status, LocalRecordStatus::LocalSaved); + assert_eq!(listing_record.source_runtime, SourceRuntime::Cli); + assert_eq!(listing_record.outbox_status, PublishOutboxStatus::None); + assert_eq!(listing_record.owner_account_id.as_deref(), Some(account_id)); + assert_eq!(listing_record.owner_pubkey.as_deref(), Some(seller_pubkey)); + assert_eq!(listing_record.farm_id.as_deref(), Some(farm_d_tag)); + assert!( + listing_record + .listing_addr + .as_deref() + .expect("listing addr") + .starts_with(format!("30402:{seller_pubkey}:").as_str()) + ); + let listing_payload = listing_record + .local_work_json + .as_ref() + .expect("listing local work payload"); + assert_eq!(listing_payload["path"], listing_file.display().to_string()); + assert_eq!( + listing_payload["document"]["product"]["key"], + "shared-local-eggs" + ); + + let farm_update = sandbox.json_success(&[ + "--format", + "json", + "farm", + "profile", + "update", + "--value", + "Green Farm Updated", + ]); + assert_eq!(farm_update["operation_id"], "farm.profile.update"); + assert_eq!(farm_update["result"]["state"], "updated"); + let second = sandbox.json_success(&["--format", "json", "account", "create"]); + let second_account_id = second["result"]["account"]["id"] + .as_str() + .expect("second account id"); + sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "listing", + "rebind", + listing_file.to_string_lossy().as_ref(), + second_account_id, + "--farm-d-tag", + farm_d_tag, + ]); + + let updated_records = sandbox.local_event_records(); + assert_eq!(updated_records.len(), 4); + let latest_farm_payload = updated_records + .iter() + .filter(|record| { + record + .local_work_json + .as_ref() + .and_then(|payload| payload["record_kind"].as_str()) + == Some("farm_config_v1") + }) + .max_by_key(|record| record.seq) + .and_then(|record| record.local_work_json.as_ref()) + .expect("latest farm payload"); + assert_eq!( + latest_farm_payload["document"]["profile"]["name"], + "Green Farm Updated" + ); + let latest_listing = updated_records + .iter() + .filter(|record| { + record + .local_work_json + .as_ref() + .and_then(|payload| payload["record_kind"].as_str()) + == Some("listing_draft_v1") + }) + .max_by_key(|record| record.seq) + .expect("latest listing record"); + assert_eq!( + latest_listing.owner_account_id.as_deref(), + Some(second_account_id) + ); + let latest_listing_payload = latest_listing + .local_work_json + .as_ref() + .expect("latest listing payload"); + assert_eq!( + latest_listing_payload["document"]["seller_actor"]["account_id"], + second_account_id + ); +} + +#[test] fn sync_push_partial_mixed_author_queue_reports_error_envelope() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]);