diff --git a/.gitignore b/.gitignore index 6ed7e81897..104fcab5b5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ docs/.vitepress/cache mise.local.toml # Docker +compose.override.yml +compose.override.yaml docker-compose.override.yml # IDE @@ -33,11 +35,15 @@ docker-compose.override.yml .vscode/ *.swp *.swo +*~ # OS .DS_Store Thumbs.db +# Logs +*.log + # trogonai internal .trogonai/ *.internal.trogonai.md @@ -50,6 +56,9 @@ coverage-*.xml *.profraw *.profdata +# NATS +*.creds + # Misc tmp 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/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..df2801e489 --- /dev/null +++ b/docs/adr/0044-inbound-media-fetch-out-of-band.md @@ -0,0 +1,254 @@ +--- +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 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: + +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 +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. + +**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 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 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 + +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. 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 +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 credential that received the handle together with the handle itself: + +```text +channel_media_{prefix}: + {channel}.{account}.{platform_ref} -> + { state: ready | failed, object_ref, mime, size, error } +``` + +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. + +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. + +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. 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. 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 + +`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, 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 + +- 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 + 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. +- 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. +- 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. + +## 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 cost is that the downloader has to write that + 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 + 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 + 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 new file mode 100644 index 0000000000..55d6ac85d2 --- /dev/null +++ b/docs/architecture/multi-channel-agent-routing.md @@ -0,0 +1,491 @@ +# Multi-Channel Agent Routing + +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. `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. + +## The path a message takes + +```text + SUBJECT / PROTOCOL PROCESS + +Telegram ─HTTP─▶ webhook validated, published verbatim trogon-gateway + telegram.{update_type}, stream TELEGRAM (Telegram source, inbound only) + │ + ▼ durable consumer, ack_wait 600s + normalize the Update into InboundEvent channel-bridge-telegram + resolve principal + conversation in KV + dispatch the prompt through AgentPort + │ + ▼ ACP over acp-nats + ═══ agent works, streams notifications ═══ + │ + ▼ buffer text, flush when the turn ends + Telegram Bot API ─HTTPS─▶ channel-bridge-telegram + send_message, chunked at 4096 (same process) +``` + +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 +`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` +(`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 +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 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 +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 + +Four words carry this whole design, so here they are with values taken from the +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. + +| 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 +(`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 +chat being allowed says nothing about who spoke in it. + +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. + +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. + +**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 + │ │ + │ 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 +``` + +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 + 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 endpoints into one session must 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. + +## State: JetStream KV buckets + +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 | +| --- | --- | --- | +| `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 +at the bridge, which logs, acks, and drops. This replaces the per-channel +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 + +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**, what any channel bridge produces after stripping its +platform's shape: + +```text +{ + endpoint: { channel, account, peer }, + sender: { platform_user_id, display_name }, + text: string | null, + command: bridge command | null, + attachments: [ { kind, mime, size, 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**, the one output vocabulary every channel implements: + +| 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 | + +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`, 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 + +The bridge reaches agents through one in-process trait: + +```text +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. +- 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. + +Whatever the bridge does not carry is not destroyed: the raw `TELEGRAM` stream +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 + +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 + 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). +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 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 + [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](../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 + 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. +- **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. +- **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. +- **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, 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. + +## 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, 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 + 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 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. diff --git a/docs/glossary/binding.md b/docs/glossary/binding.md new file mode 100644 index 0000000000..b5ae367595 --- /dev/null +++ b/docs/glossary/binding.md @@ -0,0 +1,20 @@ +--- +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. 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 +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..3d560e12c3 --- /dev/null +++ b/docs/glossary/endpoint.md @@ -0,0 +1,21 @@ +--- +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 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 +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..d67b52cf3e --- /dev/null +++ b/docs/glossary/principal.md @@ -0,0 +1,28 @@ +--- +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 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 +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. diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 7ff7d2dfb4..00abdc4c04 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -438,7 +438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5c231915b4ab578c722eca2d1bd7df4d300bfd6cac3b8e9f0d1e3ddc95b187c" dependencies = [ "anyhow", - "derive_more", + "derive_more 2.1.1", "schemars 1.2.1", "serde", "serde_json", @@ -573,6 +573,29 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[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.118", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -1228,6 +1251,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" @@ -1309,6 +1338,27 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "channel-bridge-telegram" +version = "0.1.0" +dependencies = [ + "acp-nats", + "agent-client-protocol", + "anyhow", + "async-nats", + "async-trait", + "futures", + "serde_json", + "teloxide", + "thiserror 2.0.19", + "tokio", + "tracing", + "trogon-channel", + "trogon-nats", + "trogon-std", + "trogon-telemetry", +] + [[package]] name = "chrono" version = "0.4.45" @@ -2072,13 +2122,34 @@ dependencies = [ "syn 2.0.118", ] +[[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]] name = "derive_more" 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.118", + "unicode-xid", ] [[package]] @@ -2168,6 +2239,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" @@ -2286,6 +2366,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" @@ -3125,6 +3215,25 @@ dependencies = [ "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" @@ -3169,6 +3278,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" @@ -3638,6 +3756,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" @@ -4368,6 +4496,27 @@ 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" @@ -4413,7 +4562,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools", + "itertools 0.14.0", "log", "multimap", "petgraph 0.8.3", @@ -4434,7 +4583,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.118", @@ -4449,6 +4598,16 @@ dependencies = [ "prost", ] +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -4690,6 +4849,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" @@ -4823,6 +4991,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -4893,6 +5062,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" @@ -5820,6 +5998,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -5935,6 +6126,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" @@ -5947,6 +6150,80 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" +[[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.6", + "serde", + "serde_json", + "teloxide-core", + "teloxide-macros", + "thiserror 2.0.19", + "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 2.13.0", + "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.19", + "tokio", + "tokio-util", + "url", + "uuid", +] + +[[package]] +name = "teloxide-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3118a980ed2ec11f73d9495a6606905bd74726e3ffe95a42fbeb187ded8fdbf4" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -5985,7 +6262,7 @@ dependencies = [ "ferroid", "futures", "http", - "itertools", + "itertools 0.14.0", "log", "memchr", "parse-display", @@ -6400,6 +6677,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", "url", ] @@ -6614,6 +6892,21 @@ dependencies = [ "wiremock", ] +[[package]] +name = "trogon-channel" +version = "0.1.0" +dependencies = [ + "async-nats", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tracing", + "trogon-nats", + "trogon-std", + "uuid", +] + [[package]] name = "trogon-decider" version = "0.1.0" @@ -7671,7 +7964,7 @@ dependencies = [ "cranelift-frontend", "cranelift-native", "gimli", - "itertools", + "itertools 0.14.0", "log", "object", "pulley-interpreter", diff --git a/rsworkspace/Cargo.toml b/rsworkspace/Cargo.toml index c1f02c44e6..7845414a01 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-channel = { path = "crates/channel/trogon-channel" } +channel-bridge-telegram = { path = "crates/channel/channel-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", default-features = false, features = ["macros", "webhooks-axum", "rustls"] } + # Serialization confique = { version = "=0.4.0", features = ["toml"] } serde = { version = "=1.0.229", features = ["derive"] } diff --git a/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml new file mode 100644 index 0000000000..c56a208957 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "channel-bridge-telegram" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lints] +workspace = true + +[dependencies] +acp-nats = { workspace = true } +trogon-channel = { workspace = true } +trogon-nats = { workspace = true } +trogon-std = { workspace = true, features = ["signal", "uuid"] } +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] +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 new file mode 100644 index 0000000000..092ce23833 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port.rs @@ -0,0 +1,312 @@ +#[cfg(test)] +mod tests; + +use agent_client_protocol::ErrorCode; +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; + +#[derive(Debug, thiserror::Error)] +pub enum AcpPortError { + #[error("agent request failed: {0}")] + Rpc(agent_client_protocol::Error), + #[error(transparent)] + SessionId(#[from] trogon_channel::AgentSessionIdError), +} + +impl AgentPortError for AcpPortError { + fn is_session_lost(&self) -> bool { + // 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), + Self::SessionId(_) => false, + } + } +} + +/// 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 +/// (`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 { + bridge, + agent_cwd, + 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 +/// 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) +} + +/// 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( + "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 +} + +#[cfg(not(coverage))] +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)?; + 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 { + 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.cancel_id(session.as_str()).await + } + + /// 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 { + 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 new file mode 100644 index 0000000000..f72a3ec257 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/acp_port/tests.rs @@ -0,0 +1,138 @@ +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" + ); + } +} + +/// 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"); + 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 new file mode 100644 index 0000000000..f760537603 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config.rs @@ -0,0 +1,186 @@ +#[cfg(test)] +mod tests; + +use crate::constants::CHANNEL; +use acp_nats::{AcpPrefix, AcpPrefixError, NatsConfig}; +use std::path::PathBuf; +use trogon_channel::{AgentId, ChannelAccount, CommandTriggerError, CommandTriggers, EndpointError}; +use trogon_nats::jetstream::ClaimBucket; +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 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 { + let trimmed = raw.as_ref().trim(); + if trimmed.is_empty() { + return Err(BlankBotTokenError); + } + 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()) +} + +/// 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("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, + #[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. + 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. + /// + /// 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, + /// 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. + /// 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, + /// 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) -> 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()); + 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") { + Some(raw) => raw + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(|entry| { + entry.parse::().map_err(|source| BridgeConfigError::SeedUser { + entry: entry.to_string(), + source, + }) + }) + .collect::, _>>()?, + 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(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from), + )?, + 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)?; + let acp = acp_nats::Config::with_prefix(acp_prefix, NatsConfig::from_env(env)); + + Ok(Self { + acp, + channel_prefix, + inbound_stream, + claim_bucket: ClaimBucket::default(), + bot_token, + account, + agent_id, + agent_cwd, + seed_users, + command_triggers, + }) + } +} 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..f7c13ea648 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/config/tests.rs @@ -0,0 +1,211 @@ +use super::*; +use trogon_std::env::InMemoryEnv; + +/// 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 +/// 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"] { + assert!(matches!(BotToken::new(token), Err(BlankBotTokenError)), "{token:?}"); + + let env = InMemoryEnv::new(); + env.set("TELEGRAM_BOT_TOKEN", token); + assert!( + matches!(rejection(&env), BridgeConfigError::BotToken(_)), + "blank token {token:?} must be refused as a token, not as something else" + ); + } + + assert!(matches!(rejection(&InMemoryEnv::new()), BridgeConfigError::BotToken(_))); +} + +/// 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] +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", + "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.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()); +} + +/// 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", "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", "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"); + + 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"); + // 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""# + ); +} + +/// 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(); + 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(' ')) + )); +} + +/// 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(); + 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.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 new file mode 100644 index 0000000000..cdd03e032b --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/constants.rs @@ -0,0 +1,18 @@ +/// 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 +/// strand every deployment's position in the stream. +pub const INBOUND_DURABLE: &str = "channel-bridge-telegram"; + +/// 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 +/// 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 new file mode 100644 index 0000000000..0bf841b1b6 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/main.rs @@ -0,0 +1,245 @@ +//! 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 +//! session notifications -> Telegram API calls). Everything channel-neutral +//! 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))] + +#[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; + +// 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, PrincipalId, SafeToken}, + 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)?; + trogon_telemetry::init_logger(ServiceName::ChannelBridgeTelegram, [], &SystemEnv, &SystemFs); + + 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.channel_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 + ) + })?; + // 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_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 + .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), + 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.as_str()); + + let local = tokio::task::LocalSet::new(); + 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"); + } + 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!("{}-{user}", constants::CHANNEL))?; + let endpoint = config.account.endpoint_for(&SafeToken::from(*user)); + store + .link_endpoint(&principal, &PrincipalRecord { display_name: None }, &endpoint) + .await?; + info!(principal = %principal, endpoint = %endpoint, "Seeded principal"); + } + Ok(()) +} + +#[cfg(not(coverage))] +async fn run( + nats_client: async_nats::Client, + store: ChannelStore, + claims: ClaimResolver, + mut messages: async_nats::jetstream::consumer::pull::Stream, + bot: Bot, + config: BridgeConfig, +) -> anyhow::Result<()> { + 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( + nats_client.clone(), + js_client, + trogon_std::time::SystemClock, + &meter, + config.acp.clone(), + notification_tx, + )); + let renderer = Arc::new(TelegramRenderClient::new()); + + 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 mut notification_task = tokio::task::spawn_local(async move { + while let Some(notification) = notification_rx.recv().await { + if let Err(e) = renderer_for_rx.session_notification(notification).await { + error!(error = ?e, "Render client rejected a session notification"); + break; + } + } + }); + + let initialized = bridge + .initialize(InitializeRequest::new(ProtocolVersion::LATEST)) + .await + .map_err(|e| anyhow::anyhow!("ACP initialize failed: {e}"))?; + 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, + port: &port, + renderer: renderer.as_ref(), + outbound: &telegram, + claims: &claims, + account: &config.account, + agent_id: &config.agent_id, + triggers: &config.command_triggers, + ids: &UuidV7Generator, + }; + + let shutdown = shutdown_signal(); + tokio::pin!(shutdown); + loop { + tokio::select! { + () = &mut shutdown => { + 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"); + 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(()) +} + +#[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 new file mode 100644 index 0000000000..46cf5c6330 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/outbound.rs @@ -0,0 +1,61 @@ +// 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, Message, True}; + +/// Show the typing indicator in a chat. One trait per outbound operation; +/// never carries agent concepts. +#[allow(async_fn_in_trait)] +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))] +pub struct TelegramOutbound { + bot: Bot, +} + +#[cfg(not(coverage))] +impl TelegramOutbound { + pub fn new(bot: Bot) -> Self { + Self { bot } + } +} + +#[cfg(not(coverage))] +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) -> 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 new file mode 100644 index 0000000000..9c4d3b4b0a --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse.rs @@ -0,0 +1,52 @@ +#[cfg(test)] +mod tests; + +use teloxide::types::{Update, UpdateKind}; +use trogon_channel::{ + ChannelAccount, CommandTriggers, Endpoint, InboundEvent, MessageRef, PlatformUserId, SafeToken, Sender, +}; + +/// 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; + }; + // 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()); + + // 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: account.endpoint_for(&SafeToken::from(msg.chat.id.0)), + sender: Sender { + platform_user_id: PlatformUserId::from(from.id.0), + display_name: from.full_name(), + }, + 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(), + }) +} + +/// 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. 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 new file mode 100644 index 0000000000..f29d53d837 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/parse/tests.rs @@ -0,0 +1,128 @@ +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 +/// 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, + } + })) +} + +/// 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. +#[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, &account(), &triggers).is_none()); +} + +/// 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 a_group_chats_negative_id_reaches_the_endpoint_intact() { + let update = message_update(-1_001_234_567_890, 42, "hello"); + let triggers = CommandTriggers::default(); + let event = inbound_event(&update, &account(), &triggers).expect("event"); + + 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 +/// 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, &account(), &triggers).expect("event"); + + 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 new file mode 100644 index 0000000000..64071d3765 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline.rs @@ -0,0 +1,368 @@ +#[cfg(test)] +mod tests; + +use crate::constants::{NEW_SESSION_ACKNOWLEDGEMENT, TEXT_CHUNK_LIMIT}; +use crate::outbound::{SendText, SendTyping}; +use crate::parse; +use crate::render::{TelegramRenderClient, chunk_text}; +use tracing::{info, warn}; +use trogon_channel::{ + 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; + +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 account: &'a ChannelAccount, + pub agent_id: &'a AgentId, + pub triggers: &'a CommandTriggers, + 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("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) -> 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> +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) -> Result { + let endpoint = parse::sender_endpoint(self.account, &event.sender); + 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, + ) -> Result<(), ChannelStoreError> { + 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 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, + ) -> 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 + // 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(PipelineError::Claim)?; + + let update = match serde_json::from_slice::(&body) { + Ok(update) => update, + Err(e) => { + warn!(error = %e, body_len = body.len(), "Unparseable Telegram update; dropping"); + return ack(msg).await; + } + }; + + let Some(event) = parse::inbound_event(&update, self.account, self.triggers) 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::() + .map_err(PipelineError::PeerNotChatId)?; + + let now = now_unix(); + let (conversation_id, mut record) = match self.store.conversation_for(&event.endpoint).await? { + Some(found) => found, + None => { + // Routing policy: every new conversation binds to the single + // configured agent. Sticky from here on. + let record = ConversationRecord { + principal: principal.clone(), + agent_id: self.agent_id.clone(), + current_session: None, + created_at: now, + last_activity_at: now, + }; + self.store + .create_conversation(&event.endpoint, &record, self.ids) + .await? + .into_conversation(&event.endpoint, record) + } + }; + + 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() { + self.outbound + .send_text(chat_id, NEW_SESSION_ACKNOWLEDGEMENT.to_string()) + .await + .map_err(PipelineError::SendText)?; + 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 => { + let session = self + .port + .create_session(&record) + .await + .map_err(PipelineError::CreateSession)?; + record.current_session = Some(session.clone()); + 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 + } + }; + + let _ = self.outbound.typing(chat_id).await; + + let outcome = match self.port.prompt(&active_session, &event).await { + Ok(outcome) => outcome, + // 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. + // + // 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 may no longer have the session; trying a fresh one"); + 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) => { + record.current_session = Some(fresh.clone()); + 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::Unrecorded).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 + // 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 + } + // 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()); + // 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, + cancelled = ?release.cancelled, + closed = ?release.closed, + "Released the session opened to repair a suspected loss" + ); + return Err(PipelineError::PromptRetry { + session: active_session, + first: first_error, + retry: retry_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(); + 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 + .map_err(PipelineError::SendText)?; + } + } + None => warn!(outcome = ?outcome, session = %active_session, "Agent turn produced no text"), + } + + 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 new file mode 100644 index 0000000000..5d630a5a21 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/pipeline/tests.rs @@ -0,0 +1,1275 @@ +use super::*; +use crate::outbound::{SendText, SendTyping}; +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 trogon_channel::store::PrincipalRecord; +use trogon_channel::{ + AgentPortError, AgentSessionId, ChannelAccount, Endpoint, InboundEvent, PrincipalId, PromptOutcome, ReleaseReason, + ReleaseStep, 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 +// the coverage build leaves out; the scenarios that carry no claim do not. +#[cfg(not(coverage))] +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 { + session_lost: bool, +} + +impl AgentPortError for FakeError { + fn is_session_lost(&self) -> bool { + self.session_lost + } +} + +/// 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>, + 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, + /// 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, + /// 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>, + /// 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. +fn scripted(counter: &RefCell) -> bool { + let mut left = counter.borrow_mut(); + let scripted = *left > 0; + *left = left.saturating_sub(1); + scripted +} + +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), + refusals: RefCell::new(0), + creation_failures: RefCell::new(0), + silent_turns: RefCell::new(0), + bucket_to_drop: RefCell::new(None), + bucket_to_drop_on_creation: RefCell::new(None), + } + } + + 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; + } + + 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())); + } + + 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(), + 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 { + type Error = FakeError; + + async fn create_session( + &self, + _conversation: &trogon_channel::ConversationRecord, + ) -> Result { + if scripted(&self.creation_failures) { + return Err(FakeError { session_lost: false }); + } + *self.sessions_created.borrow_mut() += 1; + 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 { + self.prompted + .borrow_mut() + .push((session.as_str().to_string(), event.text.clone().unwrap_or_default())); + + // 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); + } + + self.stream(session, &self.reply).await; + drop_scheduled_bucket(&self.bucket_to_drop).await; + Ok(PromptOutcome::Completed) + } + + async fn cancel(&self, _session: &AgentSessionId) -> Result<(), Self::Error> { + Ok(()) + } + + 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, + } + } +} + +#[derive(Default)] +struct FakeOutbound { + typing: RefCell, + sent: RefCell>, +} + +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) -> 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") +} + +/// 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 +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 +/// 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 as u64 == ack_pending && info.num_pending == 0 { + return info; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + 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(ClaimBucketBinding::for_test( + MockObjectStore::new(), + ClaimBucket::default(), + )) +} + +/// 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 { + NatsObjectStore::provision_claim_bucket(js, ClaimBucket::default(), ClaimRetention::EventSourced) + .await + .expect("provision 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 +/// 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() { + 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"); + + for (update_id, chat, user, text) in [ + (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 + .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, + }; + + for _ in 0..6 { + let msg = next_message(&mut messages).await; + 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() + ); + + // 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(), ReleaseReason::NewSession), + ("sess-2".to_string(), ReleaseReason::NewSession), + ] + ); + + // The conversation and its principal outlive every session rotation. + 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-3") + ); + + assert_eq!(*outbound.typing.borrow(), 4); + assert_eq!( + *outbound.sent.borrow(), + 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 = settled_consumer_info(&stream, "bridge-test", 0).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. +#[cfg(not(coverage))] +#[tokio::test] +async fn pipeline_redeems_a_claim_checked_update() { + 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"); + + let claims = claim_resolver(&js).await; + let gateway = ClaimCheckPublisher::new( + NatsJetStreamClient::new(js.clone()), + NatsObjectStore::bind_claim_bucket(&js, ClaimBucket::default()) + .await + .expect("bind claim bucket"), + 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::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, + account: &account, + agent_id: &agent_id, + triggers: &triggers, + ids: &UuidV7Generator, + }; + + 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"); + + 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", 0).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. +#[cfg(not(coverage))] +#[tokio::test] +async fn pipeline_leaves_an_unredeemable_claim_unacked() { + 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 claims = claim_resolver(&js).await; + let gateway = ClaimCheckPublisher::new( + NatsJetStreamClient::new(js.clone()), + NatsObjectStore::bind_claim_bucket(&js, ClaimBucket::default()) + .await + .expect("bind claim bucket"), + 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::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, + account: &account, + agent_id: &agent_id, + triggers: &triggers, + ids: &UuidV7Generator, + }; + + 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()); + + 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 = 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"); + + 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 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, + }; + + pipeline + .handle_message(&next_message(&mut messages).await) + .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); + assert!( + 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, + // 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(), ReleaseReason::RepairFailed)] + ); + + // So the next message continues on it rather than starting over. + 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(&mut messages).await) + .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") + ); + + // 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(), + 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. 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); +} + +/// 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 = 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"); + + 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 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, + }; + + 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::Unrecorded)], + "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); +} + +/// 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 +/// never succeed. One container for the whole scenario. +#[tokio::test] +async fn pipeline_acks_and_drops_what_no_redelivery_would_fix() { + 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"); + 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 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, + }; + + 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()); + + 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 = 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"); + + 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 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, + }; + + 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.rs b/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs new file mode 100644 index 0000000000..9a99e89899 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/render.rs @@ -0,0 +1,108 @@ +#[cfg(test)] +mod tests; + +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 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()) + } + + /// 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 { + 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. 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() { + 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 += width; + } + if !current.is_empty() { + chunks.push(current); + } + chunks +} 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..6717884764 --- /dev/null +++ b/rsworkspace/crates/channel/channel-bridge-telegram/src/render/tests.rs @@ -0,0 +1,109 @@ +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() { + 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(), 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}"); +} 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/Cargo.toml b/rsworkspace/crates/channel/trogon-channel/Cargo.toml new file mode 100644 index 0000000000..e28ad768a8 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "trogon-channel" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lints] +workspace = true + +[dependencies] +trogon-nats = { workspace = true } +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 } + +[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/agent_port.rs b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs new file mode 100644 index 0000000000..c544c8ef69 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port.rs @@ -0,0 +1,177 @@ +#[cfg(test)] +mod tests; + +use crate::conversation::ConversationRecord; +use crate::event::InboundEvent; +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. +/// +/// 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 AsRef) -> Result { + Ok(Self(NatsToken::new(id)?)) + } + + pub fn as_str(&self) -> &str { + 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.as_str()) + } +} + +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 + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::new(raw).map_err(serde::de::Error::custom) + } +} + +/// 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, +} + +/// 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 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; +} + +/// 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, + /// 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 + /// immediately rather than being left for the agent's lifetime. + Unnamable, +} + +/// 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 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, + pub closed: ReleaseStep, +} + +/// 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 +/// addressability already exists per protocol (see the architecture doc). +#[allow(async_fn_in_trait)] +pub trait AgentPort { + type Error: AgentPortError; + + /// 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: &InboundEvent) -> 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/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..59ad72bef2 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/agent_port/tests.rs @@ -0,0 +1,60 @@ +use super::*; + +/// 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_a_subject_token() { + assert_eq!(AgentSessionId::new("sess-1").expect("valid").as_str(), "sess-1"); + assert_eq!( + AgentSessionId::new("sess 1").unwrap_err(), + AgentSessionIdError::InvalidCharacter(' ') + ); + 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. +#[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`) +/// 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_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 new file mode 100644 index 0000000000..f7753aaab0 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/command.rs @@ -0,0 +1,96 @@ +#[cfg(test)] +mod 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 +/// 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 Command { + /// Release the conversation's current session. The agent binding is + /// untouched; only the ephemeral session is replaced. + NewSession, +} + +/// 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 { + #[allow(clippy::expect_used)] + fn default() -> Self { + Self::new(["/new", "/reset"]).expect("the default triggers are single non-empty tokens") + } +} + +/// 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: I) -> Result + where + I: IntoIterator, + T: Into, + { + let new_session = new_session + .into_iter() + .map(|trigger| CommandTrigger::try_from(trigger.into())) + .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. + /// + /// `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, ""), + }; + + let (token, addressed_to) = match head.split_once('@') { + Some((token, account)) => (token, Some(account)), + None => (head, None), + }; + // 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), + }; + 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, + None => text, + }; + ParsedText { + command, + body: (!body.trim().is_empty()).then(|| body.to_string()), + } + } +} 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..8515b4f3b8 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/command/tests.rs @@ -0,0 +1,86 @@ +use super::*; + +#[test] +fn bare_trigger_yields_a_command_and_no_body() { + 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 ", "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", "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", "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 ", "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", "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"]).expect("valid triggers"); + assert_eq!(triggers.parse("!rotate", "mybot").command, Some(Command::NewSession)); + 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))); + assert!(matches!( + 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..5672ac17b0 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/command_trigger.rs @@ -0,0 +1,44 @@ +//! 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. +/// +/// 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); + +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_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..79a34a597b --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/command_trigger_input.rs @@ -0,0 +1,31 @@ +//! Untrusted command-trigger text before validation. + +#[cfg(test)] +mod 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 new file mode 100644 index 0000000000..0fcf32a379 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/conversation.rs @@ -0,0 +1,94 @@ +#[cfg(test)] +mod tests; + +use crate::agent_port::AgentSessionId; +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)] +#[serde(transparent)] +pub struct AgentId(SafeToken); + +impl AgentId { + pub fn new(id: impl Into) -> Result { + Ok(Self(SafeToken::new(id)?)) + } + + pub fn as_str(&self) -> &str { + 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.as_str()) + } +} + +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 + /// 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")) + } + + pub fn from_string(id: impl Into) -> Result { + Ok(Self(SafeToken::new(id)?)) + } + + pub fn as_str(&self) -> &str { + 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.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) + } +} + +/// 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/channel/trogon-channel/src/conversation/tests.rs b/rsworkspace/crates/channel/trogon-channel/src/conversation/tests.rs new file mode 100644 index 0000000000..7572035171 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/conversation/tests.rs @@ -0,0 +1,98 @@ +use super::*; +use crate::agent_port::{AgentSessionId, AgentSessionIdError}; +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()); +} + +#[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_eq!(err.classify(), serde_json::error::Category::Data, "{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_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); +} + +#[test] +fn agent_session_id_rejects_unsafe_tokens() { + assert_eq!( + AgentSessionId::new("sess.1").unwrap_err(), + AgentSessionIdError::InvalidCharacter('.') + ); +} + +#[test] +fn agent_session_id_deserialize_rejects_unsafe_tokens() { + let err = serde_json::from_str::("\"sess.1\"").expect_err("dot is unsafe"); + assert_eq!(err.classify(), serde_json::error::Category::Data, "{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_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs new file mode 100644 index 0000000000..f4791a3cc9 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/endpoint.rs @@ -0,0 +1,160 @@ +#[cfg(test)] +mod tests; + +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("token must not be empty")] + Empty, + #[error("token contains invalid character: {0:?}")] + InvalidCharacter(char), +} + +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 { + Ok(Self { + channel: SafeToken::new(channel)?, + account: SafeToken::new(account)?, + peer: SafeToken::new(peer)?, + }) + } + + pub fn channel(&self) -> &str { + self.channel.as_str() + } + + pub fn account(&self) -> &str { + self.account.as_str() + } + + pub fn peer(&self) -> &str { + self.peer.as_str() + } + + /// 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<'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()) + } +} + +/// 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. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct PrincipalId(SafeToken); + +impl PrincipalId { + pub fn new(id: impl Into) -> Result { + Ok(Self(SafeToken::new(id)?)) + } + + pub fn as_str(&self) -> &str { + 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.as_str()) + } +} 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..04b2fc79b2 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/endpoint/tests.rs @@ -0,0 +1,112 @@ +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_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_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(); + 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_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/event.rs b/rsworkspace/crates/channel/trogon-channel/src/event.rs new file mode 100644 index 0000000000..2ddd2c586c --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/event.rs @@ -0,0 +1,314 @@ +#[cfg(test)] +mod tests; + +use crate::command::Command; +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(transparent)] + NotAMediaType(#[from] MediaTypeError), +} + +/// 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: 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() + } + + /// 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 +/// 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, +} + +/// 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 has one subtype, so its subtype may not contain '/'")] + SubtypeIsNotOne, + #[error("a media type's type and subtype may not contain whitespace")] + InteriorWhitespace, +} + +/// 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 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); + +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.trim_end(), Some(parameters.trim_start())), + None => (trimmed, None), + }; + let (kind, subtype) = essence.split_once('/').ok_or(MediaTypeError::MissingSeparator)?; + if kind.is_empty() { + return Err(MediaTypeError::EmptyType.into()); + } + if subtype.is_empty() { + return Err(MediaTypeError::EmptySubtype.into()); + } + if subtype.contains('/') { + return Err(MediaTypeError::SubtypeIsNotOne.into()); + } + // 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()); + if let Some(parameters) = parameters { + normalized.push(';'); + normalized.push_str(parameters); + } + Ok(Self(normalized)) + } + + 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 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); + +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}`, + /// 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) + } +} + +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: 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: 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, + 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. + 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..065ad1c173 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/event/tests.rs @@ -0,0 +1,250 @@ +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); + // 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 +/// 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_eq!(err.classify(), serde_json::error::Category::Data, "{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, reason) in [ + ("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!( + MimeType::new(raw).unwrap_err(), + EventFieldError::NotAMediaType(reason), + "{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 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. +#[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 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"); + 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. +#[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(), "file-abc"); +} + +#[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 new file mode 100644 index 0000000000..a7a9efe817 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/lib.rs @@ -0,0 +1,44 @@ +//! 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 +//! commands), the JetStream KV registries, and the [`AgentPort`] trait through +//! 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. +//! +//! 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))] + +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, AgentSessionIdError, PromptOutcome, ReleaseReason, ReleaseStep, + SessionRelease, +}; +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::{ChannelAccount, Endpoint, EndpointError, PrincipalId}; +pub use event::{ + Attachment, AttachmentKind, EventFieldError, InboundEvent, MediaTypeError, MessageRef, MimeType, PlatformRef, + PlatformUserId, Sender, +}; +pub use render::RenderCommand; +pub use safe_token::{SafeToken, SafeTokenError}; +pub use store::{BoundConversationError, ChannelStore, ChannelStoreError, EndpointBinding, ReserveEndpointError}; diff --git a/rsworkspace/crates/channel/trogon-channel/src/render.rs b/rsworkspace/crates/channel/trogon-channel/src/render.rs new file mode 100644 index 0000000000..dfb83f95fe --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/render.rs @@ -0,0 +1,27 @@ +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. +#[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/channel/trogon-channel/src/safe_token.rs b/rsworkspace/crates/channel/trogon-channel/src/safe_token.rs new file mode 100644 index 0000000000..2328ae1e88 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/safe_token.rs @@ -0,0 +1,78 @@ +//! 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)] +mod 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()) + } +} + +/// 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) + } +} + +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..2a9e6cf9be --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/safe_token/tests.rs @@ -0,0 +1,62 @@ +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") + ); + // 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] +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_eq!(err.classify(), serde_json::error::Category::Data, "{err}"); +} diff --git a/rsworkspace/crates/channel/trogon-channel/src/store.rs b/rsworkspace/crates/channel/trogon-channel/src/store.rs new file mode 100644 index 0000000000..a739f2f3b2 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/store.rs @@ -0,0 +1,395 @@ +use crate::conversation::{ConversationId, ConversationRecord}; +use crate::endpoint::{Endpoint, PrincipalId}; +use async_nats::jetstream; +use serde::{Deserialize, Serialize}; +use tracing::info; +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)] +mod 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, + #[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), + /// 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: 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 + /// 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: ReserveEndpointError, + #[source] + source: async_nats::jetstream::kv::DeleteError, + }, +} + +/// 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)] + 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), + /// 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 +/// [`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), +} + +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 { + pub display_name: Option, +} + +/// The four registries behind conversations, all JetStream KV, all owned by +/// 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 { + principals: jetstream::kv::Store, + endpoints: jetstream::kv::Store, + bindings: jetstream::kv::Store, + 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 { + 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"); + 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 { + pub async fn ensure(js: &jetstream::Context, prefix: &str) -> Result { + Ok(Self { + 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?, + }) + } + + /// 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, ChannelStoreError> { + 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<(), ChannelStoreError> { + 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, ChannelStoreError> { + Ok(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, BoundConversationError> { + let Some(bytes) = binding else { + return Ok(None); + }; + let id: ConversationId = serde_json::from_slice(bytes.as_ref())?; + match self.conversations.get(id.as_str()).await? { + Some(bytes) => Ok(Some((id, 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. + /// + /// 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. + /// + /// 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 { + 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 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), + }; + + // 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 (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 { + 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 = %id, + "The endpoint's claim leads to no conversation record; taking it over" + ); + 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), + } + } + + /// 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 + /// 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, + source: refused, + }, + Err(source) => ChannelStoreError::OrphanedConversation { + endpoint: endpoint.kv_key(), + conversation, + bind_error: refused, + source, + }, + } + } + + /// Update a conversation record in place (session replacement, activity). + pub async fn update_conversation( + &self, + id: &ConversationId, + record: &ConversationRecord, + ) -> Result<(), ChannelStoreError> { + self.conversations + .put(id.as_str(), serde_json::to_vec(record)?.into()) + .await?; + Ok(()) + } +} 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..3388158ad9 --- /dev/null +++ b/rsworkspace/crates/channel/trogon-channel/src/store/tests.rs @@ -0,0 +1,726 @@ +use super::*; +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") +} + +fn principal(id: &str) -> PrincipalId { + PrincipalId::new(id).expect("principal") +} + +fn record(principal: &PrincipalId) -> ConversationRecord { + ConversationRecord { + principal: principal.clone(), + agent_id: AgentId::new("default").expect("agent id"), + current_session: None, + created_at: 1, + last_activity_at: 1, + } +} + +/// 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] +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 = 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"); + + 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").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" + ); + + // 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" + ); +} + +/// 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 +/// 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" + ); +} + +/// 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" + ); +} + +/// 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 +/// 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:?}" + ); +} + +/// 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:?}" + ); +} 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-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/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 e47bfb637a..12800fcad0 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::{ + ClaimBucket, ClaimCheckPublisher, ClaimRetention, MaxPayload, NatsJetStreamClient, NatsObjectStore, +}; #[cfg(not(coverage))] use trogon_nats::{connect, wait_for_server_info}; #[cfg(not(coverage))] @@ -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, CLAIM_CHECK_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_CHECK_BUCKET.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/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_bucket.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs new file mode 100644 index 0000000000..7bcdd5e761 --- /dev/null +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket.rs @@ -0,0 +1,99 @@ +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); + +/// The characters a bucket name is drawn from. +fn is_permitted(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '-' | '_') +} + +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| !is_permitted(*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 the factory is fallible and this cannot be: + /// 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()) + } +} + +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 + } +} + +/// 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_bucket/tests.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs new file mode 100644 index 0000000000..a2c401006b --- /dev/null +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/claim_bucket/tests.rs @@ -0,0 +1,60 @@ +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('/') + ); + assert_eq!( + ClaimBucket::new("claims-ñ").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); +} + +/// 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"); +} 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..87a19d817d 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, ClaimBucketError, ClaimBucketHeader}; +use super::object_store::{ClaimBucketBinding, ObjectStoreGet, ObjectStorePut}; use super::publish::PublishOutcome; use super::traits::JetStreamPublisher; @@ -73,21 +74,100 @@ 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. 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: ClaimBucket, +} + +impl ClaimResolver { + pub fn new(binding: ClaimBucketBinding) -> Self { + let (store, bucket) = binding.into_parts(); + Self { store, bucket } + } + + pub fn bucket(&self) -> &ClaimBucket { + &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(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 + } +} + #[derive(Debug, thiserror::Error)] pub enum ClaimResolveError { #[error("claim message missing {} header", HEADER_CLAIM_KEY)] MissingKey, + /// 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 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. + #[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}")] 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, } @@ -96,17 +176,18 @@ 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), } } @@ -189,7 +270,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 316bd11a97..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 @@ -29,14 +29,20 @@ 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(); 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), ); @@ -62,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), ); @@ -104,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), ); @@ -128,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( @@ -170,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), ); @@ -194,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), ); @@ -258,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), ); @@ -286,3 +282,153 @@ 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(test_binding(MockObjectStore::new())); + 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(test_binding(MockObjectStore::new())); + 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(test_binding(store)); + + 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(test_binding(store)); + + 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(test_binding(store)); + 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" + )); +} + +/// 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. +#[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(test_binding(store)); + + 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(), + test_binding(store.clone()), + 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(test_binding(store)); + 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/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/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 bc3458df18..00cfd49d7e 100644 --- a/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs +++ b/rsworkspace/crates/platform/trogon-nats/src/jetstream/mod.rs @@ -1,9 +1,11 @@ +pub mod claim_bucket; pub mod claim_check; pub mod claim_retention; #[cfg(not(coverage))] 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; @@ -12,19 +14,23 @@ 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_bucket::{ClaimBucket, ClaimBucketError, ClaimBucketHeader}; +pub use claim_check::{ClaimCheckPublisher, ClaimResolveError, ClaimResolver, MaxPayload, is_claim, resolve_claim}; pub use claim_retention::ClaimRetention; #[cfg(not(coverage))] 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))] 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/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/jetstream/object_store.rs b/rsworkspace/crates/platform/trogon-nats/src/jetstream/object_store.rs index 354863d6e8..862b608f54 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,33 @@ 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) + } + + /// 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,6 +120,28 @@ impl NatsObjectStore { } } + /// 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.as_str()) + .await + .map_err(ProvisionObjectStoreError::Get)?; + Ok(ClaimBucketBinding { + store: Self { store }, + bucket, + }) + } + /// 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 @@ -103,24 +154,26 @@ 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: impl Into, + bucket: ClaimBucket, retention: super::claim_retention::ClaimRetention, - ) -> Result { - let bucket = bucket.into(); + ) -> Result, ProvisionObjectStoreError> { 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?; - Ok(store) + reconcile_bucket_max_age(js, bucket.as_str(), max_age).await?; + 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 db48ee9ab1..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 @@ -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()); +} 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..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 @@ -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 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_parts(), ("a-store-handle", bucket)); +} 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") } } diff --git a/rsworkspace/crates/platform/trogon-telemetry/src/service_name.rs b/rsworkspace/crates/platform/trogon-telemetry/src/service_name.rs index f53a837750..cb3dc975df 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, + ChannelBridgeTelegram, } impl ServiceName { @@ -30,6 +31,7 @@ impl ServiceName { Self::TrogonSourceLinear => "trogon-source-linear", Self::TrogonSourceSlack => "trogon-source-slack", Self::TrogonSourceTelegram => "trogon-source-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" + ); }