Co-authored-by: Aaron D. Lee <himself@adlee.work> Co-committed-by: Aaron D. Lee <himself@adlee.work>
1079 lines
52 KiB
Markdown
1079 lines
52 KiB
Markdown
# Slice 5 — Rented transport: a real phone number via Twilio Media Streams — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Stand up spearhead step 5 — a real phone number with no first-party SIP stack. Twilio
|
||
Media Streams forks the PSTN audio over WSS to rutster; call control is Twilio's REST API
|
||
(green-zone, the brain never sees credentials). The PSTN leg enters the same FOB reflex loop
|
||
WebRTC legs use — same `Reflex<TapAudioPipe>` + `LocalVadReflex` composition (slice-4 REUSED
|
||
unchanged), same `MediaThread` tick loop, same barge-in semantics.
|
||
|
||
**Architecture:** `crates/rutster-trunk/` (currently a stub; ADR-0007 pre-paved the boundary)
|
||
fills in with: `G711Codec` (in-core µ-law encode/decode + 8 kHz↔24 kHz linear-interpolated
|
||
resampling), `TwilioMediaStreamsServer` (axum WSS endpoint), `TrunkSession` + a parallel
|
||
`trunk_driver::drive` function (in the trunk crate, NOT in `crates/rutster-media/src/loop_driver.rs`
|
||
— the seam holds), `CallControlClient` trait + `MockCallControlClient` + (feature-gated)
|
||
`TwilioCallControlClient`. The binary-side `MediaThread` gains a `MediaLeg` enum
|
||
(`WebRTC(RtcSession) | Trunk(TrunkSession)`) + a new `MediaCmd::RegisterTrunk` variant; the
|
||
tick loop dispatches via the enum.
|
||
|
||
**Tech Stack:** Rust stable + 1.85 (CI matrix), the existing workspace, plus `async-trait` +
|
||
`reqwest` (already workspace members per slice-2's TapClient WS pattern; reused). No other new deps.
|
||
|
||
## Global Constraints
|
||
|
||
- **License:** GPL-3.0-or-later on every crate manifest (ADR-0004).
|
||
- **DCO:** every commit signed off — `git commit -s` (AGENTS.md Git workflow).
|
||
- **Seam gate (UNCHANGED):** `crates/rutster-media/src/loop_driver.rs` + `crates/rutster-media/src/rtc_session.rs`
|
||
stay byte-identical (CI pinned-blob from slice-4 Task 10 continuously enforced). The new trunk
|
||
driver lives in `crates/rutster-trunk/src/loop_driver.rs` — a parallel-titled file in a different
|
||
crate. The `MediaLeg` match-arm that calls `loop_driver::drive(s, now)` for WebRTC legs is one
|
||
new line in `crates/rutster/src/media_thread.rs` (the existing call to `loop_driver::drive`
|
||
routes through it); the `MediaLeg::Trunk(s) => trunk_driver::drive(s, now)` arm is its sibling.
|
||
- **Hot-path policy:** never `?`-propagate on the 20 ms tick; match-and-continue;
|
||
"drop + observe." No `unwrap()`/`expect()` outside tests/const-init.
|
||
- **Credential isolation (ADR-0009, load-bearing):** `TwilioCredentials` lives ONLY in
|
||
`crates/rutster-trunk/`. NOT re-exported through the workspace. NOT in `rutster-media`'s
|
||
public API. NOT in `rutster-tap`'s public API. NOT in the brain's WS protocol. The brain
|
||
never sees account SID, auth_token, REST endpoint URL, OR CallSid (CallSid is operational
|
||
correlation ID for the FOB's logs only).
|
||
- **Code style:** `cargo fmt` is the single whitespace source of truth. `clippy -D warnings` is
|
||
the lint bar. Newtype wrappers per AGENTS.md where applicable (none intrinsically needed
|
||
this slice — `CallSid: String` is operational correlation, not a type-safety boundary).
|
||
- **Naming:** `snake_case` fns/vars/modules; `PascalCase` types; `UPPER_SNAKE_CASE` consts.
|
||
- **Learner-facing comments:** per AGENTS.md code style. µ-law codec comments TEACH the
|
||
companding formula — this is exactly the kind of telephony-history the project's "learns Rust
|
||
from this codebase" goal benefits from.
|
||
- **CI gates:** `cargo fmt --check`, `cargo clippy --all --all-targets -- -D warnings` (with
|
||
AND without `--features=twilio-live`), `cargo test --all` (default-features off — Mock runs
|
||
in CI), `cargo test --all --features=twilio-live` (LOCAL ONLY — maintainer runs when validating
|
||
a release), `cargo deny check`.
|
||
- **Branch/PR:** branches `slice-5/rented-transport-dev-b` (T2+T6+T9+T10) + `slice-5/rented-transport-dev-c`
|
||
(T1+T3+T4+T5+T7+T8) per strategic plan §4.1; PR via `tea`.
|
||
|
||
## File Structure
|
||
|
||
### New files
|
||
|
||
| Path | Responsibility |
|
||
|---|---|
|
||
| `crates/rutster-trunk/src/g711.rs` | `G711Codec` — µ-law encode/decode tables + 8 kHz↔24 kHz linear-interpolated resampling. Generates `mulaw_decode_table.rs` + `mulaw_encode_table.rs` as build-time `include!`s OR as compile-time-computed `const` arrays. |
|
||
| `crates/rutster-trunk/src/mulaw_decode_table.rs` | 256-element `[i16; 256]` decode table, generated from the ITU-T G.711 formula. (Author writes the generator snippet in a comment; the table itself is the compiled output.) |
|
||
| `crates/rutster-trunk/src/mulaw_encode_table.rs` | 65536-element `[u8; 65536]` encode table, same generator pattern. |
|
||
| `crates/rutster-trunk/src/twilio_media_streams.rs` | `TwilioMediaStreamsServer` — axum WS handler accepting Twilio Media Streams forks; parses JSON envelope (`connected` / `start` / `media` / `stop`); ferries decoded PCM frames into a per-call mpsc + drains the outbound mpsc to send back. |
|
||
| `crates/rutster-trunk/src/session.rs` | `TrunkSession` — the per-trunk-leg session struct (parallels slice-1's `RtcSession` minus str0m/Opus/UDP). |
|
||
| `crates/rutster-trunk/src/loop_driver.rs` | `trunk_driver::drive(&mut TrunkSession, now) -> Option<Duration>` — the trunk-leg tick function. Parallels `crates/rutster-media/src/loop_driver.rs` minus the str0m/Opus/RTP machinery. |
|
||
| `crates/rutster-trunk/src/provider/mod.rs` | `CallControlClient` trait + `CallControlError` + `SpendToken` placeholder (pre-paved seam for step 6). |
|
||
| `crates/rutster-trunk/src/provider/mock.rs` | `MockCallControlClient` — in-process test double; CI gate uses this. |
|
||
| `crates/rutster-trunk/src/provider/twilio.rs` | `TwilioCallControlClient` — REST client via reqwest (feature-gated behind `twilio-live`; NOT compiled in CI default). |
|
||
| `crates/rutster-trunk/tests/reflex_on_trunk.rs` | Reflex-on-trunk-leg verification test (T7): proves `Reflex<TapAudioPipe>` + `LocalVadReflex` (slice-4, REUSED) decorate the trunk leg's TapAudioPipe identically to a WebRTC leg. Barge-in fires. |
|
||
| `crates/rutster-trunk/tests/sim_integ.rs` | PSTN sim e2e integration test (T8): `MockCallControlClient` + `MockTwilioMediaStreamsServer` → MediaThread → Reflex → barge-in verified on PSTN leg → brain reply observed → CDR/EventSink emission. |
|
||
|
||
### Modified files
|
||
|
||
| Path | What changes |
|
||
|---|---|
|
||
| `crates/rutster-trunk/Cargo.toml` | Add deps: `rutster-media`, `rutster`, `rutster-call-model`, `rutster-tap`, `tokio`, `axum`, `reqwest`, `async-trait`, `serde`, `serde_json`, `tracing`, `thiserror`, `base64`, `url`. Add features: `default = []`, `twilio-live = []` (gates `provider/twilio.rs`). |
|
||
| `crates/rutster-trunk/src/lib.rs` | Replace stub doc-comment-only with `pub mod` declarations + re-exports per spec §3. |
|
||
| `crates/rutster/src/media_thread.rs` | Add `MediaLeg` enum + `MediaCmd::RegisterTrunk { call_sid, inbound_from_twilio_rx, outbound_to_twilio_tx, tap_url, reply }` variant + the std-thread match-arm dispatch: `MediaLeg::WebRTC(s) => loop_driver::drive(s, now)` (UNCHANGED) vs `MediaLeg::Trunk(s) => trunk_driver::drive(s, now)` (NEW). The session map's value type changes from `RtcSession` to `MediaLeg`. |
|
||
| `crates/rutster/src/main.rs` | Construct + mount `TwilioMediaStreamsServer::router(register_tx)` on the existing axum router. Routes `/twilio/media-stream` (WS), `POST /v1/trunk/sessions`, `POST /v1/trunk/webhook` (T5). |
|
||
| `crates/rutster/src/routes.rs` | Add the `POST /v1/trunk/sessions` (originate) + `POST /v1/trunk/webhook` (Twilio callback receiver) handlers. Handlers route via `CallControlClient` (green-zone). |
|
||
| `crates/rutster/src/config.rs` | Add `pub fn twilio_credentials() -> Option<TwilioCredentials>` env parser per slice-5/seams pattern (`RUTSTER_TWILIO_ACCOUNT_SID`, `RUTSTER_TWILIO_AUTH_TOKEN`, `RUTSTER_TWILIO_MEDIA_BIND`, `RUTSTER_TWILIO_WEBHOOK_BASE`). |
|
||
| `crates/rutster/src/lib.rs` | `pub mod twilio_credentials;` re-export OR fold into existing `config`. (Prefer: keep `TwilioCredentials` in `rutster-trunk` + import via `crates/rutster/src/config.rs::twilio_credentials()`.) |
|
||
| `.github/workflows/ci.yml` | Add a `twilio-live` job (runs locally only, NOT on every PR — gated by a manual trigger). The routine CI job stays unchanged (no `--features=twilio-live`). |
|
||
| `docs/QUICKSTART.md` | Add env-var table for `RUTSTER_TWILIO_*` env vars + a "make a real phone call" walkthrough (with `--features=twilio-live`). |
|
||
| `README.md` | Update the spearhead status line: slice-1–4 + 4½ + slice-5 all green; "make a real phone call via Twilio Media Streams" linked from the Quickstart. |
|
||
| `deny.toml` | Add `reqwest`'s transitive deps to the allowlist if `cargo deny check` flags any (most are already admitted via existing transitive deps). |
|
||
|
||
### SEAM-INVARIANT files (DO NOT TOUCH)
|
||
|
||
- `crates/rutster-media/src/loop_driver.rs` — byte-identical, all tasks.
|
||
- `crates/rutster-media/src/rtc_session.rs` — byte-identical, all tasks.
|
||
|
||
## Task ordering (for Kimi-worker subagent dispatch)
|
||
|
||
Per strategic plan §3.2 + §4.1: **dev-c owns the FOB media-streams chain (T1→T3→T4→T5→T7→T8)**,
|
||
**dev-b owns the green-zone / provider + docs + final sweep chain (T2→T6→T9→T10)**. Both
|
||
devs work in their own worktrees + branches. dev-b can start T2 in parallel with dev-c on T1
|
||
— the provider trait doesn't depend on the codec's internals (separate modules).
|
||
|
||
- **T1** — CRITICAL-PATH FOUNDATION for dev-c. `G711Codec` + decode/encode tables. Lands first
|
||
on the dev-c chain; T3 + T4 consume it. NOT parallelizable across devs (single owner).
|
||
- **T2** — FOUNDATION for dev-b. `CallControlClient` trait + `MockCallControlClient` +
|
||
`TwilioCredentials` + env parser. PARALLEL with T1 (different module).
|
||
- **T3** — depends on T1 (consumes `G711Codec`). `TwilioMediaStreamsServer`.
|
||
- **T4** — depends on T3 (consumes the WSS server's mpsc wiring). `TrunkSession` +
|
||
`trunk_driver::drive`.
|
||
- **T5** — depends on T4 (TrunkSession is real) + T2 (CallControlClient for the route handlers).
|
||
`MediaCmd::RegisterTrunk` + `MediaLeg` enum + the two new HTTP routes.
|
||
- **T6** — depends on T2. `TwilioCallControlClient` (live REST impl behind `twilio-live` feature)
|
||
+ config env parser.
|
||
- **T7** — depends on T4 + T5. Reflex-on-trunk-leg verification test.
|
||
- **T8** — depends on T5 + T6 + T7. PSTN-sim e2e integration test.
|
||
- **T9** — filler after T5 + T6 land. QUICKSTART + README updates.
|
||
- **T10** — final sweep depends on T6 (the `reqwest` dep). Cargo-deny + CI gate.
|
||
|
||
Parallelizable-now filler: LEARNING.md pointers (after T1 + T4 land), `cargo doc` rendering
|
||
checks (after the crate skeleton stabilizes).
|
||
|
||
---
|
||
|
||
### Task T1: `G711Codec` + µ-law decode/encode tables — the dev-c critical-path foundation
|
||
|
||
**Files:**
|
||
- Create: `crates/rutster-trunk/Cargo.toml` (rewrite the stub — add deps)
|
||
- Create: `crates/rutster-trunk/src/g711.rs`
|
||
- Create: `crates/rutster-trunk/src/mulaw_decode_table.rs`
|
||
- Create: `crates/rutster-trunk/src/mulaw_encode_table.rs`
|
||
- Modify: `crates/rutster-trunk/src/lib.rs` (add `pub mod g711;`)
|
||
- Test: inline `#[cfg(test)] mod tests` in `g711.rs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `rutster_media::PcmFrame` (the 24 kHz canonical format from slice-1).
|
||
- Produces:
|
||
- `pub struct G711Codec;` (no state — pure static-method form for MVP)
|
||
- `pub fn G711Codec::decode_mulaw_to_pcm(mulaw: &[u8]) -> PcmFrame`
|
||
- `pub fn G711Codec::encode_pcm_to_mulaw(frame: &PcmFrame) -> Vec<u8>`
|
||
|
||
- [ ] **Step 1: Author the `Cargo.toml` with deps**
|
||
|
||
```toml
|
||
# crates/rutster-trunk/Cargo.toml
|
||
[package]
|
||
name = "rutster-trunk"
|
||
version = "0.0.0"
|
||
license.workspace = true
|
||
edition.workspace = true
|
||
repository.workspace = true
|
||
description = "Rented carrier transport — CPaaS media-leg ingress; no first-party SIP (spearhead step 5, ADR-0007)."
|
||
|
||
[dependencies]
|
||
rutster-media = { path = "../rutster-media" }
|
||
rutster-call-model = { path = "../rutster-call-model" }
|
||
rutster-tap = { path = "../rutster-tap" }
|
||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] }
|
||
axum = { workspace = true }
|
||
async-trait = { workspace = true }
|
||
reqwest = { workspace = true, features = ["json"] }
|
||
serde = { workspace = true, features = ["derive"] }
|
||
serde_json = { workspace = true }
|
||
tracing = { workspace = true }
|
||
thiserror = { workspace = true }
|
||
base64 = { workspace = true }
|
||
url = { workspace = true }
|
||
|
||
[features]
|
||
default = []
|
||
# The live Twilio CallControlClient is feature-gated; CI default-features-off
|
||
# keeps the routine gate clean (Mock runs in CI). Maintainer runs
|
||
# `cargo test --features=twilio-live` + the e2e suite when validating a release.
|
||
twilio-live = []
|
||
```
|
||
|
||
(If `reqwest`/`base64`/`async-trait` aren't yet workspace deps in root `Cargo.toml`, add them
|
||
under `[workspace.dependencies]` mirroring slice-2's TapClient wiring. The dev-c
|
||
implementer should check first: `grep '^reqwest\|^base64\|^async-trait' Cargo.toml`.)
|
||
|
||
- [ ] **Step 2: Author the two lookup tables**
|
||
|
||
`crates/rutster-trunk/src/mulaw_decode_table.rs`:
|
||
|
||
```rust
|
||
//! 8-bit µ-law to 16-bit linear decode table. Generated from the ITU-T G.711
|
||
//! standard piecewise-linear decoding formula. See `g711.rs` for the
|
||
//! formula's explanation. Hand-verified against the ITU-T spec tables.
|
||
//!
|
||
//! The table is the actual compiled output of:
|
||
//! fn mulaw_to_linear(u: u8) -> i16 { /* G.711 formula */ }
|
||
//! Run once at maintainer time; pasted here as a const array.
|
||
//!
|
||
//! # Generated-by-note
|
||
//!
|
||
//! To regenerate: `cargo test --features=regen-tables` (hypothetical;
|
||
//! the table was authored once and is versioned as a constant — there is
|
||
//! no regen task in MVP).
|
||
|
||
#[rustfmt::skip]
|
||
pub static MULAW_TO_LINEAR: [i16; 256] = [
|
||
/* generated table - 256 entries */
|
||
// FIXME (implementer): paste the 256 i16 values from the G.711 standard
|
||
// table. Authoritative source: ITU-T G.711 (1972, reaffirmed 2000).
|
||
// The formula: bias=0x84, clip=32635; let sign = u & 0x80; let exponent = (u >> 4) & 0x07;
|
||
// let mantissa = u & 0x0F; let sample = ((mantissa << 3) + bias) << exponent;
|
||
// sample = if sample > clip { clip } else { sample };
|
||
// return if sign != 0 { -(sample) } else { sample } as i16;
|
||
// (Implementer note: write a one-shot test that produces the table; the
|
||
// values ARE the table. The test becomes the documentation.)
|
||
0, 0, /* ... 254 more entries ... */ 0,
|
||
];
|
||
```
|
||
|
||
Actually — the simpler path: write a `const fn decode_one(u: u8) -> i16` per the formula +
|
||
build the table at compile time. That's cleaner + doesn't require regenerating a literal
|
||
list. **Recommended path: use a `const fn` + `const` array initialization.**
|
||
|
||
```rust
|
||
// crates/rutster-trunk/src/g711.rs (table approach via const fn)
|
||
|
||
/// The ITU-T G.711 µ-law to linear decoding formula. Const fn so the
|
||
/// table is computed at compile time (zero runtime cost).
|
||
const fn mulaw_decode_one(u: u8) -> i16 {
|
||
let bias: i32 = 0x84;
|
||
let clip: i32 = 32635;
|
||
let sign = (u & 0x80) != 0;
|
||
let exponent = ((u >> 4) & 0x07) as i32;
|
||
let mantissa = (u & 0x0F) as i32;
|
||
let mut sample = ((mantissa << 3) + bias) << exponent;
|
||
if sample > clip { sample = clip; }
|
||
if sign { (-(sample)) as i16 } else { sample as i16 }
|
||
}
|
||
|
||
pub static MULAW_TO_LINEAR: [i16; 256] = {
|
||
let mut t = [0i16; 256];
|
||
let mut i = 0;
|
||
while i < 256 {
|
||
t[i] = mulaw_decode_one(i as u8);
|
||
i += 1;
|
||
}
|
||
t
|
||
};
|
||
```
|
||
|
||
For the inverse direction (linear→µ-law), the table is 65536 elements = 64 KB binary size. Author
|
||
the encode table as `const fn`:
|
||
|
||
```rust
|
||
const fn linear_to_mulaw_one(s: i16) -> u8 {
|
||
// ITU-T G.711's reference encode formula. See ITU-T G.711 §2.2.
|
||
// Pseudocode (from the spec, adapted to Rust):
|
||
let bias: i32 = 0x84;
|
||
let clip: i32 = 32635;
|
||
let s = if s < 0 { -s.max(-(clip + 1)) } else { s.min(clip) };
|
||
let s_bias = s + bias;
|
||
let sign = if s < 0 { 0x80 } else { 0x00 };
|
||
// Find the highest set bit position (encoded as exponent).
|
||
let exponent = (s_bias.leading_zeros() as i32).wrapping_sub(1).max(0).min(7);
|
||
let mantissa = ((s_bias >> (exponent + 3)) & 0x0F) as u8;
|
||
let u = !(sign as u8 | ((exponent as u8) << 4) | mantissa);
|
||
u
|
||
}
|
||
```
|
||
|
||
(The formula above is a sketch — the implementer MUST cross-verify against the ITU-T G.711
|
||
spec's reference C code, which is widely available. The correctness test in Step 5
|
||
cross-checks encode(decode(x)) == x for every byte value.)
|
||
|
||
- [ ] **Step 3: Author `g711.rs` with `decode_mulaw_to_pcm` + `encode_pcm_to_mulaw`**
|
||
|
||
The bodies mirror spec §3.1 verbatim — 3× linear upsample + 3× decimation downsample. Don't
|
||
invent new code.
|
||
|
||
- [ ] **Step 4: Write failing tests**
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use rutster_media::PcmFrame;
|
||
|
||
#[test]
|
||
fn decode_then_encode_round_trips_a_loud_signal() {
|
||
// Hand-craft a 24 kHz PcmFrame with a loud sample → encode to µ-law →
|
||
// decode back → assert energy is preserved within 12% (µ-law's intrinsic
|
||
// quantization error floor).
|
||
let mut frame = PcmFrame::zeroed();
|
||
for s in frame.samples.iter_mut() { *s = 10_000; }
|
||
let mulaw = G711Codec::encode_pcm_to_mulaw(&frame);
|
||
assert_eq!(mulaw.len(), 160);
|
||
let decoded = G711Codec::decode_mulaw_to_pcm(&mulaw);
|
||
// Energy invariant: decoded RMS should be within 12% of the original.
|
||
let orig_rms = rms(&frame.samples);
|
||
let dec_rms = rms(&decoded.samples);
|
||
let drift = (dec_rms - orig_rms).abs() / orig_rms.max(1.0);
|
||
assert!(drift <= 0.12, "µ-law round-trip energy drift {}% > 12%", drift * 100.0);
|
||
}
|
||
|
||
#[test]
|
||
fn decode_160_byte_frame_yields_480_samples() {
|
||
let mulaw = vec![0u8; 160]; // 160 µ-law bytes (silence)
|
||
let frame = G711Codec::decode_mulaw_to_pcm(&mulaw);
|
||
assert_eq!(frame.samples.len(), 480);
|
||
}
|
||
|
||
fn rms(samples: &[i16]) -> f64 {
|
||
let sum_sq: u64 = samples.iter().map(|&s| (s as i64 * s as i64) as u64).sum();
|
||
(sum_sq as f64 / samples.len() as f64).sqrt()
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: fmt + clippy + test + commit**
|
||
|
||
```bash
|
||
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
|
||
git add crates/rutster-trunk/Cargo.toml crates/rutster-trunk/src/{g711.rs,mulaw_decode_table.rs,mulaw_encode_table.rs,lib.rs}
|
||
git commit -s -m "feat(trunk): G711Codec — µ-law encode/decode + 8kHz↔24kHz linear-interpolated resampling (slice-5 T1)
|
||
|
||
In-core ~30-line table-driven codec (no dep). The ITU-T G.711 µ-law
|
||
companding formula is a piece of telephony history worth teaching (AGENTS.md
|
||
learner-facing comment mandate). 3× linear upsample on decode; 3× decimation
|
||
downsample on encode. The resampler artifacts are below the barge-in trigger
|
||
threshold (LocalVadReflex only needs RMS energy); rubato lands in a post-
|
||
spearhead refinement if a downstream consumer needs better (spec §6.6).
|
||
|
||
Task T1 of slice-5 — T3 (TwilioMediaStreamsServer) consumes this codec."
|
||
```
|
||
|
||
---
|
||
|
||
### Task T2: `CallControlClient` trait + `MockCallControlClient` + `TwilioCredentials` config
|
||
|
||
**Files:**
|
||
- Create: `crates/rutster-trunk/src/provider/mod.rs`
|
||
- Create: `crates/rutster-trunk/src/provider/mock.rs`
|
||
- Modify: `crates/rutster-trunk/src/lib.rs` (`pub mod provider;`)
|
||
- Test: inline in `provider/mock.rs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing new.
|
||
- Produces:
|
||
- `#[async_trait] pub trait CallControlClient: Send + Sync { async fn originate(&self, to, from, spend_token: Option<SpendToken>) -> Result<String, CallControlError>; async fn hangup(&self, correlation_id) -> Result<(), CallControlError>; }`
|
||
- `pub struct CallControlError(pub String);`
|
||
- `pub struct SpendToken;` (the pre-paved seam for step 6; opaque placeholder).
|
||
- `pub struct MockCallControlClient { /* in-process queues */ }`
|
||
- `pub struct TwilioCredentials { account_sid, auth_token (NEVER logged), media_streams_bind, webhook_base }` — the struct lives in `provider/mod.rs` (NOT re-exported through the workspace; ADR-0009). The `TwilioCallControlClient` consumes it (T6).
|
||
|
||
- [ ] **Step 1: Define the trait + SpendToken + CallControlError**
|
||
|
||
(Verbatim from spec §3.4 + `async_trait` attribute.)
|
||
|
||
- [ ] **Step 2: Implement `MockCallControlClient`**
|
||
|
||
An in-process test double — `originate` returns a synthetic correlation ID + records the call
|
||
in an internal `Vec`; `hangup` records the terminate. Both async fns never touch the network.
|
||
|
||
- [ ] **Step 3: Define `TwilioCredentials` (no impl yet — fields only)**
|
||
|
||
The struct fields per spec §3.4. NO logging of `auth_token` — implement `Debug` manually with
|
||
a redacted field; don't derive `Debug`.
|
||
|
||
```rust
|
||
impl std::fmt::Debug for TwilioCredentials {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.debug_struct("TwilioCredentials")
|
||
.field("account_sid", &self.account_sid)
|
||
.field("auth_token", &"<redacted>")
|
||
.field("media_streams_bind", &self.media_streams_bind)
|
||
.field("webhook_base", &self.webhook_base)
|
||
.finish()
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tests + fmt + clippy + commit**
|
||
|
||
```bash
|
||
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
|
||
git commit -s -m "feat(trunk): CallControlClient trait + MockCallControlClient + TwilioCredentials (slice-5 T2)
|
||
|
||
The provider abstraction. The trait locks the seam so the next provider
|
||
(Telnyx, etc.) is an implementation, not a refactor. The Mock is the CI
|
||
test double; TwilioCallControlClient (T6) lives behind the twilio-live
|
||
feature flag. TwilioCredentials is NEVER re-exported through the workspace
|
||
(ADR-0009 — provider credentials never reach the brain). The
|
||
Option<SpendToken> parameter on originate pre-paves the spearhead step-6
|
||
spend-cap seam."
|
||
```
|
||
|
||
---
|
||
|
||
### Task T3: `TwilioMediaStreamsServer` — the WebSocket ingress
|
||
|
||
**Files:**
|
||
- Create: `crates/rutster-trunk/src/twilio_media_streams.rs`
|
||
- Modify: `crates/rutster-trunk/src/lib.rs` (`pub mod twilio_media_streams;`)
|
||
- Test: inline `#[cfg(test)] mod tests` covering the JSON envelope parser
|
||
|
||
**Interfaces:**
|
||
- Consumes: `G711Codec` (T1), axum WebSocket types, tokio mpsc, `base64`.
|
||
- Produces:
|
||
- `pub struct TwilioMediaStreamsServer;`
|
||
- `pub struct RegisterTrunkInboundChannel { call_sid: String, inbound_from_twilio_rx: mpsc::Receiver<PcmFrame>, outbound_to_twilio_tx: mpsc::Sender<PcmFrame>, tap_url: url::Url }`
|
||
- `pub fn TwilioMediaStreamsServer::router(register_tx: mpsc::Sender<RegisterTrunkInboundChannel>) -> axum::Router` — mounts `/twilio/media-stream` (axum `get` WebSocket upgrade).
|
||
|
||
- [ ] **Step 1: Define the envelope-parsing types**
|
||
|
||
Twilio Media Streams JSON frames per their documented protocol:
|
||
- `connected`: handshake.
|
||
- `start`: `{ event: "start", stream_sid, call_sid, ... }` — read CallSid + StreamSid.
|
||
- `media`: `{ event: "media", media: { payload: "<base64 µ-law bytes>" } }` — decode payload.
|
||
- `stop`: end.
|
||
|
||
Define `TwilioMediaEvent` serde-representable enum mirroring these.
|
||
|
||
- [ ] **Step 2: Implement `run_media_stream` async loop**
|
||
|
||
Pseudocode:
|
||
1. Wait for first WS message. Expect `connected` envelope; ignore.
|
||
2. Read `start` envelope. Extract CallSid + StreamSid. Construct the two mpsc pairs.
|
||
3. Send `RegisterTrunkInboundChannel` to register_tx. Await `MediaThread`'s `RegisterTrunk` reply
|
||
(a oneshot confirming the ChannelId).
|
||
4. Loop:
|
||
a. Read next WS message:
|
||
- `media`: base64-decode, `G711Codec::decode_mulaw_to_pcm` → `PcmFrame`, push to `inbound_tx`.
|
||
- `stop` (or WS close): break.
|
||
b. Concurrently drain `outbound_rx` (PcmFrame produced by the std thread's TrunkSession via
|
||
`trunk_driver::drive`): `G711Codec::encode_pcm_to_mulaw` → wrap in a JSON `media` envelope
|
||
→ send WS Text frame.
|
||
5. On stop / close: send `MediaCmd::Delete` for the ChannelId; tear down.
|
||
|
||
Use `tokio::select!` for the bidirectional loop (Step 4a + 4b concurrently).
|
||
|
||
- [ ] **Step 3: Tests**
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn media_event_parses_start() {
|
||
let s = r#"{"event":"start","stream_sid":"ZZ...","call_sid":"CA..."}"#;
|
||
let e: TwilioMediaEvent = serde_json::from_str(s).unwrap();
|
||
match e {
|
||
TwilioMediaEvent::Start { call_sid, .. } => assert_eq!(call_sid, "CA..."),
|
||
_ => panic!("expected Start"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn media_event_parses_media() {
|
||
let s = r#"{"event":"media","media":{"payload":"//MA"}}"#;
|
||
let e: TwilioMediaEvent = serde_json::from_str(s).unwrap();
|
||
match e {
|
||
TwilioMediaEvent::Media { payload } => assert!(!payload.is_empty()),
|
||
_ => panic!("expected Media"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn media_event_parses_stop() {
|
||
let s = r#"{"event":"stop"}"#;
|
||
let e: TwilioMediaEvent = serde_json::from_str(s).unwrap();
|
||
assert!(matches!(e, TwilioMediaEvent::Stop));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn wss_pump_decodes_base64_mulaw_and_pushes_pcm_frame() {
|
||
// Integration-ish: feed a synthetic "media" JSON frame into the WS
|
||
// pump task; assert a PcmFrame lands on the inbound_tx mpsc.
|
||
// (Spot-test the base64 decode + G711Codec decode + mpsc push wiring.)
|
||
// Skip the full WS handshake; test the post-parse path.
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: fmt + clippy + test + commit**
|
||
|
||
```bash
|
||
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
|
||
git commit -s -m "feat(trunk): TwilioMediaStreamsServer — axum WS handler for Twilio Media Streams (slice-5 T3)
|
||
|
||
Accepts Twilio's inbound WSS connections; parses the JSON envelope
|
||
(connected/start/media/stop per Twilio's documented protocol); decodes
|
||
base64 µ-law via G711Codec; ferries decoded PCM frames to the per-call
|
||
inbound mpsc. Concurrently drains the outbound mpsc + sends back JSON
|
||
media frames. Same tokio/std-thread split as slice-2's TapEngine: tokio
|
||
owns IO; the std thread owns the 20ms tick via trunk_driver::drive (T4)."
|
||
```
|
||
|
||
---
|
||
|
||
### Task T4: `TrunkSession` + `trunk_driver::drive` — the FOB-side trunk tick function
|
||
|
||
**Files:**
|
||
- Create: `crates/rutster-trunk/src/session.rs`
|
||
- Create: `crates/rutster-trunk/src/loop_driver.rs`
|
||
- Modify: `crates/rutster-trunk/src/lib.rs` (`pub mod session; pub use session::TrunkSession; pub mod loop_driver;`)
|
||
- Test: inline tests in `session.rs` exercising the tick
|
||
|
||
**Interfaces:**
|
||
- Consumes: slice-4 `Reflex<P>` + `LocalVadReflex<P>` + `ReflexMetrics` (REUSED), slice-2
|
||
`TapAudioPipe` (REUSED as the wrapped inner), tokio mpsc, `rutster_call_model::Channel`.
|
||
- Produces:
|
||
- `pub struct TrunkSession { channel, pipe: LocalVadReflex<Reflex<TapAudioPipe>>, inbound_from_twilio_rx, outbound_to_twilio_tx, last_idle_rx, next_timeout }`
|
||
- `pub fn drive(session: &mut TrunkSession, now: Instant) -> Option<Duration>` — the trunk
|
||
tick function.
|
||
|
||
- [ ] **Step 1: Author `TrunkSession`**
|
||
|
||
```rust
|
||
pub struct TrunkSession {
|
||
pub channel: Channel,
|
||
/// The wrapped reflex stack. REUSED from slice-4 verbatim:
|
||
/// `LocalVadReflex<Reflex<TapAudioPipe>>`. Construction reused from
|
||
/// slice-4 Task 6 — same composition site (Reflex::new(inner_pipe,
|
||
/// advisory_rx, metrics) → LocalVadReflex::new(reflex, advisory_tx)).
|
||
pub pipe: LocalVadReflex<Reflex<TapAudioPipe>>,
|
||
pub inbound_from_twilio_rx: mpsc::Receiver<PcmFrame>,
|
||
pub outbound_to_twilio_tx: mpsc::Sender<PcmFrame>,
|
||
pub last_idle_rx: Instant,
|
||
pub next_timeout: Option<Instant>,
|
||
pub last_outbound_at: Instant,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Author `trunk_driver::drive`**
|
||
|
||
Body mirrors spec §3.3 verbatim:
|
||
1. Drain `inbound_from_twilio_rx` via `try_recv` → for each `PcmFrame`:
|
||
`session.pipe.on_pcm_frame(frame)` (hits `LocalVadReflex` first → `Reflex` → `TapAudioPipe` →
|
||
`tx_pcm_in` → TapEngine → brain WS as `audio_in`).
|
||
2. Outbound encode tick (every 20 ms): pull `session.pipe.next_pcm_frame()` → push to
|
||
`outbound_to_twilio_tx` (try_send; drop + observe on full).
|
||
3. Idle timeout: 60 s of no inbound = `ChannelState::Closed`.
|
||
|
||
- [ ] **Step 3: Tests**
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::time::Instant;
|
||
|
||
fn trivial_trunk_session() -> TrunkSession {
|
||
let (inbound_tx, inbound_rx) = mpsc::channel(8);
|
||
let (outbound_tx, mut outbound_rx) = mpsc::channel(8);
|
||
// ... construct the wrapped pipe stack;
|
||
// (For the unit test, the real spawn_tap_engine against a real brain WS
|
||
// is heavy — use a stub TapAudioPipe OR a no-op brain.)
|
||
// Recommendation: simplify this test by constructing the stack with a
|
||
// mock AudioPipe as the inner (instead of TapAudioPipe) — verify the
|
||
// tick function dispatches incoming frames to pipe.on_pcm_frame + pulls
|
||
// from pipe.next_pcm_frame without setting up real TapEngine wiring.
|
||
// The full barge-in e2e is verified in T8, not here.
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn tick_drains_inbound_mpsc_and_pushes_to_pipe_sink() {
|
||
let mut s = trivial_trunk_session();
|
||
s.inbound_from_twilio_tx.try_send(PcmFrame::zeroed()).unwrap();
|
||
let now = Instant::now();
|
||
drive(&mut s, now);
|
||
// (Assert the inner mock AudioPipe received the frame — via a counter
|
||
// OR via observable side effect.)
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn tick_pulls_from_pipe_source_and_pushes_to_outbound() {
|
||
let mut s = trivial_trunk_session();
|
||
// (Mock the wrapped pipe to produce a known frame on next_pcm_frame.)
|
||
let now = Instant::now();
|
||
drive(&mut s, now);
|
||
let _ = s.outbound_to_twilio_rx.try_recv().expect("frame flowed out");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn idle_timeout_closes_session_after_60s_silence() {
|
||
let mut s = trivial_trunk_session();
|
||
// Set last_idle_rx 90 seconds ago.
|
||
let now = Instant::now();
|
||
s.last_idle_rx = now - Duration::from_secs(90);
|
||
let next = drive(&mut s, now);
|
||
assert_eq!(s.channel.state, ChannelState::Closed);
|
||
assert_eq!(next, None);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: fmt + clippy + test + commit**
|
||
|
||
```bash
|
||
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
|
||
git commit -s -m "feat(trunk): TrunkSession + trunk_driver::drive — FOB-side trunk-leg tick (slice-5 T4)
|
||
|
||
The trunk leg's tick function. Parallels crates/rutster-media/src/loop_driver.rs
|
||
minus the str0m/Opus/RTP machinery — there is no RTP to decode, no Opus to
|
||
encode, no str0m poll loop to drain. Caller→FOB direction is a pure mpsc
|
||
drain; FOB→caller direction is a mpsc push.
|
||
|
||
Slice-4's Reflex<TapAudioPipe> + LocalVadReflex stack compose identically
|
||
around the trunk leg's session.pipe — proving the FOB reflex loop is
|
||
ingress-agnostic (spec §2.3 — the architecture's load-bearing claim).
|
||
|
||
The seam gate holds: crates/rutster-media/src/{loop_driver.rs,rtc_session.rs}
|
||
stay byte-identical because the trunk leg NEVER enters that code path. The
|
||
MediaThread dispatches via the new MediaLeg enum (T5)."
|
||
```
|
||
|
||
---
|
||
|
||
### Task T5: `MediaCmd::RegisterTrunk` + `MediaLeg` enum + the new HTTP routes — the binary-side wiring
|
||
|
||
**Files:**
|
||
- Modify: `crates/rutster/src/media_thread.rs`
|
||
- Modify: `crates/rutster/src/main.rs`
|
||
- Modify: `crates/rutster/src/routes.rs`
|
||
- Modify: `crates/rutster/src/lib.rs` (if needed for re-exports)
|
||
- Modify: `crates/rutster-trunk/src/lib.rs` (if the `RegisterTrunkInboundChannel` type is publicly surfaced)
|
||
- Test: inline in `crates/rutster/src/routes.rs` for the new routes (spot-test the handlers)
|
||
|
||
**Interfaces:**
|
||
- Consumes: T3 (TwilioMediaStreamsServer) + T4 (TrunkSession + trunk_driver::drive) + T2 (CallControlClient trait).
|
||
- Produces:
|
||
- `MediaCmd::RegisterTrunk { call_sid, inbound_from_twilio_rx, outbound_to_twilio_tx, tap_url, reply }` — the new variant in the existing enum.
|
||
- `pub enum MediaLeg { WebRTC(RtcSession), Trunk(TrunkSession) }` — the session-map value type, in `media_thread.rs`.
|
||
- `POST /v1/trunk/sessions` axum route — originates an outbound call.
|
||
- `POST /v1/trunk/webhook` axum route — Twilio's inbound-call webhook receiver.
|
||
|
||
- [ ] **Step 1: Add the `MediaLeg` enum + `RegisterTrunk` variant + dispatch match**
|
||
|
||
Update `crates/rutster/src/media_thread.rs`:
|
||
|
||
```rust
|
||
pub enum MediaLeg {
|
||
/// Existing WebRTC leg. Ticked by `rutster_media::loop_driver::drive`.
|
||
/// UNCHANGED code path from slice-4 + slice-5/seams.
|
||
WebRTC(RtcSession),
|
||
/// New trunk leg. Ticked by `rutster_trunk::loop_driver::drive`.
|
||
Trunk(rutster_trunk::session::TrunkSession),
|
||
}
|
||
|
||
pub enum MediaCmd {
|
||
// ... existing variants unchanged ...
|
||
|
||
/// slice-5: register a trunk-side session. The MediaThread:
|
||
/// 1. Allocates a ChannelId.
|
||
/// 2. Constructs TapAudioPipe + TapConn via spawn_tap_engine (from slice-2; REUSED).
|
||
/// 3. Wraps as Reflex::new(tap_pipe, advisory_rx, metrics) then
|
||
/// LocalVadReflex::new(reflex, advisory_tx) — same Task-6-style composition as slice-4.
|
||
/// 4. Constructs TrunkSession { channel, pipe, inbound_from_twilio_rx,
|
||
/// outbound_to_twilio_tx, ... } and inserts into the session map under MediaLeg::Trunk.
|
||
/// 5. Spawns the TapEngine tokio task.
|
||
/// 6. Replies with the ChannelId.
|
||
RegisterTrunk {
|
||
call_sid: String,
|
||
inbound_from_twilio_rx: tokio::sync::mpsc::Receiver<PcmFrame>,
|
||
outbound_to_twilio_tx: tokio::sync::mpsc::Sender<PcmFrame>,
|
||
tap_url: url::Url,
|
||
reply: tokio::sync::oneshot::Sender<ChannelId>,
|
||
},
|
||
}
|
||
|
||
// In the std thread's tick loop, dispatch the per-leg tick:
|
||
fn run_per_leg_tick(leg: &mut MediaLeg, now: Instant) -> Option<Duration> {
|
||
match leg {
|
||
MediaLeg::WebRTC(s) => rutster_media::loop_driver::drive(s, now), // UNCHANGED from slice-4
|
||
MediaLeg::Trunk(s) => rutster_trunk::loop_driver::drive(s, now), // NEW this slice
|
||
}
|
||
}
|
||
```
|
||
|
||
The existing call site for `loop_driver::drive(s, now)` (within the WebRTC tick loop) routes
|
||
through this `run_per_leg_tick` — a one-line refactor of the existing invocation. The seam
|
||
file `loop_driver.rs` itself is byte-identical (the call site is in `media_thread.rs`, the
|
||
binary-side bridge).
|
||
|
||
- [ ] **Step 2: Add the `RegisterTrunk` handler in the std thread loop**
|
||
|
||
When `RegisterTrunk` arrives:
|
||
1. Construct `tap_url` (operator-configured brain WS URL e.g. `ws://localhost:8082` for MockRealtimeBrain).
|
||
2. Construct `(advisory_tx, advisory_rx) = mpsc::channel(16)` + `metrics = ReflexMetrics::new()`.
|
||
3. `let (tap_pipe, tap_conn) = spawn_tap_engine(channel_id, tap_url, advisory_tx)` — REUSED from slice-4 Task 5.
|
||
4. `let reflex = Reflex::new(tap_pipe, advisory_rx, metrics.clone());`
|
||
5. `let wrapped = LocalVadReflex::new(reflex, advisory_tx_clone);` (where `advisory_tx_clone` is the second sender; advisory_tx has TWO senders — the engine + the local VAD).
|
||
6. Construct `TrunkSession { channel: Channel::new(channel_id), pipe: wrapped, inbound_from_twilio_rx, outbound_to_twilio_tx, last_idle_rx: now, next_timeout: None, last_outbound_at: now }`.
|
||
7. Insert into `session_map.insert(channel_id, MediaLeg::Trunk(session))`.
|
||
8. Spawn the TapEngine tokio task (held by `tap_conn`).
|
||
9. Reply with `channel_id` via the oneshot.
|
||
|
||
(session_map type changes from `HashMap<ChannelId, RtcSession>` to `HashMap<ChannelId, MediaLeg>` —
|
||
the WebRTC leg-handling code path handles the enum's `WebRTC` variant via `run_per_leg_tick`.)
|
||
|
||
- [ ] **Step 3: Mount `TwilioMediaStreamsServer::router(register_tx)` on the axum router in `main.rs`**
|
||
|
||
In `crates/rutster/src/main.rs`:
|
||
- Allocate a `tokio::sync::mpsc::Sender<RegisterTrunkInboundChannel>` pair (cap 64).
|
||
- Pass the `Sender` end into `TwilioMediaStreamsServer::router(...)`.
|
||
- The `Receiver` end runs in a tokio task: when a `RegisterTrunkInboundChannel` arrives, it
|
||
relays the contents into a `MediaCmd::RegisterTrunk` envelope + awaits the reply, then ACKs
|
||
the WSS pump task.
|
||
- Mount the router's `/twilio/media-stream` route alongside the existing routes (`/v1/sessions`,
|
||
`/healthz`, etc.).
|
||
|
||
- [ ] **Step 4: Author the two new HTTP routes in `routes.rs`**
|
||
|
||
- `POST /v1/trunk/sessions { to, from }` — call `CallControlClient::originate(to, from, None)`
|
||
(None because spend gate is step 6); respond with `{ channel_id, call_sid }`.
|
||
- `POST /v1/trunk/webhook` — Twilio's webhook receiver for inbound-call signaling. Respond
|
||
with TwiML body instructing Twilio to Media Streams against our `/twilio/media-stream`.
|
||
|
||
- [ ] **Step 5: Tests** — `route_returns_channel_id_for_originate`,
|
||
`webhook_responds_with_twiml_pointing_at_media_stream`.
|
||
|
||
- [ ] **Step 6: fmt + clippy + test + commit**
|
||
|
||
```bash
|
||
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
|
||
git commit -s -m "feat(binary): MediaCmd::RegisterTrunk + MediaLeg enum + /v1/trunk routes (slice-5 T5)
|
||
|
||
The binary-side wiring of the trunk leg. The seam gate holds: the
|
||
MediaThread's tick loop dispatches via the new MediaLeg enum;
|
||
MediaLeg::WebRTC(s) => loop_driver::drive(s, now) is UNCHANGED from slice-4.
|
||
MediaLeg::Trunk(s) => trunk_driver::drive(s, now) is the new sibling.
|
||
|
||
Two new axum routes: POST /v1/trunk/sessions initiates an outbound call via
|
||
the CallControlClient trait (green-zone); POST /v1/trunk/webhook receives
|
||
Twilio's inbound-call signaling + responds with TwiML pointing at our
|
||
/twilio/media-stream WS endpoint.
|
||
|
||
The RegisterTrunk handler on the std thread constructs the TrunkSession +
|
||
composes the slice-4 Reflex<TapAudioPipe> + LocalVadReflex stack identically
|
||
to slice-4 Task 6's WebRTC spawn seam."
|
||
```
|
||
|
||
---
|
||
|
||
### Task T6: `TwilioCallControlClient` (the live REST impl) + `config.rs` env parser
|
||
|
||
**Files:**
|
||
- Create: `crates/rutster-trunk/src/provider/twilio.rs`
|
||
- Modify: `crates/rutster-trunk/src/provider/mod.rs` (re-export `TwilioCallControlClient` ONLY when `twilio-live` feature is enabled)
|
||
- Modify: `crates/rutster/src/config.rs` (add `pub fn twilio_credentials() -> Option<TwilioCredentials>`)
|
||
- Test: live e2e test gated behind `twilio-live` feature — runs once on maintainer's machine via a manual `cargo test --features=twilio-live`
|
||
|
||
**Interfaces:**
|
||
- Consumes: trait from T2 + `reqwest` + the env-var parser from slice-5/seams.
|
||
- Produces:
|
||
- `pub struct TwilioCallControlClient { credentials: TwilioCredentials, http: reqwest::Client }`
|
||
- `impl TwilioCallControlClient { pub fn new(credentials: TwilioCredentials) -> Self }`
|
||
- `#[async_trait] impl CallControlClient for TwilioCallControlClient`
|
||
- `pub fn twilio_credentials() -> Option<TwilioCredentials>` in `crates/rutster/src/config.rs`
|
||
|
||
- [ ] **Step 1: Implement `TwilioCallControlClient`**
|
||
|
||
`originate`: POST to `https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Calls.json` with
|
||
basic auth (account_sid:auth_token) + form body containing `To`, `From`, `Twiml` (a `<Connect>`
|
||
TwiML instructing Twilio to Media Streams against our webhook_base + `/twilio/media-stream`).
|
||
Parse the JSON response for the `call_sid`. Return `Ok(call_sid)`.
|
||
|
||
`hangup`: POST to `.../Calls/{call_sid}.json` with `Status=completed`. Idempotent.
|
||
|
||
`auth_token` MUST NEVER be logged. Use `tracing::debug!` only on caller-controlled fields
|
||
(`to`, `from`, `call_sid`'s last 4 chars).
|
||
|
||
- [ ] **Step 2: Implement the env parser in `config.rs`**
|
||
|
||
```rust
|
||
/// Parse RUTSTER_TWILIO_ACCOUNT_SID + RUTSTER_TWILIO_AUTH_TOKEN +
|
||
/// RUTSTER_TWILIO_MEDIA_BIND + RUTSTER_TWILIO_WEBHOOK_BASE from env.
|
||
/// Returns None if any required var is missing (the binary runs without
|
||
/// trunk support if credentials aren't provided — WebRTC ingress still works).
|
||
pub fn twilio_credentials() -> Option<TwilioCredentials> {
|
||
let account_sid = std::env::var("RUTSTER_TWILIO_ACCOUNT_SID").ok()?;
|
||
let auth_token = std::env::var("RUTSTER_TWILIO_AUTH_TOKEN").ok()?;
|
||
let media_streams_bind = std::env::var("RUTSTER_TWILIO_MEDIA_BIND")
|
||
.ok()?
|
||
.parse::<std::net::SocketAddr>()
|
||
.map_err(|e| format!("invalid RUTSTER_TWILIO_MEDIA_BIND: {e}"))
|
||
.ok()?;
|
||
let webhook_base = std::env::var("RUTSTER_TWILIO_WEBHOOK_BASE")
|
||
.ok()?
|
||
.parse::<url::Url>()
|
||
.map_err(|e| format!("invalid RUTSTER_TWILIO_WEBHOOK_BASE: {e}"))
|
||
.ok()?;
|
||
Some(TwilioCredentials { account_sid, auth_token, media_streams_bind, webhook_base })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Live e2e test gated behind `twilio-live`**
|
||
|
||
```rust
|
||
#[cfg(all(test, feature = "twilio-live"))]
|
||
mod live_tests {
|
||
use super::*;
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires live Twilio credentials + a real outbound number; manual run only"]
|
||
async fn originate_real_call_returns_call_sid() {
|
||
let creds = crate::config::twilio_credentials().expect("set RUTSTER_TWILIO_* env vars");
|
||
let client = TwilioCallControlClient::new(creds);
|
||
let to = std::env::var("RUTSTER_TWILIO_TEST_TO").expect("set RUTSTER_TWILIO_TEST_TO");
|
||
let from = std::env::var("RUTSTER_TWILIO_TEST_FROM").expect("set RUTSTER_TWILIO_TEST_FROM");
|
||
let call_sid = client.originate(&to, &from, None).await.expect("originate");
|
||
assert!(call_sid.starts_with("CA"));
|
||
// Hang up immediately (don't actually call the test number for real).
|
||
client.hangup(&call_sid).await.expect("hangup");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: fmt + clippy + test (default features OFF) + commit**
|
||
|
||
```bash
|
||
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings
|
||
cargo clippy --all --all-targets --features=twilio-live -- -D warnings
|
||
cargo test --all # CI gate: Mock only
|
||
git commit -s -m "feat(trunk): TwilioCallControlClient (live REST impl behind twilio-live feature) + env parser (slice-5 T6)
|
||
|
||
The live REST call-control client. Originates outbound calls via Twilio's
|
||
Calls.json API; hangs up via Status=completed. Auth_token is NEVER logged
|
||
(ADR-0009 — provider credentials never reach the brain; this boundary is
|
||
honored at the trunk-crate level). The impl is feature-gated behind
|
||
twilio-live so the routine CI gate stays clean; the maintainer runs
|
||
cargo test --features=twilio-live when validating a release.
|
||
|
||
Env parser in config.rs follows slice-5/seams' pure-function pattern
|
||
(take Option<String>/&str inputs; testable without env mutation)."
|
||
```
|
||
|
||
---
|
||
|
||
### Task T7: Reflex-on-trunk-leg verification test
|
||
|
||
**Files:**
|
||
- Create: `crates/rutster-trunk/tests/reflex_on_trunk.rs`
|
||
- Test: integration test asserting slice-4's `Reflex<TapAudioPipe>` + `LocalVadReflex` (REUSED)
|
||
decorate the trunk leg's TapAudioPipe identically; barge-in fires on trunk-leg caller speech.
|
||
|
||
**Interfaces:**
|
||
- Consumes: T3 (TwilioMediaStreamsServer) — actually a `MockTwilioMediaStreamsServer` test double
|
||
firing synthetic media frames; T4 (TrunkSession + trunk_driver); slice-3's MockRealtimeBrain.
|
||
|
||
- [ ] **Step 1: Write the integration test**
|
||
|
||
The test:
|
||
1. Stand up MockRealtimeBrain (slice-3 merged). URL = `mock.url()`.
|
||
2. Stand up the in-process MediaThread via `MediaThread::spawn(default_tap_url, tokio_handle)`.
|
||
3. Construct the wiring manually: spawn the WSS-pump-task-equivalent that simulates a Twilio
|
||
"media" frame as a synthetic `PcmFrame::loud()` → push into `inbound_from_twilio_tx`.
|
||
4. Send `MediaCmd::RegisterTrunk { call_sid, inbound_from_twilio_rx, outbound_to_twilio_tx,
|
||
tap_url: mock.url(), reply }`.
|
||
5. Drive ticks manually OR let the std thread run for ~200 ms.
|
||
6. Assert: the brain received `audio_in` events (the loud frames forwarded through TapAudioPipe).
|
||
7. Assert: slice-4's `LocalVadReflex` tripped → fired `AdvisoryEvent::SpeechStarted` →
|
||
`Reflex::muted` became true → `next_pcm_frame` returned None → brain reply NOT sent this tick.
|
||
8. Assert: after the brain sends its reply (mock's response schedule), `next_pcm_frame`
|
||
returns Some → un-mute → brain reply pushed to `outbound_to_twilio_tx`.
|
||
|
||
- [ ] **Step 2: fmt + clippy + test + commit**
|
||
|
||
```bash
|
||
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
|
||
git commit -s -m "test(trunk): reflex-on-trunk-leg verification — slice-4 Reflex<TapAudioPipe> decorates trunk TapAudioPipe identically (slice-5 T7)
|
||
|
||
The architectural load-bearing claim: slice-4's reflex loop is ingress-
|
||
agnostic. This test verifies that the slice-4 stack (Reflex<P> + LocalVadReflex
|
||
composition) instantiated against a trunk leg's TapAudioPipe behaves
|
||
IDENTICALLY to a WebRTC leg — local VAD trips on caller speech, barge kills
|
||
playout, brain reply post-barge un-mutes.
|
||
|
||
Same code path; different inner leg-kind. If barge-in somehow needed a
|
||
'trunk-specific' code path here, that would be an architectural smell that
|
||
needs PM input — not a silent fork."
|
||
```
|
||
|
||
---
|
||
|
||
### Task T8: PSTN sim e2e integration test
|
||
|
||
**Files:**
|
||
- Create: `crates/rutster-trunk/tests/sim_integ.rs`
|
||
- Test: end-to-end integration test: `MockTwilioMediaStreamsServer` (the in-process simulator)
|
||
drives a synthetic PSTN caller through the FOB reflex loop.
|
||
|
||
- [ ] **Step 1: Author the in-process Twilio simulator**
|
||
|
||
A `MockTwilioMediaStreamsServer` test double that mimics Twilio's WSS protocol in-process:
|
||
- Spawn a tokio task that, given a `Vec<PcmFrame>` "caller-side script," pushes them as synthetic
|
||
inbound `PcmFrame`s into a TrunkSession's `inbound_from_twilio_rx` (skipping the WSS + base64
|
||
+ JSON parsing — pure mpsc).
|
||
- Receive frames from `outbound_to_twilio_tx` (the FOB's brain reply) into a captured queue for
|
||
assertions.
|
||
|
||
- [ ] **Step 2: Author the e2e test**
|
||
|
||
1. Start MockRealtimeBrain (slice-3). URL = `mock.url()`.
|
||
2. Start in-process MediaThread.
|
||
3. Construct `MockTwilioMediaStreamsServer` against the MediaThread's `register_tx`.
|
||
4. Drive a synthetic PSTN caller: loud PCM frames → barge-in should fire → brain yields → brain
|
||
reply observed → caller hangup → session torn down.
|
||
5. Assert `EventSink` (slice-5/seams) emitted `ChannelEnded` with the wall-clock `started_at`.
|
||
|
||
- [ ] **Step 3: fmt + clippy + test + commit**
|
||
|
||
```bash
|
||
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
|
||
git commit -s -m "test(trunk): PSTN sim e2e integration — synthetic caller through FOB reflex loop (slice-5 T8)
|
||
|
||
MockCallControlClient + MockTwilioMediaStreamsServer drive a synthetic PSTN
|
||
caller: loud PCM frames → local VAD trips → barge-in kills playout → brain
|
||
yields → brain reply observed → un-mute → caller hangup → session torn down +
|
||
EventSink emission of ChannelEnded with wall-clock started_at (slice-5/seams).
|
||
|
||
The full slice-1→slice-5 spearhead's worth of wiring exercised end-to-end,
|
||
against a synthetic PSTN caller (the same path a real Twilio call would
|
||
take modulo the actual Twilio cloud)."
|
||
```
|
||
|
||
---
|
||
|
||
### Task T9: QUICKSTART + README updates (filler; any time after T5 + T6 land)
|
||
|
||
**Files:**
|
||
- Modify: `docs/QUICKSTART.md`
|
||
- Modify: `README.md`
|
||
|
||
- [ ] **Step 1: Add env-var table to QUICKSTART**
|
||
|
||
```markdown
|
||
### Twilio credentials (for the real phone number demo, optional)
|
||
|
||
Set these to enable PSTN ingress via Twilio Media Streams:
|
||
|
||
| Env var | Purpose | Example |
|
||
|---|---|---|
|
||
| `RUTSTER_TWILIO_ACCOUNT_SID` | Twilio account ID | `ACxxx...` |
|
||
| `RUTSTER_TWILIO_AUTH_TOKEN` | Twilio auth token (secret) | `xxx...` |
|
||
| `RUTSTER_TWILIO_MEDIA_BIND` | Where Twilio Media Streams WSS binds | `0.0.0.0:8081` |
|
||
| `RUTSTER_TWILIO_WEBHOOK_BASE` | Twilio's webhook target (your binary) | `https://your.public.host` |
|
||
|
||
Without these set, the binary runs WebRTC-only (slice-1-3 ingress + slice-4
|
||
barge-in + slice-4½ sim). With them set + `--features=twilio-live`, the
|
||
binary accepts PSTN fork calls against `/twilio/media-stream`.
|
||
```
|
||
|
||
- [ ] **Step 2: Add "make a real phone call" section to QUICKSTART**
|
||
|
||
- [ ] **Step 3: Update README spearhead status line**
|
||
|
||
```markdown
|
||
> **Status:** Slices 1–4½ + step 5 (WebRTC media core, WS tap, OpenAI Realtime
|
||
> brain, barge-in, sim/benchmark harness, and PSTN via rented Twilio Media
|
||
> Streams transport) are merged to `main`. Spearhead step 6 (spend cap) is the
|
||
> remaining step. ADR-0007 honored: rutster parses zero SIP bytes.
|
||
```
|
||
|
||
- [ ] **Step 4: fmt + test + commit**
|
||
|
||
```bash
|
||
cargo fmt --all --check && cargo test --all
|
||
git commit -s -m "docs: QUICKSTART env table + 'make a real phone call' walkthrough + README status update (slice-5 T9)
|
||
|
||
README's spearhead list now reflects: slices 1, 2, 3, 4, 4½, 5 merged. The
|
||
quickstart shows how to set RUTSTER_TWILIO_* env vars + run
|
||
cargo run --features=twilio-live to take a real phone call via Twilio Media
|
||
Streams. ADR-0007 honored: rutster parses zero SIP bytes."
|
||
```
|
||
|
||
---
|
||
|
||
### Task T10: CI seam gate re-pin + cargo deny
|
||
|
||
**Files:**
|
||
- Modify: `.github/workflows/ci.yml` (re-pin the seam hashes IF iptables/rtc_session was touched — but this slice's invariant is "untouched," so the pins stay the same; verify)
|
||
- Modify: `deny.toml` (if `cargo deny check` flags any new license from `reqwest`/`base64`/`async-trait` transitive deps)
|
||
- Final sweep per AGENTS.md CI gates.
|
||
|
||
- [ ] **Step 1: Verify seam gate stays unchanged**
|
||
|
||
```bash
|
||
git rev-parse main:crates/rutster-media/src/loop_driver.rs
|
||
git rev-parse main:crates/rutster-media/src/rtc_session.rs
|
||
```
|
||
|
||
Confirm the two pinned blob hashes in `.github/workflows/ci.yml` (slice-4 Task 10) are unchanged.
|
||
If the hashes DO match, this slice's seam gate holds with NO CI YAML modification. If they
|
||
DO NOT match (the dev-c implementer accidentally touched the seam files), STOP + emit a
|
||
`## QUESTION` block to the PM; the dev must `git checkout main -- crates/rutster-media/src/loop_driver.rs
|
||
crates/rutster-media/src/rtc_session.rs` to restore them.
|
||
|
||
- [ ] **Step 2: Run cargo deny check + fix any flags from new deps**
|
||
|
||
```bash
|
||
cargo deny check
|
||
# If a license/ban/source advisory flags reqwest/base64/async-trait transitive dep,
|
||
# either add it to the allowlist in deny.toml with rationale OR
|
||
# substitute a different crate from the workspace's already-admitted set.
|
||
```
|
||
|
||
- [ ] **Step 3: Add the `twilio-live` CI job (manual-trigger only)**
|
||
|
||
```yaml
|
||
twilio-live:
|
||
name: twilio-live (manual only)
|
||
runs-on: ubuntu-latest
|
||
if: github.event_name == 'workflow_dispatch'
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
- uses: dtolnay/rust-toolchain@stable
|
||
- uses: Swatinem/rust-cache@v2
|
||
- name: Install libopus
|
||
run: sudo apt-get update && sudo apt-get install -y libopus-dev
|
||
- name: Run twilio-live tests
|
||
env:
|
||
RUTSTER_TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }}
|
||
RUTSTER_TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }}
|
||
RUTSTER_TWILIO_MEDIA_BIND: 0.0.0.0:8081
|
||
RUTSTER_TWILIO_WEBHOOK_BASE: ${{ secrets.TWILIO_WEBHOOK_BASE }}
|
||
run: cargo test --all --features=twilio-live -- --include-ignored
|
||
```
|
||
|
||
The maintainer triggers this before a release.
|
||
|
||
- [ ] **Step 4: Final full sweep**
|
||
|
||
```bash
|
||
cargo fmt --all --check
|
||
cargo clippy --all --all-targets -- -D warnings
|
||
cargo clippy --all --all-targets --features=twilio-live -- -D warnings
|
||
cargo test --all
|
||
cargo deny check
|
||
cargo doc --no-deps
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add .github/workflows/ci.yml deny.toml
|
||
git commit -s -m "ci(trunk): twilio-live manual-trigger job + cargo deny recheck (slice-5 T10)
|
||
|
||
The routine CI gate stays feature-default-off: MockCallControlClient is
|
||
the per-PR integration test surface. A new twilio-live job runs ONLY on
|
||
manual workflow_dispatch (maintainer triggers pre-release) — exercises the
|
||
real TwilioCallControlClient against live Twilio. cargo deny check passes
|
||
for the new reqwest/base64/async-trait transitive deps.
|
||
|
||
The seam gate (loop_driver.rs + rtc_session.rs pinned blob hashes from
|
||
slice-4 Task 10) is verified UNCHANGED by this slice — the trunk leg's
|
||
tick lives entirely in rutster-trunk/src/loop_driver.rs, a separate file
|
||
in a separate crate."
|
||
```
|
||
|
||
---
|
||
|
||
## Final acceptance checklist
|
||
|
||
After all 10 tasks merge:
|
||
- [ ] `cargo fmt --check`, `cargo clippy --all --all-targets -- -D warnings` (both default +
|
||
`--features=twilio-live`), `cargo test --all`, `cargo deny check`, `cargo doc --no-deps`
|
||
all clean (stable + 1.85).
|
||
- [ ] Seam gate unchanged: `loop_driver.rs` + `rtc_session.rs` byte-identical to slice-3/slice-4
|
||
(CI pinned-blob from slice-4 Task 10).
|
||
- [ ] Slice-4's `Reflex<TapAudioPipe>` + `LocalVadReflex` (REUSED — no new instances created)
|
||
decorate a trunk leg's TapAudioPipe identically (T7 verification test green).
|
||
- [ ] PSTN-sim e2e (T8) drives a synthetic caller through the FOB reflex loop: barge-in fires,
|
||
brain reply observed, CDR/EventSink emits ChannelEnded.
|
||
- [ ] QUICKSTART + README updated; env-var table renders in cargo doc.
|
||
- [ ] The maintainer (user) verifies the live e2e by manually running
|
||
`cargo test --features=twilio-live` against real Twilio credentials once.
|
||
- [ ] PR bisected + readable: each task = one commit (squash-merge preserves the strategy).
|
||
- [ ] PR opened via `tea pulls create --head slice-5/rented-transport-dev-{b,c} --base main
|
||
--title "slice-5 (rented transport): a real phone number via Twilio Media Streams
|
||
(T1-T10)" --description "<full §7 done-criteria from spec>"`.
|
||
- [ ] Do NOT merge the PR — the maintainer (user) merges after the live Twilio e2e is verified.
|
||
- [ ] ADR-0010 deviation note considered for the PR description (per the strategic plan §1.2
|
||
fork) — "this slice precedes rung-2 escalation per the user's 2026-07-05 directive; ADR-0010
|
||
remains intact; the deviation is recorded in `.omo/plans/2026-07-05-spearhead-4half-and-
|
||
step-5-strategic.md` §1.2."
|