Files
buzz/crates/sprout-cli/src/error.rs
T
Michael Neale 84f84931f1 feat(serverless): run channels, DMs, messages, and agents against a generic Nostr relay
Adds a 'serverless' workspace mode that points the desktop app at any generic
public Nostr relay (e.g. wss://relay.damus.io) with zero Sprout server
infrastructure — no Postgres, no HTTP /query|/events bridge, no NIP-98 auth.
Reuses Sprout's native event kinds (39000 channel metadata, 39002 membership,
kind 9 messages, h-tag scoping); 'serverless' is purely a transport + auth
concern, so the server-mode path is untouched.

Transport: reads/writes go over plain WebSocket (REQ/EOSE, EVENT/OK) instead
of the HTTP bridge; NIP-42 AUTH is answered only if the relay challenges.

Desktop backend:
- AppState.serverless flag + is_serverless(); apply_workspace gains a flag
- ws_relay.rs: query_relay_ws / submit_event_ws / publish_signed_event_ws
- relay.rs: query_relay/submit_event + agent profile sync branch to WS
- events.rs: build_channel_metadata_serverless (39000) + members (39002)
- create_channel/open_dm publish those events directly (DM ids = UUIDv5 over
  sorted participants so both sides converge without a server)
- managed agents inherit SPROUT_SERVERLESS into the ACP subprocess env

Agents (full support, not a follow-up):
- sprout-acp: RestClient + HarnessRelay gain serverless mode; query/submit use
  plain WS, NIP-42 handshake skipped on (re)connect; SPROUT_SERVERLESS env/arg
- sprout-cli: SproutClient serverless WS query/submit; --serverless flag,
  to_ws_url helper, NetworkMsg error variant
- npub respond-to permissions are in-process and work unchanged

Frontend:
- Workspace.mode + workspaceMode()/isServerlessWorkspace() helpers
- relayClient.setServerless() skips the AUTH-wait on connect; late challenges
  still answered so writes succeed on relays that require auth
- ServerlessContext/useIsServerless() hides search, pulse, projects, workflows
- Serverless toggle in the Add Workspace dialog and Welcome screen

Docs: docs/SPROUT_LITE_MODE.md updated with the implementation + testing steps.
2026-06-01 20:42:29 +10:00

92 lines
2.7 KiB
Rust

use thiserror::Error;
#[derive(Debug, Error)]
pub enum CliError {
/// Invalid argument or flag value — user error
#[error("{0}")]
Usage(String),
/// Relay returned a non-2xx response
#[error("relay error {status}: {body}")]
Relay { status: u16, body: String },
/// Network-level failure (connect, timeout, DNS)
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
/// Network-level failure described by a string (e.g. WebSocket transport
/// errors in serverless mode, which don't originate from `reqwest`).
#[error("network error: {0}")]
NetworkMsg(String),
/// Auth missing or rejected (401/403)
#[error("auth error: {0}")]
Auth(String),
/// Nostr key error (NIP-98 signing in `sprout auth`)
#[error("key error: {0}")]
Key(String),
/// Relay accepted the event but reported it as superseded by a newer
/// head — used by `sprout mem` set/rm to surface NIP-33 LWW conflicts.
#[error("conflict: {0}")]
Conflict(String),
/// Requested resource was absent or tombstoned (e.g. `sprout mem get`
/// for a slug with no head).
#[error("{0}")]
NotFound(String),
/// Catch-all for unexpected failures
#[error("{0}")]
Other(String),
}
/// Map CliError to process exit code.
/// 0=success (not an error), 1=user/not-found, 2=network/relay, 3=auth,
/// 4=other, 5=write conflict (NIP-33 dominated head).
pub fn exit_code(e: &CliError) -> i32 {
match e {
CliError::Usage(_) => 1,
CliError::Relay { status, .. } => {
if *status == 401 || *status == 403 {
3
} else {
2
}
}
CliError::Network(_) | CliError::NetworkMsg(_) => 2,
CliError::Auth(_) => 3,
CliError::Key(_) => 3,
CliError::Conflict(_) => 5,
CliError::NotFound(_) => 1,
CliError::Other(_) => 4,
}
}
/// Serialize error to JSON and write to stderr.
/// Format: {"error": "<category>", "message": "<human-readable detail>"}
pub fn print_error(e: &CliError) {
let category = match e {
CliError::Usage(_) => "user_error",
CliError::Relay { status, .. } => {
if *status == 401 || *status == 403 {
"auth_error"
} else {
"relay_error"
}
}
CliError::Network(_) | CliError::NetworkMsg(_) => "network_error",
CliError::Auth(_) => "auth_error",
CliError::Key(_) => "key_error",
CliError::Conflict(_) => "conflict",
CliError::NotFound(_) => "not_found",
CliError::Other(_) => "error",
};
let obj = serde_json::json!({
"error": category,
"message": e.to_string(),
});
eprintln!("{}", obj);
}