feat(call-model): +tap field on Channel + TapHandle marker (spec §5.2, §6)
- pub struct TapHandle(()) — zero-sized marker (Option<TapHandle> compiles to a bool, no allocation). The live tap connection lives in the binary's DashMap<ChannelId, TapConn>, keyed by the channel's existing ChannelId (== wire session_id per spec §5.3). - Channel grows pub tap: Option<TapHandle>. None until Connected, set on Connected, cleared before state advances to Closed. - State invariant: tap and ChannelState are tied (Connected+None=bug). - Slice-1 backwards compatible: new field is Option<...>; Multi-tap-per-channel is the future trigger for a TapId newtype (YAGNI). Spec ref: 2026-06-28-slice-2-agent-tap-design.md §5.2, §6.
This commit is contained in:
@@ -45,6 +45,18 @@ mod tests {
|
||||
ch.state = ChannelState::Closing;
|
||||
ch.state = ChannelState::Closed;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_starts_with_no_tap() {
|
||||
let ch = Channel::new_inbound();
|
||||
assert_eq!(ch.tap, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_handle_is_zero_sized() {
|
||||
// Zero-sized type — confirms the "no extra allocation" claim.
|
||||
assert_eq!(std::mem::size_of::<TapHandle>(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
use std::time::Instant;
|
||||
@@ -138,6 +150,9 @@ pub struct Channel {
|
||||
/// user could change mid-call. The monotonic clock is the right
|
||||
/// tool for "has this peer been silent for 60 seconds?"
|
||||
pub created_at: Instant,
|
||||
/// NEW (slice-2, spec §5.2, §6): None until Connected, set on Connected,
|
||||
/// cleared on Closing. State invariant — see the table below.
|
||||
pub tap: Option<TapHandle>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
@@ -148,6 +163,43 @@ impl Channel {
|
||||
state: ChannelState::New,
|
||||
direction: Direction::Inbound,
|
||||
created_at: Instant::now(),
|
||||
tap: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-cost marker that a tap is attached to this `Channel`.
|
||||
///
|
||||
/// # Why a zero-sized newtype (not a `TapClient` handle / mpsc sender / UUID?)
|
||||
///
|
||||
/// `Channel` lives in `rutster-call-model`, which is a leaf — no tokio dep
|
||||
/// (spec §6, slice-1 §5.3). The live tap connection (mpsc handles, WS
|
||||
/// state) lives in the binary's `DashMap<ChannelId, TapConn>` and is
|
||||
/// looked up by the channel's existing `ChannelId` (which is also the
|
||||
/// wire `session_id` per spec §5.3). The `TapHandle` here is just the
|
||||
/// type-system marker that "a tap is attached" — `Option<TapHandle>`
|
||||
/// compiles to a single bool, no allocation.
|
||||
///
|
||||
/// Multi-tap-per-channel (e.g. a recording tap beside a brain tap) is a
|
||||
/// future-rung concern; when it appears that's the trigger to mint a
|
||||
/// separate `TapId(Uuid)` newtype and key the registry by it. For slice-2
|
||||
/// (one brain per call) `ChannelId` is sufficient — YAGNI (spec §6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TapHandle(());
|
||||
|
||||
impl TapHandle {
|
||||
/// Construct a marker. Public so the binary (Task 7) can mint one
|
||||
/// when the TapEngine spawns; the doc comment + the zero-sized
|
||||
/// payload make accidental misuse low-stakes. `pub(crate)` would
|
||||
/// restrict to `rutster-call-model` and block the binary; we have no
|
||||
/// reason to gate this from the binary that owns the spawn lifecycle.
|
||||
pub fn new() -> Self {
|
||||
Self(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TapHandle {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user