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:
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"]);