diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5beacd2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,215 @@ +# Contributing + +Thanks for considering a contribution to Rutster. This file is the short +form — read [`AGENTS.md`](AGENTS.md) for the full orientation any agent +(human, AI, hybrid) working in the repo should have. + +--- + +## Trunk-based development + +1. Branch from `main` for any change. +2. Open a PR targeting `main`. +3. CI gates (below) must pass before merge. +4. Squash-merge to keep `main` linear. + +**Never push directly to `main`.** Branch protection is planned to +enforce this; until then, self-discipline. + +### Branch naming + +Single-purpose branches named after the change: + +- `slice-1-webrtc-loopback` — long-running build-target branch +- `docs/quickstart` — documentation additions +- `media/opus-roundtrip-fix` — bug fix with crate scope +- `ci/deny-toml` — CI config + +No strict scheme — short, descriptive, hyphen-separated. + +--- + +## CI gates + +All four must pass before merge to `main`: + +```bash +cargo fmt --check # formatting +cargo clippy -- -D warnings # lints (warnings = failures) +cargo test --all # all unit + integration tests +cargo deny check # licenses, advisories, bans, sources +``` + +CI runs on push + PR to `main`, matrix: latest stable + the MSRV pinned +in `rust-toolchain.toml`. The `clippy` + `test` jobs install `libopus-dev` +(the one system dependency, per PORT_PLAN §7). + +If your change adds a new dependency, run `cargo deny check` locally +before pushing — a license conflict or duplicate-version ban will fail +CI, and it's faster to catch locally. + +--- + +## Commit messages + +- **Imperative mood**: "Add X" / "Fix Y", not "Added X" / "Fixes Y". +- **Subject ≤ 72 chars**. +- **Body wraps at 72**, blank line between subject and body. +- **Reference ADRs / specs** by number when relevant: `ADR-0002`, + `slice-1 spec §3.4`. +- Match the style of recent commits: + ```bash + git log --oneline -10 + ``` + +Example shape (from the repo history): + +``` +media: PcmFrame + AudioSource/Sink + Opus codec pair + +PcmFrame is the canonical tap format (16-bit mono @ 24 kHz, 480 samples +per 20 ms frame — ARCHITECTURE.md). AudioSource/AudioSink are the seam +step 2 splices the tap client into (spec §3.3); EchoAudioPipe is the +slice-1 wiring of that seam. OpusDecoder/OpusEncoder wrap the opus +crate's libopus FFI with hot-path match-and-continue. +``` + +### Atomic commits + +One logical change per commit. Doc ratifications, code, and tests each +land as separate commits when practical. Don't bundle unrelated work. + +A new feature + its tests can land in one commit if the test is part of +the feature's correctness story (TDD). A refactor + a new feature should +be two commits. + +### Never commit secrets + +`.gitignore` covers `.env*`, `*.pem`, `*.key`. If a new secret pattern +appears in your work, extend `.gitignore` in the same commit. + +--- + +## Code style + +See [`AGENTS.md`](AGENTS.md) "Code style" for the full guide. Highlights: + +- `cargo fmt` is the source of truth for formatting. Don't hand-format. +- `clippy -D warnings` is the lint bar. Fix the code, don't suppress + with `#[allow]` unless the rationale is documented inline. +- `snake_case` (functions, variables, modules, crates), `PascalCase` + (types), `UPPER_SNAKE_CASE` (constants). +- Newtype wrappers over primitives for type-safety — e.g. `ChannelId(Uuid)`, + not bare `Uuid`. +- Hot path (the 20 ms media loop): **never** `?`-propagate. + Match-and-continue. Dropped packet must not terminate the peer. +- Cold path: `thiserror`-derived error enums + `?`, converted to HTTP + status codes at the axum boundary. +- Never `unwrap()` / `expect()` outside tests or const-init contexts. + +### Documentation comments (learner-facing — important) + +**This project overrides the default "no comments" convention.** The +user is learning Rust from this codebase. Code in slice 1 (and the +spearhead steps) carries thorough educational comments: + +- `//!` module docs on every `lib.rs` / `main.rs` / sub-module +- `///` item docs on every public struct / enum / fn / trait +- `//` inline comments on the *mechanism*, not the what — why this + ownership pattern, why `Arc>` vs `Arc>`, why an + `enum` over a struct with a `kind` field, etc. Aim: a Rust learner + reads the comment and learns a specific Rust concept they wouldn't + have inferred from the code alone. + +`LEARNING.md` at the repo root indexes "to learn concept X, read file Y" +pointers. Add to it when you introduce a new pattern worth surfacing. + +This verbosity is a deliberate trade-off: more tokens now, compound +educational value later. Once a pattern is established, later slices can +be sparser on the well-trodden patterns. + +--- + +## Slice-1 boundaries — what NOT to add (yet) + +If an agent (you, an AI pair, a contributor) proposes adding any of these +in slice 1, the right answer is "no, see slice-1 spec §1.2": + +- Dedicated timing thread for the media loop (step 4) +- TLS on the HTTP signaling surface (step 5) +- Authn / authz / multi-tenancy (step 6) +- Trickle ICE (when NATs demand it) +- The tap itself (step 2 — slice 1 only *pre-paves* the seam) +- The brain / STT / LLM / TTS (step 3) +- Barge-in / VAD-driven playout kill (step 4) +- PSTN trunk / SIP client (step 5) +- Spend cap / abuse gate (step 6) +- Browser-based automated e2e tests / Selenium / Playwright (post-slice-1) +- Docker / compose (later rung) +- Event bus / Valkey / CDR emission (step 5) +- Transfer / park / pickup / barge features (escalation rung 2) + +The spearhead depends on this sequencing. Adding things early breaks +the ordering each step is its own proof. + +--- + +## Terminology policy + +**Avoid authoritarian / exclusionary terms** in code, prose, identifiers, +and endpoint names. Use equally-descriptive alternatives: + +| Avoid | Use instead | +|---|---| +| police / policing (the verb) | enforce / gate / guard | +| master / slave | primary / replica, leader / follower, controller / worker | +| blacklist / whitelist | denylist / allowlist, blocklist / safelist | +| officer | operator / handler / controller | +| censor | suppress / filter | + +**Exception: protocol-convention names are kept verbatim** when they come +from upstream specs or libraries — replacing them would hurt the +educational mapping to upstream docs. **ICE** (Interactive Connectivity +Establishment, RFC 8445) stays: it's the protocol name in `str0m::ice`, +`RTCIceCandidate`, and the cargo ecosystem. + +See [`AGENTS.md`](AGENTS.md) "Terminology policy" for the full table. + +--- + +## Pull request workflow + +1. Branch from `main`. +2. Make atomic commits per the guidance above. +3. Push the branch + open a PR targeting `main`. +4. CI runs: fmt, clippy, test, deny. All four must be green. +5. Reviewer checks the diff against the spec / ADRs cited in the commit + message. New dependencies require scrutiny — `cargo deny check` + enforces license + source bans, but reviewers should also sanity-check + the dependency choice against the architecture. +6. Squash-merge once approved + green. +7. Delete the branch post-merge (keeps the branch list tidy). + +### Reviewing + +When reviewing a PR: + +- Does it cite the relevant ADR / spec section? (For substantial changes.) +- Does it add anything from the slice-1 "what NOT to add" list? (Reject + if so — refer to slice-1 spec §1.2.) +- Are educational comments present where a new pattern is introduced? +- Any `unwrap()` / `expect()` outside tests? (Reject unless justified.) +- Does the hot-path code use `?`? (Reject — it must match-and-continue.) +- Does the diff bundle unrelated work? (Ask for split commits.) + +--- + +## License + +By contributing, you agree your contributions are licensed under +**GPL-3.0-or-later** (ADR-0004). Strong copyleft in the Asterisk lineage. + +If you contribute code with a different license header / SPDX expression +in a Cargo manifest, CI will reject it (`cargo deny check licenses`). +Don't introduce dependencies whose licenses conflict — check `deny.toml` +for the allow-list. diff --git a/README.md b/README.md index 1fdce9b..d8b30c2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,36 @@ or its architecture. > technical builder uses to stand up a contact center* — and re-aims it at a category AI is > actively disrupting, instead of a PBX category UCaaS already ate. +## Quickstart + +```bash +# Prereqs: Rust (rustup), libopus dev headers (libopus-dev / opus-devel / brew install opus) +cargo run +# listening on http://0.0.0.0:8080 +``` + +Open → click "Start call" → grant mic → hear yourself echo. +Full walkthrough + troubleshooting: **[`docs/QUICKSTART.md`](docs/QUICKSTART.md)**. + +> **Status:** Slice 1 (WebRTC media loopback) is the active build target. The workspace is +> landing task-by-task on the `slice-1-webrtc-loopback` branch. Design: +> [`docs/superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md`](docs/superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md). +> Implementation plan: +> [`docs/superpowers/plans/2026-06-28-slice-1-webrtc-loopback.md`](docs/superpowers/plans/2026-06-28-slice-1-webrtc-loopback.md). + +## Documentation + +| Doc | For when you want to… | +|---|---| +| [`docs/QUICKSTART.md`](docs/QUICKSTART.md) | Run it in 5 minutes | +| [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | Iterate on the codebase (workspace layout, per-crate testing, dev loop) | +| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Understand the fused per-call vertical + composable platform + agent tap | +| [`docs/PORT_PLAN.md`](docs/PORT_PLAN.md) | See every Asterisk subsystem mapped to a disposition (capability checklist, not template) | +| [`docs/adr/`](docs/adr/) | Load-bearing architecture decisions | +| [`AGENTS.md`](AGENTS.md) | Project orientation for any agent (human/AI/hybrid) working in the repo | +| [`CONTRIBUTING.md`](CONTRIBUTING.md) | Trunk-based dev workflow, CI gates, commit style, review checklist | +| [`LEARNING.md`](LEARNING.md) | Index of "to learn concept X, read file Y" (learner-facing codebase) | + ## Why it exists Asterisk won because contact centers were **built on it** (Vicidial, GOautodial, a thousand diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..2600f2a --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,195 @@ +# Development + +The dev loop: how to iterate on the Rust workspace, run tests, and find +your way around the codebase. + +> **Status:** Slice 1 is the active build target. The workspace is +> landing task-by-task on the `slice-1-webrtc-loopback` branch. Once it +> merges to `main`, everything below applies directly. Until then, +> `git checkout slice-1-webrtc-loopback` to follow along. +> Implementation plan: [`docs/superpowers/plans/2026-06-28-slice-1-webrtc-loopback.md`](superpowers/plans/2026-06-28-slice-1-webrtc-loopback.md). + +--- + +## Workspace layout + +Cargo workspace at the repo root, shaped to ADR-0002's fused per-call +vertical. One binary crate + five library crates. + +``` +rutster/ +├── Cargo.toml # [workspace] + [workspace.dependencies] +├── deny.toml # cargo-deny config (licenses/bans/sources) +├── rust-toolchain.toml # pinned stable +├── LEARNING.md # index of "to learn concept X, read file Y" +├── crates/ +│ ├── rutster/ # binary: axum signaling server + media driver +│ │ ├── src/main.rs +│ │ ├── src/session_map.rs # DashMap +│ │ ├── src/routes.rs # HTTP routes +│ │ └── static/index.html # browser test client +│ ├── rutster-media/ # str0m WebRTC + Opus⇄PCM codec boundary +│ │ ├── src/pcm.rs # PcmFrame, AudioSource/Sink traits, EchoAudioPipe +│ │ ├── src/opus_codec.rs # OpusDecoder / OpusEncoder wrappers +│ │ ├── src/rtc_session.rs # RtcSession (per-peer owner) +│ │ └── src/loop_driver.rs # str0m poll loop +│ ├── rutster-call-model/ # the Channel/Leg object embryo +│ ├── rutster-signaling-sip/ # stub until spearhead step 5 +│ ├── rutster-tap/ # stub until spearhead step 2 +│ └── rutster-spend/ # stub until spearhead step 6 +└── fuzz/ # placeholder cargo-fuzz dir (real harnesses: step 5) +``` + +The three stub crates (`rutster-signaling-sip`, `rutster-tap`, +`rutster-spend`) exist to lock the ADR-0002 boundary shape without +anticipating code. Each is a `lib.rs` with a `//!` module doc comment +describing what will land there and when. Don't fill them in early — +see [`AGENTS.md`](../AGENTS.md) "Slice-1 boundaries — what NOT to add." + +### Dependency direction + +- `rutster` (binary) → `rutster-media`, `rutster-call-model` +- `rutster-media` → `rutster-call-model` +- `rutster-call-model` is a leaf (depends on nothing in the workspace) +- The three stubs depend on nothing in slice 1 + +--- + +## Build / test / lint commands + +The four CI gates — all must pass before merge. Run them locally before +pushing: + +```bash +cargo fmt --check # formatting check +cargo clippy -- -D warnings # lints (warnings = failures) +cargo test --all # all unit + integration tests +cargo deny check # licenses, advisories, bans, sources +``` + +To auto-fix formatting + lint warnings: + +```bash +cargo fmt # apply formatting +cargo clippy --fix # apply lint suggestions +cargo clippy --fix --all-features # apply across all features +``` + +### Per-crate iteration + +When working on one crate, skip the rest for faster cycles: + +```bash +cargo test -p rutster-media # one crate's tests +cargo test -p rutster-media -- --nocapture # see println! output +cargo run -p rutster # run the binary +cargo build -p rutster-call-model # type-check one crate +cargo doc -p rutster-media --open # render one crate's docs +``` + +### Render full API docs + +```bash +cargo doc --no-deps --open +``` + +Every module has `//!` module docs explaining what it does + why it +exists in the architecture. `cargo doc` is genuinely useful here, not +just ceremony — the user is learning Rust from this codebase and slice 1 +carries thorough educational comments per spec §7. + +--- + +## Running the binary + +```bash +cargo run -p rutster +# listening on http://0.0.0.0:8080 +``` + +Open in a browser → click "Start call" → grant +mic → hear yourself echo. See [`QUICKSTART.md`](QUICKSTART.md) for +the full walkthrough + troubleshooting. + +Verbose tracing: + +```bash +RUST_LOG=rutster=debug cargo run -p rutster +``` + +Filter to one module: + +```bash +RUST_LOG=rutster_media::loop_driver=trace,rutster=info cargo run -p rutster +``` + +--- + +## The 20 ms media loop & the "drop + observe" rule + +The hot path is the str0m poll loop in +`crates/rutster-media/src/loop_driver.rs`. It runs every ~10 ms (tokio +interval, slice-1 deviation per spec §3.4 — step 4 lands a dedicated +timing thread). + +Error policy on the hot path: **never** `?`-propagate. Match-and-continue. +A dropped packet must not terminate the peer. Policy: "drop + observe +(log + counter), don't crash." The eventual fuzz harness (step 5) will +test against this exact posture. + +Cold path (signaling, setup, request handlers) uses `thiserror`-derived +error enums + `?` propagation, converted to HTTP status codes at the +axum boundary. + +See [`AGENTS.md`](../AGENTS.md) "Error handling" for the full policy. + +--- + +## Shipped vs. deferred + +Slice 1 proves the **media core** only: WebRTC termination + the +Opus⇄PCM codec boundary. The tap, the brain, barge-in, the trunk, and +the spend cap are all explicitly deferred — each lands in a subsequent +spearhead step. + +The full "what's deferred + when it returns" table is in +[`docs/superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md` §1.2](superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md). + +The short version — **don't add these in slice 1:** + +- ❌ Dedicated timing thread (step 4) +- ❌ TLS on the HTTP signaling surface (step 5) +- ❌ Authn / authz / multi-tenancy (step 6) +- ❌ Trickle ICE (when NATs demand it) +- ❌ The tap itself (step 2 — slice 1 only *pre-paves* the seam) +- ❌ The brain / STT / LLM / TTS (step 3) +- ❌ Barge-in / VAD (step 4) +- ❌ PSTN trunk / SIP (step 5) +- ❌ Spend cap (step 6) +- ❌ Event bus / Valkey / CDR emission (step 5) +- ❌ Transfer / park / pickup / barge features (escalation rung 2) +- ❌ Browser automation e2e tests (post-slice-1) +- ❌ Docker / compose (later rung) + +If you find yourself reaching for any of these, the right answer is +"no, see slice-1 spec §1.2." + +--- + +## Dev loop gotchas + +- **libopus system dependency.** The `opus` crate links system libopus + via FFI. If you see `error: linking with cc failed: exit code`, install + `libopus-dev` (Debian/Ubuntu) / `opus-devel` (Fedora) / `brew install opus` + (macOS). See [`QUICKSTART.md`](QUICKSTART.md) for platform-specific + commands. +- **`cargo deny` first-time setup.** Run `cargo install cargo-deny --locked` + once. `cargo deny check` then validates licenses, advisories, and + duplicate-version bans. +- **Editor IDE.** `rust-analyzer` is recommended — it's what the plan was + written against. Open the repo root in your editor; `rust-analyzer` + picks up `rust-toolchain.toml` and the workspace manifest + automatically. +- **Slow first build.** str0m + axum + tokio compile fresh on first + build (~2 minutes). Incremental builds are fast. Use + `cargo check -p ` instead of `cargo build` for type-check-only. diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 0000000..223eee9 --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,115 @@ +# Quickstart + +Get Rutster running and hear your own voice echoed back in under 5 minutes. + +> **Status:** Slice 1 (WebRTC media loopback) is the active build target. +> If the workspace isn't on `main` yet, check the `slice-1-webrtc-loopback` +> branch — that's where the implementation is landing task-by-task. +> See [`docs/superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md`](superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md) +> for the full design. + +--- + +## Prerequisites + +### 1. Rust toolchain + +Install via [rustup](https://rustup.rs/): + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +The repo pins a specific stable channel in `rust-toolchain.toml` — `rustup` +will pick it up automatically on first `cargo` invocation. No manual +toolchain selection needed. + +### 2. libopus (FFI dependency) + +The `opus` crate links system libopus via FFI (per PORT_PLAN §7's +"🦀 Core (FFI)" disposition — Opus is the codec surface Rust doesn't need +to re-implement). Install the dev headers: + +| Platform | Command | +|---|---| +| Debian/Ubuntu | `sudo apt-get install -y libopus-dev` | +| Fedora | `sudo dnf install -y opus-devel` | +| Arch | `sudo pacman -S opus` | +| macOS (Homebrew) | `brew install opus` | + +Verify: `pkg-config --cflags opus` should print a path with no error. + +That's the only system dependency in slice 1. Everything else is pure +Rust from crates.io. + +--- + +## Run the server + +```bash +cargo run +# listening on http://0.0.0.0:8080 +``` + +First build takes ~2 minutes (str0m + axum + tokio compile fresh). +Subsequent builds are incremental. + +--- + +## Hear the echo + +1. Open a browser to . +2. Click **Start call**. +3. Grant microphone permission when the browser prompts. +4. Speak — you should hear yourself back within ~200 ms + (no perceptible delay). +5. Click **Hang up** to tear down. The server logs + `Closing → Closed` for the session. + +Verbose tracing for debugging: + +```bash +RUST_LOG=rutster=debug cargo run +``` + +--- + +## Troubleshooting + +| Symptom | Likely cause / fix | +|---|---| +| `error: linking with cc failed` / `could not find opus` | libopus dev headers not installed. Re-run the install command above. | +| Browser shows no mic prompt | Another tab/app holding the mic, or mic permissions disabled for `localhost`. Check browser settings. | +| `ICE connection failed` in the browser | Shouldn't happen on loopback (host candidates only). If it does, check the server console for the str0m error. | +| Click Start call, nothing happens | Open the browser console (F12). The page logs ICE state + connection state to a `
` element. Look for the failure there. |
+| Port 8080 already in use | Another process holding the port. Either stop it or edit `crates/rutster/src/main.rs` to bind a different port. |
+
+The browser test page at `GET /` is a single self-contained HTML file
+with inline JS — no build step. View source to see exactly what the
+client side is doing.
+
+---
+
+## What's happening
+
+When you click "Start call":
+
+1. Browser captures microphone audio via `getUserMedia`.
+2. Browser creates an `RTCPeerConnection` and generates an SDP offer
+   (audio-only, Opus codec).
+3. Browser POSTs the offer to `POST /v1/sessions/:id/offer`.
+4. The Rutster core (built on [`str0m`](https://docs.rs/str0m), a sans-IO
+   WebRTC implementation) accepts the offer, generates an SDP answer with
+   its DTLS fingerprint + ICE credentials.
+5. Browser sets the answer as remote description; ICE + DTLS handshake
+   completes.
+6. RTP starts flowing: browser → core terminates DTLS-SRTP → decodes
+   Opus to 16-bit PCM @ 24 kHz mono → echoes PCM back → re-encodes to
+   Opus → DTLS-SRTP → browser plays it.
+
+The "codec-to-PCM boundary" is the canonical point where, in a future
+slice, the audio tap for an external AI brain splices in. Slice 1 just
+echoes; step 2 of the spearhead swaps the echo for a real tap.
+
+For the why, see [`ARCHITECTURE.md`](ARCHITECTURE.md). For the dev loop,
+see [`DEVELOPMENT.md`](DEVELOPMENT.md).