new tasks, devnet topup, cli, new model support
This commit is contained in:
88
docs/issues/36-relay-streamed-chat.md
Normal file
88
docs/issues/36-relay-streamed-chat.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# US-036 — Streamed chat completions over the relay RPC path
|
||||
|
||||
Status: planned
|
||||
Priority: Critical (blocks public friends-test deployment)
|
||||
Stage: Designed
|
||||
|
||||
## Context
|
||||
|
||||
With the tracker deployed on a public VPS (`cloud.neuron.d-popov.com`), every node is
|
||||
behind NAT, so **every** chat request is proxied tracker → relay → head node via
|
||||
`_relay_http_request` (`packages/tracker/meshnet_tracker/server.py`). That function does a
|
||||
single blocking `ws.recv()` and the node's `RelayHttpBridge._handle_request` does a single
|
||||
`resp.read()`. Two consequences for `stream: true` requests:
|
||||
|
||||
1. **No live streaming** — the client sees nothing until generation completes, then
|
||||
receives the entire SSE body at once with a `Content-Length` header.
|
||||
2. **Zero billing** — the tracker runs `_billable_non_stream_tokens(json.loads(body))`
|
||||
on the buffered body; SSE text is not JSON, the parse fails, and the request is
|
||||
billed/credited as 0 tokens. Off-LAN this silently zeroes out *all* streamed-request
|
||||
accounting.
|
||||
|
||||
Decision (grilled 2026-07-06): implement **true multi-frame streaming** over the relay
|
||||
RPC WebSocket, scoped to the tracker → head-node leg. Rejected alternatives:
|
||||
billing-only SSE parse (fragile heuristic, blank-screen UX stays) and forcing
|
||||
`stream:false` over the relay (exact billing but still no live tokens for testers).
|
||||
Streaming through the existing SSE accounting loop fixes both symptoms with one
|
||||
mechanism. Inter-node `/forward` activation hops stay single-frame (ADR-0014) — they
|
||||
are one-tensor-in/one-tensor-out and gain nothing from chunking.
|
||||
|
||||
## Protocol
|
||||
|
||||
A relayed response becomes a sequence of `relay-http-response` envelopes sharing one
|
||||
`request_id`:
|
||||
|
||||
```json
|
||||
// first frame — status + headers, opens the stream
|
||||
{"request_id": "<id>", "status": 200, "headers": {"Content-Type": "text/event-stream"},
|
||||
"stream": true, "chunk": "data: {...}\n\n", "done": false}
|
||||
// zero or more continuation frames
|
||||
{"request_id": "<id>", "stream": true, "chunk": "data: {...}\n\n", "done": false}
|
||||
// terminal frame
|
||||
{"request_id": "<id>", "stream": true, "done": true}
|
||||
```
|
||||
|
||||
**Backward compatibility:** a frame with no `stream` key is a complete single response
|
||||
(today's format, still used for `/forward` hops, non-SSE responses, and older nodes).
|
||||
The relay and tracker treat it as terminal.
|
||||
|
||||
### Per component
|
||||
|
||||
- **Node bridge** (`packages/node/meshnet_node/relay_bridge.py`): when the local
|
||||
response `Content-Type` is `text/event-stream`, read line-by-line and emit chunk
|
||||
frames as lines arrive; otherwise keep the existing single-frame path (including
|
||||
`body_base64` for binary). Frame sends go through the bridge's WS send lock
|
||||
(US-037) so frames from concurrent requests interleave whole, never torn.
|
||||
- **Relay server** (`packages/relay/meshnet_relay/server.py`): `_handle_rpc` replaces
|
||||
the single `asyncio.Future` in `_pending_rpc` with a per-request `asyncio.Queue`.
|
||||
Frames arriving on the target peer's gossip connection are routed by `request_id`
|
||||
and forwarded to the requester WS until `done` (or a terminal legacy frame).
|
||||
Timeouts: keep the 310 s overall cap; add a 120 s per-frame idle timeout so a dead
|
||||
node doesn't pin the queue.
|
||||
- **Tracker** (`packages/tracker/meshnet_tracker/server.py`): `_relay_http_request`
|
||||
grows a streaming mode — loop `ws.recv()`; on the first frame send status/headers to
|
||||
the client; write each `chunk` to the client immediately and feed it through the
|
||||
same SSE token-accounting used by the direct-proxy stream loop
|
||||
(`reported_stream_tokens` / `_record_observed_throughput` / `_bill_completed`), so
|
||||
relayed streams bill identically to direct streams. Non-stream frames keep the
|
||||
current buffered handling.
|
||||
|
||||
### Known limitation (accepted for alpha)
|
||||
|
||||
If the client disconnects mid-stream, the relay drops undeliverable frames but the
|
||||
node keeps generating until completion — wasted compute bounded by one generation.
|
||||
Cancellation propagation is future work.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `stream: true` chat request via relay delivers SSE chunks to the client
|
||||
incrementally (test observes ≥2 distinct frame arrivals before `[DONE]`)
|
||||
- Relayed streamed request records nonzero billed tokens and node work credit
|
||||
- Non-streamed relayed requests and `/forward` binary hops behave exactly as before
|
||||
(single frame, `body_base64` round-trip intact)
|
||||
- Legacy single-frame response from an old node is accepted as terminal
|
||||
- Idle stream (no frame for 120 s) returns 504 to the client and cleans up the
|
||||
relay-side queue
|
||||
- Extend `tests/test_gossip_and_relay.py` alongside
|
||||
`test_relay_rpc_round_trips_http_request_to_peer`
|
||||
- `python -m pytest` passes from repo root
|
||||
51
docs/issues/37-relay-bridge-concurrency.md
Normal file
51
docs/issues/37-relay-bridge-concurrency.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# US-037 — Concurrent request handling in the node relay bridge
|
||||
|
||||
Status: planned
|
||||
Priority: Critical (blocks public friends-test deployment)
|
||||
Stage: Designed
|
||||
|
||||
## Context
|
||||
|
||||
`RelayHttpBridge._run` (`packages/node/meshnet_node/relay_bridge.py`) handles
|
||||
`relay-http-request` envelopes serially inside its recv loop: `_handle_request` blocks
|
||||
on `urllib.request.urlopen(..., timeout=300)` before the next envelope is read.
|
||||
|
||||
Off-LAN this is a correctness bug, not just a throughput limit: a node can be the
|
||||
**head** of one inference route and a **downstream hop** of another at the same time.
|
||||
While the head request occupies the bridge (up to 300 s of generation), the other
|
||||
route's per-token `/forward` calls sit unread in the WebSocket buffer — overlapping
|
||||
routes through a shared node are effectively broken.
|
||||
|
||||
Decision (grilled 2026-07-06): dispatch requests on a **bounded worker pool, default
|
||||
8, configurable**. Rejected alternatives: unbounded thread-per-request (a public
|
||||
deployment exposes volunteer machines to request stampedes) and reject-when-full
|
||||
(bouncing `/forward` hops kills other routes' in-flight sessions; queueing beyond the
|
||||
cap is today's behavior, just 8-wide).
|
||||
|
||||
## Design
|
||||
|
||||
- Recv loop only parses envelopes and submits to a
|
||||
`ThreadPoolExecutor(max_workers=N)`; workers run `_handle_request` and send the
|
||||
response frame(s).
|
||||
- All sends on the single relay WebSocket go through a `threading.Lock`. With US-036
|
||||
a response may be multiple frames — the lock is held **per frame**, so streams from
|
||||
concurrent requests interleave frame-atomically (receivers demux by `request_id`).
|
||||
- `N` defaults to 8; configurable via `meshnet-node start --relay-concurrency N`
|
||||
(env `MESHNET_RELAY_CONCURRENCY`). Requests beyond `N` queue in the executor.
|
||||
- On reconnect (`_run`'s outer loop), in-flight workers from the dead connection may
|
||||
still try to send; the send helper swallows failures on a closed socket, and the
|
||||
relay side times the orphaned request out (US-036 idle timeout / existing 310 s cap).
|
||||
- `stop()` shuts the executor down without waiting for stragglers (daemon threads,
|
||||
same as today's bridge thread).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- While one relayed request is in flight (slow local handler), a second
|
||||
`relay-http-request` to the same node completes without waiting for the first
|
||||
- Responses are correctly matched by `request_id` when they complete out of order
|
||||
- More than `N` simultaneous requests queue and all eventually complete; thread count
|
||||
never exceeds `N` workers
|
||||
- Bridge survives a relay reconnect with workers still in flight (no crash, no
|
||||
deadlock; orphaned responses dropped)
|
||||
- Extend `tests/test_gossip_and_relay.py`
|
||||
- `python -m pytest` passes from repo root
|
||||
62
docs/issues/38-tracker-seed-join.md
Normal file
62
docs/issues/38-tracker-seed-join.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# US-038 — Tracker cluster join via a single seed peer
|
||||
|
||||
Status: planned
|
||||
Priority: High (blocks adding trackers without restarting the fleet)
|
||||
Stage: Proposed
|
||||
|
||||
## Context
|
||||
|
||||
Today the tracker cluster is fully static. `RaftNode` and `NodeGossip`
|
||||
(`packages/tracker/meshnet_tracker/raft.py`, `gossip.py`) take a peer list at
|
||||
construction and never change it; `/v1/raft/status` does not expose membership.
|
||||
|
||||
Consequences of starting a new tracker with only one existing peer in
|
||||
`--cluster-peers`:
|
||||
|
||||
- Existing trackers never learn about the newcomer — they keep heartbeating and
|
||||
replicating to their original list only.
|
||||
- Quorum math (`(len(self.peers) + 1) / 2`) differs per tracker, so vote counts
|
||||
and commit decisions disagree — the cluster silently splits rather than erroring.
|
||||
|
||||
Required behavior: a joining tracker is configured with **any one** live tracker
|
||||
(a seed). It announces itself, the membership change replicates through the Raft
|
||||
log, and every tracker — including the newcomer — converges on the same full peer
|
||||
list. Removing the need to restart existing trackers when the hive grows.
|
||||
|
||||
## Design
|
||||
|
||||
1. **Expose membership.** `GET /v1/cluster/peers` returns
|
||||
`{"self": <url>, "peers": [<url>, ...]}` (admin-safe: URLs only).
|
||||
2. **Join handshake.** On startup, the joiner calls the seed's
|
||||
`POST /v1/cluster/join` with its public self-url. The request is
|
||||
hive-HMAC-signed (`MESHNET_HIVE_SECRET`, same fail-closed rule as gossip —
|
||||
an unauthenticated join on a public tracker would let anyone enter the hive).
|
||||
A non-leader seed forwards to the current leader (same pattern as
|
||||
`/v1/nodes/register` forwarding).
|
||||
3. **Membership through the log.** The leader appends a
|
||||
`cluster-membership` entry (single-server change: add one peer per entry).
|
||||
`RaftNode` gains `apply`-side handling that swaps `self.peers` — quorum is
|
||||
recomputed from the applied membership, never from local config. Gossip peer
|
||||
list updates from the same apply hook.
|
||||
4. **Joiner bootstrap.** After a successful join response (which includes the
|
||||
current peer list), the joiner sets its own peers and starts Raft as a
|
||||
follower; it catches up via normal append-entries.
|
||||
5. **Persistence.** Applied membership is written to the stats/state sqlite so a
|
||||
restarted tracker rejoins with the last known list even if its seed is down.
|
||||
6. **Config semantics.** `--cluster-peers` becomes "seed list": tried in order
|
||||
until one join succeeds. A tracker with no seeds and no persisted membership
|
||||
runs standalone (current single-tracker behavior unchanged).
|
||||
|
||||
Out of scope: peer removal/eviction (leave via operator restart for now),
|
||||
joint consensus for multi-server changes, automatic seed retry after startup.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Start trackers A+B as a cluster; start C with only A as seed → within one
|
||||
election timeout, A, B, and C all report the same 3-peer membership on
|
||||
`GET /v1/cluster/peers`, and a value proposed on C commits on A and B
|
||||
- Join without a valid hive signature is rejected with 403; join to a follower
|
||||
is forwarded to the leader transparently
|
||||
- Restarting C with its seed offline rejoins from persisted membership
|
||||
- Standalone tracker (no seeds) behaves exactly as today
|
||||
- `python -m pytest` passes from repo root
|
||||
60
docs/issues/39-caller-credit-account-keys.md
Normal file
60
docs/issues/39-caller-credit-account-keys.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# US-039 — Caller Credit granted once per account; chat requires account keys
|
||||
|
||||
Status: planned
|
||||
Priority: Critical (blocks friends-test inference — every request 402s)
|
||||
Stage: Designed
|
||||
|
||||
## Context
|
||||
|
||||
`DEFAULT_STARTING_CREDIT = 0.0` and nothing can raise it: no CLI flag, no admin
|
||||
credit endpoint. The only funding path is the on-chain USDT deposit watcher —
|
||||
so on a fresh public tracker every inference request returns
|
||||
`402 insufficient balance`, including keys created through the working
|
||||
account flow (`/v1/auth/register` → `/v1/account/keys`, also in the dashboard).
|
||||
|
||||
Separately, the billing gate accepts **any** bearer string (only revoked keys
|
||||
are rejected), so wiring starter credit into `BillingLedger.ensure_client`
|
||||
would let anyone mint unlimited free credit by inventing keys.
|
||||
|
||||
Decision (grilled 2026-07-06): **Caller Credit is granted once per account**,
|
||||
when the account creates its first API key; chat requests on an
|
||||
accounts-enabled tracker must present a real active `sk-mesh-` key.
|
||||
Rejected: credit-any-bearer (free-inference farming against volunteers' GPUs
|
||||
on the open internet) and manual-admin-credit-only (operator toil, no
|
||||
self-serve testing).
|
||||
|
||||
## Design
|
||||
|
||||
1. **`BillingLedger.grant_caller_credit(api_key, account_id, amount) -> bool`**
|
||||
Appends a credit event with the deterministic id
|
||||
`caller-credit-{account_id}` and note `caller-credit`. Already-seen id →
|
||||
no-op returning False. The deterministic id makes the once-per-account rule
|
||||
idempotent locally *and* across hive gossip (event dedupe is by id), so a
|
||||
second key, a retried request, or a replicated event can never double-grant.
|
||||
2. **`AccountStore.is_active_key(api_key) -> bool`.** The chat billing gate
|
||||
(`_handle_proxy_chat`) rejects keys that are not active account keys with
|
||||
`401 invalid_api_key` — but only when an accounts store is configured, so
|
||||
embedded/test trackers without accounts keep today's any-key behavior.
|
||||
3. **`--starting-credit N`** (USDT) on `meshnet-tracker start`. Default is the
|
||||
single constant `DEFAULT_CALLER_CREDIT_USDT = 1.0` in `server.py`
|
||||
(devnet-friendly alpha, revised 2026-07-06; set 0 on mainnet). Threaded
|
||||
through `TrackerServer` → `_TrackerHTTPServer`; registration and
|
||||
`_handle_account_key_create` call `grant_caller_credit` when billing is on
|
||||
and the amount is positive. The ledger's own `_starting_credit` stays 0 —
|
||||
implicit per-key grants in `ensure_client`/`charge_request` remain dead
|
||||
code paths.
|
||||
4. **Stack variable `STARTING_CREDIT`** in
|
||||
`deploy/portainer/meshnet-tracker-nobuild-stack.yml` (default 1).
|
||||
5. **Docs.** QUICKSTART gains "Get an API key" (register/login/create key via
|
||||
dashboard or curl, what 402 means); DEPLOY_PORTAINER's stale "any key is
|
||||
accepted and starts with the configured starter credit" paragraph replaced.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Fresh account → first key → key has `--starting-credit` balance; chat succeeds
|
||||
- Second key on the same account → no additional credit
|
||||
- Revoke-and-recreate keys → still no additional credit (deterministic event id)
|
||||
- Random bearer string on an accounts-enabled tracker → 401, never 402/free work
|
||||
- Tracker without accounts store: gate behavior unchanged
|
||||
- `--starting-credit 0` disables the grant entirely (mainnet posture)
|
||||
- `python -m pytest` passes from repo root
|
||||
38
docs/issues/40-devnet-dashboard-topup.md
Normal file
38
docs/issues/40-devnet-dashboard-topup.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# US-040 — Devnet top-up button on the dashboard
|
||||
|
||||
Status: planned
|
||||
Priority: High (friends-test convenience — refill without on-chain deposits)
|
||||
Stage: Designed
|
||||
|
||||
## Context
|
||||
|
||||
Once Caller Credit (US-039) is spent, the only refill path is a real USDT
|
||||
deposit through the treasury watcher — devnet Solana plumbing friends won't
|
||||
have. For test deployments the dashboard needs a "add $10" style button.
|
||||
This is a faucet: 0 disables it, and mainnet deployments must set 0.
|
||||
|
||||
## Design
|
||||
|
||||
1. **`--devnet-topup N`** (USDT per click) on `meshnet-tracker start`; stack
|
||||
variable `DEVNET_TOPUP`. Default is the single constant
|
||||
`DEFAULT_DEVNET_TOPUP_USDT = 1.0` in `server.py` (devnet-friendly alpha,
|
||||
revised 2026-07-06 — the alpha's public trackers are devnet-only).
|
||||
2. **`POST /v1/account/topup`** — session-authenticated (same as key
|
||||
management). Body: `{"api_key": "sk-mesh-..."}`. Rules:
|
||||
- 404 when the feature is disabled (flag absent/0)
|
||||
- key must belong to the logged-in account (403 otherwise)
|
||||
- credits exactly the configured amount (client cannot choose it),
|
||||
note `devnet-topup`; returns the new balance
|
||||
3. **Dashboard**: `/v1/account` response gains `topup_amount` (0 when
|
||||
disabled); when positive, each key row renders a `+$N (devnet)` button
|
||||
calling the endpoint.
|
||||
4. **Abuse bound**: per-click amount is operator-set and requires a registered
|
||||
session; acceptable for a friends-test faucet. Mainnet deployments simply
|
||||
never set the flag. (Rate limiting is deliberately out of scope.)
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Flag off: endpoint 404s, dashboard shows no top-up button
|
||||
- Flag on: logged-in user tops up own key, balance rises by exactly N
|
||||
- Topping up another account's key → 403
|
||||
- `python -m pytest` passes from repo root
|
||||
65
docs/issues/41-account-wallet-keypair.md
Normal file
65
docs/issues/41-account-wallet-keypair.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# US-041 — Account wallet: browser-extension signing, in-browser generation, export-only
|
||||
|
||||
Status: planned
|
||||
Priority: Medium (not needed for devnet friends test; needed before mainnet)
|
||||
Stage: Designed
|
||||
|
||||
## Context
|
||||
|
||||
Registration accepts an optional `wallet` field (a pubkey string) and the
|
||||
dashboard never shows it. Users need a visible wallet address on the account
|
||||
panel and a path to real Solana later, without the tracker ever holding user
|
||||
private keys.
|
||||
|
||||
Today wallets matter in two places: client deposit attribution
|
||||
(`bind_wallet` → deposit watcher) and node payout identity (Pending Balance
|
||||
keyed by wallet). Accounts themselves hold no keypair — and will stay that way.
|
||||
|
||||
## Decision (2026-07-06)
|
||||
|
||||
**Non-custodial.** The tracker stores public keys only; private keys never
|
||||
leave the browser.
|
||||
|
||||
1. **External wallets via browser extension.** The dashboard integrates the
|
||||
standard Solana wallet-adapter flow (Phantom, Solflare, …): connect the
|
||||
extension, prove ownership by signing a nonce, and the verified pubkey is
|
||||
stored as the account wallet. All future transaction authorization
|
||||
(deposits; later, anything requiring a user signature) happens in the
|
||||
extension — the tracker only ever sees signatures and pubkeys.
|
||||
2. **New wallets generated in-browser.** For users without an extension the
|
||||
dashboard generates a keypair client-side (dashboard JS, `Ed25519` via
|
||||
WebCrypto or bundled tweetnacl — CSP-safe, no CDN). The pubkey is uploaded;
|
||||
the private key is shown once for export (base58, plaintext acceptable for
|
||||
devnet) with a "save this now" warning, and optionally kept in
|
||||
localStorage for convenience on devnet.
|
||||
3. **No private-key import.** Users cannot paste an existing private key into
|
||||
the dashboard — accepting foreign private keys is a liability the project
|
||||
refuses. Users with existing wallets use the extension path (which imports
|
||||
nothing; it signs in place).
|
||||
|
||||
## Scope
|
||||
|
||||
- Account panel shows the account wallet address (copy button) or the two
|
||||
attach flows (connect extension / generate in browser)
|
||||
- `POST /v1/account/wallet` — session-authenticated; body carries pubkey and,
|
||||
for the extension flow, the signed-nonce ownership proof (reuse
|
||||
`wallet_proof.py` if it fits); binds the wallet to the account's keys for
|
||||
deposit attribution (existing `bind_wallet` path)
|
||||
- Wallet is per-account; the tracker fans the binding out to the account's
|
||||
API keys internally
|
||||
- Mainnet cutover remains config-only (same boundary as Mock USDT, ADR-0015)
|
||||
|
||||
Out of scope: password-wrapped keystore export, hardware wallets, changing
|
||||
the node/miner wallet flow.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Connect-extension flow stores a verified pubkey (rejects unsigned/mismatched
|
||||
nonce proofs)
|
||||
- Generate flow: pubkey lands on the account; private key is never sent to the
|
||||
tracker (assert no such field in the request), export works
|
||||
- No endpoint or UI accepts a private key
|
||||
- Deposits to the shown address credit the account's keys via the existing
|
||||
watcher
|
||||
- Address visible on the account panel after either flow
|
||||
- `python -m pytest` passes from repo root
|
||||
Reference in New Issue
Block a user