From f41088fa6e45b558b631acfa8874231bce850b25 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 28 Jul 2026 16:40:34 -0400 Subject: [PATCH 01/55] feat(chat): land shared-agent chat bridge and supporting runtime infrastructure Squashes the telegram branch history into one commit so it lands as a single reviewable unit rather than 113 incremental commits accumulated during development. Signed-off-by: Yordis Prieto --- .github/canary-container-services.json | 5 + .github/workflows/canary-container-images.yml | 1 + .gitignore | 15 +- devops/docker/compose/compose.yml | 26 + .../services/chat-bridge-telegram/Dockerfile | 43 + .../multi-channel-agent-routing.md | 290 ++++ rsworkspace/Cargo.lock | 1383 ++++++++++------- rsworkspace/Cargo.toml | 5 + .../chat/chat-bridge-telegram/Cargo.toml | 28 + .../chat/chat-bridge-telegram/src/acp_port.rs | 98 ++ .../chat/chat-bridge-telegram/src/config.rs | 74 + .../chat/chat-bridge-telegram/src/main.rs | 182 +++ .../chat/chat-bridge-telegram/src/outbound.rs | 34 + .../chat/chat-bridge-telegram/src/parse.rs | 33 + .../chat/chat-bridge-telegram/src/pipeline.rs | 133 ++ .../src/pipeline_tests.rs | 239 +++ .../chat/chat-bridge-telegram/src/render.rs | 116 ++ .../crates/chat/trogon-chat/Cargo.toml | 15 + .../crates/chat/trogon-chat/src/agent_port.rs | 56 + .../chat/trogon-chat/src/conversation.rs | 61 + .../crates/chat/trogon-chat/src/endpoint.rs | 111 ++ .../crates/chat/trogon-chat/src/event.rs | 37 + .../crates/chat/trogon-chat/src/lib.rs | 24 + .../crates/chat/trogon-chat/src/render.rs | 28 + .../crates/chat/trogon-chat/src/store.rs | 138 ++ .../trogon-telemetry/src/service_name.rs | 2 + 26 files changed, 2572 insertions(+), 605 deletions(-) create mode 100644 devops/docker/compose/services/chat-bridge-telegram/Dockerfile create mode 100644 docs/architecture/multi-channel-agent-routing.md create mode 100644 rsworkspace/crates/chat/chat-bridge-telegram/Cargo.toml create mode 100644 rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs create mode 100644 rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs create mode 100644 rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs create mode 100644 rsworkspace/crates/chat/chat-bridge-telegram/src/outbound.rs create mode 100644 rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs create mode 100644 rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs create mode 100644 rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs create mode 100644 rsworkspace/crates/chat/chat-bridge-telegram/src/render.rs create mode 100644 rsworkspace/crates/chat/trogon-chat/Cargo.toml create mode 100644 rsworkspace/crates/chat/trogon-chat/src/agent_port.rs create mode 100644 rsworkspace/crates/chat/trogon-chat/src/conversation.rs create mode 100644 rsworkspace/crates/chat/trogon-chat/src/endpoint.rs create mode 100644 rsworkspace/crates/chat/trogon-chat/src/event.rs create mode 100644 rsworkspace/crates/chat/trogon-chat/src/lib.rs create mode 100644 rsworkspace/crates/chat/trogon-chat/src/render.rs create mode 100644 rsworkspace/crates/chat/trogon-chat/src/store.rs diff --git a/.github/canary-container-services.json b/.github/canary-container-services.json index f910e40842..6c57df21bb 100644 --- a/.github/canary-container-services.json +++ b/.github/canary-container-services.json @@ -3,5 +3,10 @@ "image": "trogonai/trogon-gateway", "context": "./rsworkspace", "dockerfile": "./devops/docker/compose/services/trogon-gateway/Dockerfile" + }, + "chat-bridge-telegram": { + "image": "trogonai/chat-bridge-telegram", + "context": "./rsworkspace", + "dockerfile": "./devops/docker/compose/services/chat-bridge-telegram/Dockerfile" } } diff --git a/.github/workflows/canary-container-images.yml b/.github/workflows/canary-container-images.yml index 4a8095e601..e5c081feb0 100644 --- a/.github/workflows/canary-container-images.yml +++ b/.github/workflows/canary-container-images.yml @@ -24,6 +24,7 @@ jobs: matrix: service: - trogon-gateway + - chat-bridge-telegram steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.gitignore b/.gitignore index 6ed7e81897..d024647c16 100644 --- a/.gitignore +++ b/.gitignore @@ -25,22 +25,19 @@ docs/.vitepress/cache !.env.example mise.local.toml -# Docker -docker-compose.override.yml - # IDE .idea/ .vscode/ *.swp *.swo +*~ # OS .DS_Store Thumbs.db -# trogonai internal -.trogonai/ -*.internal.trogonai.md +# Logs +*.log # Coverage lcov.info @@ -50,6 +47,12 @@ coverage-*.xml *.profraw *.profdata +# Docker +.dockerignore + +# NATS +*.creds + # Misc tmp diff --git a/devops/docker/compose/compose.yml b/devops/docker/compose/compose.yml index 9d75732694..98cf22f3a7 100644 --- a/devops/docker/compose/compose.yml +++ b/devops/docker/compose/compose.yml @@ -45,6 +45,31 @@ services: start_period: 10s retries: 3 + # Telegram chat bridge: consumes the gateway's raw TELEGRAM stream and + # drives the shared agent over acp-nats. Needs the gateway's Telegram + # source enabled (provisions the stream) and an agent behind acp-nats. + chat-bridge-telegram: + build: + context: ../../../rsworkspace + dockerfile: ../devops/docker/compose/services/chat-bridge-telegram/Dockerfile + env_file: + - path: .env + required: false + environment: + TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}" + CHAT_SEED_TELEGRAM_USERS: "${CHAT_SEED_TELEGRAM_USERS:-}" + CHAT_PREFIX: "${CHAT_PREFIX:-prod}" + TELEGRAM_INBOUND_STREAM: "${TELEGRAM_INBOUND_STREAM:-TELEGRAM}" + ACP_PREFIX: "${ACP_PREFIX:-acp}" + NATS_URL: "nats:4222" + RUST_LOG: "${RUST_LOG:-info}" + depends_on: + nats: + condition: service_healthy + restart: unless-stopped + profiles: + - telegram + # Backing store for the optional Postgres schedules read-model projection # (SCHEDULER_PROJECTION_BACKEND=postgres). The default NATS KV projection does # not need this service. @@ -66,6 +91,7 @@ services: start_period: 5s retries: 5 + ngrok: image: ngrok/ngrok:3.39.6-alpine env_file: diff --git a/devops/docker/compose/services/chat-bridge-telegram/Dockerfile b/devops/docker/compose/services/chat-bridge-telegram/Dockerfile new file mode 100644 index 0000000000..2f783c7efc --- /dev/null +++ b/devops/docker/compose/services/chat-bridge-telegram/Dockerfile @@ -0,0 +1,43 @@ +# ── Stage 1: chef — generate dependency recipe ────────────────────────────── +FROM rust:1.96.0-slim-bookworm AS chef + +RUN cargo install cargo-chef --locked + +WORKDIR /build + +# ── Stage 2: planner — capture dependency graph ───────────────────────────── +FROM chef AS planner + +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ + +RUN cargo chef prepare --recipe-path recipe.json + +# ── Stage 3: builder — cached dependency build + final compile ────────────── +FROM chef AS builder + +COPY --from=planner /build/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json -p chat-bridge-telegram + +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ + +RUN cargo build --release -p chat-bridge-telegram && \ + strip target/release/chat-bridge-telegram + +# ── Stage 4: runtime ──────────────────────────────────────────────────────── +FROM debian:bookworm-20260518-slim AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --no-create-home --shell /usr/sbin/nologin trogon + +COPY --from=builder /build/target/release/chat-bridge-telegram /usr/local/bin/chat-bridge-telegram + +USER trogon + +STOPSIGNAL SIGTERM + +ENTRYPOINT ["/usr/local/bin/chat-bridge-telegram"] diff --git a/docs/architecture/multi-channel-agent-routing.md b/docs/architecture/multi-channel-agent-routing.md new file mode 100644 index 0000000000..a86050be9d --- /dev/null +++ b/docs/architecture/multi-channel-agent-routing.md @@ -0,0 +1,290 @@ +# Multi-Channel Agent Routing + +How a chat channel (Telegram first, Discord and others later) binds to an AI +agent. This document records two things: the **v1 implementation**, which goes +directly from the raw Telegram stream to an ACP agent through one bridge +worker, and the **multi-channel end state**, whose seams v1 keeps as code +boundaries so the extraction later is mechanical rather than a rewrite. + +## The shape in one paragraph + +There is no per-channel intelligence. A bridge translates between a platform +and the agent protocol; agents are plural and protocol-diverse (ACP today, A2A +and HTTP later) and are reached through in-process adapters behind a single +trait, never through a new NATS namespace. All conversational state (identity, +bindings, sessions) lives in JetStream KV, owned by exactly one worker, and is +designed channel-neutral from day one even while only Telegram exists. + +## V1: the direct path + +``` + SUBJECTS / PROTOCOL WORKER + +Telegram ─HTTP─▶ telegram.{update_type} trogon-gateway (exists) + stream TELEGRAM, raw verbatim JSON + │ + ▼ durable consumer + normalize: parse Update, endpoint, chat-bridge-telegram + eager-download attachments (one worker) + identity + binding via KV + dispatch prompt via AgentPort + │ + ▼ acp-nats (already NATS-native) + ═══ agent works, streams notifications ═══ + │ + ▼ render notifications + Telegram Bot API calls chat-bridge-telegram + (send, edit-in-place, chunk, throttle) +``` + +Two workers total, one of which already exists. The bridge is the fusion of +what the end state calls the "edge" and the "router". We fuse them because: + +- The prompt/notification traffic **already crosses NATS** inside `acp-nats`; + a `chat.>` middle namespace would add hops without adding a capability v1 + needs. ACP over NATS is our version of the direct function call OpenClaw and + Hermes make in-process (both reference systems are monoliths whose channel + handlers call the agent loop as a library). +- Raw-inbound replay is already covered by the gateway's `TELEGRAM` stream. +- The multi-channel benefits of the middle namespace only exist once there is + a second channel or a second consumer. + +The guard that keeps this from becoming a monolith: everything channel-neutral +(the KV schemas and binding logic, the `AgentPort` trait, the inbound event +and render-command types) lives in a **shared crate**, not in the Telegram +binary. A second channel imports the same brain; it never copies it. + +## The multi-channel end state + +When a second channel or a second consumer of conversations (audit, analytics) +arrives, the bridge splits along the seams the shared crate already defines: + +``` +telegram.{update_type} (stream TELEGRAM) trogon-gateway + │ + ▼ +chat.{prefix}.in.{channel}.{account}.{peer} chat-edge-telegram +stream CHAT_IN_{prefix}, neutral inbound events (normalize half) + │ + ▼ +[identity, binding, conversation KV, chat-router + per-conversation serialization, AgentPort] (generic, channel-blind) + │ + ▼ +chat.{prefix}.out.{channel}.{account}.{peer} chat-router publishes, +stream CHAT_OUT_{prefix}, render commands chat-edge-telegram + │ (render half) consumes + ▼ +platform API calls +``` + +- `{channel}.{account}.{peer}` is the **endpoint address**; tokens must be + subject-safe and edges own the encoding. +- The router subscribes `chat.{prefix}.in.>` and is channel-blind; a new + channel is a new edge binary and zero router changes. +- The subjects carry exactly the types the shared crate already defines; the + extraction is deployment surgery, not schema design. + +## Domain model + +``` +endpoint (channel, account, peer) where messages arrive and leave + │ many-to-one + ▼ +principal the human, across all channels + │ + ▼ +conversation the shared context, cross-channel + ├── agent_id sticky: set at creation by routing policy + └── current_session ephemeral: belongs to the agent +``` + +- **Conversations are cross-channel.** The same conversation can be picked up + from Telegram, Discord, or the CLI. The conversation is the root object and + endpoints are pointers into it, never the other way around. +- **Binding is the session-routing record itself**, not a layer in front of + it: an incoming message resolves endpoint to conversation and follows it. + Routing policy (which agent handles a new conversation) is consulted exactly + once, at conversation creation, then the binding is sticky. Operator config + changes affect new conversations only; live conversations never silently + change agents. +- **Sessions belong to agents and churn freely.** A session id alone is not + routable (it is only meaningful at the agent that created it) and sessions + die for boring reasons (reset, expiry, agent restart). Session replacement + never re-runs routing policy and never changes the bound agent. +- Operations map onto the hierarchy: `/new` replaces `current_session` and + keeps the agent; rebind is an explicit mutation of `agent_id` and discards + the session; a stale session is repaired in place. +- **Reply-to-origin is per prompt, not per conversation.** Several endpoints + can attach to one conversation, so each prompt records the endpoint it came + from and the response renders there. Per-conversation serialization is + mandatory: prompts from two channels into one session queue in order. + +## State: JetStream KV buckets + +All stateful registries live in JetStream KV, owned exclusively by the bridge +(the router, after extraction). Config files carry only wiring (NATS +connection, agent registry). The admin surface for these buckets (CLI, config +seeding, later GUI or MCP) is deliberately out of scope; KV is the source of +truth and whatever tool mutates it is pluggable. + +| Bucket | Key | Value | +| --- | --- | --- | +| `chat_principals_{prefix}` | principal id | display info, policy flags | +| `chat_endpoints_{prefix}` | endpoint address | principal id | +| `chat_bindings_{prefix}` | endpoint address | conversation id | +| `chat_conversations_{prefix}` | conversation id | principal id, agent_id, current_session, activity timestamps | + +Access control is identity: an endpoint that resolves to no principal is +rejected (or ignored) at the bridge. This replaces the per-channel allowlist +concept with one channel-neutral mechanism. + +## Shared-crate types (the wire schemas in waiting) + +**Inbound chat event** (a Rust type in v1; the `chat.*.in.*` payload after +extraction): + +``` +{ + endpoint: { channel, account, peer }, + sender: { platform_user_id, display_name }, + text: string | null, + attachments: [ { kind, mime, size, object_ref, platform_ref } ], + message_ref: platform message id (for dedup, replies, edits), + occurred_at: timestamp +} +``` + +**Render commands** (a Rust enum in v1; the `chat.*.out.*` payload after +extraction): + +| Command | Purpose | +| --- | --- | +| `send_text` | new message | +| `edit_text` | streaming preview via edit-in-place | +| `send_attachment` | upload a produced file, by `object_ref` | +| `typing` | activity indicator | +| `react` | acknowledge without text | + +The render vocabulary is the one contract every channel implements; it stays +small on purpose. Both reference systems studied (OpenClaw, Hermes) converged +on essentially this set. + +**Attachments are eager claim-check.** At normalize time the bridge downloads +the media from the platform (only the token holder can redeem a Telegram +`file_id`), stores the bytes in the object store, and the event carries the +reference. Nothing downstream ever needs platform credentials or a callback. +Lazy fetch-on-demand was rejected: it needs a request/reply surface, fails +mid-conversation instead of at ingestion, and platform download URLs expire. +Size is capped at the bridge. + +## Agent dispatch: the AgentPort trait + +The bridge reaches agents through one in-process trait: + +``` +AgentPort: + create_session / resume_session + prompt(session, content) -> stream of agent events + cancel(session) +``` + +- v1 ships exactly one implementation: ACP, using the existing `acp-nats` + client machinery. A2A and HTTP become additional implementations later. +- The agent registry is config: `agent_id -> { protocol, address }` (for ACP: + the acp prefix; the agent's workspace/cwd is agent configuration, never a + channel concern). + +## Carrying platform structure over ACP: the `_meta` convention + +ACP reserves a `_meta` field on nearly every type (`PromptRequest`, every +`ContentBlock` variant, session notifications) explicitly for attaching +arbitrary metadata; `acp-nats` already uses it for prompt correlation. Three +tiers of Telegram structure map as follows: + +1. **Content** (text, images, voice, documents): ACP content blocks directly. + No loss. Claim-check references travel as embedded resources or links. +2. **Conversational context** (sender, reply-to, group vs DM, forwards): + carried **twice, deliberately**. A human-readable prefix in the text block + (works with any ACP agent, since only prompt text reaches the model) and a + structured object in `PromptRequest._meta` (works richly with agents that + opt in). `_meta` is machine-visible, not model-visible: a generic agent + carries it and ignores it, which is safe. +3. **Platform interactivity** (inline buttons, callback queries, polls, + edits): inbound, handled at the bridge and translated to synthetic prompt + text ("user chose: Approve"). Outbound, an agent that participates in the + convention attaches e.g. `{ telegram: { buttons: [...] } }` to a + notification's `_meta` and the bridge renders it; event-shaped extensions + use ACP `ExtNotification`. Agents that do not participate simply produce + plain text, and the bot degrades gracefully. + +Whatever the bridge does not carry is not destroyed: the raw `TELEGRAM` stream +retains full fidelity for replay when a future need appears. + +## Decisions and rejected alternatives + +1. **V1 goes direct: one bridge worker, no `chat.>` subjects yet.** The + neutral vocabulary ships as types in a shared crate; the namespace is the + documented extraction path, triggered by a second channel or a second + consumer. Rationale: acp-nats already provides the NATS seam and its + buffering/observability; the middle namespace pays off only at channel two. +2. **Channel-neutral vocabulary from day one** even while fused: the shared + crate, not the Telegram binary, owns the schemas, KV logic, and AgentPort. + The `tgbot.>` subject space introduced during the Telegram refactor is + transitional and gets absorbed. +3. **No `agents.>` NATS namespace; adapters are libraries.** Protocol-neutral + agent addressability already exists twice in this workspace (`acp-nats` + for ACP, `a2a-gateway` for A2A). A generic namespace would add a second + hop and force redesigning streaming RPC over NATS, which `acp-nats` + already solved. Revisit only if a service other than the bridge/router + needs to prompt agents. +4. **Conversation is the root; binding is sticky; policy runs once at + creation.** Live conversations never hop agents because config changed. +5. **State in JetStream KV, not config files.** Admin surface out of band and + unspecified (CLI/config now, GUI or MCP later). +6. **Eager claim-check attachments**, size-capped at the bridge. +7. **Platform structure over ACP via `_meta`**, dual-carried (text for any + agent, `_meta` for participating agents); interactivity degrades + gracefully with non-participating agents. +8. **Text rendering via edit-in-place streaming** (`edit_text`), the pattern + both OpenClaw and Hermes converged on. +9. **No ADRs for this**: local domain design, recorded here. + +## Consequences for existing crates + +- `telegram-agent`: its `llm.rs` and `conversation.rs` are the wrong layer + (channels must not own a model loop) and disappear. Its consumer skeleton + seeds `chat-bridge-telegram`. +- `telegram-bot`: its bridge/transform and outbound halves fold into + `chat-bridge-telegram`, re-targeted at the shared-crate types; the typed + Telegram event vocabulary in `telegram-types` is explicitly not the neutral + model and shrinks to whatever the bridge still needs internally. +- `telegram-nats` (`tgbot.>` subjects, per-prefix streams): transitional, + removed with the fusion (the bot-to-agent bus it modeled no longer exists + as a NATS boundary in v1). +- `trogon-gateway`: unchanged. Its Telegram source stays the single raw + ingress. Evolution path, not v1: a generic **sink** concept (NATS to + HTTP-out) symmetric to its sources, which would centralize outbound token + custody; today the bot token intentionally lives in both the gateway + (webhook registration) and the bridge (API calls). + +## End-to-end walkthrough (v1) + +1. User sends "hello" to the bot on Telegram. Telegram POSTs the webhook; + trogon-gateway validates and publishes the raw Update to + `telegram.message` (stream `TELEGRAM`). +2. chat-bridge-telegram consumes it, parses the Update, encodes the endpoint + address, and eager-downloads any attachments into the object store. +3. The bridge resolves endpoint to principal (reject if unknown), endpoint to + conversation (create via routing policy if absent, writing the sticky + `agent_id`), and ensures a live session on that agent through the ACP + adapter (create or resume). +4. The bridge dispatches the prompt with conversational context dual-carried + (text prefix + `_meta`), recording the origin endpoint for this prompt. +5. The agent streams session notifications over acp-nats. The bridge renders + them: `typing`, then edit-in-place preview updates, finally the completed + text, chunked at 4096 chars with edit throttling, plus any `_meta`-carried + interactivity (buttons) the agent attached. +6. The same user later opens the CLI or Discord: a different endpoint mapped + to the same principal binds to the same conversation and continues it; + replies go to whichever endpoint prompted. diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 5405e5945d..551b7112f2 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -394,8 +394,8 @@ dependencies = [ "futures", "futures-concurrency", "rustc-hash", - "rustix 1.1.4", - "schemars 1.2.1", + "rustix", + "schemars 1.2.2", "serde", "serde_json", "shell-words", @@ -438,8 +438,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5c231915b4ab578c722eca2d1bd7df4d300bfd6cac3b8e9f0d1e3ddc95b187c" dependencies = [ "anyhow", - "derive_more", - "schemars 1.2.1", + "derive_more 2.1.1", + "schemars 1.2.2", "serde", "serde_json", "serde_with", @@ -573,6 +573,29 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools 0.10.5", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +dependencies = [ + "object 0.37.3", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -644,7 +667,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -656,7 +679,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -671,15 +694,15 @@ dependencies = [ [[package]] name = "astral-tokio-tar" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08648fef353ab39a9d26f909ad53fc4f071be4c91853b78523f5cc3d9e5ebffd" +checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" dependencies = [ "futures-core", "libc", "portable-atomic", "rustc-hash", - "rustix 0.38.44", + "rustix", "tokio", "tokio-stream", "xattr", @@ -735,7 +758,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -764,7 +787,7 @@ dependencies = [ "nkeys", "pin-project", "portable-atomic", - "rand 0.10.1", + "rand 0.10.2", "regex", "ring", "rustls-native-certs", @@ -801,7 +824,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -816,7 +839,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -841,7 +864,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -858,7 +881,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -878,15 +901,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ "aws-lc-sys", "zeroize", @@ -894,14 +917,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -931,7 +955,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1 0.10.6", + "sha1 0.10.7", "sync_wrapper", "tokio", "tokio-tungstenite", @@ -968,7 +992,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1027,9 +1051,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -1045,9 +1069,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -1091,7 +1115,7 @@ dependencies = [ "log", "num", "pin-project-lite", - "rand 0.9.2", + "rand 0.9.5", "rustls", "rustls-native-certs", "rustls-pki-types", @@ -1208,9 +1232,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" dependencies = [ "allocator-api2", ] @@ -1221,6 +1245,12 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" @@ -1238,15 +1268,15 @@ dependencies = [ [[package]] name = "bytesize" -version = "2.4.0" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49e78e506b9d7633710dab98996f22f95f3d0f488e8f1aa162830556ed9fc14d" +checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e" [[package]] name = "cc" -version = "1.2.59" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "jobserver", @@ -1287,21 +1317,43 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", "rand_core 0.10.1", ] +[[package]] +name = "chat-bridge-telegram" +version = "0.1.0" +dependencies = [ + "acp-nats", + "agent-client-protocol", + "anyhow", + "async-nats", + "async-trait", + "futures", + "serde_json", + "teloxide", + "testcontainers-modules", + "thiserror 2.0.18", + "tokio", + "tracing", + "trogon-chat", + "trogon-nats", + "trogon-std", + "trogon-telemetry", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1368,7 +1420,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1466,14 +1518,14 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "const-hex" -version = "1.18.1" +version = "1.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531185e432bb31db1ecda541e9e7ab21468d4d844ad7505e0546a49b4945d49b" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -1729,18 +1781,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1748,9 +1800,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -1766,9 +1818,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crypto-bigint" @@ -1795,9 +1847,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -1864,7 +1916,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1898,7 +1950,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1911,7 +1963,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1922,7 +1974,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1933,7 +1985,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2021,7 +2073,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2031,7 +2083,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.118", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", ] [[package]] @@ -2040,7 +2101,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl", + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -2053,7 +2126,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", "unicode-xid", ] @@ -2071,13 +2144,13 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", - "crypto-common 0.2.1", + "crypto-common 0.2.2", "ctutils", ] @@ -2104,13 +2177,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -2130,6 +2203,15 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dptree" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81175dab5ec79c30e0576df2ed2c244e1721720c302000bb321b107e82e265c" +dependencies = [ + "futures", +] + [[package]] name = "dunce" version = "1.0.5" @@ -2184,9 +2266,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" dependencies = [ "serde", ] @@ -2248,6 +2330,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erasable" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437cfb75878119ed8265685c41a115724eae43fb7cc5a0bf0e4ecc3b803af1c4" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "errno" version = "0.3.14" @@ -2270,11 +2362,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2302,9 +2393,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "ferroid" @@ -2313,7 +2404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" dependencies = [ "portable-atomic", - "rand 0.10.1", + "rand 0.10.2", "web-time", ] @@ -2411,6 +2502,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2453,9 +2559,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -2476,9 +2582,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" @@ -2504,9 +2610,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-lite" @@ -2529,20 +2635,20 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" @@ -2615,17 +2721,15 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", "wasm-bindgen", ] @@ -2643,9 +2747,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "group" @@ -2660,9 +2764,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -2775,7 +2879,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -2799,9 +2903,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -2809,9 +2913,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -2834,18 +2938,18 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -2865,9 +2969,9 @@ dependencies = [ [[package]] name = "hyper-named-pipe" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" dependencies = [ "hex", "hyper", @@ -2875,24 +2979,22 @@ dependencies = [ "pin-project-lite", "tokio", "tower-service", - "winapi", ] [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.6", + "webpki-roots 1.0.9", ] [[package]] @@ -2908,6 +3010,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -3077,14 +3195,33 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3129,6 +3266,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3191,7 +3337,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3210,28 +3356,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -3278,9 +3423,9 @@ dependencies = [ [[package]] name = "jsonschema-regex" -version = "0.46.9" +version = "0.46.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b7fc96cd6677cc81b00607d43477327d719419937ef1c44b3556a40f517d54c" +checksum = "6dbd1086b01b9349fd4ef9a07433965af64c8ce8159abe633a189e4ff817bd13" dependencies = [ "regex-syntax", ] @@ -3299,7 +3444,7 @@ dependencies = [ "p256", "p384", "pem", - "rand 0.8.6", + "rand 0.8.7", "rsa", "serde", "serde_json", @@ -3326,9 +3471,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -3338,9 +3483,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -3355,12 +3500,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3384,9 +3523,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru-slab" @@ -3408,7 +3547,7 @@ checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3492,14 +3631,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memfd" @@ -3507,7 +3646,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" dependencies = [ - "rustix 1.1.4", + "rustix", ] [[package]] @@ -3522,6 +3661,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3540,9 +3689,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -3584,6 +3733,23 @@ dependencies = [ "byteorder", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nats-jwt-rs" version = "0.1.1" @@ -3614,7 +3780,7 @@ dependencies = [ "ed25519-dalek", "getrandom 0.2.17", "log", - "rand 0.8.6", + "rand 0.8.7", "signatory", ] @@ -3653,9 +3819,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -3672,7 +3838,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -3709,11 +3875,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -3749,6 +3914,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "object" version = "0.39.1" @@ -3788,12 +3962,49 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.32.0" @@ -3876,7 +4087,7 @@ dependencies = [ "opentelemetry", "percent-encoding", "portable-atomic", - "rand 0.9.2", + "rand 0.9.5", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -3972,7 +4183,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3983,9 +4194,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pastey" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "pem" @@ -4059,7 +4270,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -4072,7 +4283,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4095,22 +4306,22 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4167,7 +4378,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -4184,9 +4395,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "postcard" @@ -4231,7 +4442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4243,11 +4454,32 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", +] + [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -4262,7 +4494,7 @@ dependencies = [ "bit-vec 0.8.0", "bitflags", "num-traits", - "rand 0.9.2", + "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -4273,9 +4505,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -4283,12 +4515,12 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", - "itertools", + "itertools 0.14.0", "log", "multimap", "petgraph 0.8.3", @@ -4298,32 +4530,42 @@ dependencies = [ "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.118", + "syn 2.0.119", "tempfile", ] [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -4364,7 +4606,7 @@ checksum = "5a7ac85c0bb3fb351f10d531230aaa5e366b46d7c4e5328e5f02801d6dac1165" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4375,9 +4617,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -4401,9 +4643,9 @@ checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.4.2", + "getrandom 0.4.3", "lru-slab", - "rand 0.10.1", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -4418,23 +4660,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -4453,9 +4695,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4464,9 +4706,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -4474,12 +4716,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -4565,6 +4807,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rc-box" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897fecc9fac6febd4408f9e935e86df739b0023b625e610e0357535b9c8adad0" +dependencies = [ + "erasable", +] + [[package]] name = "rcgen" version = "0.14.8" @@ -4601,29 +4852,29 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "referencing" -version = "0.46.9" +version = "0.46.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77954d81b2e1c5e8ab889f9f597a92f852ab073255de9e37ee3fb443efd57051" +checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d" dependencies = [ "ahash", "fluent-uri", @@ -4638,9 +4889,9 @@ dependencies = [ [[package]] name = "regalloc2" -version = "0.15.1" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" dependencies = [ "allocator-api2", "bumpalo", @@ -4653,9 +4904,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -4665,9 +4916,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -4676,9 +4927,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -4695,9 +4946,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -4708,6 +4962,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -4718,7 +4973,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.6", + "webpki-roots 1.0.9", ] [[package]] @@ -4768,6 +5023,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -4798,8 +5062,8 @@ dependencies = [ "http-body-util", "pastey", "pin-project-lite", - "rand 0.10.1", - "schemars 1.2.1", + "rand 0.10.2", + "schemars 1.2.2", "serde", "serde_json", "sse-stream", @@ -4847,15 +5111,15 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -4875,19 +5139,6 @@ dependencies = [ "nom", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -4897,15 +5148,15 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -4919,9 +5170,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -4989,9 +5240,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -5052,9 +5303,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "chrono", "dyn-clone", @@ -5066,14 +5317,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -5166,18 +5417,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "serde_derive_internals" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -5216,13 +5467,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -5259,7 +5510,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.14.0", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -5275,7 +5526,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5293,9 +5544,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -5310,7 +5561,7 @@ checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -5338,7 +5589,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -5358,9 +5609,9 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -5402,9 +5653,9 @@ checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -5442,9 +5693,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -5460,9 +5711,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -5484,9 +5735,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -5559,7 +5810,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5582,7 +5833,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.118", + "syn 2.0.119", "thiserror 2.0.18", "tokio", "url", @@ -5599,7 +5850,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest 0.11.2", + "digest 0.11.3", "dotenvy", "either", "futures-core", @@ -5639,7 +5890,7 @@ dependencies = [ "log", "md-5", "memchr", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.11.0", @@ -5695,6 +5946,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -5721,7 +5985,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5732,7 +5996,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5753,7 +6017,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5764,9 +6028,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -5801,7 +6065,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5810,6 +6074,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "take_mut" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" + +[[package]] +name = "takecell" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07dd1d452d2c3dc94a4e1c5c3c9a3cc88c2ef5926674b75881e454c4dc3a14c4" + [[package]] name = "target-lexicon" version = "0.13.5" @@ -5818,9 +6094,83 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "target-triple" -version = "1.0.0" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" + +[[package]] +name = "teloxide" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ef4a652466aa655a1c54581ecf41e6a5f9920203706dbc10f00153976435082" +dependencies = [ + "aquamarine", + "axum", + "bytes", + "derive_more 1.0.0", + "dptree", + "either", + "futures", + "log", + "mime", + "pin-project", + "rand 0.8.7", + "serde", + "serde_json", + "teloxide-core", + "teloxide-macros", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tower", + "tower-http 0.6.11", + "url", +] + +[[package]] +name = "teloxide-core" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2f70a3cd58c2b31ca899691b99573a40c6da713ab230bb78bbb4fb0b5c751a" +dependencies = [ + "bitflags", + "bytes", + "chrono", + "derive_more 1.0.0", + "either", + "futures", + "log", + "mime", + "once_cell", + "pin-project", + "rc-box", + "reqwest 0.12.28", + "rgb", + "serde", + "serde_json", + "serde_with", + "stacker", + "take_mut", + "takecell", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "url", + "uuid", +] + +[[package]] +name = "teloxide-macros" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" +checksum = "3118a980ed2ec11f73d9495a6606905bd74726e3ffe95a42fbeb187ded8fdbf4" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "tempfile" @@ -5829,9 +6179,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -5860,7 +6210,7 @@ dependencies = [ "ferroid", "futures", "http", - "itertools", + "itertools 0.14.0", "log", "memchr", "parse-display", @@ -5910,7 +6260,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5921,14 +6271,14 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -5975,9 +6325,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -6007,13 +6357,23 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", ] [[package]] @@ -6028,9 +6388,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -6078,7 +6438,7 @@ dependencies = [ "futures-sink", "http", "httparse", - "rand 0.8.6", + "rand 0.8.7", "ring", "rustls-pki-types", "tokio", @@ -6136,7 +6496,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.1", + "winnow 1.0.4", ] [[package]] @@ -6159,18 +6519,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.1", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tonic" @@ -6210,14 +6570,14 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "tonic-prost" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", "prost", @@ -6235,7 +6595,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn 2.0.118", + "syn 2.0.119", "tempfile", "tonic-build", ] @@ -6274,6 +6634,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", "url", ] @@ -6330,7 +6691,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6488,6 +6849,18 @@ dependencies = [ "wiremock", ] +[[package]] +name = "trogon-chat" +version = "0.1.0" +dependencies = [ + "async-nats", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", + "uuid", +] + [[package]] name = "trogon-decider" version = "0.1.0" @@ -6502,7 +6875,7 @@ version = "0.1.0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "thiserror 2.0.18", "trogon-decider", "trogon-decider-guest-sdk", @@ -6873,9 +7246,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "trybuild" -version = "1.0.117" +version = "1.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0710d4dfbeae4f9c390baa784c49858a7468fa433f3fe5d0ec5ebef651cf59f9" +checksum = "06649c6f63d86604ba0c8950d5a1829fc9a17afd70fc6629f481d75b6a624c78" dependencies = [ "glob", "serde", @@ -6907,10 +7280,10 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.2", + "rand 0.9.5", "rustls", "rustls-pki-types", - "sha1 0.10.6", + "sha1 0.10.7", "thiserror 2.0.18", ] @@ -6964,9 +7337,9 @@ checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unarray" @@ -7015,9 +7388,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -7117,7 +7490,7 @@ version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "sha1_smol", "wasm-bindgen", @@ -7193,27 +7566,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen 0.57.1", ] [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -7224,9 +7588,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -7234,9 +7598,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7244,22 +7608,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] @@ -7281,16 +7645,6 @@ dependencies = [ "wat", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser 0.244.0", -] - [[package]] name = "wasm-encoder" version = "0.251.0" @@ -7303,24 +7657,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.252.0" +version = "0.254.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" +checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393" dependencies = [ "leb128fmt", - "wasmparser 0.252.0", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder 0.244.0", - "wasmparser 0.244.0", + "wasmparser 0.254.0", ] [[package]] @@ -7350,21 +7692,22 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.244.0" +version = "0.251.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" dependencies = [ "bitflags", - "hashbrown 0.15.5", + "hashbrown 0.17.1", "indexmap 2.14.0", "semver", + "serde", ] [[package]] name = "wasmparser" -version = "0.251.0" +version = "0.252.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" dependencies = [ "bitflags", "hashbrown 0.17.1", @@ -7375,15 +7718,13 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.252.0" +version = "0.254.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" +checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27" dependencies = [ "bitflags", - "hashbrown 0.17.1", "indexmap 2.14.0", "semver", - "serde", ] [[package]] @@ -7418,12 +7759,12 @@ dependencies = [ "log", "mach2", "memfd", - "object", + "object 0.39.1", "once_cell", "postcard", "pulley-interpreter", "rayon", - "rustix 1.1.4", + "rustix", "semver", "serde", "serde_derive", @@ -7447,7 +7788,7 @@ dependencies = [ "wasmtime-internal-versioned-export-macros", "wat", "windows-sys 0.61.2", - "wit-parser 0.251.0", + "wit-parser", ] [[package]] @@ -7465,7 +7806,7 @@ dependencies = [ "hashbrown 0.17.1", "indexmap 2.14.0", "log", - "object", + "object 0.39.1", "postcard", "rustc-demangle", "semver", @@ -7491,7 +7832,7 @@ dependencies = [ "directories-next", "log", "postcard", - "rustix 1.1.4", + "rustix", "serde", "serde_derive", "sha2 0.10.9", @@ -7510,10 +7851,10 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasmtime-internal-component-util", "wasmtime-internal-wit-bindgen", - "wit-parser 0.251.0", + "wit-parser", ] [[package]] @@ -7547,9 +7888,9 @@ dependencies = [ "cranelift-frontend", "cranelift-native", "gimli", - "itertools", + "itertools 0.14.0", "log", - "object", + "object 0.39.1", "pulley-interpreter", "smallvec", "target-lexicon", @@ -7570,7 +7911,7 @@ dependencies = [ "cc", "cfg-if", "libc", - "rustix 1.1.4", + "rustix", "wasmtime-environ", "wasmtime-internal-versioned-export-macros", "windows-sys 0.61.2", @@ -7583,8 +7924,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f667288cb4dfa68a4639ffac4d5628535dda64ebdc2b990526efb12b30ba803" dependencies = [ "cc", - "object", - "rustix 1.1.4", + "object 0.39.1", + "rustix", "wasmtime-internal-versioned-export-macros", ] @@ -7609,7 +7950,7 @@ dependencies = [ "cfg-if", "cranelift-codegen", "log", - "object", + "object 0.39.1", "wasmtime-environ", ] @@ -7621,7 +7962,7 @@ checksum = "e747f4a074699ba1b4e4d841fb263f9b7df5bd1555181c4752bf5990d21ba676" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -7634,36 +7975,36 @@ dependencies = [ "bitflags", "heck", "indexmap 2.14.0", - "wit-parser 0.251.0", + "wit-parser", ] [[package]] name = "wast" -version = "252.0.0" +version = "254.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65" +checksum = "e7ed4dfc8f6b9fc38b231065e2cdfbf7359af5ab945990abf09658dcc63c3e32" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width", - "wasm-encoder 0.252.0", + "wasm-encoder 0.254.0", ] [[package]] name = "wat" -version = "1.252.0" +version = "1.254.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb" +checksum = "7127f7f9b8f127c879991cecd35f494e4628bae1b0874c681414d8d8831e952c" dependencies = [ "wast", ] [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -7681,9 +8022,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -7694,14 +8035,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.6", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -7764,7 +8105,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -7775,7 +8116,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -7808,16 +8149,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -7835,31 +8167,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -7868,96 +8183,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.15" @@ -7969,9 +8236,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] name = "wiremock" @@ -7998,12 +8265,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro 0.51.0", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wit-bindgen" @@ -8012,18 +8276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a43552cfa071f246cfd99e5dbb23710dfe7336b3259e09339818483359470749" dependencies = [ "bitflags", - "wit-bindgen-rust-macro 0.58.0", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser 0.244.0", + "wit-bindgen-rust-macro", ] [[package]] @@ -8034,23 +8287,7 @@ checksum = "4738d1c9a78e97bc7f664bfafd5d8e67d7bb26faa5c41e6d628e8bbdad3ec351" dependencies = [ "anyhow", "heck", - "wit-parser 0.251.0", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.118", - "wasm-metadata 0.244.0", - "wit-bindgen-core 0.51.0", - "wit-component 0.244.0", + "wit-parser", ] [[package]] @@ -8063,25 +8300,10 @@ dependencies = [ "heck", "indexmap 2.14.0", "prettyplease", - "syn 2.0.118", - "wasm-metadata 0.251.0", - "wit-bindgen-core 0.58.0", - "wit-component 0.251.0", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.118", - "wit-bindgen-core 0.51.0", - "wit-bindgen-rust 0.51.0", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] @@ -8095,28 +8317,9 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.118", - "wit-bindgen-core 0.58.0", - "wit-bindgen-rust 0.58.0", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder 0.244.0", - "wasm-metadata 0.244.0", - "wasmparser 0.244.0", - "wit-parser 0.244.0", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] @@ -8133,27 +8336,9 @@ dependencies = [ "serde_derive", "serde_json", "wasm-encoder 0.251.0", - "wasm-metadata 0.251.0", + "wasm-metadata", "wasmparser 0.251.0", - "wit-parser 0.251.0", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.244.0", + "wit-parser", ] [[package]] @@ -8206,7 +8391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -8221,9 +8406,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -8238,35 +8423,35 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -8279,15 +8464,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] @@ -8300,7 +8485,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -8333,14 +8518,14 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" diff --git a/rsworkspace/Cargo.toml b/rsworkspace/Cargo.toml index 09967e9c93..ea5535a3e1 100644 --- a/rsworkspace/Cargo.toml +++ b/rsworkspace/Cargo.toml @@ -47,6 +47,8 @@ trogon-semconv = { path = "crates/platform/trogon-semconv" } trogon-service-config = { path = "crates/platform/trogon-service-config" } trogon-std = { path = "crates/platform/trogon-std" } trogonai-proto = { path = "crates/platform/trogonai-proto" } +trogon-chat = { path = "crates/chat/trogon-chat" } +chat-bridge-telegram = { path = "crates/chat/chat-bridge-telegram" } # A2A a2a = { package = "a2a-lf", version = "=0.3.0" } @@ -102,6 +104,9 @@ tower-http = { version = "=0.7.0", features = ["trace"] } # Database sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "macros", "migrate", "postgres", "chrono", "json"] } +# Telegram +teloxide = { version = "=0.14.1", features = ["macros", "webhooks-axum"] } + # Serialization confique = { version = "=0.4.0", features = ["toml"] } serde = { version = "=1.0.228", features = ["derive"] } diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/Cargo.toml b/rsworkspace/crates/chat/chat-bridge-telegram/Cargo.toml new file mode 100644 index 0000000000..5cb77ecbd0 --- /dev/null +++ b/rsworkspace/crates/chat/chat-bridge-telegram/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "chat-bridge-telegram" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +acp-nats = { workspace = true } +trogon-chat = { workspace = true } +trogon-nats = { workspace = true } +trogon-std = { workspace = true, features = ["signal"] } +trogon-telemetry = { workspace = true } + +agent-client-protocol = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +async-nats = { workspace = true, features = ["jetstream", "kv"] } +futures = { workspace = true } +serde_json = { workspace = true } +teloxide = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "signal"] } +tracing = { workspace = true } + +[dev-dependencies] +testcontainers-modules = { version = "0.15", features = ["nats"] } diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs new file mode 100644 index 0000000000..eaab46fe7b --- /dev/null +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs @@ -0,0 +1,98 @@ +use acp_nats::AgentHandler; +use agent_client_protocol::schema::v1::{ + CancelNotification, ContentBlock, NewSessionRequest, PromptRequest, StopReason, TextContent, +}; +use std::path::PathBuf; +use std::sync::Arc; +use trogon_chat::{AgentPort, AgentSessionId, ConversationRecord, InboundChatEvent, PromptOutcome}; + +pub type AcpBridge = + acp_nats::Bridge; + +#[derive(Debug, thiserror::Error)] +pub enum AcpPortError { + #[error("agent request failed: {0}")] + Rpc(agent_client_protocol::Error), +} + +/// The ACP implementation of [`AgentPort`]: forwards session/prompt calls +/// through the acp-nats Bridge. Streamed agent output does not come back +/// through this port; it arrives at the bridge's ACP client half +/// (`TelegramRenderClient`) as session notifications. +pub struct AcpPort { + bridge: Arc, + agent_cwd: PathBuf, +} + +impl AcpPort { + pub fn new(bridge: Arc, agent_cwd: PathBuf) -> Self { + Self { bridge, agent_cwd } + } +} + +/// Human-readable context prefix: the only part of the conversational +/// metadata a non-participating agent is guaranteed to see, since only prompt +/// text reaches the model. +fn prompt_text(event: &InboundChatEvent) -> String { + let body = event.text.as_deref().unwrap_or_default(); + format!( + "[telegram message from {}]\n{}", + event.sender.display_name, body + ) +} + +/// Structured twin of the context prefix, for agents that opt into reading +/// `_meta` (see the architecture doc's `_meta` convention). +fn prompt_meta(event: &InboundChatEvent) -> agent_client_protocol::schema::v1::Meta { + let mut meta = serde_json::Map::new(); + meta.insert( + "chat".to_string(), + serde_json::json!({ + "channel": event.endpoint.channel(), + "endpoint": event.endpoint.kv_key(), + "sender": { + "platform_user_id": event.sender.platform_user_id, + "display_name": event.sender.display_name, + }, + "message_ref": event.message_ref, + "occurred_at": event.occurred_at, + }), + ); + meta +} + +impl AgentPort for AcpPort { + type Error = AcpPortError; + + async fn create_session(&self, _conversation: &ConversationRecord) -> Result { + let response = self + .bridge + .new_session(NewSessionRequest::new(self.agent_cwd.clone())) + .await + .map_err(AcpPortError::Rpc)?; + Ok(AgentSessionId::new(response.session_id.to_string())) + } + + async fn prompt(&self, session: &AgentSessionId, event: &InboundChatEvent) -> Result { + let mut request = PromptRequest::new( + session.as_str().to_string(), + vec![ContentBlock::Text(TextContent::new(prompt_text(event)))], + ); + request.meta = Some(prompt_meta(event)); + + let response = self.bridge.prompt(request).await.map_err(AcpPortError::Rpc)?; + Ok(match response.stop_reason { + StopReason::EndTurn => PromptOutcome::Completed, + StopReason::Cancelled => PromptOutcome::Cancelled, + StopReason::Refusal => PromptOutcome::Refused, + _ => PromptOutcome::Truncated, + }) + } + + async fn cancel(&self, session: &AgentSessionId) -> Result<(), Self::Error> { + self.bridge + .cancel(CancelNotification::new(session.as_str().to_string())) + .await + .map_err(AcpPortError::Rpc) + } +} diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs new file mode 100644 index 0000000000..4a983bd6ee --- /dev/null +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs @@ -0,0 +1,74 @@ +use acp_nats::{AcpPrefix, NatsConfig}; +use anyhow::Context; +use std::path::PathBuf; +use trogon_std::env::ReadEnv; + +pub struct BridgeConfig { + pub acp: acp_nats::Config, + /// Environment/tenant token for KV buckets and the durable consumer name. + pub chat_prefix: String, + /// JetStream stream the trogon-gateway Telegram source provisions. + pub inbound_stream: String, + pub bot_token: String, + /// Endpoint account token; identifies which bot account on Telegram. + pub bot_account: String, + /// Agent every new conversation binds to (v1 routing policy: single agent). + pub agent_id: String, + /// Workspace the agent roots its sessions in; agent configuration, never + /// a channel concern (see the architecture doc). + pub agent_cwd: PathBuf, + /// Telegram user ids seeded as principals at startup. Bootstrap only; + /// ongoing administration mutates the KV buckets out of band. + pub seed_users: Vec, +} + +impl BridgeConfig { + pub fn from_env(env: &E) -> anyhow::Result { + let bot_token = env + .var("TELEGRAM_BOT_TOKEN") + .context("TELEGRAM_BOT_TOKEN not set")?; + + let chat_prefix = env.var("CHAT_PREFIX").unwrap_or_else(|_| "prod".to_string()); + let inbound_stream = env + .var("TELEGRAM_INBOUND_STREAM") + .unwrap_or_else(|_| "TELEGRAM".to_string()); + let bot_account = env + .var("TELEGRAM_BOT_ACCOUNT") + .unwrap_or_else(|_| "bot".to_string()); + let agent_id = env.var("CHAT_AGENT_ID").unwrap_or_else(|_| "default".to_string()); + let agent_cwd = env + .var("CHAT_AGENT_CWD") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()); + + let seed_users = match env.var("CHAT_SEED_TELEGRAM_USERS") { + Ok(raw) => raw + .split(',') + .filter(|s| !s.trim().is_empty()) + .map(|s| { + s.trim() + .parse::() + .with_context(|| format!("invalid Telegram user id in CHAT_SEED_TELEGRAM_USERS: {s:?}")) + }) + .collect::>>()?, + Err(_) => Vec::new(), + }; + + let raw_prefix = env + .var(acp_nats::ENV_ACP_PREFIX) + .unwrap_or_else(|_| acp_nats::DEFAULT_ACP_PREFIX.to_string()); + let acp_prefix = AcpPrefix::new(raw_prefix).context("invalid ACP prefix")?; + let acp = acp_nats::Config::with_prefix(acp_prefix, NatsConfig::from_env(env)); + + Ok(Self { + acp, + chat_prefix, + inbound_stream, + bot_token, + bot_account, + agent_id, + agent_cwd, + seed_users, + }) + } +} diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs new file mode 100644 index 0000000000..de6893f8df --- /dev/null +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs @@ -0,0 +1,182 @@ +//! Telegram chat bridge: the v1 direct path from the gateway's raw Telegram +//! stream to an ACP agent. See `docs/architecture/multi-channel-agent-routing.md`. +//! +//! One worker, two halves: normalize (raw Update -> `InboundChatEvent`, +//! identity + conversation via KV, prompt via `AgentPort`) and render (agent +//! session notifications -> Telegram API calls). Everything channel-neutral +//! lives in `trogon-chat`; this binary is allowed to know about Telegram and +//! nothing else. +#![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))] + +mod acp_port; +mod config; +mod outbound; +mod parse; +mod pipeline; +mod render; + +use acp_nats::{AgentHandler, ClientHandler}; +use acp_port::{AcpBridge, AcpPort}; +use agent_client_protocol::schema::ProtocolVersion; +use agent_client_protocol::schema::v1::InitializeRequest; +use anyhow::Context as _; +use config::BridgeConfig; +use futures::StreamExt; +use outbound::TelegramOutbound; +use pipeline::Pipeline; +use render::TelegramRenderClient; +use std::rc::Rc; +use std::sync::Arc; +use teloxide::Bot; +use tracing::{error, info, warn}; +use trogon_chat::store::PrincipalRecord; +use trogon_chat::{ChatStore, Endpoint, PrincipalId}; +use trogon_std::env::SystemEnv; +use trogon_std::fs::SystemFs; +use trogon_std::signal::shutdown_signal; +use trogon_telemetry::ServiceName; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let config = BridgeConfig::from_env(&SystemEnv)?; + trogon_telemetry::init_logger(ServiceName::ChatBridgeTelegram, [], &SystemEnv, &SystemFs); + + info!("Telegram chat bridge starting"); + + let nats_connect_timeout = acp_nats::nats_connect_timeout(&SystemEnv); + let nats_client = acp_nats::nats::connect(config.acp.nats(), nats_connect_timeout).await?; + let js = async_nats::jetstream::new(nats_client.clone()); + + let store = ChatStore::ensure(&js, &config.chat_prefix).await?; + seed_principals(&store, &config).await?; + + let stream = js.get_stream(&config.inbound_stream).await.map_err(|e| { + anyhow::anyhow!( + "inbound stream '{}' not found; the trogon-gateway Telegram source must provision it: {e}", + config.inbound_stream + ) + })?; + let consumer_name = format!("chat-bridge-telegram-{}", config.chat_prefix); + let consumer = stream + .get_or_create_consumer( + &consumer_name, + async_nats::jetstream::consumer::pull::Config { + durable_name: Some(consumer_name.clone()), + // Generous ack window: a prompt turn can legitimately run for + // minutes before the turn ends and we ack. + ack_wait: std::time::Duration::from_secs(600), + max_deliver: 5, + ..Default::default() + }, + ) + .await + .context("failed to create inbound consumer")?; + let messages = consumer.messages().await.context("failed to open inbound messages")?; + + let bot = Bot::new(config.bot_token.clone()); + + let local = tokio::task::LocalSet::new(); + let result = local + .run_until(run(nats_client, store, messages, bot, config)) + .await; + + if let Err(e) = trogon_telemetry::shutdown_otel() { + error!(error = %e, "OpenTelemetry shutdown failed"); + } + result +} + +async fn seed_principals(store: &ChatStore, config: &BridgeConfig) -> anyhow::Result<()> { + for user in &config.seed_users { + let principal = PrincipalId::new(format!("telegram-{user}"))?; + let endpoint = Endpoint::new("telegram", &config.bot_account, user.to_string())?; + store + .link_endpoint(&principal, &PrincipalRecord { display_name: None }, &endpoint) + .await?; + info!(principal = %principal, endpoint = %endpoint, "Seeded principal"); + } + Ok(()) +} + +async fn run( + nats_client: async_nats::Client, + store: ChatStore, + mut messages: async_nats::jetstream::consumer::pull::Stream, + bot: Bot, + config: BridgeConfig, +) -> anyhow::Result<()> { + let meter = trogon_telemetry::meter("chat-bridge-telegram"); + let (notification_tx, mut notification_rx) = tokio::sync::mpsc::channel(64); + let js_client = trogon_nats::jetstream::NatsJetStreamClient::new(async_nats::jetstream::new(nats_client.clone())); + let bridge: Arc = Arc::new(acp_nats::Bridge::new( + nats_client.clone(), + js_client, + trogon_std::time::SystemClock, + &meter, + config.acp.clone(), + notification_tx, + )); + let renderer = Rc::new(TelegramRenderClient::new()); + + let client_task = tokio::task::spawn_local(acp_nats::client::run( + nats_client.clone(), + renderer.clone(), + bridge.clone(), + )); + let renderer_for_rx = renderer.clone(); + let notification_task = tokio::task::spawn_local(async move { + while let Some(notification) = notification_rx.recv().await { + if renderer_for_rx.session_notification(notification).await.is_err() { + break; + } + } + }); + + bridge + .initialize(InitializeRequest::new(ProtocolVersion::LATEST)) + .await + .map_err(|e| anyhow::anyhow!("ACP initialize failed: {e}"))?; + info!("ACP agent initialized; consuming inbound updates"); + + let port = AcpPort::new(bridge.clone(), config.agent_cwd.clone()); + let telegram = TelegramOutbound::new(bot); + let pipeline = Pipeline { + store: &store, + port: &port, + renderer: renderer.as_ref(), + outbound: &telegram, + bot_account: &config.bot_account, + agent_id: &config.agent_id, + }; + + let shutdown = shutdown_signal(); + tokio::pin!(shutdown); + loop { + tokio::select! { + () = &mut shutdown => { + info!("Shutting down"); + break; + } + next = messages.next() => { + let Some(next) = next else { + warn!("Inbound consumer stream ended"); + break; + }; + match next { + Ok(msg) => { + if let Err(e) = pipeline.handle_message(&msg).await { + // Left unacked on purpose: JetStream redelivers + // (max_deliver bounds the retries). + error!(error = ?e, "Failed to process update; leaving unacked for redelivery"); + } + } + Err(e) => warn!(error = %e, "Error receiving from inbound consumer"), + } + } + } + } + + client_task.abort(); + notification_task.abort(); + Ok(()) +} diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/outbound.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/outbound.rs new file mode 100644 index 0000000000..82a81f6152 --- /dev/null +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/outbound.rs @@ -0,0 +1,34 @@ +use teloxide::Bot; +use teloxide::requests::Requester; +use teloxide::types::{ChatAction, ChatId}; + +/// The render half's platform seam: what the pipeline needs from Telegram, +/// narrow enough to fake in tests. Grows with the render vocabulary +/// (edit-in-place, attachments), never with agent concepts. +#[allow(async_fn_in_trait)] +pub trait Outbound { + async fn typing(&self, chat_id: i64) -> anyhow::Result<()>; + async fn send_text(&self, chat_id: i64, text: String) -> anyhow::Result<()>; +} + +pub struct TelegramOutbound { + bot: Bot, +} + +impl TelegramOutbound { + pub fn new(bot: Bot) -> Self { + Self { bot } + } +} + +impl Outbound for TelegramOutbound { + async fn typing(&self, chat_id: i64) -> anyhow::Result<()> { + self.bot.send_chat_action(ChatId(chat_id), ChatAction::Typing).await?; + Ok(()) + } + + async fn send_text(&self, chat_id: i64, text: String) -> anyhow::Result<()> { + self.bot.send_message(ChatId(chat_id), text).await?; + Ok(()) + } +} diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs new file mode 100644 index 0000000000..49c7407f34 --- /dev/null +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs @@ -0,0 +1,33 @@ +use teloxide::types::{Update, UpdateKind}; +use trogon_chat::{Endpoint, InboundChatEvent, Sender}; + +/// Normalize a raw Telegram update into the channel-neutral event, or `None` +/// for update kinds v1 does not carry (media, edits, membership, ...). The +/// raw stream retains those with full fidelity for later. +pub fn inbound_event(update: &Update, bot_account: &str) -> Option { + let UpdateKind::Message(msg) = &update.kind else { + return None; + }; + let text = msg.text()?; + let from = msg.from.as_ref()?; + + let endpoint = match Endpoint::new("telegram", bot_account, msg.chat.id.0.to_string()) { + Ok(endpoint) => endpoint, + Err(e) => { + tracing::warn!(error = %e, chat_id = msg.chat.id.0, "Skipping update with unencodable endpoint"); + return None; + } + }; + + Some(InboundChatEvent { + endpoint, + sender: Sender { + platform_user_id: from.id.0.to_string(), + display_name: from.full_name(), + }, + text: Some(text.to_string()), + attachments: Vec::new(), + message_ref: msg.id.0.to_string(), + occurred_at: msg.date.timestamp(), + }) +} diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs new file mode 100644 index 0000000000..af2a1cc772 --- /dev/null +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs @@ -0,0 +1,133 @@ +#[cfg(test)] +#[path = "pipeline_tests.rs"] +mod pipeline_tests; + +use crate::outbound::Outbound; +use crate::parse; +use crate::render::{TEXT_CHUNK_LIMIT, TelegramRenderClient, chunk_text}; +use anyhow::Context as _; +use tracing::{info, warn}; +use trogon_chat::{AgentId, AgentPort, ChatStore, ConversationRecord}; + +pub struct Pipeline<'a, P, O> { + pub store: &'a ChatStore, + pub port: &'a P, + pub renderer: &'a TelegramRenderClient, + pub outbound: &'a O, + pub bot_account: &'a str, + pub agent_id: &'a str, +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) +} + +async fn ack(msg: &async_nats::jetstream::Message) -> anyhow::Result<()> { + msg.ack().await.map_err(|e| anyhow::anyhow!("ack failed: {e}")) +} + +impl Pipeline<'_, P, O> { + /// Process one raw gateway message end to end. Unrecoverable messages + /// (unparseable, unauthorized, kinds v1 does not carry) are acked and + /// dropped; processing errors return `Err` with the message unacked so + /// JetStream redelivers. + pub async fn handle_message(&self, msg: &async_nats::jetstream::Message) -> anyhow::Result<()> { + let update = match serde_json::from_slice::(&msg.payload) { + Ok(update) => update, + Err(e) => { + warn!(error = %e, body_len = msg.payload.len(), "Unparseable Telegram update; dropping"); + return ack(msg).await; + } + }; + + let Some(event) = parse::inbound_event(&update, self.bot_account) else { + return ack(msg).await; + }; + + let Some(principal) = self.store.principal_for(&event.endpoint).await? else { + info!(endpoint = %event.endpoint, "Unknown endpoint; ignoring (no principal linked)"); + return ack(msg).await; + }; + + let chat_id = event + .endpoint + .peer() + .parse::() + .context("telegram peer is not an i64 chat id")?; + + let now = now_unix(); + let (conversation_id, mut record) = match self.store.conversation_for(&event.endpoint).await? { + Some(found) => found, + None => { + // Routing policy, v1: every new conversation binds to the + // single configured agent. Sticky from here on. + let record = ConversationRecord { + principal: principal.clone(), + agent_id: AgentId::new(self.agent_id), + current_session: None, + created_at: now, + last_activity_at: now, + }; + let id = self.store.create_conversation(&event.endpoint, &record).await?; + info!(conversation = %id, endpoint = %event.endpoint, agent = %record.agent_id, "Created conversation"); + (id, record) + } + }; + + let mut active_session = match record.current_session.clone() { + Some(session) => session, + None => { + let session = self + .port + .create_session(&record) + .await + .map_err(|e| anyhow::anyhow!("create_session failed: {e}"))?; + record.current_session = Some(session.clone()); + self.store.update_conversation(&conversation_id, &record).await?; + session + } + }; + + let _ = self.outbound.typing(chat_id).await; + + let outcome = match self.port.prompt(&active_session, &event).await { + Ok(outcome) => outcome, + Err(first_error) => { + // Sessions are ephemeral and belong to the agent: repair the + // session in place, never re-run routing policy. + warn!(error = %first_error, session = %active_session, "Prompt failed; retrying with a fresh session"); + let fresh = self + .port + .create_session(&record) + .await + .map_err(|e| anyhow::anyhow!("create_session failed: {e}"))?; + record.current_session = Some(fresh.clone()); + self.store.update_conversation(&conversation_id, &record).await?; + active_session = fresh; + self.port + .prompt(&active_session, &event) + .await + .map_err(|e| anyhow::anyhow!("prompt retry failed: {e}"))? + } + }; + + record.last_activity_at = now_unix(); + self.store.update_conversation(&conversation_id, &record).await?; + + match self.renderer.take_buffer(active_session.as_str()) { + Some(text) => { + for chunk in chunk_text(&text, TEXT_CHUNK_LIMIT) { + self.outbound + .send_text(chat_id, chunk) + .await + .context("telegram send failed")?; + } + } + None => warn!(outcome = ?outcome, session = %active_session, "Agent turn produced no text"), + } + + ack(msg).await + } +} diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs new file mode 100644 index 0000000000..878ffc73ba --- /dev/null +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs @@ -0,0 +1,239 @@ +use super::*; +use crate::outbound::Outbound; +use acp_nats::ClientHandler; +use agent_client_protocol::schema::v1::{ + ContentBlock, ContentChunk, SessionNotification, SessionUpdate, TextContent, +}; +use futures::StreamExt; +use std::cell::RefCell; +use std::rc::Rc; +use testcontainers_modules::nats::{Nats, NatsServerCmd}; +use testcontainers_modules::testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner}; +use trogon_chat::store::PrincipalRecord; +use trogon_chat::{AgentSessionId, Endpoint, InboundChatEvent, PrincipalId, PromptOutcome}; + +struct NatsServer { + _container: ContainerAsync, + url: String, +} + +impl NatsServer { + async fn start() -> Self { + let cmd = NatsServerCmd::default().with_jetstream(); + let container = Nats::default() + .with_cmd(&cmd) + .start() + .await + .expect("start NATS testcontainer"); + let host = container.get_host().await.expect("get host"); + let port = container.get_host_port_ipv4(4222).await.expect("get port"); + Self { + _container: container, + url: format!("{host}:{port}"), + } + } +} + +#[derive(Debug, thiserror::Error)] +#[error("fake agent failure")] +struct FakeError; + +/// Simulates the agent side: mints sessions, records prompts, and streams a +/// reply into the renderer the way real session notifications would. +struct FakePort { + renderer: Rc, + reply: String, + sessions_created: RefCell, + prompted: RefCell>, +} + +impl trogon_chat::AgentPort for FakePort { + type Error = FakeError; + + async fn create_session( + &self, + _conversation: &trogon_chat::ConversationRecord, + ) -> Result { + *self.sessions_created.borrow_mut() += 1; + Ok(AgentSessionId::new(format!("sess-{}", self.sessions_created.borrow()))) + } + + async fn prompt( + &self, + session: &AgentSessionId, + event: &InboundChatEvent, + ) -> Result { + self.prompted + .borrow_mut() + .push((session.as_str().to_string(), event.text.clone().unwrap_or_default())); + let notification = SessionNotification::new( + session.as_str().to_string(), + SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(TextContent::new( + self.reply.clone(), + )))), + ); + self.renderer + .session_notification(notification) + .await + .expect("renderer accepts notification"); + Ok(PromptOutcome::Completed) + } + + async fn cancel(&self, _session: &AgentSessionId) -> Result<(), Self::Error> { + Ok(()) + } +} + +#[derive(Default)] +struct FakeOutbound { + typing: RefCell, + sent: RefCell>, +} + +impl Outbound for FakeOutbound { + async fn typing(&self, _chat_id: i64) -> anyhow::Result<()> { + *self.typing.borrow_mut() += 1; + Ok(()) + } + + async fn send_text(&self, chat_id: i64, text: String) -> anyhow::Result<()> { + self.sent.borrow_mut().push((chat_id, text)); + Ok(()) + } +} + +fn raw_update(update_id: u64, chat_id: i64, user_id: u64, text: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "update_id": update_id, + "message": { + "message_id": update_id, + "date": 1_700_000_000, + "chat": { "id": chat_id, "type": "private", "first_name": "Test" }, + "from": { "id": user_id, "is_bot": false, "first_name": "Test" }, + "text": text, + } + })) + .expect("serialize update") +} + +/// End to end against a real NATS: gateway-shaped raw updates in, identity +/// gate, conversation + session KV, prompt, rendered reply out. One container +/// for the whole scenario. +#[tokio::test] +async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { + let server = NatsServer::start().await; + let client = async_nats::connect(&server.url).await.expect("connect"); + let js = async_nats::jetstream::new(client); + + js.create_stream(async_nats::jetstream::stream::Config { + name: "TELEGRAM".to_string(), + subjects: vec!["telegram.>".to_string()], + ..Default::default() + }) + .await + .expect("create TELEGRAM stream"); + + let store = ChatStore::ensure(&js, "test").await.expect("ensure buckets"); + let principal = PrincipalId::new("telegram-42").expect("principal"); + let endpoint = Endpoint::new("telegram", "mybot", "42").expect("endpoint"); + store + .link_endpoint(&principal, &PrincipalRecord { display_name: None }, &endpoint) + .await + .expect("seed principal"); + + for (update_id, chat, user, text) in [ + (1u64, 99i64, 99u64, "intruder"), + (2, 42, 42, "hello"), + (3, 42, 42, "again"), + ] { + js.publish("telegram.message", raw_update(update_id, chat, user, text).into()) + .await + .expect("publish") + .await + .expect("ack"); + } + + let stream = js.get_stream("TELEGRAM").await.expect("get stream"); + let consumer = stream + .get_or_create_consumer( + "bridge-test", + async_nats::jetstream::consumer::pull::Config { + durable_name: Some("bridge-test".to_string()), + ..Default::default() + }, + ) + .await + .expect("consumer"); + let mut messages = consumer.messages().await.expect("messages"); + + let renderer = Rc::new(TelegramRenderClient::new()); + let port = FakePort { + renderer: renderer.clone(), + reply: "hi there".to_string(), + sessions_created: RefCell::new(0), + prompted: RefCell::new(Vec::new()), + }; + let outbound = FakeOutbound::default(); + let pipeline = Pipeline { + store: &store, + port: &port, + renderer: renderer.as_ref(), + outbound: &outbound, + bot_account: "mybot", + agent_id: "default", + }; + + for _ in 0..3 { + let msg = messages + .next() + .await + .expect("stream yields") + .expect("message received"); + pipeline.handle_message(&msg).await.expect("handled"); + } + + // The unauthorized endpoint never reached the agent and got no reply. + let intruder_endpoint = Endpoint::new("telegram", "mybot", "99").expect("endpoint"); + assert!( + store + .conversation_for(&intruder_endpoint) + .await + .expect("kv read") + .is_none() + ); + + // Both authorized messages flowed through one conversation and one session. + assert_eq!(*port.sessions_created.borrow(), 1); + assert_eq!( + *port.prompted.borrow(), + vec![ + ("sess-1".to_string(), "hello".to_string()), + ("sess-1".to_string(), "again".to_string()), + ] + ); + + let (_, record) = store + .conversation_for(&endpoint) + .await + .expect("kv read") + .expect("conversation exists"); + assert_eq!(record.principal, principal); + assert_eq!( + record.current_session.as_ref().map(AgentSessionId::as_str), + Some("sess-1") + ); + + assert_eq!(*outbound.typing.borrow(), 2); + assert_eq!( + *outbound.sent.borrow(), + vec![(42, "hi there".to_string()), (42, "hi there".to_string())] + ); + + // Everything acked: nothing left pending for redelivery. + let info = stream + .consumer_info("bridge-test") + .await + .expect("consumer info"); + assert_eq!(info.num_ack_pending, 0); + assert_eq!(info.num_pending, 0); +} diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/render.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/render.rs new file mode 100644 index 0000000000..e12c875ea8 --- /dev/null +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/render.rs @@ -0,0 +1,116 @@ +use acp_nats::ClientHandler; +use agent_client_protocol::schema::v1::{ + ContentBlock, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionNotification, + SessionUpdate, +}; +use std::collections::HashMap; +use std::sync::Mutex; +use tracing::{debug, warn}; + +/// The Telegram limit for a single message. +pub const TEXT_CHUNK_LIMIT: usize = 4096; + +/// The bridge's ACP client half: receives agent session notifications and +/// accumulates streamed text per session; the message loop flushes the buffer +/// to Telegram when the prompt turn ends. `ClientHandler` requires `Sync`, so +/// the per-session buffers use a `Mutex` (no lock is held across an await). +pub struct TelegramRenderClient { + buffers: Mutex>, +} + +impl TelegramRenderClient { + pub fn new() -> Self { + Self { + buffers: Mutex::new(HashMap::new()), + } + } + + /// Take the accumulated agent text for a session, if any. + pub fn take_buffer(&self, session_id: &str) -> Option { + self.buffers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(session_id) + .filter(|s| !s.trim().is_empty()) + } +} + +impl Default for TelegramRenderClient { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl ClientHandler for TelegramRenderClient { + async fn request_permission( + &self, + args: RequestPermissionRequest, + ) -> agent_client_protocol::Result { + // A chat channel has no interactive permission surface yet; refuse + // rather than silently grant. + warn!(session_id = %args.session_id, "Agent requested permission; cancelling (no permission UI on this channel)"); + Ok(RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled)) + } + + async fn session_notification(&self, args: SessionNotification) -> agent_client_protocol::Result<()> { + let session_id = args.session_id.to_string(); + match args.update { + SessionUpdate::AgentMessageChunk(chunk) => { + if let ContentBlock::Text(text) = chunk.content { + self.buffers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .entry(session_id) + .or_default() + .push_str(&text.text); + } + } + other => { + debug!(session_id = %session_id, update = ?std::mem::discriminant(&other), "Ignoring non-message session update"); + } + } + Ok(()) + } +} + +/// Split text at Telegram's message size limit on char boundaries. +pub fn chunk_text(text: &str, limit: usize) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut count = 0usize; + for ch in text.chars() { + if count == limit { + chunks.push(std::mem::take(&mut current)); + count = 0; + } + current.push(ch); + count += 1; + } + if !current.is_empty() { + chunks.push(current); + } + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chunk_text_splits_on_char_boundaries() { + let text = "ab".repeat(3000); + let chunks = chunk_text(&text, TEXT_CHUNK_LIMIT); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].chars().count(), TEXT_CHUNK_LIMIT); + assert_eq!(chunks[1].chars().count(), 6000 - TEXT_CHUNK_LIMIT); + } + + #[test] + fn chunk_text_handles_multibyte() { + let text = "\u{1F980}".repeat(10); + let chunks = chunk_text(&text, 4); + assert_eq!(chunks.len(), 3); + assert!(chunks.iter().all(|c| c.chars().count() <= 4)); + } +} diff --git a/rsworkspace/crates/chat/trogon-chat/Cargo.toml b/rsworkspace/crates/chat/trogon-chat/Cargo.toml new file mode 100644 index 0000000000..4223d2c44c --- /dev/null +++ b/rsworkspace/crates/chat/trogon-chat/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "trogon-chat" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +async-nats = { workspace = true, features = ["jetstream", "kv"] } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } diff --git a/rsworkspace/crates/chat/trogon-chat/src/agent_port.rs b/rsworkspace/crates/chat/trogon-chat/src/agent_port.rs new file mode 100644 index 0000000000..485896b534 --- /dev/null +++ b/rsworkspace/crates/chat/trogon-chat/src/agent_port.rs @@ -0,0 +1,56 @@ +use crate::conversation::ConversationRecord; +use crate::event::InboundChatEvent; +use serde::{Deserialize, Serialize}; + +/// An agent-side session handle. Opaque to everything except the port +/// implementation that minted it: only meaningful at the agent it belongs to, +/// which is why a conversation stores it next to (never instead of) the +/// agent binding. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct AgentSessionId(String); + +impl AgentSessionId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for AgentSessionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// How a prompt turn ended, protocol-neutral. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptOutcome { + Completed, + Cancelled, + Refused, + /// The turn ended for a protocol-specific reason the bridge treats as + /// completed-with-caveats (e.g. token or turn limits). + Truncated, +} + +/// The one seam between chat routing and agent protocols. One implementation +/// per protocol (ACP first; A2A and HTTP later); the implementation owns all +/// protocol specifics including how streamed agent output reaches the +/// renderer. Deliberately not a NATS namespace: protocol-neutral agent +/// addressability already exists per protocol (see the architecture doc). +#[allow(async_fn_in_trait)] +pub trait AgentPort { + type Error: std::error::Error + 'static; + + /// Create a fresh session for a conversation on its bound agent. + async fn create_session(&self, conversation: &ConversationRecord) -> Result; + + /// Send one inbound event as a prompt and wait for the turn to end. + /// Streamed output is delivered out-of-band by the implementation. + async fn prompt(&self, session: &AgentSessionId, event: &InboundChatEvent) -> Result; + + async fn cancel(&self, session: &AgentSessionId) -> Result<(), Self::Error>; +} diff --git a/rsworkspace/crates/chat/trogon-chat/src/conversation.rs b/rsworkspace/crates/chat/trogon-chat/src/conversation.rs new file mode 100644 index 0000000000..ec6a4d0645 --- /dev/null +++ b/rsworkspace/crates/chat/trogon-chat/src/conversation.rs @@ -0,0 +1,61 @@ +use crate::agent_port::AgentSessionId; +use crate::endpoint::PrincipalId; +use serde::{Deserialize, Serialize}; + +/// Which configured agent a conversation is bound to. Resolution from id to +/// protocol + address is bridge/router configuration, never stored here. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct AgentId(String); + +impl AgentId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for AgentId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ConversationId(String); + +impl ConversationId { + pub fn generate() -> Self { + Self(uuid::Uuid::new_v4().simple().to_string()) + } + + pub fn from_string(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ConversationId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// The durable half of a conversation. The agent binding is sticky (set once +/// by routing policy at creation, changed only by explicit rebind); the +/// session is ephemeral and belongs to the agent, replaced freely without +/// re-running policy. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationRecord { + pub principal: PrincipalId, + pub agent_id: AgentId, + pub current_session: Option, + /// Unix seconds; supplied by the caller (this crate takes no clock). + pub created_at: i64, + pub last_activity_at: i64, +} diff --git a/rsworkspace/crates/chat/trogon-chat/src/endpoint.rs b/rsworkspace/crates/chat/trogon-chat/src/endpoint.rs new file mode 100644 index 0000000000..9f9a134d88 --- /dev/null +++ b/rsworkspace/crates/chat/trogon-chat/src/endpoint.rs @@ -0,0 +1,111 @@ +use serde::{Deserialize, Serialize}; + +/// Characters permitted in endpoint tokens: the intersection of what NATS KV +/// keys accept and what NATS subject tokens accept, so an endpoint can address +/// both a KV entry and (after extraction) a subject without re-encoding. +fn is_safe_token(token: &str) -> bool { + !token.is_empty() + && token + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '=')) +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum EndpointError { + #[error("endpoint token {0:?} is empty or contains unsafe characters")] + UnsafeToken(String), +} + +/// Where a message arrives and leaves: a platform, a bot account on it, and a +/// chat/user on that platform. Many endpoints can point at one conversation. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Endpoint { + channel: String, + account: String, + peer: String, +} + +impl Endpoint { + pub fn new( + channel: impl Into, + account: impl Into, + peer: impl Into, + ) -> Result { + let channel = channel.into(); + let account = account.into(); + let peer = peer.into(); + for token in [&channel, &account, &peer] { + if !is_safe_token(token) { + return Err(EndpointError::UnsafeToken(token.clone())); + } + } + Ok(Self { channel, account, peer }) + } + + pub fn channel(&self) -> &str { + &self.channel + } + + pub fn account(&self) -> &str { + &self.account + } + + pub fn peer(&self) -> &str { + &self.peer + } + + /// Stable KV key for this endpoint (also a valid subject suffix). + pub fn kv_key(&self) -> String { + format!("{}.{}.{}", self.channel, self.account, self.peer) + } +} + +impl std::fmt::Display for Endpoint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.kv_key()) + } +} + +/// The human behind one or more endpoints. Cross-channel by design: linking a +/// Telegram user and a Discord user to the same principal is what lets one +/// conversation continue across channels. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PrincipalId(String); + +impl PrincipalId { + pub fn new(id: impl Into) -> Result { + let id = id.into(); + if !is_safe_token(&id) { + return Err(EndpointError::UnsafeToken(id)); + } + Ok(Self(id)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for PrincipalId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_accepts_negative_telegram_chat_ids() { + let e = Endpoint::new("telegram", "mybot", "-1001234567890").expect("valid"); + assert_eq!(e.kv_key(), "telegram.mybot.-1001234567890"); + } + + #[test] + fn endpoint_rejects_unsafe_tokens() { + assert!(Endpoint::new("telegram", "my bot", "1").is_err()); + assert!(Endpoint::new("", "mybot", "1").is_err()); + assert!(Endpoint::new("telegram", "mybot", "a.b").is_err()); + } +} diff --git a/rsworkspace/crates/chat/trogon-chat/src/event.rs b/rsworkspace/crates/chat/trogon-chat/src/event.rs new file mode 100644 index 0000000000..c3aea7e4a3 --- /dev/null +++ b/rsworkspace/crates/chat/trogon-chat/src/event.rs @@ -0,0 +1,37 @@ +use crate::endpoint::Endpoint; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sender { + pub platform_user_id: String, + pub display_name: String, +} + +/// Media that arrived with a message, already claim-checked: the bytes live in +/// the object store and `object_ref` points at them. `platform_ref` keeps the +/// platform's own handle (e.g. a Telegram `file_id`) for provenance. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Attachment { + pub kind: String, + pub mime: String, + pub size: u64, + pub object_ref: String, + pub platform_ref: String, +} + +/// A normalized inbound message: what any channel bridge produces after +/// stripping its platform's shape. This type is the `chat.*.in.*` payload +/// once the multi-channel extraction happens; until then it travels +/// in-process. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InboundChatEvent { + pub endpoint: Endpoint, + pub sender: Sender, + pub text: Option, + #[serde(default)] + pub attachments: Vec, + /// Platform message identity, for dedup, replies, and edits. + pub message_ref: String, + /// Unix seconds, as reported by the platform. + pub occurred_at: i64, +} diff --git a/rsworkspace/crates/chat/trogon-chat/src/lib.rs b/rsworkspace/crates/chat/trogon-chat/src/lib.rs new file mode 100644 index 0000000000..d27eb1a28a --- /dev/null +++ b/rsworkspace/crates/chat/trogon-chat/src/lib.rs @@ -0,0 +1,24 @@ +//! Channel-neutral chat domain: the shared brain every channel bridge imports. +//! +//! See `docs/architecture/multi-channel-agent-routing.md`. This crate owns the +//! vocabulary (endpoints, principals, conversations, inbound events, render +//! commands), the JetStream KV registries, and the [`AgentPort`] trait through +//! which bridges reach agents. Channel binaries (e.g. `chat-bridge-telegram`) +//! contain platform I/O only; nothing in this crate may reference a specific +//! platform or agent protocol. + +#![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))] + +pub mod agent_port; +pub mod conversation; +pub mod endpoint; +pub mod event; +pub mod render; +pub mod store; + +pub use agent_port::{AgentPort, AgentSessionId, PromptOutcome}; +pub use conversation::{AgentId, ConversationId, ConversationRecord}; +pub use endpoint::{Endpoint, EndpointError, PrincipalId}; +pub use event::{Attachment, InboundChatEvent, Sender}; +pub use render::RenderCommand; +pub use store::{ChatStore, ChatStoreError}; diff --git a/rsworkspace/crates/chat/trogon-chat/src/render.rs b/rsworkspace/crates/chat/trogon-chat/src/render.rs new file mode 100644 index 0000000000..92782f3cc5 --- /dev/null +++ b/rsworkspace/crates/chat/trogon-chat/src/render.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +/// The channel-neutral output vocabulary: the one contract every channel +/// bridge implements. Kept deliberately small; platform-specific richness +/// (e.g. Telegram inline buttons) rides agent `_meta` and is rendered by the +/// bridge that understands it. This enum is the `chat.*.out.*` payload once +/// the multi-channel extraction happens. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "snake_case")] +pub enum RenderCommand { + SendText { + text: String, + }, + /// Streaming preview: replace the text of a previously sent message. + EditText { + message_ref: String, + text: String, + }, + SendAttachment { + object_ref: String, + mime: String, + }, + Typing, + React { + message_ref: String, + emoji: String, + }, +} diff --git a/rsworkspace/crates/chat/trogon-chat/src/store.rs b/rsworkspace/crates/chat/trogon-chat/src/store.rs new file mode 100644 index 0000000000..df31984d6a --- /dev/null +++ b/rsworkspace/crates/chat/trogon-chat/src/store.rs @@ -0,0 +1,138 @@ +use crate::conversation::{ConversationId, ConversationRecord}; +use crate::endpoint::{Endpoint, PrincipalId}; +use async_nats::jetstream; +use serde::{Deserialize, Serialize}; +use tracing::info; + +#[derive(Debug, thiserror::Error)] +pub enum ChatStoreError { + #[error("failed to create KV bucket {bucket}: {source}")] + CreateBucket { + bucket: String, + #[source] + source: async_nats::jetstream::context::CreateKeyValueError, + }, + #[error("KV read failed: {0}")] + Read(#[from] async_nats::jetstream::kv::EntryError), + #[error("KV write failed: {0}")] + Write(#[from] async_nats::jetstream::kv::PutError), + #[error("stored record is not valid JSON: {0}")] + Decode(#[from] serde_json::Error), +} + +/// What we know about a principal beyond its id. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrincipalRecord { + pub display_name: Option, +} + +/// The four registries behind conversations, all JetStream KV, all owned by +/// exactly one worker (the bridge today, the router after extraction). Config +/// files never hold this state; the admin surface that seeds/mutates it is +/// out of band by design. +pub struct ChatStore { + principals: jetstream::kv::Store, + endpoints: jetstream::kv::Store, + bindings: jetstream::kv::Store, + conversations: jetstream::kv::Store, +} + +async fn ensure_bucket( + js: &jetstream::Context, + bucket: String, +) -> Result { + if let Ok(store) = js.get_key_value(&bucket).await { + return Ok(store); + } + info!(bucket = %bucket, "Creating chat KV bucket"); + js.create_key_value(jetstream::kv::Config { + bucket: bucket.clone(), + history: 5, + storage: jetstream::stream::StorageType::File, + ..Default::default() + }) + .await + .map_err(|source| ChatStoreError::CreateBucket { bucket, source }) +} + +impl ChatStore { + pub async fn ensure(js: &jetstream::Context, prefix: &str) -> Result { + Ok(Self { + principals: ensure_bucket(js, format!("chat_principals_{prefix}")).await?, + endpoints: ensure_bucket(js, format!("chat_endpoints_{prefix}")).await?, + bindings: ensure_bucket(js, format!("chat_bindings_{prefix}")).await?, + conversations: ensure_bucket(js, format!("chat_conversations_{prefix}")).await?, + }) + } + + /// Identity: which principal owns this endpoint. `None` means the + /// endpoint is unknown and the bridge must reject the message; this is + /// the access-control mechanism. + pub async fn principal_for(&self, endpoint: &Endpoint) -> Result, ChatStoreError> { + match self.endpoints.get(endpoint.kv_key()).await? { + Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)), + None => Ok(None), + } + } + + /// Register a principal and map an endpoint to it (idempotent). + pub async fn link_endpoint( + &self, + principal: &PrincipalId, + record: &PrincipalRecord, + endpoint: &Endpoint, + ) -> Result<(), ChatStoreError> { + self.principals + .put(principal.as_str(), serde_json::to_vec(record)?.into()) + .await?; + self.endpoints + .put(endpoint.kv_key(), serde_json::to_vec(principal)?.into()) + .await?; + Ok(()) + } + + /// Binding: which conversation this endpoint currently feeds. + pub async fn conversation_for( + &self, + endpoint: &Endpoint, + ) -> Result, ChatStoreError> { + let Some(bytes) = self.bindings.get(endpoint.kv_key()).await? else { + return Ok(None); + }; + let id: ConversationId = serde_json::from_slice(&bytes)?; + match self.conversations.get(id.as_str()).await? { + Some(bytes) => Ok(Some((id.clone(), serde_json::from_slice(&bytes)?))), + None => Ok(None), + } + } + + /// Create a conversation and bind an endpoint to it. Routing policy runs + /// before this call (it decided `record.agent_id`); after it, the binding + /// is sticky. + pub async fn create_conversation( + &self, + endpoint: &Endpoint, + record: &ConversationRecord, + ) -> Result { + let id = ConversationId::generate(); + self.conversations + .put(id.as_str(), serde_json::to_vec(record)?.into()) + .await?; + self.bindings + .put(endpoint.kv_key(), serde_json::to_vec(&id)?.into()) + .await?; + Ok(id) + } + + /// Update a conversation record in place (session replacement, activity). + pub async fn update_conversation( + &self, + id: &ConversationId, + record: &ConversationRecord, + ) -> Result<(), ChatStoreError> { + self.conversations + .put(id.as_str(), serde_json::to_vec(record)?.into()) + .await?; + Ok(()) + } +} diff --git a/rsworkspace/crates/platform/trogon-telemetry/src/service_name.rs b/rsworkspace/crates/platform/trogon-telemetry/src/service_name.rs index f53a837750..961ee3efea 100644 --- a/rsworkspace/crates/platform/trogon-telemetry/src/service_name.rs +++ b/rsworkspace/crates/platform/trogon-telemetry/src/service_name.rs @@ -14,6 +14,7 @@ pub enum ServiceName { TrogonSourceLinear, TrogonSourceSlack, TrogonSourceTelegram, + ChatBridgeTelegram, } impl ServiceName { @@ -30,6 +31,7 @@ impl ServiceName { Self::TrogonSourceLinear => "trogon-source-linear", Self::TrogonSourceSlack => "trogon-source-slack", Self::TrogonSourceTelegram => "trogon-source-telegram", + Self::ChatBridgeTelegram => "chat-bridge-telegram", } } } From 4f77567465ccfc0ceaebbf99d41ae13ca91814d9 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 2 Aug 2026 00:24:22 -0400 Subject: [PATCH 02/55] feat(chat): let a chat surface reset its agent session A conversation outlives the ephemeral session behind it, so a user needs a way to abandon accumulated context without losing their binding to an agent. Rotating on any prompt failure also discarded live conversations that were merely unreachable for a moment. Signed-off-by: Yordis Prieto --- devops/docker/compose/compose.yml | 1 + .../multi-channel-agent-routing.md | 22 +++ .../chat/chat-bridge-telegram/src/acp_port.rs | 168 +++++++++++++++++- .../chat/chat-bridge-telegram/src/config.rs | 25 ++- .../chat/chat-bridge-telegram/src/main.rs | 23 +-- .../chat/chat-bridge-telegram/src/parse.rs | 22 ++- .../chat/chat-bridge-telegram/src/pipeline.rs | 90 +++++++++- .../src/pipeline_tests.rs | 75 +++++--- .../chat/chat-bridge-telegram/src/render.rs | 10 ++ .../crates/chat/trogon-chat/src/agent_port.rs | 45 ++++- .../crates/chat/trogon-chat/src/command.rs | 157 ++++++++++++++++ .../crates/chat/trogon-chat/src/event.rs | 8 + .../crates/chat/trogon-chat/src/lib.rs | 6 +- .../crates/chat/trogon-chat/src/store.rs | 5 +- 14 files changed, 593 insertions(+), 64 deletions(-) create mode 100644 rsworkspace/crates/chat/trogon-chat/src/command.rs diff --git a/devops/docker/compose/compose.yml b/devops/docker/compose/compose.yml index 98cf22f3a7..d216ea84ac 100644 --- a/devops/docker/compose/compose.yml +++ b/devops/docker/compose/compose.yml @@ -59,6 +59,7 @@ services: TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}" CHAT_SEED_TELEGRAM_USERS: "${CHAT_SEED_TELEGRAM_USERS:-}" CHAT_PREFIX: "${CHAT_PREFIX:-prod}" + CHAT_NEW_SESSION_TRIGGERS: "${CHAT_NEW_SESSION_TRIGGERS:-/new,/reset}" TELEGRAM_INBOUND_STREAM: "${TELEGRAM_INBOUND_STREAM:-TELEGRAM}" ACP_PREFIX: "${ACP_PREFIX:-acp}" NATS_URL: "nats:4222" diff --git a/docs/architecture/multi-channel-agent-routing.md b/docs/architecture/multi-channel-agent-routing.md index a86050be9d..0c0f59a2d7 100644 --- a/docs/architecture/multi-channel-agent-routing.md +++ b/docs/architecture/multi-channel-agent-routing.md @@ -149,12 +149,22 @@ extraction): endpoint: { channel, account, peer }, sender: { platform_user_id, display_name }, text: string | null, + command: bridge command | null, attachments: [ { kind, mime, size, object_ref, platform_ref } ], message_ref: platform message id (for dedup, replies, edits), occurred_at: timestamp } ``` +**Commands are extracted at the channel edge and never forwarded.** A trigger +(`/new`, `/reset`, configurable) counts only as the whole first token of a +message; anything after it stays in `text` and becomes the first prompt of +whatever the command sets up. Leading-slash vocabulary is a channel affordance, +so the bridge owns its own control words regardless of what the agent behind it +happens to advertise. A destructive command additionally authorizes the sender's +own endpoint rather than the conversation's, since a group chat is one endpoint +shared by everyone in it. + **Render commands** (a Rust enum in v1; the `chat.*.out.*` payload after extraction): @@ -187,8 +197,20 @@ AgentPort: create_session / resume_session prompt(session, content) -> stream of agent events cancel(session) + release_session(session, reason) -> report ``` +- `release_session` is infallible by signature. The conversation drops its + pointer to the session and persists that *before* the agent is told, so a + crash mid-reset orphans an agent session (recoverable) instead of resurrecting + one the user asked to be rid of. Each step is capability-gated and best + effort; an agent that cannot release must never wedge the conversation it was + released from. Releasing is not deleting: the bridge is done with the session, + which is not the same as the user asking for its history to be destroyed. +- Prompt failures rotate the session only when the agent says it does not have + it. Timeouts and transport errors redeliver instead, because rotating on those + discards a conversation that was merely unreachable for a moment. + - v1 ships exactly one implementation: ACP, using the existing `acp-nats` client machinery. A2A and HTTP become additional implementations later. - The agent registry is config: `agent_id -> { protocol, address }` (for ACP: diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs index eaab46fe7b..55d8b6c6ef 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs @@ -1,10 +1,16 @@ use acp_nats::AgentHandler; +use agent_client_protocol::ErrorCode; use agent_client_protocol::schema::v1::{ - CancelNotification, ContentBlock, NewSessionRequest, PromptRequest, StopReason, TextContent, + CancelNotification, CloseSessionRequest, ContentBlock, InitializeResponse, NewSessionRequest, PromptRequest, + StopReason, TextContent, }; use std::path::PathBuf; use std::sync::Arc; -use trogon_chat::{AgentPort, AgentSessionId, ConversationRecord, InboundChatEvent, PromptOutcome}; +use tracing::{info, warn}; +use trogon_chat::{ + AgentPort, AgentPortError, AgentSessionId, ConversationRecord, InboundChatEvent, PromptOutcome, ReleaseReason, + ReleaseStep, SessionRelease, +}; pub type AcpBridge = acp_nats::Bridge; @@ -15,6 +21,115 @@ pub enum AcpPortError { Rpc(agent_client_protocol::Error), } +impl AgentPortError for AcpPortError { + fn is_session_lost(&self) -> bool { + // The acp-nats bridge maps every transport failure and timeout to + // `InternalError` and passes agent-returned errors through untouched, + // so only the codes an agent uses to reject an unknown session id mean + // the session is actually gone. + match self { + Self::Rpc(error) => matches!(error.code, ErrorCode::InvalidParams | ErrorCode::ResourceNotFound), + } + } +} + +/// A session lifecycle method whose availability an agent declares at +/// initialize. ACP requires the client to check before calling one, so nothing +/// in this port reaches for a lifecycle method without asking +/// [`SessionMethods`] first. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionMethod { + Load, + List, + Delete, + Resume, + Close, + AdditionalDirectories, +} + +impl SessionMethod { + const ALL: [Self; 6] = [ + Self::Load, + Self::List, + Self::Delete, + Self::Resume, + Self::Close, + Self::AdditionalDirectories, + ]; + + fn wire_name(self) -> &'static str { + match self { + Self::Load => "session/load", + Self::List => "session/list", + Self::Delete => "session/delete", + Self::Resume => "session/resume", + Self::Close => "session/close", + Self::AdditionalDirectories => "additionalDirectories", + } + } +} + +/// What the agent said it can do with sessions, captured once from the +/// `initialize` response. Every capability in ACP is present-means-supported, +/// which is easy to invert by accident; reading them through one value object +/// keeps that decision in a single place. +#[derive(Debug, Clone, Copy, Default)] +pub struct SessionMethods { + load: bool, + list: bool, + delete: bool, + resume: bool, + close: bool, + additional_directories: bool, +} + +impl SessionMethods { + #[must_use] + pub fn advertised(response: &InitializeResponse) -> Self { + let sessions = &response.agent_capabilities.session_capabilities; + Self { + load: response.agent_capabilities.load_session, + list: sessions.list.is_some(), + delete: sessions.delete.is_some(), + resume: sessions.resume.is_some(), + close: sessions.close.is_some(), + additional_directories: sessions.additional_directories.is_some(), + } + } + + #[must_use] + pub fn supports(self, method: SessionMethod) -> bool { + match method { + SessionMethod::Load => self.load, + SessionMethod::List => self.list, + SessionMethod::Delete => self.delete, + SessionMethod::Resume => self.resume, + SessionMethod::Close => self.close, + SessionMethod::AdditionalDirectories => self.additional_directories, + } + } +} + +impl std::fmt::Display for SessionMethods { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut first = true; + for method in SessionMethod::ALL { + if !self.supports(method) { + continue; + } + if !first { + f.write_str(", ")?; + } + f.write_str(method.wire_name())?; + first = false; + } + if first { + f.write_str("none")?; + } + Ok(()) + } +} + /// The ACP implementation of [`AgentPort`]: forwards session/prompt calls /// through the acp-nats Bridge. Streamed agent output does not come back /// through this port; it arrives at the bridge's ACP client half @@ -22,11 +137,16 @@ pub enum AcpPortError { pub struct AcpPort { bridge: Arc, agent_cwd: PathBuf, + methods: SessionMethods, } impl AcpPort { - pub fn new(bridge: Arc, agent_cwd: PathBuf) -> Self { - Self { bridge, agent_cwd } + pub fn new(bridge: Arc, agent_cwd: PathBuf, methods: SessionMethods) -> Self { + Self { + bridge, + agent_cwd, + methods, + } } } @@ -35,10 +155,7 @@ impl AcpPort { /// text reaches the model. fn prompt_text(event: &InboundChatEvent) -> String { let body = event.text.as_deref().unwrap_or_default(); - format!( - "[telegram message from {}]\n{}", - event.sender.display_name, body - ) + format!("[telegram message from {}]\n{}", event.sender.display_name, body) } /// Structured twin of the context prefix, for agents that opt into reading @@ -95,4 +212,39 @@ impl AgentPort for AcpPort { .await .map_err(AcpPortError::Rpc) } + + /// Stop the turn first, then hand the session back. Cancel before close so + /// an agent mid-turn is told to wind down rather than having the session + /// pulled from under it; both steps are bounded by the bridge's operation + /// timeout and neither can fail the release. Deleting is deliberately not + /// part of this: the bridge is done with the session, which is not the same + /// as the user asking for its history to be destroyed. + async fn release_session(&self, session: &AgentSessionId, reason: ReleaseReason) -> SessionRelease { + let cancelled = match self.cancel(session).await { + Ok(()) => ReleaseStep::Done, + Err(error) => { + warn!(session = %session, reason = ?reason, error = %error, "Cancel failed while releasing session"); + ReleaseStep::Failed + } + }; + + let closed = if self.methods.supports(SessionMethod::Close) { + match self + .bridge + .close_session(CloseSessionRequest::new(session.as_str().to_string())) + .await + { + Ok(_) => ReleaseStep::Done, + Err(error) => { + warn!(session = %session, reason = ?reason, error = %error, "Close failed while releasing session"); + ReleaseStep::Failed + } + } + } else { + info!(session = %session, "Agent does not advertise session/close; releasing without it"); + ReleaseStep::Unsupported + }; + + SessionRelease { cancelled, closed } + } } diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs index 4a983bd6ee..bbec70d2ed 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs @@ -1,6 +1,7 @@ use acp_nats::{AcpPrefix, NatsConfig}; use anyhow::Context; use std::path::PathBuf; +use trogon_chat::CommandTriggers; use trogon_std::env::ReadEnv; pub struct BridgeConfig { @@ -20,21 +21,21 @@ pub struct BridgeConfig { /// Telegram user ids seeded as principals at startup. Bootstrap only; /// ongoing administration mutates the KV buckets out of band. pub seed_users: Vec, + /// Message text the bridge answers to itself. Configurable so a deployment + /// can move out of the way of an agent that advertises the same triggers, + /// or set none at all to forward everything. + pub command_triggers: CommandTriggers, } impl BridgeConfig { pub fn from_env(env: &E) -> anyhow::Result { - let bot_token = env - .var("TELEGRAM_BOT_TOKEN") - .context("TELEGRAM_BOT_TOKEN not set")?; + let bot_token = env.var("TELEGRAM_BOT_TOKEN").context("TELEGRAM_BOT_TOKEN not set")?; let chat_prefix = env.var("CHAT_PREFIX").unwrap_or_else(|_| "prod".to_string()); let inbound_stream = env .var("TELEGRAM_INBOUND_STREAM") .unwrap_or_else(|_| "TELEGRAM".to_string()); - let bot_account = env - .var("TELEGRAM_BOT_ACCOUNT") - .unwrap_or_else(|_| "bot".to_string()); + let bot_account = env.var("TELEGRAM_BOT_ACCOUNT").unwrap_or_else(|_| "bot".to_string()); let agent_id = env.var("CHAT_AGENT_ID").unwrap_or_else(|_| "default".to_string()); let agent_cwd = env .var("CHAT_AGENT_CWD") @@ -54,6 +55,17 @@ impl BridgeConfig { Err(_) => Vec::new(), }; + let command_triggers = match env.var("CHAT_NEW_SESSION_TRIGGERS") { + Ok(raw) => CommandTriggers::new( + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from), + ) + .context("invalid CHAT_NEW_SESSION_TRIGGERS")?, + Err(_) => CommandTriggers::default(), + }; + let raw_prefix = env .var(acp_nats::ENV_ACP_PREFIX) .unwrap_or_else(|_| acp_nats::DEFAULT_ACP_PREFIX.to_string()); @@ -69,6 +81,7 @@ impl BridgeConfig { agent_id, agent_cwd, seed_users, + command_triggers, }) } } diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs index de6893f8df..5f4637f575 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs @@ -16,7 +16,7 @@ mod pipeline; mod render; use acp_nats::{AgentHandler, ClientHandler}; -use acp_port::{AcpBridge, AcpPort}; +use acp_port::{AcpBridge, AcpPort, SessionMethods}; use agent_client_protocol::schema::ProtocolVersion; use agent_client_protocol::schema::v1::InitializeRequest; use anyhow::Context as _; @@ -25,7 +25,6 @@ use futures::StreamExt; use outbound::TelegramOutbound; use pipeline::Pipeline; use render::TelegramRenderClient; -use std::rc::Rc; use std::sync::Arc; use teloxide::Bot; use tracing::{error, info, warn}; @@ -76,9 +75,7 @@ async fn main() -> anyhow::Result<()> { let bot = Bot::new(config.bot_token.clone()); let local = tokio::task::LocalSet::new(); - let result = local - .run_until(run(nats_client, store, messages, bot, config)) - .await; + let result = local.run_until(run(nats_client, store, messages, bot, config)).await; if let Err(e) = trogon_telemetry::shutdown_otel() { error!(error = %e, "OpenTelemetry shutdown failed"); @@ -116,7 +113,7 @@ async fn run( config.acp.clone(), notification_tx, )); - let renderer = Rc::new(TelegramRenderClient::new()); + let renderer = Arc::new(TelegramRenderClient::new()); let client_task = tokio::task::spawn_local(acp_nats::client::run( nats_client.clone(), @@ -132,13 +129,18 @@ async fn run( } }); - bridge + let initialized = bridge .initialize(InitializeRequest::new(ProtocolVersion::LATEST)) .await .map_err(|e| anyhow::anyhow!("ACP initialize failed: {e}"))?; - info!("ACP agent initialized; consuming inbound updates"); - - let port = AcpPort::new(bridge.clone(), config.agent_cwd.clone()); + let session_methods = SessionMethods::advertised(&initialized); + info!( + protocol = %initialized.protocol_version, + session_methods = %session_methods, + "ACP agent initialized; consuming inbound updates" + ); + + let port = AcpPort::new(bridge.clone(), config.agent_cwd.clone(), session_methods); let telegram = TelegramOutbound::new(bot); let pipeline = Pipeline { store: &store, @@ -147,6 +149,7 @@ async fn run( outbound: &telegram, bot_account: &config.bot_account, agent_id: &config.agent_id, + triggers: &config.command_triggers, }; let shutdown = shutdown_signal(); diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs index 49c7407f34..9de15faf9b 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs @@ -1,10 +1,10 @@ use teloxide::types::{Update, UpdateKind}; -use trogon_chat::{Endpoint, InboundChatEvent, Sender}; +use trogon_chat::{CommandTriggers, Endpoint, InboundChatEvent, Sender}; /// Normalize a raw Telegram update into the channel-neutral event, or `None` /// for update kinds v1 does not carry (media, edits, membership, ...). The /// raw stream retains those with full fidelity for later. -pub fn inbound_event(update: &Update, bot_account: &str) -> Option { +pub fn inbound_event(update: &Update, bot_account: &str, triggers: &CommandTriggers) -> Option { let UpdateKind::Message(msg) = &update.kind else { return None; }; @@ -19,15 +19,31 @@ pub fn inbound_event(update: &Update, bot_account: &str) -> Option Option { + match Endpoint::new("telegram", bot_account, sender.platform_user_id.clone()) { + Ok(endpoint) => Some(endpoint), + Err(e) => { + tracing::warn!(error = %e, user_id = %sender.platform_user_id, "Sender has an unencodable endpoint"); + None + } + } +} diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs index af2a1cc772..7549f99820 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs @@ -7,7 +7,15 @@ use crate::parse; use crate::render::{TEXT_CHUNK_LIMIT, TelegramRenderClient, chunk_text}; use anyhow::Context as _; use tracing::{info, warn}; -use trogon_chat::{AgentId, AgentPort, ChatStore, ConversationRecord}; +use trogon_chat::{ + AgentId, AgentPort, AgentPortError as _, ChatCommand, ChatStore, CommandTriggers, ConversationId, + ConversationRecord, InboundChatEvent, ReleaseReason, +}; + +/// What the bridge says back when a command has nothing else to do. A reset +/// with no follow-up prompt produces no agent output, so without this the user +/// gets silence. +const NEW_SESSION_ACKNOWLEDGEMENT: &str = "Started a new session."; pub struct Pipeline<'a, P, O> { pub store: &'a ChatStore, @@ -16,6 +24,7 @@ pub struct Pipeline<'a, P, O> { pub outbound: &'a O, pub bot_account: &'a str, pub agent_id: &'a str, + pub triggers: &'a CommandTriggers, } fn now_unix() -> i64 { @@ -29,6 +38,44 @@ async fn ack(msg: &async_nats::jetstream::Message) -> anyhow::Result<()> { } impl Pipeline<'_, P, O> { + /// Whether the individual who sent this message is a known principal. The + /// conversation gate authorizes the chat, which in a group is everyone in + /// it; destructive commands ask the narrower question. + async fn sender_is_authorized(&self, event: &InboundChatEvent) -> anyhow::Result { + let Some(endpoint) = parse::sender_endpoint(self.bot_account, &event.sender) else { + return Ok(false); + }; + Ok(self.store.principal_for(&endpoint).await?.is_some()) + } + + /// Drop the conversation's pointer to its session, then tell the agent it + /// can let go. In that order: a crash in between leaves an orphaned agent + /// session, which costs the agent some memory, where the reverse order + /// resurrects a session the user just asked to be rid of. Redelivery is + /// safe for the same reason, since the pointer is already gone. + async fn release_current_session( + &self, + conversation_id: &ConversationId, + record: &mut ConversationRecord, + ) -> anyhow::Result<()> { + let Some(session) = record.current_session.take() else { + return Ok(()); + }; + record.last_activity_at = now_unix(); + self.store.update_conversation(conversation_id, record).await?; + self.renderer.discard(session.as_str()); + + let release = self.port.release_session(&session, ReleaseReason::NewSession).await; + info!( + conversation = %conversation_id, + session = %session, + cancelled = ?release.cancelled, + closed = ?release.closed, + "Released session" + ); + Ok(()) + } + /// Process one raw gateway message end to end. Unrecoverable messages /// (unparseable, unauthorized, kinds v1 does not carry) are acked and /// dropped; processing errors return `Err` with the message unacked so @@ -42,7 +89,7 @@ impl Pipeline<'_, P, O> { } }; - let Some(event) = parse::inbound_event(&update, self.bot_account) else { + let Some(event) = parse::inbound_event(&update, self.bot_account, self.triggers) else { return ack(msg).await; }; @@ -76,6 +123,32 @@ impl Pipeline<'_, P, O> { } }; + if event.command == Some(ChatCommand::NewSession) { + if self.sender_is_authorized(&event).await? { + self.release_current_session(&conversation_id, &mut record).await?; + if event.text.is_none() { + self.outbound + .send_text(chat_id, NEW_SESSION_ACKNOWLEDGEMENT.to_string()) + .await + .context("telegram send failed")?; + return ack(msg).await; + } + } else { + // The command is refused, not the message. Whatever followed + // the trigger is still the user talking to the agent, and the + // trigger itself is never forwarded. + warn!( + conversation = %conversation_id, + sender = %event.sender.platform_user_id, + "Sender is not a linked principal; ignoring new-session command" + ); + } + } + + if event.text.is_none() { + return ack(msg).await; + } + let mut active_session = match record.current_session.clone() { Some(session) => session, None => { @@ -94,10 +167,13 @@ impl Pipeline<'_, P, O> { let outcome = match self.port.prompt(&active_session, &event).await { Ok(outcome) => outcome, - Err(first_error) => { - // Sessions are ephemeral and belong to the agent: repair the - // session in place, never re-run routing policy. - warn!(error = %first_error, session = %active_session, "Prompt failed; retrying with a fresh session"); + // Only a session the agent no longer has is repaired here, and + // repaired in place: sessions are ephemeral and belong to the + // agent, so routing policy never re-runs. Every other failure is + // left to redelivery, because rotating on a timeout or a transport + // blip would throw away a conversation that was merely unreachable. + Err(first_error) if first_error.is_session_lost() => { + warn!(error = %first_error, session = %active_session, "Agent no longer has the session; retrying with a fresh one"); let fresh = self .port .create_session(&record) @@ -105,12 +181,14 @@ impl Pipeline<'_, P, O> { .map_err(|e| anyhow::anyhow!("create_session failed: {e}"))?; record.current_session = Some(fresh.clone()); self.store.update_conversation(&conversation_id, &record).await?; + self.renderer.discard(active_session.as_str()); active_session = fresh; self.port .prompt(&active_session, &event) .await .map_err(|e| anyhow::anyhow!("prompt retry failed: {e}"))? } + Err(error) => return Err(anyhow::anyhow!("prompt failed: {error}")), }; record.last_activity_at = now_unix(); diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs index 878ffc73ba..34d60dd034 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs @@ -1,16 +1,16 @@ use super::*; use crate::outbound::Outbound; use acp_nats::ClientHandler; -use agent_client_protocol::schema::v1::{ - ContentBlock, ContentChunk, SessionNotification, SessionUpdate, TextContent, -}; +use agent_client_protocol::schema::v1::{ContentBlock, ContentChunk, SessionNotification, SessionUpdate, TextContent}; use futures::StreamExt; use std::cell::RefCell; use std::rc::Rc; use testcontainers_modules::nats::{Nats, NatsServerCmd}; use testcontainers_modules::testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner}; use trogon_chat::store::PrincipalRecord; -use trogon_chat::{AgentSessionId, Endpoint, InboundChatEvent, PrincipalId, PromptOutcome}; +use trogon_chat::{ + AgentPortError, AgentSessionId, Endpoint, InboundChatEvent, PrincipalId, PromptOutcome, ReleaseStep, SessionRelease, +}; struct NatsServer { _container: ContainerAsync, @@ -38,6 +38,12 @@ impl NatsServer { #[error("fake agent failure")] struct FakeError; +impl AgentPortError for FakeError { + fn is_session_lost(&self) -> bool { + false + } +} + /// Simulates the agent side: mints sessions, records prompts, and streams a /// reply into the renderer the way real session notifications would. struct FakePort { @@ -45,6 +51,7 @@ struct FakePort { reply: String, sessions_created: RefCell, prompted: RefCell>, + released: RefCell>, } impl trogon_chat::AgentPort for FakePort { @@ -58,11 +65,7 @@ impl trogon_chat::AgentPort for FakePort { Ok(AgentSessionId::new(format!("sess-{}", self.sessions_created.borrow()))) } - async fn prompt( - &self, - session: &AgentSessionId, - event: &InboundChatEvent, - ) -> Result { + async fn prompt(&self, session: &AgentSessionId, event: &InboundChatEvent) -> Result { self.prompted .borrow_mut() .push((session.as_str().to_string(), event.text.clone().unwrap_or_default())); @@ -82,6 +85,14 @@ impl trogon_chat::AgentPort for FakePort { async fn cancel(&self, _session: &AgentSessionId) -> Result<(), Self::Error> { Ok(()) } + + async fn release_session(&self, session: &AgentSessionId, _reason: trogon_chat::ReleaseReason) -> SessionRelease { + self.released.borrow_mut().push(session.as_str().to_string()); + SessionRelease { + cancelled: ReleaseStep::Done, + closed: ReleaseStep::Done, + } + } } #[derive(Default)] @@ -117,7 +128,8 @@ fn raw_update(update_id: u64, chat_id: i64, user_id: u64, text: &str) -> Vec } /// End to end against a real NATS: gateway-shaped raw updates in, identity -/// gate, conversation + session KV, prompt, rendered reply out. One container +/// gate, conversation + session KV, prompt, rendered reply out, and the reset +/// command that rotates the session under a stable conversation. One container /// for the whole scenario. #[tokio::test] async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { @@ -145,6 +157,9 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { (1u64, 99i64, 99u64, "intruder"), (2, 42, 42, "hello"), (3, 42, 42, "again"), + (4, 42, 42, "/new"), + (5, 42, 42, "keep going"), + (6, 42, 42, "/reset finish up"), ] { js.publish("telegram.message", raw_update(update_id, chat, user, text).into()) .await @@ -172,8 +187,10 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { reply: "hi there".to_string(), sessions_created: RefCell::new(0), prompted: RefCell::new(Vec::new()), + released: RefCell::new(Vec::new()), }; let outbound = FakeOutbound::default(); + let triggers = CommandTriggers::default(); let pipeline = Pipeline { store: &store, port: &port, @@ -181,14 +198,11 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { outbound: &outbound, bot_account: "mybot", agent_id: "default", + triggers: &triggers, }; - for _ in 0..3 { - let msg = messages - .next() - .await - .expect("stream yields") - .expect("message received"); + for _ in 0..6 { + let msg = messages.next().await.expect("stream yields").expect("message received"); pipeline.handle_message(&msg).await.expect("handled"); } @@ -202,16 +216,24 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { .is_none() ); - // Both authorized messages flowed through one conversation and one session. - assert_eq!(*port.sessions_created.borrow(), 1); + // Consecutive messages share a session; each reset mints the next one, and + // the text after a trigger is prompted rather than forwarded verbatim. + assert_eq!(*port.sessions_created.borrow(), 3); assert_eq!( *port.prompted.borrow(), vec![ ("sess-1".to_string(), "hello".to_string()), ("sess-1".to_string(), "again".to_string()), + ("sess-2".to_string(), "keep going".to_string()), + ("sess-3".to_string(), "finish up".to_string()), ] ); + assert_eq!( + *port.released.borrow(), + vec!["sess-1".to_string(), "sess-2".to_string()] + ); + // The conversation and its principal outlive every session rotation. let (_, record) = store .conversation_for(&endpoint) .await @@ -220,20 +242,23 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { assert_eq!(record.principal, principal); assert_eq!( record.current_session.as_ref().map(AgentSessionId::as_str), - Some("sess-1") + Some("sess-3") ); - assert_eq!(*outbound.typing.borrow(), 2); + assert_eq!(*outbound.typing.borrow(), 4); assert_eq!( *outbound.sent.borrow(), - vec![(42, "hi there".to_string()), (42, "hi there".to_string())] + vec![ + (42, "hi there".to_string()), + (42, "hi there".to_string()), + (42, "Started a new session.".to_string()), + (42, "hi there".to_string()), + (42, "hi there".to_string()), + ] ); // Everything acked: nothing left pending for redelivery. - let info = stream - .consumer_info("bridge-test") - .await - .expect("consumer info"); + let info = stream.consumer_info("bridge-test").await.expect("consumer info"); assert_eq!(info.num_ack_pending, 0); assert_eq!(info.num_pending, 0); } diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/render.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/render.rs index e12c875ea8..da26311b53 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/render.rs +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/render.rs @@ -33,6 +33,16 @@ impl TelegramRenderClient { .remove(session_id) .filter(|s| !s.trim().is_empty()) } + + /// Drop whatever a session accumulated without sending it. A released + /// session can still have text in flight, and none of it belongs to the + /// conversation that moved on. + pub fn discard(&self, session_id: &str) { + self.buffers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(session_id); + } } impl Default for TelegramRenderClient { diff --git a/rsworkspace/crates/chat/trogon-chat/src/agent_port.rs b/rsworkspace/crates/chat/trogon-chat/src/agent_port.rs index 485896b534..0d8ed17b82 100644 --- a/rsworkspace/crates/chat/trogon-chat/src/agent_port.rs +++ b/rsworkspace/crates/chat/trogon-chat/src/agent_port.rs @@ -36,6 +36,44 @@ pub enum PromptOutcome { Truncated, } +/// Port errors carry the one distinction the routing layer must act on. Every +/// port classifies its own protocol's failures; nothing above this trait +/// inspects error codes. +pub trait AgentPortError: std::error::Error + 'static { + /// True only when the agent no longer has the session, which a fresh + /// session repairs. Transport failures, timeouts, and agent-internal + /// errors are false: rotating on those would discard a live conversation + /// that was merely unreachable for a moment. + fn is_session_lost(&self) -> bool; +} + +/// Why a session is being released. Carried into port logs and available to +/// protocols that can pass a reason to the agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseReason { + /// The user asked for a fresh conversation. + NewSession, +} + +/// How one step of the release ladder ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseStep { + Done, + /// The agent does not advertise the capability, so nothing was sent. + Unsupported, + /// The step was attempted and failed. Recorded, never fatal. + Failed, +} + +/// Report of a best-effort release. The conversation has already dropped its +/// pointer to the session by the time this runs, so no step here can fail the +/// reset; the report exists so an operator can see what the agent did with it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionRelease { + pub cancelled: ReleaseStep, + pub closed: ReleaseStep, +} + /// The one seam between chat routing and agent protocols. One implementation /// per protocol (ACP first; A2A and HTTP later); the implementation owns all /// protocol specifics including how streamed agent output reaches the @@ -43,7 +81,7 @@ pub enum PromptOutcome { /// addressability already exists per protocol (see the architecture doc). #[allow(async_fn_in_trait)] pub trait AgentPort { - type Error: std::error::Error + 'static; + type Error: AgentPortError; /// Create a fresh session for a conversation on its bound agent. async fn create_session(&self, conversation: &ConversationRecord) -> Result; @@ -53,4 +91,9 @@ pub trait AgentPort { async fn prompt(&self, session: &AgentSessionId, event: &InboundChatEvent) -> Result; async fn cancel(&self, session: &AgentSessionId) -> Result<(), Self::Error>; + + /// Tell the agent the bridge is done with a session so it can stop work + /// and free resources. Infallible on purpose: an agent that cannot or will + /// not release must never wedge the conversation it was released from. + async fn release_session(&self, session: &AgentSessionId, reason: ReleaseReason) -> SessionRelease; } diff --git a/rsworkspace/crates/chat/trogon-chat/src/command.rs b/rsworkspace/crates/chat/trogon-chat/src/command.rs new file mode 100644 index 0000000000..1a264b9d2c --- /dev/null +++ b/rsworkspace/crates/chat/trogon-chat/src/command.rs @@ -0,0 +1,157 @@ +use serde::{Deserialize, Serialize}; + +/// A bridge-level instruction recognized in message text. Commands are +/// consumed by the bridge and never forwarded to the agent, so the chat +/// surface owns its own control vocabulary regardless of what the agent +/// behind it happens to understand. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChatCommand { + /// Release the conversation's current session. The agent binding is + /// untouched; only the ephemeral session is replaced. + NewSession, +} + +#[derive(Debug, thiserror::Error)] +pub enum CommandTriggerError { + #[error("command trigger must not be empty")] + Empty, + #[error("command trigger {0:?} must be a single token")] + NotASingleToken(String), +} + +/// The trigger vocabulary a bridge recognizes, matched against the whole first +/// token of a message. Configurable because the leading marker is a channel +/// affordance rather than a domain concept. +#[derive(Debug, Clone)] +pub struct CommandTriggers { + new_session: Vec, +} + +impl Default for CommandTriggers { + fn default() -> Self { + Self { + new_session: vec!["/new".to_string(), "/reset".to_string()], + } + } +} + +/// Message text after command extraction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedText { + pub command: Option, + /// What remains once the trigger is removed, or the message unchanged when + /// no trigger matched. `None` when nothing but the command was sent. + pub body: Option, +} + +impl CommandTriggers { + pub fn new(new_session: impl IntoIterator) -> Result { + let new_session = new_session + .into_iter() + .map(|trigger| { + let trigger = trigger.trim().to_ascii_lowercase(); + if trigger.is_empty() { + return Err(CommandTriggerError::Empty); + } + if trigger.split_whitespace().count() != 1 { + return Err(CommandTriggerError::NotASingleToken(trigger)); + } + Ok(trigger) + }) + .collect::, _>>()?; + Ok(Self { new_session }) + } + + /// Split a message into its command (if the first token is a trigger) and + /// the remaining text, which becomes the first prompt of whatever the + /// command sets up. + pub fn parse(&self, text: &str) -> ParsedText { + let trimmed = text.trim_start(); + let (head, rest) = match trimmed.find(char::is_whitespace) { + Some(index) => (&trimmed[..index], trimmed[index..].trim()), + None => (trimmed, ""), + }; + + // Channels let a user address a command to one bot account among + // several by suffixing it (`/new@somebot`). The suffix selects the + // recipient and is not part of the trigger. + let token = head.split('@').next().unwrap_or(head).to_ascii_lowercase(); + let command = self.new_session.contains(&token).then_some(ChatCommand::NewSession); + + let body = match command { + Some(_) => rest, + None => text, + }; + ParsedText { + command, + body: (!body.trim().is_empty()).then(|| body.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_trigger_yields_a_command_and_no_body() { + let parsed = CommandTriggers::default().parse("/new"); + assert_eq!(parsed.command, Some(ChatCommand::NewSession)); + assert_eq!(parsed.body, None); + } + + #[test] + fn trailing_text_becomes_the_body() { + let parsed = CommandTriggers::default().parse("/reset ship the thing "); + assert_eq!(parsed.command, Some(ChatCommand::NewSession)); + assert_eq!(parsed.body.as_deref(), Some("ship the thing")); + } + + #[test] + fn account_suffix_and_case_do_not_defeat_the_trigger() { + let parsed = CommandTriggers::default().parse("/New@SomeBot hello"); + assert_eq!(parsed.command, Some(ChatCommand::NewSession)); + assert_eq!(parsed.body.as_deref(), Some("hello")); + } + + #[test] + fn a_trigger_that_is_only_a_prefix_of_the_token_is_not_a_command() { + let parsed = CommandTriggers::default().parse("/newsletter please"); + assert_eq!(parsed.command, None); + assert_eq!(parsed.body.as_deref(), Some("/newsletter please")); + } + + #[test] + fn ordinary_text_passes_through_unchanged() { + let parsed = CommandTriggers::default().parse(" keep my spacing "); + assert_eq!(parsed.command, None); + assert_eq!(parsed.body.as_deref(), Some(" keep my spacing ")); + } + + #[test] + fn a_trigger_in_the_middle_is_not_a_command() { + let parsed = CommandTriggers::default().parse("say /new out loud"); + assert_eq!(parsed.command, None); + assert_eq!(parsed.body.as_deref(), Some("say /new out loud")); + } + + #[test] + fn triggers_are_configurable() { + let triggers = CommandTriggers::new(["!Rotate".to_string()]).expect("valid triggers"); + assert_eq!(triggers.parse("!rotate").command, Some(ChatCommand::NewSession)); + assert_eq!(triggers.parse("/new").command, None); + } + + #[test] + fn blank_and_multi_token_triggers_are_rejected() { + assert!(matches!( + CommandTriggers::new([" ".to_string()]), + Err(CommandTriggerError::Empty) + )); + assert!(matches!( + CommandTriggers::new(["/new session".to_string()]), + Err(CommandTriggerError::NotASingleToken(_)) + )); + } +} diff --git a/rsworkspace/crates/chat/trogon-chat/src/event.rs b/rsworkspace/crates/chat/trogon-chat/src/event.rs index c3aea7e4a3..c98e925bce 100644 --- a/rsworkspace/crates/chat/trogon-chat/src/event.rs +++ b/rsworkspace/crates/chat/trogon-chat/src/event.rs @@ -1,3 +1,4 @@ +use crate::command::ChatCommand; use crate::endpoint::Endpoint; use serde::{Deserialize, Serialize}; @@ -27,7 +28,14 @@ pub struct Attachment { pub struct InboundChatEvent { pub endpoint: Endpoint, pub sender: Sender, + /// Message text with any command trigger already removed, so what reaches + /// the agent is only what the user meant for it. pub text: Option, + /// A bridge command found in the text. Extracted at the channel edge + /// because the trigger vocabulary is a channel affordance; acted on by the + /// routing layer and never forwarded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, #[serde(default)] pub attachments: Vec, /// Platform message identity, for dedup, replies, and edits. diff --git a/rsworkspace/crates/chat/trogon-chat/src/lib.rs b/rsworkspace/crates/chat/trogon-chat/src/lib.rs index d27eb1a28a..1fda52b7fd 100644 --- a/rsworkspace/crates/chat/trogon-chat/src/lib.rs +++ b/rsworkspace/crates/chat/trogon-chat/src/lib.rs @@ -10,13 +10,17 @@ #![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))] pub mod agent_port; +pub mod command; pub mod conversation; pub mod endpoint; pub mod event; pub mod render; pub mod store; -pub use agent_port::{AgentPort, AgentSessionId, PromptOutcome}; +pub use agent_port::{ + AgentPort, AgentPortError, AgentSessionId, PromptOutcome, ReleaseReason, ReleaseStep, SessionRelease, +}; +pub use command::{ChatCommand, CommandTriggerError, CommandTriggers, ParsedText}; pub use conversation::{AgentId, ConversationId, ConversationRecord}; pub use endpoint::{Endpoint, EndpointError, PrincipalId}; pub use event::{Attachment, InboundChatEvent, Sender}; diff --git a/rsworkspace/crates/chat/trogon-chat/src/store.rs b/rsworkspace/crates/chat/trogon-chat/src/store.rs index df31984d6a..a9fe8075fd 100644 --- a/rsworkspace/crates/chat/trogon-chat/src/store.rs +++ b/rsworkspace/crates/chat/trogon-chat/src/store.rs @@ -37,10 +37,7 @@ pub struct ChatStore { conversations: jetstream::kv::Store, } -async fn ensure_bucket( - js: &jetstream::Context, - bucket: String, -) -> Result { +async fn ensure_bucket(js: &jetstream::Context, bucket: String) -> Result { if let Ok(store) = js.get_key_value(&bucket).await { return Ok(store); } From ac5dbffa46476006241ab873fcf9be838ffbcb75 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 2 Aug 2026 00:28:40 -0400 Subject: [PATCH 03/55] refactor(channel): name the crate after the vocabulary it already uses The data model and the architecture doc both call this axis a channel, while only the crate and three type names said chat. That read the boundary as chat-app-specific when it covers email, SMS, and push just as well, and it gave no signal about the surfaces that genuinely do not belong. Signed-off-by: Yordis Prieto --- rsworkspace/Cargo.lock | 4 ++-- rsworkspace/Cargo.toml | 2 +- .../chat/chat-bridge-telegram/Cargo.toml | 2 +- .../chat/chat-bridge-telegram/src/acp_port.rs | 10 ++++----- .../chat/chat-bridge-telegram/src/config.rs | 2 +- .../chat/chat-bridge-telegram/src/main.rs | 14 ++++++------ .../chat/chat-bridge-telegram/src/parse.rs | 6 ++--- .../chat/chat-bridge-telegram/src/pipeline.rs | 12 +++++----- .../src/pipeline_tests.rs | 20 ++++++++++------- .../Cargo.toml | 2 +- .../src/agent_port.rs | 4 ++-- .../src/command.rs | 14 ++++++------ .../src/conversation.rs | 0 .../src/endpoint.rs | 0 .../src/event.rs | 6 ++--- .../src/lib.rs | 14 ++++++++---- .../src/render.rs | 0 .../src/store.rs | 22 +++++++++---------- 18 files changed, 72 insertions(+), 62 deletions(-) rename rsworkspace/crates/chat/{trogon-chat => trogon-channel}/Cargo.toml (92%) rename rsworkspace/crates/chat/{trogon-chat => trogon-channel}/src/agent_port.rs (97%) rename rsworkspace/crates/chat/{trogon-chat => trogon-channel}/src/command.rs (93%) rename rsworkspace/crates/chat/{trogon-chat => trogon-channel}/src/conversation.rs (100%) rename rsworkspace/crates/chat/{trogon-chat => trogon-channel}/src/endpoint.rs (100%) rename rsworkspace/crates/chat/{trogon-chat => trogon-channel}/src/event.rs (94%) rename rsworkspace/crates/chat/{trogon-chat => trogon-channel}/src/lib.rs (58%) rename rsworkspace/crates/chat/{trogon-chat => trogon-channel}/src/render.rs (100%) rename rsworkspace/crates/chat/{trogon-chat => trogon-channel}/src/store.rs (91%) diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 551b7112f2..6d81ea6a79 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -1348,7 +1348,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", - "trogon-chat", + "trogon-channel", "trogon-nats", "trogon-std", "trogon-telemetry", @@ -6850,7 +6850,7 @@ dependencies = [ ] [[package]] -name = "trogon-chat" +name = "trogon-channel" version = "0.1.0" dependencies = [ "async-nats", diff --git a/rsworkspace/Cargo.toml b/rsworkspace/Cargo.toml index ea5535a3e1..39b1904749 100644 --- a/rsworkspace/Cargo.toml +++ b/rsworkspace/Cargo.toml @@ -47,7 +47,7 @@ trogon-semconv = { path = "crates/platform/trogon-semconv" } trogon-service-config = { path = "crates/platform/trogon-service-config" } trogon-std = { path = "crates/platform/trogon-std" } trogonai-proto = { path = "crates/platform/trogonai-proto" } -trogon-chat = { path = "crates/chat/trogon-chat" } +trogon-channel = { path = "crates/chat/trogon-channel" } chat-bridge-telegram = { path = "crates/chat/chat-bridge-telegram" } # A2A diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/Cargo.toml b/rsworkspace/crates/chat/chat-bridge-telegram/Cargo.toml index 5cb77ecbd0..40a2864e10 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/Cargo.toml +++ b/rsworkspace/crates/chat/chat-bridge-telegram/Cargo.toml @@ -8,7 +8,7 @@ workspace = true [dependencies] acp-nats = { workspace = true } -trogon-chat = { workspace = true } +trogon-channel = { workspace = true } trogon-nats = { workspace = true } trogon-std = { workspace = true, features = ["signal"] } trogon-telemetry = { workspace = true } diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs index 55d8b6c6ef..a95ee9b1a2 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs @@ -7,8 +7,8 @@ use agent_client_protocol::schema::v1::{ use std::path::PathBuf; use std::sync::Arc; use tracing::{info, warn}; -use trogon_chat::{ - AgentPort, AgentPortError, AgentSessionId, ConversationRecord, InboundChatEvent, PromptOutcome, ReleaseReason, +use trogon_channel::{ + AgentPort, AgentPortError, AgentSessionId, ConversationRecord, InboundEvent, PromptOutcome, ReleaseReason, ReleaseStep, SessionRelease, }; @@ -153,14 +153,14 @@ impl AcpPort { /// Human-readable context prefix: the only part of the conversational /// metadata a non-participating agent is guaranteed to see, since only prompt /// text reaches the model. -fn prompt_text(event: &InboundChatEvent) -> String { +fn prompt_text(event: &InboundEvent) -> String { let body = event.text.as_deref().unwrap_or_default(); format!("[telegram message from {}]\n{}", event.sender.display_name, body) } /// Structured twin of the context prefix, for agents that opt into reading /// `_meta` (see the architecture doc's `_meta` convention). -fn prompt_meta(event: &InboundChatEvent) -> agent_client_protocol::schema::v1::Meta { +fn prompt_meta(event: &InboundEvent) -> agent_client_protocol::schema::v1::Meta { let mut meta = serde_json::Map::new(); meta.insert( "chat".to_string(), @@ -190,7 +190,7 @@ impl AgentPort for AcpPort { Ok(AgentSessionId::new(response.session_id.to_string())) } - async fn prompt(&self, session: &AgentSessionId, event: &InboundChatEvent) -> Result { + async fn prompt(&self, session: &AgentSessionId, event: &InboundEvent) -> Result { let mut request = PromptRequest::new( session.as_str().to_string(), vec![ContentBlock::Text(TextContent::new(prompt_text(event)))], diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs index bbec70d2ed..d87a7050fe 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs @@ -1,7 +1,7 @@ use acp_nats::{AcpPrefix, NatsConfig}; use anyhow::Context; use std::path::PathBuf; -use trogon_chat::CommandTriggers; +use trogon_channel::CommandTriggers; use trogon_std::env::ReadEnv; pub struct BridgeConfig { diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs index 5f4637f575..6724889632 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs @@ -1,10 +1,10 @@ //! Telegram chat bridge: the v1 direct path from the gateway's raw Telegram //! stream to an ACP agent. See `docs/architecture/multi-channel-agent-routing.md`. //! -//! One worker, two halves: normalize (raw Update -> `InboundChatEvent`, +//! One worker, two halves: normalize (raw Update -> `InboundEvent`, //! identity + conversation via KV, prompt via `AgentPort`) and render (agent //! session notifications -> Telegram API calls). Everything channel-neutral -//! lives in `trogon-chat`; this binary is allowed to know about Telegram and +//! lives in `trogon-channel`; this binary is allowed to know about Telegram and //! nothing else. #![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))] @@ -28,8 +28,8 @@ use render::TelegramRenderClient; use std::sync::Arc; use teloxide::Bot; use tracing::{error, info, warn}; -use trogon_chat::store::PrincipalRecord; -use trogon_chat::{ChatStore, Endpoint, PrincipalId}; +use trogon_channel::store::PrincipalRecord; +use trogon_channel::{ChannelStore, Endpoint, PrincipalId}; use trogon_std::env::SystemEnv; use trogon_std::fs::SystemFs; use trogon_std::signal::shutdown_signal; @@ -46,7 +46,7 @@ async fn main() -> anyhow::Result<()> { let nats_client = acp_nats::nats::connect(config.acp.nats(), nats_connect_timeout).await?; let js = async_nats::jetstream::new(nats_client.clone()); - let store = ChatStore::ensure(&js, &config.chat_prefix).await?; + let store = ChannelStore::ensure(&js, &config.chat_prefix).await?; seed_principals(&store, &config).await?; let stream = js.get_stream(&config.inbound_stream).await.map_err(|e| { @@ -83,7 +83,7 @@ async fn main() -> anyhow::Result<()> { result } -async fn seed_principals(store: &ChatStore, config: &BridgeConfig) -> anyhow::Result<()> { +async fn seed_principals(store: &ChannelStore, config: &BridgeConfig) -> anyhow::Result<()> { for user in &config.seed_users { let principal = PrincipalId::new(format!("telegram-{user}"))?; let endpoint = Endpoint::new("telegram", &config.bot_account, user.to_string())?; @@ -97,7 +97,7 @@ async fn seed_principals(store: &ChatStore, config: &BridgeConfig) -> anyhow::Re async fn run( nats_client: async_nats::Client, - store: ChatStore, + store: ChannelStore, mut messages: async_nats::jetstream::consumer::pull::Stream, bot: Bot, config: BridgeConfig, diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs index 9de15faf9b..99bd8297a4 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs @@ -1,10 +1,10 @@ use teloxide::types::{Update, UpdateKind}; -use trogon_chat::{CommandTriggers, Endpoint, InboundChatEvent, Sender}; +use trogon_channel::{CommandTriggers, Endpoint, InboundEvent, Sender}; /// Normalize a raw Telegram update into the channel-neutral event, or `None` /// for update kinds v1 does not carry (media, edits, membership, ...). The /// raw stream retains those with full fidelity for later. -pub fn inbound_event(update: &Update, bot_account: &str, triggers: &CommandTriggers) -> Option { +pub fn inbound_event(update: &Update, bot_account: &str, triggers: &CommandTriggers) -> Option { let UpdateKind::Message(msg) = &update.kind else { return None; }; @@ -21,7 +21,7 @@ pub fn inbound_event(update: &Update, bot_account: &str, triggers: &CommandTrigg let parsed = triggers.parse(text); - Some(InboundChatEvent { + Some(InboundEvent { endpoint, sender: Sender { platform_user_id: from.id.0.to_string(), diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs index 7549f99820..679c1d7473 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs @@ -7,9 +7,9 @@ use crate::parse; use crate::render::{TEXT_CHUNK_LIMIT, TelegramRenderClient, chunk_text}; use anyhow::Context as _; use tracing::{info, warn}; -use trogon_chat::{ - AgentId, AgentPort, AgentPortError as _, ChatCommand, ChatStore, CommandTriggers, ConversationId, - ConversationRecord, InboundChatEvent, ReleaseReason, +use trogon_channel::{ + AgentId, AgentPort, AgentPortError as _, ChannelStore, Command, CommandTriggers, ConversationId, + ConversationRecord, InboundEvent, ReleaseReason, }; /// What the bridge says back when a command has nothing else to do. A reset @@ -18,7 +18,7 @@ use trogon_chat::{ const NEW_SESSION_ACKNOWLEDGEMENT: &str = "Started a new session."; pub struct Pipeline<'a, P, O> { - pub store: &'a ChatStore, + pub store: &'a ChannelStore, pub port: &'a P, pub renderer: &'a TelegramRenderClient, pub outbound: &'a O, @@ -41,7 +41,7 @@ impl Pipeline<'_, P, O> { /// Whether the individual who sent this message is a known principal. The /// conversation gate authorizes the chat, which in a group is everyone in /// it; destructive commands ask the narrower question. - async fn sender_is_authorized(&self, event: &InboundChatEvent) -> anyhow::Result { + async fn sender_is_authorized(&self, event: &InboundEvent) -> anyhow::Result { let Some(endpoint) = parse::sender_endpoint(self.bot_account, &event.sender) else { return Ok(false); }; @@ -123,7 +123,7 @@ impl Pipeline<'_, P, O> { } }; - if event.command == Some(ChatCommand::NewSession) { + if event.command == Some(Command::NewSession) { if self.sender_is_authorized(&event).await? { self.release_current_session(&conversation_id, &mut record).await?; if event.text.is_none() { diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs index 34d60dd034..5d9d183e59 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs +++ b/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs @@ -7,9 +7,9 @@ use std::cell::RefCell; use std::rc::Rc; use testcontainers_modules::nats::{Nats, NatsServerCmd}; use testcontainers_modules::testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner}; -use trogon_chat::store::PrincipalRecord; -use trogon_chat::{ - AgentPortError, AgentSessionId, Endpoint, InboundChatEvent, PrincipalId, PromptOutcome, ReleaseStep, SessionRelease, +use trogon_channel::store::PrincipalRecord; +use trogon_channel::{ + AgentPortError, AgentSessionId, Endpoint, InboundEvent, PrincipalId, PromptOutcome, ReleaseStep, SessionRelease, }; struct NatsServer { @@ -54,18 +54,18 @@ struct FakePort { released: RefCell>, } -impl trogon_chat::AgentPort for FakePort { +impl trogon_channel::AgentPort for FakePort { type Error = FakeError; async fn create_session( &self, - _conversation: &trogon_chat::ConversationRecord, + _conversation: &trogon_channel::ConversationRecord, ) -> Result { *self.sessions_created.borrow_mut() += 1; Ok(AgentSessionId::new(format!("sess-{}", self.sessions_created.borrow()))) } - async fn prompt(&self, session: &AgentSessionId, event: &InboundChatEvent) -> Result { + async fn prompt(&self, session: &AgentSessionId, event: &InboundEvent) -> Result { self.prompted .borrow_mut() .push((session.as_str().to_string(), event.text.clone().unwrap_or_default())); @@ -86,7 +86,11 @@ impl trogon_chat::AgentPort for FakePort { Ok(()) } - async fn release_session(&self, session: &AgentSessionId, _reason: trogon_chat::ReleaseReason) -> SessionRelease { + async fn release_session( + &self, + session: &AgentSessionId, + _reason: trogon_channel::ReleaseReason, + ) -> SessionRelease { self.released.borrow_mut().push(session.as_str().to_string()); SessionRelease { cancelled: ReleaseStep::Done, @@ -145,7 +149,7 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { .await .expect("create TELEGRAM stream"); - let store = ChatStore::ensure(&js, "test").await.expect("ensure buckets"); + let store = ChannelStore::ensure(&js, "test").await.expect("ensure buckets"); let principal = PrincipalId::new("telegram-42").expect("principal"); let endpoint = Endpoint::new("telegram", "mybot", "42").expect("endpoint"); store diff --git a/rsworkspace/crates/chat/trogon-chat/Cargo.toml b/rsworkspace/crates/chat/trogon-channel/Cargo.toml similarity index 92% rename from rsworkspace/crates/chat/trogon-chat/Cargo.toml rename to rsworkspace/crates/chat/trogon-channel/Cargo.toml index 4223d2c44c..69b737c058 100644 --- a/rsworkspace/crates/chat/trogon-chat/Cargo.toml +++ b/rsworkspace/crates/chat/trogon-channel/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "trogon-chat" +name = "trogon-channel" version = "0.1.0" edition = "2024" diff --git a/rsworkspace/crates/chat/trogon-chat/src/agent_port.rs b/rsworkspace/crates/chat/trogon-channel/src/agent_port.rs similarity index 97% rename from rsworkspace/crates/chat/trogon-chat/src/agent_port.rs rename to rsworkspace/crates/chat/trogon-channel/src/agent_port.rs index 0d8ed17b82..2ccb8ba988 100644 --- a/rsworkspace/crates/chat/trogon-chat/src/agent_port.rs +++ b/rsworkspace/crates/chat/trogon-channel/src/agent_port.rs @@ -1,5 +1,5 @@ use crate::conversation::ConversationRecord; -use crate::event::InboundChatEvent; +use crate::event::InboundEvent; use serde::{Deserialize, Serialize}; /// An agent-side session handle. Opaque to everything except the port @@ -88,7 +88,7 @@ pub trait AgentPort { /// Send one inbound event as a prompt and wait for the turn to end. /// Streamed output is delivered out-of-band by the implementation. - async fn prompt(&self, session: &AgentSessionId, event: &InboundChatEvent) -> Result; + async fn prompt(&self, session: &AgentSessionId, event: &InboundEvent) -> Result; async fn cancel(&self, session: &AgentSessionId) -> Result<(), Self::Error>; diff --git a/rsworkspace/crates/chat/trogon-chat/src/command.rs b/rsworkspace/crates/chat/trogon-channel/src/command.rs similarity index 93% rename from rsworkspace/crates/chat/trogon-chat/src/command.rs rename to rsworkspace/crates/chat/trogon-channel/src/command.rs index 1a264b9d2c..54c3790360 100644 --- a/rsworkspace/crates/chat/trogon-chat/src/command.rs +++ b/rsworkspace/crates/chat/trogon-channel/src/command.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; /// behind it happens to understand. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum ChatCommand { +pub enum Command { /// Release the conversation's current session. The agent binding is /// untouched; only the ephemeral session is replaced. NewSession, @@ -39,7 +39,7 @@ impl Default for CommandTriggers { /// Message text after command extraction. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParsedText { - pub command: Option, + pub command: Option, /// What remains once the trigger is removed, or the message unchanged when /// no trigger matched. `None` when nothing but the command was sent. pub body: Option, @@ -77,7 +77,7 @@ impl CommandTriggers { // several by suffixing it (`/new@somebot`). The suffix selects the // recipient and is not part of the trigger. let token = head.split('@').next().unwrap_or(head).to_ascii_lowercase(); - let command = self.new_session.contains(&token).then_some(ChatCommand::NewSession); + let command = self.new_session.contains(&token).then_some(Command::NewSession); let body = match command { Some(_) => rest, @@ -97,21 +97,21 @@ mod tests { #[test] fn bare_trigger_yields_a_command_and_no_body() { let parsed = CommandTriggers::default().parse("/new"); - assert_eq!(parsed.command, Some(ChatCommand::NewSession)); + assert_eq!(parsed.command, Some(Command::NewSession)); assert_eq!(parsed.body, None); } #[test] fn trailing_text_becomes_the_body() { let parsed = CommandTriggers::default().parse("/reset ship the thing "); - assert_eq!(parsed.command, Some(ChatCommand::NewSession)); + assert_eq!(parsed.command, Some(Command::NewSession)); assert_eq!(parsed.body.as_deref(), Some("ship the thing")); } #[test] fn account_suffix_and_case_do_not_defeat_the_trigger() { let parsed = CommandTriggers::default().parse("/New@SomeBot hello"); - assert_eq!(parsed.command, Some(ChatCommand::NewSession)); + assert_eq!(parsed.command, Some(Command::NewSession)); assert_eq!(parsed.body.as_deref(), Some("hello")); } @@ -139,7 +139,7 @@ mod tests { #[test] fn triggers_are_configurable() { let triggers = CommandTriggers::new(["!Rotate".to_string()]).expect("valid triggers"); - assert_eq!(triggers.parse("!rotate").command, Some(ChatCommand::NewSession)); + assert_eq!(triggers.parse("!rotate").command, Some(Command::NewSession)); assert_eq!(triggers.parse("/new").command, None); } diff --git a/rsworkspace/crates/chat/trogon-chat/src/conversation.rs b/rsworkspace/crates/chat/trogon-channel/src/conversation.rs similarity index 100% rename from rsworkspace/crates/chat/trogon-chat/src/conversation.rs rename to rsworkspace/crates/chat/trogon-channel/src/conversation.rs diff --git a/rsworkspace/crates/chat/trogon-chat/src/endpoint.rs b/rsworkspace/crates/chat/trogon-channel/src/endpoint.rs similarity index 100% rename from rsworkspace/crates/chat/trogon-chat/src/endpoint.rs rename to rsworkspace/crates/chat/trogon-channel/src/endpoint.rs diff --git a/rsworkspace/crates/chat/trogon-chat/src/event.rs b/rsworkspace/crates/chat/trogon-channel/src/event.rs similarity index 94% rename from rsworkspace/crates/chat/trogon-chat/src/event.rs rename to rsworkspace/crates/chat/trogon-channel/src/event.rs index c98e925bce..863c79df0d 100644 --- a/rsworkspace/crates/chat/trogon-chat/src/event.rs +++ b/rsworkspace/crates/chat/trogon-channel/src/event.rs @@ -1,4 +1,4 @@ -use crate::command::ChatCommand; +use crate::command::Command; use crate::endpoint::Endpoint; use serde::{Deserialize, Serialize}; @@ -25,7 +25,7 @@ pub struct Attachment { /// once the multi-channel extraction happens; until then it travels /// in-process. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InboundChatEvent { +pub struct InboundEvent { pub endpoint: Endpoint, pub sender: Sender, /// Message text with any command trigger already removed, so what reaches @@ -35,7 +35,7 @@ pub struct InboundChatEvent { /// because the trigger vocabulary is a channel affordance; acted on by the /// routing layer and never forwarded. #[serde(default, skip_serializing_if = "Option::is_none")] - pub command: Option, + pub command: Option, #[serde(default)] pub attachments: Vec, /// Platform message identity, for dedup, replies, and edits. diff --git a/rsworkspace/crates/chat/trogon-chat/src/lib.rs b/rsworkspace/crates/chat/trogon-channel/src/lib.rs similarity index 58% rename from rsworkspace/crates/chat/trogon-chat/src/lib.rs rename to rsworkspace/crates/chat/trogon-channel/src/lib.rs index 1fda52b7fd..d1ce3896de 100644 --- a/rsworkspace/crates/chat/trogon-chat/src/lib.rs +++ b/rsworkspace/crates/chat/trogon-channel/src/lib.rs @@ -1,4 +1,4 @@ -//! Channel-neutral chat domain: the shared brain every channel bridge imports. +//! Channel-neutral agent routing: the shared brain every channel bridge imports. //! //! See `docs/architecture/multi-channel-agent-routing.md`. This crate owns the //! vocabulary (endpoints, principals, conversations, inbound events, render @@ -6,6 +6,12 @@ //! which bridges reach agents. Channel binaries (e.g. `chat-bridge-telegram`) //! contain platform I/O only; nothing in this crate may reference a specific //! platform or agent protocol. +//! +//! A surface belongs here when it carries discrete messages to a peer that +//! stays addressable between them, and the sender's identity is foreign to the +//! agent. Chat apps qualify, and so do email, SMS, and push. A surface that +//! owns a workspace and can prompt its own user (a desktop app, an editor) +//! does not: it is an agent protocol client already and needs none of this. #![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))] @@ -20,9 +26,9 @@ pub mod store; pub use agent_port::{ AgentPort, AgentPortError, AgentSessionId, PromptOutcome, ReleaseReason, ReleaseStep, SessionRelease, }; -pub use command::{ChatCommand, CommandTriggerError, CommandTriggers, ParsedText}; +pub use command::{Command, CommandTriggerError, CommandTriggers, ParsedText}; pub use conversation::{AgentId, ConversationId, ConversationRecord}; pub use endpoint::{Endpoint, EndpointError, PrincipalId}; -pub use event::{Attachment, InboundChatEvent, Sender}; +pub use event::{Attachment, InboundEvent, Sender}; pub use render::RenderCommand; -pub use store::{ChatStore, ChatStoreError}; +pub use store::{ChannelStore, ChannelStoreError}; diff --git a/rsworkspace/crates/chat/trogon-chat/src/render.rs b/rsworkspace/crates/chat/trogon-channel/src/render.rs similarity index 100% rename from rsworkspace/crates/chat/trogon-chat/src/render.rs rename to rsworkspace/crates/chat/trogon-channel/src/render.rs diff --git a/rsworkspace/crates/chat/trogon-chat/src/store.rs b/rsworkspace/crates/chat/trogon-channel/src/store.rs similarity index 91% rename from rsworkspace/crates/chat/trogon-chat/src/store.rs rename to rsworkspace/crates/chat/trogon-channel/src/store.rs index a9fe8075fd..795df108b4 100644 --- a/rsworkspace/crates/chat/trogon-chat/src/store.rs +++ b/rsworkspace/crates/chat/trogon-channel/src/store.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use tracing::info; #[derive(Debug, thiserror::Error)] -pub enum ChatStoreError { +pub enum ChannelStoreError { #[error("failed to create KV bucket {bucket}: {source}")] CreateBucket { bucket: String, @@ -30,14 +30,14 @@ pub struct PrincipalRecord { /// exactly one worker (the bridge today, the router after extraction). Config /// files never hold this state; the admin surface that seeds/mutates it is /// out of band by design. -pub struct ChatStore { +pub struct ChannelStore { principals: jetstream::kv::Store, endpoints: jetstream::kv::Store, bindings: jetstream::kv::Store, conversations: jetstream::kv::Store, } -async fn ensure_bucket(js: &jetstream::Context, bucket: String) -> Result { +async fn ensure_bucket(js: &jetstream::Context, bucket: String) -> Result { if let Ok(store) = js.get_key_value(&bucket).await { return Ok(store); } @@ -49,11 +49,11 @@ async fn ensure_bucket(js: &jetstream::Context, bucket: String) -> Result Result { +impl ChannelStore { + pub async fn ensure(js: &jetstream::Context, prefix: &str) -> Result { Ok(Self { principals: ensure_bucket(js, format!("chat_principals_{prefix}")).await?, endpoints: ensure_bucket(js, format!("chat_endpoints_{prefix}")).await?, @@ -65,7 +65,7 @@ impl ChatStore { /// Identity: which principal owns this endpoint. `None` means the /// endpoint is unknown and the bridge must reject the message; this is /// the access-control mechanism. - pub async fn principal_for(&self, endpoint: &Endpoint) -> Result, ChatStoreError> { + pub async fn principal_for(&self, endpoint: &Endpoint) -> Result, ChannelStoreError> { match self.endpoints.get(endpoint.kv_key()).await? { Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)), None => Ok(None), @@ -78,7 +78,7 @@ impl ChatStore { principal: &PrincipalId, record: &PrincipalRecord, endpoint: &Endpoint, - ) -> Result<(), ChatStoreError> { + ) -> Result<(), ChannelStoreError> { self.principals .put(principal.as_str(), serde_json::to_vec(record)?.into()) .await?; @@ -92,7 +92,7 @@ impl ChatStore { pub async fn conversation_for( &self, endpoint: &Endpoint, - ) -> Result, ChatStoreError> { + ) -> Result, ChannelStoreError> { let Some(bytes) = self.bindings.get(endpoint.kv_key()).await? else { return Ok(None); }; @@ -110,7 +110,7 @@ impl ChatStore { &self, endpoint: &Endpoint, record: &ConversationRecord, - ) -> Result { + ) -> Result { let id = ConversationId::generate(); self.conversations .put(id.as_str(), serde_json::to_vec(record)?.into()) @@ -126,7 +126,7 @@ impl ChatStore { &self, id: &ConversationId, record: &ConversationRecord, - ) -> Result<(), ChatStoreError> { + ) -> Result<(), ChannelStoreError> { self.conversations .put(id.as_str(), serde_json::to_vec(record)?.into()) .await?; From 876e58ed74f311852694e63ec423edb4e84736de Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 2 Aug 2026 01:26:23 -0400 Subject: [PATCH 04/55] refactor(channel): finish the vocabulary move through the deployment surface Every crate directory in the workspace shares one vocabulary with the crates inside it, and chat/ became the sole exception the moment the shared crate stopped being called chat. A half-finished rename reads worse than none: the crate doc described its own sibling as a channel binary named chat-bridge. Deriving the durable consumer identity from the crate name meant any future rename could silently reset a deployment's position in the stream, and a first run replayed history the agent had no business answering. Signed-off-by: Yordis Prieto --- .github/canary-container-services.json | 6 +-- .github/workflows/canary-container-images.yml | 2 +- devops/docker/compose/compose.yml | 12 +++--- .../Dockerfile | 10 ++--- .../multi-channel-agent-routing.md | 40 +++++++++---------- rsworkspace/Cargo.lock | 2 +- rsworkspace/Cargo.toml | 4 +- .../channel-bridge-telegram}/Cargo.toml | 2 +- .../channel-bridge-telegram}/src/acp_port.rs | 0 .../channel-bridge-telegram}/src/config.rs | 18 ++++----- .../channel-bridge-telegram}/src/main.rs | 24 ++++++++--- .../channel-bridge-telegram}/src/outbound.rs | 0 .../channel-bridge-telegram}/src/parse.rs | 0 .../channel-bridge-telegram}/src/pipeline.rs | 0 .../src/pipeline_tests.rs | 0 .../channel-bridge-telegram}/src/render.rs | 0 .../trogon-channel/Cargo.toml | 0 .../trogon-channel/src/agent_port.rs | 2 +- .../trogon-channel/src/command.rs | 0 .../trogon-channel/src/conversation.rs | 0 .../trogon-channel/src/endpoint.rs | 2 +- .../trogon-channel/src/event.rs | 2 +- .../trogon-channel/src/lib.rs | 2 +- .../trogon-channel/src/render.rs | 2 +- .../trogon-channel/src/store.rs | 10 ++--- .../trogon-telemetry/src/service_name.rs | 4 +- .../src/service_name/tests.rs | 5 +++ 27 files changed, 83 insertions(+), 66 deletions(-) rename devops/docker/compose/services/{chat-bridge-telegram => channel-bridge-telegram}/Dockerfile (77%) rename rsworkspace/crates/{chat/chat-bridge-telegram => channel/channel-bridge-telegram}/Cargo.toml (96%) rename rsworkspace/crates/{chat/chat-bridge-telegram => channel/channel-bridge-telegram}/src/acp_port.rs (100%) rename rsworkspace/crates/{chat/chat-bridge-telegram => channel/channel-bridge-telegram}/src/config.rs (84%) rename rsworkspace/crates/{chat/chat-bridge-telegram => channel/channel-bridge-telegram}/src/main.rs (84%) rename rsworkspace/crates/{chat/chat-bridge-telegram => channel/channel-bridge-telegram}/src/outbound.rs (100%) rename rsworkspace/crates/{chat/chat-bridge-telegram => channel/channel-bridge-telegram}/src/parse.rs (100%) rename rsworkspace/crates/{chat/chat-bridge-telegram => channel/channel-bridge-telegram}/src/pipeline.rs (100%) rename rsworkspace/crates/{chat/chat-bridge-telegram => channel/channel-bridge-telegram}/src/pipeline_tests.rs (100%) rename rsworkspace/crates/{chat/chat-bridge-telegram => channel/channel-bridge-telegram}/src/render.rs (100%) rename rsworkspace/crates/{chat => channel}/trogon-channel/Cargo.toml (100%) rename rsworkspace/crates/{chat => channel}/trogon-channel/src/agent_port.rs (97%) rename rsworkspace/crates/{chat => channel}/trogon-channel/src/command.rs (100%) rename rsworkspace/crates/{chat => channel}/trogon-channel/src/conversation.rs (100%) rename rsworkspace/crates/{chat => channel}/trogon-channel/src/endpoint.rs (97%) rename rsworkspace/crates/{chat => channel}/trogon-channel/src/event.rs (95%) rename rsworkspace/crates/{chat => channel}/trogon-channel/src/lib.rs (94%) rename rsworkspace/crates/{chat => channel}/trogon-channel/src/render.rs (90%) rename rsworkspace/crates/{chat => channel}/trogon-channel/src/store.rs (91%) diff --git a/.github/canary-container-services.json b/.github/canary-container-services.json index 6c57df21bb..03bd122421 100644 --- a/.github/canary-container-services.json +++ b/.github/canary-container-services.json @@ -4,9 +4,9 @@ "context": "./rsworkspace", "dockerfile": "./devops/docker/compose/services/trogon-gateway/Dockerfile" }, - "chat-bridge-telegram": { - "image": "trogonai/chat-bridge-telegram", + "channel-bridge-telegram": { + "image": "trogonai/channel-bridge-telegram", "context": "./rsworkspace", - "dockerfile": "./devops/docker/compose/services/chat-bridge-telegram/Dockerfile" + "dockerfile": "./devops/docker/compose/services/channel-bridge-telegram/Dockerfile" } } diff --git a/.github/workflows/canary-container-images.yml b/.github/workflows/canary-container-images.yml index e5c081feb0..3d9839641f 100644 --- a/.github/workflows/canary-container-images.yml +++ b/.github/workflows/canary-container-images.yml @@ -24,7 +24,7 @@ jobs: matrix: service: - trogon-gateway - - chat-bridge-telegram + - channel-bridge-telegram steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/devops/docker/compose/compose.yml b/devops/docker/compose/compose.yml index d216ea84ac..0c97398998 100644 --- a/devops/docker/compose/compose.yml +++ b/devops/docker/compose/compose.yml @@ -45,21 +45,21 @@ services: start_period: 10s retries: 3 - # Telegram chat bridge: consumes the gateway's raw TELEGRAM stream and + # Telegram channel bridge: consumes the gateway's raw TELEGRAM stream and # drives the shared agent over acp-nats. Needs the gateway's Telegram # source enabled (provisions the stream) and an agent behind acp-nats. - chat-bridge-telegram: + channel-bridge-telegram: build: context: ../../../rsworkspace - dockerfile: ../devops/docker/compose/services/chat-bridge-telegram/Dockerfile + dockerfile: ../devops/docker/compose/services/channel-bridge-telegram/Dockerfile env_file: - path: .env required: false environment: TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}" - CHAT_SEED_TELEGRAM_USERS: "${CHAT_SEED_TELEGRAM_USERS:-}" - CHAT_PREFIX: "${CHAT_PREFIX:-prod}" - CHAT_NEW_SESSION_TRIGGERS: "${CHAT_NEW_SESSION_TRIGGERS:-/new,/reset}" + CHANNEL_SEED_TELEGRAM_USERS: "${CHANNEL_SEED_TELEGRAM_USERS:-}" + CHANNEL_PREFIX: "${CHANNEL_PREFIX:-prod}" + CHANNEL_NEW_SESSION_TRIGGERS: "${CHANNEL_NEW_SESSION_TRIGGERS:-/new,/reset}" TELEGRAM_INBOUND_STREAM: "${TELEGRAM_INBOUND_STREAM:-TELEGRAM}" ACP_PREFIX: "${ACP_PREFIX:-acp}" NATS_URL: "nats:4222" diff --git a/devops/docker/compose/services/chat-bridge-telegram/Dockerfile b/devops/docker/compose/services/channel-bridge-telegram/Dockerfile similarity index 77% rename from devops/docker/compose/services/chat-bridge-telegram/Dockerfile rename to devops/docker/compose/services/channel-bridge-telegram/Dockerfile index 2f783c7efc..0d0468a8eb 100644 --- a/devops/docker/compose/services/chat-bridge-telegram/Dockerfile +++ b/devops/docker/compose/services/channel-bridge-telegram/Dockerfile @@ -17,13 +17,13 @@ RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder COPY --from=planner /build/recipe.json recipe.json -RUN cargo chef cook --release --recipe-path recipe.json -p chat-bridge-telegram +RUN cargo chef cook --release --recipe-path recipe.json -p channel-bridge-telegram COPY Cargo.toml Cargo.lock ./ COPY crates/ crates/ -RUN cargo build --release -p chat-bridge-telegram && \ - strip target/release/chat-bridge-telegram +RUN cargo build --release -p channel-bridge-telegram && \ + strip target/release/channel-bridge-telegram # ── Stage 4: runtime ──────────────────────────────────────────────────────── FROM debian:bookworm-20260518-slim AS runtime @@ -34,10 +34,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN useradd --no-create-home --shell /usr/sbin/nologin trogon -COPY --from=builder /build/target/release/chat-bridge-telegram /usr/local/bin/chat-bridge-telegram +COPY --from=builder /build/target/release/channel-bridge-telegram /usr/local/bin/channel-bridge-telegram USER trogon STOPSIGNAL SIGTERM -ENTRYPOINT ["/usr/local/bin/chat-bridge-telegram"] +ENTRYPOINT ["/usr/local/bin/channel-bridge-telegram"] diff --git a/docs/architecture/multi-channel-agent-routing.md b/docs/architecture/multi-channel-agent-routing.md index 0c0f59a2d7..289c13e30c 100644 --- a/docs/architecture/multi-channel-agent-routing.md +++ b/docs/architecture/multi-channel-agent-routing.md @@ -1,6 +1,6 @@ # Multi-Channel Agent Routing -How a chat channel (Telegram first, Discord and others later) binds to an AI +How a channel (Telegram first, Discord and others later) binds to an AI agent. This document records two things: the **v1 implementation**, which goes directly from the raw Telegram stream to an ACP agent through one bridge worker, and the **multi-channel end state**, whose seams v1 keeps as code @@ -24,7 +24,7 @@ Telegram ─HTTP─▶ telegram.{update_type} trogon-gateway (e stream TELEGRAM, raw verbatim JSON │ ▼ durable consumer - normalize: parse Update, endpoint, chat-bridge-telegram + normalize: parse Update, endpoint, channel-bridge-telegram eager-download attachments (one worker) identity + binding via KV dispatch prompt via AgentPort @@ -33,7 +33,7 @@ Telegram ─HTTP─▶ telegram.{update_type} trogon-gateway (e ═══ agent works, streams notifications ═══ │ ▼ render notifications - Telegram Bot API calls chat-bridge-telegram + Telegram Bot API calls channel-bridge-telegram (send, edit-in-place, chunk, throttle) ``` @@ -41,7 +41,7 @@ Two workers total, one of which already exists. The bridge is the fusion of what the end state calls the "edge" and the "router". We fuse them because: - The prompt/notification traffic **already crosses NATS** inside `acp-nats`; - a `chat.>` middle namespace would add hops without adding a capability v1 + a `channel.>` middle namespace would add hops without adding a capability v1 needs. ACP over NATS is our version of the direct function call OpenClaw and Hermes make in-process (both reference systems are monoliths whose channel handlers call the agent loop as a library). @@ -63,16 +63,16 @@ arrives, the bridge splits along the seams the shared crate already defines: telegram.{update_type} (stream TELEGRAM) trogon-gateway │ ▼ -chat.{prefix}.in.{channel}.{account}.{peer} chat-edge-telegram -stream CHAT_IN_{prefix}, neutral inbound events (normalize half) +channel.{prefix}.in.{channel}.{account}.{peer} channel-bridge-telegram +stream CHANNEL_IN_{prefix}, neutral inbound events (normalize half) │ ▼ -[identity, binding, conversation KV, chat-router +[identity, binding, conversation KV, channel-router per-conversation serialization, AgentPort] (generic, channel-blind) │ ▼ -chat.{prefix}.out.{channel}.{account}.{peer} chat-router publishes, -stream CHAT_OUT_{prefix}, render commands chat-edge-telegram +channel.{prefix}.out.{channel}.{account}.{peer} channel-router publishes, +stream CHANNEL_OUT_{prefix}, render commands channel-bridge-telegram │ (render half) consumes ▼ platform API calls @@ -80,7 +80,7 @@ platform API calls - `{channel}.{account}.{peer}` is the **endpoint address**; tokens must be subject-safe and edges own the encoding. -- The router subscribes `chat.{prefix}.in.>` and is channel-blind; a new +- The router subscribes `channel.{prefix}.in.>` and is channel-blind; a new channel is a new edge binary and zero router changes. - The subjects carry exactly the types the shared crate already defines; the extraction is deployment surgery, not schema design. @@ -130,10 +130,10 @@ truth and whatever tool mutates it is pluggable. | Bucket | Key | Value | | --- | --- | --- | -| `chat_principals_{prefix}` | principal id | display info, policy flags | -| `chat_endpoints_{prefix}` | endpoint address | principal id | -| `chat_bindings_{prefix}` | endpoint address | conversation id | -| `chat_conversations_{prefix}` | conversation id | principal id, agent_id, current_session, activity timestamps | +| `channel_principals_{prefix}` | principal id | display info, policy flags | +| `channel_endpoints_{prefix}` | endpoint address | principal id | +| `channel_bindings_{prefix}` | endpoint address | conversation id | +| `channel_conversations_{prefix}` | conversation id | principal id, agent_id, current_session, activity timestamps | Access control is identity: an endpoint that resolves to no principal is rejected (or ignored) at the bridge. This replaces the per-channel allowlist @@ -141,7 +141,7 @@ concept with one channel-neutral mechanism. ## Shared-crate types (the wire schemas in waiting) -**Inbound chat event** (a Rust type in v1; the `chat.*.in.*` payload after +**Inbound event** (a Rust type in v1; the `channel.*.in.*` payload after extraction): ``` @@ -165,7 +165,7 @@ happens to advertise. A destructive command additionally authorizes the sender's own endpoint rather than the conversation's, since a group chat is one endpoint shared by everyone in it. -**Render commands** (a Rust enum in v1; the `chat.*.out.*` payload after +**Render commands** (a Rust enum in v1; the `channel.*.out.*` payload after extraction): | Command | Purpose | @@ -245,7 +245,7 @@ retains full fidelity for replay when a future need appears. ## Decisions and rejected alternatives -1. **V1 goes direct: one bridge worker, no `chat.>` subjects yet.** The +1. **V1 goes direct: one bridge worker, no `channel.>` subjects yet.** The neutral vocabulary ships as types in a shared crate; the namespace is the documented extraction path, triggered by a second channel or a second consumer. Rationale: acp-nats already provides the NATS seam and its @@ -276,9 +276,9 @@ retains full fidelity for replay when a future need appears. - `telegram-agent`: its `llm.rs` and `conversation.rs` are the wrong layer (channels must not own a model loop) and disappear. Its consumer skeleton - seeds `chat-bridge-telegram`. + seeds `channel-bridge-telegram`. - `telegram-bot`: its bridge/transform and outbound halves fold into - `chat-bridge-telegram`, re-targeted at the shared-crate types; the typed + `channel-bridge-telegram`, re-targeted at the shared-crate types; the typed Telegram event vocabulary in `telegram-types` is explicitly not the neutral model and shrinks to whatever the bridge still needs internally. - `telegram-nats` (`tgbot.>` subjects, per-prefix streams): transitional, @@ -295,7 +295,7 @@ retains full fidelity for replay when a future need appears. 1. User sends "hello" to the bot on Telegram. Telegram POSTs the webhook; trogon-gateway validates and publishes the raw Update to `telegram.message` (stream `TELEGRAM`). -2. chat-bridge-telegram consumes it, parses the Update, encodes the endpoint +2. channel-bridge-telegram consumes it, parses the Update, encodes the endpoint address, and eager-downloads any attachments into the object store. 3. The bridge resolves endpoint to principal (reject if unknown), endpoint to conversation (create via routing policy if absent, writing the sticky diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 6d81ea6a79..877bbb9b3f 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -1333,7 +1333,7 @@ dependencies = [ ] [[package]] -name = "chat-bridge-telegram" +name = "channel-bridge-telegram" version = "0.1.0" dependencies = [ "acp-nats", diff --git a/rsworkspace/Cargo.toml b/rsworkspace/Cargo.toml index 39b1904749..6d517a0b73 100644 --- a/rsworkspace/Cargo.toml +++ b/rsworkspace/Cargo.toml @@ -47,8 +47,8 @@ trogon-semconv = { path = "crates/platform/trogon-semconv" } trogon-service-config = { path = "crates/platform/trogon-service-config" } trogon-std = { path = "crates/platform/trogon-std" } trogonai-proto = { path = "crates/platform/trogonai-proto" } -trogon-channel = { path = "crates/chat/trogon-channel" } -chat-bridge-telegram = { path = "crates/chat/chat-bridge-telegram" } +trogon-channel = { path = "crates/channel/trogon-channel" } +channel-bridge-telegram = { path = "crates/channel/channel-bridge-telegram" } # A2A a2a = { package = "a2a-lf", version = "=0.3.0" } diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/Cargo.toml b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml similarity index 96% rename from rsworkspace/crates/chat/chat-bridge-telegram/Cargo.toml rename to rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml index 40a2864e10..47a6ff3e05 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/Cargo.toml +++ b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "chat-bridge-telegram" +name = "channel-bridge-telegram" version = "0.1.0" edition = "2024" diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs similarity index 100% rename from rsworkspace/crates/chat/chat-bridge-telegram/src/acp_port.rs rename to rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs similarity index 84% rename from rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs rename to rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs index d87a7050fe..33a3259882 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/config.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs @@ -7,7 +7,7 @@ use trogon_std::env::ReadEnv; pub struct BridgeConfig { pub acp: acp_nats::Config, /// Environment/tenant token for KV buckets and the durable consumer name. - pub chat_prefix: String, + pub channel_prefix: String, /// JetStream stream the trogon-gateway Telegram source provisions. pub inbound_stream: String, pub bot_token: String, @@ -31,38 +31,38 @@ impl BridgeConfig { pub fn from_env(env: &E) -> anyhow::Result { let bot_token = env.var("TELEGRAM_BOT_TOKEN").context("TELEGRAM_BOT_TOKEN not set")?; - let chat_prefix = env.var("CHAT_PREFIX").unwrap_or_else(|_| "prod".to_string()); + let channel_prefix = env.var("CHANNEL_PREFIX").unwrap_or_else(|_| "prod".to_string()); let inbound_stream = env .var("TELEGRAM_INBOUND_STREAM") .unwrap_or_else(|_| "TELEGRAM".to_string()); let bot_account = env.var("TELEGRAM_BOT_ACCOUNT").unwrap_or_else(|_| "bot".to_string()); - let agent_id = env.var("CHAT_AGENT_ID").unwrap_or_else(|_| "default".to_string()); + let agent_id = env.var("CHANNEL_AGENT_ID").unwrap_or_else(|_| "default".to_string()); let agent_cwd = env - .var("CHAT_AGENT_CWD") + .var("CHANNEL_AGENT_CWD") .map(PathBuf::from) .unwrap_or_else(|_| std::env::temp_dir()); - let seed_users = match env.var("CHAT_SEED_TELEGRAM_USERS") { + let seed_users = match env.var("CHANNEL_SEED_TELEGRAM_USERS") { Ok(raw) => raw .split(',') .filter(|s| !s.trim().is_empty()) .map(|s| { s.trim() .parse::() - .with_context(|| format!("invalid Telegram user id in CHAT_SEED_TELEGRAM_USERS: {s:?}")) + .with_context(|| format!("invalid Telegram user id in CHANNEL_SEED_TELEGRAM_USERS: {s:?}")) }) .collect::>>()?, Err(_) => Vec::new(), }; - let command_triggers = match env.var("CHAT_NEW_SESSION_TRIGGERS") { + let command_triggers = match env.var("CHANNEL_NEW_SESSION_TRIGGERS") { Ok(raw) => CommandTriggers::new( raw.split(',') .map(str::trim) .filter(|s| !s.is_empty()) .map(String::from), ) - .context("invalid CHAT_NEW_SESSION_TRIGGERS")?, + .context("invalid CHANNEL_NEW_SESSION_TRIGGERS")?, Err(_) => CommandTriggers::default(), }; @@ -74,7 +74,7 @@ impl BridgeConfig { Ok(Self { acp, - chat_prefix, + channel_prefix, inbound_stream, bot_token, bot_account, diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs similarity index 84% rename from rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs rename to rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs index 6724889632..2d83343dbb 100644 --- a/rsworkspace/crates/chat/chat-bridge-telegram/src/main.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs @@ -1,4 +1,4 @@ -//! Telegram chat bridge: the v1 direct path from the gateway's raw Telegram +//! Telegram channel bridge: the v1 direct path from the gateway's raw Telegram //! stream to an ACP agent. See `docs/architecture/multi-channel-agent-routing.md`. //! //! One worker, two halves: normalize (raw Update -> `InboundEvent`, @@ -20,6 +20,7 @@ use acp_port::{AcpBridge, AcpPort, SessionMethods}; use agent_client_protocol::schema::ProtocolVersion; use agent_client_protocol::schema::v1::InitializeRequest; use anyhow::Context as _; +use async_nats::jetstream::consumer::DeliverPolicy; use config::BridgeConfig; use futures::StreamExt; use outbound::TelegramOutbound; @@ -35,18 +36,24 @@ use trogon_std::fs::SystemFs; use trogon_std::signal::shutdown_signal; use trogon_telemetry::ServiceName; +/// Durable consumer identity on the inbound stream. JetStream keys the ack +/// floor by this string, so it is deployment state rather than a build +/// artifact name: a literal here means a future crate rename cannot silently +/// strand every deployment's position in the stream. +const INBOUND_DURABLE: &str = "channel-bridge-telegram"; + #[tokio::main] async fn main() -> anyhow::Result<()> { let config = BridgeConfig::from_env(&SystemEnv)?; - trogon_telemetry::init_logger(ServiceName::ChatBridgeTelegram, [], &SystemEnv, &SystemFs); + trogon_telemetry::init_logger(ServiceName::ChannelBridgeTelegram, [], &SystemEnv, &SystemFs); - info!("Telegram chat bridge starting"); + info!("Telegram channel bridge starting"); let nats_connect_timeout = acp_nats::nats_connect_timeout(&SystemEnv); let nats_client = acp_nats::nats::connect(config.acp.nats(), nats_connect_timeout).await?; let js = async_nats::jetstream::new(nats_client.clone()); - let store = ChannelStore::ensure(&js, &config.chat_prefix).await?; + let store = ChannelStore::ensure(&js, &config.channel_prefix).await?; seed_principals(&store, &config).await?; let stream = js.get_stream(&config.inbound_stream).await.map_err(|e| { @@ -55,12 +62,17 @@ async fn main() -> anyhow::Result<()> { config.inbound_stream ) })?; - let consumer_name = format!("chat-bridge-telegram-{}", config.chat_prefix); + let consumer_name = format!("{INBOUND_DURABLE}-{}", config.channel_prefix); let consumer = stream .get_or_create_consumer( &consumer_name, async_nats::jetstream::consumer::pull::Config { durable_name: Some(consumer_name.clone()), + // Honoured only when the durable does not exist yet, so a + // first run answers what arrives from now on instead of + // replaying everything the stream still retains. Restarts + // resume from the durable's own ack floor. + deliver_policy: DeliverPolicy::New, // Generous ack window: a prompt turn can legitimately run for // minutes before the turn ends and we ack. ack_wait: std::time::Duration::from_secs(600), @@ -102,7 +114,7 @@ async fn run( bot: Bot, config: BridgeConfig, ) -> anyhow::Result<()> { - let meter = trogon_telemetry::meter("chat-bridge-telegram"); + let meter = trogon_telemetry::meter("channel-bridge-telegram"); let (notification_tx, mut notification_rx) = tokio::sync::mpsc::channel(64); let js_client = trogon_nats::jetstream::NatsJetStreamClient::new(async_nats::jetstream::new(nats_client.clone())); let bridge: Arc = Arc::new(acp_nats::Bridge::new( diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/outbound.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/outbound.rs similarity index 100% rename from rsworkspace/crates/chat/chat-bridge-telegram/src/outbound.rs rename to rsworkspace/crates/channel/channel-bridge-telegram/src/outbound.rs diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs similarity index 100% rename from rsworkspace/crates/chat/chat-bridge-telegram/src/parse.rs rename to rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs similarity index 100% rename from rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline.rs rename to rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs similarity index 100% rename from rsworkspace/crates/chat/chat-bridge-telegram/src/pipeline_tests.rs rename to rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs diff --git a/rsworkspace/crates/chat/chat-bridge-telegram/src/render.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs similarity index 100% rename from rsworkspace/crates/chat/chat-bridge-telegram/src/render.rs rename to rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs diff --git a/rsworkspace/crates/chat/trogon-channel/Cargo.toml b/rsworkspace/crates/channel/trogon-channel/Cargo.toml similarity index 100% rename from rsworkspace/crates/chat/trogon-channel/Cargo.toml rename to rsworkspace/crates/channel/trogon-channel/Cargo.toml diff --git a/rsworkspace/crates/chat/trogon-channel/src/agent_port.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs similarity index 97% rename from rsworkspace/crates/chat/trogon-channel/src/agent_port.rs rename to rsworkspace/crates/channel/trogon-channel/src/agent_port.rs index 2ccb8ba988..9e16660c8c 100644 --- a/rsworkspace/crates/chat/trogon-channel/src/agent_port.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs @@ -74,7 +74,7 @@ pub struct SessionRelease { pub closed: ReleaseStep, } -/// The one seam between chat routing and agent protocols. One implementation +/// The one seam between channel routing and agent protocols. One implementation /// per protocol (ACP first; A2A and HTTP later); the implementation owns all /// protocol specifics including how streamed agent output reaches the /// renderer. Deliberately not a NATS namespace: protocol-neutral agent diff --git a/rsworkspace/crates/chat/trogon-channel/src/command.rs b/rsworkspace/crates/channel/trogon-channel/src/command.rs similarity index 100% rename from rsworkspace/crates/chat/trogon-channel/src/command.rs rename to rsworkspace/crates/channel/trogon-channel/src/command.rs diff --git a/rsworkspace/crates/chat/trogon-channel/src/conversation.rs b/rsworkspace/crates/channel/trogon-channel/src/conversation.rs similarity index 100% rename from rsworkspace/crates/chat/trogon-channel/src/conversation.rs rename to rsworkspace/crates/channel/trogon-channel/src/conversation.rs diff --git a/rsworkspace/crates/chat/trogon-channel/src/endpoint.rs b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs similarity index 97% rename from rsworkspace/crates/chat/trogon-channel/src/endpoint.rs rename to rsworkspace/crates/channel/trogon-channel/src/endpoint.rs index 9f9a134d88..5afa359e98 100644 --- a/rsworkspace/crates/chat/trogon-channel/src/endpoint.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs @@ -17,7 +17,7 @@ pub enum EndpointError { } /// Where a message arrives and leaves: a platform, a bot account on it, and a -/// chat/user on that platform. Many endpoints can point at one conversation. +/// peer on that platform. Many endpoints can point at one conversation. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Endpoint { channel: String, diff --git a/rsworkspace/crates/chat/trogon-channel/src/event.rs b/rsworkspace/crates/channel/trogon-channel/src/event.rs similarity index 95% rename from rsworkspace/crates/chat/trogon-channel/src/event.rs rename to rsworkspace/crates/channel/trogon-channel/src/event.rs index 863c79df0d..7ce86ff650 100644 --- a/rsworkspace/crates/chat/trogon-channel/src/event.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event.rs @@ -21,7 +21,7 @@ pub struct Attachment { } /// A normalized inbound message: what any channel bridge produces after -/// stripping its platform's shape. This type is the `chat.*.in.*` payload +/// stripping its platform's shape. This type is the `channel.*.in.*` payload /// once the multi-channel extraction happens; until then it travels /// in-process. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/rsworkspace/crates/chat/trogon-channel/src/lib.rs b/rsworkspace/crates/channel/trogon-channel/src/lib.rs similarity index 94% rename from rsworkspace/crates/chat/trogon-channel/src/lib.rs rename to rsworkspace/crates/channel/trogon-channel/src/lib.rs index d1ce3896de..70973c96c4 100644 --- a/rsworkspace/crates/chat/trogon-channel/src/lib.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/lib.rs @@ -3,7 +3,7 @@ //! See `docs/architecture/multi-channel-agent-routing.md`. This crate owns the //! vocabulary (endpoints, principals, conversations, inbound events, render //! commands), the JetStream KV registries, and the [`AgentPort`] trait through -//! which bridges reach agents. Channel binaries (e.g. `chat-bridge-telegram`) +//! which bridges reach agents. Channel binaries (e.g. `channel-bridge-telegram`) //! contain platform I/O only; nothing in this crate may reference a specific //! platform or agent protocol. //! diff --git a/rsworkspace/crates/chat/trogon-channel/src/render.rs b/rsworkspace/crates/channel/trogon-channel/src/render.rs similarity index 90% rename from rsworkspace/crates/chat/trogon-channel/src/render.rs rename to rsworkspace/crates/channel/trogon-channel/src/render.rs index 92782f3cc5..6928b40cf3 100644 --- a/rsworkspace/crates/chat/trogon-channel/src/render.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/render.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; /// The channel-neutral output vocabulary: the one contract every channel /// bridge implements. Kept deliberately small; platform-specific richness /// (e.g. Telegram inline buttons) rides agent `_meta` and is rendered by the -/// bridge that understands it. This enum is the `chat.*.out.*` payload once +/// bridge that understands it. This enum is the `channel.*.out.*` payload once /// the multi-channel extraction happens. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "command", rename_all = "snake_case")] diff --git a/rsworkspace/crates/chat/trogon-channel/src/store.rs b/rsworkspace/crates/channel/trogon-channel/src/store.rs similarity index 91% rename from rsworkspace/crates/chat/trogon-channel/src/store.rs rename to rsworkspace/crates/channel/trogon-channel/src/store.rs index 795df108b4..a4a8e24304 100644 --- a/rsworkspace/crates/chat/trogon-channel/src/store.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store.rs @@ -41,7 +41,7 @@ async fn ensure_bucket(js: &jetstream::Context, bucket: String) -> Result Result Result { Ok(Self { - principals: ensure_bucket(js, format!("chat_principals_{prefix}")).await?, - endpoints: ensure_bucket(js, format!("chat_endpoints_{prefix}")).await?, - bindings: ensure_bucket(js, format!("chat_bindings_{prefix}")).await?, - conversations: ensure_bucket(js, format!("chat_conversations_{prefix}")).await?, + principals: ensure_bucket(js, format!("channel_principals_{prefix}")).await?, + endpoints: ensure_bucket(js, format!("channel_endpoints_{prefix}")).await?, + bindings: ensure_bucket(js, format!("channel_bindings_{prefix}")).await?, + conversations: ensure_bucket(js, format!("channel_conversations_{prefix}")).await?, }) } diff --git a/rsworkspace/crates/platform/trogon-telemetry/src/service_name.rs b/rsworkspace/crates/platform/trogon-telemetry/src/service_name.rs index 961ee3efea..cb3dc975df 100644 --- a/rsworkspace/crates/platform/trogon-telemetry/src/service_name.rs +++ b/rsworkspace/crates/platform/trogon-telemetry/src/service_name.rs @@ -14,7 +14,7 @@ pub enum ServiceName { TrogonSourceLinear, TrogonSourceSlack, TrogonSourceTelegram, - ChatBridgeTelegram, + ChannelBridgeTelegram, } impl ServiceName { @@ -31,7 +31,7 @@ impl ServiceName { Self::TrogonSourceLinear => "trogon-source-linear", Self::TrogonSourceSlack => "trogon-source-slack", Self::TrogonSourceTelegram => "trogon-source-telegram", - Self::ChatBridgeTelegram => "chat-bridge-telegram", + Self::ChannelBridgeTelegram => "channel-bridge-telegram", } } } diff --git a/rsworkspace/crates/platform/trogon-telemetry/src/service_name/tests.rs b/rsworkspace/crates/platform/trogon-telemetry/src/service_name/tests.rs index e9ef962098..54913f4930 100644 --- a/rsworkspace/crates/platform/trogon-telemetry/src/service_name/tests.rs +++ b/rsworkspace/crates/platform/trogon-telemetry/src/service_name/tests.rs @@ -13,6 +13,7 @@ fn as_str_returns_expected_values() { assert_eq!(ServiceName::TrogonSourceLinear.as_str(), "trogon-source-linear"); assert_eq!(ServiceName::TrogonSourceSlack.as_str(), "trogon-source-slack"); assert_eq!(ServiceName::TrogonSourceTelegram.as_str(), "trogon-source-telegram"); + assert_eq!(ServiceName::ChannelBridgeTelegram.as_str(), "channel-bridge-telegram"); } #[test] @@ -31,4 +32,8 @@ fn display_delegates_to_as_str() { format!("{}", ServiceName::TrogonSourceTelegram), "trogon-source-telegram" ); + assert_eq!( + format!("{}", ServiceName::ChannelBridgeTelegram), + "channel-bridge-telegram" + ); } From 5e0600f9d32584053c7b8a0d860daa7b3b9286ac Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 2 Aug 2026 02:50:02 -0400 Subject: [PATCH 05/55] refactor(channel): mint conversation ids the way the rest of the workspace does Conversation ids are KV keys, so a random v4 threw away the creation ordering every other durable id in the workspace gets for free. The v4 here was the last one outside the correlation-token cases where ordering genuinely does not matter. Taking the generator as an argument keeps the crate honest about the clock discipline it already documents one field away. Signed-off-by: Yordis Prieto --- rsworkspace/Cargo.lock | 2 +- .../channel-bridge-telegram/Cargo.toml | 2 +- .../channel-bridge-telegram/src/main.rs | 2 ++ .../channel-bridge-telegram/src/pipeline.rs | 11 +++++-- .../src/pipeline_tests.rs | 2 ++ .../crates/channel/trogon-channel/Cargo.toml | 3 +- .../trogon-channel/src/conversation.rs | 30 +++++++++++++++++-- .../channel/trogon-channel/src/store.rs | 4 ++- 8 files changed, 47 insertions(+), 9 deletions(-) diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 877bbb9b3f..07f98bece6 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -6858,7 +6858,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tracing", - "uuid", + "trogon-std", ] [[package]] diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml index 47a6ff3e05..a5aa64868a 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml +++ b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml @@ -10,7 +10,7 @@ workspace = true acp-nats = { workspace = true } trogon-channel = { workspace = true } trogon-nats = { workspace = true } -trogon-std = { workspace = true, features = ["signal"] } +trogon-std = { workspace = true, features = ["signal", "uuid"] } trogon-telemetry = { workspace = true } agent-client-protocol = { workspace = true } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs index 2d83343dbb..012d546e9c 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs @@ -31,6 +31,7 @@ use teloxide::Bot; use tracing::{error, info, warn}; use trogon_channel::store::PrincipalRecord; use trogon_channel::{ChannelStore, Endpoint, PrincipalId}; +use trogon_std::UuidV7Generator; use trogon_std::env::SystemEnv; use trogon_std::fs::SystemFs; use trogon_std::signal::shutdown_signal; @@ -162,6 +163,7 @@ async fn run( bot_account: &config.bot_account, agent_id: &config.agent_id, triggers: &config.command_triggers, + ids: &UuidV7Generator, }; let shutdown = shutdown_signal(); diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs index 679c1d7473..423b257e0e 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -11,13 +11,14 @@ use trogon_channel::{ AgentId, AgentPort, AgentPortError as _, ChannelStore, Command, CommandTriggers, ConversationId, ConversationRecord, InboundEvent, ReleaseReason, }; +use trogon_std::NowV7; /// What the bridge says back when a command has nothing else to do. A reset /// with no follow-up prompt produces no agent output, so without this the user /// gets silence. const NEW_SESSION_ACKNOWLEDGEMENT: &str = "Started a new session."; -pub struct Pipeline<'a, P, O> { +pub struct Pipeline<'a, P, O, G> { pub store: &'a ChannelStore, pub port: &'a P, pub renderer: &'a TelegramRenderClient, @@ -25,6 +26,7 @@ pub struct Pipeline<'a, P, O> { pub bot_account: &'a str, pub agent_id: &'a str, pub triggers: &'a CommandTriggers, + pub ids: &'a G, } fn now_unix() -> i64 { @@ -37,7 +39,7 @@ async fn ack(msg: &async_nats::jetstream::Message) -> anyhow::Result<()> { msg.ack().await.map_err(|e| anyhow::anyhow!("ack failed: {e}")) } -impl Pipeline<'_, P, O> { +impl Pipeline<'_, P, O, G> { /// Whether the individual who sent this message is a known principal. The /// conversation gate authorizes the chat, which in a group is everyone in /// it; destructive commands ask the narrower question. @@ -117,7 +119,10 @@ impl Pipeline<'_, P, O> { created_at: now, last_activity_at: now, }; - let id = self.store.create_conversation(&event.endpoint, &record).await?; + let id = self + .store + .create_conversation(&event.endpoint, &record, self.ids) + .await?; info!(conversation = %id, endpoint = %event.endpoint, agent = %record.agent_id, "Created conversation"); (id, record) } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs index 5d9d183e59..a1ec6a4d08 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs @@ -11,6 +11,7 @@ use trogon_channel::store::PrincipalRecord; use trogon_channel::{ AgentPortError, AgentSessionId, Endpoint, InboundEvent, PrincipalId, PromptOutcome, ReleaseStep, SessionRelease, }; +use trogon_std::UuidV7Generator; struct NatsServer { _container: ContainerAsync, @@ -203,6 +204,7 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { bot_account: "mybot", agent_id: "default", triggers: &triggers, + ids: &UuidV7Generator, }; for _ in 0..6 { diff --git a/rsworkspace/crates/channel/trogon-channel/Cargo.toml b/rsworkspace/crates/channel/trogon-channel/Cargo.toml index 69b737c058..3506d1780e 100644 --- a/rsworkspace/crates/channel/trogon-channel/Cargo.toml +++ b/rsworkspace/crates/channel/trogon-channel/Cargo.toml @@ -7,9 +7,10 @@ edition = "2024" workspace = true [dependencies] +trogon-std = { workspace = true, features = ["uuid"] } + async-nats = { workspace = true, features = ["jetstream", "kv"] } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } -uuid = { workspace = true } diff --git a/rsworkspace/crates/channel/trogon-channel/src/conversation.rs b/rsworkspace/crates/channel/trogon-channel/src/conversation.rs index ec6a4d0645..2add0b6e33 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/conversation.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/conversation.rs @@ -1,6 +1,7 @@ use crate::agent_port::AgentSessionId; use crate::endpoint::PrincipalId; use serde::{Deserialize, Serialize}; +use trogon_std::NowV7; /// Which configured agent a conversation is bound to. Resolution from id to /// protocol + address is bridge/router configuration, never stored here. @@ -27,8 +28,12 @@ impl std::fmt::Display for AgentId { pub struct ConversationId(String); impl ConversationId { - pub fn generate() -> Self { - Self(uuid::Uuid::new_v4().simple().to_string()) + /// Opaque and time-ordered: this doubles as the conversation KV key, so + /// v7 makes the bucket list in creation order. The generator is passed in + /// for the same reason `ConversationRecord::created_at` is: no ambient + /// clock in this crate. + pub fn generate(ids: &impl NowV7) -> Self { + Self(ids.now_v7().simple().to_string()) } pub fn from_string(id: impl Into) -> Self { @@ -59,3 +64,24 @@ pub struct ConversationRecord { pub created_at: i64, pub last_activity_at: i64, } + +#[cfg(test)] +mod tests { + use super::*; + use trogon_std::UuidV7Generator; + + #[test] + fn generated_ids_are_v7_in_simple_form() { + let id = ConversationId::generate(&UuidV7Generator); + assert_eq!(id.as_str().len(), 32); + assert!(id.as_str().chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(id.as_str().chars().nth(12), Some('7'), "version nibble"); + } + + #[test] + fn generated_ids_sort_in_creation_order() { + let first = ConversationId::generate(&UuidV7Generator); + let second = ConversationId::generate(&UuidV7Generator); + assert!(first.as_str() < second.as_str()); + } +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/store.rs b/rsworkspace/crates/channel/trogon-channel/src/store.rs index a4a8e24304..1f427922cc 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store.rs @@ -3,6 +3,7 @@ use crate::endpoint::{Endpoint, PrincipalId}; use async_nats::jetstream; use serde::{Deserialize, Serialize}; use tracing::info; +use trogon_std::NowV7; #[derive(Debug, thiserror::Error)] pub enum ChannelStoreError { @@ -110,8 +111,9 @@ impl ChannelStore { &self, endpoint: &Endpoint, record: &ConversationRecord, + ids: &impl NowV7, ) -> Result { - let id = ConversationId::generate(); + let id = ConversationId::generate(ids); self.conversations .put(id.as_str(), serde_json::to_vec(record)?.into()) .await?; From a4adc053538d2d5b1f0b9806dda84605f3765378 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 2 Aug 2026 02:58:00 -0400 Subject: [PATCH 06/55] fix(repo): restore the ignore rules a wholesale block rewrite dropped Internal notes were only staying out of the index because of a machine-local global ignore, so the repo did not protect itself on a fresh clone or in CI. Ignoring .dockerignore was the inverse mistake: build context config belongs in version control. Signed-off-by: Yordis Prieto --- .gitignore | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index d024647c16..1fff7a205b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,10 @@ Thumbs.db # Logs *.log +# trogonai internal +.trogonai/ +*.internal.trogonai.md + # Coverage lcov.info *.lcov @@ -47,9 +51,6 @@ coverage-*.xml *.profraw *.profdata -# Docker -.dockerignore - # NATS *.creds From 9e8a548f49b0629b48d395b5450b60d3ccbf9628 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 2 Aug 2026 03:24:46 -0400 Subject: [PATCH 07/55] docs(adr): record where inbound media redemption belongs The eager claim-check the routing design assumed was never built, and it forced every turn to pay for media the agent may never read while pushing platform credentials toward a component whose value is being generic. Signed-off-by: Yordis Prieto --- .../0044-inbound-media-fetch-out-of-band.md | 166 ++++++++++++++++++ docs/adr/index.md | 1 + .../multi-channel-agent-routing.md | 44 +++-- .../channel/trogon-channel/src/event.rs | 8 +- 4 files changed, 198 insertions(+), 21 deletions(-) create mode 100644 docs/adr/0044-inbound-media-fetch-out-of-band.md diff --git a/docs/adr/0044-inbound-media-fetch-out-of-band.md b/docs/adr/0044-inbound-media-fetch-out-of-band.md new file mode 100644 index 0000000000..bcf9acbaf3 --- /dev/null +++ b/docs/adr/0044-inbound-media-fetch-out-of-band.md @@ -0,0 +1,166 @@ +--- +number: "0044" +slug: inbound-media-fetch-out-of-band +status: draft +date: 2026-08-02 +--- + +# ADR#0044: Inbound Media Is Fetched Out of Band by a Dedicated Consumer + +## Context + +A chat platform does not deliver media inline. It delivers an opaque handle +(a Telegram `file_id`, a Slack `file.url_private`, a Discord attachment URL) +that only the holder of the platform credential can redeem. Something in this +system has to do that redemption, and where it happens determines which +processes need platform credentials, which components stop being generic, and +what a conversational turn has to wait for. + +[The multi-channel routing design](../architecture/multi-channel-agent-routing.md) +originally recorded "eager claim-check": the bridge downloads media at +normalize time, before dispatching the prompt. That decision was never +implemented. `channel-bridge-telegram`'s parser drops every media update +(`parse.rs` returns `None` for any message without text and hardcodes +`attachments: Vec::new()`), so nothing is sunk and the decision is open. + +Three properties of the problem constrain the answer: + +1. **Redemption needs the platform credential.** Whoever fetches holds the bot + token. This is the whole reason a handle cannot simply be passed downstream + to a credential-free consumer. +2. **Redemption is slow and optional.** A turn that is pure text, or where the + agent never opens the attached file, pays nothing for media it does not + read. Fetching before dispatch makes every turn pay for the worst case. +3. **Handles are durable; redemption URLs are not.** A Telegram `file_id` is + permanent and redeemable by the token holder indefinitely. What expires + (roughly one hour) is the `file_path` that `getFile` returns. The original + design rejected lazy fetch partly on "platform download URLs expire," which + is true of the second step only and does not rule out deferring the first. + +Three placements were considered. + +**In the gateway.** `trogon-gateway` already holds a `TelegramBotToken` for +webhook registration, and the claim-check machinery with `ObjectStorePut` and +`ObjectStoreGet` already exists generically in `trogon-nats`. Both pieces are +in place, so the objection is not capability. The objection is scope: the +gateway is a verbatim transport shared across GitHub, GitLab, Linear, Slack, +Discord, and Telegram sources, and its stated contract is raw fidelity. Media +fetching would make one source materially smarter than its siblings and would +add an object-store dependency to a component whose value is being dumb. It +also cannot be done in the request path at all: Telegram retries webhooks that +do not return promptly, so a download inside the handler trades a fast ingress +for a slow one. + +**In the bridge, before dispatch.** The original decision. It concentrates the +credential in a component that already has it and needs no coordination, but it +makes property 2 impossible: every turn waits for media the agent may never +read, on a path that is already fully serial. + +**In a dedicated consumer.** JetStream already supports multiple independent +durables on one stream, so a second consumer of the raw stream costs no new +transport and no gateway change. + +## Decision + +### 1. The gateway stays a verbatim transport + +`trogon-gateway` does not fetch media, does not depend on an object store, and +does not gain per-source intelligence. Its contract remains raw fidelity from +webhook to stream. This is a deliberate purchase: we accept a third credential +holder (below) to keep the ingress generic. + +### 2. A dedicated downloader consumes the raw stream on its own durable + +A per-platform downloader (`channel-downloader-telegram` first) takes its own +durable consumer on the same raw stream the bridge reads, redeems the platform +handle with the bot token, and writes bytes through the existing +`ObjectStorePut` in `trogon-nats`. It is size-capped, and the cap is its own +configuration rather than the bridge's. + +This is deliberately not a request/reply service. It is driven by the same +stream as the bridge, so a fetch begins at ingestion whether or not any agent +ever asks for the file, and the freshness window for `getFile` is entered +immediately rather than at some later moment of the agent's choosing. + +### 3. Readiness is a KV status record, not an object-store lookup + +An object store cannot answer "not yet." A `get` on a missing key returns +not-found, which is indistinguishable from a permanent failure and from a dead +downloader. Readiness therefore lives in its own JetStream KV bucket, keyed by +the platform handle: + +``` +channel_media_{prefix}: + -> { state: ready | failed, object_ref, mime, size, error } +``` + +Absence means in flight. That is unambiguous because any reader derives the key +from a handle it parsed out of the same stream message the downloader is +working on, so the reader already knows the file exists. + +Readers await readiness with a KV watch and a deadline, not a poll. A late +reader observes current state directly with no replay concern, and a deadline +expiry is reported to the agent as an unavailable attachment rather than as a +turn failure. + +### 4. The inbound event carries the handle, never the object reference + +`Attachment` on the inbound event carries `platform_ref` and drops +`object_ref`. The event states that a photo exists and gives its handle; +resolving that handle to bytes is a lookup performed later, not a field +populated earlier. An event that carried `object_ref` would be asserting the +presence of bytes that may not exist yet. + +Outbound is not symmetric and does not change. `RenderCommand::SendAttachment` +keeps its `object_ref` because the agent produced that file and already put it +in the object store; there is no handle to redeem and nothing to wait for. + +### 5. The turn does not block on media; the agent's tool does + +The bridge builds the inbound event and dispatches the prompt without waiting. +Waiting happens inside the agent-facing download tool, at the moment the agent +actually opens the file. Text-only turns and turns that ignore an attachment +pay nothing. + +## Invariants + +- The gateway never holds an object-store dependency and never interprets a + source's payload beyond what publishing requires. +- No component blocks a conversational turn on media the agent has not asked + for. +- Any component that redeems a platform handle holds that platform's + credential; no credential-free component is ever handed a handle it is + expected to resolve. +- Readiness is always observable as an explicit state. "Bytes absent from the + object store" is never interpreted as a lifecycle signal. +- An inbound event never asserts the existence of bytes that have not been + written. + +## Consequences + +- **The bot token lives in three processes**: the gateway (webhook + registration), the bridge (Bot API sends), and the downloader (`getFile`). + This is the direct cost of keeping the gateway generic, and it is accepted. + The evolution path already recorded in the routing design, a generic + gateway **sink** concept that would centralize outbound token custody, is + the eventual consolidation and remains out of scope here. +- **A third worker appears, but only when media does.** The routing design's + "two workers total" claim holds until the first platform that carries media + is supported. Nothing needs to be built before then. +- **A new KV bucket** (`channel_media_{prefix}`) joins the four the channel + store already provisions. +- **Failure is legible.** A download that fails permanently is a `failed` + record with a reason, distinguishable from one still in flight, so an agent + can be told the difference. +- **The downloader can be restarted or backfilled independently.** Because it + is a durable consumer of a retained raw stream rather than a request/reply + service, a downloader that was down comes back and works through what it + missed without the bridge participating. +- **Two consumers now read the same raw stream.** This is ordinary JetStream + usage, but it does mean the bridge no longer has exclusive knowledge of what + arrived, and the two consumers' positions can differ. + +## References + +- [Multi-Channel Agent Routing](../architecture/multi-channel-agent-routing.md) +- [ADR#0024: Agent Platform Stream Topology](./0024-agent-platform-stream-topology.md) diff --git a/docs/adr/index.md b/docs/adr/index.md index fa52dff471..bf59ba928d 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -49,3 +49,4 @@ future implementation work. - [ADR#0041: Canonical MCP JSON-RPC Bodies over NATS (Draft)](./0041-canonical-mcp-jsonrpc-bodies-over-nats.md) - [ADR#0042: NATS Trace Context and Message Path Tracing (Draft)](./0042-nats-trace-context-and-message-path-tracing.md) - [ADR#0043: Agent Instructions Ownership and Shape (Draft)](./0043-agent-instructions-ownership-and-shape.md) +- [ADR#0044: Inbound Media Is Fetched Out of Band by a Dedicated Consumer (Draft)](./0044-inbound-media-fetch-out-of-band.md) diff --git a/docs/architecture/multi-channel-agent-routing.md b/docs/architecture/multi-channel-agent-routing.md index 289c13e30c..d3b4c63189 100644 --- a/docs/architecture/multi-channel-agent-routing.md +++ b/docs/architecture/multi-channel-agent-routing.md @@ -21,22 +21,27 @@ designed channel-neutral from day one even while only Telegram exists. SUBJECTS / PROTOCOL WORKER Telegram ─HTTP─▶ telegram.{update_type} trogon-gateway (exists) - stream TELEGRAM, raw verbatim JSON + stream TELEGRAM, raw verbatim JSON (inbound path only) │ ▼ durable consumer normalize: parse Update, endpoint, channel-bridge-telegram - eager-download attachments (one worker) - identity + binding via KV + identity + binding via KV, (one worker, both halves) dispatch prompt via AgentPort │ ▼ acp-nats (already NATS-native) ═══ agent works, streams notifications ═══ │ ▼ render notifications - Telegram Bot API calls channel-bridge-telegram - (send, edit-in-place, chunk, throttle) + Telegram Bot API calls ─HTTPS─▶ channel-bridge-telegram + (send, edit-in-place, chunk, throttle) (same process as above) ``` +**The two legs are not symmetric.** Inbound goes through the gateway, which +owns the webhook and publishes verbatim. Outbound does not: the bridge holds a +`teloxide::Bot` and calls `send_message`, `edit_message_text`, and +`send_chat_action` against the Telegram API itself. The gateway has no outbound +role in v1, which is why the bot token lives in both processes. + Two workers total, one of which already exists. The bridge is the fusion of what the end state calls the "edge" and the "router". We fuse them because: @@ -150,7 +155,7 @@ extraction): sender: { platform_user_id, display_name }, text: string | null, command: bridge command | null, - attachments: [ { kind, mime, size, object_ref, platform_ref } ], + attachments: [ { kind, mime, size, platform_ref } ], message_ref: platform message id (for dedup, replies, edits), occurred_at: timestamp } @@ -180,13 +185,13 @@ The render vocabulary is the one contract every channel implements; it stays small on purpose. Both reference systems studied (OpenClaw, Hermes) converged on essentially this set. -**Attachments are eager claim-check.** At normalize time the bridge downloads -the media from the platform (only the token holder can redeem a Telegram -`file_id`), stores the bytes in the object store, and the event carries the -reference. Nothing downstream ever needs platform credentials or a callback. -Lazy fetch-on-demand was rejected: it needs a request/reply surface, fails -mid-conversation instead of at ingestion, and platform download URLs expire. -Size is capped at the bridge. +**Inbound media is fetched out of band** by a dedicated downloader on its own +durable consumer of the raw stream, never by the gateway and never inline in a +turn. The inbound event carries only `platform_ref`; readiness lives in a +`channel_media_{prefix}` KV record that a reader awaits by watch, at the moment +the agent opens the file. Outbound is not symmetric: `send_attachment` keeps +its `object_ref`, because the agent produced that file and there is nothing to +redeem. See [ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md). ## Agent dispatch: the AgentPort trait @@ -264,13 +269,17 @@ retains full fidelity for replay when a future need appears. creation.** Live conversations never hop agents because config changed. 5. **State in JetStream KV, not config files.** Admin surface out of band and unspecified (CLI/config now, GUI or MCP later). -6. **Eager claim-check attachments**, size-capped at the bridge. +6. **Inbound media fetched out of band** by a dedicated stream consumer, with + readiness in KV and the wait deferred to the agent's own tool call + ([ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md)). 7. **Platform structure over ACP via `_meta`**, dual-carried (text for any agent, `_meta` for participating agents); interactivity degrades gracefully with non-participating agents. 8. **Text rendering via edit-in-place streaming** (`edit_text`), the pattern both OpenClaw and Hermes converged on. -9. **No ADRs for this**: local domain design, recorded here. +9. **Recorded here, not in ADRs**, except where a decision constrains a + component outside this design. Media placement did, because it decides what + the gateway is allowed to become, so it is [ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md). ## Consequences for existing crates @@ -295,8 +304,9 @@ retains full fidelity for replay when a future need appears. 1. User sends "hello" to the bot on Telegram. Telegram POSTs the webhook; trogon-gateway validates and publishes the raw Update to `telegram.message` (stream `TELEGRAM`). -2. channel-bridge-telegram consumes it, parses the Update, encodes the endpoint - address, and eager-downloads any attachments into the object store. +2. channel-bridge-telegram consumes it, parses the Update, and encodes the + endpoint address. Any attachment contributes its `platform_ref` and nothing + is downloaded on this path. 3. The bridge resolves endpoint to principal (reject if unknown), endpoint to conversation (create via routing policy if absent, writing the sticky `agent_id`), and ensures a live session on that agent through the ACP diff --git a/rsworkspace/crates/channel/trogon-channel/src/event.rs b/rsworkspace/crates/channel/trogon-channel/src/event.rs index 7ce86ff650..a5abae7d5d 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event.rs @@ -8,15 +8,15 @@ pub struct Sender { pub display_name: String, } -/// Media that arrived with a message, already claim-checked: the bytes live in -/// the object store and `object_ref` points at them. `platform_ref` keeps the -/// platform's own handle (e.g. a Telegram `file_id`) for provenance. +/// Media that arrived with a message, as a handle rather than as bytes. +/// `platform_ref` is the platform's own reference (e.g. a Telegram `file_id`); +/// redeeming it happens out of band, so this type never asserts that bytes +/// exist yet. See ADR#0044. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Attachment { pub kind: String, pub mime: String, pub size: u64, - pub object_ref: String, pub platform_ref: String, } From cad6254e3d92c1627cb61aef5a20288d735f62e9 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 2 Aug 2026 03:57:36 -0400 Subject: [PATCH 08/55] docs(channel): define the endpoint vocabulary before the topology that uses it Signed-off-by: Yordis Prieto --- .../multi-channel-agent-routing.md | 105 +++++++++++++----- 1 file changed, 76 insertions(+), 29 deletions(-) diff --git a/docs/architecture/multi-channel-agent-routing.md b/docs/architecture/multi-channel-agent-routing.md index d3b4c63189..5ec30d28a8 100644 --- a/docs/architecture/multi-channel-agent-routing.md +++ b/docs/architecture/multi-channel-agent-routing.md @@ -59,38 +59,49 @@ The guard that keeps this from becoming a monolith: everything channel-neutral and render-command types) lives in a **shared crate**, not in the Telegram binary. A second channel imports the same brain; it never copies it. -## The multi-channel end state - -When a second channel or a second consumer of conversations (audit, analytics) -arrives, the bridge splits along the seams the shared crate already defines: +## Domain model -``` -telegram.{update_type} (stream TELEGRAM) trogon-gateway - │ - ▼ -channel.{prefix}.in.{channel}.{account}.{peer} channel-bridge-telegram -stream CHANNEL_IN_{prefix}, neutral inbound events (normalize half) - │ - ▼ -[identity, binding, conversation KV, channel-router - per-conversation serialization, AgentPort] (generic, channel-blind) - │ - ▼ -channel.{prefix}.out.{channel}.{account}.{peer} channel-router publishes, -stream CHANNEL_OUT_{prefix}, render commands channel-bridge-telegram - │ (render half) consumes - ▼ -platform API calls -``` +Four words carry this whole design, so here they are with values taken from the +running code. -- `{channel}.{account}.{peer}` is the **endpoint address**; tokens must be - subject-safe and edges own the encoding. -- The router subscribes `channel.{prefix}.in.>` and is channel-blind; a new - channel is a new edge binary and zero router changes. -- The subjects carry exactly the types the shared crate already defines; the - extraction is deployment surgery, not schema design. +An **endpoint** is a mailbox: one place messages arrive and leave. It is three +tokens, nothing more. -## Domain model +| Token | What it is | A real value | +| --- | --- | --- | +| `channel` | which platform | `telegram` | +| `account` | which of our bots on that platform | `mybot` | +| `peer` | which chat on the far side | `-1001234567890` (a group), `42` (a DM) | + +Joined, that is `telegram.mybot.-1001234567890`, meaning "the chat +-1001234567890, talking to @mybot, on Telegram". That exact string is the KV key +today (`Endpoint::kv_key`) and becomes the tail of the NATS subject after +extraction. One value, two uses, no re-encoding, which is the reason the tokens +are restricted to characters that both KV keys and subject tokens accept. + +**`peer` is a chat, not a person.** Everyone in a group shares one endpoint, +which is why the sender is checked separately before a destructive command: the +chat being allowed says nothing about who spoke in it. + +A **principal** is the human. One person can hold several endpoints (a Telegram +DM, a Discord DM, the CLI) and they all resolve to the same principal. That is +the only thing that makes "continue this conversation somewhere else" mean +anything. + +A **conversation** is the context the agent works in, and it is the root +object: endpoints point at it, never the reverse. + +A **binding** is one KV entry, `endpoint -> conversation id`, and nothing more. +A message arrives, the bridge looks up its endpoint, and either finds a +conversation id or does not. If it does not, this is a new conversation: +routing policy picks the agent once, the conversation is created, and the entry +is written. The word makes it sound like a subsystem; it is a lookup table with +one column. + +`{prefix}`, which appears in bucket and stream names below, is none of the +above. It is the deployment namespace (`CHANNEL_PREFIX`, default `prod`) so +staging and production can share a NATS cluster without sharing state. It never +appears inside an endpoint. ``` endpoint (channel, account, peer) where messages arrive and leave @@ -125,6 +136,42 @@ conversation the shared context, cross-channel from and the response renders there. Per-conversation serialization is mandatory: prompts from two channels into one session queue in order. +## The multi-channel end state + +When a second channel or a second consumer of conversations (audit, analytics) +arrives, the bridge splits along the seams the shared crate already defines: + +``` +telegram.{update_type} (stream TELEGRAM) trogon-gateway + │ + ▼ +channel.{prefix}.in.{channel}.{account}.{peer} channel-bridge-telegram +stream CHANNEL_IN_{prefix}, neutral inbound events (normalize half) + │ + ▼ +[identity, binding, conversation KV, channel-router + per-conversation serialization, AgentPort] (generic, channel-blind) + │ + ▼ +channel.{prefix}.out.{channel}.{account}.{peer} channel-router publishes, +stream CHANNEL_OUT_{prefix}, render commands channel-bridge-telegram + │ (render half) consumes + ▼ +platform API calls +``` + +Filled in, an inbound subject reads +`channel.prod.in.telegram.mybot.-1001234567890`: the deployment, the direction, +then the endpoint address unchanged from its KV form. + +- The last three tokens are the **endpoint address** defined above. Edges own + the encoding, which is why `Endpoint` refuses tokens that would not survive + as a subject. +- The router subscribes `channel.{prefix}.in.>` and is channel-blind; a new + channel is a new edge binary and zero router changes. +- The subjects carry exactly the types the shared crate already defines; the + extraction is deployment surgery, not schema design. + ## State: JetStream KV buckets All stateful registries live in JetStream KV, owned exclusively by the bridge From ef6c9ecd801193a6009915ae19b6b99c07e4fe1c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 3 Aug 2026 11:46:40 -0400 Subject: [PATCH 09/55] docs(channel): write down that the chat, not the speaker, is the unit of identity The group semantics were only discoverable by reading the pipeline, and the one place they surprise a reader is authorization: linking a room authorizes everyone in it, present and future. The routing page was also unreachable from the site nav, so none of this vocabulary was findable. Signed-off-by: Yordis Prieto --- docs/.vitepress/config.mts | 4 ++ docs/.vitepress/helpers.ts | 1 + .../multi-channel-agent-routing.md | 64 ++++++++++++++++--- docs/glossary/binding.md | 18 ++++++ docs/glossary/channel.md | 15 +++++ docs/glossary/conversation.md | 18 ++++++ docs/glossary/endpoint.md | 20 ++++++ docs/glossary/index.md | 13 ++++ docs/glossary/principal.md | 23 +++++++ 9 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 docs/glossary/binding.md create mode 100644 docs/glossary/channel.md create mode 100644 docs/glossary/conversation.md create mode 100644 docs/glossary/endpoint.md create mode 100644 docs/glossary/principal.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e884c20e51..bfa6acad10 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -43,6 +43,10 @@ export default async () => { { text: "Key Custody", link: "/architecture/key-custody" }, { text: "Key Management", link: "/architecture/key-management" }, { text: "Key States", link: "/architecture/key-states" }, + { + text: "Multi-Channel Agent Routing", + link: "/architecture/multi-channel-agent-routing", + }, { text: "OpenTelemetry Transport Context", link: "/architecture/opentelemetry-transport-context", diff --git a/docs/.vitepress/helpers.ts b/docs/.vitepress/helpers.ts index 0a14e435de..fab5192f2a 100644 --- a/docs/.vitepress/helpers.ts +++ b/docs/.vitepress/helpers.ts @@ -64,6 +64,7 @@ const GLOSSARY_SECTION_ORDER = [ "Protocols and transports", "Event sourcing and the decider", "Agent execution model", + "Channels and conversations", "Messaging and storage infrastructure", "WebAssembly execution", "Wire contracts and serialization", diff --git a/docs/architecture/multi-channel-agent-routing.md b/docs/architecture/multi-channel-agent-routing.md index 5ec30d28a8..79e699d5cd 100644 --- a/docs/architecture/multi-channel-agent-routing.md +++ b/docs/architecture/multi-channel-agent-routing.md @@ -62,7 +62,8 @@ binary. A second channel imports the same brain; it never copies it. ## Domain model Four words carry this whole design, so here they are with values taken from the -running code. +running code. Each also has a one-paragraph entry in the +[glossary](../glossary/index.md) under "Channels and conversations". An **endpoint** is a mailbox: one place messages arrive and leave. It is three tokens, nothing more. @@ -83,10 +84,11 @@ are restricted to characters that both KV keys and subject tokens accept. which is why the sender is checked separately before a destructive command: the chat being allowed says nothing about who spoke in it. -A **principal** is the human. One person can hold several endpoints (a Telegram -DM, a Discord DM, the CLI) and they all resolve to the same principal. That is -the only thing that makes "continue this conversation somewhere else" mean -anything. +A **principal** is the identity an endpoint resolves to, normally a human. One +person can hold several endpoints (a Telegram DM, a Discord DM, the CLI) and they +all resolve to the same principal. That is the only thing that makes "continue +this conversation somewhere else" mean anything. A group chat is the exception, +covered below. A **conversation** is the context the agent works in, and it is the root object: endpoints point at it, never the reverse. @@ -136,6 +138,42 @@ conversation the shared context, cross-channel from and the response renders there. Per-conversation serialization is mandatory: prompts from two channels into one session queue in order. +### Groups: what an endpoint actually authorizes + +Both lookups key on the **chat** endpoint, never on the sender's: +`principal_for(endpoint)` is the access gate and `conversation_for(endpoint)` is +the binding. The individual who typed is consulted in exactly one place, +`sender_is_authorized`, and only before a destructive command. + +In a direct message the distinction is invisible, because Telegram gives a +private chat the same id as the user it belongs to. Seeding a user id through +`CHANNEL_SEED_TELEGRAM_USERS` writes the endpoint `telegram.{account}.{user_id}`, +which is also that DM's endpoint, so one write authorizes the person and their +first message creates the conversation bound to that same endpoint. + +In a group the two come apart. A group's chat id is a distinct negative number +that equals no user id, so seeding members does nothing for the room: the +bridge finds no principal, logs, acks, and drops. A group works only once an +operator links the group's own chat endpoint to a principal, and then: + +- **The room is the unit of conversation.** One endpoint means one binding, one + conversation, one session. Members share the agent's context, which is the + reason to put a bot in a room at all. +- **Authorizing the room authorizes everyone in it**, including whoever joins + later. Ordinary messages have no per-member gate, deliberately. This is + exactly why the narrower sender check guards `/new`: an unlinked member can + talk to the agent but cannot destroy the room's session. +- **That principal names a room, not a human.** `ConversationRecord.principal` + holds it, so for group conversations "principal" stops meaning "the person", + and a room principal cannot participate in cross-channel continuation, which + is a per-human idea. + +The last point is a known imprecision, not a result we are happy with. The fix +is either a principal kind (human or room) or a conversation owner distinct from +the principal, and it stays deferred until a second channel makes cross-channel +continuation real. Until then groups work correctly and the vocabulary is +slightly dishonest about them. + ## The multi-channel end state When a second channel or a second consumer of conversations (audit, analytics) @@ -314,17 +352,23 @@ retains full fidelity for replay when a future need appears. needs to prompt agents. 4. **Conversation is the root; binding is sticky; policy runs once at creation.** Live conversations never hop agents because config changed. -5. **State in JetStream KV, not config files.** Admin surface out of band and +5. **The chat is the unit of identity and conversation, not the speaker.** A + group is one endpoint, so it gets one shared conversation and one shared + authorization. Per-member conversations inside a room were rejected: they + defeat the reason a bot is in a room. The cost is that a group's principal + names a room rather than a person, accepted for now and revisited when + cross-channel continuation becomes real. +6. **State in JetStream KV, not config files.** Admin surface out of band and unspecified (CLI/config now, GUI or MCP later). -6. **Inbound media fetched out of band** by a dedicated stream consumer, with +7. **Inbound media fetched out of band** by a dedicated stream consumer, with readiness in KV and the wait deferred to the agent's own tool call ([ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md)). -7. **Platform structure over ACP via `_meta`**, dual-carried (text for any +8. **Platform structure over ACP via `_meta`**, dual-carried (text for any agent, `_meta` for participating agents); interactivity degrades gracefully with non-participating agents. -8. **Text rendering via edit-in-place streaming** (`edit_text`), the pattern +9. **Text rendering via edit-in-place streaming** (`edit_text`), the pattern both OpenClaw and Hermes converged on. -9. **Recorded here, not in ADRs**, except where a decision constrains a +10. **Recorded here, not in ADRs**, except where a decision constrains a component outside this design. Media placement did, because it decides what the gateway is allowed to become, so it is [ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md). diff --git a/docs/glossary/binding.md b/docs/glossary/binding.md new file mode 100644 index 0000000000..cb25a5a526 --- /dev/null +++ b/docs/glossary/binding.md @@ -0,0 +1,18 @@ +--- +term: "Binding" +section: "Channels and conversations" +order: 4 +--- + +# Binding + +One [KV bucket](./kv-bucket) entry mapping an [endpoint](./endpoint) to a +[conversation](./conversation) id, and nothing more than that. A message +arrives, the bridge reads the entry, and either follows it or, when the entry is +absent, treats the message as the start of a new conversation: routing policy +runs once, the conversation is created, and the entry is written. + +The binding is the routing record itself rather than a layer in front of one, +and it is sticky, so operator config changes affect new conversations only and a +live conversation never silently changes agents. See +[Multi-Channel Agent Routing](../architecture/multi-channel-agent-routing.md). diff --git a/docs/glossary/channel.md b/docs/glossary/channel.md new file mode 100644 index 0000000000..a28d5f3ed0 --- /dev/null +++ b/docs/glossary/channel.md @@ -0,0 +1,15 @@ +--- +term: "Channel" +section: "Channels and conversations" +order: 0 +--- + +# Channel + +A messaging platform a human reaches an agent through (Telegram, Discord, Slack, +the CLI). Channels carry no intelligence of their own: a channel +[bridge](./bridge) translates between the platform's shape and the neutral +inbound event and render commands, and everything downstream of that translation +is channel-blind. `channel` is also the first token of an +[endpoint](./endpoint). See +[Multi-Channel Agent Routing](../architecture/multi-channel-agent-routing.md). diff --git a/docs/glossary/conversation.md b/docs/glossary/conversation.md new file mode 100644 index 0000000000..71eaad8d6d --- /dev/null +++ b/docs/glossary/conversation.md @@ -0,0 +1,18 @@ +--- +term: "Conversation" +section: "Channels and conversations" +order: 3 +--- + +# Conversation + +The durable context an agent works in, and the root object of the channel +domain: [endpoints](./endpoint) point at conversations, never the reverse. A +conversation holds a sticky `agent_id`, chosen once by routing policy when the +conversation is created and never changed by later operator config edits, plus a +pointer to the current [session](./session). + +A conversation outlives its sessions. Sessions belong to the agent and churn for +ordinary reasons (reset, expiry, agent restart); replacing one never re-runs +routing policy and never changes the bound agent. See +[Multi-Channel Agent Routing](../architecture/multi-channel-agent-routing.md). diff --git a/docs/glossary/endpoint.md b/docs/glossary/endpoint.md new file mode 100644 index 0000000000..25f0d177ea --- /dev/null +++ b/docs/glossary/endpoint.md @@ -0,0 +1,20 @@ +--- +term: "Endpoint" +section: "Channels and conversations" +order: 1 +--- + +# Endpoint + +One place messages arrive and leave, addressed by three tokens: `channel` (which +platform), `account` (which of our bots on that platform), and `peer` (which chat +on the far side). Joined with dots it reads +`telegram.mybot.-1001234567890`, and that one string serves as both a +[KV bucket](./kv-bucket) key and the tail of a NATS subject, which is why the +tokens are restricted to characters that both accept. + +An endpoint addresses a **chat, not a person**: everyone in a group shares one +endpoint, so authorizing an endpoint authorizes the room. Many endpoints can +point at one [conversation](./conversation), which is what makes a conversation +cross-channel. See +[Multi-Channel Agent Routing](../architecture/multi-channel-agent-routing.md). diff --git a/docs/glossary/index.md b/docs/glossary/index.md index eaa42aa5e3..466b45dd93 100644 --- a/docs/glossary/index.md +++ b/docs/glossary/index.md @@ -77,6 +77,19 @@ it to the runtime, and reconciling them is its own decision. - [ModelAccessGrant](./modelaccessgrant) - [Model access service](./model-access-service) +## Channels and conversations + +How a human on a messaging platform reaches an agent. The full narrative lives in +[Multi-Channel Agent Routing](../architecture/multi-channel-agent-routing.md); +these entries are the quick reference. Telegram is the only channel implemented +today, and the vocabulary is deliberately channel-neutral ahead of the second. + +- [Channel](./channel) +- [Endpoint](./endpoint) +- [Principal](./principal) +- [Conversation](./conversation) +- [Binding](./binding) + ## Messaging and storage infrastructure - [NATS](./nats) diff --git a/docs/glossary/principal.md b/docs/glossary/principal.md new file mode 100644 index 0000000000..0e0988093e --- /dev/null +++ b/docs/glossary/principal.md @@ -0,0 +1,23 @@ +--- +term: "Principal" +section: "Channels and conversations" +order: 2 +--- + +# Principal + +The identity behind one or more [endpoints](./endpoint). An endpoint that +resolves to no principal is rejected at the [bridge](./bridge), and that +rejection is the entire access-control mechanism for channels: there is no +separate allowlist. Linking one person's Telegram and Discord endpoints to a +single principal is what allows a [conversation](./conversation) to continue +across channels. + +For a group chat the linked principal stands for the room rather than for a +person, since the room is one endpoint. That imprecision is recorded, with its +consequences, in +[Multi-Channel Agent Routing](../architecture/multi-channel-agent-routing.md). + +Distinct from the decider's command-authorization principal +([ADR#0026](../adr/0026-command-authorization-principal.md)), which authorizes +command execution at a different layer. From ae36482cfaaba0ca7f59f4cec85d1d79cb02dd55 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 3 Aug 2026 13:02:08 -0400 Subject: [PATCH 10/55] docs(channel): say whose perspective the subject direction token is from Signed-off-by: Yordis Prieto --- docs/architecture/multi-channel-agent-routing.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/architecture/multi-channel-agent-routing.md b/docs/architecture/multi-channel-agent-routing.md index 79e699d5cd..6d01cffcfd 100644 --- a/docs/architecture/multi-channel-agent-routing.md +++ b/docs/architecture/multi-channel-agent-routing.md @@ -202,9 +202,22 @@ Filled in, an inbound subject reads `channel.prod.in.telegram.mybot.-1001234567890`: the deployment, the direction, then the endpoint address unchanged from its KV form. +- **`in` and `out` are relative to the user, not to the component.** The edge + publishes to `in` and consumes from `out`; the router does the reverse, so the + same token names one process's input and another's output. Naming the payload + instead (`event` and `render`, matching the types the shared crate already + defines) would remove the ambiguity. Left open deliberately: these subjects + exist only on paper, and the choice belongs to the change that first creates + them. - The last three tokens are the **endpoint address** defined above. Edges own the encoding, which is why `Endpoint` refuses tokens that would not survive as a subject. +- Direction precedes the endpoint so the address stays a contiguous suffix, + byte-identical to `Endpoint::kv_key()`. It also has to exist: the address is + the same value both ways, so without it an inbound event and a render command + for one chat would collide on one subject, the router would consume its own + output, and `CHANNEL_IN_{prefix}` and `CHANNEL_OUT_{prefix}` could not be + separate streams. - The router subscribes `channel.{prefix}.in.>` and is channel-blind; a new channel is a new edge binary and zero router changes. - The subjects carry exactly the types the shared crate already defines; the From 5741837dfb5c91ff5abd21efe71f7b614d66b7cb Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 3 Aug 2026 13:24:56 -0400 Subject: [PATCH 11/55] docs(channel): describe one architecture instead of a phase plan The document carried the shipped topology and a speculative one side by side, both in the present tense, so a reader could not tell which one runs. That ambiguity produced the same question twice about subjects that do not exist. Rejected alternatives belong in the decision list, not in a second diagram. Signed-off-by: Yordis Prieto --- .../multi-channel-agent-routing.md | 492 +++++++++--------- .../channel-bridge-telegram/src/config.rs | 2 +- .../channel-bridge-telegram/src/main.rs | 4 +- .../channel-bridge-telegram/src/parse.rs | 4 +- .../channel-bridge-telegram/src/pipeline.rs | 6 +- .../channel/trogon-channel/src/endpoint.rs | 7 +- .../channel/trogon-channel/src/event.rs | 5 +- .../channel/trogon-channel/src/render.rs | 3 +- .../channel/trogon-channel/src/store.rs | 2 +- 9 files changed, 252 insertions(+), 273 deletions(-) diff --git a/docs/architecture/multi-channel-agent-routing.md b/docs/architecture/multi-channel-agent-routing.md index 6d01cffcfd..f32fca1234 100644 --- a/docs/architecture/multi-channel-agent-routing.md +++ b/docs/architecture/multi-channel-agent-routing.md @@ -1,63 +1,73 @@ # Multi-Channel Agent Routing -How a channel (Telegram first, Discord and others later) binds to an AI -agent. This document records two things: the **v1 implementation**, which goes -directly from the raw Telegram stream to an ACP agent through one bridge -worker, and the **multi-channel end state**, whose seams v1 keeps as code -boundaries so the extraction later is mechanical rather than a rewrite. +How a Telegram chat reaches an AI agent and gets a reply, and why the layer +between them knows nothing about Telegram. "Multi-channel" names the contract of +that middle layer, not a count of channels: Telegram is the only one +implemented, and everything between the platform edge and the agent is +channel-neutral so a second channel is a new edge, never a new brain. ## The shape in one paragraph -There is no per-channel intelligence. A bridge translates between a platform -and the agent protocol; agents are plural and protocol-diverse (ACP today, A2A -and HTTP later) and are reached through in-process adapters behind a single -trait, never through a new NATS namespace. All conversational state (identity, -bindings, sessions) lives in JetStream KV, owned by exactly one worker, and is -designed channel-neutral from day one even while only Telegram exists. +There is no per-channel intelligence. `trogon-gateway` owns the webhook and +publishes Telegram updates verbatim to NATS. `channel-bridge-telegram` consumes +them, normalizes each one into a channel-neutral event, resolves who is speaking +and which conversation they are in, and dispatches a prompt to an agent through +one in-process trait. Agents are plural and protocol-diverse (ACP today, A2A and +HTTP later) and are reached through adapters behind that trait, never through a +NATS namespace of their own. Replies stream back and render into the chat by +direct Bot API calls. All conversational state lives in JetStream KV, owned by +exactly one process. -## V1: the direct path +## The path a message takes ``` - SUBJECTS / PROTOCOL WORKER + SUBJECT / PROTOCOL PROCESS -Telegram ─HTTP─▶ telegram.{update_type} trogon-gateway (exists) - stream TELEGRAM, raw verbatim JSON (inbound path only) +Telegram ─HTTP─▶ webhook validated, published verbatim trogon-gateway + telegram.{update_type}, stream TELEGRAM (Telegram source, inbound only) │ - ▼ durable consumer - normalize: parse Update, endpoint, channel-bridge-telegram - identity + binding via KV, (one worker, both halves) - dispatch prompt via AgentPort + ▼ durable consumer, ack_wait 600s + normalize the Update into InboundEvent channel-bridge-telegram + resolve principal + conversation in KV + dispatch the prompt through AgentPort │ - ▼ acp-nats (already NATS-native) + ▼ ACP over acp-nats ═══ agent works, streams notifications ═══ │ ▼ render notifications - Telegram Bot API calls ─HTTPS─▶ channel-bridge-telegram - (send, edit-in-place, chunk, throttle) (same process as above) + Telegram Bot API ─HTTPS─▶ channel-bridge-telegram + send, edit-in-place, chunk, throttle (same process) ``` -**The two legs are not symmetric.** Inbound goes through the gateway, which -owns the webhook and publishes verbatim. Outbound does not: the bridge holds a +Two processes, and that is the whole topology. There is no subject between the +bridge and the agent other than the one `acp-nats` already owns, and no subject +between the bridge's halves, because it has no halves. + +**The two legs are not symmetric.** Inbound goes through the gateway, which owns +the webhook and publishes verbatim. Outbound does not: the bridge holds a `teloxide::Bot` and calls `send_message`, `edit_message_text`, and `send_chat_action` against the Telegram API itself. The gateway has no outbound -role in v1, which is why the bot token lives in both processes. - -Two workers total, one of which already exists. The bridge is the fusion of -what the end state calls the "edge" and the "router". We fuse them because: - -- The prompt/notification traffic **already crosses NATS** inside `acp-nats`; - a `channel.>` middle namespace would add hops without adding a capability v1 - needs. ACP over NATS is our version of the direct function call OpenClaw and - Hermes make in-process (both reference systems are monoliths whose channel - handlers call the agent loop as a library). -- Raw-inbound replay is already covered by the gateway's `TELEGRAM` stream. -- The multi-channel benefits of the middle namespace only exist once there is - a second channel or a second consumer. - -The guard that keeps this from becoming a monolith: everything channel-neutral -(the KV schemas and binding logic, the `AgentPort` trait, the inbound event -and render-command types) lives in a **shared crate**, not in the Telegram -binary. A second channel imports the same brain; it never copies it. +role, which is why the bot token lives in both processes. + +**Delivery is the consumer's configuration.** The durable is +`channel-bridge-telegram-{prefix}` on stream `TELEGRAM` +(`TELEGRAM_INBOUND_STREAM`). `DeliverPolicy::New` means a first run answers what +arrives from then on rather than replaying whatever the stream still retains; +restarts resume from the durable's own ack floor. `ack_wait` is 600 seconds +because a prompt turn legitimately runs for minutes, and a turn that fails is +left unacked so JetStream redelivers, bounded by `max_deliver` 5. The bridge does +not create the stream: the gateway's Telegram source provisions it, and the +bridge refuses to start if it is missing. + +**The bridge handles one update at a time.** The inbound loop awaits each turn to +completion before pulling the next message. What the design requires is +per-conversation serialization; what runs is global serialization, which is +stricter than needed and a real limitation (see [Known gaps](#known-gaps)). + +The guard that keeps this from being a monolith: everything channel-neutral (the +KV schemas and binding logic, the `AgentPort` trait, the inbound event and +render-command types) lives in the shared `trogon-channel` crate, not in the +Telegram binary. A second channel imports the same brain; it never copies it. ## Domain model @@ -76,9 +86,10 @@ tokens, nothing more. Joined, that is `telegram.mybot.-1001234567890`, meaning "the chat -1001234567890, talking to @mybot, on Telegram". That exact string is the KV key -today (`Endpoint::kv_key`) and becomes the tail of the NATS subject after -extraction. One value, two uses, no re-encoding, which is the reason the tokens -are restricted to characters that both KV keys and subject tokens accept. +(`Endpoint::kv_key`). Tokens are joined with `.`, so a token may not contain one, +and the permitted set narrows further to characters safe in both a NATS KV key +and a NATS subject token: that costs nothing and keeps the composite publishable +if it ever needs to be. **`peer` is a chat, not a person.** Everyone in a group shares one endpoint, which is why the sender is checked separately before a destructive command: the @@ -90,20 +101,20 @@ all resolve to the same principal. That is the only thing that makes "continue this conversation somewhere else" mean anything. A group chat is the exception, covered below. -A **conversation** is the context the agent works in, and it is the root -object: endpoints point at it, never the reverse. +A **conversation** is the context the agent works in, and it is the root object: +endpoints point at it, never the reverse. -A **binding** is one KV entry, `endpoint -> conversation id`, and nothing more. -A message arrives, the bridge looks up its endpoint, and either finds a -conversation id or does not. If it does not, this is a new conversation: -routing policy picks the agent once, the conversation is created, and the entry -is written. The word makes it sound like a subsystem; it is a lookup table with -one column. +A **binding** is one KV entry, `endpoint -> conversation id`, and nothing more. A +message arrives, the bridge looks up its endpoint, and either finds a +conversation id or does not. If it does not, this is a new conversation: routing +policy picks the agent once, the conversation is created, and the entry is +written. The word makes it sound like a subsystem; it is a lookup table with one +column. -`{prefix}`, which appears in bucket and stream names below, is none of the -above. It is the deployment namespace (`CHANNEL_PREFIX`, default `prod`) so -staging and production can share a NATS cluster without sharing state. It never -appears inside an endpoint. +`{prefix}`, which appears in bucket names below, is none of the above. It is the +deployment namespace (`CHANNEL_PREFIX`, default `prod`) so staging and production +can share a NATS cluster without sharing state. It never appears inside an +endpoint. ``` endpoint (channel, account, peer) where messages arrive and leave @@ -118,25 +129,26 @@ conversation the shared context, cross-channel ``` - **Conversations are cross-channel.** The same conversation can be picked up - from Telegram, Discord, or the CLI. The conversation is the root object and - endpoints are pointers into it, never the other way around. -- **Binding is the session-routing record itself**, not a layer in front of - it: an incoming message resolves endpoint to conversation and follows it. - Routing policy (which agent handles a new conversation) is consulted exactly - once, at conversation creation, then the binding is sticky. Operator config - changes affect new conversations only; live conversations never silently - change agents. + from any endpoint that resolves to the same principal. The conversation is the + root object and endpoints are pointers into it, never the other way around. + Only Telegram implements an endpoint today, so this is a property the model + guarantees rather than a path that currently runs. +- **Binding is the session-routing record itself**, not a layer in front of it: + an incoming message resolves endpoint to conversation and follows it. Routing + policy (which agent handles a new conversation) is consulted exactly once, at + conversation creation, then the binding is sticky. Operator config changes + affect new conversations only; live conversations never silently change agents. - **Sessions belong to agents and churn freely.** A session id alone is not - routable (it is only meaningful at the agent that created it) and sessions - die for boring reasons (reset, expiry, agent restart). Session replacement - never re-runs routing policy and never changes the bound agent. -- Operations map onto the hierarchy: `/new` replaces `current_session` and - keeps the agent; rebind is an explicit mutation of `agent_id` and discards - the session; a stale session is repaired in place. -- **Reply-to-origin is per prompt, not per conversation.** Several endpoints - can attach to one conversation, so each prompt records the endpoint it came - from and the response renders there. Per-conversation serialization is - mandatory: prompts from two channels into one session queue in order. + routable (it is only meaningful at the agent that created it) and sessions die + for boring reasons (reset, expiry, agent restart). Session replacement never + re-runs routing policy and never changes the bound agent. +- Operations map onto the hierarchy: `/new` replaces `current_session` and keeps + the agent; rebind is an explicit mutation of `agent_id` and discards the + session; a stale session is repaired in place. +- **Reply-to-origin is per prompt, not per conversation.** Several endpoints can + attach to one conversation, so each prompt records the endpoint it came from + and the response renders there. Per-conversation serialization is mandatory: + prompts from two endpoints into one session must queue in order. ### Groups: what an endpoint actually authorizes @@ -152,84 +164,35 @@ which is also that DM's endpoint, so one write authorizes the person and their first message creates the conversation bound to that same endpoint. In a group the two come apart. A group's chat id is a distinct negative number -that equals no user id, so seeding members does nothing for the room: the -bridge finds no principal, logs, acks, and drops. A group works only once an -operator links the group's own chat endpoint to a principal, and then: +that equals no user id, so seeding members does nothing for the room: the bridge +finds no principal, logs, acks, and drops. A group works only once an operator +links the group's own chat endpoint to a principal, and then: - **The room is the unit of conversation.** One endpoint means one binding, one conversation, one session. Members share the agent's context, which is the reason to put a bot in a room at all. - **Authorizing the room authorizes everyone in it**, including whoever joins - later. Ordinary messages have no per-member gate, deliberately. This is - exactly why the narrower sender check guards `/new`: an unlinked member can - talk to the agent but cannot destroy the room's session. + later. Ordinary messages have no per-member gate, deliberately. This is exactly + why the narrower sender check guards `/new`: an unlinked member can talk to the + agent but cannot destroy the room's session. - **That principal names a room, not a human.** `ConversationRecord.principal` holds it, so for group conversations "principal" stops meaning "the person", - and a room principal cannot participate in cross-channel continuation, which - is a per-human idea. + and a room principal cannot participate in cross-channel continuation, which is + a per-human idea. -The last point is a known imprecision, not a result we are happy with. The fix -is either a principal kind (human or room) or a conversation owner distinct from -the principal, and it stays deferred until a second channel makes cross-channel +The last point is a known imprecision, not a result we are happy with. The fix is +either a principal kind (human or room) or a conversation owner distinct from the +principal, and it stays deferred until a second channel makes cross-channel continuation real. Until then groups work correctly and the vocabulary is slightly dishonest about them. -## The multi-channel end state - -When a second channel or a second consumer of conversations (audit, analytics) -arrives, the bridge splits along the seams the shared crate already defines: - -``` -telegram.{update_type} (stream TELEGRAM) trogon-gateway - │ - ▼ -channel.{prefix}.in.{channel}.{account}.{peer} channel-bridge-telegram -stream CHANNEL_IN_{prefix}, neutral inbound events (normalize half) - │ - ▼ -[identity, binding, conversation KV, channel-router - per-conversation serialization, AgentPort] (generic, channel-blind) - │ - ▼ -channel.{prefix}.out.{channel}.{account}.{peer} channel-router publishes, -stream CHANNEL_OUT_{prefix}, render commands channel-bridge-telegram - │ (render half) consumes - ▼ -platform API calls -``` - -Filled in, an inbound subject reads -`channel.prod.in.telegram.mybot.-1001234567890`: the deployment, the direction, -then the endpoint address unchanged from its KV form. - -- **`in` and `out` are relative to the user, not to the component.** The edge - publishes to `in` and consumes from `out`; the router does the reverse, so the - same token names one process's input and another's output. Naming the payload - instead (`event` and `render`, matching the types the shared crate already - defines) would remove the ambiguity. Left open deliberately: these subjects - exist only on paper, and the choice belongs to the change that first creates - them. -- The last three tokens are the **endpoint address** defined above. Edges own - the encoding, which is why `Endpoint` refuses tokens that would not survive - as a subject. -- Direction precedes the endpoint so the address stays a contiguous suffix, - byte-identical to `Endpoint::kv_key()`. It also has to exist: the address is - the same value both ways, so without it an inbound event and a render command - for one chat would collide on one subject, the router would consume its own - output, and `CHANNEL_IN_{prefix}` and `CHANNEL_OUT_{prefix}` could not be - separate streams. -- The router subscribes `channel.{prefix}.in.>` and is channel-blind; a new - channel is a new edge binary and zero router changes. -- The subjects carry exactly the types the shared crate already defines; the - extraction is deployment surgery, not schema design. - ## State: JetStream KV buckets -All stateful registries live in JetStream KV, owned exclusively by the bridge -(the router, after extraction). Config files carry only wiring (NATS -connection, agent registry). The admin surface for these buckets (CLI, config -seeding, later GUI or MCP) is deliberately out of scope; KV is the source of -truth and whatever tool mutates it is pluggable. +All stateful registries live in JetStream KV, owned exclusively by the bridge. +Config files carry only wiring (NATS connection, agent registry). The admin +surface for these buckets (CLI, config seeding, later GUI or MCP) is deliberately +out of scope; KV is the source of truth and whatever tool mutates it is +pluggable. | Bucket | Key | Value | | --- | --- | --- | @@ -238,14 +201,18 @@ truth and whatever tool mutates it is pluggable. | `channel_bindings_{prefix}` | endpoint address | conversation id | | `channel_conversations_{prefix}` | conversation id | principal id, agent_id, current_session, activity timestamps | -Access control is identity: an endpoint that resolves to no principal is -rejected (or ignored) at the bridge. This replaces the per-channel allowlist -concept with one channel-neutral mechanism. +Access control is identity: an endpoint that resolves to no principal is rejected +at the bridge, which logs, acks, and drops. This replaces the per-channel +allowlist concept with one channel-neutral mechanism. + +## Channel-neutral types -## Shared-crate types (the wire schemas in waiting) +These live in `trogon-channel` and are the contract a second channel implements. +They are Rust types passed in process, and they are `Serialize` because the same +shapes are what any future transport between an edge and a router would carry. -**Inbound event** (a Rust type in v1; the `channel.*.in.*` payload after -extraction): +**Inbound event**, what any channel bridge produces after stripping its +platform's shape: ``` { @@ -262,14 +229,13 @@ extraction): **Commands are extracted at the channel edge and never forwarded.** A trigger (`/new`, `/reset`, configurable) counts only as the whole first token of a message; anything after it stays in `text` and becomes the first prompt of -whatever the command sets up. Leading-slash vocabulary is a channel affordance, -so the bridge owns its own control words regardless of what the agent behind it +whatever the command sets up. Leading-slash vocabulary is a channel affordance, so +the bridge owns its own control words regardless of what the agent behind it happens to advertise. A destructive command additionally authorizes the sender's own endpoint rather than the conversation's, since a group chat is one endpoint shared by everyone in it. -**Render commands** (a Rust enum in v1; the `channel.*.out.*` payload after -extraction): +**Render commands**, the one output vocabulary every channel implements: | Command | Purpose | | --- | --- | @@ -279,17 +245,17 @@ extraction): | `typing` | activity indicator | | `react` | acknowledge without text | -The render vocabulary is the one contract every channel implements; it stays -small on purpose. Both reference systems studied (OpenClaw, Hermes) converged -on essentially this set. +It stays small on purpose. Both reference systems studied (OpenClaw, Hermes) +converged on essentially this set. **Inbound media is fetched out of band** by a dedicated downloader on its own durable consumer of the raw stream, never by the gateway and never inline in a turn. The inbound event carries only `platform_ref`; readiness lives in a `channel_media_{prefix}` KV record that a reader awaits by watch, at the moment -the agent opens the file. Outbound is not symmetric: `send_attachment` keeps -its `object_ref`, because the agent produced that file and there is nothing to -redeem. See [ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md). +the agent opens the file. Outbound is not symmetric: `send_attachment` keeps its +`object_ref`, because the agent produced that file and there is nothing to +redeem. See [ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md). The +downloader is designed and not built; today media is dropped. ## Agent dispatch: the AgentPort trait @@ -304,69 +270,78 @@ AgentPort: ``` - `release_session` is infallible by signature. The conversation drops its - pointer to the session and persists that *before* the agent is told, so a - crash mid-reset orphans an agent session (recoverable) instead of resurrecting - one the user asked to be rid of. Each step is capability-gated and best - effort; an agent that cannot release must never wedge the conversation it was - released from. Releasing is not deleting: the bridge is done with the session, - which is not the same as the user asking for its history to be destroyed. -- Prompt failures rotate the session only when the agent says it does not have - it. Timeouts and transport errors redeliver instead, because rotating on those + pointer to the session and persists that *before* the agent is told, so a crash + mid-reset orphans an agent session (recoverable) instead of resurrecting one the + user asked to be rid of. Each step is capability-gated and best effort; an agent + that cannot release must never wedge the conversation it was released from. + Releasing is not deleting: the bridge is done with the session, which is not the + same as the user asking for its history to be destroyed. +- Prompt failures rotate the session only when the agent says it does not have it. + Timeouts and transport errors redeliver instead, because rotating on those discards a conversation that was merely unreachable for a moment. - -- v1 ships exactly one implementation: ACP, using the existing `acp-nats` - client machinery. A2A and HTTP become additional implementations later. -- The agent registry is config: `agent_id -> { protocol, address }` (for ACP: - the acp prefix; the agent's workspace/cwd is agent configuration, never a - channel concern). +- There is exactly one implementation, `AcpPort`, over the existing `acp-nats` + client machinery, and it adopts only the session methods the agent advertises at + initialize. A2A and HTTP are shapes the trait allows, not code that exists. +- The agent registry is config: `agent_id -> { protocol, address }` (for ACP: the + acp prefix; the agent's workspace/cwd is agent configuration, never a channel + concern). ## Carrying platform structure over ACP: the `_meta` convention ACP reserves a `_meta` field on nearly every type (`PromptRequest`, every -`ContentBlock` variant, session notifications) explicitly for attaching -arbitrary metadata; `acp-nats` already uses it for prompt correlation. Three -tiers of Telegram structure map as follows: - -1. **Content** (text, images, voice, documents): ACP content blocks directly. - No loss. Claim-check references travel as embedded resources or links. -2. **Conversational context** (sender, reply-to, group vs DM, forwards): - carried **twice, deliberately**. A human-readable prefix in the text block - (works with any ACP agent, since only prompt text reaches the model) and a - structured object in `PromptRequest._meta` (works richly with agents that - opt in). `_meta` is machine-visible, not model-visible: a generic agent - carries it and ignores it, which is safe. -3. **Platform interactivity** (inline buttons, callback queries, polls, - edits): inbound, handled at the bridge and translated to synthetic prompt - text ("user chose: Approve"). Outbound, an agent that participates in the - convention attaches e.g. `{ telegram: { buttons: [...] } }` to a - notification's `_meta` and the bridge renders it; event-shaped extensions - use ACP `ExtNotification`. Agents that do not participate simply produce - plain text, and the bot degrades gracefully. +`ContentBlock` variant, session notifications) explicitly for attaching arbitrary +metadata; `acp-nats` already uses it for prompt correlation. Three tiers of +Telegram structure map as follows: + +1. **Content** (text, images, voice, documents): ACP content blocks directly. No + loss. Claim-check references travel as embedded resources or links. +2. **Conversational context** (sender, reply-to, group vs DM, forwards): carried + **twice, deliberately**. A human-readable prefix in the text block (works with + any ACP agent, since only prompt text reaches the model) and a structured + object in `PromptRequest._meta` (works richly with agents that opt in). `_meta` + is machine-visible, not model-visible: a generic agent carries it and ignores + it, which is safe. +3. **Platform interactivity** (inline buttons, callback queries, polls, edits): + inbound, handled at the bridge and translated to synthetic prompt text ("user + chose: Approve"). Outbound, an agent that participates in the convention + attaches e.g. `{ telegram: { buttons: [...] } }` to a notification's `_meta` + and the bridge renders it; event-shaped extensions use ACP `ExtNotification`. + Agents that do not participate simply produce plain text, and the bot degrades + gracefully. Whatever the bridge does not carry is not destroyed: the raw `TELEGRAM` stream retains full fidelity for replay when a future need appears. ## Decisions and rejected alternatives -1. **V1 goes direct: one bridge worker, no `channel.>` subjects yet.** The - neutral vocabulary ships as types in a shared crate; the namespace is the - documented extraction path, triggered by a second channel or a second - consumer. Rationale: acp-nats already provides the NATS seam and its - buffering/observability; the middle namespace pays off only at channel two. -2. **Channel-neutral vocabulary from day one** even while fused: the shared - crate, not the Telegram binary, owns the schemas, KV logic, and AgentPort. - The `tgbot.>` subject space introduced during the Telegram refactor is - transitional and gets absorbed. -3. **No `agents.>` NATS namespace; adapters are libraries.** Protocol-neutral - agent addressability already exists twice in this workspace (`acp-nats` - for ACP, `a2a-gateway` for A2A). A generic namespace would add a second - hop and force redesigning streaming RPC over NATS, which `acp-nats` - already solved. Revisit only if a service other than the bridge/router - needs to prompt agents. -4. **Conversation is the root; binding is sticky; policy runs once at - creation.** Live conversations never hop agents because config changed. -5. **The chat is the unit of identity and conversation, not the speaker.** A - group is one endpoint, so it gets one shared conversation and one shared +1. **No middle `channel.>` NATS namespace.** A topology where a per-platform + "edge" publishes neutral events to + `channel.{prefix}.in.{channel}.{account}.{peer}` for a channel-blind router to + consume, and receives render commands back on a matching `out` subject, was + considered and rejected. The prompt and notification traffic already crosses + NATS inside `acp-nats`, so the extra namespace adds hops without adding a + capability; raw-inbound replay is already covered by the `TELEGRAM` stream; and + a neutral event that only ever travels in process needs no wire encoding, no + subject scheme, and no direction token. What would reopen it: a second channel + whose conversations can be continued from the first. Two ingress processes that + each own identity, binding, and dispatch would both write the same KV buckets + and could prompt one agent session concurrently, and the cheap fix for that is + a single process owning conversations, which in turn needs the + platform-specific processes to hand it neutral events over a stream. A second + consumer of conversations (audit, analytics) reopens it for a different reason: + an in-process event cannot be tapped. +2. **No `agents.>` NATS namespace; adapters are libraries.** Protocol-neutral + agent addressability already exists twice in this workspace (`acp-nats` for + ACP, `a2a-gateway` for A2A). A generic namespace would add a second hop and + force redesigning streaming RPC over NATS, which `acp-nats` already solved. + Revisit only if a service other than the bridge needs to prompt agents. +3. **Channel-neutral vocabulary lives in a shared crate**, not in the Telegram + binary: `trogon-channel` owns the schemas, the KV logic, and `AgentPort`, and + the Telegram crate owns only parsing and rendering. +4. **Conversation is the root; binding is sticky; policy runs once at creation.** + Live conversations never hop agents because config changed. +5. **The chat is the unit of identity and conversation, not the speaker.** A group + is one endpoint, so it gets one shared conversation and one shared authorization. Per-member conversations inside a room were rejected: they defeat the reason a bot is in a room. The cost is that a group's principal names a room rather than a person, accepted for now and revisited when @@ -376,51 +351,56 @@ retains full fidelity for replay when a future need appears. 7. **Inbound media fetched out of band** by a dedicated stream consumer, with readiness in KV and the wait deferred to the agent's own tool call ([ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md)). -8. **Platform structure over ACP via `_meta`**, dual-carried (text for any - agent, `_meta` for participating agents); interactivity degrades - gracefully with non-participating agents. -9. **Text rendering via edit-in-place streaming** (`edit_text`), the pattern - both OpenClaw and Hermes converged on. -10. **Recorded here, not in ADRs**, except where a decision constrains a - component outside this design. Media placement did, because it decides what - the gateway is allowed to become, so it is [ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md). - -## Consequences for existing crates - -- `telegram-agent`: its `llm.rs` and `conversation.rs` are the wrong layer - (channels must not own a model loop) and disappear. Its consumer skeleton - seeds `channel-bridge-telegram`. -- `telegram-bot`: its bridge/transform and outbound halves fold into - `channel-bridge-telegram`, re-targeted at the shared-crate types; the typed - Telegram event vocabulary in `telegram-types` is explicitly not the neutral - model and shrinks to whatever the bridge still needs internally. -- `telegram-nats` (`tgbot.>` subjects, per-prefix streams): transitional, - removed with the fusion (the bot-to-agent bus it modeled no longer exists - as a NATS boundary in v1). -- `trogon-gateway`: unchanged. Its Telegram source stays the single raw - ingress. Evolution path, not v1: a generic **sink** concept (NATS to - HTTP-out) symmetric to its sources, which would centralize outbound token - custody; today the bot token intentionally lives in both the gateway - (webhook registration) and the bridge (API calls). - -## End-to-end walkthrough (v1) - -1. User sends "hello" to the bot on Telegram. Telegram POSTs the webhook; - trogon-gateway validates and publishes the raw Update to - `telegram.message` (stream `TELEGRAM`). -2. channel-bridge-telegram consumes it, parses the Update, and encodes the - endpoint address. Any attachment contributes its `platform_ref` and nothing - is downloaded on this path. -3. The bridge resolves endpoint to principal (reject if unknown), endpoint to - conversation (create via routing policy if absent, writing the sticky - `agent_id`), and ensures a live session on that agent through the ACP - adapter (create or resume). -4. The bridge dispatches the prompt with conversational context dual-carried - (text prefix + `_meta`), recording the origin endpoint for this prompt. -5. The agent streams session notifications over acp-nats. The bridge renders - them: `typing`, then edit-in-place preview updates, finally the completed - text, chunked at 4096 chars with edit throttling, plus any `_meta`-carried - interactivity (buttons) the agent attached. -6. The same user later opens the CLI or Discord: a different endpoint mapped - to the same principal binds to the same conversation and continues it; - replies go to whichever endpoint prompted. +8. **Platform structure over ACP via `_meta`**, dual-carried (text for any agent, + `_meta` for participating agents); interactivity degrades gracefully with + non-participating agents. +9. **Text rendering via edit-in-place streaming** (`edit_text`), the pattern both + OpenClaw and Hermes converged on. +10. **Recorded here, not in ADRs**, except where a decision constrains a component + outside this design. Media placement did, because it decides what the gateway + is allowed to become, so it is + [ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md). + +## Known gaps + +Things this design commits to that the running system does not do yet. None of +them change the topology above. + +- **Inbound media is dropped.** Parsing keeps only the message text, so a photo, + voice note, or document arrives as nothing at all. ADR#0044 settles where the + fetch belongs; the downloader and the `channel_media_{prefix}` bucket do not + exist. +- **No per-conversation concurrency.** The inbound loop awaits each turn to + completion, so one slow agent blocks every conversation, including the `/new` + meant to rescue it. This is the largest operational gap. +- **Groups need a hand-written KV entry.** Only `CHANNEL_SEED_TELEGRAM_USERS` + exists, and it seeds user-id endpoints, which are DM endpoints. A bot added to a + room stays silent until someone links the room's own endpoint, and there is no + admin surface for that. +- **A group's principal names a room, not a human**, so it cannot take part in + cross-channel continuation. +- **The bot token lives in two processes**, the gateway for webhook registration + and the bridge for API calls. A generic gateway sink (NATS to HTTP-out, + symmetric to its sources) would centralize outbound custody. It does not exist, + and adding one is a gateway decision, not a channel one. +- **One agent protocol.** `AgentPort` has a single implementation. + +## End-to-end walkthrough + +1. A user sends "hello" to the bot on Telegram. Telegram POSTs the webhook; + `trogon-gateway` validates it and publishes the raw Update to + `telegram.message` on stream `TELEGRAM`. +2. `channel-bridge-telegram` consumes it on its durable, parses the Update, and + encodes the endpoint address. +3. The bridge resolves endpoint to principal, dropping the message if there is + none; then endpoint to conversation, creating one via routing policy if absent + and writing the sticky `agent_id`; then ensures a live session on that agent + through the ACP adapter, creating or resuming. +4. The bridge dispatches the prompt with conversational context dual-carried (text + prefix plus `_meta`), recording the origin endpoint for this prompt. +5. The agent streams session notifications back over `acp-nats`. The bridge + renders them: `typing`, then edit-in-place preview updates, finally the + completed text, chunked at 4096 characters with edit throttling, plus any + `_meta`-carried interactivity the agent attached. +6. The turn ends and the bridge acks the inbound message, which is when it becomes + free to pull the next update. diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs index 33a3259882..f5442665cc 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs @@ -13,7 +13,7 @@ pub struct BridgeConfig { pub bot_token: String, /// Endpoint account token; identifies which bot account on Telegram. pub bot_account: String, - /// Agent every new conversation binds to (v1 routing policy: single agent). + /// Agent every new conversation binds to; the routing policy is one agent. pub agent_id: String, /// Workspace the agent roots its sessions in; agent configuration, never /// a channel concern (see the architecture doc). diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs index 012d546e9c..f7e370c8d1 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs @@ -1,5 +1,5 @@ -//! Telegram channel bridge: the v1 direct path from the gateway's raw Telegram -//! stream to an ACP agent. See `docs/architecture/multi-channel-agent-routing.md`. +//! Telegram channel bridge: the path from the gateway's raw Telegram stream to +//! an ACP agent. See `docs/architecture/multi-channel-agent-routing.md`. //! //! One worker, two halves: normalize (raw Update -> `InboundEvent`, //! identity + conversation via KV, prompt via `AgentPort`) and render (agent diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs index 99bd8297a4..dcecf6d34f 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs @@ -2,8 +2,8 @@ use teloxide::types::{Update, UpdateKind}; use trogon_channel::{CommandTriggers, Endpoint, InboundEvent, Sender}; /// Normalize a raw Telegram update into the channel-neutral event, or `None` -/// for update kinds v1 does not carry (media, edits, membership, ...). The -/// raw stream retains those with full fidelity for later. +/// for update kinds the bridge does not carry (media, edits, membership, ...). +/// The raw stream retains those with full fidelity for later. pub fn inbound_event(update: &Update, bot_account: &str, triggers: &CommandTriggers) -> Option { let UpdateKind::Message(msg) = &update.kind else { return None; diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs index 423b257e0e..abf890afe8 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -79,7 +79,7 @@ impl Pipeline<'_, P, O, G> { } /// Process one raw gateway message end to end. Unrecoverable messages - /// (unparseable, unauthorized, kinds v1 does not carry) are acked and + /// (unparseable, unauthorized, kinds the bridge does not carry) are acked and /// dropped; processing errors return `Err` with the message unacked so /// JetStream redelivers. pub async fn handle_message(&self, msg: &async_nats::jetstream::Message) -> anyhow::Result<()> { @@ -110,8 +110,8 @@ impl Pipeline<'_, P, O, G> { let (conversation_id, mut record) = match self.store.conversation_for(&event.endpoint).await? { Some(found) => found, None => { - // Routing policy, v1: every new conversation binds to the - // single configured agent. Sticky from here on. + // Routing policy: every new conversation binds to the single + // configured agent. Sticky from here on. let record = ConversationRecord { principal: principal.clone(), agent_id: AgentId::new(self.agent_id), diff --git a/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs index 5afa359e98..c4513bd3e0 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; -/// Characters permitted in endpoint tokens: the intersection of what NATS KV -/// keys accept and what NATS subject tokens accept, so an endpoint can address -/// both a KV entry and (after extraction) a subject without re-encoding. +/// Characters permitted in endpoint tokens. Tokens are joined with `.` into +/// one composite key, so `.` is out; the rest is the intersection of what NATS +/// KV keys and NATS subject tokens accept, which keeps the composite usable as +/// either without re-encoding. fn is_safe_token(token: &str) -> bool { !token.is_empty() && token diff --git a/rsworkspace/crates/channel/trogon-channel/src/event.rs b/rsworkspace/crates/channel/trogon-channel/src/event.rs index a5abae7d5d..674ad7015c 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event.rs @@ -21,9 +21,8 @@ pub struct Attachment { } /// A normalized inbound message: what any channel bridge produces after -/// stripping its platform's shape. This type is the `channel.*.in.*` payload -/// once the multi-channel extraction happens; until then it travels -/// in-process. +/// stripping its platform's shape. Travels in process; `Serialize` because the +/// shape is the cross-channel contract, not because anything publishes it. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InboundEvent { pub endpoint: Endpoint, diff --git a/rsworkspace/crates/channel/trogon-channel/src/render.rs b/rsworkspace/crates/channel/trogon-channel/src/render.rs index 6928b40cf3..dfb83f95fe 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/render.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/render.rs @@ -3,8 +3,7 @@ use serde::{Deserialize, Serialize}; /// The channel-neutral output vocabulary: the one contract every channel /// bridge implements. Kept deliberately small; platform-specific richness /// (e.g. Telegram inline buttons) rides agent `_meta` and is rendered by the -/// bridge that understands it. This enum is the `channel.*.out.*` payload once -/// the multi-channel extraction happens. +/// bridge that understands it. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "command", rename_all = "snake_case")] pub enum RenderCommand { diff --git a/rsworkspace/crates/channel/trogon-channel/src/store.rs b/rsworkspace/crates/channel/trogon-channel/src/store.rs index 1f427922cc..5579ea934d 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store.rs @@ -28,7 +28,7 @@ pub struct PrincipalRecord { } /// The four registries behind conversations, all JetStream KV, all owned by -/// exactly one worker (the bridge today, the router after extraction). Config +/// exactly one worker, the bridge. Config /// files never hold this state; the admin surface that seeds/mutates it is /// out of band by design. pub struct ChannelStore { From d12b521fec6192f6d685c61a3b0455fb8346c76f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 00:01:42 -0400 Subject: [PATCH 12/55] docs(channel): correct the rendering claims and record the claim-check hole The rewrite described streaming edit-in-place output that the Telegram outbound surface has no method for, restating the kind of unverified claim it was meant to remove. Separately, both this doc and ADR#0044 asserted the gateway carries no object-store dependency while its publish path has claim-checked since before either was written, which hid a consumer that drops oversized updates and blames the parser. Signed-off-by: Yordis Prieto --- .../0044-inbound-media-fetch-out-of-band.md | 21 ++++--- .../multi-channel-agent-routing.md | 56 +++++++++++++++---- 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/docs/adr/0044-inbound-media-fetch-out-of-band.md b/docs/adr/0044-inbound-media-fetch-out-of-band.md index bcf9acbaf3..f42090d9ae 100644 --- a/docs/adr/0044-inbound-media-fetch-out-of-band.md +++ b/docs/adr/0044-inbound-media-fetch-out-of-band.md @@ -45,9 +45,9 @@ webhook registration, and the claim-check machinery with `ObjectStorePut` and in place, so the objection is not capability. The objection is scope: the gateway is a verbatim transport shared across GitHub, GitLab, Linear, Slack, Discord, and Telegram sources, and its stated contract is raw fidelity. Media -fetching would make one source materially smarter than its siblings and would -add an object-store dependency to a component whose value is being dumb. It -also cannot be done in the request path at all: Telegram retries webhooks that +fetching would make one source materially smarter than its siblings, and would +put the bot token in the ingress. It also cannot be done in the request path at +all: Telegram retries webhooks that do not return promptly, so a download inside the handler trades a fast ingress for a slow one. @@ -64,10 +64,17 @@ transport and no gateway change. ### 1. The gateway stays a verbatim transport -`trogon-gateway` does not fetch media, does not depend on an object store, and -does not gain per-source intelligence. Its contract remains raw fidelity from -webhook to stream. This is a deliberate purchase: we accept a third credential -holder (below) to keep the ingress generic. +`trogon-gateway` does not fetch media and does not gain per-source +intelligence. Its contract remains raw fidelity from webhook to stream. This is +a deliberate purchase: we accept a third credential holder (below) to keep the +ingress generic. + +The gateway does already depend on an object store, through +`ClaimCheckPublisher`, which offloads any body over the NATS max payload and +publishes claim headers in its place. That is transport plumbing applied +identically to every source, not knowledge of what Telegram media is, so it does +not weaken this decision. It does mean any consumer of a raw stream must call +`resolve_claim` before deserializing, which today none does. ### 2. A dedicated downloader consumes the raw stream on its own durable diff --git a/docs/architecture/multi-channel-agent-routing.md b/docs/architecture/multi-channel-agent-routing.md index f32fca1234..f4fb1431e8 100644 --- a/docs/architecture/multi-channel-agent-routing.md +++ b/docs/architecture/multi-channel-agent-routing.md @@ -34,9 +34,9 @@ Telegram ─HTTP─▶ webhook validated, published verbatim trogon-gateway ▼ ACP over acp-nats ═══ agent works, streams notifications ═══ │ - ▼ render notifications + ▼ buffer text, flush when the turn ends Telegram Bot API ─HTTPS─▶ channel-bridge-telegram - send, edit-in-place, chunk, throttle (same process) + send_message, chunked at 4096 (same process) ``` Two processes, and that is the whole topology. There is no subject between the @@ -310,7 +310,12 @@ Telegram structure map as follows: gracefully. Whatever the bridge does not carry is not destroyed: the raw `TELEGRAM` stream -retains full fidelity for replay when a future need appears. +retains full fidelity for replay when a future need appears. With one caveat that +is currently a bug, not a design: the gateway publishes through +`ClaimCheckPublisher`, so an update larger than the NATS max payload is stored in +an object store and published as an empty body carrying claim headers. Fidelity is +preserved in the stream, but only a consumer that calls `resolve_claim` sees it, +and none does. ## Decisions and rejected alternatives @@ -354,8 +359,10 @@ retains full fidelity for replay when a future need appears. 8. **Platform structure over ACP via `_meta`**, dual-carried (text for any agent, `_meta` for participating agents); interactivity degrades gracefully with non-participating agents. -9. **Text rendering via edit-in-place streaming** (`edit_text`), the pattern both - OpenClaw and Hermes converged on. +9. **Text rendering should stream via edit-in-place** (`edit_text`), the pattern + both OpenClaw and Hermes converged on. Decided, not implemented: the Telegram + `Outbound` trait has only `typing` and `send_text`, so today the bridge buffers + and sends once at the end of the turn. 10. **Recorded here, not in ADRs**, except where a decision constrains a component outside this design. Media placement did, because it decides what the gateway is allowed to become, so it is @@ -366,10 +373,34 @@ retains full fidelity for replay when a future need appears. Things this design commits to that the running system does not do yet. None of them change the topology above. +- **A claim-checked update is silently destroyed.** The gateway offloads any body + over the NATS max payload to an object store and publishes an empty payload with + claim headers. The bridge deserializes `msg.payload` directly, so it sees zero + bytes, logs "Unparseable Telegram update; dropping", and acks. The loss is + permanent and the log names the wrong cause. `resolve_claim` already exists in + `trogon-nats` and is called nowhere outside its own tests. This is the one gap + here that is a defect rather than absent work. - **Inbound media is dropped.** Parsing keeps only the message text, so a photo, voice note, or document arrives as nothing at all. ADR#0044 settles where the fetch belongs; the downloader and the `channel_media_{prefix}` bucket do not exist. +- **No streaming output.** The renderer buffers agent text for the whole turn and + sends it at the end, so the chat shows a typing indicator and then silence. Only + `AgentMessageChunk` text is kept; tool calls, plans, thoughts, and non-text + content blocks are logged and dropped, which is why an agent that answers only + through tool output produces "Agent turn produced no text" and an empty chat. +- **Permission requests are always refused.** A chat has no permission surface, so + `request_permission` returns `Cancelled`. That is the right default over + silently granting, but it means the bridge only works against an agent + configured not to ask. +- **A turn longer than `ack_wait` is prompted twice.** At 600 seconds the message + becomes redeliverable while the turn is still running, and nothing dedups on + `message_ref` even though the field exists for it. `max_deliver` 5 bounds the + duplicates. The same path makes any redelivery after a partial turn re-prompt + the agent. +- **The bridge exits if the agent is down at boot.** ACP `initialize` runs before + the consumer opens and propagates its error, so an agent that is not yet + reachable turns into a restart loop rather than a bridge that waits. - **No per-conversation concurrency.** The inbound loop awaits each turn to completion, so one slow agent blocks every conversation, including the `/new` meant to rescue it. This is the largest operational gap. @@ -398,9 +429,12 @@ them change the topology above. through the ACP adapter, creating or resuming. 4. The bridge dispatches the prompt with conversational context dual-carried (text prefix plus `_meta`), recording the origin endpoint for this prompt. -5. The agent streams session notifications back over `acp-nats`. The bridge - renders them: `typing`, then edit-in-place preview updates, finally the - completed text, chunked at 4096 characters with edit throttling, plus any - `_meta`-carried interactivity the agent attached. -6. The turn ends and the bridge acks the inbound message, which is when it becomes - free to pull the next update. +5. The agent streams session notifications back over `acp-nats`. The bridge sends + one `typing` action before prompting, then accumulates the text of every + `AgentMessageChunk` into a per-session buffer. Every other kind of session + update is logged and dropped. +6. When the turn ends, the bridge takes the buffer and sends it with + `send_message`, split at 4096 characters. Nothing reaches the chat before the + turn is over, so a long turn shows a typing indicator and then silence. +7. The bridge acks the inbound message, which is when it becomes free to pull the + next update. From 7a32ca3cb2c0576b6bcc125b6190780a3546c1f6 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 00:33:40 -0400 Subject: [PATCH 13/55] fix(channel): stop losing Telegram updates the gateway had to offload An update over the NATS max payload arrives with an empty body and claim headers, so the bridge read it as unparseable and acked it: the only copy of a real user message was destroyed silently, and the log blamed the parser. A redeem failure now leaves the message for redelivery, because the bytes exist and the failure is recoverable. The consumer is bound to the bucket it reads so a wrong bucket reports itself instead of masquerading as data loss, and the bucket default moved next to the headers it travels with so the two sides cannot drift apart. Signed-off-by: Yordis Prieto --- devops/docker/compose/compose.yml | 5 +- .../0044-inbound-media-fetch-out-of-band.md | 10 +- .../multi-channel-agent-routing.md | 48 ++-- .../channel-bridge-telegram/src/config.rs | 8 + .../channel-bridge-telegram/src/main.rs | 21 +- .../channel-bridge-telegram/src/pipeline.rs | 21 +- .../src/pipeline_tests.rs | 227 +++++++++++++++++- .../platform/trogon-gateway/src/constants.rs | 1 - .../platform/trogon-gateway/src/main.rs | 12 +- .../platform/trogon-nats/src/constants.rs | 5 + .../trogon-nats/src/jetstream/claim_check.rs | 55 +++++ .../claim_check/integration_tests.rs | 126 ++++++++++ .../platform/trogon-nats/src/jetstream/mod.rs | 4 +- .../trogon-nats/src/jetstream/object_store.rs | 12 + 14 files changed, 513 insertions(+), 42 deletions(-) diff --git a/devops/docker/compose/compose.yml b/devops/docker/compose/compose.yml index 0c97398998..c7bca547fe 100644 --- a/devops/docker/compose/compose.yml +++ b/devops/docker/compose/compose.yml @@ -46,8 +46,9 @@ services: retries: 3 # Telegram channel bridge: consumes the gateway's raw TELEGRAM stream and - # drives the shared agent over acp-nats. Needs the gateway's Telegram - # source enabled (provisions the stream) and an agent behind acp-nats. + # drives the shared agent over acp-nats. Needs the gateway running (it + # provisions both the stream and the claim bucket an oversized update is + # offloaded into) and an agent behind acp-nats. channel-bridge-telegram: build: context: ../../../rsworkspace diff --git a/docs/adr/0044-inbound-media-fetch-out-of-band.md b/docs/adr/0044-inbound-media-fetch-out-of-band.md index f42090d9ae..1f15258bbf 100644 --- a/docs/adr/0044-inbound-media-fetch-out-of-band.md +++ b/docs/adr/0044-inbound-media-fetch-out-of-band.md @@ -73,8 +73,9 @@ The gateway does already depend on an object store, through `ClaimCheckPublisher`, which offloads any body over the NATS max payload and publishes claim headers in its place. That is transport plumbing applied identically to every source, not knowledge of what Telegram media is, so it does -not weaken this decision. It does mean any consumer of a raw stream must call -`resolve_claim` before deserializing, which today none does. +not weaken this decision. It does mean any consumer of a raw stream must redeem +the claim before deserializing, the downloader below included. A consumer that +skips it gets no error, only an empty body. ### 2. A dedicated downloader consumes the raw stream on its own durable @@ -131,8 +132,9 @@ pay nothing. ## Invariants -- The gateway never holds an object-store dependency and never interprets a - source's payload beyond what publishing requires. +- The gateway never interprets a source's payload beyond what publishing + requires. Its object-store use is claim-check transport, identical for every + source. - No component blocks a conversational turn on media the agent has not asked for. - Any component that redeems a platform handle holds that platform's diff --git a/docs/architecture/multi-channel-agent-routing.md b/docs/architecture/multi-channel-agent-routing.md index f4fb1431e8..f7d1329398 100644 --- a/docs/architecture/multi-channel-agent-routing.md +++ b/docs/architecture/multi-channel-agent-routing.md @@ -45,9 +45,9 @@ between the bridge's halves, because it has no halves. **The two legs are not symmetric.** Inbound goes through the gateway, which owns the webhook and publishes verbatim. Outbound does not: the bridge holds a -`teloxide::Bot` and calls `send_message`, `edit_message_text`, and -`send_chat_action` against the Telegram API itself. The gateway has no outbound -role, which is why the bot token lives in both processes. +`teloxide::Bot` and calls `send_message` and `send_chat_action` against the +Telegram API itself. The gateway has no outbound role, which is why the bot token +lives in both processes. **Delivery is the consumer's configuration.** The durable is `channel-bridge-telegram-{prefix}` on stream `TELEGRAM` @@ -55,9 +55,13 @@ role, which is why the bot token lives in both processes. arrives from then on rather than replaying whatever the stream still retains; restarts resume from the durable's own ack floor. `ack_wait` is 600 seconds because a prompt turn legitimately runs for minutes, and a turn that fails is -left unacked so JetStream redelivers, bounded by `max_deliver` 5. The bridge does -not create the stream: the gateway's Telegram source provisions it, and the -bridge refuses to start if it is missing. +left unacked so JetStream redelivers, bounded by `max_deliver` 5. The bridge +creates neither of the two resources it reads. The gateway provisions the stream +and the claim bucket, sizing the bucket's retention against the longest-retained +stream it serves; the bridge only names the bucket (`TROGON_CLAIM_BUCKET`, +defaulting to the same `trogon-claims` the gateway writes) and refuses to start if +either resource is missing, rather than create a wrong one and find out on the +first oversized update. **The bridge handles one update at a time.** The inbound loop awaits each turn to completion before pulling the next message. What the design requires is @@ -310,12 +314,14 @@ Telegram structure map as follows: gracefully. Whatever the bridge does not carry is not destroyed: the raw `TELEGRAM` stream -retains full fidelity for replay when a future need appears. With one caveat that -is currently a bug, not a design: the gateway publishes through -`ClaimCheckPublisher`, so an update larger than the NATS max payload is stored in -an object store and published as an empty body carrying claim headers. Fidelity is -preserved in the stream, but only a consumer that calls `resolve_claim` sees it, -and none does. +retains full fidelity for replay when a future need appears. Fidelity there is +not the same thing as fidelity in the payload, though. The gateway publishes +through `ClaimCheckPublisher`, so an update larger than the NATS max payload is +stored in an object store and published as an empty body carrying claim headers. +The bytes are only visible to a consumer that redeems the claim, so the bridge +holds a `ClaimResolver` bound to the same bucket and resolves before it +deserializes. Any future consumer of a raw stream owes the same, and a consumer +that skips it does not see an error: it sees an empty body. ## Decisions and rejected alternatives @@ -373,13 +379,6 @@ and none does. Things this design commits to that the running system does not do yet. None of them change the topology above. -- **A claim-checked update is silently destroyed.** The gateway offloads any body - over the NATS max payload to an object store and publishes an empty payload with - claim headers. The bridge deserializes `msg.payload` directly, so it sees zero - bytes, logs "Unparseable Telegram update; dropping", and acks. The loss is - permanent and the log names the wrong cause. `resolve_claim` already exists in - `trogon-nats` and is called nowhere outside its own tests. This is the one gap - here that is a defect rather than absent work. - **Inbound media is dropped.** Parsing keeps only the message text, so a photo, voice note, or document arrives as nothing at all. ADR#0044 settles where the fetch belongs; the downloader and the `channel_media_{prefix}` bucket do not @@ -398,6 +397,12 @@ them change the topology above. `message_ref` even though the field exists for it. `max_deliver` 5 bounds the duplicates. The same path makes any redelivery after a partial turn re-prompt the agent. +- **Exhausted redeliveries go nowhere.** A message the bridge keeps failing on is + dropped by JetStream after `max_deliver` 5 with no dead-letter subject, so a + claim whose object genuinely expired, or a bucket misconfiguration, costs five + loud failures and then silence. The failure is at least legible in the log, + which is the difference from acking on the first attempt, but nothing holds the + message for inspection. - **The bridge exits if the agent is down at boot.** ACP `initialize` runs before the consumer opens and propagates its error, so an agent that is not yet reachable turns into a restart loop rather than a bridge that waits. @@ -421,8 +426,9 @@ them change the topology above. 1. A user sends "hello" to the bot on Telegram. Telegram POSTs the webhook; `trogon-gateway` validates it and publishes the raw Update to `telegram.message` on stream `TELEGRAM`. -2. `channel-bridge-telegram` consumes it on its durable, parses the Update, and - encodes the endpoint address. +2. `channel-bridge-telegram` consumes it on its durable, redeems the body if the + message is a claim rather than a payload, parses the Update, and encodes the + endpoint address. 3. The bridge resolves endpoint to principal, dropping the message if there is none; then endpoint to conversation, creating one via routing policy if absent and writing the sticky `agent_id`; then ensures a live session on that agent diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs index f5442665cc..5732321f5d 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs @@ -10,6 +10,10 @@ pub struct BridgeConfig { pub channel_prefix: String, /// JetStream stream the trogon-gateway Telegram source provisions. pub inbound_stream: String, + /// Object-store bucket the gateway offloads oversized bodies to. Reading it + /// is not optional: an update over the NATS max payload arrives as an empty + /// body plus claim headers, and the bytes are only in the bucket. + pub claim_bucket: String, pub bot_token: String, /// Endpoint account token; identifies which bot account on Telegram. pub bot_account: String, @@ -35,6 +39,9 @@ impl BridgeConfig { let inbound_stream = env .var("TELEGRAM_INBOUND_STREAM") .unwrap_or_else(|_| "TELEGRAM".to_string()); + let claim_bucket = env + .var("TROGON_CLAIM_BUCKET") + .unwrap_or_else(|_| trogon_nats::jetstream::DEFAULT_CLAIM_BUCKET.to_string()); let bot_account = env.var("TELEGRAM_BOT_ACCOUNT").unwrap_or_else(|_| "bot".to_string()); let agent_id = env.var("CHANNEL_AGENT_ID").unwrap_or_else(|_| "default".to_string()); let agent_cwd = env @@ -76,6 +83,7 @@ impl BridgeConfig { acp, channel_prefix, inbound_stream, + claim_bucket, bot_token, bot_account, agent_id, diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs index f7e370c8d1..487a2c022c 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs @@ -31,6 +31,7 @@ use teloxide::Bot; use tracing::{error, info, warn}; use trogon_channel::store::PrincipalRecord; use trogon_channel::{ChannelStore, Endpoint, PrincipalId}; +use trogon_nats::jetstream::{ClaimResolver, NatsObjectStore}; use trogon_std::UuidV7Generator; use trogon_std::env::SystemEnv; use trogon_std::fs::SystemFs; @@ -63,6 +64,20 @@ async fn main() -> anyhow::Result<()> { config.inbound_stream ) })?; + // Same ownership as the stream: the gateway creates this bucket at startup + // and sizes its retention against the longest-retained stream it serves. + // Refusing to start without it turns a missing gateway into a boot failure, + // rather than into an oversized update that arrives one day and cannot be + // redeemed. + let claims = ClaimResolver::new( + NatsObjectStore::bind(&js, &config.claim_bucket).await.map_err(|e| { + anyhow::anyhow!( + "claim bucket '{}' not found; the trogon-gateway must provision it: {e}", + config.claim_bucket + ) + })?, + config.claim_bucket.clone(), + ); let consumer_name = format!("{INBOUND_DURABLE}-{}", config.channel_prefix); let consumer = stream .get_or_create_consumer( @@ -88,7 +103,9 @@ async fn main() -> anyhow::Result<()> { let bot = Bot::new(config.bot_token.clone()); let local = tokio::task::LocalSet::new(); - let result = local.run_until(run(nats_client, store, messages, bot, config)).await; + let result = local + .run_until(run(nats_client, store, claims, messages, bot, config)) + .await; if let Err(e) = trogon_telemetry::shutdown_otel() { error!(error = %e, "OpenTelemetry shutdown failed"); @@ -111,6 +128,7 @@ async fn seed_principals(store: &ChannelStore, config: &BridgeConfig) -> anyhow: async fn run( nats_client: async_nats::Client, store: ChannelStore, + claims: ClaimResolver, mut messages: async_nats::jetstream::consumer::pull::Stream, bot: Bot, config: BridgeConfig, @@ -160,6 +178,7 @@ async fn run( port: &port, renderer: renderer.as_ref(), outbound: &telegram, + claims: &claims, bot_account: &config.bot_account, agent_id: &config.agent_id, triggers: &config.command_triggers, diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs index abf890afe8..1f50f9583f 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -11,6 +11,7 @@ use trogon_channel::{ AgentId, AgentPort, AgentPortError as _, ChannelStore, Command, CommandTriggers, ConversationId, ConversationRecord, InboundEvent, ReleaseReason, }; +use trogon_nats::jetstream::{ClaimResolver, ObjectStoreGet}; use trogon_std::NowV7; /// What the bridge says back when a command has nothing else to do. A reset @@ -18,11 +19,12 @@ use trogon_std::NowV7; /// gets silence. const NEW_SESSION_ACKNOWLEDGEMENT: &str = "Started a new session."; -pub struct Pipeline<'a, P, O, G> { +pub struct Pipeline<'a, P, O, G, S> { pub store: &'a ChannelStore, pub port: &'a P, pub renderer: &'a TelegramRenderClient, pub outbound: &'a O, + pub claims: &'a ClaimResolver, pub bot_account: &'a str, pub agent_id: &'a str, pub triggers: &'a CommandTriggers, @@ -39,7 +41,7 @@ async fn ack(msg: &async_nats::jetstream::Message) -> anyhow::Result<()> { msg.ack().await.map_err(|e| anyhow::anyhow!("ack failed: {e}")) } -impl Pipeline<'_, P, O, G> { +impl Pipeline<'_, P, O, G, S> { /// Whether the individual who sent this message is a known principal. The /// conversation gate authorizes the chat, which in a group is everyone in /// it; destructive commands ask the narrower question. @@ -83,10 +85,21 @@ impl Pipeline<'_, P, O, G> { /// dropped; processing errors return `Err` with the message unacked so /// JetStream redelivers. pub async fn handle_message(&self, msg: &async_nats::jetstream::Message) -> anyhow::Result<()> { - let update = match serde_json::from_slice::(&msg.payload) { + // An update over the NATS max payload reaches the stream as an empty + // body plus claim headers, so the parse below has to run on the redeemed + // bytes. A failure here returns Err rather than acking: the update is + // real and recoverable, and dropping it would lose it permanently while + // blaming the parser. + let body = self + .claims + .resolve(msg.headers.as_ref(), msg.payload.clone()) + .await + .map_err(|e| anyhow::anyhow!("failed to redeem claim-checked update: {e}"))?; + + let update = match serde_json::from_slice::(&body) { Ok(update) => update, Err(e) => { - warn!(error = %e, body_len = msg.payload.len(), "Unparseable Telegram update; dropping"); + warn!(error = %e, body_len = body.len(), "Unparseable Telegram update; dropping"); return ack(msg).await; } }; diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs index a1ec6a4d08..9db4f9ea7b 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs @@ -11,6 +11,9 @@ use trogon_channel::store::PrincipalRecord; use trogon_channel::{ AgentPortError, AgentSessionId, Endpoint, InboundEvent, PrincipalId, PromptOutcome, ReleaseStep, SessionRelease, }; +use trogon_nats::jetstream::{ + ClaimCheckPublisher, ClaimRetention, DEFAULT_CLAIM_BUCKET, MaxPayload, NatsJetStreamClient, NatsObjectStore, +}; use trogon_std::UuidV7Generator; struct NatsServer { @@ -132,6 +135,33 @@ fn raw_update(update_id: u64, chat_id: i64, user_id: u64, text: &str) -> Vec .expect("serialize update") } +/// Consumer state once its acks have landed. `msg.ack()` does not wait for the +/// server, so a snapshot taken the instant the pipeline returns can still show +/// the last message pending. +async fn settled_consumer_info( + stream: &async_nats::jetstream::stream::Stream, + consumer: &str, +) -> async_nats::jetstream::consumer::Info { + for _ in 0..40 { + let info = stream.consumer_info(consumer).await.expect("consumer info"); + if info.num_ack_pending == 0 && info.num_pending == 0 { + return info; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + stream.consumer_info(consumer).await.expect("consumer info") +} + +/// The bucket the gateway offloads oversized bodies into, opened the way the +/// bridge opens it. Provisioned here because in a deployment the gateway has +/// already done so. +async fn claim_resolver(js: &async_nats::jetstream::Context) -> ClaimResolver { + let store = NatsObjectStore::provision_claim_bucket(js, DEFAULT_CLAIM_BUCKET, ClaimRetention::EventSourced) + .await + .expect("provision claim bucket"); + ClaimResolver::new(store, DEFAULT_CLAIM_BUCKET) +} + /// End to end against a real NATS: gateway-shaped raw updates in, identity /// gate, conversation + session KV, prompt, rendered reply out, and the reset /// command that rotates the session under a stable conversation. One container @@ -196,11 +226,13 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { }; let outbound = FakeOutbound::default(); let triggers = CommandTriggers::default(); + let claims = claim_resolver(&js).await; let pipeline = Pipeline { store: &store, port: &port, renderer: renderer.as_ref(), outbound: &outbound, + claims: &claims, bot_account: "mybot", agent_id: "default", triggers: &triggers, @@ -264,7 +296,200 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { ); // Everything acked: nothing left pending for redelivery. - let info = stream.consumer_info("bridge-test").await.expect("consumer info"); + let info = settled_consumer_info(&stream, "bridge-test").await; assert_eq!(info.num_ack_pending, 0); assert_eq!(info.num_pending, 0); } + +/// An update the gateway had to offload still reaches the agent. The gateway +/// publishes through `ClaimCheckPublisher`, so a body over the NATS max payload +/// arrives on the stream as an empty payload plus claim headers, and a consumer +/// that deserializes the payload sees zero bytes. Published here through the +/// same publisher the gateway uses, with the threshold driven to zero so every +/// body takes that path. +#[tokio::test] +async fn pipeline_redeems_a_claim_checked_update() { + let server = NatsServer::start().await; + let client = async_nats::connect(&server.url).await.expect("connect"); + let js = async_nats::jetstream::new(client); + + js.create_stream(async_nats::jetstream::stream::Config { + name: "TELEGRAM".to_string(), + subjects: vec!["telegram.>".to_string()], + ..Default::default() + }) + .await + .expect("create TELEGRAM stream"); + + let store = ChannelStore::ensure(&js, "test").await.expect("ensure buckets"); + let principal = PrincipalId::new("telegram-42").expect("principal"); + let endpoint = Endpoint::new("telegram", "mybot", "42").expect("endpoint"); + store + .link_endpoint(&principal, &PrincipalRecord { display_name: None }, &endpoint) + .await + .expect("seed principal"); + + let claims = claim_resolver(&js).await; + let gateway = ClaimCheckPublisher::new( + NatsJetStreamClient::new(js.clone()), + NatsObjectStore::bind(&js, DEFAULT_CLAIM_BUCKET) + .await + .expect("bind claim bucket"), + DEFAULT_CLAIM_BUCKET.to_string(), + MaxPayload::from_server_limit(0), + ); + let outcome = gateway + .publish_event( + "telegram.message".to_string(), + async_nats::HeaderMap::new(), + raw_update(1, 42, 42, "hello from a big body").into(), + std::time::Duration::from_secs(5), + ) + .await; + assert!(outcome.is_ok(), "gateway publish failed: {outcome:?}"); + + let stream = js.get_stream("TELEGRAM").await.expect("get stream"); + let consumer = stream + .get_or_create_consumer( + "bridge-test", + async_nats::jetstream::consumer::pull::Config { + durable_name: Some("bridge-test".to_string()), + ..Default::default() + }, + ) + .await + .expect("consumer"); + let mut messages = consumer.messages().await.expect("messages"); + + let renderer = Rc::new(TelegramRenderClient::new()); + let port = FakePort { + renderer: renderer.clone(), + reply: "hi there".to_string(), + sessions_created: RefCell::new(0), + prompted: RefCell::new(Vec::new()), + released: RefCell::new(Vec::new()), + }; + let outbound = FakeOutbound::default(); + let triggers = CommandTriggers::default(); + let pipeline = Pipeline { + store: &store, + port: &port, + renderer: renderer.as_ref(), + outbound: &outbound, + claims: &claims, + bot_account: "mybot", + agent_id: "default", + triggers: &triggers, + ids: &UuidV7Generator, + }; + + let msg = messages.next().await.expect("stream yields").expect("message received"); + // The premise of the test: parsing what arrived would have failed. + assert!(msg.payload.is_empty()); + pipeline.handle_message(&msg).await.expect("handled"); + + assert_eq!( + *port.prompted.borrow(), + vec![("sess-1".to_string(), "hello from a big body".to_string())] + ); + assert_eq!(*outbound.sent.borrow(), vec![(42, "hi there".to_string())]); + + let info = settled_consumer_info(&stream, "bridge-test").await; + assert_eq!(info.num_ack_pending, 0); + assert_eq!(info.num_pending, 0); +} + +/// A claim that cannot be redeemed is left for redelivery instead of acked. +/// Dropping it would be permanent, and the payload alone carries no sign that +/// anything was lost. +#[tokio::test] +async fn pipeline_leaves_an_unredeemable_claim_unacked() { + let server = NatsServer::start().await; + let client = async_nats::connect(&server.url).await.expect("connect"); + let js = async_nats::jetstream::new(client); + + js.create_stream(async_nats::jetstream::stream::Config { + name: "TELEGRAM".to_string(), + subjects: vec!["telegram.>".to_string()], + ..Default::default() + }) + .await + .expect("create TELEGRAM stream"); + + let store = ChannelStore::ensure(&js, "test").await.expect("ensure buckets"); + let claims = claim_resolver(&js).await; + let gateway = ClaimCheckPublisher::new( + NatsJetStreamClient::new(js.clone()), + NatsObjectStore::bind(&js, DEFAULT_CLAIM_BUCKET) + .await + .expect("bind claim bucket"), + DEFAULT_CLAIM_BUCKET.to_string(), + MaxPayload::from_server_limit(0), + ); + let outcome = gateway + .publish_event( + "telegram.message".to_string(), + async_nats::HeaderMap::new(), + raw_update(1, 42, 42, "hello").into(), + std::time::Duration::from_secs(5), + ) + .await; + assert!(outcome.is_ok(), "gateway publish failed: {outcome:?}"); + + // Simulates an object expired or never written: the claim survives, the + // bytes do not. + js.delete_object_store(DEFAULT_CLAIM_BUCKET) + .await + .expect("drop claim bucket"); + js.create_object_store(async_nats::jetstream::object_store::Config { + bucket: DEFAULT_CLAIM_BUCKET.to_string(), + ..Default::default() + }) + .await + .expect("recreate claim bucket"); + + let stream = js.get_stream("TELEGRAM").await.expect("get stream"); + let consumer = stream + .get_or_create_consumer( + "bridge-test", + async_nats::jetstream::consumer::pull::Config { + durable_name: Some("bridge-test".to_string()), + ..Default::default() + }, + ) + .await + .expect("consumer"); + let mut messages = consumer.messages().await.expect("messages"); + + let renderer = Rc::new(TelegramRenderClient::new()); + let port = FakePort { + renderer: renderer.clone(), + reply: "hi there".to_string(), + sessions_created: RefCell::new(0), + prompted: RefCell::new(Vec::new()), + released: RefCell::new(Vec::new()), + }; + let outbound = FakeOutbound::default(); + let triggers = CommandTriggers::default(); + let pipeline = Pipeline { + store: &store, + port: &port, + renderer: renderer.as_ref(), + outbound: &outbound, + claims: &claims, + bot_account: "mybot", + agent_id: "default", + triggers: &triggers, + ids: &UuidV7Generator, + }; + + let msg = messages.next().await.expect("stream yields").expect("message received"); + let error = pipeline.handle_message(&msg).await.expect_err("must not be acked"); + assert!(error.to_string().contains("failed to redeem claim-checked update")); + + assert!(port.prompted.borrow().is_empty()); + assert!(outbound.sent.borrow().is_empty()); + + let info = stream.consumer_info("bridge-test").await.expect("consumer info"); + assert_eq!(info.num_ack_pending, 1); +} diff --git a/rsworkspace/crates/platform/trogon-gateway/src/constants.rs b/rsworkspace/crates/platform/trogon-gateway/src/constants.rs index d272db0b46..28bb344f3f 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/constants.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/constants.rs @@ -4,7 +4,6 @@ use trogon_std::NonZeroDuration; pub const NATS_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); pub const NATS_SERVER_INFO_POLL_INTERVAL: Duration = Duration::from_millis(50); -pub const CLAIM_CHECK_BUCKET: &str = "trogon-claims"; /// Grace window added to the longest configured stream retention when sizing the /// claim-check bucket TTL, so a message at the edge of expiry can still resolve /// its claim before the object is reclaimed. diff --git a/rsworkspace/crates/platform/trogon-gateway/src/main.rs b/rsworkspace/crates/platform/trogon-gateway/src/main.rs index e47bfb637a..6c80c09cb4 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/main.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/main.rs @@ -24,9 +24,7 @@ use std::io::Write; use std::net::SocketAddr; #[cfg(not(coverage))] -use crate::constants::{ - CLAIM_CHECK_BUCKET, CLAIM_CHECK_TTL_GRACE, NATS_CONNECT_TIMEOUT, NATS_SERVER_INFO_POLL_INTERVAL, -}; +use crate::constants::{CLAIM_CHECK_TTL_GRACE, NATS_CONNECT_TIMEOUT, NATS_SERVER_INFO_POLL_INTERVAL}; #[cfg(not(coverage))] use anyhow::Context; #[cfg(not(coverage))] @@ -34,7 +32,9 @@ use tokio::task::JoinSet; #[cfg(not(coverage))] use tracing::{error, info}; #[cfg(not(coverage))] -use trogon_nats::jetstream::{ClaimCheckPublisher, ClaimRetention, MaxPayload, NatsJetStreamClient, NatsObjectStore}; +use trogon_nats::jetstream::{ + ClaimCheckPublisher, ClaimRetention, DEFAULT_CLAIM_BUCKET, MaxPayload, NatsJetStreamClient, NatsObjectStore, +}; #[cfg(not(coverage))] use trogon_nats::{connect, wait_for_server_info}; #[cfg(not(coverage))] @@ -99,7 +99,7 @@ async fn serve(resolved: config::ResolvedConfig) -> anyhow::Result<()> { .map(|stream_max_age| ClaimRetention::tracking(stream_max_age, CLAIM_CHECK_TTL_GRACE)) .unwrap_or(ClaimRetention::EventSourced); let object_store = - NatsObjectStore::provision_claim_bucket(&js_context, CLAIM_CHECK_BUCKET, claim_retention).await?; + NatsObjectStore::provision_claim_bucket(&js_context, DEFAULT_CLAIM_BUCKET, claim_retention).await?; let client = NatsJetStreamClient::new(js_context); streams::provision(&client, &resolved).await?; @@ -141,7 +141,7 @@ async fn serve(resolved: config::ResolvedConfig) -> anyhow::Result<()> { let publisher = ClaimCheckPublisher::new( client.clone(), object_store.clone(), - CLAIM_CHECK_BUCKET.to_string(), + DEFAULT_CLAIM_BUCKET.to_string(), nats.clone(), ); diff --git a/rsworkspace/crates/platform/trogon-nats/src/constants.rs b/rsworkspace/crates/platform/trogon-nats/src/constants.rs index 428c7cb355..96a9058139 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/constants.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/constants.rs @@ -20,6 +20,11 @@ pub const HEADER_CLAIM_CHECK: &str = "Trogon-Claim-Check"; pub const HEADER_CLAIM_BUCKET: &str = "Trogon-Claim-Bucket"; pub const HEADER_CLAIM_KEY: &str = "Trogon-Claim-Key"; +/// Bucket a claim check writes to and reads from when nothing overrides it. +/// Whoever publishes and whoever consumes must name the same bucket, so the +/// default belongs to the protocol rather than to either side of it. +pub const DEFAULT_CLAIM_BUCKET: &str = "trogon-claims"; + pub(crate) const CLAIM_CHECK_VERSION: &str = "v1"; pub(crate) const PROTOCOL_OVERHEAD: usize = 8 * 1024; pub(crate) const CLAIM_HEADER_PREFIX: &str = "Trogon-Claim-"; diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs index 0518677a09..0f3ec8a538 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs @@ -73,10 +73,65 @@ pub async fn resolve_claim( Ok(Bytes::from(buf)) } +/// The consumer half of a claim check: an object store bound to the bucket the +/// publisher was configured to write. +/// +/// [`resolve_claim`] takes an already-bound store and cannot tell whether it is +/// the right one, so a consumer pointed at the wrong bucket reports every claim +/// as a missing object. Pairing the store with its bucket name lets the +/// [`HEADER_CLAIM_BUCKET`] the publisher already sends be checked, which turns +/// that misconfiguration into its own error. +#[derive(Debug, Clone)] +pub struct ClaimResolver { + store: S, + bucket: String, +} + +impl ClaimResolver { + pub fn new(store: S, bucket: impl Into) -> Self { + Self { + store, + bucket: bucket.into(), + } + } + + pub fn bucket(&self) -> &str { + &self.bucket + } + + /// The body a consumer should act on: `payload` itself when the message + /// carries one, or the stored object when the payload was offloaded. + /// Headers are optional because that is how a subscription hands them over, + /// and a message without headers is never a claim. + pub async fn resolve( + &self, + headers: Option<&HeaderMap>, + payload: Bytes, + ) -> Result> { + let Some(headers) = headers else { + return Ok(payload); + }; + if !is_claim(headers) { + return Ok(payload); + } + if let Some(named) = headers.get(HEADER_CLAIM_BUCKET) + && named.as_str() != self.bucket + { + return Err(ClaimResolveError::BucketMismatch { + expected: self.bucket.clone(), + named: named.as_str().to_string(), + }); + } + resolve_claim(headers, payload, &self.store).await + } +} + #[derive(Debug, thiserror::Error)] pub enum ClaimResolveError { #[error("claim message missing {} header", HEADER_CLAIM_KEY)] MissingKey, + #[error("claim names bucket {named:?} but this consumer reads {expected:?}")] + BucketMismatch { expected: String, named: String }, #[error("failed to resolve claim from object store: {0}")] StoreFailed(#[source] E), #[error("failed to read claim payload: {0}")] diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs index 316bd11a97..815b014dd9 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs @@ -286,3 +286,129 @@ async fn small_payload_strips_claim_headers() { assert!(msg.headers.get(HEADER_CLAIM_KEY).is_none()); assert!(store.stored_objects().is_empty()); } + +#[tokio::test] +async fn resolver_returns_payload_when_message_has_no_headers() { + let resolver = ClaimResolver::new(MockObjectStore::new(), "test-bucket"); + let payload = Bytes::from("raw data"); + + let result = resolver.resolve(None, payload.clone()).await; + assert_eq!(result.unwrap(), payload); +} + +#[tokio::test] +async fn resolver_returns_payload_when_headers_carry_no_claim() { + let resolver = ClaimResolver::new(MockObjectStore::new(), "test-bucket"); + let headers = HeaderMap::new(); + let payload = Bytes::from("raw data"); + + let result = resolver.resolve(Some(&headers), payload.clone()).await; + assert_eq!(result.unwrap(), payload); +} + +#[tokio::test] +async fn resolver_redeems_a_claim_from_its_bucket() { + let store = MockObjectStore::new(); + let expected = Bytes::from("offloaded body"); + store.seed("test.subject/some-id", expected.clone()); + let resolver = ClaimResolver::new(store, "test-bucket"); + + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CLAIM_CHECK, CLAIM_CHECK_VERSION); + headers.insert(HEADER_CLAIM_BUCKET, "test-bucket"); + headers.insert(HEADER_CLAIM_KEY, "test.subject/some-id"); + + let result = resolver.resolve(Some(&headers), Bytes::new()).await; + assert_eq!(result.unwrap(), expected); +} + +#[tokio::test] +async fn resolver_redeems_a_claim_that_names_no_bucket() { + let store = MockObjectStore::new(); + let expected = Bytes::from("offloaded body"); + store.seed("test.subject/some-id", expected.clone()); + let resolver = ClaimResolver::new(store, "test-bucket"); + + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CLAIM_CHECK, CLAIM_CHECK_VERSION); + headers.insert(HEADER_CLAIM_KEY, "test.subject/some-id"); + + let result = resolver.resolve(Some(&headers), Bytes::new()).await; + assert_eq!(result.unwrap(), expected); +} + +/// A consumer bound to the wrong bucket would otherwise read a bucket that +/// happens to hold nothing under that key and report a missing object, which +/// reads as a lost payload instead of as a misconfiguration. +#[tokio::test] +async fn resolver_rejects_a_claim_from_another_bucket() { + let store = MockObjectStore::new(); + store.seed("test.subject/some-id", Bytes::from("offloaded body")); + let resolver = ClaimResolver::new(store, "test-bucket"); + assert_eq!(resolver.bucket(), "test-bucket"); + + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CLAIM_CHECK, CLAIM_CHECK_VERSION); + headers.insert(HEADER_CLAIM_BUCKET, "someone-elses-bucket"); + headers.insert(HEADER_CLAIM_KEY, "test.subject/some-id"); + + let error = resolver.resolve(Some(&headers), Bytes::new()).await.unwrap_err(); + assert!(matches!( + error, + ClaimResolveError::BucketMismatch { ref expected, ref named } + if expected == "test-bucket" && named == "someone-elses-bucket" + )); + assert!(error.to_string().contains("someone-elses-bucket")); +} + +/// The publisher writes the object and only then publishes the claim, so a +/// consumer that cannot read the object is looking at a transient failure and +/// must not treat the message as consumed. +#[tokio::test] +async fn resolver_surfaces_a_store_failure_rather_than_an_empty_body() { + let store = MockObjectStore::new(); + store.seed("test.subject/some-id", Bytes::from("offloaded body")); + store.fail_next_get(); + let resolver = ClaimResolver::new(store, "test-bucket"); + + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CLAIM_CHECK, CLAIM_CHECK_VERSION); + headers.insert(HEADER_CLAIM_BUCKET, "test-bucket"); + headers.insert(HEADER_CLAIM_KEY, "test.subject/some-id"); + + let result = resolver.resolve(Some(&headers), Bytes::new()).await; + assert!(matches!(result, Err(ClaimResolveError::StoreFailed(_)))); +} + +#[tokio::test] +async fn published_claim_round_trips_through_a_resolver() { + let publisher = MockJetStreamPublisher::new(); + let store = MockObjectStore::new(); + let cc = ClaimCheckPublisher::new( + publisher.clone(), + store.clone(), + "test-bucket".to_string(), + MaxPayload::from_server_limit(1024 + PROTOCOL_OVERHEAD), + ); + let body = Bytes::from(vec![7u8; 4096]); + + let outcome = cc + .publish_event( + "test.subject".to_string(), + HeaderMap::new(), + body.clone(), + Duration::from_secs(5), + ) + .await; + assert!(outcome.is_ok()); + + let msg = &publisher.published_messages()[0]; + assert!(msg.payload.is_empty()); + + let resolver = ClaimResolver::new(store, "test-bucket"); + let resolved = resolver + .resolve(Some(&msg.headers), msg.payload.clone()) + .await + .expect("resolve"); + assert_eq!(resolved, body); +} diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs index bc3458df18..fcf4742072 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs @@ -12,8 +12,8 @@ pub mod traits; #[cfg(any(test, feature = "test-support"))] pub mod mocks; -pub use crate::constants::{HEADER_CLAIM_BUCKET, HEADER_CLAIM_CHECK, HEADER_CLAIM_KEY}; -pub use claim_check::{ClaimCheckPublisher, ClaimResolveError, MaxPayload, is_claim, resolve_claim}; +pub use crate::constants::{DEFAULT_CLAIM_BUCKET, HEADER_CLAIM_BUCKET, HEADER_CLAIM_CHECK, HEADER_CLAIM_KEY}; +pub use claim_check::{ClaimCheckPublisher, ClaimResolveError, ClaimResolver, MaxPayload, is_claim, resolve_claim}; pub use claim_retention::ClaimRetention; #[cfg(not(coverage))] pub use client::{ diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store.rs index 354863d6e8..436946b446 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store.rs @@ -91,6 +91,18 @@ impl NatsObjectStore { } } + /// Open an existing bucket without creating it or touching its retention. + /// A consumer redeeming claims reads a bucket whose lifecycle belongs to the + /// publisher that fills it, so creating one here would only hide the fact + /// that the publisher never ran. + pub async fn bind(js: &async_nats::jetstream::Context, bucket: &str) -> Result { + let store = js + .get_object_store(bucket) + .await + .map_err(ProvisionObjectStoreError::Get)?; + Ok(Self { store }) + } + /// Provision a bucket that backs claim-check payloads, sizing its `max_age` /// from [`ClaimRetention`] so the object always outlives the messages that /// reference it. Callers cannot forget the retention or let it drift from From f05f5d07a91a696ed5439c11351eebed6f31d4f7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 01:49:00 -0400 Subject: [PATCH 14/55] fix(channel): stop a rejected prompt from costing a conversation its session No protocol carries a distinct "no such session" code, so the code that rejects an unknown session id is also the code an agent uses to reject a prompt it simply will not answer. Betting the conversation's session pointer on that guess meant an ordinary refusal rotated the conversation onto a fresh session and failed anyway, leaving the user talking to an agent that had lost the thread. Signed-off-by: Yordis Prieto --- .../channel-bridge-telegram/src/acp_port.rs | 16 +- .../channel-bridge-telegram/src/pipeline.rs | 45 +++- .../src/pipeline_tests.rs | 233 +++++++++++++++--- .../channel/trogon-channel/src/agent_port.rs | 18 +- 4 files changed, 267 insertions(+), 45 deletions(-) diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs index a95ee9b1a2..7fa122e503 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs @@ -23,10 +23,18 @@ pub enum AcpPortError { impl AgentPortError for AcpPortError { fn is_session_lost(&self) -> bool { - // The acp-nats bridge maps every transport failure and timeout to - // `InternalError` and passes agent-returned errors through untouched, - // so only the codes an agent uses to reject an unknown session id mean - // the session is actually gone. + // ACP has no session-not-found code. The acp-nats bridge maps every + // transport failure and timeout to `InternalError` and passes + // agent-returned errors through untouched, which narrows it to the codes + // an agent plausibly uses to reject an unknown session id: the session + // id is a parameter, so `InvalidParams` is the likeliest, and it is also + // how an agent rejects a prompt it dislikes for any other reason. + // + // Kept deliberately broad. The caller treats this as a hint and keeps a + // fresh session only once it has answered, so a false positive costs one + // unused session, whereas a false negative would leave the conversation + // pinned to a session the agent has forgotten, failing every future + // message rather than just this one. match self { Self::Rpc(error) => matches!(error.code, ErrorCode::InvalidParams | ErrorCode::ResourceNotFound), } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs index 1f50f9583f..0316d12aaa 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -190,21 +190,48 @@ impl Pipeline<'_, P, O, // agent, so routing policy never re-runs. Every other failure is // left to redelivery, because rotating on a timeout or a transport // blip would throw away a conversation that was merely unreachable. + // + // Whether the session is really gone is a guess (see + // `AgentPortError::is_session_lost`), so nothing is committed until + // the fresh session has answered. A wrong guess then costs one unused + // session instead of the conversation's history, which is the whole + // reason the pointer is not moved first. Err(first_error) if first_error.is_session_lost() => { - warn!(error = %first_error, session = %active_session, "Agent no longer has the session; retrying with a fresh one"); + warn!(error = %first_error, session = %active_session, "Agent may no longer have the session; trying a fresh one"); let fresh = self .port .create_session(&record) .await .map_err(|e| anyhow::anyhow!("create_session failed: {e}"))?; - record.current_session = Some(fresh.clone()); - self.store.update_conversation(&conversation_id, &record).await?; - self.renderer.discard(active_session.as_str()); - active_session = fresh; - self.port - .prompt(&active_session, &event) - .await - .map_err(|e| anyhow::anyhow!("prompt retry failed: {e}"))? + + match self.port.prompt(&fresh, &event).await { + Ok(outcome) => { + record.current_session = Some(fresh.clone()); + self.store.update_conversation(&conversation_id, &record).await?; + self.renderer.discard(active_session.as_str()); + active_session = fresh; + outcome + } + // A fresh session failed the same way, so the session was + // never the problem: the prompt itself is being rejected. + // Hand back the session nobody used and leave the + // conversation where it was, so redelivery retries the + // prompt rather than compounding the rotation. + Err(retry_error) => { + let release = self.port.release_session(&fresh, ReleaseReason::RepairFailed).await; + self.renderer.discard(fresh.as_str()); + info!( + conversation = %conversation_id, + session = %fresh, + cancelled = ?release.cancelled, + closed = ?release.closed, + "Released the session opened to repair a suspected loss" + ); + return Err(anyhow::anyhow!( + "prompt failed on session {active_session} and again on a fresh session, so the session was not the cause: {first_error} (retry: {retry_error})" + )); + } + } } Err(error) => return Err(anyhow::anyhow!("prompt failed: {error}")), }; diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs index 9db4f9ea7b..6e0412ea0d 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs @@ -39,12 +39,14 @@ impl NatsServer { } #[derive(Debug, thiserror::Error)] -#[error("fake agent failure")] -struct FakeError; +#[error("fake agent failure (session_lost={session_lost})")] +struct FakeError { + session_lost: bool, +} impl AgentPortError for FakeError { fn is_session_lost(&self) -> bool { - false + self.session_lost } } @@ -56,6 +58,29 @@ struct FakePort { sessions_created: RefCell, prompted: RefCell>, released: RefCell>, + /// How many upcoming prompts fail with an error classified as a lost + /// session. One models a session the agent really did forget, so the fresh + /// session succeeds; two makes the fresh session fail the same way, which is + /// the misclassification, where the prompt itself is being rejected and no + /// fresh session helps. + rejections: RefCell, +} + +impl FakePort { + fn new(renderer: Rc, reply: &str) -> Self { + Self { + renderer, + reply: reply.to_string(), + sessions_created: RefCell::new(0), + prompted: RefCell::new(Vec::new()), + released: RefCell::new(Vec::new()), + rejections: RefCell::new(0), + } + } + + fn reject_next_prompts(&self, count: u32) { + *self.rejections.borrow_mut() = count; + } } impl trogon_channel::AgentPort for FakePort { @@ -73,6 +98,17 @@ impl trogon_channel::AgentPort for FakePort { self.prompted .borrow_mut() .push((session.as_str().to_string(), event.text.clone().unwrap_or_default())); + + let reject = { + let mut left = self.rejections.borrow_mut(); + let reject = *left > 0; + *left = left.saturating_sub(1); + reject + }; + if reject { + return Err(FakeError { session_lost: true }); + } + let notification = SessionNotification::new( session.as_str().to_string(), SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(TextContent::new( @@ -137,14 +173,17 @@ fn raw_update(update_id: u64, chat_id: i64, user_id: u64, text: &str) -> Vec /// Consumer state once its acks have landed. `msg.ack()` does not wait for the /// server, so a snapshot taken the instant the pipeline returns can still show -/// the last message pending. +/// the last message pending. `ack_pending` is what the caller expects to remain +/// outstanding: zero when everything was acked, and one per message the pipeline +/// deliberately left for redelivery. async fn settled_consumer_info( stream: &async_nats::jetstream::stream::Stream, consumer: &str, + ack_pending: u64, ) -> async_nats::jetstream::consumer::Info { for _ in 0..40 { let info = stream.consumer_info(consumer).await.expect("consumer info"); - if info.num_ack_pending == 0 && info.num_pending == 0 { + if info.num_ack_pending as u64 == ack_pending && info.num_pending == 0 { return info; } tokio::time::sleep(std::time::Duration::from_millis(50)).await; @@ -217,13 +256,7 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { let mut messages = consumer.messages().await.expect("messages"); let renderer = Rc::new(TelegramRenderClient::new()); - let port = FakePort { - renderer: renderer.clone(), - reply: "hi there".to_string(), - sessions_created: RefCell::new(0), - prompted: RefCell::new(Vec::new()), - released: RefCell::new(Vec::new()), - }; + let port = FakePort::new(renderer.clone(), "hi there"); let outbound = FakeOutbound::default(); let triggers = CommandTriggers::default(); let claims = claim_resolver(&js).await; @@ -296,7 +329,7 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { ); // Everything acked: nothing left pending for redelivery. - let info = settled_consumer_info(&stream, "bridge-test").await; + let info = settled_consumer_info(&stream, "bridge-test", 0).await; assert_eq!(info.num_ack_pending, 0); assert_eq!(info.num_pending, 0); } @@ -362,13 +395,7 @@ async fn pipeline_redeems_a_claim_checked_update() { let mut messages = consumer.messages().await.expect("messages"); let renderer = Rc::new(TelegramRenderClient::new()); - let port = FakePort { - renderer: renderer.clone(), - reply: "hi there".to_string(), - sessions_created: RefCell::new(0), - prompted: RefCell::new(Vec::new()), - released: RefCell::new(Vec::new()), - }; + let port = FakePort::new(renderer.clone(), "hi there"); let outbound = FakeOutbound::default(); let triggers = CommandTriggers::default(); let pipeline = Pipeline { @@ -394,7 +421,7 @@ async fn pipeline_redeems_a_claim_checked_update() { ); assert_eq!(*outbound.sent.borrow(), vec![(42, "hi there".to_string())]); - let info = settled_consumer_info(&stream, "bridge-test").await; + let info = settled_consumer_info(&stream, "bridge-test", 0).await; assert_eq!(info.num_ack_pending, 0); assert_eq!(info.num_pending, 0); } @@ -462,13 +489,7 @@ async fn pipeline_leaves_an_unredeemable_claim_unacked() { let mut messages = consumer.messages().await.expect("messages"); let renderer = Rc::new(TelegramRenderClient::new()); - let port = FakePort { - renderer: renderer.clone(), - reply: "hi there".to_string(), - sessions_created: RefCell::new(0), - prompted: RefCell::new(Vec::new()), - released: RefCell::new(Vec::new()), - }; + let port = FakePort::new(renderer.clone(), "hi there"); let outbound = FakeOutbound::default(); let triggers = CommandTriggers::default(); let pipeline = Pipeline { @@ -493,3 +514,159 @@ async fn pipeline_leaves_an_unredeemable_claim_unacked() { let info = stream.consumer_info("bridge-test").await.expect("consumer info"); assert_eq!(info.num_ack_pending, 1); } + +/// A suspected lost session is repaired without betting the conversation on the +/// suspicion. "Session lost" is a guess drawn from an error code that also +/// covers ordinary rejections, so the fresh session has to answer before the +/// conversation points at it: a prompt the agent simply refuses must leave the +/// conversation on the session it already had, with its history, rather than +/// rotating it onto a new one and failing anyway. One container for the whole +/// scenario. +#[tokio::test] +async fn pipeline_keeps_the_session_when_a_fresh_one_fails_the_same_way() { + let server = NatsServer::start().await; + let client = async_nats::connect(&server.url).await.expect("connect"); + let js = async_nats::jetstream::new(client); + + js.create_stream(async_nats::jetstream::stream::Config { + name: "TELEGRAM".to_string(), + subjects: vec!["telegram.>".to_string()], + ..Default::default() + }) + .await + .expect("create TELEGRAM stream"); + + let store = ChannelStore::ensure(&js, "test").await.expect("ensure buckets"); + let principal = PrincipalId::new("telegram-42").expect("principal"); + let endpoint = Endpoint::new("telegram", "mybot", "42").expect("endpoint"); + store + .link_endpoint(&principal, &PrincipalRecord { display_name: None }, &endpoint) + .await + .expect("seed principal"); + + for (update_id, text) in [(1u64, "hello"), (2, "refused"), (3, "after"), (4, "recover")] { + js.publish("telegram.message", raw_update(update_id, 42, 42, text).into()) + .await + .expect("publish") + .await + .expect("ack"); + } + + let stream = js.get_stream("TELEGRAM").await.expect("get stream"); + let consumer = stream + .get_or_create_consumer( + "bridge-test", + async_nats::jetstream::consumer::pull::Config { + durable_name: Some("bridge-test".to_string()), + ..Default::default() + }, + ) + .await + .expect("consumer"); + let mut messages = consumer.messages().await.expect("messages"); + + let renderer = Rc::new(TelegramRenderClient::new()); + let port = FakePort::new(renderer.clone(), "hi there"); + let outbound = FakeOutbound::default(); + let triggers = CommandTriggers::default(); + let claims = claim_resolver(&js).await; + let pipeline = Pipeline { + store: &store, + port: &port, + renderer: renderer.as_ref(), + outbound: &outbound, + claims: &claims, + bot_account: "mybot", + agent_id: "default", + triggers: &triggers, + ids: &UuidV7Generator, + }; + + macro_rules! next_message { + () => { + messages + .next() + .await + .expect("stream yields") + .expect("message received") + }; + } + + pipeline.handle_message(&next_message!()).await.expect("handled"); + let session_before = store + .conversation_for(&endpoint) + .await + .expect("kv read") + .expect("conversation exists") + .1 + .current_session; + assert_eq!(session_before.as_ref().map(AgentSessionId::as_str), Some("sess-1")); + + // Both the original session and the fresh one reject this prompt, which is + // what an ordinary rejection misread as a lost session looks like. + port.reject_next_prompts(2); + let error = pipeline + .handle_message(&next_message!()) + .await + .expect_err("must not be acked"); + assert!( + error.to_string().contains("the session was not the cause"), + "unexpected error: {error}" + ); + + // The point of the test: the conversation still holds the session it had, + // and the session nobody got to use was handed back. + let (_, record) = store + .conversation_for(&endpoint) + .await + .expect("kv read") + .expect("conversation exists"); + assert_eq!(record.current_session, session_before); + assert_eq!(*port.released.borrow(), vec!["sess-2".to_string()]); + + // So the next message continues on it rather than starting over. + pipeline.handle_message(&next_message!()).await.expect("handled"); + + // A session the agent really has forgotten is still repaired: the fresh one + // answers, so the conversation moves onto it. + port.reject_next_prompts(1); + pipeline.handle_message(&next_message!()).await.expect("handled"); + + assert_eq!(*port.sessions_created.borrow(), 3); + assert_eq!( + *port.prompted.borrow(), + vec![ + ("sess-1".to_string(), "hello".to_string()), + ("sess-1".to_string(), "refused".to_string()), + ("sess-2".to_string(), "refused".to_string()), + ("sess-1".to_string(), "after".to_string()), + ("sess-1".to_string(), "recover".to_string()), + ("sess-3".to_string(), "recover".to_string()), + ] + ); + let (_, record) = store + .conversation_for(&endpoint) + .await + .expect("kv read") + .expect("conversation exists"); + assert_eq!( + record.current_session.as_ref().map(AgentSessionId::as_str), + Some("sess-3") + ); + + assert_eq!(*outbound.typing.borrow(), 4); + assert_eq!( + *outbound.sent.borrow(), + vec![ + (42, "hi there".to_string()), + (42, "hi there".to_string()), + (42, "hi there".to_string()), + ] + ); + + // The rejected message is left for redelivery, so the prompt is retried + // instead of lost to a rotation that did not help. + let info = settled_consumer_info(&stream, "bridge-test", 1).await; + assert_eq!(info.num_ack_pending, 1); + assert_eq!(info.num_pending, 0); +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs index 9e16660c8c..89daa7827a 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs @@ -40,10 +40,16 @@ pub enum PromptOutcome { /// port classifies its own protocol's failures; nothing above this trait /// inspects error codes. pub trait AgentPortError: std::error::Error + 'static { - /// True only when the agent no longer has the session, which a fresh + /// True when the agent may no longer have the session, which a fresh /// session repairs. Transport failures, timeouts, and agent-internal /// errors are false: rotating on those would discard a live conversation /// that was merely unreachable for a moment. + /// + /// A hint, not a verdict. A protocol need not carry a distinct "no such + /// session" code, so whatever code rejects an unknown session id may also + /// reject a prompt the agent simply dislikes. A caller must therefore never + /// destroy conversation state on this alone: it may open a fresh session + /// and keep it only once that session has actually answered. fn is_session_lost(&self) -> bool; } @@ -53,6 +59,9 @@ pub trait AgentPortError: std::error::Error + 'static { pub enum ReleaseReason { /// The user asked for a fresh conversation. NewSession, + /// A session opened to repair a suspected lost session failed the same way, + /// so the bridge is handing back one it never got to use. + RepairFailed, } /// How one step of the release ladder ended. @@ -65,9 +74,10 @@ pub enum ReleaseStep { Failed, } -/// Report of a best-effort release. The conversation has already dropped its -/// pointer to the session by the time this runs, so no step here can fail the -/// reset; the report exists so an operator can see what the agent did with it. +/// Report of a best-effort release. The conversation does not point at the +/// session by the time this runs, whether it dropped the pointer or never took +/// one, so no step here can fail anything; the report exists so an operator can +/// see what the agent did with it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SessionRelease { pub cancelled: ReleaseStep, From 21a7fe96a525a85f8f8e65e360a57b6e2ae5791e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 01:49:09 -0400 Subject: [PATCH 15/55] fix(channel): reject a blank bot token instead of booting on it "Unset" reaches a process as the empty string more often than as an absent variable: Compose renders it that way, and so does a Kubernetes secret reference to a missing key. Reading that as configured meant the bridge started, reported nothing wrong, and then failed on its first Bot API call with nothing at startup to point at the cause. Signed-off-by: Yordis Prieto --- devops/docker/compose/compose.yml | 7 +- .../channel-bridge-telegram/Cargo.toml | 1 + .../channel-bridge-telegram/src/config.rs | 93 ++++++++++--- .../src/config_tests.rs | 122 ++++++++++++++++++ .../channel-bridge-telegram/src/main.rs | 2 +- 5 files changed, 201 insertions(+), 24 deletions(-) create mode 100644 rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs diff --git a/devops/docker/compose/compose.yml b/devops/docker/compose/compose.yml index c7bca547fe..6a30111046 100644 --- a/devops/docker/compose/compose.yml +++ b/devops/docker/compose/compose.yml @@ -57,8 +57,11 @@ services: - path: .env required: false environment: - TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}" - CHANNEL_SEED_TELEGRAM_USERS: "${CHANNEL_SEED_TELEGRAM_USERS:-}" + # Passed through with no value on purpose. Writing "${VAR:-}" would set the + # variable to the empty string when the host has not set it, which reads as + # "configured, blank" instead of "absent"; a bare key leaves it unset. + TELEGRAM_BOT_TOKEN: + CHANNEL_SEED_TELEGRAM_USERS: CHANNEL_PREFIX: "${CHANNEL_PREFIX:-prod}" CHANNEL_NEW_SESSION_TRIGGERS: "${CHANNEL_NEW_SESSION_TRIGGERS:-/new,/reset}" TELEGRAM_INBOUND_STREAM: "${TELEGRAM_INBOUND_STREAM:-TELEGRAM}" diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml index a5aa64868a..dad4239527 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml +++ b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml @@ -26,3 +26,4 @@ tracing = { workspace = true } [dev-dependencies] testcontainers-modules = { version = "0.15", features = ["nats"] } +trogon-std = { workspace = true, features = ["test-support"] } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs index 5732321f5d..10cc50657c 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs @@ -1,9 +1,63 @@ +#[cfg(test)] +#[path = "config_tests.rs"] +mod config_tests; + use acp_nats::{AcpPrefix, NatsConfig}; use anyhow::Context; use std::path::PathBuf; use trogon_channel::CommandTriggers; use trogon_std::env::ReadEnv; +/// A Telegram Bot API token that cannot be blank. +/// +/// Blank is rejected because "unset" reaches a process as the empty string more +/// often than as an absent variable: Compose renders `"${TELEGRAM_BOT_TOKEN:-}"` +/// that way, and so does a Kubernetes secret reference to a missing key. Reading +/// that as present would boot the bridge on a token that only fails later, on +/// the first Bot API call, with nothing at startup to say why. +#[derive(Clone, PartialEq, Eq)] +pub struct BotToken(String); + +#[derive(Debug, thiserror::Error)] +#[error("bot token is blank")] +pub struct BlankBotToken; + +impl BotToken { + /// Surrounding whitespace is trimmed rather than rejected: a token read + /// from a file or a heredoc almost always arrives with a trailing newline, + /// and Telegram would reject it with no hint as to which byte was wrong. + pub fn new(raw: impl AsRef) -> Result { + let trimmed = raw.as_ref().trim(); + if trimmed.is_empty() { + return Err(BlankBotToken); + } + Ok(Self(trimmed.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Redacted, and deliberately without a `Display`: a config struct is exactly +/// the kind of value that ends up in a debug log. +impl std::fmt::Debug for BotToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("BotToken(redacted)") + } +} + +/// Reads a variable, treating one that is present but blank as absent. Same +/// reason as [`BotToken`]: a deployment that renders an unset variable as the +/// empty string must fall back to the default rather than configure an empty +/// stream name or KV bucket prefix. +fn var(env: &E, key: &str) -> Option { + env.var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + pub struct BridgeConfig { pub acp: acp_nats::Config, /// Environment/tenant token for KV buckets and the durable consumer name. @@ -14,7 +68,7 @@ pub struct BridgeConfig { /// is not optional: an update over the NATS max payload arrives as an empty /// body plus claim headers, and the bytes are only in the bucket. pub claim_bucket: String, - pub bot_token: String, + pub bot_token: BotToken, /// Endpoint account token; identifies which bot account on Telegram. pub bot_account: String, /// Agent every new conversation binds to; the routing policy is one agent. @@ -33,24 +87,19 @@ pub struct BridgeConfig { impl BridgeConfig { pub fn from_env(env: &E) -> anyhow::Result { - let bot_token = env.var("TELEGRAM_BOT_TOKEN").context("TELEGRAM_BOT_TOKEN not set")?; + let bot_token = + BotToken::new(env.var("TELEGRAM_BOT_TOKEN").unwrap_or_default()).context("TELEGRAM_BOT_TOKEN not set")?; - let channel_prefix = env.var("CHANNEL_PREFIX").unwrap_or_else(|_| "prod".to_string()); - let inbound_stream = env - .var("TELEGRAM_INBOUND_STREAM") - .unwrap_or_else(|_| "TELEGRAM".to_string()); - let claim_bucket = env - .var("TROGON_CLAIM_BUCKET") - .unwrap_or_else(|_| trogon_nats::jetstream::DEFAULT_CLAIM_BUCKET.to_string()); - let bot_account = env.var("TELEGRAM_BOT_ACCOUNT").unwrap_or_else(|_| "bot".to_string()); - let agent_id = env.var("CHANNEL_AGENT_ID").unwrap_or_else(|_| "default".to_string()); - let agent_cwd = env - .var("CHANNEL_AGENT_CWD") - .map(PathBuf::from) - .unwrap_or_else(|_| std::env::temp_dir()); + let channel_prefix = var(env, "CHANNEL_PREFIX").unwrap_or_else(|| "prod".to_string()); + let inbound_stream = var(env, "TELEGRAM_INBOUND_STREAM").unwrap_or_else(|| "TELEGRAM".to_string()); + let claim_bucket = + var(env, "TROGON_CLAIM_BUCKET").unwrap_or_else(|| trogon_nats::jetstream::DEFAULT_CLAIM_BUCKET.to_string()); + let bot_account = var(env, "TELEGRAM_BOT_ACCOUNT").unwrap_or_else(|| "bot".to_string()); + let agent_id = var(env, "CHANNEL_AGENT_ID").unwrap_or_else(|| "default".to_string()); + let agent_cwd = var(env, "CHANNEL_AGENT_CWD").map_or_else(std::env::temp_dir, PathBuf::from); - let seed_users = match env.var("CHANNEL_SEED_TELEGRAM_USERS") { - Ok(raw) => raw + let seed_users = match var(env, "CHANNEL_SEED_TELEGRAM_USERS") { + Some(raw) => raw .split(',') .filter(|s| !s.trim().is_empty()) .map(|s| { @@ -59,9 +108,13 @@ impl BridgeConfig { .with_context(|| format!("invalid Telegram user id in CHANNEL_SEED_TELEGRAM_USERS: {s:?}")) }) .collect::>>()?, - Err(_) => Vec::new(), + None => Vec::new(), }; + // Read directly rather than through `var`: this is the one setting where + // blank is a value rather than an omission. An empty trigger list means + // "recognize nothing, forward everything", which a deployment can only + // ask for by setting the variable to empty. let command_triggers = match env.var("CHANNEL_NEW_SESSION_TRIGGERS") { Ok(raw) => CommandTriggers::new( raw.split(',') @@ -73,9 +126,7 @@ impl BridgeConfig { Err(_) => CommandTriggers::default(), }; - let raw_prefix = env - .var(acp_nats::ENV_ACP_PREFIX) - .unwrap_or_else(|_| acp_nats::DEFAULT_ACP_PREFIX.to_string()); + let raw_prefix = var(env, acp_nats::ENV_ACP_PREFIX).unwrap_or_else(|| acp_nats::DEFAULT_ACP_PREFIX.to_string()); let acp_prefix = AcpPrefix::new(raw_prefix).context("invalid ACP prefix")?; let acp = acp_nats::Config::with_prefix(acp_prefix, NatsConfig::from_env(env)); diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs new file mode 100644 index 0000000000..4260ac9c36 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs @@ -0,0 +1,122 @@ +use super::*; +use trogon_std::env::InMemoryEnv; + +/// Why a config refused to load. Spelled out instead of `expect_err` because a +/// `BridgeConfig` is not `Debug`, which is the point: it holds a token. +fn rejected(env: &InMemoryEnv) -> String { + match BridgeConfig::from_env(env) { + Ok(_) => panic!("config must not load"), + Err(error) => error.to_string(), + } +} + +/// A token is the one required variable, so every way of not supplying one has +/// to fail the same way. Compose renders an unset variable as the empty string, +/// which is why blank is not merely tolerated-but-odd: it is the common case. +#[test] +fn a_blank_bot_token_fails_like_an_unset_one() { + for token in ["", " ", "\n"] { + let env = InMemoryEnv::new(); + env.set("TELEGRAM_BOT_TOKEN", token); + let error = rejected(&env); + assert!( + error.contains("TELEGRAM_BOT_TOKEN not set"), + "unexpected error for {token:?}: {error}" + ); + } + + let error = rejected(&InMemoryEnv::new()); + assert!( + error.contains("TELEGRAM_BOT_TOKEN not set"), + "unexpected error: {error}" + ); +} + +/// A token read from a file or a heredoc arrives with a trailing newline, which +/// Telegram rejects without saying which byte was wrong. +#[test] +fn a_bot_token_is_trimmed() { + let env = InMemoryEnv::new(); + env.set("TELEGRAM_BOT_TOKEN", " secret-token\n"); + let config = BridgeConfig::from_env(&env).expect("config"); + assert_eq!(config.bot_token.as_str(), "secret-token"); +} + +#[test] +fn a_bot_token_does_not_print_itself() { + let token = BotToken::new("secret-token").expect("token"); + let rendered = format!("{token:?}"); + assert!( + !rendered.contains("secret-token"), + "token leaked into Debug: {rendered}" + ); +} + +/// Every optional variable has a default, and a deployment that renders an +/// unset variable as blank must land on that default rather than on an empty +/// bucket prefix or stream name. +#[test] +fn blank_optional_variables_fall_back_to_their_defaults() { + let env = InMemoryEnv::new(); + env.set("TELEGRAM_BOT_TOKEN", "secret-token"); + for key in [ + "CHANNEL_PREFIX", + "TELEGRAM_INBOUND_STREAM", + "TROGON_CLAIM_BUCKET", + "TELEGRAM_BOT_ACCOUNT", + "CHANNEL_AGENT_ID", + "CHANNEL_AGENT_CWD", + "CHANNEL_SEED_TELEGRAM_USERS", + ] { + env.set(key, ""); + } + + let config = BridgeConfig::from_env(&env).expect("config"); + assert_eq!(config.channel_prefix, "prod"); + assert_eq!(config.inbound_stream, "TELEGRAM"); + assert_eq!(config.claim_bucket, trogon_nats::jetstream::DEFAULT_CLAIM_BUCKET); + assert_eq!(config.bot_account, "bot"); + assert_eq!(config.agent_id, "default"); + assert_eq!(config.agent_cwd, std::env::temp_dir()); + assert!(config.seed_users.is_empty()); +} + +/// The trigger list is the exception to blank-means-absent: an empty list is how +/// a deployment says "recognize no commands and forward everything", so a blank +/// value must not be quietly replaced by the defaults. +#[test] +fn a_blank_trigger_list_means_no_triggers_rather_than_the_defaults() { + let env = InMemoryEnv::new(); + env.set("TELEGRAM_BOT_TOKEN", "secret-token"); + env.set("CHANNEL_NEW_SESSION_TRIGGERS", ""); + let config = BridgeConfig::from_env(&env).expect("config"); + assert_eq!(config.command_triggers.parse("/new").command, None); + + let env = InMemoryEnv::new(); + env.set("TELEGRAM_BOT_TOKEN", "secret-token"); + let config = BridgeConfig::from_env(&env).expect("config"); + assert_eq!( + config.command_triggers.parse("/new").command, + Some(trogon_channel::Command::NewSession) + ); +} + +#[test] +fn set_variables_are_read_and_trimmed() { + let env = InMemoryEnv::new(); + env.set("TELEGRAM_BOT_TOKEN", "secret-token"); + env.set("CHANNEL_PREFIX", " staging "); + env.set("TELEGRAM_INBOUND_STREAM", "TG"); + env.set("TELEGRAM_BOT_ACCOUNT", "mybot"); + env.set("CHANNEL_AGENT_ID", "coder"); + env.set("CHANNEL_AGENT_CWD", "/workspace"); + env.set("CHANNEL_SEED_TELEGRAM_USERS", "42, 43 ,,"); + + let config = BridgeConfig::from_env(&env).expect("config"); + assert_eq!(config.channel_prefix, "staging"); + assert_eq!(config.inbound_stream, "TG"); + assert_eq!(config.bot_account, "mybot"); + assert_eq!(config.agent_id, "coder"); + assert_eq!(config.agent_cwd, PathBuf::from("/workspace")); + assert_eq!(config.seed_users, vec![42, 43]); +} diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs index 487a2c022c..d81e542871 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs @@ -100,7 +100,7 @@ async fn main() -> anyhow::Result<()> { .context("failed to create inbound consumer")?; let messages = consumer.messages().await.context("failed to open inbound messages")?; - let bot = Bot::new(config.bot_token.clone()); + let bot = Bot::new(config.bot_token.as_str()); let local = tokio::task::LocalSet::new(); let result = local From b86f1d74618aa196bc05ebec477de318a24affaa Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 01:50:16 -0400 Subject: [PATCH 16/55] fix(docs): link the one ADR reference that CI rejects The reference linter requires every ADR mention to be a markdown link, so a bare one fails the branch on a formatting rule rather than on anything about the design. Signed-off-by: Yordis Prieto --- docs/architecture/multi-channel-agent-routing.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/architecture/multi-channel-agent-routing.md b/docs/architecture/multi-channel-agent-routing.md index f7d1329398..c7092d4e66 100644 --- a/docs/architecture/multi-channel-agent-routing.md +++ b/docs/architecture/multi-channel-agent-routing.md @@ -380,7 +380,8 @@ Things this design commits to that the running system does not do yet. None of them change the topology above. - **Inbound media is dropped.** Parsing keeps only the message text, so a photo, - voice note, or document arrives as nothing at all. ADR#0044 settles where the + voice note, or document arrives as nothing at all. + [ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md) settles where the fetch belongs; the downloader and the `channel_media_{prefix}` bucket do not exist. - **No streaming output.** The renderer buffers agent text for the whole turn and From a491fd51c50ddb5087e97d46a60dcdf792c6c553 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 02:06:16 -0400 Subject: [PATCH 17/55] fix(deps): keep teloxide off native-tls Cargo unifies features across the workspace, so teloxide's default feature pulled openssl into every binary here, not just the Telegram bridge. That is the TLS stack ADR#0015 rules out, and cargo-deny fails the build on it. Signed-off-by: Yordis Prieto --- rsworkspace/Cargo.lock | 98 ------------------------------------------ rsworkspace/Cargo.toml | 2 +- 2 files changed, 1 insertion(+), 99 deletions(-) diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 3260775c5a..06e3223d36 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -2540,21 +2540,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -3054,22 +3039,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -3860,23 +3829,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nats-jwt-rs" version = "0.1.1" @@ -4081,49 +4033,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "opentelemetry" version = "0.32.0" @@ -5074,12 +4989,10 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", "mime_guess", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -5090,7 +5003,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -6507,16 +6419,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/rsworkspace/Cargo.toml b/rsworkspace/Cargo.toml index 47bf0d1e95..7845414a01 100644 --- a/rsworkspace/Cargo.toml +++ b/rsworkspace/Cargo.toml @@ -105,7 +105,7 @@ tower-http = { version = "=0.7.0", features = ["trace"] } sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "macros", "migrate", "postgres", "chrono", "json"] } # Telegram -teloxide = { version = "=0.14.1", features = ["macros", "webhooks-axum"] } +teloxide = { version = "=0.14.1", default-features = false, features = ["macros", "webhooks-axum", "rustls"] } # Serialization confique = { version = "=0.4.0", features = ["toml"] } From 26e89d64006abe2cd23b4a183dc0c2cf827a3881 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 02:06:16 -0400 Subject: [PATCH 18/55] chore(channel): declare the license both new crates were missing Every crate in the workspace has to state it, and CI enforces that. Signed-off-by: Yordis Prieto --- rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml | 1 + rsworkspace/crates/channel/trogon-channel/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml index dad4239527..9478fb0c9d 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml +++ b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml @@ -2,6 +2,7 @@ name = "channel-bridge-telegram" version = "0.1.0" edition = "2024" +license = "Apache-2.0" [lints] workspace = true diff --git a/rsworkspace/crates/channel/trogon-channel/Cargo.toml b/rsworkspace/crates/channel/trogon-channel/Cargo.toml index 3506d1780e..a62c2e048a 100644 --- a/rsworkspace/crates/channel/trogon-channel/Cargo.toml +++ b/rsworkspace/crates/channel/trogon-channel/Cargo.toml @@ -2,6 +2,7 @@ name = "trogon-channel" version = "0.1.0" edition = "2024" +license = "Apache-2.0" [lints] workspace = true From 98b42d4df2b694bb241551d367d96d65e04a1850 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 02:06:34 -0400 Subject: [PATCH 19/55] fix(channel): satisfy the repo Rust policy lints The branch had six violations of lints that already exist on main: an error type without the required suffix, three constants outside the constants module, inline test modules, a function-local macro, and an assertion on an error's display string. CI denies each of them, so the branch could not go green without moving them. Signed-off-by: Yordis Prieto --- .../channel-bridge-telegram/src/config.rs | 6 +- .../src/config_tests.rs | 22 +++--- .../channel-bridge-telegram/src/constants.rs | 13 ++++ .../channel-bridge-telegram/src/main.rs | 9 +-- .../channel-bridge-telegram/src/pipeline.rs | 8 +-- .../src/pipeline_tests.rs | 57 ++++++++------- .../channel-bridge-telegram/src/render.rs | 29 ++------ .../src/render_tests.rs | 19 +++++ .../channel/trogon-channel/src/command.rs | 70 ++----------------- .../trogon-channel/src/command_tests.rs | 62 ++++++++++++++++ .../trogon-channel/src/conversation.rs | 25 ++----- .../trogon-channel/src/conversation_tests.rs | 17 +++++ .../channel/trogon-channel/src/endpoint.rs | 22 ++---- .../trogon-channel/src/endpoint_tests.rs | 14 ++++ .../claim_check/integration_tests.rs | 1 - 15 files changed, 189 insertions(+), 185 deletions(-) create mode 100644 rsworkspace/crates/channel/channel-bridge-telegram/src/constants.rs create mode 100644 rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs create mode 100644 rsworkspace/crates/channel/trogon-channel/src/command_tests.rs create mode 100644 rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs create mode 100644 rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs index 10cc50657c..b272da8ffe 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs @@ -20,16 +20,16 @@ pub struct BotToken(String); #[derive(Debug, thiserror::Error)] #[error("bot token is blank")] -pub struct BlankBotToken; +pub struct BlankBotTokenError; impl BotToken { /// Surrounding whitespace is trimmed rather than rejected: a token read /// from a file or a heredoc almost always arrives with a trailing newline, /// and Telegram would reject it with no hint as to which byte was wrong. - pub fn new(raw: impl AsRef) -> Result { + pub fn new(raw: impl AsRef) -> Result { let trimmed = raw.as_ref().trim(); if trimmed.is_empty() { - return Err(BlankBotToken); + return Err(BlankBotTokenError); } Ok(Self(trimmed.to_string())) } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs index 4260ac9c36..2f41dfe927 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs @@ -1,13 +1,10 @@ use super::*; use trogon_std::env::InMemoryEnv; -/// Why a config refused to load. Spelled out instead of `expect_err` because a +/// Whether a config loaded. Spelled out instead of `is_ok` because a /// `BridgeConfig` is not `Debug`, which is the point: it holds a token. -fn rejected(env: &InMemoryEnv) -> String { - match BridgeConfig::from_env(env) { - Ok(_) => panic!("config must not load"), - Err(error) => error.to_string(), - } +fn loads(env: &InMemoryEnv) -> bool { + BridgeConfig::from_env(env).is_ok() } /// A token is the one required variable, so every way of not supplying one has @@ -16,19 +13,16 @@ fn rejected(env: &InMemoryEnv) -> String { #[test] fn a_blank_bot_token_fails_like_an_unset_one() { for token in ["", " ", "\n"] { + assert!(matches!(BotToken::new(token), Err(BlankBotTokenError)), "{token:?}"); + let env = InMemoryEnv::new(); env.set("TELEGRAM_BOT_TOKEN", token); - let error = rejected(&env); - assert!( - error.contains("TELEGRAM_BOT_TOKEN not set"), - "unexpected error for {token:?}: {error}" - ); + assert!(!loads(&env), "blank token {token:?} must not configure the bridge"); } - let error = rejected(&InMemoryEnv::new()); assert!( - error.contains("TELEGRAM_BOT_TOKEN not set"), - "unexpected error: {error}" + !loads(&InMemoryEnv::new()), + "an absent token must not configure the bridge" ); } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/constants.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/constants.rs new file mode 100644 index 0000000000..205ae9a636 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/constants.rs @@ -0,0 +1,13 @@ +/// Durable consumer identity on the inbound stream. JetStream keys the ack +/// floor by this string, so it is deployment state rather than a build +/// artifact name: a literal here means a future crate rename cannot silently +/// strand every deployment's position in the stream. +pub const INBOUND_DURABLE: &str = "channel-bridge-telegram"; + +/// The Telegram limit for a single message. +pub const TEXT_CHUNK_LIMIT: usize = 4096; + +/// What the bridge says back when a command has nothing else to do. A reset +/// with no follow-up prompt produces no agent output, so without this the user +/// gets silence. +pub const NEW_SESSION_ACKNOWLEDGEMENT: &str = "Started a new session."; diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs index d81e542871..3510e18d64 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs @@ -10,6 +10,7 @@ mod acp_port; mod config; +mod constants; mod outbound; mod parse; mod pipeline; @@ -38,12 +39,6 @@ use trogon_std::fs::SystemFs; use trogon_std::signal::shutdown_signal; use trogon_telemetry::ServiceName; -/// Durable consumer identity on the inbound stream. JetStream keys the ack -/// floor by this string, so it is deployment state rather than a build -/// artifact name: a literal here means a future crate rename cannot silently -/// strand every deployment's position in the stream. -const INBOUND_DURABLE: &str = "channel-bridge-telegram"; - #[tokio::main] async fn main() -> anyhow::Result<()> { let config = BridgeConfig::from_env(&SystemEnv)?; @@ -78,7 +73,7 @@ async fn main() -> anyhow::Result<()> { })?, config.claim_bucket.clone(), ); - let consumer_name = format!("{INBOUND_DURABLE}-{}", config.channel_prefix); + let consumer_name = format!("{}-{}", constants::INBOUND_DURABLE, config.channel_prefix); let consumer = stream .get_or_create_consumer( &consumer_name, diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs index 0316d12aaa..dd81a3ae84 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -2,9 +2,10 @@ #[path = "pipeline_tests.rs"] mod pipeline_tests; +use crate::constants::{NEW_SESSION_ACKNOWLEDGEMENT, TEXT_CHUNK_LIMIT}; use crate::outbound::Outbound; use crate::parse; -use crate::render::{TEXT_CHUNK_LIMIT, TelegramRenderClient, chunk_text}; +use crate::render::{TelegramRenderClient, chunk_text}; use anyhow::Context as _; use tracing::{info, warn}; use trogon_channel::{ @@ -14,11 +15,6 @@ use trogon_channel::{ use trogon_nats::jetstream::{ClaimResolver, ObjectStoreGet}; use trogon_std::NowV7; -/// What the bridge says back when a command has nothing else to do. A reset -/// with no follow-up prompt produces no agent output, so without this the user -/// gets silence. -const NEW_SESSION_ACKNOWLEDGEMENT: &str = "Started a new session."; - pub struct Pipeline<'a, P, O, G, S> { pub store: &'a ChannelStore, pub port: &'a P, diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs index 6e0412ea0d..c4c11e328e 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs @@ -171,6 +171,16 @@ fn raw_update(update_id: u64, chat_id: i64, user_id: u64, text: &str) -> Vec .expect("serialize update") } +/// The next message off the stream. Taken one at a time rather than in a loop +/// so a scenario can change the agent's behaviour between messages. +async fn next_message(messages: &mut S) -> async_nats::jetstream::Message +where + S: futures::Stream> + Unpin, + E: std::fmt::Debug, +{ + messages.next().await.expect("stream yields").expect("message received") +} + /// Consumer state once its acks have landed. `msg.ack()` does not wait for the /// server, so a snapshot taken the instant the pipeline returns can still show /// the last message pending. `ack_pending` is what the caller expects to remain @@ -273,7 +283,7 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { }; for _ in 0..6 { - let msg = messages.next().await.expect("stream yields").expect("message received"); + let msg = next_message(&mut messages).await; pipeline.handle_message(&msg).await.expect("handled"); } @@ -410,7 +420,7 @@ async fn pipeline_redeems_a_claim_checked_update() { ids: &UuidV7Generator, }; - let msg = messages.next().await.expect("stream yields").expect("message received"); + let msg = next_message(&mut messages).await; // The premise of the test: parsing what arrived would have failed. assert!(msg.payload.is_empty()); pipeline.handle_message(&msg).await.expect("handled"); @@ -504,10 +514,11 @@ async fn pipeline_leaves_an_unredeemable_claim_unacked() { ids: &UuidV7Generator, }; - let msg = messages.next().await.expect("stream yields").expect("message received"); - let error = pipeline.handle_message(&msg).await.expect_err("must not be acked"); - assert!(error.to_string().contains("failed to redeem claim-checked update")); + let msg = next_message(&mut messages).await; + assert!(pipeline.handle_message(&msg).await.is_err(), "must not be acked"); + // Where the failure came from: the claim never resolved, so the agent was + // never reached. assert!(port.prompted.borrow().is_empty()); assert!(outbound.sent.borrow().is_empty()); @@ -582,17 +593,10 @@ async fn pipeline_keeps_the_session_when_a_fresh_one_fails_the_same_way() { ids: &UuidV7Generator, }; - macro_rules! next_message { - () => { - messages - .next() - .await - .expect("stream yields") - .expect("message received") - }; - } - - pipeline.handle_message(&next_message!()).await.expect("handled"); + pipeline + .handle_message(&next_message(&mut messages).await) + .await + .expect("handled"); let session_before = store .conversation_for(&endpoint) .await @@ -605,13 +609,12 @@ async fn pipeline_keeps_the_session_when_a_fresh_one_fails_the_same_way() { // Both the original session and the fresh one reject this prompt, which is // what an ordinary rejection misread as a lost session looks like. port.reject_next_prompts(2); - let error = pipeline - .handle_message(&next_message!()) - .await - .expect_err("must not be acked"); assert!( - error.to_string().contains("the session was not the cause"), - "unexpected error: {error}" + pipeline + .handle_message(&next_message(&mut messages).await) + .await + .is_err(), + "a prompt the agent will not answer must not be acked" ); // The point of the test: the conversation still holds the session it had, @@ -625,12 +628,18 @@ async fn pipeline_keeps_the_session_when_a_fresh_one_fails_the_same_way() { assert_eq!(*port.released.borrow(), vec!["sess-2".to_string()]); // So the next message continues on it rather than starting over. - pipeline.handle_message(&next_message!()).await.expect("handled"); + pipeline + .handle_message(&next_message(&mut messages).await) + .await + .expect("handled"); // A session the agent really has forgotten is still repaired: the fresh one // answers, so the conversation moves onto it. port.reject_next_prompts(1); - pipeline.handle_message(&next_message!()).await.expect("handled"); + pipeline + .handle_message(&next_message(&mut messages).await) + .await + .expect("handled"); assert_eq!(*port.sessions_created.borrow(), 3); assert_eq!( diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs index da26311b53..6b4039666c 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs @@ -1,3 +1,7 @@ +#[cfg(test)] +#[path = "render_tests.rs"] +mod render_tests; + use acp_nats::ClientHandler; use agent_client_protocol::schema::v1::{ ContentBlock, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionNotification, @@ -7,9 +11,6 @@ use std::collections::HashMap; use std::sync::Mutex; use tracing::{debug, warn}; -/// The Telegram limit for a single message. -pub const TEXT_CHUNK_LIMIT: usize = 4096; - /// The bridge's ACP client half: receives agent session notifications and /// accumulates streamed text per session; the message loop flushes the buffer /// to Telegram when the prompt turn ends. `ClientHandler` requires `Sync`, so @@ -102,25 +103,3 @@ pub fn chunk_text(text: &str, limit: usize) -> Vec { } chunks } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn chunk_text_splits_on_char_boundaries() { - let text = "ab".repeat(3000); - let chunks = chunk_text(&text, TEXT_CHUNK_LIMIT); - assert_eq!(chunks.len(), 2); - assert_eq!(chunks[0].chars().count(), TEXT_CHUNK_LIMIT); - assert_eq!(chunks[1].chars().count(), 6000 - TEXT_CHUNK_LIMIT); - } - - #[test] - fn chunk_text_handles_multibyte() { - let text = "\u{1F980}".repeat(10); - let chunks = chunk_text(&text, 4); - assert_eq!(chunks.len(), 3); - assert!(chunks.iter().all(|c| c.chars().count() <= 4)); - } -} diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs new file mode 100644 index 0000000000..eb4da87177 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs @@ -0,0 +1,19 @@ +use super::*; +use crate::constants::TEXT_CHUNK_LIMIT; + +#[test] +fn chunk_text_splits_on_char_boundaries() { + let text = "ab".repeat(3000); + let chunks = chunk_text(&text, TEXT_CHUNK_LIMIT); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].chars().count(), TEXT_CHUNK_LIMIT); + assert_eq!(chunks[1].chars().count(), 6000 - TEXT_CHUNK_LIMIT); +} + +#[test] +fn chunk_text_handles_multibyte() { + let text = "\u{1F980}".repeat(10); + let chunks = chunk_text(&text, 4); + assert_eq!(chunks.len(), 3); + assert!(chunks.iter().all(|c| c.chars().count() <= 4)); +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/command.rs b/rsworkspace/crates/channel/trogon-channel/src/command.rs index 54c3790360..92968848fb 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/command.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/command.rs @@ -1,3 +1,7 @@ +#[cfg(test)] +#[path = "command_tests.rs"] +mod command_tests; + use serde::{Deserialize, Serialize}; /// A bridge-level instruction recognized in message text. Commands are @@ -89,69 +93,3 @@ impl CommandTriggers { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bare_trigger_yields_a_command_and_no_body() { - let parsed = CommandTriggers::default().parse("/new"); - assert_eq!(parsed.command, Some(Command::NewSession)); - assert_eq!(parsed.body, None); - } - - #[test] - fn trailing_text_becomes_the_body() { - let parsed = CommandTriggers::default().parse("/reset ship the thing "); - assert_eq!(parsed.command, Some(Command::NewSession)); - assert_eq!(parsed.body.as_deref(), Some("ship the thing")); - } - - #[test] - fn account_suffix_and_case_do_not_defeat_the_trigger() { - let parsed = CommandTriggers::default().parse("/New@SomeBot hello"); - assert_eq!(parsed.command, Some(Command::NewSession)); - assert_eq!(parsed.body.as_deref(), Some("hello")); - } - - #[test] - fn a_trigger_that_is_only_a_prefix_of_the_token_is_not_a_command() { - let parsed = CommandTriggers::default().parse("/newsletter please"); - assert_eq!(parsed.command, None); - assert_eq!(parsed.body.as_deref(), Some("/newsletter please")); - } - - #[test] - fn ordinary_text_passes_through_unchanged() { - let parsed = CommandTriggers::default().parse(" keep my spacing "); - assert_eq!(parsed.command, None); - assert_eq!(parsed.body.as_deref(), Some(" keep my spacing ")); - } - - #[test] - fn a_trigger_in_the_middle_is_not_a_command() { - let parsed = CommandTriggers::default().parse("say /new out loud"); - assert_eq!(parsed.command, None); - assert_eq!(parsed.body.as_deref(), Some("say /new out loud")); - } - - #[test] - fn triggers_are_configurable() { - let triggers = CommandTriggers::new(["!Rotate".to_string()]).expect("valid triggers"); - assert_eq!(triggers.parse("!rotate").command, Some(Command::NewSession)); - assert_eq!(triggers.parse("/new").command, None); - } - - #[test] - fn blank_and_multi_token_triggers_are_rejected() { - assert!(matches!( - CommandTriggers::new([" ".to_string()]), - Err(CommandTriggerError::Empty) - )); - assert!(matches!( - CommandTriggers::new(["/new session".to_string()]), - Err(CommandTriggerError::NotASingleToken(_)) - )); - } -} diff --git a/rsworkspace/crates/channel/trogon-channel/src/command_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/command_tests.rs new file mode 100644 index 0000000000..298d7f566a --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/command_tests.rs @@ -0,0 +1,62 @@ +use super::*; + +#[test] +fn bare_trigger_yields_a_command_and_no_body() { + let parsed = CommandTriggers::default().parse("/new"); + assert_eq!(parsed.command, Some(Command::NewSession)); + assert_eq!(parsed.body, None); +} + +#[test] +fn trailing_text_becomes_the_body() { + let parsed = CommandTriggers::default().parse("/reset ship the thing "); + assert_eq!(parsed.command, Some(Command::NewSession)); + assert_eq!(parsed.body.as_deref(), Some("ship the thing")); +} + +#[test] +fn account_suffix_and_case_do_not_defeat_the_trigger() { + let parsed = CommandTriggers::default().parse("/New@SomeBot hello"); + assert_eq!(parsed.command, Some(Command::NewSession)); + assert_eq!(parsed.body.as_deref(), Some("hello")); +} + +#[test] +fn a_trigger_that_is_only_a_prefix_of_the_token_is_not_a_command() { + let parsed = CommandTriggers::default().parse("/newsletter please"); + assert_eq!(parsed.command, None); + assert_eq!(parsed.body.as_deref(), Some("/newsletter please")); +} + +#[test] +fn ordinary_text_passes_through_unchanged() { + let parsed = CommandTriggers::default().parse(" keep my spacing "); + assert_eq!(parsed.command, None); + assert_eq!(parsed.body.as_deref(), Some(" keep my spacing ")); +} + +#[test] +fn a_trigger_in_the_middle_is_not_a_command() { + let parsed = CommandTriggers::default().parse("say /new out loud"); + assert_eq!(parsed.command, None); + assert_eq!(parsed.body.as_deref(), Some("say /new out loud")); +} + +#[test] +fn triggers_are_configurable() { + let triggers = CommandTriggers::new(["!Rotate".to_string()]).expect("valid triggers"); + assert_eq!(triggers.parse("!rotate").command, Some(Command::NewSession)); + assert_eq!(triggers.parse("/new").command, None); +} + +#[test] +fn blank_and_multi_token_triggers_are_rejected() { + assert!(matches!( + CommandTriggers::new([" ".to_string()]), + Err(CommandTriggerError::Empty) + )); + assert!(matches!( + CommandTriggers::new(["/new session".to_string()]), + Err(CommandTriggerError::NotASingleToken(_)) + )); +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/conversation.rs b/rsworkspace/crates/channel/trogon-channel/src/conversation.rs index 2add0b6e33..47387119a1 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/conversation.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/conversation.rs @@ -1,3 +1,7 @@ +#[cfg(test)] +#[path = "conversation_tests.rs"] +mod conversation_tests; + use crate::agent_port::AgentSessionId; use crate::endpoint::PrincipalId; use serde::{Deserialize, Serialize}; @@ -64,24 +68,3 @@ pub struct ConversationRecord { pub created_at: i64, pub last_activity_at: i64, } - -#[cfg(test)] -mod tests { - use super::*; - use trogon_std::UuidV7Generator; - - #[test] - fn generated_ids_are_v7_in_simple_form() { - let id = ConversationId::generate(&UuidV7Generator); - assert_eq!(id.as_str().len(), 32); - assert!(id.as_str().chars().all(|c| c.is_ascii_hexdigit())); - assert_eq!(id.as_str().chars().nth(12), Some('7'), "version nibble"); - } - - #[test] - fn generated_ids_sort_in_creation_order() { - let first = ConversationId::generate(&UuidV7Generator); - let second = ConversationId::generate(&UuidV7Generator); - assert!(first.as_str() < second.as_str()); - } -} diff --git a/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs new file mode 100644 index 0000000000..25310ac225 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs @@ -0,0 +1,17 @@ +use super::*; +use trogon_std::UuidV7Generator; + +#[test] +fn generated_ids_are_v7_in_simple_form() { + let id = ConversationId::generate(&UuidV7Generator); + assert_eq!(id.as_str().len(), 32); + assert!(id.as_str().chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(id.as_str().chars().nth(12), Some('7'), "version nibble"); +} + +#[test] +fn generated_ids_sort_in_creation_order() { + let first = ConversationId::generate(&UuidV7Generator); + let second = ConversationId::generate(&UuidV7Generator); + assert!(first.as_str() < second.as_str()); +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs index c4513bd3e0..6346838007 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs @@ -1,3 +1,7 @@ +#[cfg(test)] +#[path = "endpoint_tests.rs"] +mod endpoint_tests; + use serde::{Deserialize, Serialize}; /// Characters permitted in endpoint tokens. Tokens are joined with `.` into @@ -92,21 +96,3 @@ impl std::fmt::Display for PrincipalId { f.write_str(&self.0) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn endpoint_accepts_negative_telegram_chat_ids() { - let e = Endpoint::new("telegram", "mybot", "-1001234567890").expect("valid"); - assert_eq!(e.kv_key(), "telegram.mybot.-1001234567890"); - } - - #[test] - fn endpoint_rejects_unsafe_tokens() { - assert!(Endpoint::new("telegram", "my bot", "1").is_err()); - assert!(Endpoint::new("", "mybot", "1").is_err()); - assert!(Endpoint::new("telegram", "mybot", "a.b").is_err()); - } -} diff --git a/rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs new file mode 100644 index 0000000000..a75f09eb1a --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs @@ -0,0 +1,14 @@ +use super::*; + +#[test] +fn endpoint_accepts_negative_telegram_chat_ids() { + let e = Endpoint::new("telegram", "mybot", "-1001234567890").expect("valid"); + assert_eq!(e.kv_key(), "telegram.mybot.-1001234567890"); +} + +#[test] +fn endpoint_rejects_unsafe_tokens() { + assert!(Endpoint::new("telegram", "my bot", "1").is_err()); + assert!(Endpoint::new("", "mybot", "1").is_err()); + assert!(Endpoint::new("telegram", "mybot", "a.b").is_err()); +} diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs index 815b014dd9..ebe3a40cd7 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs @@ -358,7 +358,6 @@ async fn resolver_rejects_a_claim_from_another_bucket() { ClaimResolveError::BucketMismatch { ref expected, ref named } if expected == "test-bucket" && named == "someone-elses-bucket" )); - assert!(error.to_string().contains("someone-elses-bucket")); } /// The publisher writes the object and only then publishes the claim, so a From 20858cf664bc75610d3e888cddc72cda639acd70 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 02:18:38 -0400 Subject: [PATCH 20/55] fix(channel): keep chunked replies inside the limit Telegram actually enforces Signed-off-by: Yordis Prieto --- .../channel-bridge-telegram/src/constants.rs | 2 +- .../channel-bridge-telegram/src/render.rs | 10 +++++-- .../src/render_tests.rs | 29 +++++++++++++++++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/constants.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/constants.rs index 205ae9a636..c12b1b8498 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/constants.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/constants.rs @@ -4,7 +4,7 @@ /// strand every deployment's position in the stream. pub const INBOUND_DURABLE: &str = "channel-bridge-telegram"; -/// The Telegram limit for a single message. +/// The Telegram limit for a single message, counted in UTF-16 code units. pub const TEXT_CHUNK_LIMIT: usize = 4096; /// What the bridge says back when a command has nothing else to do. A reset diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs index 6b4039666c..7b5e37fdf4 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs @@ -85,18 +85,22 @@ impl ClientHandler for TelegramRenderClient { } } -/// Split text at Telegram's message size limit on char boundaries. +/// Split text at Telegram's message size limit on char boundaries. Telegram +/// measures the limit in UTF-16 code units, so characters outside the basic +/// multilingual plane (most emoji) cost two: counting scalar values instead +/// would let an emoji-heavy chunk pass here and still be rejected by the API. pub fn chunk_text(text: &str, limit: usize) -> Vec { let mut chunks = Vec::new(); let mut current = String::new(); let mut count = 0usize; for ch in text.chars() { - if count == limit { + let width = ch.len_utf16(); + if count + width > limit && !current.is_empty() { chunks.push(std::mem::take(&mut current)); count = 0; } current.push(ch); - count += 1; + count += width; } if !current.is_empty() { chunks.push(current); diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs index eb4da87177..e2bdd1de59 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs @@ -14,6 +14,31 @@ fn chunk_text_splits_on_char_boundaries() { fn chunk_text_handles_multibyte() { let text = "\u{1F980}".repeat(10); let chunks = chunk_text(&text, 4); - assert_eq!(chunks.len(), 3); - assert!(chunks.iter().all(|c| c.chars().count() <= 4)); + assert_eq!(chunks.len(), 5); + assert!( + chunks + .iter() + .all(|c| c.chars().map(char::len_utf16).sum::() <= 4) + ); +} + +#[test] +fn chunk_text_counts_utf16_code_units() { + let text = "\u{1F980}".repeat(TEXT_CHUNK_LIMIT); + let chunks = chunk_text(&text, TEXT_CHUNK_LIMIT); + assert_eq!(chunks.len(), 2); + assert!( + chunks + .iter() + .all(|c| c.chars().map(char::len_utf16).sum::() <= TEXT_CHUNK_LIMIT) + ); +} + +#[test] +fn chunk_text_does_not_split_a_surrogate_pair() { + let text = format!("{}\u{1F980}", "a".repeat(TEXT_CHUNK_LIMIT - 1)); + let chunks = chunk_text(&text, TEXT_CHUNK_LIMIT); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].chars().count(), TEXT_CHUNK_LIMIT - 1); + assert_eq!(chunks[1], "\u{1F980}"); } From 157bbb853f1f5c4ef20fca20622b9db4a76cfef4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 02:29:25 -0400 Subject: [PATCH 21/55] fix(devops): let a deployment actually ask for no session triggers Signed-off-by: Yordis Prieto --- devops/docker/compose/compose.yml | 6 +- rsworkspace/Cargo.lock | 2 + .../crates/channel/trogon-channel/Cargo.toml | 5 + .../channel/trogon-channel/src/store.rs | 52 ++++++-- .../channel/trogon-channel/src/store_tests.rs | 125 ++++++++++++++++++ .../trogon-decider-nats/src/provision.rs | 27 +--- .../platform/trogon-nats/src/jetstream/mod.rs | 2 + .../trogon-nats/src/jetstream/not_found.rs | 37 ++++++ .../src/jetstream/not_found/tests.rs | 75 +++++++++++ .../platform/trogon-nats/src/test_support.rs | 11 +- 10 files changed, 306 insertions(+), 36 deletions(-) create mode 100644 rsworkspace/crates/channel/trogon-channel/src/store_tests.rs create mode 100644 rsworkspace/crates/platform/trogon-nats/src/jetstream/not_found.rs create mode 100644 rsworkspace/crates/platform/trogon-nats/src/jetstream/not_found/tests.rs diff --git a/devops/docker/compose/compose.yml b/devops/docker/compose/compose.yml index 6a30111046..c336835fc5 100644 --- a/devops/docker/compose/compose.yml +++ b/devops/docker/compose/compose.yml @@ -63,7 +63,11 @@ services: TELEGRAM_BOT_TOKEN: CHANNEL_SEED_TELEGRAM_USERS: CHANNEL_PREFIX: "${CHANNEL_PREFIX:-prod}" - CHANNEL_NEW_SESSION_TRIGGERS: "${CHANNEL_NEW_SESSION_TRIGGERS:-/new,/reset}" + # "${VAR-default}" rather than "${VAR:-default}": the bridge reads a blank + # trigger list as "recognize nothing, forward everything", so the default + # may only fill in for an unset variable. The colon form substitutes on + # blank too, which would make that setting unreachable from Compose. + CHANNEL_NEW_SESSION_TRIGGERS: "${CHANNEL_NEW_SESSION_TRIGGERS-/new,/reset}" TELEGRAM_INBOUND_STREAM: "${TELEGRAM_INBOUND_STREAM:-TELEGRAM}" ACP_PREFIX: "${ACP_PREFIX:-acp}" NATS_URL: "nats:4222" diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 06e3223d36..525f5656a5 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -6901,7 +6901,9 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.19", + "tokio", "tracing", + "trogon-nats", "trogon-std", ] diff --git a/rsworkspace/crates/channel/trogon-channel/Cargo.toml b/rsworkspace/crates/channel/trogon-channel/Cargo.toml index a62c2e048a..f1b492a9dc 100644 --- a/rsworkspace/crates/channel/trogon-channel/Cargo.toml +++ b/rsworkspace/crates/channel/trogon-channel/Cargo.toml @@ -8,6 +8,7 @@ license = "Apache-2.0" workspace = true [dependencies] +trogon-nats = { workspace = true } trogon-std = { workspace = true, features = ["uuid"] } async-nats = { workspace = true, features = ["jetstream", "kv"] } @@ -15,3 +16,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +trogon-nats = { workspace = true, features = ["test-support"] } diff --git a/rsworkspace/crates/channel/trogon-channel/src/store.rs b/rsworkspace/crates/channel/trogon-channel/src/store.rs index 5579ea934d..193af17ca6 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store.rs @@ -3,10 +3,21 @@ use crate::endpoint::{Endpoint, PrincipalId}; use async_nats::jetstream; use serde::{Deserialize, Serialize}; use tracing::info; +use trogon_nats::jetstream::{is_create_key_value_already_exists, is_get_key_value_not_found}; use trogon_std::NowV7; +#[cfg(test)] +#[path = "store_tests.rs"] +mod store_tests; + #[derive(Debug, thiserror::Error)] pub enum ChannelStoreError { + #[error("failed to open KV bucket {bucket}: {source}")] + OpenBucket { + bucket: String, + #[source] + source: async_nats::jetstream::context::KeyValueError, + }, #[error("failed to create KV bucket {bucket}: {source}")] CreateBucket { bucket: String, @@ -38,19 +49,40 @@ pub struct ChannelStore { conversations: jetstream::kv::Store, } +/// Opens a bucket, creating it only when JetStream says it is not there. +/// +/// A get that failed for any other reason (a request timeout, a denied +/// `STREAM.INFO`) is surfaced instead of being read as absence. The bucket +/// probably does exist in that case, and `STREAM.CREATE` silently applies some +/// divergent fields as an in-place update of the existing stream, so falling +/// through would let a momentary read failure reconfigure live storage. async fn ensure_bucket(js: &jetstream::Context, bucket: String) -> Result { - if let Ok(store) = js.get_key_value(&bucket).await { - return Ok(store); + match js.get_key_value(&bucket).await { + Ok(store) => return Ok(store), + Err(source) if is_get_key_value_not_found(&source) => {} + Err(source) => return Err(ChannelStoreError::OpenBucket { bucket, source }), } + info!(bucket = %bucket, "Creating channel KV bucket"); - js.create_key_value(jetstream::kv::Config { - bucket: bucket.clone(), - history: 5, - storage: jetstream::stream::StorageType::File, - ..Default::default() - }) - .await - .map_err(|source| ChannelStoreError::CreateBucket { bucket, source }) + match js + .create_key_value(jetstream::kv::Config { + bucket: bucket.clone(), + history: 5, + storage: jetstream::stream::StorageType::File, + ..Default::default() + }) + .await + { + Ok(store) => Ok(store), + // Another replica created it between the get and the create, which is + // the one already-exists that means the bucket is ready rather than + // that provisioning went wrong. + Err(source) if is_create_key_value_already_exists(&source) => match js.get_key_value(&bucket).await { + Ok(store) => Ok(store), + Err(source) => Err(ChannelStoreError::OpenBucket { bucket, source }), + }, + Err(source) => Err(ChannelStoreError::CreateBucket { bucket, source }), + } } impl ChannelStore { diff --git a/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs new file mode 100644 index 0000000000..fe35e885fa --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs @@ -0,0 +1,125 @@ +use super::*; +use crate::agent_port::AgentSessionId; +use crate::conversation::AgentId; +use trogon_nats::test_support::JetStreamTestServer; +use trogon_std::UuidV7Generator; + +fn endpoint(peer: &str) -> Endpoint { + Endpoint::new("telegram", "bot", peer).expect("endpoint") +} + +fn principal(id: &str) -> PrincipalId { + PrincipalId::new(id).expect("principal") +} + +fn record(principal: &PrincipalId) -> ConversationRecord { + ConversationRecord { + principal: principal.clone(), + agent_id: AgentId::new("default"), + current_session: None, + created_at: 1, + last_activity_at: 1, + } +} + +/// A bridge restarts far more often than it first starts, so opening the +/// existing buckets is the common path, not the exceptional one. +#[tokio::test] +async fn ensure_creates_its_buckets_then_reopens_them() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + + let store = ChannelStore::ensure(&js, "first").await.expect("first ensure"); + let endpoint = endpoint("111"); + let principal = principal("user-1"); + store + .link_endpoint( + &principal, + &PrincipalRecord { + display_name: Some("Ada".to_string()), + }, + &endpoint, + ) + .await + .expect("link endpoint"); + + let reopened = ChannelStore::ensure(&js, "first").await.expect("second ensure"); + + assert_eq!( + reopened.principal_for(&endpoint).await.expect("principal lookup"), + Some(principal), + "the second ensure must reopen the buckets rather than replace them" + ); +} + +/// Two replicas booting together both race the same four buckets, and neither +/// losing the race is a provisioning failure. +#[tokio::test] +async fn ensure_is_idempotent_under_concurrent_creation() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + + let (first, second) = tokio::join!(ChannelStore::ensure(&js, "race"), ChannelStore::ensure(&js, "race")); + + first.expect("one concurrent ensure"); + second.expect("the other concurrent ensure"); +} + +/// The regression this guards: a get that failed without JetStream answering +/// says nothing about whether the bucket exists. Reading it as absence sends the +/// bridge on to `STREAM.CREATE`, which applies some divergent fields as an +/// in-place update, so a momentary read failure could reconfigure live storage. +/// An unreachable API prefix is the cheapest way to fail a get for a reason +/// other than absence. +#[tokio::test] +async fn an_unreadable_bucket_is_not_treated_as_a_missing_one() { + let server = JetStreamTestServer::start().await; + let unreachable = jetstream::with_prefix(server.client().await, "NOT.THE.API"); + + // Matched rather than `expect_err`ed because a `ChannelStore` is not + // `Debug`: it is four live KV handles. + let Err(error) = ChannelStore::ensure(&unreachable, "unreachable").await else { + panic!("ensure must fail when the buckets cannot be read"); + }; + + assert!( + matches!(error, ChannelStoreError::OpenBucket { .. }), + "expected the read failure to surface, got {error:?}" + ); + + let js = server.jetstream().await; + assert!( + js.get_key_value("channel_principals_unreachable").await.is_err(), + "no bucket should have been created off the back of a read failure" + ); +} + +/// The whole point of the four buckets: a conversation survives a restart, and +/// its session pointer is replaceable in place. +#[tokio::test] +async fn a_conversation_round_trips_through_its_buckets() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + let store = ChannelStore::ensure(&js, "roundtrip").await.expect("ensure"); + + let endpoint = endpoint("222"); + let principal = principal("user-2"); + let mut record = record(&principal); + let id = store + .create_conversation(&endpoint, &record, &UuidV7Generator) + .await + .expect("create conversation"); + + record.current_session = Some(AgentSessionId::new("sess-1")); + store.update_conversation(&id, &record).await.expect("update"); + + let (found_id, found) = store + .conversation_for(&endpoint) + .await + .expect("conversation lookup") + .expect("conversation is bound"); + + assert_eq!(found_id, id); + assert_eq!(found.current_session, Some(AgentSessionId::new("sess-1"))); + assert_eq!(found.principal, principal); +} diff --git a/rsworkspace/crates/decider/trogon-decider-nats/src/provision.rs b/rsworkspace/crates/decider/trogon-decider-nats/src/provision.rs index 2ef2cea0a7..b5704bad6b 100644 --- a/rsworkspace/crates/decider/trogon-decider-nats/src/provision.rs +++ b/rsworkspace/crates/decider/trogon-decider-nats/src/provision.rs @@ -18,13 +18,13 @@ //! field that diverged, rather than dumping the whole configuration. use async_nats::jetstream; -use async_nats::jetstream::ErrorCode; -use async_nats::jetstream::context::{ - CreateKeyValueError, CreateStreamError, GetStreamError, GetStreamErrorKind, KeyValueError, KeyValueErrorKind, -}; +use async_nats::jetstream::context::{CreateKeyValueError, CreateStreamError, GetStreamError, KeyValueError}; use async_nats::jetstream::kv; use async_nats::jetstream::stream::RetentionPolicy; -use trogon_nats::jetstream::{is_create_key_value_already_exists, is_create_stream_already_exists}; +use trogon_nats::jetstream::{ + is_create_key_value_already_exists, is_create_stream_already_exists, is_get_key_value_not_found, + is_get_stream_not_found, +}; /// A single divergent field between a required and an existing stream /// configuration. @@ -209,23 +209,6 @@ pub async fn ensure_bucket(js: &jetstream::Context, config: kv::Config) -> Resul } } -fn is_get_stream_not_found(error: &GetStreamError) -> bool { - matches!( - error.kind(), - GetStreamErrorKind::JetStream(ref source) if source.error_code() == ErrorCode::STREAM_NOT_FOUND - ) -} - -fn is_get_key_value_not_found(error: &KeyValueError) -> bool { - if error.kind() != KeyValueErrorKind::GetBucket { - return false; - } - - std::error::Error::source(error) - .and_then(|source| source.downcast_ref::()) - .is_some_and(is_get_stream_not_found) -} - fn validate_stream_config( name: &str, required: &jetstream::stream::Config, diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs index fcf4742072..d6f90bda0d 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs @@ -4,6 +4,7 @@ pub mod claim_retention; pub mod client; pub mod create_conflicts; pub mod message; +pub mod not_found; pub mod object_store; pub mod publish; pub mod stream_max_age; @@ -22,6 +23,7 @@ pub use client::{ }; pub use create_conflicts::{is_create_key_value_already_exists, is_create_stream_already_exists}; pub use message::{JsAck, JsAckWith, JsDispatchMessage, JsDoubleAck, JsDoubleAckWith, JsMessageRef, JsRequestMessage}; +pub use not_found::{is_get_key_value_not_found, is_get_stream_not_found}; #[cfg(not(coverage))] pub use object_store::NatsObjectStore; pub use object_store::{ObjectStoreGet, ObjectStorePut}; diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/not_found.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/not_found.rs new file mode 100644 index 0000000000..9715a8c789 --- /dev/null +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/not_found.rs @@ -0,0 +1,37 @@ +//! Reading "the resource does not exist" out of a failed JetStream get. +//! +//! async-nats collapses every reason a KV get can fail into +//! [`KeyValueErrorKind::GetBucket`], so a bucket that is absent and a bucket +//! that could not be read are the same value until the wrapped +//! [`GetStreamError`] is unwrapped. Provisioning code that skips that step +//! reads a request timeout or a denied `STREAM.INFO` as absence and then +//! creates over storage that already exists. + +use async_nats::jetstream::{ + ErrorCode, + context::{GetStreamError, GetStreamErrorKind, KeyValueError, KeyValueErrorKind}, +}; + +/// True only when JetStream itself answered that the stream is not there. +pub fn is_get_stream_not_found(error: &GetStreamError) -> bool { + matches!( + error.kind(), + GetStreamErrorKind::JetStream(ref source) if source.error_code() == ErrorCode::STREAM_NOT_FOUND + ) +} + +/// True only when JetStream itself answered that the bucket's backing stream is +/// not there. The kind cannot say so on its own, hence the downcast: every get +/// failure carries `GetBucket` and the reason lives in the source. +pub fn is_get_key_value_not_found(error: &KeyValueError) -> bool { + if error.kind() != KeyValueErrorKind::GetBucket { + return false; + } + + std::error::Error::source(error) + .and_then(|source| source.downcast_ref::()) + .is_some_and(is_get_stream_not_found) +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/not_found/tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/not_found/tests.rs new file mode 100644 index 0000000000..dd5dbd9665 --- /dev/null +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/not_found/tests.rs @@ -0,0 +1,75 @@ +use super::*; + +/// A server-side JetStream API error, built the way the wire delivers it so the +/// `err_code` under test is the one a real server would send. +fn api_error(code: u16, err_code: u64, description: &str) -> async_nats::jetstream::Error { + serde_json::from_value(serde_json::json!({ + "code": code, + "err_code": err_code, + "description": description, + })) + .unwrap() +} + +fn stream_not_found() -> GetStreamError { + GetStreamError::new(GetStreamErrorKind::JetStream(api_error(404, 10059, "stream not found"))) +} + +fn bucket_get_failed(source: GetStreamError) -> KeyValueError { + KeyValueError::with_source(KeyValueErrorKind::GetBucket, source) +} + +#[test] +fn stream_not_found_is_recognised() { + assert!(is_get_stream_not_found(&stream_not_found())); + assert!(is_get_key_value_not_found(&bucket_get_failed(stream_not_found()))); +} + +/// The distinction the callers depend on: a get that failed on the way to the +/// server says nothing about whether the resource exists, so provisioning must +/// not read it as absence and create over live storage. +#[test] +fn a_request_failure_is_not_absence() { + let unreachable = GetStreamError::with_source( + GetStreamErrorKind::Request, + std::io::Error::other("no responders available"), + ); + + assert!(!is_get_stream_not_found(&unreachable)); + assert!(!is_get_key_value_not_found(&bucket_get_failed(unreachable))); +} + +/// JetStream answers a great many things that are not "not found", and the ones +/// most likely to be met in production (an account without JetStream, a denied +/// API subject) are exactly the ones that must not trigger a create. +#[test] +fn another_jetstream_answer_is_not_absence() { + for (code, err_code, description) in [ + (503, 10039, "jetstream not enabled for account"), + (400, 10003, "bad request"), + (500, 10008, "jetstream system temporarily unavailable"), + ] { + let error = GetStreamError::new(GetStreamErrorKind::JetStream(api_error(code, err_code, description))); + + assert!(!is_get_stream_not_found(&error), "{description}"); + assert!(!is_get_key_value_not_found(&bucket_get_failed(error)), "{description}"); + } +} + +/// A bucket name the client rejected never reached the server, so nothing is +/// known about the bucket either way. +#[test] +fn a_client_side_rejection_is_not_absence() { + assert!(!is_get_key_value_not_found(&KeyValueError::new( + KeyValueErrorKind::InvalidStoreName + ))); + assert!(!is_get_key_value_not_found(&KeyValueError::new( + KeyValueErrorKind::JetStream + ))); + assert!(!is_get_stream_not_found(&GetStreamError::new( + GetStreamErrorKind::EmptyName + ))); + assert!(!is_get_stream_not_found(&GetStreamError::new( + GetStreamErrorKind::InvalidStreamName + ))); +} diff --git a/rsworkspace/crates/platform/trogon-nats/src/test_support.rs b/rsworkspace/crates/platform/trogon-nats/src/test_support.rs index e641c71290..80d8a99a10 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/test_support.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/test_support.rs @@ -40,11 +40,16 @@ impl JetStreamTestServer { /// Connects to the isolated server and returns its JetStream context. pub async fn jetstream(&self) -> jetstream::Context { - let client = async_nats::ConnectOptions::new() + jetstream::new(self.client().await) + } + + /// A raw connection to the isolated server, for tests that need a context + /// built some other way (a non-default API prefix, a domain). + pub async fn client(&self) -> async_nats::Client { + async_nats::ConnectOptions::new() .connection_timeout(CONNECT_TIMEOUT) .connect(&self.address) .await - .expect("connect to JetStream testcontainer"); - jetstream::new(client) + .expect("connect to JetStream testcontainer") } } From 53df69167d1da31bc281c6af1d7db9b33cfa7066 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 02:53:15 -0400 Subject: [PATCH 22/55] fix(channel): let the coverage build compile the Telegram bridge The coverage build leaves the real NATS clients out, so naming them unconditionally made the new crate impossible to compile there and took the whole test job down with it. Its capability reading and error classification had no tests of their own, so drawing the line at the transport would otherwise have cost the crate every measurable statement it has. Signed-off-by: Yordis Prieto --- .../channel-bridge-telegram/Cargo.toml | 1 + .../channel-bridge-telegram/src/acp_port.rs | 42 ++++-- .../src/acp_port_tests.rs | 127 ++++++++++++++++++ .../channel-bridge-telegram/src/main.rs | 65 +++++---- .../channel-bridge-telegram/src/outbound.rs | 9 ++ .../src/pipeline_tests.rs | 23 +++- 6 files changed, 228 insertions(+), 39 deletions(-) create mode 100644 rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml index 9478fb0c9d..b7a6882757 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml +++ b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml @@ -27,4 +27,5 @@ tracing = { workspace = true } [dev-dependencies] testcontainers-modules = { version = "0.15", features = ["nats"] } +trogon-nats = { workspace = true, features = ["test-support"] } trogon-std = { workspace = true, features = ["test-support"] } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs index 7fa122e503..00435f3250 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs @@ -1,17 +1,32 @@ -use acp_nats::AgentHandler; +#[cfg(test)] +#[path = "acp_port_tests.rs"] +mod acp_port_tests; + use agent_client_protocol::ErrorCode; -use agent_client_protocol::schema::v1::{ - CancelNotification, CloseSessionRequest, ContentBlock, InitializeResponse, NewSessionRequest, PromptRequest, - StopReason, TextContent, -}; -use std::path::PathBuf; -use std::sync::Arc; -use tracing::{info, warn}; -use trogon_channel::{ - AgentPort, AgentPortError, AgentSessionId, ConversationRecord, InboundEvent, PromptOutcome, ReleaseReason, - ReleaseStep, SessionRelease, +use agent_client_protocol::schema::v1::InitializeResponse; +use trogon_channel::AgentPortError; + +// `NatsJetStreamClient` is left out of the coverage build, so the bridge built on +// it and everything that speaks to that bridge is left out with it. What remains +// is the part with no transport in it: the capability reading and the error +// classification. +#[cfg(not(coverage))] +use { + acp_nats::AgentHandler, + agent_client_protocol::schema::v1::{ + CancelNotification, CloseSessionRequest, ContentBlock, NewSessionRequest, PromptRequest, StopReason, + TextContent, + }, + std::path::PathBuf, + std::sync::Arc, + tracing::{info, warn}, + trogon_channel::{ + AgentPort, AgentSessionId, ConversationRecord, InboundEvent, PromptOutcome, ReleaseReason, ReleaseStep, + SessionRelease, + }, }; +#[cfg(not(coverage))] pub type AcpBridge = acp_nats::Bridge; @@ -142,12 +157,14 @@ impl std::fmt::Display for SessionMethods { /// through the acp-nats Bridge. Streamed agent output does not come back /// through this port; it arrives at the bridge's ACP client half /// (`TelegramRenderClient`) as session notifications. +#[cfg(not(coverage))] pub struct AcpPort { bridge: Arc, agent_cwd: PathBuf, methods: SessionMethods, } +#[cfg(not(coverage))] impl AcpPort { pub fn new(bridge: Arc, agent_cwd: PathBuf, methods: SessionMethods) -> Self { Self { @@ -161,6 +178,7 @@ impl AcpPort { /// Human-readable context prefix: the only part of the conversational /// metadata a non-participating agent is guaranteed to see, since only prompt /// text reaches the model. +#[cfg(not(coverage))] fn prompt_text(event: &InboundEvent) -> String { let body = event.text.as_deref().unwrap_or_default(); format!("[telegram message from {}]\n{}", event.sender.display_name, body) @@ -168,6 +186,7 @@ fn prompt_text(event: &InboundEvent) -> String { /// Structured twin of the context prefix, for agents that opt into reading /// `_meta` (see the architecture doc's `_meta` convention). +#[cfg(not(coverage))] fn prompt_meta(event: &InboundEvent) -> agent_client_protocol::schema::v1::Meta { let mut meta = serde_json::Map::new(); meta.insert( @@ -186,6 +205,7 @@ fn prompt_meta(event: &InboundEvent) -> agent_client_protocol::schema::v1::Meta meta } +#[cfg(not(coverage))] impl AgentPort for AcpPort { type Error = AcpPortError; diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs new file mode 100644 index 0000000000..4bd9481991 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs @@ -0,0 +1,127 @@ +use super::*; +use agent_client_protocol::Error as AcpError; + +/// An initialize response carrying the given agent capabilities, deserialized +/// rather than constructed because the schema types are `#[non_exhaustive]`. This +/// also puts the wire names under test, which is where a capability gets misread. +fn initialized(agent_capabilities: serde_json::Value) -> InitializeResponse { + serde_json::from_value(serde_json::json!({ + "protocolVersion": 1, + "agentCapabilities": agent_capabilities, + })) + .expect("initialize response") +} + +fn all_advertised() -> InitializeResponse { + initialized(serde_json::json!({ + "loadSession": true, + "sessionCapabilities": { + "list": {}, + "delete": {}, + "resume": {}, + "close": {}, + "additionalDirectories": {}, + }, + })) +} + +#[test] +fn every_advertised_method_is_read_as_supported() { + let methods = SessionMethods::advertised(&all_advertised()); + + for method in SessionMethod::ALL { + assert!(methods.supports(method), "{} should be supported", method.wire_name()); + } +} + +/// The inversion this guards: ACP capabilities are present-means-supported, so an +/// agent that advertises nothing must come back as supporting nothing rather than +/// as supporting everything. +#[test] +fn an_agent_that_advertises_nothing_supports_nothing() { + let methods = SessionMethods::advertised(&initialized(serde_json::json!({}))); + + for method in SessionMethod::ALL { + assert!( + !methods.supports(method), + "{} should not be supported", + method.wire_name() + ); + } + assert_eq!(SessionMethods::default().to_string(), "none"); +} + +/// `null` is how an agent declines a capability it knows about, and it has to read +/// the same as leaving the key out entirely. +#[test] +fn a_null_capability_is_declined_not_advertised() { + let methods = SessionMethods::advertised(&initialized(serde_json::json!({ + "loadSession": false, + "sessionCapabilities": { + "list": null, + "delete": null, + "resume": null, + "close": {}, + "additionalDirectories": null, + }, + }))); + + assert!(methods.supports(SessionMethod::Close)); + for method in [ + SessionMethod::Load, + SessionMethod::List, + SessionMethod::Delete, + SessionMethod::Resume, + SessionMethod::AdditionalDirectories, + ] { + assert!( + !methods.supports(method), + "{} should not be supported", + method.wire_name() + ); + } +} + +#[test] +fn display_lists_the_advertised_methods_by_wire_name() { + assert_eq!( + SessionMethods::advertised(&all_advertised()).to_string(), + "session/load, session/list, session/delete, session/resume, session/close, additionalDirectories" + ); + assert_eq!( + SessionMethods::advertised(&initialized(serde_json::json!({ + "sessionCapabilities": { "close": {} }, + }))) + .to_string(), + "session/close" + ); +} + +/// The classification the caller treats as a hint: broad enough to cover the codes +/// an agent rejects an unknown session id with, and no broader, so a transport +/// failure or an unimplemented method does not trigger a session rotation. +#[test] +fn only_a_rejected_session_id_reads_as_a_lost_session() { + for error in [AcpError::invalid_params(), AcpError::resource_not_found(None)] { + let message = error.message.clone(); + assert!( + AcpPortError::Rpc(error).is_session_lost(), + "{message} should read as a lost session" + ); + } + + for error in [ + AcpError::internal_error(), + AcpError::method_not_found(), + AcpError::request_cancelled(), + AcpError::auth_required(), + AcpError::invalid_request(), + AcpError::parse_error(), + ] { + let message = error.message.clone(); + assert!( + !AcpPortError::Rpc(error).is_session_lost(), + "{message} should not read as a lost session" + ); + } +} diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs index 3510e18d64..5f5ea99c7e 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs @@ -8,37 +8,54 @@ //! nothing else. #![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))] +#[cfg_attr(coverage, allow(dead_code))] mod acp_port; +#[cfg_attr(coverage, allow(dead_code))] mod config; +#[cfg_attr(coverage, allow(dead_code))] mod constants; +#[cfg_attr(coverage, allow(dead_code))] mod outbound; +#[cfg_attr(coverage, allow(dead_code))] mod parse; +#[cfg_attr(coverage, allow(dead_code))] mod pipeline; +#[cfg_attr(coverage, allow(dead_code))] mod render; -use acp_nats::{AgentHandler, ClientHandler}; -use acp_port::{AcpBridge, AcpPort, SessionMethods}; -use agent_client_protocol::schema::ProtocolVersion; -use agent_client_protocol::schema::v1::InitializeRequest; -use anyhow::Context as _; -use async_nats::jetstream::consumer::DeliverPolicy; -use config::BridgeConfig; -use futures::StreamExt; -use outbound::TelegramOutbound; -use pipeline::Pipeline; -use render::TelegramRenderClient; -use std::sync::Arc; -use teloxide::Bot; -use tracing::{error, info, warn}; -use trogon_channel::store::PrincipalRecord; -use trogon_channel::{ChannelStore, Endpoint, PrincipalId}; -use trogon_nats::jetstream::{ClaimResolver, NatsObjectStore}; -use trogon_std::UuidV7Generator; -use trogon_std::env::SystemEnv; -use trogon_std::fs::SystemFs; -use trogon_std::signal::shutdown_signal; -use trogon_telemetry::ServiceName; - +// The wiring below is nothing but transport: it builds the real NATS clients, +// which the coverage build leaves out. The logic it wires together stays in the +// coverage build and is exercised by the module tests. +#[cfg(not(coverage))] +use { + acp_nats::{AgentHandler, ClientHandler}, + acp_port::{AcpBridge, AcpPort, SessionMethods}, + agent_client_protocol::schema::ProtocolVersion, + agent_client_protocol::schema::v1::InitializeRequest, + anyhow::Context as _, + async_nats::jetstream::consumer::DeliverPolicy, + config::BridgeConfig, + futures::StreamExt, + outbound::TelegramOutbound, + pipeline::Pipeline, + render::TelegramRenderClient, + std::sync::Arc, + teloxide::Bot, + tracing::{error, info, warn}, + trogon_channel::store::PrincipalRecord, + trogon_channel::{ChannelStore, Endpoint, PrincipalId}, + trogon_nats::jetstream::{ClaimResolver, NatsObjectStore}, + trogon_std::UuidV7Generator, + trogon_std::env::SystemEnv, + trogon_std::fs::SystemFs, + trogon_std::signal::shutdown_signal, + trogon_telemetry::ServiceName, +}; + +#[cfg(coverage)] +fn main() {} + +#[cfg(not(coverage))] #[tokio::main] async fn main() -> anyhow::Result<()> { let config = BridgeConfig::from_env(&SystemEnv)?; @@ -108,6 +125,7 @@ async fn main() -> anyhow::Result<()> { result } +#[cfg(not(coverage))] async fn seed_principals(store: &ChannelStore, config: &BridgeConfig) -> anyhow::Result<()> { for user in &config.seed_users { let principal = PrincipalId::new(format!("telegram-{user}"))?; @@ -120,6 +138,7 @@ async fn seed_principals(store: &ChannelStore, config: &BridgeConfig) -> anyhow: Ok(()) } +#[cfg(not(coverage))] async fn run( nats_client: async_nats::Client, store: ChannelStore, diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/outbound.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/outbound.rs index 82a81f6152..375d4cef11 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/outbound.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/outbound.rs @@ -1,5 +1,11 @@ +// Only the implementation talks to Telegram, and it is nothing but those calls, +// so the coverage build leaves it out and keeps the seam the pipeline is written +// against. +#[cfg(not(coverage))] use teloxide::Bot; +#[cfg(not(coverage))] use teloxide::requests::Requester; +#[cfg(not(coverage))] use teloxide::types::{ChatAction, ChatId}; /// The render half's platform seam: what the pipeline needs from Telegram, @@ -11,16 +17,19 @@ pub trait Outbound { async fn send_text(&self, chat_id: i64, text: String) -> anyhow::Result<()>; } +#[cfg(not(coverage))] pub struct TelegramOutbound { bot: Bot, } +#[cfg(not(coverage))] impl TelegramOutbound { pub fn new(bot: Bot) -> Self { Self { bot } } } +#[cfg(not(coverage))] impl Outbound for TelegramOutbound { async fn typing(&self, chat_id: i64) -> anyhow::Result<()> { self.bot.send_chat_action(ChatId(chat_id), ChatAction::Typing).await?; diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs index c4c11e328e..28aab34b2e 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs @@ -11,11 +11,14 @@ use trogon_channel::store::PrincipalRecord; use trogon_channel::{ AgentPortError, AgentSessionId, Endpoint, InboundEvent, PrincipalId, PromptOutcome, ReleaseStep, SessionRelease, }; -use trogon_nats::jetstream::{ - ClaimCheckPublisher, ClaimRetention, DEFAULT_CLAIM_BUCKET, MaxPayload, NatsJetStreamClient, NatsObjectStore, -}; +use trogon_nats::jetstream::{DEFAULT_CLAIM_BUCKET, MockObjectStore}; use trogon_std::UuidV7Generator; +// The claim-check scenarios below need the real object store and publisher, which +// the coverage build leaves out; the scenarios that carry no claim do not. +#[cfg(not(coverage))] +use trogon_nats::jetstream::{ClaimCheckPublisher, ClaimRetention, MaxPayload, NatsJetStreamClient, NatsObjectStore}; + struct NatsServer { _container: ContainerAsync, url: String, @@ -201,9 +204,17 @@ async fn settled_consumer_info( stream.consumer_info(consumer).await.expect("consumer info") } +/// A resolver for scenarios whose updates carry no claim headers, so nothing is +/// ever redeemed through it. Mock-backed rather than bucket-backed to keep those +/// scenarios off the object store. +fn unclaimed_resolver() -> ClaimResolver { + ClaimResolver::new(MockObjectStore::new(), DEFAULT_CLAIM_BUCKET) +} + /// The bucket the gateway offloads oversized bodies into, opened the way the /// bridge opens it. Provisioned here because in a deployment the gateway has /// already done so. +#[cfg(not(coverage))] async fn claim_resolver(js: &async_nats::jetstream::Context) -> ClaimResolver { let store = NatsObjectStore::provision_claim_bucket(js, DEFAULT_CLAIM_BUCKET, ClaimRetention::EventSourced) .await @@ -269,7 +280,7 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { let port = FakePort::new(renderer.clone(), "hi there"); let outbound = FakeOutbound::default(); let triggers = CommandTriggers::default(); - let claims = claim_resolver(&js).await; + let claims = unclaimed_resolver(); let pipeline = Pipeline { store: &store, port: &port, @@ -350,6 +361,7 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { /// that deserializes the payload sees zero bytes. Published here through the /// same publisher the gateway uses, with the threshold driven to zero so every /// body takes that path. +#[cfg(not(coverage))] #[tokio::test] async fn pipeline_redeems_a_claim_checked_update() { let server = NatsServer::start().await; @@ -439,6 +451,7 @@ async fn pipeline_redeems_a_claim_checked_update() { /// A claim that cannot be redeemed is left for redelivery instead of acked. /// Dropping it would be permanent, and the payload alone carries no sign that /// anything was lost. +#[cfg(not(coverage))] #[tokio::test] async fn pipeline_leaves_an_unredeemable_claim_unacked() { let server = NatsServer::start().await; @@ -580,7 +593,7 @@ async fn pipeline_keeps_the_session_when_a_fresh_one_fails_the_same_way() { let port = FakePort::new(renderer.clone(), "hi there"); let outbound = FakeOutbound::default(); let triggers = CommandTriggers::default(); - let claims = claim_resolver(&js).await; + let claims = unclaimed_resolver(); let pipeline = Pipeline { store: &store, port: &port, From 06b6ebe600bbd877d56bd13497bb9d6c485b2472 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 02:57:10 -0400 Subject: [PATCH 23/55] fix(channel): stop a wrong lost-session guess from orphaning a live session Signed-off-by: Yordis Prieto --- .../channel-bridge-telegram/src/pipeline.rs | 21 ++++++++++++ .../src/pipeline_tests.rs | 34 +++++++++++++------ .../channel/trogon-channel/src/agent_port.rs | 4 +++ 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs index dd81a3ae84..016b59b466 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -205,6 +205,27 @@ impl Pipeline<'_, P, O, record.current_session = Some(fresh.clone()); self.store.update_conversation(&conversation_id, &record).await?; self.renderer.discard(active_session.as_str()); + + // The suspicion that got us here is a guess, so the + // agent may well still have the old session. Nothing + // points at it now, and only the agent can free it, so + // release it rather than let a wrong guess orphan a live + // session for the agent's lifetime. Release is + // best-effort by contract: one that really was lost + // simply reports failed steps. + let release = self + .port + .release_session(&active_session, ReleaseReason::Replaced) + .await; + info!( + conversation = %conversation_id, + session = %active_session, + replaced_by = %fresh, + cancelled = ?release.cancelled, + closed = ?release.closed, + "Released the session a fresh one replaced" + ); + active_session = fresh; outcome } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs index 28aab34b2e..ab46567378 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs @@ -9,7 +9,8 @@ use testcontainers_modules::nats::{Nats, NatsServerCmd}; use testcontainers_modules::testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner}; use trogon_channel::store::PrincipalRecord; use trogon_channel::{ - AgentPortError, AgentSessionId, Endpoint, InboundEvent, PrincipalId, PromptOutcome, ReleaseStep, SessionRelease, + AgentPortError, AgentSessionId, Endpoint, InboundEvent, PrincipalId, PromptOutcome, ReleaseReason, ReleaseStep, + SessionRelease, }; use trogon_nats::jetstream::{DEFAULT_CLAIM_BUCKET, MockObjectStore}; use trogon_std::UuidV7Generator; @@ -60,7 +61,7 @@ struct FakePort { reply: String, sessions_created: RefCell, prompted: RefCell>, - released: RefCell>, + released: RefCell>, /// How many upcoming prompts fail with an error classified as a lost /// session. One models a session the agent really did forget, so the fresh /// session succeeds; two makes the fresh session fail the same way, which is @@ -129,12 +130,8 @@ impl trogon_channel::AgentPort for FakePort { Ok(()) } - async fn release_session( - &self, - session: &AgentSessionId, - _reason: trogon_channel::ReleaseReason, - ) -> SessionRelease { - self.released.borrow_mut().push(session.as_str().to_string()); + async fn release_session(&self, session: &AgentSessionId, reason: ReleaseReason) -> SessionRelease { + self.released.borrow_mut().push((session.as_str().to_string(), reason)); SessionRelease { cancelled: ReleaseStep::Done, closed: ReleaseStep::Done, @@ -322,7 +319,10 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { ); assert_eq!( *port.released.borrow(), - vec!["sess-1".to_string(), "sess-2".to_string()] + vec![ + ("sess-1".to_string(), ReleaseReason::NewSession), + ("sess-2".to_string(), ReleaseReason::NewSession), + ] ); // The conversation and its principal outlive every session rotation. @@ -638,7 +638,10 @@ async fn pipeline_keeps_the_session_when_a_fresh_one_fails_the_same_way() { .expect("kv read") .expect("conversation exists"); assert_eq!(record.current_session, session_before); - assert_eq!(*port.released.borrow(), vec!["sess-2".to_string()]); + assert_eq!( + *port.released.borrow(), + vec![("sess-2".to_string(), ReleaseReason::RepairFailed)] + ); // So the next message continues on it rather than starting over. pipeline @@ -676,6 +679,17 @@ async fn pipeline_keeps_the_session_when_a_fresh_one_fails_the_same_way() { Some("sess-3") ); + // The replaced session is handed back too. `is_session_lost` is a guess, so + // the agent may still have had `sess-1`; without this a wrong guess orphans + // a live session that nothing points at any more. + assert_eq!( + *port.released.borrow(), + vec![ + ("sess-2".to_string(), ReleaseReason::RepairFailed), + ("sess-1".to_string(), ReleaseReason::Replaced), + ] + ); + assert_eq!(*outbound.typing.borrow(), 4); assert_eq!( *outbound.sent.borrow(), diff --git a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs index 89daa7827a..7fe2e2ff8e 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs @@ -62,6 +62,10 @@ pub enum ReleaseReason { /// A session opened to repair a suspected lost session failed the same way, /// so the bridge is handing back one it never got to use. RepairFailed, + /// A suspected lost session was replaced by a fresh one that answered. The + /// suspicion is a guess, so the agent may still hold the old session; it is + /// told to let go rather than left holding one nothing points at. + Replaced, } /// How one step of the release ladder ended. From 4f5d80bfc36b082f416510e407f1157bd378add2 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 04:40:41 -0400 Subject: [PATCH 24/55] fix(channel): harden domain validation and prompt-failure cleanup Address review findings so corrupt KV/wire values and mid-turn prompt failures cannot poison routing state: validate identifiers and inbound fields through fallible factories, discard partial renderer buffers on every prompt error path, compensate failed conversation binding writes, and bind claim stores to typed buckets shared with the gateway. Co-authored-by: Cursor Signed-off-by: Yordis Prieto --- devops/docker/compose/compose.yml | 2 + .../0044-inbound-media-fetch-out-of-band.md | 2 +- .../multi-channel-agent-routing.md | 60 ++- docs/glossary/binding.md | 4 +- docs/glossary/endpoint.md | 7 +- docs/glossary/principal.md | 11 +- .../channel-bridge-telegram/src/acp_port.rs | 5 +- .../src/acp_port_tests.rs | 10 + .../channel-bridge-telegram/src/config.rs | 14 +- .../src/config_tests.rs | 42 +- .../channel-bridge-telegram/src/main.rs | 33 +- .../channel-bridge-telegram/src/outbound.rs | 46 +- .../channel-bridge-telegram/src/parse.rs | 19 +- .../src/parse_tests.rs | 81 ++++ .../channel-bridge-telegram/src/pipeline.rs | 123 ++++- .../src/pipeline_tests.rs | 443 ++++++++++++++++-- .../src/render_tests.rs | 65 +++ .../channel-bridge-telegram/src/tests.rs | 5 + .../channel/trogon-channel/src/agent_port.rs | 31 +- .../trogon-channel/src/agent_port_tests.rs | 32 ++ .../channel/trogon-channel/src/command.rs | 58 +-- .../trogon-channel/src/command_tests.rs | 38 +- .../trogon-channel/src/command_trigger.rs | 38 ++ .../src/command_trigger_input.rs | 32 ++ .../src/command_trigger_input_tests.rs | 16 + .../trogon-channel/src/conversation.rs | 53 ++- .../trogon-channel/src/conversation_tests.rs | 81 ++++ .../channel/trogon-channel/src/endpoint.rs | 102 ++-- .../trogon-channel/src/endpoint_tests.rs | 73 ++- .../channel/trogon-channel/src/event.rs | 232 ++++++++- .../channel/trogon-channel/src/event_tests.rs | 171 +++++++ .../crates/channel/trogon-channel/src/lib.rs | 13 +- .../channel/trogon-channel/src/safe_token.rs | 70 +++ .../trogon-channel/src/safe_token_tests.rs | 57 +++ .../channel/trogon-channel/src/store.rs | 59 ++- .../channel/trogon-channel/src/store_tests.rs | 238 +++++++++- .../platform/trogon-gateway/src/main.rs | 8 +- .../trogon-nats/src/jetstream/claim_bucket.rs | 65 +++ .../src/jetstream/claim_bucket/tests.rs | 38 ++ .../trogon-nats/src/jetstream/claim_check.rs | 32 +- .../claim_check/integration_tests.rs | 21 +- .../platform/trogon-nats/src/jetstream/mod.rs | 4 +- .../trogon-nats/src/jetstream/object_store.rs | 66 ++- .../object_store/integration_tests.rs | 31 +- 44 files changed, 2340 insertions(+), 291 deletions(-) create mode 100644 rsworkspace/crates/channel/channel-bridge-telegram/src/parse_tests.rs create mode 100644 rsworkspace/crates/channel/channel-bridge-telegram/src/tests.rs create mode 100644 rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs create mode 100644 rsworkspace/crates/channel/trogon-channel/src/command_trigger.rs create mode 100644 rsworkspace/crates/channel/trogon-channel/src/command_trigger_input.rs create mode 100644 rsworkspace/crates/channel/trogon-channel/src/command_trigger_input_tests.rs create mode 100644 rsworkspace/crates/channel/trogon-channel/src/event_tests.rs create mode 100644 rsworkspace/crates/channel/trogon-channel/src/safe_token.rs create mode 100644 rsworkspace/crates/channel/trogon-channel/src/safe_token_tests.rs create mode 100644 rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs create mode 100644 rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs diff --git a/devops/docker/compose/compose.yml b/devops/docker/compose/compose.yml index c336835fc5..08c6b9d04c 100644 --- a/devops/docker/compose/compose.yml +++ b/devops/docker/compose/compose.yml @@ -73,6 +73,8 @@ services: NATS_URL: "nats:4222" RUST_LOG: "${RUST_LOG:-info}" depends_on: + trogon-gateway: + condition: service_healthy nats: condition: service_healthy restart: unless-stopped diff --git a/docs/adr/0044-inbound-media-fetch-out-of-band.md b/docs/adr/0044-inbound-media-fetch-out-of-band.md index 1f15258bbf..80494940a3 100644 --- a/docs/adr/0044-inbound-media-fetch-out-of-band.md +++ b/docs/adr/0044-inbound-media-fetch-out-of-band.md @@ -97,7 +97,7 @@ not-found, which is indistinguishable from a permanent failure and from a dead downloader. Readiness therefore lives in its own JetStream KV bucket, keyed by the platform handle: -``` +```text channel_media_{prefix}: -> { state: ready | failed, object_ref, mime, size, error } ``` diff --git a/docs/architecture/multi-channel-agent-routing.md b/docs/architecture/multi-channel-agent-routing.md index c7092d4e66..ae6f33edfa 100644 --- a/docs/architecture/multi-channel-agent-routing.md +++ b/docs/architecture/multi-channel-agent-routing.md @@ -20,7 +20,7 @@ exactly one process. ## The path a message takes -``` +```text SUBJECT / PROTOCOL PROCESS Telegram ─HTTP─▶ webhook validated, published verbatim trogon-gateway @@ -58,10 +58,12 @@ because a prompt turn legitimately runs for minutes, and a turn that fails is left unacked so JetStream redelivers, bounded by `max_deliver` 5. The bridge creates neither of the two resources it reads. The gateway provisions the stream and the claim bucket, sizing the bucket's retention against the longest-retained -stream it serves; the bridge only names the bucket (`TROGON_CLAIM_BUCKET`, -defaulting to the same `trogon-claims` the gateway writes) and refuses to start if -either resource is missing, rather than create a wrong one and find out on the -first oversized update. +stream it serves; the bridge refuses to start if either resource is missing, +rather than create a wrong one and find out on the first oversized update. Which +bucket that is, `trogon-claims`, is a shared compile-time constant and not an +operator knob on either side: a claim only resolves in the bucket it was written +to, so any value the two could disagree on is a value one of them is wrong +about. **The bridge handles one update at a time.** The inbound loop awaits each turn to completion before pulling the next message. What the design requires is @@ -115,28 +117,44 @@ policy picks the agent once, the conversation is created, and the entry is written. The word makes it sound like a subsystem; it is a lookup table with one column. +**Absence is judged per endpoint.** An endpoint with no entry starts a new +conversation even when its principal already holds one, because the lookup is +keyed by endpoint address and falls back to nothing. + `{prefix}`, which appears in bucket names below, is none of the above. It is the deployment namespace (`CHANNEL_PREFIX`, default `prod`) so staging and production can share a NATS cluster without sharing state. It never appears inside an endpoint. -``` +```text endpoint (channel, account, peer) where messages arrive and leave - │ many-to-one - ▼ -principal the human, across all channels - │ - ▼ -conversation the shared context, cross-channel + │ │ + │ identity │ binding: endpoint -> conversation id + │ many-to-one │ one entry per endpoint + ▼ │ +principal │ the human, across all channels + │ owns │ + ▼ ▼ +conversation the shared context ├── agent_id sticky: set at creation by routing policy └── current_session ephemeral: belongs to the agent ``` -- **Conversations are cross-channel.** The same conversation can be picked up - from any endpoint that resolves to the same principal. The conversation is the - root object and endpoints are pointers into it, never the other way around. - Only Telegram implements an endpoint today, so this is a property the model - guarantees rather than a path that currently runs. +Two arrows leave the endpoint because they are two separate lookups. Identity +answers "may this endpoint speak"; the binding answers "into which conversation". +A conversation records its principal, but nothing reads a conversation *by* +principal. + +- **Conversations are cross-channel, by explicit link.** The conversation is the + root object and endpoints are pointers into it, never the other way around, so + several endpoints on different channels can point at one conversation and each + will feed it. What the model does not do is infer that pointer: a shared + principal is an access grant, not a route, so continuation from a second channel + means writing that endpoint's binding to the existing conversation id. Reading + the principal's most recent conversation instead would make continuation + automatic and is deliberately not implemented, because guessing which + conversation a new channel meant to resume is worse than starting a fresh one. + Only Telegram implements an endpoint today, so none of this runs yet. - **Binding is the session-routing record itself**, not a layer in front of it: an incoming message resolves endpoint to conversation and follows it. Routing policy (which agent handles a new conversation) is consulted exactly once, at @@ -218,7 +236,7 @@ shapes are what any future transport between an edge and a router would carry. **Inbound event**, what any channel bridge produces after stripping its platform's shape: -``` +```text { endpoint: { channel, account, peer }, sender: { platform_user_id, display_name }, @@ -265,7 +283,7 @@ downloader is designed and not built; today media is dropped. The bridge reaches agents through one in-process trait: -``` +```text AgentPort: create_session / resume_session prompt(session, content) -> stream of agent events @@ -416,6 +434,10 @@ them change the topology above. admin surface for that. - **A group's principal names a room, not a human**, so it cannot take part in cross-channel continuation. +- **Cross-channel continuation needs a binding written by hand**, and there is no + admin surface that writes one. The model holds (the conversation is the root and + takes pointers from any number of endpoints); what is missing is anything that + creates the second pointer. - **The bot token lives in two processes**, the gateway for webhook registration and the bridge for API calls. A generic gateway sink (NATS to HTTP-out, symmetric to its sources) would centralize outbound custody. It does not exist, diff --git a/docs/glossary/binding.md b/docs/glossary/binding.md index cb25a5a526..b5ae367595 100644 --- a/docs/glossary/binding.md +++ b/docs/glossary/binding.md @@ -10,7 +10,9 @@ One [KV bucket](./kv-bucket) entry mapping an [endpoint](./endpoint) to a [conversation](./conversation) id, and nothing more than that. A message arrives, the bridge reads the entry, and either follows it or, when the entry is absent, treats the message as the start of a new conversation: routing policy -runs once, the conversation is created, and the entry is written. +runs once, the conversation is created, and the entry is written. Absence is read +per endpoint and nothing else is consulted, so an endpoint whose +[principal](./principal) already holds conversations still gets a new one. The binding is the routing record itself rather than a layer in front of one, and it is sticky, so operator config changes affect new conversations only and a diff --git a/docs/glossary/endpoint.md b/docs/glossary/endpoint.md index 25f0d177ea..3d560e12c3 100644 --- a/docs/glossary/endpoint.md +++ b/docs/glossary/endpoint.md @@ -9,9 +9,10 @@ order: 1 One place messages arrive and leave, addressed by three tokens: `channel` (which platform), `account` (which of our bots on that platform), and `peer` (which chat on the far side). Joined with dots it reads -`telegram.mybot.-1001234567890`, and that one string serves as both a -[KV bucket](./kv-bucket) key and the tail of a NATS subject, which is why the -tokens are restricted to characters that both accept. +`telegram.mybot.-1001234567890`, and that one string is the +[KV bucket](./kv-bucket) key the registries look it up by. The tokens are +restricted to characters a NATS subject accepts too, so the composite could +serve as a subject tail, though nothing publishes it today. An endpoint addresses a **chat, not a person**: everyone in a group shares one endpoint, so authorizing an endpoint authorizes the room. Many endpoints can diff --git a/docs/glossary/principal.md b/docs/glossary/principal.md index 0e0988093e..d67b52cf3e 100644 --- a/docs/glossary/principal.md +++ b/docs/glossary/principal.md @@ -9,9 +9,14 @@ order: 2 The identity behind one or more [endpoints](./endpoint). An endpoint that resolves to no principal is rejected at the [bridge](./bridge), and that rejection is the entire access-control mechanism for channels: there is no -separate allowlist. Linking one person's Telegram and Discord endpoints to a -single principal is what allows a [conversation](./conversation) to continue -across channels. +separate allowlist. + +Linking one person's Telegram and Discord endpoints to a single principal grants +both endpoints access; on its own it does not continue a +[conversation](./conversation) across them. Routing reads the +[binding](./binding) at the arriving endpoint's own address and never searches by +principal, so the second endpoint starts its own conversation until its binding +is pointed at the first conversation's id. For a group chat the linked principal stands for the room rather than for a person, since the room is one endpoint. That imprecision is recorded, with its diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs index 00435f3250..726ef80a57 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs @@ -34,6 +34,8 @@ pub type AcpBridge = pub enum AcpPortError { #[error("agent request failed: {0}")] Rpc(agent_client_protocol::Error), + #[error(transparent)] + SessionId(#[from] trogon_channel::EndpointError), } impl AgentPortError for AcpPortError { @@ -52,6 +54,7 @@ impl AgentPortError for AcpPortError { // message rather than just this one. match self { Self::Rpc(error) => matches!(error.code, ErrorCode::InvalidParams | ErrorCode::ResourceNotFound), + Self::SessionId(_) => false, } } } @@ -215,7 +218,7 @@ impl AgentPort for AcpPort { .new_session(NewSessionRequest::new(self.agent_cwd.clone())) .await .map_err(AcpPortError::Rpc)?; - Ok(AgentSessionId::new(response.session_id.to_string())) + Ok(AgentSessionId::new(response.session_id.to_string())?) } async fn prompt(&self, session: &AgentSessionId, event: &InboundEvent) -> Result { diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs index 4bd9481991..4941e07d14 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs @@ -125,3 +125,13 @@ fn only_a_rejected_session_id_reads_as_a_lost_session() { ); } } + +/// An id the agent handed back that is not a usable token failed before any +/// session existed, so there is no session for a fresh one to repair. Reading it +/// as a lost session would have the pipeline open a replacement against an agent +/// that is going to name the next one just as unusably. +#[test] +fn an_unusable_session_id_is_not_a_lost_session() { + let error = trogon_channel::AgentSessionId::new("sess 1").expect_err("an id with a space is not a token"); + assert!(!AcpPortError::SessionId(error).is_session_lost()); +} diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs index b272da8ffe..d120d3d732 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs @@ -6,6 +6,7 @@ use acp_nats::{AcpPrefix, NatsConfig}; use anyhow::Context; use std::path::PathBuf; use trogon_channel::CommandTriggers; +use trogon_nats::jetstream::ClaimBucket; use trogon_std::env::ReadEnv; /// A Telegram Bot API token that cannot be blank. @@ -67,7 +68,14 @@ pub struct BridgeConfig { /// Object-store bucket the gateway offloads oversized bodies to. Reading it /// is not optional: an update over the NATS max payload arrives as an empty /// body plus claim headers, and the bytes are only in the bucket. - pub claim_bucket: String, + /// + /// Deliberately not configurable. The gateway provisions and publishes to + /// [`ClaimBucket::default`] unconditionally and stamps that name into every + /// claim's headers, so any other value here can only be wrong: the bucket + /// would be missing at boot, or present and rejected as a `BucketMismatch` + /// when a real claim arrives. A shared constant is what keeps the publisher + /// and the resolver in agreement; an env knob on one side cannot. + pub claim_bucket: ClaimBucket, pub bot_token: BotToken, /// Endpoint account token; identifies which bot account on Telegram. pub bot_account: String, @@ -92,8 +100,6 @@ impl BridgeConfig { let channel_prefix = var(env, "CHANNEL_PREFIX").unwrap_or_else(|| "prod".to_string()); let inbound_stream = var(env, "TELEGRAM_INBOUND_STREAM").unwrap_or_else(|| "TELEGRAM".to_string()); - let claim_bucket = - var(env, "TROGON_CLAIM_BUCKET").unwrap_or_else(|| trogon_nats::jetstream::DEFAULT_CLAIM_BUCKET.to_string()); let bot_account = var(env, "TELEGRAM_BOT_ACCOUNT").unwrap_or_else(|| "bot".to_string()); let agent_id = var(env, "CHANNEL_AGENT_ID").unwrap_or_else(|| "default".to_string()); let agent_cwd = var(env, "CHANNEL_AGENT_CWD").map_or_else(std::env::temp_dir, PathBuf::from); @@ -134,7 +140,7 @@ impl BridgeConfig { acp, channel_prefix, inbound_stream, - claim_bucket, + claim_bucket: ClaimBucket::default(), bot_token, bot_account, agent_id, diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs index 2f41dfe927..3b44962d8e 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs @@ -26,6 +26,21 @@ fn a_blank_bot_token_fails_like_an_unset_one() { ); } +/// The bridge resolves claims from exactly the bucket the gateway publishes +/// them to. The gateway provisions `DEFAULT_CLAIM_BUCKET` unconditionally and +/// stamps that name into every claim header, so this is not a default the +/// environment may override: a different value here resolves nothing. Setting +/// the variable that used to steer it must therefore change nothing. +#[test] +fn the_claim_bucket_is_the_gateways_bucket_and_the_environment_cannot_move_it() { + let env = InMemoryEnv::new(); + env.set("TELEGRAM_BOT_TOKEN", "secret-token"); + env.set("TROGON_CLAIM_BUCKET", "somewhere-the-gateway-never-writes"); + + let config = BridgeConfig::from_env(&env).expect("config"); + assert_eq!(config.claim_bucket, ClaimBucket::default()); +} + /// A token read from a file or a heredoc arrives with a trailing newline, which /// Telegram rejects without saying which byte was wrong. #[test] @@ -56,7 +71,6 @@ fn blank_optional_variables_fall_back_to_their_defaults() { for key in [ "CHANNEL_PREFIX", "TELEGRAM_INBOUND_STREAM", - "TROGON_CLAIM_BUCKET", "TELEGRAM_BOT_ACCOUNT", "CHANNEL_AGENT_ID", "CHANNEL_AGENT_CWD", @@ -68,7 +82,6 @@ fn blank_optional_variables_fall_back_to_their_defaults() { let config = BridgeConfig::from_env(&env).expect("config"); assert_eq!(config.channel_prefix, "prod"); assert_eq!(config.inbound_stream, "TELEGRAM"); - assert_eq!(config.claim_bucket, trogon_nats::jetstream::DEFAULT_CLAIM_BUCKET); assert_eq!(config.bot_account, "bot"); assert_eq!(config.agent_id, "default"); assert_eq!(config.agent_cwd, std::env::temp_dir()); @@ -84,17 +97,38 @@ fn a_blank_trigger_list_means_no_triggers_rather_than_the_defaults() { env.set("TELEGRAM_BOT_TOKEN", "secret-token"); env.set("CHANNEL_NEW_SESSION_TRIGGERS", ""); let config = BridgeConfig::from_env(&env).expect("config"); - assert_eq!(config.command_triggers.parse("/new").command, None); + assert_eq!(config.command_triggers.parse("/new", "bot").command, None); let env = InMemoryEnv::new(); env.set("TELEGRAM_BOT_TOKEN", "secret-token"); let config = BridgeConfig::from_env(&env).expect("config"); assert_eq!( - config.command_triggers.parse("/new").command, + config.command_triggers.parse("/new", "bot").command, Some(trogon_channel::Command::NewSession) ); } +/// A seeded user id becomes a Telegram chat id, so a typo has to stop the +/// bridge at boot rather than silently seed a shorter list: the operator who +/// wrote it would otherwise find out only when that person is refused, and the +/// message has to name the entry so they know which one. +#[test] +fn a_seed_list_with_an_unparseable_id_fails_and_names_it() { + let env = InMemoryEnv::new(); + env.set("TELEGRAM_BOT_TOKEN", "secret-token"); + env.set("CHANNEL_SEED_TELEGRAM_USERS", "42, not-an-id ,43"); + + // Matched rather than `expect_err`ed because a `BridgeConfig` is not + // `Debug`: it holds a token. + let Err(error) = BridgeConfig::from_env(&env) else { + panic!("an unparseable seed id must not configure the bridge"); + }; + assert!( + format!("{error:#}").contains("not-an-id"), + "the failure must name the offending entry: {error:#}" + ); +} + #[test] fn set_variables_are_read_and_trimmed() { let env = InMemoryEnv::new(); diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs index 5f5ea99c7e..54b9ea1730 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs @@ -82,13 +82,14 @@ async fn main() -> anyhow::Result<()> { // rather than into an oversized update that arrives one day and cannot be // redeemed. let claims = ClaimResolver::new( - NatsObjectStore::bind(&js, &config.claim_bucket).await.map_err(|e| { - anyhow::anyhow!( - "claim bucket '{}' not found; the trogon-gateway must provision it: {e}", - config.claim_bucket - ) - })?, - config.claim_bucket.clone(), + NatsObjectStore::bind_claim_bucket(&js, config.claim_bucket.clone()) + .await + .map_err(|e| { + anyhow::anyhow!( + "claim bucket '{}' not found; the trogon-gateway must provision it: {e}", + config.claim_bucket + ) + })?, ); let consumer_name = format!("{}-{}", constants::INBOUND_DURABLE, config.channel_prefix); let consumer = stream @@ -160,15 +161,16 @@ async fn run( )); let renderer = Arc::new(TelegramRenderClient::new()); - let client_task = tokio::task::spawn_local(acp_nats::client::run( + let mut client_task = tokio::task::spawn_local(acp_nats::client::run( nats_client.clone(), renderer.clone(), bridge.clone(), )); let renderer_for_rx = renderer.clone(); - let notification_task = tokio::task::spawn_local(async move { + let mut notification_task = tokio::task::spawn_local(async move { while let Some(notification) = notification_rx.recv().await { - if renderer_for_rx.session_notification(notification).await.is_err() { + if let Err(e) = renderer_for_rx.session_notification(notification).await { + error!(error = ?e, "Render client rejected a session notification"); break; } } @@ -207,6 +209,14 @@ async fn run( info!("Shutting down"); break; } + result = &mut client_task => { + error!(?result, "ACP client task ended; agent responses can no longer be rendered"); + break; + } + result = &mut notification_task => { + error!(?result, "Notification task ended; agent responses can no longer be rendered"); + break; + } next = messages.next() => { let Some(next) = next else { warn!("Inbound consumer stream ended"); @@ -230,3 +240,6 @@ async fn run( notification_task.abort(); Ok(()) } + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/outbound.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/outbound.rs index 375d4cef11..46cf5c6330 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/outbound.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/outbound.rs @@ -6,15 +6,26 @@ use teloxide::Bot; #[cfg(not(coverage))] use teloxide::requests::Requester; #[cfg(not(coverage))] -use teloxide::types::{ChatAction, ChatId}; +use teloxide::types::{ChatAction, ChatId, Message, True}; -/// The render half's platform seam: what the pipeline needs from Telegram, -/// narrow enough to fake in tests. Grows with the render vocabulary -/// (edit-in-place, attachments), never with agent concepts. +/// Show the typing indicator in a chat. One trait per outbound operation; +/// never carries agent concepts. #[allow(async_fn_in_trait)] -pub trait Outbound { - async fn typing(&self, chat_id: i64) -> anyhow::Result<()>; - async fn send_text(&self, chat_id: i64, text: String) -> anyhow::Result<()>; +pub trait SendTyping { + type Error: std::error::Error + 'static; + type Output; + + async fn typing(&self, chat_id: i64) -> Result; +} + +/// Send a text message to a chat. One trait per outbound operation; never +/// carries agent concepts. +#[allow(async_fn_in_trait)] +pub trait SendText { + type Error: std::error::Error + 'static; + type Message; + + async fn send_text(&self, chat_id: i64, text: String) -> Result; } #[cfg(not(coverage))] @@ -30,14 +41,21 @@ impl TelegramOutbound { } #[cfg(not(coverage))] -impl Outbound for TelegramOutbound { - async fn typing(&self, chat_id: i64) -> anyhow::Result<()> { - self.bot.send_chat_action(ChatId(chat_id), ChatAction::Typing).await?; - Ok(()) +impl SendTyping for TelegramOutbound { + type Error = teloxide::RequestError; + type Output = True; + + async fn typing(&self, chat_id: i64) -> Result { + self.bot.send_chat_action(ChatId(chat_id), ChatAction::Typing).await } +} + +#[cfg(not(coverage))] +impl SendText for TelegramOutbound { + type Error = teloxide::RequestError; + type Message = Message; - async fn send_text(&self, chat_id: i64, text: String) -> anyhow::Result<()> { - self.bot.send_message(ChatId(chat_id), text).await?; - Ok(()) + async fn send_text(&self, chat_id: i64, text: String) -> Result { + self.bot.send_message(ChatId(chat_id), text).await } } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs index dcecf6d34f..e816369c96 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs @@ -1,5 +1,9 @@ +#[cfg(test)] +#[path = "parse_tests.rs"] +mod parse_tests; + use teloxide::types::{Update, UpdateKind}; -use trogon_channel::{CommandTriggers, Endpoint, InboundEvent, Sender}; +use trogon_channel::{CommandTriggers, Endpoint, InboundEvent, MessageRef, PlatformUserId, Sender}; /// Normalize a raw Telegram update into the channel-neutral event, or `None` /// for update kinds the bridge does not carry (media, edits, membership, ...). @@ -19,18 +23,21 @@ pub fn inbound_event(update: &Update, bot_account: &str, triggers: &CommandTrigg } }; - let parsed = triggers.parse(text); + let parsed = triggers.parse(text, bot_account); + // Telegram numbers users and messages, and both value objects take an + // integer without a failure case, so the account above is the only token + // here that a deployment can get wrong. Some(InboundEvent { endpoint, sender: Sender { - platform_user_id: from.id.0.to_string(), + platform_user_id: PlatformUserId::from(from.id.0), display_name: from.full_name(), }, text: parsed.body, command: parsed.command, attachments: Vec::new(), - message_ref: msg.id.0.to_string(), + message_ref: MessageRef::from(i64::from(msg.id.0)), occurred_at: msg.date.timestamp(), }) } @@ -38,8 +45,10 @@ pub fn inbound_event(update: &Update, bot_account: &str, triggers: &CommandTrigg /// The endpoint of whoever sent a message, which is not the conversation's /// endpoint: a group chat is one endpoint shared by everyone in it, so /// authorizing the chat says nothing about authorizing the speaker. +/// The sender's own id is already a valid token, so only a misconfigured +/// `bot_account` can fail here. pub fn sender_endpoint(bot_account: &str, sender: &Sender) -> Option { - match Endpoint::new("telegram", bot_account, sender.platform_user_id.clone()) { + match Endpoint::new("telegram", bot_account, sender.platform_user_id.as_str()) { Ok(endpoint) => Some(endpoint), Err(e) => { tracing::warn!(error = %e, user_id = %sender.platform_user_id, "Sender has an unencodable endpoint"); diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse_tests.rs new file mode 100644 index 0000000000..a808d68121 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse_tests.rs @@ -0,0 +1,81 @@ +use super::*; + +/// The bridge parses updates the same way the pipeline does: bytes off the +/// wire, not a pre-built `serde_json::Value`. `teloxide`'s nested +/// `flatten`/`untagged` types round-trip through the streaming deserializer +/// but not through `Value`, so building the fixture this way is required, not +/// stylistic. +fn update_from(body: serde_json::Value) -> Update { + let bytes = serde_json::to_vec(&body).expect("serialize update"); + serde_json::from_slice(&bytes).expect("deserialize update") +} + +fn message_update(chat_id: i64, user_id: u64, text: &str) -> Update { + update_from(serde_json::json!({ + "update_id": 1, + "message": { + "message_id": 1, + "date": 1_700_000_000, + "chat": { "id": chat_id, "type": "private", "first_name": "Test" }, + "from": { "id": user_id, "is_bot": false, "first_name": "Test" }, + "text": text, + } + })) +} + +/// `inbound_event` only carries `UpdateKind::Message`. An edit is a real update +/// kind the raw stream keeps for later, and it must come back as `None` here +/// rather than being misread as a fresh message. +#[test] +fn an_edited_message_update_yields_no_inbound_event() { + let update = update_from(serde_json::json!({ + "update_id": 1, + "edited_message": { + "message_id": 1, + "date": 1_700_000_000, + "chat": { "id": 42, "type": "private", "first_name": "Test" }, + "from": { "id": 42, "is_bot": false, "first_name": "Test" }, + "text": "edited", + } + })); + + let triggers = CommandTriggers::default(); + assert!(inbound_event(&update, "mybot", &triggers).is_none()); +} + +/// A chat id is always digits or a leading `-`, so `Endpoint::new` can never +/// reject the peer token built from one; the only way to reach this arm is a +/// misconfigured `bot_account`, which is what this pins. +#[test] +fn an_unsafe_bot_account_drops_the_update_instead_of_panicking() { + let update = message_update(42, 42, "hello"); + let triggers = CommandTriggers::default(); + assert!(inbound_event(&update, "bad bot", &triggers).is_none()); +} + +/// An unsafe sender id can no longer reach this function: `PlatformUserId` +/// refuses to hold one, so the only token left that can spoil the endpoint is +/// the account the bridge was configured with. +#[test] +fn sender_endpoint_returns_none_for_an_unsafe_bot_account() { + let sender = Sender { + platform_user_id: PlatformUserId::new("42").expect("valid id"), + display_name: "Test".to_string(), + }; + assert!(sender_endpoint("bad bot", &sender).is_none()); + assert!(sender_endpoint("mybot", &sender).is_some()); +} + +/// The whole reason `sender_endpoint` exists: a group chat is one endpoint +/// shared by everyone in it, so its peer must be the sender's id and never the +/// chat's, or authorizing the chat would silently authorize the wrong party. +#[test] +fn sender_endpoint_peer_is_the_sender_not_the_chat() { + let update = message_update(999, 42, "hello"); + let triggers = CommandTriggers::default(); + let event = inbound_event(&update, "mybot", &triggers).expect("event"); + + let endpoint = sender_endpoint("mybot", &event.sender).expect("endpoint"); + assert_eq!(endpoint.peer(), "42"); + assert_ne!(endpoint.peer(), event.endpoint.peer()); +} diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs index 016b59b466..38fe96397e 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -3,16 +3,15 @@ mod pipeline_tests; use crate::constants::{NEW_SESSION_ACKNOWLEDGEMENT, TEXT_CHUNK_LIMIT}; -use crate::outbound::Outbound; +use crate::outbound::{SendText, SendTyping}; use crate::parse; use crate::render::{TelegramRenderClient, chunk_text}; -use anyhow::Context as _; use tracing::{info, warn}; use trogon_channel::{ - AgentId, AgentPort, AgentPortError as _, ChannelStore, Command, CommandTriggers, ConversationId, - ConversationRecord, InboundEvent, ReleaseReason, + AgentId, AgentPort, AgentPortError as _, AgentSessionId, ChannelStore, ChannelStoreError, Command, CommandTriggers, + ConversationId, ConversationRecord, EndpointError, InboundEvent, ReleaseReason, }; -use trogon_nats::jetstream::{ClaimResolver, ObjectStoreGet}; +use trogon_nats::jetstream::{ClaimResolveError, ClaimResolver, ObjectStoreGet}; use trogon_std::NowV7; pub struct Pipeline<'a, P, O, G, S> { @@ -27,21 +26,71 @@ pub struct Pipeline<'a, P, O, G, S> { pub ids: &'a G, } +/// Failures while processing one inbound update. Source errors stay typed so +/// callers can match or log the causal chain without stringifying at the boundary. +#[derive(Debug, thiserror::Error)] +pub enum PipelineError +where + PE: std::error::Error + 'static, + SE: std::error::Error + 'static, + OE: std::error::Error + 'static, +{ + #[error("failed to acknowledge message")] + Ack(#[source] async_nats::Error), + #[error("failed to redeem claim-checked update")] + Claim(#[source] ClaimResolveError), + #[error(transparent)] + Store(#[from] ChannelStoreError), + #[error("telegram peer is not an i64 chat id")] + PeerNotChatId(#[source] std::num::ParseIntError), + #[error(transparent)] + AgentId(#[from] EndpointError), + #[error("failed to create an agent session")] + CreateSession(#[source] PE), + #[error("prompt failed on session {session}")] + Prompt { + session: AgentSessionId, + #[source] + source: PE, + }, + #[error( + "prompt failed on session {session} and again on a fresh session, so the session was not the cause (first: {first}; retry: {retry})" + )] + PromptRetry { + session: AgentSessionId, + first: PE, + retry: PE, + }, + #[error("failed to send telegram text")] + SendText(#[source] OE), +} + fn now_unix() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) } -async fn ack(msg: &async_nats::jetstream::Message) -> anyhow::Result<()> { - msg.ack().await.map_err(|e| anyhow::anyhow!("ack failed: {e}")) +async fn ack(msg: &async_nats::jetstream::Message) -> Result<(), PipelineError> +where + PE: std::error::Error + 'static, + SE: std::error::Error + 'static, + OE: std::error::Error + 'static, +{ + msg.ack().await.map_err(PipelineError::Ack) } -impl Pipeline<'_, P, O, G, S> { +impl Pipeline<'_, P, O, G, S> +where + P: AgentPort, + O: SendTyping + SendText, + G: NowV7, + S: ObjectStoreGet, +{ /// Whether the individual who sent this message is a known principal. The /// conversation gate authorizes the chat, which in a group is everyone in /// it; destructive commands ask the narrower question. - async fn sender_is_authorized(&self, event: &InboundEvent) -> anyhow::Result { + async fn sender_is_authorized(&self, event: &InboundEvent) -> Result { let Some(endpoint) = parse::sender_endpoint(self.bot_account, &event.sender) else { return Ok(false); }; @@ -57,7 +106,7 @@ impl Pipeline<'_, P, O, &self, conversation_id: &ConversationId, record: &mut ConversationRecord, - ) -> anyhow::Result<()> { + ) -> Result<(), ChannelStoreError> { let Some(session) = record.current_session.take() else { return Ok(()); }; @@ -80,7 +129,10 @@ impl Pipeline<'_, P, O, /// (unparseable, unauthorized, kinds the bridge does not carry) are acked and /// dropped; processing errors return `Err` with the message unacked so /// JetStream redelivers. - pub async fn handle_message(&self, msg: &async_nats::jetstream::Message) -> anyhow::Result<()> { + pub async fn handle_message( + &self, + msg: &async_nats::jetstream::Message, + ) -> Result<(), PipelineError::Error>> { // An update over the NATS max payload reaches the stream as an empty // body plus claim headers, so the parse below has to run on the redeemed // bytes. A failure here returns Err rather than acking: the update is @@ -90,7 +142,7 @@ impl Pipeline<'_, P, O, .claims .resolve(msg.headers.as_ref(), msg.payload.clone()) .await - .map_err(|e| anyhow::anyhow!("failed to redeem claim-checked update: {e}"))?; + .map_err(PipelineError::Claim)?; let update = match serde_json::from_slice::(&body) { Ok(update) => update, @@ -113,7 +165,7 @@ impl Pipeline<'_, P, O, .endpoint .peer() .parse::() - .context("telegram peer is not an i64 chat id")?; + .map_err(PipelineError::PeerNotChatId)?; let now = now_unix(); let (conversation_id, mut record) = match self.store.conversation_for(&event.endpoint).await? { @@ -123,7 +175,7 @@ impl Pipeline<'_, P, O, // configured agent. Sticky from here on. let record = ConversationRecord { principal: principal.clone(), - agent_id: AgentId::new(self.agent_id), + agent_id: AgentId::new(self.agent_id)?, current_session: None, created_at: now, last_activity_at: now, @@ -144,7 +196,7 @@ impl Pipeline<'_, P, O, self.outbound .send_text(chat_id, NEW_SESSION_ACKNOWLEDGEMENT.to_string()) .await - .context("telegram send failed")?; + .map_err(PipelineError::SendText)?; return ack(msg).await; } } else { @@ -170,7 +222,7 @@ impl Pipeline<'_, P, O, .port .create_session(&record) .await - .map_err(|e| anyhow::anyhow!("create_session failed: {e}"))?; + .map_err(PipelineError::CreateSession)?; record.current_session = Some(session.clone()); self.store.update_conversation(&conversation_id, &record).await?; session @@ -194,11 +246,15 @@ impl Pipeline<'_, P, O, // reason the pointer is not moved first. Err(first_error) if first_error.is_session_lost() => { warn!(error = %first_error, session = %active_session, "Agent may no longer have the session; trying a fresh one"); - let fresh = self - .port - .create_session(&record) - .await - .map_err(|e| anyhow::anyhow!("create_session failed: {e}"))?; + let fresh = match self.port.create_session(&record).await { + Ok(fresh) => fresh, + Err(error) => { + // The first prompt may already have streamed into this + // session's buffer; drop it before redelivery retries. + self.renderer.discard(active_session.as_str()); + return Err(PipelineError::CreateSession(error)); + } + }; match self.port.prompt(&fresh, &event).await { Ok(outcome) => { @@ -237,6 +293,10 @@ impl Pipeline<'_, P, O, Err(retry_error) => { let release = self.port.release_session(&fresh, ReleaseReason::RepairFailed).await; self.renderer.discard(fresh.as_str()); + // The original prompt may have streamed into + // active_session before failing; without this, + // redelivery joins that partial turn to the next. + self.renderer.discard(active_session.as_str()); info!( conversation = %conversation_id, session = %fresh, @@ -244,13 +304,24 @@ impl Pipeline<'_, P, O, closed = ?release.closed, "Released the session opened to repair a suspected loss" ); - return Err(anyhow::anyhow!( - "prompt failed on session {active_session} and again on a fresh session, so the session was not the cause: {first_error} (retry: {retry_error})" - )); + return Err(PipelineError::PromptRetry { + session: active_session, + first: first_error, + retry: retry_error, + }); } } } - Err(error) => return Err(anyhow::anyhow!("prompt failed: {error}")), + Err(source) => { + // Prompt can stream agent chunks into the buffer before failing. + // JetStream will redeliver, so leave nothing for the next turn + // to take_buffer and join onto a fresh reply. + self.renderer.discard(active_session.as_str()); + return Err(PipelineError::Prompt { + session: active_session, + source, + }); + } }; record.last_activity_at = now_unix(); @@ -262,7 +333,7 @@ impl Pipeline<'_, P, O, self.outbound .send_text(chat_id, chunk) .await - .context("telegram send failed")?; + .map_err(PipelineError::SendText)?; } } None => warn!(outcome = ?outcome, session = %active_session, "Agent turn produced no text"), diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs index ab46567378..bdbdd8a94a 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::outbound::Outbound; +use crate::outbound::{SendText, SendTyping}; use acp_nats::ClientHandler; use agent_client_protocol::schema::v1::{ContentBlock, ContentChunk, SessionNotification, SessionUpdate, TextContent}; use futures::StreamExt; @@ -9,16 +9,18 @@ use testcontainers_modules::nats::{Nats, NatsServerCmd}; use testcontainers_modules::testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner}; use trogon_channel::store::PrincipalRecord; use trogon_channel::{ - AgentPortError, AgentSessionId, Endpoint, InboundEvent, PrincipalId, PromptOutcome, ReleaseReason, ReleaseStep, - SessionRelease, + AgentPortError, AgentSessionId, Endpoint, InboundEvent, MessageRef, PlatformUserId, PrincipalId, PromptOutcome, + ReleaseReason, ReleaseStep, Sender, SessionRelease, }; -use trogon_nats::jetstream::{DEFAULT_CLAIM_BUCKET, MockObjectStore}; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding, MockObjectStore}; use trogon_std::UuidV7Generator; // The claim-check scenarios below need the real object store and publisher, which // the coverage build leaves out; the scenarios that carry no claim do not. #[cfg(not(coverage))] -use trogon_nats::jetstream::{ClaimCheckPublisher, ClaimRetention, MaxPayload, NatsJetStreamClient, NatsObjectStore}; +use trogon_nats::jetstream::{ + ClaimCheckPublisher, ClaimRetention, DEFAULT_CLAIM_BUCKET, MaxPayload, NatsJetStreamClient, NatsObjectStore, +}; struct NatsServer { _container: ContainerAsync, @@ -68,6 +70,25 @@ struct FakePort { /// the misclassification, where the prompt itself is being rejected and no /// fresh session helps. rejections: RefCell, + /// How many upcoming prompts fail with an error that is *not* a lost + /// session, which is every ordinary agent failure. No fresh session is + /// opened for these: redelivery retries on the session the conversation has. + refusals: RefCell, + /// How many upcoming session creations fail. Models an agent that has + /// stopped issuing sessions, which is what turns a suspected lost session + /// into a dead end rather than a repair. + creation_failures: RefCell, + /// How many upcoming turns end without streaming any text. A real agent + /// does this when it acts only through tool calls. + silent_turns: RefCell, +} + +/// Consume one use of a scripted behaviour. +fn scripted(counter: &RefCell) -> bool { + let mut left = counter.borrow_mut(); + let scripted = *left > 0; + *left = left.saturating_sub(1); + scripted } impl FakePort { @@ -79,12 +100,40 @@ impl FakePort { prompted: RefCell::new(Vec::new()), released: RefCell::new(Vec::new()), rejections: RefCell::new(0), + refusals: RefCell::new(0), + creation_failures: RefCell::new(0), + silent_turns: RefCell::new(0), } } fn reject_next_prompts(&self, count: u32) { *self.rejections.borrow_mut() = count; } + + fn refuse_next_prompts(&self, count: u32) { + *self.refusals.borrow_mut() = count; + } + + fn fail_next_session_creations(&self, count: u32) { + *self.creation_failures.borrow_mut() = count; + } + + fn stay_silent_for_next_turns(&self, count: u32) { + *self.silent_turns.borrow_mut() = count; + } + + async fn stream(&self, session: &AgentSessionId, text: &str) { + let notification = SessionNotification::new( + session.as_str().to_string(), + SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(TextContent::new( + text.to_string(), + )))), + ); + self.renderer + .session_notification(notification) + .await + .expect("renderer accepts notification"); + } } impl trogon_channel::AgentPort for FakePort { @@ -94,8 +143,11 @@ impl trogon_channel::AgentPort for FakePort { &self, _conversation: &trogon_channel::ConversationRecord, ) -> Result { + if scripted(&self.creation_failures) { + return Err(FakeError { session_lost: false }); + } *self.sessions_created.borrow_mut() += 1; - Ok(AgentSessionId::new(format!("sess-{}", self.sessions_created.borrow()))) + Ok(AgentSessionId::new(format!("sess-{}", self.sessions_created.borrow())).expect("session id")) } async fn prompt(&self, session: &AgentSessionId, event: &InboundEvent) -> Result { @@ -103,26 +155,21 @@ impl trogon_channel::AgentPort for FakePort { .borrow_mut() .push((session.as_str().to_string(), event.text.clone().unwrap_or_default())); - let reject = { - let mut left = self.rejections.borrow_mut(); - let reject = *left > 0; - *left = left.saturating_sub(1); - reject - }; - if reject { + // A real agent streams some text before the turn fails, so pipeline + // tests catch a leftover buffer surviving into redelivery. + if scripted(&self.rejections) { + self.stream(session, "partial-").await; return Err(FakeError { session_lost: true }); } + if scripted(&self.refusals) { + self.stream(session, "partial-").await; + return Err(FakeError { session_lost: false }); + } + if scripted(&self.silent_turns) { + return Ok(PromptOutcome::Completed); + } - let notification = SessionNotification::new( - session.as_str().to_string(), - SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(TextContent::new( - self.reply.clone(), - )))), - ); - self.renderer - .session_notification(notification) - .await - .expect("renderer accepts notification"); + self.stream(session, &self.reply).await; Ok(PromptOutcome::Completed) } @@ -145,13 +192,21 @@ struct FakeOutbound { sent: RefCell>, } -impl Outbound for FakeOutbound { - async fn typing(&self, _chat_id: i64) -> anyhow::Result<()> { +impl SendTyping for FakeOutbound { + type Error = std::convert::Infallible; + type Output = (); + + async fn typing(&self, _chat_id: i64) -> Result { *self.typing.borrow_mut() += 1; Ok(()) } +} + +impl SendText for FakeOutbound { + type Error = std::convert::Infallible; + type Message = (); - async fn send_text(&self, chat_id: i64, text: String) -> anyhow::Result<()> { + async fn send_text(&self, chat_id: i64, text: String) -> Result { self.sent.borrow_mut().push((chat_id, text)); Ok(()) } @@ -171,6 +226,40 @@ fn raw_update(update_id: u64, chat_id: i64, user_id: u64, text: &str) -> Vec .expect("serialize update") } +/// A group message, where the chat and the speaker are two different endpoints. +/// That split is the whole reason `sender_is_authorized` exists: authorizing a +/// group chat authorizes everyone who can post in it. +fn raw_group_update(update_id: u64, chat_id: i64, user_id: u64, text: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "update_id": update_id, + "message": { + "message_id": update_id, + "date": 1_700_000_000, + "chat": { "id": chat_id, "type": "group", "title": "Team" }, + "from": { "id": user_id, "is_bot": false, "first_name": "Test" }, + "text": text, + } + })) + .expect("serialize update") +} + +/// An update kind the bridge does not carry. Kept whole on the raw stream, but +/// nothing downstream of `parse` ever sees it. +fn raw_edit(update_id: u64, chat_id: i64, user_id: u64, text: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "update_id": update_id, + "edited_message": { + "message_id": update_id, + "date": 1_700_000_000, + "edit_date": 1_700_000_001, + "chat": { "id": chat_id, "type": "private", "first_name": "Test" }, + "from": { "id": user_id, "is_bot": false, "first_name": "Test" }, + "text": text, + } + })) + .expect("serialize update") +} + /// The next message off the stream. Taken one at a time rather than in a loop /// so a scenario can change the agent's behaviour between messages. async fn next_message(messages: &mut S) -> async_nats::jetstream::Message @@ -205,7 +294,7 @@ async fn settled_consumer_info( /// ever redeemed through it. Mock-backed rather than bucket-backed to keep those /// scenarios off the object store. fn unclaimed_resolver() -> ClaimResolver { - ClaimResolver::new(MockObjectStore::new(), DEFAULT_CLAIM_BUCKET) + ClaimResolver::new(ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default())) } /// The bucket the gateway offloads oversized bodies into, opened the way the @@ -213,10 +302,14 @@ fn unclaimed_resolver() -> ClaimResolver { /// already done so. #[cfg(not(coverage))] async fn claim_resolver(js: &async_nats::jetstream::Context) -> ClaimResolver { - let store = NatsObjectStore::provision_claim_bucket(js, DEFAULT_CLAIM_BUCKET, ClaimRetention::EventSourced) + NatsObjectStore::provision_claim_bucket(js, &ClaimBucket::default(), ClaimRetention::EventSourced) .await .expect("provision claim bucket"); - ClaimResolver::new(store, DEFAULT_CLAIM_BUCKET) + ClaimResolver::new( + NatsObjectStore::bind_claim_bucket(js, ClaimBucket::default()) + .await + .expect("bind claim bucket"), + ) } /// End to end against a real NATS: gateway-shaped raw updates in, identity @@ -387,9 +480,10 @@ async fn pipeline_redeems_a_claim_checked_update() { let claims = claim_resolver(&js).await; let gateway = ClaimCheckPublisher::new( NatsJetStreamClient::new(js.clone()), - NatsObjectStore::bind(&js, DEFAULT_CLAIM_BUCKET) + NatsObjectStore::bind_claim_bucket(&js, ClaimBucket::default()) .await - .expect("bind claim bucket"), + .expect("bind claim bucket") + .into_store(), DEFAULT_CLAIM_BUCKET.to_string(), MaxPayload::from_server_limit(0), ); @@ -470,9 +564,10 @@ async fn pipeline_leaves_an_unredeemable_claim_unacked() { let claims = claim_resolver(&js).await; let gateway = ClaimCheckPublisher::new( NatsJetStreamClient::new(js.clone()), - NatsObjectStore::bind(&js, DEFAULT_CLAIM_BUCKET) + NatsObjectStore::bind_claim_bucket(&js, ClaimBucket::default()) .await - .expect("bind claim bucket"), + .expect("bind claim bucket") + .into_store(), DEFAULT_CLAIM_BUCKET.to_string(), MaxPayload::from_server_limit(0), ); @@ -701,8 +796,288 @@ async fn pipeline_keeps_the_session_when_a_fresh_one_fails_the_same_way() { ); // The rejected message is left for redelivery, so the prompt is retried - // instead of lost to a rotation that did not help. + // instead of lost to a rotation that did not help. Outbound replies stay + // clean: any partial text streamed before the failure was discarded rather + // than joined onto the next successful turn. let info = settled_consumer_info(&stream, "bridge-test", 1).await; assert_eq!(info.num_ack_pending, 1); assert_eq!(info.num_pending, 0); } + +/// Everything the bridge cannot act on is acked and dropped rather than left to +/// redeliver: none of it will parse, authorize, or route any better the second +/// time, so redelivering it would wedge the consumer behind a message that can +/// never succeed. One container for the whole scenario. +#[tokio::test] +async fn pipeline_acks_and_drops_what_no_redelivery_would_fix() { + let server = NatsServer::start().await; + let client = async_nats::connect(&server.url).await.expect("connect"); + let js = async_nats::jetstream::new(client); + + js.create_stream(async_nats::jetstream::stream::Config { + name: "TELEGRAM".to_string(), + subjects: vec!["telegram.>".to_string()], + ..Default::default() + }) + .await + .expect("create TELEGRAM stream"); + + let store = ChannelStore::ensure(&js, "test").await.expect("ensure buckets"); + let principal = PrincipalId::new("telegram-42").expect("principal"); + let endpoint = Endpoint::new("telegram", "mybot", "42").expect("endpoint"); + let group = Endpoint::new("telegram", "mybot", "-1001").expect("group endpoint"); + for linked in [&endpoint, &group] { + store + .link_endpoint(&principal, &PrincipalRecord { display_name: None }, linked) + .await + .expect("seed principal"); + } + + // Not JSON at all: the gateway carries raw bodies, so a malformed one reaches + // the bridge intact and no amount of retrying will make it parse. + js.publish("telegram.message", b"not a telegram update".to_vec().into()) + .await + .expect("publish") + .await + .expect("ack"); + for body in [ + // An update kind the bridge does not carry. + raw_edit(2, 42, 42, "edited"), + // A reset before this endpoint has ever had a session. + raw_update(3, 42, 42, "/new"), + // A group member the chat authorizes but the store does not know: the + // command is refused, and since the trigger was the whole message there + // is nothing left to forward to the agent. + raw_group_update(4, -1001, 777, "/new"), + ] { + js.publish("telegram.message", body.into()) + .await + .expect("publish") + .await + .expect("ack"); + } + + let stream = js.get_stream("TELEGRAM").await.expect("get stream"); + let consumer = stream + .get_or_create_consumer( + "bridge-test", + async_nats::jetstream::consumer::pull::Config { + durable_name: Some("bridge-test".to_string()), + ..Default::default() + }, + ) + .await + .expect("consumer"); + let mut messages = consumer.messages().await.expect("messages"); + + let renderer = Rc::new(TelegramRenderClient::new()); + let port = FakePort::new(renderer.clone(), "hi there"); + let outbound = FakeOutbound::default(); + let triggers = CommandTriggers::default(); + let claims = unclaimed_resolver(); + let pipeline = Pipeline { + store: &store, + port: &port, + renderer: renderer.as_ref(), + outbound: &outbound, + claims: &claims, + bot_account: "mybot", + agent_id: "default", + triggers: &triggers, + ids: &UuidV7Generator, + }; + + for _ in 0..4 { + pipeline + .handle_message(&next_message(&mut messages).await) + .await + .expect("dropped rather than returned as an error"); + } + + // None of the four reached the agent, so nothing opened a session. + assert!(port.prompted.borrow().is_empty()); + assert_eq!(*port.sessions_created.borrow(), 0); + assert!(port.released.borrow().is_empty()); + + // Only the reset from a linked sender is answered. Resetting a conversation + // that has no session is not an error, so it is acknowledged like any other. + assert_eq!( + *outbound.sent.borrow(), + vec![(42, "Started a new session.".to_string())] + ); + + // The conversation the reset created is still bound and still sessionless. + let (_, record) = store + .conversation_for(&endpoint) + .await + .expect("kv read") + .expect("conversation exists"); + assert_eq!(record.current_session, None); + + // The refused command left the group's conversation intact: the sender was + // not authorized for the command, which says nothing about the chat. + assert!(store.conversation_for(&group).await.expect("kv read").is_some()); + + // A bot account that is not an endpoint token can build no sender endpoint + // at all, so it authorizes nobody rather than authorizing everybody. Only + // reachable by calling in directly: `parse::inbound_event` rejects the same + // account earlier, so no update can carry a message this far. + let misconfigured = Pipeline { + bot_account: "my bot", + ..pipeline + }; + let event = InboundEvent { + endpoint: endpoint.clone(), + sender: Sender { + platform_user_id: PlatformUserId::new("42").expect("id"), + display_name: "Test".to_string(), + }, + text: None, + command: Some(Command::NewSession), + attachments: Vec::new(), + message_ref: MessageRef::new("1").expect("message ref"), + occurred_at: 1_700_000_000, + }; + assert!( + !misconfigured + .sender_is_authorized(&event) + .await + .expect("the store is readable; only the endpoint cannot be built"), + "a sender whose endpoint cannot be built must not be authorized" + ); + + let info = settled_consumer_info(&stream, "bridge-test", 0).await; + assert_eq!(info.num_ack_pending, 0); + assert_eq!(info.num_pending, 0); +} + +/// A turn that fails leaves nothing behind for the next one. The agent may have +/// streamed part of a reply before failing, and the message is going to be +/// redelivered, so any buffered text has to be dropped or the retry would send +/// the failed turn's fragment glued to the front of the real answer. One +/// container for the whole scenario. +#[tokio::test] +async fn pipeline_leaves_no_partial_reply_behind_when_a_turn_fails() { + let server = NatsServer::start().await; + let client = async_nats::connect(&server.url).await.expect("connect"); + let js = async_nats::jetstream::new(client); + + js.create_stream(async_nats::jetstream::stream::Config { + name: "TELEGRAM".to_string(), + subjects: vec!["telegram.>".to_string()], + ..Default::default() + }) + .await + .expect("create TELEGRAM stream"); + + let store = ChannelStore::ensure(&js, "test").await.expect("ensure buckets"); + let principal = PrincipalId::new("telegram-42").expect("principal"); + let endpoint = Endpoint::new("telegram", "mybot", "42").expect("endpoint"); + store + .link_endpoint(&principal, &PrincipalRecord { display_name: None }, &endpoint) + .await + .expect("seed principal"); + + for (update_id, text) in [(1u64, "hello"), (2, "refused"), (3, "dead end"), (4, "quiet")] { + js.publish("telegram.message", raw_update(update_id, 42, 42, text).into()) + .await + .expect("publish") + .await + .expect("ack"); + } + + let stream = js.get_stream("TELEGRAM").await.expect("get stream"); + let consumer = stream + .get_or_create_consumer( + "bridge-test", + async_nats::jetstream::consumer::pull::Config { + durable_name: Some("bridge-test".to_string()), + ..Default::default() + }, + ) + .await + .expect("consumer"); + let mut messages = consumer.messages().await.expect("messages"); + + let renderer = Rc::new(TelegramRenderClient::new()); + let port = FakePort::new(renderer.clone(), "hi there"); + let outbound = FakeOutbound::default(); + let triggers = CommandTriggers::default(); + let claims = unclaimed_resolver(); + let pipeline = Pipeline { + store: &store, + port: &port, + renderer: renderer.as_ref(), + outbound: &outbound, + claims: &claims, + bot_account: "mybot", + agent_id: "default", + triggers: &triggers, + ids: &UuidV7Generator, + }; + + pipeline + .handle_message(&next_message(&mut messages).await) + .await + .expect("handled"); + + // An ordinary agent failure, which is not a suspected lost session: the + // conversation must stay on the session it has and simply be retried. + port.refuse_next_prompts(1); + let refused = pipeline.handle_message(&next_message(&mut messages).await).await; + assert!( + matches!(refused, Err(PipelineError::Prompt { .. })), + "an agent failure must surface as a prompt failure: {refused:?}" + ); + + // A suspected lost session with no fresh session to be had. Nothing is + // committed, because nothing answered. + port.reject_next_prompts(1); + port.fail_next_session_creations(1); + let dead_end = pipeline.handle_message(&next_message(&mut messages).await).await; + assert!( + matches!(dead_end, Err(PipelineError::CreateSession(_))), + "a repair with no session to open must surface as a creation failure: {dead_end:?}" + ); + + // An agent that ends a turn without saying anything, which is what acting + // only through tool calls looks like. Acked: the turn did complete. + port.stay_silent_for_next_turns(1); + pipeline + .handle_message(&next_message(&mut messages).await) + .await + .expect("a silent turn is still a completed turn"); + + // The point of the test: every reply the user saw is a whole reply. Neither + // failure sent the `partial-` fragment it streamed, and the silent turn sent + // nothing rather than the fragment left by the turn before it. + assert_eq!(*outbound.sent.borrow(), vec![(42, "hi there".to_string())]); + + // Neither failure rotated the conversation: no session was handed back, none + // was minted beyond the first, and the pointer never moved. + assert!(port.released.borrow().is_empty()); + assert_eq!(*port.sessions_created.borrow(), 1); + assert_eq!( + *port.prompted.borrow(), + vec![ + ("sess-1".to_string(), "hello".to_string()), + ("sess-1".to_string(), "refused".to_string()), + ("sess-1".to_string(), "dead end".to_string()), + ("sess-1".to_string(), "quiet".to_string()), + ] + ); + let (_, record) = store + .conversation_for(&endpoint) + .await + .expect("kv read") + .expect("conversation exists"); + assert_eq!( + record.current_session.as_ref().map(AgentSessionId::as_str), + Some("sess-1") + ); + + // Both failures are left for redelivery; the silent turn is not. + let info = settled_consumer_info(&stream, "bridge-test", 2).await; + assert_eq!(info.num_ack_pending, 2); + assert_eq!(info.num_pending, 0); +} diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs index e2bdd1de59..6717884764 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs @@ -1,5 +1,70 @@ use super::*; use crate::constants::TEXT_CHUNK_LIMIT; +use agent_client_protocol::schema::v1::{ContentChunk, TextContent, ToolCallUpdate, ToolCallUpdateFields}; + +#[test] +fn a_defaulted_client_has_no_buffered_text_for_any_session() { + let client = TelegramRenderClient::default(); + assert_eq!(client.take_buffer("any-session"), None); +} + +/// A chat channel has no interactive permission surface, so the handler must +/// refuse rather than silently grant, regardless of what the agent asked for. +#[tokio::test] +async fn request_permission_is_always_cancelled() { + let client = TelegramRenderClient::new(); + let tool_call = ToolCallUpdate::new("call-1", ToolCallUpdateFields::new()); + let request = RequestPermissionRequest::new("session-1", tool_call, Vec::new()); + + let response = client + .request_permission(request) + .await + .expect("request_permission does not fail"); + + assert_eq!(response.outcome, RequestPermissionOutcome::Cancelled); +} + +/// `ClientHandler` requires `Sync`, so the buffers use a `Mutex`; one session's +/// handler panicking while holding the lock must not poison rendering for every +/// other session. Poison the lock for real (rather than asserting on the +/// recovery closure directly) so this fails if the recovery is ever removed. +#[tokio::test] +async fn a_poisoned_lock_does_not_stop_text_from_accumulating() { + let client = TelegramRenderClient::new(); + + let panicked = std::thread::scope(|scope| { + scope + .spawn(|| { + let _guard = client.buffers.lock().unwrap(); + panic!("poison the buffers lock"); + }) + .join() + }); + assert!(panicked.is_err(), "the spawned thread should have panicked"); + + let session_id = "poisoned-session"; + let chunk = + |text: &str| SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(TextContent::new(text)))); + + client + .session_notification(SessionNotification::new(session_id, chunk("hello "))) + .await + .expect("session_notification recovers from the poisoned lock"); + client + .session_notification(SessionNotification::new(session_id, chunk("world"))) + .await + .expect("session_notification recovers from the poisoned lock"); + + assert_eq!(client.take_buffer(session_id), Some("hello world".to_string())); + assert_eq!(client.take_buffer(session_id), None); + + client + .session_notification(SessionNotification::new(session_id, chunk("leftover"))) + .await + .expect("session_notification recovers from the poisoned lock"); + client.discard(session_id); + assert_eq!(client.take_buffer(session_id), None); +} #[test] fn chunk_text_splits_on_char_boundaries() { diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/tests.rs new file mode 100644 index 0000000000..632837de75 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/tests.rs @@ -0,0 +1,5 @@ +#[test] +#[cfg(coverage)] +fn coverage_main_stub_is_callable() { + super::main(); +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs index 7fe2e2ff8e..2579a17e34 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs @@ -1,27 +1,44 @@ +#[cfg(test)] +#[path = "agent_port_tests.rs"] +mod agent_port_tests; + use crate::conversation::ConversationRecord; +use crate::endpoint::EndpointError; use crate::event::InboundEvent; -use serde::{Deserialize, Serialize}; +use crate::safe_token::SafeToken; +use serde::{Deserialize, Deserializer, Serialize}; /// An agent-side session handle. Opaque to everything except the port /// implementation that minted it: only meaningful at the agent it belongs to, /// which is why a conversation stores it next to (never instead of) the /// agent binding. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct AgentSessionId(String); +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct AgentSessionId(SafeToken); impl AgentSessionId { - pub fn new(id: impl Into) -> Self { - Self(id.into()) + pub fn new(id: impl Into) -> Result { + Ok(Self(SafeToken::new(id)?)) } pub fn as_str(&self) -> &str { - &self.0 + self.0.as_str() } } impl std::fmt::Display for AgentSessionId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) + f.write_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for AgentSessionId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::new(raw).map_err(serde::de::Error::custom) } } diff --git a/rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs new file mode 100644 index 0000000000..582c3a9b96 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs @@ -0,0 +1,32 @@ +use super::*; + +/// A session id is an endpoint token because it becomes part of one: it is +/// stored in `ConversationRecord` and printed back into the pipeline's logs. +#[test] +fn a_session_id_must_be_an_endpoint_token() { + assert_eq!(AgentSessionId::new("sess-1").expect("valid").as_str(), "sess-1"); + assert_eq!( + AgentSessionId::new("sess 1").unwrap_err(), + EndpointError::InvalidCharacter(' ') + ); + assert_eq!(AgentSessionId::new("").unwrap_err(), EndpointError::Empty); +} + +/// Every log line the pipeline writes about a session (`session = %session`) +/// goes through this, so it has to print what the KV store holds. +#[test] +fn a_session_id_displays_as_the_token_it_wraps() { + let session = AgentSessionId::new("sess-1").expect("valid"); + assert_eq!(session.to_string(), session.as_str()); +} + +/// The record is what a restarted bridge reads back, so an id that could not +/// have been constructed must not arrive through JSON either. +#[test] +fn deserializing_a_session_id_rejects_one_the_constructor_would_reject() { + let ok: AgentSessionId = serde_json::from_str(r#""sess-1""#).expect("valid session id"); + assert_eq!(ok.as_str(), "sess-1"); + + let err = serde_json::from_str::(r#""sess 1""#).expect_err("unsafe id must not deserialize"); + assert!(err.to_string().contains("invalid character"), "{err}"); +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/command.rs b/rsworkspace/crates/channel/trogon-channel/src/command.rs index 92968848fb..aa7ff7ba18 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/command.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/command.rs @@ -2,6 +2,9 @@ #[path = "command_tests.rs"] mod command_tests; +use crate::CommandTrigger; +use crate::CommandTriggerInput; +pub use crate::command_trigger::CommandTriggerError; use serde::{Deserialize, Serialize}; /// A bridge-level instruction recognized in message text. Commands are @@ -16,26 +19,21 @@ pub enum Command { NewSession, } -#[derive(Debug, thiserror::Error)] -pub enum CommandTriggerError { - #[error("command trigger must not be empty")] - Empty, - #[error("command trigger {0:?} must be a single token")] - NotASingleToken(String), -} - /// The trigger vocabulary a bridge recognizes, matched against the whole first /// token of a message. Configurable because the leading marker is a channel /// affordance rather than a domain concept. #[derive(Debug, Clone)] pub struct CommandTriggers { - new_session: Vec, + new_session: Vec, } impl Default for CommandTriggers { fn default() -> Self { Self { - new_session: vec!["/new".to_string(), "/reset".to_string()], + new_session: vec![ + CommandTrigger::try_from(CommandTriggerInput::new("/new")).expect("/new"), + CommandTrigger::try_from(CommandTriggerInput::new("/reset")).expect("/reset"), + ], } } } @@ -50,19 +48,14 @@ pub struct ParsedText { } impl CommandTriggers { - pub fn new(new_session: impl IntoIterator) -> Result { + pub fn new(new_session: I) -> Result + where + I: IntoIterator, + T: Into, + { let new_session = new_session .into_iter() - .map(|trigger| { - let trigger = trigger.trim().to_ascii_lowercase(); - if trigger.is_empty() { - return Err(CommandTriggerError::Empty); - } - if trigger.split_whitespace().count() != 1 { - return Err(CommandTriggerError::NotASingleToken(trigger)); - } - Ok(trigger) - }) + .map(|trigger| CommandTrigger::try_from(trigger.into())) .collect::, _>>()?; Ok(Self { new_session }) } @@ -70,18 +63,29 @@ impl CommandTriggers { /// Split a message into its command (if the first token is a trigger) and /// the remaining text, which becomes the first prompt of whatever the /// command sets up. - pub fn parse(&self, text: &str) -> ParsedText { + /// + /// `recipient_account` is this bridge's account on the channel. Channels + /// let a user address a command to one bot among several by suffixing it + /// (`/new@somebot`); only an absent suffix or a suffix that matches this + /// account is recognized. + pub fn parse(&self, text: &str, recipient_account: &str) -> ParsedText { let trimmed = text.trim_start(); let (head, rest) = match trimmed.find(char::is_whitespace) { Some(index) => (&trimmed[..index], trimmed[index..].trim()), None => (trimmed, ""), }; - // Channels let a user address a command to one bot account among - // several by suffixing it (`/new@somebot`). The suffix selects the - // recipient and is not part of the trigger. - let token = head.split('@').next().unwrap_or(head).to_ascii_lowercase(); - let command = self.new_session.contains(&token).then_some(Command::NewSession); + let (token, addressed_to) = match head.split_once('@') { + Some((token, account)) => (token, Some(account)), + None => (head, None), + }; + let token = token.to_ascii_lowercase(); + let addressed_to_us = match addressed_to { + None => true, + Some(account) => account.eq_ignore_ascii_case(recipient_account), + }; + let command = + (addressed_to_us && self.new_session.iter().any(|t| t.as_str() == token)).then_some(Command::NewSession); let body = match command { Some(_) => rest, diff --git a/rsworkspace/crates/channel/trogon-channel/src/command_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/command_tests.rs index 298d7f566a..f8def806c0 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/command_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/command_tests.rs @@ -2,61 +2,69 @@ use super::*; #[test] fn bare_trigger_yields_a_command_and_no_body() { - let parsed = CommandTriggers::default().parse("/new"); + let parsed = CommandTriggers::default().parse("/new", "mybot"); assert_eq!(parsed.command, Some(Command::NewSession)); assert_eq!(parsed.body, None); } #[test] fn trailing_text_becomes_the_body() { - let parsed = CommandTriggers::default().parse("/reset ship the thing "); + let parsed = CommandTriggers::default().parse("/reset ship the thing ", "mybot"); assert_eq!(parsed.command, Some(Command::NewSession)); assert_eq!(parsed.body.as_deref(), Some("ship the thing")); } #[test] fn account_suffix_and_case_do_not_defeat_the_trigger() { - let parsed = CommandTriggers::default().parse("/New@SomeBot hello"); + let parsed = CommandTriggers::default().parse("/New@SomeBot hello", "SomeBot"); assert_eq!(parsed.command, Some(Command::NewSession)); assert_eq!(parsed.body.as_deref(), Some("hello")); } +#[test] +fn a_command_addressed_to_another_account_is_not_recognized() { + let parsed = CommandTriggers::default().parse("/new@otherbot", "mybot"); + assert_eq!(parsed.command, None); + assert_eq!(parsed.body.as_deref(), Some("/new@otherbot")); + + let parsed = CommandTriggers::default().parse("/new@otherbot hello", "mybot"); + assert_eq!(parsed.command, None); + assert_eq!(parsed.body.as_deref(), Some("/new@otherbot hello")); +} + #[test] fn a_trigger_that_is_only_a_prefix_of_the_token_is_not_a_command() { - let parsed = CommandTriggers::default().parse("/newsletter please"); + let parsed = CommandTriggers::default().parse("/newsletter please", "mybot"); assert_eq!(parsed.command, None); assert_eq!(parsed.body.as_deref(), Some("/newsletter please")); } #[test] fn ordinary_text_passes_through_unchanged() { - let parsed = CommandTriggers::default().parse(" keep my spacing "); + let parsed = CommandTriggers::default().parse(" keep my spacing ", "mybot"); assert_eq!(parsed.command, None); assert_eq!(parsed.body.as_deref(), Some(" keep my spacing ")); } #[test] fn a_trigger_in_the_middle_is_not_a_command() { - let parsed = CommandTriggers::default().parse("say /new out loud"); + let parsed = CommandTriggers::default().parse("say /new out loud", "mybot"); assert_eq!(parsed.command, None); assert_eq!(parsed.body.as_deref(), Some("say /new out loud")); } #[test] fn triggers_are_configurable() { - let triggers = CommandTriggers::new(["!Rotate".to_string()]).expect("valid triggers"); - assert_eq!(triggers.parse("!rotate").command, Some(Command::NewSession)); - assert_eq!(triggers.parse("/new").command, None); + let triggers = CommandTriggers::new(["!Rotate"]).expect("valid triggers"); + assert_eq!(triggers.parse("!rotate", "mybot").command, Some(Command::NewSession)); + assert_eq!(triggers.parse("/new", "mybot").command, None); } #[test] fn blank_and_multi_token_triggers_are_rejected() { + assert!(matches!(CommandTriggers::new([" "]), Err(CommandTriggerError::Empty))); assert!(matches!( - CommandTriggers::new([" ".to_string()]), - Err(CommandTriggerError::Empty) - )); - assert!(matches!( - CommandTriggers::new(["/new session".to_string()]), - Err(CommandTriggerError::NotASingleToken(_)) + CommandTriggers::new(["/new session"]), + Err(CommandTriggerError::MultipleTokens) )); } diff --git a/rsworkspace/crates/channel/trogon-channel/src/command_trigger.rs b/rsworkspace/crates/channel/trogon-channel/src/command_trigger.rs new file mode 100644 index 0000000000..5b38fb6836 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/command_trigger.rs @@ -0,0 +1,38 @@ +//! A validated command trigger token. + +use crate::CommandTriggerInput; + +/// Why a [`CommandTriggerInput`] could not become a [`CommandTrigger`]. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum CommandTriggerError { + #[error("command trigger must not be empty")] + Empty, + #[error("command trigger must be a single token")] + MultipleTokens, +} + +/// One normalized trigger matched against the first token of a message. +/// Guarantees a non-empty, single-token, lowercased value at construction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandTrigger(String); + +impl CommandTrigger { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for CommandTrigger { + type Error = CommandTriggerError; + + fn try_from(input: CommandTriggerInput) -> Result { + let trigger = input.as_str().trim().to_ascii_lowercase(); + if trigger.is_empty() { + return Err(CommandTriggerError::Empty); + } + if trigger.split_whitespace().count() != 1 { + return Err(CommandTriggerError::MultipleTokens); + } + Ok(Self(trigger)) + } +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/command_trigger_input.rs b/rsworkspace/crates/channel/trogon-channel/src/command_trigger_input.rs new file mode 100644 index 0000000000..6b25ceed1b --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/command_trigger_input.rs @@ -0,0 +1,32 @@ +//! Untrusted command-trigger text before validation. + +#[cfg(test)] +#[path = "command_trigger_input_tests.rs"] +mod command_trigger_input_tests; + +/// Raw trigger text from config or another boundary. Convert once into +/// [`crate::CommandTrigger`]. +#[derive(Debug, Clone)] +pub struct CommandTriggerInput(String); + +impl CommandTriggerInput { + pub fn new(raw: impl Into) -> Self { + Self(raw.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From for CommandTriggerInput { + fn from(raw: String) -> Self { + Self(raw) + } +} + +impl From<&str> for CommandTriggerInput { + fn from(raw: &str) -> Self { + Self(raw.to_string()) + } +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/command_trigger_input_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/command_trigger_input_tests.rs new file mode 100644 index 0000000000..828b3ee858 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/command_trigger_input_tests.rs @@ -0,0 +1,16 @@ +use super::*; + +/// Config reaches this type as an owned `String` from the environment and as a +/// `&str` from literals in tests and defaults, so both conversions are on the +/// path a deployment takes and neither may trim, lower-case, or otherwise +/// pre-judge text that [`crate::CommandTrigger`] is the one to validate. +#[test] +fn a_trigger_arrives_unaltered_from_either_kind_of_string() { + for input in [ + CommandTriggerInput::from("/New ".to_string()), + CommandTriggerInput::from("/New "), + CommandTriggerInput::new("/New "), + ] { + assert_eq!(input.as_str(), "/New "); + } +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/conversation.rs b/rsworkspace/crates/channel/trogon-channel/src/conversation.rs index 47387119a1..5f196298af 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/conversation.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/conversation.rs @@ -3,33 +3,46 @@ mod conversation_tests; use crate::agent_port::AgentSessionId; -use crate::endpoint::PrincipalId; -use serde::{Deserialize, Serialize}; +use crate::endpoint::{EndpointError, PrincipalId}; +use crate::safe_token::SafeToken; +use serde::{Deserialize, Deserializer, Serialize}; use trogon_std::NowV7; /// Which configured agent a conversation is bound to. Resolution from id to /// protocol + address is bridge/router configuration, never stored here. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct AgentId(String); +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct AgentId(SafeToken); impl AgentId { - pub fn new(id: impl Into) -> Self { - Self(id.into()) + pub fn new(id: impl Into) -> Result { + Ok(Self(SafeToken::new(id)?)) } pub fn as_str(&self) -> &str { - &self.0 + self.0.as_str() } } impl std::fmt::Display for AgentId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) + f.write_str(self.as_str()) } } -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ConversationId(String); +impl<'de> Deserialize<'de> for AgentId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::new(raw).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct ConversationId(SafeToken); impl ConversationId { /// Opaque and time-ordered: this doubles as the conversation KV key, so @@ -37,21 +50,31 @@ impl ConversationId { /// for the same reason `ConversationRecord::created_at` is: no ambient /// clock in this crate. pub fn generate(ids: &impl NowV7) -> Self { - Self(ids.now_v7().simple().to_string()) + Self(SafeToken::new(ids.now_v7().simple().to_string()).expect("uuid v7 simple form is a safe token")) } - pub fn from_string(id: impl Into) -> Self { - Self(id.into()) + pub fn from_string(id: impl Into) -> Result { + Ok(Self(SafeToken::new(id)?)) } pub fn as_str(&self) -> &str { - &self.0 + self.0.as_str() } } impl std::fmt::Display for ConversationId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) + f.write_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ConversationId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::from_string(raw).map_err(serde::de::Error::custom) } } diff --git a/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs index 25310ac225..af6aa5ec12 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::agent_port::AgentSessionId; use trogon_std::UuidV7Generator; #[test] @@ -15,3 +16,83 @@ fn generated_ids_sort_in_creation_order() { let second = ConversationId::generate(&UuidV7Generator); assert!(first.as_str() < second.as_str()); } + +#[test] +fn conversation_id_from_string_round_trips_the_given_id() { + let id = ConversationId::from_string("some-opaque-id").expect("valid"); + assert_eq!(id.as_str(), "some-opaque-id"); +} + +#[test] +fn conversation_id_display_renders_the_bare_id() { + let id = ConversationId::from_string("some-opaque-id").expect("valid"); + assert_eq!(id.to_string(), "some-opaque-id"); +} + +#[test] +fn conversation_id_rejects_unsafe_tokens() { + assert_eq!( + ConversationId::from_string("a.b").unwrap_err(), + EndpointError::InvalidCharacter('.') + ); + assert_eq!(ConversationId::from_string("").unwrap_err(), EndpointError::Empty); +} + +#[test] +fn conversation_id_deserialize_rejects_unsafe_tokens() { + let err = serde_json::from_str::("\"a.b\"").expect_err("dot is unsafe"); + assert!(err.to_string().contains("invalid character"), "{err}"); +} + +#[test] +fn agent_id_as_str_returns_the_constructed_id() { + let agent = AgentId::new("sales-agent").expect("valid"); + assert_eq!(agent.as_str(), "sales-agent"); +} + +#[test] +fn agent_id_display_renders_the_bare_id() { + let agent = AgentId::new("sales-agent").expect("valid"); + assert_eq!(agent.to_string(), "sales-agent"); +} + +#[test] +fn agent_id_rejects_unsafe_tokens() { + assert_eq!( + AgentId::new("sales.agent").unwrap_err(), + EndpointError::InvalidCharacter('.') + ); +} + +#[test] +fn agent_id_deserialize_rejects_unsafe_tokens() { + let err = serde_json::from_str::("\"sales.agent\"").expect_err("dot is unsafe"); + assert!(err.to_string().contains("invalid character"), "{err}"); +} + +#[test] +fn agent_session_id_rejects_unsafe_tokens() { + assert_eq!( + AgentSessionId::new("sess.1").unwrap_err(), + EndpointError::InvalidCharacter('.') + ); +} + +#[test] +fn agent_session_id_deserialize_rejects_unsafe_tokens() { + let err = serde_json::from_str::("\"sess.1\"").expect_err("dot is unsafe"); + assert!(err.to_string().contains("invalid character"), "{err}"); +} + +#[test] +fn conversation_record_deserialize_rejects_a_corrupt_agent_id() { + let err = serde_json::from_value::(serde_json::json!({ + "principal": "user-1", + "agent_id": "bad.id", + "current_session": null, + "created_at": 1, + "last_activity_at": 1, + })) + .expect_err("corrupt agent_id"); + assert!(err.to_string().contains("invalid character"), "{err}"); +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs index 6346838007..a8dbf6645a 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs @@ -2,61 +2,68 @@ #[path = "endpoint_tests.rs"] mod endpoint_tests; -use serde::{Deserialize, Serialize}; - -/// Characters permitted in endpoint tokens. Tokens are joined with `.` into -/// one composite key, so `.` is out; the rest is the intersection of what NATS -/// KV keys and NATS subject tokens accept, which keeps the composite usable as -/// either without re-encoding. -fn is_safe_token(token: &str) -> bool { - !token.is_empty() - && token - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '=')) -} +use crate::safe_token::{SafeToken, SafeTokenError}; +use serde::{Deserialize, Deserializer, Serialize}; +/// Why an endpoint or principal identifier could not be constructed. #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum EndpointError { - #[error("endpoint token {0:?} is empty or contains unsafe characters")] - UnsafeToken(String), + #[error("token must not be empty")] + Empty, + #[error("token contains invalid character: {0:?}")] + InvalidCharacter(char), } -/// Where a message arrives and leaves: a platform, a bot account on it, and a -/// peer on that platform. Many endpoints can point at one conversation. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct Endpoint { +impl From for EndpointError { + fn from(error: SafeTokenError) -> Self { + match error { + SafeTokenError::Empty => Self::Empty, + SafeTokenError::InvalidCharacter(c) => Self::InvalidCharacter(c), + } + } +} + +/// Wire shape for an [`Endpoint`]. Converted through [`Endpoint::new`] so each +/// token is validated independently before the domain value exists. +#[derive(Debug, Deserialize)] +struct EndpointWire { channel: String, account: String, peer: String, } +/// Where a message arrives and leaves: a platform, a bot account on it, and a +/// peer on that platform. Many endpoints can point at one conversation. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct Endpoint { + channel: SafeToken, + account: SafeToken, + peer: SafeToken, +} + impl Endpoint { pub fn new( channel: impl Into, account: impl Into, peer: impl Into, ) -> Result { - let channel = channel.into(); - let account = account.into(); - let peer = peer.into(); - for token in [&channel, &account, &peer] { - if !is_safe_token(token) { - return Err(EndpointError::UnsafeToken(token.clone())); - } - } - Ok(Self { channel, account, peer }) + Ok(Self { + channel: SafeToken::new(channel)?, + account: SafeToken::new(account)?, + peer: SafeToken::new(peer)?, + }) } pub fn channel(&self) -> &str { - &self.channel + self.channel.as_str() } pub fn account(&self) -> &str { - &self.account + self.account.as_str() } pub fn peer(&self) -> &str { - &self.peer + self.peer.as_str() } /// Stable KV key for this endpoint (also a valid subject suffix). @@ -65,6 +72,16 @@ impl Endpoint { } } +impl<'de> Deserialize<'de> for Endpoint { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = EndpointWire::deserialize(deserializer)?; + Self::new(wire.channel, wire.account, wire.peer).map_err(serde::de::Error::custom) + } +} + impl std::fmt::Display for Endpoint { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.kv_key()) @@ -74,25 +91,32 @@ impl std::fmt::Display for Endpoint { /// The human behind one or more endpoints. Cross-channel by design: linking a /// Telegram user and a Discord user to the same principal is what lets one /// conversation continue across channels. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct PrincipalId(String); +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct PrincipalId(SafeToken); impl PrincipalId { pub fn new(id: impl Into) -> Result { - let id = id.into(); - if !is_safe_token(&id) { - return Err(EndpointError::UnsafeToken(id)); - } - Ok(Self(id)) + Ok(Self(SafeToken::new(id)?)) } pub fn as_str(&self) -> &str { - &self.0 + self.0.as_str() + } +} + +impl<'de> Deserialize<'de> for PrincipalId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::new(raw).map_err(serde::de::Error::custom) } } impl std::fmt::Display for PrincipalId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) + f.write_str(self.as_str()) } } diff --git a/rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs index a75f09eb1a..ced55e4ef8 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs @@ -8,7 +8,74 @@ fn endpoint_accepts_negative_telegram_chat_ids() { #[test] fn endpoint_rejects_unsafe_tokens() { - assert!(Endpoint::new("telegram", "my bot", "1").is_err()); - assert!(Endpoint::new("", "mybot", "1").is_err()); - assert!(Endpoint::new("telegram", "mybot", "a.b").is_err()); + assert_eq!( + Endpoint::new("telegram", "my bot", "1").unwrap_err(), + EndpointError::InvalidCharacter(' ') + ); + assert_eq!(Endpoint::new("", "mybot", "1").unwrap_err(), EndpointError::Empty); + assert_eq!( + Endpoint::new("telegram", "mybot", "a.b").unwrap_err(), + EndpointError::InvalidCharacter('.') + ); +} + +#[test] +fn endpoint_accessors_each_return_their_own_token() { + let e = Endpoint::new("telegram", "mybot", "42").expect("valid"); + assert_eq!(e.channel(), "telegram"); + assert_eq!(e.account(), "mybot"); + assert_eq!(e.peer(), "42"); +} + +/// Display must go through kv_key(), not a field dump, so it is a valid +/// composite key wherever an endpoint is formatted as a string. +#[test] +fn endpoint_display_renders_the_dotted_composite_key() { + let e = Endpoint::new("telegram", "mybot", "42").expect("valid"); + assert_eq!(e.to_string(), "telegram.mybot.42"); + assert_eq!(e.to_string(), e.kv_key()); +} + +#[test] +fn endpoint_deserialize_rejects_an_unsafe_token() { + let err = serde_json::from_value::(serde_json::json!({ + "channel": "telegram", + "account": "my bot", + "peer": "1", + })) + .expect_err("space is unsafe"); + assert!(err.to_string().contains("invalid character"), "{err}"); +} + +#[test] +fn principal_id_rejects_an_empty_id() { + let err = PrincipalId::new("").unwrap_err(); + assert_eq!(err, EndpointError::Empty); +} + +/// A `.` is the interesting rejection case: tokens are joined with `.` into +/// the composite key, so allowing it in a principal id would make the key +/// ambiguous to split back apart. +#[test] +fn principal_id_rejects_a_dot() { + let err = PrincipalId::new("abc.def").unwrap_err(); + assert_eq!(err, EndpointError::InvalidCharacter('.')); +} + +#[test] +fn principal_id_as_str_returns_the_constructed_id() { + let id = PrincipalId::new("user-42").expect("valid"); + assert_eq!(id.as_str(), "user-42"); +} + +#[test] +fn principal_id_display_renders_the_bare_id() { + let id = PrincipalId::new("user-42").expect("valid"); + assert_eq!(id.to_string(), "user-42"); +} + +#[test] +fn principal_id_deserialize_rejects_an_unsafe_token() { + let err = serde_json::from_str::("\"abc.def\"").expect_err("dot is unsafe"); + assert!(err.to_string().contains("invalid character"), "{err}"); } diff --git a/rsworkspace/crates/channel/trogon-channel/src/event.rs b/rsworkspace/crates/channel/trogon-channel/src/event.rs index 674ad7015c..3ecbd6b453 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event.rs @@ -1,28 +1,242 @@ +#[cfg(test)] +#[path = "event_tests.rs"] +mod event_tests; + use crate::command::Command; -use crate::endpoint::Endpoint; -use serde::{Deserialize, Serialize}; +use crate::endpoint::{Endpoint, EndpointError}; +use crate::safe_token::SafeToken; +use serde::{Deserialize, Deserializer, Serialize}; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum EventFieldError { + #[error("a message reference may not be blank")] + BlankMessageRef, + #[error("media type {0:?} is not a type/subtype pair")] + NotAMediaType(String), +} +/// Who sent a message, in the sending platform's own terms. This is an +/// identity, not an address: it says nothing about where a reply goes, which is +/// what [`Endpoint`] is for. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Sender { - pub platform_user_id: String, + pub platform_user_id: PlatformUserId, + /// Free-form, as the platform renders it. Carries no invariant on purpose: + /// it exists to be shown to the agent and never to be matched on. pub display_name: String, } +/// The sender's id on its platform. Constrained to an endpoint token because +/// that is what it becomes: a sender is authorized by building the endpoint +/// `{channel}.{account}.{platform_user_id}` and looking up its principal, so an +/// id that cannot be a token could never be authorized anyway. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct PlatformUserId(SafeToken); + +impl PlatformUserId { + pub fn new(id: impl Into) -> Result { + Ok(Self(SafeToken::new(id)?)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +/// A platform that numbers its users hands the id over as an integer, and every +/// integer is already a token, so this path has no failure to report. +impl From for PlatformUserId { + fn from(id: u64) -> Self { + Self(SafeToken::from(id)) + } +} + +impl std::fmt::Display for PlatformUserId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for PlatformUserId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::new(raw).map_err(serde::de::Error::custom) + } +} + +/// A platform's own identifier for one message, used to recognize a message the +/// bridge has already handled and to address edits and reactions back at it. +/// Opaque: only equality and round-tripping are ever asked of it, so the single +/// invariant is that it is not blank. Deliberately looser than an endpoint +/// token, because message ids elsewhere are not tokens (an email `Message-ID` +/// carries `@` and `.`) and this type is channel-neutral. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct MessageRef(String); + +impl MessageRef { + pub fn new(reference: impl Into) -> Result { + let reference = reference.into(); + if reference.trim().is_empty() { + return Err(EventFieldError::BlankMessageRef); + } + Ok(Self(reference)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// A platform that numbers its messages hands the id over as an integer, whose +/// decimal form is never blank, so this path has no failure to report either. +impl From for MessageRef { + fn from(id: i64) -> Self { + Self(id.to_string()) + } +} + +impl std::fmt::Display for MessageRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for MessageRef { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::new(raw).map_err(serde::de::Error::custom) + } +} + +/// What kind of media arrived, in the channel-neutral vocabulary. A closed set +/// for the same reason [`crate::RenderCommand`] is one: a bridge must be able to +/// render every kind, so a kind no bridge knows is not a kind. A platform +/// distinction this cannot express (a Telegram voice note versus an audio file) +/// is either mapped onto the nearest kind or carried in agent `_meta`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AttachmentKind { + Image, + Video, + Audio, + /// Speech recorded in the client, which platforms treat as its own kind + /// because it is transcribable rather than merely playable. + Voice, + Document, +} + +/// An IANA media type, normalized to lower case because the standard defines +/// type and subtype as case-insensitive and a caller comparing them as bytes +/// would otherwise be wrong for `IMAGE/PNG`. Parameters are kept as given. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct MimeType(String); + +impl MimeType { + pub fn new(raw: impl Into) -> Result { + let raw = raw.into(); + let trimmed = raw.trim(); + let (kind, subtype) = trimmed + .split_once('/') + .ok_or_else(|| EventFieldError::NotAMediaType(raw.clone()))?; + let subtype_only = subtype.split(';').next().unwrap_or_default().trim(); + if kind.is_empty() || subtype_only.is_empty() || trimmed.chars().any(char::is_whitespace) { + return Err(EventFieldError::NotAMediaType(raw)); + } + Ok(Self(trimmed.to_ascii_lowercase())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for MimeType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for MimeType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::new(raw).map_err(serde::de::Error::custom) + } +} + +/// The platform's handle for a file, redeemable for bytes only by the channel +/// that issued it (e.g. a Telegram `file_id`). Constrained to an endpoint token +/// because it is also a KV key: readiness for the fetch lives at this handle in +/// `channel_media_{prefix}`, so a handle that is not a safe key has nowhere to +/// report. See ADR#0044. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct PlatformRef(SafeToken); + +impl PlatformRef { + pub fn new(reference: impl Into) -> Result { + Ok(Self(SafeToken::new(reference)?)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// KV key for this handle's readiness record in `channel_media_{prefix}`. + pub fn kv_key(&self) -> &str { + self.0.as_str() + } +} + +impl std::fmt::Display for PlatformRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for PlatformRef { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::new(raw).map_err(serde::de::Error::custom) + } +} + /// Media that arrived with a message, as a handle rather than as bytes. /// `platform_ref` is the platform's own reference (e.g. a Telegram `file_id`); /// redeeming it happens out of band, so this type never asserts that bytes /// exist yet. See ADR#0044. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Attachment { - pub kind: String, - pub mime: String, + pub kind: AttachmentKind, + pub mime: MimeType, + /// Bytes, as the platform reports them before the fetch. Advisory: the + /// downloader reports the size it actually stored. pub size: u64, - pub platform_ref: String, + pub platform_ref: PlatformRef, } /// A normalized inbound message: what any channel bridge produces after /// stripping its platform's shape. Travels in process; `Serialize` because the /// shape is the cross-channel contract, not because anything publishes it. +/// +/// Every field that carries an invariant is a value object that enforces it at +/// construction, which is also what makes `Deserialize` safe here: there is no +/// path that turns channel-provided JSON into an unchecked field, so the type +/// needs no separate wire twin. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InboundEvent { pub endpoint: Endpoint, @@ -38,7 +252,9 @@ pub struct InboundEvent { #[serde(default)] pub attachments: Vec, /// Platform message identity, for dedup, replies, and edits. - pub message_ref: String, - /// Unix seconds, as reported by the platform. + pub message_ref: MessageRef, + /// Unix seconds, as reported by the platform. A bare integer for the same + /// reason `ConversationRecord::created_at` is: this crate takes no clock, + /// and the whole crate spells timestamps one way. pub occurred_at: i64, } diff --git a/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs new file mode 100644 index 0000000000..e597c986ed --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs @@ -0,0 +1,171 @@ +use super::*; + +#[test] +fn a_platform_user_id_must_be_an_endpoint_token() { + assert_eq!(PlatformUserId::new("42").expect("valid").as_str(), "42"); + assert_eq!( + PlatformUserId::new("user id").unwrap_err(), + EndpointError::InvalidCharacter(' ') + ); + // A `.` would split the composite endpoint key it becomes part of. + assert_eq!( + PlatformUserId::new("4.2").unwrap_err(), + EndpointError::InvalidCharacter('.') + ); + assert_eq!(PlatformUserId::new("").unwrap_err(), EndpointError::Empty); +} + +/// The point of the type: channel-provided JSON cannot produce an id the +/// constructor would have rejected, which is what makes deriving `Deserialize` +/// on `InboundEvent` safe without a separate wire twin. +#[test] +fn deserializing_a_sender_rejects_an_id_the_constructor_would_reject() { + let ok: Sender = serde_json::from_str(r#"{"platform_user_id":"42","display_name":"Ada"}"#).expect("valid sender"); + assert_eq!(ok.platform_user_id.as_str(), "42"); + + let err = serde_json::from_str::(r#"{"platform_user_id":"user id","display_name":"Ada"}"#) + .expect_err("unsafe id must not deserialize"); + assert!(err.to_string().contains("invalid character"), "{err}"); +} + +/// A display name is shown, never matched on, so it holds no invariant: names +/// really do contain spaces, dots, and emoji. +#[test] +fn a_display_name_is_free_form() { + let sender: Sender = + serde_json::from_str(r#"{"platform_user_id":"42","display_name":"Ada L. 👋"}"#).expect("valid sender"); + assert_eq!(sender.display_name, "Ada L. 👋"); +} + +#[test] +fn a_message_ref_only_rejects_blankness() { + assert_eq!(MessageRef::new("1").expect("valid").as_str(), "1"); + assert_eq!(MessageRef::new(" ").unwrap_err(), EventFieldError::BlankMessageRef); + assert_eq!(MessageRef::new("").unwrap_err(), EventFieldError::BlankMessageRef); +} + +/// Looser than an endpoint token on purpose: this type is channel-neutral, and +/// message ids on other channels are not tokens. +#[test] +fn a_message_ref_accepts_an_email_style_id() { + let reference = MessageRef::new("").expect("valid"); + assert_eq!(reference.as_str(), ""); +} + +#[test] +fn a_media_type_normalizes_case_so_comparisons_hold() { + assert_eq!(MimeType::new("IMAGE/PNG").expect("valid").as_str(), "image/png"); + assert_eq!(MimeType::new(" image/png ").expect("valid").as_str(), "image/png"); + assert_eq!( + MimeType::new("image/png").expect("valid"), + MimeType::new("Image/PNG").expect("valid") + ); +} + +#[test] +fn a_media_type_needs_a_type_and_a_subtype() { + for raw in ["image", "image/", "/png", "", "image / png"] { + assert!( + matches!(MimeType::new(raw), Err(EventFieldError::NotAMediaType(_))), + "{raw:?} must not be a media type" + ); + } +} + +#[test] +fn a_media_type_keeps_parameters() { + assert_eq!( + MimeType::new("text/plain;charset=utf-8").expect("valid").as_str(), + "text/plain;charset=utf-8" + ); +} + +/// The handle doubles as the readiness key in `channel_media_{prefix}` +/// (ADR#0044), so it has to be safe as a KV key. +#[test] +fn a_platform_ref_must_be_usable_as_a_kv_key() { + let handle = PlatformRef::new("AgACAgQAAx0-Ef_9").expect("valid"); + assert_eq!(handle.kv_key(), "AgACAgQAAx0-Ef_9"); + assert!(PlatformRef::new("has space").is_err()); + assert!(PlatformRef::new("has.dot").is_err()); + assert!(PlatformRef::new("").is_err()); +} + +/// Why `parse` carries no error arm for a numeric id: a platform that numbers +/// its users and messages can only ever produce a token, so the checked path and +/// the unchecked one have to agree for every integer either could see. +#[test] +fn a_numeric_platform_id_needs_no_validation() { + assert_eq!(PlatformUserId::from(u64::MAX).as_str(), u64::MAX.to_string()); + assert_eq!( + PlatformUserId::from(42), + PlatformUserId::new("42").expect("the checked path agrees") + ); + + // A message id may be negative, and `-` is an allowed character. + assert_eq!(MessageRef::from(i64::MIN).as_str(), i64::MIN.to_string()); + assert_eq!( + MessageRef::from(-7), + MessageRef::new("-7").expect("the checked path agrees") + ); +} + +/// Each of these is printed next to the key or id it came from: the pipeline +/// logs `sender = %event.sender.platform_user_id`, and `parse` logs the handle +/// it could not encode. A `Display` that diverged from `as_str` would leave an +/// operator grepping the KV store for a value that was never printed. +#[test] +fn a_value_object_displays_as_the_scalar_it_wraps() { + assert_eq!(PlatformUserId::new("42").expect("id").to_string(), "42"); + assert_eq!(MessageRef::new("7").expect("ref").to_string(), "7"); + assert_eq!(MimeType::new("IMAGE/PNG").expect("mime").to_string(), "image/png"); + + let handle = PlatformRef::new("file-abc").expect("handle"); + assert_eq!(handle.as_str(), "file-abc"); + assert_eq!(handle.to_string(), handle.kv_key()); +} + +#[test] +fn an_attachment_kind_is_a_closed_set() { + let kind: AttachmentKind = serde_json::from_str(r#""voice""#).expect("known kind"); + assert_eq!(kind, AttachmentKind::Voice); + assert_eq!( + serde_json::to_string(&AttachmentKind::Document).expect("json"), + r#""document""# + ); + assert!(serde_json::from_str::(r#""hologram""#).is_err()); +} + +/// The value objects serialize as the bare scalars they wrap, so the `_meta` +/// shape the architecture doc documents is unchanged by introducing them. +#[test] +fn an_event_serializes_its_value_objects_transparently() { + let event = InboundEvent { + endpoint: Endpoint::new("telegram", "mybot", "42").expect("endpoint"), + sender: Sender { + platform_user_id: PlatformUserId::new("42").expect("id"), + display_name: "Ada".to_string(), + }, + text: Some("hello".to_string()), + command: None, + attachments: vec![Attachment { + kind: AttachmentKind::Image, + mime: MimeType::new("image/png").expect("mime"), + size: 1024, + platform_ref: PlatformRef::new("file-abc").expect("handle"), + }], + message_ref: MessageRef::new("7").expect("ref"), + occurred_at: 1_700_000_000, + }; + + let json = serde_json::to_value(&event).expect("serialize"); + assert_eq!(json["sender"]["platform_user_id"], "42"); + assert_eq!(json["message_ref"], "7"); + assert_eq!(json["attachments"][0]["kind"], "image"); + assert_eq!(json["attachments"][0]["mime"], "image/png"); + assert_eq!(json["attachments"][0]["platform_ref"], "file-abc"); + + let round_tripped: InboundEvent = serde_json::from_value(json).expect("deserialize"); + assert_eq!(round_tripped.message_ref, event.message_ref); + assert_eq!(round_tripped.attachments[0].mime, event.attachments[0].mime); +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/lib.rs b/rsworkspace/crates/channel/trogon-channel/src/lib.rs index 70973c96c4..105fefe564 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/lib.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/lib.rs @@ -17,18 +17,27 @@ pub mod agent_port; pub mod command; +pub mod command_trigger; +pub mod command_trigger_input; pub mod conversation; pub mod endpoint; pub mod event; pub mod render; +pub mod safe_token; pub mod store; pub use agent_port::{ AgentPort, AgentPortError, AgentSessionId, PromptOutcome, ReleaseReason, ReleaseStep, SessionRelease, }; -pub use command::{Command, CommandTriggerError, CommandTriggers, ParsedText}; +pub use command::{Command, CommandTriggers, ParsedText}; +pub use command_trigger::{CommandTrigger, CommandTriggerError}; +pub use command_trigger_input::CommandTriggerInput; pub use conversation::{AgentId, ConversationId, ConversationRecord}; pub use endpoint::{Endpoint, EndpointError, PrincipalId}; -pub use event::{Attachment, InboundEvent, Sender}; +pub use event::{ + Attachment, AttachmentKind, EventFieldError, InboundEvent, MessageRef, MimeType, PlatformRef, PlatformUserId, + Sender, +}; pub use render::RenderCommand; +pub use safe_token::{SafeToken, SafeTokenError}; pub use store::{ChannelStore, ChannelStoreError}; diff --git a/rsworkspace/crates/channel/trogon-channel/src/safe_token.rs b/rsworkspace/crates/channel/trogon-channel/src/safe_token.rs new file mode 100644 index 0000000000..9f55365cd4 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/safe_token.rs @@ -0,0 +1,70 @@ +//! A single token safe for channel KV keys and NATS subject tails. +//! +//! Tokens are joined with `.` into composite keys, so `.` is out; the rest is +//! the intersection of what NATS KV keys and NATS subject tokens accept. + +#[cfg(test)] +#[path = "safe_token_tests.rs"] +mod safe_token_tests; + +use serde::{Deserialize, Deserializer, Serialize}; + +/// Why a candidate token failed [`SafeToken`] construction. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum SafeTokenError { + #[error("token must not be empty")] + Empty, + #[error("token contains invalid character: {0:?}")] + InvalidCharacter(char), +} + +/// One NATS/KV-safe token. Validity is guaranteed at construction. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct SafeToken(String); + +impl SafeToken { + pub fn new(token: impl Into) -> Result { + let token = token.into(); + if token.is_empty() { + return Err(SafeTokenError::Empty); + } + if let Some(c) = token + .chars() + .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '='))) + { + return Err(SafeTokenError::InvalidCharacter(c)); + } + Ok(Self(token)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Platforms number their users, chats, and messages, and a decimal integer is +/// a token by construction: its digits are all in the allowed set. Converting +/// through [`SafeToken::new`] instead would leave every caller on that path with +/// an error arm nothing can reach. +impl From for SafeToken { + fn from(value: u64) -> Self { + Self(value.to_string()) + } +} + +impl std::fmt::Display for SafeToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for SafeToken { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::new(raw).map_err(serde::de::Error::custom) + } +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/safe_token_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/safe_token_tests.rs new file mode 100644 index 0000000000..52e67faf56 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/safe_token_tests.rs @@ -0,0 +1,57 @@ +use super::*; + +#[test] +fn a_token_is_the_intersection_of_a_kv_key_and_a_subject_token() { + assert_eq!(SafeToken::new("Ab-9_=").expect("valid").as_str(), "Ab-9_="); + assert_eq!(SafeToken::new("").unwrap_err(), SafeTokenError::Empty); + // `.` separates the tokens of a composite key, so it can never be inside one. + assert_eq!( + SafeToken::new("a.b").unwrap_err(), + SafeTokenError::InvalidCharacter('.') + ); + assert_eq!( + SafeToken::new("a b").unwrap_err(), + SafeTokenError::InvalidCharacter(' ') + ); + // A subject wildcard would match keys it was never given. + assert_eq!( + SafeToken::new("a*").unwrap_err(), + SafeTokenError::InvalidCharacter('*') + ); +} + +/// The seam that lets the value objects take a platform's numeric id without an +/// unreachable error arm: every decimal digit is already an allowed character. +#[test] +fn a_numeric_id_is_a_token_without_being_checked() { + assert_eq!(SafeToken::from(0_u64).as_str(), "0"); + assert_eq!(SafeToken::from(u64::MAX).as_str(), u64::MAX.to_string()); + assert_eq!( + SafeToken::from(42_u64), + SafeToken::new("42").expect("the checked path agrees") + ); +} + +#[test] +fn a_token_displays_as_the_string_it_wraps() { + assert_eq!(SafeToken::new("token").expect("valid").to_string(), "token"); +} + +/// The wrapper types (`PrincipalId`, `PlatformUserId`, ...) each hand-roll +/// `Deserialize` through their own constructor, so nothing in the workspace +/// reaches this impl today. It exists so that the first type to hold a +/// `SafeToken` in a `#[derive(Deserialize)]` struct validates rather than +/// silently admitting an unsafe key, and this pins that. +#[test] +fn deserializing_a_token_validates_it_rather_than_admitting_it() { + #[derive(Debug, Deserialize)] + struct Holder { + token: SafeToken, + } + + let ok: Holder = serde_json::from_str(r#"{"token":"ok-1"}"#).expect("valid token"); + assert_eq!(ok.token.as_str(), "ok-1"); + + let err = serde_json::from_str::(r#"{"token":"not ok"}"#).expect_err("unsafe token must not deserialize"); + assert!(err.to_string().contains("invalid character"), "{err}"); +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/store.rs b/rsworkspace/crates/channel/trogon-channel/src/store.rs index 193af17ca6..4086e31562 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store.rs @@ -30,6 +30,29 @@ pub enum ChannelStoreError { Write(#[from] async_nats::jetstream::kv::PutError), #[error("stored record is not valid JSON: {0}")] Decode(#[from] serde_json::Error), + /// The conversation record was written but binding its endpoint failed. The + /// record has been rolled back, so the store is as it was before the call. + #[error("failed to bind endpoint {endpoint} to new conversation {conversation}: {source}")] + BindEndpoint { + endpoint: String, + conversation: ConversationId, + #[source] + source: async_nats::jetstream::kv::PutError, + }, + /// Binding failed and so did the rollback, so the conversation record is + /// still in the bucket with nothing pointing at it. Both failures are kept + /// typed: the operator needs the key to sweep, and the cause to know why. + #[error( + "failed to bind endpoint {endpoint} to new conversation {conversation} ({bind_error}), \ + and removing the now-unreachable conversation record failed too: {source}" + )] + OrphanedConversation { + endpoint: String, + conversation: ConversationId, + bind_error: async_nats::jetstream::kv::PutError, + #[source] + source: async_nats::jetstream::kv::DeleteError, + }, } /// What we know about a principal beyond its id. @@ -139,6 +162,13 @@ impl ChannelStore { /// Create a conversation and bind an endpoint to it. Routing policy runs /// before this call (it decided `record.agent_id`); after it, the binding /// is sticky. + /// + /// The record has to be written before the binding, so that no binding is + /// ever briefly visible pointing at a record that does not exist. That + /// leaves the opposite exposure: a binding write that fails would strand a + /// record nothing can reach. It is rolled back before the error returns, + /// because each attempt generates a fresh id, so without the rollback every + /// redelivery of one message would leave another unreachable record behind. pub async fn create_conversation( &self, endpoint: &Endpoint, @@ -146,13 +176,28 @@ impl ChannelStore { ids: &impl NowV7, ) -> Result { let id = ConversationId::generate(ids); - self.conversations - .put(id.as_str(), serde_json::to_vec(record)?.into()) - .await?; - self.bindings - .put(endpoint.kv_key(), serde_json::to_vec(&id)?.into()) - .await?; - Ok(id) + let conversation = serde_json::to_vec(record)?; + let binding = serde_json::to_vec(&id)?; + + self.conversations.put(id.as_str(), conversation.into()).await?; + + let Err(source) = self.bindings.put(endpoint.kv_key(), binding.into()).await else { + return Ok(id); + }; + + match self.conversations.delete(id.as_str()).await { + Ok(()) => Err(ChannelStoreError::BindEndpoint { + endpoint: endpoint.kv_key(), + conversation: id, + source, + }), + Err(cleanup) => Err(ChannelStoreError::OrphanedConversation { + endpoint: endpoint.kv_key(), + conversation: id, + bind_error: source, + source: cleanup, + }), + } } /// Update a conversation record in place (session replacement, activity). diff --git a/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs index fe35e885fa..084ab9bfd9 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs @@ -15,7 +15,7 @@ fn principal(id: &str) -> PrincipalId { fn record(principal: &PrincipalId) -> ConversationRecord { ConversationRecord { principal: principal.clone(), - agent_id: AgentId::new("default"), + agent_id: AgentId::new("default").expect("agent id"), current_session: None, created_at: 1, last_activity_at: 1, @@ -110,7 +110,7 @@ async fn a_conversation_round_trips_through_its_buckets() { .await .expect("create conversation"); - record.current_session = Some(AgentSessionId::new("sess-1")); + record.current_session = Some(AgentSessionId::new("sess-1").expect("session id")); store.update_conversation(&id, &record).await.expect("update"); let (found_id, found) = store @@ -120,6 +120,238 @@ async fn a_conversation_round_trips_through_its_buckets() { .expect("conversation is bound"); assert_eq!(found_id, id); - assert_eq!(found.current_session, Some(AgentSessionId::new("sess-1"))); + assert_eq!( + found.current_session, + Some(AgentSessionId::new("sess-1").expect("session id")) + ); assert_eq!(found.principal, principal); } + +/// A binding can outlive the conversation it points at (the record aged out, +/// or was deleted, while the binding survived), and that dangling state must +/// read as unbound rather than panic on a missing record. +#[tokio::test] +async fn a_binding_with_no_conversation_record_reads_as_unbound() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + let store = ChannelStore::ensure(&js, "dangling").await.expect("ensure"); + + let endpoint = endpoint("333"); + let id = ConversationId::from_string("gone").expect("conversation id"); + + // No public API writes a binding without its conversation record, so the + // dangling state is written straight to the private `bindings` bucket. + store + .bindings + .put(endpoint.kv_key(), serde_json::to_vec(&id).expect("encode id").into()) + .await + .expect("write dangling binding"); + + assert!( + store + .conversation_for(&endpoint) + .await + .expect("conversation lookup") + .is_none(), + "a dangling binding must not be reported as a bound conversation" + ); +} + +/// The exposure the write order creates: the record goes in first so no binding +/// is ever briefly visible pointing at nothing, which leaves a failed binding +/// able to strand a record instead. Each attempt mints a fresh id, so without +/// the rollback every redelivery of one message would leave one more +/// unreachable record behind. +#[tokio::test] +async fn a_failed_binding_takes_the_conversation_record_with_it() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + let store = ChannelStore::ensure(&js, "rollback").await.expect("ensure"); + + // Dropping the bucket fails the binding write and nothing else, which is + // the only case the rollback is there for. + js.delete_key_value("channel_bindings_rollback") + .await + .expect("drop the bindings bucket"); + + let endpoint = endpoint("444"); + let Err(error) = store + .create_conversation(&endpoint, &record(&principal("user-4")), &UuidV7Generator) + .await + else { + panic!("create must fail when the binding cannot be written"); + }; + + let conversation = match error { + ChannelStoreError::BindEndpoint { conversation, .. } => conversation, + other => panic!("expected the binding failure to surface, got {other:?}"), + }; + + assert!( + store + .conversations + .get(conversation.as_str()) + .await + .expect("read the rolled back record") + .is_none(), + "a conversation nothing can reach must not survive the call that failed to bind it" + ); +} + +/// The rollback that keeps a failed bind from stranding a record can itself +/// fail, and then the record really is unreachable, so the error has to carry +/// both causes and the key an operator needs to sweep. A conversations bucket +/// that accepts one write per subject and refuses the next forces that +/// deterministically: the record write is the one it accepts, and the rollback's +/// delete marker is a second write to the same subject. +#[tokio::test] +async fn a_rollback_that_also_fails_reports_the_record_it_could_not_remove() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + + // Hand-built rather than left to `ensure_bucket`, whose own config would + // accept the rollback. `get_key_value` asks only for a per-subject limit of + // at least one, so this still opens as the store's conversations bucket. + js.create_stream(jetstream::stream::Config { + name: "KV_channel_conversations_orphan".to_string(), + subjects: vec!["$KV.channel_conversations_orphan.>".to_string()], + max_messages_per_subject: 1, + discard: jetstream::stream::DiscardPolicy::New, + discard_new_per_subject: true, + ..Default::default() + }) + .await + .expect("a conversations bucket that refuses a second write to one subject"); + + let store = ChannelStore::ensure(&js, "orphan").await.expect("ensure"); + + js.delete_key_value("channel_bindings_orphan") + .await + .expect("drop the bindings bucket so only the bind fails"); + + let Err(error) = store + .create_conversation(&endpoint("555"), &record(&principal("user-5")), &UuidV7Generator) + .await + else { + panic!("create must fail when the binding cannot be written"); + }; + + let ChannelStoreError::OrphanedConversation { conversation, .. } = error else { + panic!("expected the failed rollback to surface as an orphaned record, got {error:?}"); + }; + + assert!( + store + .conversations + .get(conversation.as_str()) + .await + .expect("read the record the rollback could not remove") + .is_some(), + "the error must name a record that really is still there to be swept" + ); +} + +/// `ensure_is_idempotent_under_concurrent_creation` races two *identical* +/// configs, and neither side ever takes this arm: `STREAM.CREATE` only +/// errors when the stream that beat it has a different config, and an +/// identical race succeeds silently on both sides. Racing a bare create +/// against `ensure_bucket`'s own get-then-create for the same bucket name +/// reliably loses that race instead: the bare create skips the get's extra +/// round trip, so its differently-configured bucket already exists by the +/// time this store's own create is rejected. +#[tokio::test] +async fn a_bucket_created_with_a_different_config_between_the_get_and_the_create_is_still_opened() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + let bucket = "conflict".to_string(); + + // `ensure_bucket` is private and reachable only through `ChannelStore::ensure`, + // so it is called directly here to race a single bucket instead of all four. + let racing_create = js.create_key_value(jetstream::kv::Config { + bucket: bucket.clone(), + history: 1, + storage: jetstream::stream::StorageType::File, + ..Default::default() + }); + + let (ours, theirs) = tokio::join!(ensure_bucket(&js, bucket.clone()), racing_create); + + ours.expect("ensure_bucket must recover the bucket the race left behind"); + theirs.expect("the racing create must succeed for there to be anything to recover"); + + let mut stream = js + .get_stream(format!("KV_{bucket}")) + .await + .expect("the bucket the race created must still be there"); + let info = stream.info().await.expect("stream info"); + assert_eq!( + info.config.max_messages_per_subject, 1, + "the surviving config must be the racing create's, not ensure_bucket's own attempt" + ); +} + +/// `STREAM.CREATE` also rejects a bucket for reasons that have nothing to do +/// with a name already in use, and that class of failure must surface as-is +/// rather than being mistaken for the recoverable race above. A stream that +/// already claims this bucket's subject space under a different name forces +/// exactly that: JetStream reports a subject overlap (error code 10065), not +/// the name-in-use conflict (10058) `is_create_key_value_already_exists` +/// looks for, and the claim sits there deterministically before +/// `ensure_bucket` ever runs, so there is no race to lose. +#[tokio::test] +async fn a_bucket_whose_subject_space_is_already_claimed_fails_to_create() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + let bucket = "claimed".to_string(); + + js.create_stream(jetstream::stream::Config { + name: "squatter".to_string(), + subjects: vec![format!("$KV.{bucket}.>")], + ..Default::default() + }) + .await + .expect("claim the bucket's subject space under an unrelated stream name"); + + let Err(error) = ensure_bucket(&js, bucket).await else { + panic!("ensure_bucket must fail when STREAM.CREATE is rejected for a reason other than a name conflict"); + }; + + assert!( + matches!(error, ChannelStoreError::CreateBucket { .. }), + "expected the create failure to surface as-is, got {error:?}" + ); +} + +/// Line 105's arm: the already-exists recovery read can itself fail. Racing +/// a stream into existence with `max_messages_per_subject` below the minimum +/// a real KV config ever produces (`kv_to_stream_config` floors it at 1) +/// forces exactly that: the name conflict sends `ensure_bucket` to recover by +/// reading the bucket back, and that read rejects what it finds as not a +/// valid KV store rather than returning it. +#[tokio::test] +async fn a_recovery_read_that_also_fails_surfaces_as_a_bucket_read_failure() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + let bucket = "conflict-broken".to_string(); + + // Plain `create_stream`, not `create_key_value`, because the KV wrapper + // floors `max_messages_per_subject` at 1 and could never produce this. + let racing_create = js.create_stream(jetstream::stream::Config { + name: format!("KV_{bucket}"), + subjects: vec![format!("$KV.{bucket}.>")], + max_messages_per_subject: 0, + ..Default::default() + }); + + let (ours, theirs) = tokio::join!(ensure_bucket(&js, bucket.clone()), racing_create); + + theirs.expect("the racing create must win for there to be a conflicting bucket to recover"); + + let Err(error) = ours else { + panic!("ensure_bucket must fail when its own recovery read also fails"); + }; + assert!( + matches!(error, ChannelStoreError::OpenBucket { .. }), + "expected the recovery read failure to surface, got {error:?}" + ); +} diff --git a/rsworkspace/crates/platform/trogon-gateway/src/main.rs b/rsworkspace/crates/platform/trogon-gateway/src/main.rs index 6c80c09cb4..bfe57923ed 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/main.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/main.rs @@ -33,7 +33,7 @@ use tokio::task::JoinSet; use tracing::{error, info}; #[cfg(not(coverage))] use trogon_nats::jetstream::{ - ClaimCheckPublisher, ClaimRetention, DEFAULT_CLAIM_BUCKET, MaxPayload, NatsJetStreamClient, NatsObjectStore, + ClaimBucket, ClaimCheckPublisher, ClaimRetention, MaxPayload, NatsJetStreamClient, NatsObjectStore, }; #[cfg(not(coverage))] use trogon_nats::{connect, wait_for_server_info}; @@ -98,8 +98,8 @@ async fn serve(resolved: config::ResolvedConfig) -> anyhow::Result<()> { .max_stream_max_age() .map(|stream_max_age| ClaimRetention::tracking(stream_max_age, CLAIM_CHECK_TTL_GRACE)) .unwrap_or(ClaimRetention::EventSourced); - let object_store = - NatsObjectStore::provision_claim_bucket(&js_context, DEFAULT_CLAIM_BUCKET, claim_retention).await?; + let claim_bucket = ClaimBucket::default(); + let object_store = NatsObjectStore::provision_claim_bucket(&js_context, &claim_bucket, claim_retention).await?; let client = NatsJetStreamClient::new(js_context); streams::provision(&client, &resolved).await?; @@ -141,7 +141,7 @@ async fn serve(resolved: config::ResolvedConfig) -> anyhow::Result<()> { let publisher = ClaimCheckPublisher::new( client.clone(), object_store.clone(), - DEFAULT_CLAIM_BUCKET.to_string(), + claim_bucket.as_str().to_string(), nats.clone(), ); diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs new file mode 100644 index 0000000000..98abb704b2 --- /dev/null +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs @@ -0,0 +1,65 @@ +use std::fmt; + +use crate::constants::DEFAULT_CLAIM_BUCKET; + +/// Why a name cannot be a claim bucket. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ClaimBucketError { + #[error("a claim bucket name must not be empty")] + Empty, + #[error("a claim bucket name may not contain {0:?}")] + InvalidCharacter(char), +} + +/// The object-store bucket claim-check payloads live in. +/// +/// Constrained to what NATS accepts as a bucket name, so a name the server +/// would refuse fails where it is configured rather than on the first oversized +/// message. It exists mostly to be inseparable from the handle opened on it: +/// see [`ClaimBucketBinding`](super::object_store::ClaimBucketBinding). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ClaimBucket(String); + +impl ClaimBucket { + pub fn new(name: impl Into) -> Result { + let name = name.into(); + if name.is_empty() { + return Err(ClaimBucketError::Empty); + } + if let Some(invalid) = name + .chars() + .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))) + { + return Err(ClaimBucketError::InvalidCharacter(invalid)); + } + Ok(Self(name)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for ClaimBucket { + /// The one bucket a trogon deployment uses. Built without going through + /// [`ClaimBucket::new`] because a constant cannot fail; `the_default_bucket_is_a_valid_name` + /// is what holds that claim to account. + fn default() -> Self { + Self(DEFAULT_CLAIM_BUCKET.to_string()) + } +} + +impl fmt::Display for ClaimBucket { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl PartialEq for ClaimBucket { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +#[cfg(test)] +mod tests; diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs new file mode 100644 index 0000000000..e73e9db63f --- /dev/null +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs @@ -0,0 +1,38 @@ +use super::*; + +#[test] +fn a_bucket_name_is_what_nats_accepts_as_one() { + assert_eq!(ClaimBucket::new("trogon-claims").expect("valid").as_str(), "trogon-claims"); + assert_eq!(ClaimBucket::new("claims_2").expect("valid").as_str(), "claims_2"); +} + +/// A `.` would name a subject, and a `/` a path inside a bucket; both are names +/// JetStream rejects, and rejecting them here means the operator hears about it +/// at startup instead of on the first oversized message. +#[test] +fn a_name_nats_would_refuse_is_refused_here() { + assert_eq!(ClaimBucket::new("").unwrap_err(), ClaimBucketError::Empty); + assert_eq!( + ClaimBucket::new("trogon.claims").unwrap_err(), + ClaimBucketError::InvalidCharacter('.') + ); + assert_eq!( + ClaimBucket::new("trogon claims").unwrap_err(), + ClaimBucketError::InvalidCharacter(' ') + ); + assert_eq!( + ClaimBucket::new("claims/one").unwrap_err(), + ClaimBucketError::InvalidCharacter('/') + ); +} + +/// [`ClaimBucket::default`] skips validation because the constant cannot fail. +/// This is the test that makes that true rather than assumed. +#[test] +fn the_default_bucket_is_a_valid_name() { + assert_eq!( + ClaimBucket::new(DEFAULT_CLAIM_BUCKET).expect("the default bucket must be a valid name"), + ClaimBucket::default() + ); + assert_eq!(ClaimBucket::default().to_string(), DEFAULT_CLAIM_BUCKET); +} diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs index 0f3ec8a538..d9d5e1676b 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs @@ -12,7 +12,8 @@ use crate::constants::{ PROTOCOL_OVERHEAD, }; -use super::object_store::{ObjectStoreGet, ObjectStorePut}; +use super::claim_bucket::ClaimBucket; +use super::object_store::{ClaimBucketBinding, ObjectStoreGet, ObjectStorePut}; use super::publish::PublishOutcome; use super::traits::JetStreamPublisher; @@ -78,24 +79,23 @@ pub async fn resolve_claim( /// /// [`resolve_claim`] takes an already-bound store and cannot tell whether it is /// the right one, so a consumer pointed at the wrong bucket reports every claim -/// as a missing object. Pairing the store with its bucket name lets the -/// [`HEADER_CLAIM_BUCKET`] the publisher already sends be checked, which turns -/// that misconfiguration into its own error. +/// as a missing object. Checking the [`HEADER_CLAIM_BUCKET`] the publisher +/// already sends turns that misconfiguration into its own error, which is only +/// worth anything if the name checked against is the name actually opened: +/// hence a [`ClaimBucketBinding`] rather than a store and a name. #[derive(Debug, Clone)] pub struct ClaimResolver { store: S, - bucket: String, + bucket: ClaimBucket, } impl ClaimResolver { - pub fn new(store: S, bucket: impl Into) -> Self { - Self { - store, - bucket: bucket.into(), - } + pub fn new(binding: ClaimBucketBinding) -> Self { + let (store, bucket) = binding.into_parts(); + Self { store, bucket } } - pub fn bucket(&self) -> &str { + pub fn bucket(&self) -> &ClaimBucket { &self.bucket } @@ -115,7 +115,7 @@ impl ClaimResolver { return Ok(payload); } if let Some(named) = headers.get(HEADER_CLAIM_BUCKET) - && named.as_str() != self.bucket + && named.as_str() != self.bucket.as_str() { return Err(ClaimResolveError::BucketMismatch { expected: self.bucket.clone(), @@ -130,8 +130,12 @@ impl ClaimResolver { pub enum ClaimResolveError { #[error("claim message missing {} header", HEADER_CLAIM_KEY)] MissingKey, - #[error("claim names bucket {named:?} but this consumer reads {expected:?}")] - BucketMismatch { expected: String, named: String }, + /// `named` stays a string because it is whatever the header carried, which + /// in this arm is by definition not the bucket this consumer opened and may + /// not be a legal bucket name at all. Narrowing it would discard the one + /// value an operator needs to read. + #[error("claim names bucket {named:?} but this consumer reads {expected}")] + BucketMismatch { expected: ClaimBucket, named: String }, #[error("failed to resolve claim from object store: {0}")] StoreFailed(#[source] E), #[error("failed to read claim payload: {0}")] diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs index ebe3a40cd7..727847785b 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs @@ -29,6 +29,13 @@ impl MaxPayloadLimit for DynamicMaxPayload { } } +/// A fake store has no bucket to open, so these tests assert the pair by hand. +/// Production code has no such constructor: it gets a binding from opening the +/// bucket, which is the whole point of the type. +fn test_binding(store: S) -> ClaimBucketBinding { + ClaimBucketBinding::for_test(store, ClaimBucket::new("test-bucket").expect("valid bucket name")) +} + #[tokio::test] async fn small_payload_publishes_directly() { let publisher = MockJetStreamPublisher::new(); @@ -289,7 +296,7 @@ async fn small_payload_strips_claim_headers() { #[tokio::test] async fn resolver_returns_payload_when_message_has_no_headers() { - let resolver = ClaimResolver::new(MockObjectStore::new(), "test-bucket"); + let resolver = ClaimResolver::new(test_binding(MockObjectStore::new())); let payload = Bytes::from("raw data"); let result = resolver.resolve(None, payload.clone()).await; @@ -298,7 +305,7 @@ async fn resolver_returns_payload_when_message_has_no_headers() { #[tokio::test] async fn resolver_returns_payload_when_headers_carry_no_claim() { - let resolver = ClaimResolver::new(MockObjectStore::new(), "test-bucket"); + let resolver = ClaimResolver::new(test_binding(MockObjectStore::new())); let headers = HeaderMap::new(); let payload = Bytes::from("raw data"); @@ -311,7 +318,7 @@ async fn resolver_redeems_a_claim_from_its_bucket() { let store = MockObjectStore::new(); let expected = Bytes::from("offloaded body"); store.seed("test.subject/some-id", expected.clone()); - let resolver = ClaimResolver::new(store, "test-bucket"); + let resolver = ClaimResolver::new(test_binding(store)); let mut headers = HeaderMap::new(); headers.insert(HEADER_CLAIM_CHECK, CLAIM_CHECK_VERSION); @@ -327,7 +334,7 @@ async fn resolver_redeems_a_claim_that_names_no_bucket() { let store = MockObjectStore::new(); let expected = Bytes::from("offloaded body"); store.seed("test.subject/some-id", expected.clone()); - let resolver = ClaimResolver::new(store, "test-bucket"); + let resolver = ClaimResolver::new(test_binding(store)); let mut headers = HeaderMap::new(); headers.insert(HEADER_CLAIM_CHECK, CLAIM_CHECK_VERSION); @@ -344,7 +351,7 @@ async fn resolver_redeems_a_claim_that_names_no_bucket() { async fn resolver_rejects_a_claim_from_another_bucket() { let store = MockObjectStore::new(); store.seed("test.subject/some-id", Bytes::from("offloaded body")); - let resolver = ClaimResolver::new(store, "test-bucket"); + let resolver = ClaimResolver::new(test_binding(store)); assert_eq!(resolver.bucket(), "test-bucket"); let mut headers = HeaderMap::new(); @@ -368,7 +375,7 @@ async fn resolver_surfaces_a_store_failure_rather_than_an_empty_body() { let store = MockObjectStore::new(); store.seed("test.subject/some-id", Bytes::from("offloaded body")); store.fail_next_get(); - let resolver = ClaimResolver::new(store, "test-bucket"); + let resolver = ClaimResolver::new(test_binding(store)); let mut headers = HeaderMap::new(); headers.insert(HEADER_CLAIM_CHECK, CLAIM_CHECK_VERSION); @@ -404,7 +411,7 @@ async fn published_claim_round_trips_through_a_resolver() { let msg = &publisher.published_messages()[0]; assert!(msg.payload.is_empty()); - let resolver = ClaimResolver::new(store, "test-bucket"); + let resolver = ClaimResolver::new(test_binding(store)); let resolved = resolver .resolve(Some(&msg.headers), msg.payload.clone()) .await diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs index d6f90bda0d..56786fcbbc 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs @@ -1,3 +1,4 @@ +pub mod claim_bucket; pub mod claim_check; pub mod claim_retention; #[cfg(not(coverage))] @@ -14,6 +15,7 @@ pub mod traits; pub mod mocks; pub use crate::constants::{DEFAULT_CLAIM_BUCKET, HEADER_CLAIM_BUCKET, HEADER_CLAIM_CHECK, HEADER_CLAIM_KEY}; +pub use claim_bucket::{ClaimBucket, ClaimBucketError}; pub use claim_check::{ClaimCheckPublisher, ClaimResolveError, ClaimResolver, MaxPayload, is_claim, resolve_claim}; pub use claim_retention::ClaimRetention; #[cfg(not(coverage))] @@ -26,7 +28,7 @@ pub use message::{JsAck, JsAckWith, JsDispatchMessage, JsDoubleAck, JsDoubleAckW pub use not_found::{is_get_key_value_not_found, is_get_stream_not_found}; #[cfg(not(coverage))] pub use object_store::NatsObjectStore; -pub use object_store::{ObjectStoreGet, ObjectStorePut}; +pub use object_store::{ClaimBucketBinding, ObjectStoreGet, ObjectStorePut}; pub use publish::{PublishOutcome, publish_event}; pub use stream_max_age::StreamMaxAge; pub use traits::{ diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store.rs index 436946b446..4952e54582 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store.rs @@ -4,6 +4,8 @@ use std::time::Duration; use tokio::io::AsyncRead; +use super::claim_bucket::ClaimBucket; + #[cfg(not(coverage))] use async_nats::jetstream::context::CreateKeyValueErrorKind; @@ -34,6 +36,39 @@ fn widened_max_age(current: Duration, desired: Duration) -> Option { } } +/// An object-store handle together with the claim bucket it was opened on. +/// +/// A handle cannot be asked which bucket it reads, so the two have to travel as +/// one. Outside tests the only way to get a binding is to open the bucket, which +/// is what stops a consumer from validating one bucket while reading another. +pub struct ClaimBucketBinding { + store: S, + bucket: ClaimBucket, +} + +impl ClaimBucketBinding { + pub fn bucket(&self) -> &ClaimBucket { + &self.bucket + } + + pub(crate) fn into_parts(self) -> (S, ClaimBucket) { + (self.store, self.bucket) + } + + /// Drop the bucket and keep the handle, for a publisher that names its + /// bucket in the claim headers it writes rather than checking one it reads. + pub fn into_store(self) -> S { + self.store + } + + /// Pair a store with a bucket name directly, for tests that redeem claims + /// from a fake store and so have no bucket to open. + #[cfg(any(test, feature = "test-support"))] + pub fn for_test(store: S, bucket: ClaimBucket) -> Self { + Self { store, bucket } + } +} + pub trait ObjectStorePut: Send + Sync + Clone + 'static { type Error: Error + Send + Sync; type Info: Send; @@ -91,16 +126,26 @@ impl NatsObjectStore { } } - /// Open an existing bucket without creating it or touching its retention. - /// A consumer redeeming claims reads a bucket whose lifecycle belongs to the - /// publisher that fills it, so creating one here would only hide the fact - /// that the publisher never ran. - pub async fn bind(js: &async_nats::jetstream::Context, bucket: &str) -> Result { + /// Open an existing claim bucket without creating it or touching its + /// retention. A consumer redeeming claims reads a bucket whose lifecycle + /// belongs to the publisher that fills it, so creating one here would only + /// hide the fact that the publisher never ran. + /// + /// The bucket comes back bound to the handle, because the name is what a + /// consumer checks incoming claims against and a handle it does not match is + /// worse than no handle at all. + pub async fn bind_claim_bucket( + js: &async_nats::jetstream::Context, + bucket: ClaimBucket, + ) -> Result, ProvisionObjectStoreError> { let store = js - .get_object_store(bucket) + .get_object_store(bucket.as_str()) .await .map_err(ProvisionObjectStoreError::Get)?; - Ok(Self { store }) + Ok(ClaimBucketBinding { + store: Self { store }, + bucket, + }) } /// Provision a bucket that backs claim-check payloads, sizing its `max_age` @@ -117,21 +162,20 @@ impl NatsObjectStore { /// messages reference. pub async fn provision_claim_bucket( js: &async_nats::jetstream::Context, - bucket: impl Into, + bucket: &ClaimBucket, retention: super::claim_retention::ClaimRetention, ) -> Result { - let bucket = bucket.into(); let max_age = retention.bucket_max_age(); let store = Self::provision( js, async_nats::jetstream::object_store::Config { - bucket: bucket.clone(), + bucket: bucket.as_str().to_string(), max_age, ..Default::default() }, ) .await?; - reconcile_bucket_max_age(js, &bucket, max_age).await?; + reconcile_bucket_max_age(js, bucket.as_str(), max_age).await?; Ok(store) } } diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/integration_tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/integration_tests.rs index db48ee9ab1..01487dc5ff 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/integration_tests.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/integration_tests.rs @@ -3,6 +3,8 @@ use std::time::Duration; use trogon_std::NonZeroDuration; use super::NatsObjectStore; +use crate::jetstream::claim_bucket::ClaimBucket; +use crate::jetstream::claim_check::ClaimResolver; use crate::jetstream::claim_retention::ClaimRetention; use crate::jetstream::stream_max_age::StreamMaxAge; use crate::test_support::JetStreamTestServer; @@ -10,6 +12,10 @@ use crate::test_support::JetStreamTestServer; const BUCKET: &str = "trogon-claims-test"; const GRACE_SECS: u64 = 3600; +fn claim_bucket() -> ClaimBucket { + ClaimBucket::new(BUCKET).expect("valid bucket name") +} + fn tracking(stream_secs: u64) -> ClaimRetention { ClaimRetention::tracking( StreamMaxAge::from_secs(stream_secs).expect("non-zero"), @@ -32,7 +38,7 @@ async fn provisioning_an_existing_bucket_reconciles_its_retention() { let js = server.jetstream().await; let short = tracking(3600); - NatsObjectStore::provision_claim_bucket(&js, BUCKET, short) + NatsObjectStore::provision_claim_bucket(&js, &claim_bucket(), short) .await .expect("first provision"); assert_eq!(backing_stream_max_age(&js).await, short.bucket_max_age()); @@ -41,7 +47,7 @@ async fn provisioning_an_existing_bucket_reconciles_its_retention() { // effect, not silently keep the old TTL. let long = tracking(3 * 3600); assert_ne!(long.bucket_max_age(), short.bucket_max_age()); - NatsObjectStore::provision_claim_bucket(&js, BUCKET, long) + NatsObjectStore::provision_claim_bucket(&js, &claim_bucket(), long) .await .expect("re-provision wider"); assert_eq!(backing_stream_max_age(&js).await, long.bucket_max_age()); @@ -49,8 +55,27 @@ async fn provisioning_an_existing_bucket_reconciles_its_retention() { // Re-provisioning with a shorter retention must NOT shrink the bucket: // older, still-deliverable messages could reference claims that would // otherwise expire early. - NatsObjectStore::provision_claim_bucket(&js, BUCKET, short) + NatsObjectStore::provision_claim_bucket(&js, &claim_bucket(), short) .await .expect("re-provision narrower"); assert_eq!(backing_stream_max_age(&js).await, long.bucket_max_age()); } + +/// What the binding is for: the bucket a resolver checks incoming claims +/// against is the bucket it actually opened, because one call produced both. +/// Labelling a handle with some other name is not merely wrong here, it is +/// unwritable, which is why there is no negative case to pair with this. +#[tokio::test] +async fn a_resolver_reads_the_bucket_its_binding_opened() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + NatsObjectStore::provision_claim_bucket(&js, &claim_bucket(), tracking(3600)) + .await + .expect("provision claim bucket"); + + let binding = NatsObjectStore::bind_claim_bucket(&js, claim_bucket()) + .await + .expect("bind claim bucket"); + assert_eq!(binding.bucket(), &claim_bucket()); + assert_eq!(ClaimResolver::new(binding).bucket(), &claim_bucket()); +} From 14c4a2cbbf44a4bca6b33e3dc68e2c556ae9439c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 05:14:11 -0400 Subject: [PATCH 25/55] fix(repo): clear the lint and coverage gates the branch was failing Signed-off-by: Yordis Prieto --- .../src/pipeline_tests.rs | 5 ++++- .../trogon-channel/src/agent_port_tests.rs | 2 +- .../crates/channel/trogon-channel/src/command.rs | 8 ++------ .../channel/trogon-channel/src/conversation.rs | 2 ++ .../trogon-channel/src/conversation_tests.rs | 8 ++++---- .../channel/trogon-channel/src/endpoint_tests.rs | 4 ++-- .../channel/trogon-channel/src/event_tests.rs | 2 +- .../trogon-channel/src/safe_token_tests.rs | 7 ++----- .../src/jetstream/claim_bucket/tests.rs | 5 ++++- .../src/jetstream/object_store/tests.rs | 16 +++++++++++++++- 10 files changed, 37 insertions(+), 22 deletions(-) diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs index bdbdd8a94a..5a8a5d05ac 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs @@ -294,7 +294,10 @@ async fn settled_consumer_info( /// ever redeemed through it. Mock-backed rather than bucket-backed to keep those /// scenarios off the object store. fn unclaimed_resolver() -> ClaimResolver { - ClaimResolver::new(ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default())) + ClaimResolver::new(ClaimBucketBinding::for_test( + MockObjectStore::new(), + ClaimBucket::default(), + )) } /// The bucket the gateway offloads oversized bodies into, opened the way the diff --git a/rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs index 582c3a9b96..38eefdf7c7 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs @@ -28,5 +28,5 @@ fn deserializing_a_session_id_rejects_one_the_constructor_would_reject() { assert_eq!(ok.as_str(), "sess-1"); let err = serde_json::from_str::(r#""sess 1""#).expect_err("unsafe id must not deserialize"); - assert!(err.to_string().contains("invalid character"), "{err}"); + assert_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); } diff --git a/rsworkspace/crates/channel/trogon-channel/src/command.rs b/rsworkspace/crates/channel/trogon-channel/src/command.rs index aa7ff7ba18..dc618dba65 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/command.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/command.rs @@ -28,13 +28,9 @@ pub struct CommandTriggers { } impl Default for CommandTriggers { + #[allow(clippy::expect_used)] fn default() -> Self { - Self { - new_session: vec![ - CommandTrigger::try_from(CommandTriggerInput::new("/new")).expect("/new"), - CommandTrigger::try_from(CommandTriggerInput::new("/reset")).expect("/reset"), - ], - } + Self::new(["/new", "/reset"]).expect("the default triggers are single non-empty tokens") } } diff --git a/rsworkspace/crates/channel/trogon-channel/src/conversation.rs b/rsworkspace/crates/channel/trogon-channel/src/conversation.rs index 5f196298af..f9891d6f56 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/conversation.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/conversation.rs @@ -49,7 +49,9 @@ impl ConversationId { /// v7 makes the bucket list in creation order. The generator is passed in /// for the same reason `ConversationRecord::created_at` is: no ambient /// clock in this crate. + #[allow(clippy::expect_used)] pub fn generate(ids: &impl NowV7) -> Self { + // `simple()` strips the hyphens, leaving only hex digits. Self(SafeToken::new(ids.now_v7().simple().to_string()).expect("uuid v7 simple form is a safe token")) } diff --git a/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs index af6aa5ec12..92e800b428 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs @@ -41,7 +41,7 @@ fn conversation_id_rejects_unsafe_tokens() { #[test] fn conversation_id_deserialize_rejects_unsafe_tokens() { let err = serde_json::from_str::("\"a.b\"").expect_err("dot is unsafe"); - assert!(err.to_string().contains("invalid character"), "{err}"); + assert_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); } #[test] @@ -67,7 +67,7 @@ fn agent_id_rejects_unsafe_tokens() { #[test] fn agent_id_deserialize_rejects_unsafe_tokens() { let err = serde_json::from_str::("\"sales.agent\"").expect_err("dot is unsafe"); - assert!(err.to_string().contains("invalid character"), "{err}"); + assert_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); } #[test] @@ -81,7 +81,7 @@ fn agent_session_id_rejects_unsafe_tokens() { #[test] fn agent_session_id_deserialize_rejects_unsafe_tokens() { let err = serde_json::from_str::("\"sess.1\"").expect_err("dot is unsafe"); - assert!(err.to_string().contains("invalid character"), "{err}"); + assert_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); } #[test] @@ -94,5 +94,5 @@ fn conversation_record_deserialize_rejects_a_corrupt_agent_id() { "last_activity_at": 1, })) .expect_err("corrupt agent_id"); - assert!(err.to_string().contains("invalid character"), "{err}"); + assert_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); } diff --git a/rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs index ced55e4ef8..7584a792af 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs @@ -44,7 +44,7 @@ fn endpoint_deserialize_rejects_an_unsafe_token() { "peer": "1", })) .expect_err("space is unsafe"); - assert!(err.to_string().contains("invalid character"), "{err}"); + assert_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); } #[test] @@ -77,5 +77,5 @@ fn principal_id_display_renders_the_bare_id() { #[test] fn principal_id_deserialize_rejects_an_unsafe_token() { let err = serde_json::from_str::("\"abc.def\"").expect_err("dot is unsafe"); - assert!(err.to_string().contains("invalid character"), "{err}"); + assert_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); } diff --git a/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs index e597c986ed..d87926a445 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs @@ -25,7 +25,7 @@ fn deserializing_a_sender_rejects_an_id_the_constructor_would_reject() { let err = serde_json::from_str::(r#"{"platform_user_id":"user id","display_name":"Ada"}"#) .expect_err("unsafe id must not deserialize"); - assert!(err.to_string().contains("invalid character"), "{err}"); + assert_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); } /// A display name is shown, never matched on, so it holds no invariant: names diff --git a/rsworkspace/crates/channel/trogon-channel/src/safe_token_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/safe_token_tests.rs index 52e67faf56..2b0137cded 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/safe_token_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/safe_token_tests.rs @@ -14,10 +14,7 @@ fn a_token_is_the_intersection_of_a_kv_key_and_a_subject_token() { SafeTokenError::InvalidCharacter(' ') ); // A subject wildcard would match keys it was never given. - assert_eq!( - SafeToken::new("a*").unwrap_err(), - SafeTokenError::InvalidCharacter('*') - ); + assert_eq!(SafeToken::new("a*").unwrap_err(), SafeTokenError::InvalidCharacter('*')); } /// The seam that lets the value objects take a platform's numeric id without an @@ -53,5 +50,5 @@ fn deserializing_a_token_validates_it_rather_than_admitting_it() { assert_eq!(ok.token.as_str(), "ok-1"); let err = serde_json::from_str::(r#"{"token":"not ok"}"#).expect_err("unsafe token must not deserialize"); - assert!(err.to_string().contains("invalid character"), "{err}"); + assert_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); } diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs index e73e9db63f..caab261950 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs @@ -2,7 +2,10 @@ use super::*; #[test] fn a_bucket_name_is_what_nats_accepts_as_one() { - assert_eq!(ClaimBucket::new("trogon-claims").expect("valid").as_str(), "trogon-claims"); + assert_eq!( + ClaimBucket::new("trogon-claims").expect("valid").as_str(), + "trogon-claims" + ); assert_eq!(ClaimBucket::new("claims_2").expect("valid").as_str(), "claims_2"); } diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/tests.rs index afcb8af4fb..f6a1c3b53d 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/tests.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/tests.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use super::widened_max_age; +use super::{ClaimBucket, ClaimBucketBinding, widened_max_age}; const HOUR: Duration = Duration::from_secs(3600); const NO_EXPIRY: Duration = Duration::ZERO; @@ -34,3 +34,17 @@ fn never_shrinks_from_no_expiry_to_finite() { fn no_expiry_to_no_expiry_is_a_no_op() { assert_eq!(widened_max_age(NO_EXPIRY, NO_EXPIRY), None); } + +/// A consumer validates the claim's `Nats-Claim-Bucket` header against +/// `bucket()` while reading through the handle, and a publisher that only writes +/// that header takes `into_store()` and drops the name. Both halves have to come +/// back out as the halves that went in, or the validation passes on one bucket +/// while the handle reads another. +#[test] +fn a_binding_hands_each_half_back_as_the_half_it_was_given() { + let bucket = ClaimBucket::new("claims").expect("valid bucket name"); + let binding = ClaimBucketBinding::for_test("a-store-handle", bucket.clone()); + + assert_eq!(binding.bucket(), &bucket); + assert_eq!(binding.into_store(), "a-store-handle"); +} From 70729c4505ed7fa0552ea0a7934a40bac5bb85c9 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 14:08:40 -0400 Subject: [PATCH 26/55] fix(channel): stop a failed pointer write from stranding a live session Signed-off-by: Yordis Prieto --- .../channel-bridge-telegram/src/pipeline.rs | 19 ++- .../src/pipeline_tests.rs | 119 ++++++++++++++++++ .../channel/trogon-channel/src/agent_port.rs | 6 +- 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs index 38fe96397e..44863a580f 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -259,7 +259,24 @@ where match self.port.prompt(&fresh, &event).await { Ok(outcome) => { record.current_session = Some(fresh.clone()); - self.store.update_conversation(&conversation_id, &record).await?; + if let Err(error) = self.store.update_conversation(&conversation_id, &record).await { + // The stored pointer still names active_session and + // every later repair mints its own id, so nothing + // will ever read this reply or hand this session + // back. Redelivery retries the prompt on the session + // the conversation still has. + let release = self.port.release_session(&fresh, ReleaseReason::RepairFailed).await; + self.renderer.discard(fresh.as_str()); + self.renderer.discard(active_session.as_str()); + warn!( + conversation = %conversation_id, + session = %fresh, + cancelled = ?release.cancelled, + closed = ?release.closed, + "Could not point the conversation at the fresh session; released it instead" + ); + return Err(PipelineError::Store(error)); + } self.renderer.discard(active_session.as_str()); // The suspicion that got us here is a guess, so the diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs index 5a8a5d05ac..607cbc7ab7 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs @@ -81,6 +81,11 @@ struct FakePort { /// How many upcoming turns end without streaming any text. A real agent /// does this when it acts only through tool calls. silent_turns: RefCell, + /// A KV bucket to drop once the next prompt has answered. The pipeline reads + /// the conversation, prompts, then writes the pointer, so a prompt is the + /// only place from which the write can be failed without also failing the + /// read that precedes it. + bucket_to_drop: RefCell>, } /// Consume one use of a scripted behaviour. @@ -103,6 +108,7 @@ impl FakePort { refusals: RefCell::new(0), creation_failures: RefCell::new(0), silent_turns: RefCell::new(0), + bucket_to_drop: RefCell::new(None), } } @@ -122,6 +128,10 @@ impl FakePort { *self.silent_turns.borrow_mut() = count; } + fn drop_bucket_after_next_reply(&self, js: &async_nats::jetstream::Context, bucket: &str) { + *self.bucket_to_drop.borrow_mut() = Some((js.clone(), bucket.to_string())); + } + async fn stream(&self, session: &AgentSessionId, text: &str) { let notification = SessionNotification::new( session.as_str().to_string(), @@ -170,6 +180,10 @@ impl trogon_channel::AgentPort for FakePort { } self.stream(session, &self.reply).await; + let dropped = self.bucket_to_drop.borrow_mut().take(); + if let Some((js, bucket)) = dropped { + js.delete_key_value(&bucket).await.expect("drop the KV bucket"); + } Ok(PromptOutcome::Completed) } @@ -807,6 +821,111 @@ async fn pipeline_keeps_the_session_when_a_fresh_one_fails_the_same_way() { assert_eq!(info.num_pending, 0); } +/// A fresh session that answered but could not be recorded is handed back rather +/// than left open. The write is what makes the rotation real, so once it fails +/// the conversation still names the old session and every later repair mints its +/// own id: nothing will ever read that reply or release that session. Without +/// the cleanup each redelivery of one message leaves the agent holding one more. +#[tokio::test] +async fn pipeline_hands_back_a_fresh_session_it_could_not_record() { + let server = NatsServer::start().await; + let client = async_nats::connect(&server.url).await.expect("connect"); + let js = async_nats::jetstream::new(client); + + js.create_stream(async_nats::jetstream::stream::Config { + name: "TELEGRAM".to_string(), + subjects: vec!["telegram.>".to_string()], + ..Default::default() + }) + .await + .expect("create TELEGRAM stream"); + + let store = ChannelStore::ensure(&js, "test").await.expect("ensure buckets"); + let principal = PrincipalId::new("telegram-42").expect("principal"); + let endpoint = Endpoint::new("telegram", "mybot", "42").expect("endpoint"); + store + .link_endpoint(&principal, &PrincipalRecord { display_name: None }, &endpoint) + .await + .expect("seed principal"); + + for (update_id, text) in [(1u64, "hello"), (2, "rotate")] { + js.publish("telegram.message", raw_update(update_id, 42, 42, text).into()) + .await + .expect("publish") + .await + .expect("ack"); + } + + let stream = js.get_stream("TELEGRAM").await.expect("get stream"); + let consumer = stream + .get_or_create_consumer( + "bridge-test", + async_nats::jetstream::consumer::pull::Config { + durable_name: Some("bridge-test".to_string()), + ..Default::default() + }, + ) + .await + .expect("consumer"); + let mut messages = consumer.messages().await.expect("messages"); + + let renderer = Rc::new(TelegramRenderClient::new()); + let port = FakePort::new(renderer.clone(), "hi there"); + let outbound = FakeOutbound::default(); + let triggers = CommandTriggers::default(); + let claims = unclaimed_resolver(); + let pipeline = Pipeline { + store: &store, + port: &port, + renderer: renderer.as_ref(), + outbound: &outbound, + claims: &claims, + bot_account: "mybot", + agent_id: "default", + triggers: &triggers, + ids: &UuidV7Generator, + }; + + pipeline + .handle_message(&next_message(&mut messages).await) + .await + .expect("handled"); + + // The old session looks lost, the fresh one answers, and the bucket the + // pointer lives in disappears in between. + port.reject_next_prompts(1); + port.drop_bucket_after_next_reply(&js, "channel_conversations_test"); + let error = pipeline + .handle_message(&next_message(&mut messages).await) + .await + .expect_err("the pointer write must fail"); + assert!( + matches!(error, PipelineError::Store(_)), + "the store failure must surface, got {error:?}" + ); + + assert_eq!( + *port.released.borrow(), + vec![("sess-2".to_string(), ReleaseReason::RepairFailed)], + "a session nothing points at must be handed back" + ); + for session in ["sess-1", "sess-2"] { + assert_eq!( + renderer.take_buffer(session), + None, + "{session} kept a reply nothing will ever send" + ); + } + assert_eq!( + *outbound.sent.borrow(), + vec![(42, "hi there".to_string())], + "only the turn that was recorded may reach the chat" + ); + + let info = settled_consumer_info(&stream, "bridge-test", 1).await; + assert_eq!(info.num_ack_pending, 1); +} + /// Everything the bridge cannot act on is acked and dropped rather than left to /// redeliver: none of it will parse, authorize, or route any better the second /// time, so redelivering it would wedge the consumer behind a message that can diff --git a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs index 2579a17e34..ad7ef3c415 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs @@ -76,8 +76,10 @@ pub trait AgentPortError: std::error::Error + 'static { pub enum ReleaseReason { /// The user asked for a fresh conversation. NewSession, - /// A session opened to repair a suspected lost session failed the same way, - /// so the bridge is handing back one it never got to use. + /// A session opened to repair a suspected lost session is being handed back + /// unused: either it failed the same way the old one did, or it answered and + /// the conversation could not be pointed at it, which leaves its reply + /// unreadable either way. RepairFailed, /// A suspected lost session was replaced by a fresh one that answered. The /// suspicion is a guess, so the agent may still hold the old session; it is From 122764e7adbf717009df9851a3c22e749b6b3106 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 14:08:40 -0400 Subject: [PATCH 27/55] fix(channel): let a trigger outside ASCII be reachable at all Signed-off-by: Yordis Prieto --- .../crates/channel/trogon-channel/src/command.rs | 4 +++- .../channel/trogon-channel/src/command_tests.rs | 16 ++++++++++++++++ .../trogon-channel/src/command_trigger.rs | 8 +++++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/rsworkspace/crates/channel/trogon-channel/src/command.rs b/rsworkspace/crates/channel/trogon-channel/src/command.rs index dc618dba65..8475039796 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/command.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/command.rs @@ -75,7 +75,9 @@ impl CommandTriggers { Some((token, account)) => (token, Some(account)), None => (head, None), }; - let token = token.to_ascii_lowercase(); + // The same fold `CommandTrigger` applied at construction, or a + // configured trigger outside ASCII would never match what was typed. + let token = token.to_lowercase(); let addressed_to_us = match addressed_to { None => true, Some(account) => account.eq_ignore_ascii_case(recipient_account), diff --git a/rsworkspace/crates/channel/trogon-channel/src/command_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/command_tests.rs index f8def806c0..8515b4f3b8 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/command_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/command_tests.rs @@ -60,6 +60,22 @@ fn triggers_are_configurable() { assert_eq!(triggers.parse("/new", "mybot").command, None); } +/// A trigger is chat text, so a deployment may configure one outside ASCII. The +/// factory and `parse` have to fold case the same way for that trigger to be +/// reachable at all: with an ASCII-only fold, `/AÑADIR` would be stored as +/// `/aÑadir` and no plausible typing of it would ever match. +#[test] +fn a_non_ascii_trigger_matches_whatever_case_it_is_typed_in() { + let triggers = CommandTriggers::new(["/AÑADIR"]).expect("valid triggers"); + for typed in ["/añadir", "/AÑADIR", "/Añadir"] { + assert_eq!( + triggers.parse(typed, "mybot").command, + Some(Command::NewSession), + "{typed:?} must reach the trigger" + ); + } +} + #[test] fn blank_and_multi_token_triggers_are_rejected() { assert!(matches!(CommandTriggers::new([" "]), Err(CommandTriggerError::Empty))); diff --git a/rsworkspace/crates/channel/trogon-channel/src/command_trigger.rs b/rsworkspace/crates/channel/trogon-channel/src/command_trigger.rs index 5b38fb6836..5672ac17b0 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/command_trigger.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/command_trigger.rs @@ -13,6 +13,12 @@ pub enum CommandTriggerError { /// One normalized trigger matched against the first token of a message. /// Guarantees a non-empty, single-token, lowercased value at construction. +/// +/// Lowercasing is Unicode-aware rather than ASCII-only, because a trigger is +/// chat text a person types and nothing restricts it to ASCII. Matching folds +/// the incoming token the same way (see [`crate::CommandTriggers::parse`]); an +/// ASCII-only fold would leave `/Nuevo` matching but `/AÑADIR` not, which is a +/// distinction no operator would predict. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandTrigger(String); @@ -26,7 +32,7 @@ impl TryFrom for CommandTrigger { type Error = CommandTriggerError; fn try_from(input: CommandTriggerInput) -> Result { - let trigger = input.as_str().trim().to_ascii_lowercase(); + let trigger = input.as_str().trim().to_lowercase(); if trigger.is_empty() { return Err(CommandTriggerError::Empty); } From 5842fce8cbe431da9b5cc0f1936eba2bd41b59cb Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 14:08:40 -0400 Subject: [PATCH 28/55] refactor(channel): say why a media type was rejected rather than echo it Signed-off-by: Yordis Prieto --- .../channel/trogon-channel/src/event.rs | 33 +++++++++++++++---- .../channel/trogon-channel/src/event_tests.rs | 13 ++++++-- .../crates/channel/trogon-channel/src/lib.rs | 4 +-- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/rsworkspace/crates/channel/trogon-channel/src/event.rs b/rsworkspace/crates/channel/trogon-channel/src/event.rs index 3ecbd6b453..9a8210dd3f 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event.rs @@ -11,8 +11,8 @@ use serde::{Deserialize, Deserializer, Serialize}; pub enum EventFieldError { #[error("a message reference may not be blank")] BlankMessageRef, - #[error("media type {0:?} is not a type/subtype pair")] - NotAMediaType(String), + #[error(transparent)] + NotAMediaType(#[from] MediaTypeError), } /// Who sent a message, in the sending platform's own terms. This is an @@ -133,6 +133,21 @@ pub enum AttachmentKind { Document, } +/// Which rule a would-be media type broke. Named reasons rather than a copy of +/// the input: the rejected text belongs to whatever log records the rejection, +/// and a caller matching on why can tell a missing subtype from a stray space. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum MediaTypeError { + #[error("a media type needs a type and a subtype separated by '/'")] + MissingSeparator, + #[error("a media type's type may not be empty")] + EmptyType, + #[error("a media type's subtype may not be empty")] + EmptySubtype, + #[error("a media type may not contain whitespace")] + InteriorWhitespace, +} + /// An IANA media type, normalized to lower case because the standard defines /// type and subtype as case-insensitive and a caller comparing them as bytes /// would otherwise be wrong for `IMAGE/PNG`. Parameters are kept as given. @@ -144,12 +159,16 @@ impl MimeType { pub fn new(raw: impl Into) -> Result { let raw = raw.into(); let trimmed = raw.trim(); - let (kind, subtype) = trimmed - .split_once('/') - .ok_or_else(|| EventFieldError::NotAMediaType(raw.clone()))?; + let (kind, subtype) = trimmed.split_once('/').ok_or(MediaTypeError::MissingSeparator)?; let subtype_only = subtype.split(';').next().unwrap_or_default().trim(); - if kind.is_empty() || subtype_only.is_empty() || trimmed.chars().any(char::is_whitespace) { - return Err(EventFieldError::NotAMediaType(raw)); + if kind.is_empty() { + return Err(MediaTypeError::EmptyType.into()); + } + if subtype_only.is_empty() { + return Err(MediaTypeError::EmptySubtype.into()); + } + if trimmed.chars().any(char::is_whitespace) { + return Err(MediaTypeError::InteriorWhitespace.into()); } Ok(Self(trimmed.to_ascii_lowercase())) } diff --git a/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs index d87926a445..be7ca21013 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs @@ -64,9 +64,16 @@ fn a_media_type_normalizes_case_so_comparisons_hold() { #[test] fn a_media_type_needs_a_type_and_a_subtype() { - for raw in ["image", "image/", "/png", "", "image / png"] { - assert!( - matches!(MimeType::new(raw), Err(EventFieldError::NotAMediaType(_))), + for (raw, reason) in [ + ("image", MediaTypeError::MissingSeparator), + ("", MediaTypeError::MissingSeparator), + ("image/", MediaTypeError::EmptySubtype), + ("/png", MediaTypeError::EmptyType), + ("image / png", MediaTypeError::InteriorWhitespace), + ] { + assert_eq!( + MimeType::new(raw).unwrap_err(), + EventFieldError::NotAMediaType(reason), "{raw:?} must not be a media type" ); } diff --git a/rsworkspace/crates/channel/trogon-channel/src/lib.rs b/rsworkspace/crates/channel/trogon-channel/src/lib.rs index 105fefe564..d9df933e7b 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/lib.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/lib.rs @@ -35,8 +35,8 @@ pub use command_trigger_input::CommandTriggerInput; pub use conversation::{AgentId, ConversationId, ConversationRecord}; pub use endpoint::{Endpoint, EndpointError, PrincipalId}; pub use event::{ - Attachment, AttachmentKind, EventFieldError, InboundEvent, MessageRef, MimeType, PlatformRef, PlatformUserId, - Sender, + Attachment, AttachmentKind, EventFieldError, InboundEvent, MediaTypeError, MessageRef, MimeType, PlatformRef, + PlatformUserId, Sender, }; pub use render::RenderCommand; pub use safe_token::{SafeToken, SafeTokenError}; From 953b14ac4067a59f3b4beacd6240a5c29a72f38e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 14:21:07 -0400 Subject: [PATCH 29/55] fix(channel): keep a media type's parameters out of its normalization Case-insensitivity is defined for the type and subtype only, so folding a multipart boundary changed what it delimits, and a subtype was accepted with a second slash in it. Signed-off-by: Yordis Prieto --- .../channel/trogon-channel/src/event.rs | 31 ++++++++++++++----- .../channel/trogon-channel/src/event_tests.rs | 13 ++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/rsworkspace/crates/channel/trogon-channel/src/event.rs b/rsworkspace/crates/channel/trogon-channel/src/event.rs index 9a8210dd3f..5167dd9fc3 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event.rs @@ -144,13 +144,19 @@ pub enum MediaTypeError { EmptyType, #[error("a media type's subtype may not be empty")] EmptySubtype, + #[error("a media type has one subtype, so its subtype may not contain '/'")] + SubtypeIsNotOne, #[error("a media type may not contain whitespace")] InteriorWhitespace, } -/// An IANA media type, normalized to lower case because the standard defines -/// type and subtype as case-insensitive and a caller comparing them as bytes -/// would otherwise be wrong for `IMAGE/PNG`. Parameters are kept as given. +/// An IANA media type, whose type and subtype are normalized to lower case +/// because the standard defines those two as case-insensitive and a caller +/// comparing them as bytes would otherwise be wrong for `IMAGE/PNG`. +/// +/// Parameters are kept byte for byte, because case-insensitivity stops at the +/// subtype: a `multipart` boundary and a `filename` are values a sender chose +/// and folding them changes what they refer to. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] #[serde(transparent)] pub struct MimeType(String); @@ -159,18 +165,29 @@ impl MimeType { pub fn new(raw: impl Into) -> Result { let raw = raw.into(); let trimmed = raw.trim(); - let (kind, subtype) = trimmed.split_once('/').ok_or(MediaTypeError::MissingSeparator)?; - let subtype_only = subtype.split(';').next().unwrap_or_default().trim(); + let (essence, parameters) = match trimmed.split_once(';') { + Some((essence, parameters)) => (essence, Some(parameters)), + None => (trimmed, None), + }; + let (kind, subtype) = essence.split_once('/').ok_or(MediaTypeError::MissingSeparator)?; if kind.is_empty() { return Err(MediaTypeError::EmptyType.into()); } - if subtype_only.is_empty() { + if subtype.is_empty() { return Err(MediaTypeError::EmptySubtype.into()); } + if subtype.contains('/') { + return Err(MediaTypeError::SubtypeIsNotOne.into()); + } if trimmed.chars().any(char::is_whitespace) { return Err(MediaTypeError::InteriorWhitespace.into()); } - Ok(Self(trimmed.to_ascii_lowercase())) + let mut normalized = format!("{}/{}", kind.to_ascii_lowercase(), subtype.to_ascii_lowercase()); + if let Some(parameters) = parameters { + normalized.push(';'); + normalized.push_str(parameters); + } + Ok(Self(normalized)) } pub fn as_str(&self) -> &str { diff --git a/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs index be7ca21013..63a9ce4803 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs @@ -68,7 +68,9 @@ fn a_media_type_needs_a_type_and_a_subtype() { ("image", MediaTypeError::MissingSeparator), ("", MediaTypeError::MissingSeparator), ("image/", MediaTypeError::EmptySubtype), + ("image/;charset=utf-8", MediaTypeError::EmptySubtype), ("/png", MediaTypeError::EmptyType), + ("image/png/extra", MediaTypeError::SubtypeIsNotOne), ("image / png", MediaTypeError::InteriorWhitespace), ] { assert_eq!( @@ -87,6 +89,17 @@ fn a_media_type_keeps_parameters() { ); } +/// Case-insensitivity is defined for the type and the subtype only. A +/// `multipart` boundary is a delimiter the sender picked and has to survive as +/// typed, or the body it delimits stops being parseable. +#[test] +fn a_media_type_normalizes_the_subtype_without_touching_its_parameters() { + assert_eq!( + MimeType::new("MULTIPART/Mixed;boundary=AbCd").expect("valid").as_str(), + "multipart/mixed;boundary=AbCd" + ); +} + /// The handle doubles as the readiness key in `channel_media_{prefix}` /// (ADR#0044), so it has to be safe as a KV key. #[test] From 5a60b78d1d10b87dc5c57d60e392dc77facef89c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 14:28:00 -0400 Subject: [PATCH 30/55] fix(channel): stop a second conversation from burying an endpoint's first Two workers can each read an endpoint as unbound, and an overwriting bind let the loser hide a conversation whose id nothing else knows, so a live chat would carry on against a record no message reaches again. Signed-off-by: Yordis Prieto --- rsworkspace/Cargo.lock | 1 + .../channel-bridge-telegram/src/pipeline.rs | 23 +++- .../crates/channel/trogon-channel/Cargo.toml | 1 + .../crates/channel/trogon-channel/src/lib.rs | 2 +- .../channel/trogon-channel/src/store.rs | 122 +++++++++++++++--- .../channel/trogon-channel/src/store_tests.rs | 114 +++++++++++++++- .../src/jetstream/create_conflicts.rs | 9 ++ .../platform/trogon-nats/src/jetstream/mod.rs | 4 +- 8 files changed, 249 insertions(+), 27 deletions(-) diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 525f5656a5..189126b939 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -6905,6 +6905,7 @@ dependencies = [ "tracing", "trogon-nats", "trogon-std", + "uuid", ] [[package]] diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs index 44863a580f..6516fccdc2 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -9,7 +9,7 @@ use crate::render::{TelegramRenderClient, chunk_text}; use tracing::{info, warn}; use trogon_channel::{ AgentId, AgentPort, AgentPortError as _, AgentSessionId, ChannelStore, ChannelStoreError, Command, CommandTriggers, - ConversationId, ConversationRecord, EndpointError, InboundEvent, ReleaseReason, + ConversationId, ConversationRecord, EndpointBinding, EndpointError, InboundEvent, ReleaseReason, }; use trogon_nats::jetstream::{ClaimResolveError, ClaimResolver, ObjectStoreGet}; use trogon_std::NowV7; @@ -180,12 +180,25 @@ where created_at: now, last_activity_at: now, }; - let id = self + match self .store .create_conversation(&event.endpoint, &record, self.ids) - .await?; - info!(conversation = %id, endpoint = %event.endpoint, agent = %record.agent_id, "Created conversation"); - (id, record) + .await? + { + EndpointBinding::Created(id) => { + info!(conversation = %id, endpoint = %event.endpoint, agent = %record.agent_id, "Created conversation"); + (id, record) + } + EndpointBinding::AlreadyBound(id, bound) => { + info!( + conversation = %id, + endpoint = %event.endpoint, + agent = %bound.agent_id, + "Endpoint was bound while this message was being handled; continuing on that conversation" + ); + (id, bound) + } + } } }; diff --git a/rsworkspace/crates/channel/trogon-channel/Cargo.toml b/rsworkspace/crates/channel/trogon-channel/Cargo.toml index f1b492a9dc..e28ad768a8 100644 --- a/rsworkspace/crates/channel/trogon-channel/Cargo.toml +++ b/rsworkspace/crates/channel/trogon-channel/Cargo.toml @@ -20,3 +20,4 @@ tracing = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } trogon-nats = { workspace = true, features = ["test-support"] } +uuid = { workspace = true } diff --git a/rsworkspace/crates/channel/trogon-channel/src/lib.rs b/rsworkspace/crates/channel/trogon-channel/src/lib.rs index d9df933e7b..a57ace2891 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/lib.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/lib.rs @@ -40,4 +40,4 @@ pub use event::{ }; pub use render::RenderCommand; pub use safe_token::{SafeToken, SafeTokenError}; -pub use store::{ChannelStore, ChannelStoreError}; +pub use store::{ChannelStore, ChannelStoreError, EndpointBinding, ReserveEndpointError}; diff --git a/rsworkspace/crates/channel/trogon-channel/src/store.rs b/rsworkspace/crates/channel/trogon-channel/src/store.rs index 4086e31562..495cb43bda 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store.rs @@ -3,7 +3,9 @@ use crate::endpoint::{Endpoint, PrincipalId}; use async_nats::jetstream; use serde::{Deserialize, Serialize}; use tracing::info; -use trogon_nats::jetstream::{is_create_key_value_already_exists, is_get_key_value_not_found}; +use trogon_nats::jetstream::{ + is_create_key_already_exists, is_create_key_value_already_exists, is_get_key_value_not_found, +}; use trogon_std::NowV7; #[cfg(test)] @@ -37,7 +39,7 @@ pub enum ChannelStoreError { endpoint: String, conversation: ConversationId, #[source] - source: async_nats::jetstream::kv::PutError, + source: ReserveEndpointError, }, /// Binding failed and so did the rollback, so the conversation record is /// still in the bucket with nothing pointing at it. Both failures are kept @@ -49,12 +51,40 @@ pub enum ChannelStoreError { OrphanedConversation { endpoint: String, conversation: ConversationId, - bind_error: async_nats::jetstream::kv::PutError, + bind_error: ReserveEndpointError, #[source] source: async_nats::jetstream::kv::DeleteError, }, } +/// Why an endpoint could not be pointed at a new conversation. Two ways in, +/// because an unbound endpoint is claimed with a create while one left pointing +/// at a record that is gone is re-pointed with a compare-and-swap. +#[derive(Debug, thiserror::Error)] +pub enum ReserveEndpointError { + #[error(transparent)] + Claim(#[from] async_nats::jetstream::kv::CreateError), + /// The stale binding moved between the read and the swap, so another writer + /// re-pointed the endpoint first. + #[error(transparent)] + Repoint(#[from] async_nats::jetstream::kv::UpdateError), +} + +/// Which conversation an endpoint is bound to once +/// [`ChannelStore::create_conversation`] returns. +#[derive(Debug)] +pub enum EndpointBinding { + /// The record handed in is the endpoint's conversation now. + Created(ConversationId), + /// The endpoint was claimed between the caller's lookup and this + /// reservation, so the winner's conversation is the one the endpoint feeds + /// and the record this call would have added has been rolled back. The + /// caller has to continue with what comes back here: its own record is + /// gone, and a second conversation on one endpoint would split the history + /// a user sees as one chat. + AlreadyBound(ConversationId, ConversationRecord), +} + /// What we know about a principal beyond its id. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrincipalRecord { @@ -169,34 +199,94 @@ impl ChannelStore { /// record nothing can reach. It is rolled back before the error returns, /// because each attempt generates a fresh id, so without the rollback every /// redelivery of one message would leave another unreachable record behind. + /// + /// The binding is claimed rather than overwritten. Callers reach this after + /// a lookup found the endpoint unbound, and two workers can hold that answer + /// at once; an overwrite would let the loser's binding bury the winner's + /// conversation, and the buried record is unreachable forever because + /// nothing else knows its id. Losing the claim is therefore not a failure: + /// the winner's conversation comes back instead ([`EndpointBinding`]). + /// + /// A claim also has to cope with a binding that points at a record that is + /// no longer there (see `a_binding_with_no_conversation_record_reads_as_unbound`), + /// which reads as unbound and so arrives here. That one is re-pointed on the + /// revision it was read at, because an endpoint whose claim can only ever be + /// refused is an endpoint no message can get through. pub async fn create_conversation( &self, endpoint: &Endpoint, record: &ConversationRecord, ids: &impl NowV7, - ) -> Result { + ) -> Result { let id = ConversationId::generate(ids); let conversation = serde_json::to_vec(record)?; let binding = serde_json::to_vec(&id)?; self.conversations.put(id.as_str(), conversation.into()).await?; - let Err(source) = self.bindings.put(endpoint.kv_key(), binding.into()).await else { - return Ok(id); + let taken = match self.bindings.create(endpoint.kv_key(), binding.clone().into()).await { + Ok(_) => return Ok(EndpointBinding::Created(id)), + Err(taken) if is_create_key_already_exists(&taken) => taken, + Err(source) => return Err(self.unwind(endpoint, id, source.into()).await), }; - match self.conversations.delete(id.as_str()).await { - Ok(()) => Err(ChannelStoreError::BindEndpoint { + // Read the claim that won as an entry rather than a value: replacing a + // stale one is only safe against the revision it was read at. + let Some(bound) = self.bindings.entry(endpoint.kv_key()).await? else { + return Err(self.unwind(endpoint, id, taken.into()).await); + }; + let bound_id: ConversationId = serde_json::from_slice(&bound.value)?; + + if let Some(bytes) = self.conversations.get(bound_id.as_str()).await? { + let bound_record = serde_json::from_slice(&bytes)?; + return match self.conversations.delete(id.as_str()).await { + Ok(()) => Ok(EndpointBinding::AlreadyBound(bound_id, bound_record)), + Err(cleanup) => Err(ChannelStoreError::OrphanedConversation { + endpoint: endpoint.kv_key(), + conversation: id, + bind_error: taken.into(), + source: cleanup, + }), + }; + } + + info!( + endpoint = %endpoint, + conversation = %bound_id, + "Endpoint was bound to a conversation record that is gone; re-pointing it" + ); + match self + .bindings + .update(endpoint.kv_key(), binding.into(), bound.revision) + .await + { + Ok(_) => Ok(EndpointBinding::Created(id)), + Err(source) => Err(self.unwind(endpoint, id, source.into()).await), + } + } + + /// Take back the record this call wrote, so a reservation that never + /// happened leaves nothing behind. `refused` travels into the error because + /// a rollback that fails too leaves the record for an operator to sweep, and + /// both causes are what makes that actionable. + async fn unwind( + &self, + endpoint: &Endpoint, + conversation: ConversationId, + refused: ReserveEndpointError, + ) -> ChannelStoreError { + match self.conversations.delete(conversation.as_str()).await { + Ok(()) => ChannelStoreError::BindEndpoint { endpoint: endpoint.kv_key(), - conversation: id, - source, - }), - Err(cleanup) => Err(ChannelStoreError::OrphanedConversation { + conversation, + source: refused, + }, + Err(source) => ChannelStoreError::OrphanedConversation { endpoint: endpoint.kv_key(), - conversation: id, - bind_error: source, - source: cleanup, - }), + conversation, + bind_error: refused, + source, + }, } } diff --git a/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs index 084ab9bfd9..fac10c2125 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs @@ -3,6 +3,7 @@ use crate::agent_port::AgentSessionId; use crate::conversation::AgentId; use trogon_nats::test_support::JetStreamTestServer; use trogon_std::UuidV7Generator; +use uuid::Uuid; fn endpoint(peer: &str) -> Endpoint { Endpoint::new("telegram", "bot", peer).expect("endpoint") @@ -22,6 +23,33 @@ fn record(principal: &PrincipalId) -> ConversationRecord { } } +/// Conversation ids handed out in a known order, so a record a call was +/// supposed to take back can be looked for by name. +const QUEUED_IDS: [Uuid; 2] = [ + Uuid::from_u128(0x0195_0000_7000_8000_0000_0000_0000_0001), + Uuid::from_u128(0x0195_0000_7000_8000_0000_0000_0000_0002), +]; + +#[derive(Default)] +struct QueuedIds { + handed_out: std::cell::Cell, +} + +impl NowV7 for QueuedIds { + fn now_v7(&self) -> Uuid { + let index = self.handed_out.get(); + self.handed_out.set(index + 1); + QUEUED_IDS[index] + } +} + +fn created(binding: EndpointBinding) -> ConversationId { + match binding { + EndpointBinding::Created(id) => id, + EndpointBinding::AlreadyBound(id, _) => panic!("expected a fresh conversation, the endpoint was bound to {id}"), + } +} + /// A bridge restarts far more often than it first starts, so opening the /// existing buckets is the common path, not the exceptional one. #[tokio::test] @@ -105,10 +133,12 @@ async fn a_conversation_round_trips_through_its_buckets() { let endpoint = endpoint("222"); let principal = principal("user-2"); let mut record = record(&principal); - let id = store - .create_conversation(&endpoint, &record, &UuidV7Generator) - .await - .expect("create conversation"); + let id = created( + store + .create_conversation(&endpoint, &record, &UuidV7Generator) + .await + .expect("create conversation"), + ); record.current_session = Some(AgentSessionId::new("sess-1").expect("session id")); store.update_conversation(&id, &record).await.expect("update"); @@ -155,6 +185,82 @@ async fn a_binding_with_no_conversation_record_reads_as_unbound() { .is_none(), "a dangling binding must not be reported as a bound conversation" ); + + // Reading as unbound is what sends the next message here, so this is the + // only path that can clear the dangling pointer. Refusing the claim because + // the key is taken would leave the endpoint unable to ever route again. + let record = record(&principal("user-3")); + let fresh = created( + store + .create_conversation(&endpoint, &record, &UuidV7Generator) + .await + .expect("re-point the dangling binding"), + ); + + let (bound_id, _) = store + .conversation_for(&endpoint) + .await + .expect("conversation lookup") + .expect("the endpoint routes again"); + assert_eq!( + bound_id, fresh, + "the endpoint must point at the conversation it can reach" + ); +} + +/// Two workers can each read an endpoint as unbound and both get here, and only +/// one of them can own it: whoever binds second must not overwrite the winner's +/// binding, because nothing else knows the id it would bury and the user's chat +/// would carry on against a conversation no message ever reaches again. +#[tokio::test] +async fn a_second_conversation_on_one_endpoint_yields_to_the_one_already_bound() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + let store = ChannelStore::ensure(&js, "contested").await.expect("ensure"); + + let ids = QueuedIds::default(); + let endpoint = endpoint("666"); + let winner = created( + store + .create_conversation(&endpoint, &record(&principal("user-6")), &ids) + .await + .expect("first claim"), + ); + + // What the loser sees: it read the endpoint as unbound before the winner + // wrote, so it arrives with a record of its own already built. + let outcome = store + .create_conversation(&endpoint, &record(&principal("user-6-again")), &ids) + .await + .expect("losing the claim is not a failure"); + let loser = ConversationId::from_string(QUEUED_IDS[1].simple().to_string()).expect("conversation id"); + + let EndpointBinding::AlreadyBound(bound_id, bound_record) = outcome else { + panic!("the second claim must yield to the binding already there, got {outcome:?}"); + }; + assert_eq!(bound_id, winner, "the winner's conversation is the endpoint's"); + assert_eq!( + bound_record.principal, + principal("user-6"), + "the caller must be handed the record it has to continue on" + ); + + let (still_bound, _) = store + .conversation_for(&endpoint) + .await + .expect("conversation lookup") + .expect("the endpoint is still bound"); + assert_eq!(still_bound, winner, "the loser must not have moved the binding"); + + assert!( + store + .conversations + .get(loser.as_str()) + .await + .expect("read the rolled back record") + .is_none(), + "the record the loser wrote must not survive as an unreachable one" + ); } /// The exposure the write order creates: the record goes in first so no binding diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/create_conflicts.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/create_conflicts.rs index eee72daf4d..ccaf06a68d 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/create_conflicts.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/create_conflicts.rs @@ -1,6 +1,7 @@ use async_nats::jetstream::{ ErrorCode, context::{CreateKeyValueError, CreateKeyValueErrorKind, CreateStreamError, CreateStreamErrorKind}, + kv::{CreateError, CreateErrorKind}, }; pub fn is_create_stream_already_exists(error: &CreateStreamError) -> bool { @@ -21,5 +22,13 @@ pub fn is_create_key_value_already_exists(error: &CreateKeyValueError) -> bool { .is_some_and(is_create_stream_already_exists) } +/// A `create` that lost the key to somebody else, as opposed to one that never +/// reached the bucket. The only conflict a caller reserving a key can act on: +/// the key is taken, so there is a winner to read, whereas any other failure +/// says nothing about what is stored. +pub fn is_create_key_already_exists(error: &CreateError) -> bool { + error.kind() == CreateErrorKind::AlreadyExists +} + #[cfg(test)] mod tests; diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs index 56786fcbbc..c91a8f79ae 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs @@ -23,7 +23,9 @@ pub use client::{ ConsumerError, GetStreamError, MessagesError, NatsJetStreamClient, NatsJetStreamConsumer, PublishAckFuture, PublishError, StreamError, }; -pub use create_conflicts::{is_create_key_value_already_exists, is_create_stream_already_exists}; +pub use create_conflicts::{ + is_create_key_already_exists, is_create_key_value_already_exists, is_create_stream_already_exists, +}; pub use message::{JsAck, JsAckWith, JsDispatchMessage, JsDoubleAck, JsDoubleAckWith, JsMessageRef, JsRequestMessage}; pub use not_found::{is_get_key_value_not_found, is_get_stream_not_found}; #[cfg(not(coverage))] From 8d29d07527ac8765e4a638d4e948003d6cbae43e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 14:37:09 -0400 Subject: [PATCH 31/55] fix(nats): stop a claim's bucket header from naming a bucket it was not written to Signed-off-by: Yordis Prieto --- .../src/pipeline_tests.rs | 10 ++----- .../platform/trogon-gateway/src/http/tests.rs | 4 +-- .../platform/trogon-gateway/src/main.rs | 11 ++------ .../src/source/datadog/server/tests.rs | 4 +-- .../src/source/discord/gateway/tests.rs | 4 +-- .../src/source/github/server/tests.rs | 10 +++---- .../src/source/gitlab/server/tests.rs | 10 +++---- .../src/source/incidentio/server/tests.rs | 4 +-- .../src/source/linear/server/tests.rs | 4 +-- .../source/linear/webhook_fixtures_tests.rs | 4 +-- .../source/microsoft_graph/server/tests.rs | 4 +-- .../src/source/notion/server/tests.rs | 4 +-- .../src/source/sentry/server/tests.rs | 4 +-- .../src/source/slack/server/tests.rs | 10 +++---- .../src/source/slack/socket_mode/tests.rs | 4 +-- .../src/source/telegram/server/tests.rs | 10 +++---- .../src/source/twitter/server/tests.rs | 10 +++---- .../trogon-nats/src/jetstream/claim_check.rs | 22 +++++++++++---- .../claim_check/integration_tests.rs | 28 ++++++------------- .../src/jetstream/claim_check/tests.rs | 3 +- .../trogon-nats/src/jetstream/object_store.rs | 15 ++++------ .../object_store/integration_tests.rs | 8 +++--- .../src/jetstream/object_store/tests.rs | 10 +++---- 23 files changed, 87 insertions(+), 110 deletions(-) diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs index 607cbc7ab7..70e30435d5 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs @@ -319,7 +319,7 @@ fn unclaimed_resolver() -> ClaimResolver { /// already done so. #[cfg(not(coverage))] async fn claim_resolver(js: &async_nats::jetstream::Context) -> ClaimResolver { - NatsObjectStore::provision_claim_bucket(js, &ClaimBucket::default(), ClaimRetention::EventSourced) + NatsObjectStore::provision_claim_bucket(js, ClaimBucket::default(), ClaimRetention::EventSourced) .await .expect("provision claim bucket"); ClaimResolver::new( @@ -499,9 +499,7 @@ async fn pipeline_redeems_a_claim_checked_update() { NatsJetStreamClient::new(js.clone()), NatsObjectStore::bind_claim_bucket(&js, ClaimBucket::default()) .await - .expect("bind claim bucket") - .into_store(), - DEFAULT_CLAIM_BUCKET.to_string(), + .expect("bind claim bucket"), MaxPayload::from_server_limit(0), ); let outcome = gateway @@ -583,9 +581,7 @@ async fn pipeline_leaves_an_unredeemable_claim_unacked() { NatsJetStreamClient::new(js.clone()), NatsObjectStore::bind_claim_bucket(&js, ClaimBucket::default()) .await - .expect("bind claim bucket") - .into_store(), - DEFAULT_CLAIM_BUCKET.to_string(), + .expect("bind claim bucket"), MaxPayload::from_server_limit(0), ); let outcome = gateway diff --git a/rsworkspace/crates/platform/trogon-gateway/src/http/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/http/tests.rs index d739a867fa..98d6a26838 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/http/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/http/tests.rs @@ -6,6 +6,7 @@ use axum::http::{Request, StatusCode}; use hmac::{KeyInit, Mac}; use std::io::Write; use tower::ServiceExt; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ClaimCheckPublisher, MaxPayload, MockJetStreamPublisher, MockObjectStore}; type HmacSha256 = hmac::Hmac; @@ -13,8 +14,7 @@ type HmacSha256 = hmac::Hmac; fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } diff --git a/rsworkspace/crates/platform/trogon-gateway/src/main.rs b/rsworkspace/crates/platform/trogon-gateway/src/main.rs index bfe57923ed..12800fcad0 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/main.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/main.rs @@ -98,8 +98,8 @@ async fn serve(resolved: config::ResolvedConfig) -> anyhow::Result<()> { .max_stream_max_age() .map(|stream_max_age| ClaimRetention::tracking(stream_max_age, CLAIM_CHECK_TTL_GRACE)) .unwrap_or(ClaimRetention::EventSourced); - let claim_bucket = ClaimBucket::default(); - let object_store = NatsObjectStore::provision_claim_bucket(&js_context, &claim_bucket, claim_retention).await?; + let claim_binding = + NatsObjectStore::provision_claim_bucket(&js_context, ClaimBucket::default(), claim_retention).await?; let client = NatsJetStreamClient::new(js_context); streams::provision(&client, &resolved).await?; @@ -138,12 +138,7 @@ async fn serve(resolved: config::ResolvedConfig) -> anyhow::Result<()> { let port = resolved.http_server.port; let mut join_set: JoinSet = JoinSet::new(); - let publisher = ClaimCheckPublisher::new( - client.clone(), - object_store.clone(), - claim_bucket.as_str().to_string(), - nats.clone(), - ); + let publisher = ClaimCheckPublisher::new(client.clone(), claim_binding, nats.clone()); { if let Some(ref cfg) = resolved.discord { diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/datadog/server/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/datadog/server/tests.rs index 1cc99f36e3..bbdae449ad 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/datadog/server/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/datadog/server/tests.rs @@ -4,6 +4,7 @@ use axum::http::Request; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; use trogon_nats::jetstream::StreamMaxAge; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, }; @@ -14,8 +15,7 @@ const DEFAULT_HEADER: &str = "x-datadog-webhook-token"; fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/discord/gateway/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/discord/gateway/tests.rs index f05c6ea384..66629d7b72 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/discord/gateway/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/discord/gateway/tests.rs @@ -1,4 +1,5 @@ use super::*; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, StreamMaxAge, }; @@ -7,8 +8,7 @@ use trogon_std::NonZeroDuration; fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/github/server/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/github/server/tests.rs index 73707fee54..60783214f0 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/github/server/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/github/server/tests.rs @@ -7,6 +7,7 @@ use std::time::Duration; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; use trogon_nats::jetstream::StreamMaxAge; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, }; @@ -17,8 +18,7 @@ type HmacSha256 = Hmac; fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } @@ -291,8 +291,7 @@ async fn ack_failure_returns_500() { let state = AppState { publisher: ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ), webhook_secret: GitHubWebhookSecret::new(TEST_SECRET).unwrap(), @@ -323,8 +322,7 @@ async fn ack_timeout_returns_500() { let state = AppState { publisher: ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ), webhook_secret: GitHubWebhookSecret::new(TEST_SECRET).unwrap(), diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/gitlab/server/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/gitlab/server/tests.rs index cdaa6b6b49..d9a1ba777a 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/gitlab/server/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/gitlab/server/tests.rs @@ -9,6 +9,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; use trogon_nats::jetstream::StreamMaxAge; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, }; @@ -18,8 +19,7 @@ const TEST_SIGNING_TOKEN: &str = "whsec_MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5 fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } @@ -430,8 +430,7 @@ async fn ack_failure_returns_500() { let state = AppState { publisher: ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ), signing_token: signing_token(), @@ -458,8 +457,7 @@ async fn ack_timeout_returns_500() { let state = AppState { publisher: ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ), signing_token: signing_token(), diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/incidentio/server/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/incidentio/server/tests.rs index c033241b40..d9959d7a41 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/incidentio/server/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/incidentio/server/tests.rs @@ -8,6 +8,7 @@ use sha2::Sha256; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; use trogon_nats::jetstream::StreamMaxAge; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, }; @@ -21,8 +22,7 @@ fn test_secret() -> String { fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/linear/server/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/linear/server/tests.rs index c19d7d3625..3991b42c4f 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/linear/server/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/linear/server/tests.rs @@ -6,6 +6,7 @@ use sha2::Sha256; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; use trogon_nats::jetstream::StreamMaxAge; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, }; @@ -15,8 +16,7 @@ type HmacSha256 = Hmac; fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/linear/webhook_fixtures_tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/linear/webhook_fixtures_tests.rs index 1d38060929..3f93bf9c60 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/linear/webhook_fixtures_tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/linear/webhook_fixtures_tests.rs @@ -4,6 +4,7 @@ use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; use tower::ServiceExt; use trogon_nats::NatsToken; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ClaimCheckPublisher, MaxPayload, MockJetStreamPublisher, MockObjectStore, StreamMaxAge}; use trogon_std::NonZeroDuration; @@ -28,8 +29,7 @@ fn make_app() -> (MockJetStreamPublisher, axum::Router) { let publisher = MockJetStreamPublisher::new(); let cc = ClaimCheckPublisher::new( publisher.clone(), - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ); let config = super::config::LinearConfig { diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/microsoft_graph/server/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/microsoft_graph/server/tests.rs index 74c62e994a..7f37a403f4 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/microsoft_graph/server/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/microsoft_graph/server/tests.rs @@ -5,6 +5,7 @@ use axum::http::Request; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; use trogon_nats::jetstream::StreamMaxAge; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, }; @@ -14,8 +15,7 @@ const TEST_CLIENT_STATE: &str = "secret-client-state"; fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/notion/server/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/notion/server/tests.rs index a3e014df44..f7ebf81b9c 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/notion/server/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/notion/server/tests.rs @@ -6,6 +6,7 @@ use sha2::Sha256; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; use trogon_nats::jetstream::StreamMaxAge; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, }; @@ -17,8 +18,7 @@ const TEST_VERIFICATION_TOKEN: &str = "notion-verification-token-example"; fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/sentry/server/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/sentry/server/tests.rs index e01e42d623..14cc16b2b3 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/sentry/server/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/sentry/server/tests.rs @@ -12,6 +12,7 @@ use sha2::Sha256; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; use trogon_nats::jetstream::StreamMaxAge; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, }; @@ -23,8 +24,7 @@ const TEST_SECRET: &str = "test-secret"; fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/slack/server/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/slack/server/tests.rs index 135c076077..240c4d9033 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/slack/server/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/slack/server/tests.rs @@ -6,6 +6,7 @@ use sha2::Sha256; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; use trogon_nats::jetstream::StreamMaxAge; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, }; @@ -16,8 +17,7 @@ type HmacSha256 = Hmac; fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } @@ -436,8 +436,7 @@ async fn ack_failure_returns_500() { bridge: SlackBridge::new( ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ), &config, @@ -482,8 +481,7 @@ async fn ack_timeout_returns_500() { bridge: SlackBridge::new( ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ), &config, diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/slack/socket_mode/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/slack/socket_mode/tests.rs index b1bd9551bf..6307c5bda4 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/slack/socket_mode/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/slack/socket_mode/tests.rs @@ -11,14 +11,14 @@ use std::net::SocketAddr; use tokio::net::TcpListener; use tokio::sync::mpsc; use trogon_nats::NatsToken; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{MaxPayload, MockJetStreamPublisher, MockObjectStore, StreamMaxAge}; use trogon_std::NonZeroDuration; fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/telegram/server/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/telegram/server/tests.rs index 482c31963d..fc67b4a725 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/telegram/server/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/telegram/server/tests.rs @@ -5,6 +5,7 @@ use std::time::Duration; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; use trogon_nats::jetstream::StreamMaxAge; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, }; @@ -26,8 +27,7 @@ fn test_config() -> TelegramSourceConfig { fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } @@ -322,8 +322,7 @@ async fn ack_failure_returns_500() { let state = AppState { publisher: ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ), webhook_secret: TelegramWebhookSecret::new(TEST_SECRET).unwrap(), @@ -350,8 +349,7 @@ async fn ack_timeout_returns_500() { let state = AppState { publisher: ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ), webhook_secret: TelegramWebhookSecret::new(TEST_SECRET).unwrap(), diff --git a/rsworkspace/crates/platform/trogon-gateway/src/source/twitter/server/tests.rs b/rsworkspace/crates/platform/trogon-gateway/src/source/twitter/server/tests.rs index 000a0eb2be..482e79a8bd 100644 --- a/rsworkspace/crates/platform/trogon-gateway/src/source/twitter/server/tests.rs +++ b/rsworkspace/crates/platform/trogon-gateway/src/source/twitter/server/tests.rs @@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex}; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; use trogon_nats::jetstream::StreamMaxAge; +use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding}; use trogon_nats::jetstream::{ ClaimCheckPublisher, JetStreamPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, }; @@ -23,8 +24,7 @@ const TEST_SECRET: &str = "test-secret"; fn wrap_publisher(publisher: MockJetStreamPublisher) -> ClaimCheckPublisher { ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ) } @@ -353,8 +353,7 @@ async fn publish_ack_failure_returns_internal_server_error() { let state = AppState { publisher: ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ), consumer_secret: TwitterConsumerSecret::new(TEST_SECRET).unwrap(), @@ -386,8 +385,7 @@ async fn unroutable_publish_ack_failure_returns_internal_server_error() { let state = AppState { publisher: ClaimCheckPublisher::new( publisher, - MockObjectStore::new(), - "test-bucket".to_string(), + ClaimBucketBinding::for_test(MockObjectStore::new(), ClaimBucket::default()), MaxPayload::from_server_limit(usize::MAX), ), consumer_secret: TwitterConsumerSecret::new(TEST_SECRET).unwrap(), diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs index d9d5e1676b..06a1c35115 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs @@ -142,11 +142,18 @@ pub enum ClaimResolveError { ReadFailed(#[from] std::io::Error), } +/// The producing half of a claim check: a store to offload oversized bodies +/// into, and the bucket name every claim it publishes carries. +/// +/// The two arrive as a [`ClaimBucketBinding`] for the same reason the consumer +/// takes one. Here the name is not checked but asserted, so a store and a name +/// passed separately could put the body in one bucket and send consumers to +/// another, which no test of either half on its own would catch. #[derive(Clone)] pub struct ClaimCheckPublisher { publisher: P, store: S, - bucket_name: String, + bucket: ClaimBucket, max_payload: Arc, } @@ -155,20 +162,25 @@ impl fmt::Debug for ClaimCheckPublisher { f.debug_struct("ClaimCheckPublisher") .field("publisher", &self.publisher) .field("store", &self.store) - .field("bucket_name", &self.bucket_name) + .field("bucket", &self.bucket) .finish_non_exhaustive() } } impl ClaimCheckPublisher { - pub fn new(publisher: P, store: S, bucket_name: String, max_payload: M) -> Self { + pub fn new(publisher: P, binding: ClaimBucketBinding, max_payload: M) -> Self { + let (store, bucket) = binding.into_parts(); Self { publisher, store, - bucket_name, + bucket, max_payload: Arc::new(max_payload), } } + + pub fn bucket(&self) -> &ClaimBucket { + &self.bucket + } } fn strip_claim_headers(headers: HeaderMap) -> HeaderMap { @@ -248,7 +260,7 @@ impl ClaimCheckPublisher { let mut claim_headers = headers; claim_headers.insert(HEADER_CLAIM_CHECK, CLAIM_CHECK_VERSION); - claim_headers.insert(HEADER_CLAIM_BUCKET, self.bucket_name.as_str()); + claim_headers.insert(HEADER_CLAIM_BUCKET, self.bucket.as_str()); claim_headers.insert(HEADER_CLAIM_KEY, key.as_str()); super::publish::publish_event(&self.publisher, subject, claim_headers, Bytes::new(), ack_timeout).await diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs index 727847785b..aeb1eecf20 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs @@ -42,8 +42,7 @@ async fn small_payload_publishes_directly() { let store = MockObjectStore::new(); let cc = ClaimCheckPublisher::new( publisher.clone(), - store.clone(), - "test-bucket".to_string(), + test_binding(store.clone()), MaxPayload::from_server_limit(1024 + PROTOCOL_OVERHEAD), ); @@ -69,8 +68,7 @@ async fn large_payload_stores_in_object_store_and_publishes_claim() { let store = MockObjectStore::new(); let cc = ClaimCheckPublisher::new( publisher.clone(), - store.clone(), - "test-bucket".to_string(), + test_binding(store.clone()), MaxPayload::from_server_limit(1024 + PROTOCOL_OVERHEAD), ); @@ -111,8 +109,7 @@ async fn payload_at_exact_threshold_publishes_directly() { let store = MockObjectStore::new(); let cc = ClaimCheckPublisher::new( publisher.clone(), - store.clone(), - "test-bucket".to_string(), + test_binding(store.clone()), MaxPayload::from_server_limit(1024 + PROTOCOL_OVERHEAD), ); @@ -135,12 +132,7 @@ async fn publish_uses_current_max_payload_limit() { let publisher = MockJetStreamPublisher::new(); let store = MockObjectStore::new(); let max_payload = DynamicMaxPayload::new(1024 + PROTOCOL_OVERHEAD); - let cc = ClaimCheckPublisher::new( - publisher.clone(), - store.clone(), - "test-bucket".to_string(), - max_payload.clone(), - ); + let cc = ClaimCheckPublisher::new(publisher.clone(), test_binding(store.clone()), max_payload.clone()); let direct = cc .publish_event( @@ -177,8 +169,7 @@ async fn object_store_failure_returns_store_failed() { store.fail_next_put(); let cc = ClaimCheckPublisher::new( publisher.clone(), - store, - "test-bucket".to_string(), + test_binding(store), MaxPayload::from_server_limit(1024 + PROTOCOL_OVERHEAD), ); @@ -201,8 +192,7 @@ async fn large_payload_preserves_original_headers() { let store = MockObjectStore::new(); let cc = ClaimCheckPublisher::new( publisher.clone(), - store, - "test-bucket".to_string(), + test_binding(store), MaxPayload::from_server_limit(1024 + PROTOCOL_OVERHEAD), ); @@ -265,8 +255,7 @@ async fn small_payload_strips_claim_headers() { let store = MockObjectStore::new(); let cc = ClaimCheckPublisher::new( publisher.clone(), - store.clone(), - "test-bucket".to_string(), + test_binding(store.clone()), MaxPayload::from_server_limit(1024 + PROTOCOL_OVERHEAD), ); @@ -392,8 +381,7 @@ async fn published_claim_round_trips_through_a_resolver() { let store = MockObjectStore::new(); let cc = ClaimCheckPublisher::new( publisher.clone(), - store.clone(), - "test-bucket".to_string(), + test_binding(store.clone()), MaxPayload::from_server_limit(1024 + PROTOCOL_OVERHEAD), ); let body = Bytes::from(vec![7u8; 4096]); diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/tests.rs index 250eefa2b4..3b253d0c58 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/tests.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/tests.rs @@ -121,8 +121,7 @@ fn claim_check_publisher_debug_formats() { let publisher = ClaimCheckPublisher::new( MockPublisher, - MockStore, - "claims".into(), + ClaimBucketBinding::for_test(MockStore, ClaimBucket::new("claims").expect("valid bucket name")), MaxPayload::from_server_limit(1_024), ); let debug = format!("{publisher:?}"); diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store.rs index 4952e54582..862b608f54 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store.rs @@ -55,12 +55,6 @@ impl ClaimBucketBinding { (self.store, self.bucket) } - /// Drop the bucket and keep the handle, for a publisher that names its - /// bucket in the claim headers it writes rather than checking one it reads. - pub fn into_store(self) -> S { - self.store - } - /// Pair a store with a bucket name directly, for tests that redeem claims /// from a fake store and so have no bucket to open. #[cfg(any(test, feature = "test-support"))] @@ -160,11 +154,14 @@ impl NatsObjectStore { /// shrinks the bucket: source streams do not shrink their own retention, so /// a lowered config must not expire claims that older, still-deliverable /// messages reference. + /// + /// The bucket comes back bound to the handle, so a publisher stamps claims + /// with the bucket it actually wrote them to. pub async fn provision_claim_bucket( js: &async_nats::jetstream::Context, - bucket: &ClaimBucket, + bucket: ClaimBucket, retention: super::claim_retention::ClaimRetention, - ) -> Result { + ) -> Result, ProvisionObjectStoreError> { let max_age = retention.bucket_max_age(); let store = Self::provision( js, @@ -176,7 +173,7 @@ impl NatsObjectStore { ) .await?; reconcile_bucket_max_age(js, bucket.as_str(), max_age).await?; - Ok(store) + Ok(ClaimBucketBinding { store, bucket }) } } diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/integration_tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/integration_tests.rs index 01487dc5ff..60ed92261f 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/integration_tests.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/integration_tests.rs @@ -38,7 +38,7 @@ async fn provisioning_an_existing_bucket_reconciles_its_retention() { let js = server.jetstream().await; let short = tracking(3600); - NatsObjectStore::provision_claim_bucket(&js, &claim_bucket(), short) + NatsObjectStore::provision_claim_bucket(&js, claim_bucket(), short) .await .expect("first provision"); assert_eq!(backing_stream_max_age(&js).await, short.bucket_max_age()); @@ -47,7 +47,7 @@ async fn provisioning_an_existing_bucket_reconciles_its_retention() { // effect, not silently keep the old TTL. let long = tracking(3 * 3600); assert_ne!(long.bucket_max_age(), short.bucket_max_age()); - NatsObjectStore::provision_claim_bucket(&js, &claim_bucket(), long) + NatsObjectStore::provision_claim_bucket(&js, claim_bucket(), long) .await .expect("re-provision wider"); assert_eq!(backing_stream_max_age(&js).await, long.bucket_max_age()); @@ -55,7 +55,7 @@ async fn provisioning_an_existing_bucket_reconciles_its_retention() { // Re-provisioning with a shorter retention must NOT shrink the bucket: // older, still-deliverable messages could reference claims that would // otherwise expire early. - NatsObjectStore::provision_claim_bucket(&js, &claim_bucket(), short) + NatsObjectStore::provision_claim_bucket(&js, claim_bucket(), short) .await .expect("re-provision narrower"); assert_eq!(backing_stream_max_age(&js).await, long.bucket_max_age()); @@ -69,7 +69,7 @@ async fn provisioning_an_existing_bucket_reconciles_its_retention() { async fn a_resolver_reads_the_bucket_its_binding_opened() { let server = JetStreamTestServer::start().await; let js = server.jetstream().await; - NatsObjectStore::provision_claim_bucket(&js, &claim_bucket(), tracking(3600)) + NatsObjectStore::provision_claim_bucket(&js, claim_bucket(), tracking(3600)) .await .expect("provision claim bucket"); diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/tests.rs index f6a1c3b53d..a3aa8776e3 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/tests.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store/tests.rs @@ -36,15 +36,15 @@ fn no_expiry_to_no_expiry_is_a_no_op() { } /// A consumer validates the claim's `Nats-Claim-Bucket` header against -/// `bucket()` while reading through the handle, and a publisher that only writes -/// that header takes `into_store()` and drops the name. Both halves have to come -/// back out as the halves that went in, or the validation passes on one bucket -/// while the handle reads another. +/// `bucket()` while reading through the handle, and a publisher stamps that same +/// header on what it writes through it. Both halves have to come back out as the +/// halves that went in, or the header names one bucket while the handle reads +/// another. #[test] fn a_binding_hands_each_half_back_as_the_half_it_was_given() { let bucket = ClaimBucket::new("claims").expect("valid bucket name"); let binding = ClaimBucketBinding::for_test("a-store-handle", bucket.clone()); assert_eq!(binding.bucket(), &bucket); - assert_eq!(binding.into_store(), "a-store-handle"); + assert_eq!(binding.into_parts(), ("a-store-handle", bucket)); } From 9521b2858851eb8cb6875323d919704fd7a01c7b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 16:38:09 -0400 Subject: [PATCH 32/55] refactor(nats): drop the claim publisher accessor nothing reads Signed-off-by: Yordis Prieto --- .../crates/platform/trogon-nats/src/jetstream/claim_check.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs index 06a1c35115..fdbee628c8 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs @@ -177,10 +177,6 @@ impl ClaimCheckPublisher { max_payload: Arc::new(max_payload), } } - - pub fn bucket(&self) -> &ClaimBucket { - &self.bucket - } } fn strip_claim_headers(headers: HeaderMap) -> HeaderMap { From bcc0b30062a379e3689c90a915f214ffc77cf59b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 16:38:09 -0400 Subject: [PATCH 33/55] refactor(channel): keep what an endpoint's binding leads to in one place A binding that is gone, one that vanished mid-reservation, and one pointing at a record that is not there all leave the endpoint unable to route, so the answer belongs in one lookup rather than in each caller. Which conversation a reservation ends up on is the store's to report too: a caller that decides for itself can be left carrying a record nothing routes to. Signed-off-by: Yordis Prieto --- .../channel-bridge-telegram/src/pipeline.rs | 21 +---- .../channel/trogon-channel/src/store.rs | 79 +++++++++++++++---- 2 files changed, 65 insertions(+), 35 deletions(-) diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs index 6516fccdc2..15f19b95b6 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -9,7 +9,7 @@ use crate::render::{TelegramRenderClient, chunk_text}; use tracing::{info, warn}; use trogon_channel::{ AgentId, AgentPort, AgentPortError as _, AgentSessionId, ChannelStore, ChannelStoreError, Command, CommandTriggers, - ConversationId, ConversationRecord, EndpointBinding, EndpointError, InboundEvent, ReleaseReason, + ConversationId, ConversationRecord, EndpointError, InboundEvent, ReleaseReason, }; use trogon_nats::jetstream::{ClaimResolveError, ClaimResolver, ObjectStoreGet}; use trogon_std::NowV7; @@ -180,25 +180,10 @@ where created_at: now, last_activity_at: now, }; - match self - .store + self.store .create_conversation(&event.endpoint, &record, self.ids) .await? - { - EndpointBinding::Created(id) => { - info!(conversation = %id, endpoint = %event.endpoint, agent = %record.agent_id, "Created conversation"); - (id, record) - } - EndpointBinding::AlreadyBound(id, bound) => { - info!( - conversation = %id, - endpoint = %event.endpoint, - agent = %bound.agent_id, - "Endpoint was bound while this message was being handled; continuing on that conversation" - ); - (id, bound) - } - } + .into_conversation(&event.endpoint, record) } }; diff --git a/rsworkspace/crates/channel/trogon-channel/src/store.rs b/rsworkspace/crates/channel/trogon-channel/src/store.rs index 495cb43bda..0e93b7b7e9 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store.rs @@ -85,6 +85,39 @@ pub enum EndpointBinding { AlreadyBound(ConversationId, ConversationRecord), } +impl EndpointBinding { + /// The conversation this endpoint's next message belongs to, and the record + /// to carry on with. `mine` is the record the caller built for the + /// reservation, and it survives only if the reservation was won; losing the + /// claim means continuing on the winner's record instead, so the caller + /// cannot be left holding one that nothing routes to. + /// + /// Which way it went is reported here rather than by the caller, because + /// only one of the two is a race worth reading in a log and every caller + /// wants the same pair out of it. + pub fn into_conversation( + self, + endpoint: &Endpoint, + mine: ConversationRecord, + ) -> (ConversationId, ConversationRecord) { + match self { + Self::Created(id) => { + info!(conversation = %id, endpoint = %endpoint, agent = %mine.agent_id, "Created conversation"); + (id, mine) + } + Self::AlreadyBound(id, bound) => { + info!( + conversation = %id, + endpoint = %endpoint, + agent = %bound.agent_id, + "Endpoint was bound while this conversation was being created; continuing on the one already bound" + ); + (id, bound) + } + } + } +} + /// What we know about a principal beyond its id. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrincipalRecord { @@ -179,12 +212,24 @@ impl ChannelStore { &self, endpoint: &Endpoint, ) -> Result, ChannelStoreError> { - let Some(bytes) = self.bindings.get(endpoint.kv_key()).await? else { + self.bound_conversation(self.bindings.get(endpoint.kv_key()).await?) + .await + } + + /// The conversation an encoded binding leads to. An endpoint with no binding + /// and one bound to a record that is no longer there answer alike, because + /// neither routes anywhere: the next message for that endpoint has to open a + /// conversation either way. + async fn bound_conversation( + &self, + binding: Option>, + ) -> Result, ChannelStoreError> { + let Some(bytes) = binding else { return Ok(None); }; - let id: ConversationId = serde_json::from_slice(&bytes)?; + let id: ConversationId = serde_json::from_slice(bytes.as_ref())?; match self.conversations.get(id.as_str()).await? { - Some(bytes) => Ok(Some((id.clone(), serde_json::from_slice(&bytes)?))), + Some(bytes) => Ok(Some((id, serde_json::from_slice(&bytes)?))), None => Ok(None), } } @@ -231,14 +276,18 @@ impl ChannelStore { }; // Read the claim that won as an entry rather than a value: replacing a - // stale one is only safe against the revision it was read at. - let Some(bound) = self.bindings.entry(endpoint.kv_key()).await? else { - return Err(self.unwind(endpoint, id, taken.into()).await); - }; - let bound_id: ConversationId = serde_json::from_slice(&bound.value)?; + // stale one is only safe against the revision it was read at. A claim + // that is gone by the time it is read is claimed at revision zero, which + // the server honours only while the key is still absent, so the race + // that emptied it cannot be lost twice. + let claimed = self.bindings.entry(endpoint.kv_key()).await?; + let revision = claimed.as_ref().map_or(0, |claim| claim.revision); - if let Some(bytes) = self.conversations.get(bound_id.as_str()).await? { - let bound_record = serde_json::from_slice(&bytes)?; + // Only a claim that leads to a conversation is one to yield to. One that + // leads nowhere is taken over below, and so is a claim that is gone by + // the time it is read: revision zero says the key must still be absent, + // so the race that emptied it cannot be lost twice. + if let Some((bound_id, bound_record)) = self.bound_conversation(claimed.map(|claim| claim.value)).await? { return match self.conversations.delete(id.as_str()).await { Ok(()) => Ok(EndpointBinding::AlreadyBound(bound_id, bound_record)), Err(cleanup) => Err(ChannelStoreError::OrphanedConversation { @@ -252,14 +301,10 @@ impl ChannelStore { info!( endpoint = %endpoint, - conversation = %bound_id, - "Endpoint was bound to a conversation record that is gone; re-pointing it" + conversation = %id, + "The endpoint's claim leads to no conversation record; taking it over" ); - match self - .bindings - .update(endpoint.kv_key(), binding.into(), bound.revision) - .await - { + match self.bindings.update(endpoint.kv_key(), binding.into(), revision).await { Ok(_) => Ok(EndpointBinding::Created(id)), Err(source) => Err(self.unwind(endpoint, id, source.into()).await), } From f6d745aa2343a112d67583878be582f8aef02db9 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 16:38:10 -0400 Subject: [PATCH 34/55] test(channel): cover the records a contested claim can still leave behind Losing a claim and having a re-point refused both roll a record back, and neither rollback had a test that forced it, so nothing proved an operator is told about the record that survives when the rollback fails too. Signed-off-by: Yordis Prieto --- .../channel/trogon-channel/src/store_tests.rs | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs index fac10c2125..f3171d3cbb 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs @@ -263,6 +263,101 @@ async fn a_second_conversation_on_one_endpoint_yields_to_the_one_already_bound() ); } +/// Whoever loses the claim has already built a record of its own, and the +/// caller cannot be handed that one back: nothing routes to it, and it is gone +/// by the time this returns. Both ways out of a reservation therefore have to +/// yield the record the endpoint really feeds. +#[test] +fn a_binding_hands_back_the_record_the_endpoint_routes_to() { + let endpoint = endpoint("777"); + let mine = record(&principal("user-7")); + let winner = record(&principal("user-7-first")); + let fresh = ConversationId::from_string("fresh").expect("conversation id"); + let bound = ConversationId::from_string("bound").expect("conversation id"); + + let (claimed, kept) = EndpointBinding::Created(fresh.clone()).into_conversation(&endpoint, mine.clone()); + assert_eq!(claimed, fresh); + assert_eq!( + kept.principal, mine.principal, + "a won claim carries on with the record the caller built" + ); + + let (yielded, adopted) = + EndpointBinding::AlreadyBound(bound.clone(), winner.clone()).into_conversation(&endpoint, mine); + assert_eq!(yielded, bound); + assert_eq!( + adopted.principal, winner.principal, + "a lost claim carries on with the record already bound, not the one just rolled back" + ); +} + +/// Losing the claim still leaves this call's own record behind, and taking it +/// back can fail just as the rollback of a failed bind can. The loser then has +/// to be told, because it is holding the winner's conversation while a record +/// nothing reaches stays in the bucket. Same forcing as +/// `a_rollback_that_also_fails_reports_the_record_it_could_not_remove`: a +/// conversations bucket that accepts one write per subject takes the record and +/// refuses the delete marker that would remove it. +#[tokio::test] +async fn a_lost_claim_whose_rollback_fails_reports_the_record_it_could_not_remove() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + + js.create_stream(jetstream::stream::Config { + name: "KV_channel_conversations_lost".to_string(), + subjects: vec!["$KV.channel_conversations_lost.>".to_string()], + max_messages_per_subject: 1, + discard: jetstream::stream::DiscardPolicy::New, + discard_new_per_subject: true, + ..Default::default() + }) + .await + .expect("a conversations bucket that refuses a second write to one subject"); + + let store = ChannelStore::ensure(&js, "lost").await.expect("ensure"); + + let ids = QueuedIds::default(); + let endpoint = endpoint("777"); + let winner = created( + store + .create_conversation(&endpoint, &record(&principal("user-7")), &ids) + .await + .expect("first claim"), + ); + + let Err(error) = store + .create_conversation(&endpoint, &record(&principal("user-7-again")), &ids) + .await + else { + panic!("losing the claim without being able to roll back must not read as success"); + }; + + let ChannelStoreError::OrphanedConversation { conversation, .. } = error else { + panic!("expected the failed rollback to surface as an orphaned record, got {error:?}"); + }; + assert_eq!( + conversation.as_str(), + QUEUED_IDS[1].simple().to_string(), + "the error must name the loser's own record, not the winner's" + ); + assert!( + store + .conversations + .get(conversation.as_str()) + .await + .expect("read the record the rollback could not remove") + .is_some(), + "the error must name a record that really is still there to be swept" + ); + + let (still_bound, _) = store + .conversation_for(&endpoint) + .await + .expect("conversation lookup") + .expect("the endpoint is still bound"); + assert_eq!(still_bound, winner, "the loser must not have moved the binding"); +} + /// The exposure the write order creates: the record goes in first so no binding /// is ever briefly visible pointing at nothing, which leaves a failed binding /// able to strand a record instead. Each attempt mints a fresh id, so without @@ -357,6 +452,79 @@ async fn a_rollback_that_also_fails_reports_the_record_it_could_not_remove() { ); } +/// Re-pointing a binding whose record is gone is a compare-and-swap, so it can +/// be refused, and then this call has written a record it never bound: the same +/// strand the failed claim leaves, reached the other way. A bindings bucket that +/// accepts one write per subject forces it deterministically: the dangling +/// binding is the write it accepts, and the swap that would replace it is a +/// second write to the same subject. +#[tokio::test] +async fn a_refused_re_point_takes_the_conversation_record_with_it() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + + js.create_stream(jetstream::stream::Config { + name: "KV_channel_bindings_repoint".to_string(), + subjects: vec!["$KV.channel_bindings_repoint.>".to_string()], + max_messages_per_subject: 1, + discard: jetstream::stream::DiscardPolicy::New, + discard_new_per_subject: true, + ..Default::default() + }) + .await + .expect("a bindings bucket that refuses a second write to one subject"); + + let store = ChannelStore::ensure(&js, "repoint").await.expect("ensure"); + + let endpoint = endpoint("888"); + let gone = ConversationId::from_string("gone").expect("conversation id"); + store + .bindings + .put(endpoint.kv_key(), serde_json::to_vec(&gone).expect("encode id").into()) + .await + .expect("write dangling binding"); + + let Err(error) = store + .create_conversation(&endpoint, &record(&principal("user-8")), &UuidV7Generator) + .await + else { + panic!("create must fail when the dangling binding cannot be replaced"); + }; + + let ChannelStoreError::BindEndpoint { + conversation, + source: ReserveEndpointError::Repoint(_), + .. + } = error + else { + panic!("expected the refused swap to surface as a bind failure, got {error:?}"); + }; + + assert!( + store + .conversations + .get(conversation.as_str()) + .await + .expect("read the rolled back record") + .is_none(), + "a conversation nothing can reach must not survive the call that failed to bind it" + ); + + let still_dangling: ConversationId = serde_json::from_slice( + &store + .bindings + .get(endpoint.kv_key()) + .await + .expect("read the binding") + .expect("the binding is still there"), + ) + .expect("decode the binding"); + assert_eq!( + still_dangling, gone, + "a refused swap must leave the binding as it found it" + ); +} + /// `ensure_is_idempotent_under_concurrent_creation` races two *identical* /// configs, and neither side ever takes this arm: `STREAM.CREATE` only /// errors when the stream that beat it has a different config, and an From 68588912a846edb931f2b395b2202b674ebf63b3 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 20:51:40 -0400 Subject: [PATCH 35/55] fix(channel): read a media type spelled the way the standard writes it `Content-Type: text/plain; charset=utf-8` is how nearly every sender writes a parameter, so refusing the space after the separator would have rejected ordinary types the attachment contract is going to receive. The space says nothing about which type it is, so it is dropped rather than kept: two spellings of one media type must not compare as two. What a sender quoted still survives as typed. Signed-off-by: Yordis Prieto --- .../channel/trogon-channel/src/event.rs | 19 +++++++---- .../channel/trogon-channel/src/event_tests.rs | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/rsworkspace/crates/channel/trogon-channel/src/event.rs b/rsworkspace/crates/channel/trogon-channel/src/event.rs index 5167dd9fc3..5a995fe712 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event.rs @@ -146,7 +146,7 @@ pub enum MediaTypeError { EmptySubtype, #[error("a media type has one subtype, so its subtype may not contain '/'")] SubtypeIsNotOne, - #[error("a media type may not contain whitespace")] + #[error("a media type's type and subtype may not contain whitespace")] InteriorWhitespace, } @@ -154,9 +154,10 @@ pub enum MediaTypeError { /// because the standard defines those two as case-insensitive and a caller /// comparing them as bytes would otherwise be wrong for `IMAGE/PNG`. /// -/// Parameters are kept byte for byte, because case-insensitivity stops at the -/// subtype: a `multipart` boundary and a `filename` are values a sender chose -/// and folding them changes what they refer to. +/// Parameters are kept byte for byte apart from the optional space around the +/// separator, because case-insensitivity stops at the subtype: a `multipart` +/// boundary and a `filename` are values a sender chose and folding them changes +/// what they refer to. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] #[serde(transparent)] pub struct MimeType(String); @@ -165,8 +166,11 @@ impl MimeType { pub fn new(raw: impl Into) -> Result { let raw = raw.into(); let trimmed = raw.trim(); + // The space either side of the separator is optional in the standard, so + // it says nothing about which media type this is and is dropped here + // rather than left to make one spelling of a type unequal to another. let (essence, parameters) = match trimmed.split_once(';') { - Some((essence, parameters)) => (essence, Some(parameters)), + Some((essence, parameters)) => (essence.trim_end(), Some(parameters.trim_start())), None => (trimmed, None), }; let (kind, subtype) = essence.split_once('/').ok_or(MediaTypeError::MissingSeparator)?; @@ -179,7 +183,10 @@ impl MimeType { if subtype.contains('/') { return Err(MediaTypeError::SubtypeIsNotOne.into()); } - if trimmed.chars().any(char::is_whitespace) { + // Only the type and subtype. A parameter value is the sender's to choose + // and a quoted one may hold spaces, so what is inside the parameters is + // not this constructor's to reject. + if essence.chars().any(char::is_whitespace) { return Err(MediaTypeError::InteriorWhitespace.into()); } let mut normalized = format!("{}/{}", kind.to_ascii_lowercase(), subtype.to_ascii_lowercase()); diff --git a/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs index 63a9ce4803..e69b0a3d5b 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs @@ -89,6 +89,39 @@ fn a_media_type_keeps_parameters() { ); } +/// The space after the separator is how `Content-Type` is written nearly +/// everywhere, so the attachment contract will be handed this spelling and has +/// to read it as the type it is. It is dropped rather than kept, because two +/// spellings of one media type must not compare as two types. +#[test] +fn a_media_type_reads_the_spelling_the_standard_writes_parameters_in() { + for raw in [ + "text/plain;charset=utf-8", + "text/plain; charset=utf-8", + "text/plain ; charset=utf-8", + " text/plain; charset=utf-8 ", + ] { + assert_eq!( + MimeType::new(raw).expect("valid").as_str(), + "text/plain;charset=utf-8", + "{raw:?} is one media type spelled several ways" + ); + } +} + +/// A quoted parameter value holds whatever the sender put in it, spaces +/// included, and a boundary that loses one stops delimiting the body it was +/// picked for. +#[test] +fn a_media_type_keeps_the_spaces_inside_a_quoted_parameter() { + assert_eq!( + MimeType::new("MULTIPART/Mixed; boundary=\"a b c\"") + .expect("valid") + .as_str(), + "multipart/mixed;boundary=\"a b c\"" + ); +} + /// Case-insensitivity is defined for the type and the subtype only. A /// `multipart` boundary is a delimiter the sender picked and has to survive as /// typed, or the body it delimits stops being parseable. From ec0c8d09eec66d959e1dbf56a0b51e6b4ee170a7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 21:49:08 -0400 Subject: [PATCH 36/55] chore(devops): hold the Telegram bridge's container surface The canary workflow only fires on a push to main and nothing builds these images at pull request time, so a build failure would land as a red main rather than a failed check. This one would have: the image could not build at all, because the workspace root declares members outside crates/ that the recipe never received. Signed-off-by: Yordis Prieto --- .github/canary-container-services.json | 5 --- .github/workflows/canary-container-images.yml | 1 - .../channel-bridge-telegram/Dockerfile | 43 ------------------- 3 files changed, 49 deletions(-) delete mode 100644 devops/docker/compose/services/channel-bridge-telegram/Dockerfile diff --git a/.github/canary-container-services.json b/.github/canary-container-services.json index 03bd122421..f910e40842 100644 --- a/.github/canary-container-services.json +++ b/.github/canary-container-services.json @@ -3,10 +3,5 @@ "image": "trogonai/trogon-gateway", "context": "./rsworkspace", "dockerfile": "./devops/docker/compose/services/trogon-gateway/Dockerfile" - }, - "channel-bridge-telegram": { - "image": "trogonai/channel-bridge-telegram", - "context": "./rsworkspace", - "dockerfile": "./devops/docker/compose/services/channel-bridge-telegram/Dockerfile" } } diff --git a/.github/workflows/canary-container-images.yml b/.github/workflows/canary-container-images.yml index 3d9839641f..4a8095e601 100644 --- a/.github/workflows/canary-container-images.yml +++ b/.github/workflows/canary-container-images.yml @@ -24,7 +24,6 @@ jobs: matrix: service: - trogon-gateway - - channel-bridge-telegram steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/devops/docker/compose/services/channel-bridge-telegram/Dockerfile b/devops/docker/compose/services/channel-bridge-telegram/Dockerfile deleted file mode 100644 index 0d0468a8eb..0000000000 --- a/devops/docker/compose/services/channel-bridge-telegram/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -# ── Stage 1: chef — generate dependency recipe ────────────────────────────── -FROM rust:1.96.0-slim-bookworm AS chef - -RUN cargo install cargo-chef --locked - -WORKDIR /build - -# ── Stage 2: planner — capture dependency graph ───────────────────────────── -FROM chef AS planner - -COPY Cargo.toml Cargo.lock ./ -COPY crates/ crates/ - -RUN cargo chef prepare --recipe-path recipe.json - -# ── Stage 3: builder — cached dependency build + final compile ────────────── -FROM chef AS builder - -COPY --from=planner /build/recipe.json recipe.json -RUN cargo chef cook --release --recipe-path recipe.json -p channel-bridge-telegram - -COPY Cargo.toml Cargo.lock ./ -COPY crates/ crates/ - -RUN cargo build --release -p channel-bridge-telegram && \ - strip target/release/channel-bridge-telegram - -# ── Stage 4: runtime ──────────────────────────────────────────────────────── -FROM debian:bookworm-20260518-slim AS runtime - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -RUN useradd --no-create-home --shell /usr/sbin/nologin trogon - -COPY --from=builder /build/target/release/channel-bridge-telegram /usr/local/bin/channel-bridge-telegram - -USER trogon - -STOPSIGNAL SIGTERM - -ENTRYPOINT ["/usr/local/bin/channel-bridge-telegram"] From bc047fdffb7c87e0864a112fef4f9bbbc9d461d6 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 21:49:13 -0400 Subject: [PATCH 37/55] fix(repo): ignore a Compose override again, under the name Compose looks for A wholesale block rewrite dropped the rule, so a local override file was free to reach the index. The old name only covered the Compose v1 spelling, which this layout would never produce. Signed-off-by: Yordis Prieto --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 1fff7a205b..104fcab5b5 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,11 @@ docs/.vitepress/cache !.env.example mise.local.toml +# Docker +compose.override.yml +compose.override.yaml +docker-compose.override.yml + # IDE .idea/ .vscode/ From 36442996d14e8891aa569458198f6ad99da7607f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 23:53:06 -0400 Subject: [PATCH 38/55] fix(channel): scope a media handle to the credential that can redeem it A platform handle is meaningless to anyone but the bot account it was issued to, so two accounts sharing one key would read each other's readiness records for a file neither can fetch. Signed-off-by: Yordis Prieto --- .../0044-inbound-media-fetch-out-of-band.md | 63 ++++++++++++++++--- .../multi-channel-agent-routing.md | 52 ++++++++++----- .../channel/trogon-channel/src/event.rs | 21 ++++--- .../channel/trogon-channel/src/event_tests.rs | 26 +++++++- 4 files changed, 128 insertions(+), 34 deletions(-) diff --git a/docs/adr/0044-inbound-media-fetch-out-of-band.md b/docs/adr/0044-inbound-media-fetch-out-of-band.md index 80494940a3..4abd984210 100644 --- a/docs/adr/0044-inbound-media-fetch-out-of-band.md +++ b/docs/adr/0044-inbound-media-fetch-out-of-band.md @@ -83,7 +83,19 @@ A per-platform downloader (`channel-downloader-telegram` first) takes its own durable consumer on the same raw stream the bridge reads, redeems the platform handle with the bot token, and writes bytes through the existing `ObjectStorePut` in `trogon-nats`. It is size-capped, and the cap is its own -configuration rather than the bridge's. +configuration rather than the bridge's. For Telegram that cap is bounded from +above by the platform: the public Bot API refuses `getFile` for anything over +20 MB, so a larger configured cap only means something against a self-hosted Bot +API server, which is what lifts the limit. + +Redemption is gated by the same identity check the bridge applies. The +downloader resolves the message's endpoint against `channel_endpoints_{prefix}` +and drops the update when that endpoint names no principal, before it calls +`getFile` and before anything reaches `ObjectStorePut`. Reading the raw stream +directly buys independence from the bridge, not exemption from its access +control. Without this check an unlinked chat could spend our bot token and our +object store by sending a file, and the bridge's later decision to drop the +message would arrive after the work was already done. This is deliberately not a request/reply service. It is driven by the same stream as the bridge, so a fetch begins at ingestion whether or not any agent @@ -95,16 +107,36 @@ immediately rather than at some later moment of the agent's choosing. An object store cannot answer "not yet." A `get` on a missing key returns not-found, which is indistinguishable from a permanent failure and from a dead downloader. Readiness therefore lives in its own JetStream KV bucket, keyed by -the platform handle: +the credential that received the handle together with the handle itself: ```text channel_media_{prefix}: - -> { state: ready | failed, object_ref, mime, size, error } + {channel}.{account}.{platform_ref} -> + { state: ready | failed, object_ref, mime, size, error } ``` -Absence means in flight. That is unambiguous because any reader derives the key -from a handle it parsed out of the same stream message the downloader is -working on, so the reader already knows the file exists. +The credential belongs in the key because a handle is not globally meaningful. A +Telegram `file_id` is issued per bot and redeemable only by the token that +received it, so the same string seen by two of our bot accounts denotes two +different files, and the bucket is channel-neutral besides. A bare handle key +would let one account's record answer for another's, and would point redemption +at a credential that cannot honor it. Readers already have both tokens: they are +the leading part of the endpoint on the event the attachment arrived with, and +they are what selects the token used to redeem. + +Absence means not yet resolved, and it says nothing about whether work is under +way. The record is absent before the downloader's durable has reached the +message, while a download is running, and for as long as the downloader is down +or behind. A reader cannot tell those apart and does not need to. What it needs +is that absence is never permanent by accident, and two rules give it that. + +The downloader writes a `failed` record for every permanent error, and also on +its last delivery attempt, which it recognizes from the delivery count JetStream +puts on the message. A handle whose deliveries are exhausted therefore ends as a +terminal record rather than as silence, since a consumer that has stopped +redelivering will never speak again on its own. The reader's deadline is the +backstop for the one case no consumer can cover, a downloader that never runs at +all. Readers await readiness with a KV watch and a deadline, not a poll. A late reader observes current state directly with no replay concern, and a deadline @@ -140,8 +172,17 @@ pay nothing. - Any component that redeems a platform handle holds that platform's credential; no credential-free component is ever handed a handle it is expected to resolve. +- A handle is redeemed with the credential of the account that received it, and + is never keyed or cached in a way that lets one account's handle be resolved + by another's. +- No handle is redeemed for an endpoint that resolves to no principal. + Authorization precedes credential use, in every component that holds a + credential. - Readiness is always observable as an explicit state. "Bytes absent from the - object store" is never interpreted as a lifecycle signal. + object store" is never interpreted as a lifecycle signal, and an absent + readiness record is never read as an assertion that a download is running. +- Every handle a downloader stops working on leaves a terminal record. Giving up + is written down, not expressed by falling silent. - An inbound event never asserts the existence of bytes that have not been written. @@ -160,7 +201,13 @@ pay nothing. store already provisions. - **Failure is legible.** A download that fails permanently is a `failed` record with a reason, distinguishable from one still in flight, so an agent - can be told the difference. + can be told the difference. The cost is that the downloader has to write that + record on the way out, including on its final delivery attempt, rather than + letting the consumer's own give-up be the ending. +- **The endpoints bucket gains a second reader.** `channel_endpoints_{prefix}` + stays the bridge's to write, but the downloader reads it to authorize before + redeeming, so identity is one registry consulted by every component that acts + on a message rather than a check the bridge performs on everyone's behalf. - **The downloader can be restarted or backfilled independently.** Because it is a durable consumer of a retained raw stream rather than a request/reply service, a downloader that was down comes back and works through what it diff --git a/docs/architecture/multi-channel-agent-routing.md b/docs/architecture/multi-channel-agent-routing.md index ae6f33edfa..55d6ac85d2 100644 --- a/docs/architecture/multi-channel-agent-routing.md +++ b/docs/architecture/multi-channel-agent-routing.md @@ -39,9 +39,13 @@ Telegram ─HTTP─▶ webhook validated, published verbatim trogon-gateway send_message, chunked at 4096 (same process) ``` -Two processes, and that is the whole topology. There is no subject between the -bridge and the agent other than the one `acp-nats` already owns, and no subject -between the bridge's halves, because it has no halves. +Two processes, and that is the whole topology while the channel is text only. +There is no subject between the bridge and the agent other than the one +`acp-nats` already owns, and no subject between the bridge's halves, because it +has no halves. Supporting media adds a third process, the downloader of +[ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md), which takes its own +durable on the same inbound stream and still introduces no subject between these +two. **The two legs are not symmetric.** Inbound goes through the gateway, which owns the webhook and publishes verbatim. Outbound does not: the bridge holds a @@ -210,11 +214,17 @@ slightly dishonest about them. ## State: JetStream KV buckets -All stateful registries live in JetStream KV, owned exclusively by the bridge. -Config files carry only wiring (NATS connection, agent registry). The admin -surface for these buckets (CLI, config seeding, later GUI or MCP) is deliberately -out of scope; KV is the source of truth and whatever tool mutates it is -pluggable. +The four conversational registries live in JetStream KV, owned exclusively by +the bridge. Config files carry only wiring (NATS connection, agent registry). +The admin surface for these buckets (CLI, config seeding, later GUI or MCP) is +deliberately out of scope; KV is the source of truth and whatever tool mutates +it is pluggable. + +"Owned exclusively" is a claim about these four, not about every bucket a +channel deployment has. `channel_media_{prefix}` is a cross-component record: +the downloader of [ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md) +writes readiness there and the bridge's download tool reads it, which is what +makes it a handoff rather than a registry. | Bucket | Key | Value | | --- | --- | --- | @@ -225,7 +235,12 @@ pluggable. Access control is identity: an endpoint that resolves to no principal is rejected at the bridge, which logs, acks, and drops. This replaces the per-channel -allowlist concept with one channel-neutral mechanism. +allowlist concept with one channel-neutral mechanism. The check belongs to the +registry rather than to the bridge, so any other component that acts on a raw +message applies it too: the media downloader of +[ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md) reads +`channel_endpoints_{prefix}` for exactly this and writes to none of these +buckets. ## Channel-neutral types @@ -272,11 +287,17 @@ converged on essentially this set. **Inbound media is fetched out of band** by a dedicated downloader on its own durable consumer of the raw stream, never by the gateway and never inline in a -turn. The inbound event carries only `platform_ref`; readiness lives in a -`channel_media_{prefix}` KV record that a reader awaits by watch, at the moment -the agent opens the file. Outbound is not symmetric: `send_attachment` keeps its -`object_ref`, because the agent produced that file and there is nothing to -redeem. See [ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md). The +turn. The inbound event carries only `platform_ref`, which is meaningful only +together with the `account` on its endpoint: a Telegram `file_id` is issued per +bot and redeemable only by the token that received it. Readiness accordingly +lives in a `channel_media_{prefix}` KV record keyed by the receiving `channel` +and `account` together with the handle, which a reader awaits by watch at the +moment the agent opens the file. The downloader authorizes the endpoint against +`channel_endpoints_{prefix}` before it redeems anything, so an unlinked chat +cannot reach the bot token by sending a file. Outbound is not symmetric: +`send_attachment` keeps its `object_ref`, because the agent produced that file +and there is nothing to redeem. +See [ADR#0044](../adr/0044-inbound-media-fetch-out-of-band.md). The downloader is designed and not built; today media is dropped. ## Agent dispatch: the AgentPort trait @@ -439,7 +460,8 @@ them change the topology above. takes pointers from any number of endpoints); what is missing is anything that creates the second pointer. - **The bot token lives in two processes**, the gateway for webhook registration - and the bridge for API calls. A generic gateway sink (NATS to HTTP-out, + and the bridge for API calls, and in a third once media support lands and the + downloader needs `getFile`. A generic gateway sink (NATS to HTTP-out, symmetric to its sources) would centralize outbound custody. It does not exist, and adding one is a gateway decision, not a channel one. - **One agent protocol.** `AgentPort` has a single implementation. diff --git a/rsworkspace/crates/channel/trogon-channel/src/event.rs b/rsworkspace/crates/channel/trogon-channel/src/event.rs index 5a995fe712..8f48221bc4 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event.rs @@ -218,11 +218,11 @@ impl<'de> Deserialize<'de> for MimeType { } } -/// The platform's handle for a file, redeemable for bytes only by the channel -/// that issued it (e.g. a Telegram `file_id`). Constrained to an endpoint token -/// because it is also a KV key: readiness for the fetch lives at this handle in -/// `channel_media_{prefix}`, so a handle that is not a safe key has nowhere to -/// report. See ADR#0044. +/// The platform's handle for a file, redeemable for bytes only by the bot +/// account that received it (e.g. a Telegram `file_id`). Constrained to an +/// endpoint token because it is part of a KV key: readiness for the fetch lives +/// in `channel_media_{prefix}`, so a handle that is not a safe key has nowhere +/// to report. See ADR#0044. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] #[serde(transparent)] pub struct PlatformRef(SafeToken); @@ -236,9 +236,14 @@ impl PlatformRef { self.0.as_str() } - /// KV key for this handle's readiness record in `channel_media_{prefix}`. - pub fn kv_key(&self) -> &str { - self.0.as_str() + /// KV key for this handle's readiness record in `channel_media_{prefix}`, + /// scoped by the endpoint that received it. Only the endpoint's channel and + /// account take part: they name the credential that can redeem the handle, + /// and a handle is meaningless to any other. The peer is left out because + /// the same file reaches the same account under one identity whichever chat + /// it arrived in. + pub fn kv_key(&self, endpoint: &Endpoint) -> String { + format!("{}.{}.{}", endpoint.channel(), endpoint.account(), self.0) } } diff --git a/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs index e69b0a3d5b..145824c75d 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs @@ -133,17 +133,37 @@ fn a_media_type_normalizes_the_subtype_without_touching_its_parameters() { ); } -/// The handle doubles as the readiness key in `channel_media_{prefix}` +/// The handle is part of the readiness key in `channel_media_{prefix}` /// (ADR#0044), so it has to be safe as a KV key. #[test] fn a_platform_ref_must_be_usable_as_a_kv_key() { let handle = PlatformRef::new("AgACAgQAAx0-Ef_9").expect("valid"); - assert_eq!(handle.kv_key(), "AgACAgQAAx0-Ef_9"); + let endpoint = Endpoint::new("telegram", "mybot", "-1001234567890").expect("endpoint"); + assert_eq!(handle.kv_key(&endpoint), "telegram.mybot.AgACAgQAAx0-Ef_9"); assert!(PlatformRef::new("has space").is_err()); assert!(PlatformRef::new("has.dot").is_err()); assert!(PlatformRef::new("").is_err()); } +/// A Telegram `file_id` is issued per bot and only redeemable by the token that +/// received it, so two accounts that report the same handle mean two different +/// files. Keying readiness by the handle alone would let one account's record +/// answer for the other's, and point redemption at a credential that cannot +/// honor it. See ADR#0044. +#[test] +fn a_readiness_key_separates_two_accounts_that_report_the_same_handle() { + let handle = PlatformRef::new("AgACAgQAAx0-Ef_9").expect("valid"); + let ours = Endpoint::new("telegram", "mybot", "77").expect("endpoint"); + let theirs = Endpoint::new("telegram", "otherbot", "77").expect("endpoint"); + + assert_ne!(handle.kv_key(&ours), handle.kv_key(&theirs)); + + // The peer is deliberately absent: one account holds one identity for a + // file however many chats forward it. + let elsewhere = Endpoint::new("telegram", "mybot", "-1001234567890").expect("endpoint"); + assert_eq!(handle.kv_key(&ours), handle.kv_key(&elsewhere)); +} + /// Why `parse` carries no error arm for a numeric id: a platform that numbers /// its users and messages can only ever produce a token, so the checked path and /// the unchecked one have to agree for every integer either could see. @@ -175,7 +195,7 @@ fn a_value_object_displays_as_the_scalar_it_wraps() { let handle = PlatformRef::new("file-abc").expect("handle"); assert_eq!(handle.as_str(), "file-abc"); - assert_eq!(handle.to_string(), handle.kv_key()); + assert_eq!(handle.to_string(), "file-abc"); } #[test] From 7e1f69ca412c1c3ca7891b6a4f27b26cf945b6e9 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 23:53:12 -0400 Subject: [PATCH 39/55] refactor(channel): let a boot failure name the variable an operator must edit A bridge that will not start is read by someone who has to go and change something, which an opaque chain of context strings does not tell them. Signed-off-by: Yordis Prieto --- rsworkspace/Cargo.lock | 1 - .../channel-bridge-telegram/Cargo.toml | 1 - .../channel-bridge-telegram/src/config.rs | 51 +++++++++++----- .../src/config_tests.rs | 55 ++++++++++++----- .../src/pipeline_tests.rs | 60 +++++-------------- 5 files changed, 91 insertions(+), 77 deletions(-) diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 189126b939..00abdc4c04 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -1350,7 +1350,6 @@ dependencies = [ "futures", "serde_json", "teloxide", - "testcontainers-modules", "thiserror 2.0.19", "tokio", "tracing", diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml index b7a6882757..c56a208957 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml +++ b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml @@ -26,6 +26,5 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "ti tracing = { workspace = true } [dev-dependencies] -testcontainers-modules = { version = "0.15", features = ["nats"] } trogon-nats = { workspace = true, features = ["test-support"] } trogon-std = { workspace = true, features = ["test-support"] } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs index d120d3d732..12b9211c87 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs @@ -2,10 +2,9 @@ #[path = "config_tests.rs"] mod config_tests; -use acp_nats::{AcpPrefix, NatsConfig}; -use anyhow::Context; +use acp_nats::{AcpPrefix, AcpPrefixError, NatsConfig}; use std::path::PathBuf; -use trogon_channel::CommandTriggers; +use trogon_channel::{CommandTriggerError, CommandTriggers}; use trogon_nats::jetstream::ClaimBucket; use trogon_std::env::ReadEnv; @@ -59,6 +58,28 @@ fn var(env: &E, key: &str) -> Option { .filter(|value| !value.is_empty()) } +/// Why the environment did not describe a bridge. +/// +/// One variant per variable that can be wrong, each keeping the error that +/// rejected it. A boot failure is read by an operator who has to go and edit +/// something, so the variant names the variable and the source says what was +/// unacceptable about the value. +#[derive(Debug, thiserror::Error)] +pub enum BridgeConfigError { + #[error("TELEGRAM_BOT_TOKEN is unset or blank")] + BotToken(#[from] BlankBotTokenError), + #[error("CHANNEL_SEED_TELEGRAM_USERS contains an entry that is not a Telegram user id: {entry:?}")] + SeedUser { + entry: String, + #[source] + source: std::num::ParseIntError, + }, + #[error("CHANNEL_NEW_SESSION_TRIGGERS is not a usable trigger list")] + CommandTriggers(#[from] CommandTriggerError), + #[error("{} is not a usable ACP prefix", acp_nats::ENV_ACP_PREFIX)] + AcpPrefix(#[from] AcpPrefixError), +} + pub struct BridgeConfig { pub acp: acp_nats::Config, /// Environment/tenant token for KV buckets and the durable consumer name. @@ -94,9 +115,8 @@ pub struct BridgeConfig { } impl BridgeConfig { - pub fn from_env(env: &E) -> anyhow::Result { - let bot_token = - BotToken::new(env.var("TELEGRAM_BOT_TOKEN").unwrap_or_default()).context("TELEGRAM_BOT_TOKEN not set")?; + pub fn from_env(env: &E) -> Result { + let bot_token = BotToken::new(env.var("TELEGRAM_BOT_TOKEN").unwrap_or_default())?; let channel_prefix = var(env, "CHANNEL_PREFIX").unwrap_or_else(|| "prod".to_string()); let inbound_stream = var(env, "TELEGRAM_INBOUND_STREAM").unwrap_or_else(|| "TELEGRAM".to_string()); @@ -107,13 +127,15 @@ impl BridgeConfig { let seed_users = match var(env, "CHANNEL_SEED_TELEGRAM_USERS") { Some(raw) => raw .split(',') - .filter(|s| !s.trim().is_empty()) - .map(|s| { - s.trim() - .parse::() - .with_context(|| format!("invalid Telegram user id in CHANNEL_SEED_TELEGRAM_USERS: {s:?}")) + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(|entry| { + entry.parse::().map_err(|source| BridgeConfigError::SeedUser { + entry: entry.to_string(), + source, + }) }) - .collect::>>()?, + .collect::, _>>()?, None => Vec::new(), }; @@ -127,13 +149,12 @@ impl BridgeConfig { .map(str::trim) .filter(|s| !s.is_empty()) .map(String::from), - ) - .context("invalid CHANNEL_NEW_SESSION_TRIGGERS")?, + )?, Err(_) => CommandTriggers::default(), }; let raw_prefix = var(env, acp_nats::ENV_ACP_PREFIX).unwrap_or_else(|| acp_nats::DEFAULT_ACP_PREFIX.to_string()); - let acp_prefix = AcpPrefix::new(raw_prefix).context("invalid ACP prefix")?; + let acp_prefix = AcpPrefix::new(raw_prefix)?; let acp = acp_nats::Config::with_prefix(acp_prefix, NatsConfig::from_env(env)); Ok(Self { diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs index 3b44962d8e..82790f73ab 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs @@ -1,10 +1,13 @@ use super::*; use trogon_std::env::InMemoryEnv; -/// Whether a config loaded. Spelled out instead of `is_ok` because a -/// `BridgeConfig` is not `Debug`, which is the point: it holds a token. -fn loads(env: &InMemoryEnv) -> bool { - BridgeConfig::from_env(env).is_ok() +/// Why a config did not load. `BridgeConfig` is not `Debug` (it holds a token), +/// so `expect_err` is out and the failure has to be taken by pattern. +fn rejection(env: &InMemoryEnv) -> BridgeConfigError { + let Err(error) = BridgeConfig::from_env(env) else { + panic!("this environment must not configure the bridge"); + }; + error } /// A token is the one required variable, so every way of not supplying one has @@ -17,13 +20,13 @@ fn a_blank_bot_token_fails_like_an_unset_one() { let env = InMemoryEnv::new(); env.set("TELEGRAM_BOT_TOKEN", token); - assert!(!loads(&env), "blank token {token:?} must not configure the bridge"); + assert!( + matches!(rejection(&env), BridgeConfigError::BotToken(_)), + "blank token {token:?} must be refused as a token, not as something else" + ); } - assert!( - !loads(&InMemoryEnv::new()), - "an absent token must not configure the bridge" - ); + assert!(matches!(rejection(&InMemoryEnv::new()), BridgeConfigError::BotToken(_))); } /// The bridge resolves claims from exactly the bucket the gateway publishes @@ -118,17 +121,39 @@ fn a_seed_list_with_an_unparseable_id_fails_and_names_it() { env.set("TELEGRAM_BOT_TOKEN", "secret-token"); env.set("CHANNEL_SEED_TELEGRAM_USERS", "42, not-an-id ,43"); - // Matched rather than `expect_err`ed because a `BridgeConfig` is not - // `Debug`: it holds a token. - let Err(error) = BridgeConfig::from_env(&env) else { - panic!("an unparseable seed id must not configure the bridge"); + let error = rejection(&env); + let BridgeConfigError::SeedUser { entry, .. } = &error else { + panic!("an unparseable seed id must be refused as one: {error}"); }; + assert_eq!(entry, "not-an-id"); assert!( - format!("{error:#}").contains("not-an-id"), - "the failure must name the offending entry: {error:#}" + error.to_string().contains("not-an-id"), + "the operator has to be told which entry to go and fix: {error}" ); } +/// The trigger list and the ACP prefix are the other two values a deployment can +/// get wrong, and each has to be refused as itself: an operator reading a boot +/// failure is being told which variable to go and edit. +#[test] +fn each_unusable_value_is_refused_as_the_variable_it_came_from() { + let env = InMemoryEnv::new(); + env.set("TELEGRAM_BOT_TOKEN", "secret-token"); + env.set("CHANNEL_NEW_SESSION_TRIGGERS", "/new session"); + assert!(matches!( + rejection(&env), + BridgeConfigError::CommandTriggers(CommandTriggerError::MultipleTokens) + )); + + let env = InMemoryEnv::new(); + env.set("TELEGRAM_BOT_TOKEN", "secret-token"); + env.set(acp_nats::ENV_ACP_PREFIX, "not a prefix"); + assert!(matches!( + rejection(&env), + BridgeConfigError::AcpPrefix(AcpPrefixError::InvalidCharacter(' ')) + )); +} + #[test] fn set_variables_are_read_and_trimmed() { let env = InMemoryEnv::new(); diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs index 70e30435d5..918c8a2967 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs @@ -5,14 +5,13 @@ use agent_client_protocol::schema::v1::{ContentBlock, ContentChunk, SessionNotif use futures::StreamExt; use std::cell::RefCell; use std::rc::Rc; -use testcontainers_modules::nats::{Nats, NatsServerCmd}; -use testcontainers_modules::testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner}; use trogon_channel::store::PrincipalRecord; use trogon_channel::{ AgentPortError, AgentSessionId, Endpoint, InboundEvent, MessageRef, PlatformUserId, PrincipalId, PromptOutcome, ReleaseReason, ReleaseStep, Sender, SessionRelease, }; use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding, MockObjectStore}; +use trogon_nats::test_support::JetStreamTestServer; use trogon_std::UuidV7Generator; // The claim-check scenarios below need the real object store and publisher, which @@ -22,28 +21,6 @@ use trogon_nats::jetstream::{ ClaimCheckPublisher, ClaimRetention, DEFAULT_CLAIM_BUCKET, MaxPayload, NatsJetStreamClient, NatsObjectStore, }; -struct NatsServer { - _container: ContainerAsync, - url: String, -} - -impl NatsServer { - async fn start() -> Self { - let cmd = NatsServerCmd::default().with_jetstream(); - let container = Nats::default() - .with_cmd(&cmd) - .start() - .await - .expect("start NATS testcontainer"); - let host = container.get_host().await.expect("get host"); - let port = container.get_host_port_ipv4(4222).await.expect("get port"); - Self { - _container: container, - url: format!("{host}:{port}"), - } - } -} - #[derive(Debug, thiserror::Error)] #[error("fake agent failure (session_lost={session_lost})")] struct FakeError { @@ -335,9 +312,8 @@ async fn claim_resolver(js: &async_nats::jetstream::Context) -> ClaimResolver Date: Tue, 4 Aug 2026 23:53:23 -0400 Subject: [PATCH 40/55] fix(channel): settle a contested endpoint claim by reading the claim that won Losing the race is not the same as being bound elsewhere, and a bridge that cannot tell them apart either abandons a conversation it owns or takes over one it does not. Signed-off-by: Yordis Prieto --- .../crates/channel/trogon-channel/src/lib.rs | 2 +- .../channel/trogon-channel/src/store.rs | 59 ++++++++++++++++--- .../channel/trogon-channel/src/store_tests.rs | 45 ++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/rsworkspace/crates/channel/trogon-channel/src/lib.rs b/rsworkspace/crates/channel/trogon-channel/src/lib.rs index a57ace2891..85c244ebfe 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/lib.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/lib.rs @@ -40,4 +40,4 @@ pub use event::{ }; pub use render::RenderCommand; pub use safe_token::{SafeToken, SafeTokenError}; -pub use store::{ChannelStore, ChannelStoreError, EndpointBinding, ReserveEndpointError}; +pub use store::{BoundConversationError, ChannelStore, ChannelStoreError, EndpointBinding, ReserveEndpointError}; diff --git a/rsworkspace/crates/channel/trogon-channel/src/store.rs b/rsworkspace/crates/channel/trogon-channel/src/store.rs index 0e93b7b7e9..8c85f55184 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store.rs @@ -57,9 +57,10 @@ pub enum ChannelStoreError { }, } -/// Why an endpoint could not be pointed at a new conversation. Two ways in, -/// because an unbound endpoint is claimed with a create while one left pointing -/// at a record that is gone is re-pointed with a compare-and-swap. +/// Why an endpoint could not be pointed at a new conversation. Four ways in: +/// an unbound endpoint is claimed with a create, one left pointing at a record +/// that is gone is re-pointed with a compare-and-swap, and a lost claim has to +/// be read and followed before either of those is decided. #[derive(Debug, thiserror::Error)] pub enum ReserveEndpointError { #[error(transparent)] @@ -68,6 +69,34 @@ pub enum ReserveEndpointError { /// re-pointed the endpoint first. #[error(transparent)] Repoint(#[from] async_nats::jetstream::kv::UpdateError), + /// The claim that won could not be read back, so there is no telling whether + /// to yield to it or take it over. + #[error("the claim on the endpoint could not be read back: {0}")] + Inspect(#[from] async_nats::jetstream::kv::EntryError), + /// The claim was read but the conversation it leads to was not. + #[error(transparent)] + Follow(#[from] BoundConversationError), +} + +/// Why the conversation an encoded binding leads to could not be read. Narrower +/// than [`ChannelStoreError`] so that a reservation still holding an unbound +/// record can carry the cause into [`ReserveEndpointError`] on its way out, +/// rather than the two error types nesting inside one another. +#[derive(Debug, thiserror::Error)] +pub enum BoundConversationError { + #[error("KV read failed: {0}")] + Read(#[from] async_nats::jetstream::kv::EntryError), + #[error("stored record is not valid JSON: {0}")] + Decode(#[from] serde_json::Error), +} + +impl From for ChannelStoreError { + fn from(error: BoundConversationError) -> Self { + match error { + BoundConversationError::Read(source) => Self::Read(source), + BoundConversationError::Decode(source) => Self::Decode(source), + } + } } /// Which conversation an endpoint is bound to once @@ -212,8 +241,9 @@ impl ChannelStore { &self, endpoint: &Endpoint, ) -> Result, ChannelStoreError> { - self.bound_conversation(self.bindings.get(endpoint.kv_key()).await?) - .await + Ok(self + .bound_conversation(self.bindings.get(endpoint.kv_key()).await?) + .await?) } /// The conversation an encoded binding leads to. An endpoint with no binding @@ -223,7 +253,7 @@ impl ChannelStore { async fn bound_conversation( &self, binding: Option>, - ) -> Result, ChannelStoreError> { + ) -> Result, BoundConversationError> { let Some(bytes) = binding else { return Ok(None); }; @@ -280,14 +310,27 @@ impl ChannelStore { // that is gone by the time it is read is claimed at revision zero, which // the server honours only while the key is still absent, so the race // that emptied it cannot be lost twice. - let claimed = self.bindings.entry(endpoint.kv_key()).await?; + // + // Both this read and the one that follows it unwind the record written + // above, for the same reason the write failures do: the id is minted per + // attempt, so a record left behind by a read that failed is one nothing + // can ever reach again. + let claimed = match self.bindings.entry(endpoint.kv_key()).await { + Ok(claimed) => claimed, + Err(source) => return Err(self.unwind(endpoint, id, source.into()).await), + }; let revision = claimed.as_ref().map_or(0, |claim| claim.revision); // Only a claim that leads to a conversation is one to yield to. One that // leads nowhere is taken over below, and so is a claim that is gone by // the time it is read: revision zero says the key must still be absent, // so the race that emptied it cannot be lost twice. - if let Some((bound_id, bound_record)) = self.bound_conversation(claimed.map(|claim| claim.value)).await? { + let bound = match self.bound_conversation(claimed.map(|claim| claim.value)).await { + Ok(bound) => bound, + Err(source) => return Err(self.unwind(endpoint, id, source.into()).await), + }; + + if let Some((bound_id, bound_record)) = bound { return match self.conversations.delete(id.as_str()).await { Ok(()) => Ok(EndpointBinding::AlreadyBound(bound_id, bound_record)), Err(cleanup) => Err(ChannelStoreError::OrphanedConversation { diff --git a/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs index f3171d3cbb..2e82ea57a3 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs @@ -525,6 +525,51 @@ async fn a_refused_re_point_takes_the_conversation_record_with_it() { ); } +/// A lost claim is read back before it is yielded to, and that read can fail +/// just as the writes around it can. The record this call wrote is unreachable +/// either way, so it has to come back out: a binding holding bytes that are not +/// a conversation id fails the read deterministically, where a KV outage would +/// only fail it sometimes. +#[tokio::test] +async fn a_claim_that_cannot_be_read_back_takes_the_conversation_record_with_it() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + let store = ChannelStore::ensure(&js, "unreadable").await.expect("ensure"); + + let endpoint = endpoint("999"); + store + .bindings + .put(endpoint.kv_key(), "not a conversation id".into()) + .await + .expect("write a binding nothing can decode"); + + let Err(error) = store + .create_conversation(&endpoint, &record(&principal("user-9")), &UuidV7Generator) + .await + else { + panic!("create must fail when the claim it lost cannot be read"); + }; + + let ChannelStoreError::BindEndpoint { + conversation, + source: ReserveEndpointError::Follow(BoundConversationError::Decode(_)), + .. + } = error + else { + panic!("expected the unreadable claim to surface as a bind failure, got {error:?}"); + }; + + assert!( + store + .conversations + .get(conversation.as_str()) + .await + .expect("read the rolled back record") + .is_none(), + "a conversation nothing can reach must not survive the read that failed to follow its claim" + ); +} + /// `ensure_is_idempotent_under_concurrent_creation` races two *identical* /// configs, and neither side ever takes this arm: `STREAM.CREATE` only /// errors when the stream that beat it has a different config, and an From 59c7f9bba4484c3f5cf359d4c550591efe18eb66 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 23:53:36 -0400 Subject: [PATCH 41/55] refactor(nats): tell a foreign bucket apart from a name no bucket could have A header is written by whoever published the message, so a consumer reading one is reading input: folding a corrupt name into a mismatch hides which of the two an operator is actually looking at. Signed-off-by: Yordis Prieto --- .../trogon-nats/src/jetstream/claim_bucket.rs | 71 +++++++++++++++++-- .../trogon-nats/src/jetstream/claim_check.rs | 42 +++++++---- .../claim_check/integration_tests.rs | 26 +++++++ .../platform/trogon-nats/src/jetstream/mod.rs | 2 +- 4 files changed, 120 insertions(+), 21 deletions(-) diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs index 98abb704b2..607fb5a9ae 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs @@ -20,16 +20,43 @@ pub enum ClaimBucketError { #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ClaimBucket(String); +/// The characters a bucket name is drawn from, stated once so that +/// [`ClaimBucket::new`] and the compile-time check below cannot come to +/// different answers about the same name. +const fn is_permitted(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '-' | '_') +} + +/// Whether [`ClaimBucket::new`] would accept this name, asked at compile time. +/// Walks bytes because that is what a `const` can walk; a multi-byte character +/// fails it, which is the answer the factory gives too. +const fn is_a_bucket_name(name: &str) -> bool { + let bytes = name.as_bytes(); + if bytes.is_empty() { + return false; + } + let mut index = 0; + while index < bytes.len() { + if !is_permitted(bytes[index] as char) { + return false; + } + index += 1; + } + true +} + +const _: () = assert!( + is_a_bucket_name(DEFAULT_CLAIM_BUCKET), + "DEFAULT_CLAIM_BUCKET must be a name ClaimBucket::new accepts" +); + impl ClaimBucket { pub fn new(name: impl Into) -> Result { let name = name.into(); if name.is_empty() { return Err(ClaimBucketError::Empty); } - if let Some(invalid) = name - .chars() - .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))) - { + if let Some(invalid) = name.chars().find(|c| !is_permitted(*c)) { return Err(ClaimBucketError::InvalidCharacter(invalid)); } Ok(Self(name)) @@ -42,8 +69,9 @@ impl ClaimBucket { impl Default for ClaimBucket { /// The one bucket a trogon deployment uses. Built without going through - /// [`ClaimBucket::new`] because a constant cannot fail; `the_default_bucket_is_a_valid_name` - /// is what holds that claim to account. + /// [`ClaimBucket::new`] because the factory is fallible and this cannot be: + /// the constant is held to the factory's rule by `is_a_bucket_name` above, + /// which fails the build rather than a deployment. fn default() -> Self { Self(DEFAULT_CLAIM_BUCKET.to_string()) } @@ -61,5 +89,36 @@ impl PartialEq for ClaimBucket { } } +/// The bucket a claim message says it was written to, exactly as the header +/// spelled it. +/// +/// Whoever published the message wrote this, so it is input rather than a name: +/// it may be a bucket this deployment does not open, or not a legal bucket name +/// at all. Keeping it a type of its own means nothing can pass it where a +/// [`ClaimBucket`] belongs without going through [`ClaimBucketHeader::parse`], +/// while the text an operator has to read still survives into the error. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ClaimBucketHeader(String); + +impl ClaimBucketHeader { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn parse(&self) -> Result { + ClaimBucket::new(self.0.as_str()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ClaimBucketHeader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + #[cfg(test)] mod tests; diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs index fdbee628c8..207cfa2ea9 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs @@ -12,7 +12,7 @@ use crate::constants::{ PROTOCOL_OVERHEAD, }; -use super::claim_bucket::ClaimBucket; +use super::claim_bucket::{ClaimBucket, ClaimBucketError, ClaimBucketHeader}; use super::object_store::{ClaimBucketBinding, ObjectStoreGet, ObjectStorePut}; use super::publish::PublishOutcome; use super::traits::JetStreamPublisher; @@ -114,13 +114,18 @@ impl ClaimResolver { if !is_claim(headers) { return Ok(payload); } - if let Some(named) = headers.get(HEADER_CLAIM_BUCKET) - && named.as_str() != self.bucket.as_str() - { - return Err(ClaimResolveError::BucketMismatch { - expected: self.bucket.clone(), - named: named.as_str().to_string(), - }); + if let Some(header) = headers.get(HEADER_CLAIM_BUCKET) { + let header = ClaimBucketHeader::new(header.as_str()); + let named = match header.parse() { + Ok(named) => named, + Err(source) => return Err(ClaimResolveError::UnnamableBucket { named: header, source }), + }; + if named != self.bucket { + return Err(ClaimResolveError::BucketMismatch { + expected: self.bucket.clone(), + named, + }); + } } resolve_claim(headers, payload, &self.store).await } @@ -130,12 +135,21 @@ impl ClaimResolver { pub enum ClaimResolveError { #[error("claim message missing {} header", HEADER_CLAIM_KEY)] MissingKey, - /// `named` stays a string because it is whatever the header carried, which - /// in this arm is by definition not the bucket this consumer opened and may - /// not be a legal bucket name at all. Narrowing it would discard the one - /// value an operator needs to read. - #[error("claim names bucket {named:?} but this consumer reads {expected}")] - BucketMismatch { expected: ClaimBucket, named: String }, + /// A claim written to a bucket this consumer does not read: both names are + /// bucket names, they are simply not the same one. + #[error("claim names bucket {named} but this consumer reads {expected}")] + BucketMismatch { expected: ClaimBucket, named: ClaimBucket }, + /// The header did not carry a bucket name at all, so there is nothing to + /// compare against the bucket this consumer opened. Kept apart from a + /// mismatch because it says something different: a publisher naming a real + /// but foreign bucket is a deployment pointed the wrong way, whereas a name + /// no NATS server would accept is a corrupted or forged header. + #[error("claim names {named:?}, which is not a bucket name: {source}")] + UnnamableBucket { + named: ClaimBucketHeader, + #[source] + source: ClaimBucketError, + }, #[error("failed to resolve claim from object store: {0}")] StoreFailed(#[source] E), #[error("failed to read claim payload: {0}")] diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs index aeb1eecf20..670ac54fa5 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check/integration_tests.rs @@ -356,6 +356,32 @@ async fn resolver_rejects_a_claim_from_another_bucket() { )); } +/// A header nobody could have written by configuring a bucket says something a +/// mismatch does not, so it is reported as what it is rather than folded into +/// "some other bucket". The object is left alone either way: a consumer that +/// cannot trust the header has no business spending the key next to it. +#[tokio::test] +async fn resolver_rejects_a_claim_naming_something_that_is_not_a_bucket() { + let store = MockObjectStore::new(); + store.seed("test.subject/some-id", Bytes::from("offloaded body")); + let resolver = ClaimResolver::new(test_binding(store)); + + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CLAIM_CHECK, CLAIM_CHECK_VERSION); + headers.insert(HEADER_CLAIM_BUCKET, "not a bucket"); + headers.insert(HEADER_CLAIM_KEY, "test.subject/some-id"); + + let error = resolver.resolve(Some(&headers), Bytes::new()).await.unwrap_err(); + assert!( + matches!( + error, + ClaimResolveError::UnnamableBucket { ref named, source: ClaimBucketError::InvalidCharacter(' ') } + if named.as_str() == "not a bucket" + ), + "{error}" + ); +} + /// The publisher writes the object and only then publishes the claim, so a /// consumer that cannot read the object is looking at a transient failure and /// must not treat the message as consumed. diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs index c91a8f79ae..00cfd49d7e 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs @@ -15,7 +15,7 @@ pub mod traits; pub mod mocks; pub use crate::constants::{DEFAULT_CLAIM_BUCKET, HEADER_CLAIM_BUCKET, HEADER_CLAIM_CHECK, HEADER_CLAIM_KEY}; -pub use claim_bucket::{ClaimBucket, ClaimBucketError}; +pub use claim_bucket::{ClaimBucket, ClaimBucketError, ClaimBucketHeader}; pub use claim_check::{ClaimCheckPublisher, ClaimResolveError, ClaimResolver, MaxPayload, is_claim, resolve_claim}; pub use claim_retention::ClaimRetention; #[cfg(not(coverage))] From d39f1a222445734ac8bcb8a7c91f13dee818c0de Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 23:53:37 -0400 Subject: [PATCH 42/55] fix(channel): stop orphaning a session the agent has already opened The bridge does not mint these handles and held them to an alphabet of its own, so an agent naming sessions the way its protocol allows had each one refused after it was already holding it, once per redelivery. Signed-off-by: Yordis Prieto --- .../channel-bridge-telegram/src/acp_port.rs | 96 ++++++++++++------- .../src/acp_port_tests.rs | 9 +- .../channel/trogon-channel/src/agent_port.rs | 56 +++++++++-- .../trogon-channel/src/agent_port_tests.rs | 20 +++- .../trogon-channel/src/conversation_tests.rs | 4 +- .../crates/channel/trogon-channel/src/lib.rs | 3 +- 6 files changed, 137 insertions(+), 51 deletions(-) diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs index 726ef80a57..ba417d9c62 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs @@ -35,7 +35,7 @@ pub enum AcpPortError { #[error("agent request failed: {0}")] Rpc(agent_client_protocol::Error), #[error(transparent)] - SessionId(#[from] trogon_channel::EndpointError), + SessionId(#[from] trogon_channel::AgentSessionIdError), } impl AgentPortError for AcpPortError { @@ -176,6 +176,46 @@ impl AcpPort { methods, } } + + async fn cancel_id(&self, session_id: &str) -> Result<(), AcpPortError> { + self.bridge + .cancel(CancelNotification::new(session_id.to_string())) + .await + .map_err(AcpPortError::Rpc) + } + + /// The release ladder, walked against a session named the way the agent + /// named it. [`AgentPort::release_session`] is the same walk over a session + /// the bridge can hold; this one also serves the session it cannot, which + /// has no [`AgentSessionId`] to be passed as. + async fn release_id(&self, session_id: &str, reason: ReleaseReason) -> SessionRelease { + let cancelled = match self.cancel_id(session_id).await { + Ok(()) => ReleaseStep::Done, + Err(error) => { + warn!(session = %session_id, reason = ?reason, error = %error, "Cancel failed while releasing session"); + ReleaseStep::Failed + } + }; + + let closed = if self.methods.supports(SessionMethod::Close) { + match self + .bridge + .close_session(CloseSessionRequest::new(session_id.to_string())) + .await + { + Ok(_) => ReleaseStep::Done, + Err(error) => { + warn!(session = %session_id, reason = ?reason, error = %error, "Close failed while releasing session"); + ReleaseStep::Failed + } + } + } else { + info!(session = %session_id, "Agent does not advertise session/close; releasing without it"); + ReleaseStep::Unsupported + }; + + SessionRelease { cancelled, closed } + } } /// Human-readable context prefix: the only part of the conversational @@ -218,7 +258,27 @@ impl AgentPort for AcpPort { .new_session(NewSessionRequest::new(self.agent_cwd.clone())) .await .map_err(AcpPortError::Rpc)?; - Ok(AgentSessionId::new(response.session_id.to_string())?) + let minted = response.session_id.to_string(); + match AgentSessionId::new(&minted) { + Ok(session) => Ok(session), + // The agent is holding a session by the time it answers, so failing + // here is not failing to create one: it is being handed one nothing + // can ask for again. Nobody above the port can release what it + // cannot name, and redelivery calls this again, so an id refused + // without this would leave one live session per attempt at the + // agent until the message is finally dropped. + Err(error) => { + let release = self.release_id(&minted, ReleaseReason::Unnamable).await; + warn!( + session = %minted, + error = %error, + cancelled = ?release.cancelled, + closed = ?release.closed, + "Agent named a session this bridge cannot hold; released it instead" + ); + Err(AcpPortError::SessionId(error)) + } + } } async fn prompt(&self, session: &AgentSessionId, event: &InboundEvent) -> Result { @@ -238,10 +298,7 @@ impl AgentPort for AcpPort { } async fn cancel(&self, session: &AgentSessionId) -> Result<(), Self::Error> { - self.bridge - .cancel(CancelNotification::new(session.as_str().to_string())) - .await - .map_err(AcpPortError::Rpc) + self.cancel_id(session.as_str()).await } /// Stop the turn first, then hand the session back. Cancel before close so @@ -251,31 +308,6 @@ impl AgentPort for AcpPort { /// part of this: the bridge is done with the session, which is not the same /// as the user asking for its history to be destroyed. async fn release_session(&self, session: &AgentSessionId, reason: ReleaseReason) -> SessionRelease { - let cancelled = match self.cancel(session).await { - Ok(()) => ReleaseStep::Done, - Err(error) => { - warn!(session = %session, reason = ?reason, error = %error, "Cancel failed while releasing session"); - ReleaseStep::Failed - } - }; - - let closed = if self.methods.supports(SessionMethod::Close) { - match self - .bridge - .close_session(CloseSessionRequest::new(session.as_str().to_string())) - .await - { - Ok(_) => ReleaseStep::Done, - Err(error) => { - warn!(session = %session, reason = ?reason, error = %error, "Close failed while releasing session"); - ReleaseStep::Failed - } - } - } else { - info!(session = %session, "Agent does not advertise session/close; releasing without it"); - ReleaseStep::Unsupported - }; - - SessionRelease { cancelled, closed } + self.release_id(session.as_str(), reason).await } } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs index 4941e07d14..f72a3ec257 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs @@ -126,10 +126,11 @@ fn only_a_rejected_session_id_reads_as_a_lost_session() { } } -/// An id the agent handed back that is not a usable token failed before any -/// session existed, so there is no session for a fresh one to repair. Reading it -/// as a lost session would have the pipeline open a replacement against an agent -/// that is going to name the next one just as unusably. +/// An id the agent handed back that is not a usable token names a session the +/// port has already handed straight back, so there is nothing for a fresh one to +/// repair. Reading it as a lost session would have the pipeline open a +/// replacement against an agent that is going to name the next one just as +/// unusably. #[test] fn an_unusable_session_id_is_not_a_lost_session() { let error = trogon_channel::AgentSessionId::new("sess 1").expect_err("an id with a space is not a token"); diff --git a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs index ad7ef3c415..c6a92b4b41 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs @@ -3,22 +3,50 @@ mod agent_port_tests; use crate::conversation::ConversationRecord; -use crate::endpoint::EndpointError; use crate::event::InboundEvent; -use crate::safe_token::SafeToken; use serde::{Deserialize, Deserializer, Serialize}; +use trogon_nats::{NatsToken, SubjectTokenViolationError}; + +/// Why a handle an agent minted cannot be an [`AgentSessionId`]. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum AgentSessionIdError { + #[error("a session id must not be empty")] + Empty, + #[error("a session id contains invalid character: {0:?}")] + InvalidCharacter(char), + #[error("a session id is too long: {0} characters")] + TooLong(usize), +} + +impl From for AgentSessionIdError { + fn from(error: SubjectTokenViolationError) -> Self { + match error { + SubjectTokenViolationError::Empty => Self::Empty, + SubjectTokenViolationError::InvalidCharacter(c) => Self::InvalidCharacter(c), + SubjectTokenViolationError::TooLong(len) => Self::TooLong(len), + } + } +} /// An agent-side session handle. Opaque to everything except the port /// implementation that minted it: only meaningful at the agent it belongs to, /// which is why a conversation stores it next to (never instead of) the /// agent binding. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] -#[serde(transparent)] -pub struct AgentSessionId(SafeToken); +/// +/// A subject token rather than a channel token: this is the one identifier here +/// the bridge does not choose, and it is only ever a value inside a stored +/// record, never a KV key, so the channel's key alphabet has no claim on it. +/// What does constrain it is the narrowest thing every agent transport must do +/// with a handle, which is address a session by it (acp-nats spends it as a +/// subject token). Holding it to less than that rejects ids a protocol and its +/// transport both accept, and the rejection lands after the agent has already +/// minted the session. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AgentSessionId(NatsToken); impl AgentSessionId { - pub fn new(id: impl Into) -> Result { - Ok(Self(SafeToken::new(id)?)) + pub fn new(id: impl AsRef) -> Result { + Ok(Self(NatsToken::new(id)?)) } pub fn as_str(&self) -> &str { @@ -32,6 +60,15 @@ impl std::fmt::Display for AgentSessionId { } } +impl Serialize for AgentSessionId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + impl<'de> Deserialize<'de> for AgentSessionId { fn deserialize(deserializer: D) -> Result where @@ -85,6 +122,11 @@ pub enum ReleaseReason { /// suspicion is a guess, so the agent may still hold the old session; it is /// told to let go rather than left holding one nothing points at. Replaced, + /// The agent answered `session/new` with a handle this bridge cannot name + /// (see [`AgentSessionIdError`]). The session exists at the agent and + /// nothing above the port will ever be able to ask for it, so it goes back + /// immediately rather than being left for the agent's lifetime. + Unnamable, } /// How one step of the release ladder ended. diff --git a/rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs index 38eefdf7c7..3f869c1772 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs @@ -1,15 +1,25 @@ use super::*; -/// A session id is an endpoint token because it becomes part of one: it is -/// stored in `ConversationRecord` and printed back into the pipeline's logs. +/// A session id has to survive being spent as a subject token, which is how a +/// port addresses the session it names. #[test] -fn a_session_id_must_be_an_endpoint_token() { +fn a_session_id_must_be_a_subject_token() { assert_eq!(AgentSessionId::new("sess-1").expect("valid").as_str(), "sess-1"); assert_eq!( AgentSessionId::new("sess 1").unwrap_err(), - EndpointError::InvalidCharacter(' ') + AgentSessionIdError::InvalidCharacter(' ') ); - assert_eq!(AgentSessionId::new("").unwrap_err(), EndpointError::Empty); + assert_eq!(AgentSessionId::new("").unwrap_err(), AgentSessionIdError::Empty); +} + +/// The bridge never mints these, so the alphabet its own keys are drawn from +/// has no say: an agent that names sessions the way ACP allows must not have +/// them refused after it has already opened one. +#[test] +fn a_session_id_accepts_what_a_channel_key_would_not() { + for id in ["sess:1", "urn:acp:session:9", "sess/1", "sess+1"] { + assert_eq!(AgentSessionId::new(id).expect("valid").as_str(), id); + } } /// Every log line the pipeline writes about a session (`session = %session`) diff --git a/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs index 92e800b428..7572035171 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::agent_port::AgentSessionId; +use crate::agent_port::{AgentSessionId, AgentSessionIdError}; use trogon_std::UuidV7Generator; #[test] @@ -74,7 +74,7 @@ fn agent_id_deserialize_rejects_unsafe_tokens() { fn agent_session_id_rejects_unsafe_tokens() { assert_eq!( AgentSessionId::new("sess.1").unwrap_err(), - EndpointError::InvalidCharacter('.') + AgentSessionIdError::InvalidCharacter('.') ); } diff --git a/rsworkspace/crates/channel/trogon-channel/src/lib.rs b/rsworkspace/crates/channel/trogon-channel/src/lib.rs index 85c244ebfe..861ec54e82 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/lib.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/lib.rs @@ -27,7 +27,8 @@ pub mod safe_token; pub mod store; pub use agent_port::{ - AgentPort, AgentPortError, AgentSessionId, PromptOutcome, ReleaseReason, ReleaseStep, SessionRelease, + AgentPort, AgentPortError, AgentSessionId, AgentSessionIdError, PromptOutcome, ReleaseReason, ReleaseStep, + SessionRelease, }; pub use command::{Command, CommandTriggers, ParsedText}; pub use command_trigger::{CommandTrigger, CommandTriggerError}; From 05e335a7acdbe3881f00dab8a328661517c34605 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 4 Aug 2026 23:54:33 -0400 Subject: [PATCH 43/55] fix(devops): give the Compose service the image it says it builds The service was left naming a Dockerfile the tree no longer had, so the profile that exists to run this bridge locally could not bring it up at all. Signed-off-by: Yordis Prieto --- .github/canary-container-services.json | 5 ++ .github/workflows/canary-container-images.yml | 1 + .../channel-bridge-telegram/Dockerfile | 50 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 devops/docker/compose/services/channel-bridge-telegram/Dockerfile diff --git a/.github/canary-container-services.json b/.github/canary-container-services.json index f910e40842..03bd122421 100644 --- a/.github/canary-container-services.json +++ b/.github/canary-container-services.json @@ -3,5 +3,10 @@ "image": "trogonai/trogon-gateway", "context": "./rsworkspace", "dockerfile": "./devops/docker/compose/services/trogon-gateway/Dockerfile" + }, + "channel-bridge-telegram": { + "image": "trogonai/channel-bridge-telegram", + "context": "./rsworkspace", + "dockerfile": "./devops/docker/compose/services/channel-bridge-telegram/Dockerfile" } } diff --git a/.github/workflows/canary-container-images.yml b/.github/workflows/canary-container-images.yml index 4a8095e601..3d9839641f 100644 --- a/.github/workflows/canary-container-images.yml +++ b/.github/workflows/canary-container-images.yml @@ -24,6 +24,7 @@ jobs: matrix: service: - trogon-gateway + - channel-bridge-telegram steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/devops/docker/compose/services/channel-bridge-telegram/Dockerfile b/devops/docker/compose/services/channel-bridge-telegram/Dockerfile new file mode 100644 index 0000000000..8512189403 --- /dev/null +++ b/devops/docker/compose/services/channel-bridge-telegram/Dockerfile @@ -0,0 +1,50 @@ +# ── Stage 1: chef — generate dependency recipe ────────────────────────────── +FROM rust:1.96.0-slim-bookworm AS chef + +RUN cargo install cargo-chef --locked + +WORKDIR /build + +# ── Stage 2: planner — capture dependency graph ───────────────────────────── +FROM chef AS planner + +# Every workspace member the root manifest declares, not just the ones this +# binary depends on: cargo refuses to read a workspace whose members are +# missing, so a recipe prepared without them fails before any crate is built. +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ +COPY cli/ cli/ +COPY wasm-components/ wasm-components/ + +RUN cargo chef prepare --recipe-path recipe.json + +# ── Stage 3: builder — cached dependency build + final compile ────────────── +FROM chef AS builder + +COPY --from=planner /build/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json -p channel-bridge-telegram + +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ +COPY cli/ cli/ +COPY wasm-components/ wasm-components/ + +RUN cargo build --release -p channel-bridge-telegram && \ + strip target/release/channel-bridge-telegram + +# ── Stage 4: runtime ──────────────────────────────────────────────────────── +FROM debian:bookworm-20260518-slim AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --no-create-home --shell /usr/sbin/nologin trogon + +COPY --from=builder /build/target/release/channel-bridge-telegram /usr/local/bin/channel-bridge-telegram + +USER trogon + +STOPSIGNAL SIGTERM + +ENTRYPOINT ["/usr/local/bin/channel-bridge-telegram"] From 1071adbd72ce4627eba68e49b7beefb9946b27ce Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 5 Aug 2026 03:48:53 -0400 Subject: [PATCH 44/55] refactor(channel): declare test modules without a path attribute These crates were the last place where finding a module's tests meant following a path attribute to a sibling file, instead of the layout every other crate in the workspace already uses. Signed-off-by: Yordis Prieto --- .../crates/channel/channel-bridge-telegram/src/acp_port.rs | 3 +-- .../src/{acp_port_tests.rs => acp_port/tests.rs} | 0 .../crates/channel/channel-bridge-telegram/src/config.rs | 3 +-- .../src/{config_tests.rs => config/tests.rs} | 0 .../crates/channel/channel-bridge-telegram/src/parse.rs | 3 +-- .../src/{parse_tests.rs => parse/tests.rs} | 0 .../crates/channel/channel-bridge-telegram/src/pipeline.rs | 3 +-- .../src/{pipeline_tests.rs => pipeline/tests.rs} | 0 .../crates/channel/channel-bridge-telegram/src/render.rs | 3 +-- .../src/{render_tests.rs => render/tests.rs} | 0 rsworkspace/crates/channel/trogon-channel/src/agent_port.rs | 3 +-- .../src/{agent_port_tests.rs => agent_port/tests.rs} | 0 rsworkspace/crates/channel/trogon-channel/src/command.rs | 3 +-- .../trogon-channel/src/{command_tests.rs => command/tests.rs} | 0 .../crates/channel/trogon-channel/src/command_trigger_input.rs | 3 +-- .../tests.rs} | 0 rsworkspace/crates/channel/trogon-channel/src/conversation.rs | 3 +-- .../src/{conversation_tests.rs => conversation/tests.rs} | 0 rsworkspace/crates/channel/trogon-channel/src/endpoint.rs | 3 +-- .../src/{endpoint_tests.rs => endpoint/tests.rs} | 0 rsworkspace/crates/channel/trogon-channel/src/event.rs | 3 +-- .../trogon-channel/src/{event_tests.rs => event/tests.rs} | 0 rsworkspace/crates/channel/trogon-channel/src/safe_token.rs | 3 +-- .../src/{safe_token_tests.rs => safe_token/tests.rs} | 0 rsworkspace/crates/channel/trogon-channel/src/store.rs | 3 +-- .../trogon-channel/src/{store_tests.rs => store/tests.rs} | 0 26 files changed, 13 insertions(+), 26 deletions(-) rename rsworkspace/crates/channel/channel-bridge-telegram/src/{acp_port_tests.rs => acp_port/tests.rs} (100%) rename rsworkspace/crates/channel/channel-bridge-telegram/src/{config_tests.rs => config/tests.rs} (100%) rename rsworkspace/crates/channel/channel-bridge-telegram/src/{parse_tests.rs => parse/tests.rs} (100%) rename rsworkspace/crates/channel/channel-bridge-telegram/src/{pipeline_tests.rs => pipeline/tests.rs} (100%) rename rsworkspace/crates/channel/channel-bridge-telegram/src/{render_tests.rs => render/tests.rs} (100%) rename rsworkspace/crates/channel/trogon-channel/src/{agent_port_tests.rs => agent_port/tests.rs} (100%) rename rsworkspace/crates/channel/trogon-channel/src/{command_tests.rs => command/tests.rs} (100%) rename rsworkspace/crates/channel/trogon-channel/src/{command_trigger_input_tests.rs => command_trigger_input/tests.rs} (100%) rename rsworkspace/crates/channel/trogon-channel/src/{conversation_tests.rs => conversation/tests.rs} (100%) rename rsworkspace/crates/channel/trogon-channel/src/{endpoint_tests.rs => endpoint/tests.rs} (100%) rename rsworkspace/crates/channel/trogon-channel/src/{event_tests.rs => event/tests.rs} (100%) rename rsworkspace/crates/channel/trogon-channel/src/{safe_token_tests.rs => safe_token/tests.rs} (100%) rename rsworkspace/crates/channel/trogon-channel/src/{store_tests.rs => store/tests.rs} (100%) diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs index ba417d9c62..092ce23833 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs @@ -1,6 +1,5 @@ #[cfg(test)] -#[path = "acp_port_tests.rs"] -mod acp_port_tests; +mod tests; use agent_client_protocol::ErrorCode; use agent_client_protocol::schema::v1::InitializeResponse; diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port/tests.rs similarity index 100% rename from rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port_tests.rs rename to rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port/tests.rs diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs index 12b9211c87..1527eb1dbc 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs @@ -1,6 +1,5 @@ #[cfg(test)] -#[path = "config_tests.rs"] -mod config_tests; +mod tests; use acp_nats::{AcpPrefix, AcpPrefixError, NatsConfig}; use std::path::PathBuf; diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config/tests.rs similarity index 100% rename from rsworkspace/crates/channel/channel-bridge-telegram/src/config_tests.rs rename to rsworkspace/crates/channel/channel-bridge-telegram/src/config/tests.rs diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs index e816369c96..510a34694c 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs @@ -1,6 +1,5 @@ #[cfg(test)] -#[path = "parse_tests.rs"] -mod parse_tests; +mod tests; use teloxide::types::{Update, UpdateKind}; use trogon_channel::{CommandTriggers, Endpoint, InboundEvent, MessageRef, PlatformUserId, Sender}; diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse/tests.rs similarity index 100% rename from rsworkspace/crates/channel/channel-bridge-telegram/src/parse_tests.rs rename to rsworkspace/crates/channel/channel-bridge-telegram/src/parse/tests.rs diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs index 15f19b95b6..6e22657c28 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -1,6 +1,5 @@ #[cfg(test)] -#[path = "pipeline_tests.rs"] -mod pipeline_tests; +mod tests; use crate::constants::{NEW_SESSION_ACKNOWLEDGEMENT, TEXT_CHUNK_LIMIT}; use crate::outbound::{SendText, SendTyping}; diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline/tests.rs similarity index 100% rename from rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline_tests.rs rename to rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline/tests.rs diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs index 7b5e37fdf4..9a99e89899 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs @@ -1,6 +1,5 @@ #[cfg(test)] -#[path = "render_tests.rs"] -mod render_tests; +mod tests; use acp_nats::ClientHandler; use agent_client_protocol::schema::v1::{ diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/render/tests.rs similarity index 100% rename from rsworkspace/crates/channel/channel-bridge-telegram/src/render_tests.rs rename to rsworkspace/crates/channel/channel-bridge-telegram/src/render/tests.rs diff --git a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs index c6a92b4b41..414d0ef6b3 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs @@ -1,6 +1,5 @@ #[cfg(test)] -#[path = "agent_port_tests.rs"] -mod agent_port_tests; +mod tests; use crate::conversation::ConversationRecord; use crate::event::InboundEvent; diff --git a/rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port/tests.rs similarity index 100% rename from rsworkspace/crates/channel/trogon-channel/src/agent_port_tests.rs rename to rsworkspace/crates/channel/trogon-channel/src/agent_port/tests.rs diff --git a/rsworkspace/crates/channel/trogon-channel/src/command.rs b/rsworkspace/crates/channel/trogon-channel/src/command.rs index 8475039796..f7753aaab0 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/command.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/command.rs @@ -1,6 +1,5 @@ #[cfg(test)] -#[path = "command_tests.rs"] -mod command_tests; +mod tests; use crate::CommandTrigger; use crate::CommandTriggerInput; diff --git a/rsworkspace/crates/channel/trogon-channel/src/command_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/command/tests.rs similarity index 100% rename from rsworkspace/crates/channel/trogon-channel/src/command_tests.rs rename to rsworkspace/crates/channel/trogon-channel/src/command/tests.rs diff --git a/rsworkspace/crates/channel/trogon-channel/src/command_trigger_input.rs b/rsworkspace/crates/channel/trogon-channel/src/command_trigger_input.rs index 6b25ceed1b..79a34a597b 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/command_trigger_input.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/command_trigger_input.rs @@ -1,8 +1,7 @@ //! Untrusted command-trigger text before validation. #[cfg(test)] -#[path = "command_trigger_input_tests.rs"] -mod command_trigger_input_tests; +mod tests; /// Raw trigger text from config or another boundary. Convert once into /// [`crate::CommandTrigger`]. diff --git a/rsworkspace/crates/channel/trogon-channel/src/command_trigger_input_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/command_trigger_input/tests.rs similarity index 100% rename from rsworkspace/crates/channel/trogon-channel/src/command_trigger_input_tests.rs rename to rsworkspace/crates/channel/trogon-channel/src/command_trigger_input/tests.rs diff --git a/rsworkspace/crates/channel/trogon-channel/src/conversation.rs b/rsworkspace/crates/channel/trogon-channel/src/conversation.rs index f9891d6f56..0fcf32a379 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/conversation.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/conversation.rs @@ -1,6 +1,5 @@ #[cfg(test)] -#[path = "conversation_tests.rs"] -mod conversation_tests; +mod tests; use crate::agent_port::AgentSessionId; use crate::endpoint::{EndpointError, PrincipalId}; diff --git a/rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/conversation/tests.rs similarity index 100% rename from rsworkspace/crates/channel/trogon-channel/src/conversation_tests.rs rename to rsworkspace/crates/channel/trogon-channel/src/conversation/tests.rs diff --git a/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs index a8dbf6645a..3a2b9c1d82 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs @@ -1,6 +1,5 @@ #[cfg(test)] -#[path = "endpoint_tests.rs"] -mod endpoint_tests; +mod tests; use crate::safe_token::{SafeToken, SafeTokenError}; use serde::{Deserialize, Deserializer, Serialize}; diff --git a/rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/endpoint/tests.rs similarity index 100% rename from rsworkspace/crates/channel/trogon-channel/src/endpoint_tests.rs rename to rsworkspace/crates/channel/trogon-channel/src/endpoint/tests.rs diff --git a/rsworkspace/crates/channel/trogon-channel/src/event.rs b/rsworkspace/crates/channel/trogon-channel/src/event.rs index 8f48221bc4..c3cfb6882e 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event.rs @@ -1,6 +1,5 @@ #[cfg(test)] -#[path = "event_tests.rs"] -mod event_tests; +mod tests; use crate::command::Command; use crate::endpoint::{Endpoint, EndpointError}; diff --git a/rsworkspace/crates/channel/trogon-channel/src/event_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/event/tests.rs similarity index 100% rename from rsworkspace/crates/channel/trogon-channel/src/event_tests.rs rename to rsworkspace/crates/channel/trogon-channel/src/event/tests.rs diff --git a/rsworkspace/crates/channel/trogon-channel/src/safe_token.rs b/rsworkspace/crates/channel/trogon-channel/src/safe_token.rs index 9f55365cd4..1a4ef66ad0 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/safe_token.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/safe_token.rs @@ -4,8 +4,7 @@ //! the intersection of what NATS KV keys and NATS subject tokens accept. #[cfg(test)] -#[path = "safe_token_tests.rs"] -mod safe_token_tests; +mod tests; use serde::{Deserialize, Deserializer, Serialize}; diff --git a/rsworkspace/crates/channel/trogon-channel/src/safe_token_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/safe_token/tests.rs similarity index 100% rename from rsworkspace/crates/channel/trogon-channel/src/safe_token_tests.rs rename to rsworkspace/crates/channel/trogon-channel/src/safe_token/tests.rs diff --git a/rsworkspace/crates/channel/trogon-channel/src/store.rs b/rsworkspace/crates/channel/trogon-channel/src/store.rs index 8c85f55184..2c53aa0316 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store.rs @@ -9,8 +9,7 @@ use trogon_nats::jetstream::{ use trogon_std::NowV7; #[cfg(test)] -#[path = "store_tests.rs"] -mod store_tests; +mod tests; #[derive(Debug, thiserror::Error)] pub enum ChannelStoreError { diff --git a/rsworkspace/crates/channel/trogon-channel/src/store_tests.rs b/rsworkspace/crates/channel/trogon-channel/src/store/tests.rs similarity index 100% rename from rsworkspace/crates/channel/trogon-channel/src/store_tests.rs rename to rsworkspace/crates/channel/trogon-channel/src/store/tests.rs From e14f65754f02037068c0f45f0f5d71a0a5eb90cd Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 5 Aug 2026 03:48:58 -0400 Subject: [PATCH 45/55] chore(devops): keep the Telegram bridge out of the container surface Nothing builds this image at pull request time and the canary workflow only fires on a push to main, so a build that cannot succeed lands as a red main rather than as a failed check. The Compose service goes with the file it built from, since a profile naming a Dockerfile the tree does not have cannot come up either. Signed-off-by: Yordis Prieto --- .github/canary-container-services.json | 5 -- .github/workflows/canary-container-images.yml | 1 - devops/docker/compose/compose.yml | 37 -------------- .../channel-bridge-telegram/Dockerfile | 50 ------------------- 4 files changed, 93 deletions(-) delete mode 100644 devops/docker/compose/services/channel-bridge-telegram/Dockerfile diff --git a/.github/canary-container-services.json b/.github/canary-container-services.json index 03bd122421..f910e40842 100644 --- a/.github/canary-container-services.json +++ b/.github/canary-container-services.json @@ -3,10 +3,5 @@ "image": "trogonai/trogon-gateway", "context": "./rsworkspace", "dockerfile": "./devops/docker/compose/services/trogon-gateway/Dockerfile" - }, - "channel-bridge-telegram": { - "image": "trogonai/channel-bridge-telegram", - "context": "./rsworkspace", - "dockerfile": "./devops/docker/compose/services/channel-bridge-telegram/Dockerfile" } } diff --git a/.github/workflows/canary-container-images.yml b/.github/workflows/canary-container-images.yml index 3d9839641f..4a8095e601 100644 --- a/.github/workflows/canary-container-images.yml +++ b/.github/workflows/canary-container-images.yml @@ -24,7 +24,6 @@ jobs: matrix: service: - trogon-gateway - - channel-bridge-telegram steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/devops/docker/compose/compose.yml b/devops/docker/compose/compose.yml index 08c6b9d04c..9d75732694 100644 --- a/devops/docker/compose/compose.yml +++ b/devops/docker/compose/compose.yml @@ -45,42 +45,6 @@ services: start_period: 10s retries: 3 - # Telegram channel bridge: consumes the gateway's raw TELEGRAM stream and - # drives the shared agent over acp-nats. Needs the gateway running (it - # provisions both the stream and the claim bucket an oversized update is - # offloaded into) and an agent behind acp-nats. - channel-bridge-telegram: - build: - context: ../../../rsworkspace - dockerfile: ../devops/docker/compose/services/channel-bridge-telegram/Dockerfile - env_file: - - path: .env - required: false - environment: - # Passed through with no value on purpose. Writing "${VAR:-}" would set the - # variable to the empty string when the host has not set it, which reads as - # "configured, blank" instead of "absent"; a bare key leaves it unset. - TELEGRAM_BOT_TOKEN: - CHANNEL_SEED_TELEGRAM_USERS: - CHANNEL_PREFIX: "${CHANNEL_PREFIX:-prod}" - # "${VAR-default}" rather than "${VAR:-default}": the bridge reads a blank - # trigger list as "recognize nothing, forward everything", so the default - # may only fill in for an unset variable. The colon form substitutes on - # blank too, which would make that setting unreachable from Compose. - CHANNEL_NEW_SESSION_TRIGGERS: "${CHANNEL_NEW_SESSION_TRIGGERS-/new,/reset}" - TELEGRAM_INBOUND_STREAM: "${TELEGRAM_INBOUND_STREAM:-TELEGRAM}" - ACP_PREFIX: "${ACP_PREFIX:-acp}" - NATS_URL: "nats:4222" - RUST_LOG: "${RUST_LOG:-info}" - depends_on: - trogon-gateway: - condition: service_healthy - nats: - condition: service_healthy - restart: unless-stopped - profiles: - - telegram - # Backing store for the optional Postgres schedules read-model projection # (SCHEDULER_PROJECTION_BACKEND=postgres). The default NATS KV projection does # not need this service. @@ -102,7 +66,6 @@ services: start_period: 5s retries: 5 - ngrok: image: ngrok/ngrok:3.39.6-alpine env_file: diff --git a/devops/docker/compose/services/channel-bridge-telegram/Dockerfile b/devops/docker/compose/services/channel-bridge-telegram/Dockerfile deleted file mode 100644 index 8512189403..0000000000 --- a/devops/docker/compose/services/channel-bridge-telegram/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# ── Stage 1: chef — generate dependency recipe ────────────────────────────── -FROM rust:1.96.0-slim-bookworm AS chef - -RUN cargo install cargo-chef --locked - -WORKDIR /build - -# ── Stage 2: planner — capture dependency graph ───────────────────────────── -FROM chef AS planner - -# Every workspace member the root manifest declares, not just the ones this -# binary depends on: cargo refuses to read a workspace whose members are -# missing, so a recipe prepared without them fails before any crate is built. -COPY Cargo.toml Cargo.lock ./ -COPY crates/ crates/ -COPY cli/ cli/ -COPY wasm-components/ wasm-components/ - -RUN cargo chef prepare --recipe-path recipe.json - -# ── Stage 3: builder — cached dependency build + final compile ────────────── -FROM chef AS builder - -COPY --from=planner /build/recipe.json recipe.json -RUN cargo chef cook --release --recipe-path recipe.json -p channel-bridge-telegram - -COPY Cargo.toml Cargo.lock ./ -COPY crates/ crates/ -COPY cli/ cli/ -COPY wasm-components/ wasm-components/ - -RUN cargo build --release -p channel-bridge-telegram && \ - strip target/release/channel-bridge-telegram - -# ── Stage 4: runtime ──────────────────────────────────────────────────────── -FROM debian:bookworm-20260518-slim AS runtime - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -RUN useradd --no-create-home --shell /usr/sbin/nologin trogon - -COPY --from=builder /build/target/release/channel-bridge-telegram /usr/local/bin/channel-bridge-telegram - -USER trogon - -STOPSIGNAL SIGTERM - -ENTRYPOINT ["/usr/local/bin/channel-bridge-telegram"] From b09d4bd22cac5c645247817bad2d89af753da3a0 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 5 Aug 2026 03:49:06 -0400 Subject: [PATCH 46/55] refactor(nats): let a test carry what a compile-time check was carrying The check had to be spelled as a constant, which meant widening the repo's own rule about where constants may live in order to accept it, and being evaluated at compile time it left the file reporting lines no test can reach. What it guaranteed is one assertion wide. Signed-off-by: Yordis Prieto --- .../trogon-nats/src/jetstream/claim_bucket.rs | 33 +++---------------- .../src/jetstream/claim_bucket/tests.rs | 19 +++++++++++ 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs index 607fb5a9ae..7bcdd5e761 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs @@ -20,36 +20,11 @@ pub enum ClaimBucketError { #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ClaimBucket(String); -/// The characters a bucket name is drawn from, stated once so that -/// [`ClaimBucket::new`] and the compile-time check below cannot come to -/// different answers about the same name. -const fn is_permitted(c: char) -> bool { +/// The characters a bucket name is drawn from. +fn is_permitted(c: char) -> bool { c.is_ascii_alphanumeric() || matches!(c, '-' | '_') } -/// Whether [`ClaimBucket::new`] would accept this name, asked at compile time. -/// Walks bytes because that is what a `const` can walk; a multi-byte character -/// fails it, which is the answer the factory gives too. -const fn is_a_bucket_name(name: &str) -> bool { - let bytes = name.as_bytes(); - if bytes.is_empty() { - return false; - } - let mut index = 0; - while index < bytes.len() { - if !is_permitted(bytes[index] as char) { - return false; - } - index += 1; - } - true -} - -const _: () = assert!( - is_a_bucket_name(DEFAULT_CLAIM_BUCKET), - "DEFAULT_CLAIM_BUCKET must be a name ClaimBucket::new accepts" -); - impl ClaimBucket { pub fn new(name: impl Into) -> Result { let name = name.into(); @@ -70,8 +45,8 @@ impl ClaimBucket { impl Default for ClaimBucket { /// The one bucket a trogon deployment uses. Built without going through /// [`ClaimBucket::new`] because the factory is fallible and this cannot be: - /// the constant is held to the factory's rule by `is_a_bucket_name` above, - /// which fails the build rather than a deployment. + /// the constant is held to the factory's rule by the tests, so a name the + /// factory would refuse fails there rather than in a deployment. fn default() -> Self { Self(DEFAULT_CLAIM_BUCKET.to_string()) } diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs index caab261950..a2c401006b 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs @@ -27,6 +27,10 @@ fn a_name_nats_would_refuse_is_refused_here() { ClaimBucket::new("claims/one").unwrap_err(), ClaimBucketError::InvalidCharacter('/') ); + assert_eq!( + ClaimBucket::new("claims-ñ").unwrap_err(), + ClaimBucketError::InvalidCharacter('ñ') + ); } /// [`ClaimBucket::default`] skips validation because the constant cannot fail. @@ -39,3 +43,18 @@ fn the_default_bucket_is_a_valid_name() { ); assert_eq!(ClaimBucket::default().to_string(), DEFAULT_CLAIM_BUCKET); } + +/// A header is input, so what it holds may not be a bucket name at all. Both +/// answers matter: the name to compare against the bucket this consumer opened, +/// and the text an operator has to read when there is nothing to compare. +#[test] +fn a_header_parses_to_a_bucket_name_or_says_why_it_cannot() { + let named = ClaimBucketHeader::new("trogon-claims"); + assert_eq!(named.parse().expect("valid"), ClaimBucket::default()); + assert_eq!(named.as_str(), "trogon-claims"); + assert_eq!(named.to_string(), "trogon-claims"); + + let unnamable = ClaimBucketHeader::new("trogon.claims"); + assert_eq!(unnamable.parse().unwrap_err(), ClaimBucketError::InvalidCharacter('.')); + assert_eq!(unnamable.to_string(), "trogon.claims"); +} From 3a9aebb4742e0709421460708b336cf2ee71f33e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 5 Aug 2026 03:49:06 -0400 Subject: [PATCH 47/55] docs(nats): say what an unnamable bucket header actually reports The variant read as though the header had been left out, which is a different deployment problem from the one it is raised for. Signed-off-by: Yordis Prieto --- .../crates/platform/trogon-nats/src/jetstream/claim_check.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs index 207cfa2ea9..87a19d817d 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_check.rs @@ -139,8 +139,8 @@ pub enum ClaimResolveError { /// bucket names, they are simply not the same one. #[error("claim names bucket {named} but this consumer reads {expected}")] BucketMismatch { expected: ClaimBucket, named: ClaimBucket }, - /// The header did not carry a bucket name at all, so there is nothing to - /// compare against the bucket this consumer opened. Kept apart from a + /// The header carried a value that is not a legal bucket name, so there is + /// nothing to compare against the bucket this consumer opened. Kept apart from a /// mismatch because it says something different: a publisher naming a real /// but foreign bucket is a deployment pointed the wrong way, whereas a name /// no NATS server would accept is a corrupted or forged header. From c47facabf008a456c3b15f914ec3c074ef504fcb Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 5 Aug 2026 03:49:15 -0400 Subject: [PATCH 48/55] docs(adr): stop promising a terminal record a failed write can withhold The decision read as though every abandoned handle ends in a record, so a reader could conclude that waiting on one is safe. Liveness rests on the reader's deadline, and only the explanation an agent receives depends on the record. Signed-off-by: Yordis Prieto --- .../0044-inbound-media-fetch-out-of-band.md | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/adr/0044-inbound-media-fetch-out-of-band.md b/docs/adr/0044-inbound-media-fetch-out-of-band.md index 4abd984210..4dc615c0a1 100644 --- a/docs/adr/0044-inbound-media-fetch-out-of-band.md +++ b/docs/adr/0044-inbound-media-fetch-out-of-band.md @@ -132,11 +132,23 @@ is that absence is never permanent by accident, and two rules give it that. The downloader writes a `failed` record for every permanent error, and also on its last delivery attempt, which it recognizes from the delivery count JetStream -puts on the message. A handle whose deliveries are exhausted therefore ends as a -terminal record rather than as silence, since a consumer that has stopped -redelivering will never speak again on its own. The reader's deadline is the -backstop for the one case no consumer can cover, a downloader that never runs at -all. +puts on the message. The write comes before the acknowledgement, and the +acknowledgement is what the write earns: a downloader that cannot reach the KV +bucket leaves the message unacknowledged, so JetStream redelivers and the +terminal record is attempted again on the next delivery. A handle whose +deliveries are exhausted therefore ends as a terminal record rather than as +silence, since a consumer that has stopped redelivering will never speak again on +its own. + +Redelivery is a bounded number of attempts, so ordering the write before the ack +narrows the window in which a handle ends absent without closing it. A KV bucket +unreachable for the whole life of a message, or a downloader that dies between +its final read and its final write, exhausts the deliveries with nothing written. +That is the same shape as a downloader that never runs at all, and it has the +same backstop: the reader's deadline, not the terminal record, is what bounds how +long absence can last. The terminal record is what turns the failures a +downloader survives into an explanation the agent can be given instead of a +timeout, and it is not load-bearing for liveness. Readers await readiness with a KV watch and a deadline, not a poll. A late reader observes current state directly with no replay concern, and a deadline @@ -181,8 +193,13 @@ pay nothing. - Readiness is always observable as an explicit state. "Bytes absent from the object store" is never interpreted as a lifecycle signal, and an absent readiness record is never read as an assertion that a download is running. -- Every handle a downloader stops working on leaves a terminal record. Giving up - is written down, not expressed by falling silent. +- Every handle a downloader stops working on leaves a terminal record, written + before the message is acknowledged. Giving up is written down, not expressed by + falling silent. +- No wait for readiness depends on a record arriving. A reader's deadline bounds + absence on its own, so a downloader that cannot write its terminal record + degrades the explanation an agent receives, never the reader's ability to stop + waiting. - An inbound event never asserts the existence of bytes that have not been written. @@ -202,8 +219,11 @@ pay nothing. - **Failure is legible.** A download that fails permanently is a `failed` record with a reason, distinguishable from one still in flight, so an agent can be told the difference. The cost is that the downloader has to write that - record on the way out, including on its final delivery attempt, rather than - letting the consumer's own give-up be the ending. + record on the way out, including on its final delivery attempt and before it + acknowledges, rather than letting the consumer's own give-up be the ending. + Redelivery covers a write that fails while deliveries remain, and the reader's + deadline covers the rest, which is why readers keep a deadline instead of + trusting that a record always arrives. - **The endpoints bucket gains a second reader.** `channel_endpoints_{prefix}` stays the bridge's to write, but the downloader reads it to authorize before redeeming, so identity is one registry consulted by every component that acts From 75b2072595a7b922d456640ac0a659af31fb077e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 5 Aug 2026 03:49:15 -0400 Subject: [PATCH 49/55] refactor(channel): reach the claim read through a single unwind One of the two unwind paths could not be reached from any test, because the create it follows performs that same read internally and cannot report a conflict unless it already succeeded. Signed-off-by: Yordis Prieto --- .../channel/trogon-channel/src/store.rs | 46 +++++++++-------- .../channel/trogon-channel/src/store/tests.rs | 50 +++++++++++++++++++ 2 files changed, 75 insertions(+), 21 deletions(-) diff --git a/rsworkspace/crates/channel/trogon-channel/src/store.rs b/rsworkspace/crates/channel/trogon-channel/src/store.rs index 2c53aa0316..a739f2f3b2 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store.rs @@ -304,29 +304,13 @@ impl ChannelStore { Err(source) => return Err(self.unwind(endpoint, id, source.into()).await), }; - // Read the claim that won as an entry rather than a value: replacing a - // stale one is only safe against the revision it was read at. A claim - // that is gone by the time it is read is claimed at revision zero, which - // the server honours only while the key is still absent, so the race - // that emptied it cannot be lost twice. - // - // Both this read and the one that follows it unwind the record written - // above, for the same reason the write failures do: the id is minted per + // Reading the claim that won unwinds the record written above when it + // fails, for the same reason the write failures do: the id is minted per // attempt, so a record left behind by a read that failed is one nothing // can ever reach again. - let claimed = match self.bindings.entry(endpoint.kv_key()).await { - Ok(claimed) => claimed, - Err(source) => return Err(self.unwind(endpoint, id, source.into()).await), - }; - let revision = claimed.as_ref().map_or(0, |claim| claim.revision); - - // Only a claim that leads to a conversation is one to yield to. One that - // leads nowhere is taken over below, and so is a claim that is gone by - // the time it is read: revision zero says the key must still be absent, - // so the race that emptied it cannot be lost twice. - let bound = match self.bound_conversation(claimed.map(|claim| claim.value)).await { - Ok(bound) => bound, - Err(source) => return Err(self.unwind(endpoint, id, source.into()).await), + let (revision, bound) = match self.winning_claim(endpoint).await { + Ok(winner) => winner, + Err(source) => return Err(self.unwind(endpoint, id, source).await), }; if let Some((bound_id, bound_record)) = bound { @@ -352,6 +336,26 @@ impl ChannelStore { } } + /// The revision the endpoint's claim was read at, and the conversation that + /// claim leads to. One read answers both, because neither answer is usable + /// without the other: a claim is only worth yielding to when it leads to a + /// conversation, and replacing one that leads nowhere is only safe against + /// the revision it was read at. + /// + /// The claim is read as an entry rather than a value for that revision. A + /// claim that is gone by the time it is read comes back as revision zero, + /// which the server honours only while the key is still absent, so the race + /// that emptied it cannot be lost twice. + async fn winning_claim( + &self, + endpoint: &Endpoint, + ) -> Result<(u64, Option<(ConversationId, ConversationRecord)>), ReserveEndpointError> { + let claimed = self.bindings.entry(endpoint.kv_key()).await?; + let revision = claimed.as_ref().map_or(0, |claim| claim.revision); + let bound = self.bound_conversation(claimed.map(|claim| claim.value)).await?; + Ok((revision, bound)) + } + /// Take back the record this call wrote, so a reservation that never /// happened leaves nothing behind. `refused` travels into the error because /// a rollback that fails too leaves the record for an operator to sweep, and diff --git a/rsworkspace/crates/channel/trogon-channel/src/store/tests.rs b/rsworkspace/crates/channel/trogon-channel/src/store/tests.rs index 2e82ea57a3..3388158ad9 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/store/tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/store/tests.rs @@ -674,3 +674,53 @@ async fn a_recovery_read_that_also_fails_surfaces_as_a_bucket_read_failure() { "expected the recovery read failure to surface, got {error:?}" ); } + +/// A plain lookup carries the same two failures a reservation does, and it has +/// no reservation to unwind, so they have to arrive as themselves rather than +/// wrapped in a bind failure. A binding that is not a conversation id fails the +/// decode; a conversations bucket that is gone fails the read. +#[tokio::test] +async fn a_lookup_reports_a_binding_it_cannot_decode_and_a_record_it_cannot_read() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + let store = ChannelStore::ensure(&js, "unfollowable").await.expect("ensure"); + + let undecodable = endpoint("777"); + store + .bindings + .put(undecodable.kv_key(), "not a conversation id".into()) + .await + .expect("write a binding nothing can decode"); + + let Err(error) = store.conversation_for(&undecodable).await else { + panic!("a lookup must fail when the binding it finds is not a conversation id"); + }; + assert!( + matches!(error, ChannelStoreError::Decode(_)), + "expected the decode failure to surface as itself, got {error:?}" + ); + + let unreadable = endpoint("888"); + let record = record(&principal("user-8")); + let bound = created( + store + .create_conversation(&unreadable, &record, &UuidV7Generator) + .await + .expect("bind an endpoint to a record that can still be read"), + ); + + // The binding survives its conversation here, which no public API does: the + // record is not deleted, the bucket holding it is, so the read fails for a + // reason other than absence. + js.delete_key_value("channel_conversations_unfollowable") + .await + .expect("take the conversations bucket out from under the binding"); + + let Err(error) = store.conversation_for(&unreadable).await else { + panic!("a lookup must fail when the record its binding leads to cannot be read: {bound}"); + }; + assert!( + matches!(error, ChannelStoreError::Read(_)), + "expected the read failure to surface as itself, got {error:?}" + ); +} From 7d86940201219a1eb04a317f578f292f1a58dd2c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 5 Aug 2026 03:49:20 -0400 Subject: [PATCH 50/55] test(channel): pin the length a session id may not exceed Nothing showed that an agent handing over an id too long for a subject token is told which rule it broke, or that an id sitting exactly at the limit can still address a session. Signed-off-by: Yordis Prieto --- .../trogon-channel/src/agent_port/tests.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/rsworkspace/crates/channel/trogon-channel/src/agent_port/tests.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port/tests.rs index 3f869c1772..59ad72bef2 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/agent_port/tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port/tests.rs @@ -12,6 +12,24 @@ fn a_session_id_must_be_a_subject_token() { assert_eq!(AgentSessionId::new("").unwrap_err(), AgentSessionIdError::Empty); } +/// How long a handle may be is the transport's rule, not the channel's, so the +/// limit is read from there rather than restated here. An id that breaks it must +/// be refused for its length: an agent that has already minted the session is +/// told which rule it broke, and a handle sitting exactly at the limit can still +/// address one. +#[test] +fn a_session_id_too_long_for_a_subject_token_is_refused_for_its_length() { + let limit = trogon_nats::constants::MAX_NATS_TOKEN_LENGTH; + assert_eq!( + AgentSessionId::new("s".repeat(limit)).expect("valid").as_str().len(), + limit + ); + assert_eq!( + AgentSessionId::new("s".repeat(limit + 1)).unwrap_err(), + AgentSessionIdError::TooLong(limit + 1) + ); +} + /// The bridge never mints these, so the alphabet its own keys are drawn from /// has no say: an agent that names sessions the way ACP allows must not have /// them refused after it has already opened one. From 3d477596f3c2c7f956145292c5e3d0c2648f1a2a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 5 Aug 2026 03:49:20 -0400 Subject: [PATCH 51/55] test(channel): assert the whole line an operator has to read Matching a fragment of the message left the half that names the variable to edit unasserted, which is the half an operator needs. Signed-off-by: Yordis Prieto --- .../channel/channel-bridge-telegram/src/config/tests.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config/tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config/tests.rs index 82790f73ab..a5e515bee8 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/config/tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config/tests.rs @@ -126,9 +126,11 @@ fn a_seed_list_with_an_unparseable_id_fails_and_names_it() { panic!("an unparseable seed id must be refused as one: {error}"); }; assert_eq!(entry, "not-an-id"); - assert!( - error.to_string().contains("not-an-id"), - "the operator has to be told which entry to go and fix: {error}" + // The whole rendered line, because the entry alone does not tell an operator + // which variable to open: both halves of that sentence are the message. + assert_eq!( + error.to_string(), + r#"CHANNEL_SEED_TELEGRAM_USERS contains an entry that is not a Telegram user id: "not-an-id""# ); } From 55f96baa0cef23a59e1531f78c05ab7ad8d8f002 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 5 Aug 2026 13:41:00 -0400 Subject: [PATCH 52/55] docs(adr): stop promising a readiness state the bucket never writes Signed-off-by: Yordis Prieto --- .../0044-inbound-media-fetch-out-of-band.md | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/adr/0044-inbound-media-fetch-out-of-band.md b/docs/adr/0044-inbound-media-fetch-out-of-band.md index 4dc615c0a1..00f2689f5f 100644 --- a/docs/adr/0044-inbound-media-fetch-out-of-band.md +++ b/docs/adr/0044-inbound-media-fetch-out-of-band.md @@ -124,8 +124,14 @@ at a credential that cannot honor it. Readers already have both tokens: they are the leading part of the endpoint on the event the attachment arrived with, and they are what selects the token used to redeem. -Absence means not yet resolved, and it says nothing about whether work is under -way. The record is absent before the downloader's durable has reached the +The bucket holds outcomes only. Nothing is written when a handle is first seen, +so there is no `pending` state and no record whose job is to say that work +started: a `pending` write would be a second thing the downloader must do before +it may fail, and it would still be missing in exactly the case a reader has to +survive, which is a downloader that never ran. + +Absence is therefore the unresolved state, and it says nothing about whether work +is under way. The record is absent before the downloader's durable has reached the message, while a download is running, and for as long as the downloader is down or behind. A reader cannot tell those apart and does not need to. What it needs is that absence is never permanent by accident, and two rules give it that. @@ -150,10 +156,11 @@ long absence can last. The terminal record is what turns the failures a downloader survives into an explanation the agent can be given instead of a timeout, and it is not load-bearing for liveness. -Readers await readiness with a KV watch and a deadline, not a poll. A late -reader observes current state directly with no replay concern, and a deadline -expiry is reported to the agent as an unavailable attachment rather than as a -turn failure. +Readers await readiness with a KV watch and a deadline, not a poll. Finding +nothing is where a reader starts, not a failure it reports: it watches for the +first record the key ever gets and stops on its own deadline. A late reader +observes current state directly with no replay concern, and a deadline expiry is +reported to the agent as an unavailable attachment rather than as a turn failure. ### 4. The inbound event carries the handle, never the object reference @@ -171,8 +178,10 @@ in the object store; there is no handle to redeem and nothing to wait for. The bridge builds the inbound event and dispatches the prompt without waiting. Waiting happens inside the agent-facing download tool, at the moment the agent -actually opens the file. Text-only turns and turns that ignore an attachment -pay nothing. +actually opens the file, and it is the reader described above: absence means keep +waiting until the deadline, `failed` is an explanation to hand the agent, and +`ready` is the object reference. Text-only turns and turns that ignore an +attachment pay nothing. ## Invariants @@ -190,9 +199,11 @@ pay nothing. - No handle is redeemed for an endpoint that resolves to no principal. Authorization precedes credential use, in every component that holds a credential. -- Readiness is always observable as an explicit state. "Bytes absent from the - object store" is never interpreted as a lifecycle signal, and an absent - readiness record is never read as an assertion that a download is running. +- The readiness bucket records outcomes and nothing else: `ready` and `failed` + are the only states ever written, and absence is the unresolved state. It is + never read as an assertion that a download is running, has not started, or ever + will. "Bytes absent from the object store" is not a lifecycle signal either, + because readiness is asked of the bucket and never of the store. - Every handle a downloader stops working on leaves a terminal record, written before the message is acknowledged. Giving up is written down, not expressed by falling silent. From 2c82b0fd66fc3dbcd1a0661d174512d6ef068628 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 5 Aug 2026 13:41:00 -0400 Subject: [PATCH 53/55] fix(channel): refuse at boot an account no endpoint could carry Signed-off-by: Yordis Prieto --- .../channel-bridge-telegram/src/config.rs | 24 ++++-- .../src/config/tests.rs | 48 +++++++++-- .../channel-bridge-telegram/src/constants.rs | 5 ++ .../channel-bridge-telegram/src/main.rs | 8 +- .../channel-bridge-telegram/src/parse.rs | 39 +++------ .../src/parse/tests.rs | 36 ++++---- .../channel-bridge-telegram/src/pipeline.rs | 18 ++-- .../src/pipeline/tests.rs | 84 +++++++++---------- .../channel/trogon-channel/src/endpoint.rs | 39 +++++++++ .../trogon-channel/src/endpoint/tests.rs | 31 +++++++ .../channel/trogon-channel/src/event.rs | 7 ++ .../channel/trogon-channel/src/event/tests.rs | 6 ++ .../crates/channel/trogon-channel/src/lib.rs | 2 +- .../channel/trogon-channel/src/safe_token.rs | 9 ++ .../trogon-channel/src/safe_token/tests.rs | 8 ++ 15 files changed, 245 insertions(+), 119 deletions(-) diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs index 1527eb1dbc..f760537603 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs @@ -1,9 +1,10 @@ #[cfg(test)] mod tests; +use crate::constants::CHANNEL; use acp_nats::{AcpPrefix, AcpPrefixError, NatsConfig}; use std::path::PathBuf; -use trogon_channel::{CommandTriggerError, CommandTriggers}; +use trogon_channel::{AgentId, ChannelAccount, CommandTriggerError, CommandTriggers, EndpointError}; use trogon_nats::jetstream::ClaimBucket; use trogon_std::env::ReadEnv; @@ -67,6 +68,10 @@ fn var(env: &E, key: &str) -> Option { pub enum BridgeConfigError { #[error("TELEGRAM_BOT_TOKEN is unset or blank")] BotToken(#[from] BlankBotTokenError), + #[error("TELEGRAM_BOT_ACCOUNT is not usable as the account half of an endpoint")] + BotAccount(#[source] EndpointError), + #[error("CHANNEL_AGENT_ID is not a usable agent id")] + AgentId(#[source] EndpointError), #[error("CHANNEL_SEED_TELEGRAM_USERS contains an entry that is not a Telegram user id: {entry:?}")] SeedUser { entry: String, @@ -97,10 +102,17 @@ pub struct BridgeConfig { /// and the resolver in agreement; an env knob on one side cannot. pub claim_bucket: ClaimBucket, pub bot_token: BotToken, - /// Endpoint account token; identifies which bot account on Telegram. - pub bot_account: String, + /// Which bot account on Telegram this process is, as the endpoint half every + /// message's endpoint is built from. A value object rather than the string it + /// was read from because an account that cannot be an endpoint token can name + /// no endpoint at all: the bridge would resolve no principal for any update, + /// ack every one of them, and answer nobody while looking healthy. + pub account: ChannelAccount, /// Agent every new conversation binds to; the routing policy is one agent. - pub agent_id: String, + /// Checked here for the same reason as the account: it is written into every + /// conversation this bridge creates, so a value that is not an id fails on + /// the first message of every conversation rather than at boot. + pub agent_id: AgentId, /// Workspace the agent roots its sessions in; agent configuration, never /// a channel concern (see the architecture doc). pub agent_cwd: PathBuf, @@ -120,7 +132,9 @@ impl BridgeConfig { let channel_prefix = var(env, "CHANNEL_PREFIX").unwrap_or_else(|| "prod".to_string()); let inbound_stream = var(env, "TELEGRAM_INBOUND_STREAM").unwrap_or_else(|| "TELEGRAM".to_string()); let bot_account = var(env, "TELEGRAM_BOT_ACCOUNT").unwrap_or_else(|| "bot".to_string()); + let account = ChannelAccount::new(CHANNEL, bot_account).map_err(BridgeConfigError::BotAccount)?; let agent_id = var(env, "CHANNEL_AGENT_ID").unwrap_or_else(|| "default".to_string()); + let agent_id = AgentId::new(agent_id).map_err(BridgeConfigError::AgentId)?; let agent_cwd = var(env, "CHANNEL_AGENT_CWD").map_or_else(std::env::temp_dir, PathBuf::from); let seed_users = match var(env, "CHANNEL_SEED_TELEGRAM_USERS") { @@ -162,7 +176,7 @@ impl BridgeConfig { inbound_stream, claim_bucket: ClaimBucket::default(), bot_token, - bot_account, + account, agent_id, agent_cwd, seed_users, diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/config/tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/config/tests.rs index a5e515bee8..f7c13ea648 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/config/tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config/tests.rs @@ -85,8 +85,8 @@ fn blank_optional_variables_fall_back_to_their_defaults() { let config = BridgeConfig::from_env(&env).expect("config"); assert_eq!(config.channel_prefix, "prod"); assert_eq!(config.inbound_stream, "TELEGRAM"); - assert_eq!(config.bot_account, "bot"); - assert_eq!(config.agent_id, "default"); + assert_eq!(config.account.account(), "bot"); + assert_eq!(config.agent_id.as_str(), "default"); assert_eq!(config.agent_cwd, std::env::temp_dir()); assert!(config.seed_users.is_empty()); } @@ -134,9 +134,8 @@ fn a_seed_list_with_an_unparseable_id_fails_and_names_it() { ); } -/// The trigger list and the ACP prefix are the other two values a deployment can -/// get wrong, and each has to be refused as itself: an operator reading a boot -/// failure is being told which variable to go and edit. +/// Every value a deployment can get wrong has to be refused as itself: an +/// operator reading a boot failure is being told which variable to go and edit. #[test] fn each_unusable_value_is_refused_as_the_variable_it_came_from() { let env = InMemoryEnv::new(); @@ -156,6 +155,41 @@ fn each_unusable_value_is_refused_as_the_variable_it_came_from() { )); } +/// The account and the agent id become endpoint tokens, and both are values a +/// person plausibly mistypes: Telegram displays the account as `@mybot`, and a +/// dotted name reads like a hostname. Neither may be discovered later. An +/// account that is not a token can name no endpoint, so the bridge would read +/// every update, find no principal, ack it, and answer nobody; an agent id that +/// is not a token would fail on the first message of every conversation. +#[test] +fn an_account_or_agent_id_that_cannot_be_a_token_stops_the_boot() { + let env = InMemoryEnv::new(); + env.set("TELEGRAM_BOT_TOKEN", "secret-token"); + env.set("TELEGRAM_BOT_ACCOUNT", "@mybot"); + let error = rejection(&env); + assert!( + matches!( + error, + BridgeConfigError::BotAccount(EndpointError::InvalidCharacter('@')) + ), + "the account must be refused as the account: {error}" + ); + assert_eq!( + error.to_string(), + "TELEGRAM_BOT_ACCOUNT is not usable as the account half of an endpoint" + ); + + let env = InMemoryEnv::new(); + env.set("TELEGRAM_BOT_TOKEN", "secret-token"); + env.set("CHANNEL_AGENT_ID", "my.agent"); + let error = rejection(&env); + assert!( + matches!(error, BridgeConfigError::AgentId(EndpointError::InvalidCharacter('.'))), + "the agent id must be refused as the agent id: {error}" + ); + assert_eq!(error.to_string(), "CHANNEL_AGENT_ID is not a usable agent id"); +} + #[test] fn set_variables_are_read_and_trimmed() { let env = InMemoryEnv::new(); @@ -170,8 +204,8 @@ fn set_variables_are_read_and_trimmed() { let config = BridgeConfig::from_env(&env).expect("config"); assert_eq!(config.channel_prefix, "staging"); assert_eq!(config.inbound_stream, "TG"); - assert_eq!(config.bot_account, "mybot"); - assert_eq!(config.agent_id, "coder"); + assert_eq!(config.account.account(), "mybot"); + assert_eq!(config.agent_id.as_str(), "coder"); assert_eq!(config.agent_cwd, PathBuf::from("/workspace")); assert_eq!(config.seed_users, vec![42, 43]); } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/constants.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/constants.rs index c12b1b8498..cdd03e032b 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/constants.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/constants.rs @@ -1,3 +1,8 @@ +/// The channel token of every endpoint this bridge builds. It is the first +/// component of a KV key other channels' bridges write beside, so it names the +/// platform and nothing about this process. +pub const CHANNEL: &str = "telegram"; + /// Durable consumer identity on the inbound stream. JetStream keys the ack /// floor by this string, so it is deployment state rather than a build /// artifact name: a literal here means a future crate rename cannot silently diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs index 54b9ea1730..0bf841b1b6 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs @@ -43,7 +43,7 @@ use { teloxide::Bot, tracing::{error, info, warn}, trogon_channel::store::PrincipalRecord, - trogon_channel::{ChannelStore, Endpoint, PrincipalId}, + trogon_channel::{ChannelStore, PrincipalId, SafeToken}, trogon_nats::jetstream::{ClaimResolver, NatsObjectStore}, trogon_std::UuidV7Generator, trogon_std::env::SystemEnv, @@ -129,8 +129,8 @@ async fn main() -> anyhow::Result<()> { #[cfg(not(coverage))] async fn seed_principals(store: &ChannelStore, config: &BridgeConfig) -> anyhow::Result<()> { for user in &config.seed_users { - let principal = PrincipalId::new(format!("telegram-{user}"))?; - let endpoint = Endpoint::new("telegram", &config.bot_account, user.to_string())?; + let principal = PrincipalId::new(format!("{}-{user}", constants::CHANNEL))?; + let endpoint = config.account.endpoint_for(&SafeToken::from(*user)); store .link_endpoint(&principal, &PrincipalRecord { display_name: None }, &endpoint) .await?; @@ -195,7 +195,7 @@ async fn run( renderer: renderer.as_ref(), outbound: &telegram, claims: &claims, - bot_account: &config.bot_account, + account: &config.account, agent_id: &config.agent_id, triggers: &config.command_triggers, ids: &UuidV7Generator, diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs index 510a34694c..f5050b3dcf 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs @@ -2,33 +2,27 @@ mod tests; use teloxide::types::{Update, UpdateKind}; -use trogon_channel::{CommandTriggers, Endpoint, InboundEvent, MessageRef, PlatformUserId, Sender}; +use trogon_channel::{ + ChannelAccount, CommandTriggers, Endpoint, InboundEvent, MessageRef, PlatformUserId, SafeToken, Sender, +}; /// Normalize a raw Telegram update into the channel-neutral event, or `None` /// for update kinds the bridge does not carry (media, edits, membership, ...). /// The raw stream retains those with full fidelity for later. -pub fn inbound_event(update: &Update, bot_account: &str, triggers: &CommandTriggers) -> Option { +pub fn inbound_event(update: &Update, account: &ChannelAccount, triggers: &CommandTriggers) -> Option { let UpdateKind::Message(msg) = &update.kind else { return None; }; let text = msg.text()?; let from = msg.from.as_ref()?; - let endpoint = match Endpoint::new("telegram", bot_account, msg.chat.id.0.to_string()) { - Ok(endpoint) => endpoint, - Err(e) => { - tracing::warn!(error = %e, chat_id = msg.chat.id.0, "Skipping update with unencodable endpoint"); - return None; - } - }; - - let parsed = triggers.parse(text, bot_account); + let parsed = triggers.parse(text, account.account()); - // Telegram numbers users and messages, and both value objects take an - // integer without a failure case, so the account above is the only token - // here that a deployment can get wrong. + // Telegram numbers chats, users, and messages, and every value object below + // takes an integer without a failure case, so nothing an update carries can + // spoil this event. The account was checked once, at boot. Some(InboundEvent { - endpoint, + endpoint: account.endpoint_for(&SafeToken::from(msg.chat.id.0)), sender: Sender { platform_user_id: PlatformUserId::from(from.id.0), display_name: from.full_name(), @@ -43,15 +37,8 @@ pub fn inbound_event(update: &Update, bot_account: &str, triggers: &CommandTrigg /// The endpoint of whoever sent a message, which is not the conversation's /// endpoint: a group chat is one endpoint shared by everyone in it, so -/// authorizing the chat says nothing about authorizing the speaker. -/// The sender's own id is already a valid token, so only a misconfigured -/// `bot_account` can fail here. -pub fn sender_endpoint(bot_account: &str, sender: &Sender) -> Option { - match Endpoint::new("telegram", bot_account, sender.platform_user_id.as_str()) { - Ok(endpoint) => Some(endpoint), - Err(e) => { - tracing::warn!(error = %e, user_id = %sender.platform_user_id, "Sender has an unencodable endpoint"); - None - } - } +/// authorizing the chat says nothing about authorizing the speaker. Named rather +/// than inlined because that distinction is the only thing it exists to keep. +pub fn sender_endpoint(account: &ChannelAccount, sender: &Sender) -> Endpoint { + account.endpoint_for(sender.platform_user_id.token()) } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse/tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse/tests.rs index a808d68121..c45381f18e 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse/tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse/tests.rs @@ -1,5 +1,9 @@ use super::*; +fn account() -> ChannelAccount { + ChannelAccount::new("telegram", "mybot").expect("valid account") +} + /// The bridge parses updates the same way the pipeline does: bytes off the /// wire, not a pre-built `serde_json::Value`. `teloxide`'s nested /// `flatten`/`untagged` types round-trip through the streaming deserializer @@ -40,30 +44,20 @@ fn an_edited_message_update_yields_no_inbound_event() { })); let triggers = CommandTriggers::default(); - assert!(inbound_event(&update, "mybot", &triggers).is_none()); + assert!(inbound_event(&update, &account(), &triggers).is_none()); } -/// A chat id is always digits or a leading `-`, so `Endpoint::new` can never -/// reject the peer token built from one; the only way to reach this arm is a -/// misconfigured `bot_account`, which is what this pins. +/// A group chat is numbered negatively, and that minus sign has to survive into +/// the endpoint: a peer token is what the principal lookup is keyed by, so a +/// mangled one authorizes nobody. #[test] -fn an_unsafe_bot_account_drops_the_update_instead_of_panicking() { - let update = message_update(42, 42, "hello"); +fn a_group_chats_negative_id_reaches_the_endpoint_intact() { + let update = message_update(-1_001_234_567_890, 42, "hello"); let triggers = CommandTriggers::default(); - assert!(inbound_event(&update, "bad bot", &triggers).is_none()); -} + let event = inbound_event(&update, &account(), &triggers).expect("event"); -/// An unsafe sender id can no longer reach this function: `PlatformUserId` -/// refuses to hold one, so the only token left that can spoil the endpoint is -/// the account the bridge was configured with. -#[test] -fn sender_endpoint_returns_none_for_an_unsafe_bot_account() { - let sender = Sender { - platform_user_id: PlatformUserId::new("42").expect("valid id"), - display_name: "Test".to_string(), - }; - assert!(sender_endpoint("bad bot", &sender).is_none()); - assert!(sender_endpoint("mybot", &sender).is_some()); + assert_eq!(event.endpoint.peer(), "-1001234567890"); + assert_eq!(event.endpoint.kv_key(), "telegram.mybot.-1001234567890"); } /// The whole reason `sender_endpoint` exists: a group chat is one endpoint @@ -73,9 +67,9 @@ fn sender_endpoint_returns_none_for_an_unsafe_bot_account() { fn sender_endpoint_peer_is_the_sender_not_the_chat() { let update = message_update(999, 42, "hello"); let triggers = CommandTriggers::default(); - let event = inbound_event(&update, "mybot", &triggers).expect("event"); + let event = inbound_event(&update, &account(), &triggers).expect("event"); - let endpoint = sender_endpoint("mybot", &event.sender).expect("endpoint"); + let endpoint = sender_endpoint(&account(), &event.sender); assert_eq!(endpoint.peer(), "42"); assert_ne!(endpoint.peer(), event.endpoint.peer()); } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs index 6e22657c28..4f92ec2bae 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -7,8 +7,8 @@ use crate::parse; use crate::render::{TelegramRenderClient, chunk_text}; use tracing::{info, warn}; use trogon_channel::{ - AgentId, AgentPort, AgentPortError as _, AgentSessionId, ChannelStore, ChannelStoreError, Command, CommandTriggers, - ConversationId, ConversationRecord, EndpointError, InboundEvent, ReleaseReason, + AgentId, AgentPort, AgentPortError as _, AgentSessionId, ChannelAccount, ChannelStore, ChannelStoreError, Command, + CommandTriggers, ConversationId, ConversationRecord, InboundEvent, ReleaseReason, }; use trogon_nats::jetstream::{ClaimResolveError, ClaimResolver, ObjectStoreGet}; use trogon_std::NowV7; @@ -19,8 +19,8 @@ pub struct Pipeline<'a, P, O, G, S> { pub renderer: &'a TelegramRenderClient, pub outbound: &'a O, pub claims: &'a ClaimResolver, - pub bot_account: &'a str, - pub agent_id: &'a str, + pub account: &'a ChannelAccount, + pub agent_id: &'a AgentId, pub triggers: &'a CommandTriggers, pub ids: &'a G, } @@ -42,8 +42,6 @@ where Store(#[from] ChannelStoreError), #[error("telegram peer is not an i64 chat id")] PeerNotChatId(#[source] std::num::ParseIntError), - #[error(transparent)] - AgentId(#[from] EndpointError), #[error("failed to create an agent session")] CreateSession(#[source] PE), #[error("prompt failed on session {session}")] @@ -90,9 +88,7 @@ where /// conversation gate authorizes the chat, which in a group is everyone in /// it; destructive commands ask the narrower question. async fn sender_is_authorized(&self, event: &InboundEvent) -> Result { - let Some(endpoint) = parse::sender_endpoint(self.bot_account, &event.sender) else { - return Ok(false); - }; + let endpoint = parse::sender_endpoint(self.account, &event.sender); Ok(self.store.principal_for(&endpoint).await?.is_some()) } @@ -151,7 +147,7 @@ where } }; - let Some(event) = parse::inbound_event(&update, self.bot_account, self.triggers) else { + let Some(event) = parse::inbound_event(&update, self.account, self.triggers) else { return ack(msg).await; }; @@ -174,7 +170,7 @@ where // configured agent. Sticky from here on. let record = ConversationRecord { principal: principal.clone(), - agent_id: AgentId::new(self.agent_id)?, + agent_id: self.agent_id.clone(), current_session: None, created_at: now, last_activity_at: now, diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline/tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline/tests.rs index 918c8a2967..d256f78b1f 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline/tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline/tests.rs @@ -7,8 +7,8 @@ use std::cell::RefCell; use std::rc::Rc; use trogon_channel::store::PrincipalRecord; use trogon_channel::{ - AgentPortError, AgentSessionId, Endpoint, InboundEvent, MessageRef, PlatformUserId, PrincipalId, PromptOutcome, - ReleaseReason, ReleaseStep, Sender, SessionRelease, + AgentPortError, AgentSessionId, ChannelAccount, Endpoint, InboundEvent, PrincipalId, PromptOutcome, ReleaseReason, + ReleaseStep, SessionRelease, }; use trogon_nats::jetstream::{ClaimBucket, ClaimBucketBinding, MockObjectStore}; use trogon_nats::test_support::JetStreamTestServer; @@ -21,6 +21,16 @@ use trogon_nats::jetstream::{ ClaimCheckPublisher, ClaimRetention, DEFAULT_CLAIM_BUCKET, MaxPayload, NatsJetStreamClient, NatsObjectStore, }; +/// What the bridge is configured as, in the form the config hands over: checked +/// once, so nothing downstream can be handed an account that is not a token. +fn bridge_account() -> ChannelAccount { + ChannelAccount::new("telegram", "mybot").expect("valid account") +} + +fn configured_agent() -> AgentId { + AgentId::new("default").expect("valid agent id") +} + #[derive(Debug, thiserror::Error)] #[error("fake agent failure (session_lost={session_lost})")] struct FakeError { @@ -363,6 +373,8 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { let port = FakePort::new(renderer.clone(), "hi there"); let outbound = FakeOutbound::default(); let triggers = CommandTriggers::default(); + let account = bridge_account(); + let agent_id = configured_agent(); let claims = unclaimed_resolver(); let pipeline = Pipeline { store: &store, @@ -370,8 +382,8 @@ async fn pipeline_routes_gateway_updates_to_the_agent_and_back() { renderer: renderer.as_ref(), outbound: &outbound, claims: &claims, - bot_account: "mybot", - agent_id: "default", + account: &account, + agent_id: &agent_id, triggers: &triggers, ids: &UuidV7Generator, }; @@ -504,14 +516,16 @@ async fn pipeline_redeems_a_claim_checked_update() { let port = FakePort::new(renderer.clone(), "hi there"); let outbound = FakeOutbound::default(); let triggers = CommandTriggers::default(); + let account = bridge_account(); + let agent_id = configured_agent(); let pipeline = Pipeline { store: &store, port: &port, renderer: renderer.as_ref(), outbound: &outbound, claims: &claims, - bot_account: "mybot", - agent_id: "default", + account: &account, + agent_id: &agent_id, triggers: &triggers, ids: &UuidV7Generator, }; @@ -597,14 +611,16 @@ async fn pipeline_leaves_an_unredeemable_claim_unacked() { let port = FakePort::new(renderer.clone(), "hi there"); let outbound = FakeOutbound::default(); let triggers = CommandTriggers::default(); + let account = bridge_account(); + let agent_id = configured_agent(); let pipeline = Pipeline { store: &store, port: &port, renderer: renderer.as_ref(), outbound: &outbound, claims: &claims, - bot_account: "mybot", - agent_id: "default", + account: &account, + agent_id: &agent_id, triggers: &triggers, ids: &UuidV7Generator, }; @@ -674,6 +690,8 @@ async fn pipeline_keeps_the_session_when_a_fresh_one_fails_the_same_way() { let port = FakePort::new(renderer.clone(), "hi there"); let outbound = FakeOutbound::default(); let triggers = CommandTriggers::default(); + let account = bridge_account(); + let agent_id = configured_agent(); let claims = unclaimed_resolver(); let pipeline = Pipeline { store: &store, @@ -681,8 +699,8 @@ async fn pipeline_keeps_the_session_when_a_fresh_one_fails_the_same_way() { renderer: renderer.as_ref(), outbound: &outbound, claims: &claims, - bot_account: "mybot", - agent_id: "default", + account: &account, + agent_id: &agent_id, triggers: &triggers, ids: &UuidV7Generator, }; @@ -841,6 +859,8 @@ async fn pipeline_hands_back_a_fresh_session_it_could_not_record() { let port = FakePort::new(renderer.clone(), "hi there"); let outbound = FakeOutbound::default(); let triggers = CommandTriggers::default(); + let account = bridge_account(); + let agent_id = configured_agent(); let claims = unclaimed_resolver(); let pipeline = Pipeline { store: &store, @@ -848,8 +868,8 @@ async fn pipeline_hands_back_a_fresh_session_it_could_not_record() { renderer: renderer.as_ref(), outbound: &outbound, claims: &claims, - bot_account: "mybot", - agent_id: "default", + account: &account, + agent_id: &agent_id, triggers: &triggers, ids: &UuidV7Generator, }; @@ -963,6 +983,8 @@ async fn pipeline_acks_and_drops_what_no_redelivery_would_fix() { let port = FakePort::new(renderer.clone(), "hi there"); let outbound = FakeOutbound::default(); let triggers = CommandTriggers::default(); + let account = bridge_account(); + let agent_id = configured_agent(); let claims = unclaimed_resolver(); let pipeline = Pipeline { store: &store, @@ -970,8 +992,8 @@ async fn pipeline_acks_and_drops_what_no_redelivery_would_fix() { renderer: renderer.as_ref(), outbound: &outbound, claims: &claims, - bot_account: "mybot", - agent_id: "default", + account: &account, + agent_id: &agent_id, triggers: &triggers, ids: &UuidV7Generator, }; @@ -1007,34 +1029,6 @@ async fn pipeline_acks_and_drops_what_no_redelivery_would_fix() { // not authorized for the command, which says nothing about the chat. assert!(store.conversation_for(&group).await.expect("kv read").is_some()); - // A bot account that is not an endpoint token can build no sender endpoint - // at all, so it authorizes nobody rather than authorizing everybody. Only - // reachable by calling in directly: `parse::inbound_event` rejects the same - // account earlier, so no update can carry a message this far. - let misconfigured = Pipeline { - bot_account: "my bot", - ..pipeline - }; - let event = InboundEvent { - endpoint: endpoint.clone(), - sender: Sender { - platform_user_id: PlatformUserId::new("42").expect("id"), - display_name: "Test".to_string(), - }, - text: None, - command: Some(Command::NewSession), - attachments: Vec::new(), - message_ref: MessageRef::new("1").expect("message ref"), - occurred_at: 1_700_000_000, - }; - assert!( - !misconfigured - .sender_is_authorized(&event) - .await - .expect("the store is readable; only the endpoint cannot be built"), - "a sender whose endpoint cannot be built must not be authorized" - ); - let info = settled_consumer_info(&stream, "bridge-test", 0).await; assert_eq!(info.num_ack_pending, 0); assert_eq!(info.num_pending, 0); @@ -1091,6 +1085,8 @@ async fn pipeline_leaves_no_partial_reply_behind_when_a_turn_fails() { let port = FakePort::new(renderer.clone(), "hi there"); let outbound = FakeOutbound::default(); let triggers = CommandTriggers::default(); + let account = bridge_account(); + let agent_id = configured_agent(); let claims = unclaimed_resolver(); let pipeline = Pipeline { store: &store, @@ -1098,8 +1094,8 @@ async fn pipeline_leaves_no_partial_reply_behind_when_a_turn_fails() { renderer: renderer.as_ref(), outbound: &outbound, claims: &claims, - bot_account: "mybot", - agent_id: "default", + account: &account, + agent_id: &agent_id, triggers: &triggers, ids: &UuidV7Generator, }; diff --git a/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs index 3a2b9c1d82..f4791a3cc9 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs @@ -87,6 +87,45 @@ impl std::fmt::Display for Endpoint { } } +/// The half of an endpoint a bridge process is: the channel it speaks for and +/// the account it speaks as. Both come from the deployment, so both are checked +/// once here, which leaves [`ChannelAccount::endpoint_for`] with nothing left to +/// reject. +/// +/// This exists because the alternative validated them per message, which is the +/// wrong moment to find out. A bridge configured with an account that is not a +/// token started, read every update, failed to name an endpoint for any of them, +/// and acked them all: an operator saw a healthy process answering nobody. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelAccount { + channel: SafeToken, + account: SafeToken, +} + +impl ChannelAccount { + pub fn new(channel: impl Into, account: impl Into) -> Result { + Ok(Self { + channel: SafeToken::new(channel)?, + account: SafeToken::new(account)?, + }) + } + + /// The account token, for the platform-facing uses that are not endpoints: + /// a Telegram command may be addressed as `/new@account`. + pub fn account(&self) -> &str { + self.account.as_str() + } + + /// Where one peer on this account is reached. + pub fn endpoint_for(&self, peer: &SafeToken) -> Endpoint { + Endpoint { + channel: self.channel.clone(), + account: self.account.clone(), + peer: peer.clone(), + } + } +} + /// The human behind one or more endpoints. Cross-channel by design: linking a /// Telegram user and a Discord user to the same principal is what lets one /// conversation continue across channels. diff --git a/rsworkspace/crates/channel/trogon-channel/src/endpoint/tests.rs b/rsworkspace/crates/channel/trogon-channel/src/endpoint/tests.rs index 7584a792af..04b2fc79b2 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/endpoint/tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/endpoint/tests.rs @@ -47,6 +47,37 @@ fn endpoint_deserialize_rejects_an_unsafe_token() { assert_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); } +/// The point of the type: the account is checked once, and every endpoint built +/// afterwards is built without a failure case to handle. +#[test] +fn a_channel_account_builds_the_endpoint_of_any_peer() { + let account = ChannelAccount::new("telegram", "mybot").expect("valid"); + assert_eq!(account.account(), "mybot"); + assert_eq!( + account.endpoint_for(&SafeToken::from(-1_001_234_567_890_i64)), + Endpoint::new("telegram", "mybot", "-1001234567890").expect("valid") + ); + assert_eq!( + account.endpoint_for(&SafeToken::from(42_u64)).kv_key(), + "telegram.mybot.42" + ); +} + +/// A bridge reads these from its environment, so the rejection has to happen at +/// construction; that is the whole reason the type exists. +#[test] +fn a_channel_account_refuses_a_token_no_endpoint_could_carry() { + assert_eq!( + ChannelAccount::new("telegram", "my bot").unwrap_err(), + EndpointError::InvalidCharacter(' ') + ); + assert_eq!(ChannelAccount::new("telegram", "").unwrap_err(), EndpointError::Empty); + assert_eq!( + ChannelAccount::new("tele.gram", "mybot").unwrap_err(), + EndpointError::InvalidCharacter('.') + ); +} + #[test] fn principal_id_rejects_an_empty_id() { let err = PrincipalId::new("").unwrap_err(); diff --git a/rsworkspace/crates/channel/trogon-channel/src/event.rs b/rsworkspace/crates/channel/trogon-channel/src/event.rs index c3cfb6882e..2ddd2c586c 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event.rs @@ -41,6 +41,13 @@ impl PlatformUserId { pub fn as_str(&self) -> &str { self.0.as_str() } + + /// The id as the endpoint token it already is. Authorizing a sender means + /// building its endpoint, and handing that builder the validated token + /// rather than a string is what leaves it with no failure to report. + pub fn token(&self) -> &SafeToken { + &self.0 + } } /// A platform that numbers its users hands the id over as an integer, and every diff --git a/rsworkspace/crates/channel/trogon-channel/src/event/tests.rs b/rsworkspace/crates/channel/trogon-channel/src/event/tests.rs index 145824c75d..065ad1c173 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/event/tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/event/tests.rs @@ -13,6 +13,12 @@ fn a_platform_user_id_must_be_an_endpoint_token() { EndpointError::InvalidCharacter('.') ); assert_eq!(PlatformUserId::new("").unwrap_err(), EndpointError::Empty); + // And it hands that token over, so building the sender's endpoint re-checks + // nothing. + assert_eq!( + PlatformUserId::new("42").expect("valid").token(), + &SafeToken::new("42").expect("valid") + ); } /// The point of the type: channel-provided JSON cannot produce an id the diff --git a/rsworkspace/crates/channel/trogon-channel/src/lib.rs b/rsworkspace/crates/channel/trogon-channel/src/lib.rs index 861ec54e82..a7a9efe817 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/lib.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/lib.rs @@ -34,7 +34,7 @@ pub use command::{Command, CommandTriggers, ParsedText}; pub use command_trigger::{CommandTrigger, CommandTriggerError}; pub use command_trigger_input::CommandTriggerInput; pub use conversation::{AgentId, ConversationId, ConversationRecord}; -pub use endpoint::{Endpoint, EndpointError, PrincipalId}; +pub use endpoint::{ChannelAccount, Endpoint, EndpointError, PrincipalId}; pub use event::{ Attachment, AttachmentKind, EventFieldError, InboundEvent, MediaTypeError, MessageRef, MimeType, PlatformRef, PlatformUserId, Sender, diff --git a/rsworkspace/crates/channel/trogon-channel/src/safe_token.rs b/rsworkspace/crates/channel/trogon-channel/src/safe_token.rs index 1a4ef66ad0..2328ae1e88 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/safe_token.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/safe_token.rs @@ -52,6 +52,15 @@ impl From for SafeToken { } } +/// Some of that numbering is signed: a Telegram group chat id is negative, and +/// `-` is in the allowed set, so a signed integer is a token for the same reason +/// an unsigned one is. +impl From for SafeToken { + fn from(value: i64) -> Self { + Self(value.to_string()) + } +} + impl std::fmt::Display for SafeToken { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) diff --git a/rsworkspace/crates/channel/trogon-channel/src/safe_token/tests.rs b/rsworkspace/crates/channel/trogon-channel/src/safe_token/tests.rs index 2b0137cded..2a9e6cf9be 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/safe_token/tests.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/safe_token/tests.rs @@ -27,6 +27,14 @@ fn a_numeric_id_is_a_token_without_being_checked() { SafeToken::from(42_u64), SafeToken::new("42").expect("the checked path agrees") ); + // A Telegram group chat is numbered negatively, which is the reason the + // signed conversion exists at all. + assert_eq!(SafeToken::from(-1_001_234_567_890_i64).as_str(), "-1001234567890"); + assert_eq!(SafeToken::from(i64::MIN).as_str(), i64::MIN.to_string()); + assert_eq!( + SafeToken::from(-42_i64), + SafeToken::new("-42").expect("the checked path agrees") + ); } #[test] From 188716d3614a4fa2d62e7342590ded1cd210056d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 5 Aug 2026 14:49:26 -0400 Subject: [PATCH 54/55] fix(channel): hand back a session the conversation never recorded Signed-off-by: Yordis Prieto --- .../channel-bridge-telegram/src/pipeline.rs | 18 ++- .../src/pipeline/tests.rs | 120 +++++++++++++++++- .../channel/trogon-channel/src/agent_port.rs | 12 +- 3 files changed, 138 insertions(+), 12 deletions(-) diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs index 4f92ec2bae..64071d3765 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -217,7 +217,21 @@ where .await .map_err(PipelineError::CreateSession)?; record.current_session = Some(session.clone()); - self.store.update_conversation(&conversation_id, &record).await?; + if let Err(error) = self.store.update_conversation(&conversation_id, &record).await { + // The write is what makes this session the conversation's. + // Without it the conversation still points nowhere and the + // redelivery of this message opens another session, so this one + // would be named by nothing and freed by nobody. + let release = self.port.release_session(&session, ReleaseReason::Unrecorded).await; + warn!( + conversation = %conversation_id, + session = %session, + cancelled = ?release.cancelled, + closed = ?release.closed, + "Could not point the conversation at the session opened for it; released it instead" + ); + return Err(PipelineError::Store(error)); + } session } }; @@ -258,7 +272,7 @@ where // will ever read this reply or hand this session // back. Redelivery retries the prompt on the session // the conversation still has. - let release = self.port.release_session(&fresh, ReleaseReason::RepairFailed).await; + let release = self.port.release_session(&fresh, ReleaseReason::Unrecorded).await; self.renderer.discard(fresh.as_str()); self.renderer.discard(active_session.as_str()); warn!( diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline/tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline/tests.rs index d256f78b1f..5d630a5a21 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline/tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline/tests.rs @@ -73,6 +73,19 @@ struct FakePort { /// only place from which the write can be failed without also failing the /// read that precedes it. bucket_to_drop: RefCell>, + /// The same, but dropped once the next session has been minted. The pipeline + /// records a brand new session before it prompts, so that write can only be + /// failed from inside the creation it follows. + bucket_to_drop_on_creation: RefCell>, +} + +/// Take away the bucket a scheduled failure needs, if one is scheduled. Dropping +/// the bucket is how a KV write is made to fail against a real server. +async fn drop_scheduled_bucket(scheduled: &RefCell>) { + let dropped = scheduled.borrow_mut().take(); + if let Some((js, bucket)) = dropped { + js.delete_key_value(&bucket).await.expect("drop the KV bucket"); + } } /// Consume one use of a scripted behaviour. @@ -96,6 +109,7 @@ impl FakePort { creation_failures: RefCell::new(0), silent_turns: RefCell::new(0), bucket_to_drop: RefCell::new(None), + bucket_to_drop_on_creation: RefCell::new(None), } } @@ -119,6 +133,10 @@ impl FakePort { *self.bucket_to_drop.borrow_mut() = Some((js.clone(), bucket.to_string())); } + fn drop_bucket_after_next_session(&self, js: &async_nats::jetstream::Context, bucket: &str) { + *self.bucket_to_drop_on_creation.borrow_mut() = Some((js.clone(), bucket.to_string())); + } + async fn stream(&self, session: &AgentSessionId, text: &str) { let notification = SessionNotification::new( session.as_str().to_string(), @@ -144,7 +162,9 @@ impl trogon_channel::AgentPort for FakePort { return Err(FakeError { session_lost: false }); } *self.sessions_created.borrow_mut() += 1; - Ok(AgentSessionId::new(format!("sess-{}", self.sessions_created.borrow())).expect("session id")) + let session = AgentSessionId::new(format!("sess-{}", self.sessions_created.borrow())).expect("session id"); + drop_scheduled_bucket(&self.bucket_to_drop_on_creation).await; + Ok(session) } async fn prompt(&self, session: &AgentSessionId, event: &InboundEvent) -> Result { @@ -167,10 +187,7 @@ impl trogon_channel::AgentPort for FakePort { } self.stream(session, &self.reply).await; - let dropped = self.bucket_to_drop.borrow_mut().take(); - if let Some((js, bucket)) = dropped { - js.delete_key_value(&bucket).await.expect("drop the KV bucket"); - } + drop_scheduled_bucket(&self.bucket_to_drop).await; Ok(PromptOutcome::Completed) } @@ -894,7 +911,7 @@ async fn pipeline_hands_back_a_fresh_session_it_could_not_record() { assert_eq!( *port.released.borrow(), - vec![("sess-2".to_string(), ReleaseReason::RepairFailed)], + vec![("sess-2".to_string(), ReleaseReason::Unrecorded)], "a session nothing points at must be handed back" ); for session in ["sess-1", "sess-2"] { @@ -914,6 +931,97 @@ async fn pipeline_hands_back_a_fresh_session_it_could_not_record() { assert_eq!(info.num_ack_pending, 1); } +/// The same cleanup for a conversation's very first session, which reaches the +/// pointer write by another route: no repair, no prompt yet, just a session minted +/// for a conversation that has none. If that write fails, redelivery mints another +/// and no turn can ever name this one, so each retry would leave one more session +/// open at the agent. +#[tokio::test] +async fn pipeline_hands_back_a_first_session_it_could_not_record() { + let server = JetStreamTestServer::start().await; + let js = server.jetstream().await; + + js.create_stream(async_nats::jetstream::stream::Config { + name: "TELEGRAM".to_string(), + subjects: vec!["telegram.>".to_string()], + ..Default::default() + }) + .await + .expect("create TELEGRAM stream"); + + let store = ChannelStore::ensure(&js, "test").await.expect("ensure buckets"); + let principal = PrincipalId::new("telegram-42").expect("principal"); + let endpoint = Endpoint::new("telegram", "mybot", "42").expect("endpoint"); + store + .link_endpoint(&principal, &PrincipalRecord { display_name: None }, &endpoint) + .await + .expect("seed principal"); + + js.publish("telegram.message", raw_update(1, 42, 42, "hello").into()) + .await + .expect("publish") + .await + .expect("ack"); + + let stream = js.get_stream("TELEGRAM").await.expect("get stream"); + let consumer = stream + .get_or_create_consumer( + "bridge-test", + async_nats::jetstream::consumer::pull::Config { + durable_name: Some("bridge-test".to_string()), + ..Default::default() + }, + ) + .await + .expect("consumer"); + let mut messages = consumer.messages().await.expect("messages"); + + let renderer = Rc::new(TelegramRenderClient::new()); + let port = FakePort::new(renderer.clone(), "hi there"); + let outbound = FakeOutbound::default(); + let triggers = CommandTriggers::default(); + let account = bridge_account(); + let agent_id = configured_agent(); + let claims = unclaimed_resolver(); + let pipeline = Pipeline { + store: &store, + port: &port, + renderer: renderer.as_ref(), + outbound: &outbound, + claims: &claims, + account: &account, + agent_id: &agent_id, + triggers: &triggers, + ids: &UuidV7Generator, + }; + + // The conversation is created, the session is minted, and the bucket the + // pointer lives in disappears in between. + port.drop_bucket_after_next_session(&js, "channel_conversations_test"); + let error = pipeline + .handle_message(&next_message(&mut messages).await) + .await + .expect_err("the pointer write must fail"); + assert!( + matches!(error, PipelineError::Store(_)), + "the store failure must surface, got {error:?}" + ); + + assert_eq!( + *port.released.borrow(), + vec![("sess-1".to_string(), ReleaseReason::Unrecorded)], + "a session the conversation never recorded must be handed back" + ); + assert!( + port.prompted.borrow().is_empty(), + "the turn must not reach the agent on a session the conversation does not have" + ); + assert!(outbound.sent.borrow().is_empty(), "nothing may reach the chat"); + + let info = settled_consumer_info(&stream, "bridge-test", 1).await; + assert_eq!(info.num_ack_pending, 1); +} + /// Everything the bridge cannot act on is acked and dropped rather than left to /// redeliver: none of it will parse, authorize, or route any better the second /// time, so redelivering it would wedge the consumer behind a message that can diff --git a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs index 414d0ef6b3..c544c8ef69 100644 --- a/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs @@ -112,15 +112,19 @@ pub trait AgentPortError: std::error::Error + 'static { pub enum ReleaseReason { /// The user asked for a fresh conversation. NewSession, - /// A session opened to repair a suspected lost session is being handed back - /// unused: either it failed the same way the old one did, or it answered and - /// the conversation could not be pointed at it, which leaves its reply - /// unreadable either way. + /// A session opened to repair a suspected lost session failed the same way + /// the old one did, so the session was never the problem and this one is + /// handed back unused. RepairFailed, /// A suspected lost session was replaced by a fresh one that answered. The /// suspicion is a guess, so the agent may still hold the old session; it is /// told to let go rather than left holding one nothing points at. Replaced, + /// A session was opened for a conversation whose pointer to it could not be + /// written. Every later turn mints its own id, so nothing above the port will + /// ever name this one again; it goes back rather than staying open for the + /// agent's lifetime. + Unrecorded, /// The agent answered `session/new` with a handle this bridge cannot name /// (see [`AgentSessionIdError`]). The session exists at the agent and /// nothing above the port will ever be able to ask for it, so it goes back From 15fc0d241b8bb93f7f89ce207fd2abcf78a7e283 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 5 Aug 2026 18:55:49 -0400 Subject: [PATCH 55/55] fix(channel): keep the words a photo arrived with Signed-off-by: Yordis Prieto --- .../0044-inbound-media-fetch-out-of-band.md | 7 +-- .../channel-bridge-telegram/src/parse.rs | 16 ++++-- .../src/parse/tests.rs | 53 +++++++++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/docs/adr/0044-inbound-media-fetch-out-of-band.md b/docs/adr/0044-inbound-media-fetch-out-of-band.md index 00f2689f5f..df2801e489 100644 --- a/docs/adr/0044-inbound-media-fetch-out-of-band.md +++ b/docs/adr/0044-inbound-media-fetch-out-of-band.md @@ -19,9 +19,10 @@ what a conversational turn has to wait for. [The multi-channel routing design](../architecture/multi-channel-agent-routing.md) originally recorded "eager claim-check": the bridge downloads media at normalize time, before dispatching the prompt. That decision was never -implemented. `channel-bridge-telegram`'s parser drops every media update -(`parse.rs` returns `None` for any message without text and hardcodes -`attachments: Vec::new()`), so nothing is sunk and the decision is open. +implemented. `channel-bridge-telegram`'s parser carries no media: `parse.rs` +takes the words a message came with, from `text` or from a media `caption`, and +hardcodes `attachments: Vec::new()`, so a photo reaches the agent as whatever was +said about it and never as a handle. Nothing is sunk and the decision is open. Three properties of the problem constrain the answer: diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs index f5050b3dcf..9c4d3b4b0a 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs @@ -6,14 +6,19 @@ use trogon_channel::{ ChannelAccount, CommandTriggers, Endpoint, InboundEvent, MessageRef, PlatformUserId, SafeToken, Sender, }; -/// Normalize a raw Telegram update into the channel-neutral event, or `None` -/// for update kinds the bridge does not carry (media, edits, membership, ...). -/// The raw stream retains those with full fidelity for later. +/// Normalize a raw Telegram update into the channel-neutral event, or `None` for +/// what the bridge does not carry: update kinds other than a new message (edits, +/// membership, ...) and messages with no words in them. Whatever is dropped here +/// stays on the raw stream with full fidelity for later. pub fn inbound_event(update: &Update, account: &ChannelAccount, triggers: &CommandTriggers) -> Option { let UpdateKind::Message(msg) = &update.kind else { return None; }; - let text = msg.text()?; + // Telegram files the words under `text` for a text message and under + // `caption` for one that carries media, and teloxide keeps the two apart. A + // caption is the user talking, and it costs no download to forward, so it + // must not go missing while the media beside it waits for a downloader. + let text = msg.text().or_else(|| msg.caption())?; let from = msg.from.as_ref()?; let parsed = triggers.parse(text, account.account()); @@ -29,6 +34,9 @@ pub fn inbound_event(update: &Update, account: &ChannelAccount, triggers: &Comma }, text: parsed.body, command: parsed.command, + // Empty until a downloader exists to redeem handles out of band, which is + // ADR#0044's decision and not something this parser can anticipate: an + // event that named an attachment nothing can resolve would promise bytes. attachments: Vec::new(), message_ref: MessageRef::from(i64::from(msg.id.0)), occurred_at: msg.date.timestamp(), diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse/tests.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse/tests.rs index c45381f18e..f29d53d837 100644 --- a/rsworkspace/crates/channel/channel-bridge-telegram/src/parse/tests.rs +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse/tests.rs @@ -27,6 +27,59 @@ fn message_update(chat_id: i64, user_id: u64, text: &str) -> Update { })) } +/// A photo, which is where Telegram files the sender's words under `caption` +/// rather than `text`. The caption is omitted entirely when there is none, which +/// is a bare photo: media with nothing said about it. +fn photo_update(chat_id: i64, user_id: u64, caption: Option<&str>) -> Update { + let mut message = serde_json::json!({ + "message_id": 1, + "date": 1_700_000_000, + "chat": { "id": chat_id, "type": "private", "first_name": "Test" }, + "from": { "id": user_id, "is_bot": false, "first_name": "Test" }, + "photo": [{ + "file_id": "photo-file-id", + "file_unique_id": "photo-unique-id", + "width": 320, + "height": 320, + "file_size": 3452, + }], + }); + if let Some(caption) = caption { + message["caption"] = serde_json::json!(caption); + } + update_from(serde_json::json!({ "update_id": 1, "message": message })) +} + +/// A caption is the user talking, so it reaches the agent as the message text and +/// is read for a trigger like any other sentence. Nothing else about the photo is +/// carried: redeeming the handle is out of band (ADR#0044), and waiting for a +/// downloader that does not exist yet would lose the words too. +#[test] +fn a_captioned_photo_carries_the_caption_as_the_message_text() { + let triggers = CommandTriggers::default(); + + let update = photo_update(42, 42, Some("what does this say?")); + let event = inbound_event(&update, &account(), &triggers).expect("event"); + assert_eq!(event.text.as_deref(), Some("what does this say?")); + assert!(event.command.is_none()); + assert!(event.attachments.is_empty(), "no handle may be named before ADR#0044"); + + let update = photo_update(42, 42, Some("/new read this one")); + let event = inbound_event(&update, &account(), &triggers).expect("event"); + assert_eq!(event.command, Some(trogon_channel::Command::NewSession)); + assert_eq!(event.text.as_deref(), Some("read this one")); +} + +/// A photo with nothing said about it is still dropped: there are no words to +/// forward, and the bytes are the downloader's to fetch, so an event here would +/// prompt the agent with an empty turn. +#[test] +fn a_photo_with_no_caption_yields_no_inbound_event() { + let update = photo_update(42, 42, None); + let triggers = CommandTriggers::default(); + assert!(inbound_event(&update, &account(), &triggers).is_none()); +} + /// `inbound_event` only carries `UpdateKind::Message`. An edit is a real update /// kind the raw stream keeps for later, and it must come back as `None` here /// rather than being misread as a fresh message.