smoke-compose (gated on image-build):
* Rebuilds rutster-edge/engine/brain:smoke from warm GHA cache.
* Runs deploy/smoke/compose_smoke.sh — `docker compose up -d --wait`,
asserts all four services State=running, curls /healthz + /readyz
through Caddy TLS (internal CA). Lighter than the all-in-one smoke
(which already proved the WS path) — its job is the orchestrator
shape + the network_mode: "service:engine" brain->engine tap posture.
smoke-reload (gated on smoke):
* Reuses rutster-allinone:smoke + the allinone_smoke.py WS helpers.
* Runs deploy/smoke/reload_during_call.py — holds a live WS streaming
frames at 20ms while a concurrent `caddy reload` fires mid-call;
asserts zero frames dropped + WS survives across the reload.
Regresses the stream_close_delay 24h mitigation against caddy #6420/
#7222 (TLS brief §5 risk 1; spec §9 reload-during-call smoke).
Both jobs run on ubuntu-latest — separate runners for clearer failure
attribution + parallelism (the smoke gate is the failing CI signal,
not a single serial run).
Signed-off-by: Aaron D. Lee <himself@adlee.work>
Adds the smoke job to .github/workflows/ci.yml, gated on image-build.
Runs deploy/smoke/allinone_smoke.py against the freshly-built
rutster-allinone:smoke image:
* Boots the all-in-one container with RUTSTER_LOCAL_CERTS=true (Caddy
internal CA, no ACME in CI per spec §9).
* Extracts Caddy's internal-CA root cert from /data/caddy/pki/
authorities/local/root.crt via docker cp.
* Opens wss:// WS to /twilio/media-stream using the extracted root
(Python stdlib ssl + socket — no pip install required, CI runner-
image ships Python 3 + ssl).
* Sends Twilio's connected+start handshake frames (the same JSON
sequence crates/rutster-trunk/tests/ws_ping.rs uses).
* Streams 200 PCM-zeroed frames at 20ms cadence as Twilio Media events.
* Asserts the engine replies with at least one text frame — proving
the real edge->FOB WS path works end-to-end through TLS.
The compose smoke + reload-during-call smoke (Task 9) gate on this job.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
Extends .github/workflows/ci.yml with an image-build job, gated on the
existing fmt/clippy/test/deny/sim-bench matrix
(needs: [fmt, clippy, test, deny, sim-bench]). Builds all four images via
docker buildx against deploy/Dockerfile's named --targets, with type=gha
caching scoped per image so warm caches take image-build from ~6min to
~90s.
Tags the freshly-built :ci-<sha> images as :smoke too, for the
downstream smoke job to consume. Runs `caddy validate` inside the
freshly-built rutster-edge as a cheap Caddyfile-syntax regression.
No changes to the existing fmt/clippy/test/deny/sim-bench/twilio-live
jobs — those stay the cargo-side source of truth.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
Three Python-stdlib-only smoke scripts (no pip install required in CI;
the runner image ships Python 3 + ssl/socket/json/base64/time):
* allinone_smoke.py — boots rutster-allinone with RUTSTER_LOCAL_CERTS=true
(Caddy internal CA, no ACME per spec §9), extracts the root cert from
/data, opens wss:// WS to /twilio/media-stream, sends Twilio's
connected+start handshake (the same JSON sequence the in-tree
ws_ping.rs/nodelay.rs tests use), streams 200 PCM frames at 20ms cadence,
asserts the engine echoes/replies at least one text frame. Reuses the
T8 stub's handshake pattern (crates/rutster/tests/trunk_sim_e2e.rs TODO
body) against a real containerized binary rather than an in-process
MockTwilioMediaStreamsServer.
* compose_smoke.sh — T2 four-service boots + health checks via Caddy TLS.
The # TODO slice-C: assert lifecycle event in Valkey stream named hook
documents the contract slice C will assert; the valkey service is dark
on day one (no consumers) so the stream length is always 0 today.
* reload_during_call.py — holds a live WS streaming frames at 20ms while a
concurrent `caddy reload` fires mid-call; asserts zero frames dropped
+ WS survives across the reload. Regresses the stream_close_delay 24h
mitigation against caddy #6420/#7222 (TLS brief §5 risk 1; spec §9
reload-during-call smoke).
WS frame encode/decode is hand-rolled (RFC 6455 §5 — ten lines on top of
socket+ssl) so the smoke needs no websocket-client/websockets pip
dependency + no network round-trip in CI.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
Four services per spec §2.2:
* caddy — image rutster-edge; ports 80/443; volume caddy-data:/data
(Caddy ACME state — loss risks LE 5/week duplicate-cert lockout
per TLS brief §5 risk 5; mounts ./Caddyfile for edit-without-
rebuild).
* engine — image rutster-engine; ports 8080 + 49152-49407/udp (media
UDP direct, never proxied — spec §2.1); stop_grace_period 660s
paired with RUTSTER_DRAIN_DEADLINE_SECS=600 (grace exceeds drain
invariant, spec §2.2/§8).
* brain — image rutster-brain; network_mode: "service:engine" so the
loopback-only tap posture survives unchanged (resolve_tap_url
rejects non-loopback until step 6 per spec §2.2). Quoted in
YAML — `service:engine` would parse as a mapping otherwise.
* valkey — upstream valkey/valkey:8.0 (BSD-3); volume valkey-data:/data;
dark on day one except slice-E ValkeyEventSink consumer.
Bring-your-own-proxy: comment out the caddy service; the engine keeps
plaintext :8080; tuned-timeout snippets land in slice-G reverse-proxies.md.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
The operator documentation surface. Lists every RUTSTER_* var with its
default + the slice that introduced it + the fail-fast behavior. Includes:
* Artifact-level (this slice): RUTSTER_DOMAIN, RUTSTER_ACME_EMAIL,
RUTSTER_LOCAL_CERTS (CI override).
* FOB engine (slice-5 + plan-A): RUTSTER_HTTP_BIND,
RUTSTER_DRAIN_DEADLINE_SECS=600 (paired with compose stop_grace_period
660s per spec §2.2 — grace exceeds drain), RUTSTER_MAX_SESSIONS,
RUTSTER_MEDIA_BIND_IP/ADVERTISED_IP/PORT_RANGE, RUTSTER_WS_PING_SECS,
RUTSTER_TRUSTED_PROXIES.
* Trunk (slice-5): the four RUTSTER_TWILIO_* (all-or-none — config.rs
twilio_credentials rejects partial).
* Brain (slice-2/3): RUTSTER_TAP_URL (loopback-only until step 6).
* Future var stubs: RUTSTER_TLS_CERT/KEY (slice C), RUTSTER_METRICS_BIND
(slice D), RUTSTER_VALKEY_URL (slice E) — commented out with a pointer
to the sibling plan.
Bundled-binary licenses noted in the header comment (Caddy Apache-2.0,
valkey BSD-3, s6-overlay ISC, libopus0 BSD-3 — aggregation-clean vs
GPL-3 per ADR-0004); seeds the future cargo-deny-adjacent image license
scan (spec §6.1, out of scope for this slice).
Registry namespace git.adlee.work/alee/rutster-* + the 'if your Gitea
registry uses a different owner segment, replace alee throughout' note.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
The shared edge config for rutster-edge (T2 modular) and rutster-allinone
(T1 solo). ~6-line site block + global options per spec §3.2 / TLS brief
§3(a):
* read_body 30s / read_header 30s — Twilio webhook budget (15s cap by
invariant 6 + slack).
* write 0 — 20ms media frames self-flush; never write-timeout.
* idle 24h / stream_close_delay 24h — above max call duration (invariant
3); the universal 60s-class default kills quiet mid-call WS.
* reverse_proxy + header_up — Caddy sets honest X-Forwarded-Proto/Host/For
for the engine's reconstruct_public_url (plan-A Task 5); engine-side
RUTSTER_TRUSTED_PROXIES is the trust gate (fail-closed default).
CI override via RUTSTER_LOCAL_CERTS=true uses Caddy's internal CA (no
ACME in CI per spec §9). The reload-during-live-call smoke (Task 9)
regresses the stream_close_delay mitigation against caddy #6420/#7222.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
One Dockerfile, four named --target stages: rutster-engine (FOB),
rutster-brain (brain), rutster-edge (custom xcaddy + curated DNS plugins
cloudflare/route53/porkbun/hetzner/desec, duckdns excluded per spec §3.2),
rutster-allinone (s6-overlay v3 supervising caddy + FOB + brain +
valkey-server).
Builder pins rust:1.85-slim-bookworm (matches rust-toolchain.toml).
libopus-dev in builder, libopus0 in runtime (matches dev — spec §6.1
distro-over-static-bundled). Brain image gets libopus0 too: although the
brain never calls an opus symbol, the linker records NEEDED:libopus.so.0
because rutster-brain-realtime depends on rutster-media (spec ambiguity
resolved in this slice's plan README).
All-in-one vendors s6-overlay v3.2.0.0 (ISC) + uses upstream valkey/valkey
binary (COPY --from pattern, BSD-3). Bundled-binary licenses
aggregation-clean vs GPL-3 (Caddy Apache-2.0). No Rust crates touched;
cargo-deny unaffected.
s6-rc service definitions land in this commit (caddy/engine/brain/
valkey-database run scripts + dependency edges). The Caddyfile lands in
Task 3 (same PR); the image-build CI job (Task 7) runs after both.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
T7: proves slice-4's Reflex<TapAudioPipe> + LocalVadReflex decorates the
trunk leg's TapAudioPipe identically; barge-in fires on PSTN caller speech
through the same state machine as WebRTC caller speech.
T8: MockRealtimeBrain + BrainShim drives a synthetic PSTN caller through the
FOB reflex loop end-to-end: loud PCM -> local VAD trips -> barge kills ->
brain reply -> un-mute -> idle timeout (caller hangup) closes the session.
Dev-dependencies added to rutster-trunk/Cargo.toml so the integration tests
reach MockRealtimeBrain, futures-util, and tokio-tungstenite without pulling
FOB source into the trunk crate.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
Step A -- minimal-viable T5 surface (no ThreadSession refactor):
* MediaLeg enum { WebRTC(RtcSession), Trunk(TrunkSession<TapAudioPipe>) }
+ as_webrtc_mut + channel_id + channel_state + is_closed + set_tap_handle
helpers (dead_code-suppressed per AGENTS.md with documented rationale;
Step B will wire them).
* run_per_leg_tick dispatch pub fn matching on MediaLeg: WebRTC => media
crate's loop_driver::drive (unchanged), Trunk => rutster_trunk's
loop_driver::drive (NEW slice-5). The seam gate holds: trunk leg never
enters the media crate's loop_driver.rs.
* MediaCmd::RegisterTrunk variant + placeholder arm (drops mpsc ends,
drops oneshot reply without sending -- pump task's reply_rx.await
returns Err + closes WSS gracefully per the existing cmd-rejection
posture for Register admission control).
* Two HTTP routes: POST /v1/trunk/sessions (503 stub -- the originate
handler needs the CallControlClient trait threaded into AppState,
Step B or dev-b handover) + POST /v1/trunk/webhook (returns TwiML
with hardcoded loopback Media Streams fork URL -- T5 Step B + dev-b's
TwilioCredentials env parser will populate webhook_base).
* main.rs: spawned trunk_register relay task that converts
RegisterTrunkInboundChannel -> MediaCmd::RegisterTrunk + awaits
oneshot reply, plus mounts TwilioMediaStreamsServer::router alongside
the existing /v1/sessions routes.
Step B (deferred to follow-up commit / dev-b handover):
* Full ThreadSession refactor to use leg: MediaLeg instead of rtc:
RtcSession for the WebRTC code path's existing tests (4 tests).
* Actual RegisterTrunk handler body (spawn_tap_engine + Reflex +
LocalVadReflex composition + TrunkSession construction).
* ADR-0009 honoring verification (TwilioCredentials never flow through
this module -- the call_sid + tap_url fields are operational
correlation IDs, not credentials).
* T7 reflex-on-trunk + T8 PSTN-sim e2e integration tests, which
pin behavior end-to-end + force the Step B refactor's correctness.
ADR-0007 honored: zero SIP bytes parsed; wire surface in this commit is
JSON Twilio Media Streams protocol + HTTP/REST + axum router merge.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
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<P> + 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). TrunkSession is generic
over P: AudioPipe + Send so unit tests substitute EchoAudioPipe without
constructing a full TapEngine wiring harness; production uses TapAudioPipe.
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).
Signed-off-by: Aaron D. Lee <himself@adlee.work>
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 a 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).
ADR-0009 honored: TwilioCredentials never reach this module -- only
per-call CallSid (operational log correlation only) + tap_url (operator
configured brain WS URL) flow through the RegisterTrunkInboundChannel.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
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.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
Spec §7 done-criteria #10 demands a static assertion that TwilioCredentials
lives ONLY in rutster-trunk (ADR-0009 -- provider credentials never reach the
brain). The test compiles only because crate::provider::TwilioCredentials
resolves (the type's canonical home); if someone moved/re-exported it through
the workspace root or a sibling crate, this test's doc + the dep-graph change
would surface in review. The invariant is structural: sibling crates
(rutster-media, rutster-tap) do not depend on rutster-trunk, so the type
cannot reach them. The binary's config::twilio_credentials is the single
expected import path.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
The routine CI gate stays feature-default-off: MockCallControlClient is the
per-PR test surface. A new twilio-live job runs ONLY on manual
workflow_dispatch (maintainer triggers pre-release) -- it exercises clippy
--features=twilio-live + the live TwilioCallControlClient tests against real
Twilio credentials (TWILIO_* secrets). Never runs per-PR.
Seam gate verified UNCHANGED: loop_driver.rs (744bf314...) + rtc_session.rs
(f47d63b9...) blob hashes match the slice-4 Task 10 pins exactly -- the trunk
leg's tick lives entirely in rutster-trunk/src/loop_driver.rs (a separate file
in a separate crate), so the media-crate seam files stay byte-identical.
cargo deny: reqwest (rustls-tls) + base64 + async-trait introduce ZERO new
duplicate dep versions (verified via `cargo tree -d` with vs without
--features=twilio-live: identical duplicate sets -- the existing skip list in
deny.toml remains sufficient). Local cargo-deny 0.18.3 cannot parse the
`-or-later` SPDX form + CVSS 4.0 advisory entries (pre-existing limitation
documented in deny.toml; CI's cargo-deny-action@v2 bundles 0.19.x which handles
both) -- CI is the authoritative deny gate.
Two rustdoc intra-doc-link warnings in my code fixed (mock.rs private-item
link -> plain inline code; lib.rs redundant explicit link target simplified).
Two pre-existing rustdoc warnings remain in rutster-tap/protocol.rs +
rutster/tap_engine.rs (out of scope -- pre-existing from slices 2-3, not
introduced by slice-5).
T10 of slice-5. This is the final task on the dev-b chain (T2 + T6 + T9 + T10
all landed).
Signed-off-by: Aaron D. Lee <himself@adlee.work>
QUICKSTART gains a Twilio Media Streams section: env-var table for the four
RUTSTER_TWILIO_* vars, the run-with-twilio-live command, the point-Twilio-
at-rutster webhook/TwiML walkthrough, + the outbound-call curl example. The
/v1/trunk routes' auth-deferral (slice 6) is flagged. A 'what's different
from WebRTC' note explains the architectural reuse -- the reflex stack is
ingress-agnostic (Reflex<TapAudioPipe> + LocalVadReflex REUSED from slice-4).
README's spearhead status is corrected + extended: slices 1-4 are merged to
main (the prior status stalled at '1-3 merged, slice-4 active' -- stale);
4.5 (sim/benchmark, ADR-0010) + step 5 (PSTN via rented transport, ADR-0007)
are the active build targets. ADR-0007 honored: rutster parses zero SIP bytes.
T9 of slice-5.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
The live Twilio call-control client. Originates outbound calls via Twilio's
Calls.json API (TwiML <Connect><Stream> instructs Twilio to fork audio back
to our /twilio/media-stream WSS endpoint); hangs up via Status=completed.
HTTP basic auth over HTTPS; auth_token is NEVER logged (ADR-0009 -- provider
credentials never reach the brain) -- tracing fields expose only caller-
controlled values (to, from) + the CallSid's last 4 chars.
Feature-gated behind `twilio-live` so the routine CI gate stays feature-
default-off: MockCallControlClient is the per-PR test surface; the maintainer
runs cargo test --features=twilio-live when validating a release. reqwest +
tracing + serde_json are optional deps (dep:foo syntax) -- pulled in only
when the feature is on, keeping the default resolve lean.
The env parser (config::twilio_credentials) follows slice-5/seams' pure-
function pattern: takes Option<String> inputs (testable without env mutation),
returns Ok(None) when all four RUTSTER_TWILIO_* vars are unset (WebRTC-only
mode), Ok(Some) when all four present + parse, Err on partial config (fail-
fast at startup) or malformed values. TwilioCredentials is imported by the
binary but never re-exported through the workspace (ADR-0009).
T6 of slice-5. Depends on T2 (TwilioCredentials + CallControlClient trait).
Signed-off-by: Aaron D. Lee <himself@adlee.work>
The provider call-control seam (green zone, ADR-0008). The trait locks the
boundary so the next provider (Telnyx, etc.) is an implementation, not a
refactor. MockCallControlClient is the CI test double; the live
TwilioCallControlClient (T6) lives behind the twilio-live feature flag.
TwilioCredentials lives ONLY in crates/rutster-trunk/ -- never re-exported
through the workspace (ADR-0009 -- provider credentials never reach the
brain). Its Debug impl is hand-written (NOT derived) so the auth_token
renders as <redacted>, never leaking into tracing/panic output.
Option<SpendToken> on originate is the pre-paved seam for spearhead step-6
(spend cap); this slice passes None everywhere. The signature is locked so
step 6 is additive, not a refactor.
T2 of slice-5. lib.rs gains `pub mod provider;` -- stacked-branches carve-out
(rebase-merge, not squash) per AGENTS.md Git workflow; dev-c rebases forward
for the FOB-side pub mod declarations (g711/twilio_media_streams/session/
loop_driver).
Signed-off-by: Aaron D. Lee <himself@adlee.work>
Single-agent (no PM/relay) kickoff to close out slice-4: the
secondary-path advisory e2e and the CI seam gate. API facts verified
against main @ 60b65ab (mock schedule API, ReflexMetrics fields,
lib.rs exports); seam gate specced as pinned blob hashes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QndwfhjyTiZcUYp87dwW8
Signed-off-by: Aaron D. Lee <himself@adlee.work>
These four reviews were filed on slice-1-review-fixes and
strategic-reviews-post-pivot-rescore but never reached main; both
branches are being retired in the post-slice-4 cleanup. Kept for
lineage: the 06-29 post-pivot re-score is where ADR-0007/0008 era
strategy was re-scored, and the 07-03 reviews build on these.
Sources: slice-1-review-fixes @ d2ef53b (gtm-path, vision-sanity-check,
slice-1-claude-adversarial-assessment), strategic-reviews-post-pivot-
rescore @ ea27167 (post-pivot re-score).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QndwfhjyTiZcUYp87dwW8
Signed-off-by: Aaron D. Lee <himself@adlee.work>
Task 1 review flagged the new reflex module was missing from the
module-map //! catalogue. AGENTS.md learner-facing convention requires
the catalogue entry describing what the module does + why it exists.
Addresses the Minor finding from the Task 1 task review.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
The critical-path foundation for the barge-in reflex. AdvisoryEvent is
the enum carried over a tokio mpsc from TapEngine to Reflex (brain →
FOB). ReflexMetrics is the observable surface. barge_in_flush is the
new AudioPipe trait method (default delegates to clear_playout_ring) —
the kill-now path that clears the ring AND drains rx_audio_out.
Task 1 of the slice-4 plan. Everything else depends on this landing.
Signed-off-by: Aaron D. Lee <himself@adlee.work>