mobee-node: persistent daemon shell — exclusive lock, unix-socket RPC, wallet/identity behind the daemon (step 1 of #127) - #131
Conversation
Feedback — daemon lifecycle UX (for the migration phase)Step-1 shell looks right, and the The human must not have to run anything. The MCP client should lazy-start-or-adopt the daemon transparently. Rationale: today the buyer's agent just has the MCP and gets going — the human runs nothing. Introducing a separate long-lived Concretely, for the migration PR:
No change requested to this PR's scope — this is the boundary condition for when the shell gets wired to the MCP. Flagging now so the design carries it forward. |
| @@ -0,0 +1,357 @@ | |||
| //! The persistent per-home **mobee node** (step 1 of the stateful-buyer design, #127). | |||
There was a problem hiding this comment.
I'm not sure we should just call it "mobee node" especially looking at the file structure. It's not totally clear that this is a buyer kind of thing.
| pub mod buyer_fund; | ||
| /// Persistent per-home node daemon (exclusive lock, unix-socket RPC, wallet/identity | ||
| /// behind serialized actors, durable state DB). See [`node`]. | ||
| #[cfg(feature = "wallet")] |
There was a problem hiding this comment.
Kind of weird IMO that this is beyond the wallet feature. Also I kind of think that the wallet shouldn't even be feature flagged as a buyer. So can you file this as an issue to just reassess our future flags and usage and like I'm not really sure all these feature flags we have are actually doing as much good or maybe we need to rethink the structure between between buyer and seller, but I think buyers should always have a wallet
Add `mobee node`: a persistent per-home daemon that is the single owner of the wallet, Nostr identity, and durable state for a home. Every other process is a thin, stateless client over its local unix socket. - Exclusive home lock ($MOBEE_HOME/node.lock) via flock(LOCK_EX|LOCK_NB); a second daemon on the same home fails closed before it opens the wallet. - Local unix socket ($MOBEE_HOME/node.sock, 0600) with a newline-delimited JSON-RPC surface: live `status`/`health`; the buyer trade methods are recognized but return a structured not-implemented error (deferred). - Wallet + identity behind serialized in-process actors (single queue each, concurrency 1): the daemon exclusively opens the CDK wallet and holds the buyer key; the secret never crosses the socket. - Durable state DB ($MOBEE_HOME/node.sqlite) in WAL mode with FK + FULL sync and a minimal node_meta/jobs schema — the home later phases build on. - Thin client: `mobee node status` routes over the socket and prints daemon status, holding no wallet/key/state. Additive: existing `mobee mcp` buyer/seller flows and their in-process wallet paths are untouched. The node core lives in mobee-core so a seller service can share it in a later phase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eassessment #133 The per-home daemon added in this PR was named generically "node". Rename it comprehensively to "buyer" (its actual role, restoring #127's own naming): - crates/mobee-core/src/node -> crates/mobee-core/src/buyer (mod path + all types: NodeContext/NodeError/NodeStore -> Buyer*, node_meta table -> buyer_meta) - crates/mobee/src/node.rs -> crates/mobee/src/buyer.rs - runtime leaves node.lock/node.sqlite/node.sock -> buyer.{lock,sqlite,sock} - CLI subcommand `mobee node[/status]` -> `mobee buyer[/status]` + all help/user strings - doc comments/test names: node daemon -> buyer daemon A shared buyer/seller core can be extracted once a seller daemon actually exists; not generalized preemptively. The wallet/buyer feature-flag structure is under review in issue #133 (not restructured here). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9000c02 to
306b62b
Compare
…rt on pay (#123) (#134) * buyer: reservation ledger — reserve on award, release on death, convert on pay (#123) Phase 2 of the buyer daemon (#127), built on the daemon shell's durable store (#131). Adds a reservation ledger to buyer.sqlite enforcing the symmetric money invariant: available = balance − reserved − spent - Never over-commit: an award that would push `reserved` past `available` is refused atomically (BEGIN IMMEDIATE) with ZERO reserve written. - Never lock up: a reservation for a job that is no longer payable is released so the funds become available again; reconcile-on-restart reclaims stale reservations after a crash. `reserved` sums ONLY `reserved`-state rows, so a collect converting reserve→spent moves the amount out of the reserved term exactly as the budget ledger's `spent` term takes it up — never subtracted twice. The budget ledger (crate::budget) remains the sole spend authority; a `spent`-state row is a label. Schema bumped to v2 (forward-only, additive `reservations` table). Guard lives in the store (core), so future auto-award (#126) inherits it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * buyer: fix double-counted spend in reservation available — two-ceiling model (#134) `compute_available` computed `available = balance − reserved − spent` where `balance` is the LIVE wallet balance. A completed payment already melted that ecash, so subtracting cumulative `spent` again double-counted every completed spend and progressively refused awards the buyer could actually afford as spend history grew (gudnuf's catch). Replace with the correct two-ceiling model — the codebase has two independent ceilings (see budget.rs): available = min( wallet_balance − reserved , total_cap − spent − reserved ) - wallet ceiling: physical ecash on hand. `spent` is NOT subtracted (the live balance already netted completed melts). - budget ceiling: policy cap `total_cap` consumed by cumulative `spent` + in-flight `reserved`; budget.rs remains the sole spend authority. In-flight `reserved` consumes BOTH ceilings. i128 intermediates, each ceiling saturates at 0 (never wraps). `total_cap` threaded through `compute_available`, `reserve`, and `available`. Refusal now names the binding ceiling (wallet vs budget) via a new `Ceiling` field on `InsufficientAvailable`. Docs: module `//!` rewritten to the two-ceiling model; the reserved→spent ordering obligation on `convert_to_spent` (#126 wiring) now covers BOTH ceilings (budget spent-append AND wallet melt must land before the flip). Tests: existing 5 money teeth kept green with corrected expectations; the available test rewritten to exercise both ceilings; added a red-on-revert regression for the exact double-count bug (affordable award wrongly refused by the old formula) and a test that the budget ceiling still bites when the wallet could cover. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Step 1 of the stateful-buyer "mobee node" (#127). Both design plans in the issue converge on one architecture: one persistent per-home daemon owns the wallet, identity, and state; every session is a thin client over a local socket. This PR builds the shell — the boundary — and nothing more.
What step 1 delivers
mobee nodetakes an OS advisory lock on$MOBEE_HOME/node.lock(flock(LOCK_EX | LOCK_NB)). A second daemon on the same home fails closed before it ever opens the wallet — proven live and by test. The lock releases on process exit/crash (no stale-lock reaping).$MOBEE_HOME/node.sock(user-only,0600), newline-delimited JSON, one request/response per connection.status/healthis live. The buyer trade methods (post_job,get_job,accept_claim,authorize_pay) are recognized but return a structured not-implemented error (-32001) rather than silently succeeding — honest step-1 stubs.buyer_fund::open_wallet_async) and holds the Nostr key. Both sit behind serialized in-process actors (one mpsc queue each, concurrency 1) — the queue, not SQLite locking, is the in-process concurrency boundary. The secret key never crosses the socket.$MOBEE_HOME/node.sqlitein WAL mode withforeign_keys=ON,synchronous=FULL, bounded busy timeout, and a minimalnode_meta/jobsschema. This is the state home the reservation ledger / payment saga / lifecycle build on.rusqliteuses the bundled sqlite so the node needs no system libsqlite3.mobee node statusconnects to the running daemon and prints its status, holding no wallet/key/state.Thin-client demo (live, release binary)
Deliberately deferred to later phases (per the #127 build order)
jobs/reservations/payment_attempts/outboxtables,BEGIN IMMEDIATEaward, uniqueness invariants, transactional outbox.PAYMENT_UNCERTAIN.mobee mcpin-process wallet paths are untouched; later phases migrate MCP sessions to be thin clients over this socket. This PR is purely additive.Design forks resolved (flagged per instructions)
buyer.sqlite/mobee-buyerd; I usedmobee node,node.lock,node.sock,node.sqlite, and put the core inmobee-core::node— per the issue's symmetry note so a seller service can share the same node core (identity/wallet/relay/lifecycle) later. The daemon file leaves arenode.*.balance(a read) to prove exclusive ownership + serialization; proof-changing ops are the phase-3 single-slot work.Tests
New (
crates/mobee-core/src/node/):lock: second acquire fails closed while first is held; releases cleanly.store: opens in WAL, carries schema version + recorded start.wallet_actor: 32 concurrent callers observe peak in-flight = 1 (serialization regression guard) + balance read through the queue.signer: serves pubkey (cached + via actor); never equals the secret.node(integration):statusround-trips over the real socket (wallet + store + signer all answer; secret not echoed; socket is0600); a second node on the same home fails closed at the lock.Acceptance (all green, exit 0)
Do not merge — gudnuf reviews.
🤖 Generated with Claude Code