From 4a08a8d7ada068dbb59d1e045bc56db7e93135f6 Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Mon, 13 Apr 2026 13:14:10 -0300 Subject: [PATCH 1/4] refactor: rename spaces to channels --- .../2026-04-13-network-rename-hard-cut.md | 38 + docs/rfcs/003_agh-network-OLD.md | 96 +- docs/rfcs/003_agh-network-v0.md | 84 +- docs/rfcs/004_agh-network-v1.md | 16 +- internal/acp/handlers.go | 2 +- internal/api/contract/bridges.go | 189 + .../{channels_test.go => bridges_test.go} | 146 +- internal/api/contract/channels.go | 189 - internal/api/contract/contract.go | 36 +- internal/api/contract/responses.go | 6 +- internal/api/core/bridges.go | 278 + internal/api/core/bridges_test.go | 327 + internal/api/core/channels.go | 278 - internal/api/core/channels_test.go | 327 - internal/api/core/conversions.go | 20 +- internal/api/core/conversions_parsers_test.go | 4 +- internal/api/core/errors.go | 22 +- internal/api/core/errors_test.go | 26 +- internal/api/core/handlers.go | 14 +- internal/api/core/handlers_test.go | 2 +- internal/api/core/hooks_test.go | 8 +- internal/api/core/interfaces.go | 22 +- internal/api/core/network.go | 40 +- internal/api/core/network_test.go | 58 +- internal/api/core/test_helpers_test.go | 2 +- .../api/httpapi/bridges_integration_test.go | 400 + internal/api/httpapi/bridges_test.go | 131 + .../api/httpapi/channels_integration_test.go | 400 - internal/api/httpapi/channels_test.go | 131 - internal/api/httpapi/handlers.go | 4 +- internal/api/httpapi/handlers_test.go | 30 +- internal/api/httpapi/helpers_test.go | 18 +- .../api/httpapi/httpapi_integration_test.go | 108 +- internal/api/httpapi/routes.go | 26 +- internal/api/httpapi/server.go | 10 +- internal/api/spec/spec.go | 190 +- internal/api/spec/spec_test.go | 22 +- internal/api/testutil/apitest.go | 124 +- .../api/udsapi/bridges_integration_test.go | 90 + internal/api/udsapi/bridges_test.go | 125 + .../api/udsapi/channels_integration_test.go | 90 - internal/api/udsapi/channels_test.go | 125 - internal/api/udsapi/handlers_test.go | 30 +- internal/api/udsapi/helpers_test.go | 14 +- internal/api/udsapi/network_test.go | 4 +- internal/api/udsapi/routes.go | 22 +- internal/api/udsapi/server.go | 14 +- internal/api/udsapi/server_test.go | 8 +- .../api/udsapi/udsapi_integration_test.go | 72 +- .../{channels => bridges}/delivery_broker.go | 230 +- .../delivery_broker_test.go | 144 +- .../{channels => bridges}/delivery_metrics.go | 8 +- .../delivery_projection_test.go | 136 +- .../{channels => bridges}/delivery_types.go | 82 +- internal/{channels => bridges}/dimensions.go | 24 +- internal/bridges/doc.go | 3 + internal/{channels => bridges}/lifecycle.go | 62 +- internal/bridges/registry.go | 519 + .../registry_integration_test.go | 136 +- .../{channels => bridges}/registry_test.go | 470 +- internal/{channels => bridges}/routing.go | 132 +- internal/{channels => bridges}/target.go | 56 +- .../target_integration_test.go | 68 +- internal/{channels => bridges}/target_test.go | 84 +- internal/{channels => bridges}/types.go | 236 +- internal/{channels => bridges}/types_test.go | 134 +- internal/channels/doc.go | 3 - internal/channels/registry.go | 519 - internal/cli/{channel.go => bridge.go} | 200 +- internal/cli/bridge_test.go | 417 + internal/cli/channel_test.go | 417 - internal/cli/cli_integration_test.go | 206 +- internal/cli/client.go | 144 +- internal/cli/client_test.go | 178 +- internal/cli/command_paths_test.go | 32 +- internal/cli/daemon.go | 6 +- internal/cli/helpers_test.go | 100 +- internal/cli/network.go | 74 +- internal/cli/network_client_test.go | 32 +- internal/cli/network_test.go | 32 +- internal/cli/root.go | 2 +- internal/cli/session.go | 20 +- internal/cli/session_test.go | 24 +- internal/cli/skill_test.go | 2 +- internal/config/config.go | 40 +- internal/config/config_test.go | 16 +- internal/config/merge.go | 18 +- internal/config/merge_test.go | 6 +- internal/daemon/boot.go | 40 +- internal/daemon/bridges.go | 428 + .../{channels_test.go => bridges_test.go} | 404 +- internal/daemon/channels.go | 428 - internal/daemon/daemon.go | 158 +- internal/daemon/daemon_integration_test.go | 316 +- internal/daemon/daemon_test.go | 76 +- ...go => bridge_delivery_integration_test.go} | 140 +- ...otifier.go => bridge_delivery_notifier.go} | 32 +- ...st.go => bridge_delivery_notifier_test.go} | 136 +- internal/extension/capability.go | 60 +- internal/extension/capability_test.go | 20 +- internal/extension/contract/host_api.go | 98 +- internal/extension/contract/sdk.go | 36 +- internal/extension/describe_test.go | 4 +- internal/extension/host_api.go | 176 +- internal/extension/host_api_bridges.go | 826 + internal/extension/host_api_channels.go | 826 - .../extension/host_api_integration_test.go | 122 +- internal/extension/host_api_test.go | 398 +- internal/extension/manager.go | 172 +- .../extension/manager_integration_test.go | 92 +- internal/extension/manager_test.go | 132 +- internal/extension/protocol/host_api.go | 82 +- internal/extension/protocol/host_api_test.go | 6 +- .../telegram_reference_integration_test.go | 100 +- ...r_harness.go => bridge_adapter_harness.go} | 202 +- ...ridge_adapter_harness_integration_test.go} | 42 +- ...test.go => bridge_adapter_harness_test.go} | 138 +- internal/network/audit.go | 2 +- internal/network/audit_test.go | 2 +- internal/network/delivery.go | 30 +- internal/network/delivery_test.go | 10 +- internal/network/envelope.go | 6 +- internal/network/envelope_integration_test.go | 16 +- internal/network/helpers_test.go | 16 +- internal/network/lifecycle.go | 18 +- internal/network/lifecycle_test.go | 40 +- internal/network/manager.go | 120 +- internal/network/manager_test.go | 136 +- internal/network/peer.go | 184 +- internal/network/peer_test.go | 20 +- internal/network/router.go | 50 +- internal/network/router_integration_test.go | 14 +- internal/network/router_test.go | 34 +- internal/network/transport.go | 18 +- .../network/transport_integration_test.go | 2 +- internal/network/transport_test.go | 18 +- internal/network/validate.go | 20 +- internal/network/validate_test.go | 30 +- internal/observe/bridges.go | 279 + internal/observe/bridges_test.go | 326 + internal/observe/channels.go | 279 - internal/observe/channels_test.go | 326 - internal/observe/health.go | 20 +- internal/observe/observer.go | 12 +- internal/observe/observer_test.go | 24 +- internal/observe/reconcile.go | 2 +- internal/session/interfaces.go | 4 +- internal/session/manager.go | 2 +- internal/session/manager_helpers.go | 10 +- internal/session/manager_hooks_test.go | 14 +- internal/session/manager_integration_test.go | 4 +- internal/session/manager_lifecycle.go | 4 +- internal/session/manager_network_skill.go | 4 +- internal/session/manager_start.go | 18 +- internal/session/manager_test.go | 82 +- internal/session/query.go | 2 +- internal/session/query_test.go | 20 +- internal/session/session.go | 8 +- internal/skills/bundled/bundled_test.go | 4 +- .../bundled/skills/agh-network/SKILL.md | 32 +- internal/store/globaldb/global_db.go | 30 +- internal/store/globaldb/global_db_bridge.go | 840 + .../global_db_bridges_integration_test.go | 222 + .../store/globaldb/global_db_bridges_test.go | 451 + internal/store/globaldb/global_db_channel.go | 840 - .../global_db_channels_integration_test.go | 222 - .../store/globaldb/global_db_channels_test.go | 451 - .../store/globaldb/global_db_network_audit.go | 10 +- .../globaldb/global_db_network_audit_test.go | 24 +- internal/store/globaldb/global_db_session.go | 14 +- .../store/globaldb/global_db_session_test.go | 8 +- internal/store/globaldb/global_db_test.go | 20 +- internal/store/globaldb/migrate_workspace.go | 16 +- internal/store/meta_test.go | 4 +- internal/store/types.go | 10 +- internal/subprocess/handshake.go | 66 +- internal/subprocess/process_test.go | 8 +- openapi/agh.json | 22450 ++++++++-------- sdk/examples/telegram-reference/README.md | 40 +- .../telegram-reference/extension.toml | 30 +- sdk/examples/telegram-reference/main.go | 134 +- sdk/examples/telegram-reference/main_test.go | 160 +- sdk/typescript/src/extension.test.ts | 32 +- sdk/typescript/src/extension.ts | 2 +- sdk/typescript/src/generated/contracts.ts | 80 +- sdk/typescript/src/host-api.test.ts | 18 +- sdk/typescript/src/host-api.ts | 22 +- sdk/typescript/src/index.ts | 14 +- web/src/generated/agh-openapi.d.ts | 178 +- .../daemon/adapters/daemon-api.test.ts | 2 +- .../daemon/hooks/use-daemon-health.test.ts | 2 +- web/src/systems/daemon/types.test.ts | 2 +- 192 files changed, 21303 insertions(+), 22481 deletions(-) create mode 100644 .codex/plans/2026-04-13-network-rename-hard-cut.md create mode 100644 internal/api/contract/bridges.go rename internal/api/contract/{channels_test.go => bridges_test.go} (55%) delete mode 100644 internal/api/contract/channels.go create mode 100644 internal/api/core/bridges.go create mode 100644 internal/api/core/bridges_test.go delete mode 100644 internal/api/core/channels.go delete mode 100644 internal/api/core/channels_test.go create mode 100644 internal/api/httpapi/bridges_integration_test.go create mode 100644 internal/api/httpapi/bridges_test.go delete mode 100644 internal/api/httpapi/channels_integration_test.go delete mode 100644 internal/api/httpapi/channels_test.go create mode 100644 internal/api/udsapi/bridges_integration_test.go create mode 100644 internal/api/udsapi/bridges_test.go delete mode 100644 internal/api/udsapi/channels_integration_test.go delete mode 100644 internal/api/udsapi/channels_test.go rename internal/{channels => bridges}/delivery_broker.go (80%) rename internal/{channels => bridges}/delivery_broker_test.go (78%) rename internal/{channels => bridges}/delivery_metrics.go (71%) rename internal/{channels => bridges}/delivery_projection_test.go (86%) rename internal/{channels => bridges}/delivery_types.go (79%) rename internal/{channels => bridges}/dimensions.go (80%) create mode 100644 internal/bridges/doc.go rename internal/{channels => bridges}/lifecycle.go (52%) create mode 100644 internal/bridges/registry.go rename internal/{channels => bridges}/registry_integration_test.go (53%) rename internal/{channels => bridges}/registry_test.go (52%) rename internal/{channels => bridges}/routing.go (57%) rename internal/{channels => bridges}/target.go (71%) rename internal/{channels => bridges}/target_integration_test.go (54%) rename internal/{channels => bridges}/target_test.go (56%) rename internal/{channels => bridges}/types.go (57%) rename internal/{channels => bridges}/types_test.go (69%) delete mode 100644 internal/channels/doc.go delete mode 100644 internal/channels/registry.go rename internal/cli/{channel.go => bridge.go} (67%) create mode 100644 internal/cli/bridge_test.go delete mode 100644 internal/cli/channel_test.go create mode 100644 internal/daemon/bridges.go rename internal/daemon/{channels_test.go => bridges_test.go} (55%) delete mode 100644 internal/daemon/channels.go rename internal/extension/{channel_delivery_integration_test.go => bridge_delivery_integration_test.go} (78%) rename internal/extension/{channel_delivery_notifier.go => bridge_delivery_notifier.go} (58%) rename internal/extension/{channel_delivery_notifier_test.go => bridge_delivery_notifier_test.go} (67%) create mode 100644 internal/extension/host_api_bridges.go delete mode 100644 internal/extension/host_api_channels.go rename internal/extensiontest/{channel_adapter_harness.go => bridge_adapter_harness.go} (79%) rename internal/extensiontest/{channel_adapter_harness_integration_test.go => bridge_adapter_harness_integration_test.go} (83%) rename internal/extensiontest/{channel_adapter_harness_test.go => bridge_adapter_harness_test.go} (57%) create mode 100644 internal/observe/bridges.go create mode 100644 internal/observe/bridges_test.go delete mode 100644 internal/observe/channels.go delete mode 100644 internal/observe/channels_test.go create mode 100644 internal/store/globaldb/global_db_bridge.go create mode 100644 internal/store/globaldb/global_db_bridges_integration_test.go create mode 100644 internal/store/globaldb/global_db_bridges_test.go delete mode 100644 internal/store/globaldb/global_db_channel.go delete mode 100644 internal/store/globaldb/global_db_channels_integration_test.go delete mode 100644 internal/store/globaldb/global_db_channels_test.go diff --git a/.codex/plans/2026-04-13-network-rename-hard-cut.md b/.codex/plans/2026-04-13-network-rename-hard-cut.md new file mode 100644 index 000000000..91cb8382f --- /dev/null +++ b/.codex/plans/2026-04-13-network-rename-hard-cut.md @@ -0,0 +1,38 @@ +# Hard-Cut Rename: Bridges + Network Channels + +## Summary + +- Execute a rename in two ordered passes: first free the term `channel` by renaming the existing external messaging subsystem from `channel` to `bridge`; only then rename AGH Network `space` to `channel`. +- Treat this as a hard cut across code, storage, APIs, CLI, extension protocol, specs, and RFCs. Do not ship aliases, dual JSON fields, alternate CLI flags, deprecated paths, schema fallbacks, or old/new namespaces side by side. +- Keep `internal/network` as the package name. Rename the namespace concept inside it to `channel`. Rename the external adapter subsystem package from `internal/channels` to `internal/bridges`. + +## Implementation Changes + +- Phase 1: rename the external adapter domain from `channel` to `bridge` in `internal/channels -> internal/bridges`, daemon wiring, observability, API handlers, CLI client/commands, OpenAPI, codegen, tests, docs, and `.compozy/tasks/channel-adapters` artifacts. Use `Bridge`, `BridgeStatus`, `BridgeRoute`, `BridgeSecretBinding`, `BridgeDeliveryMetrics`, `BridgeService`, `bridgeRuntime`, and `bridgepkg`. +- Phase 1 public namespace changes: CLI `agh channel ...` becomes `agh bridge ...`; HTTP/UDS `/api/channels...` becomes `/api/bridges...`; OpenAPI tag `channels` becomes `bridges`; capability `channel.adapter` becomes `bridge.adapter`; host methods `channels/messages/ingest`, `channels/instances/get`, `channels/instances/report_state`, and extension service `channels/deliver` become `bridges/messages/ingest`, `bridges/instances/get`, `bridges/instances/report_state`, and `bridges/deliver`; vault/test fixture prefixes `vault://channels/...` become `vault://bridges/...`. +- Phase 1 storage changes: rename `channel_instances`, `channel_secret_bindings`, `channel_routes`, and `channel_ingest_dedup` to `bridge_instances`, `bridge_secret_bindings`, `bridge_routes`, and `bridge_ingest_dedup`. Rewrite schema builders/assertions to the final names only; do not add migration code for the old table names. +- Phase 2: rename AGH Network `space` to `channel` across `internal/network`, session opt-in, config, store, API contract, CLI, prompt wrappers, bundled skill docs, and RFC examples. Inside `internal/network`, use `Channel`, `ChannelInfo`, `ValidateChannel`, `JoinChannel`, `LeaveChannel`, `ListChannels`, and NATS subjects `agh.network.v0..broadcast` / `agh.network.v0..peer.`. +- Phase 2 cross-package naming rule: inside `internal/network` use the bare noun `Channel`; outside `internal/network` use `NetworkChannel` for session/config/store structs where a bare `Channel` would be ambiguous. User-facing JSON, TOML, and CLI still use `channel`. +- Phase 2 public namespace changes: session create payload/flags `space` and `--space` become `channel` and `--channel`; session env `AGH_SESSION_SPACE` becomes `AGH_SESSION_CHANNEL`; network API `/api/network/spaces` becomes `/api/network/channels`; peer filter query `space` becomes `channel`; network send payload/response/envelope field `space` becomes `channel`; contract payloads `NetworkSpacePayload` / `NetworkSpacesResponse` become `NetworkChannelPayload` / `NetworkChannelsResponse`; config `network.default_space` becomes `network.default_channel`. +- Phase 2 storage changes: rename `sessions.space` to `sessions.channel`, `network_audit_log.space` to `network_audit_log.channel`, `store.NetworkAuditEntry.Space` to `Channel`, session metadata JSON `space` to `channel`, and related query/filter helpers/assertions. Rewrite current schema helpers and migration helpers to the final column names only; do not preserve `space` fallback paths. +- Phase 3: update specs and docs in place so the repo has one vocabulary everywhere. Rewrite `docs/rfcs/003_agh-network-v0.md` and `docs/rfcs/004_agh-network-v1.md` to use `channel`, update subject mappings, examples, and diagrams, keep the v0/v1 relationship text consistent after the rename, rename channel-adapter techspec and ADR docs to bridge-adapter naming, regenerate API spec/codegen outputs, and update help text and prompt wrappers. + +## Public APIs / Interfaces / Types + +- CLI: `agh bridge {list|get|create|update|enable|disable|restart|routes|test-delivery}` replaces `agh channel ...`; `agh network channels` replaces `agh network spaces`; `agh network send --channel`; `agh session new --channel`. +- HTTP/UDS API: `/api/bridges...` replaces `/api/channels...`; `/api/network/channels` replaces `/api/network/spaces`; request/response fields `space` become `channel` in session creation, network send, network peers, network inbox, and surfaced envelopes. +- Extension protocol: provide capability `bridge.adapter`; host methods under `bridges/*`; extension service `bridges/deliver`; runtime init payload exposes bridge instance data and bound secrets under bridge naming. +- Config, env, and schema: `network.default_channel` replaces `network.default_space`; `AGH_SESSION_CHANNEL` replaces `AGH_SESSION_SPACE`; session meta and DB schema use `channel`; bridge DB schema uses `bridge_*` names only. + +## Test Plan + +- Rename and update all unit and integration tests that assert old nouns in CLI paths, API routes, JSON field names, NATS subjects, env vars, DB table or column names, OpenAPI tags, and extension protocol method names. +- Add explicit regression checks that the old surfaces are gone: no `/api/channels`, no `/api/network/spaces`, no `--space`, no `AGH_SESSION_SPACE`, no `channel.adapter`, no `channels/*`, no `sessions.space`, no `network_audit_log.space`, and no `channel_*` bridge tables. +- Keep the behavioral coverage intact for bridge lifecycle, route resolution, delivery target resolution, bridge runtime reload and secret binding, network join/leave/list/send/inbox, prompt wrappers, session startup env injection, audit logging, and OpenAPI/codegen snapshots. +- Final verification gate for the implementation turn: `make verify` must pass. + +## Assumptions + +- Historical memory ledgers may remain as historical artifacts; current product, docs, and spec surfaces must use the new vocabulary only. +- Because this is unreleased alpha, the rename is applied in place under the current API and protocol roots instead of adding compatibility shims or dual-version support. +- The web app does not currently consume the bridge or network surfaces at runtime beyond generated contract artifacts, so regenerating API spec and codegen is sufficient unless a new consumer appears during implementation. diff --git a/docs/rfcs/003_agh-network-OLD.md b/docs/rfcs/003_agh-network-OLD.md index 1df4a83ef..c8577b8d8 100644 --- a/docs/rfcs/003_agh-network-OLD.md +++ b/docs/rfcs/003_agh-network-OLD.md @@ -102,17 +102,17 @@ The layered design in this RFC is the approved answer: A `Peer` is any implementation that can emit, receive, or both emit and receive `AGH Network` envelopes. -### 3.2 Space +### 3.2 Channel -A `Space` is a logical communication namespace. Spaces are protocol-visible but transport-neutral. A transport profile decides how spaces map to transport primitives. +A `Channel` is a logical communication namechannel. Channels are protocol-visible but transport-neutral. A transport profile decides how channels map to transport primitives. -A `space` value MUST match `[a-z0-9][a-z0-9_-]{0,63}`. Characters outside this set — including dots, whitespace, and NATS wildcard tokens (`>`, `*`) — are forbidden because space values are interpolated directly into transport subjects. +A `channel` value MUST match `[a-z0-9][a-z0-9_-]{0,63}`. Characters outside this set — including dots, whitespace, and NATS wildcard tokens (`>`, `*`) — are forbidden because channel values are interpolated directly into transport subjects. ### 3.3 Interaction An `Interaction` is the lightweight logical container for work or conversation progression. It is identified by `interaction_id` and may move through a small lifecycle. -An interaction is scoped to the tuple `(space, interaction_id)`. The same `interaction_id` string in different spaces denotes different interactions. Only the two original peers — the initiator who sent the first `direct` and the target identified in `to` — MAY emit lifecycle messages (`receipt`, `trace`, `direct`) for that interaction. Messages from other peers referencing an `interaction_id` they did not initiate or were not targeted by SHOULD be ignored. +An interaction is scoped to the tuple `(channel, interaction_id)`. The same `interaction_id` string in different channels denotes different interactions. Only the two original peers — the initiator who sent the first `direct` and the target identified in `to` — MAY emit lifecycle messages (`receipt`, `trace`, `direct`) for that interaction. Messages from other peers referencing an `interaction_id` they did not initiate or were not targeted by SHOULD be ignored. ### 3.4 Recipe @@ -258,7 +258,7 @@ A `Core Receiver` MUST: - honor expiration semantics - tolerate duplicate delivery semantics at the application level - surface trust state as `verified`, `unverified`, or `rejected` -- ignore unknown extension namespaces rather than failing the whole message +- ignore unknown extension namechannels rather than failing the whole message ### 5.3 Core Peer @@ -298,7 +298,7 @@ Every message is a single envelope carrying protocol semantics independent of tr | `protocol` | string | yes | MUST be `agh-network/v1` | | `id` | string | yes | collision-resistant message identifier | | `kind` | string | yes | one of the normative kinds defined by this RFC | -| `space` | string | yes | logical namespace | +| `channel` | string | yes | logical namechannel | | `from` | string | yes | claimed sender identity | | `to` | string or null | no | target peer for directed communication | | `interaction_id` | string or null | no | logical interaction identifier | @@ -309,7 +309,7 @@ Every message is a single envelope carrying protocol semantics independent of tr | `expires_at` | integer or null | no | sender-declared TTL boundary | | `body` | object | yes | kind-specific payload | | `proof` | object or null | no | trust-profile-specific proof object | -| `ext` | object | no | extension namespace map | +| `ext` | object | no | extension namechannel map | #### 6.1.2 Field requirements by kind @@ -321,7 +321,7 @@ Every message is a single envelope carrying protocol semantics independent of tr #### 6.1.3 Extension model -`ext` keys MUST be namespaced strings. Reverse-DNS style names are RECOMMENDED, for example: +`ext` keys MUST be namechanneld strings. Reverse-DNS style names are RECOMMENDED, for example: - `io.agh.runtime` - `dev.example.sandbox` @@ -336,7 +336,7 @@ When a receiver processes a core envelope it MUST, in this order: 2. Reject malformed messages 3. Evaluate expiration if `expires_at` is present 4. Evaluate trust state if `proof` is present -5. Route based on `kind`, `space`, and `to` +5. Route based on `kind`, `channel`, and `to` 6. Apply lifecycle semantics if `interaction_id` is present 7. Apply extension-specific handling only after successful core validation @@ -361,7 +361,7 @@ flowchart TD TrustOk -->|Yes| Verified[Trust state = verified] TrustOk -->|No| Rejected[Trust state = rejected] - Verified --> Route[Route by kind + space + to] + Verified --> Route[Route by kind + channel + to] Unverified --> Route Rejected --> Stop([Reject / stop processing]) @@ -424,7 +424,7 @@ The core does not define: ### 7.4 Capability semantics -Capabilities are opaque strings defined by implementations or future profiles. Namespaced strings are RECOMMENDED, for example: +Capabilities are opaque strings defined by implementations or future profiles. Namechanneld strings are RECOMMENDED, for example: - `chat.translate` - `artifact.recipe.consume` @@ -559,7 +559,7 @@ The normative core kinds are: ```mermaid sequenceDiagram participant A as Peer A - participant S as Space + participant S as Channel participant B as Peer B A->>S: greet @@ -577,7 +577,7 @@ sequenceDiagram ### 9.2 `greet` -`greet` advertises peer presence and capabilities to a space. +`greet` advertises peer presence and capabilities to a channel. #### Body @@ -621,11 +621,11 @@ sequenceDiagram - `type` is REQUIRED and MUST be either `request` or `response` - a response `whois` MUST set `reply_to` - targeted lookup SHOULD set `to` -- untargeted lookup MAY be broadcast within a space +- untargeted lookup MAY be broadcast within a channel ### 9.4 `say` -`say` is chat-first, space-scoped communication. +`say` is chat-first, channel-scoped communication. #### Body @@ -639,7 +639,7 @@ sequenceDiagram #### Rules -- `say` SHOULD be used for space-visible communication +- `say` SHOULD be used for channel-visible communication - `to` SHOULD be null - `interaction_id` MAY be absent @@ -665,14 +665,14 @@ sequenceDiagram #### Example -The envelope below shows a peer opening a targeted handoff after seeing a space-visible request. +The envelope below shows a peer opening a targeted handoff after seeing a channel-visible request. ```json { "protocol": "agh-network/v1", "id": "msg_direct_01", "kind": "direct", - "space": "builders", + "channel": "builders", "from": "patch-worker@39f713d0a644253f04529421b9f51b9b", "to": "ops-coordinator", "interaction_id": "int_patch_42", @@ -732,14 +732,14 @@ The envelope below shows a peer opening a targeted handoff after seeing a space- #### Example -The envelope below shows a portable recipe advertised to a space without implying any execution contract. +The envelope below shows a portable recipe advertised to a channel without implying any execution contract. ```json { "protocol": "agh-network/v1", "id": "msg_recipe_01", "kind": "recipe", - "space": "builders", + "channel": "builders", "from": "recipe-curator@23d80081d9366bf46cc350aae99f6aa1", "to": null, "interaction_id": null, @@ -879,7 +879,7 @@ The core defines this initial reason-code registry: - `internal` - `interaction_closed` -Implementations MAY define namespaced reason codes under `ext`. +Implementations MAY define namechanneld reason codes under `ext`. --- @@ -906,10 +906,10 @@ The default route token is: ### 11.4 Subject mapping -| Core intent | NATS subject | -| -------------------- | ------------------------------------------- | -| Broadcast to a space | `agh.network.v1..broadcast` | -| Direct to a peer | `agh.network.v1..peer.` | +| Core intent | NATS subject | +| ---------------------- | --------------------------------------------- | +| Broadcast to a channel | `agh.network.v1..broadcast` | +| Direct to a peer | `agh.network.v1..peer.` | ```mermaid sequenceDiagram @@ -918,7 +918,7 @@ sequenceDiagram participant B as Peer B participant C as Peer C - Note over A,NATS: example space = builders + Note over A,NATS: example channel = builders A->>NATS: PUB agh.network.v1.builders.broadcast NATS-->>B: deliver greet NATS-->>C: deliver greet @@ -934,8 +934,8 @@ sequenceDiagram A `NATS Peer` MUST subscribe to: -- `agh.network.v1..broadcast` for each joined space -- its own direct subject for each joined space +- `agh.network.v1..broadcast` for each joined channel +- its own direct subject for each joined channel ### 11.6 Sending rules @@ -1091,18 +1091,18 @@ This appendix is informative and non-normative. It shows how the core message ki Where a baseline trust profile proof is shown (Section 12), `proof.pubkey` and `proof.key_id` are consistent with the `from` handle (`nickname@fingerprint`). The `proof.sig` values are **illustrative placeholders** only; a real sender MUST compute Ed25519 over the JCS-canonical envelope bytes with `proof.sig` omitted (Section 12.6). -### A.1 Space request followed by direct handoff +### A.1 Channel request followed by direct handoff -In this scenario, a coordinator asks for help in a shared space. A worker answers by opening a targeted interaction and later reports progress and completion through `trace`. +In this scenario, a coordinator asks for help in a shared channel. A worker answers by opening a targeted interaction and later reports progress and completion through `trace`. ```mermaid sequenceDiagram participant Ops as ops-coordinator - participant Space as builders + participant Channel as builders participant Patch as patch-worker - Ops->>Space: say("Who can take the failing migration tests?") - Space-->>Patch: say + Ops->>Channel: say("Who can take the failing migration tests?") + Channel-->>Patch: say Patch->>Ops: direct("I can take this") Ops-->>Patch: receipt(accepted) Patch-->>Ops: trace(working) @@ -1111,14 +1111,14 @@ sequenceDiagram Selected envelopes: -1. Initial space-visible request: +1. Initial channel-visible request: ```json { "protocol": "agh-network/v1", "id": "msg_say_01", "kind": "say", - "space": "builders", + "channel": "builders", "from": "ops-coordinator", "to": null, "interaction_id": null, @@ -1144,7 +1144,7 @@ Selected envelopes: "protocol": "agh-network/v1", "id": "msg_direct_01", "kind": "direct", - "space": "builders", + "channel": "builders", "from": "patch-worker@39f713d0a644253f04529421b9f51b9b", "to": "ops-coordinator", "interaction_id": "int_patch_42", @@ -1176,7 +1176,7 @@ Selected envelopes: "protocol": "agh-network/v1", "id": "msg_receipt_01", "kind": "receipt", - "space": "builders", + "channel": "builders", "from": "ops-coordinator", "to": "patch-worker", "interaction_id": "int_patch_42", @@ -1203,7 +1203,7 @@ Selected envelopes: "protocol": "agh-network/v1", "id": "msg_trace_02", "kind": "trace", - "space": "builders", + "channel": "builders", "from": "patch-worker@39f713d0a644253f04529421b9f51b9b", "to": "ops-coordinator", "interaction_id": "int_patch_42", @@ -1231,20 +1231,20 @@ Selected envelopes: } ``` -This example illustrates the intended split between space-scoped discovery of available help and peer-to-peer interaction management once work is actually handed off. Messages (1) and (3) omit `proof` (`unverified`); the worker messages (2) and (4) include baseline proofs (`verified` when validated). +This example illustrates the intended split between channel-scoped discovery of available help and peer-to-peer interaction management once work is actually handed off. Messages (1) and (3) omit `proof` (`unverified`); the worker messages (2) and (4) include baseline proofs (`verified` when validated). ### A.2 Recipe advertisement followed by direct follow-up -In this scenario, a peer advertises a reusable recipe to a space. Another peer then opens a direct interaction to request help applying that recipe in a concrete repository context. +In this scenario, a peer advertises a reusable recipe to a channel. Another peer then opens a direct interaction to request help applying that recipe in a concrete repository context. ```mermaid sequenceDiagram participant Curator as recipe-curator - participant Space as builders + participant Channel as builders participant Release as release-bot - Curator->>Space: recipe("fix-go-migration-tests") - Space-->>Release: recipe + Curator->>Channel: recipe("fix-go-migration-tests") + Channel-->>Release: recipe Release->>Curator: direct("Can you adapt this recipe to my repo?") Curator-->>Release: receipt(accepted) Curator-->>Release: trace(needs_input) @@ -1253,14 +1253,14 @@ sequenceDiagram Selected envelopes: -1. Space-visible recipe advertisement: +1. Channel-visible recipe advertisement: ```json { "protocol": "agh-network/v1", "id": "msg_recipe_01", "kind": "recipe", - "space": "builders", + "channel": "builders", "from": "recipe-curator@23d80081d9366bf46cc350aae99f6aa1", "to": null, "interaction_id": null, @@ -1301,7 +1301,7 @@ Selected envelopes: "protocol": "agh-network/v1", "id": "msg_direct_20", "kind": "direct", - "space": "builders", + "channel": "builders", "from": "release-bot", "to": "recipe-curator@23d80081d9366bf46cc350aae99f6aa1", "interaction_id": "int_recipe_apply_7", @@ -1327,7 +1327,7 @@ Selected envelopes: "protocol": "agh-network/v1", "id": "msg_trace_21", "kind": "trace", - "space": "builders", + "channel": "builders", "from": "recipe-curator@23d80081d9366bf46cc350aae99f6aa1", "to": "release-bot", "interaction_id": "int_recipe_apply_7", @@ -1364,7 +1364,7 @@ This envelope is a self-contained reference for **verified-mode** shape: `from` "protocol": "agh-network/v1", "id": "msg_verified_say_01", "kind": "say", - "space": "builders", + "channel": "builders", "from": "patch-worker@39f713d0a644253f04529421b9f51b9b", "to": null, "interaction_id": null, diff --git a/docs/rfcs/003_agh-network-v0.md b/docs/rfcs/003_agh-network-v0.md index 893a641ec..39c8c2a27 100644 --- a/docs/rfcs/003_agh-network-v0.md +++ b/docs/rfcs/003_agh-network-v0.md @@ -83,17 +83,17 @@ No wire format changes are required. A `Peer` is any implementation that can emit, receive, or both emit and receive `AGH Network` envelopes. -### 3.2 Space +### 3.2 Channel -A `Space` is a logical communication namespace. Spaces are protocol-visible but transport-neutral. A transport profile decides how spaces map to transport primitives. +A `Channel` is a logical communication namechannel. Channels are protocol-visible but transport-neutral. A transport profile decides how channels map to transport primitives. -A `space` value MUST match `[a-z0-9][a-z0-9_-]{0,63}`. Characters outside this set — including dots, whitespace, and NATS wildcard tokens (`>`, `*`) — are forbidden because space values are interpolated directly into transport subjects. +A `channel` value MUST match `[a-z0-9][a-z0-9_-]{0,63}`. Characters outside this set — including dots, whitespace, and NATS wildcard tokens (`>`, `*`) — are forbidden because channel values are interpolated directly into transport subjects. ### 3.3 Interaction An `Interaction` is the lightweight logical container for work or conversation progression. It is identified by `interaction_id` and may move through a small lifecycle. -An interaction is scoped to the tuple `(space, interaction_id)`. The same `interaction_id` string in different spaces denotes different interactions. Only the two original peers — the initiator who sent the first `direct` and the target identified in `to` — MAY emit lifecycle messages (`receipt`, `trace`, `direct`) for that interaction. Messages from other peers referencing an `interaction_id` they did not initiate or were not targeted by SHOULD be ignored. +An interaction is scoped to the tuple `(channel, interaction_id)`. The same `interaction_id` string in different channels denotes different interactions. Only the two original peers — the initiator who sent the first `direct` and the target identified in `to` — MAY emit lifecycle messages (`receipt`, `trace`, `direct`) for that interaction. Messages from other peers referencing an `interaction_id` they did not initiate or were not targeted by SHOULD be ignored. ### 3.4 Recipe @@ -187,7 +187,7 @@ Every message is a single envelope carrying protocol semantics independent of tr | `protocol` | string | yes | MUST be `agh-network/v0` | | `id` | string | yes | collision-resistant message identifier | | `kind` | string | yes | one of the normative kinds defined by this RFC | -| `space` | string | yes | logical namespace | +| `channel` | string | yes | logical namechannel | | `from` | string | yes | claimed sender identity | | `to` | string or null | no | target peer for directed communication | | `interaction_id` | string or null | no | logical interaction identifier | @@ -215,7 +215,7 @@ When a receiver processes a core envelope it MUST, in this order: 1. Validate required fields 2. Reject malformed messages 3. Evaluate expiration if `expires_at` is present -4. Route based on `kind`, `space`, and `to` +4. Route based on `kind`, `channel`, and `to` 5. Apply lifecycle semantics if `interaction_id` is present ```mermaid @@ -229,7 +229,7 @@ flowchart TD Exp -->|Yes| ExpCheck{Expired?} ExpCheck -->|Yes| Reject ExpCheck -->|No| Route - Exp -->|No| Route[Route by kind + space + to] + Exp -->|No| Route[Route by kind + channel + to] Route --> LC{interaction_id present?} LC -->|Yes| ApplyLC[Apply lifecycle semantics] @@ -252,7 +252,7 @@ In v0, short-prefix namespacing is RECOMMENDED but not enforced. The `agh.` pref } ``` -In v1, namespaced keys become a normative requirement (MUST). +In v1, namechanneld keys become a normative requirement (MUST). ### 5.4 Trust state in v0 @@ -300,7 +300,7 @@ The core does not define: ### 6.4 Capability semantics -Capabilities are opaque strings defined by implementations or future profiles. Namespaced strings are RECOMMENDED, for example: +Capabilities are opaque strings defined by implementations or future profiles. Namechanneld strings are RECOMMENDED, for example: - `chat.translate` - `artifact.recipe.consume` @@ -435,7 +435,7 @@ The normative core kinds are: ```mermaid sequenceDiagram participant A as Peer A - participant S as Space + participant S as Channel participant B as Peer B A->>S: greet @@ -453,7 +453,7 @@ sequenceDiagram ### 8.2 `greet` -`greet` advertises peer presence and capabilities to a space. +`greet` advertises peer presence and capabilities to a channel. #### Body @@ -497,11 +497,11 @@ sequenceDiagram - `type` is REQUIRED and MUST be either `request` or `response` - a response `whois` MUST set `reply_to` - targeted lookup SHOULD set `to` -- untargeted lookup MAY be broadcast within a space +- untargeted lookup MAY be broadcast within a channel ### 8.4 `say` -`say` is chat-first, space-scoped communication. +`say` is chat-first, channel-scoped communication. #### Body @@ -515,7 +515,7 @@ sequenceDiagram #### Rules -- `say` SHOULD be used for space-visible communication +- `say` SHOULD be used for channel-visible communication - `to` SHOULD be null - `interaction_id` MAY be absent @@ -546,7 +546,7 @@ sequenceDiagram "protocol": "agh-network/v0", "id": "msg_direct_01", "kind": "direct", - "space": "builders", + "channel": "builders", "from": "patch-worker", "to": "ops-coordinator", "interaction_id": "int_patch_42", @@ -704,7 +704,7 @@ The core defines this initial reason-code registry: - `internal` - `interaction_closed` -Implementations MAY define namespaced reason codes under `ext`. +Implementations MAY define namechanneld reason codes under `ext`. --- @@ -734,10 +734,10 @@ Implementations MUST support envelopes up to 1 MB (1,048,576 bytes) after JSON s ### 10.4 Subject mapping -| Core intent | NATS subject | -| -------------------- | ------------------------------------------- | -| Broadcast to a space | `agh.network.v0..broadcast` | -| Direct to a peer | `agh.network.v0..peer.` | +| Core intent | NATS subject | +| ---------------------- | --------------------------------------------- | +| Broadcast to a channel | `agh.network.v0..broadcast` | +| Direct to a peer | `agh.network.v0..peer.` | ```mermaid sequenceDiagram @@ -746,7 +746,7 @@ sequenceDiagram participant B as Peer B participant C as Peer C - Note over A,NATS: example space = builders + Note over A,NATS: example channel = builders A->>NATS: PUB agh.network.v0.builders.broadcast NATS-->>B: deliver greet NATS-->>C: deliver greet @@ -758,15 +758,15 @@ sequenceDiagram NATS-->>A: deliver trace ``` -### 10.5 Joining a space +### 10.5 Joining a channel -A peer joins a space by subscribing to the required NATS subjects and announcing its presence: +A peer joins a channel by subscribing to the required NATS subjects and announcing its presence: -1. Subscribe to `agh.network.v0..broadcast` -2. Subscribe to its own direct subject `agh.network.v0..peer.` +1. Subscribe to `agh.network.v0..broadcast` +2. Subscribe to its own direct subject `agh.network.v0..peer.` 3. SHOULD send a `greet` message to the broadcast subject -A peer SHOULD send `greet` upon joining a space. A peer SHOULD re-send `greet` after reconnecting to NATS following a connection loss. +A peer SHOULD send `greet` upon joining a channel. A peer SHOULD re-send `greet` after reconnecting to NATS following a connection loss. #### Presence through periodic greet @@ -863,30 +863,30 @@ The wire format is identical. The `protocol` field changes from `agh-network/v0` This appendix is informative and non-normative. -### 13.1 Space request followed by direct handoff +### 13.1 Channel request followed by direct handoff ```mermaid sequenceDiagram participant Ops as ops-coordinator - participant Space as builders + participant Channel as builders participant Patch as patch-worker - Ops->>Space: say("Who can take the failing migration tests?") - Space-->>Patch: say + Ops->>Channel: say("Who can take the failing migration tests?") + Channel-->>Patch: say Patch->>Ops: direct("I can take this") Ops-->>Patch: receipt(accepted) Patch-->>Ops: trace(working) Patch-->>Ops: trace(completed) ``` -1. Initial space-visible request: +1. Initial channel-visible request: ```json { "protocol": "agh-network/v0", "id": "msg_say_01", "kind": "say", - "space": "builders", + "channel": "builders", "from": "ops-coordinator", "to": null, "interaction_id": null, @@ -912,7 +912,7 @@ sequenceDiagram "protocol": "agh-network/v0", "id": "msg_direct_01", "kind": "direct", - "space": "builders", + "channel": "builders", "from": "patch-worker", "to": "ops-coordinator", "interaction_id": "int_patch_42", @@ -938,7 +938,7 @@ sequenceDiagram "protocol": "agh-network/v0", "id": "msg_receipt_01", "kind": "receipt", - "space": "builders", + "channel": "builders", "from": "ops-coordinator", "to": "patch-worker", "interaction_id": "int_patch_42", @@ -965,7 +965,7 @@ sequenceDiagram "protocol": "agh-network/v0", "id": "msg_trace_02", "kind": "trace", - "space": "builders", + "channel": "builders", "from": "patch-worker", "to": "ops-coordinator", "interaction_id": "int_patch_42", @@ -992,25 +992,25 @@ sequenceDiagram ```mermaid sequenceDiagram participant Curator as recipe-curator - participant Space as builders + participant Channel as builders participant Release as release-bot - Curator->>Space: recipe("fix-go-migration-tests") - Space-->>Release: recipe + Curator->>Channel: recipe("fix-go-migration-tests") + Channel-->>Release: recipe Release->>Curator: direct("Can you adapt this recipe to my repo?") Curator-->>Release: receipt(accepted) Curator-->>Release: trace(needs_input) Release->>Curator: direct("Here is the failing package path") ``` -1. Space-visible recipe advertisement: +1. Channel-visible recipe advertisement: ```json { "protocol": "agh-network/v0", "id": "msg_recipe_01", "kind": "recipe", - "space": "builders", + "channel": "builders", "from": "recipe-curator", "to": null, "interaction_id": null, @@ -1045,7 +1045,7 @@ sequenceDiagram "protocol": "agh-network/v0", "id": "msg_direct_20", "kind": "direct", - "space": "builders", + "channel": "builders", "from": "release-bot", "to": "recipe-curator", "interaction_id": "int_recipe_apply_7", @@ -1071,7 +1071,7 @@ sequenceDiagram "protocol": "agh-network/v0", "id": "msg_trace_21", "kind": "trace", - "space": "builders", + "channel": "builders", "from": "recipe-curator", "to": "release-bot", "interaction_id": "int_recipe_apply_7", diff --git a/docs/rfcs/004_agh-network-v1.md b/docs/rfcs/004_agh-network-v1.md index 769f96cab..08f206378 100644 --- a/docs/rfcs/004_agh-network-v1.md +++ b/docs/rfcs/004_agh-network-v1.md @@ -19,7 +19,7 @@ This RFC defines: 3. Verified sender identity format — self-certified `nickname@fingerprint` handles 4. Proof-stripping defense — verified-format identity without proof is `rejected` 5. Formal conformance levels — for third-party interoperability -6. Extension model processing — namespaced `ext` validation +6. Extension model processing — namechanneld `ext` validation 7. NATS request/reply correlation Everything defined in v0 (envelope, message kinds, lifecycle, delivery model) remains normative. v1 extends the NATS transport with a verified-peer routing rule and a new subject prefix (`agh.network.v1`). @@ -64,7 +64,7 @@ A `Core Receiver` MUST: - honor expiration semantics - tolerate duplicate delivery semantics at the application level - surface trust state as `verified`, `unverified`, or `rejected` -- ignore unknown extension namespaces rather than failing the whole message +- ignore unknown extension namechannels rather than failing the whole message ### 2.3 Core Peer @@ -103,7 +103,7 @@ When a receiver processes a core envelope it MUST, in this order: 2. Reject malformed messages 3. Evaluate expiration if `expires_at` is present 4. **Evaluate trust state: check `proof` if present, or check `from` format if `proof` is absent (see Section 3.3)** -5. Route based on `kind`, `space`, and `to` +5. Route based on `kind`, `channel`, and `to` 6. Apply lifecycle semantics if `interaction_id` is present 7. **Apply extension-specific handling only after successful core validation** @@ -130,7 +130,7 @@ flowchart TD TrustOk -->|Yes| Verified[Trust state = verified] TrustOk -->|No| Rejected - Verified --> Route[Route by kind + space + to] + Verified --> Route[Route by kind + channel + to] Unverified --> Route Rejected --> Stop([Reject / stop processing]) @@ -262,11 +262,11 @@ A `Verified Peer` MUST: ## 5. Extension Model Processing -In v0, the `ext` field is active with RECOMMENDED conventions: peers MAY read and act on known keys, MUST ignore unknown keys, and the `agh.` prefix is RECOMMENDED but not enforced. In v1, extension processing is normative and namespaced keys become a MUST requirement. +In v0, the `ext` field is active with RECOMMENDED conventions: peers MAY read and act on known keys, MUST ignore unknown keys, and the `agh.` prefix is RECOMMENDED but not enforced. In v1, extension processing is normative and namechanneld keys become a MUST requirement. ### 5.1 Extension keys -`ext` keys MUST be namespaced strings. Reverse-DNS style names are RECOMMENDED, for example: +`ext` keys MUST be namechanneld strings. Reverse-DNS style names are RECOMMENDED, for example: - `io.agh.runtime` - `dev.example.sandbox` @@ -287,7 +287,7 @@ When a peer is operating in baseline verified mode and its identity is a self-ce This means a verified peer's direct subject is: -`agh.network.v1..peer.` +`agh.network.v1..peer.` Where `` is the first 32 hex characters from the `from` field. @@ -340,7 +340,7 @@ This envelope shows verified-mode shape: `from` uses `nickname@fingerprint`, and "protocol": "agh-network/v1", "id": "msg_verified_say_01", "kind": "say", - "space": "builders", + "channel": "builders", "from": "patch-worker@39f713d0a644253f04529421b9f51b9b", "to": null, "interaction_id": null, diff --git a/internal/acp/handlers.go b/internal/acp/handlers.go index bb1e47f15..f47327280 100644 --- a/internal/acp/handlers.go +++ b/internal/acp/handlers.go @@ -681,7 +681,7 @@ func isAllowedNetworkTerminalArgv(argv []string) bool { } switch argv[2] { - case "send", "peers", "spaces", "status", "inbox": + case "send", "peers", "channels", "status", "inbox": return true default: return false diff --git a/internal/api/contract/bridges.go b/internal/api/contract/bridges.go new file mode 100644 index 000000000..8229200e5 --- /dev/null +++ b/internal/api/contract/bridges.go @@ -0,0 +1,189 @@ +package contract + +import ( + "encoding/json" + "strings" + "time" + + bridgepkg "github.com/pedronauck/agh/internal/bridges" +) + +// CreateBridgeRequest is the shared bridge-instance creation payload. +type CreateBridgeRequest struct { + Scope bridgepkg.Scope `json:"scope"` + WorkspaceID string `json:"workspace_id,omitempty"` + Platform string `json:"platform"` + ExtensionName string `json:"extension_name"` + DisplayName string `json:"display_name"` + Enabled bool `json:"enabled"` + Status bridgepkg.BridgeStatus `json:"status"` + RoutingPolicy bridgepkg.RoutingPolicy `json:"routing_policy"` + DeliveryDefaults json.RawMessage `json:"delivery_defaults,omitempty"` +} + +// ToCreateInstanceRequest validates and converts the transport payload into the +// daemon-owned bridge create request. +func (r CreateBridgeRequest) ToCreateInstanceRequest() (bridgepkg.CreateInstanceRequest, error) { + req := bridgepkg.CreateInstanceRequest{ + Scope: r.Scope, + WorkspaceID: strings.TrimSpace(r.WorkspaceID), + Platform: strings.TrimSpace(r.Platform), + ExtensionName: strings.TrimSpace(r.ExtensionName), + DisplayName: strings.TrimSpace(r.DisplayName), + Enabled: r.Enabled, + Status: r.Status, + RoutingPolicy: r.RoutingPolicy, + DeliveryDefaults: cloneRawMessage(r.DeliveryDefaults), + } + if err := req.Validate(); err != nil { + return bridgepkg.CreateInstanceRequest{}, err + } + return req, nil +} + +// UpdateBridgeRequest is the shared mutable bridge-instance patch payload. +type UpdateBridgeRequest struct { + DisplayName *string `json:"display_name,omitempty"` + RoutingPolicy *bridgepkg.RoutingPolicy `json:"routing_policy,omitempty"` + DeliveryDefaults *json.RawMessage `json:"delivery_defaults,omitempty"` +} + +// ToUpdateInstanceRequest validates and converts the transport patch payload +// into the daemon-owned bridge update request for the supplied instance id. +func (r UpdateBridgeRequest) ToUpdateInstanceRequest(id string) (bridgepkg.UpdateInstanceRequest, error) { + req := bridgepkg.UpdateInstanceRequest{ID: strings.TrimSpace(id)} + if r.DisplayName != nil { + value := strings.TrimSpace(*r.DisplayName) + req.DisplayName = &value + } + if r.RoutingPolicy != nil { + value := *r.RoutingPolicy + req.RoutingPolicy = &value + } + if r.DeliveryDefaults != nil { + value := cloneRawMessage(*r.DeliveryDefaults) + req.DeliveryDefaults = &value + } + if err := req.Validate(); err != nil { + return bridgepkg.UpdateInstanceRequest{}, err + } + return req, nil +} + +// BridgeDeliveryTargetInput is the shared typed delivery-target override payload. +type BridgeDeliveryTargetInput struct { + BridgeInstanceID string `json:"bridge_instance_id,omitempty"` + PeerID string `json:"peer_id,omitempty"` + ThreadID string `json:"thread_id,omitempty"` + GroupID string `json:"group_id,omitempty"` + Mode bridgepkg.DeliveryMode `json:"mode,omitempty"` +} + +// BridgeTestDeliveryRequest is the shared typed dry-run delivery payload. +type BridgeTestDeliveryRequest struct { + Message string `json:"message,omitempty"` + Target BridgeDeliveryTargetInput `json:"target"` +} + +// ToResolveDeliveryTargetRequest validates and converts the transport payload +// into the daemon-owned delivery-target resolution request for the supplied +// bridge instance id. +func (r BridgeTestDeliveryRequest) ToResolveDeliveryTargetRequest(bridgeInstanceID string) (bridgepkg.ResolveDeliveryTargetRequest, error) { + req := bridgepkg.ResolveDeliveryTargetRequest{ + BridgeInstanceID: strings.TrimSpace(r.Target.BridgeInstanceID), + PeerID: strings.TrimSpace(r.Target.PeerID), + ThreadID: strings.TrimSpace(r.Target.ThreadID), + GroupID: strings.TrimSpace(r.Target.GroupID), + Mode: r.Target.Mode.Normalize(), + } + req.BridgeInstanceID = strings.TrimSpace(req.BridgeInstanceID) + trimmedID := strings.TrimSpace(bridgeInstanceID) + if req.BridgeInstanceID == "" { + req.BridgeInstanceID = trimmedID + } + if req.BridgeInstanceID != trimmedID { + return bridgepkg.ResolveDeliveryTargetRequest{}, ErrBridgeInstanceMismatch + } + if err := req.Validate(); err != nil { + return bridgepkg.ResolveDeliveryTargetRequest{}, err + } + return req, nil +} + +// ErrBridgeInstanceMismatch reports a body/path bridge-instance mismatch for +// typed delivery-target requests. +var ErrBridgeInstanceMismatch = bridgeContractError("bridge instance id must match request path") + +type bridgeContractError string + +func (e bridgeContractError) Error() string { + return string(e) +} + +// BridgesResponse wraps the shared bridge list payload. +type BridgesResponse struct { + Bridges []bridgepkg.BridgeInstance `json:"bridges"` + BridgeHealth map[string]BridgeHealthPayload `json:"bridge_health,omitempty"` +} + +// BridgeResponse wraps one shared bridge payload. +type BridgeResponse struct { + Bridge bridgepkg.BridgeInstance `json:"bridge"` + Health BridgeHealthPayload `json:"health"` +} + +// BridgeRoutesResponse wraps one bridge's route set. +type BridgeRoutesResponse struct { + Routes []bridgepkg.BridgeRoute `json:"routes"` +} + +// BridgeTestDeliveryResponse wraps the dry-run delivery-target resolution payload. +type BridgeTestDeliveryResponse struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + DeliveryTarget bridgepkg.DeliveryTarget `json:"delivery_target"` +} + +// BridgeHealthPayload captures the additive per-instance observability fields +// exposed through bridge APIs. +type BridgeHealthPayload struct { + BridgeInstanceID string `json:"bridge_instance_id"` + Status bridgepkg.BridgeStatus `json:"status"` + RouteCount int `json:"route_count"` + DeliveryBacklog int `json:"delivery_backlog"` + DeliveryDroppedTotal int `json:"delivery_dropped_total"` + DeliveryDroppedByReason map[string]int `json:"delivery_dropped_by_reason,omitempty"` + DeliveryFailuresTotal int `json:"delivery_failures_total"` + AuthFailuresTotal int `json:"auth_failures_total"` + LastError string `json:"last_error,omitempty"` + LastErrorAt *time.Time `json:"last_error_at,omitempty"` +} + +// BridgeStatusCountsPayload captures aggregate per-status counts for bridge health. +type BridgeStatusCountsPayload struct { + Disabled int `json:"disabled"` + Starting int `json:"starting"` + Ready int `json:"ready"` + Degraded int `json:"degraded"` + AuthRequired int `json:"auth_required"` + Error int `json:"error"` +} + +// BridgeAggregateHealthPayload captures the additive bridge summary nested +// under the daemon health response. +type BridgeAggregateHealthPayload struct { + TotalInstances int `json:"total_instances"` + RouteCount int `json:"route_count"` + DeliveryBacklog int `json:"delivery_backlog"` + DeliveryDroppedTotal int `json:"delivery_dropped_total"` + DeliveryFailuresTotal int `json:"delivery_failures_total"` + AuthFailuresTotal int `json:"auth_failures_total"` + StatusCounts BridgeStatusCountsPayload `json:"status_counts"` +} + +func cloneRawMessage(value json.RawMessage) json.RawMessage { + if len(value) == 0 { + return nil + } + return append(json.RawMessage(nil), value...) +} diff --git a/internal/api/contract/channels_test.go b/internal/api/contract/bridges_test.go similarity index 55% rename from internal/api/contract/channels_test.go rename to internal/api/contract/bridges_test.go index 694de0988..d2c34fc46 100644 --- a/internal/api/contract/channels_test.go +++ b/internal/api/contract/bridges_test.go @@ -6,51 +6,51 @@ import ( "testing" "github.com/pedronauck/agh/internal/api/contract" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" ) -func TestCreateChannelRequestValidation(t *testing.T) { +func TestCreateBridgeRequestValidation(t *testing.T) { t.Parallel() tests := []struct { name string - req contract.CreateChannelRequest + req contract.CreateBridgeRequest }{ { name: "workspace scope requires workspace id", - req: contract.CreateChannelRequest{ - Scope: channelspkg.ScopeWorkspace, + req: contract.CreateBridgeRequest{ + Scope: bridgepkg.ScopeWorkspace, Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Support", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }, }, { name: "global scope rejects workspace id", - req: contract.CreateChannelRequest{ - Scope: channelspkg.ScopeGlobal, + req: contract.CreateBridgeRequest{ + Scope: bridgepkg.ScopeGlobal, WorkspaceID: "ws-alpha", Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Support", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }, }, { name: "routing policy rejects thread without peer or group", - req: contract.CreateChannelRequest{ - Scope: channelspkg.ScopeGlobal, + req: contract.CreateBridgeRequest{ + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Support", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludeThread: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludeThread: true}, }, }, } @@ -66,18 +66,18 @@ func TestCreateChannelRequestValidation(t *testing.T) { } } -func TestCreateChannelRequestPreservesNormalizedFieldsAndDefaults(t *testing.T) { +func TestCreateBridgeRequestPreservesNormalizedFieldsAndDefaults(t *testing.T) { t.Parallel() - req := contract.CreateChannelRequest{ - Scope: channelspkg.ScopeWorkspace, + req := contract.CreateBridgeRequest{ + Scope: bridgepkg.ScopeWorkspace, WorkspaceID: " ws-alpha ", Platform: " telegram ", ExtensionName: " ext-telegram ", DisplayName: " Support ", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, DeliveryDefaults: json.RawMessage(`{"mode":"reply"}`), } @@ -98,21 +98,21 @@ func TestCreateChannelRequestPreservesNormalizedFieldsAndDefaults(t *testing.T) } } -func TestChannelRoutesResponseJSONShape(t *testing.T) { +func TestBridgeRoutesResponseJSONShape(t *testing.T) { t.Parallel() - payload := contract.ChannelRoutesResponse{ - Routes: []channelspkg.ChannelRoute{ + payload := contract.BridgeRoutesResponse{ + Routes: []bridgepkg.BridgeRoute{ { - RoutingKeyHash: "hash-1", - Scope: channelspkg.ScopeWorkspace, - WorkspaceID: "ws-alpha", - ChannelInstanceID: "chan-1", - PeerID: "peer-1", - ThreadID: "thread-1", - GroupID: "group-1", - SessionID: "sess-1", - AgentName: "coder", + RoutingKeyHash: "hash-1", + Scope: bridgepkg.ScopeWorkspace, + WorkspaceID: "ws-alpha", + BridgeInstanceID: "brg-1", + PeerID: "peer-1", + ThreadID: "thread-1", + GroupID: "group-1", + SessionID: "sess-1", + AgentName: "coder", }, }, } @@ -133,31 +133,31 @@ func TestChannelRoutesResponseJSONShape(t *testing.T) { } } -func TestChannelTestDeliveryRequestPreservesTypedTargetShape(t *testing.T) { +func TestBridgeTestDeliveryRequestPreservesTypedTargetShape(t *testing.T) { t.Parallel() - req := contract.ChannelTestDeliveryRequest{ + req := contract.BridgeTestDeliveryRequest{ Message: "hello", - Target: contract.ChannelDeliveryTargetInput{ + Target: contract.BridgeDeliveryTargetInput{ PeerID: "peer-1", ThreadID: "thread-1", GroupID: "group-1", - Mode: channelspkg.DeliveryModeReply, + Mode: bridgepkg.DeliveryModeReply, }, } - mapped, err := req.ToResolveDeliveryTargetRequest("chan-1") + mapped, err := req.ToResolveDeliveryTargetRequest("brg-1") if err != nil { t.Fatalf("ToResolveDeliveryTargetRequest() error = %v", err) } - if mapped.ChannelInstanceID != "chan-1" || mapped.PeerID != "peer-1" || mapped.ThreadID != "thread-1" || mapped.GroupID != "group-1" || mapped.Mode != channelspkg.DeliveryModeReply { + if mapped.BridgeInstanceID != "brg-1" || mapped.PeerID != "peer-1" || mapped.ThreadID != "thread-1" || mapped.GroupID != "group-1" || mapped.Mode != bridgepkg.DeliveryModeReply { t.Fatalf("mapped target = %#v", mapped) } - data, err := json.Marshal(contract.ChannelTestDeliveryResponse{ + data, err := json.Marshal(contract.BridgeTestDeliveryResponse{ Status: "resolved", Message: "hello", - DeliveryTarget: channelspkg.DeliveryTarget{ChannelInstanceID: "chan-1", PeerID: "peer-1", ThreadID: "thread-1", GroupID: "group-1", Mode: channelspkg.DeliveryModeReply}, + DeliveryTarget: bridgepkg.DeliveryTarget{BridgeInstanceID: "brg-1", PeerID: "peer-1", ThreadID: "thread-1", GroupID: "group-1", Mode: bridgepkg.DeliveryModeReply}, }) if err != nil { t.Fatalf("json.Marshal(response) error = %v", err) @@ -171,51 +171,51 @@ func TestChannelTestDeliveryRequestPreservesTypedTargetShape(t *testing.T) { if !ok { t.Fatalf("delivery_target type = %T, want object", got["delivery_target"]) } - if target["peer_id"] != "peer-1" || target["thread_id"] != "thread-1" || target["group_id"] != "group-1" || target["mode"] != string(channelspkg.DeliveryModeReply) { + if target["peer_id"] != "peer-1" || target["thread_id"] != "thread-1" || target["group_id"] != "group-1" || target["mode"] != string(bridgepkg.DeliveryModeReply) { t.Fatalf("delivery_target JSON = %#v", target) } } -func TestChannelTestDeliveryRequestRejectsMismatchedInstanceID(t *testing.T) { +func TestBridgeTestDeliveryRequestRejectsMismatchedInstanceID(t *testing.T) { t.Parallel() - req := contract.ChannelTestDeliveryRequest{ - Target: contract.ChannelDeliveryTargetInput{ - ChannelInstanceID: "chan-2", - PeerID: "peer-1", + req := contract.BridgeTestDeliveryRequest{ + Target: contract.BridgeDeliveryTargetInput{ + BridgeInstanceID: "brg-2", + PeerID: "peer-1", }, } - if _, err := req.ToResolveDeliveryTargetRequest("chan-1"); !errors.Is(err, contract.ErrChannelInstanceMismatch) { - t.Fatalf("ToResolveDeliveryTargetRequest() error = %v, want %v", err, contract.ErrChannelInstanceMismatch) + if _, err := req.ToResolveDeliveryTargetRequest("brg-1"); !errors.Is(err, contract.ErrBridgeInstanceMismatch) { + t.Fatalf("ToResolveDeliveryTargetRequest() error = %v, want %v", err, contract.ErrBridgeInstanceMismatch) } } -func TestChannelTestDeliveryRequestAcceptsExplicitMatchingInstanceID(t *testing.T) { +func TestBridgeTestDeliveryRequestAcceptsExplicitMatchingInstanceID(t *testing.T) { t.Parallel() - req := contract.ChannelTestDeliveryRequest{ - Target: contract.ChannelDeliveryTargetInput{ - ChannelInstanceID: " chan-1 ", - PeerID: " peer-1 ", - Mode: "direct", + req := contract.BridgeTestDeliveryRequest{ + Target: contract.BridgeDeliveryTargetInput{ + BridgeInstanceID: " brg-1 ", + PeerID: " peer-1 ", + Mode: "direct", }, } - mapped, err := req.ToResolveDeliveryTargetRequest(" chan-1 ") + mapped, err := req.ToResolveDeliveryTargetRequest(" brg-1 ") if err != nil { t.Fatalf("ToResolveDeliveryTargetRequest() error = %v", err) } - if mapped.ChannelInstanceID != "chan-1" || mapped.PeerID != "peer-1" || mapped.Mode != channelspkg.DeliveryModeDirectSend { + if mapped.BridgeInstanceID != "brg-1" || mapped.PeerID != "peer-1" || mapped.Mode != bridgepkg.DeliveryModeDirectSend { t.Fatalf("mapped target = %#v", mapped) } } -func TestChannelTestDeliveryRequestRejectsBlankInstanceID(t *testing.T) { +func TestBridgeTestDeliveryRequestRejectsBlankInstanceID(t *testing.T) { t.Parallel() - req := contract.ChannelTestDeliveryRequest{ - Target: contract.ChannelDeliveryTargetInput{PeerID: "peer-1"}, + req := contract.BridgeTestDeliveryRequest{ + Target: contract.BridgeDeliveryTargetInput{PeerID: "peer-1"}, } if _, err := req.ToResolveDeliveryTargetRequest(" "); err == nil { @@ -223,23 +223,23 @@ func TestChannelTestDeliveryRequestRejectsBlankInstanceID(t *testing.T) { } } -func TestUpdateChannelRequestPreservesOptionalFields(t *testing.T) { +func TestUpdateBridgeRequestPreservesOptionalFields(t *testing.T) { t.Parallel() displayName := "Support Escalations" rawDefaults := json.RawMessage(`{"mode":"reply"}`) - req := contract.UpdateChannelRequest{ + req := contract.UpdateBridgeRequest{ DisplayName: &displayName, - RoutingPolicy: &channelspkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, + RoutingPolicy: &bridgepkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, DeliveryDefaults: &rawDefaults, } - mapped, err := req.ToUpdateInstanceRequest("chan-1") + mapped, err := req.ToUpdateInstanceRequest("brg-1") if err != nil { t.Fatalf("ToUpdateInstanceRequest() error = %v", err) } - if mapped.ID != "chan-1" { - t.Fatalf("mapped.ID = %q, want chan-1", mapped.ID) + if mapped.ID != "brg-1" { + t.Fatalf("mapped.ID = %q, want brg-1", mapped.ID) } if mapped.DisplayName == nil || *mapped.DisplayName != displayName { t.Fatalf("mapped.DisplayName = %#v", mapped.DisplayName) @@ -257,26 +257,26 @@ func TestUpdateChannelRequestPreservesOptionalFields(t *testing.T) { } } -func TestUpdateChannelRequestRejectsBlankDisplayName(t *testing.T) { +func TestUpdateBridgeRequestRejectsBlankDisplayName(t *testing.T) { t.Parallel() displayName := " " - req := contract.UpdateChannelRequest{DisplayName: &displayName} + req := contract.UpdateBridgeRequest{DisplayName: &displayName} - if _, err := req.ToUpdateInstanceRequest("chan-1"); err == nil { + if _, err := req.ToUpdateInstanceRequest("brg-1"); err == nil { t.Fatal("ToUpdateInstanceRequest() error = nil, want non-nil") } } -func TestChannelInstanceMismatchErrorSupportsErrorsIs(t *testing.T) { +func TestBridgeInstanceMismatchErrorSupportsErrorsIs(t *testing.T) { t.Parallel() - err := contract.ErrChannelInstanceMismatch - if err.Error() != "channel instance id must match request path" { + err := contract.ErrBridgeInstanceMismatch + if err.Error() != "bridge instance id must match request path" { t.Fatalf("Error() = %q", err.Error()) } - if !errors.Is(err, contract.ErrChannelInstanceMismatch) { - t.Fatal("expected errors.Is to match ErrChannelInstanceMismatch") + if !errors.Is(err, contract.ErrBridgeInstanceMismatch) { + t.Fatal("expected errors.Is to match ErrBridgeInstanceMismatch") } } diff --git a/internal/api/contract/channels.go b/internal/api/contract/channels.go deleted file mode 100644 index d7831431c..000000000 --- a/internal/api/contract/channels.go +++ /dev/null @@ -1,189 +0,0 @@ -package contract - -import ( - "encoding/json" - "strings" - "time" - - channelspkg "github.com/pedronauck/agh/internal/channels" -) - -// CreateChannelRequest is the shared channel-instance creation payload. -type CreateChannelRequest struct { - Scope channelspkg.Scope `json:"scope"` - WorkspaceID string `json:"workspace_id,omitempty"` - Platform string `json:"platform"` - ExtensionName string `json:"extension_name"` - DisplayName string `json:"display_name"` - Enabled bool `json:"enabled"` - Status channelspkg.ChannelStatus `json:"status"` - RoutingPolicy channelspkg.RoutingPolicy `json:"routing_policy"` - DeliveryDefaults json.RawMessage `json:"delivery_defaults,omitempty"` -} - -// ToCreateInstanceRequest validates and converts the transport payload into the -// daemon-owned channel create request. -func (r CreateChannelRequest) ToCreateInstanceRequest() (channelspkg.CreateInstanceRequest, error) { - req := channelspkg.CreateInstanceRequest{ - Scope: r.Scope, - WorkspaceID: strings.TrimSpace(r.WorkspaceID), - Platform: strings.TrimSpace(r.Platform), - ExtensionName: strings.TrimSpace(r.ExtensionName), - DisplayName: strings.TrimSpace(r.DisplayName), - Enabled: r.Enabled, - Status: r.Status, - RoutingPolicy: r.RoutingPolicy, - DeliveryDefaults: cloneRawMessage(r.DeliveryDefaults), - } - if err := req.Validate(); err != nil { - return channelspkg.CreateInstanceRequest{}, err - } - return req, nil -} - -// UpdateChannelRequest is the shared mutable channel-instance patch payload. -type UpdateChannelRequest struct { - DisplayName *string `json:"display_name,omitempty"` - RoutingPolicy *channelspkg.RoutingPolicy `json:"routing_policy,omitempty"` - DeliveryDefaults *json.RawMessage `json:"delivery_defaults,omitempty"` -} - -// ToUpdateInstanceRequest validates and converts the transport patch payload -// into the daemon-owned channel update request for the supplied instance id. -func (r UpdateChannelRequest) ToUpdateInstanceRequest(id string) (channelspkg.UpdateInstanceRequest, error) { - req := channelspkg.UpdateInstanceRequest{ID: strings.TrimSpace(id)} - if r.DisplayName != nil { - value := strings.TrimSpace(*r.DisplayName) - req.DisplayName = &value - } - if r.RoutingPolicy != nil { - value := *r.RoutingPolicy - req.RoutingPolicy = &value - } - if r.DeliveryDefaults != nil { - value := cloneRawMessage(*r.DeliveryDefaults) - req.DeliveryDefaults = &value - } - if err := req.Validate(); err != nil { - return channelspkg.UpdateInstanceRequest{}, err - } - return req, nil -} - -// ChannelDeliveryTargetInput is the shared typed delivery-target override payload. -type ChannelDeliveryTargetInput struct { - ChannelInstanceID string `json:"channel_instance_id,omitempty"` - PeerID string `json:"peer_id,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - GroupID string `json:"group_id,omitempty"` - Mode channelspkg.DeliveryMode `json:"mode,omitempty"` -} - -// ChannelTestDeliveryRequest is the shared typed dry-run delivery payload. -type ChannelTestDeliveryRequest struct { - Message string `json:"message,omitempty"` - Target ChannelDeliveryTargetInput `json:"target"` -} - -// ToResolveDeliveryTargetRequest validates and converts the transport payload -// into the daemon-owned delivery-target resolution request for the supplied -// channel instance id. -func (r ChannelTestDeliveryRequest) ToResolveDeliveryTargetRequest(channelInstanceID string) (channelspkg.ResolveDeliveryTargetRequest, error) { - req := channelspkg.ResolveDeliveryTargetRequest{ - ChannelInstanceID: strings.TrimSpace(r.Target.ChannelInstanceID), - PeerID: strings.TrimSpace(r.Target.PeerID), - ThreadID: strings.TrimSpace(r.Target.ThreadID), - GroupID: strings.TrimSpace(r.Target.GroupID), - Mode: r.Target.Mode.Normalize(), - } - req.ChannelInstanceID = strings.TrimSpace(req.ChannelInstanceID) - trimmedID := strings.TrimSpace(channelInstanceID) - if req.ChannelInstanceID == "" { - req.ChannelInstanceID = trimmedID - } - if req.ChannelInstanceID != trimmedID { - return channelspkg.ResolveDeliveryTargetRequest{}, ErrChannelInstanceMismatch - } - if err := req.Validate(); err != nil { - return channelspkg.ResolveDeliveryTargetRequest{}, err - } - return req, nil -} - -// ErrChannelInstanceMismatch reports a body/path channel-instance mismatch for -// typed delivery-target requests. -var ErrChannelInstanceMismatch = channelContractError("channel instance id must match request path") - -type channelContractError string - -func (e channelContractError) Error() string { - return string(e) -} - -// ChannelsResponse wraps the shared channel list payload. -type ChannelsResponse struct { - Channels []channelspkg.ChannelInstance `json:"channels"` - ChannelHealth map[string]ChannelHealthPayload `json:"channel_health,omitempty"` -} - -// ChannelResponse wraps one shared channel payload. -type ChannelResponse struct { - Channel channelspkg.ChannelInstance `json:"channel"` - Health ChannelHealthPayload `json:"health"` -} - -// ChannelRoutesResponse wraps one channel's route set. -type ChannelRoutesResponse struct { - Routes []channelspkg.ChannelRoute `json:"routes"` -} - -// ChannelTestDeliveryResponse wraps the dry-run delivery-target resolution payload. -type ChannelTestDeliveryResponse struct { - Status string `json:"status"` - Message string `json:"message,omitempty"` - DeliveryTarget channelspkg.DeliveryTarget `json:"delivery_target"` -} - -// ChannelHealthPayload captures the additive per-instance observability fields -// exposed through channel APIs. -type ChannelHealthPayload struct { - ChannelInstanceID string `json:"channel_instance_id"` - Status channelspkg.ChannelStatus `json:"status"` - RouteCount int `json:"route_count"` - DeliveryBacklog int `json:"delivery_backlog"` - DeliveryDroppedTotal int `json:"delivery_dropped_total"` - DeliveryDroppedByReason map[string]int `json:"delivery_dropped_by_reason,omitempty"` - DeliveryFailuresTotal int `json:"delivery_failures_total"` - AuthFailuresTotal int `json:"auth_failures_total"` - LastError string `json:"last_error,omitempty"` - LastErrorAt *time.Time `json:"last_error_at,omitempty"` -} - -// ChannelStatusCountsPayload captures aggregate per-status counts for channel health. -type ChannelStatusCountsPayload struct { - Disabled int `json:"disabled"` - Starting int `json:"starting"` - Ready int `json:"ready"` - Degraded int `json:"degraded"` - AuthRequired int `json:"auth_required"` - Error int `json:"error"` -} - -// ChannelAggregateHealthPayload captures the additive channel summary nested -// under the daemon health response. -type ChannelAggregateHealthPayload struct { - TotalInstances int `json:"total_instances"` - RouteCount int `json:"route_count"` - DeliveryBacklog int `json:"delivery_backlog"` - DeliveryDroppedTotal int `json:"delivery_dropped_total"` - DeliveryFailuresTotal int `json:"delivery_failures_total"` - AuthFailuresTotal int `json:"auth_failures_total"` - StatusCounts ChannelStatusCountsPayload `json:"status_counts"` -} - -func cloneRawMessage(value json.RawMessage) json.RawMessage { - if len(value) == 0 { - return nil - } - return append(json.RawMessage(nil), value...) -} diff --git a/internal/api/contract/contract.go b/internal/api/contract/contract.go index a9539476a..beaa34cf4 100644 --- a/internal/api/contract/contract.go +++ b/internal/api/contract/contract.go @@ -16,7 +16,7 @@ type CreateSessionRequest struct { Name string `json:"name,omitempty"` Workspace string `json:"workspace,omitempty"` WorkspacePath string `json:"workspace_path,omitempty"` - Space string `json:"space,omitempty"` + Channel string `json:"channel,omitempty"` } // ApproveSessionRequest is the interactive permission approval payload. @@ -33,7 +33,7 @@ type SessionPayload struct { AgentName string `json:"agent_name"` WorkspaceID string `json:"workspace_id,omitempty"` WorkspacePath string `json:"workspace_path,omitempty"` - Space string `json:"space,omitempty"` + Channel string `json:"channel,omitempty"` State session.SessionState `json:"state"` // StopReason is the session-level stop classification, distinct from AgentEventPayload.StopReason. StopReason store.StopReason `json:"stop_reason,omitempty"` @@ -139,14 +139,14 @@ type ObserveEventPayload struct { // ObserveHealthPayload is the shared observability health response payload. type ObserveHealthPayload struct { - Status string `json:"status"` - UptimeSeconds int64 `json:"uptime_seconds"` - ActiveSessions int `json:"active_sessions"` - ActiveAgents int `json:"active_agents"` - GlobalDBSizeBytes int64 `json:"global_db_size_bytes"` - SessionDBSizeBytes int64 `json:"session_db_size_bytes"` - Channels ChannelAggregateHealthPayload `json:"channels"` - Version string `json:"version"` + Status string `json:"status"` + UptimeSeconds int64 `json:"uptime_seconds"` + ActiveSessions int `json:"active_sessions"` + ActiveAgents int `json:"active_agents"` + GlobalDBSizeBytes int64 `json:"global_db_size_bytes"` + SessionDBSizeBytes int64 `json:"session_db_size_bytes"` + Bridges BridgeAggregateHealthPayload `json:"bridges"` + Version string `json:"version"` } // HookCatalogQuery captures the shared resolved-hook catalog filters. @@ -236,7 +236,7 @@ type NetworkStatusPayload struct { ListenerPort int `json:"listener_port,omitempty"` LocalPeers int `json:"local_peers,omitempty"` RemotePeers int `json:"remote_peers,omitempty"` - Spaces int `json:"spaces,omitempty"` + Channels int `json:"channels,omitempty"` QueuedMessages int `json:"queued_messages,omitempty"` QueuedSessions int `json:"queued_sessions,omitempty"` DeliveryWorkers int `json:"delivery_workers,omitempty"` @@ -262,7 +262,7 @@ type NetworkKindMetricPayload struct { // NetworkSendRequest is the shared daemon network send request payload. type NetworkSendRequest struct { SessionID string `json:"session_id"` - Space string `json:"space"` + Channel string `json:"channel"` Kind string `json:"kind"` To string `json:"to,omitempty"` Body json.RawMessage `json:"body"` @@ -279,7 +279,7 @@ type NetworkSendRequest struct { type NetworkSendPayload struct { ID string `json:"id"` SessionID string `json:"session_id"` - Space string `json:"space"` + Channel string `json:"channel"` Kind string `json:"kind"` To string `json:"to,omitempty"` InteractionID string `json:"interaction_id,omitempty"` @@ -305,7 +305,7 @@ type NetworkPeerCardPayload struct { type NetworkPeerPayload struct { SessionID *string `json:"session_id,omitempty"` PeerID string `json:"peer_id"` - Space string `json:"space"` + Channel string `json:"channel"` Local bool `json:"local"` PeerCard NetworkPeerCardPayload `json:"peer_card"` JoinedAt *time.Time `json:"joined_at,omitempty"` @@ -313,9 +313,9 @@ type NetworkPeerPayload struct { ExpiresAt *time.Time `json:"expires_at,omitempty"` } -// NetworkSpacePayload is the shared JSON representation of one active space. -type NetworkSpacePayload struct { - Space string `json:"space"` +// NetworkChannelPayload is the shared JSON representation of one active channel. +type NetworkChannelPayload struct { + Channel string `json:"channel"` PeerCount int `json:"peer_count"` } @@ -325,7 +325,7 @@ type NetworkEnvelopePayload struct { Protocol string `json:"protocol"` ID string `json:"id"` Kind string `json:"kind"` - Space string `json:"space"` + Channel string `json:"channel"` From string `json:"from"` To *string `json:"to,omitempty"` InteractionID *string `json:"interaction_id,omitempty"` diff --git a/internal/api/contract/responses.go b/internal/api/contract/responses.go index 3467ba258..8792e3fd5 100644 --- a/internal/api/contract/responses.go +++ b/internal/api/contract/responses.go @@ -121,9 +121,9 @@ type NetworkPeersResponse struct { Peers []NetworkPeerPayload `json:"peers"` } -// NetworkSpacesResponse wraps the active space list payload. -type NetworkSpacesResponse struct { - Spaces []NetworkSpacePayload `json:"spaces"` +// NetworkChannelsResponse wraps the active channel list payload. +type NetworkChannelsResponse struct { + Channels []NetworkChannelPayload `json:"channels"` } // NetworkSendResponse wraps the outbound send result payload. diff --git a/internal/api/core/bridges.go b/internal/api/core/bridges.go new file mode 100644 index 000000000..a905e5d8a --- /dev/null +++ b/internal/api/core/bridges.go @@ -0,0 +1,278 @@ +package core + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/pedronauck/agh/internal/api/contract" + bridgepkg "github.com/pedronauck/agh/internal/bridges" +) + +var errBridgeServiceUnavailable = errors.New("bridge service is not configured") + +// ListBridges returns all persisted bridge instances. +func (h *BaseHandlers) ListBridges(c *gin.Context) { + bridges, ok := h.bridgeService() + if !ok { + h.respondError(c, http.StatusServiceUnavailable, errBridgeServiceUnavailable) + return + } + + instances, err := bridges.ListInstances(c.Request.Context()) + if err != nil { + h.respondError(c, StatusForBridgeError(err), err) + return + } + bridgeHealth, err := h.bridgeHealthMap(c.Request.Context()) + if err != nil { + h.respondError(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, contract.BridgesResponse{Bridges: instances, BridgeHealth: bridgeHealth}) +} + +// CreateBridge persists a new bridge instance. +func (h *BaseHandlers) CreateBridge(c *gin.Context) { + bridges, ok := h.bridgeService() + if !ok { + h.respondError(c, http.StatusServiceUnavailable, errBridgeServiceUnavailable) + return + } + + var req contract.CreateBridgeRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, http.StatusBadRequest, fmt.Errorf("%s: decode create bridge request: %w", h.transportName(), err)) + return + } + + createReq, err := req.ToCreateInstanceRequest() + if err != nil { + h.respondError(c, http.StatusBadRequest, err) + return + } + + instance, err := bridges.CreateInstance(c.Request.Context(), createReq) + if err != nil { + h.respondError(c, StatusForBridgeError(err), err) + return + } + h.respondBridge(c, http.StatusCreated, *instance) +} + +// GetBridge returns one persisted bridge instance. +func (h *BaseHandlers) GetBridge(c *gin.Context) { + bridges, ok := h.bridgeService() + if !ok { + h.respondError(c, http.StatusServiceUnavailable, errBridgeServiceUnavailable) + return + } + + instance, err := bridges.GetInstance(c.Request.Context(), strings.TrimSpace(c.Param("id"))) + if err != nil { + h.respondError(c, StatusForBridgeError(err), err) + return + } + h.respondBridge(c, http.StatusOK, *instance) +} + +// UpdateBridge patches the mutable configuration fields of one bridge instance. +func (h *BaseHandlers) UpdateBridge(c *gin.Context) { + bridges, ok := h.bridgeService() + if !ok { + h.respondError(c, http.StatusServiceUnavailable, errBridgeServiceUnavailable) + return + } + + var req contract.UpdateBridgeRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, http.StatusBadRequest, fmt.Errorf("%s: decode update bridge request: %w", h.transportName(), err)) + return + } + + updateReq, err := req.ToUpdateInstanceRequest(c.Param("id")) + if err != nil { + h.respondError(c, http.StatusBadRequest, err) + return + } + + instance, err := bridges.UpdateInstance(c.Request.Context(), updateReq) + if err != nil { + h.respondError(c, StatusForBridgeError(err), err) + return + } + h.respondBridge(c, http.StatusOK, *instance) +} + +// EnableBridge moves one bridge instance into the starting lifecycle state. +func (h *BaseHandlers) EnableBridge(c *gin.Context) { + h.transitionBridge(c, (*BaseHandlers).enableBridge) +} + +// DisableBridge moves one bridge instance into the disabled lifecycle state. +func (h *BaseHandlers) DisableBridge(c *gin.Context) { + h.transitionBridge(c, (*BaseHandlers).disableBridge) +} + +// RestartBridge restarts one bridge instance while preserving route ownership. +func (h *BaseHandlers) RestartBridge(c *gin.Context) { + h.transitionBridge(c, (*BaseHandlers).restartBridge) +} + +// ListBridgeRoutes returns the persisted routes owned by one bridge instance. +func (h *BaseHandlers) ListBridgeRoutes(c *gin.Context) { + bridges, ok := h.bridgeService() + if !ok { + h.respondError(c, http.StatusServiceUnavailable, errBridgeServiceUnavailable) + return + } + + routes, err := bridges.ListRoutes(c.Request.Context(), strings.TrimSpace(c.Param("id"))) + if err != nil { + h.respondError(c, StatusForBridgeError(err), err) + return + } + c.JSON(http.StatusOK, contract.BridgeRoutesResponse{Routes: routes}) +} + +// TestBridgeDelivery resolves the typed outbound delivery target for one +// bridge instance without requiring a live platform adapter. +func (h *BaseHandlers) TestBridgeDelivery(c *gin.Context) { + bridges, ok := h.bridgeService() + if !ok { + h.respondError(c, http.StatusServiceUnavailable, errBridgeServiceUnavailable) + return + } + + var req contract.BridgeTestDeliveryRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, http.StatusBadRequest, fmt.Errorf("%s: decode test delivery request: %w", h.transportName(), err)) + return + } + + targetReq, err := req.ToResolveDeliveryTargetRequest(c.Param("id")) + if err != nil { + h.respondError(c, http.StatusBadRequest, err) + return + } + + target, err := bridges.ResolveDeliveryTarget(c.Request.Context(), targetReq) + if err != nil { + h.respondError(c, StatusForBridgeError(err), err) + return + } + c.JSON(http.StatusOK, contract.BridgeTestDeliveryResponse{ + Status: "resolved", + Message: strings.TrimSpace(req.Message), + DeliveryTarget: *target, + }) +} + +func (h *BaseHandlers) transitionBridge(c *gin.Context, fn func(*BaseHandlers, *gin.Context) (*contract.BridgeResponse, error)) { + if h == nil { + RespondError(c, http.StatusServiceUnavailable, errBridgeServiceUnavailable, false) + return + } + resp, err := fn(h, c) + if err != nil { + if errors.Is(err, errBridgeServiceUnavailable) { + h.respondError(c, http.StatusServiceUnavailable, err) + return + } + h.respondError(c, StatusForBridgeError(err), err) + return + } + c.JSON(http.StatusOK, *resp) +} + +func (h *BaseHandlers) enableBridge(c *gin.Context) (*contract.BridgeResponse, error) { + bridges, ok := h.bridgeService() + if !ok { + return nil, errBridgeServiceUnavailable + } + instance, err := bridges.StartInstance(c.Request.Context(), strings.TrimSpace(c.Param("id"))) + if err != nil { + return nil, err + } + return h.bridgeResponse(c.Request.Context(), *instance) +} + +func (h *BaseHandlers) disableBridge(c *gin.Context) (*contract.BridgeResponse, error) { + bridges, ok := h.bridgeService() + if !ok { + return nil, errBridgeServiceUnavailable + } + instance, err := bridges.StopInstance(c.Request.Context(), strings.TrimSpace(c.Param("id"))) + if err != nil { + return nil, err + } + return h.bridgeResponse(c.Request.Context(), *instance) +} + +func (h *BaseHandlers) restartBridge(c *gin.Context) (*contract.BridgeResponse, error) { + bridges, ok := h.bridgeService() + if !ok { + return nil, errBridgeServiceUnavailable + } + instance, err := bridges.RestartInstance(c.Request.Context(), strings.TrimSpace(c.Param("id"))) + if err != nil { + return nil, err + } + return h.bridgeResponse(c.Request.Context(), *instance) +} + +func (h *BaseHandlers) bridgeService() (BridgeService, bool) { + if h == nil || h.Bridges == nil { + return nil, false + } + return h.Bridges, true +} + +func (h *BaseHandlers) respondBridge(c *gin.Context, status int, instance bridgepkg.BridgeInstance) { + resp, err := h.bridgeResponse(c.Request.Context(), instance) + if err != nil { + h.respondError(c, http.StatusInternalServerError, err) + return + } + c.JSON(status, *resp) +} + +func (h *BaseHandlers) bridgeResponse(ctx context.Context, instance bridgepkg.BridgeInstance) (*contract.BridgeResponse, error) { + health, err := h.bridgeHealthLookup(ctx, strings.TrimSpace(instance.ID)) + if err != nil { + return nil, err + } + return &contract.BridgeResponse{ + Bridge: instance, + Health: health, + }, nil +} + +func (h *BaseHandlers) bridgeHealthMap(ctx context.Context) (map[string]contract.BridgeHealthPayload, error) { + if h == nil || h.Observer == nil { + return nil, nil + } + + observed, err := h.Observer.QueryBridgeHealth(ctx) + if err != nil { + return nil, err + } + + health := make(map[string]contract.BridgeHealthPayload, len(observed)) + for _, item := range observed { + health[strings.TrimSpace(item.BridgeInstanceID)] = BridgeHealthPayloadFromObserve(item) + } + return health, nil +} + +func (h *BaseHandlers) bridgeHealthLookup(ctx context.Context, bridgeInstanceID string) (contract.BridgeHealthPayload, error) { + healthMap, err := h.bridgeHealthMap(ctx) + if err != nil { + return contract.BridgeHealthPayload{}, err + } + + return healthMap[strings.TrimSpace(bridgeInstanceID)], nil +} diff --git a/internal/api/core/bridges_test.go b/internal/api/core/bridges_test.go new file mode 100644 index 000000000..44438f519 --- /dev/null +++ b/internal/api/core/bridges_test.go @@ -0,0 +1,327 @@ +package core_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/pedronauck/agh/internal/api/contract" + "github.com/pedronauck/agh/internal/api/core" + "github.com/pedronauck/agh/internal/api/testutil" + bridgepkg "github.com/pedronauck/agh/internal/bridges" + aghconfig "github.com/pedronauck/agh/internal/config" + "github.com/pedronauck/agh/internal/observe" +) + +func TestBridgeHandlersCreateListGetAndUpdate(t *testing.T) { + t.Parallel() + + var createCalled, updateCalled bool + _, engine := newBridgeHandlerFixture(t, testutil.StubBridgeService{ + CreateInstanceFn: func(_ context.Context, req bridgepkg.CreateInstanceRequest) (*bridgepkg.BridgeInstance, error) { + createCalled = true + if req.Scope != bridgepkg.ScopeGlobal || req.Platform != "telegram" || req.DisplayName != "Support" { + t.Fatalf("CreateInstance() req = %#v", req) + } + return &bridgepkg.BridgeInstance{ + ID: "brg-core", + Scope: req.Scope, + Platform: req.Platform, + ExtensionName: req.ExtensionName, + DisplayName: req.DisplayName, + Enabled: req.Enabled, + Status: req.Status, + RoutingPolicy: req.RoutingPolicy, + CreatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + }, nil + }, + ListInstancesFn: func(context.Context) ([]bridgepkg.BridgeInstance, error) { + return []bridgepkg.BridgeInstance{{ + ID: "brg-core", + Scope: bridgepkg.ScopeGlobal, + Platform: "telegram", + ExtensionName: "ext-telegram", + DisplayName: "Support", + Enabled: true, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, + }}, nil + }, + GetInstanceFn: func(_ context.Context, id string) (*bridgepkg.BridgeInstance, error) { + return &bridgepkg.BridgeInstance{ + ID: id, + Scope: bridgepkg.ScopeGlobal, + Platform: "telegram", + ExtensionName: "ext-telegram", + DisplayName: "Support", + Enabled: true, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, + }, nil + }, + UpdateInstanceFn: func(_ context.Context, req bridgepkg.UpdateInstanceRequest) (*bridgepkg.BridgeInstance, error) { + updateCalled = true + if req.ID != "brg-core" || req.DisplayName == nil || *req.DisplayName != "Renamed" { + t.Fatalf("UpdateInstance() req = %#v", req) + } + return &bridgepkg.BridgeInstance{ + ID: req.ID, + Scope: bridgepkg.ScopeGlobal, + Platform: "telegram", + ExtensionName: "ext-telegram", + DisplayName: *req.DisplayName, + Enabled: true, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, + }, nil + }, + }) + + createResp := performRequest(t, engine, http.MethodPost, "/bridges", []byte(`{"scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true}}`)) + if createResp.Code != http.StatusCreated || !createCalled { + t.Fatalf("create status = %d createCalled=%v body=%s", createResp.Code, createCalled, createResp.Body.String()) + } + + listResp := performRequest(t, engine, http.MethodGet, "/bridges", nil) + if listResp.Code != http.StatusOK { + t.Fatalf("list status = %d body=%s", listResp.Code, listResp.Body.String()) + } + var listPayload contract.BridgesResponse + testutil.DecodeJSONResponse(t, listResp, &listPayload) + if got, want := len(listPayload.Bridges), 1; got != want { + t.Fatalf("len(bridges) = %d, want %d", got, want) + } + + getResp := performRequest(t, engine, http.MethodGet, "/bridges/brg-core", nil) + if getResp.Code != http.StatusOK { + t.Fatalf("get status = %d body=%s", getResp.Code, getResp.Body.String()) + } + + updateResp := performRequest(t, engine, http.MethodPatch, "/bridges/brg-core", []byte(`{"display_name":"Renamed"}`)) + if updateResp.Code != http.StatusOK || !updateCalled { + t.Fatalf("update status = %d updateCalled=%v body=%s", updateResp.Code, updateCalled, updateResp.Body.String()) + } + +} + +func TestBridgeHandlersLifecycleTransitions(t *testing.T) { + t.Parallel() + + _, engine := newBridgeHandlerFixture(t, testutil.StubBridgeService{ + StartInstanceFn: func(_ context.Context, id string) (*bridgepkg.BridgeInstance, error) { + return &bridgepkg.BridgeInstance{ID: id, Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Support", Enabled: true, Status: bridgepkg.BridgeStatusStarting, RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}}, nil + }, + StopInstanceFn: func(_ context.Context, id string) (*bridgepkg.BridgeInstance, error) { + return &bridgepkg.BridgeInstance{ID: id, Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Support", Enabled: false, Status: bridgepkg.BridgeStatusDisabled, RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}}, nil + }, + RestartInstanceFn: func(_ context.Context, id string) (*bridgepkg.BridgeInstance, error) { + return &bridgepkg.BridgeInstance{ID: id, Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Support", Enabled: true, Status: bridgepkg.BridgeStatusStarting, RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}}, nil + }, + }) + + for _, tc := range []struct { + name string + path string + status bridgepkg.BridgeStatus + }{ + {name: "Should enable bridge", path: "/bridges/brg-core/enable", status: bridgepkg.BridgeStatusStarting}, + {name: "Should disable bridge", path: "/bridges/brg-core/disable", status: bridgepkg.BridgeStatusDisabled}, + {name: "Should restart bridge", path: "/bridges/brg-core/restart", status: bridgepkg.BridgeStatusStarting}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + resp := performRequest(t, engine, http.MethodPost, tc.path, nil) + if resp.Code != http.StatusOK { + t.Fatalf("%s status = %d body=%s", tc.path, resp.Code, resp.Body.String()) + } + var payload contract.BridgeResponse + testutil.DecodeJSONResponse(t, resp, &payload) + if payload.Bridge.Status != tc.status { + t.Fatalf("%s status payload = %q, want %q", tc.path, payload.Bridge.Status, tc.status) + } + }) + } +} + +func TestBridgeHandlersRoutesAndTestDelivery(t *testing.T) { + t.Parallel() + + _, engine := newBridgeHandlerFixture(t, testutil.StubBridgeService{ + ListRoutesFn: func(_ context.Context, bridgeInstanceID string) ([]bridgepkg.BridgeRoute, error) { + return []bridgepkg.BridgeRoute{{ + RoutingKeyHash: "hash-1", + Scope: bridgepkg.ScopeGlobal, + BridgeInstanceID: bridgeInstanceID, + PeerID: "peer-1", + ThreadID: "thread-1", + SessionID: "sess-1", + AgentName: "coder", + LastActivityAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + }}, nil + }, + ResolveDeliveryTargetFn: func(_ context.Context, req bridgepkg.ResolveDeliveryTargetRequest) (*bridgepkg.DeliveryTarget, error) { + return &bridgepkg.DeliveryTarget{ + BridgeInstanceID: req.BridgeInstanceID, + PeerID: "peer-default", + ThreadID: req.ThreadID, + Mode: bridgepkg.DeliveryModeReply, + }, nil + }, + }) + + routesResp := performRequest(t, engine, http.MethodGet, "/bridges/brg-core/routes", nil) + if routesResp.Code != http.StatusOK { + t.Fatalf("routes status = %d body=%s", routesResp.Code, routesResp.Body.String()) + } + var routes contract.BridgeRoutesResponse + testutil.DecodeJSONResponse(t, routesResp, &routes) + if got, want := len(routes.Routes), 1; got != want { + t.Fatalf("len(routes) = %d, want %d", got, want) + } + + testResp := performRequest(t, engine, http.MethodPost, "/bridges/brg-core/test-delivery", []byte(`{"target":{"thread_id":"thread-1"}}`)) + if testResp.Code != http.StatusOK { + t.Fatalf("test delivery status = %d body=%s", testResp.Code, testResp.Body.String()) + } + var payload contract.BridgeTestDeliveryResponse + testutil.DecodeJSONResponse(t, testResp, &payload) + if payload.DeliveryTarget.BridgeInstanceID != "brg-core" || payload.DeliveryTarget.ThreadID != "thread-1" { + t.Fatalf("payload = %#v", payload) + } +} + +func TestBridgeHandlersIncludeObservedHealthPayloads(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + homePaths := testutil.NewTestHomePaths(t) + cfg := aghconfig.DefaultWithHome(homePaths) + cfg.HTTP.Host = "127.0.0.1" + cfg.HTTP.Port = 2123 + + bridge := bridgepkg.BridgeInstance{ + ID: "brg-health", + Scope: bridgepkg.ScopeGlobal, + Platform: "telegram", + ExtensionName: "ext-telegram", + DisplayName: "Support", + Enabled: true, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, + } + + handlers := core.NewBaseHandlers(core.BaseHandlerConfig{ + TransportName: "api-core-test", + MaskInternalErrors: false, + IncludeSessionWorkspaceInSSE: true, + Sessions: testutil.StubSessionManager{}, + Observer: testutil.StubObserver{ + QueryBridgeHealthFn: func(context.Context) ([]observe.BridgeInstanceHealth, error) { + return []observe.BridgeInstanceHealth{{ + BridgeInstanceID: bridge.ID, + Status: bridgepkg.BridgeStatusDegraded, + RouteCount: 2, + DeliveryBacklog: 1, + DeliveryFailuresTotal: 3, + AuthFailuresTotal: 1, + LastError: "adapter unavailable", + }}, nil + }, + }, + Bridges: testutil.StubBridgeService{ListInstancesFn: func(context.Context) ([]bridgepkg.BridgeInstance, error) { + return []bridgepkg.BridgeInstance{bridge}, nil + }, GetInstanceFn: func(context.Context, string) (*bridgepkg.BridgeInstance, error) { return &bridge, nil }}, + Workspaces: testutil.StubWorkspaceService{}, + HomePaths: homePaths, + Config: cfg, + Logger: testutil.DiscardLogger(), + StartedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC), + Now: func() time.Time { + return time.Date(2026, 4, 3, 12, 0, 1, 0, time.UTC) + }, + HTTPPort: cfg.HTTP.Port, + }) + + engine := gin.New() + engine.GET("/bridges", handlers.ListBridges) + engine.GET("/bridges/:id", handlers.GetBridge) + + listResp := performRequest(t, engine, http.MethodGet, "/bridges", nil) + if listResp.Code != http.StatusOK { + t.Fatalf("list status = %d body=%s", listResp.Code, listResp.Body.String()) + } + var listPayload contract.BridgesResponse + testutil.DecodeJSONResponse(t, listResp, &listPayload) + if got, want := listPayload.BridgeHealth[bridge.ID].DeliveryBacklog, 1; got != want { + t.Fatalf("bridge_health backlog = %d, want %d", got, want) + } + + getResp := performRequest(t, engine, http.MethodGet, "/bridges/"+bridge.ID, nil) + if getResp.Code != http.StatusOK { + t.Fatalf("get status = %d body=%s", getResp.Code, getResp.Body.String()) + } + var getPayload contract.BridgeResponse + testutil.DecodeJSONResponse(t, getResp, &getPayload) + if got, want := getPayload.Health.Status, bridgepkg.BridgeStatusDegraded; got != want { + t.Fatalf("get health status = %q, want %q", got, want) + } + if got, want := getPayload.Health.RouteCount, 2; got != want { + t.Fatalf("get health route_count = %d, want %d", got, want) + } +} + +func TestBridgeHandlersReturnServiceUnavailableWhenNotConfigured(t *testing.T) { + t.Parallel() + + _, engine := newBridgeHandlerFixture(t, nil) + resp := performRequest(t, engine, http.MethodGet, "/bridges", nil) + if resp.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusServiceUnavailable, resp.Body.String()) + } +} + +func newBridgeHandlerFixture(t *testing.T, bridges core.BridgeService) (*core.BaseHandlers, *gin.Engine) { + t.Helper() + + gin.SetMode(gin.TestMode) + homePaths := testutil.NewTestHomePaths(t) + cfg := aghconfig.DefaultWithHome(homePaths) + cfg.HTTP.Host = "127.0.0.1" + cfg.HTTP.Port = 2123 + + handlers := core.NewBaseHandlers(core.BaseHandlerConfig{ + TransportName: "api-core-test", + MaskInternalErrors: false, + IncludeSessionWorkspaceInSSE: true, + Sessions: testutil.StubSessionManager{}, + Observer: testutil.StubObserver{}, + Bridges: bridges, + Workspaces: testutil.StubWorkspaceService{}, + HomePaths: homePaths, + Config: cfg, + Logger: testutil.DiscardLogger(), + StartedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC), + Now: func() time.Time { + return time.Date(2026, 4, 3, 12, 0, 1, 0, time.UTC) + }, + HTTPPort: cfg.HTTP.Port, + }) + + engine := gin.New() + engine.Use(gin.Recovery()) + engine.GET("/bridges", handlers.ListBridges) + engine.POST("/bridges", handlers.CreateBridge) + engine.GET("/bridges/:id", handlers.GetBridge) + engine.PATCH("/bridges/:id", handlers.UpdateBridge) + engine.POST("/bridges/:id/enable", handlers.EnableBridge) + engine.POST("/bridges/:id/disable", handlers.DisableBridge) + engine.POST("/bridges/:id/restart", handlers.RestartBridge) + engine.GET("/bridges/:id/routes", handlers.ListBridgeRoutes) + engine.POST("/bridges/:id/test-delivery", handlers.TestBridgeDelivery) + return handlers, engine +} diff --git a/internal/api/core/channels.go b/internal/api/core/channels.go deleted file mode 100644 index 6e2142141..000000000 --- a/internal/api/core/channels.go +++ /dev/null @@ -1,278 +0,0 @@ -package core - -import ( - "context" - "errors" - "fmt" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/pedronauck/agh/internal/api/contract" - channelspkg "github.com/pedronauck/agh/internal/channels" -) - -var errChannelServiceUnavailable = errors.New("channel service is not configured") - -// ListChannels returns all persisted channel instances. -func (h *BaseHandlers) ListChannels(c *gin.Context) { - channels, ok := h.channelService() - if !ok { - h.respondError(c, http.StatusServiceUnavailable, errChannelServiceUnavailable) - return - } - - instances, err := channels.ListInstances(c.Request.Context()) - if err != nil { - h.respondError(c, StatusForChannelError(err), err) - return - } - channelHealth, err := h.channelHealthMap(c.Request.Context()) - if err != nil { - h.respondError(c, http.StatusInternalServerError, err) - return - } - c.JSON(http.StatusOK, contract.ChannelsResponse{Channels: instances, ChannelHealth: channelHealth}) -} - -// CreateChannel persists a new channel instance. -func (h *BaseHandlers) CreateChannel(c *gin.Context) { - channels, ok := h.channelService() - if !ok { - h.respondError(c, http.StatusServiceUnavailable, errChannelServiceUnavailable) - return - } - - var req contract.CreateChannelRequest - if err := c.ShouldBindJSON(&req); err != nil { - h.respondError(c, http.StatusBadRequest, fmt.Errorf("%s: decode create channel request: %w", h.transportName(), err)) - return - } - - createReq, err := req.ToCreateInstanceRequest() - if err != nil { - h.respondError(c, http.StatusBadRequest, err) - return - } - - instance, err := channels.CreateInstance(c.Request.Context(), createReq) - if err != nil { - h.respondError(c, StatusForChannelError(err), err) - return - } - h.respondChannel(c, http.StatusCreated, *instance) -} - -// GetChannel returns one persisted channel instance. -func (h *BaseHandlers) GetChannel(c *gin.Context) { - channels, ok := h.channelService() - if !ok { - h.respondError(c, http.StatusServiceUnavailable, errChannelServiceUnavailable) - return - } - - instance, err := channels.GetInstance(c.Request.Context(), strings.TrimSpace(c.Param("id"))) - if err != nil { - h.respondError(c, StatusForChannelError(err), err) - return - } - h.respondChannel(c, http.StatusOK, *instance) -} - -// UpdateChannel patches the mutable configuration fields of one channel instance. -func (h *BaseHandlers) UpdateChannel(c *gin.Context) { - channels, ok := h.channelService() - if !ok { - h.respondError(c, http.StatusServiceUnavailable, errChannelServiceUnavailable) - return - } - - var req contract.UpdateChannelRequest - if err := c.ShouldBindJSON(&req); err != nil { - h.respondError(c, http.StatusBadRequest, fmt.Errorf("%s: decode update channel request: %w", h.transportName(), err)) - return - } - - updateReq, err := req.ToUpdateInstanceRequest(c.Param("id")) - if err != nil { - h.respondError(c, http.StatusBadRequest, err) - return - } - - instance, err := channels.UpdateInstance(c.Request.Context(), updateReq) - if err != nil { - h.respondError(c, StatusForChannelError(err), err) - return - } - h.respondChannel(c, http.StatusOK, *instance) -} - -// EnableChannel moves one channel instance into the starting lifecycle state. -func (h *BaseHandlers) EnableChannel(c *gin.Context) { - h.transitionChannel(c, (*BaseHandlers).enableChannel) -} - -// DisableChannel moves one channel instance into the disabled lifecycle state. -func (h *BaseHandlers) DisableChannel(c *gin.Context) { - h.transitionChannel(c, (*BaseHandlers).disableChannel) -} - -// RestartChannel restarts one channel instance while preserving route ownership. -func (h *BaseHandlers) RestartChannel(c *gin.Context) { - h.transitionChannel(c, (*BaseHandlers).restartChannel) -} - -// ListChannelRoutes returns the persisted routes owned by one channel instance. -func (h *BaseHandlers) ListChannelRoutes(c *gin.Context) { - channels, ok := h.channelService() - if !ok { - h.respondError(c, http.StatusServiceUnavailable, errChannelServiceUnavailable) - return - } - - routes, err := channels.ListRoutes(c.Request.Context(), strings.TrimSpace(c.Param("id"))) - if err != nil { - h.respondError(c, StatusForChannelError(err), err) - return - } - c.JSON(http.StatusOK, contract.ChannelRoutesResponse{Routes: routes}) -} - -// TestChannelDelivery resolves the typed outbound delivery target for one -// channel instance without requiring a live platform adapter. -func (h *BaseHandlers) TestChannelDelivery(c *gin.Context) { - channels, ok := h.channelService() - if !ok { - h.respondError(c, http.StatusServiceUnavailable, errChannelServiceUnavailable) - return - } - - var req contract.ChannelTestDeliveryRequest - if err := c.ShouldBindJSON(&req); err != nil { - h.respondError(c, http.StatusBadRequest, fmt.Errorf("%s: decode test delivery request: %w", h.transportName(), err)) - return - } - - targetReq, err := req.ToResolveDeliveryTargetRequest(c.Param("id")) - if err != nil { - h.respondError(c, http.StatusBadRequest, err) - return - } - - target, err := channels.ResolveDeliveryTarget(c.Request.Context(), targetReq) - if err != nil { - h.respondError(c, StatusForChannelError(err), err) - return - } - c.JSON(http.StatusOK, contract.ChannelTestDeliveryResponse{ - Status: "resolved", - Message: strings.TrimSpace(req.Message), - DeliveryTarget: *target, - }) -} - -func (h *BaseHandlers) transitionChannel(c *gin.Context, fn func(*BaseHandlers, *gin.Context) (*contract.ChannelResponse, error)) { - if h == nil { - RespondError(c, http.StatusServiceUnavailable, errChannelServiceUnavailable, false) - return - } - resp, err := fn(h, c) - if err != nil { - if errors.Is(err, errChannelServiceUnavailable) { - h.respondError(c, http.StatusServiceUnavailable, err) - return - } - h.respondError(c, StatusForChannelError(err), err) - return - } - c.JSON(http.StatusOK, *resp) -} - -func (h *BaseHandlers) enableChannel(c *gin.Context) (*contract.ChannelResponse, error) { - channels, ok := h.channelService() - if !ok { - return nil, errChannelServiceUnavailable - } - instance, err := channels.StartInstance(c.Request.Context(), strings.TrimSpace(c.Param("id"))) - if err != nil { - return nil, err - } - return h.channelResponse(c.Request.Context(), *instance) -} - -func (h *BaseHandlers) disableChannel(c *gin.Context) (*contract.ChannelResponse, error) { - channels, ok := h.channelService() - if !ok { - return nil, errChannelServiceUnavailable - } - instance, err := channels.StopInstance(c.Request.Context(), strings.TrimSpace(c.Param("id"))) - if err != nil { - return nil, err - } - return h.channelResponse(c.Request.Context(), *instance) -} - -func (h *BaseHandlers) restartChannel(c *gin.Context) (*contract.ChannelResponse, error) { - channels, ok := h.channelService() - if !ok { - return nil, errChannelServiceUnavailable - } - instance, err := channels.RestartInstance(c.Request.Context(), strings.TrimSpace(c.Param("id"))) - if err != nil { - return nil, err - } - return h.channelResponse(c.Request.Context(), *instance) -} - -func (h *BaseHandlers) channelService() (ChannelService, bool) { - if h == nil || h.Channels == nil { - return nil, false - } - return h.Channels, true -} - -func (h *BaseHandlers) respondChannel(c *gin.Context, status int, instance channelspkg.ChannelInstance) { - resp, err := h.channelResponse(c.Request.Context(), instance) - if err != nil { - h.respondError(c, http.StatusInternalServerError, err) - return - } - c.JSON(status, *resp) -} - -func (h *BaseHandlers) channelResponse(ctx context.Context, instance channelspkg.ChannelInstance) (*contract.ChannelResponse, error) { - health, err := h.channelHealthLookup(ctx, strings.TrimSpace(instance.ID)) - if err != nil { - return nil, err - } - return &contract.ChannelResponse{ - Channel: instance, - Health: health, - }, nil -} - -func (h *BaseHandlers) channelHealthMap(ctx context.Context) (map[string]contract.ChannelHealthPayload, error) { - if h == nil || h.Observer == nil { - return nil, nil - } - - observed, err := h.Observer.QueryChannelHealth(ctx) - if err != nil { - return nil, err - } - - health := make(map[string]contract.ChannelHealthPayload, len(observed)) - for _, item := range observed { - health[strings.TrimSpace(item.ChannelInstanceID)] = ChannelHealthPayloadFromObserve(item) - } - return health, nil -} - -func (h *BaseHandlers) channelHealthLookup(ctx context.Context, channelInstanceID string) (contract.ChannelHealthPayload, error) { - healthMap, err := h.channelHealthMap(ctx) - if err != nil { - return contract.ChannelHealthPayload{}, err - } - - return healthMap[strings.TrimSpace(channelInstanceID)], nil -} diff --git a/internal/api/core/channels_test.go b/internal/api/core/channels_test.go deleted file mode 100644 index b15bd77b4..000000000 --- a/internal/api/core/channels_test.go +++ /dev/null @@ -1,327 +0,0 @@ -package core_test - -import ( - "context" - "net/http" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/pedronauck/agh/internal/api/contract" - "github.com/pedronauck/agh/internal/api/core" - "github.com/pedronauck/agh/internal/api/testutil" - channelspkg "github.com/pedronauck/agh/internal/channels" - aghconfig "github.com/pedronauck/agh/internal/config" - "github.com/pedronauck/agh/internal/observe" -) - -func TestChannelHandlersCreateListGetAndUpdate(t *testing.T) { - t.Parallel() - - var createCalled, updateCalled bool - _, engine := newChannelHandlerFixture(t, testutil.StubChannelService{ - CreateInstanceFn: func(_ context.Context, req channelspkg.CreateInstanceRequest) (*channelspkg.ChannelInstance, error) { - createCalled = true - if req.Scope != channelspkg.ScopeGlobal || req.Platform != "telegram" || req.DisplayName != "Support" { - t.Fatalf("CreateInstance() req = %#v", req) - } - return &channelspkg.ChannelInstance{ - ID: "chan-core", - Scope: req.Scope, - Platform: req.Platform, - ExtensionName: req.ExtensionName, - DisplayName: req.DisplayName, - Enabled: req.Enabled, - Status: req.Status, - RoutingPolicy: req.RoutingPolicy, - CreatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - }, nil - }, - ListInstancesFn: func(context.Context) ([]channelspkg.ChannelInstance, error) { - return []channelspkg.ChannelInstance{{ - ID: "chan-core", - Scope: channelspkg.ScopeGlobal, - Platform: "telegram", - ExtensionName: "ext-telegram", - DisplayName: "Support", - Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, - }}, nil - }, - GetInstanceFn: func(_ context.Context, id string) (*channelspkg.ChannelInstance, error) { - return &channelspkg.ChannelInstance{ - ID: id, - Scope: channelspkg.ScopeGlobal, - Platform: "telegram", - ExtensionName: "ext-telegram", - DisplayName: "Support", - Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, - }, nil - }, - UpdateInstanceFn: func(_ context.Context, req channelspkg.UpdateInstanceRequest) (*channelspkg.ChannelInstance, error) { - updateCalled = true - if req.ID != "chan-core" || req.DisplayName == nil || *req.DisplayName != "Renamed" { - t.Fatalf("UpdateInstance() req = %#v", req) - } - return &channelspkg.ChannelInstance{ - ID: req.ID, - Scope: channelspkg.ScopeGlobal, - Platform: "telegram", - ExtensionName: "ext-telegram", - DisplayName: *req.DisplayName, - Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, - }, nil - }, - }) - - createResp := performRequest(t, engine, http.MethodPost, "/channels", []byte(`{"scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true}}`)) - if createResp.Code != http.StatusCreated || !createCalled { - t.Fatalf("create status = %d createCalled=%v body=%s", createResp.Code, createCalled, createResp.Body.String()) - } - - listResp := performRequest(t, engine, http.MethodGet, "/channels", nil) - if listResp.Code != http.StatusOK { - t.Fatalf("list status = %d body=%s", listResp.Code, listResp.Body.String()) - } - var listPayload contract.ChannelsResponse - testutil.DecodeJSONResponse(t, listResp, &listPayload) - if got, want := len(listPayload.Channels), 1; got != want { - t.Fatalf("len(channels) = %d, want %d", got, want) - } - - getResp := performRequest(t, engine, http.MethodGet, "/channels/chan-core", nil) - if getResp.Code != http.StatusOK { - t.Fatalf("get status = %d body=%s", getResp.Code, getResp.Body.String()) - } - - updateResp := performRequest(t, engine, http.MethodPatch, "/channels/chan-core", []byte(`{"display_name":"Renamed"}`)) - if updateResp.Code != http.StatusOK || !updateCalled { - t.Fatalf("update status = %d updateCalled=%v body=%s", updateResp.Code, updateCalled, updateResp.Body.String()) - } - -} - -func TestChannelHandlersLifecycleTransitions(t *testing.T) { - t.Parallel() - - _, engine := newChannelHandlerFixture(t, testutil.StubChannelService{ - StartInstanceFn: func(_ context.Context, id string) (*channelspkg.ChannelInstance, error) { - return &channelspkg.ChannelInstance{ID: id, Scope: channelspkg.ScopeGlobal, Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Support", Enabled: true, Status: channelspkg.ChannelStatusStarting, RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}}, nil - }, - StopInstanceFn: func(_ context.Context, id string) (*channelspkg.ChannelInstance, error) { - return &channelspkg.ChannelInstance{ID: id, Scope: channelspkg.ScopeGlobal, Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Support", Enabled: false, Status: channelspkg.ChannelStatusDisabled, RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}}, nil - }, - RestartInstanceFn: func(_ context.Context, id string) (*channelspkg.ChannelInstance, error) { - return &channelspkg.ChannelInstance{ID: id, Scope: channelspkg.ScopeGlobal, Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Support", Enabled: true, Status: channelspkg.ChannelStatusStarting, RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}}, nil - }, - }) - - for _, tc := range []struct { - name string - path string - status channelspkg.ChannelStatus - }{ - {name: "Should enable channel", path: "/channels/chan-core/enable", status: channelspkg.ChannelStatusStarting}, - {name: "Should disable channel", path: "/channels/chan-core/disable", status: channelspkg.ChannelStatusDisabled}, - {name: "Should restart channel", path: "/channels/chan-core/restart", status: channelspkg.ChannelStatusStarting}, - } { - tc := tc - t.Run(tc.name, func(t *testing.T) { - resp := performRequest(t, engine, http.MethodPost, tc.path, nil) - if resp.Code != http.StatusOK { - t.Fatalf("%s status = %d body=%s", tc.path, resp.Code, resp.Body.String()) - } - var payload contract.ChannelResponse - testutil.DecodeJSONResponse(t, resp, &payload) - if payload.Channel.Status != tc.status { - t.Fatalf("%s status payload = %q, want %q", tc.path, payload.Channel.Status, tc.status) - } - }) - } -} - -func TestChannelHandlersRoutesAndTestDelivery(t *testing.T) { - t.Parallel() - - _, engine := newChannelHandlerFixture(t, testutil.StubChannelService{ - ListRoutesFn: func(_ context.Context, channelInstanceID string) ([]channelspkg.ChannelRoute, error) { - return []channelspkg.ChannelRoute{{ - RoutingKeyHash: "hash-1", - Scope: channelspkg.ScopeGlobal, - ChannelInstanceID: channelInstanceID, - PeerID: "peer-1", - ThreadID: "thread-1", - SessionID: "sess-1", - AgentName: "coder", - LastActivityAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - CreatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - }}, nil - }, - ResolveDeliveryTargetFn: func(_ context.Context, req channelspkg.ResolveDeliveryTargetRequest) (*channelspkg.DeliveryTarget, error) { - return &channelspkg.DeliveryTarget{ - ChannelInstanceID: req.ChannelInstanceID, - PeerID: "peer-default", - ThreadID: req.ThreadID, - Mode: channelspkg.DeliveryModeReply, - }, nil - }, - }) - - routesResp := performRequest(t, engine, http.MethodGet, "/channels/chan-core/routes", nil) - if routesResp.Code != http.StatusOK { - t.Fatalf("routes status = %d body=%s", routesResp.Code, routesResp.Body.String()) - } - var routes contract.ChannelRoutesResponse - testutil.DecodeJSONResponse(t, routesResp, &routes) - if got, want := len(routes.Routes), 1; got != want { - t.Fatalf("len(routes) = %d, want %d", got, want) - } - - testResp := performRequest(t, engine, http.MethodPost, "/channels/chan-core/test-delivery", []byte(`{"target":{"thread_id":"thread-1"}}`)) - if testResp.Code != http.StatusOK { - t.Fatalf("test delivery status = %d body=%s", testResp.Code, testResp.Body.String()) - } - var payload contract.ChannelTestDeliveryResponse - testutil.DecodeJSONResponse(t, testResp, &payload) - if payload.DeliveryTarget.ChannelInstanceID != "chan-core" || payload.DeliveryTarget.ThreadID != "thread-1" { - t.Fatalf("payload = %#v", payload) - } -} - -func TestChannelHandlersIncludeObservedHealthPayloads(t *testing.T) { - t.Parallel() - - gin.SetMode(gin.TestMode) - homePaths := testutil.NewTestHomePaths(t) - cfg := aghconfig.DefaultWithHome(homePaths) - cfg.HTTP.Host = "127.0.0.1" - cfg.HTTP.Port = 2123 - - channel := channelspkg.ChannelInstance{ - ID: "chan-health", - Scope: channelspkg.ScopeGlobal, - Platform: "telegram", - ExtensionName: "ext-telegram", - DisplayName: "Support", - Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, - } - - handlers := core.NewBaseHandlers(core.BaseHandlerConfig{ - TransportName: "api-core-test", - MaskInternalErrors: false, - IncludeSessionWorkspaceInSSE: true, - Sessions: testutil.StubSessionManager{}, - Observer: testutil.StubObserver{ - QueryChannelHealthFn: func(context.Context) ([]observe.ChannelInstanceHealth, error) { - return []observe.ChannelInstanceHealth{{ - ChannelInstanceID: channel.ID, - Status: channelspkg.ChannelStatusDegraded, - RouteCount: 2, - DeliveryBacklog: 1, - DeliveryFailuresTotal: 3, - AuthFailuresTotal: 1, - LastError: "adapter unavailable", - }}, nil - }, - }, - Channels: testutil.StubChannelService{ListInstancesFn: func(context.Context) ([]channelspkg.ChannelInstance, error) { - return []channelspkg.ChannelInstance{channel}, nil - }, GetInstanceFn: func(context.Context, string) (*channelspkg.ChannelInstance, error) { return &channel, nil }}, - Workspaces: testutil.StubWorkspaceService{}, - HomePaths: homePaths, - Config: cfg, - Logger: testutil.DiscardLogger(), - StartedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC), - Now: func() time.Time { - return time.Date(2026, 4, 3, 12, 0, 1, 0, time.UTC) - }, - HTTPPort: cfg.HTTP.Port, - }) - - engine := gin.New() - engine.GET("/channels", handlers.ListChannels) - engine.GET("/channels/:id", handlers.GetChannel) - - listResp := performRequest(t, engine, http.MethodGet, "/channels", nil) - if listResp.Code != http.StatusOK { - t.Fatalf("list status = %d body=%s", listResp.Code, listResp.Body.String()) - } - var listPayload contract.ChannelsResponse - testutil.DecodeJSONResponse(t, listResp, &listPayload) - if got, want := listPayload.ChannelHealth[channel.ID].DeliveryBacklog, 1; got != want { - t.Fatalf("channel_health backlog = %d, want %d", got, want) - } - - getResp := performRequest(t, engine, http.MethodGet, "/channels/"+channel.ID, nil) - if getResp.Code != http.StatusOK { - t.Fatalf("get status = %d body=%s", getResp.Code, getResp.Body.String()) - } - var getPayload contract.ChannelResponse - testutil.DecodeJSONResponse(t, getResp, &getPayload) - if got, want := getPayload.Health.Status, channelspkg.ChannelStatusDegraded; got != want { - t.Fatalf("get health status = %q, want %q", got, want) - } - if got, want := getPayload.Health.RouteCount, 2; got != want { - t.Fatalf("get health route_count = %d, want %d", got, want) - } -} - -func TestChannelHandlersReturnServiceUnavailableWhenNotConfigured(t *testing.T) { - t.Parallel() - - _, engine := newChannelHandlerFixture(t, nil) - resp := performRequest(t, engine, http.MethodGet, "/channels", nil) - if resp.Code != http.StatusServiceUnavailable { - t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusServiceUnavailable, resp.Body.String()) - } -} - -func newChannelHandlerFixture(t *testing.T, channels core.ChannelService) (*core.BaseHandlers, *gin.Engine) { - t.Helper() - - gin.SetMode(gin.TestMode) - homePaths := testutil.NewTestHomePaths(t) - cfg := aghconfig.DefaultWithHome(homePaths) - cfg.HTTP.Host = "127.0.0.1" - cfg.HTTP.Port = 2123 - - handlers := core.NewBaseHandlers(core.BaseHandlerConfig{ - TransportName: "api-core-test", - MaskInternalErrors: false, - IncludeSessionWorkspaceInSSE: true, - Sessions: testutil.StubSessionManager{}, - Observer: testutil.StubObserver{}, - Channels: channels, - Workspaces: testutil.StubWorkspaceService{}, - HomePaths: homePaths, - Config: cfg, - Logger: testutil.DiscardLogger(), - StartedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC), - Now: func() time.Time { - return time.Date(2026, 4, 3, 12, 0, 1, 0, time.UTC) - }, - HTTPPort: cfg.HTTP.Port, - }) - - engine := gin.New() - engine.Use(gin.Recovery()) - engine.GET("/channels", handlers.ListChannels) - engine.POST("/channels", handlers.CreateChannel) - engine.GET("/channels/:id", handlers.GetChannel) - engine.PATCH("/channels/:id", handlers.UpdateChannel) - engine.POST("/channels/:id/enable", handlers.EnableChannel) - engine.POST("/channels/:id/disable", handlers.DisableChannel) - engine.POST("/channels/:id/restart", handlers.RestartChannel) - engine.GET("/channels/:id/routes", handlers.ListChannelRoutes) - engine.POST("/channels/:id/test-delivery", handlers.TestChannelDelivery) - return handlers, engine -} diff --git a/internal/api/core/conversions.go b/internal/api/core/conversions.go index 57e9dcb58..6937e017b 100644 --- a/internal/api/core/conversions.go +++ b/internal/api/core/conversions.go @@ -33,7 +33,7 @@ func SessionPayloadFromInfo(info *session.SessionInfo) contract.SessionPayload { AgentName: info.AgentName, WorkspaceID: ref.WorkspaceID, WorkspacePath: ref.WorkspacePath, - Space: info.Space, + Channel: info.Channel, State: info.State, StopReason: info.StopReason, StopDetail: info.StopDetail, @@ -194,7 +194,7 @@ func ObserveHealthPayloadFromHealth(health observepkg.Health) contract.ObserveHe ActiveAgents: health.ActiveAgents, GlobalDBSizeBytes: health.GlobalDBSizeBytes, SessionDBSizeBytes: health.SessionDBSizeBytes, - Channels: ChannelAggregateHealthPayloadFromObserve(health.Channels), + Bridges: BridgeAggregateHealthPayloadFromObserve(health.Bridges), Version: health.Version, } } @@ -317,17 +317,17 @@ func WebhookDeliveryPayloadFromResult(result automationpkg.TriggerResult) contra } } -// ChannelAggregateHealthPayloadFromObserve converts the observer channel +// BridgeAggregateHealthPayloadFromObserve converts the observer bridge // summary into the shared payload. -func ChannelAggregateHealthPayloadFromObserve(summary observepkg.ChannelAggregateHealth) contract.ChannelAggregateHealthPayload { - return contract.ChannelAggregateHealthPayload{ +func BridgeAggregateHealthPayloadFromObserve(summary observepkg.BridgeAggregateHealth) contract.BridgeAggregateHealthPayload { + return contract.BridgeAggregateHealthPayload{ TotalInstances: summary.TotalInstances, RouteCount: summary.RouteCount, DeliveryBacklog: summary.DeliveryBacklog, DeliveryDroppedTotal: summary.DeliveryDroppedTotal, DeliveryFailuresTotal: summary.DeliveryFailuresTotal, AuthFailuresTotal: summary.AuthFailuresTotal, - StatusCounts: contract.ChannelStatusCountsPayload{ + StatusCounts: contract.BridgeStatusCountsPayload{ Disabled: summary.StatusCounts.Disabled, Starting: summary.StatusCounts.Starting, Ready: summary.StatusCounts.Ready, @@ -338,17 +338,17 @@ func ChannelAggregateHealthPayloadFromObserve(summary observepkg.ChannelAggregat } } -// ChannelHealthPayloadFromObserve converts the observer per-instance channel +// BridgeHealthPayloadFromObserve converts the observer per-instance bridge // health snapshot into the shared payload. -func ChannelHealthPayloadFromObserve(health observepkg.ChannelInstanceHealth) contract.ChannelHealthPayload { +func BridgeHealthPayloadFromObserve(health observepkg.BridgeInstanceHealth) contract.BridgeHealthPayload { var lastErrorAt *time.Time if !health.LastErrorAt.IsZero() { timestamp := health.LastErrorAt lastErrorAt = ×tamp } - return contract.ChannelHealthPayload{ - ChannelInstanceID: health.ChannelInstanceID, + return contract.BridgeHealthPayload{ + BridgeInstanceID: health.BridgeInstanceID, Status: health.Status, RouteCount: health.RouteCount, DeliveryBacklog: health.DeliveryBacklog, diff --git a/internal/api/core/conversions_parsers_test.go b/internal/api/core/conversions_parsers_test.go index 0dd1db887..d0bc33f37 100644 --- a/internal/api/core/conversions_parsers_test.go +++ b/internal/api/core/conversions_parsers_test.go @@ -27,7 +27,7 @@ func TestSessionPayloadFromInfo(t *testing.T) { AgentName: "coder", WorkspaceID: "ws_alpha", Workspace: "/workspace", - Space: "builders", + Channel: "builders", State: session.StateActive, StopReason: store.StopTimeout, StopDetail: "deadline exceeded", @@ -41,7 +41,7 @@ func TestSessionPayloadFromInfo(t *testing.T) { }, }) - if payload.ID != "sess-1" || payload.WorkspaceID != "ws_alpha" || payload.WorkspacePath != "/workspace" || payload.Space != "builders" { + if payload.ID != "sess-1" || payload.WorkspaceID != "ws_alpha" || payload.WorkspacePath != "/workspace" || payload.Channel != "builders" { t.Fatalf("payload = %#v", payload) } if payload.StopReason != store.StopTimeout || payload.StopDetail != "deadline exceeded" { diff --git a/internal/api/core/errors.go b/internal/api/core/errors.go index e65b60503..ab226462e 100644 --- a/internal/api/core/errors.go +++ b/internal/api/core/errors.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pedronauck/agh/internal/api/contract" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" "github.com/pedronauck/agh/internal/memory" "github.com/pedronauck/agh/internal/network" workspacepkg "github.com/pedronauck/agh/internal/workspace" @@ -65,26 +65,26 @@ func StatusForMemoryError(err error) int { } } -// StatusForChannelError maps channel-domain and workspace-domain errors to transport statuses. -func StatusForChannelError(err error) int { +// StatusForBridgeError maps bridge-domain and workspace-domain errors to transport statuses. +func StatusForBridgeError(err error) int { switch { case err == nil: return http.StatusOK - case errors.Is(err, contract.ErrChannelInstanceMismatch): + case errors.Is(err, contract.ErrBridgeInstanceMismatch): return http.StatusBadRequest - case errors.Is(err, channelspkg.ErrChannelInstanceNotFound): + case errors.Is(err, bridgepkg.ErrBridgeInstanceNotFound): return http.StatusNotFound - case errors.Is(err, channelspkg.ErrChannelRouteNotFound): + case errors.Is(err, bridgepkg.ErrBridgeRouteNotFound): return http.StatusNotFound - case errors.Is(err, channelspkg.ErrChannelInstanceUnavailable): + case errors.Is(err, bridgepkg.ErrBridgeInstanceUnavailable): return http.StatusConflict - case errors.Is(err, channelspkg.ErrInvalidChannelStateTransition): + case errors.Is(err, bridgepkg.ErrInvalidBridgeStateTransition): return http.StatusConflict - case errors.Is(err, channelspkg.ErrDeliveryNotFound): + case errors.Is(err, bridgepkg.ErrDeliveryNotFound): return http.StatusNotFound - case errors.Is(err, channelspkg.ErrDeliveryQueueSaturated): + case errors.Is(err, bridgepkg.ErrDeliveryQueueSaturated): return http.StatusServiceUnavailable - case errors.Is(err, channelspkg.ErrDeliveryTransportUnavailable): + case errors.Is(err, bridgepkg.ErrDeliveryTransportUnavailable): return http.StatusServiceUnavailable case errors.Is(err, workspacepkg.ErrWorkspaceNotFound): return http.StatusNotFound diff --git a/internal/api/core/errors_test.go b/internal/api/core/errors_test.go index e2a55ebdd..4b9896bb3 100644 --- a/internal/api/core/errors_test.go +++ b/internal/api/core/errors_test.go @@ -6,11 +6,11 @@ import ( "testing" "github.com/pedronauck/agh/internal/api/contract" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) -func TestStatusForChannelError(t *testing.T) { +func TestStatusForBridgeError(t *testing.T) { t.Parallel() tests := []struct { @@ -20,17 +20,17 @@ func TestStatusForChannelError(t *testing.T) { }{ { name: "Should return bad request for body path mismatch", - err: contract.ErrChannelInstanceMismatch, + err: contract.ErrBridgeInstanceMismatch, want: http.StatusBadRequest, }, { - name: "Should return not found for missing channel", - err: channelspkg.ErrChannelInstanceNotFound, + name: "Should return not found for missing bridge", + err: bridgepkg.ErrBridgeInstanceNotFound, want: http.StatusNotFound, }, { name: "Should return not found for missing route", - err: channelspkg.ErrChannelRouteNotFound, + err: bridgepkg.ErrBridgeRouteNotFound, want: http.StatusNotFound, }, { @@ -40,27 +40,27 @@ func TestStatusForChannelError(t *testing.T) { }, { name: "Should return conflict for unavailable instance", - err: channelspkg.ErrChannelInstanceUnavailable, + err: bridgepkg.ErrBridgeInstanceUnavailable, want: http.StatusConflict, }, { name: "Should return conflict for invalid state transition", - err: channelspkg.ErrInvalidChannelStateTransition, + err: bridgepkg.ErrInvalidBridgeStateTransition, want: http.StatusConflict, }, { name: "Should return not found for missing delivery", - err: channelspkg.ErrDeliveryNotFound, + err: bridgepkg.ErrDeliveryNotFound, want: http.StatusNotFound, }, { name: "Should return service unavailable for saturated delivery queue", - err: channelspkg.ErrDeliveryQueueSaturated, + err: bridgepkg.ErrDeliveryQueueSaturated, want: http.StatusServiceUnavailable, }, { name: "Should return service unavailable for transport outage", - err: channelspkg.ErrDeliveryTransportUnavailable, + err: bridgepkg.ErrDeliveryTransportUnavailable, want: http.StatusServiceUnavailable, }, { @@ -74,8 +74,8 @@ func TestStatusForChannelError(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - if got := StatusForChannelError(tt.err); got != tt.want { - t.Fatalf("StatusForChannelError(%v) = %d, want %d", tt.err, got, tt.want) + if got := StatusForBridgeError(tt.err); got != tt.want { + t.Fatalf("StatusForBridgeError(%v) = %d, want %d", tt.err, got, tt.want) } }) } diff --git a/internal/api/core/handlers.go b/internal/api/core/handlers.go index fd7e28552..80cb18e31 100644 --- a/internal/api/core/handlers.go +++ b/internal/api/core/handlers.go @@ -32,7 +32,7 @@ type BaseHandlerConfig struct { Network NetworkService Observer Observer Automation AutomationManager - Channels ChannelService + Bridges BridgeService Workspaces WorkspaceService SkillsRegistry SkillsRegistry MemoryStore *memory.Store @@ -58,7 +58,7 @@ type BaseHandlers struct { Network NetworkService Observer Observer Automation AutomationManager - Channels ChannelService + Bridges BridgeService Workspaces WorkspaceService SkillsRegistry SkillsRegistry MemoryStore *memory.Store @@ -107,7 +107,7 @@ func NewBaseHandlers(cfg BaseHandlerConfig) *BaseHandlers { } if cfg.StreamDone == nil { - logger.Warn("api: stream shutdown channel not provided; streaming handlers will rely on caller context until a transport installs one") + logger.Warn("api: stream shutdown bridge not provided; streaming handlers will rely on caller context until a transport installs one") cfg.StreamDone = make(chan struct{}) } @@ -119,7 +119,7 @@ func NewBaseHandlers(cfg BaseHandlerConfig) *BaseHandlers { Network: cfg.Network, Observer: cfg.Observer, Automation: cfg.Automation, - Channels: cfg.Channels, + Bridges: cfg.Bridges, Workspaces: cfg.Workspaces, SkillsRegistry: cfg.SkillsRegistry, MemoryStore: cfg.MemoryStore, @@ -138,13 +138,13 @@ func NewBaseHandlers(cfg BaseHandlerConfig) *BaseHandlers { return handlers } -// SetStreamDone updates the transport shutdown channel used by streaming handlers. +// SetStreamDone updates the transport shutdown bridge used by streaming handlers. func (h *BaseHandlers) SetStreamDone(done <-chan struct{}) { if h == nil { return } if done == nil { - h.Logger.Warn("api: stream shutdown channel cleared; streaming handlers will rely on caller context until a transport installs one") + h.Logger.Warn("api: stream shutdown bridge cleared; streaming handlers will rely on caller context until a transport installs one") done = make(chan struct{}) } h.settingsMu.Lock() @@ -198,7 +198,7 @@ func (h *BaseHandlers) CreateSession(c *gin.Context) { Name: req.Name, Workspace: strings.TrimSpace(req.Workspace), WorkspacePath: strings.TrimSpace(req.WorkspacePath), - Space: strings.TrimSpace(req.Space), + Channel: strings.TrimSpace(req.Channel), Type: session.SessionTypeUser, }) if err != nil { diff --git a/internal/api/core/handlers_test.go b/internal/api/core/handlers_test.go index e9e5958a5..c851b3eb4 100644 --- a/internal/api/core/handlers_test.go +++ b/internal/api/core/handlers_test.go @@ -303,7 +303,7 @@ func TestDaemonStatusIncludesNetworkDiagnosticsWithoutCredentials(t *testing.T) ListenerPort: 4222, LocalPeers: 1, RemotePeers: 2, - Spaces: 3, + Channels: 3, }, nil }, } diff --git a/internal/api/core/hooks_test.go b/internal/api/core/hooks_test.go index 1b88da4e4..dbbd9e7dd 100644 --- a/internal/api/core/hooks_test.go +++ b/internal/api/core/hooks_test.go @@ -75,7 +75,7 @@ func TestHookParsersAndPayloadConverters(t *testing.T) { catalogPayloads := core.HookCatalogPayloadsFromEntries([]hookspkg.CatalogEntry{{ Order: 1, - Name: "space-opt-in", + Name: "channel-opt-in", Event: hookspkg.HookToolPreCall, Source: hookspkg.HookSourceSkill, SkillSource: hookspkg.HookSkillSourceWorkspace, @@ -99,7 +99,7 @@ func TestHookParsersAndPayloadConverters(t *testing.T) { } runPayloads := core.HookRunPayloadsFromRecords([]hookspkg.HookRunRecord{{ - HookName: "space-opt-in", + HookName: "channel-opt-in", Event: hookspkg.HookToolPreCall, Source: hookspkg.HookSourceSkill, Mode: hookspkg.HookModeSync, @@ -155,7 +155,7 @@ func TestHookHandlers(t *testing.T) { } return []hookspkg.CatalogEntry{{ Order: 1, - Name: "space-opt-in", + Name: "channel-opt-in", Event: hookspkg.HookToolPreCall, Source: hookspkg.HookSourceSkill, SkillSource: hookspkg.HookSkillSourceWorkspace, @@ -172,7 +172,7 @@ func TestHookHandlers(t *testing.T) { t.Fatalf("QueryHookRuns() query = %#v", query) } return []hookspkg.HookRunRecord{{ - HookName: "space-opt-in", + HookName: "channel-opt-in", Event: hookspkg.HookToolPreCall, Source: hookspkg.HookSourceSkill, Mode: hookspkg.HookModeSync, diff --git a/internal/api/core/interfaces.go b/internal/api/core/interfaces.go index fa4deb526..56ce29650 100644 --- a/internal/api/core/interfaces.go +++ b/internal/api/core/interfaces.go @@ -7,7 +7,7 @@ import ( "github.com/pedronauck/agh/internal/acp" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" aghconfig "github.com/pedronauck/agh/internal/config" hookspkg "github.com/pedronauck/agh/internal/hooks" "github.com/pedronauck/agh/internal/network" @@ -46,24 +46,24 @@ type Observer interface { QueryHookCatalog(ctx context.Context, filter hookspkg.CatalogFilter) ([]hookspkg.CatalogEntry, error) QueryHookRuns(ctx context.Context, query store.HookRunQuery) ([]hookspkg.HookRunRecord, error) QueryHookEvents(ctx context.Context, filter hookspkg.EventFilter) ([]hookspkg.EventDescriptor, error) - QueryChannelHealth(ctx context.Context) ([]observe.ChannelInstanceHealth, error) + QueryBridgeHealth(ctx context.Context) ([]observe.BridgeInstanceHealth, error) Health(ctx context.Context) (observe.Health, error) } -// ChannelService is the daemon-owned channel runtime surface exposed by API transports. -type ChannelService interface { - channelspkg.Registry - channelspkg.TargetResolver - StartInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) - StopInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) - RestartInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) +// BridgeService is the daemon-owned bridge runtime surface exposed by API transports. +type BridgeService interface { + bridgepkg.Registry + bridgepkg.TargetResolver + StartInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) + StopInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) + RestartInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) } // NetworkService is the runtime network surface exposed to daemon transports. type NetworkService interface { Send(ctx context.Context, req network.SendRequest) (string, error) - ListPeers(ctx context.Context, space string) ([]network.PeerInfo, error) - ListSpaces(ctx context.Context) ([]network.SpaceInfo, error) + ListPeers(ctx context.Context, channel string) ([]network.PeerInfo, error) + ListChannels(ctx context.Context) ([]network.ChannelInfo, error) Status(ctx context.Context) (*network.NetworkStatus, error) Inbox(ctx context.Context, sessionID string) ([]network.Envelope, error) } diff --git a/internal/api/core/network.go b/internal/api/core/network.go index 1ad7a3469..a5467a340 100644 --- a/internal/api/core/network.go +++ b/internal/api/core/network.go @@ -34,7 +34,7 @@ func (h *BaseHandlers) NetworkStatus(c *gin.Context) { c.JSON(http.StatusOK, contract.NetworkStatusResponse{Network: *payload}) } -// NetworkPeers returns the current visible peers, optionally filtered by space. +// NetworkPeers returns the current visible peers, optionally filtered by channel. func (h *BaseHandlers) NetworkPeers(c *gin.Context) { service, err := h.networkServiceRequired() if err != nil { @@ -42,7 +42,7 @@ func (h *BaseHandlers) NetworkPeers(c *gin.Context) { return } - peers, err := service.ListPeers(c.Request.Context(), strings.TrimSpace(c.Query("space"))) + peers, err := service.ListPeers(c.Request.Context(), strings.TrimSpace(c.Query("channel"))) if err != nil { h.respondError(c, StatusForNetworkError(err), err) return @@ -50,20 +50,20 @@ func (h *BaseHandlers) NetworkPeers(c *gin.Context) { c.JSON(http.StatusOK, contract.NetworkPeersResponse{Peers: NetworkPeerPayloadsFromInfos(peers)}) } -// NetworkSpaces returns the active runtime spaces. -func (h *BaseHandlers) NetworkSpaces(c *gin.Context) { +// NetworkChannels returns the active runtime channels. +func (h *BaseHandlers) NetworkChannels(c *gin.Context) { service, err := h.networkServiceRequired() if err != nil { h.respondError(c, http.StatusServiceUnavailable, err) return } - spaces, err := service.ListSpaces(c.Request.Context()) + channels, err := service.ListChannels(c.Request.Context()) if err != nil { h.respondError(c, StatusForNetworkError(err), err) return } - c.JSON(http.StatusOK, contract.NetworkSpacesResponse{Spaces: NetworkSpacePayloadsFromInfos(spaces)}) + c.JSON(http.StatusOK, contract.NetworkChannelsResponse{Channels: NetworkChannelPayloadsFromInfos(channels)}) } // NetworkSend validates and forwards one outbound network send request. @@ -143,7 +143,7 @@ func NetworkStatusPayloadFromStatus(status *network.NetworkStatus) *contract.Net ListenerPort: status.ListenerPort, LocalPeers: status.LocalPeers, RemotePeers: status.RemotePeers, - Spaces: status.Spaces, + Channels: status.Channels, QueuedMessages: status.QueuedMessages, QueuedSessions: status.QueuedSessions, DeliveryWorkers: status.DeliveryWorkers, @@ -163,8 +163,8 @@ func NetworkSendRequestFromPayload(req contract.NetworkSendRequest) (network.Sen if strings.TrimSpace(req.SessionID) == "" { return network.SendRequest{}, NewNetworkValidationError(errors.New("session_id is required")) } - if strings.TrimSpace(req.Space) == "" { - return network.SendRequest{}, NewNetworkValidationError(errors.New("space is required")) + if strings.TrimSpace(req.Channel) == "" { + return network.SendRequest{}, NewNetworkValidationError(errors.New("channel is required")) } if strings.TrimSpace(req.Kind) == "" { return network.SendRequest{}, NewNetworkValidationError(errors.New("kind is required")) @@ -178,7 +178,7 @@ func NetworkSendRequestFromPayload(req contract.NetworkSendRequest) (network.Sen sendReq := network.SendRequest{ SessionID: strings.TrimSpace(req.SessionID), - Space: strings.TrimSpace(req.Space), + Channel: strings.TrimSpace(req.Channel), Kind: network.Kind(strings.TrimSpace(req.Kind)), Body: cloneRawMessage(req.Body), ExpiresAt: cloneInt64Ptr(req.ExpiresAt), @@ -211,7 +211,7 @@ func NetworkSendPayloadFromRequest(id string, req contract.NetworkSendRequest) c return contract.NetworkSendPayload{ ID: strings.TrimSpace(id), SessionID: strings.TrimSpace(req.SessionID), - Space: strings.TrimSpace(req.Space), + Channel: strings.TrimSpace(req.Channel), Kind: strings.TrimSpace(req.Kind), To: strings.TrimSpace(req.To), InteractionID: strings.TrimSpace(req.InteractionID), @@ -237,7 +237,7 @@ func NetworkPeerPayloadFromInfo(peer network.PeerInfo) contract.NetworkPeerPaylo return contract.NetworkPeerPayload{ SessionID: peer.SessionID, PeerID: peer.PeerID, - Space: peer.Space, + Channel: peer.Channel, Local: peer.Local, PeerCard: contract.NetworkPeerCardPayload{ PeerID: peer.PeerCard.PeerID, @@ -254,13 +254,13 @@ func NetworkPeerPayloadFromInfo(peer network.PeerInfo) contract.NetworkPeerPaylo } } -// NetworkSpacePayloadsFromInfos converts active space summaries into shared payloads. -func NetworkSpacePayloadsFromInfos(spaces []network.SpaceInfo) []contract.NetworkSpacePayload { - payload := make([]contract.NetworkSpacePayload, 0, len(spaces)) - for _, space := range spaces { - payload = append(payload, contract.NetworkSpacePayload{ - Space: space.Space, - PeerCount: space.PeerCount, +// NetworkChannelPayloadsFromInfos converts active channel summaries into shared payloads. +func NetworkChannelPayloadsFromInfos(channels []network.ChannelInfo) []contract.NetworkChannelPayload { + payload := make([]contract.NetworkChannelPayload, 0, len(channels)) + for _, channel := range channels { + payload = append(payload, contract.NetworkChannelPayload{ + Channel: channel.Channel, + PeerCount: channel.PeerCount, }) } return payload @@ -281,7 +281,7 @@ func NetworkEnvelopePayloadFromEnvelope(envelope network.Envelope) contract.Netw Protocol: envelope.Protocol, ID: envelope.ID, Kind: string(envelope.Kind), - Space: envelope.Space, + Channel: envelope.Channel, From: envelope.From, To: cloneStringPtr(envelope.To), InteractionID: cloneStringPtr(envelope.InteractionID), diff --git a/internal/api/core/network_test.go b/internal/api/core/network_test.go index 36d7941f1..600610c1e 100644 --- a/internal/api/core/network_test.go +++ b/internal/api/core/network_test.go @@ -31,7 +31,7 @@ func TestNetworkConversionHelpersPreserveMetadata(t *testing.T) { ListenerPort: 4222, LocalPeers: 1, RemotePeers: 2, - Spaces: 1, + Channels: 1, QueuedMessages: 3, QueuedSessions: 1, DeliveryWorkers: 1, @@ -62,7 +62,7 @@ func TestNetworkConversionHelpersPreserveMetadata(t *testing.T) { req := contract.NetworkSendRequest{ SessionID: " sess-a ", - Space: " builders ", + Channel: " builders ", Kind: "say", To: " reviewer.sess-b ", Body: json.RawMessage(`{"text":"hello"}`), @@ -82,7 +82,7 @@ func TestNetworkConversionHelpersPreserveMetadata(t *testing.T) { if err != nil { t.Fatalf("NetworkSendRequestFromPayload() error = %v", err) } - if converted.SessionID != "sess-a" || converted.Space != "builders" || converted.Kind != network.KindSay { + if converted.SessionID != "sess-a" || converted.Channel != "builders" || converted.Kind != network.KindSay { t.Fatalf("converted request = %#v", converted) } if converted.To == nil || *converted.To != "reviewer.sess-b" { @@ -110,7 +110,7 @@ func TestNetworkConversionHelpersPreserveMetadata(t *testing.T) { Protocol: network.ProtocolV0, ID: "msg-1", Kind: network.KindDirect, - Space: "builders", + Channel: "builders", From: "reviewer.sess-b", ReplyTo: &replyTo, TraceID: &traceID, @@ -154,7 +154,7 @@ func TestBaseHandlersNetworkEndpoints(t *testing.T) { ListenerPort: 4222, LocalPeers: 1, RemotePeers: 1, - Spaces: 1, + Channels: 1, QueuedMessages: 2, QueuedSessions: 1, DeliveryWorkers: 1, @@ -174,16 +174,16 @@ func TestBaseHandlersNetworkEndpoints(t *testing.T) { }}, }, nil }, - ListPeersFn: func(_ context.Context, space string) ([]network.PeerInfo, error) { - if space != "builders" { - t.Fatalf("ListPeers() space = %q, want builders", space) + ListPeersFn: func(_ context.Context, channel string) ([]network.PeerInfo, error) { + if channel != "builders" { + t.Fatalf("ListPeers() channel = %q, want builders", channel) } displayName := "Reviewer" sessionID := "sess-a" return []network.PeerInfo{{ SessionID: &sessionID, PeerID: "reviewer.sess-a", - Space: "builders", + Channel: "builders", Local: true, PeerCard: network.PeerCard{ PeerID: "reviewer.sess-a", @@ -199,11 +199,11 @@ func TestBaseHandlersNetworkEndpoints(t *testing.T) { ExpiresAt: timePtr(fixedNow.Add(time.Minute)), }}, nil }, - ListSpacesFn: func(context.Context) ([]network.SpaceInfo, error) { - return []network.SpaceInfo{{Space: "builders", PeerCount: 2}}, nil + ListChannelsFn: func(context.Context) ([]network.ChannelInfo, error) { + return []network.ChannelInfo{{Channel: "builders", PeerCount: 2}}, nil }, SendFn: func(_ context.Context, req network.SendRequest) (string, error) { - if req.SessionID != "sess-a" || req.Space != "builders" || req.Kind != network.KindSay { + if req.SessionID != "sess-a" || req.Channel != "builders" || req.Kind != network.KindSay { t.Fatalf("Send() req = %#v", req) } if string(req.Ext["agh.workflow_id"]) != `"wf-1"` || string(req.Ext["agh.handoff_version"]) != `3` { @@ -222,7 +222,7 @@ func TestBaseHandlersNetworkEndpoints(t *testing.T) { Protocol: network.ProtocolV0, ID: "msg-inbox", Kind: network.KindDirect, - Space: "builders", + Channel: "builders", From: "reviewer.sess-a", ReplyTo: &replyTo, TraceID: &traceID, @@ -254,7 +254,7 @@ func TestBaseHandlersNetworkEndpoints(t *testing.T) { }) t.Run("ShouldListNetworkPeers", func(t *testing.T) { - peersResp := performRequest(t, fixture.Engine, http.MethodGet, "/network/peers?space=builders", nil) + peersResp := performRequest(t, fixture.Engine, http.MethodGet, "/network/peers?channel=builders", nil) if peersResp.Code != http.StatusOK { t.Fatalf("peers code = %d, want %d", peersResp.Code, http.StatusOK) } @@ -266,21 +266,21 @@ func TestBaseHandlersNetworkEndpoints(t *testing.T) { } }) - t.Run("ShouldListNetworkSpaces", func(t *testing.T) { - spacesResp := performRequest(t, fixture.Engine, http.MethodGet, "/network/spaces", nil) - if spacesResp.Code != http.StatusOK { - t.Fatalf("spaces code = %d, want %d", spacesResp.Code, http.StatusOK) + t.Run("ShouldListNetworkChannels", func(t *testing.T) { + channelsResp := performRequest(t, fixture.Engine, http.MethodGet, "/network/channels", nil) + if channelsResp.Code != http.StatusOK { + t.Fatalf("channels code = %d, want %d", channelsResp.Code, http.StatusOK) } - var spacesPayload contract.NetworkSpacesResponse - testutil.DecodeJSONResponse(t, spacesResp, &spacesPayload) - if len(spacesPayload.Spaces) != 1 || spacesPayload.Spaces[0].PeerCount != 2 { - t.Fatalf("spaces payload = %#v", spacesPayload.Spaces) + var channelsPayload contract.NetworkChannelsResponse + testutil.DecodeJSONResponse(t, channelsResp, &channelsPayload) + if len(channelsPayload.Channels) != 1 || channelsPayload.Channels[0].PeerCount != 2 { + t.Fatalf("channels payload = %#v", channelsPayload.Channels) } }) t.Run("ShouldSendNetworkMessages", func(t *testing.T) { - sendResp := performRequest(t, fixture.Engine, http.MethodPost, "/network/send", []byte(`{"session_id":"sess-a","space":"builders","kind":"say","body":{"text":"hello"},"ext":{"agh.workflow_id":"wf-1","agh.handoff_version":3}}`)) + sendResp := performRequest(t, fixture.Engine, http.MethodPost, "/network/send", []byte(`{"session_id":"sess-a","channel":"builders","kind":"say","body":{"text":"hello"},"ext":{"agh.workflow_id":"wf-1","agh.handoff_version":3}}`)) if sendResp.Code != http.StatusOK { t.Fatalf("send code = %d, want %d; body=%s", sendResp.Code, http.StatusOK, sendResp.Body.String()) } @@ -373,25 +373,25 @@ func TestBaseHandlersNetworkErrorsAndDisabledMode(t *testing.T) { } }) - t.Run("ShouldMapListSpacesErrorTo400", func(t *testing.T) { + t.Run("ShouldMapListChannelsErrorTo400", func(t *testing.T) { t.Parallel() fixture := newHandlerFixture(t, testutil.StubSessionManager{}, testutil.StubObserver{}, testutil.StubWorkspaceService{}, nil, nil) fixture.Handlers.Config.Network.Enabled = true fixture.Handlers.Network = testutil.StubNetworkService{ - ListSpacesFn: func(context.Context) ([]network.SpaceInfo, error) { + ListChannelsFn: func(context.Context) ([]network.ChannelInfo, error) { return nil, network.ErrInvalidField }, } - resp := performRequest(t, fixture.Engine, http.MethodGet, "/network/spaces", nil) + resp := performRequest(t, fixture.Engine, http.MethodGet, "/network/channels", nil) if resp.Code != http.StatusBadRequest { - t.Fatalf("spaces error code = %d, want %d", resp.Code, http.StatusBadRequest) + t.Fatalf("channels error code = %d, want %d", resp.Code, http.StatusBadRequest) } var payload contract.ErrorPayload testutil.DecodeJSONResponse(t, resp, &payload) if !strings.Contains(payload.Error, network.ErrInvalidField.Error()) { - t.Fatalf("spaces error payload = %#v, want invalid field", payload) + t.Fatalf("channels error payload = %#v, want invalid field", payload) } }) @@ -424,7 +424,7 @@ func TestBaseHandlersNetworkErrorsAndDisabledMode(t *testing.T) { }, } - resp := performRequest(t, fixture.Engine, http.MethodPost, "/network/send", []byte(`{"session_id":"sess-a","space":"builders","kind":"say","body":{"text":"hello"}}`)) + resp := performRequest(t, fixture.Engine, http.MethodPost, "/network/send", []byte(`{"session_id":"sess-a","channel":"builders","kind":"say","body":{"text":"hello"}}`)) if resp.Code != http.StatusNotFound { t.Fatalf("send error code = %d, want %d", resp.Code, http.StatusNotFound) } diff --git a/internal/api/core/test_helpers_test.go b/internal/api/core/test_helpers_test.go index f2c73dcb6..5fdc530e7 100644 --- a/internal/api/core/test_helpers_test.go +++ b/internal/api/core/test_helpers_test.go @@ -134,7 +134,7 @@ func newHandlerFixtureWithAutomation( engine.GET("/daemon/status", handlers.DaemonStatus) engine.GET("/network/status", handlers.NetworkStatus) engine.GET("/network/peers", handlers.NetworkPeers) - engine.GET("/network/spaces", handlers.NetworkSpaces) + engine.GET("/network/channels", handlers.NetworkChannels) engine.POST("/network/send", handlers.NetworkSend) engine.GET("/network/inbox", handlers.NetworkInbox) engine.GET("/memory", handlers.ListMemory) diff --git a/internal/api/httpapi/bridges_integration_test.go b/internal/api/httpapi/bridges_integration_test.go new file mode 100644 index 000000000..b5beed42d --- /dev/null +++ b/internal/api/httpapi/bridges_integration_test.go @@ -0,0 +1,400 @@ +//go:build integration + +package httpapi + +import ( + "context" + "io" + "net/http" + "testing" + "time" + + "github.com/pedronauck/agh/internal/api/contract" + bridgepkg "github.com/pedronauck/agh/internal/bridges" + "github.com/pedronauck/agh/internal/testutil" +) + +type blockingHTTPDeliveryTransport struct { + releaseCh chan struct{} +} + +func (t *blockingHTTPDeliveryTransport) DeliverBridge(ctx context.Context, _ string, req bridgepkg.DeliveryRequest) (bridgepkg.DeliveryAck, error) { + if t != nil && req.Event.EventType == bridgepkg.DeliveryEventTypeStart { + select { + case <-t.releaseCh: + case <-ctx.Done(): + return bridgepkg.DeliveryAck{}, ctx.Err() + } + } + return bridgepkg.DeliveryAck{ + DeliveryID: req.Event.DeliveryID, + Seq: req.Event.Seq, + }, nil +} + +func TestHTTPBridgeCreateReturnsPersistedPayload(t *testing.T) { + runtime := newIntegrationRuntime(t) + + resp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/bridges"), []byte(`{"scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true}}`), nil) + if resp.StatusCode != http.StatusCreated { + body := mustReadAll(t, resp.Body) + t.Fatalf("create bridge status = %d, want %d; body=%s", resp.StatusCode, http.StatusCreated, body) + } + + var payload contract.BridgeResponse + decodeHTTPJSON(t, resp, &payload) + if payload.Bridge.ID == "" || payload.Bridge.Platform != "telegram" || payload.Bridge.ExtensionName != "ext-telegram" { + t.Fatalf("payload.Bridge = %#v", payload.Bridge) + } + + stored, err := runtime.registry.GetBridgeInstance(context.Background(), payload.Bridge.ID) + if err != nil { + t.Fatalf("runtime.registry.GetBridgeInstance() error = %v", err) + } + if stored.DisplayName != "Support" || stored.Status != bridgepkg.BridgeStatusStarting { + t.Fatalf("stored instance = %#v", stored) + } +} + +func TestHTTPBridgeRoutesEndpointReturnsOnlyRequestedInstanceRoutes(t *testing.T) { + runtime := newIntegrationRuntime(t) + + first := createIntegrationBridge(t, runtime, bridgepkg.CreateInstanceRequest{ + ID: "brg-http-a", + Scope: bridgepkg.ScopeGlobal, + Platform: "telegram", + ExtensionName: "ext-telegram", + DisplayName: "A", + Enabled: true, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, + }) + second := createIntegrationBridge(t, runtime, bridgepkg.CreateInstanceRequest{ + ID: "brg-http-b", + Scope: bridgepkg.ScopeGlobal, + Platform: "telegram", + ExtensionName: "ext-telegram", + DisplayName: "B", + Enabled: true, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, + }) + + upsertIntegrationBridgeRoute(t, runtime, bridgepkg.BridgeRoute{ + BridgeInstanceID: first.ID, + Scope: first.Scope, + PeerID: "peer-a", + SessionID: "sess-a", + AgentName: "coder", + LastActivityAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + }) + upsertIntegrationBridgeRoute(t, runtime, bridgepkg.BridgeRoute{ + BridgeInstanceID: second.ID, + Scope: second.Scope, + PeerID: "peer-b", + SessionID: "sess-b", + AgentName: "coder", + LastActivityAt: time.Date(2026, 4, 11, 12, 1, 0, 0, time.UTC), + }) + + resp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/bridges/"+first.ID+"/routes"), nil, nil) + if resp.StatusCode != http.StatusOK { + body := mustReadAll(t, resp.Body) + t.Fatalf("routes status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, body) + } + + var payload contract.BridgeRoutesResponse + decodeHTTPJSON(t, resp, &payload) + if got, want := len(payload.Routes), 1; got != want { + t.Fatalf("len(routes) = %d, want %d", got, want) + } + if payload.Routes[0].BridgeInstanceID != first.ID || payload.Routes[0].PeerID != "peer-a" { + t.Fatalf("routes = %#v", payload.Routes) + } +} + +func TestHTTPBridgeTestDeliveryResolvesTargetWithoutLiveAdapter(t *testing.T) { + runtime := newIntegrationRuntime(t) + + instance := createIntegrationBridge(t, runtime, bridgepkg.CreateInstanceRequest{ + ID: "brg-http-test-delivery", + Scope: bridgepkg.ScopeGlobal, + Platform: "telegram", + ExtensionName: "ext-telegram", + DisplayName: "Test Delivery", + Enabled: true, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, + DeliveryDefaults: []byte(`{"peer_id":"peer-default","mode":"reply"}`), + }) + + resp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/bridges/"+instance.ID+"/test-delivery"), []byte(`{"message":"hello","target":{"thread_id":"thread-1"}}`), nil) + if resp.StatusCode != http.StatusOK { + body := mustReadAll(t, resp.Body) + t.Fatalf("test delivery status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, body) + } + + var payload contract.BridgeTestDeliveryResponse + decodeHTTPJSON(t, resp, &payload) + if payload.Status != "resolved" || payload.DeliveryTarget.BridgeInstanceID != instance.ID { + t.Fatalf("payload = %#v", payload) + } + if payload.DeliveryTarget.PeerID != "peer-default" || payload.DeliveryTarget.ThreadID != "thread-1" || payload.DeliveryTarget.Mode != bridgepkg.DeliveryModeReply { + t.Fatalf("delivery target = %#v", payload.DeliveryTarget) + } +} + +func TestHTTPObserveHealthIncludesBridgeMetricsAndPreservesSessionFields(t *testing.T) { + runtime := newIntegrationRuntime(t) + + createSessionResp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/sessions"), []byte(`{"agent_name":"coder","workspace_path":"`+runtime.workspace+`"}`), nil) + if createSessionResp.StatusCode != http.StatusCreated { + body := mustReadAll(t, createSessionResp.Body) + t.Fatalf("create session status = %d, want %d; body=%s", createSessionResp.StatusCode, http.StatusCreated, body) + } + + instance := createIntegrationBridge(t, runtime, bridgepkg.CreateInstanceRequest{ + ID: "brg-http-health", + Scope: bridgepkg.ScopeGlobal, + Platform: "telegram", + ExtensionName: "ext-telegram", + DisplayName: "Health", + Enabled: true, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, + }) + upsertIntegrationBridgeRoute(t, runtime, bridgepkg.BridgeRoute{ + BridgeInstanceID: instance.ID, + Scope: instance.Scope, + PeerID: "peer-health", + SessionID: "sess-health", + AgentName: "coder", + LastActivityAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + }) + runtime.observer.RecordBridgeAuthFailure(instance.ID) + + resp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/observe/health"), nil, nil) + if resp.StatusCode != http.StatusOK { + body := mustReadAll(t, resp.Body) + t.Fatalf("health status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, body) + } + + var payload contract.HealthResponse + decodeHTTPJSON(t, resp, &payload) + if got, want := payload.Health.Status, "ok"; got != want { + t.Fatalf("health.status = %q, want %q", got, want) + } + if got, want := payload.Health.ActiveSessions, 1; got != want { + t.Fatalf("health.active_sessions = %d, want %d", got, want) + } + if got, want := payload.Health.Bridges.TotalInstances, 1; got != want { + t.Fatalf("health.bridges.total_instances = %d, want %d", got, want) + } + if got, want := payload.Health.Bridges.RouteCount, 1; got != want { + t.Fatalf("health.bridges.route_count = %d, want %d", got, want) + } + if got, want := payload.Health.Bridges.StatusCounts.Ready, 1; got != want { + t.Fatalf("health.bridges.status_counts.ready = %d, want %d", got, want) + } + if got, want := payload.Health.Bridges.AuthFailuresTotal, 1; got != want { + t.Fatalf("health.bridges.auth_failures_total = %d, want %d", got, want) + } +} + +func TestHTTPBridgeDetailShowsAuthRequiredStatusAndHealth(t *testing.T) { + runtime := newIntegrationRuntime(t) + + instance := createIntegrationBridge(t, runtime, bridgepkg.CreateInstanceRequest{ + ID: "brg-http-auth", + Scope: bridgepkg.ScopeGlobal, + Platform: "telegram", + ExtensionName: "ext-telegram", + DisplayName: "Auth", + Enabled: true, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, + }) + if _, err := runtime.bridges.UpdateInstanceState(testutil.Context(t), bridgepkg.UpdateInstanceStateRequest{ + ID: instance.ID, + Enabled: true, + Status: bridgepkg.BridgeStatusAuthRequired, + }); err != nil { + t.Fatalf("runtime.bridges.UpdateInstanceState() error = %v", err) + } + runtime.observer.RecordBridgeAuthFailure(instance.ID) + + resp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/bridges/"+instance.ID), nil, nil) + if resp.StatusCode != http.StatusOK { + body := mustReadAll(t, resp.Body) + t.Fatalf("get bridge status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, body) + } + + var payload contract.BridgeResponse + decodeHTTPJSON(t, resp, &payload) + if got, want := payload.Bridge.Status, bridgepkg.BridgeStatusAuthRequired; got != want { + t.Fatalf("bridge.status = %q, want %q", got, want) + } + if got, want := payload.Health.Status, bridgepkg.BridgeStatusAuthRequired; got != want { + t.Fatalf("health.status = %q, want %q", got, want) + } + if got, want := payload.Health.AuthFailuresTotal, 1; got != want { + t.Fatalf("health.auth_failures_total = %d, want %d", got, want) + } +} + +func TestHTTPBridgeDetailReportsBacklogAndClearsAfterDeliveryCompletes(t *testing.T) { + runtime := newIntegrationRuntime(t) + + instance := createIntegrationBridge(t, runtime, bridgepkg.CreateInstanceRequest{ + ID: "brg-http-backlog", + Scope: bridgepkg.ScopeGlobal, + Platform: "telegram", + ExtensionName: "ext-telegram", + DisplayName: "Backlog", + Enabled: true, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, + }) + + transport := &blockingHTTPDeliveryTransport{releaseCh: make(chan struct{})} + runtime.bridges.Broker().SetTransport(transport) + registration := registerIntegrationDelivery(t, runtime, instance, "sess-http-backlog", "turn-http-backlog", "peer-http-backlog") + if err := runtime.bridges.Broker().Deliver(testutil.Context(t), integrationDeliveryEvent(registration, 1, bridgepkg.DeliveryEventTypeStart, "hello", false)); err != nil { + t.Fatalf("Broker().Deliver(start) error = %v", err) + } + if err := runtime.bridges.Broker().Deliver(testutil.Context(t), integrationDeliveryEvent(registration, 2, bridgepkg.DeliveryEventTypeDelta, "hello again", false)); err != nil { + t.Fatalf("Broker().Deliver(delta) error = %v", err) + } + + waitForHTTPCondition(t, func() bool { + bridge := getHTTPBridge(t, runtime, instance.ID) + return bridge.Health.DeliveryBacklog == 1 + }) + + bridge := getHTTPBridge(t, runtime, instance.ID) + if got, want := bridge.Health.DeliveryBacklog, 1; got != want { + t.Fatalf("bridge.health.delivery_backlog = %d, want %d", got, want) + } + health := getHTTPHealth(t, runtime) + if got, want := health.Health.Bridges.DeliveryBacklog, 1; got != want { + t.Fatalf("health.bridges.delivery_backlog = %d, want %d", got, want) + } + + close(transport.releaseCh) + waitForHTTPCondition(t, func() bool { + return getHTTPBridge(t, runtime, instance.ID).Health.DeliveryBacklog == 0 + }) + + bridge = getHTTPBridge(t, runtime, instance.ID) + if bridge.Health.DeliveryBacklog != 0 { + t.Fatalf("bridge.health.delivery_backlog = %d, want 0", bridge.Health.DeliveryBacklog) + } +} + +func createIntegrationBridge(t *testing.T, runtime integrationRuntime, req bridgepkg.CreateInstanceRequest) *bridgepkg.BridgeInstance { + t.Helper() + + instance, err := runtime.bridges.CreateInstance(testutil.Context(t), req) + if err != nil { + t.Fatalf("runtime.bridges.CreateInstance() error = %v", err) + } + return instance +} + +func upsertIntegrationBridgeRoute(t *testing.T, runtime integrationRuntime, route bridgepkg.BridgeRoute) { + t.Helper() + + if _, err := runtime.bridges.UpsertRoute(testutil.Context(t), route); err != nil { + t.Fatalf("runtime.bridges.UpsertRoute() error = %v", err) + } +} + +func registerIntegrationDelivery(t *testing.T, runtime integrationRuntime, instance *bridgepkg.BridgeInstance, sessionID string, turnID string, peerID string) bridgepkg.DeliverySnapshot { + t.Helper() + + snapshot, err := runtime.bridges.Broker().RegisterPromptDelivery(testutil.Context(t), bridgepkg.PromptDeliveryRegistration{ + SessionID: sessionID, + TurnID: turnID, + ExtensionName: instance.ExtensionName, + RoutingKey: bridgepkg.RoutingKey{ + Scope: instance.Scope, + WorkspaceID: instance.WorkspaceID, + BridgeInstanceID: instance.ID, + PeerID: peerID, + }, + DeliveryTarget: bridgepkg.DeliveryTarget{ + BridgeInstanceID: instance.ID, + PeerID: peerID, + Mode: bridgepkg.DeliveryModeReply, + }, + }) + if err != nil { + t.Fatalf("RegisterPromptDelivery(%s) error = %v", instance.ID, err) + } + return *snapshot +} + +func integrationDeliveryEvent(snapshot bridgepkg.DeliverySnapshot, seq int64, eventType string, text string, final bool) bridgepkg.DeliveryEvent { + return bridgepkg.DeliveryEvent{ + DeliveryID: snapshot.DeliveryID, + BridgeInstanceID: snapshot.BridgeInstanceID, + RoutingKey: snapshot.RoutingKey, + DeliveryTarget: snapshot.DeliveryTarget, + Seq: seq, + EventType: eventType, + Content: bridgepkg.MessageContent{Text: text}, + Final: final, + } +} + +func getHTTPBridge(t *testing.T, runtime integrationRuntime, bridgeID string) contract.BridgeResponse { + t.Helper() + + resp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/bridges/"+bridgeID), nil, nil) + if resp.StatusCode != http.StatusOK { + body := mustReadAll(t, resp.Body) + t.Fatalf("get bridge status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, body) + } + var payload contract.BridgeResponse + decodeHTTPJSON(t, resp, &payload) + return payload +} + +func getHTTPHealth(t *testing.T, runtime integrationRuntime) contract.HealthResponse { + t.Helper() + + resp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/observe/health"), nil, nil) + if resp.StatusCode != http.StatusOK { + body := mustReadAll(t, resp.Body) + t.Fatalf("get health status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, body) + } + var payload contract.HealthResponse + decodeHTTPJSON(t, resp, &payload) + return payload +} + +func waitForHTTPCondition(t *testing.T, fn func() bool) { + t.Helper() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if fn() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("condition did not become true before timeout") +} + +func mustReadAll(t *testing.T, body io.ReadCloser) string { + t.Helper() + defer func() { + _ = body.Close() + }() + + data, err := io.ReadAll(body) + if err != nil { + t.Fatalf("io.ReadAll() error = %v", err) + } + return string(data) +} diff --git a/internal/api/httpapi/bridges_test.go b/internal/api/httpapi/bridges_test.go new file mode 100644 index 000000000..816cb402e --- /dev/null +++ b/internal/api/httpapi/bridges_test.go @@ -0,0 +1,131 @@ +package httpapi + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/pedronauck/agh/internal/api/contract" + bridgepkg "github.com/pedronauck/agh/internal/bridges" +) + +func TestCreateBridgeHandlerCreatesBridgeInstance(t *testing.T) { + t.Parallel() + + homePaths := newTestHomePaths(t) + bridges := stubBridgeService{ + CreateInstanceFn: func(_ context.Context, req bridgepkg.CreateInstanceRequest) (*bridgepkg.BridgeInstance, error) { + if req.Scope != bridgepkg.ScopeWorkspace || req.WorkspaceID != "ws-alpha" || req.Platform != "telegram" || req.ExtensionName != "ext-telegram" || req.DisplayName != "Support" { + t.Fatalf("CreateInstance() req = %#v", req) + } + if !req.Enabled || req.Status != bridgepkg.BridgeStatusStarting || !req.RoutingPolicy.IncludePeer { + t.Fatalf("CreateInstance() lifecycle = %#v", req) + } + return &bridgepkg.BridgeInstance{ + ID: "brg-1", + Scope: req.Scope, + WorkspaceID: req.WorkspaceID, + Platform: req.Platform, + ExtensionName: req.ExtensionName, + DisplayName: req.DisplayName, + Enabled: req.Enabled, + Status: req.Status, + RoutingPolicy: req.RoutingPolicy, + DeliveryDefaults: req.DeliveryDefaults, + CreatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + }, nil + }, + } + + engine := newTestRouter(t, newTestHandlersWithBridges(t, stubSessionManager{}, stubObserver{}, bridges, stubWorkspaceService{}, homePaths)) + body := []byte(`{"scope":"workspace","workspace_id":"ws-alpha","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true}}`) + recorder := performRequest(t, engine, http.MethodPost, "/api/bridges", body) + if recorder.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusCreated, recorder.Body.String()) + } + + var response contract.BridgeResponse + decodeJSONResponse(t, recorder, &response) + if response.Bridge.ID != "brg-1" || response.Bridge.WorkspaceID != "ws-alpha" || response.Bridge.Status != bridgepkg.BridgeStatusStarting { + t.Fatalf("response.Bridge = %#v", response.Bridge) + } +} + +func TestListBridgeRoutesHandlerReturnsRequestedRouteSet(t *testing.T) { + t.Parallel() + + homePaths := newTestHomePaths(t) + bridges := stubBridgeService{ + ListRoutesFn: func(_ context.Context, bridgeInstanceID string) ([]bridgepkg.BridgeRoute, error) { + if bridgeInstanceID != "brg-1" { + t.Fatalf("ListRoutes() bridgeInstanceID = %q, want brg-1", bridgeInstanceID) + } + return []bridgepkg.BridgeRoute{ + { + RoutingKeyHash: "hash-1", + Scope: bridgepkg.ScopeWorkspace, + WorkspaceID: "ws-alpha", + BridgeInstanceID: "brg-1", + PeerID: "peer-1", + ThreadID: "thread-1", + GroupID: "group-1", + SessionID: "sess-1", + AgentName: "coder", + LastActivityAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + }, + }, nil + }, + } + + engine := newTestRouter(t, newTestHandlersWithBridges(t, stubSessionManager{}, stubObserver{}, bridges, stubWorkspaceService{}, homePaths)) + recorder := performRequest(t, engine, http.MethodGet, "/api/bridges/brg-1/routes", nil) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + + var response contract.BridgeRoutesResponse + decodeJSONResponse(t, recorder, &response) + if got, want := len(response.Routes), 1; got != want { + t.Fatalf("len(routes) = %d, want %d", got, want) + } + if response.Routes[0].BridgeInstanceID != "brg-1" || response.Routes[0].ThreadID != "thread-1" { + t.Fatalf("route = %#v", response.Routes[0]) + } +} + +func TestBridgeTestDeliveryHandlerResolvesTypedTarget(t *testing.T) { + t.Parallel() + + homePaths := newTestHomePaths(t) + bridges := stubBridgeService{ + ResolveDeliveryTargetFn: func(_ context.Context, req bridgepkg.ResolveDeliveryTargetRequest) (*bridgepkg.DeliveryTarget, error) { + if req.BridgeInstanceID != "brg-1" || req.PeerID != "peer-1" || req.ThreadID != "thread-1" || req.GroupID != "group-1" || req.Mode != bridgepkg.DeliveryModeReply { + t.Fatalf("ResolveDeliveryTarget() req = %#v", req) + } + return &bridgepkg.DeliveryTarget{ + BridgeInstanceID: req.BridgeInstanceID, + PeerID: req.PeerID, + ThreadID: req.ThreadID, + GroupID: req.GroupID, + Mode: req.Mode, + }, nil + }, + } + + engine := newTestRouter(t, newTestHandlersWithBridges(t, stubSessionManager{}, stubObserver{}, bridges, stubWorkspaceService{}, homePaths)) + body := []byte(`{"message":"hello","target":{"peer_id":"peer-1","thread_id":"thread-1","group_id":"group-1","mode":"reply"}}`) + recorder := performRequest(t, engine, http.MethodPost, "/api/bridges/brg-1/test-delivery", body) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + + var response contract.BridgeTestDeliveryResponse + decodeJSONResponse(t, recorder, &response) + if response.Status != "resolved" || response.DeliveryTarget.BridgeInstanceID != "brg-1" || response.DeliveryTarget.Mode != bridgepkg.DeliveryModeReply { + t.Fatalf("response = %#v", response) + } +} diff --git a/internal/api/httpapi/channels_integration_test.go b/internal/api/httpapi/channels_integration_test.go deleted file mode 100644 index d8b416c2b..000000000 --- a/internal/api/httpapi/channels_integration_test.go +++ /dev/null @@ -1,400 +0,0 @@ -//go:build integration - -package httpapi - -import ( - "context" - "io" - "net/http" - "testing" - "time" - - "github.com/pedronauck/agh/internal/api/contract" - channelspkg "github.com/pedronauck/agh/internal/channels" - "github.com/pedronauck/agh/internal/testutil" -) - -type blockingHTTPDeliveryTransport struct { - releaseCh chan struct{} -} - -func (t *blockingHTTPDeliveryTransport) DeliverChannel(ctx context.Context, _ string, req channelspkg.DeliveryRequest) (channelspkg.DeliveryAck, error) { - if t != nil && req.Event.EventType == channelspkg.DeliveryEventTypeStart { - select { - case <-t.releaseCh: - case <-ctx.Done(): - return channelspkg.DeliveryAck{}, ctx.Err() - } - } - return channelspkg.DeliveryAck{ - DeliveryID: req.Event.DeliveryID, - Seq: req.Event.Seq, - }, nil -} - -func TestHTTPChannelCreateReturnsPersistedPayload(t *testing.T) { - runtime := newIntegrationRuntime(t) - - resp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/channels"), []byte(`{"scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true}}`), nil) - if resp.StatusCode != http.StatusCreated { - body := mustReadAll(t, resp.Body) - t.Fatalf("create channel status = %d, want %d; body=%s", resp.StatusCode, http.StatusCreated, body) - } - - var payload contract.ChannelResponse - decodeHTTPJSON(t, resp, &payload) - if payload.Channel.ID == "" || payload.Channel.Platform != "telegram" || payload.Channel.ExtensionName != "ext-telegram" { - t.Fatalf("payload.Channel = %#v", payload.Channel) - } - - stored, err := runtime.registry.GetChannelInstance(context.Background(), payload.Channel.ID) - if err != nil { - t.Fatalf("runtime.registry.GetChannelInstance() error = %v", err) - } - if stored.DisplayName != "Support" || stored.Status != channelspkg.ChannelStatusStarting { - t.Fatalf("stored instance = %#v", stored) - } -} - -func TestHTTPChannelRoutesEndpointReturnsOnlyRequestedInstanceRoutes(t *testing.T) { - runtime := newIntegrationRuntime(t) - - first := createIntegrationChannel(t, runtime, channelspkg.CreateInstanceRequest{ - ID: "chan-http-a", - Scope: channelspkg.ScopeGlobal, - Platform: "telegram", - ExtensionName: "ext-telegram", - DisplayName: "A", - Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, - }) - second := createIntegrationChannel(t, runtime, channelspkg.CreateInstanceRequest{ - ID: "chan-http-b", - Scope: channelspkg.ScopeGlobal, - Platform: "telegram", - ExtensionName: "ext-telegram", - DisplayName: "B", - Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, - }) - - upsertIntegrationChannelRoute(t, runtime, channelspkg.ChannelRoute{ - ChannelInstanceID: first.ID, - Scope: first.Scope, - PeerID: "peer-a", - SessionID: "sess-a", - AgentName: "coder", - LastActivityAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - }) - upsertIntegrationChannelRoute(t, runtime, channelspkg.ChannelRoute{ - ChannelInstanceID: second.ID, - Scope: second.Scope, - PeerID: "peer-b", - SessionID: "sess-b", - AgentName: "coder", - LastActivityAt: time.Date(2026, 4, 11, 12, 1, 0, 0, time.UTC), - }) - - resp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/channels/"+first.ID+"/routes"), nil, nil) - if resp.StatusCode != http.StatusOK { - body := mustReadAll(t, resp.Body) - t.Fatalf("routes status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, body) - } - - var payload contract.ChannelRoutesResponse - decodeHTTPJSON(t, resp, &payload) - if got, want := len(payload.Routes), 1; got != want { - t.Fatalf("len(routes) = %d, want %d", got, want) - } - if payload.Routes[0].ChannelInstanceID != first.ID || payload.Routes[0].PeerID != "peer-a" { - t.Fatalf("routes = %#v", payload.Routes) - } -} - -func TestHTTPChannelTestDeliveryResolvesTargetWithoutLiveAdapter(t *testing.T) { - runtime := newIntegrationRuntime(t) - - instance := createIntegrationChannel(t, runtime, channelspkg.CreateInstanceRequest{ - ID: "chan-http-test-delivery", - Scope: channelspkg.ScopeGlobal, - Platform: "telegram", - ExtensionName: "ext-telegram", - DisplayName: "Test Delivery", - Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, - DeliveryDefaults: []byte(`{"peer_id":"peer-default","mode":"reply"}`), - }) - - resp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/channels/"+instance.ID+"/test-delivery"), []byte(`{"message":"hello","target":{"thread_id":"thread-1"}}`), nil) - if resp.StatusCode != http.StatusOK { - body := mustReadAll(t, resp.Body) - t.Fatalf("test delivery status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, body) - } - - var payload contract.ChannelTestDeliveryResponse - decodeHTTPJSON(t, resp, &payload) - if payload.Status != "resolved" || payload.DeliveryTarget.ChannelInstanceID != instance.ID { - t.Fatalf("payload = %#v", payload) - } - if payload.DeliveryTarget.PeerID != "peer-default" || payload.DeliveryTarget.ThreadID != "thread-1" || payload.DeliveryTarget.Mode != channelspkg.DeliveryModeReply { - t.Fatalf("delivery target = %#v", payload.DeliveryTarget) - } -} - -func TestHTTPObserveHealthIncludesChannelMetricsAndPreservesSessionFields(t *testing.T) { - runtime := newIntegrationRuntime(t) - - createSessionResp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/sessions"), []byte(`{"agent_name":"coder","workspace_path":"`+runtime.workspace+`"}`), nil) - if createSessionResp.StatusCode != http.StatusCreated { - body := mustReadAll(t, createSessionResp.Body) - t.Fatalf("create session status = %d, want %d; body=%s", createSessionResp.StatusCode, http.StatusCreated, body) - } - - instance := createIntegrationChannel(t, runtime, channelspkg.CreateInstanceRequest{ - ID: "chan-http-health", - Scope: channelspkg.ScopeGlobal, - Platform: "telegram", - ExtensionName: "ext-telegram", - DisplayName: "Health", - Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, - }) - upsertIntegrationChannelRoute(t, runtime, channelspkg.ChannelRoute{ - ChannelInstanceID: instance.ID, - Scope: instance.Scope, - PeerID: "peer-health", - SessionID: "sess-health", - AgentName: "coder", - LastActivityAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - }) - runtime.observer.RecordChannelAuthFailure(instance.ID) - - resp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/observe/health"), nil, nil) - if resp.StatusCode != http.StatusOK { - body := mustReadAll(t, resp.Body) - t.Fatalf("health status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, body) - } - - var payload contract.HealthResponse - decodeHTTPJSON(t, resp, &payload) - if got, want := payload.Health.Status, "ok"; got != want { - t.Fatalf("health.status = %q, want %q", got, want) - } - if got, want := payload.Health.ActiveSessions, 1; got != want { - t.Fatalf("health.active_sessions = %d, want %d", got, want) - } - if got, want := payload.Health.Channels.TotalInstances, 1; got != want { - t.Fatalf("health.channels.total_instances = %d, want %d", got, want) - } - if got, want := payload.Health.Channels.RouteCount, 1; got != want { - t.Fatalf("health.channels.route_count = %d, want %d", got, want) - } - if got, want := payload.Health.Channels.StatusCounts.Ready, 1; got != want { - t.Fatalf("health.channels.status_counts.ready = %d, want %d", got, want) - } - if got, want := payload.Health.Channels.AuthFailuresTotal, 1; got != want { - t.Fatalf("health.channels.auth_failures_total = %d, want %d", got, want) - } -} - -func TestHTTPChannelDetailShowsAuthRequiredStatusAndHealth(t *testing.T) { - runtime := newIntegrationRuntime(t) - - instance := createIntegrationChannel(t, runtime, channelspkg.CreateInstanceRequest{ - ID: "chan-http-auth", - Scope: channelspkg.ScopeGlobal, - Platform: "telegram", - ExtensionName: "ext-telegram", - DisplayName: "Auth", - Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, - }) - if _, err := runtime.channels.UpdateInstanceState(testutil.Context(t), channelspkg.UpdateInstanceStateRequest{ - ID: instance.ID, - Enabled: true, - Status: channelspkg.ChannelStatusAuthRequired, - }); err != nil { - t.Fatalf("runtime.channels.UpdateInstanceState() error = %v", err) - } - runtime.observer.RecordChannelAuthFailure(instance.ID) - - resp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/channels/"+instance.ID), nil, nil) - if resp.StatusCode != http.StatusOK { - body := mustReadAll(t, resp.Body) - t.Fatalf("get channel status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, body) - } - - var payload contract.ChannelResponse - decodeHTTPJSON(t, resp, &payload) - if got, want := payload.Channel.Status, channelspkg.ChannelStatusAuthRequired; got != want { - t.Fatalf("channel.status = %q, want %q", got, want) - } - if got, want := payload.Health.Status, channelspkg.ChannelStatusAuthRequired; got != want { - t.Fatalf("health.status = %q, want %q", got, want) - } - if got, want := payload.Health.AuthFailuresTotal, 1; got != want { - t.Fatalf("health.auth_failures_total = %d, want %d", got, want) - } -} - -func TestHTTPChannelDetailReportsBacklogAndClearsAfterDeliveryCompletes(t *testing.T) { - runtime := newIntegrationRuntime(t) - - instance := createIntegrationChannel(t, runtime, channelspkg.CreateInstanceRequest{ - ID: "chan-http-backlog", - Scope: channelspkg.ScopeGlobal, - Platform: "telegram", - ExtensionName: "ext-telegram", - DisplayName: "Backlog", - Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, - }) - - transport := &blockingHTTPDeliveryTransport{releaseCh: make(chan struct{})} - runtime.channels.Broker().SetTransport(transport) - registration := registerIntegrationDelivery(t, runtime, instance, "sess-http-backlog", "turn-http-backlog", "peer-http-backlog") - if err := runtime.channels.Broker().Deliver(testutil.Context(t), integrationDeliveryEvent(registration, 1, channelspkg.DeliveryEventTypeStart, "hello", false)); err != nil { - t.Fatalf("Broker().Deliver(start) error = %v", err) - } - if err := runtime.channels.Broker().Deliver(testutil.Context(t), integrationDeliveryEvent(registration, 2, channelspkg.DeliveryEventTypeDelta, "hello again", false)); err != nil { - t.Fatalf("Broker().Deliver(delta) error = %v", err) - } - - waitForHTTPCondition(t, func() bool { - channel := getHTTPChannel(t, runtime, instance.ID) - return channel.Health.DeliveryBacklog == 1 - }) - - channel := getHTTPChannel(t, runtime, instance.ID) - if got, want := channel.Health.DeliveryBacklog, 1; got != want { - t.Fatalf("channel.health.delivery_backlog = %d, want %d", got, want) - } - health := getHTTPHealth(t, runtime) - if got, want := health.Health.Channels.DeliveryBacklog, 1; got != want { - t.Fatalf("health.channels.delivery_backlog = %d, want %d", got, want) - } - - close(transport.releaseCh) - waitForHTTPCondition(t, func() bool { - return getHTTPChannel(t, runtime, instance.ID).Health.DeliveryBacklog == 0 - }) - - channel = getHTTPChannel(t, runtime, instance.ID) - if channel.Health.DeliveryBacklog != 0 { - t.Fatalf("channel.health.delivery_backlog = %d, want 0", channel.Health.DeliveryBacklog) - } -} - -func createIntegrationChannel(t *testing.T, runtime integrationRuntime, req channelspkg.CreateInstanceRequest) *channelspkg.ChannelInstance { - t.Helper() - - instance, err := runtime.channels.CreateInstance(testutil.Context(t), req) - if err != nil { - t.Fatalf("runtime.channels.CreateInstance() error = %v", err) - } - return instance -} - -func upsertIntegrationChannelRoute(t *testing.T, runtime integrationRuntime, route channelspkg.ChannelRoute) { - t.Helper() - - if _, err := runtime.channels.UpsertRoute(testutil.Context(t), route); err != nil { - t.Fatalf("runtime.channels.UpsertRoute() error = %v", err) - } -} - -func registerIntegrationDelivery(t *testing.T, runtime integrationRuntime, instance *channelspkg.ChannelInstance, sessionID string, turnID string, peerID string) channelspkg.DeliverySnapshot { - t.Helper() - - snapshot, err := runtime.channels.Broker().RegisterPromptDelivery(testutil.Context(t), channelspkg.PromptDeliveryRegistration{ - SessionID: sessionID, - TurnID: turnID, - ExtensionName: instance.ExtensionName, - RoutingKey: channelspkg.RoutingKey{ - Scope: instance.Scope, - WorkspaceID: instance.WorkspaceID, - ChannelInstanceID: instance.ID, - PeerID: peerID, - }, - DeliveryTarget: channelspkg.DeliveryTarget{ - ChannelInstanceID: instance.ID, - PeerID: peerID, - Mode: channelspkg.DeliveryModeReply, - }, - }) - if err != nil { - t.Fatalf("RegisterPromptDelivery(%s) error = %v", instance.ID, err) - } - return *snapshot -} - -func integrationDeliveryEvent(snapshot channelspkg.DeliverySnapshot, seq int64, eventType string, text string, final bool) channelspkg.DeliveryEvent { - return channelspkg.DeliveryEvent{ - DeliveryID: snapshot.DeliveryID, - ChannelInstanceID: snapshot.ChannelInstanceID, - RoutingKey: snapshot.RoutingKey, - DeliveryTarget: snapshot.DeliveryTarget, - Seq: seq, - EventType: eventType, - Content: channelspkg.MessageContent{Text: text}, - Final: final, - } -} - -func getHTTPChannel(t *testing.T, runtime integrationRuntime, channelID string) contract.ChannelResponse { - t.Helper() - - resp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/channels/"+channelID), nil, nil) - if resp.StatusCode != http.StatusOK { - body := mustReadAll(t, resp.Body) - t.Fatalf("get channel status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, body) - } - var payload contract.ChannelResponse - decodeHTTPJSON(t, resp, &payload) - return payload -} - -func getHTTPHealth(t *testing.T, runtime integrationRuntime) contract.HealthResponse { - t.Helper() - - resp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/observe/health"), nil, nil) - if resp.StatusCode != http.StatusOK { - body := mustReadAll(t, resp.Body) - t.Fatalf("get health status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, body) - } - var payload contract.HealthResponse - decodeHTTPJSON(t, resp, &payload) - return payload -} - -func waitForHTTPCondition(t *testing.T, fn func() bool) { - t.Helper() - - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if fn() { - return - } - time.Sleep(10 * time.Millisecond) - } - t.Fatal("condition did not become true before timeout") -} - -func mustReadAll(t *testing.T, body io.ReadCloser) string { - t.Helper() - defer func() { - _ = body.Close() - }() - - data, err := io.ReadAll(body) - if err != nil { - t.Fatalf("io.ReadAll() error = %v", err) - } - return string(data) -} diff --git a/internal/api/httpapi/channels_test.go b/internal/api/httpapi/channels_test.go deleted file mode 100644 index d6d824be6..000000000 --- a/internal/api/httpapi/channels_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package httpapi - -import ( - "context" - "net/http" - "testing" - "time" - - "github.com/pedronauck/agh/internal/api/contract" - channelspkg "github.com/pedronauck/agh/internal/channels" -) - -func TestCreateChannelHandlerCreatesChannelInstance(t *testing.T) { - t.Parallel() - - homePaths := newTestHomePaths(t) - channels := stubChannelService{ - CreateInstanceFn: func(_ context.Context, req channelspkg.CreateInstanceRequest) (*channelspkg.ChannelInstance, error) { - if req.Scope != channelspkg.ScopeWorkspace || req.WorkspaceID != "ws-alpha" || req.Platform != "telegram" || req.ExtensionName != "ext-telegram" || req.DisplayName != "Support" { - t.Fatalf("CreateInstance() req = %#v", req) - } - if !req.Enabled || req.Status != channelspkg.ChannelStatusStarting || !req.RoutingPolicy.IncludePeer { - t.Fatalf("CreateInstance() lifecycle = %#v", req) - } - return &channelspkg.ChannelInstance{ - ID: "chan-1", - Scope: req.Scope, - WorkspaceID: req.WorkspaceID, - Platform: req.Platform, - ExtensionName: req.ExtensionName, - DisplayName: req.DisplayName, - Enabled: req.Enabled, - Status: req.Status, - RoutingPolicy: req.RoutingPolicy, - DeliveryDefaults: req.DeliveryDefaults, - CreatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - }, nil - }, - } - - engine := newTestRouter(t, newTestHandlersWithChannels(t, stubSessionManager{}, stubObserver{}, channels, stubWorkspaceService{}, homePaths)) - body := []byte(`{"scope":"workspace","workspace_id":"ws-alpha","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true}}`) - recorder := performRequest(t, engine, http.MethodPost, "/api/channels", body) - if recorder.Code != http.StatusCreated { - t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusCreated, recorder.Body.String()) - } - - var response contract.ChannelResponse - decodeJSONResponse(t, recorder, &response) - if response.Channel.ID != "chan-1" || response.Channel.WorkspaceID != "ws-alpha" || response.Channel.Status != channelspkg.ChannelStatusStarting { - t.Fatalf("response.Channel = %#v", response.Channel) - } -} - -func TestListChannelRoutesHandlerReturnsRequestedRouteSet(t *testing.T) { - t.Parallel() - - homePaths := newTestHomePaths(t) - channels := stubChannelService{ - ListRoutesFn: func(_ context.Context, channelInstanceID string) ([]channelspkg.ChannelRoute, error) { - if channelInstanceID != "chan-1" { - t.Fatalf("ListRoutes() channelInstanceID = %q, want chan-1", channelInstanceID) - } - return []channelspkg.ChannelRoute{ - { - RoutingKeyHash: "hash-1", - Scope: channelspkg.ScopeWorkspace, - WorkspaceID: "ws-alpha", - ChannelInstanceID: "chan-1", - PeerID: "peer-1", - ThreadID: "thread-1", - GroupID: "group-1", - SessionID: "sess-1", - AgentName: "coder", - LastActivityAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - CreatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - }, - }, nil - }, - } - - engine := newTestRouter(t, newTestHandlersWithChannels(t, stubSessionManager{}, stubObserver{}, channels, stubWorkspaceService{}, homePaths)) - recorder := performRequest(t, engine, http.MethodGet, "/api/channels/chan-1/routes", nil) - if recorder.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) - } - - var response contract.ChannelRoutesResponse - decodeJSONResponse(t, recorder, &response) - if got, want := len(response.Routes), 1; got != want { - t.Fatalf("len(routes) = %d, want %d", got, want) - } - if response.Routes[0].ChannelInstanceID != "chan-1" || response.Routes[0].ThreadID != "thread-1" { - t.Fatalf("route = %#v", response.Routes[0]) - } -} - -func TestChannelTestDeliveryHandlerResolvesTypedTarget(t *testing.T) { - t.Parallel() - - homePaths := newTestHomePaths(t) - channels := stubChannelService{ - ResolveDeliveryTargetFn: func(_ context.Context, req channelspkg.ResolveDeliveryTargetRequest) (*channelspkg.DeliveryTarget, error) { - if req.ChannelInstanceID != "chan-1" || req.PeerID != "peer-1" || req.ThreadID != "thread-1" || req.GroupID != "group-1" || req.Mode != channelspkg.DeliveryModeReply { - t.Fatalf("ResolveDeliveryTarget() req = %#v", req) - } - return &channelspkg.DeliveryTarget{ - ChannelInstanceID: req.ChannelInstanceID, - PeerID: req.PeerID, - ThreadID: req.ThreadID, - GroupID: req.GroupID, - Mode: req.Mode, - }, nil - }, - } - - engine := newTestRouter(t, newTestHandlersWithChannels(t, stubSessionManager{}, stubObserver{}, channels, stubWorkspaceService{}, homePaths)) - body := []byte(`{"message":"hello","target":{"peer_id":"peer-1","thread_id":"thread-1","group_id":"group-1","mode":"reply"}}`) - recorder := performRequest(t, engine, http.MethodPost, "/api/channels/chan-1/test-delivery", body) - if recorder.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) - } - - var response contract.ChannelTestDeliveryResponse - decodeJSONResponse(t, recorder, &response) - if response.Status != "resolved" || response.DeliveryTarget.ChannelInstanceID != "chan-1" || response.DeliveryTarget.Mode != channelspkg.DeliveryModeReply { - t.Fatalf("response = %#v", response) - } -} diff --git a/internal/api/httpapi/handlers.go b/internal/api/httpapi/handlers.go index 5dedc543b..fc9d11e4d 100644 --- a/internal/api/httpapi/handlers.go +++ b/internal/api/httpapi/handlers.go @@ -15,7 +15,7 @@ type handlerConfig struct { network core.NetworkService observer core.Observer automation core.AutomationManager - channels core.ChannelService + bridges core.BridgeService workspaces core.WorkspaceService skillsRegistry core.SkillsRegistry memoryStore *memory.Store @@ -54,7 +54,7 @@ func newHandlers(cfg handlerConfig) *Handlers { Network: cfg.network, Observer: cfg.observer, Automation: cfg.automation, - Channels: cfg.channels, + Bridges: cfg.bridges, Workspaces: cfg.workspaces, SkillsRegistry: cfg.skillsRegistry, MemoryStore: cfg.memoryStore, diff --git a/internal/api/httpapi/handlers_test.go b/internal/api/httpapi/handlers_test.go index 575ede852..1fc4b0fea 100644 --- a/internal/api/httpapi/handlers_test.go +++ b/internal/api/httpapi/handlers_test.go @@ -52,9 +52,9 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "GET /api/automation/triggers", "GET /api/automation/triggers/:id", "GET /api/automation/triggers/:id/runs", - "GET /api/channels", - "GET /api/channels/:id", - "GET /api/channels/:id/routes", + "GET /api/bridges", + "GET /api/bridges/:id", + "GET /api/bridges/:id/routes", "GET /api/daemon/status", "GET /api/hooks/catalog", "GET /api/hooks/events", @@ -63,7 +63,7 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "GET /api/memory/:filename", "GET /api/network/inbox", "GET /api/network/peers", - "GET /api/network/spaces", + "GET /api/network/channels", "GET /api/network/status", "GET /api/observe/events", "GET /api/observe/events/stream", @@ -81,17 +81,17 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "GET /api/workspaces/:id", "PATCH /api/automation/jobs/:id", "PATCH /api/automation/triggers/:id", - "PATCH /api/channels/:id", + "PATCH /api/bridges/:id", "PATCH /api/workspaces/:id", "POST /api/automation/jobs", "POST /api/automation/jobs/:id/trigger", "POST /api/automation/triggers", "POST /api/memory/consolidate", - "POST /api/channels", - "POST /api/channels/:id/disable", - "POST /api/channels/:id/enable", - "POST /api/channels/:id/restart", - "POST /api/channels/:id/test-delivery", + "POST /api/bridges", + "POST /api/bridges/:id/disable", + "POST /api/bridges/:id/enable", + "POST /api/bridges/:id/restart", + "POST /api/bridges/:id/test-delivery", "POST /api/network/send", "POST /api/sessions", "POST /api/sessions/:id/approve", @@ -134,18 +134,18 @@ func TestCreateSessionHandlerReturnsSessionID(t *testing.T) { homePaths := newTestHomePaths(t) manager := stubSessionManager{ CreateFn: func(_ context.Context, opts session.CreateOpts) (*session.Session, error) { - if opts.AgentName != "coder" || opts.Name != "demo" || opts.Workspace != "alpha" || opts.WorkspacePath != "" || opts.Space != "builders" { + if opts.AgentName != "coder" || opts.Name != "demo" || opts.Workspace != "alpha" || opts.WorkspacePath != "" || opts.Channel != "builders" { t.Fatalf("Create() opts = %#v", opts) } sess := newSession("sess-123") - sess.Space = "builders" + sess.Channel = "builders" return sess, nil }, } handlers := newTestHandlers(t, manager, stubObserver{}, homePaths) engine := newTestRouter(t, handlers) - recorder := performRequest(t, engine, http.MethodPost, "/api/sessions", []byte(`{"agent_name":"coder","name":"demo","workspace":"alpha","space":"builders"}`)) + recorder := performRequest(t, engine, http.MethodPost, "/api/sessions", []byte(`{"agent_name":"coder","name":"demo","workspace":"alpha","channel":"builders"}`)) if recorder.Code != http.StatusCreated { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusCreated, recorder.Body.String()) } @@ -160,8 +160,8 @@ func TestCreateSessionHandlerReturnsSessionID(t *testing.T) { if response.Session.WorkspaceID != "ws-workspace" || response.Session.WorkspacePath != "/workspace" { t.Fatalf("session workspace = %#v", response.Session) } - if response.Session.Space != "builders" { - t.Fatalf("session space = %q, want %q", response.Session.Space, "builders") + if response.Session.Channel != "builders" { + t.Fatalf("session channel = %q, want %q", response.Session.Channel, "builders") } } diff --git a/internal/api/httpapi/helpers_test.go b/internal/api/httpapi/helpers_test.go index 036c2905d..b95c23cd5 100644 --- a/internal/api/httpapi/helpers_test.go +++ b/internal/api/httpapi/helpers_test.go @@ -19,33 +19,33 @@ import ( type stubSessionManager = testutil.StubSessionManager type stubObserver = testutil.StubObserver -type stubChannelService = testutil.StubChannelService +type stubBridgeService = testutil.StubBridgeService type stubWorkspaceService = testutil.StubWorkspaceService type sseRecord = testutil.SSERecord func newTestHandlers(t *testing.T, manager core.SessionManager, observer core.Observer, homePaths aghconfig.HomePaths) *Handlers { t.Helper() - return newTestHandlersWithAutomationChannelsAndWorkspace(t, manager, observer, nil, nil, stubWorkspaceService{}, homePaths) + return newTestHandlersWithAutomationBridgesAndWorkspace(t, manager, observer, nil, nil, stubWorkspaceService{}, homePaths) } -func newTestHandlersWithChannels( +func newTestHandlersWithBridges( t *testing.T, manager core.SessionManager, observer core.Observer, - channels core.ChannelService, + bridges core.BridgeService, workspaces core.WorkspaceService, homePaths aghconfig.HomePaths, ) *Handlers { t.Helper() - return newTestHandlersWithAutomationChannelsAndWorkspace(t, manager, observer, nil, channels, workspaces, homePaths) + return newTestHandlersWithAutomationBridgesAndWorkspace(t, manager, observer, nil, bridges, workspaces, homePaths) } -func newTestHandlersWithAutomationChannelsAndWorkspace( +func newTestHandlersWithAutomationBridgesAndWorkspace( t *testing.T, manager core.SessionManager, observer core.Observer, automation core.AutomationManager, - channels core.ChannelService, + bridges core.BridgeService, workspaces core.WorkspaceService, homePaths aghconfig.HomePaths, ) *Handlers { @@ -59,7 +59,7 @@ func newTestHandlersWithAutomationChannelsAndWorkspace( sessions: manager, observer: observer, automation: automation, - channels: channels, + bridges: bridges, workspaces: workspaces, staticFS: mustStaticFS(t), homePaths: homePaths, @@ -76,7 +76,7 @@ func newTestHandlersWithAutomationChannelsAndWorkspace( func newTestHandlersWithWorkspace(t *testing.T, manager core.SessionManager, observer core.Observer, workspaces core.WorkspaceService, homePaths aghconfig.HomePaths) *Handlers { t.Helper() - return newTestHandlersWithChannels(t, manager, observer, nil, workspaces, homePaths) + return newTestHandlersWithBridges(t, manager, observer, nil, workspaces, homePaths) } func newTestRouter(t *testing.T, handlers *Handlers) *gin.Engine { diff --git a/internal/api/httpapi/httpapi_integration_test.go b/internal/api/httpapi/httpapi_integration_test.go index b4caddd86..b850b64f6 100644 --- a/internal/api/httpapi/httpapi_integration_test.go +++ b/internal/api/httpapi/httpapi_integration_test.go @@ -20,7 +20,7 @@ import ( "github.com/pedronauck/agh/internal/api/contract" core "github.com/pedronauck/agh/internal/api/core" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" aghconfig "github.com/pedronauck/agh/internal/config" "github.com/pedronauck/agh/internal/memory" "github.com/pedronauck/agh/internal/observe" @@ -283,10 +283,10 @@ func TestHTTPSessionStopReasonPropagatesToGlobalDBAndAPI(t *testing.T) { } } -func TestHTTPSessionSpaceRoundTrip(t *testing.T) { +func TestHTTPSessionChannelRoundTrip(t *testing.T) { runtime := newIntegrationRuntime(t) - createResp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/sessions"), []byte(`{"agent_name":"coder","workspace_path":"`+runtime.workspace+`","space":"builders"}`), nil) + createResp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/sessions"), []byte(`{"agent_name":"coder","workspace_path":"`+runtime.workspace+`","channel":"builders"}`), nil) if createResp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(createResp.Body) _ = createResp.Body.Close() @@ -296,8 +296,8 @@ func TestHTTPSessionSpaceRoundTrip(t *testing.T) { Session sessionPayload `json:"session"` } decodeHTTPJSON(t, createResp, &created) - if created.Session.Space != "builders" { - t.Fatalf("created.Session.Space = %q, want %q", created.Session.Space, "builders") + if created.Session.Channel != "builders" { + t.Fatalf("created.Session.Channel = %q, want %q", created.Session.Channel, "builders") } listResp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/sessions"), nil, nil) @@ -313,8 +313,8 @@ func TestHTTPSessionSpaceRoundTrip(t *testing.T) { if got, want := len(listed.Sessions), 1; got != want { t.Fatalf("len(listed.Sessions) = %d, want %d", got, want) } - if listed.Sessions[0].Space != "builders" { - t.Fatalf("listed.Sessions[0].Space = %q, want %q", listed.Sessions[0].Space, "builders") + if listed.Sessions[0].Channel != "builders" { + t.Fatalf("listed.Sessions[0].Channel = %q, want %q", listed.Sessions[0].Channel, "builders") } stopIntegrationSession(t, runtime, created.Session.ID) @@ -329,7 +329,7 @@ func TestHTTPSessionSpaceRoundTrip(t *testing.T) { Session sessionPayload `json:"session"` } decodeHTTPJSON(t, statusResp, &stopped) - if stopped.Session.Space != "builders" || stopped.Session.State != session.StateStopped { + if stopped.Session.Channel != "builders" || stopped.Session.State != session.StateStopped { t.Fatalf("stopped session = %#v, want stopped builders session", stopped.Session) } @@ -340,8 +340,8 @@ func TestHTTPSessionSpaceRoundTrip(t *testing.T) { if got, want := len(indexed), 1; got != want { t.Fatalf("len(indexed stopped sessions) = %d, want %d", got, want) } - if indexed[0].Space != "builders" { - t.Fatalf("indexed[0].Space = %q, want %q", indexed[0].Space, "builders") + if indexed[0].Channel != "builders" { + t.Fatalf("indexed[0].Channel = %q, want %q", indexed[0].Channel, "builders") } resumeResp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/sessions/"+created.Session.ID+"/resume"), nil, nil) @@ -354,7 +354,7 @@ func TestHTTPSessionSpaceRoundTrip(t *testing.T) { Session sessionPayload `json:"session"` } decodeHTTPJSON(t, resumeResp, &resumed) - if resumed.Session.Space != "builders" || resumed.Session.State != session.StateActive { + if resumed.Session.Channel != "builders" || resumed.Session.State != session.StateActive { t.Fatalf("resumed session = %#v, want active builders session", resumed.Session) } } @@ -936,7 +936,7 @@ type integrationRuntime struct { driver *integrationDriver observer *observe.Observer registry *globaldb.GlobalDB - channels *integrationChannelService + bridges *integrationBridgeService memory *memory.Store dream *integrationDreamTrigger host string @@ -952,112 +952,112 @@ type integrationDreamTrigger struct { calls int } -type integrationChannelService struct { - *channelspkg.Service - broker *channelspkg.Broker +type integrationBridgeService struct { + *bridgepkg.Service + broker *bridgepkg.Broker } -func newIntegrationChannelService(store channelspkg.RegistryStore) *integrationChannelService { - return &integrationChannelService{ - Service: channelspkg.NewRegistry(store), - broker: channelspkg.NewBroker(nil), +func newIntegrationBridgeService(store bridgepkg.RegistryStore) *integrationBridgeService { + return &integrationBridgeService{ + Service: bridgepkg.NewRegistry(store), + broker: bridgepkg.NewBroker(nil), } } -func (s *integrationChannelService) StartInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { - if _, err := s.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ +func (s *integrationBridgeService) StartInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { + if _, err := s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, Enabled: true, - Status: channelspkg.ChannelStatusStarting, + Status: bridgepkg.BridgeStatusStarting, }); err != nil { - return nil, fmt.Errorf("start channel instance %q: %w", id, err) + return nil, fmt.Errorf("start bridge instance %q: %w", id, err) } - instance, err := s.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ + instance, err := s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, Enabled: true, - Status: channelspkg.ChannelStatusReady, + Status: bridgepkg.BridgeStatusReady, }) if err != nil { - return nil, fmt.Errorf("mark channel instance %q ready: %w", id, err) + return nil, fmt.Errorf("mark bridge instance %q ready: %w", id, err) } return instance, nil } -func (s *integrationChannelService) StopInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { - instance, err := s.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ +func (s *integrationBridgeService) StopInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { + instance, err := s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, Enabled: false, - Status: channelspkg.ChannelStatusDisabled, + Status: bridgepkg.BridgeStatusDisabled, }) if err != nil { - return nil, fmt.Errorf("stop channel instance %q: %w", id, err) + return nil, fmt.Errorf("stop bridge instance %q: %w", id, err) } return instance, nil } -func (s *integrationChannelService) RestartInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { - if _, err := s.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ +func (s *integrationBridgeService) RestartInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { + if _, err := s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, Enabled: true, - Status: channelspkg.ChannelStatusStarting, + Status: bridgepkg.BridgeStatusStarting, }); err != nil { - return nil, fmt.Errorf("restart channel instance %q: %w", id, err) + return nil, fmt.Errorf("restart bridge instance %q: %w", id, err) } - instance, err := s.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ + instance, err := s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, Enabled: true, - Status: channelspkg.ChannelStatusReady, + Status: bridgepkg.BridgeStatusReady, }) if err != nil { - return nil, fmt.Errorf("mark restarted channel instance %q ready: %w", id, err) + return nil, fmt.Errorf("mark restarted bridge instance %q ready: %w", id, err) } return instance, nil } -func (s *integrationChannelService) DeliveryMetrics() map[string]channelspkg.ChannelDeliveryMetrics { +func (s *integrationBridgeService) DeliveryMetrics() map[string]bridgepkg.BridgeDeliveryMetrics { if s == nil || s.broker == nil { return nil } return s.broker.DeliveryMetrics() } -func (s *integrationChannelService) Broker() *channelspkg.Broker { +func (s *integrationBridgeService) Broker() *bridgepkg.Broker { if s == nil { return nil } return s.broker } -func TestIntegrationChannelServiceLifecycleTransitionsReachReady(t *testing.T) { +func TestIntegrationBridgeServiceLifecycleTransitionsReachReady(t *testing.T) { runtime := newIntegrationRuntime(t) - created, err := runtime.channels.CreateInstance(context.Background(), channelspkg.CreateInstanceRequest{ - ID: "chan-lifecycle-ready", - Scope: channelspkg.ScopeGlobal, + created, err := runtime.bridges.CreateInstance(context.Background(), bridgepkg.CreateInstanceRequest{ + ID: "brg-lifecycle-ready", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Lifecycle Ready", Enabled: false, - Status: channelspkg.ChannelStatusDisabled, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusDisabled, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) if err != nil { t.Fatalf("CreateInstance() error = %v", err) } - started, err := runtime.channels.StartInstance(context.Background(), created.ID) + started, err := runtime.bridges.StartInstance(context.Background(), created.ID) if err != nil { t.Fatalf("StartInstance() error = %v", err) } - if !started.Enabled || started.Status != channelspkg.ChannelStatusReady { + if !started.Enabled || started.Status != bridgepkg.BridgeStatusReady { t.Fatalf("StartInstance() = %#v, want enabled ready instance", started) } - restarted, err := runtime.channels.RestartInstance(context.Background(), created.ID) + restarted, err := runtime.bridges.RestartInstance(context.Background(), created.ID) if err != nil { t.Fatalf("RestartInstance() error = %v", err) } - if !restarted.Enabled || restarted.Status != channelspkg.ChannelStatusReady { + if !restarted.Enabled || restarted.Status != bridgepkg.BridgeStatusReady { t.Fatalf("RestartInstance() = %#v, want enabled ready instance", restarted) } } @@ -1380,9 +1380,9 @@ func newIntegrationRuntimeWithPermissionWait(t *testing.T, permissionWait time.D if err != nil { t.Fatalf("session.NewManager() error = %v", err) } - channelService := newIntegrationChannelService(registry) + bridgeService := newIntegrationBridgeService(registry) t.Cleanup(func() { - if broker := channelService.Broker(); broker != nil { + if broker := bridgeService.Broker(); broker != nil { broker.Close() } }) @@ -1392,7 +1392,7 @@ func newIntegrationRuntimeWithPermissionWait(t *testing.T, permissionWait time.D observe.WithHomePaths(homePaths), observe.WithRegistry(registry), observe.WithSessionSource(manager), - observe.WithChannelSource(channelService), + observe.WithBridgeSource(bridgeService), observe.WithLogger(discardLogger()), ) if err != nil { @@ -1442,7 +1442,7 @@ func newIntegrationRuntimeWithPermissionWait(t *testing.T, permissionWait time.D WithSessionManager(manager), WithObserver(observer), WithAutomation(automationManager), - WithChannelService(channelService), + WithBridgeService(bridgeService), WithWorkspaceResolver(resolver), WithMemoryStore(memoryStore), WithDreamTrigger(dreamTrigger), @@ -1469,7 +1469,7 @@ func newIntegrationRuntimeWithPermissionWait(t *testing.T, permissionWait time.D driver: driver, observer: observer, registry: registry, - channels: channelService, + bridges: bridgeService, memory: memoryStore, dream: dreamTrigger, host: cfg.HTTP.Host, diff --git a/internal/api/httpapi/routes.go b/internal/api/httpapi/routes.go index 6b8cc4050..d26dc18ae 100644 --- a/internal/api/httpapi/routes.go +++ b/internal/api/httpapi/routes.go @@ -10,7 +10,7 @@ func RegisterRoutes(router gin.IRouter, handlers *Handlers) { api := router.Group("/api") - registerChannelRoutes(api, handlers) + registerBridgeRoutes(api, handlers) registerWorkspaceRoutes(api, handlers) registerSessionRoutes(api, handlers) registerAgentRoutes(api, handlers) @@ -28,17 +28,17 @@ func RegisterRoutes(router gin.IRouter, handlers *Handlers) { } } -func registerChannelRoutes(api gin.IRouter, handlers *Handlers) { - channels := api.Group("/channels") - channels.GET("", handlers.ListChannels) - channels.POST("", handlers.CreateChannel) - channels.GET("/:id", handlers.GetChannel) - channels.PATCH("/:id", handlers.UpdateChannel) - channels.POST("/:id/enable", handlers.EnableChannel) - channels.POST("/:id/disable", handlers.DisableChannel) - channels.POST("/:id/restart", handlers.RestartChannel) - channels.GET("/:id/routes", handlers.ListChannelRoutes) - channels.POST("/:id/test-delivery", handlers.TestChannelDelivery) +func registerBridgeRoutes(api gin.IRouter, handlers *Handlers) { + bridges := api.Group("/bridges") + bridges.GET("", handlers.ListBridges) + bridges.POST("", handlers.CreateBridge) + bridges.GET("/:id", handlers.GetBridge) + bridges.PATCH("/:id", handlers.UpdateBridge) + bridges.POST("/:id/enable", handlers.EnableBridge) + bridges.POST("/:id/disable", handlers.DisableBridge) + bridges.POST("/:id/restart", handlers.RestartBridge) + bridges.GET("/:id/routes", handlers.ListBridgeRoutes) + bridges.POST("/:id/test-delivery", handlers.TestBridgeDelivery) } func registerWorkspaceRoutes(api gin.IRouter, handlers *Handlers) { @@ -138,7 +138,7 @@ func registerNetworkRoutes(api gin.IRouter, handlers *Handlers) { networkGroup := api.Group("/network") networkGroup.GET("/status", handlers.NetworkStatus) networkGroup.GET("/peers", handlers.NetworkPeers) - networkGroup.GET("/spaces", handlers.NetworkSpaces) + networkGroup.GET("/channels", handlers.NetworkChannels) networkGroup.POST("/send", handlers.NetworkSend) networkGroup.GET("/inbox", handlers.NetworkInbox) } diff --git a/internal/api/httpapi/server.go b/internal/api/httpapi/server.go index 75076b2c4..2934d89bc 100644 --- a/internal/api/httpapi/server.go +++ b/internal/api/httpapi/server.go @@ -44,7 +44,7 @@ type Server struct { network core.NetworkService observer core.Observer automation core.AutomationManager - channels core.ChannelService + bridges core.BridgeService workspaces core.WorkspaceService skillsRegistry core.SkillsRegistry memoryStore *memory.Store @@ -146,10 +146,10 @@ func WithAutomation(manager core.AutomationManager) Option { } } -// WithChannelService injects the daemon-owned channel runtime. -func WithChannelService(channels core.ChannelService) Option { +// WithBridgeService injects the daemon-owned bridge runtime. +func WithBridgeService(bridges core.BridgeService) Option { return func(server *Server) { - server.channels = channels + server.bridges = bridges } } @@ -273,7 +273,7 @@ func New(opts ...Option) (*Server, error) { network: server.network, observer: server.observer, automation: server.automation, - channels: server.channels, + bridges: server.bridges, workspaces: server.workspaces, skillsRegistry: server.skillsRegistry, memoryStore: server.memoryStore, diff --git a/internal/api/spec/spec.go b/internal/api/spec/spec.go index a5dd36c19..88319f36e 100644 --- a/internal/api/spec/spec.go +++ b/internal/api/spec/spec.go @@ -14,7 +14,7 @@ import ( "github.com/getkin/kin-openapi/openapi3gen" "github.com/pedronauck/agh/internal/api/contract" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" extensioncontract "github.com/pedronauck/agh/internal/extension/contract" extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol" "github.com/pedronauck/agh/internal/hooks" @@ -85,7 +85,7 @@ func Document() (*openapi3.T, error) { Tags: openapi3.Tags{ {Name: "agents"}, {Name: "automation"}, - {Name: "channels"}, + {Name: "bridges"}, {Name: "daemon"}, {Name: "extensions"}, {Name: "hooks"}, @@ -474,157 +474,157 @@ func Operations() []OperationSpec { }, { Method: "GET", - Path: "/api/channels", - OperationID: "listChannels", - Summary: "List persisted channel instances", - Tags: []string{"channels"}, + Path: "/api/bridges", + OperationID: "listBridges", + Summary: "List persisted bridge instances", + Tags: []string{"bridges"}, Transports: []Transport{TransportHTTP, TransportUDS}, Responses: []ResponseSpec{ - {Status: 200, Description: "OK", Body: contract.ChannelsResponse{}}, - {Status: 503, Description: "Channel service is not configured", Body: contract.ErrorPayload{}}, + {Status: 200, Description: "OK", Body: contract.BridgesResponse{}}, + {Status: 503, Description: "Bridge service is not configured", Body: contract.ErrorPayload{}}, {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, }, }, { Method: "POST", - Path: "/api/channels", - OperationID: "createChannel", - Summary: "Create a channel instance", - Tags: []string{"channels"}, + Path: "/api/bridges", + OperationID: "createBridge", + Summary: "Create a bridge instance", + Tags: []string{"bridges"}, Transports: []Transport{TransportHTTP, TransportUDS}, - RequestBody: contract.CreateChannelRequest{}, + RequestBody: contract.CreateBridgeRequest{}, Responses: []ResponseSpec{ - {Status: 201, Description: "Created", Body: contract.ChannelResponse{}}, - {Status: 400, Description: "Invalid channel request", Body: contract.ErrorPayload{}}, + {Status: 201, Description: "Created", Body: contract.BridgeResponse{}}, + {Status: 400, Description: "Invalid bridge request", Body: contract.ErrorPayload{}}, {Status: 404, Description: "Workspace not found", Body: contract.ErrorPayload{}}, - {Status: 503, Description: "Channel service is not configured", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Bridge service is not configured", Body: contract.ErrorPayload{}}, {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, }, }, { Method: "GET", - Path: "/api/channels/{id}", - OperationID: "getChannel", - Summary: "Get one channel instance", - Tags: []string{"channels"}, + Path: "/api/bridges/{id}", + OperationID: "getBridge", + Summary: "Get one bridge instance", + Tags: []string{"bridges"}, Transports: []Transport{TransportHTTP, TransportUDS}, Parameters: []ParameterSpec{ - pathParam("id", "Channel instance id"), + pathParam("id", "Bridge instance id"), }, Responses: []ResponseSpec{ - {Status: 200, Description: "OK", Body: contract.ChannelResponse{}}, - {Status: 404, Description: "Channel instance not found", Body: contract.ErrorPayload{}}, - {Status: 503, Description: "Channel service is not configured", Body: contract.ErrorPayload{}}, + {Status: 200, Description: "OK", Body: contract.BridgeResponse{}}, + {Status: 404, Description: "Bridge instance not found", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Bridge service is not configured", Body: contract.ErrorPayload{}}, {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, }, }, { Method: "PATCH", - Path: "/api/channels/{id}", - OperationID: "updateChannel", - Summary: "Update mutable channel instance fields", - Tags: []string{"channels"}, + Path: "/api/bridges/{id}", + OperationID: "updateBridge", + Summary: "Update mutable bridge instance fields", + Tags: []string{"bridges"}, Transports: []Transport{TransportHTTP, TransportUDS}, Parameters: []ParameterSpec{ - pathParam("id", "Channel instance id"), + pathParam("id", "Bridge instance id"), }, - RequestBody: contract.UpdateChannelRequest{}, + RequestBody: contract.UpdateBridgeRequest{}, Responses: []ResponseSpec{ - {Status: 200, Description: "OK", Body: contract.ChannelResponse{}}, - {Status: 400, Description: "Invalid channel update", Body: contract.ErrorPayload{}}, - {Status: 404, Description: "Channel instance or workspace not found", Body: contract.ErrorPayload{}}, - {Status: 503, Description: "Channel service is not configured", Body: contract.ErrorPayload{}}, + {Status: 200, Description: "OK", Body: contract.BridgeResponse{}}, + {Status: 400, Description: "Invalid bridge update", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Bridge instance or workspace not found", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Bridge service is not configured", Body: contract.ErrorPayload{}}, {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, }, }, { Method: "POST", - Path: "/api/channels/{id}/enable", - OperationID: "enableChannel", - Summary: "Enable a channel instance", - Tags: []string{"channels"}, + Path: "/api/bridges/{id}/enable", + OperationID: "enableBridge", + Summary: "Enable a bridge instance", + Tags: []string{"bridges"}, Transports: []Transport{TransportHTTP, TransportUDS}, Parameters: []ParameterSpec{ - pathParam("id", "Channel instance id"), + pathParam("id", "Bridge instance id"), }, Responses: []ResponseSpec{ - {Status: 200, Description: "OK", Body: contract.ChannelResponse{}}, - {Status: 404, Description: "Channel instance not found", Body: contract.ErrorPayload{}}, - {Status: 409, Description: "Invalid channel state transition", Body: contract.ErrorPayload{}}, - {Status: 503, Description: "Channel service is not configured", Body: contract.ErrorPayload{}}, + {Status: 200, Description: "OK", Body: contract.BridgeResponse{}}, + {Status: 404, Description: "Bridge instance not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Invalid bridge state transition", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Bridge service is not configured", Body: contract.ErrorPayload{}}, {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, }, }, { Method: "POST", - Path: "/api/channels/{id}/disable", - OperationID: "disableChannel", - Summary: "Disable a channel instance", - Tags: []string{"channels"}, + Path: "/api/bridges/{id}/disable", + OperationID: "disableBridge", + Summary: "Disable a bridge instance", + Tags: []string{"bridges"}, Transports: []Transport{TransportHTTP, TransportUDS}, Parameters: []ParameterSpec{ - pathParam("id", "Channel instance id"), + pathParam("id", "Bridge instance id"), }, Responses: []ResponseSpec{ - {Status: 200, Description: "OK", Body: contract.ChannelResponse{}}, - {Status: 404, Description: "Channel instance not found", Body: contract.ErrorPayload{}}, - {Status: 409, Description: "Invalid channel state transition", Body: contract.ErrorPayload{}}, - {Status: 503, Description: "Channel service is not configured", Body: contract.ErrorPayload{}}, + {Status: 200, Description: "OK", Body: contract.BridgeResponse{}}, + {Status: 404, Description: "Bridge instance not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Invalid bridge state transition", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Bridge service is not configured", Body: contract.ErrorPayload{}}, {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, }, }, { Method: "POST", - Path: "/api/channels/{id}/restart", - OperationID: "restartChannel", - Summary: "Restart a channel instance", - Tags: []string{"channels"}, + Path: "/api/bridges/{id}/restart", + OperationID: "restartBridge", + Summary: "Restart a bridge instance", + Tags: []string{"bridges"}, Transports: []Transport{TransportHTTP, TransportUDS}, Parameters: []ParameterSpec{ - pathParam("id", "Channel instance id"), + pathParam("id", "Bridge instance id"), }, Responses: []ResponseSpec{ - {Status: 200, Description: "OK", Body: contract.ChannelResponse{}}, - {Status: 404, Description: "Channel instance not found", Body: contract.ErrorPayload{}}, - {Status: 409, Description: "Invalid channel state transition", Body: contract.ErrorPayload{}}, - {Status: 503, Description: "Channel service is not configured", Body: contract.ErrorPayload{}}, + {Status: 200, Description: "OK", Body: contract.BridgeResponse{}}, + {Status: 404, Description: "Bridge instance not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Invalid bridge state transition", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Bridge service is not configured", Body: contract.ErrorPayload{}}, {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, }, }, { Method: "GET", - Path: "/api/channels/{id}/routes", - OperationID: "listChannelRoutes", - Summary: "List routes owned by a channel instance", - Tags: []string{"channels"}, + Path: "/api/bridges/{id}/routes", + OperationID: "listBridgeRoutes", + Summary: "List routes owned by a bridge instance", + Tags: []string{"bridges"}, Transports: []Transport{TransportHTTP, TransportUDS}, Parameters: []ParameterSpec{ - pathParam("id", "Channel instance id"), + pathParam("id", "Bridge instance id"), }, Responses: []ResponseSpec{ - {Status: 200, Description: "OK", Body: contract.ChannelRoutesResponse{}}, - {Status: 404, Description: "Channel instance not found", Body: contract.ErrorPayload{}}, - {Status: 503, Description: "Channel service is not configured", Body: contract.ErrorPayload{}}, + {Status: 200, Description: "OK", Body: contract.BridgeRoutesResponse{}}, + {Status: 404, Description: "Bridge instance not found", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Bridge service is not configured", Body: contract.ErrorPayload{}}, {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, }, }, { Method: "POST", - Path: "/api/channels/{id}/test-delivery", - OperationID: "testChannelDelivery", - Summary: "Resolve a typed outbound delivery target for a channel instance", - Tags: []string{"channels"}, + Path: "/api/bridges/{id}/test-delivery", + OperationID: "testBridgeDelivery", + Summary: "Resolve a typed outbound delivery target for a bridge instance", + Tags: []string{"bridges"}, Transports: []Transport{TransportHTTP, TransportUDS}, Parameters: []ParameterSpec{ - pathParam("id", "Channel instance id"), + pathParam("id", "Bridge instance id"), }, - RequestBody: contract.ChannelTestDeliveryRequest{}, + RequestBody: contract.BridgeTestDeliveryRequest{}, Responses: []ResponseSpec{ - {Status: 200, Description: "OK", Body: contract.ChannelTestDeliveryResponse{}}, + {Status: 200, Description: "OK", Body: contract.BridgeTestDeliveryResponse{}}, {Status: 400, Description: "Invalid delivery target request", Body: contract.ErrorPayload{}}, - {Status: 404, Description: "Channel instance not found", Body: contract.ErrorPayload{}}, - {Status: 409, Description: "Channel instance is unavailable", Body: contract.ErrorPayload{}}, - {Status: 503, Description: "Channel service is not configured", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Bridge instance not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Bridge instance is unavailable", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Bridge service is not configured", Body: contract.ErrorPayload{}}, {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, }, }, @@ -1424,13 +1424,13 @@ func schemaCustomizer(_ string, t reflect.Type, _ reflect.StructTag, schema *ope case reflect.TypeOf(memory.Scope("")): setStringEnum(schema, memoryScopeValues()) return nil - case reflect.TypeOf(channelspkg.Scope("")): - setStringEnum(schema, channelScopeValues()) + case reflect.TypeOf(bridgepkg.Scope("")): + setStringEnum(schema, bridgeScopeValues()) return nil - case reflect.TypeOf(channelspkg.ChannelStatus("")): - setStringEnum(schema, channelStatusValues()) + case reflect.TypeOf(bridgepkg.BridgeStatus("")): + setStringEnum(schema, bridgeStatusValues()) return nil - case reflect.TypeOf(channelspkg.DeliveryMode("")): + case reflect.TypeOf(bridgepkg.DeliveryMode("")): setStringEnum(schema, deliveryModeValues()) return nil case reflect.TypeOf(session.SessionState("")): @@ -1742,25 +1742,25 @@ func memoryScopeValues() []string { return []string{string(memory.ScopeGlobal), string(memory.ScopeWorkspace)} } -func channelScopeValues() []string { - return []string{string(channelspkg.ScopeGlobal), string(channelspkg.ScopeWorkspace)} +func bridgeScopeValues() []string { + return []string{string(bridgepkg.ScopeGlobal), string(bridgepkg.ScopeWorkspace)} } -func channelStatusValues() []string { +func bridgeStatusValues() []string { return []string{ - string(channelspkg.ChannelStatusAuthRequired), - string(channelspkg.ChannelStatusDegraded), - string(channelspkg.ChannelStatusDisabled), - string(channelspkg.ChannelStatusError), - string(channelspkg.ChannelStatusReady), - string(channelspkg.ChannelStatusStarting), + string(bridgepkg.BridgeStatusAuthRequired), + string(bridgepkg.BridgeStatusDegraded), + string(bridgepkg.BridgeStatusDisabled), + string(bridgepkg.BridgeStatusError), + string(bridgepkg.BridgeStatusReady), + string(bridgepkg.BridgeStatusStarting), } } func deliveryModeValues() []string { return []string{ - string(channelspkg.DeliveryModeDirectSend), - string(channelspkg.DeliveryModeReply), + string(bridgepkg.DeliveryModeDirectSend), + string(bridgepkg.DeliveryModeReply), } } diff --git a/internal/api/spec/spec_test.go b/internal/api/spec/spec_test.go index aa14ecb16..b6c4f0a91 100644 --- a/internal/api/spec/spec_test.go +++ b/internal/api/spec/spec_test.go @@ -147,30 +147,30 @@ func TestDocumentTracksRequiredFieldsAndEnums(t *testing.T) { }, }, { - name: "ShouldDescribeChannelCreateRequiredFieldsAndEnums", + name: "ShouldDescribeBridgeCreateRequiredFieldsAndEnums", check: func(t *testing.T, doc *openapi3.T) { t.Helper() - createChannel := operationFor(t, doc, "/api/channels", "POST") - createChannelSchema := jsonRequestSchema(t, createChannel) - assertRequired(t, createChannelSchema, "scope", "platform", "extension_name", "display_name", "enabled", "status", "routing_policy") - assertNotRequired(t, createChannelSchema, "workspace_id", "delivery_defaults") - assertEnumValues(t, propertySchema(t, createChannelSchema, "scope"), "global", "workspace") - assertEnumValues(t, propertySchema(t, createChannelSchema, "status"), "auth_required", "degraded", "disabled", "error", "ready", "starting") + createBridge := operationFor(t, doc, "/api/bridges", "POST") + createBridgeSchema := jsonRequestSchema(t, createBridge) + assertRequired(t, createBridgeSchema, "scope", "platform", "extension_name", "display_name", "enabled", "status", "routing_policy") + assertNotRequired(t, createBridgeSchema, "workspace_id", "delivery_defaults") + assertEnumValues(t, propertySchema(t, createBridgeSchema, "scope"), "global", "workspace") + assertEnumValues(t, propertySchema(t, createBridgeSchema, "status"), "auth_required", "degraded", "disabled", "error", "ready", "starting") }, }, { - name: "ShouldDescribeChannelTestDeliveryTypedTargetShape", + name: "ShouldDescribeBridgeTestDeliveryTypedTargetShape", check: func(t *testing.T, doc *openapi3.T) { t.Helper() - testDelivery := operationFor(t, doc, "/api/channels/{id}/test-delivery", "POST") + testDelivery := operationFor(t, doc, "/api/bridges/{id}/test-delivery", "POST") testDeliverySchema := jsonRequestSchema(t, testDelivery) assertRequired(t, testDeliverySchema, "target") assertNotRequired(t, testDeliverySchema, "message") targetSchema := propertySchema(t, testDeliverySchema, "target") - assertNotRequired(t, targetSchema, "channel_instance_id", "peer_id", "thread_id", "group_id", "mode") + assertNotRequired(t, targetSchema, "bridge_instance_id", "peer_id", "thread_id", "group_id", "mode") assertEnumValues(t, propertySchema(t, targetSchema, "mode"), "direct-send", "reply") responseSchema := jsonResponseSchema(t, testDelivery, 200) @@ -292,7 +292,7 @@ func TestEnumHelpersReturnStableValues(t *testing.T) { if !slices.IsSorted(got) { t.Fatalf("values are not sorted: %v", got) } - for _, want := range []string{"channels/messages/ingest", "channels/instances/get", "channels/instances/report_state"} { + for _, want := range []string{"bridges/messages/ingest", "bridges/instances/get", "bridges/instances/report_state"} { if !contains(got, want) { t.Fatalf("expected %q in host api method values %v", want, got) } diff --git a/internal/api/testutil/apitest.go b/internal/api/testutil/apitest.go index eea403a52..9ab1b0949 100644 --- a/internal/api/testutil/apitest.go +++ b/internal/api/testutil/apitest.go @@ -19,7 +19,7 @@ import ( "github.com/pedronauck/agh/internal/acp" core "github.com/pedronauck/agh/internal/api/core" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" aghconfig "github.com/pedronauck/agh/internal/config" hookspkg "github.com/pedronauck/agh/internal/hooks" "github.com/pedronauck/agh/internal/network" @@ -145,12 +145,12 @@ func (s StubSessionManager) ApprovePermission(ctx context.Context, id string, re } type StubObserver struct { - QueryEventsFn func(context.Context, store.EventSummaryQuery) ([]store.EventSummary, error) - QueryHookCatalogFn func(context.Context, hookspkg.CatalogFilter) ([]hookspkg.CatalogEntry, error) - QueryHookRunsFn func(context.Context, store.HookRunQuery) ([]hookspkg.HookRunRecord, error) - QueryHookEventsFn func(context.Context, hookspkg.EventFilter) ([]hookspkg.EventDescriptor, error) - QueryChannelHealthFn func(context.Context) ([]observe.ChannelInstanceHealth, error) - HealthFn func(context.Context) (observe.Health, error) + QueryEventsFn func(context.Context, store.EventSummaryQuery) ([]store.EventSummary, error) + QueryHookCatalogFn func(context.Context, hookspkg.CatalogFilter) ([]hookspkg.CatalogEntry, error) + QueryHookRunsFn func(context.Context, store.HookRunQuery) ([]hookspkg.HookRunRecord, error) + QueryHookEventsFn func(context.Context, hookspkg.EventFilter) ([]hookspkg.EventDescriptor, error) + QueryBridgeHealthFn func(context.Context) ([]observe.BridgeInstanceHealth, error) + HealthFn func(context.Context) (observe.Health, error) } type StubAutomationManager struct { @@ -333,11 +333,11 @@ func (s StubObserver) QueryEvents(ctx context.Context, query store.EventSummaryQ } type StubNetworkService struct { - SendFn func(context.Context, network.SendRequest) (string, error) - ListPeersFn func(context.Context, string) ([]network.PeerInfo, error) - ListSpacesFn func(context.Context) ([]network.SpaceInfo, error) - StatusFn func(context.Context) (*network.NetworkStatus, error) - InboxFn func(context.Context, string) ([]network.Envelope, error) + SendFn func(context.Context, network.SendRequest) (string, error) + ListPeersFn func(context.Context, string) ([]network.PeerInfo, error) + ListChannelsFn func(context.Context) ([]network.ChannelInfo, error) + StatusFn func(context.Context) (*network.NetworkStatus, error) + InboxFn func(context.Context, string) ([]network.Envelope, error) } func (s StubNetworkService) Send(ctx context.Context, req network.SendRequest) (string, error) { @@ -347,16 +347,16 @@ func (s StubNetworkService) Send(ctx context.Context, req network.SendRequest) ( return "", nil } -func (s StubNetworkService) ListPeers(ctx context.Context, space string) ([]network.PeerInfo, error) { +func (s StubNetworkService) ListPeers(ctx context.Context, channel string) ([]network.PeerInfo, error) { if s.ListPeersFn != nil { - return s.ListPeersFn(ctx, space) + return s.ListPeersFn(ctx, channel) } return nil, nil } -func (s StubNetworkService) ListSpaces(ctx context.Context) ([]network.SpaceInfo, error) { - if s.ListSpacesFn != nil { - return s.ListSpacesFn(ctx) +func (s StubNetworkService) ListChannels(ctx context.Context) ([]network.ChannelInfo, error) { + if s.ListChannelsFn != nil { + return s.ListChannelsFn(ctx) } return nil, nil } @@ -382,9 +382,9 @@ func (s StubObserver) Health(ctx context.Context) (observe.Health, error) { return observe.Health{Status: "ok"}, nil } -func (s StubObserver) QueryChannelHealth(ctx context.Context) ([]observe.ChannelInstanceHealth, error) { - if s.QueryChannelHealthFn != nil { - return s.QueryChannelHealthFn(ctx) +func (s StubObserver) QueryBridgeHealth(ctx context.Context) ([]observe.BridgeInstanceHealth, error) { + if s.QueryBridgeHealthFn != nil { + return s.QueryBridgeHealthFn(ctx) } return nil, nil } @@ -410,121 +410,121 @@ func (s StubObserver) QueryHookEvents(ctx context.Context, filter hookspkg.Event return nil, nil } -type StubChannelService struct { - CreateInstanceFn func(context.Context, channelspkg.CreateInstanceRequest) (*channelspkg.ChannelInstance, error) - GetInstanceFn func(context.Context, string) (*channelspkg.ChannelInstance, error) - ListInstancesFn func(context.Context) ([]channelspkg.ChannelInstance, error) - UpdateInstanceFn func(context.Context, channelspkg.UpdateInstanceRequest) (*channelspkg.ChannelInstance, error) - UpdateInstanceStateFn func(context.Context, channelspkg.UpdateInstanceStateRequest) (*channelspkg.ChannelInstance, error) - BuildRoutingKeyFn func(context.Context, channelspkg.RoutingKey) (channelspkg.RoutingKey, error) - ResolveRouteFn func(context.Context, channelspkg.RoutingKey) (*channelspkg.ChannelRoute, error) - ResolveOrCreateRouteFn func(context.Context, channelspkg.ChannelRoute) (*channelspkg.ChannelRoute, bool, error) - UpsertRouteFn func(context.Context, channelspkg.ChannelRoute) (*channelspkg.ChannelRoute, error) - ListRoutesFn func(context.Context, string) ([]channelspkg.ChannelRoute, error) - ResolveDeliveryTargetFn func(context.Context, channelspkg.ResolveDeliveryTargetRequest) (*channelspkg.DeliveryTarget, error) - StartInstanceFn func(context.Context, string) (*channelspkg.ChannelInstance, error) - StopInstanceFn func(context.Context, string) (*channelspkg.ChannelInstance, error) - RestartInstanceFn func(context.Context, string) (*channelspkg.ChannelInstance, error) +type StubBridgeService struct { + CreateInstanceFn func(context.Context, bridgepkg.CreateInstanceRequest) (*bridgepkg.BridgeInstance, error) + GetInstanceFn func(context.Context, string) (*bridgepkg.BridgeInstance, error) + ListInstancesFn func(context.Context) ([]bridgepkg.BridgeInstance, error) + UpdateInstanceFn func(context.Context, bridgepkg.UpdateInstanceRequest) (*bridgepkg.BridgeInstance, error) + UpdateInstanceStateFn func(context.Context, bridgepkg.UpdateInstanceStateRequest) (*bridgepkg.BridgeInstance, error) + BuildRoutingKeyFn func(context.Context, bridgepkg.RoutingKey) (bridgepkg.RoutingKey, error) + ResolveRouteFn func(context.Context, bridgepkg.RoutingKey) (*bridgepkg.BridgeRoute, error) + ResolveOrCreateRouteFn func(context.Context, bridgepkg.BridgeRoute) (*bridgepkg.BridgeRoute, bool, error) + UpsertRouteFn func(context.Context, bridgepkg.BridgeRoute) (*bridgepkg.BridgeRoute, error) + ListRoutesFn func(context.Context, string) ([]bridgepkg.BridgeRoute, error) + ResolveDeliveryTargetFn func(context.Context, bridgepkg.ResolveDeliveryTargetRequest) (*bridgepkg.DeliveryTarget, error) + StartInstanceFn func(context.Context, string) (*bridgepkg.BridgeInstance, error) + StopInstanceFn func(context.Context, string) (*bridgepkg.BridgeInstance, error) + RestartInstanceFn func(context.Context, string) (*bridgepkg.BridgeInstance, error) } -var _ core.ChannelService = (*StubChannelService)(nil) +var _ core.BridgeService = (*StubBridgeService)(nil) -func (s StubChannelService) CreateInstance(ctx context.Context, req channelspkg.CreateInstanceRequest) (*channelspkg.ChannelInstance, error) { +func (s StubBridgeService) CreateInstance(ctx context.Context, req bridgepkg.CreateInstanceRequest) (*bridgepkg.BridgeInstance, error) { if s.CreateInstanceFn != nil { return s.CreateInstanceFn(ctx, req) } return nil, nil } -func (s StubChannelService) GetInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { +func (s StubBridgeService) GetInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { if s.GetInstanceFn != nil { return s.GetInstanceFn(ctx, id) } - return nil, channelspkg.ErrChannelInstanceNotFound + return nil, bridgepkg.ErrBridgeInstanceNotFound } -func (s StubChannelService) ListInstances(ctx context.Context) ([]channelspkg.ChannelInstance, error) { +func (s StubBridgeService) ListInstances(ctx context.Context) ([]bridgepkg.BridgeInstance, error) { if s.ListInstancesFn != nil { return s.ListInstancesFn(ctx) } return nil, nil } -func (s StubChannelService) UpdateInstance(ctx context.Context, req channelspkg.UpdateInstanceRequest) (*channelspkg.ChannelInstance, error) { +func (s StubBridgeService) UpdateInstance(ctx context.Context, req bridgepkg.UpdateInstanceRequest) (*bridgepkg.BridgeInstance, error) { if s.UpdateInstanceFn != nil { return s.UpdateInstanceFn(ctx, req) } - return nil, channelspkg.ErrChannelInstanceNotFound + return nil, bridgepkg.ErrBridgeInstanceNotFound } -func (s StubChannelService) UpdateInstanceState(ctx context.Context, req channelspkg.UpdateInstanceStateRequest) (*channelspkg.ChannelInstance, error) { +func (s StubBridgeService) UpdateInstanceState(ctx context.Context, req bridgepkg.UpdateInstanceStateRequest) (*bridgepkg.BridgeInstance, error) { if s.UpdateInstanceStateFn != nil { return s.UpdateInstanceStateFn(ctx, req) } - return nil, channelspkg.ErrChannelInstanceNotFound + return nil, bridgepkg.ErrBridgeInstanceNotFound } -func (s StubChannelService) BuildRoutingKey(ctx context.Context, key channelspkg.RoutingKey) (channelspkg.RoutingKey, error) { +func (s StubBridgeService) BuildRoutingKey(ctx context.Context, key bridgepkg.RoutingKey) (bridgepkg.RoutingKey, error) { if s.BuildRoutingKeyFn != nil { return s.BuildRoutingKeyFn(ctx, key) } - return channelspkg.RoutingKey{}, nil + return bridgepkg.RoutingKey{}, nil } -func (s StubChannelService) ResolveRoute(ctx context.Context, key channelspkg.RoutingKey) (*channelspkg.ChannelRoute, error) { +func (s StubBridgeService) ResolveRoute(ctx context.Context, key bridgepkg.RoutingKey) (*bridgepkg.BridgeRoute, error) { if s.ResolveRouteFn != nil { return s.ResolveRouteFn(ctx, key) } - return nil, channelspkg.ErrChannelRouteNotFound + return nil, bridgepkg.ErrBridgeRouteNotFound } -func (s StubChannelService) ResolveOrCreateRoute(ctx context.Context, route channelspkg.ChannelRoute) (*channelspkg.ChannelRoute, bool, error) { +func (s StubBridgeService) ResolveOrCreateRoute(ctx context.Context, route bridgepkg.BridgeRoute) (*bridgepkg.BridgeRoute, bool, error) { if s.ResolveOrCreateRouteFn != nil { return s.ResolveOrCreateRouteFn(ctx, route) } - return nil, false, channelspkg.ErrChannelRouteNotFound + return nil, false, bridgepkg.ErrBridgeRouteNotFound } -func (s StubChannelService) UpsertRoute(ctx context.Context, route channelspkg.ChannelRoute) (*channelspkg.ChannelRoute, error) { +func (s StubBridgeService) UpsertRoute(ctx context.Context, route bridgepkg.BridgeRoute) (*bridgepkg.BridgeRoute, error) { if s.UpsertRouteFn != nil { return s.UpsertRouteFn(ctx, route) } - return nil, channelspkg.ErrChannelRouteNotFound + return nil, bridgepkg.ErrBridgeRouteNotFound } -func (s StubChannelService) ListRoutes(ctx context.Context, channelInstanceID string) ([]channelspkg.ChannelRoute, error) { +func (s StubBridgeService) ListRoutes(ctx context.Context, bridgeInstanceID string) ([]bridgepkg.BridgeRoute, error) { if s.ListRoutesFn != nil { - return s.ListRoutesFn(ctx, channelInstanceID) + return s.ListRoutesFn(ctx, bridgeInstanceID) } return nil, nil } -func (s StubChannelService) ResolveDeliveryTarget(ctx context.Context, req channelspkg.ResolveDeliveryTargetRequest) (*channelspkg.DeliveryTarget, error) { +func (s StubBridgeService) ResolveDeliveryTarget(ctx context.Context, req bridgepkg.ResolveDeliveryTargetRequest) (*bridgepkg.DeliveryTarget, error) { if s.ResolveDeliveryTargetFn != nil { return s.ResolveDeliveryTargetFn(ctx, req) } - return nil, channelspkg.ErrChannelInstanceNotFound + return nil, bridgepkg.ErrBridgeInstanceNotFound } -func (s StubChannelService) StartInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { +func (s StubBridgeService) StartInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { if s.StartInstanceFn != nil { return s.StartInstanceFn(ctx, id) } - return nil, channelspkg.ErrChannelInstanceNotFound + return nil, bridgepkg.ErrBridgeInstanceNotFound } -func (s StubChannelService) StopInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { +func (s StubBridgeService) StopInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { if s.StopInstanceFn != nil { return s.StopInstanceFn(ctx, id) } - return nil, channelspkg.ErrChannelInstanceNotFound + return nil, bridgepkg.ErrBridgeInstanceNotFound } -func (s StubChannelService) RestartInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { +func (s StubBridgeService) RestartInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { if s.RestartInstanceFn != nil { return s.RestartInstanceFn(ctx, id) } - return nil, channelspkg.ErrChannelInstanceNotFound + return nil, bridgepkg.ErrBridgeInstanceNotFound } type StubWorkspaceService struct { diff --git a/internal/api/udsapi/bridges_integration_test.go b/internal/api/udsapi/bridges_integration_test.go new file mode 100644 index 000000000..d5d283c84 --- /dev/null +++ b/internal/api/udsapi/bridges_integration_test.go @@ -0,0 +1,90 @@ +//go:build integration + +package udsapi + +import ( + "io" + "net/http" + "testing" + "time" + + "github.com/pedronauck/agh/internal/api/contract" + bridgepkg "github.com/pedronauck/agh/internal/bridges" + "github.com/pedronauck/agh/internal/testutil" +) + +func TestUDSBridgeCreateGetAndRoutesMirrorHTTP(t *testing.T) { + runtime := newIntegrationRuntime(t) + + createResp := mustUnixRequest(t, runtime.client, http.MethodPost, "http://unix/api/bridges", []byte(`{"scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true}}`), nil) + if createResp.StatusCode != http.StatusCreated { + body := mustReadAll(t, createResp.Body) + t.Fatalf("create bridge status = %d, want %d; body=%s", createResp.StatusCode, http.StatusCreated, body) + } + + var created contract.BridgeResponse + decodeHTTPJSON(t, createResp, &created) + if created.Bridge.ID == "" { + t.Fatal("expected created bridge id") + } + + getResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/bridges/"+created.Bridge.ID, nil, nil) + if getResp.StatusCode != http.StatusOK { + body := mustReadAll(t, getResp.Body) + t.Fatalf("get bridge status = %d, want %d; body=%s", getResp.StatusCode, http.StatusOK, body) + } + + var fetched contract.BridgeResponse + decodeHTTPJSON(t, getResp, &fetched) + if fetched.Bridge.ID != created.Bridge.ID || fetched.Bridge.DisplayName != "Support" { + t.Fatalf("fetched.Bridge = %#v", fetched.Bridge) + } + + if _, err := runtime.bridges.UpdateInstanceState(testutil.Context(t), bridgepkg.UpdateInstanceStateRequest{ + ID: created.Bridge.ID, + Enabled: true, + Status: bridgepkg.BridgeStatusReady, + }); err != nil { + t.Fatalf("runtime.bridges.UpdateInstanceState() error = %v", err) + } + if _, err := runtime.bridges.UpsertRoute(testutil.Context(t), bridgepkg.BridgeRoute{ + BridgeInstanceID: created.Bridge.ID, + Scope: bridgepkg.ScopeGlobal, + PeerID: "peer-1", + SessionID: "sess-1", + AgentName: "coder", + LastActivityAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + }); err != nil { + t.Fatalf("runtime.bridges.UpsertRoute() error = %v", err) + } + + routesResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/bridges/"+created.Bridge.ID+"/routes", nil, nil) + if routesResp.StatusCode != http.StatusOK { + body := mustReadAll(t, routesResp.Body) + t.Fatalf("bridge routes status = %d, want %d; body=%s", routesResp.StatusCode, http.StatusOK, body) + } + + var routes contract.BridgeRoutesResponse + decodeHTTPJSON(t, routesResp, &routes) + if got, want := len(routes.Routes), 1; got != want { + t.Fatalf("len(routes) = %d, want %d", got, want) + } + if routes.Routes[0].BridgeInstanceID != created.Bridge.ID || routes.Routes[0].PeerID != "peer-1" { + t.Fatalf("routes = %#v", routes.Routes) + } +} + +func mustReadAll(t *testing.T, body io.ReadCloser) string { + t.Helper() + defer func() { + if err := body.Close(); err != nil { + t.Errorf("body.Close() error = %v", err) + } + }() + + data, err := io.ReadAll(body) + if err != nil { + t.Fatalf("io.ReadAll() error = %v", err) + } + return string(data) +} diff --git a/internal/api/udsapi/bridges_test.go b/internal/api/udsapi/bridges_test.go new file mode 100644 index 000000000..a8e546843 --- /dev/null +++ b/internal/api/udsapi/bridges_test.go @@ -0,0 +1,125 @@ +package udsapi + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/pedronauck/agh/internal/api/contract" + bridgepkg "github.com/pedronauck/agh/internal/bridges" +) + +func TestCreateBridgeHandlerReturnsPersistedPayload(t *testing.T) { + t.Parallel() + + homePaths := newTestHomePaths(t) + bridges := stubBridgeService{ + CreateInstanceFn: func(_ context.Context, req bridgepkg.CreateInstanceRequest) (*bridgepkg.BridgeInstance, error) { + if req.Scope != bridgepkg.ScopeGlobal || req.Platform != "telegram" || req.ExtensionName != "ext-telegram" || req.DisplayName != "Support" { + t.Fatalf("CreateInstance() req = %#v", req) + } + return &bridgepkg.BridgeInstance{ + ID: "brg-uds", + Scope: req.Scope, + Platform: req.Platform, + ExtensionName: req.ExtensionName, + DisplayName: req.DisplayName, + Enabled: req.Enabled, + Status: req.Status, + RoutingPolicy: req.RoutingPolicy, + CreatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + }, nil + }, + } + + engine := newTestRouter(t, newTestHandlersWithBridges(t, stubSessionManager{}, stubObserver{}, bridges, stubWorkspaceService{}, homePaths)) + body := []byte(`{"scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true}}`) + recorder := performRequest(t, engine, http.MethodPost, "/api/bridges", body) + if recorder.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusCreated, recorder.Body.String()) + } + + var response contract.BridgeResponse + decodeJSONResponse(t, recorder, &response) + if response.Bridge.ID != "brg-uds" || response.Bridge.Scope != bridgepkg.ScopeGlobal { + t.Fatalf("response.Bridge = %#v", response.Bridge) + } +} + +func TestGetBridgeHandlerReturnsPersistedPayload(t *testing.T) { + t.Parallel() + + homePaths := newTestHomePaths(t) + bridges := stubBridgeService{ + GetInstanceFn: func(_ context.Context, id string) (*bridgepkg.BridgeInstance, error) { + if id != "brg-uds" { + t.Fatalf("GetInstance() id = %q, want brg-uds", id) + } + return &bridgepkg.BridgeInstance{ + ID: id, + Scope: bridgepkg.ScopeGlobal, + Platform: "telegram", + ExtensionName: "ext-telegram", + DisplayName: "Support", + Enabled: true, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, + }, nil + }, + } + + engine := newTestRouter(t, newTestHandlersWithBridges(t, stubSessionManager{}, stubObserver{}, bridges, stubWorkspaceService{}, homePaths)) + recorder := performRequest(t, engine, http.MethodGet, "/api/bridges/brg-uds", nil) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + + var response contract.BridgeResponse + decodeJSONResponse(t, recorder, &response) + if response.Bridge.ID != "brg-uds" || response.Bridge.Status != bridgepkg.BridgeStatusReady { + t.Fatalf("response.Bridge = %#v", response.Bridge) + } +} + +func TestListBridgeRoutesHandlerReturnsRequestedPayload(t *testing.T) { + t.Parallel() + + homePaths := newTestHomePaths(t) + bridges := stubBridgeService{ + ListRoutesFn: func(_ context.Context, bridgeInstanceID string) ([]bridgepkg.BridgeRoute, error) { + if bridgeInstanceID != "brg-uds" { + t.Fatalf("ListRoutes() bridgeInstanceID = %q, want brg-uds", bridgeInstanceID) + } + return []bridgepkg.BridgeRoute{ + { + RoutingKeyHash: "hash-uds", + Scope: bridgepkg.ScopeGlobal, + BridgeInstanceID: "brg-uds", + PeerID: "peer-1", + SessionID: "sess-1", + AgentName: "coder", + LastActivityAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), + }, + }, nil + }, + } + + engine := newTestRouter(t, newTestHandlersWithBridges(t, stubSessionManager{}, stubObserver{}, bridges, stubWorkspaceService{}, homePaths)) + recorder := performRequest(t, engine, http.MethodGet, "/api/bridges/brg-uds/routes", nil) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + + var response contract.BridgeRoutesResponse + decodeJSONResponse(t, recorder, &response) + if got, want := len(response.Routes), 1; got != want { + t.Fatalf("len(routes) = %d, want %d", got, want) + } + if response.Routes[0].BridgeInstanceID != "brg-uds" || response.Routes[0].PeerID != "peer-1" { + t.Fatalf("route = %#v", response.Routes[0]) + } +} diff --git a/internal/api/udsapi/channels_integration_test.go b/internal/api/udsapi/channels_integration_test.go deleted file mode 100644 index 3c40fc0a8..000000000 --- a/internal/api/udsapi/channels_integration_test.go +++ /dev/null @@ -1,90 +0,0 @@ -//go:build integration - -package udsapi - -import ( - "io" - "net/http" - "testing" - "time" - - "github.com/pedronauck/agh/internal/api/contract" - channelspkg "github.com/pedronauck/agh/internal/channels" - "github.com/pedronauck/agh/internal/testutil" -) - -func TestUDSChannelCreateGetAndRoutesMirrorHTTP(t *testing.T) { - runtime := newIntegrationRuntime(t) - - createResp := mustUnixRequest(t, runtime.client, http.MethodPost, "http://unix/api/channels", []byte(`{"scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true}}`), nil) - if createResp.StatusCode != http.StatusCreated { - body := mustReadAll(t, createResp.Body) - t.Fatalf("create channel status = %d, want %d; body=%s", createResp.StatusCode, http.StatusCreated, body) - } - - var created contract.ChannelResponse - decodeHTTPJSON(t, createResp, &created) - if created.Channel.ID == "" { - t.Fatal("expected created channel id") - } - - getResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/channels/"+created.Channel.ID, nil, nil) - if getResp.StatusCode != http.StatusOK { - body := mustReadAll(t, getResp.Body) - t.Fatalf("get channel status = %d, want %d; body=%s", getResp.StatusCode, http.StatusOK, body) - } - - var fetched contract.ChannelResponse - decodeHTTPJSON(t, getResp, &fetched) - if fetched.Channel.ID != created.Channel.ID || fetched.Channel.DisplayName != "Support" { - t.Fatalf("fetched.Channel = %#v", fetched.Channel) - } - - if _, err := runtime.channels.UpdateInstanceState(testutil.Context(t), channelspkg.UpdateInstanceStateRequest{ - ID: created.Channel.ID, - Enabled: true, - Status: channelspkg.ChannelStatusReady, - }); err != nil { - t.Fatalf("runtime.channels.UpdateInstanceState() error = %v", err) - } - if _, err := runtime.channels.UpsertRoute(testutil.Context(t), channelspkg.ChannelRoute{ - ChannelInstanceID: created.Channel.ID, - Scope: channelspkg.ScopeGlobal, - PeerID: "peer-1", - SessionID: "sess-1", - AgentName: "coder", - LastActivityAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - }); err != nil { - t.Fatalf("runtime.channels.UpsertRoute() error = %v", err) - } - - routesResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/channels/"+created.Channel.ID+"/routes", nil, nil) - if routesResp.StatusCode != http.StatusOK { - body := mustReadAll(t, routesResp.Body) - t.Fatalf("channel routes status = %d, want %d; body=%s", routesResp.StatusCode, http.StatusOK, body) - } - - var routes contract.ChannelRoutesResponse - decodeHTTPJSON(t, routesResp, &routes) - if got, want := len(routes.Routes), 1; got != want { - t.Fatalf("len(routes) = %d, want %d", got, want) - } - if routes.Routes[0].ChannelInstanceID != created.Channel.ID || routes.Routes[0].PeerID != "peer-1" { - t.Fatalf("routes = %#v", routes.Routes) - } -} - -func mustReadAll(t *testing.T, body io.ReadCloser) string { - t.Helper() - defer func() { - if err := body.Close(); err != nil { - t.Errorf("body.Close() error = %v", err) - } - }() - - data, err := io.ReadAll(body) - if err != nil { - t.Fatalf("io.ReadAll() error = %v", err) - } - return string(data) -} diff --git a/internal/api/udsapi/channels_test.go b/internal/api/udsapi/channels_test.go deleted file mode 100644 index 504aebc0c..000000000 --- a/internal/api/udsapi/channels_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package udsapi - -import ( - "context" - "net/http" - "testing" - "time" - - "github.com/pedronauck/agh/internal/api/contract" - channelspkg "github.com/pedronauck/agh/internal/channels" -) - -func TestCreateChannelHandlerReturnsPersistedPayload(t *testing.T) { - t.Parallel() - - homePaths := newTestHomePaths(t) - channels := stubChannelService{ - CreateInstanceFn: func(_ context.Context, req channelspkg.CreateInstanceRequest) (*channelspkg.ChannelInstance, error) { - if req.Scope != channelspkg.ScopeGlobal || req.Platform != "telegram" || req.ExtensionName != "ext-telegram" || req.DisplayName != "Support" { - t.Fatalf("CreateInstance() req = %#v", req) - } - return &channelspkg.ChannelInstance{ - ID: "chan-uds", - Scope: req.Scope, - Platform: req.Platform, - ExtensionName: req.ExtensionName, - DisplayName: req.DisplayName, - Enabled: req.Enabled, - Status: req.Status, - RoutingPolicy: req.RoutingPolicy, - CreatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - }, nil - }, - } - - engine := newTestRouter(t, newTestHandlersWithChannels(t, stubSessionManager{}, stubObserver{}, channels, stubWorkspaceService{}, homePaths)) - body := []byte(`{"scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true}}`) - recorder := performRequest(t, engine, http.MethodPost, "/api/channels", body) - if recorder.Code != http.StatusCreated { - t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusCreated, recorder.Body.String()) - } - - var response contract.ChannelResponse - decodeJSONResponse(t, recorder, &response) - if response.Channel.ID != "chan-uds" || response.Channel.Scope != channelspkg.ScopeGlobal { - t.Fatalf("response.Channel = %#v", response.Channel) - } -} - -func TestGetChannelHandlerReturnsPersistedPayload(t *testing.T) { - t.Parallel() - - homePaths := newTestHomePaths(t) - channels := stubChannelService{ - GetInstanceFn: func(_ context.Context, id string) (*channelspkg.ChannelInstance, error) { - if id != "chan-uds" { - t.Fatalf("GetInstance() id = %q, want chan-uds", id) - } - return &channelspkg.ChannelInstance{ - ID: id, - Scope: channelspkg.ScopeGlobal, - Platform: "telegram", - ExtensionName: "ext-telegram", - DisplayName: "Support", - Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, - }, nil - }, - } - - engine := newTestRouter(t, newTestHandlersWithChannels(t, stubSessionManager{}, stubObserver{}, channels, stubWorkspaceService{}, homePaths)) - recorder := performRequest(t, engine, http.MethodGet, "/api/channels/chan-uds", nil) - if recorder.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) - } - - var response contract.ChannelResponse - decodeJSONResponse(t, recorder, &response) - if response.Channel.ID != "chan-uds" || response.Channel.Status != channelspkg.ChannelStatusReady { - t.Fatalf("response.Channel = %#v", response.Channel) - } -} - -func TestListChannelRoutesHandlerReturnsRequestedPayload(t *testing.T) { - t.Parallel() - - homePaths := newTestHomePaths(t) - channels := stubChannelService{ - ListRoutesFn: func(_ context.Context, channelInstanceID string) ([]channelspkg.ChannelRoute, error) { - if channelInstanceID != "chan-uds" { - t.Fatalf("ListRoutes() channelInstanceID = %q, want chan-uds", channelInstanceID) - } - return []channelspkg.ChannelRoute{ - { - RoutingKeyHash: "hash-uds", - Scope: channelspkg.ScopeGlobal, - ChannelInstanceID: "chan-uds", - PeerID: "peer-1", - SessionID: "sess-1", - AgentName: "coder", - LastActivityAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - CreatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC), - }, - }, nil - }, - } - - engine := newTestRouter(t, newTestHandlersWithChannels(t, stubSessionManager{}, stubObserver{}, channels, stubWorkspaceService{}, homePaths)) - recorder := performRequest(t, engine, http.MethodGet, "/api/channels/chan-uds/routes", nil) - if recorder.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) - } - - var response contract.ChannelRoutesResponse - decodeJSONResponse(t, recorder, &response) - if got, want := len(response.Routes), 1; got != want { - t.Fatalf("len(routes) = %d, want %d", got, want) - } - if response.Routes[0].ChannelInstanceID != "chan-uds" || response.Routes[0].PeerID != "peer-1" { - t.Fatalf("route = %#v", response.Routes[0]) - } -} diff --git a/internal/api/udsapi/handlers_test.go b/internal/api/udsapi/handlers_test.go index 3289809f5..9680d93a7 100644 --- a/internal/api/udsapi/handlers_test.go +++ b/internal/api/udsapi/handlers_test.go @@ -92,9 +92,9 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "GET /api/automation/triggers", "GET /api/automation/triggers/:id", "GET /api/automation/triggers/:id/runs", - "GET /api/channels", - "GET /api/channels/:id", - "GET /api/channels/:id/routes", + "GET /api/bridges", + "GET /api/bridges/:id", + "GET /api/bridges/:id/routes", "GET /api/daemon/status", "GET /api/extensions", "GET /api/extensions/:name", @@ -105,7 +105,7 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "GET /api/memory/:filename", "GET /api/network/inbox", "GET /api/network/peers", - "GET /api/network/spaces", + "GET /api/network/channels", "GET /api/network/status", "GET /api/observe/events", "GET /api/observe/events/stream", @@ -123,16 +123,16 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "GET /api/workspaces/:id", "PATCH /api/automation/jobs/:id", "PATCH /api/automation/triggers/:id", - "PATCH /api/channels/:id", + "PATCH /api/bridges/:id", "PATCH /api/workspaces/:id", "POST /api/automation/jobs", "POST /api/automation/jobs/:id/trigger", "POST /api/automation/triggers", - "POST /api/channels", - "POST /api/channels/:id/disable", - "POST /api/channels/:id/enable", - "POST /api/channels/:id/restart", - "POST /api/channels/:id/test-delivery", + "POST /api/bridges", + "POST /api/bridges/:id/disable", + "POST /api/bridges/:id/enable", + "POST /api/bridges/:id/restart", + "POST /api/bridges/:id/test-delivery", "POST /api/extensions", "POST /api/extensions/:name/disable", "POST /api/extensions/:name/enable", @@ -164,18 +164,18 @@ func TestCreateSessionHandlerReturnsSessionID(t *testing.T) { homePaths := newTestHomePaths(t) manager := stubSessionManager{ CreateFn: func(_ context.Context, opts session.CreateOpts) (*session.Session, error) { - if opts.AgentName != "coder" || opts.Name != "demo" || opts.Workspace != "alpha" || opts.WorkspacePath != "" || opts.Space != "builders" { + if opts.AgentName != "coder" || opts.Name != "demo" || opts.Workspace != "alpha" || opts.WorkspacePath != "" || opts.Channel != "builders" { t.Fatalf("Create() opts = %#v", opts) } sess := newSession("sess-123") - sess.Space = "builders" + sess.Channel = "builders" return sess, nil }, } handlers := newTestHandlers(t, manager, stubObserver{}, homePaths) engine := newTestRouter(t, handlers) - recorder := performRequest(t, engine, http.MethodPost, "/api/sessions", []byte(`{"agent_name":"coder","name":"demo","workspace":"alpha","space":"builders"}`)) + recorder := performRequest(t, engine, http.MethodPost, "/api/sessions", []byte(`{"agent_name":"coder","name":"demo","workspace":"alpha","channel":"builders"}`)) if recorder.Code != http.StatusCreated { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusCreated, recorder.Body.String()) } @@ -190,8 +190,8 @@ func TestCreateSessionHandlerReturnsSessionID(t *testing.T) { if response.Session.WorkspaceID != "ws-workspace" || response.Session.WorkspacePath != "/workspace" { t.Fatalf("session workspace = %#v", response.Session) } - if response.Session.Space != "builders" { - t.Fatalf("session space = %q, want %q", response.Session.Space, "builders") + if response.Session.Channel != "builders" { + t.Fatalf("session channel = %q, want %q", response.Session.Channel, "builders") } } diff --git a/internal/api/udsapi/helpers_test.go b/internal/api/udsapi/helpers_test.go index c57daa335..f2d6f4621 100644 --- a/internal/api/udsapi/helpers_test.go +++ b/internal/api/udsapi/helpers_test.go @@ -25,7 +25,7 @@ var errStubWorkspaceServiceNotImplemented = testutil.ErrStubWorkspaceServiceNotI type stubSessionManager = testutil.StubSessionManager type stubObserver = testutil.StubObserver -type stubChannelService = testutil.StubChannelService +type stubBridgeService = testutil.StubBridgeService type stubNetworkService = testutil.StubNetworkService type stubWorkspaceService = testutil.StubWorkspaceService type stubSkillsRegistry = testutil.StubSkillsRegistry @@ -36,16 +36,16 @@ func newTestHandlers(t *testing.T, manager core.SessionManager, observer core.Ob return newTestHandlersWithRuntime(t, manager, observer, nil, nil, stubWorkspaceService{}, nil, homePaths) } -func newTestHandlersWithChannels( +func newTestHandlersWithBridges( t *testing.T, manager core.SessionManager, observer core.Observer, - channels core.ChannelService, + bridges core.BridgeService, workspaces core.WorkspaceService, homePaths aghconfig.HomePaths, ) *Handlers { t.Helper() - return newTestHandlersWithRuntime(t, manager, observer, nil, channels, workspaces, nil, homePaths) + return newTestHandlersWithRuntime(t, manager, observer, nil, bridges, workspaces, nil, homePaths) } func newTestHandlersWithExtensions(t *testing.T, manager core.SessionManager, observer core.Observer, extensions ExtensionService, homePaths aghconfig.HomePaths) *Handlers { @@ -58,7 +58,7 @@ func newTestHandlersWithRuntime( manager core.SessionManager, observer core.Observer, automation core.AutomationManager, - channels core.ChannelService, + bridges core.BridgeService, workspaces core.WorkspaceService, extensions ExtensionService, homePaths aghconfig.HomePaths, @@ -69,7 +69,7 @@ func newTestHandlersWithRuntime( sessions: manager, observer: observer, automation: automation, - channels: channels, + bridges: bridges, workspaces: workspaces, homePaths: homePaths, config: aghconfig.DefaultWithHome(homePaths), @@ -85,7 +85,7 @@ func newTestHandlersWithRuntime( func newTestHandlersWithWorkspace(t *testing.T, manager core.SessionManager, observer core.Observer, workspaces core.WorkspaceService, homePaths aghconfig.HomePaths) *Handlers { t.Helper() - return newTestHandlersWithChannels(t, manager, observer, nil, workspaces, homePaths) + return newTestHandlersWithBridges(t, manager, observer, nil, workspaces, homePaths) } func newTestRouter(t *testing.T, handlers *Handlers) *gin.Engine { diff --git a/internal/api/udsapi/network_test.go b/internal/api/udsapi/network_test.go index b068914da..381de3eb0 100644 --- a/internal/api/udsapi/network_test.go +++ b/internal/api/udsapi/network_test.go @@ -58,7 +58,7 @@ func TestNetworkHandlersPreserveWorkflowMetadata(t *testing.T) { Protocol: network.ProtocolV0, ID: "msg-inbox", Kind: network.KindDirect, - Space: "builders", + Channel: "builders", From: "reviewer.sess-a", TS: 1775823000, Body: json.RawMessage(`{"text":"review this","intent":"review"}`), @@ -71,7 +71,7 @@ func TestNetworkHandlersPreserveWorkflowMetadata(t *testing.T) { } engine := newTestRouter(t, handlers) - sendResp := performRequest(t, engine, http.MethodPost, "/api/network/send", []byte(`{"session_id":"sess-a","space":"builders","kind":"say","body":{"text":"hello"},"ext":{"agh.workflow_id":"wf-1","agh.handoff_version":3}}`)) + sendResp := performRequest(t, engine, http.MethodPost, "/api/network/send", []byte(`{"session_id":"sess-a","channel":"builders","kind":"say","body":{"text":"hello"},"ext":{"agh.workflow_id":"wf-1","agh.handoff_version":3}}`)) if sendResp.Code != http.StatusOK { t.Fatalf("send status = %d, want %d; body=%s", sendResp.Code, http.StatusOK, sendResp.Body.String()) } diff --git a/internal/api/udsapi/routes.go b/internal/api/udsapi/routes.go index c5abb03a1..095ee07c6 100644 --- a/internal/api/udsapi/routes.go +++ b/internal/api/udsapi/routes.go @@ -6,17 +6,17 @@ import "github.com/gin-gonic/gin" func RegisterRoutes(router gin.IRouter, handlers *Handlers) { api := router.Group("/api") - channels := api.Group("/channels") + bridges := api.Group("/bridges") { - channels.GET("", handlers.ListChannels) - channels.POST("", handlers.CreateChannel) - channels.GET("/:id", handlers.GetChannel) - channels.PATCH("/:id", handlers.UpdateChannel) - channels.POST("/:id/enable", handlers.EnableChannel) - channels.POST("/:id/disable", handlers.DisableChannel) - channels.POST("/:id/restart", handlers.RestartChannel) - channels.GET("/:id/routes", handlers.ListChannelRoutes) - channels.POST("/:id/test-delivery", handlers.TestChannelDelivery) + bridges.GET("", handlers.ListBridges) + bridges.POST("", handlers.CreateBridge) + bridges.GET("/:id", handlers.GetBridge) + bridges.PATCH("/:id", handlers.UpdateBridge) + bridges.POST("/:id/enable", handlers.EnableBridge) + bridges.POST("/:id/disable", handlers.DisableBridge) + bridges.POST("/:id/restart", handlers.RestartBridge) + bridges.GET("/:id/routes", handlers.ListBridgeRoutes) + bridges.POST("/:id/test-delivery", handlers.TestBridgeDelivery) } workspaces := api.Group("/workspaces") @@ -115,7 +115,7 @@ func RegisterRoutes(router gin.IRouter, handlers *Handlers) { { network.GET("/status", handlers.NetworkStatus) network.GET("/peers", handlers.NetworkPeers) - network.GET("/spaces", handlers.NetworkSpaces) + network.GET("/channels", handlers.NetworkChannels) network.POST("/send", handlers.NetworkSend) network.GET("/inbox", handlers.NetworkInbox) } diff --git a/internal/api/udsapi/server.go b/internal/api/udsapi/server.go index 43d009ed8..7e56fb9ed 100644 --- a/internal/api/udsapi/server.go +++ b/internal/api/udsapi/server.go @@ -54,7 +54,7 @@ type Server struct { network core.NetworkService observer core.Observer automation core.AutomationManager - channels core.ChannelService + bridges core.BridgeService workspaces core.WorkspaceService skillsRegistry core.SkillsRegistry memoryStore *memory.Store @@ -77,7 +77,7 @@ type handlerConfig struct { network core.NetworkService observer core.Observer automation core.AutomationManager - channels core.ChannelService + bridges core.BridgeService workspaces core.WorkspaceService skillsRegistry core.SkillsRegistry memoryStore *memory.Store @@ -175,10 +175,10 @@ func WithAutomation(manager core.AutomationManager) Option { } } -// WithChannelService injects the daemon-owned channel runtime. -func WithChannelService(channels core.ChannelService) Option { +// WithBridgeService injects the daemon-owned bridge runtime. +func WithBridgeService(bridges core.BridgeService) Option { return func(server *Server) { - server.channels = channels + server.bridges = bridges } } @@ -299,7 +299,7 @@ func New(opts ...Option) (*Server, error) { network: server.network, observer: server.observer, automation: server.automation, - channels: server.channels, + bridges: server.bridges, workspaces: server.workspaces, skillsRegistry: server.skillsRegistry, memoryStore: server.memoryStore, @@ -509,7 +509,7 @@ func newHandlers(cfg handlerConfig) *Handlers { Network: cfg.network, Observer: cfg.observer, Automation: cfg.automation, - Channels: cfg.channels, + Bridges: cfg.bridges, Workspaces: cfg.workspaces, SkillsRegistry: cfg.skillsRegistry, MemoryStore: cfg.memoryStore, diff --git a/internal/api/udsapi/server_test.go b/internal/api/udsapi/server_test.go index 379e3b6cf..0ea8fd824 100644 --- a/internal/api/udsapi/server_test.go +++ b/internal/api/udsapi/server_test.go @@ -27,7 +27,7 @@ func TestNewHonorsOptionsAndDefaults(t *testing.T) { } store := memory.NewStore(filepath.Join(t.TempDir(), "memory")) dream := &stubDreamTrigger{} - channelService := &stubChannelService{} + bridgeService := &stubBridgeService{} extensionService := &stubExtensionService{} cfg := aghconfig.DefaultWithHome(homePaths) cfg.Daemon.Socket = socketPath @@ -42,7 +42,7 @@ func TestNewHonorsOptionsAndDefaults(t *testing.T) { WithPollInterval(25*time.Millisecond), WithSessionManager(stubSessionManager{}), WithObserver(stubObserver{}), - WithChannelService(channelService), + WithBridgeService(bridgeService), WithWorkspaceResolver(stubWorkspaceService{}), WithSkillsRegistry(stubSkillsRegistry{}), WithMemoryStore(store), @@ -78,8 +78,8 @@ func TestNewHonorsOptionsAndDefaults(t *testing.T) { if server.handlers.DreamTrigger != dream { t.Fatal("expected dream trigger option to be installed") } - if server.handlers.Channels != channelService { - t.Fatal("expected channel service option to be installed") + if server.handlers.Bridges != bridgeService { + t.Fatal("expected bridge service option to be installed") } if server.handlers.Extensions != extensionService { t.Fatal("expected extension service option to be installed") diff --git a/internal/api/udsapi/udsapi_integration_test.go b/internal/api/udsapi/udsapi_integration_test.go index 141ea2471..683a85468 100644 --- a/internal/api/udsapi/udsapi_integration_test.go +++ b/internal/api/udsapi/udsapi_integration_test.go @@ -20,7 +20,7 @@ import ( "github.com/pedronauck/agh/internal/api/contract" "github.com/pedronauck/agh/internal/api/core" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" aghconfig "github.com/pedronauck/agh/internal/config" "github.com/pedronauck/agh/internal/memory" "github.com/pedronauck/agh/internal/observe" @@ -441,10 +441,10 @@ func TestUDSShutdownWaitsForInflightRequests(t *testing.T) { } } -func TestUDSSessionSpaceRoundTrip(t *testing.T) { +func TestUDSSessionChannelRoundTrip(t *testing.T) { runtime := newIntegrationRuntime(t) - createResp := mustUnixRequest(t, runtime.client, http.MethodPost, "http://unix/api/sessions", []byte(`{"agent_name":"coder","workspace_path":"`+runtime.workspace+`","space":"builders"}`), nil) + createResp := mustUnixRequest(t, runtime.client, http.MethodPost, "http://unix/api/sessions", []byte(`{"agent_name":"coder","workspace_path":"`+runtime.workspace+`","channel":"builders"}`), nil) if createResp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(createResp.Body) _ = createResp.Body.Close() @@ -454,8 +454,8 @@ func TestUDSSessionSpaceRoundTrip(t *testing.T) { Session sessionPayload `json:"session"` } decodeHTTPJSON(t, createResp, &created) - if created.Session.Space != "builders" { - t.Fatalf("created.Session.Space = %q, want %q", created.Session.Space, "builders") + if created.Session.Channel != "builders" { + t.Fatalf("created.Session.Channel = %q, want %q", created.Session.Channel, "builders") } listResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/sessions", nil, nil) @@ -471,8 +471,8 @@ func TestUDSSessionSpaceRoundTrip(t *testing.T) { if got, want := len(listed.Sessions), 1; got != want { t.Fatalf("len(listed.Sessions) = %d, want %d", got, want) } - if listed.Sessions[0].Space != "builders" { - t.Fatalf("listed.Sessions[0].Space = %q, want %q", listed.Sessions[0].Space, "builders") + if listed.Sessions[0].Channel != "builders" { + t.Fatalf("listed.Sessions[0].Channel = %q, want %q", listed.Sessions[0].Channel, "builders") } stopIntegrationSession(t, runtime, created.Session.ID) @@ -487,7 +487,7 @@ func TestUDSSessionSpaceRoundTrip(t *testing.T) { Session sessionPayload `json:"session"` } decodeHTTPJSON(t, statusResp, &stopped) - if stopped.Session.Space != "builders" || stopped.Session.State != session.StateStopped { + if stopped.Session.Channel != "builders" || stopped.Session.State != session.StateStopped { t.Fatalf("stopped session = %#v, want stopped builders session", stopped.Session) } @@ -501,7 +501,7 @@ func TestUDSSessionSpaceRoundTrip(t *testing.T) { Session sessionPayload `json:"session"` } decodeHTTPJSON(t, resumeResp, &resumed) - if resumed.Session.Space != "builders" || resumed.Session.State != session.StateActive { + if resumed.Session.Channel != "builders" || resumed.Session.State != session.StateActive { t.Fatalf("resumed session = %#v, want active builders session", resumed.Session) } } @@ -512,7 +512,7 @@ type integrationRuntime struct { manager *session.Manager observer *observe.Observer registry *globaldb.GlobalDB - channels *integrationChannelService + bridges *integrationBridgeService memory *memory.Store dream *integrationDreamTrigger socket string @@ -527,62 +527,62 @@ type integrationDreamTrigger struct { calls int } -type integrationChannelService struct { - *channelspkg.Service +type integrationBridgeService struct { + *bridgepkg.Service } -var _ core.ChannelService = (*integrationChannelService)(nil) +var _ core.BridgeService = (*integrationBridgeService)(nil) -func newIntegrationChannelService(store channelspkg.RegistryStore) *integrationChannelService { - return &integrationChannelService{Service: channelspkg.NewRegistry(store)} +func newIntegrationBridgeService(store bridgepkg.RegistryStore) *integrationBridgeService { + return &integrationBridgeService{Service: bridgepkg.NewRegistry(store)} } -func (s *integrationChannelService) StartInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { - if _, err := s.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ +func (s *integrationBridgeService) StartInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { + if _, err := s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, Enabled: true, - Status: channelspkg.ChannelStatusStarting, + Status: bridgepkg.BridgeStatusStarting, }); err != nil { - return nil, fmt.Errorf("start channel instance %q: %w", id, err) + return nil, fmt.Errorf("start bridge instance %q: %w", id, err) } - instance, err := s.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ + instance, err := s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, Enabled: true, - Status: channelspkg.ChannelStatusReady, + Status: bridgepkg.BridgeStatusReady, }) if err != nil { - return nil, fmt.Errorf("mark channel instance %q ready: %w", id, err) + return nil, fmt.Errorf("mark bridge instance %q ready: %w", id, err) } return instance, nil } -func (s *integrationChannelService) StopInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { - instance, err := s.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ +func (s *integrationBridgeService) StopInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { + instance, err := s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, Enabled: false, - Status: channelspkg.ChannelStatusDisabled, + Status: bridgepkg.BridgeStatusDisabled, }) if err != nil { - return nil, fmt.Errorf("stop channel instance %q: %w", id, err) + return nil, fmt.Errorf("stop bridge instance %q: %w", id, err) } return instance, nil } -func (s *integrationChannelService) RestartInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { - if _, err := s.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ +func (s *integrationBridgeService) RestartInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { + if _, err := s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, Enabled: true, - Status: channelspkg.ChannelStatusStarting, + Status: bridgepkg.BridgeStatusStarting, }); err != nil { - return nil, fmt.Errorf("restart channel instance %q: %w", id, err) + return nil, fmt.Errorf("restart bridge instance %q: %w", id, err) } - instance, err := s.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ + instance, err := s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, Enabled: true, - Status: channelspkg.ChannelStatusReady, + Status: bridgepkg.BridgeStatusReady, }) if err != nil { - return nil, fmt.Errorf("mark restarted channel instance %q ready: %w", id, err) + return nil, fmt.Errorf("mark restarted bridge instance %q ready: %w", id, err) } return instance, nil } @@ -772,7 +772,7 @@ func newIntegrationRuntime(t *testing.T) integrationRuntime { if err := memoryStore.EnsureDirs(); err != nil { t.Fatalf("memoryStore.EnsureDirs() error = %v", err) } - channelService := newIntegrationChannelService(registry) + bridgeService := newIntegrationBridgeService(registry) dreamTrigger := &integrationDreamTrigger{ enabled: true, triggered: true, @@ -810,7 +810,7 @@ func newIntegrationRuntime(t *testing.T) integrationRuntime { WithSessionManager(manager), WithObserver(observer), WithAutomation(automationManager), - WithChannelService(channelService), + WithBridgeService(bridgeService), WithWorkspaceResolver(resolver), WithMemoryStore(memoryStore), WithDreamTrigger(dreamTrigger), @@ -836,7 +836,7 @@ func newIntegrationRuntime(t *testing.T) integrationRuntime { manager: manager, observer: observer, registry: registry, - channels: channelService, + bridges: bridgeService, memory: memoryStore, dream: dreamTrigger, socket: socketPath, diff --git a/internal/channels/delivery_broker.go b/internal/bridges/delivery_broker.go similarity index 80% rename from internal/channels/delivery_broker.go rename to internal/bridges/delivery_broker.go index 4d2b8ff90..1802fb651 100644 --- a/internal/channels/delivery_broker.go +++ b/internal/bridges/delivery_broker.go @@ -1,4 +1,4 @@ -package channels +package bridges import ( "context" @@ -27,22 +27,22 @@ type deliveryQueueItem struct { } type routeWorker struct { - hash string - channelInstanceID string - extensionName string - queue []deliveryQueueItem - wakeCh chan struct{} + hash string + bridgeInstanceID string + extensionName string + queue []deliveryQueueItem + wakeCh chan struct{} } type activeDelivery struct { - deliveryID string - sessionID string - turnID string - channelInstanceID string - extensionName string - routingKey RoutingKey - target DeliveryTarget - routeHash string + deliveryID string + sessionID string + turnID string + bridgeInstanceID string + extensionName string + routingKey RoutingKey + target DeliveryTarget + routeHash string latestSeq int64 lastSentSeq int64 @@ -76,7 +76,7 @@ type instanceDeliveryMetrics struct { } // Broker projects session output into ordered delivery requests for one -// channel-capable extension runtime. +// bridge-capable extension runtime. type Broker struct { mu sync.Mutex @@ -171,7 +171,7 @@ func (b *Broker) Close() { // DeliveryMetrics returns a point-in-time snapshot of per-instance broker // telemetry used by health and observability surfaces. -func (b *Broker) DeliveryMetrics() map[string]ChannelDeliveryMetrics { +func (b *Broker) DeliveryMetrics() map[string]BridgeDeliveryMetrics { if b == nil { return nil } @@ -179,8 +179,8 @@ func (b *Broker) DeliveryMetrics() map[string]ChannelDeliveryMetrics { b.mu.Lock() defer b.mu.Unlock() - snapshot := make(map[string]ChannelDeliveryMetrics, len(b.metrics)) - for channelInstanceID, metrics := range b.metrics { + snapshot := make(map[string]BridgeDeliveryMetrics, len(b.metrics)) + for bridgeInstanceID, metrics := range b.metrics { if metrics == nil { continue } @@ -192,8 +192,8 @@ func (b *Broker) DeliveryMetrics() map[string]ChannelDeliveryMetrics { totalDropped += count } - snapshot[channelInstanceID] = ChannelDeliveryMetrics{ - ChannelInstanceID: channelInstanceID, + snapshot[bridgeInstanceID] = BridgeDeliveryMetrics{ + BridgeInstanceID: bridgeInstanceID, DeliveryDroppedTotal: totalDropped, DeliveryDroppedByReason: clonedReasons, DeliveryFailuresTotal: metrics.deliveryFailuresTotal, @@ -203,13 +203,13 @@ func (b *Broker) DeliveryMetrics() map[string]ChannelDeliveryMetrics { } for _, route := range b.routes { - if route == nil || route.channelInstanceID == "" { + if route == nil || route.bridgeInstanceID == "" { continue } - entry := snapshot[route.channelInstanceID] - entry.ChannelInstanceID = route.channelInstanceID + entry := snapshot[route.bridgeInstanceID] + entry.BridgeInstanceID = route.bridgeInstanceID entry.DeliveryBacklog += len(route.queue) - snapshot[route.channelInstanceID] = entry + snapshot[route.bridgeInstanceID] = entry } return snapshot @@ -219,10 +219,10 @@ func (b *Broker) DeliveryMetrics() map[string]ChannelDeliveryMetrics { // projection and optionally seeds the broker from already-persisted turn events. func (b *Broker) RegisterPromptDelivery(ctx context.Context, reg PromptDeliveryRegistration) (*DeliverySnapshot, error) { if b == nil { - return nil, errors.New("channels: delivery broker is required") + return nil, errors.New("bridges: delivery broker is required") } if ctx == nil { - return nil, errors.New("channels: delivery registration context is required") + return nil, errors.New("bridges: delivery registration context is required") } if err := ctx.Err(); err != nil { return nil, err @@ -235,7 +235,7 @@ func (b *Broker) RegisterPromptDelivery(ctx context.Context, reg PromptDeliveryR routeHash, err := normalized.RoutingKey.Hash() if err != nil { - return nil, fmt.Errorf("channels: hash delivery routing key: %w", err) + return nil, fmt.Errorf("bridges: hash delivery routing key: %w", err) } b.mu.Lock() @@ -255,16 +255,16 @@ func (b *Broker) RegisterPromptDelivery(ctx context.Context, reg PromptDeliveryR } now := b.now() delivery := &activeDelivery{ - deliveryID: deliveryID, - sessionID: normalized.SessionID, - turnID: normalized.TurnID, - channelInstanceID: normalized.RoutingKey.ChannelInstanceID, - extensionName: normalized.ExtensionName, - routingKey: normalized.RoutingKey, - target: normalized.DeliveryTarget, - routeHash: routeHash, - updatedAt: now, - seen: make(map[string]struct{}), + deliveryID: deliveryID, + sessionID: normalized.SessionID, + turnID: normalized.TurnID, + bridgeInstanceID: normalized.RoutingKey.BridgeInstanceID, + extensionName: normalized.ExtensionName, + routingKey: normalized.RoutingKey, + target: normalized.DeliveryTarget, + routeHash: routeHash, + updatedAt: now, + seen: make(map[string]struct{}), } b.deliveries[deliveryID] = delivery b.turnIndex[deliveryKey] = deliveryID @@ -272,7 +272,7 @@ func (b *Broker) RegisterPromptDelivery(ctx context.Context, reg PromptDeliveryR b.sessionIndex[normalized.SessionID] = make(map[string]struct{}) } b.sessionIndex[normalized.SessionID][deliveryID] = struct{}{} - b.ensureRouteLocked(routeHash, normalized.RoutingKey.ChannelInstanceID, normalized.ExtensionName) + b.ensureRouteLocked(routeHash, normalized.RoutingKey.BridgeInstanceID, normalized.ExtensionName) b.mu.Unlock() for _, event := range normalized.SeedEvents { @@ -287,10 +287,10 @@ func (b *Broker) RegisterPromptDelivery(ctx context.Context, reg PromptDeliveryR // Deliver enqueues one already-projected delivery event for ordered extension delivery. func (b *Broker) Deliver(ctx context.Context, evt DeliveryEvent) error { if b == nil { - return errors.New("channels: delivery broker is required") + return errors.New("bridges: delivery broker is required") } if ctx == nil { - return errors.New("channels: delivery context is required") + return errors.New("bridges: delivery context is required") } if err := ctx.Err(); err != nil { return err @@ -303,7 +303,7 @@ func (b *Broker) Deliver(ctx context.Context, evt DeliveryEvent) error { routeHash, err := normalized.RoutingKey.Hash() if err != nil { - return fmt.Errorf("channels: hash delivery routing key: %w", err) + return fmt.Errorf("bridges: hash delivery routing key: %w", err) } b.mu.Lock() @@ -314,9 +314,9 @@ func (b *Broker) Deliver(ctx context.Context, evt DeliveryEvent) error { } if delivery.routeHash != routeHash { b.mu.Unlock() - return errors.New("channels: delivery event routing key does not match registered delivery") + return errors.New("bridges: delivery event routing key does not match registered delivery") } - route := b.ensureRouteLocked(routeHash, normalized.ChannelInstanceID, delivery.extensionName) + route := b.ensureRouteLocked(routeHash, normalized.BridgeInstanceID, delivery.extensionName) err = b.enqueueEventLocked(route, delivery, normalized) if err != nil { b.mu.Unlock() @@ -332,10 +332,10 @@ func (b *Broker) Deliver(ctx context.Context, evt DeliveryEvent) error { // Snapshot returns the current resumable state for one active delivery. func (b *Broker) Snapshot(ctx context.Context, deliveryID string) (*DeliverySnapshot, error) { if b == nil { - return nil, errors.New("channels: delivery broker is required") + return nil, errors.New("bridges: delivery broker is required") } if ctx == nil { - return nil, errors.New("channels: delivery snapshot context is required") + return nil, errors.New("bridges: delivery snapshot context is required") } if err := ctx.Err(); err != nil { return nil, err @@ -343,7 +343,7 @@ func (b *Broker) Snapshot(ctx context.Context, deliveryID string) (*DeliverySnap trimmed := strings.TrimSpace(deliveryID) if trimmed == "" { - return nil, errors.New("channels: delivery snapshot id is required") + return nil, errors.New("bridges: delivery snapshot id is required") } b.mu.Lock() @@ -361,10 +361,10 @@ func (b *Broker) Snapshot(ctx context.Context, deliveryID string) (*DeliverySnap // delivery-oriented stream for the registered prompt turn. func (b *Broker) ProjectEvent(ctx context.Context, sessionID string, event DeliveryProjectionEvent) error { if b == nil { - return errors.New("channels: delivery broker is required") + return errors.New("bridges: delivery broker is required") } if ctx == nil { - return errors.New("channels: delivery projection context is required") + return errors.New("bridges: delivery projection context is required") } if err := ctx.Err(); err != nil { return err @@ -406,7 +406,7 @@ func (b *Broker) ProjectEvent(ctx context.Context, sessionID string, event Deliv return nil } - route := b.ensureRouteLocked(delivery.routeHash, delivery.channelInstanceID, delivery.extensionName) + route := b.ensureRouteLocked(delivery.routeHash, delivery.bridgeInstanceID, delivery.extensionName) err = b.enqueueEventLocked(route, delivery, projected) if err != nil { b.mu.Unlock() @@ -423,13 +423,13 @@ func (b *Broker) ProjectEvent(ctx context.Context, sessionID string, event Deliv } // FailSession marks every unfinished delivery for the stopped session as a -// terminal error so adapters do not silently orphan channel responses. +// terminal error so adapters do not silently orphan bridge responses. func (b *Broker) FailSession(ctx context.Context, sessionID string, reason string) error { if b == nil { - return errors.New("channels: delivery broker is required") + return errors.New("bridges: delivery broker is required") } if ctx == nil { - return errors.New("channels: delivery fail context is required") + return errors.New("bridges: delivery fail context is required") } if err := ctx.Err(); err != nil { return err @@ -458,19 +458,19 @@ func (b *Broker) FailSession(ctx context.Context, sessionID string, reason strin } projected := DeliveryEvent{ - DeliveryID: delivery.deliveryID, - ChannelInstanceID: delivery.channelInstanceID, - RoutingKey: delivery.routingKey, - DeliveryTarget: delivery.target, - Seq: delivery.latestSeq + 1, - EventType: DeliveryEventTypeError, - Content: delivery.currentContent, - Final: true, + DeliveryID: delivery.deliveryID, + BridgeInstanceID: delivery.bridgeInstanceID, + RoutingKey: delivery.routingKey, + DeliveryTarget: delivery.target, + Seq: delivery.latestSeq + 1, + EventType: DeliveryEventTypeError, + Content: delivery.currentContent, + Final: true, Metadata: deliveryMetadataJSON(map[string]string{ "error": reason, }), } - route := b.ensureRouteLocked(delivery.routeHash, delivery.channelInstanceID, delivery.extensionName) + route := b.ensureRouteLocked(delivery.routeHash, delivery.bridgeInstanceID, delivery.extensionName) if err := b.enqueueEventLocked(route, delivery, projected); err != nil { b.mu.Unlock() return err @@ -486,16 +486,16 @@ func (b *Broker) FailSession(ctx context.Context, sessionID string, reason strin return nil } -func (b *Broker) ensureRouteLocked(hash string, channelInstanceID string, extensionName string) *routeWorker { +func (b *Broker) ensureRouteLocked(hash string, bridgeInstanceID string, extensionName string) *routeWorker { if route := b.routes[hash]; route != nil { return route } route := &routeWorker{ - hash: hash, - channelInstanceID: strings.TrimSpace(channelInstanceID), - extensionName: strings.TrimSpace(extensionName), - wakeCh: make(chan struct{}, 1), + hash: hash, + bridgeInstanceID: strings.TrimSpace(bridgeInstanceID), + extensionName: strings.TrimSpace(extensionName), + wakeCh: make(chan struct{}, 1), } b.routes[hash] = route @@ -549,7 +549,7 @@ func (b *Broker) processQueueItem(route *routeWorker, item deliveryQueueItem) bo } callCtx, cancel := context.WithTimeout(b.lifecycleCtx, b.requestTimeout) - ack, err := transport.DeliverChannel(callCtx, route.extensionName, req) + ack, err := transport.DeliverBridge(callCtx, route.extensionName, req) cancel() if err != nil { b.handleSendFailure(route, deliveryID, err) @@ -614,14 +614,14 @@ func (b *Broker) prepareRequest(route *routeWorker, item deliveryQueueItem) (Del delivery.queuedResume = false snapshot := cloneDeliverySnapshot(b.snapshotLocked(delivery)) event := DeliveryEvent{ - DeliveryID: delivery.deliveryID, - ChannelInstanceID: delivery.channelInstanceID, - RoutingKey: delivery.routingKey, - DeliveryTarget: delivery.target, - Seq: delivery.latestSeq, - EventType: DeliveryEventTypeResume, - Content: delivery.currentContent, - Final: delivery.final, + DeliveryID: delivery.deliveryID, + BridgeInstanceID: delivery.bridgeInstanceID, + RoutingKey: delivery.routingKey, + DeliveryTarget: delivery.target, + Seq: delivery.latestSeq, + EventType: DeliveryEventTypeResume, + Content: delivery.currentContent, + Final: delivery.final, Metadata: deliveryMetadataJSON(map[string]string{ "latest_event_type": delivery.latestEventType, }), @@ -669,7 +669,7 @@ func (b *Broker) handleSendFailure(route *routeWorker, deliveryID string, reason if delivery.latestEventType == DeliveryEventTypeError && strings.TrimSpace(delivery.errorText) != "" { return } - b.recordDeliveryIssueLocked(delivery.channelInstanceID, reason.Error()) + b.recordDeliveryIssueLocked(delivery.bridgeInstanceID, reason.Error()) } } @@ -738,7 +738,7 @@ func (b *Broker) enqueueEventLocked(route *routeWorker, delivery *activeDelivery return nil } if len(route.queue) >= b.queueCapacity && !b.dropQueuedDeltaLocked(route) { - b.recordDeliveryDropLocked(route.channelInstanceID, "queue_saturated") + b.recordDeliveryDropLocked(route.bridgeInstanceID, "queue_saturated") return ErrDeliveryQueueSaturated } cloned := cloneDeliveryEvent(event) @@ -762,7 +762,7 @@ func (b *Broker) enqueueEventLocked(route *routeWorker, delivery *activeDelivery return nil } if len(route.queue) >= b.queueCapacity && !b.dropQueuedDeltaLocked(route) { - b.recordDeliveryDropLocked(route.channelInstanceID, "queue_saturated") + b.recordDeliveryDropLocked(route.bridgeInstanceID, "queue_saturated") return ErrDeliveryQueueSaturated } cloned := cloneDeliveryEvent(event) @@ -779,7 +779,7 @@ func (b *Broker) enqueueEventLocked(route *routeWorker, delivery *activeDelivery return nil } if len(route.queue) >= b.queueCapacity && !b.dropQueuedDeltaLocked(route) { - b.recordDeliveryDropLocked(route.channelInstanceID, "queue_saturated") + b.recordDeliveryDropLocked(route.bridgeInstanceID, "queue_saturated") return ErrDeliveryQueueSaturated } cloned := cloneDeliveryEvent(event) @@ -788,7 +788,7 @@ func (b *Broker) enqueueEventLocked(route *routeWorker, delivery *activeDelivery route.queue = append(route.queue, deliveryQueueItem{deliveryID: delivery.deliveryID, kind: deliveryQueueKindTerminal}) return nil default: - return fmt.Errorf("channels: unsupported projected delivery event type %q", event.EventType) + return fmt.Errorf("bridges: unsupported projected delivery event type %q", event.EventType) } } @@ -808,40 +808,40 @@ func (b *Broker) projectEventLocked(delivery *activeDelivery, event DeliveryProj nextType = DeliveryEventTypeStart } return DeliveryEvent{ - DeliveryID: delivery.deliveryID, - ChannelInstanceID: delivery.channelInstanceID, - RoutingKey: delivery.routingKey, - DeliveryTarget: delivery.target, - Seq: delivery.latestSeq + 1, - EventType: nextType, - Content: nextContent, - Final: false, + DeliveryID: delivery.deliveryID, + BridgeInstanceID: delivery.bridgeInstanceID, + RoutingKey: delivery.routingKey, + DeliveryTarget: delivery.target, + Seq: delivery.latestSeq + 1, + EventType: nextType, + Content: nextContent, + Final: false, }, true, nil case "done": if delivery.latestSeq == 0 && strings.TrimSpace(delivery.currentContent.Text) == "" { return DeliveryEvent{}, false, nil } return DeliveryEvent{ - DeliveryID: delivery.deliveryID, - ChannelInstanceID: delivery.channelInstanceID, - RoutingKey: delivery.routingKey, - DeliveryTarget: delivery.target, - Seq: delivery.latestSeq + 1, - EventType: DeliveryEventTypeFinal, - Content: delivery.currentContent, - Final: true, + DeliveryID: delivery.deliveryID, + BridgeInstanceID: delivery.bridgeInstanceID, + RoutingKey: delivery.routingKey, + DeliveryTarget: delivery.target, + Seq: delivery.latestSeq + 1, + EventType: DeliveryEventTypeFinal, + Content: delivery.currentContent, + Final: true, }, true, nil case "error": errorText := strings.TrimSpace(event.Error) return DeliveryEvent{ - DeliveryID: delivery.deliveryID, - ChannelInstanceID: delivery.channelInstanceID, - RoutingKey: delivery.routingKey, - DeliveryTarget: delivery.target, - Seq: delivery.latestSeq + 1, - EventType: DeliveryEventTypeError, - Content: delivery.currentContent, - Final: true, + DeliveryID: delivery.deliveryID, + BridgeInstanceID: delivery.bridgeInstanceID, + RoutingKey: delivery.routingKey, + DeliveryTarget: delivery.target, + Seq: delivery.latestSeq + 1, + EventType: DeliveryEventTypeError, + Content: delivery.currentContent, + Final: true, Metadata: deliveryMetadataJSON(map[string]string{ "error": errorText, }), @@ -867,7 +867,7 @@ func (b *Broker) applyQueuedEventLocked(delivery *activeDelivery, event Delivery if normalizedType == DeliveryEventTypeError { delivery.errorText = deliveryErrorText(event.Metadata) - b.recordDeliveryFailureLocked(delivery.channelInstanceID, delivery.errorText) + b.recordDeliveryFailureLocked(delivery.bridgeInstanceID, delivery.errorText) } else if normalizedType != DeliveryEventTypeResume { delivery.errorText = "" } @@ -878,7 +878,7 @@ func (b *Broker) snapshotLocked(delivery *activeDelivery) DeliverySnapshot { DeliveryID: delivery.deliveryID, SessionID: delivery.sessionID, TurnID: delivery.turnID, - ChannelInstanceID: delivery.channelInstanceID, + BridgeInstanceID: delivery.bridgeInstanceID, RoutingKey: delivery.routingKey, DeliveryTarget: delivery.target, LatestSeq: delivery.latestSeq, @@ -928,17 +928,17 @@ func (b *Broker) dropQueuedDeltaLocked(route *routeWorker) bool { delivery.pendingDelta = nil } route.queue = append(route.queue[:idx], route.queue[idx+1:]...) - b.recordDeliveryDropLocked(route.channelInstanceID, "coalesced") + b.recordDeliveryDropLocked(route.bridgeInstanceID, "coalesced") return true } return false } -func (b *Broker) metricsLocked(channelInstanceID string) *instanceDeliveryMetrics { +func (b *Broker) metricsLocked(bridgeInstanceID string) *instanceDeliveryMetrics { if b.metrics == nil { b.metrics = make(map[string]*instanceDeliveryMetrics) } - trimmedID := strings.TrimSpace(channelInstanceID) + trimmedID := strings.TrimSpace(bridgeInstanceID) if trimmedID == "" { return nil } @@ -952,8 +952,8 @@ func (b *Broker) metricsLocked(channelInstanceID string) *instanceDeliveryMetric return metrics } -func (b *Broker) recordDeliveryDropLocked(channelInstanceID string, reason string) { - metrics := b.metricsLocked(channelInstanceID) +func (b *Broker) recordDeliveryDropLocked(bridgeInstanceID string, reason string) { + metrics := b.metricsLocked(bridgeInstanceID) if metrics == nil { return } @@ -964,8 +964,8 @@ func (b *Broker) recordDeliveryDropLocked(channelInstanceID string, reason strin metrics.droppedByReason[trimmedReason]++ } -func (b *Broker) recordDeliveryIssueLocked(channelInstanceID string, message string) { - metrics := b.metricsLocked(channelInstanceID) +func (b *Broker) recordDeliveryIssueLocked(bridgeInstanceID string, message string) { + metrics := b.metricsLocked(bridgeInstanceID) if metrics == nil { return } @@ -973,8 +973,8 @@ func (b *Broker) recordDeliveryIssueLocked(channelInstanceID string, message str metrics.lastErrorAt = b.now() } -func (b *Broker) recordDeliveryFailureLocked(channelInstanceID string, message string) { - metrics := b.metricsLocked(channelInstanceID) +func (b *Broker) recordDeliveryFailureLocked(bridgeInstanceID string, message string) { + metrics := b.metricsLocked(bridgeInstanceID) if metrics == nil { return } diff --git a/internal/channels/delivery_broker_test.go b/internal/bridges/delivery_broker_test.go similarity index 78% rename from internal/channels/delivery_broker_test.go rename to internal/bridges/delivery_broker_test.go index af52e1d4e..11ec75ae1 100644 --- a/internal/channels/delivery_broker_test.go +++ b/internal/bridges/delivery_broker_test.go @@ -1,4 +1,4 @@ -package channels +package bridges import ( "context" @@ -25,7 +25,7 @@ type fakeDeliveryTransport struct { handler func(context.Context, string, DeliveryRequest) (DeliveryAck, error) } -func (f *fakeDeliveryTransport) DeliverChannel( +func (f *fakeDeliveryTransport) DeliverBridge( ctx context.Context, extensionName string, req DeliveryRequest, @@ -124,32 +124,32 @@ func TestBrokerDeliversInOrderPerRoutingKeyWhileOtherRoutesStayActive(t *testing SessionID: "sess-a", TurnID: "turn-a", ExtensionName: "ext-telegram", - RoutingKey: testRoutingKey("chan-a", "peer-a"), + RoutingKey: testRoutingKey("brg-a", "peer-a"), DeliveryTarget: DeliveryTarget{ - ChannelInstanceID: "chan-a", - PeerID: "peer-a", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-a", + PeerID: "peer-a", + Mode: DeliveryModeReply, }, }) regB := mustRegisterTestDelivery(t, broker, PromptDeliveryRegistration{ SessionID: "sess-b", TurnID: "turn-b", ExtensionName: "ext-telegram", - RoutingKey: testRoutingKey("chan-b", "peer-b"), + RoutingKey: testRoutingKey("brg-b", "peer-b"), DeliveryTarget: DeliveryTarget{ - ChannelInstanceID: "chan-b", - PeerID: "peer-b", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-b", + PeerID: "peer-b", + Mode: DeliveryModeReply, }, }) ctx := testutil.Context(t) deliveries := []DeliveryEvent{ - testDeliveryEvent(regA.DeliveryID, regA.ChannelInstanceID, regA.RoutingKey, regA.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false), - testDeliveryEvent(regA.DeliveryID, regA.ChannelInstanceID, regA.RoutingKey, regA.DeliveryTarget, 2, DeliveryEventTypeDelta, "hello again", false), - testDeliveryEvent(regA.DeliveryID, regA.ChannelInstanceID, regA.RoutingKey, regA.DeliveryTarget, 3, DeliveryEventTypeFinal, "hello again", true), - testDeliveryEvent(regB.DeliveryID, regB.ChannelInstanceID, regB.RoutingKey, regB.DeliveryTarget, 1, DeliveryEventTypeStart, "route b", false), - testDeliveryEvent(regB.DeliveryID, regB.ChannelInstanceID, regB.RoutingKey, regB.DeliveryTarget, 2, DeliveryEventTypeFinal, "route b", true), + testDeliveryEvent(regA.DeliveryID, regA.BridgeInstanceID, regA.RoutingKey, regA.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false), + testDeliveryEvent(regA.DeliveryID, regA.BridgeInstanceID, regA.RoutingKey, regA.DeliveryTarget, 2, DeliveryEventTypeDelta, "hello again", false), + testDeliveryEvent(regA.DeliveryID, regA.BridgeInstanceID, regA.RoutingKey, regA.DeliveryTarget, 3, DeliveryEventTypeFinal, "hello again", true), + testDeliveryEvent(regB.DeliveryID, regB.BridgeInstanceID, regB.RoutingKey, regB.DeliveryTarget, 1, DeliveryEventTypeStart, "route b", false), + testDeliveryEvent(regB.DeliveryID, regB.BridgeInstanceID, regB.RoutingKey, regB.DeliveryTarget, 2, DeliveryEventTypeFinal, "route b", true), } for _, event := range deliveries { if err := broker.Deliver(ctx, event); err != nil { @@ -189,20 +189,20 @@ func TestBrokerCoalescesIntermediateDeltaUnderBackpressure(t *testing.T) { SessionID: "sess-1", TurnID: "turn-1", ExtensionName: "ext-telegram", - RoutingKey: testRoutingKey("chan-1", "peer-1"), + RoutingKey: testRoutingKey("brg-1", "peer-1"), DeliveryTarget: DeliveryTarget{ - ChannelInstanceID: "chan-1", - PeerID: "peer-1", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-1", + PeerID: "peer-1", + Mode: DeliveryModeReply, }, }) ctx := testutil.Context(t) events := []DeliveryEvent{ - testDeliveryEvent(reg.DeliveryID, reg.ChannelInstanceID, reg.RoutingKey, reg.DeliveryTarget, 1, DeliveryEventTypeStart, "h", false), - testDeliveryEvent(reg.DeliveryID, reg.ChannelInstanceID, reg.RoutingKey, reg.DeliveryTarget, 2, DeliveryEventTypeDelta, "he", false), - testDeliveryEvent(reg.DeliveryID, reg.ChannelInstanceID, reg.RoutingKey, reg.DeliveryTarget, 3, DeliveryEventTypeDelta, "hello", false), - testDeliveryEvent(reg.DeliveryID, reg.ChannelInstanceID, reg.RoutingKey, reg.DeliveryTarget, 4, DeliveryEventTypeFinal, "hello!", true), + testDeliveryEvent(reg.DeliveryID, reg.BridgeInstanceID, reg.RoutingKey, reg.DeliveryTarget, 1, DeliveryEventTypeStart, "h", false), + testDeliveryEvent(reg.DeliveryID, reg.BridgeInstanceID, reg.RoutingKey, reg.DeliveryTarget, 2, DeliveryEventTypeDelta, "he", false), + testDeliveryEvent(reg.DeliveryID, reg.BridgeInstanceID, reg.RoutingKey, reg.DeliveryTarget, 3, DeliveryEventTypeDelta, "hello", false), + testDeliveryEvent(reg.DeliveryID, reg.BridgeInstanceID, reg.RoutingKey, reg.DeliveryTarget, 4, DeliveryEventTypeFinal, "hello!", true), } for _, event := range events { if err := broker.Deliver(ctx, event); err != nil { @@ -255,19 +255,19 @@ func TestBrokerAckTracksRemoteAndReplacementIDs(t *testing.T) { SessionID: "sess-ack", TurnID: "turn-ack", ExtensionName: "ext-telegram", - RoutingKey: testRoutingKey("chan-ack", "peer-ack"), + RoutingKey: testRoutingKey("brg-ack", "peer-ack"), DeliveryTarget: DeliveryTarget{ - ChannelInstanceID: "chan-ack", - PeerID: "peer-ack", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-ack", + PeerID: "peer-ack", + Mode: DeliveryModeReply, }, }) ctx := testutil.Context(t) - if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.ChannelInstanceID, reg.RoutingKey, reg.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.BridgeInstanceID, reg.RoutingKey, reg.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false)); err != nil { t.Fatalf("Deliver(start) error = %v", err) } - if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.ChannelInstanceID, reg.RoutingKey, reg.DeliveryTarget, 2, DeliveryEventTypeDelta, "hello world", false)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.BridgeInstanceID, reg.RoutingKey, reg.DeliveryTarget, 2, DeliveryEventTypeDelta, "hello world", false)); err != nil { t.Fatalf("Deliver(delta) error = %v", err) } @@ -322,20 +322,20 @@ func TestBrokerSnapshotCapturesActiveDeliveryAfterFailure(t *testing.T) { SessionID: "sess-resume", TurnID: "turn-resume", ExtensionName: "ext-telegram", - RoutingKey: testRoutingKey("chan-resume", "peer-resume"), + RoutingKey: testRoutingKey("brg-resume", "peer-resume"), DeliveryTarget: DeliveryTarget{ - ChannelInstanceID: "chan-resume", - PeerID: "peer-resume", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-resume", + PeerID: "peer-resume", + Mode: DeliveryModeReply, }, }) ctx := testutil.Context(t) - if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.ChannelInstanceID, reg.RoutingKey, reg.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.BridgeInstanceID, reg.RoutingKey, reg.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false)); err != nil { t.Fatalf("Deliver(start) error = %v", err) } waitForCalls(t, transport, 1) - if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.ChannelInstanceID, reg.RoutingKey, reg.DeliveryTarget, 2, DeliveryEventTypeDelta, "hello world", false)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.BridgeInstanceID, reg.RoutingKey, reg.DeliveryTarget, 2, DeliveryEventTypeDelta, "hello world", false)); err != nil { t.Fatalf("Deliver(delta) error = %v", err) } @@ -389,24 +389,24 @@ func TestBrokerDeliveryMetricsReflectBacklogAndClearAfterAck(t *testing.T) { SessionID: "sess-metrics", TurnID: "turn-metrics", ExtensionName: "ext-telegram", - RoutingKey: testRoutingKey("chan-metrics", "peer-metrics"), + RoutingKey: testRoutingKey("brg-metrics", "peer-metrics"), DeliveryTarget: DeliveryTarget{ - ChannelInstanceID: "chan-metrics", - PeerID: "peer-metrics", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-metrics", + PeerID: "peer-metrics", + Mode: DeliveryModeReply, }, }) ctx := testutil.Context(t) - if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.ChannelInstanceID, reg.RoutingKey, reg.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.BridgeInstanceID, reg.RoutingKey, reg.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false)); err != nil { t.Fatalf("Deliver(start) error = %v", err) } - if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.ChannelInstanceID, reg.RoutingKey, reg.DeliveryTarget, 2, DeliveryEventTypeDelta, "hello again", false)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.BridgeInstanceID, reg.RoutingKey, reg.DeliveryTarget, 2, DeliveryEventTypeDelta, "hello again", false)); err != nil { t.Fatalf("Deliver(delta) error = %v", err) } waitForCalls(t, transport, 1) - metrics := broker.DeliveryMetrics()["chan-metrics"] + metrics := broker.DeliveryMetrics()["brg-metrics"] if got, want := metrics.DeliveryBacklog, 1; got != want { t.Fatalf("DeliveryMetrics().DeliveryBacklog = %d, want %d", got, want) } @@ -414,7 +414,7 @@ func TestBrokerDeliveryMetricsReflectBacklogAndClearAfterAck(t *testing.T) { close(releaseStart) waitForAcks(t, transport, 2) - metrics = broker.DeliveryMetrics()["chan-metrics"] + metrics = broker.DeliveryMetrics()["brg-metrics"] if got, want := metrics.DeliveryBacklog, 0; got != want { t.Fatalf("DeliveryMetrics().DeliveryBacklog after ack = %d, want %d", got, want) } @@ -435,25 +435,25 @@ func TestBrokerDeliveryMetricsCaptureTerminalFailures(t *testing.T) { SessionID: "sess-failure", TurnID: "turn-failure", ExtensionName: "ext-telegram", - RoutingKey: testRoutingKey("chan-failure", "peer-failure"), + RoutingKey: testRoutingKey("brg-failure", "peer-failure"), DeliveryTarget: DeliveryTarget{ - ChannelInstanceID: "chan-failure", - PeerID: "peer-failure", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-failure", + PeerID: "peer-failure", + Mode: DeliveryModeReply, }, }) ctx := testutil.Context(t) - if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.ChannelInstanceID, reg.RoutingKey, reg.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(reg.DeliveryID, reg.BridgeInstanceID, reg.RoutingKey, reg.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false)); err != nil { t.Fatalf("Deliver(start) error = %v", err) } - errorEvent := testDeliveryEvent(reg.DeliveryID, reg.ChannelInstanceID, reg.RoutingKey, reg.DeliveryTarget, 2, DeliveryEventTypeError, "boom", true) + errorEvent := testDeliveryEvent(reg.DeliveryID, reg.BridgeInstanceID, reg.RoutingKey, reg.DeliveryTarget, 2, DeliveryEventTypeError, "boom", true) errorEvent.Metadata = json.RawMessage(`{"error":"boom"}`) if err := broker.Deliver(ctx, errorEvent); err != nil { t.Fatalf("Deliver(error) error = %v", err) } - metrics := broker.DeliveryMetrics()["chan-failure"] + metrics := broker.DeliveryMetrics()["brg-failure"] if got, want := metrics.DeliveryFailuresTotal, 1; got != want { t.Fatalf("DeliveryMetrics().DeliveryFailuresTotal = %d, want %d", got, want) } @@ -482,11 +482,11 @@ func TestBrokerRejectedDeliverDoesNotAdvanceSnapshot(t *testing.T) { broker := NewBroker(transport, WithDeliveryBrokerQueueCapacity(2)) t.Cleanup(broker.Close) - routingKey := testRoutingKey("chan-saturated", "peer-saturated") + routingKey := testRoutingKey("brg-saturated", "peer-saturated") target := DeliveryTarget{ - ChannelInstanceID: "chan-saturated", - PeerID: "peer-saturated", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-saturated", + PeerID: "peer-saturated", + Mode: DeliveryModeReply, } regA := mustRegisterTestDelivery(t, broker, PromptDeliveryRegistration{ SessionID: "sess-saturated-a", @@ -505,18 +505,18 @@ func TestBrokerRejectedDeliverDoesNotAdvanceSnapshot(t *testing.T) { }) ctx := testutil.Context(t) - if err := broker.Deliver(ctx, testDeliveryEvent(regA.DeliveryID, regA.ChannelInstanceID, regA.RoutingKey, regA.DeliveryTarget, 1, DeliveryEventTypeStart, "alpha", false)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(regA.DeliveryID, regA.BridgeInstanceID, regA.RoutingKey, regA.DeliveryTarget, 1, DeliveryEventTypeStart, "alpha", false)); err != nil { t.Fatalf("Deliver(regA start) error = %v", err) } waitForCalls(t, transport, 1) - if err := broker.Deliver(ctx, testDeliveryEvent(regB.DeliveryID, regB.ChannelInstanceID, regB.RoutingKey, regB.DeliveryTarget, 1, DeliveryEventTypeStart, "bravo", false)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(regB.DeliveryID, regB.BridgeInstanceID, regB.RoutingKey, regB.DeliveryTarget, 1, DeliveryEventTypeStart, "bravo", false)); err != nil { t.Fatalf("Deliver(regB start) error = %v", err) } - if err := broker.Deliver(ctx, testDeliveryEvent(regB.DeliveryID, regB.ChannelInstanceID, regB.RoutingKey, regB.DeliveryTarget, 2, DeliveryEventTypeFinal, "bravo done", true)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(regB.DeliveryID, regB.BridgeInstanceID, regB.RoutingKey, regB.DeliveryTarget, 2, DeliveryEventTypeFinal, "bravo done", true)); err != nil { t.Fatalf("Deliver(regB final) error = %v", err) } - err := broker.Deliver(ctx, testDeliveryEvent(regA.DeliveryID, regA.ChannelInstanceID, regA.RoutingKey, regA.DeliveryTarget, 2, DeliveryEventTypeFinal, "alpha done", true)) + err := broker.Deliver(ctx, testDeliveryEvent(regA.DeliveryID, regA.BridgeInstanceID, regA.RoutingKey, regA.DeliveryTarget, 2, DeliveryEventTypeFinal, "alpha done", true)) if !errors.Is(err, ErrDeliveryQueueSaturated) { t.Fatalf("Deliver(regA final) error = %v, want %v", err, ErrDeliveryQueueSaturated) } @@ -557,18 +557,18 @@ func mustRegisterTestDelivery(t *testing.T, broker *Broker, reg PromptDeliveryRe return *snapshot } -func testRoutingKey(channelInstanceID string, peerID string) RoutingKey { +func testRoutingKey(bridgeInstanceID string, peerID string) RoutingKey { return RoutingKey{ - Scope: ScopeWorkspace, - WorkspaceID: "ws-1", - ChannelInstanceID: channelInstanceID, - PeerID: peerID, + Scope: ScopeWorkspace, + WorkspaceID: "ws-1", + BridgeInstanceID: bridgeInstanceID, + PeerID: peerID, } } func testDeliveryEvent( deliveryID string, - channelInstanceID string, + bridgeInstanceID string, routingKey RoutingKey, target DeliveryTarget, seq int64, @@ -577,14 +577,14 @@ func testDeliveryEvent( final bool, ) DeliveryEvent { return DeliveryEvent{ - DeliveryID: deliveryID, - ChannelInstanceID: channelInstanceID, - RoutingKey: routingKey, - DeliveryTarget: target, - Seq: seq, - EventType: eventType, - Content: MessageContent{Text: text}, - Final: final, + DeliveryID: deliveryID, + BridgeInstanceID: bridgeInstanceID, + RoutingKey: routingKey, + DeliveryTarget: target, + Seq: seq, + EventType: eventType, + Content: MessageContent{Text: text}, + Final: final, } } diff --git a/internal/channels/delivery_metrics.go b/internal/bridges/delivery_metrics.go similarity index 71% rename from internal/channels/delivery_metrics.go rename to internal/bridges/delivery_metrics.go index 6426923c2..3ec024bb8 100644 --- a/internal/channels/delivery_metrics.go +++ b/internal/bridges/delivery_metrics.go @@ -1,11 +1,11 @@ -package channels +package bridges import "time" -// ChannelDeliveryMetrics captures the per-instance delivery telemetry exposed +// BridgeDeliveryMetrics captures the per-instance delivery telemetry exposed // by the broker for observability surfaces. -type ChannelDeliveryMetrics struct { - ChannelInstanceID string `json:"channel_instance_id"` +type BridgeDeliveryMetrics struct { + BridgeInstanceID string `json:"bridge_instance_id"` DeliveryBacklog int `json:"delivery_backlog"` DeliveryDroppedTotal int `json:"delivery_dropped_total"` DeliveryDroppedByReason map[string]int `json:"delivery_dropped_by_reason,omitempty"` diff --git a/internal/channels/delivery_projection_test.go b/internal/bridges/delivery_projection_test.go similarity index 86% rename from internal/channels/delivery_projection_test.go rename to internal/bridges/delivery_projection_test.go index 5505facac..5653d91d5 100644 --- a/internal/channels/delivery_projection_test.go +++ b/internal/bridges/delivery_projection_test.go @@ -1,4 +1,4 @@ -package channels +package bridges import ( "context" @@ -38,18 +38,18 @@ func TestBrokerSetTransportFlushesQueuedResume(t *testing.T) { SessionID: "sess-resume-route", TurnID: "turn-resume-route", ExtensionName: "ext-telegram", - RoutingKey: testRoutingKey("chan-resume-route", "peer-resume-route"), + RoutingKey: testRoutingKey("brg-resume-route", "peer-resume-route"), DeliveryTarget: DeliveryTarget{ - ChannelInstanceID: "chan-resume-route", - PeerID: "peer-resume-route", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-resume-route", + PeerID: "peer-resume-route", + Mode: DeliveryModeReply, }, }) ctx := testutil.Context(t) if err := broker.Deliver(ctx, testDeliveryEvent( reg.DeliveryID, - reg.ChannelInstanceID, + reg.BridgeInstanceID, reg.RoutingKey, reg.DeliveryTarget, 1, @@ -102,11 +102,11 @@ func TestBrokerProjectEventDeduplicatesAndFailsSession(t *testing.T) { SessionID: "sess-project", TurnID: "turn-project", ExtensionName: "ext-telegram", - RoutingKey: testRoutingKey("chan-project", "peer-project"), + RoutingKey: testRoutingKey("brg-project", "peer-project"), DeliveryTarget: DeliveryTarget{ - ChannelInstanceID: "chan-project", - PeerID: "peer-project", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-project", + PeerID: "peer-project", + Mode: DeliveryModeReply, }, SeedEvents: []DeliveryProjectionEvent{ { @@ -201,11 +201,11 @@ func TestBrokerRejectedProjectedEventDoesNotAdvanceStateOrConsumeFingerprint(t * broker := NewBroker(transport, WithDeliveryBrokerQueueCapacity(2)) t.Cleanup(broker.Close) - routingKey := testRoutingKey("chan-project-saturated", "peer-project-saturated") + routingKey := testRoutingKey("brg-project-saturated", "peer-project-saturated") target := DeliveryTarget{ - ChannelInstanceID: "chan-project-saturated", - PeerID: "peer-project-saturated", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-project-saturated", + PeerID: "peer-project-saturated", + Mode: DeliveryModeReply, } regA := mustRegisterTestDelivery(t, broker, PromptDeliveryRegistration{ SessionID: "sess-project-saturated-a", @@ -224,14 +224,14 @@ func TestBrokerRejectedProjectedEventDoesNotAdvanceStateOrConsumeFingerprint(t * }) ctx := testutil.Context(t) - if err := broker.Deliver(ctx, testDeliveryEvent(regA.DeliveryID, regA.ChannelInstanceID, regA.RoutingKey, regA.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(regA.DeliveryID, regA.BridgeInstanceID, regA.RoutingKey, regA.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false)); err != nil { t.Fatalf("Deliver(regA start) error = %v", err) } waitForCalls(t, transport, 1) - if err := broker.Deliver(ctx, testDeliveryEvent(regB.DeliveryID, regB.ChannelInstanceID, regB.RoutingKey, regB.DeliveryTarget, 1, DeliveryEventTypeStart, "other", false)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(regB.DeliveryID, regB.BridgeInstanceID, regB.RoutingKey, regB.DeliveryTarget, 1, DeliveryEventTypeStart, "other", false)); err != nil { t.Fatalf("Deliver(regB start) error = %v", err) } - if err := broker.Deliver(ctx, testDeliveryEvent(regB.DeliveryID, regB.ChannelInstanceID, regB.RoutingKey, regB.DeliveryTarget, 2, DeliveryEventTypeFinal, "other done", true)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(regB.DeliveryID, regB.BridgeInstanceID, regB.RoutingKey, regB.DeliveryTarget, 2, DeliveryEventTypeFinal, "other done", true)); err != nil { t.Fatalf("Deliver(regB final) error = %v", err) } @@ -300,11 +300,11 @@ func TestBrokerRejectedFailSessionDoesNotFinalizeDelivery(t *testing.T) { broker := NewBroker(transport, WithDeliveryBrokerQueueCapacity(2)) t.Cleanup(broker.Close) - routingKey := testRoutingKey("chan-fail-saturated", "peer-fail-saturated") + routingKey := testRoutingKey("brg-fail-saturated", "peer-fail-saturated") target := DeliveryTarget{ - ChannelInstanceID: "chan-fail-saturated", - PeerID: "peer-fail-saturated", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-fail-saturated", + PeerID: "peer-fail-saturated", + Mode: DeliveryModeReply, } regA := mustRegisterTestDelivery(t, broker, PromptDeliveryRegistration{ SessionID: "sess-fail-saturated-a", @@ -323,14 +323,14 @@ func TestBrokerRejectedFailSessionDoesNotFinalizeDelivery(t *testing.T) { }) ctx := testutil.Context(t) - if err := broker.Deliver(ctx, testDeliveryEvent(regA.DeliveryID, regA.ChannelInstanceID, regA.RoutingKey, regA.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(regA.DeliveryID, regA.BridgeInstanceID, regA.RoutingKey, regA.DeliveryTarget, 1, DeliveryEventTypeStart, "hello", false)); err != nil { t.Fatalf("Deliver(regA start) error = %v", err) } waitForCalls(t, transport, 1) - if err := broker.Deliver(ctx, testDeliveryEvent(regB.DeliveryID, regB.ChannelInstanceID, regB.RoutingKey, regB.DeliveryTarget, 1, DeliveryEventTypeStart, "other", false)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(regB.DeliveryID, regB.BridgeInstanceID, regB.RoutingKey, regB.DeliveryTarget, 1, DeliveryEventTypeStart, "other", false)); err != nil { t.Fatalf("Deliver(regB start) error = %v", err) } - if err := broker.Deliver(ctx, testDeliveryEvent(regB.DeliveryID, regB.ChannelInstanceID, regB.RoutingKey, regB.DeliveryTarget, 2, DeliveryEventTypeFinal, "other done", true)); err != nil { + if err := broker.Deliver(ctx, testDeliveryEvent(regB.DeliveryID, regB.BridgeInstanceID, regB.RoutingKey, regB.DeliveryTarget, 2, DeliveryEventTypeFinal, "other done", true)); err != nil { t.Fatalf("Deliver(regB final) error = %v", err) } @@ -362,39 +362,39 @@ func TestDeliveryValidationAndMetadataHelpers(t *testing.T) { t.Parallel() updatedAt := time.Date(2026, time.April, 11, 12, 2, 0, 0, time.UTC) - routingKey := testRoutingKey("chan-validate", "peer-validate") + routingKey := testRoutingKey("brg-validate", "peer-validate") target := DeliveryTarget{ - ChannelInstanceID: "chan-validate", - PeerID: "peer-validate", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-validate", + PeerID: "peer-validate", + Mode: DeliveryModeReply, } newSnapshot := func() DeliverySnapshot { return DeliverySnapshot{ - DeliveryID: "del-validate", - SessionID: "sess-validate", - TurnID: "turn-validate", - ChannelInstanceID: "chan-validate", - RoutingKey: routingKey, - DeliveryTarget: target, - LatestSeq: 2, - LatestEventType: DeliveryEventTypeDelta, - CurrentContent: MessageContent{Text: "hello"}, - LastSentSeq: 1, - LastAckedSeq: 1, - UpdatedAt: updatedAt, + DeliveryID: "del-validate", + SessionID: "sess-validate", + TurnID: "turn-validate", + BridgeInstanceID: "brg-validate", + RoutingKey: routingKey, + DeliveryTarget: target, + LatestSeq: 2, + LatestEventType: DeliveryEventTypeDelta, + CurrentContent: MessageContent{Text: "hello"}, + LastSentSeq: 1, + LastAckedSeq: 1, + UpdatedAt: updatedAt, } } newResumeRequest := func(snapshot DeliverySnapshot) DeliveryRequest { return DeliveryRequest{ Event: DeliveryEvent{ - DeliveryID: snapshot.DeliveryID, - ChannelInstanceID: snapshot.ChannelInstanceID, - RoutingKey: routingKey, - DeliveryTarget: target, - Seq: snapshot.LatestSeq, - EventType: DeliveryEventTypeResume, - Content: snapshot.CurrentContent, - Metadata: deliveryMetadataJSON(map[string]string{"latest_event_type": DeliveryEventTypeDelta}), + DeliveryID: snapshot.DeliveryID, + BridgeInstanceID: snapshot.BridgeInstanceID, + RoutingKey: routingKey, + DeliveryTarget: target, + Seq: snapshot.LatestSeq, + EventType: DeliveryEventTypeResume, + Content: snapshot.CurrentContent, + Metadata: deliveryMetadataJSON(map[string]string{"latest_event_type": DeliveryEventTypeDelta}), }, Snapshot: &snapshot, } @@ -500,13 +500,13 @@ func TestBrokerProjectEventLockedCoversTerminalAndIgnoredPaths(t *testing.T) { t.Cleanup(broker.Close) delivery := &activeDelivery{ - deliveryID: "del-locked", - channelInstanceID: "chan-locked", - routingKey: testRoutingKey("chan-locked", "peer-locked"), + deliveryID: "del-locked", + bridgeInstanceID: "brg-locked", + routingKey: testRoutingKey("brg-locked", "peer-locked"), target: DeliveryTarget{ - ChannelInstanceID: "chan-locked", - PeerID: "peer-locked", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-locked", + PeerID: "peer-locked", + Mode: DeliveryModeReply, }, } @@ -536,13 +536,13 @@ func TestBrokerProjectEventLockedCoversTerminalAndIgnoredPaths(t *testing.T) { } errorDelivery := &activeDelivery{ - deliveryID: "del-error", - channelInstanceID: "chan-locked", - routingKey: testRoutingKey("chan-locked", "peer-locked"), + deliveryID: "del-error", + bridgeInstanceID: "brg-locked", + routingKey: testRoutingKey("brg-locked", "peer-locked"), target: DeliveryTarget{ - ChannelInstanceID: "chan-locked", - PeerID: "peer-locked", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-locked", + PeerID: "peer-locked", + Mode: DeliveryModeReply, }, currentContent: MessageContent{Text: "partial"}, } @@ -568,17 +568,17 @@ func TestBrokerEnqueueEventLockedCoversReplacementAndSaturation(t *testing.T) { broker := NewBroker(nil, WithDeliveryBrokerQueueCapacity(2)) t.Cleanup(broker.Close) - route := &routeWorker{channelInstanceID: "chan-queue"} + route := &routeWorker{bridgeInstanceID: "brg-queue"} delivery := &activeDelivery{deliveryID: "del-queue"} start := testDeliveryEvent( delivery.deliveryID, - "chan-queue", - testRoutingKey("chan-queue", "peer-queue"), + "brg-queue", + testRoutingKey("brg-queue", "peer-queue"), DeliveryTarget{ - ChannelInstanceID: "chan-queue", - PeerID: "peer-queue", - Mode: DeliveryModeReply, + BridgeInstanceID: "brg-queue", + PeerID: "peer-queue", + Mode: DeliveryModeReply, }, 1, DeliveryEventTypeStart, @@ -646,7 +646,7 @@ func TestBrokerEnqueueEventLockedCoversReplacementAndSaturation(t *testing.T) { } fullRoute := &routeWorker{ - channelInstanceID: "chan-queue", + bridgeInstanceID: "brg-queue", queue: []deliveryQueueItem{ {deliveryID: "del-a", kind: deliveryQueueKindStart}, {deliveryID: "del-b", kind: deliveryQueueKindStart}, @@ -656,7 +656,7 @@ func TestBrokerEnqueueEventLockedCoversReplacementAndSaturation(t *testing.T) { t.Fatalf("enqueueEventLocked(full route) error = %v, want ErrDeliveryQueueSaturated", err) } - metrics := broker.DeliveryMetrics()["chan-queue"] + metrics := broker.DeliveryMetrics()["brg-queue"] if got, want := metrics.DeliveryDroppedByReason["queue_saturated"], 1; got != want { t.Fatalf("DeliveryMetrics().DeliveryDroppedByReason[queue_saturated] = %d, want %d", got, want) } diff --git a/internal/channels/delivery_types.go b/internal/bridges/delivery_types.go similarity index 79% rename from internal/channels/delivery_types.go rename to internal/bridges/delivery_types.go index d96100b9d..0d5c47972 100644 --- a/internal/channels/delivery_types.go +++ b/internal/bridges/delivery_types.go @@ -1,4 +1,4 @@ -package channels +package bridges import ( "context" @@ -11,11 +11,11 @@ import ( var ( // ErrDeliveryNotFound reports that no active or retained delivery matched the lookup. - ErrDeliveryNotFound = errors.New("channels: delivery not found") + ErrDeliveryNotFound = errors.New("bridges: delivery not found") // ErrDeliveryQueueSaturated reports that a bounded delivery queue could not accept more work. - ErrDeliveryQueueSaturated = errors.New("channels: delivery queue saturated") + ErrDeliveryQueueSaturated = errors.New("bridges: delivery queue saturated") // ErrDeliveryTransportUnavailable reports that the broker has no usable extension delivery transport. - ErrDeliveryTransportUnavailable = errors.New("channels: delivery transport unavailable") + ErrDeliveryTransportUnavailable = errors.New("bridges: delivery transport unavailable") ) const ( @@ -37,15 +37,15 @@ const ( defaultDeliveryRequestTimeout = 5 * time.Second ) -// DeliveryTransport delivers negotiated daemon->extension channel requests. +// DeliveryTransport delivers negotiated daemon->extension bridge requests. // The extension name remains explicit because the broker owns routing semantics, // while the extension manager owns the subprocess runtime. type DeliveryTransport interface { - DeliverChannel(ctx context.Context, extensionName string, req DeliveryRequest) (DeliveryAck, error) + DeliverBridge(ctx context.Context, extensionName string, req DeliveryRequest) (DeliveryAck, error) } // DeliveryBroker is the daemon-owned outbound delivery surface used by the -// channel runtime. Prompt projection registers live deliveries separately and +// bridge runtime. Prompt projection registers live deliveries separately and // then enqueues projected events through Deliver. type DeliveryBroker interface { Deliver(ctx context.Context, evt DeliveryEvent) error @@ -53,8 +53,8 @@ type DeliveryBroker interface { } // DeliveryProjectionEvent is the reduced session-event shape the broker needs -// to project prompt output into delivery-oriented channel events. It remains -// ACP-agnostic so `internal/channels` does not depend on runtime transport packages. +// to project prompt output into delivery-oriented bridge events. It remains +// ACP-agnostic so `internal/bridges` does not depend on runtime transport packages. type DeliveryProjectionEvent struct { Type string `json:"type"` TurnID string `json:"turn_id"` @@ -65,7 +65,7 @@ type DeliveryProjectionEvent struct { } // DeliveryRequest is the negotiated daemon->extension request payload for -// `channels/deliver`. Regular streaming requests carry only Event. Recovery +// `bridges/deliver`. Regular streaming requests carry only Event. Recovery // requests also carry Snapshot and use EventTypeResume. type DeliveryRequest struct { Event DeliveryEvent `json:"event"` @@ -79,7 +79,7 @@ func (r DeliveryRequest) Validate() error { } if r.Snapshot == nil { if normalizeDeliveryEventType(r.Event.EventType) == DeliveryEventTypeResume { - return errors.New("channels: resume delivery request requires a snapshot") + return errors.New("bridges: resume delivery request requires a snapshot") } return nil } @@ -88,16 +88,16 @@ func (r DeliveryRequest) Validate() error { return err } if r.Snapshot.DeliveryID != r.Event.DeliveryID { - return errors.New("channels: delivery request snapshot must match event delivery id") + return errors.New("bridges: delivery request snapshot must match event delivery id") } - if r.Snapshot.ChannelInstanceID != r.Event.ChannelInstanceID { - return errors.New("channels: delivery request snapshot must match event channel instance id") + if r.Snapshot.BridgeInstanceID != r.Event.BridgeInstanceID { + return errors.New("bridges: delivery request snapshot must match event bridge instance id") } return nil } // DeliveryAck is the negotiated extension->daemon acknowledgement payload for -// one `channels/deliver` request. +// one `bridges/deliver` request. type DeliveryAck struct { DeliveryID string `json:"delivery_id,omitempty"` Seq int64 `json:"seq,omitempty"` @@ -111,13 +111,13 @@ func (a DeliveryAck) ValidateFor(event DeliveryEvent) error { normalized := a.normalize() if normalized.DeliveryID != "" && normalized.DeliveryID != strings.TrimSpace(event.DeliveryID) { return fmt.Errorf( - "channels: delivery ack delivery id %q does not match event %q", + "bridges: delivery ack delivery id %q does not match event %q", normalized.DeliveryID, strings.TrimSpace(event.DeliveryID), ) } if normalized.Seq != 0 && normalized.Seq != event.Seq { - return fmt.Errorf("channels: delivery ack sequence %d does not match event %d", normalized.Seq, event.Seq) + return fmt.Errorf("bridges: delivery ack sequence %d does not match event %d", normalized.Seq, event.Seq) } return nil } @@ -128,7 +128,7 @@ type DeliverySnapshot struct { DeliveryID string `json:"delivery_id"` SessionID string `json:"session_id"` TurnID string `json:"turn_id"` - ChannelInstanceID string `json:"channel_instance_id"` + BridgeInstanceID string `json:"bridge_instance_id"` RoutingKey RoutingKey `json:"routing_key"` DeliveryTarget DeliveryTarget `json:"delivery_target"` LatestSeq int64 `json:"latest_seq"` @@ -144,7 +144,7 @@ type DeliverySnapshot struct { } // Validate reports whether the snapshot contains the state needed to resume a -// negotiated channel delivery. +// negotiated bridge delivery. func (s DeliverySnapshot) Validate() error { normalized := s.normalize() if err := requireField(normalized.DeliveryID, "delivery snapshot id"); err != nil { @@ -156,38 +156,38 @@ func (s DeliverySnapshot) Validate() error { if err := requireField(normalized.TurnID, "delivery snapshot turn id"); err != nil { return err } - if err := requireField(normalized.ChannelInstanceID, "delivery snapshot channel instance id"); err != nil { + if err := requireField(normalized.BridgeInstanceID, "delivery snapshot bridge instance id"); err != nil { return err } if err := normalized.RoutingKey.Validate(); err != nil { return err } - if normalized.RoutingKey.ChannelInstanceID != normalized.ChannelInstanceID { - return errors.New("channels: delivery snapshot channel instance id must match routing key") + if normalized.RoutingKey.BridgeInstanceID != normalized.BridgeInstanceID { + return errors.New("bridges: delivery snapshot bridge instance id must match routing key") } if err := normalized.DeliveryTarget.Validate(); err != nil { return err } - if normalized.DeliveryTarget.ChannelInstanceID != normalized.ChannelInstanceID { - return errors.New("channels: delivery snapshot channel instance id must match delivery target") + if normalized.DeliveryTarget.BridgeInstanceID != normalized.BridgeInstanceID { + return errors.New("bridges: delivery snapshot bridge instance id must match delivery target") } if normalized.LatestSeq < 0 { - return fmt.Errorf("channels: invalid delivery snapshot latest sequence %d", normalized.LatestSeq) + return fmt.Errorf("bridges: invalid delivery snapshot latest sequence %d", normalized.LatestSeq) } if normalized.LastSentSeq < 0 { - return fmt.Errorf("channels: invalid delivery snapshot last sent sequence %d", normalized.LastSentSeq) + return fmt.Errorf("bridges: invalid delivery snapshot last sent sequence %d", normalized.LastSentSeq) } if normalized.LastAckedSeq < 0 { - return fmt.Errorf("channels: invalid delivery snapshot last acked sequence %d", normalized.LastAckedSeq) + return fmt.Errorf("bridges: invalid delivery snapshot last acked sequence %d", normalized.LastAckedSeq) } if normalized.LastAckedSeq > normalized.LastSentSeq { - return errors.New("channels: delivery snapshot last acked sequence cannot exceed last sent sequence") + return errors.New("bridges: delivery snapshot last acked sequence cannot exceed last sent sequence") } if normalized.LastSentSeq > normalized.LatestSeq { - return errors.New("channels: delivery snapshot last sent sequence cannot exceed latest sequence") + return errors.New("bridges: delivery snapshot last sent sequence cannot exceed latest sequence") } if normalized.UpdatedAt.IsZero() { - return errors.New("channels: delivery snapshot updated at is required") + return errors.New("bridges: delivery snapshot updated at is required") } if err := validateDeliveryEventType(normalized.LatestEventType, normalized.Final); err != nil { return err @@ -195,7 +195,7 @@ func (s DeliverySnapshot) Validate() error { return nil } -// PromptDeliveryRegistration binds one session prompt turn to a routed channel +// PromptDeliveryRegistration binds one session prompt turn to a routed bridge // delivery stream before or shortly after the prompt begins emitting events. type PromptDeliveryRegistration struct { SessionID string `json:"session_id"` @@ -226,8 +226,8 @@ func (r PromptDeliveryRegistration) Validate() error { if err := normalized.DeliveryTarget.Validate(); err != nil { return err } - if normalized.DeliveryTarget.ChannelInstanceID != normalized.RoutingKey.ChannelInstanceID { - return errors.New("channels: prompt delivery registration target must match routing key channel instance") + if normalized.DeliveryTarget.BridgeInstanceID != normalized.RoutingKey.BridgeInstanceID { + return errors.New("bridges: prompt delivery registration target must match routing key bridge instance") } return nil } @@ -267,7 +267,7 @@ func WithDeliveryBrokerRetryDelay(delay time.Duration) DeliveryBrokerOption { } // WithDeliveryBrokerRequestTimeout overrides the timeout applied to one -// negotiated `channels/deliver` call. +// negotiated `bridges/deliver` call. func WithDeliveryBrokerRequestTimeout(timeout time.Duration) DeliveryBrokerOption { return func(b *Broker) { if timeout > 0 { @@ -294,30 +294,30 @@ func validateDeliveryEventType(value string, final bool) error { switch normalizeDeliveryEventType(value) { case DeliveryEventTypeStart: if final { - return errors.New("channels: delivery start event cannot be final") + return errors.New("bridges: delivery start event cannot be final") } return nil case DeliveryEventTypeDelta: if final { - return errors.New("channels: delivery delta event cannot be final") + return errors.New("bridges: delivery delta event cannot be final") } return nil case DeliveryEventTypeFinal: if !final { - return errors.New("channels: delivery final event must set final=true") + return errors.New("bridges: delivery final event must set final=true") } return nil case DeliveryEventTypeError: if !final { - return errors.New("channels: delivery error event must set final=true") + return errors.New("bridges: delivery error event must set final=true") } return nil case DeliveryEventTypeResume: return nil case "": - return errors.New("channels: delivery event type is required") + return errors.New("bridges: delivery event type is required") default: - return fmt.Errorf("channels: unsupported delivery event type %q", strings.TrimSpace(value)) + return fmt.Errorf("bridges: unsupported delivery event type %q", strings.TrimSpace(value)) } } @@ -334,7 +334,7 @@ func (s DeliverySnapshot) normalize() DeliverySnapshot { normalized.DeliveryID = strings.TrimSpace(normalized.DeliveryID) normalized.SessionID = strings.TrimSpace(normalized.SessionID) normalized.TurnID = strings.TrimSpace(normalized.TurnID) - normalized.ChannelInstanceID = strings.TrimSpace(normalized.ChannelInstanceID) + normalized.BridgeInstanceID = strings.TrimSpace(normalized.BridgeInstanceID) normalized.RoutingKey = normalized.RoutingKey.normalize() normalized.DeliveryTarget = normalized.DeliveryTarget.normalize() normalized.LatestEventType = normalizeDeliveryEventType(normalized.LatestEventType) diff --git a/internal/channels/dimensions.go b/internal/bridges/dimensions.go similarity index 80% rename from internal/channels/dimensions.go rename to internal/bridges/dimensions.go index ca0ee40a8..cd3f5fab9 100644 --- a/internal/channels/dimensions.go +++ b/internal/bridges/dimensions.go @@ -1,4 +1,4 @@ -package channels +package bridges import ( "errors" @@ -20,7 +20,7 @@ type RoutingDimensions struct { // Semantics: // - `peer_id` identifies the direct conversation peer or primary counterparty. // - `thread_id` identifies a sub-conversation nested under a peer or group. -// - `group_id` identifies a shared container such as a room, forum, channel, or guild. +// - `group_id` identifies a shared container such as a room, forum, bridge, or guild. // // Adapters should publish one mapping per platform so route inspection and // cross-platform tooling can interpret `peer_id`, `thread_id`, and `group_id` @@ -40,7 +40,7 @@ func (m PlatformDimensionMapping) Validate() error { return err } if normalized.PeerIDConcept == "" && normalized.ThreadIDConcept == "" && normalized.GroupIDConcept == "" { - return errors.New("channels: platform dimension mapping must describe at least one routing dimension") + return errors.New("bridges: platform dimension mapping must describe at least one routing dimension") } return nil } @@ -53,13 +53,13 @@ func validateRoutingDimensions(policy RoutingPolicy, dims RoutingDimensions) err normalizedDims := dims.normalize() if normalizedPolicy.IncludePeer && normalizedDims.PeerID == "" { - return errors.New("channels: routing policy requires peer id") + return errors.New("bridges: routing policy requires peer id") } if normalizedPolicy.IncludeThread && normalizedDims.ThreadID == "" { - return errors.New("channels: routing policy requires thread id") + return errors.New("bridges: routing policy requires thread id") } if normalizedPolicy.IncludeGroup && normalizedDims.GroupID == "" { - return errors.New("channels: routing policy requires group id") + return errors.New("bridges: routing policy requires group id") } return nil @@ -91,27 +91,27 @@ func (m PlatformDimensionMapping) normalize() PlatformDimensionMapping { } } -func validateRoutingKeyBase(instance ChannelInstance, key RoutingKey) error { +func validateRoutingKeyBase(instance BridgeInstance, key RoutingKey) error { normalizedInstance := instance.normalize() normalizedKey := key.normalize() - if normalizedKey.ChannelInstanceID != "" && normalizedKey.ChannelInstanceID != normalizedInstance.ID { + if normalizedKey.BridgeInstanceID != "" && normalizedKey.BridgeInstanceID != normalizedInstance.ID { return fmt.Errorf( - "channels: routing key channel instance id %q does not match instance %q", - normalizedKey.ChannelInstanceID, + "bridges: routing key bridge instance id %q does not match instance %q", + normalizedKey.BridgeInstanceID, normalizedInstance.ID, ) } if normalizedKey.Scope != "" && normalizedKey.Scope != normalizedInstance.Scope { return fmt.Errorf( - "channels: routing key scope %q does not match instance scope %q", + "bridges: routing key scope %q does not match instance scope %q", normalizedKey.Scope, normalizedInstance.Scope, ) } if normalizedKey.WorkspaceID != "" && normalizedKey.WorkspaceID != normalizedInstance.WorkspaceID { return fmt.Errorf( - "channels: routing key workspace id %q does not match instance workspace id %q", + "bridges: routing key workspace id %q does not match instance workspace id %q", normalizedKey.WorkspaceID, normalizedInstance.WorkspaceID, ) diff --git a/internal/bridges/doc.go b/internal/bridges/doc.go new file mode 100644 index 000000000..a8d1edf3a --- /dev/null +++ b/internal/bridges/doc.go @@ -0,0 +1,3 @@ +// Package bridges defines the daemon-owned bridge domain models shared by +// persistence, runtime, and transport layers. +package bridges diff --git a/internal/channels/lifecycle.go b/internal/bridges/lifecycle.go similarity index 52% rename from internal/channels/lifecycle.go rename to internal/bridges/lifecycle.go index f3833bcaf..cac2750e0 100644 --- a/internal/channels/lifecycle.go +++ b/internal/bridges/lifecycle.go @@ -1,4 +1,4 @@ -package channels +package bridges import ( "fmt" @@ -6,7 +6,7 @@ import ( // ValidateInstanceStateTransition reports whether the next enabled/status pair is // a valid lifecycle transition from the current instance state. -func ValidateInstanceStateTransition(current ChannelInstance, nextEnabled bool, nextStatus ChannelStatus) error { +func ValidateInstanceStateTransition(current BridgeInstance, nextEnabled bool, nextStatus BridgeStatus) error { normalizedCurrent := current.normalize() if err := normalizedCurrent.Validate(); err != nil { return err @@ -24,7 +24,7 @@ func ValidateInstanceStateTransition(current ChannelInstance, nextEnabled bool, if !canTransitionInstanceState(normalizedCurrent.Enabled, normalizedCurrent.Status, nextEnabled, normalizedNextStatus) { return fmt.Errorf( "%w: enabled=%t,status=%s -> enabled=%t,status=%s", - ErrInvalidChannelStateTransition, + ErrInvalidBridgeStateTransition, normalizedCurrent.Enabled, normalizedCurrent.Status, nextEnabled, @@ -35,50 +35,50 @@ func ValidateInstanceStateTransition(current ChannelInstance, nextEnabled bool, return nil } -func validateInstanceLifecycle(enabled bool, status ChannelStatus) error { +func validateInstanceLifecycle(enabled bool, status BridgeStatus) error { normalizedStatus := status.Normalize() if err := normalizedStatus.Validate(); err != nil { return err } - if !enabled && normalizedStatus != ChannelStatusDisabled { - return fmt.Errorf("channels: disabled channel instance must report status %q", ChannelStatusDisabled) + if !enabled && normalizedStatus != BridgeStatusDisabled { + return fmt.Errorf("bridges: disabled bridge instance must report status %q", BridgeStatusDisabled) } - if enabled && normalizedStatus == ChannelStatusDisabled { - return fmt.Errorf("channels: enabled channel instance cannot report status %q", ChannelStatusDisabled) + if enabled && normalizedStatus == BridgeStatusDisabled { + return fmt.Errorf("bridges: enabled bridge instance cannot report status %q", BridgeStatusDisabled) } return nil } -func canTransitionInstanceState(currentEnabled bool, currentStatus ChannelStatus, nextEnabled bool, nextStatus ChannelStatus) bool { +func canTransitionInstanceState(currentEnabled bool, currentStatus BridgeStatus, nextEnabled bool, nextStatus BridgeStatus) bool { normalizedCurrent := currentStatus.Normalize() normalizedNext := nextStatus.Normalize() switch normalizedCurrent { - case ChannelStatusDisabled: - return !currentEnabled && nextEnabled && normalizedNext == ChannelStatusStarting - case ChannelStatusStarting: + case BridgeStatusDisabled: + return !currentEnabled && nextEnabled && normalizedNext == BridgeStatusStarting + case BridgeStatusStarting: if !currentEnabled { return false } return transitionFromStarting(nextEnabled, normalizedNext) - case ChannelStatusReady: + case BridgeStatusReady: if !currentEnabled { return false } return transitionFromReady(nextEnabled, normalizedNext) - case ChannelStatusDegraded: + case BridgeStatusDegraded: if !currentEnabled { return false } return transitionFromDegraded(nextEnabled, normalizedNext) - case ChannelStatusAuthRequired: + case BridgeStatusAuthRequired: if !currentEnabled { return false } return transitionFromAuthRequired(nextEnabled, normalizedNext) - case ChannelStatusError: + case BridgeStatusError: if !currentEnabled { return false } @@ -88,65 +88,65 @@ func canTransitionInstanceState(currentEnabled bool, currentStatus ChannelStatus } } -func transitionFromStarting(nextEnabled bool, nextStatus ChannelStatus) bool { +func transitionFromStarting(nextEnabled bool, nextStatus BridgeStatus) bool { if !nextEnabled { - return nextStatus == ChannelStatusDisabled + return nextStatus == BridgeStatusDisabled } switch nextStatus { - case ChannelStatusStarting, ChannelStatusReady, ChannelStatusDegraded, ChannelStatusAuthRequired, ChannelStatusError: + case BridgeStatusStarting, BridgeStatusReady, BridgeStatusDegraded, BridgeStatusAuthRequired, BridgeStatusError: return true default: return false } } -func transitionFromReady(nextEnabled bool, nextStatus ChannelStatus) bool { +func transitionFromReady(nextEnabled bool, nextStatus BridgeStatus) bool { if !nextEnabled { - return nextStatus == ChannelStatusDisabled + return nextStatus == BridgeStatusDisabled } switch nextStatus { - case ChannelStatusReady, ChannelStatusStarting, ChannelStatusDegraded, ChannelStatusAuthRequired, ChannelStatusError: + case BridgeStatusReady, BridgeStatusStarting, BridgeStatusDegraded, BridgeStatusAuthRequired, BridgeStatusError: return true default: return false } } -func transitionFromDegraded(nextEnabled bool, nextStatus ChannelStatus) bool { +func transitionFromDegraded(nextEnabled bool, nextStatus BridgeStatus) bool { if !nextEnabled { - return nextStatus == ChannelStatusDisabled + return nextStatus == BridgeStatusDisabled } switch nextStatus { - case ChannelStatusDegraded, ChannelStatusStarting, ChannelStatusReady, ChannelStatusAuthRequired, ChannelStatusError: + case BridgeStatusDegraded, BridgeStatusStarting, BridgeStatusReady, BridgeStatusAuthRequired, BridgeStatusError: return true default: return false } } -func transitionFromAuthRequired(nextEnabled bool, nextStatus ChannelStatus) bool { +func transitionFromAuthRequired(nextEnabled bool, nextStatus BridgeStatus) bool { if !nextEnabled { - return nextStatus == ChannelStatusDisabled + return nextStatus == BridgeStatusDisabled } switch nextStatus { - case ChannelStatusAuthRequired, ChannelStatusStarting, ChannelStatusError: + case BridgeStatusAuthRequired, BridgeStatusStarting, BridgeStatusError: return true default: return false } } -func transitionFromError(nextEnabled bool, nextStatus ChannelStatus) bool { +func transitionFromError(nextEnabled bool, nextStatus BridgeStatus) bool { if !nextEnabled { - return nextStatus == ChannelStatusDisabled + return nextStatus == BridgeStatusDisabled } switch nextStatus { - case ChannelStatusError, ChannelStatusStarting: + case BridgeStatusError, BridgeStatusStarting: return true default: return false diff --git a/internal/bridges/registry.go b/internal/bridges/registry.go new file mode 100644 index 000000000..625d117b9 --- /dev/null +++ b/internal/bridges/registry.go @@ -0,0 +1,519 @@ +package bridges + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/pedronauck/agh/internal/store" +) + +// RegistryStore is the persistence surface consumed by the daemon-owned bridge +// registry. The global DB implementation from task 01 satisfies this contract. +type RegistryStore interface { + InsertBridgeInstance(ctx context.Context, instance BridgeInstance) error + UpdateBridgeInstance(ctx context.Context, instance BridgeInstance) error + GetBridgeInstance(ctx context.Context, id string) (BridgeInstance, error) + ListBridgeInstances(ctx context.Context) ([]BridgeInstance, error) + PutBridgeRoute(ctx context.Context, route BridgeRoute) error + ResolveBridgeRoute(ctx context.Context, key RoutingKey) (BridgeRoute, error) + ListBridgeRoutes(ctx context.Context, bridgeInstanceID string) ([]BridgeRoute, error) +} + +// Registry owns bridge instance lifecycle validation and canonical routing-key +// construction on top of the persistence layer. +type Registry interface { + CreateInstance(ctx context.Context, req CreateInstanceRequest) (*BridgeInstance, error) + GetInstance(ctx context.Context, id string) (*BridgeInstance, error) + ListInstances(ctx context.Context) ([]BridgeInstance, error) + UpdateInstance(ctx context.Context, req UpdateInstanceRequest) (*BridgeInstance, error) + UpdateInstanceState(ctx context.Context, req UpdateInstanceStateRequest) (*BridgeInstance, error) + BuildRoutingKey(ctx context.Context, key RoutingKey) (RoutingKey, error) + ResolveRoute(ctx context.Context, key RoutingKey) (*BridgeRoute, error) + ResolveOrCreateRoute(ctx context.Context, route BridgeRoute) (*BridgeRoute, bool, error) + UpsertRoute(ctx context.Context, route BridgeRoute) (*BridgeRoute, error) + ListRoutes(ctx context.Context, bridgeInstanceID string) ([]BridgeRoute, error) +} + +// CreateInstanceRequest captures the persisted configuration for a new bridge instance. +type CreateInstanceRequest struct { + ID string `json:"id,omitempty"` + Scope Scope `json:"scope"` + WorkspaceID string `json:"workspace_id,omitempty"` + Platform string `json:"platform"` + ExtensionName string `json:"extension_name"` + DisplayName string `json:"display_name"` + Enabled bool `json:"enabled"` + Status BridgeStatus `json:"status"` + RoutingPolicy RoutingPolicy `json:"routing_policy"` + DeliveryDefaults json.RawMessage `json:"delivery_defaults,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +// Validate reports whether the creation request contains a valid instance definition. +func (r CreateInstanceRequest) Validate() error { + _, err := r.toInstance(nil) + return err +} + +// UpdateInstanceRequest captures one mutation of bridge-instance fields that +// do not change the lifecycle state machine. +type UpdateInstanceRequest struct { + ID string `json:"id"` + DisplayName *string `json:"display_name,omitempty"` + RoutingPolicy *RoutingPolicy `json:"routing_policy,omitempty"` + DeliveryDefaults *json.RawMessage `json:"delivery_defaults,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +// Validate reports whether the request contains at least one mutable field and +// each supplied value is internally consistent. +func (r UpdateInstanceRequest) Validate() error { + if err := requireField(strings.TrimSpace(r.ID), "bridge instance id"); err != nil { + return err + } + if r.DisplayName == nil && r.RoutingPolicy == nil && r.DeliveryDefaults == nil { + return errors.New("bridges: bridge instance update requires at least one mutable field") + } + if r.DisplayName != nil { + if err := requireField(strings.TrimSpace(*r.DisplayName), "bridge instance display name"); err != nil { + return err + } + } + if r.RoutingPolicy != nil { + if err := r.RoutingPolicy.Validate(); err != nil { + return err + } + } + if r.DeliveryDefaults != nil { + if _, err := normalizeRawJSON(*r.DeliveryDefaults, "bridge instance delivery defaults"); err != nil { + return err + } + } + return nil +} + +// UpdateInstanceStateRequest captures one daemon-owned lifecycle transition. +type UpdateInstanceStateRequest struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + Status BridgeStatus `json:"status"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +// Validate reports whether the request contains the fields needed for a lifecycle update. +func (r UpdateInstanceStateRequest) Validate() error { + if err := requireField(strings.TrimSpace(r.ID), "bridge instance id"); err != nil { + return err + } + return validateInstanceLifecycle(r.Enabled, r.Status.Normalize()) +} + +// Service is the concrete daemon-owned registry implementation. +type Service struct { + store RegistryStore + now func() time.Time +} + +// RegistryOption customizes Service construction. +type RegistryOption func(*Service) + +var _ Registry = (*Service)(nil) + +// NewRegistry constructs the bridge registry over the supplied persistence surface. +func NewRegistry(store RegistryStore, opts ...RegistryOption) *Service { + service := &Service{ + store: store, + now: func() time.Time { + return time.Now().UTC() + }, + } + for _, opt := range opts { + if opt != nil { + opt(service) + } + } + return service +} + +// WithNow overrides the clock used for default timestamps in tests. +func WithNow(now func() time.Time) RegistryOption { + return func(service *Service) { + if now != nil { + service.now = now + } + } +} + +// CreateInstance persists a new bridge instance after applying lifecycle validation. +func (s *Service) CreateInstance(ctx context.Context, req CreateInstanceRequest) (*BridgeInstance, error) { + if err := s.checkReady(ctx, "create bridge instance"); err != nil { + return nil, err + } + + instance, err := req.toInstance(s.now) + if err != nil { + return nil, fmt.Errorf("bridges: create bridge instance: build request: %w", err) + } + if err := s.store.InsertBridgeInstance(ctx, instance); err != nil { + return nil, fmt.Errorf("bridges: create bridge instance %q: insert: %w", instance.ID, err) + } + return cloneBridgeInstance(instance), nil +} + +// GetInstance returns one persisted bridge instance by primary key. +func (s *Service) GetInstance(ctx context.Context, id string) (*BridgeInstance, error) { + if err := s.checkReady(ctx, "get bridge instance"); err != nil { + return nil, err + } + + trimmedID := strings.TrimSpace(id) + instance, err := s.store.GetBridgeInstance(ctx, trimmedID) + if err != nil { + return nil, fmt.Errorf("bridges: get bridge instance %q: %w", trimmedID, err) + } + return cloneBridgeInstance(instance), nil +} + +// ListInstances returns all persisted bridge instances. +func (s *Service) ListInstances(ctx context.Context) ([]BridgeInstance, error) { + if err := s.checkReady(ctx, "list bridge instances"); err != nil { + return nil, err + } + + instances, err := s.store.ListBridgeInstances(ctx) + if err != nil { + return nil, fmt.Errorf("bridges: list bridge instances: %w", err) + } + if len(instances) == 0 { + return instances, nil + } + + cloned := make([]BridgeInstance, 0, len(instances)) + for _, instance := range instances { + cloned = append(cloned, *cloneBridgeInstance(instance)) + } + return cloned, nil +} + +// UpdateInstance updates one persisted bridge instance without changing its +// lifecycle state. +func (s *Service) UpdateInstance(ctx context.Context, req UpdateInstanceRequest) (*BridgeInstance, error) { + if err := s.checkReady(ctx, "update bridge instance"); err != nil { + return nil, err + } + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("bridges: update bridge instance %q: validate request: %w", strings.TrimSpace(req.ID), err) + } + + trimmedID := strings.TrimSpace(req.ID) + instance, err := s.store.GetBridgeInstance(ctx, trimmedID) + if err != nil { + return nil, fmt.Errorf("bridges: update bridge instance %q: load current state: %w", trimmedID, err) + } + if req.DisplayName != nil { + instance.DisplayName = strings.TrimSpace(*req.DisplayName) + } + if req.RoutingPolicy != nil { + instance.RoutingPolicy = *req.RoutingPolicy + } + if req.DeliveryDefaults != nil { + normalized, err := normalizeRawJSON(*req.DeliveryDefaults, "bridge instance delivery defaults") + if err != nil { + return nil, fmt.Errorf("bridges: update bridge instance %q: normalize delivery defaults: %w", trimmedID, err) + } + instance.DeliveryDefaults = normalized + } + instance.UpdatedAt = req.UpdatedAt + if instance.UpdatedAt.IsZero() { + instance.UpdatedAt = s.now() + } + if err := instance.Validate(); err != nil { + return nil, fmt.Errorf("bridges: update bridge instance %q: validate updated state: %w", trimmedID, err) + } + if err := s.store.UpdateBridgeInstance(ctx, instance); err != nil { + return nil, fmt.Errorf("bridges: update bridge instance %q: persist: %w", trimmedID, err) + } + return cloneBridgeInstance(instance), nil +} + +// UpdateInstanceState applies one validated lifecycle transition to a persisted instance. +func (s *Service) UpdateInstanceState(ctx context.Context, req UpdateInstanceStateRequest) (*BridgeInstance, error) { + if err := s.checkReady(ctx, "update bridge instance state"); err != nil { + return nil, err + } + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("bridges: update bridge instance state %q: validate request: %w", strings.TrimSpace(req.ID), err) + } + + trimmedID := strings.TrimSpace(req.ID) + instance, err := s.store.GetBridgeInstance(ctx, trimmedID) + if err != nil { + return nil, fmt.Errorf("bridges: update bridge instance state %q: load current state: %w", trimmedID, err) + } + if err := ValidateInstanceStateTransition(instance, req.Enabled, req.Status); err != nil { + return nil, fmt.Errorf("bridges: update bridge instance state %q: validate transition: %w", trimmedID, err) + } + + instance.Enabled = req.Enabled + instance.Status = req.Status.Normalize() + instance.UpdatedAt = req.UpdatedAt + if instance.UpdatedAt.IsZero() { + instance.UpdatedAt = s.now() + } + + if err := s.store.UpdateBridgeInstance(ctx, instance); err != nil { + return nil, fmt.Errorf("bridges: update bridge instance state %q: persist: %w", trimmedID, err) + } + return cloneBridgeInstance(instance), nil +} + +// BuildRoutingKey canonicalizes the supplied routing identity under the owning instance policy. +func (s *Service) BuildRoutingKey(ctx context.Context, key RoutingKey) (RoutingKey, error) { + if err := s.checkReady(ctx, "build routing key"); err != nil { + return RoutingKey{}, err + } + + trimmedID := strings.TrimSpace(key.BridgeInstanceID) + instance, err := s.store.GetBridgeInstance(ctx, trimmedID) + if err != nil { + return RoutingKey{}, fmt.Errorf("bridges: build routing key for %q: load bridge instance: %w", trimmedID, err) + } + canonicalKey, err := CanonicalizeRoutingKey(instance, key) + if err != nil { + return RoutingKey{}, fmt.Errorf("bridges: build routing key for %q: %w", trimmedID, err) + } + return canonicalKey, nil +} + +// ResolveRoute resolves one route by canonical routing identity. +func (s *Service) ResolveRoute(ctx context.Context, key RoutingKey) (*BridgeRoute, error) { + if err := s.checkReady(ctx, "resolve bridge route"); err != nil { + return nil, err + } + + trimmedID := strings.TrimSpace(key.BridgeInstanceID) + instance, err := s.loadRoutableInstance(ctx, trimmedID) + if err != nil { + return nil, fmt.Errorf("bridges: resolve bridge route for %q: load bridge instance: %w", trimmedID, err) + } + + canonicalKey, err := CanonicalizeRoutingKey(instance, key) + if err != nil { + return nil, fmt.Errorf("bridges: resolve bridge route for %q: canonicalize routing key: %w", trimmedID, err) + } + + route, err := s.store.ResolveBridgeRoute(ctx, canonicalKey) + if err != nil { + return nil, fmt.Errorf("bridges: resolve bridge route for %q: lookup route: %w", trimmedID, err) + } + return cloneBridgeRoute(route), nil +} + +// ResolveOrCreateRoute reuses an existing session binding for the canonical key +// or persists the supplied route when no binding exists yet. +func (s *Service) ResolveOrCreateRoute(ctx context.Context, route BridgeRoute) (*BridgeRoute, bool, error) { + if err := s.checkReady(ctx, "resolve or create bridge route"); err != nil { + return nil, false, err + } + + trimmedID := strings.TrimSpace(route.BridgeInstanceID) + instance, err := s.loadRoutableInstance(ctx, trimmedID) + if err != nil { + return nil, false, fmt.Errorf("bridges: resolve or create bridge route for %q: load bridge instance: %w", trimmedID, err) + } + + canonicalRoute, err := CanonicalizeRoute(instance, route) + if err != nil { + return nil, false, fmt.Errorf("bridges: resolve or create bridge route for %q: canonicalize route: %w", trimmedID, err) + } + + existing, err := s.store.ResolveBridgeRoute(ctx, canonicalRoute.RoutingKey()) + if err == nil { + refreshed := existing + refreshed.LastActivityAt = canonicalRoute.LastActivityAt + refreshed.UpdatedAt = canonicalRoute.UpdatedAt + refreshed = s.prepareRouteForWrite(refreshed, &existing) + if err := s.store.PutBridgeRoute(ctx, refreshed); err != nil { + return nil, false, fmt.Errorf("bridges: resolve or create bridge route for %q: refresh route: %w", trimmedID, err) + } + return cloneBridgeRoute(refreshed), false, nil + } + if !errors.Is(err, ErrBridgeRouteNotFound) { + return nil, false, fmt.Errorf("bridges: resolve or create bridge route for %q: lookup route: %w", trimmedID, err) + } + + canonicalRoute = s.prepareRouteForWrite(canonicalRoute, nil) + if err := s.store.PutBridgeRoute(ctx, canonicalRoute); err != nil { + return nil, false, fmt.Errorf("bridges: resolve or create bridge route for %q: create route: %w", trimmedID, err) + } + + return cloneBridgeRoute(canonicalRoute), true, nil +} + +// UpsertRoute writes a route using the canonical key derived from the owning instance policy. +func (s *Service) UpsertRoute(ctx context.Context, route BridgeRoute) (*BridgeRoute, error) { + if err := s.checkReady(ctx, "upsert bridge route"); err != nil { + return nil, err + } + + trimmedID := strings.TrimSpace(route.BridgeInstanceID) + instance, err := s.loadRoutableInstance(ctx, trimmedID) + if err != nil { + return nil, fmt.Errorf("bridges: upsert bridge route for %q: load bridge instance: %w", trimmedID, err) + } + + canonicalRoute, err := CanonicalizeRoute(instance, route) + if err != nil { + return nil, fmt.Errorf("bridges: upsert bridge route for %q: canonicalize route: %w", trimmedID, err) + } + + existing, err := s.store.ResolveBridgeRoute(ctx, canonicalRoute.RoutingKey()) + if err != nil && !errors.Is(err, ErrBridgeRouteNotFound) { + return nil, fmt.Errorf("bridges: upsert bridge route for %q: lookup route: %w", trimmedID, err) + } + var existingRoute *BridgeRoute + if err == nil { + existingRoute = &existing + } + + canonicalRoute = s.prepareRouteForWrite(canonicalRoute, existingRoute) + if err := s.store.PutBridgeRoute(ctx, canonicalRoute); err != nil { + return nil, fmt.Errorf("bridges: upsert bridge route for %q: persist route: %w", trimmedID, err) + } + + return cloneBridgeRoute(canonicalRoute), nil +} + +// ListRoutes returns the persisted routes owned by one bridge instance. +func (s *Service) ListRoutes(ctx context.Context, bridgeInstanceID string) ([]BridgeRoute, error) { + if err := s.checkReady(ctx, "list bridge routes"); err != nil { + return nil, err + } + + trimmedInstanceID := strings.TrimSpace(bridgeInstanceID) + if _, err := s.store.GetBridgeInstance(ctx, trimmedInstanceID); err != nil { + return nil, fmt.Errorf("bridges: list bridge routes for %q: load bridge instance: %w", trimmedInstanceID, err) + } + routes, err := s.store.ListBridgeRoutes(ctx, trimmedInstanceID) + if err != nil { + return nil, fmt.Errorf("bridges: list bridge routes for %q: %w", trimmedInstanceID, err) + } + if len(routes) == 0 { + return routes, nil + } + + cloned := make([]BridgeRoute, 0, len(routes)) + for _, route := range routes { + cloned = append(cloned, *cloneBridgeRoute(route)) + } + return cloned, nil +} + +func (s *Service) checkReady(ctx context.Context, action string) error { + if s == nil { + return errors.New("bridges: registry is required") + } + if s.store == nil { + return errors.New("bridges: registry store is required") + } + if ctx == nil { + return fmt.Errorf("bridges: %s context is required", action) + } + return nil +} + +func (s *Service) loadRoutableInstance(ctx context.Context, bridgeInstanceID string) (BridgeInstance, error) { + instance, err := s.store.GetBridgeInstance(ctx, bridgeInstanceID) + if err != nil { + return BridgeInstance{}, err + } + if !instance.Enabled || instance.Status.Normalize() == BridgeStatusDisabled { + return BridgeInstance{}, fmt.Errorf("%w: %s", ErrBridgeInstanceUnavailable, instance.ID) + } + return instance, nil +} + +func (s *Service) prepareRouteForWrite(route BridgeRoute, existing *BridgeRoute) BridgeRoute { + prepared := route.normalize() + + activityAt := prepared.LastActivityAt + if activityAt.IsZero() { + activityAt = s.now() + } + prepared.LastActivityAt = activityAt + + if existing != nil && prepared.CreatedAt.IsZero() { + prepared.CreatedAt = existing.CreatedAt + } + if prepared.CreatedAt.IsZero() { + prepared.CreatedAt = activityAt + } + if prepared.UpdatedAt.IsZero() { + prepared.UpdatedAt = activityAt + } + + return prepared +} + +func (r CreateInstanceRequest) toInstance(now func() time.Time) (BridgeInstance, error) { + clock := now + if clock == nil { + clock = func() time.Time { + return time.Now().UTC() + } + } + + instance := BridgeInstance{ + ID: strings.TrimSpace(r.ID), + Scope: r.Scope.Normalize(), + WorkspaceID: strings.TrimSpace(r.WorkspaceID), + Platform: strings.TrimSpace(r.Platform), + ExtensionName: strings.TrimSpace(r.ExtensionName), + DisplayName: strings.TrimSpace(r.DisplayName), + Enabled: r.Enabled, + Status: r.Status.Normalize(), + RoutingPolicy: r.RoutingPolicy, + DeliveryDefaults: r.DeliveryDefaults, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } + if instance.ID == "" { + instance.ID = store.NewID("chan") + } + if instance.CreatedAt.IsZero() { + instance.CreatedAt = clock() + } + if instance.UpdatedAt.IsZero() { + instance.UpdatedAt = instance.CreatedAt + } + + deliveryDefaults, err := normalizeRawJSON(instance.DeliveryDefaults, "bridge instance delivery defaults") + if err != nil { + return BridgeInstance{}, err + } + instance.DeliveryDefaults = deliveryDefaults + + if err := instance.Validate(); err != nil { + return BridgeInstance{}, err + } + + return instance, nil +} + +func cloneBridgeInstance(instance BridgeInstance) *BridgeInstance { + cloned := instance + if instance.DeliveryDefaults != nil { + cloned.DeliveryDefaults = append(json.RawMessage(nil), instance.DeliveryDefaults...) + } + return &cloned +} + +func cloneBridgeRoute(route BridgeRoute) *BridgeRoute { + cloned := route + return &cloned +} diff --git a/internal/channels/registry_integration_test.go b/internal/bridges/registry_integration_test.go similarity index 53% rename from internal/channels/registry_integration_test.go rename to internal/bridges/registry_integration_test.go index 0acd05166..edc878175 100644 --- a/internal/channels/registry_integration_test.go +++ b/internal/bridges/registry_integration_test.go @@ -1,11 +1,11 @@ //go:build integration -package channels_test +package bridges_test import ( "testing" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" "github.com/pedronauck/agh/internal/testutil" ) @@ -13,35 +13,35 @@ func TestRegistryGlobalAndWorkspaceRoutesStayIsolated(t *testing.T) { t.Parallel() registry, db := newRegistryTestHarness(t) - workspaceID := registerWorkspaceForChannelsTests(t, db, "ws-route-scope", "route-scope") + workspaceID := registerWorkspaceForBridgesTests(t, db, "ws-route-scope", "route-scope") - globalInstance := createTestChannelInstance(t, registry, channelspkg.CreateInstanceRequest{ - ID: "chan-global-route", - Scope: channelspkg.ScopeGlobal, + globalInstance := createTestBridgeInstance(t, registry, bridgepkg.CreateInstanceRequest{ + ID: "brg-global-route", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Global Route", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) - workspaceInstance := createTestChannelInstance(t, registry, channelspkg.CreateInstanceRequest{ - ID: "chan-workspace-route", - Scope: channelspkg.ScopeWorkspace, + workspaceInstance := createTestBridgeInstance(t, registry, bridgepkg.CreateInstanceRequest{ + ID: "brg-workspace-route", + Scope: bridgepkg.ScopeWorkspace, WorkspaceID: workspaceID, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Workspace Route", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) - globalRoute, created, err := registry.ResolveOrCreateRoute(testutil.Context(t), channelspkg.ChannelRoute{ - ChannelInstanceID: globalInstance.ID, - PeerID: "peer-1", - SessionID: "sess-global", - AgentName: "coder", + globalRoute, created, err := registry.ResolveOrCreateRoute(testutil.Context(t), bridgepkg.BridgeRoute{ + BridgeInstanceID: globalInstance.ID, + PeerID: "peer-1", + SessionID: "sess-global", + AgentName: "coder", }) if err != nil { t.Fatalf("ResolveOrCreateRoute(global) error = %v", err) @@ -50,11 +50,11 @@ func TestRegistryGlobalAndWorkspaceRoutesStayIsolated(t *testing.T) { t.Fatal("ResolveOrCreateRoute(global) created = false, want true") } - workspaceRoute, created, err := registry.ResolveOrCreateRoute(testutil.Context(t), channelspkg.ChannelRoute{ - ChannelInstanceID: workspaceInstance.ID, - PeerID: "peer-1", - SessionID: "sess-workspace", - AgentName: "coder", + workspaceRoute, created, err := registry.ResolveOrCreateRoute(testutil.Context(t), bridgepkg.BridgeRoute{ + BridgeInstanceID: workspaceInstance.ID, + PeerID: "peer-1", + SessionID: "sess-workspace", + AgentName: "coder", }) if err != nil { t.Fatalf("ResolveOrCreateRoute(workspace) error = %v", err) @@ -67,16 +67,16 @@ func TestRegistryGlobalAndWorkspaceRoutesStayIsolated(t *testing.T) { t.Fatalf("RoutingKeyHash() = %q for both routes, want distinct records", globalRoute.RoutingKeyHash) } - globalKey, err := registry.BuildRoutingKey(testutil.Context(t), channelspkg.RoutingKey{ - ChannelInstanceID: globalInstance.ID, - PeerID: "peer-1", + globalKey, err := registry.BuildRoutingKey(testutil.Context(t), bridgepkg.RoutingKey{ + BridgeInstanceID: globalInstance.ID, + PeerID: "peer-1", }) if err != nil { t.Fatalf("BuildRoutingKey(global) error = %v", err) } - workspaceKey, err := registry.BuildRoutingKey(testutil.Context(t), channelspkg.RoutingKey{ - ChannelInstanceID: workspaceInstance.ID, - PeerID: "peer-1", + workspaceKey, err := registry.BuildRoutingKey(testutil.Context(t), bridgepkg.RoutingKey{ + BridgeInstanceID: workspaceInstance.ID, + PeerID: "peer-1", }) if err != nil { t.Fatalf("BuildRoutingKey(workspace) error = %v", err) @@ -93,34 +93,34 @@ func TestRegistryUpsertRouteRebindsWithoutDuplicateRows(t *testing.T) { t.Parallel() registry, _ := newRegistryTestHarness(t) - instance := createTestChannelInstance(t, registry, channelspkg.CreateInstanceRequest{ - ID: "chan-rebind", - Scope: channelspkg.ScopeGlobal, + instance := createTestBridgeInstance(t, registry, bridgepkg.CreateInstanceRequest{ + ID: "brg-rebind", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Route Rebind", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, }) - first, err := registry.UpsertRoute(testutil.Context(t), channelspkg.ChannelRoute{ - ChannelInstanceID: instance.ID, - PeerID: "peer-1", - ThreadID: "thread-1", - SessionID: "sess-1", - AgentName: "coder", + first, err := registry.UpsertRoute(testutil.Context(t), bridgepkg.BridgeRoute{ + BridgeInstanceID: instance.ID, + PeerID: "peer-1", + ThreadID: "thread-1", + SessionID: "sess-1", + AgentName: "coder", }) if err != nil { t.Fatalf("UpsertRoute(first) error = %v", err) } - second, err := registry.UpsertRoute(testutil.Context(t), channelspkg.ChannelRoute{ - ChannelInstanceID: instance.ID, - PeerID: "peer-1", - ThreadID: "thread-1", - SessionID: "sess-2", - AgentName: "reviewer", + second, err := registry.UpsertRoute(testutil.Context(t), bridgepkg.BridgeRoute{ + BridgeInstanceID: instance.ID, + PeerID: "peer-1", + ThreadID: "thread-1", + SessionID: "sess-2", + AgentName: "reviewer", }) if err != nil { t.Fatalf("UpsertRoute(second) error = %v", err) @@ -148,40 +148,40 @@ func TestRegistryListRoutesReturnsOnlyTheRequestedInstance(t *testing.T) { t.Parallel() registry, _ := newRegistryTestHarness(t) - first := createTestChannelInstance(t, registry, channelspkg.CreateInstanceRequest{ - ID: "chan-list-a", - Scope: channelspkg.ScopeGlobal, + first := createTestBridgeInstance(t, registry, bridgepkg.CreateInstanceRequest{ + ID: "brg-list-a", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "List A", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) - second := createTestChannelInstance(t, registry, channelspkg.CreateInstanceRequest{ - ID: "chan-list-b", - Scope: channelspkg.ScopeGlobal, + second := createTestBridgeInstance(t, registry, bridgepkg.CreateInstanceRequest{ + ID: "brg-list-b", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "List B", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) - if _, err := registry.UpsertRoute(testutil.Context(t), channelspkg.ChannelRoute{ - ChannelInstanceID: first.ID, - PeerID: "peer-a", - SessionID: "sess-a", - AgentName: "coder", + if _, err := registry.UpsertRoute(testutil.Context(t), bridgepkg.BridgeRoute{ + BridgeInstanceID: first.ID, + PeerID: "peer-a", + SessionID: "sess-a", + AgentName: "coder", }); err != nil { t.Fatalf("UpsertRoute(first) error = %v", err) } - if _, err := registry.UpsertRoute(testutil.Context(t), channelspkg.ChannelRoute{ - ChannelInstanceID: second.ID, - PeerID: "peer-b", - SessionID: "sess-b", - AgentName: "coder", + if _, err := registry.UpsertRoute(testutil.Context(t), bridgepkg.BridgeRoute{ + BridgeInstanceID: second.ID, + PeerID: "peer-b", + SessionID: "sess-b", + AgentName: "coder", }); err != nil { t.Fatalf("UpsertRoute(second) error = %v", err) } @@ -193,7 +193,7 @@ func TestRegistryListRoutesReturnsOnlyTheRequestedInstance(t *testing.T) { if got, want := len(routes), 1; got != want { t.Fatalf("len(routes) = %d, want %d", got, want) } - if routes[0].ChannelInstanceID != first.ID { - t.Fatalf("routes[0].ChannelInstanceID = %q, want %q", routes[0].ChannelInstanceID, first.ID) + if routes[0].BridgeInstanceID != first.ID { + t.Fatalf("routes[0].BridgeInstanceID = %q, want %q", routes[0].BridgeInstanceID, first.ID) } } diff --git a/internal/channels/registry_test.go b/internal/bridges/registry_test.go similarity index 52% rename from internal/channels/registry_test.go rename to internal/bridges/registry_test.go index 3ddfbb71d..1f742bc15 100644 --- a/internal/channels/registry_test.go +++ b/internal/bridges/registry_test.go @@ -1,4 +1,4 @@ -package channels_test +package bridges_test import ( "context" @@ -9,7 +9,7 @@ import ( "testing" "time" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" "github.com/pedronauck/agh/internal/store" "github.com/pedronauck/agh/internal/store/globaldb" "github.com/pedronauck/agh/internal/testutil" @@ -17,60 +17,60 @@ import ( ) type stubRegistryStore struct { - insertChannelInstanceFn func(context.Context, channelspkg.ChannelInstance) error - updateChannelInstanceFn func(context.Context, channelspkg.ChannelInstance) error - getChannelInstanceFn func(context.Context, string) (channelspkg.ChannelInstance, error) - listChannelInstancesFn func(context.Context) ([]channelspkg.ChannelInstance, error) - putChannelRouteFn func(context.Context, channelspkg.ChannelRoute) error - resolveChannelRouteFn func(context.Context, channelspkg.RoutingKey) (channelspkg.ChannelRoute, error) - listChannelRoutesFn func(context.Context, string) ([]channelspkg.ChannelRoute, error) + insertBridgeInstanceFn func(context.Context, bridgepkg.BridgeInstance) error + updateBridgeInstanceFn func(context.Context, bridgepkg.BridgeInstance) error + getBridgeInstanceFn func(context.Context, string) (bridgepkg.BridgeInstance, error) + listBridgeInstancesFn func(context.Context) ([]bridgepkg.BridgeInstance, error) + putBridgeRouteFn func(context.Context, bridgepkg.BridgeRoute) error + resolveBridgeRouteFn func(context.Context, bridgepkg.RoutingKey) (bridgepkg.BridgeRoute, error) + listBridgeRoutesFn func(context.Context, string) ([]bridgepkg.BridgeRoute, error) } -func (s stubRegistryStore) InsertChannelInstance(ctx context.Context, instance channelspkg.ChannelInstance) error { - if s.insertChannelInstanceFn != nil { - return s.insertChannelInstanceFn(ctx, instance) +func (s stubRegistryStore) InsertBridgeInstance(ctx context.Context, instance bridgepkg.BridgeInstance) error { + if s.insertBridgeInstanceFn != nil { + return s.insertBridgeInstanceFn(ctx, instance) } return nil } -func (s stubRegistryStore) UpdateChannelInstance(ctx context.Context, instance channelspkg.ChannelInstance) error { - if s.updateChannelInstanceFn != nil { - return s.updateChannelInstanceFn(ctx, instance) +func (s stubRegistryStore) UpdateBridgeInstance(ctx context.Context, instance bridgepkg.BridgeInstance) error { + if s.updateBridgeInstanceFn != nil { + return s.updateBridgeInstanceFn(ctx, instance) } return nil } -func (s stubRegistryStore) GetChannelInstance(ctx context.Context, id string) (channelspkg.ChannelInstance, error) { - if s.getChannelInstanceFn != nil { - return s.getChannelInstanceFn(ctx, id) +func (s stubRegistryStore) GetBridgeInstance(ctx context.Context, id string) (bridgepkg.BridgeInstance, error) { + if s.getBridgeInstanceFn != nil { + return s.getBridgeInstanceFn(ctx, id) } - return channelspkg.ChannelInstance{}, channelspkg.ErrChannelInstanceNotFound + return bridgepkg.BridgeInstance{}, bridgepkg.ErrBridgeInstanceNotFound } -func (s stubRegistryStore) ListChannelInstances(ctx context.Context) ([]channelspkg.ChannelInstance, error) { - if s.listChannelInstancesFn != nil { - return s.listChannelInstancesFn(ctx) +func (s stubRegistryStore) ListBridgeInstances(ctx context.Context) ([]bridgepkg.BridgeInstance, error) { + if s.listBridgeInstancesFn != nil { + return s.listBridgeInstancesFn(ctx) } return nil, nil } -func (s stubRegistryStore) PutChannelRoute(ctx context.Context, route channelspkg.ChannelRoute) error { - if s.putChannelRouteFn != nil { - return s.putChannelRouteFn(ctx, route) +func (s stubRegistryStore) PutBridgeRoute(ctx context.Context, route bridgepkg.BridgeRoute) error { + if s.putBridgeRouteFn != nil { + return s.putBridgeRouteFn(ctx, route) } return nil } -func (s stubRegistryStore) ResolveChannelRoute(ctx context.Context, key channelspkg.RoutingKey) (channelspkg.ChannelRoute, error) { - if s.resolveChannelRouteFn != nil { - return s.resolveChannelRouteFn(ctx, key) +func (s stubRegistryStore) ResolveBridgeRoute(ctx context.Context, key bridgepkg.RoutingKey) (bridgepkg.BridgeRoute, error) { + if s.resolveBridgeRouteFn != nil { + return s.resolveBridgeRouteFn(ctx, key) } - return channelspkg.ChannelRoute{}, channelspkg.ErrChannelRouteNotFound + return bridgepkg.BridgeRoute{}, bridgepkg.ErrBridgeRouteNotFound } -func (s stubRegistryStore) ListChannelRoutes(ctx context.Context, channelInstanceID string) ([]channelspkg.ChannelRoute, error) { - if s.listChannelRoutesFn != nil { - return s.listChannelRoutesFn(ctx, channelInstanceID) +func (s stubRegistryStore) ListBridgeRoutes(ctx context.Context, bridgeInstanceID string) ([]bridgepkg.BridgeRoute, error) { + if s.listBridgeRoutesFn != nil { + return s.listBridgeRoutesFn(ctx, bridgeInstanceID) } return nil, nil } @@ -78,18 +78,18 @@ func (s stubRegistryStore) ListChannelRoutes(ctx context.Context, channelInstanc func TestBuildRoutingKeyAppliesPeerOnlyPolicy(t *testing.T) { t.Parallel() - instance := channelspkg.ChannelInstance{ - ID: "chan-peer-only", - Scope: channelspkg.ScopeGlobal, + instance := bridgepkg.BridgeInstance{ + ID: "brg-peer-only", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Peer Only", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, } - key, err := channelspkg.BuildRoutingKey(instance, channelspkg.RoutingDimensions{ + key, err := bridgepkg.BuildRoutingKey(instance, bridgepkg.RoutingDimensions{ PeerID: "peer-1", ThreadID: "thread-1", GroupID: "group-1", @@ -111,22 +111,22 @@ func TestBuildRoutingKeyAppliesPeerOnlyPolicy(t *testing.T) { func TestBuildRoutingKeySeparatesThreadsWhenPolicyIncludesThread(t *testing.T) { t.Parallel() - instance := channelspkg.ChannelInstance{ - ID: "chan-peer-thread", - Scope: channelspkg.ScopeGlobal, + instance := bridgepkg.BridgeInstance{ + ID: "brg-peer-thread", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Peer Thread", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, } - first, err := channelspkg.BuildRoutingKey(instance, channelspkg.RoutingDimensions{PeerID: "peer-1", ThreadID: "thread-a"}) + first, err := bridgepkg.BuildRoutingKey(instance, bridgepkg.RoutingDimensions{PeerID: "peer-1", ThreadID: "thread-a"}) if err != nil { t.Fatalf("BuildRoutingKey(first) error = %v", err) } - second, err := channelspkg.BuildRoutingKey(instance, channelspkg.RoutingDimensions{PeerID: "peer-1", ThreadID: "thread-b"}) + second, err := bridgepkg.BuildRoutingKey(instance, bridgepkg.RoutingDimensions{PeerID: "peer-1", ThreadID: "thread-b"}) if err != nil { t.Fatalf("BuildRoutingKey(second) error = %v", err) } @@ -150,37 +150,37 @@ func TestBuildRoutingKeySeparatesThreadsWhenPolicyIncludesThread(t *testing.T) { func TestValidateInstanceStateTransitionRejectsReadyFromDisabledWithoutEnablePath(t *testing.T) { t.Parallel() - current := channelspkg.ChannelInstance{ - ID: "chan-disabled", - Scope: channelspkg.ScopeGlobal, + current := bridgepkg.BridgeInstance{ + ID: "brg-disabled", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Disabled", Enabled: false, - Status: channelspkg.ChannelStatusDisabled, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusDisabled, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, } - err := channelspkg.ValidateInstanceStateTransition(current, true, channelspkg.ChannelStatusReady) - if !errors.Is(err, channelspkg.ErrInvalidChannelStateTransition) { - t.Fatalf("ValidateInstanceStateTransition() error = %v, want ErrInvalidChannelStateTransition", err) + err := bridgepkg.ValidateInstanceStateTransition(current, true, bridgepkg.BridgeStatusReady) + if !errors.Is(err, bridgepkg.ErrInvalidBridgeStateTransition) { + t.Fatalf("ValidateInstanceStateTransition() error = %v, want ErrInvalidBridgeStateTransition", err) } } func TestPlatformDimensionMappingValidate(t *testing.T) { t.Parallel() - mapping := channelspkg.PlatformDimensionMapping{ + mapping := bridgepkg.PlatformDimensionMapping{ Platform: "telegram", PeerIDConcept: "chat or user id", ThreadIDConcept: "forum topic id", - GroupIDConcept: "group or channel id", + GroupIDConcept: "group or bridge id", } if err := mapping.Validate(); err != nil { t.Fatalf("PlatformDimensionMapping.Validate(valid) error = %v", err) } - if err := (channelspkg.PlatformDimensionMapping{Platform: "telegram"}).Validate(); err == nil { + if err := (bridgepkg.PlatformDimensionMapping{Platform: "telegram"}).Validate(); err == nil { t.Fatal("PlatformDimensionMapping.Validate(no concepts) error = nil, want non-nil") } } @@ -190,26 +190,26 @@ func TestBuildRoutingKeyRequiresConfiguredDimensions(t *testing.T) { tests := []struct { name string - policy channelspkg.RoutingPolicy - dims channelspkg.RoutingDimensions + policy bridgepkg.RoutingPolicy + dims bridgepkg.RoutingDimensions wantText string }{ { name: "peer required", - policy: channelspkg.RoutingPolicy{IncludePeer: true}, - dims: channelspkg.RoutingDimensions{}, + policy: bridgepkg.RoutingPolicy{IncludePeer: true}, + dims: bridgepkg.RoutingDimensions{}, wantText: "peer id", }, { name: "thread required", - policy: channelspkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, - dims: channelspkg.RoutingDimensions{PeerID: "peer-1"}, + policy: bridgepkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, + dims: bridgepkg.RoutingDimensions{PeerID: "peer-1"}, wantText: "thread id", }, { name: "group required", - policy: channelspkg.RoutingPolicy{IncludeGroup: true}, - dims: channelspkg.RoutingDimensions{}, + policy: bridgepkg.RoutingPolicy{IncludeGroup: true}, + dims: bridgepkg.RoutingDimensions{}, wantText: "group id", }, } @@ -219,14 +219,14 @@ func TestBuildRoutingKeyRequiresConfiguredDimensions(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - _, err := channelspkg.BuildRoutingKey(channelspkg.ChannelInstance{ - ID: "chan-required", - Scope: channelspkg.ScopeGlobal, + _, err := bridgepkg.BuildRoutingKey(bridgepkg.BridgeInstance{ + ID: "brg-required", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Required", Enabled: true, - Status: channelspkg.ChannelStatusReady, + Status: bridgepkg.BridgeStatusReady, RoutingPolicy: tt.policy, }, tt.dims) if err == nil || !strings.Contains(err.Error(), tt.wantText) { @@ -239,23 +239,23 @@ func TestBuildRoutingKeyRequiresConfiguredDimensions(t *testing.T) { func TestCanonicalizeRoutingKeyRejectsBaseMismatch(t *testing.T) { t.Parallel() - instance := channelspkg.ChannelInstance{ - ID: "chan-base", - Scope: channelspkg.ScopeWorkspace, + instance := bridgepkg.BridgeInstance{ + ID: "brg-base", + Scope: bridgepkg.ScopeWorkspace, WorkspaceID: "ws-1", Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Base", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, } - _, err := channelspkg.CanonicalizeRoutingKey(instance, channelspkg.RoutingKey{ - Scope: channelspkg.ScopeGlobal, - WorkspaceID: "ws-2", - ChannelInstanceID: instance.ID, - PeerID: "peer-1", + _, err := bridgepkg.CanonicalizeRoutingKey(instance, bridgepkg.RoutingKey{ + Scope: bridgepkg.ScopeGlobal, + WorkspaceID: "ws-2", + BridgeInstanceID: instance.ID, + PeerID: "peer-1", }) if err == nil { t.Fatal("CanonicalizeRoutingKey() error = nil, want non-nil") @@ -267,99 +267,99 @@ func TestValidateInstanceStateTransitionAllowedPaths(t *testing.T) { tests := []struct { name string - current channelspkg.ChannelInstance + current bridgepkg.BridgeInstance nextEnabled bool - nextStatus channelspkg.ChannelStatus + nextStatus bridgepkg.BridgeStatus }{ { name: "starting to ready", - current: channelspkg.ChannelInstance{ - ID: "chan-starting", - Scope: channelspkg.ScopeGlobal, + current: bridgepkg.BridgeInstance{ + ID: "brg-starting", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Starting", Enabled: true, - Status: channelspkg.ChannelStatusStarting, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusStarting, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }, nextEnabled: true, - nextStatus: channelspkg.ChannelStatusReady, + nextStatus: bridgepkg.BridgeStatusReady, }, { name: "ready to starting", - current: channelspkg.ChannelInstance{ - ID: "chan-ready", - Scope: channelspkg.ScopeGlobal, + current: bridgepkg.BridgeInstance{ + ID: "brg-ready", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Ready", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }, nextEnabled: true, - nextStatus: channelspkg.ChannelStatusStarting, + nextStatus: bridgepkg.BridgeStatusStarting, }, { name: "degraded to ready", - current: channelspkg.ChannelInstance{ - ID: "chan-degraded", - Scope: channelspkg.ScopeGlobal, + current: bridgepkg.BridgeInstance{ + ID: "brg-degraded", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Degraded", Enabled: true, - Status: channelspkg.ChannelStatusDegraded, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusDegraded, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }, nextEnabled: true, - nextStatus: channelspkg.ChannelStatusReady, + nextStatus: bridgepkg.BridgeStatusReady, }, { name: "auth required to starting", - current: channelspkg.ChannelInstance{ - ID: "chan-auth", - Scope: channelspkg.ScopeGlobal, + current: bridgepkg.BridgeInstance{ + ID: "brg-auth", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Auth", Enabled: true, - Status: channelspkg.ChannelStatusAuthRequired, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusAuthRequired, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }, nextEnabled: true, - nextStatus: channelspkg.ChannelStatusStarting, + nextStatus: bridgepkg.BridgeStatusStarting, }, { name: "error to starting", - current: channelspkg.ChannelInstance{ - ID: "chan-error", - Scope: channelspkg.ScopeGlobal, + current: bridgepkg.BridgeInstance{ + ID: "brg-error", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Error", Enabled: true, - Status: channelspkg.ChannelStatusError, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusError, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }, nextEnabled: true, - nextStatus: channelspkg.ChannelStatusStarting, + nextStatus: bridgepkg.BridgeStatusStarting, }, { name: "ready to disabled", - current: channelspkg.ChannelInstance{ - ID: "chan-disable", - Scope: channelspkg.ScopeGlobal, + current: bridgepkg.BridgeInstance{ + ID: "brg-disable", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Disable", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }, nextEnabled: false, - nextStatus: channelspkg.ChannelStatusDisabled, + nextStatus: bridgepkg.BridgeStatusDisabled, }, } @@ -368,7 +368,7 @@ func TestValidateInstanceStateTransitionAllowedPaths(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - if err := channelspkg.ValidateInstanceStateTransition(tt.current, tt.nextEnabled, tt.nextStatus); err != nil { + if err := bridgepkg.ValidateInstanceStateTransition(tt.current, tt.nextEnabled, tt.nextStatus); err != nil { t.Fatalf("ValidateInstanceStateTransition() error = %v", err) } }) @@ -378,14 +378,14 @@ func TestValidateInstanceStateTransitionAllowedPaths(t *testing.T) { func TestCreateInstanceRequestValidate(t *testing.T) { t.Parallel() - req := channelspkg.CreateInstanceRequest{ - Scope: channelspkg.ScopeGlobal, + req := bridgepkg.CreateInstanceRequest{ + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Validate", Enabled: true, - Status: channelspkg.ChannelStatusStarting, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusStarting, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, } if err := req.Validate(); err != nil { t.Fatalf("CreateInstanceRequest.Validate() error = %v", err) @@ -397,10 +397,10 @@ func TestUpdateInstanceRequestValidate(t *testing.T) { displayName := "Updated" deliveryDefaults := json.RawMessage(`{"peer_id":"peer-default","mode":"reply"}`) - req := channelspkg.UpdateInstanceRequest{ - ID: "chan-update", + req := bridgepkg.UpdateInstanceRequest{ + ID: "brg-update", DisplayName: &displayName, - RoutingPolicy: &channelspkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, + RoutingPolicy: &bridgepkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, DeliveryDefaults: &deliveryDefaults, } if err := req.Validate(); err != nil { @@ -412,20 +412,20 @@ func TestRegistryCreateGetAndUpdateInstanceState(t *testing.T) { t.Parallel() registry, _ := newRegistryTestHarness(t) - created, err := registry.CreateInstance(testutil.Context(t), channelspkg.CreateInstanceRequest{ - ID: "chan-state", - Scope: channelspkg.ScopeGlobal, + created, err := registry.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{ + ID: "brg-state", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Lifecycle", Enabled: true, - Status: channelspkg.ChannelStatusStarting, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusStarting, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) if err != nil { t.Fatalf("CreateInstance() error = %v", err) } - if created.Status != channelspkg.ChannelStatusStarting { + if created.Status != bridgepkg.BridgeStatusStarting { t.Fatalf("CreateInstance().Status = %q, want starting", created.Status) } @@ -437,15 +437,15 @@ func TestRegistryCreateGetAndUpdateInstanceState(t *testing.T) { t.Fatalf("GetInstance().ID = %q, want %q", loaded.ID, created.ID) } - updated, err := registry.UpdateInstanceState(testutil.Context(t), channelspkg.UpdateInstanceStateRequest{ + updated, err := registry.UpdateInstanceState(testutil.Context(t), bridgepkg.UpdateInstanceStateRequest{ ID: created.ID, Enabled: true, - Status: channelspkg.ChannelStatusReady, + Status: bridgepkg.BridgeStatusReady, }) if err != nil { t.Fatalf("UpdateInstanceState() error = %v", err) } - if updated.Status != channelspkg.ChannelStatusReady { + if updated.Status != bridgepkg.BridgeStatusReady { t.Fatalf("UpdateInstanceState().Status = %q, want ready", updated.Status) } @@ -462,23 +462,23 @@ func TestRegistryUpdateInstanceMutatesDisplayNameRoutingPolicyAndDefaults(t *tes t.Parallel() registry, _ := newRegistryTestHarness(t) - instance := createTestChannelInstance(t, registry, channelspkg.CreateInstanceRequest{ - ID: "chan-update", - Scope: channelspkg.ScopeGlobal, + instance := createTestBridgeInstance(t, registry, bridgepkg.CreateInstanceRequest{ + ID: "brg-update", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Original", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) displayName := "Updated" deliveryDefaults := json.RawMessage(`{"peer_id":"peer-default","mode":"reply"}`) - updated, err := registry.UpdateInstance(testutil.Context(t), channelspkg.UpdateInstanceRequest{ + updated, err := registry.UpdateInstance(testutil.Context(t), bridgepkg.UpdateInstanceRequest{ ID: instance.ID, DisplayName: &displayName, - RoutingPolicy: &channelspkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, + RoutingPolicy: &bridgepkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, DeliveryDefaults: &deliveryDefaults, }) if err != nil { @@ -499,26 +499,26 @@ func TestRegistryCreateInstanceWrapsInsertErrors(t *testing.T) { t.Parallel() insertErr := errors.New("insert failed") - registry := channelspkg.NewRegistry(stubRegistryStore{ - insertChannelInstanceFn: func(context.Context, channelspkg.ChannelInstance) error { + registry := bridgepkg.NewRegistry(stubRegistryStore{ + insertBridgeInstanceFn: func(context.Context, bridgepkg.BridgeInstance) error { return insertErr }, }) - _, err := registry.CreateInstance(testutil.Context(t), channelspkg.CreateInstanceRequest{ - ID: "chan-wrap-create", - Scope: channelspkg.ScopeGlobal, + _, err := registry.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{ + ID: "brg-wrap-create", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Wrapped Create", Enabled: true, - Status: channelspkg.ChannelStatusStarting, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusStarting, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) if !errors.Is(err, insertErr) { t.Fatalf("CreateInstance() error = %v, want wrapped %v", err, insertErr) } - if !strings.Contains(err.Error(), "create channel instance") || !strings.Contains(err.Error(), "insert") { + if !strings.Contains(err.Error(), "create bridge instance") || !strings.Contains(err.Error(), "insert") { t.Fatalf("CreateInstance() error = %q, want contextual insert failure", err) } } @@ -526,19 +526,19 @@ func TestRegistryCreateInstanceWrapsInsertErrors(t *testing.T) { func TestRegistryListInstancesReturnsClonedDeliveryDefaults(t *testing.T) { t.Parallel() - stored := []channelspkg.ChannelInstance{{ - ID: "chan-list-clone", - Scope: channelspkg.ScopeGlobal, + stored := []bridgepkg.BridgeInstance{{ + ID: "brg-list-clone", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "List Clone", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, DeliveryDefaults: json.RawMessage(`{"mode":"reply"}`), }} - registry := channelspkg.NewRegistry(stubRegistryStore{ - listChannelInstancesFn: func(context.Context) ([]channelspkg.ChannelInstance, error) { + registry := bridgepkg.NewRegistry(stubRegistryStore{ + listBridgeInstancesFn: func(context.Context) ([]bridgepkg.BridgeInstance, error) { return stored, nil }, }) @@ -564,32 +564,32 @@ func TestRegistryListInstancesReturnsClonedDeliveryDefaults(t *testing.T) { func TestRegistryListRoutesReturnsClonedRoutes(t *testing.T) { t.Parallel() - stored := []channelspkg.ChannelRoute{{ - Scope: channelspkg.ScopeGlobal, - ChannelInstanceID: "chan-route-clone", - PeerID: "peer-1", - SessionID: "sess-1", - AgentName: "coder", + stored := []bridgepkg.BridgeRoute{{ + Scope: bridgepkg.ScopeGlobal, + BridgeInstanceID: "brg-route-clone", + PeerID: "peer-1", + SessionID: "sess-1", + AgentName: "coder", }} - registry := channelspkg.NewRegistry(stubRegistryStore{ - getChannelInstanceFn: func(context.Context, string) (channelspkg.ChannelInstance, error) { - return channelspkg.ChannelInstance{ - ID: "chan-route-clone", - Scope: channelspkg.ScopeGlobal, + registry := bridgepkg.NewRegistry(stubRegistryStore{ + getBridgeInstanceFn: func(context.Context, string) (bridgepkg.BridgeInstance, error) { + return bridgepkg.BridgeInstance{ + ID: "brg-route-clone", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Route Clone", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }, nil }, - listChannelRoutesFn: func(context.Context, string) ([]channelspkg.ChannelRoute, error) { + listBridgeRoutesFn: func(context.Context, string) ([]bridgepkg.BridgeRoute, error) { return stored, nil }, }) - first, err := registry.ListRoutes(testutil.Context(t), "chan-route-clone") + first, err := registry.ListRoutes(testutil.Context(t), "brg-route-clone") if err != nil { t.Fatalf("ListRoutes(first) error = %v", err) } @@ -598,7 +598,7 @@ func TestRegistryListRoutesReturnsClonedRoutes(t *testing.T) { t.Fatalf("stored route session id = %q, want %q", got, want) } - second, err := registry.ListRoutes(testutil.Context(t), "chan-route-clone") + second, err := registry.ListRoutes(testutil.Context(t), "brg-route-clone") if err != nil { t.Fatalf("ListRoutes(second) error = %v", err) } @@ -611,23 +611,23 @@ func TestRegistryResolveOrCreateRouteReusesStoredSession(t *testing.T) { t.Parallel() registry, _ := newRegistryTestHarness(t) - instance := createTestChannelInstance(t, registry, channelspkg.CreateInstanceRequest{ - ID: "chan-route-reuse", - Scope: channelspkg.ScopeGlobal, + instance := createTestBridgeInstance(t, registry, bridgepkg.CreateInstanceRequest{ + ID: "brg-route-reuse", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Route Reuse", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, }) - first, created, err := registry.ResolveOrCreateRoute(testutil.Context(t), channelspkg.ChannelRoute{ - ChannelInstanceID: instance.ID, - PeerID: "peer-1", - ThreadID: "thread-1", - SessionID: "sess-1", - AgentName: "coder", + first, created, err := registry.ResolveOrCreateRoute(testutil.Context(t), bridgepkg.BridgeRoute{ + BridgeInstanceID: instance.ID, + PeerID: "peer-1", + ThreadID: "thread-1", + SessionID: "sess-1", + AgentName: "coder", }) if err != nil { t.Fatalf("ResolveOrCreateRoute(first) error = %v", err) @@ -636,12 +636,12 @@ func TestRegistryResolveOrCreateRouteReusesStoredSession(t *testing.T) { t.Fatal("ResolveOrCreateRoute(first) created = false, want true") } - second, created, err := registry.ResolveOrCreateRoute(testutil.Context(t), channelspkg.ChannelRoute{ - ChannelInstanceID: instance.ID, - PeerID: "peer-1", - ThreadID: "thread-1", - SessionID: "sess-2", - AgentName: "reviewer", + second, created, err := registry.ResolveOrCreateRoute(testutil.Context(t), bridgepkg.BridgeRoute{ + BridgeInstanceID: instance.ID, + PeerID: "peer-1", + ThreadID: "thread-1", + SessionID: "sess-2", + AgentName: "reviewer", }) if err != nil { t.Fatalf("ResolveOrCreateRoute(second) error = %v", err) @@ -666,41 +666,41 @@ func TestRegistryBuildResolveAndUpsertRoute(t *testing.T) { t.Parallel() registry, db := newRegistryTestHarness(t) - workspaceID := registerWorkspaceForChannelsTests(t, db, "ws-build-route", "build-route") - instance := createTestChannelInstance(t, registry, channelspkg.CreateInstanceRequest{ - ID: "chan-build-route", - Scope: channelspkg.ScopeWorkspace, + workspaceID := registerWorkspaceForBridgesTests(t, db, "ws-build-route", "build-route") + instance := createTestBridgeInstance(t, registry, bridgepkg.CreateInstanceRequest{ + ID: "brg-build-route", + Scope: bridgepkg.ScopeWorkspace, WorkspaceID: workspaceID, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Build Route", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, }) - key, err := registry.BuildRoutingKey(testutil.Context(t), channelspkg.RoutingKey{ - ChannelInstanceID: instance.ID, - PeerID: "peer-1", - ThreadID: "thread-1", - GroupID: "ignored-group", + key, err := registry.BuildRoutingKey(testutil.Context(t), bridgepkg.RoutingKey{ + BridgeInstanceID: instance.ID, + PeerID: "peer-1", + ThreadID: "thread-1", + GroupID: "ignored-group", }) if err != nil { t.Fatalf("BuildRoutingKey() error = %v", err) } - if key.Scope != channelspkg.ScopeWorkspace || key.WorkspaceID != workspaceID { + if key.Scope != bridgepkg.ScopeWorkspace || key.WorkspaceID != workspaceID { t.Fatalf("BuildRoutingKey() = %#v, want workspace scope %q", key, workspaceID) } if key.GroupID != "" { t.Fatalf("BuildRoutingKey().GroupID = %q, want empty", key.GroupID) } - route, err := registry.UpsertRoute(testutil.Context(t), channelspkg.ChannelRoute{ - ChannelInstanceID: instance.ID, - PeerID: "peer-1", - ThreadID: "thread-1", - SessionID: "sess-1", - AgentName: "coder", + route, err := registry.UpsertRoute(testutil.Context(t), bridgepkg.BridgeRoute{ + BridgeInstanceID: instance.ID, + PeerID: "peer-1", + ThreadID: "thread-1", + SessionID: "sess-1", + AgentName: "coder", }) if err != nil { t.Fatalf("UpsertRoute(first) error = %v", err) @@ -709,11 +709,11 @@ func TestRegistryBuildResolveAndUpsertRoute(t *testing.T) { t.Fatalf("UpsertRoute(first).SessionID = %q, want sess-1", route.SessionID) } - resolved, err := registry.ResolveRoute(testutil.Context(t), channelspkg.RoutingKey{ - ChannelInstanceID: instance.ID, - PeerID: "peer-1", - ThreadID: "thread-1", - GroupID: "ignored-group", + resolved, err := registry.ResolveRoute(testutil.Context(t), bridgepkg.RoutingKey{ + BridgeInstanceID: instance.ID, + PeerID: "peer-1", + ThreadID: "thread-1", + GroupID: "ignored-group", }) if err != nil { t.Fatalf("ResolveRoute() error = %v", err) @@ -722,12 +722,12 @@ func TestRegistryBuildResolveAndUpsertRoute(t *testing.T) { t.Fatalf("ResolveRoute().RoutingKeyHash = %q, want %q", resolved.RoutingKeyHash, route.RoutingKeyHash) } - rebound, err := registry.UpsertRoute(testutil.Context(t), channelspkg.ChannelRoute{ - ChannelInstanceID: instance.ID, - PeerID: "peer-1", - ThreadID: "thread-1", - SessionID: "sess-2", - AgentName: "reviewer", + rebound, err := registry.UpsertRoute(testutil.Context(t), bridgepkg.BridgeRoute{ + BridgeInstanceID: instance.ID, + PeerID: "peer-1", + ThreadID: "thread-1", + SessionID: "sess-2", + AgentName: "reviewer", }) if err != nil { t.Fatalf("UpsertRoute(second) error = %v", err) @@ -741,46 +741,46 @@ func TestRegistryResolveRouteRejectsDisabledInstance(t *testing.T) { t.Parallel() registry, _ := newRegistryTestHarness(t) - instance := createTestChannelInstance(t, registry, channelspkg.CreateInstanceRequest{ - ID: "chan-disabled-route", - Scope: channelspkg.ScopeGlobal, + instance := createTestBridgeInstance(t, registry, bridgepkg.CreateInstanceRequest{ + ID: "brg-disabled-route", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Disabled Route", Enabled: false, - Status: channelspkg.ChannelStatusDisabled, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusDisabled, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) - _, err := registry.ResolveRoute(testutil.Context(t), channelspkg.RoutingKey{ - ChannelInstanceID: instance.ID, - PeerID: "peer-1", + _, err := registry.ResolveRoute(testutil.Context(t), bridgepkg.RoutingKey{ + BridgeInstanceID: instance.ID, + PeerID: "peer-1", }) - if !errors.Is(err, channelspkg.ErrChannelInstanceUnavailable) { - t.Fatalf("ResolveRoute(disabled) error = %v, want ErrChannelInstanceUnavailable", err) + if !errors.Is(err, bridgepkg.ErrBridgeInstanceUnavailable) { + t.Fatalf("ResolveRoute(disabled) error = %v, want ErrBridgeInstanceUnavailable", err) } } func TestRegistryGuardClauses(t *testing.T) { t.Parallel() - var nilRegistry *channelspkg.Service + var nilRegistry *bridgepkg.Service if _, err := nilRegistry.ListInstances(testutil.Context(t)); err == nil { t.Fatal("nilRegistry.ListInstances() error = nil, want non-nil") } - missingStore := channelspkg.NewRegistry(nil) + missingStore := bridgepkg.NewRegistry(nil) if _, err := missingStore.ListInstances(testutil.Context(t)); err == nil { t.Fatal("NewRegistry(nil).ListInstances() error = nil, want non-nil") } registry, _ := newRegistryTestHarness(t) - if _, err := registry.ListInstances(nilContextForChannelsTests()); err == nil { + if _, err := registry.ListInstances(nilContextForBridgesTests()); err == nil { t.Fatal("ListInstances(nil ctx) error = nil, want non-nil") } } -func newRegistryTestHarness(t *testing.T) (*channelspkg.Service, *globaldb.GlobalDB) { +func newRegistryTestHarness(t *testing.T) (*bridgepkg.Service, *globaldb.GlobalDB) { t.Helper() dbPath := filepath.Join(t.TempDir(), store.GlobalDatabaseName) @@ -795,11 +795,11 @@ func newRegistryTestHarness(t *testing.T) (*channelspkg.Service, *globaldb.Globa }) now := time.Date(2026, 4, 10, 16, 0, 0, 0, time.UTC) - registry := channelspkg.NewRegistry(db, channelspkg.WithNow(func() time.Time { return now })) + registry := bridgepkg.NewRegistry(db, bridgepkg.WithNow(func() time.Time { return now })) return registry, db } -func createTestChannelInstance(t *testing.T, registry *channelspkg.Service, req channelspkg.CreateInstanceRequest) *channelspkg.ChannelInstance { +func createTestBridgeInstance(t *testing.T, registry *bridgepkg.Service, req bridgepkg.CreateInstanceRequest) *bridgepkg.BridgeInstance { t.Helper() instance, err := registry.CreateInstance(testutil.Context(t), req) @@ -809,7 +809,7 @@ func createTestChannelInstance(t *testing.T, registry *channelspkg.Service, req return instance } -func registerWorkspaceForChannelsTests(t *testing.T, db *globaldb.GlobalDB, id string, name string) string { +func registerWorkspaceForBridgesTests(t *testing.T, db *globaldb.GlobalDB, id string, name string) string { t.Helper() workspace := aghworkspace.Workspace{ @@ -825,6 +825,6 @@ func registerWorkspaceForChannelsTests(t *testing.T, db *globaldb.GlobalDB, id s return workspace.ID } -func nilContextForChannelsTests() context.Context { +func nilContextForBridgesTests() context.Context { return nil } diff --git a/internal/channels/routing.go b/internal/bridges/routing.go similarity index 57% rename from internal/channels/routing.go rename to internal/bridges/routing.go index 07b9edc32..6d1e753a9 100644 --- a/internal/channels/routing.go +++ b/internal/bridges/routing.go @@ -1,4 +1,4 @@ -package channels +package bridges import ( "crypto/sha256" @@ -10,19 +10,19 @@ import ( "time" ) -// RoutingKey is the canonical identity used to resolve channel traffic to one ACP session. +// RoutingKey is the canonical identity used to resolve bridge traffic to one ACP session. type RoutingKey struct { - Scope Scope `json:"scope"` - WorkspaceID string `json:"workspace_id,omitempty"` - ChannelInstanceID string `json:"channel_instance_id"` - PeerID string `json:"peer_id,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - GroupID string `json:"group_id,omitempty"` + Scope Scope `json:"scope"` + WorkspaceID string `json:"workspace_id,omitempty"` + BridgeInstanceID string `json:"bridge_instance_id"` + PeerID string `json:"peer_id,omitempty"` + ThreadID string `json:"thread_id,omitempty"` + GroupID string `json:"group_id,omitempty"` } // BuildRoutingKey constructs the canonical routing key for one instance using // the instance's fixed base identity and policy-selected routing dimensions. -func BuildRoutingKey(instance ChannelInstance, dims RoutingDimensions) (RoutingKey, error) { +func BuildRoutingKey(instance BridgeInstance, dims RoutingDimensions) (RoutingKey, error) { normalizedInstance := instance.normalize() if err := normalizedInstance.Validate(); err != nil { return RoutingKey{}, err @@ -34,9 +34,9 @@ func BuildRoutingKey(instance ChannelInstance, dims RoutingDimensions) (RoutingK } key := RoutingKey{ - Scope: normalizedInstance.Scope, - WorkspaceID: normalizedInstance.WorkspaceID, - ChannelInstanceID: normalizedInstance.ID, + Scope: normalizedInstance.Scope, + WorkspaceID: normalizedInstance.WorkspaceID, + BridgeInstanceID: normalizedInstance.ID, } if normalizedInstance.RoutingPolicy.IncludePeer { key.PeerID = normalizedDims.PeerID @@ -53,7 +53,7 @@ func BuildRoutingKey(instance ChannelInstance, dims RoutingDimensions) (RoutingK // CanonicalizeRoutingKey rebuilds the supplied routing key under the instance's // routing policy and validates that any supplied base identity matches. -func CanonicalizeRoutingKey(instance ChannelInstance, key RoutingKey) (RoutingKey, error) { +func CanonicalizeRoutingKey(instance BridgeInstance, key RoutingKey) (RoutingKey, error) { if err := validateRoutingKeyBase(instance, key); err != nil { return RoutingKey{}, err } @@ -66,7 +66,7 @@ func (k RoutingKey) Validate() error { if err := ValidateScopeWorkspaceID(normalized.Scope, normalized.WorkspaceID); err != nil { return err } - return requireField(normalized.ChannelInstanceID, "routing key channel instance id") + return requireField(normalized.BridgeInstanceID, "routing key bridge instance id") } // Serialize returns the stable serialized representation used for routing-key hashing. @@ -77,19 +77,19 @@ func (k RoutingKey) Serialize() (string, error) { } payload, err := json.Marshal(struct { - Scope Scope `json:"scope"` - WorkspaceID string `json:"workspace_id"` - ChannelInstanceID string `json:"channel_instance_id"` - PeerID string `json:"peer_id"` - ThreadID string `json:"thread_id"` - GroupID string `json:"group_id"` + Scope Scope `json:"scope"` + WorkspaceID string `json:"workspace_id"` + BridgeInstanceID string `json:"bridge_instance_id"` + PeerID string `json:"peer_id"` + ThreadID string `json:"thread_id"` + GroupID string `json:"group_id"` }{ - Scope: normalized.Scope.Normalize(), - WorkspaceID: normalized.WorkspaceID, - ChannelInstanceID: normalized.ChannelInstanceID, - PeerID: normalized.PeerID, - ThreadID: normalized.ThreadID, - GroupID: normalized.GroupID, + Scope: normalized.Scope.Normalize(), + WorkspaceID: normalized.WorkspaceID, + BridgeInstanceID: normalized.BridgeInstanceID, + PeerID: normalized.PeerID, + ThreadID: normalized.ThreadID, + GroupID: normalized.GroupID, }) if err != nil { return "", err @@ -108,45 +108,45 @@ func (k RoutingKey) Hash() (string, error) { return hex.EncodeToString(sum[:]), nil } -// ChannelRoute persists the canonical routing-key to ACP-session mapping. -type ChannelRoute struct { - RoutingKeyHash string `json:"routing_key_hash"` - Scope Scope `json:"scope"` - WorkspaceID string `json:"workspace_id,omitempty"` - ChannelInstanceID string `json:"channel_instance_id"` - PeerID string `json:"peer_id,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - GroupID string `json:"group_id,omitempty"` - SessionID string `json:"session_id"` - AgentName string `json:"agent_name"` - LastActivityAt time.Time `json:"last_activity_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +// BridgeRoute persists the canonical routing-key to ACP-session mapping. +type BridgeRoute struct { + RoutingKeyHash string `json:"routing_key_hash"` + Scope Scope `json:"scope"` + WorkspaceID string `json:"workspace_id,omitempty"` + BridgeInstanceID string `json:"bridge_instance_id"` + PeerID string `json:"peer_id,omitempty"` + ThreadID string `json:"thread_id,omitempty"` + GroupID string `json:"group_id,omitempty"` + SessionID string `json:"session_id"` + AgentName string `json:"agent_name"` + LastActivityAt time.Time `json:"last_activity_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // RoutingKey returns the canonical routing key represented by the route. -func (r ChannelRoute) RoutingKey() RoutingKey { +func (r BridgeRoute) RoutingKey() RoutingKey { normalized := r.normalize() return RoutingKey{ - Scope: normalized.Scope, - WorkspaceID: normalized.WorkspaceID, - ChannelInstanceID: normalized.ChannelInstanceID, - PeerID: normalized.PeerID, - ThreadID: normalized.ThreadID, - GroupID: normalized.GroupID, + Scope: normalized.Scope, + WorkspaceID: normalized.WorkspaceID, + BridgeInstanceID: normalized.BridgeInstanceID, + PeerID: normalized.PeerID, + ThreadID: normalized.ThreadID, + GroupID: normalized.GroupID, } } // Validate reports whether the persisted route is complete and internally consistent. -func (r ChannelRoute) Validate() error { +func (r BridgeRoute) Validate() error { normalized := r.normalize() if err := normalized.RoutingKey().Validate(); err != nil { return err } - if err := requireField(normalized.SessionID, "channel route session id"); err != nil { + if err := requireField(normalized.SessionID, "bridge route session id"); err != nil { return err } - if err := requireField(normalized.AgentName, "channel route agent name"); err != nil { + if err := requireField(normalized.AgentName, "bridge route agent name"); err != nil { return err } if strings.TrimSpace(normalized.RoutingKeyHash) != "" { @@ -155,22 +155,22 @@ func (r ChannelRoute) Validate() error { return err } if normalized.RoutingKeyHash != hash { - return errors.New("channels: routing key hash does not match route identity") + return errors.New("bridges: routing key hash does not match route identity") } } return nil } // Canonicalize normalizes the route and fills the routing-key hash when missing. -func (r ChannelRoute) Canonicalize() (ChannelRoute, error) { +func (r BridgeRoute) Canonicalize() (BridgeRoute, error) { normalized := r.normalize() if err := normalized.Validate(); err != nil { - return ChannelRoute{}, err + return BridgeRoute{}, err } if normalized.RoutingKeyHash == "" { hash, err := normalized.RoutingKey().Hash() if err != nil { - return ChannelRoute{}, err + return BridgeRoute{}, err } normalized.RoutingKeyHash = hash } @@ -179,32 +179,32 @@ func (r ChannelRoute) Canonicalize() (ChannelRoute, error) { // CanonicalizeRoute rebuilds the supplied route identity under the instance's // routing policy and computes the expected routing-key hash. -func CanonicalizeRoute(instance ChannelInstance, route ChannelRoute) (ChannelRoute, error) { +func CanonicalizeRoute(instance BridgeInstance, route BridgeRoute) (BridgeRoute, error) { normalizedRoute := route.normalize() - if err := requireField(normalizedRoute.ChannelInstanceID, "channel route channel instance id"); err != nil { - return ChannelRoute{}, err + if err := requireField(normalizedRoute.BridgeInstanceID, "bridge route bridge instance id"); err != nil { + return BridgeRoute{}, err } key, err := CanonicalizeRoutingKey(instance, normalizedRoute.RoutingKey()) if err != nil { - return ChannelRoute{}, err + return BridgeRoute{}, err } canonical := normalizedRoute canonical.Scope = key.Scope canonical.WorkspaceID = key.WorkspaceID - canonical.ChannelInstanceID = key.ChannelInstanceID + canonical.BridgeInstanceID = key.BridgeInstanceID canonical.PeerID = key.PeerID canonical.ThreadID = key.ThreadID canonical.GroupID = key.GroupID expectedHash, err := canonical.RoutingKey().Hash() if err != nil { - return ChannelRoute{}, err + return BridgeRoute{}, err } if canonical.RoutingKeyHash != "" && canonical.RoutingKeyHash != expectedHash { - return ChannelRoute{}, fmt.Errorf( - "channels: routing key hash %q does not match canonical hash %q", + return BridgeRoute{}, fmt.Errorf( + "bridges: routing key hash %q does not match canonical hash %q", canonical.RoutingKeyHash, expectedHash, ) @@ -212,7 +212,7 @@ func CanonicalizeRoute(instance ChannelInstance, route ChannelRoute) (ChannelRou canonical.RoutingKeyHash = expectedHash if err := canonical.Validate(); err != nil { - return ChannelRoute{}, err + return BridgeRoute{}, err } return canonical, nil @@ -222,19 +222,19 @@ func (k RoutingKey) normalize() RoutingKey { normalized := k normalized.Scope = normalized.Scope.Normalize() normalized.WorkspaceID = strings.TrimSpace(normalized.WorkspaceID) - normalized.ChannelInstanceID = strings.TrimSpace(normalized.ChannelInstanceID) + normalized.BridgeInstanceID = strings.TrimSpace(normalized.BridgeInstanceID) normalized.PeerID = strings.TrimSpace(normalized.PeerID) normalized.ThreadID = strings.TrimSpace(normalized.ThreadID) normalized.GroupID = strings.TrimSpace(normalized.GroupID) return normalized } -func (r ChannelRoute) normalize() ChannelRoute { +func (r BridgeRoute) normalize() BridgeRoute { normalized := r normalized.RoutingKeyHash = strings.TrimSpace(normalized.RoutingKeyHash) normalized.Scope = normalized.Scope.Normalize() normalized.WorkspaceID = strings.TrimSpace(normalized.WorkspaceID) - normalized.ChannelInstanceID = strings.TrimSpace(normalized.ChannelInstanceID) + normalized.BridgeInstanceID = strings.TrimSpace(normalized.BridgeInstanceID) normalized.PeerID = strings.TrimSpace(normalized.PeerID) normalized.ThreadID = strings.TrimSpace(normalized.ThreadID) normalized.GroupID = strings.TrimSpace(normalized.GroupID) diff --git a/internal/channels/target.go b/internal/bridges/target.go similarity index 71% rename from internal/channels/target.go rename to internal/bridges/target.go index 566b4584a..3fc2da941 100644 --- a/internal/channels/target.go +++ b/internal/bridges/target.go @@ -1,4 +1,4 @@ -package channels +package bridges import ( "context" @@ -9,7 +9,7 @@ import ( ) // DeliveryMode identifies the daemon-owned outbound delivery behavior requested -// for one canonical channel target. +// for one canonical bridge target. type DeliveryMode string const ( @@ -37,28 +37,28 @@ func (m DeliveryMode) Validate() error { case DeliveryModeDirectSend, DeliveryModeReply: return nil case "": - return errors.New("channels: delivery target mode is required") + return errors.New("bridges: delivery target mode is required") default: - return fmt.Errorf("channels: unsupported delivery target mode %q", strings.TrimSpace(string(m))) + return fmt.Errorf("bridges: unsupported delivery target mode %q", strings.TrimSpace(string(m))) } } // ResolveDeliveryTargetRequest captures one outbound target request before -// channel-instance defaults have been merged in. +// bridge-instance defaults have been merged in. type ResolveDeliveryTargetRequest struct { - ChannelInstanceID string `json:"channel_instance_id"` - PeerID string `json:"peer_id,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - GroupID string `json:"group_id,omitempty"` - Mode DeliveryMode `json:"mode,omitempty"` + BridgeInstanceID string `json:"bridge_instance_id"` + PeerID string `json:"peer_id,omitempty"` + ThreadID string `json:"thread_id,omitempty"` + GroupID string `json:"group_id,omitempty"` + Mode DeliveryMode `json:"mode,omitempty"` } -// Validate reports whether the request identifies the owning channel instance. +// Validate reports whether the request identifies the owning bridge instance. func (r ResolveDeliveryTargetRequest) Validate() error { - return requireField(strings.TrimSpace(r.ChannelInstanceID), "delivery target request channel instance id") + return requireField(strings.TrimSpace(r.BridgeInstanceID), "delivery target request bridge instance id") } -// TargetResolver resolves one canonical outbound delivery target from channel +// TargetResolver resolves one canonical outbound delivery target from bridge // instance metadata plus explicit destination overrides. type TargetResolver interface { ResolveDeliveryTarget(ctx context.Context, req ResolveDeliveryTargetRequest) (*DeliveryTarget, error) @@ -73,9 +73,9 @@ type deliveryTargetDefaults struct { Mode DeliveryMode `json:"mode,omitempty"` } -// BuildDeliveryTarget merges channel-instance delivery defaults with explicit +// BuildDeliveryTarget merges bridge-instance delivery defaults with explicit // request overrides and returns one canonical outbound target. -func BuildDeliveryTarget(instance ChannelInstance, req ResolveDeliveryTargetRequest) (DeliveryTarget, error) { +func BuildDeliveryTarget(instance BridgeInstance, req ResolveDeliveryTargetRequest) (DeliveryTarget, error) { normalizedInstance := instance.normalize() if err := normalizedInstance.Validate(); err != nil { return DeliveryTarget{}, err @@ -85,10 +85,10 @@ func BuildDeliveryTarget(instance ChannelInstance, req ResolveDeliveryTargetRequ if err := normalizedReq.Validate(); err != nil { return DeliveryTarget{}, err } - if normalizedReq.ChannelInstanceID != normalizedInstance.ID { + if normalizedReq.BridgeInstanceID != normalizedInstance.ID { return DeliveryTarget{}, fmt.Errorf( - "channels: delivery target request channel instance id %q does not match instance %q", - normalizedReq.ChannelInstanceID, + "bridges: delivery target request bridge instance id %q does not match instance %q", + normalizedReq.BridgeInstanceID, normalizedInstance.ID, ) } @@ -99,11 +99,11 @@ func BuildDeliveryTarget(instance ChannelInstance, req ResolveDeliveryTargetRequ } target := DeliveryTarget{ - ChannelInstanceID: normalizedInstance.ID, - PeerID: firstNonEmpty(normalizedReq.PeerID, defaults.PeerID), - ThreadID: firstNonEmpty(normalizedReq.ThreadID, defaults.ThreadID), - GroupID: firstNonEmpty(normalizedReq.GroupID, defaults.GroupID), - Mode: normalizedReq.Mode, + BridgeInstanceID: normalizedInstance.ID, + PeerID: firstNonEmpty(normalizedReq.PeerID, defaults.PeerID), + ThreadID: firstNonEmpty(normalizedReq.ThreadID, defaults.ThreadID), + GroupID: firstNonEmpty(normalizedReq.GroupID, defaults.GroupID), + Mode: normalizedReq.Mode, } if target.Mode == "" { target.Mode = defaults.Mode @@ -119,7 +119,7 @@ func BuildDeliveryTarget(instance ChannelInstance, req ResolveDeliveryTargetRequ return canonical, nil } -// ResolveDeliveryTarget loads the owning channel instance and resolves the +// ResolveDeliveryTarget loads the owning bridge instance and resolves the // canonical outbound target under that instance's delivery defaults. func (s *Service) ResolveDeliveryTarget(ctx context.Context, req ResolveDeliveryTargetRequest) (*DeliveryTarget, error) { if err := s.checkReady(ctx, "resolve delivery target"); err != nil { @@ -131,7 +131,7 @@ func (s *Service) ResolveDeliveryTarget(ctx context.Context, req ResolveDelivery return nil, err } - instance, err := s.loadRoutableInstance(ctx, normalizedReq.ChannelInstanceID) + instance, err := s.loadRoutableInstance(ctx, normalizedReq.BridgeInstanceID) if err != nil { return nil, err } @@ -145,7 +145,7 @@ func (s *Service) ResolveDeliveryTarget(ctx context.Context, req ResolveDelivery func (r ResolveDeliveryTargetRequest) normalize() ResolveDeliveryTargetRequest { normalized := r - normalized.ChannelInstanceID = strings.TrimSpace(normalized.ChannelInstanceID) + normalized.BridgeInstanceID = strings.TrimSpace(normalized.BridgeInstanceID) normalized.PeerID = strings.TrimSpace(normalized.PeerID) normalized.ThreadID = strings.TrimSpace(normalized.ThreadID) normalized.GroupID = strings.TrimSpace(normalized.GroupID) @@ -163,7 +163,7 @@ func (d deliveryTargetDefaults) normalize() deliveryTargetDefaults { } func decodeDeliveryTargetDefaults(raw json.RawMessage) (deliveryTargetDefaults, error) { - normalized, err := normalizeRawJSON(raw, "channel instance delivery defaults") + normalized, err := normalizeRawJSON(raw, "bridge instance delivery defaults") if err != nil { return deliveryTargetDefaults{}, err } @@ -173,7 +173,7 @@ func decodeDeliveryTargetDefaults(raw json.RawMessage) (deliveryTargetDefaults, var defaults deliveryTargetDefaults if err := json.Unmarshal(normalized, &defaults); err != nil { - return deliveryTargetDefaults{}, fmt.Errorf("channels: decode channel instance delivery defaults: %w", err) + return deliveryTargetDefaults{}, fmt.Errorf("bridges: decode bridge instance delivery defaults: %w", err) } defaults = defaults.normalize() diff --git a/internal/channels/target_integration_test.go b/internal/bridges/target_integration_test.go similarity index 54% rename from internal/channels/target_integration_test.go rename to internal/bridges/target_integration_test.go index 924ce67a4..ffabe23df 100644 --- a/internal/channels/target_integration_test.go +++ b/internal/bridges/target_integration_test.go @@ -1,11 +1,11 @@ //go:build integration -package channels_test +package bridges_test import ( "testing" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" "github.com/pedronauck/agh/internal/testutil" ) @@ -13,26 +13,26 @@ func TestRegistryResolveDeliveryTargetUsesInstanceDefaults(t *testing.T) { t.Parallel() registry, _ := newRegistryTestHarness(t) - instance := createTestChannelInstance(t, registry, channelspkg.CreateInstanceRequest{ - ID: "chan-target-defaults", - Scope: channelspkg.ScopeGlobal, + instance := createTestBridgeInstance(t, registry, bridgepkg.CreateInstanceRequest{ + ID: "brg-target-defaults", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Target Defaults", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, DeliveryDefaults: []byte(`{"peer_id":"peer-default","thread_id":"thread-default","mode":"reply","parse_mode":"markdown"}`), }) - target, err := registry.ResolveDeliveryTarget(testutil.Context(t), channelspkg.ResolveDeliveryTargetRequest{ - ChannelInstanceID: instance.ID, + target, err := registry.ResolveDeliveryTarget(testutil.Context(t), bridgepkg.ResolveDeliveryTargetRequest{ + BridgeInstanceID: instance.ID, }) if err != nil { t.Fatalf("ResolveDeliveryTarget() error = %v", err) } - if target.ChannelInstanceID != instance.ID { - t.Fatalf("ResolveDeliveryTarget().ChannelInstanceID = %q, want %q", target.ChannelInstanceID, instance.ID) + if target.BridgeInstanceID != instance.ID { + t.Fatalf("ResolveDeliveryTarget().BridgeInstanceID = %q, want %q", target.BridgeInstanceID, instance.ID) } if target.PeerID != "peer-default" { t.Fatalf("ResolveDeliveryTarget().PeerID = %q, want peer-default", target.PeerID) @@ -40,8 +40,8 @@ func TestRegistryResolveDeliveryTargetUsesInstanceDefaults(t *testing.T) { if target.ThreadID != "thread-default" { t.Fatalf("ResolveDeliveryTarget().ThreadID = %q, want thread-default", target.ThreadID) } - if target.Mode != channelspkg.DeliveryModeReply { - t.Fatalf("ResolveDeliveryTarget().Mode = %q, want %q", target.Mode, channelspkg.DeliveryModeReply) + if target.Mode != bridgepkg.DeliveryModeReply { + t.Fatalf("ResolveDeliveryTarget().Mode = %q, want %q", target.Mode, bridgepkg.DeliveryModeReply) } } @@ -49,54 +49,54 @@ func TestRegistryResolveDeliveryTargetKeepsWorkspaceScopeIsolated(t *testing.T) t.Parallel() registry, db := newRegistryTestHarness(t) - workspaceID := registerWorkspaceForChannelsTests(t, db, "ws-target-scope", "target-scope") + workspaceID := registerWorkspaceForBridgesTests(t, db, "ws-target-scope", "target-scope") - globalInstance := createTestChannelInstance(t, registry, channelspkg.CreateInstanceRequest{ - ID: "chan-target-global", - Scope: channelspkg.ScopeGlobal, + globalInstance := createTestBridgeInstance(t, registry, bridgepkg.CreateInstanceRequest{ + ID: "brg-target-global", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Global Targets", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludeGroup: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludeGroup: true}, DeliveryDefaults: []byte(`{"group_id":"global-group","mode":"direct-send"}`), }) - workspaceInstance := createTestChannelInstance(t, registry, channelspkg.CreateInstanceRequest{ - ID: "chan-target-workspace", - Scope: channelspkg.ScopeWorkspace, + workspaceInstance := createTestBridgeInstance(t, registry, bridgepkg.CreateInstanceRequest{ + ID: "brg-target-workspace", + Scope: bridgepkg.ScopeWorkspace, WorkspaceID: workspaceID, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Workspace Targets", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, DeliveryDefaults: []byte(`{"peer_id":"workspace-peer","thread_id":"workspace-thread","mode":"reply"}`), }) - globalTarget, err := registry.ResolveDeliveryTarget(testutil.Context(t), channelspkg.ResolveDeliveryTargetRequest{ - ChannelInstanceID: globalInstance.ID, + globalTarget, err := registry.ResolveDeliveryTarget(testutil.Context(t), bridgepkg.ResolveDeliveryTargetRequest{ + BridgeInstanceID: globalInstance.ID, }) if err != nil { t.Fatalf("ResolveDeliveryTarget(global) error = %v", err) } - workspaceTarget, err := registry.ResolveDeliveryTarget(testutil.Context(t), channelspkg.ResolveDeliveryTargetRequest{ - ChannelInstanceID: workspaceInstance.ID, + workspaceTarget, err := registry.ResolveDeliveryTarget(testutil.Context(t), bridgepkg.ResolveDeliveryTargetRequest{ + BridgeInstanceID: workspaceInstance.ID, }) if err != nil { t.Fatalf("ResolveDeliveryTarget(workspace) error = %v", err) } - if globalTarget.ChannelInstanceID != globalInstance.ID { - t.Fatalf("globalTarget.ChannelInstanceID = %q, want %q", globalTarget.ChannelInstanceID, globalInstance.ID) + if globalTarget.BridgeInstanceID != globalInstance.ID { + t.Fatalf("globalTarget.BridgeInstanceID = %q, want %q", globalTarget.BridgeInstanceID, globalInstance.ID) } if globalTarget.GroupID != "global-group" { t.Fatalf("globalTarget.GroupID = %q, want global-group", globalTarget.GroupID) } - if workspaceTarget.ChannelInstanceID != workspaceInstance.ID { - t.Fatalf("workspaceTarget.ChannelInstanceID = %q, want %q", workspaceTarget.ChannelInstanceID, workspaceInstance.ID) + if workspaceTarget.BridgeInstanceID != workspaceInstance.ID { + t.Fatalf("workspaceTarget.BridgeInstanceID = %q, want %q", workspaceTarget.BridgeInstanceID, workspaceInstance.ID) } if workspaceTarget.PeerID != "workspace-peer" { t.Fatalf("workspaceTarget.PeerID = %q, want workspace-peer", workspaceTarget.PeerID) @@ -107,7 +107,7 @@ func TestRegistryResolveDeliveryTargetKeepsWorkspaceScopeIsolated(t *testing.T) if workspaceTarget.GroupID != "" { t.Fatalf("workspaceTarget.GroupID = %q, want empty", workspaceTarget.GroupID) } - if workspaceTarget.Mode != channelspkg.DeliveryModeReply { - t.Fatalf("workspaceTarget.Mode = %q, want %q", workspaceTarget.Mode, channelspkg.DeliveryModeReply) + if workspaceTarget.Mode != bridgepkg.DeliveryModeReply { + t.Fatalf("workspaceTarget.Mode = %q, want %q", workspaceTarget.Mode, bridgepkg.DeliveryModeReply) } } diff --git a/internal/channels/target_test.go b/internal/bridges/target_test.go similarity index 56% rename from internal/channels/target_test.go rename to internal/bridges/target_test.go index 97e04349f..027564401 100644 --- a/internal/channels/target_test.go +++ b/internal/bridges/target_test.go @@ -1,42 +1,42 @@ -package channels_test +package bridges_test import ( "strings" "testing" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" "github.com/pedronauck/agh/internal/testutil" ) func TestBuildDeliveryTargetDefaultsToDirectSend(t *testing.T) { t.Parallel() - instance := testChannelInstanceForTargets() + instance := testBridgeInstanceForTargets() - target, err := channelspkg.BuildDeliveryTarget(instance, channelspkg.ResolveDeliveryTargetRequest{ - ChannelInstanceID: instance.ID, - PeerID: "peer-1", + target, err := bridgepkg.BuildDeliveryTarget(instance, bridgepkg.ResolveDeliveryTargetRequest{ + BridgeInstanceID: instance.ID, + PeerID: "peer-1", }) if err != nil { t.Fatalf("BuildDeliveryTarget() error = %v", err) } - if target.ChannelInstanceID != instance.ID { - t.Fatalf("BuildDeliveryTarget().ChannelInstanceID = %q, want %q", target.ChannelInstanceID, instance.ID) + if target.BridgeInstanceID != instance.ID { + t.Fatalf("BuildDeliveryTarget().BridgeInstanceID = %q, want %q", target.BridgeInstanceID, instance.ID) } if target.PeerID != "peer-1" { t.Fatalf("BuildDeliveryTarget().PeerID = %q, want peer-1", target.PeerID) } - if target.Mode != channelspkg.DeliveryModeDirectSend { - t.Fatalf("BuildDeliveryTarget().Mode = %q, want %q", target.Mode, channelspkg.DeliveryModeDirectSend) + if target.Mode != bridgepkg.DeliveryModeDirectSend { + t.Fatalf("BuildDeliveryTarget().Mode = %q, want %q", target.Mode, bridgepkg.DeliveryModeDirectSend) } } func TestDeliveryTargetValidateRejectsMissingDestinationForMode(t *testing.T) { t.Parallel() - target := channelspkg.DeliveryTarget{ - ChannelInstanceID: "chan-1", - Mode: channelspkg.DeliveryModeDirectSend, + target := bridgepkg.DeliveryTarget{ + BridgeInstanceID: "brg-1", + Mode: bridgepkg.DeliveryModeDirectSend, } err := target.Validate() @@ -48,10 +48,10 @@ func TestDeliveryTargetValidateRejectsMissingDestinationForMode(t *testing.T) { func TestDeliveryTargetValidateRejectsThreadOnlyWithoutAnchor(t *testing.T) { t.Parallel() - target := channelspkg.DeliveryTarget{ - ChannelInstanceID: "chan-1", - ThreadID: "thread-1", - Mode: channelspkg.DeliveryModeReply, + target := bridgepkg.DeliveryTarget{ + BridgeInstanceID: "brg-1", + ThreadID: "thread-1", + Mode: bridgepkg.DeliveryModeReply, } err := target.Validate() @@ -63,14 +63,14 @@ func TestDeliveryTargetValidateRejectsThreadOnlyWithoutAnchor(t *testing.T) { func TestBuildDeliveryTargetExplicitOverridesWinOverDefaults(t *testing.T) { t.Parallel() - instance := testChannelInstanceForTargets() + instance := testBridgeInstanceForTargets() instance.DeliveryDefaults = []byte(`{"peer_id":"peer-default","thread_id":"thread-default","group_id":"group-default","mode":"reply"}`) - target, err := channelspkg.BuildDeliveryTarget(instance, channelspkg.ResolveDeliveryTargetRequest{ - ChannelInstanceID: instance.ID, - PeerID: "peer-explicit", - ThreadID: "thread-explicit", - Mode: channelspkg.DeliveryModeDirectSend, + target, err := bridgepkg.BuildDeliveryTarget(instance, bridgepkg.ResolveDeliveryTargetRequest{ + BridgeInstanceID: instance.ID, + PeerID: "peer-explicit", + ThreadID: "thread-explicit", + Mode: bridgepkg.DeliveryModeDirectSend, }) if err != nil { t.Fatalf("BuildDeliveryTarget() error = %v", err) @@ -84,8 +84,8 @@ func TestBuildDeliveryTargetExplicitOverridesWinOverDefaults(t *testing.T) { if target.GroupID != "group-default" { t.Fatalf("BuildDeliveryTarget().GroupID = %q, want group-default", target.GroupID) } - if target.Mode != channelspkg.DeliveryModeDirectSend { - t.Fatalf("BuildDeliveryTarget().Mode = %q, want %q", target.Mode, channelspkg.DeliveryModeDirectSend) + if target.Mode != bridgepkg.DeliveryModeDirectSend { + t.Fatalf("BuildDeliveryTarget().Mode = %q, want %q", target.Mode, bridgepkg.DeliveryModeDirectSend) } } @@ -93,26 +93,26 @@ func TestRegistryResolveDeliveryTargetUsesServiceSeam(t *testing.T) { t.Parallel() registry, _ := newRegistryTestHarness(t) - instance := createTestChannelInstance(t, registry, channelspkg.CreateInstanceRequest{ - ID: "chan-target-service", - Scope: channelspkg.ScopeGlobal, + instance := createTestBridgeInstance(t, registry, bridgepkg.CreateInstanceRequest{ + ID: "brg-target-service", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Target Service", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true, IncludeThread: true}, DeliveryDefaults: []byte(`{"peer_id":"peer-service","thread_id":"thread-service","mode":"reply"}`), }) - target, err := registry.ResolveDeliveryTarget(testutil.Context(t), channelspkg.ResolveDeliveryTargetRequest{ - ChannelInstanceID: instance.ID, + target, err := registry.ResolveDeliveryTarget(testutil.Context(t), bridgepkg.ResolveDeliveryTargetRequest{ + BridgeInstanceID: instance.ID, }) if err != nil { t.Fatalf("ResolveDeliveryTarget() error = %v", err) } - if target.ChannelInstanceID != instance.ID { - t.Fatalf("ResolveDeliveryTarget().ChannelInstanceID = %q, want %q", target.ChannelInstanceID, instance.ID) + if target.BridgeInstanceID != instance.ID { + t.Fatalf("ResolveDeliveryTarget().BridgeInstanceID = %q, want %q", target.BridgeInstanceID, instance.ID) } if target.PeerID != "peer-service" { t.Fatalf("ResolveDeliveryTarget().PeerID = %q, want peer-service", target.PeerID) @@ -120,20 +120,20 @@ func TestRegistryResolveDeliveryTargetUsesServiceSeam(t *testing.T) { if target.ThreadID != "thread-service" { t.Fatalf("ResolveDeliveryTarget().ThreadID = %q, want thread-service", target.ThreadID) } - if target.Mode != channelspkg.DeliveryModeReply { - t.Fatalf("ResolveDeliveryTarget().Mode = %q, want %q", target.Mode, channelspkg.DeliveryModeReply) + if target.Mode != bridgepkg.DeliveryModeReply { + t.Fatalf("ResolveDeliveryTarget().Mode = %q, want %q", target.Mode, bridgepkg.DeliveryModeReply) } } -func testChannelInstanceForTargets() channelspkg.ChannelInstance { - return channelspkg.ChannelInstance{ - ID: "chan-targets", - Scope: channelspkg.ScopeGlobal, +func testBridgeInstanceForTargets() bridgepkg.BridgeInstance { + return bridgepkg.BridgeInstance{ + ID: "brg-targets", + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "Targets", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, } } diff --git a/internal/channels/types.go b/internal/bridges/types.go similarity index 57% rename from internal/channels/types.go rename to internal/bridges/types.go index 9b170b4ac..d905b9cd9 100644 --- a/internal/channels/types.go +++ b/internal/bridges/types.go @@ -1,4 +1,4 @@ -package channels +package bridges import ( "bytes" @@ -10,27 +10,27 @@ import ( ) var ( - // ErrChannelInstanceNotFound reports that no persisted channel instance matched the lookup. - ErrChannelInstanceNotFound = errors.New("channels: channel instance not found") - // ErrChannelInstanceUnavailable reports that the instance exists but cannot currently accept routing work. - ErrChannelInstanceUnavailable = errors.New("channels: channel instance unavailable") - // ErrChannelSecretBindingNotFound reports that no persisted secret binding matched the lookup. - ErrChannelSecretBindingNotFound = errors.New("channels: channel secret binding not found") - // ErrChannelRouteNotFound reports that no persisted route matched the lookup. - ErrChannelRouteNotFound = errors.New("channels: channel route not found") + // ErrBridgeInstanceNotFound reports that no persisted bridge instance matched the lookup. + ErrBridgeInstanceNotFound = errors.New("bridges: bridge instance not found") + // ErrBridgeInstanceUnavailable reports that the instance exists but cannot currently accept routing work. + ErrBridgeInstanceUnavailable = errors.New("bridges: bridge instance unavailable") + // ErrBridgeSecretBindingNotFound reports that no persisted secret binding matched the lookup. + ErrBridgeSecretBindingNotFound = errors.New("bridges: bridge secret binding not found") + // ErrBridgeRouteNotFound reports that no persisted route matched the lookup. + ErrBridgeRouteNotFound = errors.New("bridges: bridge route not found") // ErrIngestDedupRecordNotFound reports that no active ingest dedup record matched the lookup. - ErrIngestDedupRecordNotFound = errors.New("channels: ingest dedup record not found") - // ErrInvalidChannelStateTransition reports that the requested instance lifecycle transition is not allowed. - ErrInvalidChannelStateTransition = errors.New("channels: invalid channel state transition") + ErrIngestDedupRecordNotFound = errors.New("bridges: ingest dedup record not found") + // ErrInvalidBridgeStateTransition reports that the requested instance lifecycle transition is not allowed. + ErrInvalidBridgeStateTransition = errors.New("bridges: invalid bridge state transition") ) -// Scope identifies whether a channel resource is daemon-global or workspace-owned. +// Scope identifies whether a bridge resource is daemon-global or workspace-owned. type Scope string const ( - // ScopeGlobal identifies a daemon-global channel resource. + // ScopeGlobal identifies a daemon-global bridge resource. ScopeGlobal Scope = "global" - // ScopeWorkspace identifies a workspace-owned channel resource. + // ScopeWorkspace identifies a workspace-owned bridge resource. ScopeWorkspace Scope = "workspace" ) @@ -45,9 +45,9 @@ func (s Scope) Validate() error { case ScopeGlobal, ScopeWorkspace: return nil case "": - return errors.New("channels: scope is required") + return errors.New("bridges: scope is required") default: - return fmt.Errorf("channels: unsupported scope %q", s) + return fmt.Errorf("bridges: unsupported scope %q", s) } } @@ -62,54 +62,54 @@ func ValidateScopeWorkspaceID(scope Scope, workspaceID string) error { switch normalizedScope { case ScopeGlobal: if trimmedWorkspaceID != "" { - return errors.New("channels: global scope cannot include workspace id") + return errors.New("bridges: global scope cannot include workspace id") } case ScopeWorkspace: if trimmedWorkspaceID == "" { - return errors.New("channels: workspace scope requires workspace id") + return errors.New("bridges: workspace scope requires workspace id") } } return nil } -// ChannelStatus reports the operator-visible lifecycle state of a channel instance. -type ChannelStatus string +// BridgeStatus reports the operator-visible lifecycle state of a bridge instance. +type BridgeStatus string const ( - // ChannelStatusDisabled reports an instance that is intentionally disabled. - ChannelStatusDisabled ChannelStatus = "disabled" - // ChannelStatusStarting reports an instance that is launching or reconnecting. - ChannelStatusStarting ChannelStatus = "starting" - // ChannelStatusReady reports an instance that is healthy and ready to ingest/deliver. - ChannelStatusReady ChannelStatus = "ready" - // ChannelStatusDegraded reports an instance that is partially working with known issues. - ChannelStatusDegraded ChannelStatus = "degraded" - // ChannelStatusAuthRequired reports an instance that cannot operate until authentication is refreshed. - ChannelStatusAuthRequired ChannelStatus = "auth_required" - // ChannelStatusError reports an instance that is unhealthy due to a terminal or repeated fault. - ChannelStatusError ChannelStatus = "error" + // BridgeStatusDisabled reports an instance that is intentionally disabled. + BridgeStatusDisabled BridgeStatus = "disabled" + // BridgeStatusStarting reports an instance that is launching or reconnecting. + BridgeStatusStarting BridgeStatus = "starting" + // BridgeStatusReady reports an instance that is healthy and ready to ingest/deliver. + BridgeStatusReady BridgeStatus = "ready" + // BridgeStatusDegraded reports an instance that is partially working with known issues. + BridgeStatusDegraded BridgeStatus = "degraded" + // BridgeStatusAuthRequired reports an instance that cannot operate until authentication is refreshed. + BridgeStatusAuthRequired BridgeStatus = "auth_required" + // BridgeStatusError reports an instance that is unhealthy due to a terminal or repeated fault. + BridgeStatusError BridgeStatus = "error" ) // Normalize returns the normalized representation of the status. -func (s ChannelStatus) Normalize() ChannelStatus { - return ChannelStatus(strings.ToLower(strings.TrimSpace(string(s)))) +func (s BridgeStatus) Normalize() BridgeStatus { + return BridgeStatus(strings.ToLower(strings.TrimSpace(string(s)))) } -// Validate reports whether the status belongs to the closed channel status set. -func (s ChannelStatus) Validate() error { +// Validate reports whether the status belongs to the closed bridge status set. +func (s BridgeStatus) Validate() error { switch s.Normalize() { - case ChannelStatusDisabled, - ChannelStatusStarting, - ChannelStatusReady, - ChannelStatusDegraded, - ChannelStatusAuthRequired, - ChannelStatusError: + case BridgeStatusDisabled, + BridgeStatusStarting, + BridgeStatusReady, + BridgeStatusDegraded, + BridgeStatusAuthRequired, + BridgeStatusError: return nil case "": - return errors.New("channels: channel status is required") + return errors.New("bridges: bridge status is required") default: - return fmt.Errorf("channels: unsupported channel status %q", s) + return fmt.Errorf("bridges: unsupported bridge status %q", s) } } @@ -123,13 +123,13 @@ type RoutingPolicy struct { // Validate reports whether the routing policy is internally consistent. func (p RoutingPolicy) Validate() error { if p.IncludeThread && !p.IncludePeer && !p.IncludeGroup { - return errors.New("channels: routing policy cannot include thread without peer or group") + return errors.New("bridges: routing policy cannot include thread without peer or group") } return nil } -// ChannelInstance is the authoritative persisted configuration for one channel adapter instance. -type ChannelInstance struct { +// BridgeInstance is the authoritative persisted configuration for one bridge adapter instance. +type BridgeInstance struct { ID string `json:"id"` Scope Scope `json:"scope"` WorkspaceID string `json:"workspace_id,omitempty"` @@ -137,29 +137,29 @@ type ChannelInstance struct { ExtensionName string `json:"extension_name"` DisplayName string `json:"display_name"` Enabled bool `json:"enabled"` - Status ChannelStatus `json:"status"` + Status BridgeStatus `json:"status"` RoutingPolicy RoutingPolicy `json:"routing_policy"` DeliveryDefaults json.RawMessage `json:"delivery_defaults,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } -// Validate reports whether the persisted channel instance shape is complete and valid. -func (i ChannelInstance) Validate() error { +// Validate reports whether the persisted bridge instance shape is complete and valid. +func (i BridgeInstance) Validate() error { normalized := i.normalize() - if err := requireField(normalized.ID, "channel instance id"); err != nil { + if err := requireField(normalized.ID, "bridge instance id"); err != nil { return err } if err := ValidateScopeWorkspaceID(normalized.Scope, normalized.WorkspaceID); err != nil { return err } - if err := requireField(normalized.Platform, "channel instance platform"); err != nil { + if err := requireField(normalized.Platform, "bridge instance platform"); err != nil { return err } - if err := requireField(normalized.ExtensionName, "channel instance extension name"); err != nil { + if err := requireField(normalized.ExtensionName, "bridge instance extension name"); err != nil { return err } - if err := requireField(normalized.DisplayName, "channel instance display name"); err != nil { + if err := requireField(normalized.DisplayName, "bridge instance display name"); err != nil { return err } if err := normalized.Status.Validate(); err != nil { @@ -171,54 +171,54 @@ func (i ChannelInstance) Validate() error { if err := normalized.RoutingPolicy.Validate(); err != nil { return err } - if _, err := normalizeRawJSON(normalized.DeliveryDefaults, "channel instance delivery defaults"); err != nil { + if _, err := normalizeRawJSON(normalized.DeliveryDefaults, "bridge instance delivery defaults"); err != nil { return err } return nil } -// ChannelSecretBinding binds one named channel secret slot to a daemon-managed vault reference. -type ChannelSecretBinding struct { - ChannelInstanceID string `json:"channel_instance_id"` - BindingName string `json:"binding_name"` - VaultRef string `json:"vault_ref"` - Kind string `json:"kind"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +// BridgeSecretBinding binds one named bridge secret slot to a daemon-managed vault reference. +type BridgeSecretBinding struct { + BridgeInstanceID string `json:"bridge_instance_id"` + BindingName string `json:"binding_name"` + VaultRef string `json:"vault_ref"` + Kind string `json:"kind"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Validate reports whether the persisted secret binding is complete and valid. -func (b ChannelSecretBinding) Validate() error { +func (b BridgeSecretBinding) Validate() error { normalized := b.normalize() - if err := requireField(normalized.ChannelInstanceID, "channel secret binding channel instance id"); err != nil { + if err := requireField(normalized.BridgeInstanceID, "bridge secret binding bridge instance id"); err != nil { return err } - if err := requireField(normalized.BindingName, "channel secret binding name"); err != nil { + if err := requireField(normalized.BindingName, "bridge secret binding name"); err != nil { return err } - if err := requireField(normalized.VaultRef, "channel secret binding vault ref"); err != nil { + if err := requireField(normalized.VaultRef, "bridge secret binding vault ref"); err != nil { return err } - if err := requireField(normalized.Kind, "channel secret binding kind"); err != nil { + if err := requireField(normalized.Kind, "bridge secret binding kind"); err != nil { return err } return nil } -// DeliveryTarget identifies an outbound delivery destination within one channel instance. +// DeliveryTarget identifies an outbound delivery destination within one bridge instance. type DeliveryTarget struct { - ChannelInstanceID string `json:"channel_instance_id"` - PeerID string `json:"peer_id,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - GroupID string `json:"group_id,omitempty"` - Mode DeliveryMode `json:"mode,omitempty"` + BridgeInstanceID string `json:"bridge_instance_id"` + PeerID string `json:"peer_id,omitempty"` + ThreadID string `json:"thread_id,omitempty"` + GroupID string `json:"group_id,omitempty"` + Mode DeliveryMode `json:"mode,omitempty"` } // Validate reports whether the delivery target contains a supported mode and // the identity fields required by that mode. func (t DeliveryTarget) Validate() error { normalized := t.normalize() - if err := requireField(normalized.ChannelInstanceID, "delivery target channel instance id"); err != nil { + if err := requireField(normalized.BridgeInstanceID, "delivery target bridge instance id"); err != nil { return err } if err := normalized.Mode.Validate(); err != nil { @@ -226,7 +226,7 @@ func (t DeliveryTarget) Validate() error { } if normalized.ThreadID != "" && normalized.PeerID == "" && normalized.GroupID == "" { return fmt.Errorf( - "channels: delivery target thread id requires peer id or group id for mode %q", + "bridges: delivery target thread id requires peer id or group id for mode %q", normalized.Mode, ) } @@ -235,7 +235,7 @@ func (t DeliveryTarget) Validate() error { case DeliveryModeDirectSend, DeliveryModeReply: if normalized.PeerID == "" && normalized.GroupID == "" { return fmt.Errorf( - "channels: delivery target mode %q requires peer id or group id", + "bridges: delivery target mode %q requires peer id or group id", normalized.Mode, ) } @@ -247,7 +247,7 @@ func (t DeliveryTarget) Validate() error { // IsZero reports whether the target carries any values. func (t DeliveryTarget) IsZero() bool { normalized := t.normalize() - return normalized.ChannelInstanceID == "" && + return normalized.BridgeInstanceID == "" && normalized.PeerID == "" && normalized.ThreadID == "" && normalized.GroupID == "" && @@ -261,7 +261,7 @@ type MessageSender struct { DisplayName string `json:"display_name,omitempty"` } -// MessageContent carries normalized text content shared by inbound and outbound channel models. +// MessageContent carries normalized text content shared by inbound and outbound bridge models. type MessageContent struct { Text string `json:"text,omitempty"` } @@ -274,9 +274,9 @@ type MessageAttachment struct { URL string `json:"url,omitempty"` } -// InboundMessageEnvelope is the normalized channel ingest payload delivered by adapters. +// InboundMessageEnvelope is the normalized bridge ingest payload delivered by adapters. type InboundMessageEnvelope struct { - ChannelInstanceID string `json:"channel_instance_id"` + BridgeInstanceID string `json:"bridge_instance_id"` Scope Scope `json:"scope"` WorkspaceID string `json:"workspace_id,omitempty"` PeerID string `json:"peer_id,omitempty"` @@ -293,7 +293,7 @@ type InboundMessageEnvelope struct { // Validate reports whether the inbound envelope contains the required identifying fields. func (e InboundMessageEnvelope) Validate() error { normalized := e.normalize() - if err := requireField(normalized.ChannelInstanceID, "inbound message channel instance id"); err != nil { + if err := requireField(normalized.BridgeInstanceID, "inbound message bridge instance id"); err != nil { return err } if err := ValidateScopeWorkspaceID(normalized.Scope, normalized.WorkspaceID); err != nil { @@ -303,7 +303,7 @@ func (e InboundMessageEnvelope) Validate() error { return err } if normalized.ReceivedAt.IsZero() { - return errors.New("channels: inbound message received at is required") + return errors.New("bridges: inbound message received at is required") } if err := requireField(normalized.IdempotencyKey, "inbound message idempotency key"); err != nil { return err @@ -311,17 +311,17 @@ func (e InboundMessageEnvelope) Validate() error { return nil } -// DeliveryEvent is the daemon-owned outbound projection sent to a channel adapter. +// DeliveryEvent is the daemon-owned outbound projection sent to a bridge adapter. type DeliveryEvent struct { - DeliveryID string `json:"delivery_id"` - ChannelInstanceID string `json:"channel_instance_id"` - RoutingKey RoutingKey `json:"routing_key"` - DeliveryTarget DeliveryTarget `json:"delivery_target"` - Seq int64 `json:"seq"` - EventType string `json:"event_type"` - Content MessageContent `json:"content"` - Final bool `json:"final"` - Metadata json.RawMessage `json:"metadata,omitempty"` + DeliveryID string `json:"delivery_id"` + BridgeInstanceID string `json:"bridge_instance_id"` + RoutingKey RoutingKey `json:"routing_key"` + DeliveryTarget DeliveryTarget `json:"delivery_target"` + Seq int64 `json:"seq"` + EventType string `json:"event_type"` + Content MessageContent `json:"content"` + Final bool `json:"final"` + Metadata json.RawMessage `json:"metadata,omitempty"` } // Validate reports whether the delivery event contains the required identifiers. @@ -330,25 +330,25 @@ func (e DeliveryEvent) Validate() error { if err := requireField(normalized.DeliveryID, "delivery event id"); err != nil { return err } - if err := requireField(normalized.ChannelInstanceID, "delivery event channel instance id"); err != nil { + if err := requireField(normalized.BridgeInstanceID, "delivery event bridge instance id"); err != nil { return err } if err := normalized.RoutingKey.Validate(); err != nil { return err } - if normalized.RoutingKey.ChannelInstanceID != normalized.ChannelInstanceID { - return errors.New("channels: delivery event channel instance id must match routing key") + if normalized.RoutingKey.BridgeInstanceID != normalized.BridgeInstanceID { + return errors.New("bridges: delivery event bridge instance id must match routing key") } if !normalized.DeliveryTarget.IsZero() { if err := normalized.DeliveryTarget.Validate(); err != nil { return err } - if normalized.DeliveryTarget.ChannelInstanceID != normalized.ChannelInstanceID { - return errors.New("channels: delivery target channel instance id must match delivery event") + if normalized.DeliveryTarget.BridgeInstanceID != normalized.BridgeInstanceID { + return errors.New("bridges: delivery target bridge instance id must match delivery event") } } if normalized.Seq < 0 { - return fmt.Errorf("channels: invalid delivery event sequence %d", normalized.Seq) + return fmt.Errorf("bridges: invalid delivery event sequence %d", normalized.Seq) } if err := validateDeliveryEventType(normalized.EventType, normalized.Final); err != nil { return err @@ -361,10 +361,10 @@ func (e DeliveryEvent) Validate() error { // IngestDedupRecord tracks inbound idempotency keys with an explicit TTL. type IngestDedupRecord struct { - IdempotencyKey string `json:"idempotency_key"` - ChannelInstanceID string `json:"channel_instance_id"` - ReceivedAt time.Time `json:"received_at"` - ExpiresAt time.Time `json:"expires_at"` + IdempotencyKey string `json:"idempotency_key"` + BridgeInstanceID string `json:"bridge_instance_id"` + ReceivedAt time.Time `json:"received_at"` + ExpiresAt time.Time `json:"expires_at"` } // Validate reports whether the dedup record is complete and time-consistent. @@ -373,22 +373,22 @@ func (r IngestDedupRecord) Validate() error { if err := requireField(normalized.IdempotencyKey, "ingest dedup idempotency key"); err != nil { return err } - if err := requireField(normalized.ChannelInstanceID, "ingest dedup channel instance id"); err != nil { + if err := requireField(normalized.BridgeInstanceID, "ingest dedup bridge instance id"); err != nil { return err } if normalized.ReceivedAt.IsZero() { - return errors.New("channels: ingest dedup received at is required") + return errors.New("bridges: ingest dedup received at is required") } if normalized.ExpiresAt.IsZero() { - return errors.New("channels: ingest dedup expires at is required") + return errors.New("bridges: ingest dedup expires at is required") } if !normalized.ExpiresAt.After(normalized.ReceivedAt) { - return errors.New("channels: ingest dedup expires at must be after received at") + return errors.New("bridges: ingest dedup expires at must be after received at") } return nil } -func (i ChannelInstance) normalize() ChannelInstance { +func (i BridgeInstance) normalize() BridgeInstance { normalized := i normalized.ID = strings.TrimSpace(normalized.ID) normalized.Scope = normalized.Scope.Normalize() @@ -401,9 +401,9 @@ func (i ChannelInstance) normalize() ChannelInstance { return normalized } -func (b ChannelSecretBinding) normalize() ChannelSecretBinding { +func (b BridgeSecretBinding) normalize() BridgeSecretBinding { normalized := b - normalized.ChannelInstanceID = strings.TrimSpace(normalized.ChannelInstanceID) + normalized.BridgeInstanceID = strings.TrimSpace(normalized.BridgeInstanceID) normalized.BindingName = strings.TrimSpace(normalized.BindingName) normalized.VaultRef = strings.TrimSpace(normalized.VaultRef) normalized.Kind = strings.TrimSpace(normalized.Kind) @@ -412,7 +412,7 @@ func (b ChannelSecretBinding) normalize() ChannelSecretBinding { func (t DeliveryTarget) normalize() DeliveryTarget { normalized := t - normalized.ChannelInstanceID = strings.TrimSpace(normalized.ChannelInstanceID) + normalized.BridgeInstanceID = strings.TrimSpace(normalized.BridgeInstanceID) normalized.PeerID = strings.TrimSpace(normalized.PeerID) normalized.ThreadID = strings.TrimSpace(normalized.ThreadID) normalized.GroupID = strings.TrimSpace(normalized.GroupID) @@ -425,7 +425,7 @@ func (e InboundMessageEnvelope) normalize() InboundMessageEnvelope { if len(e.Attachments) > 0 { normalized.Attachments = append([]MessageAttachment(nil), e.Attachments...) } - normalized.ChannelInstanceID = strings.TrimSpace(normalized.ChannelInstanceID) + normalized.BridgeInstanceID = strings.TrimSpace(normalized.BridgeInstanceID) normalized.Scope = normalized.Scope.Normalize() normalized.WorkspaceID = strings.TrimSpace(normalized.WorkspaceID) normalized.PeerID = strings.TrimSpace(normalized.PeerID) @@ -453,7 +453,7 @@ func (e InboundMessageEnvelope) normalize() InboundMessageEnvelope { func (e DeliveryEvent) normalize() DeliveryEvent { normalized := e normalized.DeliveryID = strings.TrimSpace(normalized.DeliveryID) - normalized.ChannelInstanceID = strings.TrimSpace(normalized.ChannelInstanceID) + normalized.BridgeInstanceID = strings.TrimSpace(normalized.BridgeInstanceID) normalized.RoutingKey = normalized.RoutingKey.normalize() normalized.DeliveryTarget = normalized.DeliveryTarget.normalize() normalized.EventType = normalizeDeliveryEventType(normalized.EventType) @@ -465,13 +465,13 @@ func (e DeliveryEvent) normalize() DeliveryEvent { func (r IngestDedupRecord) normalize() IngestDedupRecord { normalized := r normalized.IdempotencyKey = strings.TrimSpace(normalized.IdempotencyKey) - normalized.ChannelInstanceID = strings.TrimSpace(normalized.ChannelInstanceID) + normalized.BridgeInstanceID = strings.TrimSpace(normalized.BridgeInstanceID) return normalized } func requireField(value string, label string) error { if strings.TrimSpace(value) == "" { - return fmt.Errorf("channels: %s is required", label) + return fmt.Errorf("bridges: %s is required", label) } return nil } @@ -482,12 +482,12 @@ func normalizeRawJSON(value json.RawMessage, label string) (json.RawMessage, err return nil, nil } if !json.Valid(trimmed) { - return nil, fmt.Errorf("channels: %s must be valid JSON", label) + return nil, fmt.Errorf("bridges: %s must be valid JSON", label) } var compacted bytes.Buffer if err := json.Compact(&compacted, trimmed); err != nil { - return nil, fmt.Errorf("channels: compact %s: %w", label, err) + return nil, fmt.Errorf("bridges: compact %s: %w", label, err) } return compacted.Bytes(), nil diff --git a/internal/channels/types_test.go b/internal/bridges/types_test.go similarity index 69% rename from internal/channels/types_test.go rename to internal/bridges/types_test.go index 06822e090..340d1c298 100644 --- a/internal/channels/types_test.go +++ b/internal/bridges/types_test.go @@ -1,4 +1,4 @@ -package channels +package bridges import ( "testing" @@ -37,11 +37,11 @@ func TestValidateScopeWorkspaceID(t *testing.T) { } } -func TestChannelStatusAndRoutingPolicyValidation(t *testing.T) { +func TestBridgeStatusAndRoutingPolicyValidation(t *testing.T) { t.Parallel() - instance := ChannelInstance{ - ID: "chan-1", + instance := BridgeInstance{ + ID: "brg-1", Scope: ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", @@ -51,18 +51,18 @@ func TestChannelStatusAndRoutingPolicyValidation(t *testing.T) { RoutingPolicy: RoutingPolicy{IncludePeer: true}, } if err := instance.Validate(); err == nil { - t.Fatal("ChannelInstance.Validate(invalid status) error = nil, want non-nil") + t.Fatal("BridgeInstance.Validate(invalid status) error = nil, want non-nil") } - instance.Status = ChannelStatusReady + instance.Status = BridgeStatusReady instance.RoutingPolicy = RoutingPolicy{IncludeThread: true} if err := instance.Validate(); err == nil { - t.Fatal("ChannelInstance.Validate(invalid routing policy) error = nil, want non-nil") + t.Fatal("BridgeInstance.Validate(invalid routing policy) error = nil, want non-nil") } instance.RoutingPolicy = RoutingPolicy{IncludePeer: true, IncludeThread: true} if err := instance.Validate(); err != nil { - t.Fatalf("ChannelInstance.Validate(valid) error = %v", err) + t.Fatalf("BridgeInstance.Validate(valid) error = %v", err) } } @@ -70,18 +70,18 @@ func TestRoutingKeyHashStable(t *testing.T) { t.Parallel() first := RoutingKey{ - Scope: ScopeWorkspace, - WorkspaceID: " ws-1 ", - ChannelInstanceID: " chan-1 ", - PeerID: "peer-1", - ThreadID: " thread-1 ", + Scope: ScopeWorkspace, + WorkspaceID: " ws-1 ", + BridgeInstanceID: " brg-1 ", + PeerID: "peer-1", + ThreadID: " thread-1 ", } second := RoutingKey{ - Scope: "workspace", - WorkspaceID: "ws-1", - ChannelInstanceID: "chan-1", - PeerID: "peer-1", - ThreadID: "thread-1", + Scope: "workspace", + WorkspaceID: "ws-1", + BridgeInstanceID: "brg-1", + PeerID: "peer-1", + ThreadID: "thread-1", } firstSerialized, err := first.Serialize() @@ -109,56 +109,56 @@ func TestRoutingKeyHashStable(t *testing.T) { } } -func TestChannelSecretBindingValidation(t *testing.T) { +func TestBridgeSecretBindingValidation(t *testing.T) { t.Parallel() - valid := ChannelSecretBinding{ - ChannelInstanceID: "chan-1", - BindingName: "bot_token", - VaultRef: "vault://bot-token", - Kind: "token", + valid := BridgeSecretBinding{ + BridgeInstanceID: "brg-1", + BindingName: "bot_token", + VaultRef: "vault://bot-token", + Kind: "token", } if err := valid.Validate(); err != nil { - t.Fatalf("ChannelSecretBinding.Validate(valid) error = %v", err) + t.Fatalf("BridgeSecretBinding.Validate(valid) error = %v", err) } invalidName := valid invalidName.BindingName = " " if err := invalidName.Validate(); err == nil { - t.Fatal("ChannelSecretBinding.Validate(empty name) error = nil, want non-nil") + t.Fatal("BridgeSecretBinding.Validate(empty name) error = nil, want non-nil") } invalidVault := valid invalidVault.VaultRef = "" if err := invalidVault.Validate(); err == nil { - t.Fatal("ChannelSecretBinding.Validate(empty vault ref) error = nil, want non-nil") + t.Fatal("BridgeSecretBinding.Validate(empty vault ref) error = nil, want non-nil") } } -func TestChannelRouteCanonicalizeAndDedupValidation(t *testing.T) { +func TestBridgeRouteCanonicalizeAndDedupValidation(t *testing.T) { t.Parallel() - route := ChannelRoute{ - Scope: ScopeWorkspace, - WorkspaceID: "ws-1", - ChannelInstanceID: "chan-1", - PeerID: "peer-1", - SessionID: "sess-1", - AgentName: "coder", + route := BridgeRoute{ + Scope: ScopeWorkspace, + WorkspaceID: "ws-1", + BridgeInstanceID: "brg-1", + PeerID: "peer-1", + SessionID: "sess-1", + AgentName: "coder", } canonical, err := route.Canonicalize() if err != nil { - t.Fatalf("ChannelRoute.Canonicalize() error = %v", err) + t.Fatalf("BridgeRoute.Canonicalize() error = %v", err) } if canonical.RoutingKeyHash == "" { - t.Fatal("ChannelRoute.Canonicalize() routing key hash = empty, want non-empty") + t.Fatal("BridgeRoute.Canonicalize() routing key hash = empty, want non-empty") } record := IngestDedupRecord{ - IdempotencyKey: "idem-1", - ChannelInstanceID: "chan-1", - ReceivedAt: time.Date(2026, 4, 10, 10, 0, 0, 0, time.UTC), - ExpiresAt: time.Date(2026, 4, 10, 10, 5, 0, 0, time.UTC), + IdempotencyKey: "idem-1", + BridgeInstanceID: "brg-1", + ReceivedAt: time.Date(2026, 4, 10, 10, 0, 0, 0, time.UTC), + ExpiresAt: time.Date(2026, 4, 10, 10, 5, 0, 0, time.UTC), } if err := record.Validate(); err != nil { t.Fatalf("IngestDedupRecord.Validate(valid) error = %v", err) @@ -170,34 +170,34 @@ func TestChannelRouteCanonicalizeAndDedupValidation(t *testing.T) { } } -func TestChannelInstanceValidateDeliveryDefaultsJSON(t *testing.T) { +func TestBridgeInstanceValidateDeliveryDefaultsJSON(t *testing.T) { t.Parallel() - instance := ChannelInstance{ - ID: "chan-json", + instance := BridgeInstance{ + ID: "brg-json", Scope: ScopeGlobal, Platform: "telegram", ExtensionName: "telegram-adapter", DisplayName: "JSON Telegram", Enabled: true, - Status: ChannelStatusReady, + Status: BridgeStatusReady, RoutingPolicy: RoutingPolicy{IncludePeer: true}, DeliveryDefaults: []byte(`{"parse_mode":"markdown"}`), } if err := instance.Validate(); err != nil { - t.Fatalf("ChannelInstance.Validate(valid json) error = %v", err) + t.Fatalf("BridgeInstance.Validate(valid json) error = %v", err) } instance.DeliveryDefaults = []byte(`{`) if err := instance.Validate(); err == nil { - t.Fatal("ChannelInstance.Validate(invalid json) error = nil, want non-nil") + t.Fatal("BridgeInstance.Validate(invalid json) error = nil, want non-nil") } } func TestDeliveryTargetEnvelopeAndEventValidation(t *testing.T) { t.Parallel() - target := DeliveryTarget{ChannelInstanceID: "chan-1", PeerID: "peer-1", Mode: "direct"} + target := DeliveryTarget{BridgeInstanceID: "brg-1", PeerID: "peer-1", Mode: "direct"} if target.IsZero() { t.Fatal("DeliveryTarget.IsZero() = true, want false") } @@ -212,7 +212,7 @@ func TestDeliveryTargetEnvelopeAndEventValidation(t *testing.T) { } envelope := InboundMessageEnvelope{ - ChannelInstanceID: "chan-1", + BridgeInstanceID: "brg-1", Scope: ScopeWorkspace, WorkspaceID: "ws-1", PeerID: "peer-1", @@ -231,13 +231,13 @@ func TestDeliveryTargetEnvelopeAndEventValidation(t *testing.T) { } event := DeliveryEvent{ - DeliveryID: "deliv-1", - ChannelInstanceID: "chan-1", + DeliveryID: "deliv-1", + BridgeInstanceID: "brg-1", RoutingKey: RoutingKey{ - Scope: ScopeWorkspace, - WorkspaceID: "ws-1", - ChannelInstanceID: "chan-1", - PeerID: "peer-1", + Scope: ScopeWorkspace, + WorkspaceID: "ws-1", + BridgeInstanceID: "brg-1", + PeerID: "peer-1", }, DeliveryTarget: target, Seq: 1, @@ -249,12 +249,12 @@ func TestDeliveryTargetEnvelopeAndEventValidation(t *testing.T) { t.Fatalf("DeliveryEvent.Validate(valid) error = %v", err) } - event.DeliveryTarget.ChannelInstanceID = "chan-2" + event.DeliveryTarget.BridgeInstanceID = "brg-2" if err := event.Validate(); err == nil { t.Fatal("DeliveryEvent.Validate(mismatched target instance) error = nil, want non-nil") } - event.DeliveryTarget.ChannelInstanceID = "chan-1" + event.DeliveryTarget.BridgeInstanceID = "brg-1" event.Metadata = []byte(`{`) if err := event.Validate(); err == nil { t.Fatal("DeliveryEvent.Validate(invalid metadata) error = nil, want non-nil") @@ -265,7 +265,7 @@ func TestInboundMessageEnvelopeNormalizeClonesAttachments(t *testing.T) { t.Parallel() envelope := InboundMessageEnvelope{ - ChannelInstanceID: " chan-1 ", + BridgeInstanceID: " brg-1 ", Scope: ScopeWorkspace, WorkspaceID: " ws-1 ", PeerID: " peer-1 ", @@ -299,18 +299,18 @@ func TestInboundMessageEnvelopeNormalizeClonesAttachments(t *testing.T) { } } -func TestChannelRouteValidateHashMismatch(t *testing.T) { +func TestBridgeRouteValidateHashMismatch(t *testing.T) { t.Parallel() - route := ChannelRoute{ - RoutingKeyHash: "wrong", - Scope: ScopeGlobal, - ChannelInstanceID: "chan-1", - PeerID: "peer-1", - SessionID: "sess-1", - AgentName: "coder", + route := BridgeRoute{ + RoutingKeyHash: "wrong", + Scope: ScopeGlobal, + BridgeInstanceID: "brg-1", + PeerID: "peer-1", + SessionID: "sess-1", + AgentName: "coder", } if err := route.Validate(); err == nil { - t.Fatal("ChannelRoute.Validate(hash mismatch) error = nil, want non-nil") + t.Fatal("BridgeRoute.Validate(hash mismatch) error = nil, want non-nil") } } diff --git a/internal/channels/doc.go b/internal/channels/doc.go deleted file mode 100644 index f9b082df4..000000000 --- a/internal/channels/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package channels defines the daemon-owned channel domain models shared by -// persistence, runtime, and transport layers. -package channels diff --git a/internal/channels/registry.go b/internal/channels/registry.go deleted file mode 100644 index 604bc5bad..000000000 --- a/internal/channels/registry.go +++ /dev/null @@ -1,519 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/pedronauck/agh/internal/store" -) - -// RegistryStore is the persistence surface consumed by the daemon-owned channel -// registry. The global DB implementation from task 01 satisfies this contract. -type RegistryStore interface { - InsertChannelInstance(ctx context.Context, instance ChannelInstance) error - UpdateChannelInstance(ctx context.Context, instance ChannelInstance) error - GetChannelInstance(ctx context.Context, id string) (ChannelInstance, error) - ListChannelInstances(ctx context.Context) ([]ChannelInstance, error) - PutChannelRoute(ctx context.Context, route ChannelRoute) error - ResolveChannelRoute(ctx context.Context, key RoutingKey) (ChannelRoute, error) - ListChannelRoutes(ctx context.Context, channelInstanceID string) ([]ChannelRoute, error) -} - -// Registry owns channel instance lifecycle validation and canonical routing-key -// construction on top of the persistence layer. -type Registry interface { - CreateInstance(ctx context.Context, req CreateInstanceRequest) (*ChannelInstance, error) - GetInstance(ctx context.Context, id string) (*ChannelInstance, error) - ListInstances(ctx context.Context) ([]ChannelInstance, error) - UpdateInstance(ctx context.Context, req UpdateInstanceRequest) (*ChannelInstance, error) - UpdateInstanceState(ctx context.Context, req UpdateInstanceStateRequest) (*ChannelInstance, error) - BuildRoutingKey(ctx context.Context, key RoutingKey) (RoutingKey, error) - ResolveRoute(ctx context.Context, key RoutingKey) (*ChannelRoute, error) - ResolveOrCreateRoute(ctx context.Context, route ChannelRoute) (*ChannelRoute, bool, error) - UpsertRoute(ctx context.Context, route ChannelRoute) (*ChannelRoute, error) - ListRoutes(ctx context.Context, channelInstanceID string) ([]ChannelRoute, error) -} - -// CreateInstanceRequest captures the persisted configuration for a new channel instance. -type CreateInstanceRequest struct { - ID string `json:"id,omitempty"` - Scope Scope `json:"scope"` - WorkspaceID string `json:"workspace_id,omitempty"` - Platform string `json:"platform"` - ExtensionName string `json:"extension_name"` - DisplayName string `json:"display_name"` - Enabled bool `json:"enabled"` - Status ChannelStatus `json:"status"` - RoutingPolicy RoutingPolicy `json:"routing_policy"` - DeliveryDefaults json.RawMessage `json:"delivery_defaults,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` -} - -// Validate reports whether the creation request contains a valid instance definition. -func (r CreateInstanceRequest) Validate() error { - _, err := r.toInstance(nil) - return err -} - -// UpdateInstanceRequest captures one mutation of channel-instance fields that -// do not change the lifecycle state machine. -type UpdateInstanceRequest struct { - ID string `json:"id"` - DisplayName *string `json:"display_name,omitempty"` - RoutingPolicy *RoutingPolicy `json:"routing_policy,omitempty"` - DeliveryDefaults *json.RawMessage `json:"delivery_defaults,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` -} - -// Validate reports whether the request contains at least one mutable field and -// each supplied value is internally consistent. -func (r UpdateInstanceRequest) Validate() error { - if err := requireField(strings.TrimSpace(r.ID), "channel instance id"); err != nil { - return err - } - if r.DisplayName == nil && r.RoutingPolicy == nil && r.DeliveryDefaults == nil { - return errors.New("channels: channel instance update requires at least one mutable field") - } - if r.DisplayName != nil { - if err := requireField(strings.TrimSpace(*r.DisplayName), "channel instance display name"); err != nil { - return err - } - } - if r.RoutingPolicy != nil { - if err := r.RoutingPolicy.Validate(); err != nil { - return err - } - } - if r.DeliveryDefaults != nil { - if _, err := normalizeRawJSON(*r.DeliveryDefaults, "channel instance delivery defaults"); err != nil { - return err - } - } - return nil -} - -// UpdateInstanceStateRequest captures one daemon-owned lifecycle transition. -type UpdateInstanceStateRequest struct { - ID string `json:"id"` - Enabled bool `json:"enabled"` - Status ChannelStatus `json:"status"` - UpdatedAt time.Time `json:"updated_at,omitempty"` -} - -// Validate reports whether the request contains the fields needed for a lifecycle update. -func (r UpdateInstanceStateRequest) Validate() error { - if err := requireField(strings.TrimSpace(r.ID), "channel instance id"); err != nil { - return err - } - return validateInstanceLifecycle(r.Enabled, r.Status.Normalize()) -} - -// Service is the concrete daemon-owned registry implementation. -type Service struct { - store RegistryStore - now func() time.Time -} - -// RegistryOption customizes Service construction. -type RegistryOption func(*Service) - -var _ Registry = (*Service)(nil) - -// NewRegistry constructs the channel registry over the supplied persistence surface. -func NewRegistry(store RegistryStore, opts ...RegistryOption) *Service { - service := &Service{ - store: store, - now: func() time.Time { - return time.Now().UTC() - }, - } - for _, opt := range opts { - if opt != nil { - opt(service) - } - } - return service -} - -// WithNow overrides the clock used for default timestamps in tests. -func WithNow(now func() time.Time) RegistryOption { - return func(service *Service) { - if now != nil { - service.now = now - } - } -} - -// CreateInstance persists a new channel instance after applying lifecycle validation. -func (s *Service) CreateInstance(ctx context.Context, req CreateInstanceRequest) (*ChannelInstance, error) { - if err := s.checkReady(ctx, "create channel instance"); err != nil { - return nil, err - } - - instance, err := req.toInstance(s.now) - if err != nil { - return nil, fmt.Errorf("channels: create channel instance: build request: %w", err) - } - if err := s.store.InsertChannelInstance(ctx, instance); err != nil { - return nil, fmt.Errorf("channels: create channel instance %q: insert: %w", instance.ID, err) - } - return cloneChannelInstance(instance), nil -} - -// GetInstance returns one persisted channel instance by primary key. -func (s *Service) GetInstance(ctx context.Context, id string) (*ChannelInstance, error) { - if err := s.checkReady(ctx, "get channel instance"); err != nil { - return nil, err - } - - trimmedID := strings.TrimSpace(id) - instance, err := s.store.GetChannelInstance(ctx, trimmedID) - if err != nil { - return nil, fmt.Errorf("channels: get channel instance %q: %w", trimmedID, err) - } - return cloneChannelInstance(instance), nil -} - -// ListInstances returns all persisted channel instances. -func (s *Service) ListInstances(ctx context.Context) ([]ChannelInstance, error) { - if err := s.checkReady(ctx, "list channel instances"); err != nil { - return nil, err - } - - instances, err := s.store.ListChannelInstances(ctx) - if err != nil { - return nil, fmt.Errorf("channels: list channel instances: %w", err) - } - if len(instances) == 0 { - return instances, nil - } - - cloned := make([]ChannelInstance, 0, len(instances)) - for _, instance := range instances { - cloned = append(cloned, *cloneChannelInstance(instance)) - } - return cloned, nil -} - -// UpdateInstance updates one persisted channel instance without changing its -// lifecycle state. -func (s *Service) UpdateInstance(ctx context.Context, req UpdateInstanceRequest) (*ChannelInstance, error) { - if err := s.checkReady(ctx, "update channel instance"); err != nil { - return nil, err - } - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("channels: update channel instance %q: validate request: %w", strings.TrimSpace(req.ID), err) - } - - trimmedID := strings.TrimSpace(req.ID) - instance, err := s.store.GetChannelInstance(ctx, trimmedID) - if err != nil { - return nil, fmt.Errorf("channels: update channel instance %q: load current state: %w", trimmedID, err) - } - if req.DisplayName != nil { - instance.DisplayName = strings.TrimSpace(*req.DisplayName) - } - if req.RoutingPolicy != nil { - instance.RoutingPolicy = *req.RoutingPolicy - } - if req.DeliveryDefaults != nil { - normalized, err := normalizeRawJSON(*req.DeliveryDefaults, "channel instance delivery defaults") - if err != nil { - return nil, fmt.Errorf("channels: update channel instance %q: normalize delivery defaults: %w", trimmedID, err) - } - instance.DeliveryDefaults = normalized - } - instance.UpdatedAt = req.UpdatedAt - if instance.UpdatedAt.IsZero() { - instance.UpdatedAt = s.now() - } - if err := instance.Validate(); err != nil { - return nil, fmt.Errorf("channels: update channel instance %q: validate updated state: %w", trimmedID, err) - } - if err := s.store.UpdateChannelInstance(ctx, instance); err != nil { - return nil, fmt.Errorf("channels: update channel instance %q: persist: %w", trimmedID, err) - } - return cloneChannelInstance(instance), nil -} - -// UpdateInstanceState applies one validated lifecycle transition to a persisted instance. -func (s *Service) UpdateInstanceState(ctx context.Context, req UpdateInstanceStateRequest) (*ChannelInstance, error) { - if err := s.checkReady(ctx, "update channel instance state"); err != nil { - return nil, err - } - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("channels: update channel instance state %q: validate request: %w", strings.TrimSpace(req.ID), err) - } - - trimmedID := strings.TrimSpace(req.ID) - instance, err := s.store.GetChannelInstance(ctx, trimmedID) - if err != nil { - return nil, fmt.Errorf("channels: update channel instance state %q: load current state: %w", trimmedID, err) - } - if err := ValidateInstanceStateTransition(instance, req.Enabled, req.Status); err != nil { - return nil, fmt.Errorf("channels: update channel instance state %q: validate transition: %w", trimmedID, err) - } - - instance.Enabled = req.Enabled - instance.Status = req.Status.Normalize() - instance.UpdatedAt = req.UpdatedAt - if instance.UpdatedAt.IsZero() { - instance.UpdatedAt = s.now() - } - - if err := s.store.UpdateChannelInstance(ctx, instance); err != nil { - return nil, fmt.Errorf("channels: update channel instance state %q: persist: %w", trimmedID, err) - } - return cloneChannelInstance(instance), nil -} - -// BuildRoutingKey canonicalizes the supplied routing identity under the owning instance policy. -func (s *Service) BuildRoutingKey(ctx context.Context, key RoutingKey) (RoutingKey, error) { - if err := s.checkReady(ctx, "build routing key"); err != nil { - return RoutingKey{}, err - } - - trimmedID := strings.TrimSpace(key.ChannelInstanceID) - instance, err := s.store.GetChannelInstance(ctx, trimmedID) - if err != nil { - return RoutingKey{}, fmt.Errorf("channels: build routing key for %q: load channel instance: %w", trimmedID, err) - } - canonicalKey, err := CanonicalizeRoutingKey(instance, key) - if err != nil { - return RoutingKey{}, fmt.Errorf("channels: build routing key for %q: %w", trimmedID, err) - } - return canonicalKey, nil -} - -// ResolveRoute resolves one route by canonical routing identity. -func (s *Service) ResolveRoute(ctx context.Context, key RoutingKey) (*ChannelRoute, error) { - if err := s.checkReady(ctx, "resolve channel route"); err != nil { - return nil, err - } - - trimmedID := strings.TrimSpace(key.ChannelInstanceID) - instance, err := s.loadRoutableInstance(ctx, trimmedID) - if err != nil { - return nil, fmt.Errorf("channels: resolve channel route for %q: load channel instance: %w", trimmedID, err) - } - - canonicalKey, err := CanonicalizeRoutingKey(instance, key) - if err != nil { - return nil, fmt.Errorf("channels: resolve channel route for %q: canonicalize routing key: %w", trimmedID, err) - } - - route, err := s.store.ResolveChannelRoute(ctx, canonicalKey) - if err != nil { - return nil, fmt.Errorf("channels: resolve channel route for %q: lookup route: %w", trimmedID, err) - } - return cloneChannelRoute(route), nil -} - -// ResolveOrCreateRoute reuses an existing session binding for the canonical key -// or persists the supplied route when no binding exists yet. -func (s *Service) ResolveOrCreateRoute(ctx context.Context, route ChannelRoute) (*ChannelRoute, bool, error) { - if err := s.checkReady(ctx, "resolve or create channel route"); err != nil { - return nil, false, err - } - - trimmedID := strings.TrimSpace(route.ChannelInstanceID) - instance, err := s.loadRoutableInstance(ctx, trimmedID) - if err != nil { - return nil, false, fmt.Errorf("channels: resolve or create channel route for %q: load channel instance: %w", trimmedID, err) - } - - canonicalRoute, err := CanonicalizeRoute(instance, route) - if err != nil { - return nil, false, fmt.Errorf("channels: resolve or create channel route for %q: canonicalize route: %w", trimmedID, err) - } - - existing, err := s.store.ResolveChannelRoute(ctx, canonicalRoute.RoutingKey()) - if err == nil { - refreshed := existing - refreshed.LastActivityAt = canonicalRoute.LastActivityAt - refreshed.UpdatedAt = canonicalRoute.UpdatedAt - refreshed = s.prepareRouteForWrite(refreshed, &existing) - if err := s.store.PutChannelRoute(ctx, refreshed); err != nil { - return nil, false, fmt.Errorf("channels: resolve or create channel route for %q: refresh route: %w", trimmedID, err) - } - return cloneChannelRoute(refreshed), false, nil - } - if !errors.Is(err, ErrChannelRouteNotFound) { - return nil, false, fmt.Errorf("channels: resolve or create channel route for %q: lookup route: %w", trimmedID, err) - } - - canonicalRoute = s.prepareRouteForWrite(canonicalRoute, nil) - if err := s.store.PutChannelRoute(ctx, canonicalRoute); err != nil { - return nil, false, fmt.Errorf("channels: resolve or create channel route for %q: create route: %w", trimmedID, err) - } - - return cloneChannelRoute(canonicalRoute), true, nil -} - -// UpsertRoute writes a route using the canonical key derived from the owning instance policy. -func (s *Service) UpsertRoute(ctx context.Context, route ChannelRoute) (*ChannelRoute, error) { - if err := s.checkReady(ctx, "upsert channel route"); err != nil { - return nil, err - } - - trimmedID := strings.TrimSpace(route.ChannelInstanceID) - instance, err := s.loadRoutableInstance(ctx, trimmedID) - if err != nil { - return nil, fmt.Errorf("channels: upsert channel route for %q: load channel instance: %w", trimmedID, err) - } - - canonicalRoute, err := CanonicalizeRoute(instance, route) - if err != nil { - return nil, fmt.Errorf("channels: upsert channel route for %q: canonicalize route: %w", trimmedID, err) - } - - existing, err := s.store.ResolveChannelRoute(ctx, canonicalRoute.RoutingKey()) - if err != nil && !errors.Is(err, ErrChannelRouteNotFound) { - return nil, fmt.Errorf("channels: upsert channel route for %q: lookup route: %w", trimmedID, err) - } - var existingRoute *ChannelRoute - if err == nil { - existingRoute = &existing - } - - canonicalRoute = s.prepareRouteForWrite(canonicalRoute, existingRoute) - if err := s.store.PutChannelRoute(ctx, canonicalRoute); err != nil { - return nil, fmt.Errorf("channels: upsert channel route for %q: persist route: %w", trimmedID, err) - } - - return cloneChannelRoute(canonicalRoute), nil -} - -// ListRoutes returns the persisted routes owned by one channel instance. -func (s *Service) ListRoutes(ctx context.Context, channelInstanceID string) ([]ChannelRoute, error) { - if err := s.checkReady(ctx, "list channel routes"); err != nil { - return nil, err - } - - trimmedInstanceID := strings.TrimSpace(channelInstanceID) - if _, err := s.store.GetChannelInstance(ctx, trimmedInstanceID); err != nil { - return nil, fmt.Errorf("channels: list channel routes for %q: load channel instance: %w", trimmedInstanceID, err) - } - routes, err := s.store.ListChannelRoutes(ctx, trimmedInstanceID) - if err != nil { - return nil, fmt.Errorf("channels: list channel routes for %q: %w", trimmedInstanceID, err) - } - if len(routes) == 0 { - return routes, nil - } - - cloned := make([]ChannelRoute, 0, len(routes)) - for _, route := range routes { - cloned = append(cloned, *cloneChannelRoute(route)) - } - return cloned, nil -} - -func (s *Service) checkReady(ctx context.Context, action string) error { - if s == nil { - return errors.New("channels: registry is required") - } - if s.store == nil { - return errors.New("channels: registry store is required") - } - if ctx == nil { - return fmt.Errorf("channels: %s context is required", action) - } - return nil -} - -func (s *Service) loadRoutableInstance(ctx context.Context, channelInstanceID string) (ChannelInstance, error) { - instance, err := s.store.GetChannelInstance(ctx, channelInstanceID) - if err != nil { - return ChannelInstance{}, err - } - if !instance.Enabled || instance.Status.Normalize() == ChannelStatusDisabled { - return ChannelInstance{}, fmt.Errorf("%w: %s", ErrChannelInstanceUnavailable, instance.ID) - } - return instance, nil -} - -func (s *Service) prepareRouteForWrite(route ChannelRoute, existing *ChannelRoute) ChannelRoute { - prepared := route.normalize() - - activityAt := prepared.LastActivityAt - if activityAt.IsZero() { - activityAt = s.now() - } - prepared.LastActivityAt = activityAt - - if existing != nil && prepared.CreatedAt.IsZero() { - prepared.CreatedAt = existing.CreatedAt - } - if prepared.CreatedAt.IsZero() { - prepared.CreatedAt = activityAt - } - if prepared.UpdatedAt.IsZero() { - prepared.UpdatedAt = activityAt - } - - return prepared -} - -func (r CreateInstanceRequest) toInstance(now func() time.Time) (ChannelInstance, error) { - clock := now - if clock == nil { - clock = func() time.Time { - return time.Now().UTC() - } - } - - instance := ChannelInstance{ - ID: strings.TrimSpace(r.ID), - Scope: r.Scope.Normalize(), - WorkspaceID: strings.TrimSpace(r.WorkspaceID), - Platform: strings.TrimSpace(r.Platform), - ExtensionName: strings.TrimSpace(r.ExtensionName), - DisplayName: strings.TrimSpace(r.DisplayName), - Enabled: r.Enabled, - Status: r.Status.Normalize(), - RoutingPolicy: r.RoutingPolicy, - DeliveryDefaults: r.DeliveryDefaults, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - } - if instance.ID == "" { - instance.ID = store.NewID("chan") - } - if instance.CreatedAt.IsZero() { - instance.CreatedAt = clock() - } - if instance.UpdatedAt.IsZero() { - instance.UpdatedAt = instance.CreatedAt - } - - deliveryDefaults, err := normalizeRawJSON(instance.DeliveryDefaults, "channel instance delivery defaults") - if err != nil { - return ChannelInstance{}, err - } - instance.DeliveryDefaults = deliveryDefaults - - if err := instance.Validate(); err != nil { - return ChannelInstance{}, err - } - - return instance, nil -} - -func cloneChannelInstance(instance ChannelInstance) *ChannelInstance { - cloned := instance - if instance.DeliveryDefaults != nil { - cloned.DeliveryDefaults = append(json.RawMessage(nil), instance.DeliveryDefaults...) - } - return &cloned -} - -func cloneChannelRoute(route ChannelRoute) *ChannelRoute { - cloned := route - return &cloned -} diff --git a/internal/cli/channel.go b/internal/cli/bridge.go similarity index 67% rename from internal/cli/channel.go rename to internal/cli/bridge.go index 1b8a1f175..8cd55ff9c 100644 --- a/internal/cli/channel.go +++ b/internal/cli/bridge.go @@ -7,53 +7,53 @@ import ( "strings" "time" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" "github.com/spf13/cobra" ) -const channelDeliveryDefaultsFlag = "delivery-defaults" +const bridgeDeliveryDefaultsFlag = "delivery-defaults" -func newChannelCommand(deps commandDeps) *cobra.Command { +func newBridgeCommand(deps commandDeps) *cobra.Command { cmd := &cobra.Command{ - Use: "channel", - Short: "Manage channel instances", + Use: "bridge", + Short: "Manage bridge instances", } - cmd.AddCommand(newChannelListCommand(deps)) - cmd.AddCommand(newChannelGetCommand(deps)) - cmd.AddCommand(newChannelCreateCommand(deps)) - cmd.AddCommand(newChannelUpdateCommand(deps)) - cmd.AddCommand(newChannelEnableCommand(deps)) - cmd.AddCommand(newChannelDisableCommand(deps)) - cmd.AddCommand(newChannelRestartCommand(deps)) - cmd.AddCommand(newChannelRoutesCommand(deps)) - cmd.AddCommand(newChannelTestDeliveryCommand(deps)) + cmd.AddCommand(newBridgeListCommand(deps)) + cmd.AddCommand(newBridgeGetCommand(deps)) + cmd.AddCommand(newBridgeCreateCommand(deps)) + cmd.AddCommand(newBridgeUpdateCommand(deps)) + cmd.AddCommand(newBridgeEnableCommand(deps)) + cmd.AddCommand(newBridgeDisableCommand(deps)) + cmd.AddCommand(newBridgeRestartCommand(deps)) + cmd.AddCommand(newBridgeRoutesCommand(deps)) + cmd.AddCommand(newBridgeTestDeliveryCommand(deps)) return cmd } -func newChannelListCommand(deps commandDeps) *cobra.Command { +func newBridgeListCommand(deps commandDeps) *cobra.Command { return &cobra.Command{ Use: "list", - Short: "List channel instances", + Short: "List bridge instances", RunE: func(cmd *cobra.Command, _ []string) error { client, _, err := clientFromDeps(deps) if err != nil { return err } - items, err := client.ListChannels(cmd.Context()) + items, err := client.ListBridges(cmd.Context()) if err != nil { return err } - return writeCommandOutput(cmd, channelListBundle(items, deps.now)) + return writeCommandOutput(cmd, bridgeListBundle(items, deps.now)) }, } } -func newChannelGetCommand(deps commandDeps) *cobra.Command { +func newBridgeGetCommand(deps commandDeps) *cobra.Command { return &cobra.Command{ Use: "get ", - Short: "Show one channel instance", + Short: "Show one bridge instance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, _, err := clientFromDeps(deps) @@ -61,16 +61,16 @@ func newChannelGetCommand(deps commandDeps) *cobra.Command { return err } - item, err := client.GetChannel(cmd.Context(), args[0]) + item, err := client.GetBridge(cmd.Context(), args[0]) if err != nil { return err } - return writeCommandOutput(cmd, channelBundle(item)) + return writeCommandOutput(cmd, bridgeBundle(item)) }, } } -func newChannelCreateCommand(deps commandDeps) *cobra.Command { +func newBridgeCreateCommand(deps commandDeps) *cobra.Command { var ( scopeRaw string workspaceID string @@ -87,23 +87,23 @@ func newChannelCreateCommand(deps commandDeps) *cobra.Command { cmd := &cobra.Command{ Use: "create", - Short: "Create a channel instance", + Short: "Create a bridge instance", RunE: func(cmd *cobra.Command, _ []string) error { client, _, err := clientFromDeps(deps) if err != nil { return err } - scope, err := parseChannelScope(scopeRaw) + scope, err := parseBridgeScope(scopeRaw) if err != nil { return err } - status, err := resolveChannelStatus(enabled, statusRaw) + status, err := resolveBridgeStatus(enabled, statusRaw) if err != nil { return err } - payload := CreateChannelRequest{ + payload := CreateBridgeRequest{ Scope: scope, WorkspaceID: strings.TrimSpace(workspaceID), Platform: strings.TrimSpace(platform), @@ -111,44 +111,44 @@ func newChannelCreateCommand(deps commandDeps) *cobra.Command { DisplayName: strings.TrimSpace(displayName), Enabled: enabled, Status: status, - RoutingPolicy: channelspkg.RoutingPolicy{ + RoutingPolicy: bridgepkg.RoutingPolicy{ IncludePeer: includePeer, IncludeThread: includeThread, IncludeGroup: includeGroup, }, } - if raw, err := parseOptionalChannelJSON(deliveryDefaults); err != nil { + if raw, err := parseOptionalBridgeJSON(deliveryDefaults); err != nil { return err } else if raw != nil { payload.DeliveryDefaults = *raw } - item, err := client.CreateChannel(cmd.Context(), payload) + item, err := client.CreateBridge(cmd.Context(), payload) if err != nil { return err } - return writeCommandOutput(cmd, channelBundle(item)) + return writeCommandOutput(cmd, bridgeBundle(item)) }, } - cmd.Flags().StringVar(&scopeRaw, "scope", string(channelspkg.ScopeGlobal), "Channel scope: global or workspace") - cmd.Flags().StringVar(&workspaceID, "workspace-id", "", "Owning workspace ID for workspace-scoped channels") + cmd.Flags().StringVar(&scopeRaw, "scope", string(bridgepkg.ScopeGlobal), "Bridge scope: global or workspace") + cmd.Flags().StringVar(&workspaceID, "workspace-id", "", "Owning workspace ID for workspace-scoped bridges") cmd.Flags().StringVar(&platform, "platform", "", "Messaging platform name") cmd.Flags().StringVar(&extensionName, "extension", "", "Owning extension name") - cmd.Flags().StringVar(&displayName, "display-name", "", "Operator-facing channel display name") + cmd.Flags().StringVar(&displayName, "display-name", "", "Operator-facing bridge display name") cmd.Flags().BoolVar(&enabled, "enabled", true, "Whether the instance starts enabled") cmd.Flags().StringVar(&statusRaw, "status", "", "Lifecycle status (defaults to starting when enabled, disabled otherwise)") cmd.Flags().BoolVar(&includePeer, "include-peer", false, "Include peer identity in routing") cmd.Flags().BoolVar(&includeThread, "include-thread", false, "Include thread identity in routing") cmd.Flags().BoolVar(&includeGroup, "include-group", false, "Include group identity in routing") - cmd.Flags().StringVar(&deliveryDefaults, channelDeliveryDefaultsFlag, "", "JSON object or null for delivery target defaults") + cmd.Flags().StringVar(&deliveryDefaults, bridgeDeliveryDefaultsFlag, "", "JSON object or null for delivery target defaults") _ = cmd.MarkFlagRequired("platform") _ = cmd.MarkFlagRequired("extension") _ = cmd.MarkFlagRequired("display-name") return cmd } -func newChannelUpdateCommand(deps commandDeps) *cobra.Command { +func newBridgeUpdateCommand(deps commandDeps) *cobra.Command { var ( displayName string includePeer bool @@ -159,7 +159,7 @@ func newChannelUpdateCommand(deps commandDeps) *cobra.Command { cmd := &cobra.Command{ Use: "update ", - Short: "Update mutable channel fields", + Short: "Update mutable bridge fields", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, _, err := clientFromDeps(deps) @@ -168,13 +168,13 @@ func newChannelUpdateCommand(deps commandDeps) *cobra.Command { } displayChanged := cmd.Flags().Changed("display-name") - routingChanged := channelRoutingFlagsChanged(cmd) - deliveryChanged := cmd.Flags().Changed(channelDeliveryDefaultsFlag) + routingChanged := bridgeRoutingFlagsChanged(cmd) + deliveryChanged := cmd.Flags().Changed(bridgeDeliveryDefaultsFlag) if !displayChanged && !routingChanged && !deliveryChanged { return errors.New("cli: at least one update flag is required") } - req := UpdateChannelRequest{} + req := UpdateBridgeRequest{} if displayChanged { trimmed := strings.TrimSpace(displayName) if trimmed == "" { @@ -184,7 +184,7 @@ func newChannelUpdateCommand(deps commandDeps) *cobra.Command { } if routingChanged { - current, err := client.GetChannel(cmd.Context(), args[0]) + current, err := client.GetBridge(cmd.Context(), args[0]) if err != nil { return err } @@ -202,32 +202,32 @@ func newChannelUpdateCommand(deps commandDeps) *cobra.Command { } if deliveryChanged { - raw, err := parseRequiredChannelJSON(strings.TrimSpace(deliveryDefaults)) + raw, err := parseRequiredBridgeJSON(strings.TrimSpace(deliveryDefaults)) if err != nil { return err } req.DeliveryDefaults = raw } - item, err := client.UpdateChannel(cmd.Context(), args[0], req) + item, err := client.UpdateBridge(cmd.Context(), args[0], req) if err != nil { return err } - return writeCommandOutput(cmd, channelBundle(item)) + return writeCommandOutput(cmd, bridgeBundle(item)) }, } - cmd.Flags().StringVar(&displayName, "display-name", "", "New operator-facing channel display name") + cmd.Flags().StringVar(&displayName, "display-name", "", "New operator-facing bridge display name") cmd.Flags().BoolVar(&includePeer, "include-peer", false, "Override whether routing includes peer identity") cmd.Flags().BoolVar(&includeThread, "include-thread", false, "Override whether routing includes thread identity") cmd.Flags().BoolVar(&includeGroup, "include-group", false, "Override whether routing includes group identity") - cmd.Flags().StringVar(&deliveryDefaults, channelDeliveryDefaultsFlag, "", "JSON object or null for delivery target defaults") + cmd.Flags().StringVar(&deliveryDefaults, bridgeDeliveryDefaultsFlag, "", "JSON object or null for delivery target defaults") return cmd } -func newChannelEnableCommand(deps commandDeps) *cobra.Command { +func newBridgeEnableCommand(deps commandDeps) *cobra.Command { return &cobra.Command{ Use: "enable ", - Short: "Enable a channel instance", + Short: "Enable a bridge instance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, _, err := clientFromDeps(deps) @@ -235,19 +235,19 @@ func newChannelEnableCommand(deps commandDeps) *cobra.Command { return err } - item, err := client.EnableChannel(cmd.Context(), args[0]) + item, err := client.EnableBridge(cmd.Context(), args[0]) if err != nil { return err } - return writeCommandOutput(cmd, channelBundle(item)) + return writeCommandOutput(cmd, bridgeBundle(item)) }, } } -func newChannelDisableCommand(deps commandDeps) *cobra.Command { +func newBridgeDisableCommand(deps commandDeps) *cobra.Command { return &cobra.Command{ Use: "disable ", - Short: "Disable a channel instance", + Short: "Disable a bridge instance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, _, err := clientFromDeps(deps) @@ -255,19 +255,19 @@ func newChannelDisableCommand(deps commandDeps) *cobra.Command { return err } - item, err := client.DisableChannel(cmd.Context(), args[0]) + item, err := client.DisableBridge(cmd.Context(), args[0]) if err != nil { return err } - return writeCommandOutput(cmd, channelBundle(item)) + return writeCommandOutput(cmd, bridgeBundle(item)) }, } } -func newChannelRestartCommand(deps commandDeps) *cobra.Command { +func newBridgeRestartCommand(deps commandDeps) *cobra.Command { return &cobra.Command{ Use: "restart ", - Short: "Restart a channel instance", + Short: "Restart a bridge instance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, _, err := clientFromDeps(deps) @@ -275,19 +275,19 @@ func newChannelRestartCommand(deps commandDeps) *cobra.Command { return err } - item, err := client.RestartChannel(cmd.Context(), args[0]) + item, err := client.RestartBridge(cmd.Context(), args[0]) if err != nil { return err } - return writeCommandOutput(cmd, channelBundle(item)) + return writeCommandOutput(cmd, bridgeBundle(item)) }, } } -func newChannelRoutesCommand(deps commandDeps) *cobra.Command { +func newBridgeRoutesCommand(deps commandDeps) *cobra.Command { return &cobra.Command{ Use: "routes ", - Short: "Inspect routes for one channel instance", + Short: "Inspect routes for one bridge instance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, _, err := clientFromDeps(deps) @@ -295,16 +295,16 @@ func newChannelRoutesCommand(deps commandDeps) *cobra.Command { return err } - routes, err := client.ChannelRoutes(cmd.Context(), args[0]) + routes, err := client.BridgeRoutes(cmd.Context(), args[0]) if err != nil { return err } - return writeCommandOutput(cmd, channelRoutesBundle(routes, deps.now)) + return writeCommandOutput(cmd, bridgeRoutesBundle(routes, deps.now)) }, } } -func newChannelTestDeliveryCommand(deps commandDeps) *cobra.Command { +func newBridgeTestDeliveryCommand(deps commandDeps) *cobra.Command { var ( message string peerID string @@ -315,7 +315,7 @@ func newChannelTestDeliveryCommand(deps commandDeps) *cobra.Command { cmd := &cobra.Command{ Use: "test-delivery ", - Short: "Resolve a typed outbound delivery target for one channel instance", + Short: "Resolve a typed outbound delivery target for one bridge instance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, _, err := clientFromDeps(deps) @@ -323,16 +323,16 @@ func newChannelTestDeliveryCommand(deps commandDeps) *cobra.Command { return err } - mode := channelspkg.DeliveryMode(strings.TrimSpace(modeRaw)).Normalize() + mode := bridgepkg.DeliveryMode(strings.TrimSpace(modeRaw)).Normalize() if mode != "" { if err := mode.Validate(); err != nil { return err } } - item, err := client.TestChannelDelivery(cmd.Context(), args[0], ChannelTestDeliveryRequest{ + item, err := client.TestBridgeDelivery(cmd.Context(), args[0], BridgeTestDeliveryRequest{ Message: strings.TrimSpace(message), - Target: ChannelDeliveryTargetInput{ + Target: BridgeDeliveryTargetInput{ PeerID: strings.TrimSpace(peerID), ThreadID: strings.TrimSpace(threadID), GroupID: strings.TrimSpace(groupID), @@ -342,7 +342,7 @@ func newChannelTestDeliveryCommand(deps commandDeps) *cobra.Command { if err != nil { return err } - return writeCommandOutput(cmd, channelTestDeliveryBundle(item)) + return writeCommandOutput(cmd, bridgeTestDeliveryBundle(item)) }, } cmd.Flags().StringVar(&message, "message", "", "Optional dry-run message label") @@ -353,15 +353,15 @@ func newChannelTestDeliveryCommand(deps commandDeps) *cobra.Command { return cmd } -func channelListBundle(items []ChannelRecord, now func() time.Time) outputBundle { +func bridgeListBundle(items []BridgeRecord, now func() time.Time) outputBundle { return listBundle( items, items, - "Channels", + "Bridges", []string{"ID", "Name", "Platform", "Extension", "Scope", "Workspace", "Status", "Routing", "Updated"}, - "channels", + "bridges", []string{"id", "display_name", "platform", "extension_name", "scope", "workspace_id", "status", "routing", "updated_at"}, - func(item ChannelRecord) []string { + func(item BridgeRecord) []string { return []string{ stringOrDash(item.ID), stringOrDash(item.DisplayName), @@ -370,11 +370,11 @@ func channelListBundle(items []ChannelRecord, now func() time.Time) outputBundle stringOrDash(string(item.Scope)), stringOrDash(item.WorkspaceID), stringOrDash(string(item.Status)), - stringOrDash(channelRoutingPolicyLabel(item.RoutingPolicy)), + stringOrDash(bridgeRoutingPolicyLabel(item.RoutingPolicy)), stringOrDash(formatAge(now, item.UpdatedAt)), } }, - func(item ChannelRecord) []string { + func(item BridgeRecord) []string { return []string{ item.ID, item.DisplayName, @@ -383,18 +383,18 @@ func channelListBundle(items []ChannelRecord, now func() time.Time) outputBundle string(item.Scope), item.WorkspaceID, string(item.Status), - channelRoutingPolicyLabel(item.RoutingPolicy), + bridgeRoutingPolicyLabel(item.RoutingPolicy), formatTime(item.UpdatedAt), } }, ) } -func channelBundle(item ChannelRecord) outputBundle { +func bridgeBundle(item BridgeRecord) outputBundle { return outputBundle{ jsonValue: item, human: func() (string, error) { - return renderHumanSection("Channel", []keyValue{ + return renderHumanSection("Bridge", []keyValue{ {Label: "ID", Value: stringOrDash(item.ID)}, {Label: "Name", Value: stringOrDash(item.DisplayName)}, {Label: "Platform", Value: stringOrDash(item.Platform)}, @@ -403,14 +403,14 @@ func channelBundle(item ChannelRecord) outputBundle { {Label: "Workspace", Value: stringOrDash(item.WorkspaceID)}, {Label: "Enabled", Value: fmt.Sprintf("%t", item.Enabled)}, {Label: "Status", Value: stringOrDash(string(item.Status))}, - {Label: "Routing", Value: stringOrDash(channelRoutingPolicyLabel(item.RoutingPolicy))}, + {Label: "Routing", Value: stringOrDash(bridgeRoutingPolicyLabel(item.RoutingPolicy))}, {Label: "Delivery Defaults", Value: stringOrDash(compactJSON(item.DeliveryDefaults))}, {Label: "Created", Value: stringOrDash(formatTime(item.CreatedAt))}, {Label: "Updated", Value: stringOrDash(formatTime(item.UpdatedAt))}, }), nil }, toon: func() (string, error) { - return renderToonObject("channel", []string{ + return renderToonObject("bridge", []string{ "id", "display_name", "platform", "extension_name", "scope", "workspace_id", "enabled", "status", "routing", "include_peer", "include_thread", "include_group", "delivery_defaults", "created_at", "updated_at", }, []string{ item.ID, @@ -421,7 +421,7 @@ func channelBundle(item ChannelRecord) outputBundle { item.WorkspaceID, fmt.Sprintf("%t", item.Enabled), string(item.Status), - channelRoutingPolicyLabel(item.RoutingPolicy), + bridgeRoutingPolicyLabel(item.RoutingPolicy), fmt.Sprintf("%t", item.RoutingPolicy.IncludePeer), fmt.Sprintf("%t", item.RoutingPolicy.IncludeThread), fmt.Sprintf("%t", item.RoutingPolicy.IncludeGroup), @@ -433,15 +433,15 @@ func channelBundle(item ChannelRecord) outputBundle { } } -func channelRoutesBundle(routes []ChannelRouteRecord, now func() time.Time) outputBundle { +func bridgeRoutesBundle(routes []BridgeRouteRecord, now func() time.Time) outputBundle { return listBundle( routes, routes, - "Channel Routes", + "Bridge Routes", []string{"Hash", "Scope", "Workspace", "Peer", "Thread", "Group", "Session", "Agent", "Last Active"}, - "channel_routes", + "bridge_routes", []string{"routing_key_hash", "scope", "workspace_id", "peer_id", "thread_id", "group_id", "session_id", "agent_name", "last_activity_at"}, - func(route ChannelRouteRecord) []string { + func(route BridgeRouteRecord) []string { return []string{ stringOrDash(route.RoutingKeyHash), stringOrDash(string(route.Scope)), @@ -454,7 +454,7 @@ func channelRoutesBundle(routes []ChannelRouteRecord, now func() time.Time) outp stringOrDash(formatAge(now, route.LastActivityAt)), } }, - func(route ChannelRouteRecord) []string { + func(route BridgeRouteRecord) []string { return []string{ route.RoutingKeyHash, string(route.Scope), @@ -470,7 +470,7 @@ func channelRoutesBundle(routes []ChannelRouteRecord, now func() time.Time) outp ) } -func channelTestDeliveryBundle(item ChannelTestDeliveryRecord) outputBundle { +func bridgeTestDeliveryBundle(item BridgeTestDeliveryRecord) outputBundle { return outputBundle{ jsonValue: item, human: func() (string, error) { @@ -480,7 +480,7 @@ func channelTestDeliveryBundle(item ChannelTestDeliveryRecord) outputBundle { {Label: "Message", Value: stringOrDash(item.Message)}, }), renderHumanSection("Delivery Target", []keyValue{ - {Label: "Channel", Value: stringOrDash(item.DeliveryTarget.ChannelInstanceID)}, + {Label: "Bridge", Value: stringOrDash(item.DeliveryTarget.BridgeInstanceID)}, {Label: "Peer", Value: stringOrDash(item.DeliveryTarget.PeerID)}, {Label: "Thread", Value: stringOrDash(item.DeliveryTarget.ThreadID)}, {Label: "Group", Value: stringOrDash(item.DeliveryTarget.GroupID)}, @@ -490,11 +490,11 @@ func channelTestDeliveryBundle(item ChannelTestDeliveryRecord) outputBundle { }, toon: func() (string, error) { return renderToonObject("test_delivery", []string{ - "status", "message", "channel_instance_id", "peer_id", "thread_id", "group_id", "mode", + "status", "message", "bridge_instance_id", "peer_id", "thread_id", "group_id", "mode", }, []string{ item.Status, item.Message, - item.DeliveryTarget.ChannelInstanceID, + item.DeliveryTarget.BridgeInstanceID, item.DeliveryTarget.PeerID, item.DeliveryTarget.ThreadID, item.DeliveryTarget.GroupID, @@ -504,30 +504,30 @@ func channelTestDeliveryBundle(item ChannelTestDeliveryRecord) outputBundle { } } -func parseChannelScope(raw string) (channelspkg.Scope, error) { - scope := channelspkg.Scope(strings.TrimSpace(raw)).Normalize() +func parseBridgeScope(raw string) (bridgepkg.Scope, error) { + scope := bridgepkg.Scope(strings.TrimSpace(raw)).Normalize() if err := scope.Validate(); err != nil { return "", err } return scope, nil } -func resolveChannelStatus(enabled bool, raw string) (channelspkg.ChannelStatus, error) { +func resolveBridgeStatus(enabled bool, raw string) (bridgepkg.BridgeStatus, error) { if strings.TrimSpace(raw) == "" { if enabled { - return channelspkg.ChannelStatusStarting, nil + return bridgepkg.BridgeStatusStarting, nil } - return channelspkg.ChannelStatusDisabled, nil + return bridgepkg.BridgeStatusDisabled, nil } - status := channelspkg.ChannelStatus(strings.TrimSpace(raw)).Normalize() + status := bridgepkg.BridgeStatus(strings.TrimSpace(raw)).Normalize() if err := status.Validate(); err != nil { return "", err } return status, nil } -func channelRoutingFlagsChanged(cmd *cobra.Command) bool { +func bridgeRoutingFlagsChanged(cmd *cobra.Command) bool { if cmd == nil { return false } @@ -536,15 +536,15 @@ func channelRoutingFlagsChanged(cmd *cobra.Command) bool { cmd.Flags().Changed("include-group") } -func parseOptionalChannelJSON(raw string) (*json.RawMessage, error) { +func parseOptionalBridgeJSON(raw string) (*json.RawMessage, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return nil, nil } - return parseRequiredChannelJSON(trimmed) + return parseRequiredBridgeJSON(trimmed) } -func parseRequiredChannelJSON(raw string) (*json.RawMessage, error) { +func parseRequiredBridgeJSON(raw string) (*json.RawMessage, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return nil, errors.New("cli: delivery defaults must be valid JSON; use null to clear") @@ -556,7 +556,7 @@ func parseRequiredChannelJSON(raw string) (*json.RawMessage, error) { return &value, nil } -func channelRoutingPolicyLabel(policy channelspkg.RoutingPolicy) string { +func bridgeRoutingPolicyLabel(policy bridgepkg.RoutingPolicy) string { dimensions := make([]string, 0, 3) if policy.IncludePeer { dimensions = append(dimensions, "peer") diff --git a/internal/cli/bridge_test.go b/internal/cli/bridge_test.go new file mode 100644 index 000000000..4e4ba0669 --- /dev/null +++ b/internal/cli/bridge_test.go @@ -0,0 +1,417 @@ +package cli + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + bridgepkg "github.com/pedronauck/agh/internal/bridges" +) + +func TestBridgeListRendersScopePlatformAndStatusInHumanOutput(t *testing.T) { + t.Parallel() + + deps := newTestDeps(t, stubClient{ + listBridgesFn: func(context.Context) ([]BridgeRecord, error) { + return []BridgeRecord{testBridgeRecord(t)}, nil + }, + }) + + stdout, _, err := executeRootCommand(t, deps, "bridge", "list", "-o", "human") + if err != nil { + t.Fatalf("bridge list human error = %v", err) + } + + for _, token := range []string{"Bridges", "Platform", "Scope", "Status", "telegram", "workspace", "ready", "peer, thread"} { + if !strings.Contains(stdout, token) { + t.Fatalf("bridge list human output missing %q: %s", token, stdout) + } + } +} + +func TestBridgeGetReturnsStructuredJSONOutput(t *testing.T) { + t.Parallel() + + expected := testBridgeRecord(t) + deps := newTestDeps(t, stubClient{ + getBridgeFn: func(_ context.Context, id string) (BridgeRecord, error) { + if id != expected.ID { + t.Fatalf("GetBridge() id = %q, want %q", id, expected.ID) + } + return expected, nil + }, + }) + + stdout, _, err := executeRootCommand(t, deps, "bridge", "get", expected.ID, "-o", "json") + if err != nil { + t.Fatalf("bridge get json error = %v", err) + } + + var decoded BridgeRecord + if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { + t.Fatalf("json.Unmarshal(bridge get) error = %v", err) + } + if decoded.ID != expected.ID || decoded.Scope != expected.Scope || decoded.Status != expected.Status || decoded.WorkspaceID != expected.WorkspaceID { + t.Fatalf("decoded = %#v, want %#v", decoded, expected) + } +} + +func TestBridgeCreateBuildsSharedRequestAndDerivesDisabledStatus(t *testing.T) { + t.Parallel() + + var captured CreateBridgeRequest + deps := newTestDeps(t, stubClient{ + createBridgeFn: func(_ context.Context, request CreateBridgeRequest) (BridgeRecord, error) { + captured = request + record := testBridgeRecord(t) + record.Enabled = request.Enabled + record.Status = request.Status + record.Scope = request.Scope + record.WorkspaceID = request.WorkspaceID + record.Platform = request.Platform + record.ExtensionName = request.ExtensionName + record.DisplayName = request.DisplayName + record.RoutingPolicy = request.RoutingPolicy + record.DeliveryDefaults = request.DeliveryDefaults + return record, nil + }, + }) + + stdout, _, err := executeRootCommand( + t, + deps, + "bridge", "create", + "--scope", "workspace", + "--workspace-id", "ws-alpha", + "--platform", "telegram", + "--extension", "ext-telegram", + "--display-name", "Support", + "--enabled=false", + "--include-peer", + "--include-group", + "--delivery-defaults", `{"mode":"reply","group_id":"group-1"}`, + "-o", "json", + ) + if err != nil { + t.Fatalf("bridge create error = %v", err) + } + + if captured.Scope != bridgepkg.ScopeWorkspace || captured.WorkspaceID != "ws-alpha" { + t.Fatalf("captured scope payload = %#v", captured) + } + if captured.Status != bridgepkg.BridgeStatusDisabled || captured.Enabled { + t.Fatalf("captured lifecycle = enabled:%t status:%q, want false/disabled", captured.Enabled, captured.Status) + } + if !captured.RoutingPolicy.IncludePeer || !captured.RoutingPolicy.IncludeGroup || captured.RoutingPolicy.IncludeThread { + t.Fatalf("captured routing policy = %#v", captured.RoutingPolicy) + } + if string(captured.DeliveryDefaults) != `{"mode":"reply","group_id":"group-1"}` { + t.Fatalf("captured delivery defaults = %s", string(captured.DeliveryDefaults)) + } + + var decoded BridgeRecord + if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { + t.Fatalf("json.Unmarshal(bridge create) error = %v", err) + } + if decoded.Status != bridgepkg.BridgeStatusDisabled { + t.Fatalf("decoded.Status = %q, want disabled", decoded.Status) + } +} + +func TestBridgeUpdateMergesRoutingPolicyAndAllowsNullDeliveryDefaults(t *testing.T) { + t.Parallel() + + current := testBridgeRecord(t) + current.RoutingPolicy = bridgepkg.RoutingPolicy{ + IncludePeer: true, + IncludeThread: false, + IncludeGroup: true, + } + + var ( + getCalls int + captured UpdateBridgeRequest + updateID string + ) + deps := newTestDeps(t, stubClient{ + getBridgeFn: func(_ context.Context, id string) (BridgeRecord, error) { + getCalls++ + if id != current.ID { + t.Fatalf("GetBridge() id = %q, want %q", id, current.ID) + } + return current, nil + }, + updateBridgeFn: func(_ context.Context, id string, request UpdateBridgeRequest) (BridgeRecord, error) { + updateID = id + captured = request + updated := current + updated.DisplayName = *request.DisplayName + updated.RoutingPolicy = *request.RoutingPolicy + updated.DeliveryDefaults = *request.DeliveryDefaults + return updated, nil + }, + }) + + stdout, _, err := executeRootCommand( + t, + deps, + "bridge", "update", current.ID, + "--display-name", "Support Ops", + "--include-thread", + "--delivery-defaults", "null", + "-o", "json", + ) + if err != nil { + t.Fatalf("bridge update error = %v", err) + } + + if getCalls != 1 || updateID != current.ID { + t.Fatalf("getCalls/updateID = %d/%q, want 1/%q", getCalls, updateID, current.ID) + } + if captured.DisplayName == nil || *captured.DisplayName != "Support Ops" { + t.Fatalf("captured display name = %#v", captured.DisplayName) + } + if captured.RoutingPolicy == nil || !captured.RoutingPolicy.IncludePeer || !captured.RoutingPolicy.IncludeThread || !captured.RoutingPolicy.IncludeGroup { + t.Fatalf("captured routing policy = %#v", captured.RoutingPolicy) + } + if captured.DeliveryDefaults == nil || string(*captured.DeliveryDefaults) != "null" { + t.Fatalf("captured delivery defaults = %#v", captured.DeliveryDefaults) + } + + var decoded BridgeRecord + if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { + t.Fatalf("json.Unmarshal(bridge update) error = %v", err) + } + if decoded.DisplayName != "Support Ops" || !decoded.RoutingPolicy.IncludeThread { + t.Fatalf("decoded = %#v, want updated display name and thread routing", decoded) + } +} + +func TestBridgeLifecycleCommandsUseDaemonClient(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + status bridgepkg.BridgeStatus + enableFn func(context.Context, string) (BridgeRecord, error) + disableFn func(context.Context, string) (BridgeRecord, error) + restartFn func(context.Context, string) (BridgeRecord, error) + }{ + { + name: "enable", + args: []string{"bridge", "enable", "brg-1", "-o", "json"}, + status: bridgepkg.BridgeStatusStarting, + enableFn: func(_ context.Context, id string) (BridgeRecord, error) { + record := testBridgeRecord(t) + record.ID = id + record.Enabled = true + record.Status = bridgepkg.BridgeStatusStarting + return record, nil + }, + }, + { + name: "disable", + args: []string{"bridge", "disable", "brg-1", "-o", "json"}, + status: bridgepkg.BridgeStatusDisabled, + disableFn: func(_ context.Context, id string) (BridgeRecord, error) { + record := testBridgeRecord(t) + record.ID = id + record.Enabled = false + record.Status = bridgepkg.BridgeStatusDisabled + return record, nil + }, + }, + { + name: "restart", + args: []string{"bridge", "restart", "brg-1", "-o", "json"}, + status: bridgepkg.BridgeStatusStarting, + restartFn: func(_ context.Context, id string) (BridgeRecord, error) { + record := testBridgeRecord(t) + record.ID = id + record.Enabled = true + record.Status = bridgepkg.BridgeStatusStarting + return record, nil + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + deps := newTestDeps(t, stubClient{ + enableBridgeFn: tt.enableFn, + disableBridgeFn: tt.disableFn, + restartBridgeFn: tt.restartFn, + }) + + stdout, _, err := executeRootCommand(t, deps, tt.args...) + if err != nil { + t.Fatalf("executeRootCommand(%v) error = %v", tt.args, err) + } + + var decoded BridgeRecord + if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { + t.Fatalf("json.Unmarshal(lifecycle output) error = %v", err) + } + if decoded.Status != tt.status { + t.Fatalf("decoded.Status = %q, want %q", decoded.Status, tt.status) + } + }) + } +} + +func TestBridgeRoutesRenderPeerThreadAndGroupSeparately(t *testing.T) { + t.Parallel() + + deps := newTestDeps(t, stubClient{ + bridgeRoutesFn: func(_ context.Context, id string) ([]BridgeRouteRecord, error) { + if id != "brg-1" { + t.Fatalf("BridgeRoutes() id = %q, want brg-1", id) + } + return []BridgeRouteRecord{{ + RoutingKeyHash: "hash-1", + Scope: bridgepkg.ScopeWorkspace, + WorkspaceID: "ws-alpha", + BridgeInstanceID: "brg-1", + PeerID: "peer-1", + ThreadID: "thread-1", + GroupID: "group-1", + SessionID: "sess-1", + AgentName: "coder", + LastActivityAt: fixedTestNow, + CreatedAt: fixedTestNow, + UpdatedAt: fixedTestNow, + }}, nil + }, + }) + + stdout, _, err := executeRootCommand(t, deps, "bridge", "routes", "brg-1", "-o", "human") + if err != nil { + t.Fatalf("bridge routes human error = %v", err) + } + + for _, token := range []string{"Bridge Routes", "Peer", "Thread", "Group", "peer-1", "thread-1", "group-1", "sess-1"} { + if !strings.Contains(stdout, token) { + t.Fatalf("bridge routes human output missing %q: %s", token, stdout) + } + } +} + +func TestBridgeTestDeliveryUsesTypedTargetPayload(t *testing.T) { + t.Parallel() + + var ( + capturedID string + capturedRequest BridgeTestDeliveryRequest + ) + deps := newTestDeps(t, stubClient{ + testBridgeDeliveryFn: func(_ context.Context, id string, request BridgeTestDeliveryRequest) (BridgeTestDeliveryRecord, error) { + capturedID = id + capturedRequest = request + return BridgeTestDeliveryRecord{ + Status: "resolved", + Message: request.Message, + DeliveryTarget: DeliveryTargetRecord{ + BridgeInstanceID: id, + PeerID: request.Target.PeerID, + ThreadID: request.Target.ThreadID, + GroupID: request.Target.GroupID, + Mode: request.Target.Mode, + }, + }, nil + }, + }) + + stdout, _, err := executeRootCommand( + t, + deps, + "bridge", "test-delivery", "brg-1", + "--message", "hello", + "--peer-id", "peer-1", + "--thread-id", "thread-1", + "--group-id", "group-1", + "--mode", "reply", + "-o", "json", + ) + if err != nil { + t.Fatalf("bridge test-delivery error = %v", err) + } + + if capturedID != "brg-1" { + t.Fatalf("capturedID = %q, want brg-1", capturedID) + } + if capturedRequest.Message != "hello" || capturedRequest.Target.PeerID != "peer-1" || capturedRequest.Target.ThreadID != "thread-1" || capturedRequest.Target.GroupID != "group-1" || capturedRequest.Target.Mode != bridgepkg.DeliveryModeReply { + t.Fatalf("capturedRequest = %#v", capturedRequest) + } + + var decoded BridgeTestDeliveryRecord + if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { + t.Fatalf("json.Unmarshal(bridge test-delivery) error = %v", err) + } + if decoded.DeliveryTarget.ThreadID != "thread-1" || decoded.DeliveryTarget.Mode != bridgepkg.DeliveryModeReply { + t.Fatalf("decoded = %#v, want typed delivery target", decoded) + } +} + +func TestBridgeBundleAndHelpers(t *testing.T) { + t.Parallel() + + record := testBridgeRecord(t) + bundle := bridgeBundle(record) + + human, err := bundle.human() + if err != nil { + t.Fatalf("bridgeBundle().human() error = %v", err) + } + if !strings.Contains(human, "Delivery Defaults") || !strings.Contains(human, `{"mode":"reply","peer_id":"peer-default"}`) { + t.Fatalf("bridgeBundle().human() = %q, want delivery defaults", human) + } + + toon, err := bundle.toon() + if err != nil { + t.Fatalf("bridgeBundle().toon() error = %v", err) + } + if !strings.Contains(toon, "bridge{id,display_name,platform,extension_name,scope,workspace_id,enabled,status,routing,include_peer,include_thread,include_group,delivery_defaults,created_at,updated_at}:") { + t.Fatalf("bridgeBundle().toon() = %q, want bridge TOON object", toon) + } + + if got := bridgeRoutingPolicyLabel(bridgepkg.RoutingPolicy{}); got != "" { + t.Fatalf("bridgeRoutingPolicyLabel(empty) = %q, want empty string", got) + } + if _, err := parseRequiredBridgeJSON("{not-json"); err == nil { + t.Fatal("parseRequiredBridgeJSON(invalid) error = nil, want non-nil") + } + if _, err := parseBridgeScope("bogus"); err == nil { + t.Fatal("parseBridgeScope(bogus) error = nil, want non-nil") + } +} + +func testBridgeRecord(t *testing.T) BridgeRecord { + t.Helper() + + return BridgeRecord{ + ID: "brg-1", + Scope: bridgepkg.ScopeWorkspace, + WorkspaceID: "ws-alpha", + Platform: "telegram", + ExtensionName: "ext-telegram", + DisplayName: "Support", + Enabled: true, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{ + IncludePeer: true, + IncludeThread: true, + }, + DeliveryDefaults: mustJSON(t, map[string]string{ + "mode": "reply", + "peer_id": "peer-default", + }), + CreatedAt: fixedTestNow.Add(-time.Hour), + UpdatedAt: fixedTestNow, + } +} diff --git a/internal/cli/channel_test.go b/internal/cli/channel_test.go deleted file mode 100644 index 568aa181d..000000000 --- a/internal/cli/channel_test.go +++ /dev/null @@ -1,417 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "strings" - "testing" - "time" - - channelspkg "github.com/pedronauck/agh/internal/channels" -) - -func TestChannelListRendersScopePlatformAndStatusInHumanOutput(t *testing.T) { - t.Parallel() - - deps := newTestDeps(t, stubClient{ - listChannelsFn: func(context.Context) ([]ChannelRecord, error) { - return []ChannelRecord{testChannelRecord(t)}, nil - }, - }) - - stdout, _, err := executeRootCommand(t, deps, "channel", "list", "-o", "human") - if err != nil { - t.Fatalf("channel list human error = %v", err) - } - - for _, token := range []string{"Channels", "Platform", "Scope", "Status", "telegram", "workspace", "ready", "peer, thread"} { - if !strings.Contains(stdout, token) { - t.Fatalf("channel list human output missing %q: %s", token, stdout) - } - } -} - -func TestChannelGetReturnsStructuredJSONOutput(t *testing.T) { - t.Parallel() - - expected := testChannelRecord(t) - deps := newTestDeps(t, stubClient{ - getChannelFn: func(_ context.Context, id string) (ChannelRecord, error) { - if id != expected.ID { - t.Fatalf("GetChannel() id = %q, want %q", id, expected.ID) - } - return expected, nil - }, - }) - - stdout, _, err := executeRootCommand(t, deps, "channel", "get", expected.ID, "-o", "json") - if err != nil { - t.Fatalf("channel get json error = %v", err) - } - - var decoded ChannelRecord - if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { - t.Fatalf("json.Unmarshal(channel get) error = %v", err) - } - if decoded.ID != expected.ID || decoded.Scope != expected.Scope || decoded.Status != expected.Status || decoded.WorkspaceID != expected.WorkspaceID { - t.Fatalf("decoded = %#v, want %#v", decoded, expected) - } -} - -func TestChannelCreateBuildsSharedRequestAndDerivesDisabledStatus(t *testing.T) { - t.Parallel() - - var captured CreateChannelRequest - deps := newTestDeps(t, stubClient{ - createChannelFn: func(_ context.Context, request CreateChannelRequest) (ChannelRecord, error) { - captured = request - record := testChannelRecord(t) - record.Enabled = request.Enabled - record.Status = request.Status - record.Scope = request.Scope - record.WorkspaceID = request.WorkspaceID - record.Platform = request.Platform - record.ExtensionName = request.ExtensionName - record.DisplayName = request.DisplayName - record.RoutingPolicy = request.RoutingPolicy - record.DeliveryDefaults = request.DeliveryDefaults - return record, nil - }, - }) - - stdout, _, err := executeRootCommand( - t, - deps, - "channel", "create", - "--scope", "workspace", - "--workspace-id", "ws-alpha", - "--platform", "telegram", - "--extension", "ext-telegram", - "--display-name", "Support", - "--enabled=false", - "--include-peer", - "--include-group", - "--delivery-defaults", `{"mode":"reply","group_id":"group-1"}`, - "-o", "json", - ) - if err != nil { - t.Fatalf("channel create error = %v", err) - } - - if captured.Scope != channelspkg.ScopeWorkspace || captured.WorkspaceID != "ws-alpha" { - t.Fatalf("captured scope payload = %#v", captured) - } - if captured.Status != channelspkg.ChannelStatusDisabled || captured.Enabled { - t.Fatalf("captured lifecycle = enabled:%t status:%q, want false/disabled", captured.Enabled, captured.Status) - } - if !captured.RoutingPolicy.IncludePeer || !captured.RoutingPolicy.IncludeGroup || captured.RoutingPolicy.IncludeThread { - t.Fatalf("captured routing policy = %#v", captured.RoutingPolicy) - } - if string(captured.DeliveryDefaults) != `{"mode":"reply","group_id":"group-1"}` { - t.Fatalf("captured delivery defaults = %s", string(captured.DeliveryDefaults)) - } - - var decoded ChannelRecord - if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { - t.Fatalf("json.Unmarshal(channel create) error = %v", err) - } - if decoded.Status != channelspkg.ChannelStatusDisabled { - t.Fatalf("decoded.Status = %q, want disabled", decoded.Status) - } -} - -func TestChannelUpdateMergesRoutingPolicyAndAllowsNullDeliveryDefaults(t *testing.T) { - t.Parallel() - - current := testChannelRecord(t) - current.RoutingPolicy = channelspkg.RoutingPolicy{ - IncludePeer: true, - IncludeThread: false, - IncludeGroup: true, - } - - var ( - getCalls int - captured UpdateChannelRequest - updateID string - ) - deps := newTestDeps(t, stubClient{ - getChannelFn: func(_ context.Context, id string) (ChannelRecord, error) { - getCalls++ - if id != current.ID { - t.Fatalf("GetChannel() id = %q, want %q", id, current.ID) - } - return current, nil - }, - updateChannelFn: func(_ context.Context, id string, request UpdateChannelRequest) (ChannelRecord, error) { - updateID = id - captured = request - updated := current - updated.DisplayName = *request.DisplayName - updated.RoutingPolicy = *request.RoutingPolicy - updated.DeliveryDefaults = *request.DeliveryDefaults - return updated, nil - }, - }) - - stdout, _, err := executeRootCommand( - t, - deps, - "channel", "update", current.ID, - "--display-name", "Support Ops", - "--include-thread", - "--delivery-defaults", "null", - "-o", "json", - ) - if err != nil { - t.Fatalf("channel update error = %v", err) - } - - if getCalls != 1 || updateID != current.ID { - t.Fatalf("getCalls/updateID = %d/%q, want 1/%q", getCalls, updateID, current.ID) - } - if captured.DisplayName == nil || *captured.DisplayName != "Support Ops" { - t.Fatalf("captured display name = %#v", captured.DisplayName) - } - if captured.RoutingPolicy == nil || !captured.RoutingPolicy.IncludePeer || !captured.RoutingPolicy.IncludeThread || !captured.RoutingPolicy.IncludeGroup { - t.Fatalf("captured routing policy = %#v", captured.RoutingPolicy) - } - if captured.DeliveryDefaults == nil || string(*captured.DeliveryDefaults) != "null" { - t.Fatalf("captured delivery defaults = %#v", captured.DeliveryDefaults) - } - - var decoded ChannelRecord - if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { - t.Fatalf("json.Unmarshal(channel update) error = %v", err) - } - if decoded.DisplayName != "Support Ops" || !decoded.RoutingPolicy.IncludeThread { - t.Fatalf("decoded = %#v, want updated display name and thread routing", decoded) - } -} - -func TestChannelLifecycleCommandsUseDaemonClient(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - args []string - status channelspkg.ChannelStatus - enableFn func(context.Context, string) (ChannelRecord, error) - disableFn func(context.Context, string) (ChannelRecord, error) - restartFn func(context.Context, string) (ChannelRecord, error) - }{ - { - name: "enable", - args: []string{"channel", "enable", "chan-1", "-o", "json"}, - status: channelspkg.ChannelStatusStarting, - enableFn: func(_ context.Context, id string) (ChannelRecord, error) { - record := testChannelRecord(t) - record.ID = id - record.Enabled = true - record.Status = channelspkg.ChannelStatusStarting - return record, nil - }, - }, - { - name: "disable", - args: []string{"channel", "disable", "chan-1", "-o", "json"}, - status: channelspkg.ChannelStatusDisabled, - disableFn: func(_ context.Context, id string) (ChannelRecord, error) { - record := testChannelRecord(t) - record.ID = id - record.Enabled = false - record.Status = channelspkg.ChannelStatusDisabled - return record, nil - }, - }, - { - name: "restart", - args: []string{"channel", "restart", "chan-1", "-o", "json"}, - status: channelspkg.ChannelStatusStarting, - restartFn: func(_ context.Context, id string) (ChannelRecord, error) { - record := testChannelRecord(t) - record.ID = id - record.Enabled = true - record.Status = channelspkg.ChannelStatusStarting - return record, nil - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - deps := newTestDeps(t, stubClient{ - enableChannelFn: tt.enableFn, - disableChannelFn: tt.disableFn, - restartChannelFn: tt.restartFn, - }) - - stdout, _, err := executeRootCommand(t, deps, tt.args...) - if err != nil { - t.Fatalf("executeRootCommand(%v) error = %v", tt.args, err) - } - - var decoded ChannelRecord - if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { - t.Fatalf("json.Unmarshal(lifecycle output) error = %v", err) - } - if decoded.Status != tt.status { - t.Fatalf("decoded.Status = %q, want %q", decoded.Status, tt.status) - } - }) - } -} - -func TestChannelRoutesRenderPeerThreadAndGroupSeparately(t *testing.T) { - t.Parallel() - - deps := newTestDeps(t, stubClient{ - channelRoutesFn: func(_ context.Context, id string) ([]ChannelRouteRecord, error) { - if id != "chan-1" { - t.Fatalf("ChannelRoutes() id = %q, want chan-1", id) - } - return []ChannelRouteRecord{{ - RoutingKeyHash: "hash-1", - Scope: channelspkg.ScopeWorkspace, - WorkspaceID: "ws-alpha", - ChannelInstanceID: "chan-1", - PeerID: "peer-1", - ThreadID: "thread-1", - GroupID: "group-1", - SessionID: "sess-1", - AgentName: "coder", - LastActivityAt: fixedTestNow, - CreatedAt: fixedTestNow, - UpdatedAt: fixedTestNow, - }}, nil - }, - }) - - stdout, _, err := executeRootCommand(t, deps, "channel", "routes", "chan-1", "-o", "human") - if err != nil { - t.Fatalf("channel routes human error = %v", err) - } - - for _, token := range []string{"Channel Routes", "Peer", "Thread", "Group", "peer-1", "thread-1", "group-1", "sess-1"} { - if !strings.Contains(stdout, token) { - t.Fatalf("channel routes human output missing %q: %s", token, stdout) - } - } -} - -func TestChannelTestDeliveryUsesTypedTargetPayload(t *testing.T) { - t.Parallel() - - var ( - capturedID string - capturedRequest ChannelTestDeliveryRequest - ) - deps := newTestDeps(t, stubClient{ - testChannelDeliveryFn: func(_ context.Context, id string, request ChannelTestDeliveryRequest) (ChannelTestDeliveryRecord, error) { - capturedID = id - capturedRequest = request - return ChannelTestDeliveryRecord{ - Status: "resolved", - Message: request.Message, - DeliveryTarget: DeliveryTargetRecord{ - ChannelInstanceID: id, - PeerID: request.Target.PeerID, - ThreadID: request.Target.ThreadID, - GroupID: request.Target.GroupID, - Mode: request.Target.Mode, - }, - }, nil - }, - }) - - stdout, _, err := executeRootCommand( - t, - deps, - "channel", "test-delivery", "chan-1", - "--message", "hello", - "--peer-id", "peer-1", - "--thread-id", "thread-1", - "--group-id", "group-1", - "--mode", "reply", - "-o", "json", - ) - if err != nil { - t.Fatalf("channel test-delivery error = %v", err) - } - - if capturedID != "chan-1" { - t.Fatalf("capturedID = %q, want chan-1", capturedID) - } - if capturedRequest.Message != "hello" || capturedRequest.Target.PeerID != "peer-1" || capturedRequest.Target.ThreadID != "thread-1" || capturedRequest.Target.GroupID != "group-1" || capturedRequest.Target.Mode != channelspkg.DeliveryModeReply { - t.Fatalf("capturedRequest = %#v", capturedRequest) - } - - var decoded ChannelTestDeliveryRecord - if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { - t.Fatalf("json.Unmarshal(channel test-delivery) error = %v", err) - } - if decoded.DeliveryTarget.ThreadID != "thread-1" || decoded.DeliveryTarget.Mode != channelspkg.DeliveryModeReply { - t.Fatalf("decoded = %#v, want typed delivery target", decoded) - } -} - -func TestChannelBundleAndHelpers(t *testing.T) { - t.Parallel() - - record := testChannelRecord(t) - bundle := channelBundle(record) - - human, err := bundle.human() - if err != nil { - t.Fatalf("channelBundle().human() error = %v", err) - } - if !strings.Contains(human, "Delivery Defaults") || !strings.Contains(human, `{"mode":"reply","peer_id":"peer-default"}`) { - t.Fatalf("channelBundle().human() = %q, want delivery defaults", human) - } - - toon, err := bundle.toon() - if err != nil { - t.Fatalf("channelBundle().toon() error = %v", err) - } - if !strings.Contains(toon, "channel{id,display_name,platform,extension_name,scope,workspace_id,enabled,status,routing,include_peer,include_thread,include_group,delivery_defaults,created_at,updated_at}:") { - t.Fatalf("channelBundle().toon() = %q, want channel TOON object", toon) - } - - if got := channelRoutingPolicyLabel(channelspkg.RoutingPolicy{}); got != "" { - t.Fatalf("channelRoutingPolicyLabel(empty) = %q, want empty string", got) - } - if _, err := parseRequiredChannelJSON("{not-json"); err == nil { - t.Fatal("parseRequiredChannelJSON(invalid) error = nil, want non-nil") - } - if _, err := parseChannelScope("bogus"); err == nil { - t.Fatal("parseChannelScope(bogus) error = nil, want non-nil") - } -} - -func testChannelRecord(t *testing.T) ChannelRecord { - t.Helper() - - return ChannelRecord{ - ID: "chan-1", - Scope: channelspkg.ScopeWorkspace, - WorkspaceID: "ws-alpha", - Platform: "telegram", - ExtensionName: "ext-telegram", - DisplayName: "Support", - Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{ - IncludePeer: true, - IncludeThread: true, - }, - DeliveryDefaults: mustJSON(t, map[string]string{ - "mode": "reply", - "peer_id": "peer-default", - }), - CreatedAt: fixedTestNow.Add(-time.Hour), - UpdatedAt: fixedTestNow, - } -} diff --git a/internal/cli/cli_integration_test.go b/internal/cli/cli_integration_test.go index 5d28ff63f..a36b3021d 100644 --- a/internal/cli/cli_integration_test.go +++ b/internal/cli/cli_integration_test.go @@ -22,7 +22,7 @@ import ( "github.com/pedronauck/agh/internal/api/contract" "github.com/pedronauck/agh/internal/api/udsapi" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" aghconfig "github.com/pedronauck/agh/internal/config" aghdaemon "github.com/pedronauck/agh/internal/daemon" extensionpkg "github.com/pedronauck/agh/internal/extension" @@ -159,12 +159,12 @@ func TestSessionListOutputFormatsIntegration(t *testing.T) { if err != nil { t.Fatalf("session list toon error = %v", err) } - if !strings.Contains(toonOut, "sessions[1]{id,name,agent_name,state,workspace,space,updated_at}:") { + if !strings.Contains(toonOut, "sessions[1]{id,name,agent_name,state,workspace,channel,updated_at}:") { t.Fatalf("toon output = %q, want TOON table", toonOut) } } -func TestCLISessionSpaceRoundTripIntegration(t *testing.T) { +func TestCLISessionChannelRoundTripIntegration(t *testing.T) { t.Parallel() h := newIntegrationHarness(t) @@ -174,16 +174,16 @@ func TestCLISessionSpaceRoundTripIntegration(t *testing.T) { _ = h.runner.waitForExit() }() - newOut, _, err := executeRootCommand(t, h.deps, "session", "new", "--agent", "coder", "--name", "demo", "--space", "builders", "--cwd", h.workspace, "-o", "json") + newOut, _, err := executeRootCommand(t, h.deps, "session", "new", "--agent", "coder", "--name", "demo", "--channel", "builders", "--cwd", h.workspace, "-o", "json") if err != nil { - t.Fatalf("session new --space error = %v", err) + t.Fatalf("session new --channel error = %v", err) } var created SessionRecord if err := json.Unmarshal([]byte(newOut), &created); err != nil { - t.Fatalf("json.Unmarshal(session new --space) error = %v", err) + t.Fatalf("json.Unmarshal(session new --channel) error = %v", err) } - if created.Space != "builders" { - t.Fatalf("created.Space = %q, want %q", created.Space, "builders") + if created.Channel != "builders" { + t.Fatalf("created.Channel = %q, want %q", created.Channel, "builders") } listOut, _, err := executeRootCommand(t, h.deps, "session", "list", "--all", "-o", "json") @@ -197,8 +197,8 @@ func TestCLISessionSpaceRoundTripIntegration(t *testing.T) { if got, want := len(listed), 1; got != want { t.Fatalf("len(listed) = %d, want %d", got, want) } - if listed[0].Space != "builders" { - t.Fatalf("listed[0].Space = %q, want %q", listed[0].Space, "builders") + if listed[0].Channel != "builders" { + t.Fatalf("listed[0].Channel = %q, want %q", listed[0].Channel, "builders") } stopOut, _, err := executeRootCommand(t, h.deps, "session", "stop", created.ID, "-o", "json") @@ -209,7 +209,7 @@ func TestCLISessionSpaceRoundTripIntegration(t *testing.T) { if err := json.Unmarshal([]byte(stopOut), &stopped); err != nil { t.Fatalf("json.Unmarshal(session stop) error = %v", err) } - if stopped.Space != "builders" || stopped.State != session.StateStopped { + if stopped.Channel != "builders" || stopped.State != session.StateStopped { t.Fatalf("stopped = %#v, want stopped builders session", stopped) } @@ -221,7 +221,7 @@ func TestCLISessionSpaceRoundTripIntegration(t *testing.T) { if err := json.Unmarshal([]byte(resumeOut), &resumed); err != nil { t.Fatalf("json.Unmarshal(session resume) error = %v", err) } - if resumed.Space != "builders" || resumed.State != session.StateActive { + if resumed.Channel != "builders" || resumed.State != session.StateActive { t.Fatalf("resumed = %#v, want active builders session", resumed) } } @@ -236,13 +236,13 @@ func TestCLINetworkRoundTripIntegration(t *testing.T) { _ = h.runner.waitForExit() }() - newOut, _, err := executeRootCommand(t, h.deps, "session", "new", "--agent", "coder", "--name", "net-demo", "--space", "builders", "--cwd", h.workspace, "-o", "json") + newOut, _, err := executeRootCommand(t, h.deps, "session", "new", "--agent", "coder", "--name", "net-demo", "--channel", "builders", "--cwd", h.workspace, "-o", "json") if err != nil { - t.Fatalf("session new --space error = %v", err) + t.Fatalf("session new --channel error = %v", err) } var created SessionRecord if err := json.Unmarshal([]byte(newOut), &created); err != nil { - t.Fatalf("json.Unmarshal(session new --space) error = %v", err) + t.Fatalf("json.Unmarshal(session new --channel) error = %v", err) } statusOut, _, err := executeRootCommand(t, h.deps, "network", "status", "-o", "json") @@ -269,16 +269,16 @@ func TestCLINetworkRoundTripIntegration(t *testing.T) { t.Fatalf("network peers = %#v, want created session peer", peers) } - spacesOut, _, err := executeRootCommand(t, h.deps, "network", "spaces", "-o", "json") + channelsOut, _, err := executeRootCommand(t, h.deps, "network", "channels", "-o", "json") if err != nil { - t.Fatalf("network spaces error = %v", err) + t.Fatalf("network channels error = %v", err) } - var spaces []NetworkSpaceRecord - if err := json.Unmarshal([]byte(spacesOut), &spaces); err != nil { - t.Fatalf("json.Unmarshal(network spaces) error = %v", err) + var channels []NetworkChannelRecord + if err := json.Unmarshal([]byte(channelsOut), &channels); err != nil { + t.Fatalf("json.Unmarshal(network channels) error = %v", err) } - if len(spaces) != 1 || spaces[0].Space != "builders" || spaces[0].PeerCount != 1 { - t.Fatalf("network spaces = %#v, want builders peer_count=1", spaces) + if len(channels) != 1 || channels[0].Channel != "builders" || channels[0].PeerCount != 1 { + t.Fatalf("network channels = %#v, want builders peer_count=1", channels) } events, err := h.runner.blockSession(created.ID) @@ -295,7 +295,7 @@ func TestCLINetworkRoundTripIntegration(t *testing.T) { sendOut, _, err := executeRootCommand(t, h.deps, "network", "send", "--session", created.ID, - "--space", "builders", + "--channel", "builders", "--kind", "say", "--body", `{"text":"queued hello"}`, "--ext", `{"agh.workflow_id":"wf-1","agh.handoff_version":3}`, @@ -350,7 +350,7 @@ func TestCLINetworkDirectRetryAndResumeIntegration(t *testing.T) { newSession := func(name string) SessionRecord { t.Helper() - out, _, err := executeRootCommand(t, h.deps, "session", "new", "--agent", "coder", "--name", name, "--space", "builders", "--cwd", h.workspace, "-o", "json") + out, _, err := executeRootCommand(t, h.deps, "session", "new", "--agent", "coder", "--name", name, "--channel", "builders", "--cwd", h.workspace, "-o", "json") if err != nil { t.Fatalf("session new %s error = %v", name, err) } @@ -382,7 +382,7 @@ func TestCLINetworkDirectRetryAndResumeIntegration(t *testing.T) { out, _, err := executeRootCommand(t, h.deps, "network", "send", "--session", sender.ID, - "--space", "builders", + "--channel", "builders", "--kind", "direct", "--to", receiverPeerID, "--interaction-id", "int-review-1", @@ -467,7 +467,7 @@ func TestCLINetworkDirectRetryAndResumeIntegration(t *testing.T) { if err := json.Unmarshal([]byte(resumeOut), &resumed); err != nil { t.Fatalf("json.Unmarshal(session resume receiver) error = %v", err) } - if resumed.State != session.StateActive || resumed.Space != "builders" { + if resumed.State != session.StateActive || resumed.Channel != "builders" { t.Fatalf("resumed receiver = %#v, want active builders session", resumed) } @@ -899,7 +899,7 @@ func TestAutomationTriggerHistoryAndRunsIntegration(t *testing.T) { } } -func TestChannelCreateAndGetIntegration(t *testing.T) { +func TestBridgeCreateAndGetIntegration(t *testing.T) { t.Parallel() h := newIntegrationHarness(t) @@ -912,7 +912,7 @@ func TestChannelCreateAndGetIntegration(t *testing.T) { createOut := mustExecuteRoot( t, h.deps, - "channel", "create", + "bridge", "create", "--scope", "global", "--platform", "telegram", "--extension", "ext-telegram", @@ -922,26 +922,26 @@ func TestChannelCreateAndGetIntegration(t *testing.T) { "-o", "json", ) - var created ChannelRecord + var created BridgeRecord if err := json.Unmarshal([]byte(createOut), &created); err != nil { - t.Fatalf("json.Unmarshal(channel create) error = %v", err) + t.Fatalf("json.Unmarshal(bridge create) error = %v", err) } - if created.ID == "" || created.Platform != "telegram" || created.Status != channelspkg.ChannelStatusReady { - t.Fatalf("created channel = %#v", created) + if created.ID == "" || created.Platform != "telegram" || created.Status != bridgepkg.BridgeStatusReady { + t.Fatalf("created bridge = %#v", created) } - getOut := mustExecuteRoot(t, h.deps, "channel", "get", created.ID, "-o", "json") + getOut := mustExecuteRoot(t, h.deps, "bridge", "get", created.ID, "-o", "json") - var fetched ChannelRecord + var fetched BridgeRecord if err := json.Unmarshal([]byte(getOut), &fetched); err != nil { - t.Fatalf("json.Unmarshal(channel get) error = %v", err) + t.Fatalf("json.Unmarshal(bridge get) error = %v", err) } if fetched.ID != created.ID || fetched.DisplayName != "Support" || fetched.ExtensionName != "ext-telegram" { - t.Fatalf("fetched channel = %#v, want created record", fetched) + t.Fatalf("fetched bridge = %#v, want created record", fetched) } } -func TestChannelLifecycleCommandsIntegration(t *testing.T) { +func TestBridgeLifecycleCommandsIntegration(t *testing.T) { t.Parallel() h := newIntegrationHarness(t) @@ -954,7 +954,7 @@ func TestChannelLifecycleCommandsIntegration(t *testing.T) { createOut := mustExecuteRoot( t, h.deps, - "channel", "create", + "bridge", "create", "--scope", "global", "--platform", "telegram", "--extension", "ext-telegram", @@ -964,43 +964,43 @@ func TestChannelLifecycleCommandsIntegration(t *testing.T) { "-o", "json", ) - var created ChannelRecord + var created BridgeRecord if err := json.Unmarshal([]byte(createOut), &created); err != nil { - t.Fatalf("json.Unmarshal(channel create) error = %v", err) + t.Fatalf("json.Unmarshal(bridge create) error = %v", err) } - if created.Status != channelspkg.ChannelStatusDisabled || created.Enabled { + if created.Status != bridgepkg.BridgeStatusDisabled || created.Enabled { t.Fatalf("created lifecycle = %#v, want disabled false", created) } - enableOut := mustExecuteRoot(t, h.deps, "channel", "enable", created.ID, "-o", "json") - var enabled ChannelRecord + enableOut := mustExecuteRoot(t, h.deps, "bridge", "enable", created.ID, "-o", "json") + var enabled BridgeRecord if err := json.Unmarshal([]byte(enableOut), &enabled); err != nil { - t.Fatalf("json.Unmarshal(channel enable) error = %v", err) + t.Fatalf("json.Unmarshal(bridge enable) error = %v", err) } - if enabled.Status != channelspkg.ChannelStatusStarting || !enabled.Enabled { - t.Fatalf("enabled channel = %#v, want starting true", enabled) + if enabled.Status != bridgepkg.BridgeStatusStarting || !enabled.Enabled { + t.Fatalf("enabled bridge = %#v, want starting true", enabled) } - disableOut := mustExecuteRoot(t, h.deps, "channel", "disable", created.ID, "-o", "json") - var disabled ChannelRecord + disableOut := mustExecuteRoot(t, h.deps, "bridge", "disable", created.ID, "-o", "json") + var disabled BridgeRecord if err := json.Unmarshal([]byte(disableOut), &disabled); err != nil { - t.Fatalf("json.Unmarshal(channel disable) error = %v", err) + t.Fatalf("json.Unmarshal(bridge disable) error = %v", err) } - if disabled.Status != channelspkg.ChannelStatusDisabled || disabled.Enabled { - t.Fatalf("disabled channel = %#v, want disabled false", disabled) + if disabled.Status != bridgepkg.BridgeStatusDisabled || disabled.Enabled { + t.Fatalf("disabled bridge = %#v, want disabled false", disabled) } - restartOut := mustExecuteRoot(t, h.deps, "channel", "restart", created.ID, "-o", "json") - var restarted ChannelRecord + restartOut := mustExecuteRoot(t, h.deps, "bridge", "restart", created.ID, "-o", "json") + var restarted BridgeRecord if err := json.Unmarshal([]byte(restartOut), &restarted); err != nil { - t.Fatalf("json.Unmarshal(channel restart) error = %v", err) + t.Fatalf("json.Unmarshal(bridge restart) error = %v", err) } - if restarted.Status != channelspkg.ChannelStatusStarting || !restarted.Enabled { - t.Fatalf("restarted channel = %#v, want starting true", restarted) + if restarted.Status != bridgepkg.BridgeStatusStarting || !restarted.Enabled { + t.Fatalf("restarted bridge = %#v, want starting true", restarted) } } -func TestChannelRoutesIntegration(t *testing.T) { +func TestBridgeRoutesIntegration(t *testing.T) { t.Parallel() h := newIntegrationHarness(t) @@ -1013,7 +1013,7 @@ func TestChannelRoutesIntegration(t *testing.T) { createOut := mustExecuteRoot( t, h.deps, - "channel", "create", + "bridge", "create", "--scope", "global", "--platform", "telegram", "--extension", "ext-telegram", @@ -1024,41 +1024,41 @@ func TestChannelRoutesIntegration(t *testing.T) { "-o", "json", ) - var created ChannelRecord + var created BridgeRecord if err := json.Unmarshal([]byte(createOut), &created); err != nil { - t.Fatalf("json.Unmarshal(channel create) error = %v", err) - } - - channels := h.runner.channelService() - if channels == nil { - t.Fatal("channel service = nil, want running integration channel service") - } - if _, err := channels.UpsertRoute(context.Background(), channelspkg.ChannelRoute{ - ChannelInstanceID: created.ID, - Scope: created.Scope, - WorkspaceID: created.WorkspaceID, - PeerID: "peer-1", - ThreadID: "thread-1", - SessionID: "sess-1", - AgentName: "coder", - LastActivityAt: fixedTestNow, + t.Fatalf("json.Unmarshal(bridge create) error = %v", err) + } + + bridges := h.runner.bridgeService() + if bridges == nil { + t.Fatal("bridge service = nil, want running integration bridge service") + } + if _, err := bridges.UpsertRoute(context.Background(), bridgepkg.BridgeRoute{ + BridgeInstanceID: created.ID, + Scope: created.Scope, + WorkspaceID: created.WorkspaceID, + PeerID: "peer-1", + ThreadID: "thread-1", + SessionID: "sess-1", + AgentName: "coder", + LastActivityAt: fixedTestNow, }); err != nil { t.Fatalf("UpsertRoute() error = %v", err) } - routesOut := mustExecuteRoot(t, h.deps, "channel", "routes", created.ID, "-o", "json") + routesOut := mustExecuteRoot(t, h.deps, "bridge", "routes", created.ID, "-o", "json") - var routes []ChannelRouteRecord + var routes []BridgeRouteRecord if err := json.Unmarshal([]byte(routesOut), &routes); err != nil { - t.Fatalf("json.Unmarshal(channel routes) error = %v", err) + t.Fatalf("json.Unmarshal(bridge routes) error = %v", err) } if len(routes) != 1 || routes[0].PeerID != "peer-1" || routes[0].ThreadID != "thread-1" { t.Fatalf("routes = %#v, want one inserted route", routes) } - _, _, err := executeRootCommand(t, h.deps, "channel", "routes", "missing-channel", "-o", "json") - if err == nil || !strings.Contains(err.Error(), "channel instance not found") { - t.Fatalf("channel routes missing error = %v, want channel instance not found", err) + _, _, err := executeRootCommand(t, h.deps, "bridge", "routes", "missing-bridge", "-o", "json") + if err == nil || !strings.Contains(err.Error(), "bridge instance not found") { + t.Fatalf("bridge routes missing error = %v, want bridge instance not found", err) } } @@ -1100,9 +1100,9 @@ type integrationDaemon struct { cancel context.CancelFunc done chan error - channels *integrationChannelService - driver *integrationDriver - manager *session.Manager + bridges *integrationBridgeService + driver *integrationDriver + manager *session.Manager } type integrationDaemonProcess struct { @@ -1115,8 +1115,8 @@ type integrationExtensionService struct { manager *extensionpkg.Manager } -type integrationChannelService struct { - *channelspkg.Service +type integrationBridgeService struct { + *bridgepkg.Service } type integrationNotifierFanout struct { @@ -1136,31 +1136,31 @@ type lockedBuffer struct { buffer bytes.Buffer } -func newIntegrationChannelService(store channelspkg.RegistryStore) *integrationChannelService { - return &integrationChannelService{Service: channelspkg.NewRegistry(store)} +func newIntegrationBridgeService(store bridgepkg.RegistryStore) *integrationBridgeService { + return &integrationBridgeService{Service: bridgepkg.NewRegistry(store)} } -func (s *integrationChannelService) StartInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { - return s.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ +func (s *integrationBridgeService) StartInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { + return s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, Enabled: true, - Status: channelspkg.ChannelStatusStarting, + Status: bridgepkg.BridgeStatusStarting, }) } -func (s *integrationChannelService) StopInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { - return s.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ +func (s *integrationBridgeService) StopInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { + return s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, Enabled: false, - Status: channelspkg.ChannelStatusDisabled, + Status: bridgepkg.BridgeStatusDisabled, }) } -func (s *integrationChannelService) RestartInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { - return s.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ +func (s *integrationBridgeService) RestartInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { + return s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, Enabled: true, - Status: channelspkg.ChannelStatusStarting, + Status: bridgepkg.BridgeStatusStarting, }) } @@ -1403,7 +1403,7 @@ func (d *integrationDaemon) Run(ctx context.Context) error { if err := memoryStore.EnsureDirs(); err != nil { return fmt.Errorf("ensure memory dirs: %w", err) } - channelService := newIntegrationChannelService(registry) + bridgeService := newIntegrationBridgeService(registry) dreamTrigger := &integrationDreamTrigger{ enabled: true, triggered: true, @@ -1471,7 +1471,7 @@ func (d *integrationDaemon) Run(ctx context.Context) error { udsapi.WithNetworkService(networkManager), udsapi.WithObserver(observer), udsapi.WithAutomation(automationManager), - udsapi.WithChannelService(channelService), + udsapi.WithBridgeService(bridgeService), udsapi.WithWorkspaceResolver(resolver), udsapi.WithMemoryStore(memoryStore), udsapi.WithDreamTrigger(dreamTrigger), @@ -1485,7 +1485,7 @@ func (d *integrationDaemon) Run(ctx context.Context) error { return fmt.Errorf("start uds server: %w", err) } d.mu.Lock() - d.channels = channelService + d.bridges = bridgeService d.mu.Unlock() defer func() { shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -1500,7 +1500,7 @@ func (d *integrationDaemon) Run(ctx context.Context) error { _ = server.Shutdown(shutdownCtx) _ = aghdaemon.RemoveInfo(d.homePaths.DaemonInfo) d.mu.Lock() - d.channels = nil + d.bridges = nil d.manager = nil d.driver = nil d.mu.Unlock() @@ -1553,10 +1553,10 @@ func (d *integrationDaemon) waitForExit() error { return <-done } -func (d *integrationDaemon) channelService() *integrationChannelService { +func (d *integrationDaemon) bridgeService() *integrationBridgeService { d.mu.Lock() defer d.mu.Unlock() - return d.channels + return d.bridges } func (f *integrationNotifierFanout) OnSessionCreated(ctx context.Context, sess *session.Session) { diff --git a/internal/cli/client.go b/internal/cli/client.go index c14cb6811..b5f0b7ec9 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -16,7 +16,7 @@ import ( "github.com/pedronauck/agh/internal/api/contract" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" "github.com/pedronauck/agh/internal/memory" "github.com/pedronauck/agh/internal/sse" ) @@ -31,7 +31,7 @@ type DaemonClient interface { DaemonStatus(ctx context.Context) (DaemonStatus, error) NetworkStatus(ctx context.Context) (NetworkStatusRecord, error) NetworkPeers(ctx context.Context, query NetworkPeersQuery) ([]NetworkPeerRecord, error) - NetworkSpaces(ctx context.Context) ([]NetworkSpaceRecord, error) + NetworkChannels(ctx context.Context) ([]NetworkChannelRecord, error) NetworkSend(ctx context.Context, request NetworkSendRequest) (NetworkSendRecord, error) NetworkInbox(ctx context.Context, sessionID string) ([]NetworkEnvelopeRecord, error) ListExtensions(ctx context.Context) ([]ExtensionRecord, error) @@ -39,15 +39,15 @@ type DaemonClient interface { EnableExtension(ctx context.Context, name string) (ExtensionRecord, error) DisableExtension(ctx context.Context, name string) (ExtensionRecord, error) ExtensionStatus(ctx context.Context, name string) (ExtensionRecord, error) - ListChannels(ctx context.Context) ([]ChannelRecord, error) - CreateChannel(ctx context.Context, request CreateChannelRequest) (ChannelRecord, error) - GetChannel(ctx context.Context, id string) (ChannelRecord, error) - UpdateChannel(ctx context.Context, id string, request UpdateChannelRequest) (ChannelRecord, error) - EnableChannel(ctx context.Context, id string) (ChannelRecord, error) - DisableChannel(ctx context.Context, id string) (ChannelRecord, error) - RestartChannel(ctx context.Context, id string) (ChannelRecord, error) - ChannelRoutes(ctx context.Context, id string) ([]ChannelRouteRecord, error) - TestChannelDelivery(ctx context.Context, id string, request ChannelTestDeliveryRequest) (ChannelTestDeliveryRecord, error) + ListBridges(ctx context.Context) ([]BridgeRecord, error) + CreateBridge(ctx context.Context, request CreateBridgeRequest) (BridgeRecord, error) + GetBridge(ctx context.Context, id string) (BridgeRecord, error) + UpdateBridge(ctx context.Context, id string, request UpdateBridgeRequest) (BridgeRecord, error) + EnableBridge(ctx context.Context, id string) (BridgeRecord, error) + DisableBridge(ctx context.Context, id string) (BridgeRecord, error) + RestartBridge(ctx context.Context, id string) (BridgeRecord, error) + BridgeRoutes(ctx context.Context, id string) ([]BridgeRouteRecord, error) + TestBridgeDelivery(ctx context.Context, id string, request BridgeTestDeliveryRequest) (BridgeTestDeliveryRecord, error) ListSessions(ctx context.Context, query SessionListQuery) ([]SessionRecord, error) CreateSession(ctx context.Context, request CreateSessionRequest) (SessionRecord, error) GetSession(ctx context.Context, id string) (SessionRecord, error) @@ -248,15 +248,15 @@ type NetworkPeerRecord = contract.NetworkPeerPayload // NetworkPeerCardRecord is the shared peer-card payload nested under peers. type NetworkPeerCardRecord = contract.NetworkPeerCardPayload -// NetworkSpaceRecord is the shared active-space payload. -type NetworkSpaceRecord = contract.NetworkSpacePayload +// NetworkChannelRecord is the shared active-channel payload. +type NetworkChannelRecord = contract.NetworkChannelPayload // NetworkEnvelopeRecord is the shared surfaced envelope payload. type NetworkEnvelopeRecord = contract.NetworkEnvelopePayload // NetworkPeersQuery captures CLI filters for peer listing. type NetworkPeersQuery struct { - Space string + Channel string } // InstallExtensionRequest captures the shared extension install payload. @@ -265,29 +265,29 @@ type InstallExtensionRequest = contract.InstallExtensionRequest // ExtensionRecord is the shared extension response payload. type ExtensionRecord = contract.ExtensionPayload -// CreateChannelRequest captures the shared channel-instance creation payload. -type CreateChannelRequest = contract.CreateChannelRequest +// CreateBridgeRequest captures the shared bridge-instance creation payload. +type CreateBridgeRequest = contract.CreateBridgeRequest -// UpdateChannelRequest captures mutable channel-instance fields. -type UpdateChannelRequest = contract.UpdateChannelRequest +// UpdateBridgeRequest captures mutable bridge-instance fields. +type UpdateBridgeRequest = contract.UpdateBridgeRequest -// ChannelTestDeliveryRequest captures the typed channel delivery-target dry-run request. -type ChannelTestDeliveryRequest = contract.ChannelTestDeliveryRequest +// BridgeTestDeliveryRequest captures the typed bridge delivery-target dry-run request. +type BridgeTestDeliveryRequest = contract.BridgeTestDeliveryRequest -// ChannelDeliveryTargetInput captures the typed channel delivery-target override input. -type ChannelDeliveryTargetInput = contract.ChannelDeliveryTargetInput +// BridgeDeliveryTargetInput captures the typed bridge delivery-target override input. +type BridgeDeliveryTargetInput = contract.BridgeDeliveryTargetInput -// ChannelRecord is the shared channel-instance response payload. -type ChannelRecord = channelspkg.ChannelInstance +// BridgeRecord is the shared bridge-instance response payload. +type BridgeRecord = bridgepkg.BridgeInstance -// ChannelRouteRecord is one persisted channel route returned by the daemon API. -type ChannelRouteRecord = channelspkg.ChannelRoute +// BridgeRouteRecord is one persisted bridge route returned by the daemon API. +type BridgeRouteRecord = bridgepkg.BridgeRoute // DeliveryTargetRecord is the resolved typed outbound target returned by the daemon API. -type DeliveryTargetRecord = channelspkg.DeliveryTarget +type DeliveryTargetRecord = bridgepkg.DeliveryTarget -// ChannelTestDeliveryRecord is the shared dry-run channel delivery response payload. -type ChannelTestDeliveryRecord = contract.ChannelTestDeliveryResponse +// BridgeTestDeliveryRecord is the shared dry-run bridge delivery response payload. +type BridgeTestDeliveryRecord = contract.BridgeTestDeliveryResponse // IdentityRecord is the local agent identity exposed by `agh whoami`. type IdentityRecord struct { @@ -357,14 +357,14 @@ func (c *unixSocketClient) NetworkPeers(ctx context.Context, query NetworkPeersQ return response.Peers, nil } -func (c *unixSocketClient) NetworkSpaces(ctx context.Context) ([]NetworkSpaceRecord, error) { +func (c *unixSocketClient) NetworkChannels(ctx context.Context) ([]NetworkChannelRecord, error) { var response struct { - Spaces []NetworkSpaceRecord `json:"spaces"` + Channels []NetworkChannelRecord `json:"channels"` } - if err := c.doJSON(ctx, http.MethodGet, "/api/network/spaces", nil, nil, &response); err != nil { + if err := c.doJSON(ctx, http.MethodGet, "/api/network/channels", nil, nil, &response); err != nil { return nil, err } - return response.Spaces, nil + return response.Channels, nil } func (c *unixSocketClient) NetworkSend(ctx context.Context, request NetworkSendRequest) (NetworkSendRecord, error) { @@ -425,76 +425,76 @@ func (c *unixSocketClient) ExtensionStatus(ctx context.Context, name string) (Ex return response.Extension, nil } -func (c *unixSocketClient) ListChannels(ctx context.Context) ([]ChannelRecord, error) { +func (c *unixSocketClient) ListBridges(ctx context.Context) ([]BridgeRecord, error) { var response struct { - Channels []ChannelRecord `json:"channels"` + Bridges []BridgeRecord `json:"bridges"` } - if err := c.doJSON(ctx, http.MethodGet, "/api/channels", nil, nil, &response); err != nil { + if err := c.doJSON(ctx, http.MethodGet, "/api/bridges", nil, nil, &response); err != nil { return nil, err } - return response.Channels, nil + return response.Bridges, nil } -func (c *unixSocketClient) CreateChannel(ctx context.Context, request CreateChannelRequest) (ChannelRecord, error) { +func (c *unixSocketClient) CreateBridge(ctx context.Context, request CreateBridgeRequest) (BridgeRecord, error) { var response struct { - Channel ChannelRecord `json:"channel"` + Bridge BridgeRecord `json:"bridge"` } - if err := c.doJSON(ctx, http.MethodPost, "/api/channels", nil, request, &response); err != nil { - return ChannelRecord{}, err + if err := c.doJSON(ctx, http.MethodPost, "/api/bridges", nil, request, &response); err != nil { + return BridgeRecord{}, err } - return response.Channel, nil + return response.Bridge, nil } -func (c *unixSocketClient) GetChannel(ctx context.Context, id string) (ChannelRecord, error) { +func (c *unixSocketClient) GetBridge(ctx context.Context, id string) (BridgeRecord, error) { var response struct { - Channel ChannelRecord `json:"channel"` + Bridge BridgeRecord `json:"bridge"` } - path := "/api/channels/" + url.PathEscape(strings.TrimSpace(id)) + path := "/api/bridges/" + url.PathEscape(strings.TrimSpace(id)) if err := c.doJSON(ctx, http.MethodGet, path, nil, nil, &response); err != nil { - return ChannelRecord{}, err + return BridgeRecord{}, err } - return response.Channel, nil + return response.Bridge, nil } -func (c *unixSocketClient) UpdateChannel(ctx context.Context, id string, request UpdateChannelRequest) (ChannelRecord, error) { +func (c *unixSocketClient) UpdateBridge(ctx context.Context, id string, request UpdateBridgeRequest) (BridgeRecord, error) { var response struct { - Channel ChannelRecord `json:"channel"` + Bridge BridgeRecord `json:"bridge"` } - path := "/api/channels/" + url.PathEscape(strings.TrimSpace(id)) + path := "/api/bridges/" + url.PathEscape(strings.TrimSpace(id)) if err := c.doJSON(ctx, http.MethodPatch, path, nil, request, &response); err != nil { - return ChannelRecord{}, err + return BridgeRecord{}, err } - return response.Channel, nil + return response.Bridge, nil } -func (c *unixSocketClient) EnableChannel(ctx context.Context, id string) (ChannelRecord, error) { - return c.channelAction(ctx, strings.TrimSpace(id), "enable") +func (c *unixSocketClient) EnableBridge(ctx context.Context, id string) (BridgeRecord, error) { + return c.bridgeAction(ctx, strings.TrimSpace(id), "enable") } -func (c *unixSocketClient) DisableChannel(ctx context.Context, id string) (ChannelRecord, error) { - return c.channelAction(ctx, strings.TrimSpace(id), "disable") +func (c *unixSocketClient) DisableBridge(ctx context.Context, id string) (BridgeRecord, error) { + return c.bridgeAction(ctx, strings.TrimSpace(id), "disable") } -func (c *unixSocketClient) RestartChannel(ctx context.Context, id string) (ChannelRecord, error) { - return c.channelAction(ctx, strings.TrimSpace(id), "restart") +func (c *unixSocketClient) RestartBridge(ctx context.Context, id string) (BridgeRecord, error) { + return c.bridgeAction(ctx, strings.TrimSpace(id), "restart") } -func (c *unixSocketClient) ChannelRoutes(ctx context.Context, id string) ([]ChannelRouteRecord, error) { +func (c *unixSocketClient) BridgeRoutes(ctx context.Context, id string) ([]BridgeRouteRecord, error) { var response struct { - Routes []ChannelRouteRecord `json:"routes"` + Routes []BridgeRouteRecord `json:"routes"` } - path := "/api/channels/" + url.PathEscape(strings.TrimSpace(id)) + "/routes" + path := "/api/bridges/" + url.PathEscape(strings.TrimSpace(id)) + "/routes" if err := c.doJSON(ctx, http.MethodGet, path, nil, nil, &response); err != nil { return nil, err } return response.Routes, nil } -func (c *unixSocketClient) TestChannelDelivery(ctx context.Context, id string, request ChannelTestDeliveryRequest) (ChannelTestDeliveryRecord, error) { - var response ChannelTestDeliveryRecord - path := "/api/channels/" + url.PathEscape(strings.TrimSpace(id)) + "/test-delivery" +func (c *unixSocketClient) TestBridgeDelivery(ctx context.Context, id string, request BridgeTestDeliveryRequest) (BridgeTestDeliveryRecord, error) { + var response BridgeTestDeliveryRecord + path := "/api/bridges/" + url.PathEscape(strings.TrimSpace(id)) + "/test-delivery" if err := c.doJSON(ctx, http.MethodPost, path, nil, request, &response); err != nil { - return ChannelTestDeliveryRecord{}, err + return BridgeTestDeliveryRecord{}, err } return response, nil } @@ -881,15 +881,15 @@ func (c *unixSocketClient) extensionAction(ctx context.Context, name string, act return response.Extension, nil } -func (c *unixSocketClient) channelAction(ctx context.Context, id string, action string) (ChannelRecord, error) { +func (c *unixSocketClient) bridgeAction(ctx context.Context, id string, action string) (BridgeRecord, error) { var response struct { - Channel ChannelRecord `json:"channel"` + Bridge BridgeRecord `json:"bridge"` } - path := "/api/channels/" + url.PathEscape(id) + "/" + action + path := "/api/bridges/" + url.PathEscape(id) + "/" + action if err := c.doJSON(ctx, http.MethodPost, path, nil, nil, &response); err != nil { - return ChannelRecord{}, err + return BridgeRecord{}, err } - return response.Channel, nil + return response.Bridge, nil } func (c *unixSocketClient) doJSON(ctx context.Context, method string, path string, query url.Values, requestBody any, responseBody any) error { @@ -986,8 +986,8 @@ func sessionListValues(query SessionListQuery) url.Values { func networkPeersValues(query NetworkPeersQuery) url.Values { values := url.Values{} - if trimmed := strings.TrimSpace(query.Space); trimmed != "" { - values.Set("space", trimmed) + if trimmed := strings.TrimSpace(query.Channel); trimmed != "" { + values.Set("channel", trimmed) } return values } diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index adb3ded1f..f65d343d6 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -12,7 +12,7 @@ import ( "github.com/pedronauck/agh/internal/api/contract" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" "github.com/pedronauck/agh/internal/memory" ) @@ -706,7 +706,7 @@ func TestUnixSocketClientAutomationMethods(t *testing.T) { }) } -func TestUnixSocketClientChannelMethods(t *testing.T) { +func TestUnixSocketClientBridgeMethods(t *testing.T) { t.Parallel() client := &unixSocketClient{ @@ -714,45 +714,45 @@ func TestUnixSocketClientChannelMethods(t *testing.T) { httpClient: &http.Client{ Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { switch { - case req.Method == http.MethodGet && req.URL.Path == "/api/channels": - return newHTTPResponse(http.StatusOK, `{"channels":[{"id":"chan-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"ready","routing_policy":{"include_peer":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:00:00Z"}]}`), nil - case req.Method == http.MethodPost && req.URL.Path == "/api/channels": - var payload contract.CreateChannelRequest + case req.Method == http.MethodGet && req.URL.Path == "/api/bridges": + return newHTTPResponse(http.StatusOK, `{"bridges":[{"id":"brg-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"ready","routing_policy":{"include_peer":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:00:00Z"}]}`), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/bridges": + var payload contract.CreateBridgeRequest if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { - t.Fatalf("json.Decode(create channel body) error = %v", err) + t.Fatalf("json.Decode(create bridge body) error = %v", err) } if payload.Platform != "telegram" || payload.ExtensionName != "ext-telegram" || payload.DisplayName != "Support" { - t.Fatalf("create channel payload = %#v", payload) + t.Fatalf("create bridge payload = %#v", payload) } - return newHTTPResponse(http.StatusCreated, `{"channel":{"id":"chan-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:00:00Z"}}`), nil - case req.Method == http.MethodGet && req.URL.Path == "/api/channels/chan-a": - return newHTTPResponse(http.StatusOK, `{"channel":{"id":"chan-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"ready","routing_policy":{"include_peer":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:00:00Z"}}`), nil - case req.Method == http.MethodPatch && req.URL.Path == "/api/channels/chan-a": - var payload contract.UpdateChannelRequest + return newHTTPResponse(http.StatusCreated, `{"bridge":{"id":"brg-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:00:00Z"}}`), nil + case req.Method == http.MethodGet && req.URL.Path == "/api/bridges/brg-a": + return newHTTPResponse(http.StatusOK, `{"bridge":{"id":"brg-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"ready","routing_policy":{"include_peer":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:00:00Z"}}`), nil + case req.Method == http.MethodPatch && req.URL.Path == "/api/bridges/brg-a": + var payload contract.UpdateBridgeRequest if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { - t.Fatalf("json.Decode(update channel body) error = %v", err) + t.Fatalf("json.Decode(update bridge body) error = %v", err) } if payload.DisplayName == nil || *payload.DisplayName != "Support Ops" { - t.Fatalf("update channel payload = %#v, want updated display name", payload) - } - return newHTTPResponse(http.StatusOK, `{"channel":{"id":"chan-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support Ops","enabled":true,"status":"ready","routing_policy":{"include_peer":true,"include_thread":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:05:00Z"}}`), nil - case req.Method == http.MethodPost && req.URL.Path == "/api/channels/chan-a/enable": - return newHTTPResponse(http.StatusOK, `{"channel":{"id":"chan-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:06:00Z"}}`), nil - case req.Method == http.MethodPost && req.URL.Path == "/api/channels/chan-a/disable": - return newHTTPResponse(http.StatusOK, `{"channel":{"id":"chan-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":false,"status":"disabled","routing_policy":{"include_peer":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:07:00Z"}}`), nil - case req.Method == http.MethodPost && req.URL.Path == "/api/channels/chan-a/restart": - return newHTTPResponse(http.StatusOK, `{"channel":{"id":"chan-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:08:00Z"}}`), nil - case req.Method == http.MethodGet && req.URL.Path == "/api/channels/chan-a/routes": - return newHTTPResponse(http.StatusOK, `{"routes":[{"routing_key_hash":"hash-a","scope":"global","channel_instance_id":"chan-a","peer_id":"peer-1","thread_id":"thread-1","session_id":"sess-1","agent_name":"coder","last_activity_at":"2026-04-11T12:09:00Z","created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:09:00Z"}]}`), nil - case req.Method == http.MethodPost && req.URL.Path == "/api/channels/chan-a/test-delivery": - var payload contract.ChannelTestDeliveryRequest + t.Fatalf("update bridge payload = %#v, want updated display name", payload) + } + return newHTTPResponse(http.StatusOK, `{"bridge":{"id":"brg-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support Ops","enabled":true,"status":"ready","routing_policy":{"include_peer":true,"include_thread":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:05:00Z"}}`), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/bridges/brg-a/enable": + return newHTTPResponse(http.StatusOK, `{"bridge":{"id":"brg-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:06:00Z"}}`), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/bridges/brg-a/disable": + return newHTTPResponse(http.StatusOK, `{"bridge":{"id":"brg-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":false,"status":"disabled","routing_policy":{"include_peer":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:07:00Z"}}`), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/bridges/brg-a/restart": + return newHTTPResponse(http.StatusOK, `{"bridge":{"id":"brg-a","scope":"global","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"starting","routing_policy":{"include_peer":true},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:08:00Z"}}`), nil + case req.Method == http.MethodGet && req.URL.Path == "/api/bridges/brg-a/routes": + return newHTTPResponse(http.StatusOK, `{"routes":[{"routing_key_hash":"hash-a","scope":"global","bridge_instance_id":"brg-a","peer_id":"peer-1","thread_id":"thread-1","session_id":"sess-1","agent_name":"coder","last_activity_at":"2026-04-11T12:09:00Z","created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:09:00Z"}]}`), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/bridges/brg-a/test-delivery": + var payload contract.BridgeTestDeliveryRequest if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { t.Fatalf("json.Decode(test delivery body) error = %v", err) } - if payload.Message != "hello" || payload.Target.PeerID != "peer-1" || payload.Target.ThreadID != "thread-1" || payload.Target.Mode != channelspkg.DeliveryModeReply { + if payload.Message != "hello" || payload.Target.PeerID != "peer-1" || payload.Target.ThreadID != "thread-1" || payload.Target.Mode != bridgepkg.DeliveryModeReply { t.Fatalf("test delivery payload = %#v", payload) } - return newHTTPResponse(http.StatusOK, `{"status":"resolved","message":"hello","delivery_target":{"channel_instance_id":"chan-a","peer_id":"peer-1","thread_id":"thread-1","mode":"reply"}}`), nil + return newHTTPResponse(http.StatusOK, `{"status":"resolved","message":"hello","delivery_target":{"bridge_instance_id":"brg-a","peer_id":"peer-1","thread_id":"thread-1","mode":"reply"}}`), nil default: return newHTTPResponse(http.StatusNotFound, `{"error":"missing"}`), nil } @@ -762,70 +762,70 @@ func TestUnixSocketClientChannelMethods(t *testing.T) { ctx := context.Background() - listed, err := client.ListChannels(ctx) - if err != nil || len(listed) != 1 || listed[0].ID != "chan-a" { - t.Fatalf("ListChannels() = %#v, %v", listed, err) + listed, err := client.ListBridges(ctx) + if err != nil || len(listed) != 1 || listed[0].ID != "brg-a" { + t.Fatalf("ListBridges() = %#v, %v", listed, err) } - created, err := client.CreateChannel(ctx, CreateChannelRequest{ - Scope: channelspkg.ScopeGlobal, + created, err := client.CreateBridge(ctx, CreateBridgeRequest{ + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Support", Enabled: true, - Status: channelspkg.ChannelStatusStarting, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusStarting, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) - if err != nil || created.ID != "chan-a" { - t.Fatalf("CreateChannel() = %#v, %v", created, err) + if err != nil || created.ID != "brg-a" { + t.Fatalf("CreateBridge() = %#v, %v", created, err) } - status, err := client.GetChannel(ctx, "chan-a") - if err != nil || status.Status != channelspkg.ChannelStatusReady { - t.Fatalf("GetChannel() = %#v, %v", status, err) + status, err := client.GetBridge(ctx, "brg-a") + if err != nil || status.Status != bridgepkg.BridgeStatusReady { + t.Fatalf("GetBridge() = %#v, %v", status, err) } - updated, err := client.UpdateChannel(ctx, "chan-a", UpdateChannelRequest{ + updated, err := client.UpdateBridge(ctx, "brg-a", UpdateBridgeRequest{ DisplayName: ptr("Support Ops"), - RoutingPolicy: &channelspkg.RoutingPolicy{ + RoutingPolicy: &bridgepkg.RoutingPolicy{ IncludePeer: true, IncludeThread: true, }, }) if err != nil || updated.DisplayName != "Support Ops" || !updated.RoutingPolicy.IncludeThread { - t.Fatalf("UpdateChannel() = %#v, %v", updated, err) + t.Fatalf("UpdateBridge() = %#v, %v", updated, err) } - enabled, err := client.EnableChannel(ctx, " chan-a ") - if err != nil || enabled.Status != channelspkg.ChannelStatusStarting || !enabled.Enabled { - t.Fatalf("EnableChannel() = %#v, %v", enabled, err) + enabled, err := client.EnableBridge(ctx, " brg-a ") + if err != nil || enabled.Status != bridgepkg.BridgeStatusStarting || !enabled.Enabled { + t.Fatalf("EnableBridge() = %#v, %v", enabled, err) } - disabled, err := client.DisableChannel(ctx, " chan-a ") - if err != nil || disabled.Status != channelspkg.ChannelStatusDisabled || disabled.Enabled { - t.Fatalf("DisableChannel() = %#v, %v", disabled, err) + disabled, err := client.DisableBridge(ctx, " brg-a ") + if err != nil || disabled.Status != bridgepkg.BridgeStatusDisabled || disabled.Enabled { + t.Fatalf("DisableBridge() = %#v, %v", disabled, err) } - restarted, err := client.RestartChannel(ctx, " chan-a ") - if err != nil || restarted.Status != channelspkg.ChannelStatusStarting || !restarted.Enabled { - t.Fatalf("RestartChannel() = %#v, %v", restarted, err) + restarted, err := client.RestartBridge(ctx, " brg-a ") + if err != nil || restarted.Status != bridgepkg.BridgeStatusStarting || !restarted.Enabled { + t.Fatalf("RestartBridge() = %#v, %v", restarted, err) } - routes, err := client.ChannelRoutes(ctx, "chan-a") + routes, err := client.BridgeRoutes(ctx, "brg-a") if err != nil || len(routes) != 1 || routes[0].ThreadID != "thread-1" { - t.Fatalf("ChannelRoutes() = %#v, %v", routes, err) + t.Fatalf("BridgeRoutes() = %#v, %v", routes, err) } - delivery, err := client.TestChannelDelivery(ctx, "chan-a", ChannelTestDeliveryRequest{ + delivery, err := client.TestBridgeDelivery(ctx, "brg-a", BridgeTestDeliveryRequest{ Message: "hello", - Target: ChannelDeliveryTargetInput{ + Target: BridgeDeliveryTargetInput{ PeerID: "peer-1", ThreadID: "thread-1", - Mode: channelspkg.DeliveryModeReply, + Mode: bridgepkg.DeliveryModeReply, }, }) - if err != nil || delivery.DeliveryTarget.Mode != channelspkg.DeliveryModeReply || delivery.DeliveryTarget.ThreadID != "thread-1" { - t.Fatalf("TestChannelDelivery() = %#v, %v", delivery, err) + if err != nil || delivery.DeliveryTarget.Mode != bridgepkg.DeliveryModeReply || delivery.DeliveryTarget.ThreadID != "thread-1" { + t.Fatalf("TestBridgeDelivery() = %#v, %v", delivery, err) } } @@ -1069,14 +1069,14 @@ func TestCLIUsesSharedContractAliases(t *testing.T) { {name: "Should alias TriggerRecord to the shared contract", cliType: TriggerRecord{}, want: contract.TriggerPayload{}}, {name: "Should alias RunRecord to the shared contract", cliType: RunRecord{}, want: contract.RunPayload{}}, {name: "Should alias DaemonStatus to the shared contract", cliType: DaemonStatus{}, want: contract.DaemonStatusPayload{}}, - {name: "Should alias CreateChannelRequest to the shared contract", cliType: CreateChannelRequest{}, want: contract.CreateChannelRequest{}}, - {name: "Should alias UpdateChannelRequest to the shared contract", cliType: UpdateChannelRequest{}, want: contract.UpdateChannelRequest{}}, - {name: "Should alias ChannelTestDeliveryRequest to the shared contract", cliType: ChannelTestDeliveryRequest{}, want: contract.ChannelTestDeliveryRequest{}}, - {name: "Should alias ChannelDeliveryTargetInput to the shared contract", cliType: ChannelDeliveryTargetInput{}, want: contract.ChannelDeliveryTargetInput{}}, - {name: "Should alias ChannelRecord to the channel domain type", cliType: ChannelRecord{}, want: channelspkg.ChannelInstance{}}, - {name: "Should alias ChannelRouteRecord to the channel domain type", cliType: ChannelRouteRecord{}, want: channelspkg.ChannelRoute{}}, - {name: "Should alias DeliveryTargetRecord to the channel domain type", cliType: DeliveryTargetRecord{}, want: channelspkg.DeliveryTarget{}}, - {name: "Should alias ChannelTestDeliveryRecord to the shared contract", cliType: ChannelTestDeliveryRecord{}, want: contract.ChannelTestDeliveryResponse{}}, + {name: "Should alias CreateBridgeRequest to the shared contract", cliType: CreateBridgeRequest{}, want: contract.CreateBridgeRequest{}}, + {name: "Should alias UpdateBridgeRequest to the shared contract", cliType: UpdateBridgeRequest{}, want: contract.UpdateBridgeRequest{}}, + {name: "Should alias BridgeTestDeliveryRequest to the shared contract", cliType: BridgeTestDeliveryRequest{}, want: contract.BridgeTestDeliveryRequest{}}, + {name: "Should alias BridgeDeliveryTargetInput to the shared contract", cliType: BridgeDeliveryTargetInput{}, want: contract.BridgeDeliveryTargetInput{}}, + {name: "Should alias BridgeRecord to the bridge domain type", cliType: BridgeRecord{}, want: bridgepkg.BridgeInstance{}}, + {name: "Should alias BridgeRouteRecord to the bridge domain type", cliType: BridgeRouteRecord{}, want: bridgepkg.BridgeRoute{}}, + {name: "Should alias DeliveryTargetRecord to the bridge domain type", cliType: DeliveryTargetRecord{}, want: bridgepkg.DeliveryTarget{}}, + {name: "Should alias BridgeTestDeliveryRecord to the shared contract", cliType: BridgeTestDeliveryRecord{}, want: contract.BridgeTestDeliveryResponse{}}, } for _, tt := range tests { @@ -1119,25 +1119,25 @@ func TestSharedContractJSONParity(t *testing.T) { t.Fatalf("memory request json = %s, want %s", cliMemoryJSON, sharedMemoryJSON) } - channelRequest := CreateChannelRequest{ - Scope: channelspkg.ScopeGlobal, + bridgeRequest := CreateBridgeRequest{ + Scope: bridgepkg.ScopeGlobal, Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Support", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, } - cliChannelJSON, err := json.Marshal(channelRequest) + cliBridgeJSON, err := json.Marshal(bridgeRequest) if err != nil { - t.Fatalf("json.Marshal(cli channel request) error = %v", err) + t.Fatalf("json.Marshal(cli bridge request) error = %v", err) } - sharedChannelJSON, err := json.Marshal(contract.CreateChannelRequest(channelRequest)) + sharedBridgeJSON, err := json.Marshal(contract.CreateBridgeRequest(bridgeRequest)) if err != nil { - t.Fatalf("json.Marshal(shared channel request) error = %v", err) + t.Fatalf("json.Marshal(shared bridge request) error = %v", err) } - if string(cliChannelJSON) != string(sharedChannelJSON) { - t.Fatalf("channel request json = %s, want %s", cliChannelJSON, sharedChannelJSON) + if string(cliBridgeJSON) != string(sharedBridgeJSON) { + t.Fatalf("bridge request json = %s, want %s", cliBridgeJSON, sharedBridgeJSON) } readResponse := `{"content":"stored memory body"}` @@ -1238,20 +1238,20 @@ func TestSharedContractJSONParity(t *testing.T) { t.Fatalf("daemon decode = %#v, want %#v", cliDaemon, sharedDaemon) } - channelResponse := `{"channel":{"id":"chan-1","scope":"workspace","workspace_id":"ws-alpha","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"ready","routing_policy":{"include_peer":true,"include_thread":true},"delivery_defaults":{"mode":"reply"},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:00:00Z"}}` - var cliChannel struct { - Channel ChannelRecord `json:"channel"` + bridgeResponse := `{"bridge":{"id":"brg-1","scope":"workspace","workspace_id":"ws-alpha","platform":"telegram","extension_name":"ext-telegram","display_name":"Support","enabled":true,"status":"ready","routing_policy":{"include_peer":true,"include_thread":true},"delivery_defaults":{"mode":"reply"},"created_at":"2026-04-11T12:00:00Z","updated_at":"2026-04-11T12:00:00Z"}}` + var cliBridge struct { + Bridge BridgeRecord `json:"bridge"` } - if err := json.Unmarshal([]byte(channelResponse), &cliChannel); err != nil { - t.Fatalf("json.Unmarshal(cli channel response) error = %v", err) + if err := json.Unmarshal([]byte(bridgeResponse), &cliBridge); err != nil { + t.Fatalf("json.Unmarshal(cli bridge response) error = %v", err) } - var sharedChannel struct { - Channel channelspkg.ChannelInstance `json:"channel"` + var sharedBridge struct { + Bridge bridgepkg.BridgeInstance `json:"bridge"` } - if err := json.Unmarshal([]byte(channelResponse), &sharedChannel); err != nil { - t.Fatalf("json.Unmarshal(shared channel response) error = %v", err) + if err := json.Unmarshal([]byte(bridgeResponse), &sharedBridge); err != nil { + t.Fatalf("json.Unmarshal(shared bridge response) error = %v", err) } - if !reflect.DeepEqual(cliChannel, sharedChannel) { - t.Fatalf("channel decode = %#v, want %#v", cliChannel, sharedChannel) + if !reflect.DeepEqual(cliBridge, sharedBridge) { + t.Fatalf("bridge decode = %#v, want %#v", cliBridge, sharedBridge) } } diff --git a/internal/cli/command_paths_test.go b/internal/cli/command_paths_test.go index ede69f04a..070b6fba6 100644 --- a/internal/cli/command_paths_test.go +++ b/internal/cli/command_paths_test.go @@ -45,16 +45,16 @@ func TestCommandPathsAndHelpers(t *testing.T) { return NetworkStatusRecord{Enabled: true, Status: "running"}, nil }, networkPeersFn: func(context.Context, NetworkPeersQuery) ([]NetworkPeerRecord, error) { - return []NetworkPeerRecord{{PeerID: "reviewer.sess-1", Space: "builders"}}, nil + return []NetworkPeerRecord{{PeerID: "reviewer.sess-1", Channel: "builders"}}, nil }, - networkSpacesFn: func(context.Context) ([]NetworkSpaceRecord, error) { - return []NetworkSpaceRecord{{Space: "builders", PeerCount: 1}}, nil + networkChannelsFn: func(context.Context) ([]NetworkChannelRecord, error) { + return []NetworkChannelRecord{{Channel: "builders", PeerCount: 1}}, nil }, networkSendFn: func(context.Context, NetworkSendRequest) (NetworkSendRecord, error) { - return NetworkSendRecord{ID: "msg-1", SessionID: "sess-1", Space: "builders", Kind: "say"}, nil + return NetworkSendRecord{ID: "msg-1", SessionID: "sess-1", Channel: "builders", Kind: "say"}, nil }, networkInboxFn: func(context.Context, string) ([]NetworkEnvelopeRecord, error) { - return []NetworkEnvelopeRecord{{ID: "msg-1", Kind: "say", Space: "builders", From: "reviewer.sess-1"}}, nil + return []NetworkEnvelopeRecord{{ID: "msg-1", Kind: "say", Channel: "builders", From: "reviewer.sess-1"}}, nil }, observeEventsFn: func(context.Context, ObserveEventQuery) ([]ObserveEventRecord, error) { return []ObserveEventRecord{{ID: "sum-1", SessionID: "sess-1", Type: "done", AgentName: "coder", Timestamp: fixedTestNow}}, nil @@ -86,14 +86,14 @@ func TestCommandPathsAndHelpers(t *testing.T) { daemonStatusFn: func(context.Context) (DaemonStatus, error) { return DaemonStatus{Status: "running", PID: 10, StartedAt: fixedTestNow}, nil }, - getChannelFn: func(context.Context, string) (ChannelRecord, error) { - return ChannelRecord{ID: "chan-1", Scope: "global", Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Support", Enabled: true, Status: "ready"}, nil + getBridgeFn: func(context.Context, string) (BridgeRecord, error) { + return BridgeRecord{ID: "brg-1", Scope: "global", Platform: "telegram", ExtensionName: "ext-telegram", DisplayName: "Support", Enabled: true, Status: "ready"}, nil }, - channelRoutesFn: func(context.Context, string) ([]ChannelRouteRecord, error) { - return []ChannelRouteRecord{{RoutingKeyHash: "hash-1", Scope: "global", ChannelInstanceID: "chan-1", PeerID: "peer-1", SessionID: "sess-1", AgentName: "coder", LastActivityAt: fixedTestNow}}, nil + bridgeRoutesFn: func(context.Context, string) ([]BridgeRouteRecord, error) { + return []BridgeRouteRecord{{RoutingKeyHash: "hash-1", Scope: "global", BridgeInstanceID: "brg-1", PeerID: "peer-1", SessionID: "sess-1", AgentName: "coder", LastActivityAt: fixedTestNow}}, nil }, - testChannelDeliveryFn: func(context.Context, string, ChannelTestDeliveryRequest) (ChannelTestDeliveryRecord, error) { - return ChannelTestDeliveryRecord{Status: "resolved", DeliveryTarget: DeliveryTargetRecord{ChannelInstanceID: "chan-1", PeerID: "peer-1", Mode: "reply"}}, nil + testBridgeDeliveryFn: func(context.Context, string, BridgeTestDeliveryRequest) (BridgeTestDeliveryRecord, error) { + return BridgeTestDeliveryRecord{Status: "resolved", DeliveryTarget: DeliveryTargetRecord{BridgeInstanceID: "brg-1", PeerID: "peer-1", Mode: "reply"}}, nil }, } deps := newTestDeps(t, client) @@ -104,15 +104,15 @@ func TestCommandPathsAndHelpers(t *testing.T) { {"agent", "info", "coder", "-o", "json"}, {"network", "status", "-o", "json"}, {"network", "peers", "builders", "-o", "json"}, - {"network", "spaces", "-o", "json"}, - {"network", "send", "--session", "sess-1", "--space", "builders", "--kind", "say", "--body", `{"text":"hello"}`, "-o", "json"}, + {"network", "channels", "-o", "json"}, + {"network", "send", "--session", "sess-1", "--channel", "builders", "--kind", "say", "--body", `{"text":"hello"}`, "-o", "json"}, {"network", "inbox", "--session", "sess-1", "-o", "json"}, {"observe", "events", "-o", "json"}, {"observe", "events", "--follow", "-o", "json"}, {"observe", "health", "-o", "json"}, - {"channel", "get", "chan-1", "-o", "json"}, - {"channel", "routes", "chan-1", "-o", "json"}, - {"channel", "test-delivery", "chan-1", "--peer-id", "peer-1", "--mode", "reply", "-o", "json"}, + {"bridge", "get", "brg-1", "-o", "json"}, + {"bridge", "routes", "brg-1", "-o", "json"}, + {"bridge", "test-delivery", "brg-1", "--peer-id", "peer-1", "--mode", "reply", "-o", "json"}, {"session", "status", "sess-1", "-o", "json"}, {"session", "resume", "sess-1", "-o", "json"}, {"session", "wait", "sess-1", "-o", "json"}, diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index 0e5cca0b6..027356a51 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -347,7 +347,7 @@ func daemonStatusBundle(status DaemonStatus, now func() time.Time) outputBundle keyValue{Label: "Network Listener", Value: stringOrDash(networkListener(status.Network))}, keyValue{Label: "Network Local Peers", Value: strconv.Itoa(status.Network.LocalPeers)}, keyValue{Label: "Network Remote Peers", Value: strconv.Itoa(status.Network.RemotePeers)}, - keyValue{Label: "Network Spaces", Value: strconv.Itoa(status.Network.Spaces)}, + keyValue{Label: "Network Channels", Value: strconv.Itoa(status.Network.Channels)}, keyValue{Label: "Network Queued Messages", Value: strconv.Itoa(status.Network.QueuedMessages)}, keyValue{Label: "Network Delivery Workers", Value: strconv.Itoa(status.Network.DeliveryWorkers)}, keyValue{Label: "Network Messages Sent", Value: strconv.FormatInt(status.Network.MessagesSent, 10)}, @@ -359,7 +359,7 @@ func daemonStatusBundle(status DaemonStatus, now func() time.Time) outputBundle keyValue{Label: "Network Last Disconnect", Value: stringOrDash(status.Network.LastDisconnect)}, ) labels = append(labels, - "network_status", "network_listener", "network_local_peers", "network_remote_peers", "network_spaces", + "network_status", "network_listener", "network_local_peers", "network_remote_peers", "network_channels", "network_queued_messages", "network_delivery_workers", "network_messages_sent", "network_messages_received", "network_messages_rejected", "network_messages_delivered", "network_workflow_tagged_events", "network_handoff_tagged_events", "network_last_disconnect", @@ -369,7 +369,7 @@ func daemonStatusBundle(status DaemonStatus, now func() time.Time) outputBundle networkListener(status.Network), strconv.Itoa(status.Network.LocalPeers), strconv.Itoa(status.Network.RemotePeers), - strconv.Itoa(status.Network.Spaces), + strconv.Itoa(status.Network.Channels), strconv.Itoa(status.Network.QueuedMessages), strconv.Itoa(status.Network.DeliveryWorkers), strconv.FormatInt(status.Network.MessagesSent, 10), diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index 1e867ccca..9ee29bfff 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -20,7 +20,7 @@ type stubClient struct { daemonStatusFn func(context.Context) (DaemonStatus, error) networkStatusFn func(context.Context) (NetworkStatusRecord, error) networkPeersFn func(context.Context, NetworkPeersQuery) ([]NetworkPeerRecord, error) - networkSpacesFn func(context.Context) ([]NetworkSpaceRecord, error) + networkChannelsFn func(context.Context) ([]NetworkChannelRecord, error) networkSendFn func(context.Context, NetworkSendRequest) (NetworkSendRecord, error) networkInboxFn func(context.Context, string) ([]NetworkEnvelopeRecord, error) listExtensionsFn func(context.Context) ([]ExtensionRecord, error) @@ -28,15 +28,15 @@ type stubClient struct { enableExtensionFn func(context.Context, string) (ExtensionRecord, error) disableExtensionFn func(context.Context, string) (ExtensionRecord, error) extensionStatusFn func(context.Context, string) (ExtensionRecord, error) - listChannelsFn func(context.Context) ([]ChannelRecord, error) - createChannelFn func(context.Context, CreateChannelRequest) (ChannelRecord, error) - getChannelFn func(context.Context, string) (ChannelRecord, error) - updateChannelFn func(context.Context, string, UpdateChannelRequest) (ChannelRecord, error) - enableChannelFn func(context.Context, string) (ChannelRecord, error) - disableChannelFn func(context.Context, string) (ChannelRecord, error) - restartChannelFn func(context.Context, string) (ChannelRecord, error) - channelRoutesFn func(context.Context, string) ([]ChannelRouteRecord, error) - testChannelDeliveryFn func(context.Context, string, ChannelTestDeliveryRequest) (ChannelTestDeliveryRecord, error) + listBridgesFn func(context.Context) ([]BridgeRecord, error) + createBridgeFn func(context.Context, CreateBridgeRequest) (BridgeRecord, error) + getBridgeFn func(context.Context, string) (BridgeRecord, error) + updateBridgeFn func(context.Context, string, UpdateBridgeRequest) (BridgeRecord, error) + enableBridgeFn func(context.Context, string) (BridgeRecord, error) + disableBridgeFn func(context.Context, string) (BridgeRecord, error) + restartBridgeFn func(context.Context, string) (BridgeRecord, error) + bridgeRoutesFn func(context.Context, string) ([]BridgeRouteRecord, error) + testBridgeDeliveryFn func(context.Context, string, BridgeTestDeliveryRequest) (BridgeTestDeliveryRecord, error) listSessionsFn func(context.Context, SessionListQuery) ([]SessionRecord, error) createSessionFn func(context.Context, CreateSessionRequest) (SessionRecord, error) getSessionFn func(context.Context, string) (SessionRecord, error) @@ -104,11 +104,11 @@ func (s stubClient) NetworkPeers(ctx context.Context, query NetworkPeersQuery) ( return nil, errors.New("unexpected NetworkPeers call") } -func (s stubClient) NetworkSpaces(ctx context.Context) ([]NetworkSpaceRecord, error) { - if s.networkSpacesFn != nil { - return s.networkSpacesFn(ctx) +func (s stubClient) NetworkChannels(ctx context.Context) ([]NetworkChannelRecord, error) { + if s.networkChannelsFn != nil { + return s.networkChannelsFn(ctx) } - return nil, errors.New("unexpected NetworkSpaces call") + return nil, errors.New("unexpected NetworkChannels call") } func (s stubClient) NetworkSend(ctx context.Context, request NetworkSendRequest) (NetworkSendRecord, error) { @@ -160,67 +160,67 @@ func (s stubClient) ExtensionStatus(ctx context.Context, name string) (Extension return ExtensionRecord{}, errors.New("unexpected ExtensionStatus call") } -func (s stubClient) ListChannels(ctx context.Context) ([]ChannelRecord, error) { - if s.listChannelsFn != nil { - return s.listChannelsFn(ctx) +func (s stubClient) ListBridges(ctx context.Context) ([]BridgeRecord, error) { + if s.listBridgesFn != nil { + return s.listBridgesFn(ctx) } - return nil, errors.New("unexpected ListChannels call") + return nil, errors.New("unexpected ListBridges call") } -func (s stubClient) CreateChannel(ctx context.Context, request CreateChannelRequest) (ChannelRecord, error) { - if s.createChannelFn != nil { - return s.createChannelFn(ctx, request) +func (s stubClient) CreateBridge(ctx context.Context, request CreateBridgeRequest) (BridgeRecord, error) { + if s.createBridgeFn != nil { + return s.createBridgeFn(ctx, request) } - return ChannelRecord{}, errors.New("unexpected CreateChannel call") + return BridgeRecord{}, errors.New("unexpected CreateBridge call") } -func (s stubClient) GetChannel(ctx context.Context, id string) (ChannelRecord, error) { - if s.getChannelFn != nil { - return s.getChannelFn(ctx, id) +func (s stubClient) GetBridge(ctx context.Context, id string) (BridgeRecord, error) { + if s.getBridgeFn != nil { + return s.getBridgeFn(ctx, id) } - return ChannelRecord{}, errors.New("unexpected GetChannel call") + return BridgeRecord{}, errors.New("unexpected GetBridge call") } -func (s stubClient) UpdateChannel(ctx context.Context, id string, request UpdateChannelRequest) (ChannelRecord, error) { - if s.updateChannelFn != nil { - return s.updateChannelFn(ctx, id, request) +func (s stubClient) UpdateBridge(ctx context.Context, id string, request UpdateBridgeRequest) (BridgeRecord, error) { + if s.updateBridgeFn != nil { + return s.updateBridgeFn(ctx, id, request) } - return ChannelRecord{}, errors.New("unexpected UpdateChannel call") + return BridgeRecord{}, errors.New("unexpected UpdateBridge call") } -func (s stubClient) EnableChannel(ctx context.Context, id string) (ChannelRecord, error) { - if s.enableChannelFn != nil { - return s.enableChannelFn(ctx, id) +func (s stubClient) EnableBridge(ctx context.Context, id string) (BridgeRecord, error) { + if s.enableBridgeFn != nil { + return s.enableBridgeFn(ctx, id) } - return ChannelRecord{}, errors.New("unexpected EnableChannel call") + return BridgeRecord{}, errors.New("unexpected EnableBridge call") } -func (s stubClient) DisableChannel(ctx context.Context, id string) (ChannelRecord, error) { - if s.disableChannelFn != nil { - return s.disableChannelFn(ctx, id) +func (s stubClient) DisableBridge(ctx context.Context, id string) (BridgeRecord, error) { + if s.disableBridgeFn != nil { + return s.disableBridgeFn(ctx, id) } - return ChannelRecord{}, errors.New("unexpected DisableChannel call") + return BridgeRecord{}, errors.New("unexpected DisableBridge call") } -func (s stubClient) RestartChannel(ctx context.Context, id string) (ChannelRecord, error) { - if s.restartChannelFn != nil { - return s.restartChannelFn(ctx, id) +func (s stubClient) RestartBridge(ctx context.Context, id string) (BridgeRecord, error) { + if s.restartBridgeFn != nil { + return s.restartBridgeFn(ctx, id) } - return ChannelRecord{}, errors.New("unexpected RestartChannel call") + return BridgeRecord{}, errors.New("unexpected RestartBridge call") } -func (s stubClient) ChannelRoutes(ctx context.Context, id string) ([]ChannelRouteRecord, error) { - if s.channelRoutesFn != nil { - return s.channelRoutesFn(ctx, id) +func (s stubClient) BridgeRoutes(ctx context.Context, id string) ([]BridgeRouteRecord, error) { + if s.bridgeRoutesFn != nil { + return s.bridgeRoutesFn(ctx, id) } - return nil, errors.New("unexpected ChannelRoutes call") + return nil, errors.New("unexpected BridgeRoutes call") } -func (s stubClient) TestChannelDelivery(ctx context.Context, id string, request ChannelTestDeliveryRequest) (ChannelTestDeliveryRecord, error) { - if s.testChannelDeliveryFn != nil { - return s.testChannelDeliveryFn(ctx, id, request) +func (s stubClient) TestBridgeDelivery(ctx context.Context, id string, request BridgeTestDeliveryRequest) (BridgeTestDeliveryRecord, error) { + if s.testBridgeDeliveryFn != nil { + return s.testBridgeDeliveryFn(ctx, id, request) } - return ChannelTestDeliveryRecord{}, errors.New("unexpected TestChannelDelivery call") + return BridgeTestDeliveryRecord{}, errors.New("unexpected TestBridgeDelivery call") } func (s stubClient) ListSessions(ctx context.Context, query SessionListQuery) ([]SessionRecord, error) { diff --git a/internal/cli/network.go b/internal/cli/network.go index 08875312b..ee5256b08 100644 --- a/internal/cli/network.go +++ b/internal/cli/network.go @@ -19,7 +19,7 @@ func newNetworkCommand(deps commandDeps) *cobra.Command { cmd.AddCommand(newNetworkStatusCommand(deps)) cmd.AddCommand(newNetworkPeersCommand(deps)) - cmd.AddCommand(newNetworkSpacesCommand(deps)) + cmd.AddCommand(newNetworkChannelsCommand(deps)) cmd.AddCommand(newNetworkSendCommand(deps)) cmd.AddCommand(newNetworkInboxCommand(deps)) return cmd @@ -46,7 +46,7 @@ func newNetworkStatusCommand(deps commandDeps) *cobra.Command { func newNetworkPeersCommand(deps commandDeps) *cobra.Command { return &cobra.Command{ - Use: "peers [space]", + Use: "peers [channel]", Short: "List visible local and remote peers", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -57,7 +57,7 @@ func newNetworkPeersCommand(deps commandDeps) *cobra.Command { query := NetworkPeersQuery{} if len(args) == 1 { - query.Space = strings.TrimSpace(args[0]) + query.Channel = strings.TrimSpace(args[0]) } peers, err := client.NetworkPeers(cmd.Context(), query) @@ -69,21 +69,21 @@ func newNetworkPeersCommand(deps commandDeps) *cobra.Command { } } -func newNetworkSpacesCommand(deps commandDeps) *cobra.Command { +func newNetworkChannelsCommand(deps commandDeps) *cobra.Command { return &cobra.Command{ - Use: "spaces", - Short: "List active runtime spaces", + Use: "channels", + Short: "List active runtime channels", RunE: func(cmd *cobra.Command, _ []string) error { client, _, err := clientFromDeps(deps) if err != nil { return err } - spaces, err := client.NetworkSpaces(cmd.Context()) + channels, err := client.NetworkChannels(cmd.Context()) if err != nil { return err } - return writeCommandOutput(cmd, networkSpacesBundle(spaces)) + return writeCommandOutput(cmd, networkChannelsBundle(channels)) }, } } @@ -91,7 +91,7 @@ func newNetworkSpacesCommand(deps commandDeps) *cobra.Command { func newNetworkSendCommand(deps commandDeps) *cobra.Command { var ( sessionID string - space string + channel string kind string to string bodyRaw string @@ -128,7 +128,7 @@ func newNetworkSendCommand(deps commandDeps) *cobra.Command { message, err := client.NetworkSend(cmd.Context(), NetworkSendRequest{ SessionID: strings.TrimSpace(sessionID), - Space: strings.TrimSpace(space), + Channel: strings.TrimSpace(channel), Kind: strings.TrimSpace(kind), To: strings.TrimSpace(to), Body: body, @@ -148,7 +148,7 @@ func newNetworkSendCommand(deps commandDeps) *cobra.Command { } cmd.Flags().StringVar(&sessionID, "session", "", "Local source session id") - cmd.Flags().StringVar(&space, "space", "", "Target space") + cmd.Flags().StringVar(&channel, "channel", "", "Target channel") cmd.Flags().StringVar(&kind, "kind", "", "Envelope kind") cmd.Flags().StringVar(&to, "to", "", "Directed target peer id") cmd.Flags().StringVar(&bodyRaw, "body", "", "Raw JSON object for the envelope body") @@ -160,7 +160,7 @@ func newNetworkSendCommand(deps commandDeps) *cobra.Command { cmd.Flags().StringVar(&id, "id", "", "Optional explicit message id") cmd.Flags().StringVar(&extRaw, "ext", "", "Optional JSON object of extension metadata") _ = cmd.MarkFlagRequired("session") - _ = cmd.MarkFlagRequired("space") + _ = cmd.MarkFlagRequired("channel") _ = cmd.MarkFlagRequired("kind") _ = cmd.MarkFlagRequired("body") return cmd @@ -198,7 +198,7 @@ func networkStatusBundle(status NetworkStatusRecord) outputBundle { {Label: "Listener", Value: stringOrDash(networkListener(&status))}, {Label: "Local Peers", Value: strconv.Itoa(status.LocalPeers)}, {Label: "Remote Peers", Value: strconv.Itoa(status.RemotePeers)}, - {Label: "Spaces", Value: strconv.Itoa(status.Spaces)}, + {Label: "Channels", Value: strconv.Itoa(status.Channels)}, {Label: "Queued Messages", Value: strconv.Itoa(status.QueuedMessages)}, {Label: "Queued Sessions", Value: strconv.Itoa(status.QueuedSessions)}, {Label: "Delivery Workers", Value: strconv.Itoa(status.DeliveryWorkers)}, @@ -211,7 +211,7 @@ func networkStatusBundle(status NetworkStatusRecord) outputBundle { {Label: "Last Disconnect", Value: stringOrDash(status.LastDisconnect)}, } fields := []string{ - "enabled", "status", "listener", "local_peers", "remote_peers", "spaces", + "enabled", "status", "listener", "local_peers", "remote_peers", "channels", "queued_messages", "queued_sessions", "delivery_workers", "messages_sent", "messages_received", "messages_rejected", "messages_delivered", "workflow_tagged_events", "handoff_tagged_events", "last_disconnect", @@ -222,7 +222,7 @@ func networkStatusBundle(status NetworkStatusRecord) outputBundle { networkListener(&status), strconv.Itoa(status.LocalPeers), strconv.Itoa(status.RemotePeers), - strconv.Itoa(status.Spaces), + strconv.Itoa(status.Channels), strconv.Itoa(status.QueuedMessages), strconv.Itoa(status.QueuedSessions), strconv.Itoa(status.DeliveryWorkers), @@ -257,15 +257,15 @@ func networkPeersBundle(peers []NetworkPeerRecord) outputBundle { peers, peers, "Network Peers", - []string{"Peer", "Display", "Session", "Space", "Local", "Last Seen", "Expires"}, + []string{"Peer", "Display", "Session", "Channel", "Local", "Last Seen", "Expires"}, "network_peers", - []string{"peer_id", "display_name", "session_id", "space", "local", "joined_at", "last_seen", "expires_at"}, + []string{"peer_id", "display_name", "session_id", "channel", "local", "joined_at", "last_seen", "expires_at"}, func(peer NetworkPeerRecord) []string { return []string{ stringOrDash(peer.PeerID), stringOrDash(optionalString(peer.PeerCard.DisplayName)), stringOrDash(optionalString(peer.SessionID)), - stringOrDash(peer.Space), + stringOrDash(peer.Channel), strconv.FormatBool(peer.Local), stringOrDash(formatTimePtr(peer.LastSeen)), stringOrDash(formatTimePtr(peer.ExpiresAt)), @@ -276,7 +276,7 @@ func networkPeersBundle(peers []NetworkPeerRecord) outputBundle { peer.PeerID, optionalString(peer.PeerCard.DisplayName), optionalString(peer.SessionID), - peer.Space, + peer.Channel, strconv.FormatBool(peer.Local), formatTimePtr(peer.JoinedAt), formatTimePtr(peer.LastSeen), @@ -286,24 +286,24 @@ func networkPeersBundle(peers []NetworkPeerRecord) outputBundle { ) } -func networkSpacesBundle(spaces []NetworkSpaceRecord) outputBundle { +func networkChannelsBundle(channels []NetworkChannelRecord) outputBundle { return listBundle( - spaces, - spaces, - "Network Spaces", - []string{"Space", "Peers"}, - "network_spaces", - []string{"space", "peer_count"}, - func(space NetworkSpaceRecord) []string { + channels, + channels, + "Network Channels", + []string{"Channel", "Peers"}, + "network_channels", + []string{"channel", "peer_count"}, + func(channel NetworkChannelRecord) []string { return []string{ - stringOrDash(space.Space), - strconv.Itoa(space.PeerCount), + stringOrDash(channel.Channel), + strconv.Itoa(channel.PeerCount), } }, - func(space NetworkSpaceRecord) []string { + func(channel NetworkChannelRecord) []string { return []string{ - space.Space, - strconv.Itoa(space.PeerCount), + channel.Channel, + strconv.Itoa(channel.PeerCount), } }, ) @@ -316,7 +316,7 @@ func networkSendBundle(message NetworkSendRecord) outputBundle { return renderHumanSection("Network Message", []keyValue{ {Label: "ID", Value: stringOrDash(message.ID)}, {Label: "Session", Value: stringOrDash(message.SessionID)}, - {Label: "Space", Value: stringOrDash(message.Space)}, + {Label: "Channel", Value: stringOrDash(message.Channel)}, {Label: "Kind", Value: stringOrDash(message.Kind)}, {Label: "To", Value: stringOrDash(message.To)}, {Label: "Interaction", Value: stringOrDash(message.InteractionID)}, @@ -329,11 +329,11 @@ func networkSendBundle(message NetworkSendRecord) outputBundle { }, toon: func() (string, error) { return renderToonObject("network_message", []string{ - "id", "session_id", "space", "kind", "to", "interaction_id", "reply_to", "trace_id", "causation_id", "expires_at", "ext", + "id", "session_id", "channel", "kind", "to", "interaction_id", "reply_to", "trace_id", "causation_id", "expires_at", "ext", }, []string{ message.ID, message.SessionID, - message.Space, + message.Channel, message.Kind, message.To, message.InteractionID, @@ -354,7 +354,7 @@ func networkInboxBundle(messages []NetworkEnvelopeRecord) outputBundle { "Network Inbox", []string{"ID", "Kind", "From", "To", "Reply To", "Trace", "Workflow", "Handoff"}, "network_inbox", - []string{"id", "kind", "space", "from", "to", "reply_to", "trace_id", "causation_id", "workflow_id", "handoff_version", "expires_at"}, + []string{"id", "kind", "channel", "from", "to", "reply_to", "trace_id", "causation_id", "workflow_id", "handoff_version", "expires_at"}, func(message NetworkEnvelopeRecord) []string { return []string{ stringOrDash(message.ID), @@ -371,7 +371,7 @@ func networkInboxBundle(messages []NetworkEnvelopeRecord) outputBundle { return []string{ message.ID, message.Kind, - message.Space, + message.Channel, message.From, optionalString(message.To), optionalString(message.ReplyTo), diff --git a/internal/cli/network_client_test.go b/internal/cli/network_client_test.go index 8fd3b1cfc..1376b118b 100644 --- a/internal/cli/network_client_test.go +++ b/internal/cli/network_client_test.go @@ -21,14 +21,14 @@ func TestUnixSocketClientNetworkMethods(t *testing.T) { Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { switch { case req.Method == http.MethodGet && req.URL.Path == "/api/network/status": - return newHTTPResponse(http.StatusOK, `{"network":{"enabled":true,"status":"running","listener_host":"127.0.0.1","listener_port":4222,"local_peers":1,"remote_peers":2,"spaces":1,"queued_messages":3,"queued_sessions":1,"delivery_workers":1,"messages_sent":4,"messages_received":5,"messages_rejected":1,"messages_delivered":3,"workflow_tagged_events":2,"handoff_tagged_events":1,"kind_metrics":[{"kind":"say","sent":4,"received":5,"rejected":1,"delivered":3}]}}`), nil + return newHTTPResponse(http.StatusOK, `{"network":{"enabled":true,"status":"running","listener_host":"127.0.0.1","listener_port":4222,"local_peers":1,"remote_peers":2,"channels":1,"queued_messages":3,"queued_sessions":1,"delivery_workers":1,"messages_sent":4,"messages_received":5,"messages_rejected":1,"messages_delivered":3,"workflow_tagged_events":2,"handoff_tagged_events":1,"kind_metrics":[{"kind":"say","sent":4,"received":5,"rejected":1,"delivered":3}]}}`), nil case req.Method == http.MethodGet && req.URL.Path == "/api/network/peers": - if got := req.URL.Query().Get("space"); got != "builders" { - t.Fatalf("network peers space query = %q, want builders", got) + if got := req.URL.Query().Get("channel"); got != "builders" { + t.Fatalf("network peers channel query = %q, want builders", got) } - return newHTTPResponse(http.StatusOK, `{"peers":[{"peer_id":"reviewer.sess-a","session_id":"sess-a","space":"builders","local":true,"peer_card":{"peer_id":"reviewer.sess-a","display_name":"Reviewer","profiles_supported":["v0"],"capabilities":["send"],"artifacts_supported":["text"],"trust_modes_supported":["untrusted"]}}]}`), nil - case req.Method == http.MethodGet && req.URL.Path == "/api/network/spaces": - return newHTTPResponse(http.StatusOK, `{"spaces":[{"space":"builders","peer_count":2}]}`), nil + return newHTTPResponse(http.StatusOK, `{"peers":[{"peer_id":"reviewer.sess-a","session_id":"sess-a","channel":"builders","local":true,"peer_card":{"peer_id":"reviewer.sess-a","display_name":"Reviewer","profiles_supported":["v0"],"capabilities":["send"],"artifacts_supported":["text"],"trust_modes_supported":["untrusted"]}}]}`), nil + case req.Method == http.MethodGet && req.URL.Path == "/api/network/channels": + return newHTTPResponse(http.StatusOK, `{"channels":[{"channel":"builders","peer_count":2}]}`), nil case req.Method == http.MethodPost && req.URL.Path == "/api/network/send": body, err := io.ReadAll(req.Body) if err != nil { @@ -37,12 +37,12 @@ func TestUnixSocketClientNetworkMethods(t *testing.T) { if !strings.Contains(string(body), `"agh.workflow_id":"wf-1"`) || !strings.Contains(string(body), `"agh.handoff_version":3`) { t.Fatalf("network send body = %s, want ext metadata", body) } - return newHTTPResponse(http.StatusOK, `{"message":{"id":"msg-1","session_id":"sess-a","space":"builders","kind":"say","ext":{"agh.workflow_id":"wf-1","agh.handoff_version":3}}}`), nil + return newHTTPResponse(http.StatusOK, `{"message":{"id":"msg-1","session_id":"sess-a","channel":"builders","kind":"say","ext":{"agh.workflow_id":"wf-1","agh.handoff_version":3}}}`), nil case req.Method == http.MethodGet && req.URL.Path == "/api/network/inbox": if got := req.URL.Query().Get("session_id"); got != "sess-a" { t.Fatalf("network inbox session_id query = %q, want sess-a", got) } - return newHTTPResponse(http.StatusOK, `{"messages":[{"protocol":"agh-network/v0","id":"msg-inbox","kind":"direct","space":"builders","from":"reviewer.sess-a","ts":1775823000,"body":{"text":"review this","intent":"review"},"ext":{"agh.workflow_id":"wf-1","agh.handoff_version":3}}]}`), nil + return newHTTPResponse(http.StatusOK, `{"messages":[{"protocol":"agh-network/v0","id":"msg-inbox","kind":"direct","channel":"builders","from":"reviewer.sess-a","ts":1775823000,"body":{"text":"review this","intent":"review"},"ext":{"agh.workflow_id":"wf-1","agh.handoff_version":3}}]}`), nil default: return newHTTPResponse(http.StatusNotFound, `{"error":"missing"}`), nil } @@ -57,19 +57,19 @@ func TestUnixSocketClientNetworkMethods(t *testing.T) { t.Fatalf("NetworkStatus() = %#v, %v", status, err) } - peers, err := client.NetworkPeers(ctx, NetworkPeersQuery{Space: "builders"}) + peers, err := client.NetworkPeers(ctx, NetworkPeersQuery{Channel: "builders"}) if err != nil || len(peers) != 1 || peers[0].PeerID != "reviewer.sess-a" { t.Fatalf("NetworkPeers() = %#v, %v", peers, err) } - spaces, err := client.NetworkSpaces(ctx) - if err != nil || len(spaces) != 1 || spaces[0].PeerCount != 2 { - t.Fatalf("NetworkSpaces() = %#v, %v", spaces, err) + channels, err := client.NetworkChannels(ctx) + if err != nil || len(channels) != 1 || channels[0].PeerCount != 2 { + t.Fatalf("NetworkChannels() = %#v, %v", channels, err) } sent, err := client.NetworkSend(ctx, NetworkSendRequest{ SessionID: "sess-a", - Space: "builders", + Channel: "builders", Kind: "say", Body: json.RawMessage(`{"text":"hello"}`), Ext: map[string]json.RawMessage{ @@ -90,8 +90,8 @@ func TestUnixSocketClientNetworkMethods(t *testing.T) { func TestNetworkClientHelpersAndAliases(t *testing.T) { t.Parallel() - if got := networkPeersValues(NetworkPeersQuery{Space: "builders"}); got.Get("space") != "builders" { - t.Fatalf("networkPeersValues() = %v, want space filter", got) + if got := networkPeersValues(NetworkPeersQuery{Channel: "builders"}); got.Get("channel") != "builders" { + t.Fatalf("networkPeersValues() = %v, want channel filter", got) } if got := networkInboxValues("sess-a"); got.Get("session_id") != "sess-a" { t.Fatalf("networkInboxValues() = %v, want session_id filter", got) @@ -107,7 +107,7 @@ func TestNetworkClientHelpersAndAliases(t *testing.T) { {name: "NetworkSendRequest", cliType: NetworkSendRequest{}, want: contract.NetworkSendRequest{}}, {name: "NetworkSendRecord", cliType: NetworkSendRecord{}, want: contract.NetworkSendPayload{}}, {name: "NetworkPeerRecord", cliType: NetworkPeerRecord{}, want: contract.NetworkPeerPayload{}}, - {name: "NetworkSpaceRecord", cliType: NetworkSpaceRecord{}, want: contract.NetworkSpacePayload{}}, + {name: "NetworkChannelRecord", cliType: NetworkChannelRecord{}, want: contract.NetworkChannelPayload{}}, {name: "NetworkEnvelopeRecord", cliType: NetworkEnvelopeRecord{}, want: contract.NetworkEnvelopePayload{}}, } for _, tt := range tests { diff --git a/internal/cli/network_test.go b/internal/cli/network_test.go index f4fbc31cc..b1df82297 100644 --- a/internal/cli/network_test.go +++ b/internal/cli/network_test.go @@ -24,7 +24,7 @@ func TestNetworkCommandsAndFormatting(t *testing.T) { ListenerPort: 4222, LocalPeers: 1, RemotePeers: 2, - Spaces: 1, + Channels: 1, QueuedMessages: 3, QueuedSessions: 1, DeliveryWorkers: 1, @@ -53,7 +53,7 @@ func TestNetworkCommandsAndFormatting(t *testing.T) { return []NetworkPeerRecord{{ PeerID: "reviewer.sess-a", SessionID: &sessionID, - Space: "builders", + Channel: "builders", Local: true, PeerCard: NetworkPeerCardRecord{ PeerID: "reviewer.sess-a", @@ -67,15 +67,15 @@ func TestNetworkCommandsAndFormatting(t *testing.T) { ExpiresAt: &expires, }}, nil }, - networkSpacesFn: func(context.Context) ([]NetworkSpaceRecord, error) { - return []NetworkSpaceRecord{{Space: "builders", PeerCount: 2}}, nil + networkChannelsFn: func(context.Context) ([]NetworkChannelRecord, error) { + return []NetworkChannelRecord{{Channel: "builders", PeerCount: 2}}, nil }, networkSendFn: func(_ context.Context, request NetworkSendRequest) (NetworkSendRecord, error) { seenSendRequest = request return NetworkSendRecord{ ID: "msg-1", SessionID: request.SessionID, - Space: request.Space, + Channel: request.Channel, Kind: request.Kind, TraceID: request.TraceID, CausationID: request.CausationID, @@ -93,7 +93,7 @@ func TestNetworkCommandsAndFormatting(t *testing.T) { Protocol: "agh-network/v0", ID: "msg-inbox", Kind: "direct", - Space: "builders", + Channel: "builders", From: "reviewer.sess-a", ReplyTo: &replyTo, TraceID: &traceID, @@ -121,8 +121,8 @@ func TestNetworkCommandsAndFormatting(t *testing.T) { if err != nil { t.Fatalf("network peers error = %v", err) } - if seenPeersQuery.Space != "builders" { - t.Fatalf("seenPeersQuery.Space = %q, want builders", seenPeersQuery.Space) + if seenPeersQuery.Channel != "builders" { + t.Fatalf("seenPeersQuery.Channel = %q, want builders", seenPeersQuery.Channel) } var peers []NetworkPeerRecord if err := json.Unmarshal([]byte(peersOut), &peers); err != nil { @@ -132,18 +132,18 @@ func TestNetworkCommandsAndFormatting(t *testing.T) { t.Fatalf("peers = %#v, want one reviewer peer", peers) } - spacesOut, _, err := executeRootCommand(t, deps, "network", "spaces", "-o", "toon") + channelsOut, _, err := executeRootCommand(t, deps, "network", "channels", "-o", "toon") if err != nil { - t.Fatalf("network spaces error = %v", err) + t.Fatalf("network channels error = %v", err) } - if !strings.Contains(spacesOut, "network_spaces[1]{space,peer_count}:") { - t.Fatalf("network spaces toon = %q, want TOON list", spacesOut) + if !strings.Contains(channelsOut, "network_channels[1]{channel,peer_count}:") { + t.Fatalf("network channels toon = %q, want TOON list", channelsOut) } sendOut, _, err := executeRootCommand(t, deps, "network", "send", "--session", "sess-a", - "--space", "builders", + "--channel", "builders", "--kind", "say", "--body", `{"text":"hello"}`, "--interaction-id", "int-1", @@ -196,7 +196,7 @@ func TestNetworkSendParsersRejectInvalidFlags(t *testing.T) { args: []string{ "network", "send", "--session", "sess-a", - "--space", "builders", + "--channel", "builders", "--kind", "say", "--body", `not-json`, }, @@ -207,7 +207,7 @@ func TestNetworkSendParsersRejectInvalidFlags(t *testing.T) { args: []string{ "network", "send", "--session", "sess-a", - "--space", "builders", + "--channel", "builders", "--kind", "say", "--body", `{"text":"ok"}`, "--ext", `[]`, @@ -219,7 +219,7 @@ func TestNetworkSendParsersRejectInvalidFlags(t *testing.T) { args: []string{ "network", "send", "--session", "sess-a", - "--space", "builders", + "--channel", "builders", "--kind", "say", "--body", `{"text":"ok"}`, "--expires-at", `tomorrow`, diff --git a/internal/cli/root.go b/internal/cli/root.go index 27e82741d..26366e12c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -82,7 +82,7 @@ func newRootCommand(deps commandDeps) *cobra.Command { cmd.AddCommand(newDaemonCommand(deps)) cmd.AddCommand(newNetworkCommand(deps)) cmd.AddCommand(newSessionCommand(deps)) - cmd.AddCommand(newChannelCommand(deps)) + cmd.AddCommand(newBridgeCommand(deps)) cmd.AddCommand(newWorkspaceCommand(deps)) cmd.AddCommand(newAgentCommand(deps)) cmd.AddCommand(newExtensionCommand(deps)) diff --git a/internal/cli/session.go b/internal/cli/session.go index e99179bca..895d3255d 100644 --- a/internal/cli/session.go +++ b/internal/cli/session.go @@ -37,7 +37,7 @@ func newSessionCreateCommand(deps commandDeps) *cobra.Command { agentName string cwd string name string - space string + channel string workspaceRef string ) @@ -60,7 +60,7 @@ func newSessionCreateCommand(deps commandDeps) *cobra.Command { Name: name, Workspace: workspace, WorkspacePath: workspacePath, - Space: strings.TrimSpace(space), + Channel: strings.TrimSpace(channel), }) if err != nil { return err @@ -73,7 +73,7 @@ func newSessionCreateCommand(deps commandDeps) *cobra.Command { cmd.Flags().StringVar(&workspaceRef, "workspace", "", "Registered workspace name or ID") cmd.Flags().StringVar(&cwd, "cwd", "", "Absolute workspace directory to auto-register") cmd.Flags().StringVar(&name, "name", "", "Optional session label") - cmd.Flags().StringVar(&space, "space", "", "Optional network space opt-in for the session") + cmd.Flags().StringVar(&channel, "channel", "", "Optional network channel opt-in for the session") return cmd } @@ -370,7 +370,7 @@ func sessionBundle(info SessionRecord, now func() time.Time) outputBundle { {Label: "Name", Value: stringOrDash(info.Name)}, {Label: "Agent", Value: stringOrDash(info.AgentName)}, {Label: "Workspace", Value: stringOrDash(displaySessionWorkspace(info))}, - {Label: "Space", Value: stringOrDash(info.Space)}, + {Label: "Channel", Value: stringOrDash(info.Channel)}, {Label: "State", Value: stringOrDash(string(info.State))}, {Label: "ACP Session", Value: stringOrDash(info.ACPSessionID)}, {Label: "Created", Value: stringOrDash(formatTime(info.CreatedAt))}, @@ -390,13 +390,13 @@ func sessionBundle(info SessionRecord, now func() time.Time) outputBundle { }, toon: func() (string, error) { return renderToonObject("session", []string{ - "id", "name", "agent_name", "workspace", "space", "state", "acp_session_id", "created_at", "updated_at", + "id", "name", "agent_name", "workspace", "channel", "state", "acp_session_id", "created_at", "updated_at", }, []string{ info.ID, info.Name, info.AgentName, displaySessionWorkspace(info), - info.Space, + info.Channel, string(info.State), info.ACPSessionID, formatTime(info.CreatedAt), @@ -411,9 +411,9 @@ func sessionListBundle(items []SessionRecord, now func() time.Time) outputBundle items, items, "Sessions", - []string{"ID", "Name", "Agent", "State", "Workspace", "Space", "Updated"}, + []string{"ID", "Name", "Agent", "State", "Workspace", "Channel", "Updated"}, "sessions", - []string{"id", "name", "agent_name", "state", "workspace", "space", "updated_at"}, + []string{"id", "name", "agent_name", "state", "workspace", "channel", "updated_at"}, func(item SessionRecord) []string { return []string{ stringOrDash(item.ID), @@ -421,7 +421,7 @@ func sessionListBundle(items []SessionRecord, now func() time.Time) outputBundle stringOrDash(item.AgentName), stringOrDash(string(item.State)), stringOrDash(displaySessionWorkspace(item)), - stringOrDash(item.Space), + stringOrDash(item.Channel), stringOrDash(formatAge(now, item.UpdatedAt)), } }, @@ -432,7 +432,7 @@ func sessionListBundle(items []SessionRecord, now func() time.Time) outputBundle item.AgentName, string(item.State), displaySessionWorkspace(item), - item.Space, + item.Channel, formatTime(item.UpdatedAt), } }, diff --git a/internal/cli/session_test.go b/internal/cli/session_test.go index 1083bd242..8f9bab3bf 100644 --- a/internal/cli/session_test.go +++ b/internal/cli/session_test.go @@ -141,20 +141,20 @@ func TestSessionNewWorkspaceOptions(t *testing.T) { } } -func TestSessionNewPassesSpaceFlag(t *testing.T) { +func TestSessionNewPassesChannelFlag(t *testing.T) { t.Parallel() deps := newTestDeps(t, stubClient{ createSessionFn: func(_ context.Context, request CreateSessionRequest) (SessionRecord, error) { - if request.Space != "builders" { - t.Fatalf("CreateSession() Space = %q, want %q", request.Space, "builders") + if request.Channel != "builders" { + t.Fatalf("CreateSession() Channel = %q, want %q", request.Channel, "builders") } return SessionRecord{ ID: "sess-1", AgentName: "general", WorkspaceID: "ws-1", WorkspacePath: request.WorkspacePath, - Space: request.Space, + Channel: request.Channel, State: session.StateActive, CreatedAt: fixedTestNow, UpdatedAt: fixedTestNow, @@ -162,17 +162,17 @@ func TestSessionNewPassesSpaceFlag(t *testing.T) { }, }) - stdout, _, err := executeRootCommand(t, deps, "session", "new", "--space", "builders", "-o", "json") + stdout, _, err := executeRootCommand(t, deps, "session", "new", "--channel", "builders", "-o", "json") if err != nil { - t.Fatalf("executeRootCommand(session new --space) error = %v", err) + t.Fatalf("executeRootCommand(session new --channel) error = %v", err) } var decoded SessionRecord if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { - t.Fatalf("json.Unmarshal(session new --space) error = %v", err) + t.Fatalf("json.Unmarshal(session new --channel) error = %v", err) } - if decoded.Space != "builders" { - t.Fatalf("decoded.Space = %q, want %q", decoded.Space, "builders") + if decoded.Channel != "builders" { + t.Fatalf("decoded.Channel = %q, want %q", decoded.Channel, "builders") } } @@ -544,7 +544,7 @@ func TestSessionListBundleRendersHumanAndToon(t *testing.T) { AgentName: "coder", WorkspaceID: "ws-1", WorkspacePath: "/workspace/project", - Space: "builders", + Channel: "builders", State: session.StateActive, UpdatedAt: fixedTestNow, }} @@ -558,7 +558,7 @@ func TestSessionListBundleRendersHumanAndToon(t *testing.T) { t.Fatalf("sessionListBundle().human() error = %v", err) } if !strings.Contains(human, "sess-1") || !strings.Contains(human, "/workspace/project") || !strings.Contains(human, "builders") { - t.Fatalf("sessionListBundle().human() = %q, want session, workspace, and space output", human) + t.Fatalf("sessionListBundle().human() = %q, want session, workspace, and channel output", human) } toon, err := bundle.toon() @@ -566,6 +566,6 @@ func TestSessionListBundleRendersHumanAndToon(t *testing.T) { t.Fatalf("sessionListBundle().toon() error = %v", err) } if !strings.Contains(toon, "sessions") || !strings.Contains(toon, "sess-1") || !strings.Contains(toon, "builders") { - t.Fatalf("sessionListBundle().toon() = %q, want sessions array output with space", toon) + t.Fatalf("sessionListBundle().toon() = %q, want sessions array output with channel", toon) } } diff --git a/internal/cli/skill_test.go b/internal/cli/skill_test.go index 03b4eeb4c..53ea5aa77 100644 --- a/internal/cli/skill_test.go +++ b/internal/cli/skill_test.go @@ -498,7 +498,7 @@ func TestSkillCreateCommandSupportsDefaultNameAndRejectsUnsafeNames(t *testing.T "../escape", filepath.Join(string(filepath.Separator), "tmp", "skill"), "nested/skill", - "needs space", + "needs channel", "yaml: value", "anchor*name", "line\nbreak", diff --git a/internal/config/config.go b/internal/config/config.go index 6324cb9a0..f6a5fc6c6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -125,13 +125,13 @@ type SkillsConfig struct { // NetworkConfig controls the embedded AGH network runtime. type NetworkConfig struct { - Enabled bool `toml:"enabled"` - DefaultSpace string `toml:"default_space"` - Port int `toml:"port"` - MaxPayload int `toml:"max_payload"` - GreetInterval int `toml:"greet_interval"` - MaxReplayAge int `toml:"max_replay_age"` - MaxQueueDepth int `toml:"max_queue_depth"` + Enabled bool `toml:"enabled"` + DefaultChannel string `toml:"default_channel"` + Port int `toml:"port"` + MaxPayload int `toml:"max_payload"` + GreetInterval int `toml:"greet_interval"` + MaxReplayAge int `toml:"max_replay_age"` + MaxQueueDepth int `toml:"max_queue_depth"` } // Config is the fully merged AGH configuration. @@ -332,13 +332,13 @@ func DefaultWithHome(homePaths HomePaths) Config { DefaultFireLimit: automationpkg.DefaultFireLimitConfig(), }, Network: NetworkConfig{ - Enabled: false, - DefaultSpace: "default", - Port: -1, - MaxPayload: 1 << 20, - GreetInterval: 30, - MaxReplayAge: 300, - MaxQueueDepth: 100, + Enabled: false, + DefaultChannel: "default", + Port: -1, + MaxPayload: 1 << 20, + GreetInterval: 30, + MaxReplayAge: 300, + MaxQueueDepth: 100, }, } } @@ -528,18 +528,18 @@ func (c SkillsConfig) Validate() error { return nil } -var networkSpacePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) +var networkChannelPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) const maxNetworkDurationSeconds = int64(1<<63-1) / int64(time.Second) // Validate ensures the network configuration is internally consistent. func (c NetworkConfig) Validate() error { - defaultSpace := strings.TrimSpace(c.DefaultSpace) - if defaultSpace == "" { - return errors.New("network.default_space is required") + defaultChannel := strings.TrimSpace(c.DefaultChannel) + if defaultChannel == "" { + return errors.New("network.default_channel is required") } - if !networkSpacePattern.MatchString(defaultSpace) { - return fmt.Errorf("network.default_space must match %q: %q", networkSpacePattern.String(), c.DefaultSpace) + if !networkChannelPattern.MatchString(defaultChannel) { + return fmt.Errorf("network.default_channel must match %q: %q", networkChannelPattern.String(), c.DefaultChannel) } if c.Port != -1 && (c.Port <= 0 || c.Port > 65535) { return fmt.Errorf("network.port must be -1 or between 1 and 65535: %d", c.Port) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8fe710860..52d250745 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -92,7 +92,7 @@ check_interval = "45m" [network] enabled = true -default_space = "builders" +default_channel = "builders" port = 4333 max_payload = 65536 greet_interval = 45 @@ -181,8 +181,8 @@ max_queue_depth = 250 if !cfg.Network.Enabled { t.Fatal("Load() Network.Enabled = false, want true") } - if got, want := cfg.Network.DefaultSpace, "builders"; got != want { - t.Fatalf("Load() Network.DefaultSpace = %q, want %q", got, want) + if got, want := cfg.Network.DefaultChannel, "builders"; got != want { + t.Fatalf("Load() Network.DefaultChannel = %q, want %q", got, want) } if got, want := cfg.Network.Port, 4333; got != want { t.Fatalf("Load() Network.Port = %d, want %d", got, want) @@ -977,8 +977,8 @@ func TestDefaultConfigUsesResolvedHomePaths(t *testing.T) { if got, want := cfg.Skills.PollInterval, 3*time.Second; got != want { t.Fatalf("defaultConfig() Skills.PollInterval = %s, want %s", got, want) } - if got, want := cfg.Network.DefaultSpace, "default"; got != want { - t.Fatalf("defaultConfig() Network.DefaultSpace = %q, want %q", got, want) + if got, want := cfg.Network.DefaultChannel, "default"; got != want { + t.Fatalf("defaultConfig() Network.DefaultChannel = %q, want %q", got, want) } if got, want := cfg.Network.Port, -1; got != want { t.Fatalf("defaultConfig() Network.Port = %d, want %d", got, want) @@ -1044,11 +1044,11 @@ func TestNetworkConfigValidateRejectsInvalidValues(t *testing.T) { wantErr: "network.max_queue_depth", }, { - name: "invalid default space", + name: "invalid default channel", mutate: func(cfg *Config) { - cfg.Network.DefaultSpace = "Bad Space" + cfg.Network.DefaultChannel = "Bad Channel" }, - wantErr: "network.default_space", + wantErr: "network.default_channel", }, } diff --git a/internal/config/merge.go b/internal/config/merge.go index 762740273..919cd9517 100644 --- a/internal/config/merge.go +++ b/internal/config/merge.go @@ -109,13 +109,13 @@ type skillsOverlay struct { } type networkOverlay struct { - Enabled *bool `toml:"enabled"` - DefaultSpace *string `toml:"default_space"` - Port *int `toml:"port"` - MaxPayload *int `toml:"max_payload"` - GreetInterval *int `toml:"greet_interval"` - MaxReplayAge *int `toml:"max_replay_age"` - MaxQueueDepth *int `toml:"max_queue_depth"` + Enabled *bool `toml:"enabled"` + DefaultChannel *string `toml:"default_channel"` + Port *int `toml:"port"` + MaxPayload *int `toml:"max_payload"` + GreetInterval *int `toml:"greet_interval"` + MaxReplayAge *int `toml:"max_replay_age"` + MaxQueueDepth *int `toml:"max_queue_depth"` } type marketplaceOverlay struct { @@ -339,8 +339,8 @@ func (o networkOverlay) Apply(dst *NetworkConfig) { if o.Enabled != nil { dst.Enabled = *o.Enabled } - if o.DefaultSpace != nil { - dst.DefaultSpace = *o.DefaultSpace + if o.DefaultChannel != nil { + dst.DefaultChannel = *o.DefaultChannel } if o.Port != nil { dst.Port = *o.Port diff --git a/internal/config/merge_test.go b/internal/config/merge_test.go index 0721dea9c..ff8551c40 100644 --- a/internal/config/merge_test.go +++ b/internal/config/merge_test.go @@ -106,7 +106,7 @@ func TestApplyConfigOverlayFileAppliesNetworkOverlay(t *testing.T) { writeFile(t, overlayPath, ` [network] enabled = true -default_space = "builders" +default_channel = "builders" port = 4555 max_payload = 12345 greet_interval = 15 @@ -121,8 +121,8 @@ max_queue_depth = 12 if !cfg.Network.Enabled { t.Fatal("ApplyConfigOverlayFile() Network.Enabled = false, want true") } - if got, want := cfg.Network.DefaultSpace, "builders"; got != want { - t.Fatalf("ApplyConfigOverlayFile() Network.DefaultSpace = %q, want %q", got, want) + if got, want := cfg.Network.DefaultChannel, "builders"; got != want { + t.Fatalf("ApplyConfigOverlayFile() Network.DefaultChannel = %q, want %q", got, want) } if got, want := cfg.Network.Port, 4555; got != want { t.Fatalf("ApplyConfigOverlayFile() Network.Port = %d, want %d", got, want) diff --git a/internal/daemon/boot.go b/internal/daemon/boot.go index 40c46e06d..4af29b8aa 100644 --- a/internal/daemon/boot.go +++ b/internal/daemon/boot.go @@ -12,7 +12,7 @@ import ( core "github.com/pedronauck/agh/internal/api/core" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" aghconfig "github.com/pedronauck/agh/internal/config" extensionpkg "github.com/pedronauck/agh/internal/extension" hookspkg "github.com/pedronauck/agh/internal/hooks" @@ -51,7 +51,7 @@ type bootState struct { extMu sync.RWMutex extensions extensionRuntime automation automationRuntime - channels *channelRuntime + bridges *bridgeRuntime httpServer Server udsServer Server skillsCancel context.CancelFunc @@ -161,7 +161,7 @@ func (d *Daemon) beginBoot() error { d.network != nil || d.observer != nil || d.automation != nil || - d.channels != nil { + d.bridges != nil { return errors.New("daemon: already booted") } d.booting = true @@ -316,11 +316,11 @@ func (d *Daemon) bootRuntime(ctx context.Context, state *bootState, cleanup *boo state.startedAt = d.now().UTC() state.notifier = newHooksNotifier(state.logger, d.now) - state.channels = d.composeChannelRuntime(state, cleanup) + state.bridges = d.composeBridgeRuntime(state, cleanup) sessionNotifier := session.Notifier(state.notifier) - if state.channels != nil { - sessionNotifier = extensionpkg.NewChannelDeliveryNotifier(state.channels.Broker(), state.notifier) + if state.bridges != nil { + sessionNotifier = extensionpkg.NewBridgeDeliveryNotifier(state.bridges.Broker(), state.notifier) } var skillRegistryDep session.SkillRegistry @@ -380,7 +380,7 @@ func (d *Daemon) bootRuntime(ctx context.Context, state *bootState, cleanup *boo HomePaths: d.homePaths, Logger: state.logger, Sessions: sessions, - Channels: state.channels, + Bridges: state.bridges, Registry: registry, MemoryStore: state.memoryStore, WorkspaceResolver: workspaceResolver, @@ -570,18 +570,18 @@ func (d *Daemon) bootExtensions(ctx context.Context, state *bootState, cleanup * SkillsRegistry: state.skillsRegistry, WorkspaceResolver: state.workspaceResolver, Logger: state.logger, - ChannelRegistry: state.channels, - ChannelDedupStore: channelRuntimeDedupStore(state.channels), - ChannelBroker: channelRuntimeBroker(state.channels), - ChannelRuntime: state.channels, + BridgeRegistry: state.bridges, + BridgeDedupStore: bridgeRuntimeDedupStore(state.bridges), + BridgeBroker: bridgeRuntimeBroker(state.bridges), + BridgeRuntime: state.bridges, }) if manager == nil { state.logger.Warn("daemon: extension manager factory returned nil; skipping extensions") return nil } - if state.channels != nil { - state.channels.setExtensionRuntime(manager) + if state.bridges != nil { + state.bridges.setExtensionRuntime(manager) } state.setExtensionRuntime(manager) state.deps.Extensions = newDaemonExtensionService(extRegistry, manager, state.hooks, state.logger, d.now) @@ -708,7 +708,7 @@ func (d *Daemon) publishBootState(state *bootState) { d.network = state.network d.hooks = state.hooks d.extensions = state.currentExtensionRuntime() - d.channels = state.channels + d.bridges = state.bridges d.observer = state.observer d.automation = state.automation d.httpServer = state.httpServer @@ -776,20 +776,20 @@ func resolveDaemonPort(defaultPort int, server Server) int { return defaultPort } -func (d *Daemon) composeChannelRuntime(state *bootState, cleanup *bootCleanup) *channelRuntime { +func (d *Daemon) composeBridgeRuntime(state *bootState, cleanup *bootCleanup) *bridgeRuntime { if state == nil || state.registry == nil { return nil } - store, ok := state.registry.(channelRuntimeStore) + store, ok := state.registry.(bridgeRuntimeStore) if !ok { if state.logger != nil { - state.logger.Debug("daemon: skipping channel runtime because registry does not expose channel persistence") + state.logger.Debug("daemon: skipping bridge runtime because registry does not expose bridge persistence") } return nil } - runtime := newChannelRuntime(store, state.logger, d.now, d.channelSecretResolver) + runtime := newBridgeRuntime(store, state.logger, d.now, d.bridgeSecretResolver) if runtime == nil { return nil } @@ -802,14 +802,14 @@ func (d *Daemon) composeChannelRuntime(state *bootState, cleanup *bootCleanup) * return runtime } -func channelRuntimeDedupStore(runtime *channelRuntime) channelDedupStore { +func bridgeRuntimeDedupStore(runtime *bridgeRuntime) bridgeDedupStore { if runtime == nil { return nil } return runtime.store } -func channelRuntimeBroker(runtime *channelRuntime) *channelspkg.Broker { +func bridgeRuntimeBroker(runtime *bridgeRuntime) *bridgepkg.Broker { if runtime == nil { return nil } diff --git a/internal/daemon/bridges.go b/internal/daemon/bridges.go new file mode 100644 index 000000000..048675197 --- /dev/null +++ b/internal/daemon/bridges.go @@ -0,0 +1,428 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + bridgepkg "github.com/pedronauck/agh/internal/bridges" + extensionpkg "github.com/pedronauck/agh/internal/extension" + "github.com/pedronauck/agh/internal/subprocess" +) + +type bridgeDedupStore interface { + PutBridgeIngestDedup(ctx context.Context, record bridgepkg.IngestDedupRecord) error + GetBridgeIngestDedup(ctx context.Context, idempotencyKey string, lookupAt time.Time) (bridgepkg.IngestDedupRecord, error) + DeleteExpiredBridgeIngestDedup(ctx context.Context, now time.Time) (int64, error) +} + +type bridgeRuntimeStore interface { + bridgepkg.RegistryStore + bridgeDedupStore + ListBridgeSecretBindings(ctx context.Context, bridgeInstanceID string) ([]bridgepkg.BridgeSecretBinding, error) +} + +var errBridgeSecretResolverRequired = errors.New("daemon: bridge secret resolver is required") + +// BridgeSecretResolver resolves daemon-owned bound secret material for one +// persisted bridge secret binding. +type BridgeSecretResolver interface { + ResolveBridgeSecret(ctx context.Context, binding bridgepkg.BridgeSecretBinding) (string, error) +} + +type bridgeRuntime struct { + *bridgepkg.Service + + store bridgeRuntimeStore + secretResolver BridgeSecretResolver + broker *bridgepkg.Broker + logger *slog.Logger + now func() time.Time + + lifecycleMu sync.Mutex + lifecycleLocks map[string]*bridgeLifecycleLock + mu sync.RWMutex + extensions extensionRuntime +} + +type bridgeLifecycleLock struct { + mu sync.Mutex + refs int +} + +var _ extensionpkg.BridgeRuntimeResolver = (*bridgeRuntime)(nil) + +func newBridgeRuntime( + store bridgeRuntimeStore, + logger *slog.Logger, + now func() time.Time, + secretResolver BridgeSecretResolver, +) *bridgeRuntime { + if store == nil { + return nil + } + if logger == nil { + logger = slog.Default() + } + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + + return &bridgeRuntime{ + Service: bridgepkg.NewRegistry(store, bridgepkg.WithNow(now)), + store: store, + secretResolver: secretResolver, + broker: bridgepkg.NewBroker(nil, bridgepkg.WithDeliveryBrokerNow(now)), + logger: logger, + now: now, + } +} + +func (r *bridgeRuntime) Broker() *bridgepkg.Broker { + if r == nil { + return nil + } + return r.broker +} + +// CreateInstance persists one bridge instance and, when the instance is +// immediately enabled, reloads extensions so bridge-capable adapters can bind +// to the new runtime without requiring a manual restart. +func (r *bridgeRuntime) CreateInstance(ctx context.Context, req bridgepkg.CreateInstanceRequest) (*bridgepkg.BridgeInstance, error) { + if r == nil { + return nil, errors.New("daemon: bridge runtime is required") + } + + unlock := r.lockInstanceLifecycle(req.ID) + defer unlock() + + created, err := r.Service.CreateInstance(ctx, req) + if err != nil { + return nil, err + } + if created == nil || !created.Enabled || created.Status.Normalize() == bridgepkg.BridgeStatusDisabled { + return created, nil + } + if err := r.reloadExtensions(ctx, created.ID); err != nil { + compensated := *created + compensated.Enabled = false + compensated.Status = bridgepkg.BridgeStatusDisabled + if rollbackErr := r.persistCompensatingInstance(ctx, compensated, "disable newly created bridge instance after reload failure"); rollbackErr != nil { + return nil, fmt.Errorf( + "daemon: create bridge instance %q: reload failed and compensation also failed: %w", + strings.TrimSpace(created.ID), + errors.Join(err, rollbackErr), + ) + } + return nil, fmt.Errorf( + "daemon: create bridge instance %q: persisted instance rolled back to disabled after reload failure: %w", + strings.TrimSpace(created.ID), + err, + ) + } + return created, nil +} + +func (r *bridgeRuntime) DeliveryMetrics() map[string]bridgepkg.BridgeDeliveryMetrics { + if r == nil || r.broker == nil { + return nil + } + return r.broker.DeliveryMetrics() +} + +func (r *bridgeRuntime) Close() { + if r == nil || r.broker == nil { + return + } + r.broker.Close() +} + +func (r *bridgeRuntime) setExtensionRuntime(runtime extensionRuntime) { + if r == nil { + return + } + + var transport bridgepkg.DeliveryTransport + if runtimeTransport, ok := runtime.(bridgepkg.DeliveryTransport); ok { + transport = runtimeTransport + } + + r.mu.Lock() + r.extensions = runtime + r.mu.Unlock() + + if r.broker != nil { + r.broker.SetTransport(transport) + } +} + +func (r *bridgeRuntime) StartInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { + return r.transitionInstance(ctx, id, true, bridgepkg.BridgeStatusStarting, true, "start") +} + +func (r *bridgeRuntime) StopInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { + return r.transitionInstance(ctx, id, false, bridgepkg.BridgeStatusDisabled, true, "stop") +} + +func (r *bridgeRuntime) RestartInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { + return r.transitionInstance(ctx, id, true, bridgepkg.BridgeStatusStarting, true, "restart") +} + +func (r *bridgeRuntime) ResolveBridgeRuntime(ctx context.Context, extensionName string) (*subprocess.InitializeBridgeRuntime, error) { + if r == nil { + return nil, errors.New("daemon: bridge runtime is required") + } + if ctx == nil { + return nil, errors.New("daemon: bridge runtime context is required") + } + if err := ctx.Err(); err != nil { + return nil, err + } + + instance, err := r.instanceForExtension(ctx, extensionName) + if err != nil { + return nil, err + } + + boundSecrets, err := r.resolveBoundSecrets(ctx, instance.ID) + if err != nil { + return nil, fmt.Errorf("daemon: resolve bound secrets for bridge instance %q: %w", instance.ID, err) + } + + launching := instance + if !instance.Enabled || instance.Status.Normalize() != bridgepkg.BridgeStatusStarting { + launching, err = r.transitionInstance(ctx, instance.ID, true, bridgepkg.BridgeStatusStarting, false, "launch") + if err != nil { + return nil, err + } + } + + return &subprocess.InitializeBridgeRuntime{ + Instance: *launching, + BoundSecrets: boundSecrets, + }, nil +} + +func (r *bridgeRuntime) transitionInstance( + ctx context.Context, + id string, + enabled bool, + status bridgepkg.BridgeStatus, + reload bool, + action string, +) (*bridgepkg.BridgeInstance, error) { + if r == nil { + return nil, errors.New("daemon: bridge runtime is required") + } + if ctx == nil { + return nil, fmt.Errorf("daemon: %s bridge instance context is required", action) + } + if err := ctx.Err(); err != nil { + return nil, err + } + + trimmedID := strings.TrimSpace(id) + if trimmedID == "" { + return nil, fmt.Errorf("daemon: %s bridge instance id is required", action) + } + + unlock := r.lockInstanceLifecycle(trimmedID) + defer unlock() + + var previous *bridgepkg.BridgeInstance + if reload { + current, loadErr := r.GetInstance(ctx, trimmedID) + if loadErr != nil { + return nil, fmt.Errorf("daemon: %s bridge instance %q: load current state: %w", action, trimmedID, loadErr) + } + previous = current + } + + updated, err := r.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ + ID: trimmedID, + Enabled: enabled, + Status: status, + UpdatedAt: r.now().UTC(), + }) + if err != nil { + return nil, fmt.Errorf("daemon: %s bridge instance %q: %w", action, trimmedID, err) + } + + if reload { + if err := r.reloadExtensions(ctx, trimmedID); err != nil { + if previous == nil { + return nil, err + } + if rollbackErr := r.persistCompensatingInstance(ctx, *previous, "restore bridge instance after reload failure"); rollbackErr != nil { + return nil, fmt.Errorf( + "daemon: %s bridge instance %q: reload failed and persisted-state rollback also failed: %w", + action, + trimmedID, + errors.Join(err, rollbackErr), + ) + } + return nil, fmt.Errorf( + "daemon: %s bridge instance %q: restored persisted state after reload failure: %w", + action, + trimmedID, + err, + ) + } + } + + return updated, nil +} + +func (r *bridgeRuntime) reloadExtensions(ctx context.Context, bridgeInstanceID string) error { + if r == nil { + return errors.New("daemon: bridge runtime is required") + } + if ctx == nil { + return errors.New("daemon: bridge runtime reload context is required") + } + if err := ctx.Err(); err != nil { + return err + } + + r.mu.RLock() + extensions := r.extensions + r.mu.RUnlock() + if extensions == nil { + return nil + } + + if err := extensions.Reload(ctx); err != nil { + return fmt.Errorf("daemon: reload extensions for bridge instance %q: %w", bridgeInstanceID, err) + } + return nil +} + +// lockInstanceLifecycle serializes lifecycle transitions for one bridge +// instance so reload-triggered rollbacks cannot overwrite newer persisted state. +func (r *bridgeRuntime) lockInstanceLifecycle(id string) func() { + if r == nil { + return func() {} + } + + trimmedID := strings.TrimSpace(id) + if trimmedID == "" { + return func() {} + } + + r.lifecycleMu.Lock() + if r.lifecycleLocks == nil { + r.lifecycleLocks = make(map[string]*bridgeLifecycleLock) + } + lock := r.lifecycleLocks[trimmedID] + if lock == nil { + lock = &bridgeLifecycleLock{} + r.lifecycleLocks[trimmedID] = lock + } + lock.refs++ + r.lifecycleMu.Unlock() + + lock.mu.Lock() + return func() { + lock.mu.Unlock() + + r.lifecycleMu.Lock() + lock.refs-- + if lock.refs == 0 { + delete(r.lifecycleLocks, trimmedID) + } + r.lifecycleMu.Unlock() + } +} + +func (r *bridgeRuntime) instanceForExtension(ctx context.Context, extensionName string) (*bridgepkg.BridgeInstance, error) { + trimmed := strings.TrimSpace(extensionName) + if trimmed == "" { + return nil, errors.New("daemon: bridge runtime extension name is required") + } + + instances, err := r.ListInstances(ctx) + if err != nil { + return nil, fmt.Errorf("daemon: list bridge instances for extension %q: %w", trimmed, err) + } + + matches := make([]bridgepkg.BridgeInstance, 0, 1) + for _, instance := range instances { + if strings.TrimSpace(instance.ExtensionName) != trimmed { + continue + } + if !instance.Enabled || instance.Status.Normalize() == bridgepkg.BridgeStatusDisabled { + continue + } + matches = append(matches, instance) + } + + switch len(matches) { + case 0: + return nil, fmt.Errorf("%w: no enabled bridge instance configured for extension %q", extensionpkg.ErrBridgeRuntimeDeferred, trimmed) + case 1: + instance := matches[0] + return &instance, nil + default: + return nil, fmt.Errorf("daemon: multiple enabled bridge instances configured for extension %q", trimmed) + } +} + +func (r *bridgeRuntime) resolveBoundSecrets( + ctx context.Context, + bridgeInstanceID string, +) ([]subprocess.InitializeBridgeBoundSecret, error) { + bindings, err := r.store.ListBridgeSecretBindings(ctx, bridgeInstanceID) + if err != nil { + return nil, fmt.Errorf("daemon: list bridge secret bindings for %q: %w", bridgeInstanceID, err) + } + if len(bindings) == 0 { + return nil, nil + } + if r.secretResolver == nil { + return nil, errBridgeSecretResolverRequired + } + + resolved := make([]subprocess.InitializeBridgeBoundSecret, 0, len(bindings)) + for _, binding := range bindings { + value, err := r.secretResolver.ResolveBridgeSecret(ctx, binding) + if err != nil { + return nil, fmt.Errorf("binding %q: %w", binding.BindingName, err) + } + + secret := subprocess.InitializeBridgeBoundSecret{ + BindingName: binding.BindingName, + Kind: binding.Kind, + Value: value, + } + if err := secret.Validate(); err != nil { + return nil, fmt.Errorf("binding %q: %w", binding.BindingName, err) + } + resolved = append(resolved, secret) + } + + return resolved, nil +} + +func (r *bridgeRuntime) persistCompensatingInstance( + ctx context.Context, + instance bridgepkg.BridgeInstance, + action string, +) error { + if r == nil { + return errors.New("daemon: bridge runtime is required") + } + instance.UpdatedAt = r.now().UTC() + if err := instance.Validate(); err != nil { + return fmt.Errorf("daemon: %s %q: validate compensated state: %w", action, strings.TrimSpace(instance.ID), err) + } + + rollbackCtx := context.WithoutCancel(ctx) + if err := r.store.UpdateBridgeInstance(rollbackCtx, instance); err != nil { + return fmt.Errorf("daemon: %s %q: persist compensated state: %w", action, strings.TrimSpace(instance.ID), err) + } + return nil +} diff --git a/internal/daemon/channels_test.go b/internal/daemon/bridges_test.go similarity index 55% rename from internal/daemon/channels_test.go rename to internal/daemon/bridges_test.go index 5d6f25198..421c20ea0 100644 --- a/internal/daemon/channels_test.go +++ b/internal/daemon/bridges_test.go @@ -7,19 +7,19 @@ import ( "testing" "time" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" extensionpkg "github.com/pedronauck/agh/internal/extension" hookspkg "github.com/pedronauck/agh/internal/hooks" "github.com/pedronauck/agh/internal/testutil" ) -type recordingChannelSecretResolver struct { +type recordingBridgeSecretResolver struct { values map[string]string - calls []channelspkg.ChannelSecretBinding + calls []bridgepkg.BridgeSecretBinding err error } -func (r *recordingChannelSecretResolver) ResolveChannelSecret(_ context.Context, binding channelspkg.ChannelSecretBinding) (string, error) { +func (r *recordingBridgeSecretResolver) ResolveBridgeSecret(_ context.Context, binding bridgepkg.BridgeSecretBinding) (string, error) { r.calls = append(r.calls, binding) if r.err != nil { return "", r.err @@ -30,8 +30,8 @@ func (r *recordingChannelSecretResolver) ResolveChannelSecret(_ context.Context, return "resolved-" + binding.BindingName, nil } -func TestComposeChannelRuntime(t *testing.T) { - t.Run("ShouldReturnNilWhenRegistryDoesNotSupportChannelPersistence", func(t *testing.T) { +func TestComposeBridgeRuntime(t *testing.T) { + t.Run("ShouldReturnNilWhenRegistryDoesNotSupportBridgePersistence", func(t *testing.T) { t.Parallel() homePaths := testHomePaths(t) @@ -45,12 +45,12 @@ func TestComposeChannelRuntime(t *testing.T) { logger: discardLogger(), registry: &recordingRegistry{path: homePaths.DatabaseFile}, } - if runtime := d.composeChannelRuntime(state, &bootCleanup{}); runtime != nil { - t.Fatalf("composeChannelRuntime(recordingRegistry) = %#v, want nil", runtime) + if runtime := d.composeBridgeRuntime(state, &bootCleanup{}); runtime != nil { + t.Fatalf("composeBridgeRuntime(recordingRegistry) = %#v, want nil", runtime) } }) - t.Run("ShouldBuildRuntimeWhenRegistrySupportsChannelPersistence", func(t *testing.T) { + t.Run("ShouldBuildRuntimeWhenRegistrySupportsBridgePersistence", func(t *testing.T) { t.Parallel() homePaths := testHomePaths(t) @@ -66,43 +66,43 @@ func TestComposeChannelRuntime(t *testing.T) { registry: db, } - runtime := d.composeChannelRuntime(state, &bootCleanup{}) + runtime := d.composeBridgeRuntime(state, &bootCleanup{}) if runtime == nil { - t.Fatal("composeChannelRuntime(globaldb) = nil, want non-nil") + t.Fatal("composeBridgeRuntime(globaldb) = nil, want non-nil") } if runtime.Broker() == nil { - t.Fatal("composeChannelRuntime(globaldb) broker = nil, want non-nil") + t.Fatal("composeBridgeRuntime(globaldb) broker = nil, want non-nil") } if runtime.store != db { - t.Fatalf("composeChannelRuntime(globaldb) store = %#v, want global db", runtime.store) + t.Fatalf("composeBridgeRuntime(globaldb) store = %#v, want global db", runtime.store) } }) } -func TestWithChannelSecretResolver(t *testing.T) { +func TestWithBridgeSecretResolver(t *testing.T) { t.Run("ShouldStoreResolverOnDaemon", func(t *testing.T) { t.Parallel() - resolver := &recordingChannelSecretResolver{} + resolver := &recordingBridgeSecretResolver{} d := &Daemon{} - WithChannelSecretResolver(resolver)(d) + WithBridgeSecretResolver(resolver)(d) - if d.channelSecretResolver != resolver { - t.Fatalf("WithChannelSecretResolver() stored %#v, want %#v", d.channelSecretResolver, resolver) + if d.bridgeSecretResolver != resolver { + t.Fatalf("WithBridgeSecretResolver() stored %#v, want %#v", d.bridgeSecretResolver, resolver) } }) } func TestBootExtensions(t *testing.T) { - t.Run("ShouldInjectChannelRuntimeDependencies", func(t *testing.T) { + t.Run("ShouldInjectBridgeRuntimeDependencies", func(t *testing.T) { t.Parallel() db := openDaemonTestGlobalDB(t) now := time.Date(2026, 4, 11, 12, 15, 0, 0, time.UTC) - channels := newChannelRuntime(db, discardLogger(), func() time.Time { return now }, nil) - if channels == nil { - t.Fatal("newChannelRuntime() = nil, want non-nil") + bridges := newBridgeRuntime(db, discardLogger(), func() time.Time { return now }, nil) + if bridges == nil { + t.Fatal("newBridgeRuntime() = nil, want non-nil") } manager := &fakeExtensionRuntime{} @@ -122,24 +122,24 @@ func TestBootExtensions(t *testing.T) { sessions: &fakeSessionManager{}, observer: &fakeObserver{}, workspaceResolver: nil, - channels: channels, + bridges: bridges, } if err := d.bootExtensions(testutil.Context(t), state, &bootCleanup{}); err != nil { t.Fatalf("bootExtensions() error = %v", err) } - if captured.ChannelRegistry != channels { - t.Fatalf("extension deps channel registry = %#v, want channel runtime", captured.ChannelRegistry) + if captured.BridgeRegistry != bridges { + t.Fatalf("extension deps bridge registry = %#v, want bridge runtime", captured.BridgeRegistry) } - if captured.ChannelDedupStore == nil { - t.Fatal("extension deps channel dedup store = nil, want non-nil") + if captured.BridgeDedupStore == nil { + t.Fatal("extension deps bridge dedup store = nil, want non-nil") } - if captured.ChannelBroker != channels.Broker() { - t.Fatalf("extension deps channel broker = %#v, want runtime broker", captured.ChannelBroker) + if captured.BridgeBroker != bridges.Broker() { + t.Fatalf("extension deps bridge broker = %#v, want runtime broker", captured.BridgeBroker) } - if captured.ChannelRuntime != channels { - t.Fatalf("extension deps channel runtime = %#v, want channel runtime", captured.ChannelRuntime) + if captured.BridgeRuntime != bridges { + t.Fatalf("extension deps bridge runtime = %#v, want bridge runtime", captured.BridgeRuntime) } if manager.startCount != 1 { t.Fatalf("extension manager start count = %d, want 1", manager.startCount) @@ -147,21 +147,21 @@ func TestBootExtensions(t *testing.T) { }) } -func TestChannelRuntimeStartInstance(t *testing.T) { +func TestBridgeRuntimeStartInstance(t *testing.T) { t.Run("ShouldReturnNilRuntimeWhenStoreIsMissing", func(t *testing.T) { t.Parallel() - if runtime := newChannelRuntime(nil, nil, nil, nil); runtime != nil { - t.Fatalf("newChannelRuntime(nil store) = %#v, want nil", runtime) + if runtime := newBridgeRuntime(nil, nil, nil, nil); runtime != nil { + t.Fatalf("newBridgeRuntime(nil store) = %#v, want nil", runtime) } }) t.Run("ShouldHandleNilBrokerAccess", func(t *testing.T) { t.Parallel() - var nilRuntime *channelRuntime + var nilRuntime *bridgeRuntime if broker := nilRuntime.Broker(); broker != nil { - t.Fatalf("(*channelRuntime)(nil).Broker() = %#v, want nil", broker) + t.Fatalf("(*bridgeRuntime)(nil).Broker() = %#v, want nil", broker) } nilRuntime.Close() }) @@ -170,19 +170,19 @@ func TestChannelRuntimeStartInstance(t *testing.T) { t.Parallel() db := openDaemonTestGlobalDB(t) - runtime := newChannelRuntime(db, nil, nil, nil) + runtime := newBridgeRuntime(db, nil, nil, nil) extensions := &fakeExtensionRuntime{} runtime.setExtensionRuntime(extensions) - instance := mustCreateDaemonChannelInstance(t, runtime, channelspkg.CreateInstanceRequest{ - ID: "chan-start", - Scope: channelspkg.ScopeGlobal, + instance := mustCreateDaemonBridgeInstance(t, runtime, bridgepkg.CreateInstanceRequest{ + ID: "brg-start", + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: "ext-start", - DisplayName: "Start Channel", + DisplayName: "Start Bridge", Enabled: false, - Status: channelspkg.ChannelStatusDisabled, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusDisabled, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) updated, err := runtime.StartInstance(testutil.Context(t), instance.ID) @@ -192,37 +192,37 @@ func TestChannelRuntimeStartInstance(t *testing.T) { if !updated.Enabled { t.Fatal("StartInstance() left instance disabled, want enabled") } - if got, want := updated.Status, channelspkg.ChannelStatusStarting; got != want { + if got, want := updated.Status, bridgepkg.BridgeStatusStarting; got != want { t.Fatalf("StartInstance().Status = %q, want %q", got, want) } if got, want := extensions.reloadCount, 1; got != want { t.Fatalf("extension reload count = %d, want %d", got, want) } if runtime.Broker() == nil { - t.Fatal("newChannelRuntime() broker = nil, want non-nil") + t.Fatal("newBridgeRuntime() broker = nil, want non-nil") } }) } -func TestChannelRuntimeCreateInstance(t *testing.T) { +func TestBridgeRuntimeCreateInstance(t *testing.T) { t.Run("ShouldReloadExtensionsWhenEnabled", func(t *testing.T) { t.Parallel() db := openDaemonTestGlobalDB(t) now := time.Date(2026, 4, 11, 12, 20, 0, 0, time.UTC) - runtime := newChannelRuntime(db, discardLogger(), func() time.Time { return now }, nil) + runtime := newBridgeRuntime(db, discardLogger(), func() time.Time { return now }, nil) extensions := &fakeExtensionRuntime{} runtime.setExtensionRuntime(extensions) - created, err := runtime.CreateInstance(testutil.Context(t), channelspkg.CreateInstanceRequest{ - ID: "chan-create", - Scope: channelspkg.ScopeGlobal, + created, err := runtime.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{ + ID: "brg-create", + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: "ext-create", - DisplayName: "Create Channel", + DisplayName: "Create Bridge", Enabled: true, - Status: channelspkg.ChannelStatusStarting, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusStarting, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) if err != nil { t.Fatalf("CreateInstance() error = %v", err) @@ -242,18 +242,18 @@ func TestChannelRuntimeCreateInstance(t *testing.T) { now := time.Date(2026, 4, 11, 12, 21, 0, 0, time.UTC) reloadErr := errors.New("reload boom") extensions := &fakeExtensionRuntime{reloadErr: reloadErr} - runtime := newChannelRuntime(db, discardLogger(), func() time.Time { return now }, nil) + runtime := newBridgeRuntime(db, discardLogger(), func() time.Time { return now }, nil) runtime.setExtensionRuntime(extensions) - _, err := runtime.CreateInstance(testutil.Context(t), channelspkg.CreateInstanceRequest{ - ID: "chan-create-rollback", - Scope: channelspkg.ScopeGlobal, + _, err := runtime.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{ + ID: "brg-create-rollback", + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: "ext-create-rollback", - DisplayName: "Create Rollback Channel", + DisplayName: "Create Rollback Bridge", Enabled: true, - Status: channelspkg.ChannelStatusStarting, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusStarting, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) if !errors.Is(err, reloadErr) { t.Fatalf("CreateInstance() error = %v, want wrapped reload failure", err) @@ -262,75 +262,75 @@ func TestChannelRuntimeCreateInstance(t *testing.T) { t.Fatalf("extension reload count after failed create = %d, want %d", got, want) } - created, getErr := runtime.GetInstance(testutil.Context(t), "chan-create-rollback") + created, getErr := runtime.GetInstance(testutil.Context(t), "brg-create-rollback") if getErr != nil { t.Fatalf("GetInstance() error = %v", getErr) } if created.Enabled { t.Fatal("GetInstance() after failed create left instance enabled, want disabled rollback") } - if got, want := created.Status, channelspkg.ChannelStatusDisabled; got != want { + if got, want := created.Status, bridgepkg.BridgeStatusDisabled; got != want { t.Fatalf("GetInstance().Status after failed create = %q, want %q", got, want) } }) } -func TestChannelRuntimeResolveChannelRuntime(t *testing.T) { +func TestBridgeRuntimeResolveBridgeRuntime(t *testing.T) { t.Run("ShouldResolveBoundSecrets", func(t *testing.T) { t.Parallel() db := openDaemonTestGlobalDB(t) now := time.Date(2026, 4, 11, 12, 30, 0, 0, time.UTC) - resolver := &recordingChannelSecretResolver{ + resolver := &recordingBridgeSecretResolver{ values: map[string]string{ "bot_token": "secret-value", }, } - runtime := newChannelRuntime(db, discardLogger(), func() time.Time { return now }, resolver) - instance := mustCreateDaemonChannelInstance(t, runtime, channelspkg.CreateInstanceRequest{ - ID: "chan-secret", - Scope: channelspkg.ScopeGlobal, + runtime := newBridgeRuntime(db, discardLogger(), func() time.Time { return now }, resolver) + instance := mustCreateDaemonBridgeInstance(t, runtime, bridgepkg.CreateInstanceRequest{ + ID: "brg-secret", + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: "ext-secret", - DisplayName: "Secret Channel", + DisplayName: "Secret Bridge", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) - if err := db.PutChannelSecretBinding(testutil.Context(t), channelspkg.ChannelSecretBinding{ - ChannelInstanceID: instance.ID, - BindingName: "bot_token", - VaultRef: "vault://channels/ext-secret/bot-token", - Kind: "bot_token", - CreatedAt: now, - UpdatedAt: now, + if err := db.PutBridgeSecretBinding(testutil.Context(t), bridgepkg.BridgeSecretBinding{ + BridgeInstanceID: instance.ID, + BindingName: "bot_token", + VaultRef: "vault://bridges/ext-secret/bot-token", + Kind: "bot_token", + CreatedAt: now, + UpdatedAt: now, }); err != nil { - t.Fatalf("PutChannelSecretBinding() error = %v", err) + t.Fatalf("PutBridgeSecretBinding() error = %v", err) } - launch, err := runtime.ResolveChannelRuntime(testutil.Context(t), "ext-secret") + launch, err := runtime.ResolveBridgeRuntime(testutil.Context(t), "ext-secret") if err != nil { - t.Fatalf("ResolveChannelRuntime() error = %v", err) + t.Fatalf("ResolveBridgeRuntime() error = %v", err) } if launch == nil { - t.Fatal("ResolveChannelRuntime() = nil, want non-nil") + t.Fatal("ResolveBridgeRuntime() = nil, want non-nil") } if got, want := launch.Instance.ID, instance.ID; got != want { - t.Fatalf("ResolveChannelRuntime().Instance.ID = %q, want %q", got, want) + t.Fatalf("ResolveBridgeRuntime().Instance.ID = %q, want %q", got, want) } if got := launch.BoundSecrets; len(got) != 1 || got[0].BindingName != "bot_token" || got[0].Value != "secret-value" { - t.Fatalf("ResolveChannelRuntime().BoundSecrets = %#v, want resolved bot_token binding", got) + t.Fatalf("ResolveBridgeRuntime().BoundSecrets = %#v, want resolved bot_token binding", got) } if len(resolver.calls) != 1 || resolver.calls[0].BindingName != "bot_token" { - t.Fatalf("ResolveChannelSecret() calls = %#v, want bot_token binding", resolver.calls) + t.Fatalf("ResolveBridgeSecret() calls = %#v, want bot_token binding", resolver.calls) } updated, err := runtime.GetInstance(testutil.Context(t), instance.ID) if err != nil { t.Fatalf("GetInstance() error = %v", err) } - if got, want := updated.Status, channelspkg.ChannelStatusStarting; got != want { + if got, want := updated.Status, bridgepkg.BridgeStatusStarting; got != want { t.Fatalf("instance status after launch = %q, want %q", got, want) } }) @@ -340,31 +340,31 @@ func TestChannelRuntimeResolveChannelRuntime(t *testing.T) { db := openDaemonTestGlobalDB(t) now := time.Date(2026, 4, 11, 12, 35, 0, 0, time.UTC) - runtime := newChannelRuntime(db, discardLogger(), func() time.Time { return now }, nil) - instance := mustCreateDaemonChannelInstance(t, runtime, channelspkg.CreateInstanceRequest{ - ID: "chan-secret-missing", - Scope: channelspkg.ScopeGlobal, + runtime := newBridgeRuntime(db, discardLogger(), func() time.Time { return now }, nil) + instance := mustCreateDaemonBridgeInstance(t, runtime, bridgepkg.CreateInstanceRequest{ + ID: "brg-secret-missing", + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: "ext-secret-missing", DisplayName: "Secret Missing", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) - if err := db.PutChannelSecretBinding(testutil.Context(t), channelspkg.ChannelSecretBinding{ - ChannelInstanceID: instance.ID, - BindingName: "bot_token", - VaultRef: "vault://channels/ext-secret-missing/bot-token", - Kind: "bot_token", - CreatedAt: now, - UpdatedAt: now, + if err := db.PutBridgeSecretBinding(testutil.Context(t), bridgepkg.BridgeSecretBinding{ + BridgeInstanceID: instance.ID, + BindingName: "bot_token", + VaultRef: "vault://bridges/ext-secret-missing/bot-token", + Kind: "bot_token", + CreatedAt: now, + UpdatedAt: now, }); err != nil { - t.Fatalf("PutChannelSecretBinding() error = %v", err) + t.Fatalf("PutBridgeSecretBinding() error = %v", err) } - _, err := runtime.ResolveChannelRuntime(testutil.Context(t), instance.ExtensionName) - if !errors.Is(err, errChannelSecretResolverRequired) { - t.Fatalf("ResolveChannelRuntime() error = %v, want missing secret resolver sentinel", err) + _, err := runtime.ResolveBridgeRuntime(testutil.Context(t), instance.ExtensionName) + if !errors.Is(err, errBridgeSecretResolverRequired) { + t.Fatalf("ResolveBridgeRuntime() error = %v, want missing secret resolver sentinel", err) } }) @@ -374,40 +374,40 @@ func TestChannelRuntimeResolveChannelRuntime(t *testing.T) { db := openDaemonTestGlobalDB(t) now := time.Date(2026, 4, 11, 12, 36, 0, 0, time.UTC) resolverErr := errors.New("vault boom") - resolver := &recordingChannelSecretResolver{err: resolverErr} - runtime := newChannelRuntime(db, discardLogger(), func() time.Time { return now }, resolver) + resolver := &recordingBridgeSecretResolver{err: resolverErr} + runtime := newBridgeRuntime(db, discardLogger(), func() time.Time { return now }, resolver) - instance := mustCreateDaemonChannelInstance(t, runtime, channelspkg.CreateInstanceRequest{ - ID: "chan-secret-fail", - Scope: channelspkg.ScopeGlobal, + instance := mustCreateDaemonBridgeInstance(t, runtime, bridgepkg.CreateInstanceRequest{ + ID: "brg-secret-fail", + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: "ext-secret-fail", DisplayName: "Secret Failure", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) - if err := db.PutChannelSecretBinding(testutil.Context(t), channelspkg.ChannelSecretBinding{ - ChannelInstanceID: instance.ID, - BindingName: "bot_token", - VaultRef: "vault://channels/ext-secret-fail/bot-token", - Kind: "bot_token", - CreatedAt: now, - UpdatedAt: now, + if err := db.PutBridgeSecretBinding(testutil.Context(t), bridgepkg.BridgeSecretBinding{ + BridgeInstanceID: instance.ID, + BindingName: "bot_token", + VaultRef: "vault://bridges/ext-secret-fail/bot-token", + Kind: "bot_token", + CreatedAt: now, + UpdatedAt: now, }); err != nil { - t.Fatalf("PutChannelSecretBinding() error = %v", err) + t.Fatalf("PutBridgeSecretBinding() error = %v", err) } - _, err := runtime.ResolveChannelRuntime(testutil.Context(t), instance.ExtensionName) + _, err := runtime.ResolveBridgeRuntime(testutil.Context(t), instance.ExtensionName) if !errors.Is(err, resolverErr) { - t.Fatalf("ResolveChannelRuntime() error = %v, want wrapped resolver error", err) + t.Fatalf("ResolveBridgeRuntime() error = %v, want wrapped resolver error", err) } updated, getErr := runtime.GetInstance(testutil.Context(t), instance.ID) if getErr != nil { t.Fatalf("GetInstance() error = %v", getErr) } - if got, want := updated.Status, channelspkg.ChannelStatusReady; got != want { + if got, want := updated.Status, bridgepkg.BridgeStatusReady; got != want { t.Fatalf("instance status after failed secret resolution = %q, want %q", got, want) } }) @@ -416,43 +416,43 @@ func TestChannelRuntimeResolveChannelRuntime(t *testing.T) { t.Parallel() db := openDaemonTestGlobalDB(t) - runtime := newChannelRuntime(db, discardLogger(), nil, nil) + runtime := newBridgeRuntime(db, discardLogger(), nil, nil) - _, err := runtime.ResolveChannelRuntime(testutil.Context(t), "ext-missing") - if !errors.Is(err, extensionpkg.ErrChannelRuntimeDeferred) { - t.Fatalf("ResolveChannelRuntime() error = %v, want deferred sentinel", err) + _, err := runtime.ResolveBridgeRuntime(testutil.Context(t), "ext-missing") + if !errors.Is(err, extensionpkg.ErrBridgeRuntimeDeferred) { + t.Fatalf("ResolveBridgeRuntime() error = %v, want deferred sentinel", err) } }) } -func TestChannelRuntimeStopInstance(t *testing.T) { +func TestBridgeRuntimeStopInstance(t *testing.T) { t.Run("ShouldBlockIngressAndPreserveRoutes", func(t *testing.T) { t.Parallel() db := openDaemonTestGlobalDB(t) now := time.Date(2026, 4, 11, 12, 45, 0, 0, time.UTC) - runtime := newChannelRuntime(db, discardLogger(), func() time.Time { return now }, nil) + runtime := newBridgeRuntime(db, discardLogger(), func() time.Time { return now }, nil) extensions := &fakeExtensionRuntime{} runtime.setExtensionRuntime(extensions) - instance := mustCreateDaemonChannelInstance(t, runtime, channelspkg.CreateInstanceRequest{ - ID: "chan-stop", - Scope: channelspkg.ScopeGlobal, + instance := mustCreateDaemonBridgeInstance(t, runtime, bridgepkg.CreateInstanceRequest{ + ID: "brg-stop", + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: "ext-stop", - DisplayName: "Stop Channel", + DisplayName: "Stop Bridge", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) extensions.reloadCount = 0 - route := mustUpsertDaemonChannelRoute(t, runtime, channelspkg.ChannelRoute{ - Scope: channelspkg.ScopeGlobal, - ChannelInstanceID: instance.ID, - PeerID: "peer-stop", - SessionID: "sess-stop", - AgentName: "coder", - LastActivityAt: now, + route := mustUpsertDaemonBridgeRoute(t, runtime, bridgepkg.BridgeRoute{ + Scope: bridgepkg.ScopeGlobal, + BridgeInstanceID: instance.ID, + PeerID: "peer-stop", + SessionID: "sess-stop", + AgentName: "coder", + LastActivityAt: now, }) updated, err := runtime.StopInstance(testutil.Context(t), instance.ID) @@ -462,7 +462,7 @@ func TestChannelRuntimeStopInstance(t *testing.T) { if updated.Enabled { t.Fatal("StopInstance() left instance enabled, want disabled") } - if got, want := updated.Status, channelspkg.ChannelStatusDisabled; got != want { + if got, want := updated.Status, bridgepkg.BridgeStatusDisabled; got != want { t.Fatalf("StopInstance().Status = %q, want %q", got, want) } @@ -474,40 +474,40 @@ func TestChannelRuntimeStopInstance(t *testing.T) { t.Fatalf("ListRoutes() = %#v, want preserved route %#v", routes, route) } - if _, err := runtime.ResolveRoute(testutil.Context(t), route.RoutingKey()); !errors.Is(err, channelspkg.ErrChannelInstanceUnavailable) { - t.Fatalf("ResolveRoute(disabled) error = %v, want ErrChannelInstanceUnavailable", err) + if _, err := runtime.ResolveRoute(testutil.Context(t), route.RoutingKey()); !errors.Is(err, bridgepkg.ErrBridgeInstanceUnavailable) { + t.Fatalf("ResolveRoute(disabled) error = %v, want ErrBridgeInstanceUnavailable", err) } }) } -func TestChannelRuntimeRestartInstance(t *testing.T) { +func TestBridgeRuntimeRestartInstance(t *testing.T) { t.Run("ShouldPreserveRoutesAndReloadExtensions", func(t *testing.T) { t.Parallel() db := openDaemonTestGlobalDB(t) now := time.Date(2026, 4, 11, 13, 0, 0, 0, time.UTC) - runtime := newChannelRuntime(db, discardLogger(), func() time.Time { return now }, nil) + runtime := newBridgeRuntime(db, discardLogger(), func() time.Time { return now }, nil) extensions := &fakeExtensionRuntime{} runtime.setExtensionRuntime(extensions) - instance := mustCreateDaemonChannelInstance(t, runtime, channelspkg.CreateInstanceRequest{ - ID: "chan-restart", - Scope: channelspkg.ScopeGlobal, + instance := mustCreateDaemonBridgeInstance(t, runtime, bridgepkg.CreateInstanceRequest{ + ID: "brg-restart", + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: "ext-restart", - DisplayName: "Restart Channel", + DisplayName: "Restart Bridge", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) extensions.reloadCount = 0 - route := mustUpsertDaemonChannelRoute(t, runtime, channelspkg.ChannelRoute{ - Scope: channelspkg.ScopeGlobal, - ChannelInstanceID: instance.ID, - PeerID: "peer-restart", - SessionID: "sess-restart", - AgentName: "coder", - LastActivityAt: now, + route := mustUpsertDaemonBridgeRoute(t, runtime, bridgepkg.BridgeRoute{ + Scope: bridgepkg.ScopeGlobal, + BridgeInstanceID: instance.ID, + PeerID: "peer-restart", + SessionID: "sess-restart", + AgentName: "coder", + LastActivityAt: now, }) updated, err := runtime.RestartInstance(testutil.Context(t), instance.ID) @@ -520,7 +520,7 @@ func TestChannelRuntimeRestartInstance(t *testing.T) { if !updated.Enabled { t.Fatal("RestartInstance() disabled the instance, want enabled") } - if got, want := updated.Status, channelspkg.ChannelStatusStarting; got != want { + if got, want := updated.Status, bridgepkg.BridgeStatusStarting; got != want { t.Fatalf("RestartInstance().Status = %q, want %q", got, want) } @@ -542,7 +542,7 @@ func TestChannelRuntimeRestartInstance(t *testing.T) { }) } -func TestChannelRuntimeTransition(t *testing.T) { +func TestBridgeRuntimeTransition(t *testing.T) { t.Run("ShouldRestorePreviousStateWhenReloadFails", func(t *testing.T) { t.Parallel() @@ -550,63 +550,63 @@ func TestChannelRuntimeTransition(t *testing.T) { testCases := []struct { name string - request channelspkg.CreateInstanceRequest - transition func(*channelRuntime, context.Context, string) (*channelspkg.ChannelInstance, error) - wantState channelspkg.ChannelStatus + request bridgepkg.CreateInstanceRequest + transition func(*bridgeRuntime, context.Context, string) (*bridgepkg.BridgeInstance, error) + wantState bridgepkg.BridgeStatus wantEnable bool }{ { name: "ShouldRollbackStart", - request: channelspkg.CreateInstanceRequest{ - ID: "chan-start-rollback", - Scope: channelspkg.ScopeGlobal, + request: bridgepkg.CreateInstanceRequest{ + ID: "brg-start-rollback", + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: "ext-start-rollback", DisplayName: "Start Rollback", Enabled: false, - Status: channelspkg.ChannelStatusDisabled, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusDisabled, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }, - transition: func(runtime *channelRuntime, ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { + transition: func(runtime *bridgeRuntime, ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { return runtime.StartInstance(ctx, id) }, - wantState: channelspkg.ChannelStatusDisabled, + wantState: bridgepkg.BridgeStatusDisabled, wantEnable: false, }, { name: "ShouldRollbackStop", - request: channelspkg.CreateInstanceRequest{ - ID: "chan-stop-rollback", - Scope: channelspkg.ScopeGlobal, + request: bridgepkg.CreateInstanceRequest{ + ID: "brg-stop-rollback", + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: "ext-stop-rollback", DisplayName: "Stop Rollback", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }, - transition: func(runtime *channelRuntime, ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { + transition: func(runtime *bridgeRuntime, ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { return runtime.StopInstance(ctx, id) }, - wantState: channelspkg.ChannelStatusReady, + wantState: bridgepkg.BridgeStatusReady, wantEnable: true, }, { name: "ShouldRollbackRestart", - request: channelspkg.CreateInstanceRequest{ - ID: "chan-restart-rollback", - Scope: channelspkg.ScopeGlobal, + request: bridgepkg.CreateInstanceRequest{ + ID: "brg-restart-rollback", + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: "ext-restart-rollback", DisplayName: "Restart Rollback", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }, - transition: func(runtime *channelRuntime, ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { + transition: func(runtime *bridgeRuntime, ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { return runtime.RestartInstance(ctx, id) }, - wantState: channelspkg.ChannelStatusReady, + wantState: bridgepkg.BridgeStatusReady, wantEnable: true, }, } @@ -618,9 +618,9 @@ func TestChannelRuntimeTransition(t *testing.T) { db := openDaemonTestGlobalDB(t) reloadErr := errors.New("reload boom") - runtime := newChannelRuntime(db, discardLogger(), func() time.Time { return now }, nil) + runtime := newBridgeRuntime(db, discardLogger(), func() time.Time { return now }, nil) - instance := mustCreateDaemonChannelInstance(t, runtime, tt.request) + instance := mustCreateDaemonBridgeInstance(t, runtime, tt.request) runtime.setExtensionRuntime(&fakeExtensionRuntime{reloadErr: reloadErr}) _, err := tt.transition(runtime, testutil.Context(t), instance.ID) @@ -648,17 +648,17 @@ func TestChannelRuntimeTransition(t *testing.T) { db := openDaemonTestGlobalDB(t) now := time.Date(2026, 4, 11, 13, 10, 0, 0, time.UTC) reloadErr := errors.New("reload boom") - runtime := newChannelRuntime(db, discardLogger(), func() time.Time { return now }, nil) + runtime := newBridgeRuntime(db, discardLogger(), func() time.Time { return now }, nil) - instance := mustCreateDaemonChannelInstance(t, runtime, channelspkg.CreateInstanceRequest{ - ID: "chan-race", - Scope: channelspkg.ScopeGlobal, + instance := mustCreateDaemonBridgeInstance(t, runtime, bridgepkg.CreateInstanceRequest{ + ID: "brg-race", + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: "ext-race", - DisplayName: "Race Channel", + DisplayName: "Race Bridge", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) extensions := newBlockingReloadExtensionRuntime(reloadErr) runtime.setExtensionRuntime(extensions) @@ -706,17 +706,17 @@ func TestChannelRuntimeTransition(t *testing.T) { if updated.Enabled { t.Fatal("GetInstance() after concurrent restart/stop left instance enabled, want disabled") } - if got, want := updated.Status, channelspkg.ChannelStatusDisabled; got != want { + if got, want := updated.Status, bridgepkg.BridgeStatusDisabled; got != want { t.Fatalf("GetInstance().Status after concurrent restart/stop = %q, want %q", got, want) } }) } -func mustCreateDaemonChannelInstance( +func mustCreateDaemonBridgeInstance( t *testing.T, - runtime *channelRuntime, - req channelspkg.CreateInstanceRequest, -) *channelspkg.ChannelInstance { + runtime *bridgeRuntime, + req bridgepkg.CreateInstanceRequest, +) *bridgepkg.BridgeInstance { t.Helper() instance, err := runtime.CreateInstance(testutil.Context(t), req) @@ -726,11 +726,11 @@ func mustCreateDaemonChannelInstance( return instance } -func mustUpsertDaemonChannelRoute( +func mustUpsertDaemonBridgeRoute( t *testing.T, - runtime *channelRuntime, - route channelspkg.ChannelRoute, -) *channelspkg.ChannelRoute { + runtime *bridgeRuntime, + route bridgepkg.BridgeRoute, +) *bridgepkg.BridgeRoute { t.Helper() resolved, err := runtime.UpsertRoute(testutil.Context(t), route) diff --git a/internal/daemon/channels.go b/internal/daemon/channels.go deleted file mode 100644 index bec7e5832..000000000 --- a/internal/daemon/channels.go +++ /dev/null @@ -1,428 +0,0 @@ -package daemon - -import ( - "context" - "errors" - "fmt" - "log/slog" - "strings" - "sync" - "time" - - channelspkg "github.com/pedronauck/agh/internal/channels" - extensionpkg "github.com/pedronauck/agh/internal/extension" - "github.com/pedronauck/agh/internal/subprocess" -) - -type channelDedupStore interface { - PutChannelIngestDedup(ctx context.Context, record channelspkg.IngestDedupRecord) error - GetChannelIngestDedup(ctx context.Context, idempotencyKey string, lookupAt time.Time) (channelspkg.IngestDedupRecord, error) - DeleteExpiredChannelIngestDedup(ctx context.Context, now time.Time) (int64, error) -} - -type channelRuntimeStore interface { - channelspkg.RegistryStore - channelDedupStore - ListChannelSecretBindings(ctx context.Context, channelInstanceID string) ([]channelspkg.ChannelSecretBinding, error) -} - -var errChannelSecretResolverRequired = errors.New("daemon: channel secret resolver is required") - -// ChannelSecretResolver resolves daemon-owned bound secret material for one -// persisted channel secret binding. -type ChannelSecretResolver interface { - ResolveChannelSecret(ctx context.Context, binding channelspkg.ChannelSecretBinding) (string, error) -} - -type channelRuntime struct { - *channelspkg.Service - - store channelRuntimeStore - secretResolver ChannelSecretResolver - broker *channelspkg.Broker - logger *slog.Logger - now func() time.Time - - lifecycleMu sync.Mutex - lifecycleLocks map[string]*channelLifecycleLock - mu sync.RWMutex - extensions extensionRuntime -} - -type channelLifecycleLock struct { - mu sync.Mutex - refs int -} - -var _ extensionpkg.ChannelRuntimeResolver = (*channelRuntime)(nil) - -func newChannelRuntime( - store channelRuntimeStore, - logger *slog.Logger, - now func() time.Time, - secretResolver ChannelSecretResolver, -) *channelRuntime { - if store == nil { - return nil - } - if logger == nil { - logger = slog.Default() - } - if now == nil { - now = func() time.Time { return time.Now().UTC() } - } - - return &channelRuntime{ - Service: channelspkg.NewRegistry(store, channelspkg.WithNow(now)), - store: store, - secretResolver: secretResolver, - broker: channelspkg.NewBroker(nil, channelspkg.WithDeliveryBrokerNow(now)), - logger: logger, - now: now, - } -} - -func (r *channelRuntime) Broker() *channelspkg.Broker { - if r == nil { - return nil - } - return r.broker -} - -// CreateInstance persists one channel instance and, when the instance is -// immediately enabled, reloads extensions so channel-capable adapters can bind -// to the new runtime without requiring a manual restart. -func (r *channelRuntime) CreateInstance(ctx context.Context, req channelspkg.CreateInstanceRequest) (*channelspkg.ChannelInstance, error) { - if r == nil { - return nil, errors.New("daemon: channel runtime is required") - } - - unlock := r.lockInstanceLifecycle(req.ID) - defer unlock() - - created, err := r.Service.CreateInstance(ctx, req) - if err != nil { - return nil, err - } - if created == nil || !created.Enabled || created.Status.Normalize() == channelspkg.ChannelStatusDisabled { - return created, nil - } - if err := r.reloadExtensions(ctx, created.ID); err != nil { - compensated := *created - compensated.Enabled = false - compensated.Status = channelspkg.ChannelStatusDisabled - if rollbackErr := r.persistCompensatingInstance(ctx, compensated, "disable newly created channel instance after reload failure"); rollbackErr != nil { - return nil, fmt.Errorf( - "daemon: create channel instance %q: reload failed and compensation also failed: %w", - strings.TrimSpace(created.ID), - errors.Join(err, rollbackErr), - ) - } - return nil, fmt.Errorf( - "daemon: create channel instance %q: persisted instance rolled back to disabled after reload failure: %w", - strings.TrimSpace(created.ID), - err, - ) - } - return created, nil -} - -func (r *channelRuntime) DeliveryMetrics() map[string]channelspkg.ChannelDeliveryMetrics { - if r == nil || r.broker == nil { - return nil - } - return r.broker.DeliveryMetrics() -} - -func (r *channelRuntime) Close() { - if r == nil || r.broker == nil { - return - } - r.broker.Close() -} - -func (r *channelRuntime) setExtensionRuntime(runtime extensionRuntime) { - if r == nil { - return - } - - var transport channelspkg.DeliveryTransport - if runtimeTransport, ok := runtime.(channelspkg.DeliveryTransport); ok { - transport = runtimeTransport - } - - r.mu.Lock() - r.extensions = runtime - r.mu.Unlock() - - if r.broker != nil { - r.broker.SetTransport(transport) - } -} - -func (r *channelRuntime) StartInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { - return r.transitionInstance(ctx, id, true, channelspkg.ChannelStatusStarting, true, "start") -} - -func (r *channelRuntime) StopInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { - return r.transitionInstance(ctx, id, false, channelspkg.ChannelStatusDisabled, true, "stop") -} - -func (r *channelRuntime) RestartInstance(ctx context.Context, id string) (*channelspkg.ChannelInstance, error) { - return r.transitionInstance(ctx, id, true, channelspkg.ChannelStatusStarting, true, "restart") -} - -func (r *channelRuntime) ResolveChannelRuntime(ctx context.Context, extensionName string) (*subprocess.InitializeChannelRuntime, error) { - if r == nil { - return nil, errors.New("daemon: channel runtime is required") - } - if ctx == nil { - return nil, errors.New("daemon: channel runtime context is required") - } - if err := ctx.Err(); err != nil { - return nil, err - } - - instance, err := r.instanceForExtension(ctx, extensionName) - if err != nil { - return nil, err - } - - boundSecrets, err := r.resolveBoundSecrets(ctx, instance.ID) - if err != nil { - return nil, fmt.Errorf("daemon: resolve bound secrets for channel instance %q: %w", instance.ID, err) - } - - launching := instance - if !instance.Enabled || instance.Status.Normalize() != channelspkg.ChannelStatusStarting { - launching, err = r.transitionInstance(ctx, instance.ID, true, channelspkg.ChannelStatusStarting, false, "launch") - if err != nil { - return nil, err - } - } - - return &subprocess.InitializeChannelRuntime{ - Instance: *launching, - BoundSecrets: boundSecrets, - }, nil -} - -func (r *channelRuntime) transitionInstance( - ctx context.Context, - id string, - enabled bool, - status channelspkg.ChannelStatus, - reload bool, - action string, -) (*channelspkg.ChannelInstance, error) { - if r == nil { - return nil, errors.New("daemon: channel runtime is required") - } - if ctx == nil { - return nil, fmt.Errorf("daemon: %s channel instance context is required", action) - } - if err := ctx.Err(); err != nil { - return nil, err - } - - trimmedID := strings.TrimSpace(id) - if trimmedID == "" { - return nil, fmt.Errorf("daemon: %s channel instance id is required", action) - } - - unlock := r.lockInstanceLifecycle(trimmedID) - defer unlock() - - var previous *channelspkg.ChannelInstance - if reload { - current, loadErr := r.GetInstance(ctx, trimmedID) - if loadErr != nil { - return nil, fmt.Errorf("daemon: %s channel instance %q: load current state: %w", action, trimmedID, loadErr) - } - previous = current - } - - updated, err := r.UpdateInstanceState(ctx, channelspkg.UpdateInstanceStateRequest{ - ID: trimmedID, - Enabled: enabled, - Status: status, - UpdatedAt: r.now().UTC(), - }) - if err != nil { - return nil, fmt.Errorf("daemon: %s channel instance %q: %w", action, trimmedID, err) - } - - if reload { - if err := r.reloadExtensions(ctx, trimmedID); err != nil { - if previous == nil { - return nil, err - } - if rollbackErr := r.persistCompensatingInstance(ctx, *previous, "restore channel instance after reload failure"); rollbackErr != nil { - return nil, fmt.Errorf( - "daemon: %s channel instance %q: reload failed and persisted-state rollback also failed: %w", - action, - trimmedID, - errors.Join(err, rollbackErr), - ) - } - return nil, fmt.Errorf( - "daemon: %s channel instance %q: restored persisted state after reload failure: %w", - action, - trimmedID, - err, - ) - } - } - - return updated, nil -} - -func (r *channelRuntime) reloadExtensions(ctx context.Context, channelInstanceID string) error { - if r == nil { - return errors.New("daemon: channel runtime is required") - } - if ctx == nil { - return errors.New("daemon: channel runtime reload context is required") - } - if err := ctx.Err(); err != nil { - return err - } - - r.mu.RLock() - extensions := r.extensions - r.mu.RUnlock() - if extensions == nil { - return nil - } - - if err := extensions.Reload(ctx); err != nil { - return fmt.Errorf("daemon: reload extensions for channel instance %q: %w", channelInstanceID, err) - } - return nil -} - -// lockInstanceLifecycle serializes lifecycle transitions for one channel -// instance so reload-triggered rollbacks cannot overwrite newer persisted state. -func (r *channelRuntime) lockInstanceLifecycle(id string) func() { - if r == nil { - return func() {} - } - - trimmedID := strings.TrimSpace(id) - if trimmedID == "" { - return func() {} - } - - r.lifecycleMu.Lock() - if r.lifecycleLocks == nil { - r.lifecycleLocks = make(map[string]*channelLifecycleLock) - } - lock := r.lifecycleLocks[trimmedID] - if lock == nil { - lock = &channelLifecycleLock{} - r.lifecycleLocks[trimmedID] = lock - } - lock.refs++ - r.lifecycleMu.Unlock() - - lock.mu.Lock() - return func() { - lock.mu.Unlock() - - r.lifecycleMu.Lock() - lock.refs-- - if lock.refs == 0 { - delete(r.lifecycleLocks, trimmedID) - } - r.lifecycleMu.Unlock() - } -} - -func (r *channelRuntime) instanceForExtension(ctx context.Context, extensionName string) (*channelspkg.ChannelInstance, error) { - trimmed := strings.TrimSpace(extensionName) - if trimmed == "" { - return nil, errors.New("daemon: channel runtime extension name is required") - } - - instances, err := r.ListInstances(ctx) - if err != nil { - return nil, fmt.Errorf("daemon: list channel instances for extension %q: %w", trimmed, err) - } - - matches := make([]channelspkg.ChannelInstance, 0, 1) - for _, instance := range instances { - if strings.TrimSpace(instance.ExtensionName) != trimmed { - continue - } - if !instance.Enabled || instance.Status.Normalize() == channelspkg.ChannelStatusDisabled { - continue - } - matches = append(matches, instance) - } - - switch len(matches) { - case 0: - return nil, fmt.Errorf("%w: no enabled channel instance configured for extension %q", extensionpkg.ErrChannelRuntimeDeferred, trimmed) - case 1: - instance := matches[0] - return &instance, nil - default: - return nil, fmt.Errorf("daemon: multiple enabled channel instances configured for extension %q", trimmed) - } -} - -func (r *channelRuntime) resolveBoundSecrets( - ctx context.Context, - channelInstanceID string, -) ([]subprocess.InitializeChannelBoundSecret, error) { - bindings, err := r.store.ListChannelSecretBindings(ctx, channelInstanceID) - if err != nil { - return nil, fmt.Errorf("daemon: list channel secret bindings for %q: %w", channelInstanceID, err) - } - if len(bindings) == 0 { - return nil, nil - } - if r.secretResolver == nil { - return nil, errChannelSecretResolverRequired - } - - resolved := make([]subprocess.InitializeChannelBoundSecret, 0, len(bindings)) - for _, binding := range bindings { - value, err := r.secretResolver.ResolveChannelSecret(ctx, binding) - if err != nil { - return nil, fmt.Errorf("binding %q: %w", binding.BindingName, err) - } - - secret := subprocess.InitializeChannelBoundSecret{ - BindingName: binding.BindingName, - Kind: binding.Kind, - Value: value, - } - if err := secret.Validate(); err != nil { - return nil, fmt.Errorf("binding %q: %w", binding.BindingName, err) - } - resolved = append(resolved, secret) - } - - return resolved, nil -} - -func (r *channelRuntime) persistCompensatingInstance( - ctx context.Context, - instance channelspkg.ChannelInstance, - action string, -) error { - if r == nil { - return errors.New("daemon: channel runtime is required") - } - instance.UpdatedAt = r.now().UTC() - if err := instance.Validate(); err != nil { - return fmt.Errorf("daemon: %s %q: validate compensated state: %w", action, strings.TrimSpace(instance.ID), err) - } - - rollbackCtx := context.WithoutCancel(ctx) - if err := r.store.UpdateChannelInstance(rollbackCtx, instance); err != nil { - return fmt.Errorf("daemon: %s %q: persist compensated state: %w", action, strings.TrimSpace(instance.ID), err) - } - return nil -} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 9176372f1..36abcf4ef 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -17,7 +17,7 @@ import ( "github.com/pedronauck/agh/internal/api/httpapi" "github.com/pedronauck/agh/internal/api/udsapi" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" aghconfig "github.com/pedronauck/agh/internal/config" extensionpkg "github.com/pedronauck/agh/internal/extension" hookspkg "github.com/pedronauck/agh/internal/hooks" @@ -74,7 +74,7 @@ type RuntimeDeps struct { Network core.NetworkService Observer Observer Automation core.AutomationManager - Channels core.ChannelService + Bridges core.BridgeService Registry Registry MemoryStore *memory.Store WorkspaceResolver workspacepkg.WorkspaceResolver @@ -127,11 +127,11 @@ type extensionRuntime interface { HookDeclarations(context.Context) ([]hookspkg.HookDecl, error) } -func channelObserveSource(service core.ChannelService) observe.ChannelSource { +func bridgeObserveSource(service core.BridgeService) observe.BridgeSource { if service == nil { return nil } - source, _ := service.(observe.ChannelSource) + source, _ := service.(observe.BridgeSource) return source } @@ -144,10 +144,10 @@ type extensionManagerDeps struct { SkillsRegistry *skills.Registry WorkspaceResolver workspacepkg.WorkspaceResolver Logger *slog.Logger - ChannelRegistry channelspkg.Registry - ChannelDedupStore channelDedupStore - ChannelBroker *channelspkg.Broker - ChannelRuntime extensionpkg.ChannelRuntimeResolver + BridgeRegistry bridgepkg.Registry + BridgeDedupStore bridgeDedupStore + BridgeBroker *bridgepkg.Broker + BridgeRuntime extensionpkg.BridgeRuntimeResolver } type automationRuntime interface { @@ -186,54 +186,54 @@ type SessionManagerDeps struct { type Daemon struct { mu sync.Mutex - homePaths aghconfig.HomePaths - loadConfig ConfigLoader - logger *slog.Logger - closeLogger func() error - now func() time.Time - pid func() int - acquireLock func(path string, pid int) (*Lock, error) - openRegistry registryOpener - newSessionManager sessionManagerFactory - newDreamService consolidation.ServiceFactory - newObserver observerFactory - newExtensionManager extensionManagerFactory - newAutomationManager automationManagerFactory - httpFactory ServerFactory - udsFactory ServerFactory - listProcesses func(context.Context) ([]processInfo, error) - signalProcess func(int, syscall.Signal) error - processAlive func(int) bool - signalCh <-chan os.Signal - verifyBoundaries bool - boundaryRoot string - getenv func(string) string - channelSecretResolver ChannelSecretResolver - readyCh chan struct{} - readyClosed bool - booting bool - orphanGraceWait time.Duration - orphanPollWait time.Duration - config aghconfig.Config - startedAt time.Time - info Info - lock *Lock - registry Registry - memoryStore *memory.Store - sessions SessionManager - network networkRuntime - hooks hookRuntime - extensions extensionRuntime - observer Observer - automation automationRuntime - channels *channelRuntime - httpServer Server - udsServer Server - dreamRuntime *consolidation.Runtime - workspaceResolver workspacepkg.WorkspaceResolver - skillsRegistry *skills.Registry - skillsCancel context.CancelFunc - skillsDone chan struct{} + homePaths aghconfig.HomePaths + loadConfig ConfigLoader + logger *slog.Logger + closeLogger func() error + now func() time.Time + pid func() int + acquireLock func(path string, pid int) (*Lock, error) + openRegistry registryOpener + newSessionManager sessionManagerFactory + newDreamService consolidation.ServiceFactory + newObserver observerFactory + newExtensionManager extensionManagerFactory + newAutomationManager automationManagerFactory + httpFactory ServerFactory + udsFactory ServerFactory + listProcesses func(context.Context) ([]processInfo, error) + signalProcess func(int, syscall.Signal) error + processAlive func(int) bool + signalCh <-chan os.Signal + verifyBoundaries bool + boundaryRoot string + getenv func(string) string + bridgeSecretResolver BridgeSecretResolver + readyCh chan struct{} + readyClosed bool + booting bool + orphanGraceWait time.Duration + orphanPollWait time.Duration + config aghconfig.Config + startedAt time.Time + info Info + lock *Lock + registry Registry + memoryStore *memory.Store + sessions SessionManager + network networkRuntime + hooks hookRuntime + extensions extensionRuntime + observer Observer + automation automationRuntime + bridges *bridgeRuntime + httpServer Server + udsServer Server + dreamRuntime *consolidation.Runtime + workspaceResolver workspacepkg.WorkspaceResolver + skillsRegistry *skills.Registry + skillsCancel context.CancelFunc + skillsDone chan struct{} } // WithHomePaths overrides the resolved AGH home layout. @@ -267,11 +267,11 @@ func WithLogger(logger *slog.Logger) Option { } } -// WithChannelSecretResolver injects the daemon-owned resolver used to convert -// channel secret bindings into launch-time bound secret material. -func WithChannelSecretResolver(resolver ChannelSecretResolver) Option { +// WithBridgeSecretResolver injects the daemon-owned resolver used to convert +// bridge secret bindings into launch-time bound secret material. +func WithBridgeSecretResolver(resolver BridgeSecretResolver) Option { return func(d *Daemon) { - d.channelSecretResolver = resolver + d.bridgeSecretResolver = resolver } } @@ -296,8 +296,8 @@ func WithUDSServerFactory(factory ServerFactory) Option { } } -// WithSignalChannel overrides OS signal delivery, mainly for tests. -func WithSignalChannel(ch <-chan os.Signal) Option { +// WithSignalBridge overrides OS signal delivery, mainly for tests. +func WithSignalBridge(ch <-chan os.Signal) Option { return func(d *Daemon) { d.signalCh = ch } @@ -385,7 +385,7 @@ func (d *Daemon) applyDefaults() error { observe.WithWorkspaceResolver(deps.WorkspaceResolver), observe.WithLogger(deps.Logger), observe.WithStartTime(deps.StartedAt), - observe.WithChannelSource(channelObserveSource(deps.Channels)), + observe.WithBridgeSource(bridgeObserveSource(deps.Bridges)), ) } } @@ -401,14 +401,14 @@ func (d *Daemon) applyDefaults() error { extensionpkg.WithHostAPICapabilityChecker(capChecker), extensionpkg.WithHostAPIWorkspaceResolver(deps.WorkspaceResolver), } - if deps.ChannelRegistry != nil { - hostAPIOpts = append(hostAPIOpts, extensionpkg.WithHostAPIChannelRegistry(deps.ChannelRegistry)) + if deps.BridgeRegistry != nil { + hostAPIOpts = append(hostAPIOpts, extensionpkg.WithHostAPIBridgeRegistry(deps.BridgeRegistry)) } - if deps.ChannelDedupStore != nil { - hostAPIOpts = append(hostAPIOpts, extensionpkg.WithHostAPIChannelDedupStore(deps.ChannelDedupStore)) + if deps.BridgeDedupStore != nil { + hostAPIOpts = append(hostAPIOpts, extensionpkg.WithHostAPIBridgeDedupStore(deps.BridgeDedupStore)) } - if deps.ChannelBroker != nil { - hostAPIOpts = append(hostAPIOpts, extensionpkg.WithHostAPIDeliveryBroker(deps.ChannelBroker)) + if deps.BridgeBroker != nil { + hostAPIOpts = append(hostAPIOpts, extensionpkg.WithHostAPIDeliveryBroker(deps.BridgeBroker)) } hostAPI := extensionpkg.NewHostAPIHandler( @@ -424,11 +424,11 @@ func (d *Daemon) applyDefaults() error { extensionpkg.WithSkillsRegistry(deps.SkillsRegistry), extensionpkg.WithLogger(deps.Logger), } - if sink, ok := deps.Observer.(extensionpkg.ChannelTelemetrySink); ok { - opts = append(opts, extensionpkg.WithChannelTelemetrySink(sink)) + if sink, ok := deps.Observer.(extensionpkg.BridgeTelemetrySink); ok { + opts = append(opts, extensionpkg.WithBridgeTelemetrySink(sink)) } - if deps.ChannelRuntime != nil { - opts = append(opts, extensionpkg.WithChannelRuntimeResolver(deps.ChannelRuntime)) + if deps.BridgeRuntime != nil { + opts = append(opts, extensionpkg.WithBridgeRuntimeResolver(deps.BridgeRuntime)) } for method, handler := range hostAPI.MethodHandlers() { opts = append(opts, extensionpkg.WithHostMethodHandler(method, handler)) @@ -465,7 +465,7 @@ func (d *Daemon) applyDefaults() error { httpapi.WithNetworkService(deps.Network), httpapi.WithObserver(deps.Observer), httpapi.WithAutomation(deps.Automation), - httpapi.WithChannelService(deps.Channels), + httpapi.WithBridgeService(deps.Bridges), httpapi.WithWorkspaceResolver(deps.WorkspaceService), httpapi.WithSkillsRegistry(deps.SkillsRegistry), httpapi.WithMemoryStore(deps.MemoryStore), @@ -484,7 +484,7 @@ func (d *Daemon) applyDefaults() error { udsapi.WithNetworkService(deps.Network), udsapi.WithObserver(deps.Observer), udsapi.WithAutomation(deps.Automation), - udsapi.WithChannelService(deps.Channels), + udsapi.WithBridgeService(deps.Bridges), udsapi.WithWorkspaceResolver(deps.WorkspaceService), udsapi.WithSkillsRegistry(deps.SkillsRegistry), udsapi.WithMemoryStore(deps.MemoryStore), @@ -564,7 +564,7 @@ func (d *Daemon) Shutdown(ctx context.Context) error { hooks := d.hooks extensions := d.extensions automation := d.automation - channels := d.channels + bridges := d.bridges httpServer := d.httpServer udsServer := d.udsServer registry := d.registry @@ -594,7 +594,7 @@ func (d *Daemon) Shutdown(ctx context.Context) error { d.workspaceResolver = nil d.skillsCancel = nil d.skillsDone = nil - d.channels = nil + d.bridges = nil d.network = nil d.mu.Unlock() @@ -626,8 +626,8 @@ func (d *Daemon) Shutdown(ctx context.Context) error { errs = append(errs, fmt.Errorf("daemon: shutdown uds server: %w", err)) } } - if channels != nil { - channels.Close() + if bridges != nil { + bridges.Close() } if network != nil { if err := network.Shutdown(ctx); err != nil { diff --git a/internal/daemon/daemon_integration_test.go b/internal/daemon/daemon_integration_test.go index f76920924..cdf94e8ec 100644 --- a/internal/daemon/daemon_integration_test.go +++ b/internal/daemon/daemon_integration_test.go @@ -19,7 +19,7 @@ import ( "github.com/kballard/go-shellquote" "github.com/pedronauck/agh/internal/acp" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" aghconfig "github.com/pedronauck/agh/internal/config" extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol" hookspkg "github.com/pedronauck/agh/internal/hooks" @@ -480,8 +480,8 @@ func TestBootNetworkEnabledDeliversInboundAndShutsDownCleanly(t *testing.T) { if lifecycle == nil { t.Fatal("network lifecycle binding = nil, want boot-time late binding") } - if err := lifecycle.JoinSpace(testutil.Context(t), "sess-net", "coder.sess-net", "builders"); err != nil { - t.Fatalf("JoinSpace() error = %v", err) + if err := lifecycle.JoinChannel(testutil.Context(t), "sess-net", "coder.sess-net", "builders"); err != nil { + t.Fatalf("JoinChannel() error = %v", err) } body, err := json.Marshal(map[string]any{"text": "hello from network"}) @@ -490,7 +490,7 @@ func TestBootNetworkEnabledDeliversInboundAndShutsDownCleanly(t *testing.T) { } if _, err := d.network.Send(testutil.Context(t), network.SendRequest{ SessionID: "sess-net", - Space: "builders", + Channel: "builders", Kind: network.KindSay, Body: body, }); err != nil { @@ -510,8 +510,8 @@ func TestBootNetworkEnabledDeliversInboundAndShutsDownCleanly(t *testing.T) { if err != nil { t.Fatalf("network.Status() error = %v", err) } - if status.LocalPeers != 1 || status.Spaces != 1 { - t.Fatalf("network.Status() = %#v, want 1 local peer and 1 space", status) + if status.LocalPeers != 1 || status.Channels != 1 { + t.Fatalf("network.Status() = %#v, want 1 local peer and 1 channel", status) } if err := d.Shutdown(testutil.Context(t)); err != nil { @@ -576,8 +576,8 @@ func TestBootNetworkShutdownTracksInterruptedInFlightDelivery(t *testing.T) { if lifecycle == nil { t.Fatal("network lifecycle binding = nil, want boot-time late binding") } - if err := lifecycle.JoinSpace(testutil.Context(t), "sess-net", "coder.sess-net", "builders"); err != nil { - t.Fatalf("JoinSpace() error = %v", err) + if err := lifecycle.JoinChannel(testutil.Context(t), "sess-net", "coder.sess-net", "builders"); err != nil { + t.Fatalf("JoinChannel() error = %v", err) } body, err := json.Marshal(map[string]any{"text": "shutdown during delivery"}) @@ -586,7 +586,7 @@ func TestBootNetworkShutdownTracksInterruptedInFlightDelivery(t *testing.T) { } if _, err := d.network.Send(testutil.Context(t), network.SendRequest{ SessionID: "sess-net", - Space: "builders", + Channel: "builders", Kind: network.KindSay, Body: body, }); err != nil { @@ -851,7 +851,7 @@ func TestRunGracefulShutdownViaSignal(t *testing.T) { WithHomePaths(homePaths), WithConfig(cfg), WithLogger(discardLogger()), - WithSignalChannel(signalCh), + WithSignalBridge(signalCh), ) if err != nil { t.Fatalf("New() error = %v", err) @@ -1383,53 +1383,53 @@ func TestRunDreamTickerAndSpawnerIntegration(t *testing.T) { } } -func TestBootStartsChannelExtensionWithBoundRuntime(t *testing.T) { +func TestBootStartsBridgeExtensionWithBoundRuntime(t *testing.T) { homePaths := integrationHomePaths(t) cfg := testConfig(t, homePaths) - markerPath := filepath.Join(t.TempDir(), "channel-init.jsonl") - extensionName := "ext-channel-daemon" - instanceID := "chan-daemon-init" + markerPath := filepath.Join(t.TempDir(), "bridge-init.jsonl") + extensionName := "ext-bridge-daemon" + instanceID := "brg-daemon-init" installExtensionForDaemonIntegration(t, homePaths.DatabaseFile, extensionName, daemonTestExtensionOptions{ runtimeCommand: daemonExtensionHelperCommand(t), runtimeArgs: daemonExtensionHelperArgs(), runtimeEnv: daemonExtensionHelperScenarioEnv("record_initialize", markerPath), - capabilities: []string{extensionprotocol.CapabilityProvideChannelAdapter}, + capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter}, actions: []string{ - string(extensionprotocol.HostAPIMethodChannelsMessagesIngest), - string(extensionprotocol.HostAPIMethodChannelsInstancesGet), - string(extensionprotocol.HostAPIMethodChannelsInstancesReportState), + string(extensionprotocol.HostAPIMethodBridgesMessagesIngest), + string(extensionprotocol.HostAPIMethodBridgesInstancesGet), + string(extensionprotocol.HostAPIMethodBridgesInstancesReportState), }, - security: []string{"channel.read", "channel.write"}, + security: []string{"bridge.read", "bridge.write"}, }, true) registry := openDaemonIntegrationGlobalDB(t, homePaths.DatabaseFile) - channelRegistry := channelspkg.NewRegistry(registry) - instance, err := channelRegistry.CreateInstance(testutil.Context(t), channelspkg.CreateInstanceRequest{ + bridgeRegistry := bridgepkg.NewRegistry(registry) + instance, err := bridgeRegistry.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{ ID: instanceID, - Scope: channelspkg.ScopeGlobal, + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: extensionName, - DisplayName: "Daemon Channel", + DisplayName: "Daemon Bridge", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) if err != nil { t.Fatalf("CreateInstance() error = %v", err) } - if err := registry.PutChannelSecretBinding(testutil.Context(t), channelspkg.ChannelSecretBinding{ - ChannelInstanceID: instance.ID, - BindingName: "bot_token", - VaultRef: "vault://channels/ext-channel-daemon/bot-token", - Kind: "bot_token", - CreatedAt: time.Date(2026, 4, 11, 13, 30, 0, 0, time.UTC), - UpdatedAt: time.Date(2026, 4, 11, 13, 30, 0, 0, time.UTC), + if err := registry.PutBridgeSecretBinding(testutil.Context(t), bridgepkg.BridgeSecretBinding{ + BridgeInstanceID: instance.ID, + BindingName: "bot_token", + VaultRef: "vault://bridges/ext-bridge-daemon/bot-token", + Kind: "bot_token", + CreatedAt: time.Date(2026, 4, 11, 13, 30, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 11, 13, 30, 0, 0, time.UTC), }); err != nil { - t.Fatalf("PutChannelSecretBinding() error = %v", err) + t.Fatalf("PutBridgeSecretBinding() error = %v", err) } - resolver := &recordingChannelSecretResolver{ + resolver := &recordingBridgeSecretResolver{ values: map[string]string{ "bot_token": "token-daemon", }, @@ -1439,7 +1439,7 @@ func TestBootStartsChannelExtensionWithBoundRuntime(t *testing.T) { WithHomePaths(homePaths), WithConfig(cfg), WithLogger(discardLogger()), - WithChannelSecretResolver(resolver), + WithBridgeSecretResolver(resolver), ) if err != nil { t.Fatalf("New() error = %v", err) @@ -1453,54 +1453,54 @@ func TestBootStartsChannelExtensionWithBoundRuntime(t *testing.T) { } }) - if d.channels == nil { - t.Fatal("boot() did not publish the channel runtime") + if d.bridges == nil { + t.Fatal("boot() did not publish the bridge runtime") } - waitForCondition(t, "channel initialize marker", func() bool { + waitForCondition(t, "bridge initialize marker", func() bool { return markerLineCount(markerPath) >= 1 }) markers := readDaemonInitializeMarkers(t, markerPath) if len(markers) == 0 { - t.Fatal("initialize markers = empty, want channel launch handshake") + t.Fatal("initialize markers = empty, want bridge launch handshake") } request := markers[0].Request - if len(request.Methods.ExtensionServices) != 1 || request.Methods.ExtensionServices[0] != "channels/deliver" { - t.Fatalf("initialize extension services = %#v, want [channels/deliver]", request.Methods.ExtensionServices) + if len(request.Methods.ExtensionServices) != 1 || request.Methods.ExtensionServices[0] != "bridges/deliver" { + t.Fatalf("initialize extension services = %#v, want [bridges/deliver]", request.Methods.ExtensionServices) } - if request.Runtime.Channel == nil { - t.Fatal("initialize runtime channel = nil, want bound launch payload") + if request.Runtime.Bridge == nil { + t.Fatal("initialize runtime bridge = nil, want bound launch payload") } - if got, want := request.Runtime.Channel.Instance.ID, instanceID; got != want { - t.Fatalf("initialize runtime channel instance id = %q, want %q", got, want) + if got, want := request.Runtime.Bridge.Instance.ID, instanceID; got != want { + t.Fatalf("initialize runtime bridge instance id = %q, want %q", got, want) } - if got := request.Runtime.Channel.BoundSecrets; len(got) != 1 || got[0].BindingName != "bot_token" || got[0].Value != "token-daemon" { - t.Fatalf("initialize runtime channel bound secrets = %#v, want resolved bot_token binding", got) + if got := request.Runtime.Bridge.BoundSecrets; len(got) != 1 || got[0].BindingName != "bot_token" || got[0].Value != "token-daemon" { + t.Fatalf("initialize runtime bridge bound secrets = %#v, want resolved bot_token binding", got) } - if len(resolver.calls) != 1 || resolver.calls[0].ChannelInstanceID != instanceID { - t.Fatalf("ResolveChannelSecret() calls = %#v, want one call for %q", resolver.calls, instanceID) + if len(resolver.calls) != 1 || resolver.calls[0].BridgeInstanceID != instanceID { + t.Fatalf("ResolveBridgeSecret() calls = %#v, want one call for %q", resolver.calls, instanceID) } } -func TestCreateEnabledChannelAfterBootReloadsErroredExtension(t *testing.T) { +func TestCreateEnabledBridgeAfterBootReloadsErroredExtension(t *testing.T) { homePaths := integrationHomePaths(t) cfg := testConfig(t, homePaths) - markerPath := filepath.Join(t.TempDir(), "channel-create.jsonl") - extensionName := "ext-channel-create" - instanceID := "chan-daemon-create" + markerPath := filepath.Join(t.TempDir(), "bridge-create.jsonl") + extensionName := "ext-bridge-create" + instanceID := "brg-daemon-create" installExtensionForDaemonIntegration(t, homePaths.DatabaseFile, extensionName, daemonTestExtensionOptions{ runtimeCommand: daemonExtensionHelperCommand(t), runtimeArgs: daemonExtensionHelperArgs(), runtimeEnv: daemonExtensionHelperScenarioEnv("record_initialize", markerPath), - capabilities: []string{extensionprotocol.CapabilityProvideChannelAdapter}, + capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter}, actions: []string{ - string(extensionprotocol.HostAPIMethodChannelsMessagesIngest), - string(extensionprotocol.HostAPIMethodChannelsInstancesGet), - string(extensionprotocol.HostAPIMethodChannelsInstancesReportState), + string(extensionprotocol.HostAPIMethodBridgesMessagesIngest), + string(extensionprotocol.HostAPIMethodBridgesInstancesGet), + string(extensionprotocol.HostAPIMethodBridgesInstancesReportState), }, - security: []string{"channel.read", "channel.write"}, + security: []string{"bridge.read", "bridge.write"}, }, true) d, err := New( @@ -1520,11 +1520,11 @@ func TestCreateEnabledChannelAfterBootReloadsErroredExtension(t *testing.T) { } }) - if d.channels == nil { - t.Fatal("boot() did not publish the channel runtime") + if d.bridges == nil { + t.Fatal("boot() did not publish the bridge runtime") } - waitForCondition(t, "channel extension stays registered until an instance exists", func() bool { + waitForCondition(t, "bridge extension stays registered until an instance exists", func() bool { ext, err := d.extensions.Get(extensionName) return err == nil && ext != nil && ext.Status.Registered && !ext.Status.Active && ext.Status.LastError == "" }) @@ -1532,15 +1532,15 @@ func TestCreateEnabledChannelAfterBootReloadsErroredExtension(t *testing.T) { t.Fatalf("initialize marker count before create = %d, want 0", got) } - created, err := d.channels.CreateInstance(testutil.Context(t), channelspkg.CreateInstanceRequest{ + created, err := d.bridges.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{ ID: instanceID, - Scope: channelspkg.ScopeGlobal, + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: extensionName, - DisplayName: "Create Channel", + DisplayName: "Create Bridge", Enabled: true, - Status: channelspkg.ChannelStatusStarting, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusStarting, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) if err != nil { t.Fatalf("CreateInstance() error = %v", err) @@ -1549,54 +1549,54 @@ func TestCreateEnabledChannelAfterBootReloadsErroredExtension(t *testing.T) { t.Fatal("CreateInstance() = nil, want non-nil") } - waitForCondition(t, "channel initialize marker after create", func() bool { + waitForCondition(t, "bridge initialize marker after create", func() bool { return markerLineCount(markerPath) >= 1 }) markers := readDaemonInitializeMarkers(t, markerPath) if len(markers) == 0 { t.Fatal("initialize markers after create = empty, want launch handshake") } - if got, want := markers[len(markers)-1].Request.Runtime.Channel.Instance.ID, instanceID; got != want { - t.Fatalf("initialize runtime channel instance id after create = %q, want %q", got, want) + if got, want := markers[len(markers)-1].Request.Runtime.Bridge.Instance.ID, instanceID; got != want { + t.Fatalf("initialize runtime bridge instance id after create = %q, want %q", got, want) } - waitForCondition(t, "channel extension recovers after create", func() bool { + waitForCondition(t, "bridge extension recovers after create", func() bool { ext, err := d.extensions.Get(extensionName) return err == nil && ext != nil && ext.Status.Active }) } -func TestChannelRuntimeRestartPreservesRouteContinuity(t *testing.T) { +func TestBridgeRuntimeRestartPreservesRouteContinuity(t *testing.T) { homePaths := integrationHomePaths(t) cfg := testConfig(t, homePaths) - markerPath := filepath.Join(t.TempDir(), "channel-restart.jsonl") - extensionName := "ext-channel-restart" - instanceID := "chan-daemon-restart" + markerPath := filepath.Join(t.TempDir(), "bridge-restart.jsonl") + extensionName := "ext-bridge-restart" + instanceID := "brg-daemon-restart" installExtensionForDaemonIntegration(t, homePaths.DatabaseFile, extensionName, daemonTestExtensionOptions{ runtimeCommand: daemonExtensionHelperCommand(t), runtimeArgs: daemonExtensionHelperArgs(), runtimeEnv: daemonExtensionHelperScenarioEnv("exit_once_record_deliveries", markerPath), - capabilities: []string{extensionprotocol.CapabilityProvideChannelAdapter}, + capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter}, actions: []string{ - string(extensionprotocol.HostAPIMethodChannelsMessagesIngest), - string(extensionprotocol.HostAPIMethodChannelsInstancesGet), - string(extensionprotocol.HostAPIMethodChannelsInstancesReportState), + string(extensionprotocol.HostAPIMethodBridgesMessagesIngest), + string(extensionprotocol.HostAPIMethodBridgesInstancesGet), + string(extensionprotocol.HostAPIMethodBridgesInstancesReportState), }, - security: []string{"channel.read", "channel.write"}, + security: []string{"bridge.read", "bridge.write"}, }, true) registry := openDaemonIntegrationGlobalDB(t, homePaths.DatabaseFile) - channelRegistry := channelspkg.NewRegistry(registry) - if _, err := channelRegistry.CreateInstance(testutil.Context(t), channelspkg.CreateInstanceRequest{ + bridgeRegistry := bridgepkg.NewRegistry(registry) + if _, err := bridgeRegistry.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{ ID: instanceID, - Scope: channelspkg.ScopeGlobal, + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: extensionName, - DisplayName: "Restart Channel", + DisplayName: "Restart Bridge", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }); err != nil { t.Fatalf("CreateInstance() error = %v", err) } @@ -1617,28 +1617,28 @@ func TestChannelRuntimeRestartPreservesRouteContinuity(t *testing.T) { if err := d.boot(testutil.Context(t)); err != nil { t.Fatalf("boot() error = %v", err) } - if d.channels == nil { - t.Fatal("boot() did not publish the channel runtime") + if d.bridges == nil { + t.Fatal("boot() did not publish the bridge runtime") } - route, err := d.channels.UpsertRoute(testutil.Context(t), channelspkg.ChannelRoute{ - Scope: channelspkg.ScopeGlobal, - ChannelInstanceID: instanceID, - PeerID: "peer-restart", - SessionID: "sess-restart", - AgentName: "coder", - LastActivityAt: time.Date(2026, 4, 11, 13, 45, 0, 0, time.UTC), + route, err := d.bridges.UpsertRoute(testutil.Context(t), bridgepkg.BridgeRoute{ + Scope: bridgepkg.ScopeGlobal, + BridgeInstanceID: instanceID, + PeerID: "peer-restart", + SessionID: "sess-restart", + AgentName: "coder", + LastActivityAt: time.Date(2026, 4, 11, 13, 45, 0, 0, time.UTC), }) if err != nil { t.Fatalf("UpsertRoute() error = %v", err) } - target := channelspkg.DeliveryTarget{ - ChannelInstanceID: instanceID, - PeerID: "peer-restart", - Mode: channelspkg.DeliveryModeDirectSend, + target := bridgepkg.DeliveryTarget{ + BridgeInstanceID: instanceID, + PeerID: "peer-restart", + Mode: bridgepkg.DeliveryModeDirectSend, } - if _, err := d.channels.Broker().RegisterPromptDelivery(testutil.Context(t), channelspkg.PromptDeliveryRegistration{ + if _, err := d.bridges.Broker().RegisterPromptDelivery(testutil.Context(t), bridgepkg.PromptDeliveryRegistration{ SessionID: "sess-restart", TurnID: "turn-restart", ExtensionName: extensionName, @@ -1648,31 +1648,31 @@ func TestChannelRuntimeRestartPreservesRouteContinuity(t *testing.T) { }); err != nil { t.Fatalf("RegisterPromptDelivery() error = %v", err) } - if err := d.channels.Broker().Deliver(testutil.Context(t), channelspkg.DeliveryEvent{ - DeliveryID: "del-restart", - ChannelInstanceID: instanceID, - RoutingKey: route.RoutingKey(), - DeliveryTarget: target, - Seq: 1, - EventType: channelspkg.DeliveryEventTypeStart, - Content: channelspkg.MessageContent{Text: "hello"}, + if err := d.bridges.Broker().Deliver(testutil.Context(t), bridgepkg.DeliveryEvent{ + DeliveryID: "del-restart", + BridgeInstanceID: instanceID, + RoutingKey: route.RoutingKey(), + DeliveryTarget: target, + Seq: 1, + EventType: bridgepkg.DeliveryEventTypeStart, + Content: bridgepkg.MessageContent{Text: "hello"}, }); err != nil { t.Fatalf("Deliver(start) error = %v", err) } - if err := d.channels.Broker().Deliver(testutil.Context(t), channelspkg.DeliveryEvent{ - DeliveryID: "del-restart", - ChannelInstanceID: instanceID, - RoutingKey: route.RoutingKey(), - DeliveryTarget: target, - Seq: 2, - EventType: channelspkg.DeliveryEventTypeFinal, - Content: channelspkg.MessageContent{Text: "hello"}, - Final: true, + if err := d.bridges.Broker().Deliver(testutil.Context(t), bridgepkg.DeliveryEvent{ + DeliveryID: "del-restart", + BridgeInstanceID: instanceID, + RoutingKey: route.RoutingKey(), + DeliveryTarget: target, + Seq: 2, + EventType: bridgepkg.DeliveryEventTypeFinal, + Content: bridgepkg.MessageContent{Text: "hello"}, + Final: true, }); err != nil { t.Fatalf("Deliver(final) error = %v", err) } - waitForCondition(t, "channel delivery resume marker", func() bool { + waitForCondition(t, "bridge delivery resume marker", func() bool { payload, err := os.ReadFile(markerPath) return err == nil && strings.Contains(string(payload), `"event_type":"resume"`) }) @@ -1681,13 +1681,13 @@ func TestChannelRuntimeRestartPreservesRouteContinuity(t *testing.T) { if len(markers) < 2 { t.Fatalf("delivery markers = %d, want at least start + resume", len(markers)) } - if got := markers[0].Request.Event.EventType; got != channelspkg.DeliveryEventTypeStart { + if got := markers[0].Request.Event.EventType; got != bridgepkg.DeliveryEventTypeStart { t.Fatalf("first delivery event = %q, want start", got) } resumeIndex := -1 for idx, marker := range markers { - if marker.Request.Event.EventType == channelspkg.DeliveryEventTypeResume { + if marker.Request.Event.EventType == bridgepkg.DeliveryEventTypeResume { resumeIndex = idx break } @@ -1705,7 +1705,7 @@ func TestChannelRuntimeRestartPreservesRouteContinuity(t *testing.T) { t.Fatalf("resume snapshot delivery id = %q, want %q", got, want) } - resolved, err := d.channels.ResolveRoute(testutil.Context(t), route.RoutingKey()) + resolved, err := d.bridges.ResolveRoute(testutil.Context(t), route.RoutingKey()) if err != nil { t.Fatalf("ResolveRoute(after restart) error = %v", err) } @@ -1714,37 +1714,37 @@ func TestChannelRuntimeRestartPreservesRouteContinuity(t *testing.T) { } } -func TestDaemonShutdownClosesChannelRuntimeCleanly(t *testing.T) { +func TestDaemonShutdownClosesBridgeRuntimeCleanly(t *testing.T) { homePaths := integrationHomePaths(t) cfg := testConfig(t, homePaths) - markerPath := filepath.Join(t.TempDir(), "channel-shutdown.txt") - extensionName := "ext-channel-shutdown" - instanceID := "chan-daemon-shutdown" + markerPath := filepath.Join(t.TempDir(), "bridge-shutdown.txt") + extensionName := "ext-bridge-shutdown" + instanceID := "brg-daemon-shutdown" installExtensionForDaemonIntegration(t, homePaths.DatabaseFile, extensionName, daemonTestExtensionOptions{ runtimeCommand: daemonExtensionHelperCommand(t), runtimeArgs: daemonExtensionHelperArgs(), runtimeEnv: daemonExtensionHelperScenarioEnv("slow_record_deliveries", markerPath), - capabilities: []string{extensionprotocol.CapabilityProvideChannelAdapter}, + capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter}, actions: []string{ - string(extensionprotocol.HostAPIMethodChannelsMessagesIngest), - string(extensionprotocol.HostAPIMethodChannelsInstancesGet), - string(extensionprotocol.HostAPIMethodChannelsInstancesReportState), + string(extensionprotocol.HostAPIMethodBridgesMessagesIngest), + string(extensionprotocol.HostAPIMethodBridgesInstancesGet), + string(extensionprotocol.HostAPIMethodBridgesInstancesReportState), }, - security: []string{"channel.read", "channel.write"}, + security: []string{"bridge.read", "bridge.write"}, }, true) registry := openDaemonIntegrationGlobalDB(t, homePaths.DatabaseFile) - channelRegistry := channelspkg.NewRegistry(registry) - if _, err := channelRegistry.CreateInstance(testutil.Context(t), channelspkg.CreateInstanceRequest{ + bridgeRegistry := bridgepkg.NewRegistry(registry) + if _, err := bridgeRegistry.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{ ID: instanceID, - Scope: channelspkg.ScopeGlobal, + Scope: bridgepkg.ScopeGlobal, Platform: "slack", ExtensionName: extensionName, - DisplayName: "Shutdown Channel", + DisplayName: "Shutdown Bridge", Enabled: true, - Status: channelspkg.ChannelStatusReady, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + Status: bridgepkg.BridgeStatusReady, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }); err != nil { t.Fatalf("CreateInstance() error = %v", err) } @@ -1760,28 +1760,28 @@ func TestDaemonShutdownClosesChannelRuntimeCleanly(t *testing.T) { if err := d.boot(testutil.Context(t)); err != nil { t.Fatalf("boot() error = %v", err) } - if d.channels == nil { - t.Fatal("boot() did not publish the channel runtime") + if d.bridges == nil { + t.Fatal("boot() did not publish the bridge runtime") } - route, err := d.channels.UpsertRoute(testutil.Context(t), channelspkg.ChannelRoute{ - Scope: channelspkg.ScopeGlobal, - ChannelInstanceID: instanceID, - PeerID: "peer-shutdown", - SessionID: "sess-shutdown", - AgentName: "coder", - LastActivityAt: time.Date(2026, 4, 11, 14, 0, 0, 0, time.UTC), + route, err := d.bridges.UpsertRoute(testutil.Context(t), bridgepkg.BridgeRoute{ + Scope: bridgepkg.ScopeGlobal, + BridgeInstanceID: instanceID, + PeerID: "peer-shutdown", + SessionID: "sess-shutdown", + AgentName: "coder", + LastActivityAt: time.Date(2026, 4, 11, 14, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("UpsertRoute() error = %v", err) } - target := channelspkg.DeliveryTarget{ - ChannelInstanceID: instanceID, - PeerID: "peer-shutdown", - Mode: channelspkg.DeliveryModeDirectSend, + target := bridgepkg.DeliveryTarget{ + BridgeInstanceID: instanceID, + PeerID: "peer-shutdown", + Mode: bridgepkg.DeliveryModeDirectSend, } - if _, err := d.channels.Broker().RegisterPromptDelivery(testutil.Context(t), channelspkg.PromptDeliveryRegistration{ + if _, err := d.bridges.Broker().RegisterPromptDelivery(testutil.Context(t), bridgepkg.PromptDeliveryRegistration{ SessionID: "sess-shutdown", TurnID: "turn-shutdown", ExtensionName: extensionName, @@ -1791,19 +1791,19 @@ func TestDaemonShutdownClosesChannelRuntimeCleanly(t *testing.T) { }); err != nil { t.Fatalf("RegisterPromptDelivery() error = %v", err) } - if err := d.channels.Broker().Deliver(testutil.Context(t), channelspkg.DeliveryEvent{ - DeliveryID: "del-shutdown", - ChannelInstanceID: instanceID, - RoutingKey: route.RoutingKey(), - DeliveryTarget: target, - Seq: 1, - EventType: channelspkg.DeliveryEventTypeStart, - Content: channelspkg.MessageContent{Text: "hello"}, + if err := d.bridges.Broker().Deliver(testutil.Context(t), bridgepkg.DeliveryEvent{ + DeliveryID: "del-shutdown", + BridgeInstanceID: instanceID, + RoutingKey: route.RoutingKey(), + DeliveryTarget: target, + Seq: 1, + EventType: bridgepkg.DeliveryEventTypeStart, + Content: bridgepkg.MessageContent{Text: "hello"}, }); err != nil { t.Fatalf("Deliver(start) error = %v", err) } - waitForCondition(t, "channel delivery started before shutdown", func() bool { + waitForCondition(t, "bridge delivery started before shutdown", func() bool { return markerLineCount(markerPath) >= 1 }) diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index ca4be3b94..3f902138c 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -24,7 +24,7 @@ import ( "github.com/pedronauck/agh/internal/acp" "github.com/pedronauck/agh/internal/api/contract" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" aghconfig "github.com/pedronauck/agh/internal/config" extensionpkg "github.com/pedronauck/agh/internal/extension" extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol" @@ -1394,7 +1394,7 @@ func TestOptionsConfigureDaemon(t *testing.T) { WithNow(func() time.Time { return now }), WithHTTPServerFactory(httpFactory), WithUDSServerFactory(udsFactory), - WithSignalChannel(signalCh), + WithSignalBridge(signalCh), WithBoundaryVerification(true), ) if err != nil { @@ -1410,7 +1410,7 @@ func TestOptionsConfigureDaemon(t *testing.T) { t.Fatalf("now() = %v, want %v", got, now) } if d.signalCh != signalCh { - t.Fatal("WithSignalChannel() did not apply") + t.Fatal("WithSignalBridge() did not apply") } if !d.verifyBoundaries { t.Fatal("WithBoundaryVerification(true) did not apply") @@ -1596,7 +1596,7 @@ func TestSignalSourceDefaultsToOSSignalRegistration(t *testing.T) { ch, stop := d.signalSource() if ch == nil { - t.Fatal("signalSource() channel = nil") + t.Fatal("signalSource() bridge = nil") } stop() } @@ -2808,7 +2808,7 @@ type fakeNetworkRuntime struct { type fakeNetworkJoinCall struct { sessionID string peerID string - space string + channel string } func (f *fakeNetworkRuntime) Send(_ context.Context, req network.SendRequest) (string, error) { @@ -2828,7 +2828,7 @@ func (f *fakeNetworkRuntime) ListPeers(context.Context, string) ([]network.PeerI return nil, nil } -func (f *fakeNetworkRuntime) ListSpaces(context.Context) ([]network.SpaceInfo, error) { +func (f *fakeNetworkRuntime) ListChannels(context.Context) ([]network.ChannelInfo, error) { return nil, nil } @@ -2854,18 +2854,18 @@ func (f *fakeNetworkRuntime) Inbox(_ context.Context, sessionID string) ([]netwo return append([]network.Envelope(nil), f.inboxes[sessionID]...), nil } -func (f *fakeNetworkRuntime) JoinSpace(_ context.Context, sessionID string, peerID string, space string) error { +func (f *fakeNetworkRuntime) JoinChannel(_ context.Context, sessionID string, peerID string, channel string) error { f.mu.Lock() defer f.mu.Unlock() f.joinCalls = append(f.joinCalls, fakeNetworkJoinCall{ sessionID: sessionID, peerID: peerID, - space: space, + channel: channel, }) return nil } -func (f *fakeNetworkRuntime) LeaveSpace(_ context.Context, sessionID string) error { +func (f *fakeNetworkRuntime) LeaveChannel(_ context.Context, sessionID string) error { f.mu.Lock() defer f.mu.Unlock() f.leaveCalls = append(f.leaveCalls, sessionID) @@ -2909,7 +2909,7 @@ func (f *fakeObserver) QueryHookEvents(context.Context, hookspkg.EventFilter) ([ return nil, nil } -func (f *fakeObserver) QueryChannelHealth(context.Context) ([]observe.ChannelInstanceHealth, error) { +func (f *fakeObserver) QueryBridgeHealth(context.Context) ([]observe.BridgeInstanceHealth, error) { return nil, nil } @@ -3932,22 +3932,22 @@ func TestDaemonExtensionHelperHandleRequest(t *testing.T) { server := newDaemonExtensionHelperServer("", marker) server.encoder = json.NewEncoder(&output) - params, err := json.Marshal(channelspkg.DeliveryRequest{ - Event: channelspkg.DeliveryEvent{ - DeliveryID: "delivery-1", - ChannelInstanceID: "chan-1", - RoutingKey: channelspkg.RoutingKey{ - Scope: channelspkg.ScopeGlobal, - ChannelInstanceID: "chan-1", - PeerID: "peer-1", + params, err := json.Marshal(bridgepkg.DeliveryRequest{ + Event: bridgepkg.DeliveryEvent{ + DeliveryID: "delivery-1", + BridgeInstanceID: "brg-1", + RoutingKey: bridgepkg.RoutingKey{ + Scope: bridgepkg.ScopeGlobal, + BridgeInstanceID: "brg-1", + PeerID: "peer-1", }, - DeliveryTarget: channelspkg.DeliveryTarget{ - ChannelInstanceID: "chan-1", - PeerID: "peer-1", - Mode: channelspkg.DeliveryModeDirectSend, + DeliveryTarget: bridgepkg.DeliveryTarget{ + BridgeInstanceID: "brg-1", + PeerID: "peer-1", + Mode: bridgepkg.DeliveryModeDirectSend, }, Seq: 1, - EventType: channelspkg.DeliveryEventTypeResume, + EventType: bridgepkg.DeliveryEventTypeResume, }, }) if err != nil { @@ -3956,17 +3956,17 @@ func TestDaemonExtensionHelperHandleRequest(t *testing.T) { exit, err := server.handleRequest(daemonExtensionHelperRequest{ ID: "1", - Method: "channels/deliver", + Method: "bridges/deliver", Params: params, }) if exit { - t.Fatal("handleRequest(channels/deliver) exit = true, want false") + t.Fatal("handleRequest(bridges/deliver) exit = true, want false") } if err == nil { - t.Fatal("handleRequest(channels/deliver) error = nil, want delivery validation failure") + t.Fatal("handleRequest(bridges/deliver) error = nil, want delivery validation failure") } - if !strings.Contains(err.Error(), "validate channels/deliver request") { - t.Fatalf("handleRequest(channels/deliver) error = %q, want validation context", err) + if !strings.Contains(err.Error(), "validate bridges/deliver request") { + t.Fatalf("handleRequest(bridges/deliver) error = %q, want validation context", err) } payload, readErr := os.ReadFile(marker) @@ -4013,7 +4013,7 @@ func TestDaemonExtensionHelperMarkerRecording(t *testing.T) { } server := newDaemonExtensionHelperServer("", marker) - err := server.recordDelivery(channelspkg.DeliveryRequest{}) + err := server.recordDelivery(bridgepkg.DeliveryRequest{}) if err == nil { t.Fatal("recordDelivery() error = nil, want marker append failure") } @@ -4101,19 +4101,19 @@ func (h *daemonExtensionHelperServer) handleRequest(req daemonExtensionHelperReq return h.scenario == "auto_exit_record_initialize", nil case "health_check": return false, h.sendResult(req.ID, subprocess.HealthCheckResponse{Healthy: true}) - case "channels/deliver": - var params channelspkg.DeliveryRequest + case "bridges/deliver": + var params bridgepkg.DeliveryRequest if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return false, fmt.Errorf("decode channels/deliver request: %w", err) + return false, fmt.Errorf("decode bridges/deliver request: %w", err) } if err := params.Validate(); err != nil { - return false, fmt.Errorf("validate channels/deliver request: %w", err) + return false, fmt.Errorf("validate bridges/deliver request: %w", err) } if err := h.recordDelivery(params); err != nil { return false, err } - ack := channelspkg.DeliveryAck{ + ack := bridgepkg.DeliveryAck{ DeliveryID: strings.TrimSpace(params.Event.DeliveryID), Seq: params.Event.Seq, } @@ -4147,7 +4147,7 @@ func (h *daemonExtensionHelperServer) handleRequest(req daemonExtensionHelperReq } } -func (h *daemonExtensionHelperServer) sendDelayedDeliveryResult(id any, ack channelspkg.DeliveryAck) { +func (h *daemonExtensionHelperServer) sendDelayedDeliveryResult(id any, ack bridgepkg.DeliveryAck) { h.slowDeliveryWG.Add(1) go func() { defer h.slowDeliveryWG.Done() @@ -4200,7 +4200,7 @@ func (h *daemonExtensionHelperServer) recordInitialize( return nil } -func (h *daemonExtensionHelperServer) recordDelivery(request channelspkg.DeliveryRequest) error { +func (h *daemonExtensionHelperServer) recordDelivery(request bridgepkg.DeliveryRequest) error { if strings.TrimSpace(h.marker) == "" { return nil } @@ -4247,8 +4247,8 @@ type daemonInitializeMarker struct { } type daemonDeliveryMarker struct { - PID int `json:"pid"` - Request channelspkg.DeliveryRequest `json:"request"` + PID int `json:"pid"` + Request bridgepkg.DeliveryRequest `json:"request"` } func appendMarkerLine(path string, line string) (err error) { diff --git a/internal/extension/channel_delivery_integration_test.go b/internal/extension/bridge_delivery_integration_test.go similarity index 78% rename from internal/extension/channel_delivery_integration_test.go rename to internal/extension/bridge_delivery_integration_test.go index 9a672ea88..e9093f955 100644 --- a/internal/extension/channel_delivery_integration_test.go +++ b/internal/extension/bridge_delivery_integration_test.go @@ -16,7 +16,7 @@ import ( "time" "github.com/pedronauck/agh/internal/acp" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" aghconfig "github.com/pedronauck/agh/internal/config" "github.com/pedronauck/agh/internal/memory" "github.com/pedronauck/agh/internal/session" @@ -54,16 +54,16 @@ type deliveryIntegrationEnv struct { workspace workspacepkg.ResolvedWorkspace workspaces *hostAPIFakeWorkspaceResolver globalDB *globaldb.GlobalDB - channels *channelspkg.Service + bridges *bridgepkg.Service sessions *session.Manager manager *Manager - broker *channelspkg.Broker + broker *bridgepkg.Broker handler *HostAPIHandler checker *CapabilityChecker extensionName string } -func TestChannelDeliveryIntegrationPromptProducesOrderedDeliveryStream(t *testing.T) { +func TestBridgeDeliveryIntegrationPromptProducesOrderedDeliveryStream(t *testing.T) { withDaemonVersion(t, "0.5.0") driver := newScriptedPromptDriver(time.Date(2026, 4, 11, 3, 0, 0, 0, time.UTC), []scriptedPromptEvent{ @@ -72,15 +72,15 @@ func TestChannelDeliveryIntegrationPromptProducesOrderedDeliveryStream(t *testin {Type: acp.EventTypeDone}, }) markerPath := filepath.Join(t.TempDir(), "deliveries.jsonl") - env := newDeliveryIntegrationEnv(t, driver, "ext-channel-order", "record_deliveries", markerPath) + env := newDeliveryIntegrationEnv(t, driver, "ext-bridge-order", "record_deliveries", markerPath) - instance := env.createChannelInstance(t, channelspkg.CreateInstanceRequest{ - ID: "chan-order", + instance := env.createBridgeInstance(t, bridgepkg.CreateInstanceRequest{ + ID: "brg-order", ExtensionName: env.extensionName, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) params := map[string]any{ - "channel_instance_id": instance.ID, + "bridge_instance_id": instance.ID, "scope": instance.Scope, "workspace_id": instance.WorkspaceID, "peer_id": "peer-1", @@ -90,19 +90,19 @@ func TestChannelDeliveryIntegrationPromptProducesOrderedDeliveryStream(t *testin "content": map[string]any{"text": "hello"}, } - if _, err := env.callWithContext(t, env.channelContext(instance), env.extensionName, "channels/messages/ingest", params); err != nil { - t.Fatalf("Handle(channels/messages/ingest) error = %v", err) + if _, err := env.callWithContext(t, env.bridgeContext(instance), env.extensionName, "bridges/messages/ingest", params); err != nil { + t.Fatalf("Handle(bridges/messages/ingest) error = %v", err) } waitForDeliveryMarkers(t, markerPath, func(markers []managerDeliveryMarker) bool { - return len(markers) >= 2 && markers[len(markers)-1].Request.Event.EventType == channelspkg.DeliveryEventTypeFinal + return len(markers) >= 2 && markers[len(markers)-1].Request.Event.EventType == bridgepkg.DeliveryEventTypeFinal }) markers := readDeliveryMarkers(t, markerPath) assertMarkerDeliveryProgress(t, markers) } -func TestChannelDeliveryIntegrationSlowAdapterCoalescesIntermediateDeltas(t *testing.T) { +func TestBridgeDeliveryIntegrationSlowAdapterCoalescesIntermediateDeltas(t *testing.T) { withDaemonVersion(t, "0.5.0") driver := newScriptedPromptDriver(time.Date(2026, 4, 11, 3, 5, 0, 0, time.UTC), []scriptedPromptEvent{ @@ -116,19 +116,19 @@ func TestChannelDeliveryIntegrationSlowAdapterCoalescesIntermediateDeltas(t *tes env := newDeliveryIntegrationEnv( t, driver, - "ext-channel-slow", + "ext-bridge-slow", "slow_record_deliveries", markerPath, - channelspkg.WithDeliveryBrokerQueueCapacity(2), + bridgepkg.WithDeliveryBrokerQueueCapacity(2), ) - instance := env.createChannelInstance(t, channelspkg.CreateInstanceRequest{ - ID: "chan-slow", + instance := env.createBridgeInstance(t, bridgepkg.CreateInstanceRequest{ + ID: "brg-slow", ExtensionName: env.extensionName, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) params := map[string]any{ - "channel_instance_id": instance.ID, + "bridge_instance_id": instance.ID, "scope": instance.Scope, "workspace_id": instance.WorkspaceID, "peer_id": "peer-1", @@ -138,23 +138,23 @@ func TestChannelDeliveryIntegrationSlowAdapterCoalescesIntermediateDeltas(t *tes "content": map[string]any{"text": "hello"}, } - if _, err := env.callWithContext(t, env.channelContext(instance), env.extensionName, "channels/messages/ingest", params); err != nil { - t.Fatalf("Handle(channels/messages/ingest) error = %v", err) + if _, err := env.callWithContext(t, env.bridgeContext(instance), env.extensionName, "bridges/messages/ingest", params); err != nil { + t.Fatalf("Handle(bridges/messages/ingest) error = %v", err) } waitForDeliveryMarkers(t, markerPath, func(markers []managerDeliveryMarker) bool { - return len(markers) >= 2 && markers[len(markers)-1].Request.Event.EventType == channelspkg.DeliveryEventTypeFinal + return len(markers) >= 2 && markers[len(markers)-1].Request.Event.EventType == bridgepkg.DeliveryEventTypeFinal }) markers := readDeliveryMarkers(t, markerPath) if len(markers) >= 5 { t.Fatalf("len(delivery markers) = %d, want coalesced stream smaller than 5 projected events", len(markers)) } - if got := markers[0].Request.Event.EventType; got != channelspkg.DeliveryEventTypeStart { + if got := markers[0].Request.Event.EventType; got != bridgepkg.DeliveryEventTypeStart { t.Fatalf("first delivery event = %q, want start", got) } last := markers[len(markers)-1].Request.Event - if got := last.EventType; got != channelspkg.DeliveryEventTypeFinal { + if got := last.EventType; got != bridgepkg.DeliveryEventTypeFinal { t.Fatalf("last delivery event = %q, want final", got) } if got, want := last.Seq, int64(5); got != want { @@ -162,7 +162,7 @@ func TestChannelDeliveryIntegrationSlowAdapterCoalescesIntermediateDeltas(t *tes } } -func TestChannelDeliveryIntegrationRestartResumesActiveDelivery(t *testing.T) { +func TestBridgeDeliveryIntegrationRestartResumesActiveDelivery(t *testing.T) { withDaemonVersion(t, "0.5.0") driver := newScriptedPromptDriver(time.Date(2026, 4, 11, 3, 10, 0, 0, time.UTC), []scriptedPromptEvent{ @@ -173,19 +173,19 @@ func TestChannelDeliveryIntegrationRestartResumesActiveDelivery(t *testing.T) { env := newDeliveryIntegrationEnv( t, driver, - "ext-channel-resume", + "ext-bridge-resume", "exit_once_record_deliveries", markerPath, - channelspkg.WithDeliveryBrokerRetryDelay(20*time.Millisecond), + bridgepkg.WithDeliveryBrokerRetryDelay(20*time.Millisecond), ) - instance := env.createChannelInstance(t, channelspkg.CreateInstanceRequest{ - ID: "chan-resume", + instance := env.createBridgeInstance(t, bridgepkg.CreateInstanceRequest{ + ID: "brg-resume", ExtensionName: env.extensionName, - RoutingPolicy: channelspkg.RoutingPolicy{IncludePeer: true}, + RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true}, }) params := map[string]any{ - "channel_instance_id": instance.ID, + "bridge_instance_id": instance.ID, "scope": instance.Scope, "workspace_id": instance.WorkspaceID, "peer_id": "peer-1", @@ -195,13 +195,13 @@ func TestChannelDeliveryIntegrationRestartResumesActiveDelivery(t *testing.T) { "content": map[string]any{"text": "hello"}, } - if _, err := env.callWithContext(t, env.channelContext(instance), env.extensionName, "channels/messages/ingest", params); err != nil { - t.Fatalf("Handle(channels/messages/ingest) error = %v", err) + if _, err := env.callWithContext(t, env.bridgeContext(instance), env.extensionName, "bridges/messages/ingest", params); err != nil { + t.Fatalf("Handle(bridges/messages/ingest) error = %v", err) } waitForDeliveryMarkers(t, markerPath, func(markers []managerDeliveryMarker) bool { for _, marker := range markers { - if marker.Request.Event.EventType == channelspkg.DeliveryEventTypeResume { + if marker.Request.Event.EventType == bridgepkg.DeliveryEventTypeResume { return true } } @@ -212,13 +212,13 @@ func TestChannelDeliveryIntegrationRestartResumesActiveDelivery(t *testing.T) { if len(markers) < 2 { t.Fatalf("len(delivery markers) = %d, want at least start + resume", len(markers)) } - if got := markers[0].Request.Event.EventType; got != channelspkg.DeliveryEventTypeStart { + if got := markers[0].Request.Event.EventType; got != bridgepkg.DeliveryEventTypeStart { t.Fatalf("first delivery event = %q, want start", got) } resumeIndex := -1 for idx, marker := range markers { - if marker.Request.Event.EventType == channelspkg.DeliveryEventTypeResume { + if marker.Request.Event.EventType == bridgepkg.DeliveryEventTypeResume { resumeIndex = idx break } @@ -235,7 +235,7 @@ func TestChannelDeliveryIntegrationRestartResumesActiveDelivery(t *testing.T) { if got, want := markers[resumeIndex].Request.Snapshot.DeliveryID, markers[0].Request.Event.DeliveryID; got != want { t.Fatalf("resume snapshot delivery id = %q, want %q", got, want) } - if got, want := markers[resumeIndex].Request.Snapshot.LatestEventType, channelspkg.DeliveryEventTypeFinal; got != want { + if got, want := markers[resumeIndex].Request.Snapshot.LatestEventType, bridgepkg.DeliveryEventTypeFinal; got != want { t.Fatalf("resume snapshot latest event type = %q, want %q", got, want) } } @@ -246,7 +246,7 @@ func newDeliveryIntegrationEnv( extensionName string, scenario string, markerPath string, - brokerOpts ...channelspkg.DeliveryBrokerOption, + brokerOpts ...bridgepkg.DeliveryBrokerOption, ) *deliveryIntegrationEnv { t.Helper() @@ -262,9 +262,9 @@ func newDeliveryIntegrationEnv( baseNow := time.Date(2026, 4, 11, 3, 0, 0, 0, time.UTC) resolvedWorkspace := workspacepkg.ResolvedWorkspace{ Workspace: workspacepkg.Workspace{ - ID: "ws-channel-delivery", + ID: "ws-bridge-delivery", RootDir: workspaceRoot, - Name: "channel-delivery-workspace", + Name: "bridge-delivery-workspace", }, Config: aghconfig.Config{ Defaults: aghconfig.DefaultsConfig{Agent: "coder"}, @@ -296,28 +296,28 @@ func newDeliveryIntegrationEnv( t.Fatalf("globalDB.InsertWorkspace() error = %v", err) } - channelRegistry := channelspkg.NewRegistry(globalDB, channelspkg.WithNow(func() time.Time { return baseNow })) + bridgeRegistry := bridgepkg.NewRegistry(globalDB, bridgepkg.WithNow(func() time.Time { return baseNow })) registryEnv := newRegistryTestEnv(t) fixture := createManagerTestExtension(t, managerTestManifest(extensionName, managerManifestOptions{ command: helperCommand(t), args: helperArgs(), withEnv: helperEnv(scenario, markerPath), - capabilities: []string{"channel.adapter"}, + capabilities: []string{"bridge.adapter"}, actions: []string{ - "channels/messages/ingest", - "channels/instances/get", - "channels/instances/report_state", + "bridges/messages/ingest", + "bridges/instances/get", + "bridges/instances/report_state", }, - security: []string{"channel.read", "channel.write"}, + security: []string{"bridge.read", "bridge.write"}, }), nil) installManagerFixture(t, registryEnv.registry, fixture, SourceUser, true) manager := NewManager( registryEnv.registry, - WithChannelRuntimeResolver(&stubChannelRuntimeResolver{ - runtimes: map[string]*subprocess.InitializeChannelRuntime{ + WithBridgeRuntimeResolver(&stubBridgeRuntimeResolver{ + runtimes: map[string]*subprocess.InitializeBridgeRuntime{ extensionName: { - Instance: testChannelRuntimeInstance(extensionName, "runtime-"+extensionName), + Instance: testBridgeRuntimeInstance(extensionName, "runtime-"+extensionName), }, }, }), @@ -330,10 +330,10 @@ func newDeliveryIntegrationEnv( t.Fatalf("manager.Start() error = %v", err) } - broker := channelspkg.NewBroker(manager, brokerOpts...) + broker := bridgepkg.NewBroker(manager, brokerOpts...) skillsRegistry := skillspkg.NewRegistry(skillspkg.RegistryConfig{}, skillspkg.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))) checker := &CapabilityChecker{} - notifier := NewChannelDeliveryNotifier(broker, nil) + notifier := NewBridgeDeliveryNotifier(broker, nil) sessions, err := session.NewManager( session.WithHomePaths(homePaths), session.WithDriver(driver), @@ -358,16 +358,16 @@ func newDeliveryIntegrationEnv( skillsRegistry, WithHostAPICapabilityChecker(checker), WithHostAPIWorkspaceResolver(workspaces), - WithHostAPIChannelRegistry(channelRegistry), - WithHostAPIChannelDedupStore(globalDB), + WithHostAPIBridgeRegistry(bridgeRegistry), + WithHostAPIBridgeDedupStore(globalDB), WithHostAPIDeliveryBroker(broker), WithHostAPINow(func() time.Time { return baseNow }), - WithHostAPIChannelIngressConfig(15*time.Minute, time.Minute), + WithHostAPIBridgeIngressConfig(15*time.Minute, time.Minute), WithHostAPIRateLimit(1000, 1000), ) checker.Register(extensionName, SourceUser, &Manifest{ - Actions: ActionsConfig{Requires: []string{"channels/messages/ingest"}}, - Security: SecurityConfig{Capabilities: []string{"channel.write"}}, + Actions: ActionsConfig{Requires: []string{"bridges/messages/ingest"}}, + Security: SecurityConfig{Capabilities: []string{"bridge.write"}}, }) env := &deliveryIntegrationEnv{ @@ -376,7 +376,7 @@ func newDeliveryIntegrationEnv( workspace: resolvedWorkspace, workspaces: workspaces, globalDB: globalDB, - channels: channelRegistry, + bridges: bridgeRegistry, sessions: sessions, manager: manager, broker: broker, @@ -411,22 +411,22 @@ func (e *deliveryIntegrationEnv) callWithContext( return e.handler.Handle(ctx, extName, method, raw) } -func (e *deliveryIntegrationEnv) channelContext(instance *channelspkg.ChannelInstance) context.Context { - return withHostAPIChannelRuntime(context.Background(), &subprocess.InitializeChannelRuntime{ +func (e *deliveryIntegrationEnv) bridgeContext(instance *bridgepkg.BridgeInstance) context.Context { + return withHostAPIBridgeRuntime(context.Background(), &subprocess.InitializeBridgeRuntime{ Instance: *instance, }) } -func (e *deliveryIntegrationEnv) createChannelInstance( +func (e *deliveryIntegrationEnv) createBridgeInstance( t *testing.T, - req channelspkg.CreateInstanceRequest, -) *channelspkg.ChannelInstance { + req bridgepkg.CreateInstanceRequest, +) *bridgepkg.BridgeInstance { t.Helper() if req.Scope == "" { - req.Scope = channelspkg.ScopeWorkspace + req.Scope = bridgepkg.ScopeWorkspace } - if req.WorkspaceID == "" && req.Scope == channelspkg.ScopeWorkspace { + if req.WorkspaceID == "" && req.Scope == bridgepkg.ScopeWorkspace { req.WorkspaceID = e.workspace.ID } if req.Platform == "" { @@ -436,16 +436,16 @@ func (e *deliveryIntegrationEnv) createChannelInstance( req.ExtensionName = e.extensionName } if req.DisplayName == "" { - req.DisplayName = "Channel Delivery Test" + req.DisplayName = "Bridge Delivery Test" } if req.Status == "" { - req.Status = channelspkg.ChannelStatusReady + req.Status = bridgepkg.BridgeStatusReady req.Enabled = true } - instance, err := e.channels.CreateInstance(testutil.Context(t), req) + instance, err := e.bridges.CreateInstance(testutil.Context(t), req) if err != nil { - t.Fatalf("channels.CreateInstance() error = %v", err) + t.Fatalf("bridges.CreateInstance() error = %v", err) } return instance } @@ -608,10 +608,10 @@ func assertMarkerDeliveryProgress(t *testing.T, markers []managerDeliveryMarker) if len(markers) < 2 { t.Fatalf("len(markers) = %d, want at least start and final", len(markers)) } - if got := markers[0].Request.Event.EventType; got != channelspkg.DeliveryEventTypeStart { + if got := markers[0].Request.Event.EventType; got != bridgepkg.DeliveryEventTypeStart { t.Fatalf("first marker event = %q, want start", got) } - if got := markers[len(markers)-1].Request.Event.EventType; got != channelspkg.DeliveryEventTypeFinal { + if got := markers[len(markers)-1].Request.Event.EventType; got != bridgepkg.DeliveryEventTypeFinal { t.Fatalf("last marker event = %q, want final", got) } diff --git a/internal/extension/channel_delivery_notifier.go b/internal/extension/bridge_delivery_notifier.go similarity index 58% rename from internal/extension/channel_delivery_notifier.go rename to internal/extension/bridge_delivery_notifier.go index abe3fc816..0a2931116 100644 --- a/internal/extension/channel_delivery_notifier.go +++ b/internal/extension/bridge_delivery_notifier.go @@ -5,39 +5,39 @@ import ( "log/slog" "github.com/pedronauck/agh/internal/acp" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" "github.com/pedronauck/agh/internal/session" "github.com/pedronauck/agh/internal/transcript" ) -// ChannelDeliveryNotifier projects prompt-time ACP events into the channel +// BridgeDeliveryNotifier projects prompt-time ACP events into the bridge // delivery broker while preserving an optional downstream notifier chain. -type ChannelDeliveryNotifier struct { - broker *channelspkg.Broker +type BridgeDeliveryNotifier struct { + broker *bridgepkg.Broker downstream session.Notifier } -var _ session.Notifier = (*ChannelDeliveryNotifier)(nil) +var _ session.Notifier = (*BridgeDeliveryNotifier)(nil) -// NewChannelDeliveryNotifier wraps the provided downstream notifier with -// session-to-channel delivery projection. -func NewChannelDeliveryNotifier(broker *channelspkg.Broker, downstream session.Notifier) *ChannelDeliveryNotifier { - return &ChannelDeliveryNotifier{ +// NewBridgeDeliveryNotifier wraps the provided downstream notifier with +// session-to-bridge delivery projection. +func NewBridgeDeliveryNotifier(broker *bridgepkg.Broker, downstream session.Notifier) *BridgeDeliveryNotifier { + return &BridgeDeliveryNotifier{ broker: broker, downstream: downstream, } } // OnSessionCreated forwards the lifecycle callback unchanged. -func (n *ChannelDeliveryNotifier) OnSessionCreated(ctx context.Context, sess *session.Session) { +func (n *BridgeDeliveryNotifier) OnSessionCreated(ctx context.Context, sess *session.Session) { if n == nil || n.downstream == nil { return } n.downstream.OnSessionCreated(ctx, sess) } -// OnSessionStopped fails unfinished channel deliveries before forwarding the lifecycle callback. -func (n *ChannelDeliveryNotifier) OnSessionStopped(ctx context.Context, sess *session.Session) { +// OnSessionStopped fails unfinished bridge deliveries before forwarding the lifecycle callback. +func (n *BridgeDeliveryNotifier) OnSessionStopped(ctx context.Context, sess *session.Session) { if n == nil { return } @@ -55,14 +55,14 @@ func (n *ChannelDeliveryNotifier) OnSessionStopped(ctx context.Context, sess *se } // OnAgentEvent projects ACP runtime output into the delivery broker before forwarding. -func (n *ChannelDeliveryNotifier) OnAgentEvent(ctx context.Context, sessionID string, payload any) { +func (n *BridgeDeliveryNotifier) OnAgentEvent(ctx context.Context, sessionID string, payload any) { if n == nil { return } if n.broker != nil { if event, ok := payload.(acp.AgentEvent); ok { if err := n.broker.ProjectEvent(ctx, sessionID, projectionEventFromAgentEvent(event)); err != nil { - slog.ErrorContext(ctx, "extension: project channel delivery event", + slog.ErrorContext(ctx, "extension: project bridge delivery event", "session_id", sessionID, "event_type", event.Type, "turn_id", event.TurnID, @@ -76,8 +76,8 @@ func (n *ChannelDeliveryNotifier) OnAgentEvent(ctx context.Context, sessionID st } } -func projectionEventFromAgentEvent(event acp.AgentEvent) channelspkg.DeliveryProjectionEvent { - projected := channelspkg.DeliveryProjectionEvent{ +func projectionEventFromAgentEvent(event acp.AgentEvent) bridgepkg.DeliveryProjectionEvent { + projected := bridgepkg.DeliveryProjectionEvent{ Type: event.Type, TurnID: event.TurnID, Timestamp: event.Timestamp, diff --git a/internal/extension/channel_delivery_notifier_test.go b/internal/extension/bridge_delivery_notifier_test.go similarity index 67% rename from internal/extension/channel_delivery_notifier_test.go rename to internal/extension/bridge_delivery_notifier_test.go index a3c59efb8..5d5b1e999 100644 --- a/internal/extension/channel_delivery_notifier_test.go +++ b/internal/extension/bridge_delivery_notifier_test.go @@ -9,7 +9,7 @@ import ( "time" "github.com/pedronauck/agh/internal/acp" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol" "github.com/pedronauck/agh/internal/session" "github.com/pedronauck/agh/internal/subprocess" @@ -18,15 +18,15 @@ import ( type recordingDeliveryTransport struct { mu sync.Mutex - calls []channelspkg.DeliveryRequest + calls []bridgepkg.DeliveryRequest notify chan struct{} } -func (t *recordingDeliveryTransport) DeliverChannel( +func (t *recordingDeliveryTransport) DeliverBridge( _ context.Context, _ string, - req channelspkg.DeliveryRequest, -) (channelspkg.DeliveryAck, error) { + req bridgepkg.DeliveryRequest, +) (bridgepkg.DeliveryAck, error) { t.mu.Lock() if t.notify == nil { t.notify = make(chan struct{}, 1) @@ -39,17 +39,17 @@ func (t *recordingDeliveryTransport) DeliverChannel( default: } - return channelspkg.DeliveryAck{ + return bridgepkg.DeliveryAck{ DeliveryID: req.Event.DeliveryID, Seq: req.Event.Seq, }, nil } -func (t *recordingDeliveryTransport) snapshotCalls() []channelspkg.DeliveryRequest { +func (t *recordingDeliveryTransport) snapshotCalls() []bridgepkg.DeliveryRequest { t.mu.Lock() defer t.mu.Unlock() - out := make([]channelspkg.DeliveryRequest, 0, len(t.calls)) + out := make([]bridgepkg.DeliveryRequest, 0, len(t.calls)) for _, call := range t.calls { out = append(out, cloneExtensionDeliveryRequest(call)) } @@ -97,27 +97,27 @@ func (n *recordingNotifier) snapshot() (created int, stopped int, events []any) return len(n.created), len(n.stopped), append([]any(nil), n.events...) } -func TestChannelDeliveryNotifierProjectsEventsAndForwardsLifecycle(t *testing.T) { +func TestBridgeDeliveryNotifierProjectsEventsAndForwardsLifecycle(t *testing.T) { t.Parallel() transport := &recordingDeliveryTransport{} - broker := channelspkg.NewBroker(transport) + broker := bridgepkg.NewBroker(transport) t.Cleanup(broker.Close) - registration, err := broker.RegisterPromptDelivery(testutil.Context(t), channelspkg.PromptDeliveryRegistration{ + registration, err := broker.RegisterPromptDelivery(testutil.Context(t), bridgepkg.PromptDeliveryRegistration{ SessionID: "sess-notify", TurnID: "turn-notify", ExtensionName: "ext-telegram", - RoutingKey: channelspkg.RoutingKey{ - Scope: channelspkg.ScopeWorkspace, - WorkspaceID: "ws-1", - ChannelInstanceID: "chan-notify", - PeerID: "peer-notify", + RoutingKey: bridgepkg.RoutingKey{ + Scope: bridgepkg.ScopeWorkspace, + WorkspaceID: "ws-1", + BridgeInstanceID: "brg-notify", + PeerID: "peer-notify", }, - DeliveryTarget: channelspkg.DeliveryTarget{ - ChannelInstanceID: "chan-notify", - PeerID: "peer-notify", - Mode: channelspkg.DeliveryModeReply, + DeliveryTarget: bridgepkg.DeliveryTarget{ + BridgeInstanceID: "brg-notify", + PeerID: "peer-notify", + Mode: bridgepkg.DeliveryModeReply, }, }) if err != nil { @@ -125,9 +125,9 @@ func TestChannelDeliveryNotifierProjectsEventsAndForwardsLifecycle(t *testing.T) } downstream := &recordingNotifier{} - notifier := NewChannelDeliveryNotifier(broker, downstream) + notifier := NewBridgeDeliveryNotifier(broker, downstream) if notifier == nil { - t.Fatal("NewChannelDeliveryNotifier() = nil, want notifier") + t.Fatal("NewBridgeDeliveryNotifier() = nil, want notifier") } sess := &session.Session{ID: registration.SessionID} @@ -148,7 +148,7 @@ func TestChannelDeliveryNotifierProjectsEventsAndForwardsLifecycle(t *testing.T) waitForExtensionDeliveryCalls(t, transport, 1) calls := transport.snapshotCalls() - if got, want := calls[0].Event.EventType, channelspkg.DeliveryEventTypeStart; got != want { + if got, want := calls[0].Event.EventType, bridgepkg.DeliveryEventTypeStart; got != want { t.Fatalf("projected event type = %q, want %q", got, want) } if got, want := calls[0].Event.Content.Text, "hello"; got != want { @@ -166,7 +166,7 @@ func TestChannelDeliveryNotifierProjectsEventsAndForwardsLifecycle(t *testing.T) calls = transport.snapshotCalls() last := calls[len(calls)-1].Event - if got, want := last.EventType, channelspkg.DeliveryEventTypeError; got != want { + if got, want := last.EventType, bridgepkg.DeliveryEventTypeError; got != want { t.Fatalf("session stop event type = %q, want %q", got, want) } if !last.Final { @@ -179,41 +179,41 @@ func TestChannelDeliveryNotifierProjectsEventsAndForwardsLifecycle(t *testing.T) } } -func TestChannelDeliveryNotifierNilPathsAreNoOps(t *testing.T) { +func TestBridgeDeliveryNotifierNilPathsAreNoOps(t *testing.T) { t.Parallel() - var notifier *ChannelDeliveryNotifier + var notifier *BridgeDeliveryNotifier notifier.OnSessionCreated(testutil.Context(t), nil) notifier.OnSessionStopped(testutil.Context(t), nil) notifier.OnAgentEvent(testutil.Context(t), "sess-nil", nil) - standalone := NewChannelDeliveryNotifier(nil, nil) + standalone := NewBridgeDeliveryNotifier(nil, nil) standalone.OnSessionCreated(testutil.Context(t), &session.Session{ID: "sess-nil"}) standalone.OnSessionStopped(testutil.Context(t), &session.Session{ID: "sess-nil"}) standalone.OnAgentEvent(testutil.Context(t), "sess-nil", "ignored") } -func TestManagerDeliverChannel(t *testing.T) { +func TestManagerDeliverBridge(t *testing.T) { t.Parallel() - req := channelspkg.DeliveryRequest{ - Event: channelspkg.DeliveryEvent{ - DeliveryID: "del-manager", - ChannelInstanceID: "chan-manager", - RoutingKey: channelspkg.RoutingKey{ - Scope: channelspkg.ScopeWorkspace, - WorkspaceID: "ws-1", - ChannelInstanceID: "chan-manager", - PeerID: "peer-manager", + req := bridgepkg.DeliveryRequest{ + Event: bridgepkg.DeliveryEvent{ + DeliveryID: "del-manager", + BridgeInstanceID: "brg-manager", + RoutingKey: bridgepkg.RoutingKey{ + Scope: bridgepkg.ScopeWorkspace, + WorkspaceID: "ws-1", + BridgeInstanceID: "brg-manager", + PeerID: "peer-manager", }, - DeliveryTarget: channelspkg.DeliveryTarget{ - ChannelInstanceID: "chan-manager", - PeerID: "peer-manager", - Mode: channelspkg.DeliveryModeReply, + DeliveryTarget: bridgepkg.DeliveryTarget{ + BridgeInstanceID: "brg-manager", + PeerID: "peer-manager", + Mode: bridgepkg.DeliveryModeReply, }, Seq: 1, - EventType: channelspkg.DeliveryEventTypeStart, - Content: channelspkg.MessageContent{Text: "hello"}, + EventType: bridgepkg.DeliveryEventTypeStart, + Content: bridgepkg.MessageContent{Text: "hello"}, }, } @@ -222,17 +222,17 @@ func TestManagerDeliverChannel(t *testing.T) { process := newFakeProcess(9001) process.callFn = func(_ context.Context, method string, params, result any) error { - if got, want := method, string(extensionprotocol.ExtensionServiceMethodChannelsDeliver); got != want { + if got, want := method, string(extensionprotocol.ExtensionServiceMethodBridgesDeliver); got != want { t.Fatalf("Call() method = %q, want %q", got, want) } - typedReq, ok := params.(channelspkg.DeliveryRequest) + typedReq, ok := params.(bridgepkg.DeliveryRequest) if !ok { - t.Fatalf("Call() params type = %T, want channel delivery request", params) + t.Fatalf("Call() params type = %T, want bridge delivery request", params) } if got, want := typedReq.Event.DeliveryID, req.Event.DeliveryID; got != want { t.Fatalf("Call() delivery id = %q, want %q", got, want) } - ack, ok := result.(*channelspkg.DeliveryAck) + ack, ok := result.(*bridgepkg.DeliveryAck) if !ok { t.Fatalf("Call() result type = %T, want *DeliveryAck", result) } @@ -248,15 +248,15 @@ func TestManagerDeliverChannel(t *testing.T) { active: true, process: process, initialize: &subprocess.InitializeResponse{ - ImplementedMethods: []string{string(extensionprotocol.ExtensionServiceMethodChannelsDeliver)}, + ImplementedMethods: []string{string(extensionprotocol.ExtensionServiceMethodBridgesDeliver)}, }, }, }, } - ack, err := manager.DeliverChannel(testutil.Context(t), "ext-telegram", req) + ack, err := manager.DeliverBridge(testutil.Context(t), "ext-telegram", req) if err != nil { - t.Fatalf("DeliverChannel() error = %v", err) + t.Fatalf("DeliverBridge() error = %v", err) } if got, want := ack.RemoteMessageID, "remote-manager"; got != want { t.Fatalf("ack.RemoteMessageID = %q, want %q", got, want) @@ -270,9 +270,9 @@ func TestManagerDeliverChannel(t *testing.T) { cancel() manager := &Manager{} - _, err := manager.DeliverChannel(ctx, "ext-telegram", req) + _, err := manager.DeliverBridge(ctx, "ext-telegram", req) if !errors.Is(err, context.Canceled) { - t.Fatalf("DeliverChannel() error = %v, want context.Canceled", err) + t.Fatalf("DeliverBridge() error = %v, want context.Canceled", err) } }) @@ -280,9 +280,9 @@ func TestManagerDeliverChannel(t *testing.T) { t.Parallel() var manager *Manager - _, err := manager.DeliverChannel(testutil.Context(t), "ext-telegram", req) + _, err := manager.DeliverBridge(testutil.Context(t), "ext-telegram", req) if err == nil || !strings.Contains(err.Error(), "manager is required") { - t.Fatalf("DeliverChannel() error = %v, want nil manager error", err) + t.Fatalf("DeliverBridge() error = %v, want nil manager error", err) } }) @@ -295,9 +295,9 @@ func TestManagerDeliverChannel(t *testing.T) { }, } - _, err := manager.DeliverChannel(testutil.Context(t), "ext-telegram", req) - if !errors.Is(err, channelspkg.ErrDeliveryTransportUnavailable) { - t.Fatalf("DeliverChannel() error = %v, want ErrDeliveryTransportUnavailable", err) + _, err := manager.DeliverBridge(testutil.Context(t), "ext-telegram", req) + if !errors.Is(err, bridgepkg.ErrDeliveryTransportUnavailable) { + t.Fatalf("DeliverBridge() error = %v, want ErrDeliveryTransportUnavailable", err) } }) @@ -314,9 +314,9 @@ func TestManagerDeliverChannel(t *testing.T) { }, } - _, err := manager.DeliverChannel(testutil.Context(t), "ext-telegram", req) - if !errors.Is(err, channelspkg.ErrDeliveryTransportUnavailable) { - t.Fatalf("DeliverChannel() error = %v, want wrapped ErrDeliveryTransportUnavailable", err) + _, err := manager.DeliverBridge(testutil.Context(t), "ext-telegram", req) + if !errors.Is(err, bridgepkg.ErrDeliveryTransportUnavailable) { + t.Fatalf("DeliverBridge() error = %v, want wrapped ErrDeliveryTransportUnavailable", err) } }) @@ -334,15 +334,15 @@ func TestManagerDeliverChannel(t *testing.T) { active: true, process: process, initialize: &subprocess.InitializeResponse{ - ImplementedMethods: []string{string(extensionprotocol.ExtensionServiceMethodChannelsDeliver)}, + ImplementedMethods: []string{string(extensionprotocol.ExtensionServiceMethodBridgesDeliver)}, }, }, }, } - _, err := manager.DeliverChannel(testutil.Context(t), "ext-telegram", req) + _, err := manager.DeliverBridge(testutil.Context(t), "ext-telegram", req) if err == nil || !strings.Contains(err.Error(), "rpc failed") { - t.Fatalf("DeliverChannel() error = %v, want wrapped process failure", err) + t.Fatalf("DeliverBridge() error = %v, want wrapped process failure", err) } }) @@ -350,9 +350,9 @@ func TestManagerDeliverChannel(t *testing.T) { t.Parallel() manager := &Manager{} - _, err := manager.DeliverChannel(testutil.Context(t), "ext-telegram", channelspkg.DeliveryRequest{}) + _, err := manager.DeliverBridge(testutil.Context(t), "ext-telegram", bridgepkg.DeliveryRequest{}) if err == nil { - t.Fatal("DeliverChannel() error = nil, want validation error") + t.Fatal("DeliverBridge() error = nil, want validation error") } }) @@ -360,14 +360,14 @@ func TestManagerDeliverChannel(t *testing.T) { t.Parallel() manager := &Manager{} - _, err := manager.DeliverChannel(testutil.Context(t), " ", req) + _, err := manager.DeliverBridge(testutil.Context(t), " ", req) if err == nil || !strings.Contains(err.Error(), "extension name is required") { - t.Fatalf("DeliverChannel() error = %v, want missing extension name error", err) + t.Fatalf("DeliverBridge() error = %v, want missing extension name error", err) } }) } -func cloneExtensionDeliveryRequest(req channelspkg.DeliveryRequest) channelspkg.DeliveryRequest { +func cloneExtensionDeliveryRequest(req bridgepkg.DeliveryRequest) bridgepkg.DeliveryRequest { cloned := req cloned.Event.Metadata = append([]byte(nil), req.Event.Metadata...) if req.Snapshot != nil { diff --git a/internal/extension/capability.go b/internal/extension/capability.go index 22b4e448a..bbd20d7e7 100644 --- a/internal/extension/capability.go +++ b/internal/extension/capability.go @@ -16,36 +16,36 @@ const ( var ( hostAPIMethodSecurityCapability = map[string]string{ - "automation/jobs": "automation.read", - "automation/jobs/get": "automation.read", - "automation/jobs/create": "automation.write", - "automation/jobs/update": "automation.write", - "automation/jobs/delete": "automation.write", - "automation/jobs/trigger": "automation.write", - "automation/jobs/runs": "automation.read", - "automation/triggers": "automation.read", - "automation/triggers/get": "automation.read", - "automation/triggers/create": "automation.write", - "automation/triggers/update": "automation.write", - "automation/triggers/delete": "automation.write", - "automation/triggers/runs": "automation.read", - "automation/triggers/fire": "automation.write", - "automation/runs": "automation.read", - "channels/instances/get": "channel.read", - "channels/instances/report_state": "channel.write", - "channels/messages/ingest": "channel.write", - "memory/forget": "memory.write", - "memory/recall": "memory.read", - "memory/store": "memory.write", - "observe/events": "observe.read", - "observe/health": "observe.read", - "sessions/create": "session.write", - "sessions/events": "session.read", - "sessions/list": "session.read", - "sessions/prompt": "session.write", - "sessions/status": "session.read", - "sessions/stop": "session.write", - "skills/list": "skills.read", + "automation/jobs": "automation.read", + "automation/jobs/get": "automation.read", + "automation/jobs/create": "automation.write", + "automation/jobs/update": "automation.write", + "automation/jobs/delete": "automation.write", + "automation/jobs/trigger": "automation.write", + "automation/jobs/runs": "automation.read", + "automation/triggers": "automation.read", + "automation/triggers/get": "automation.read", + "automation/triggers/create": "automation.write", + "automation/triggers/update": "automation.write", + "automation/triggers/delete": "automation.write", + "automation/triggers/runs": "automation.read", + "automation/triggers/fire": "automation.write", + "automation/runs": "automation.read", + "bridges/instances/get": "bridge.read", + "bridges/instances/report_state": "bridge.write", + "bridges/messages/ingest": "bridge.write", + "memory/forget": "memory.write", + "memory/recall": "memory.read", + "memory/store": "memory.write", + "observe/events": "observe.read", + "observe/health": "observe.read", + "sessions/create": "session.write", + "sessions/events": "session.read", + "sessions/list": "session.read", + "sessions/prompt": "session.write", + "sessions/status": "session.read", + "sessions/stop": "session.write", + "skills/list": "skills.read", } marketplaceSecurityCeiling = []string{ diff --git a/internal/extension/capability_test.go b/internal/extension/capability_test.go index 87fda9c49..e733c0f75 100644 --- a/internal/extension/capability_test.go +++ b/internal/extension/capability_test.go @@ -73,10 +73,10 @@ func TestCapabilityCheckerCheckHostAPIShouldEnforceDualGates(t *testing.T) { method: "sessions/list", }, { - name: "allows channel read method with matching grant", - actions: []string{"channels/instances/get"}, - security: []string{"channel.read"}, - method: "channels/instances/get", + name: "allows bridge read method with matching grant", + actions: []string{"bridges/instances/get"}, + security: []string{"bridge.read"}, + method: "bridges/instances/get", }, { name: "fails when action grant is missing", @@ -109,12 +109,12 @@ func TestCapabilityCheckerCheckHostAPIShouldEnforceDualGates(t *testing.T) { method: "automation/jobs/create", }, { - name: "fails for channel write method without channel security grant", - actions: []string{"channels/messages/ingest"}, - security: []string{"channel.read"}, - method: "channels/messages/ingest", - wantRequired: []string{"channel.write"}, - wantGranted: []string{"channel.read"}, + name: "fails for bridge write method without bridge security grant", + actions: []string{"bridges/messages/ingest"}, + security: []string{"bridge.read"}, + method: "bridges/messages/ingest", + wantRequired: []string{"bridge.write"}, + wantGranted: []string{"bridge.read"}, wantErr: true, }, } diff --git a/internal/extension/contract/host_api.go b/internal/extension/contract/host_api.go index af656949f..accc023c7 100644 --- a/internal/extension/contract/host_api.go +++ b/internal/extension/contract/host_api.go @@ -5,7 +5,7 @@ import ( apicontract "github.com/pedronauck/agh/internal/api/contract" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol" "github.com/pedronauck/agh/internal/memory" observepkg "github.com/pedronauck/agh/internal/observe" @@ -17,36 +17,36 @@ import ( type HostAPIMethod = extensionprotocol.HostAPIMethod const ( - HostAPIMethodSessionsList = extensionprotocol.HostAPIMethodSessionsList - HostAPIMethodSessionsCreate = extensionprotocol.HostAPIMethodSessionsCreate - HostAPIMethodSessionsPrompt = extensionprotocol.HostAPIMethodSessionsPrompt - HostAPIMethodSessionsStop = extensionprotocol.HostAPIMethodSessionsStop - HostAPIMethodSessionsStatus = extensionprotocol.HostAPIMethodSessionsStatus - HostAPIMethodSessionsEvents = extensionprotocol.HostAPIMethodSessionsEvents - HostAPIMethodMemoryRecall = extensionprotocol.HostAPIMethodMemoryRecall - HostAPIMethodMemoryStore = extensionprotocol.HostAPIMethodMemoryStore - HostAPIMethodMemoryForget = extensionprotocol.HostAPIMethodMemoryForget - HostAPIMethodObserveHealth = extensionprotocol.HostAPIMethodObserveHealth - HostAPIMethodObserveEvents = extensionprotocol.HostAPIMethodObserveEvents - HostAPIMethodSkillsList = extensionprotocol.HostAPIMethodSkillsList - HostAPIMethodAutomationJobs = extensionprotocol.HostAPIMethodAutomationJobs - HostAPIMethodAutomationJobsGet = extensionprotocol.HostAPIMethodAutomationJobsGet - HostAPIMethodAutomationJobsCreate = extensionprotocol.HostAPIMethodAutomationJobsCreate - HostAPIMethodAutomationJobsUpdate = extensionprotocol.HostAPIMethodAutomationJobsUpdate - HostAPIMethodAutomationJobsDelete = extensionprotocol.HostAPIMethodAutomationJobsDelete - HostAPIMethodAutomationJobsTrigger = extensionprotocol.HostAPIMethodAutomationJobsTrigger - HostAPIMethodAutomationJobsRuns = extensionprotocol.HostAPIMethodAutomationJobsRuns - HostAPIMethodAutomationTriggers = extensionprotocol.HostAPIMethodAutomationTriggers - HostAPIMethodAutomationTriggersGet = extensionprotocol.HostAPIMethodAutomationTriggersGet - HostAPIMethodAutomationTriggersCreate = extensionprotocol.HostAPIMethodAutomationTriggersCreate - HostAPIMethodAutomationTriggersUpdate = extensionprotocol.HostAPIMethodAutomationTriggersUpdate - HostAPIMethodAutomationTriggersDelete = extensionprotocol.HostAPIMethodAutomationTriggersDelete - HostAPIMethodAutomationTriggersRuns = extensionprotocol.HostAPIMethodAutomationTriggersRuns - HostAPIMethodAutomationTriggersFire = extensionprotocol.HostAPIMethodAutomationTriggersFire - HostAPIMethodAutomationRuns = extensionprotocol.HostAPIMethodAutomationRuns - HostAPIMethodChannelsMessagesIngest = extensionprotocol.HostAPIMethodChannelsMessagesIngest - HostAPIMethodChannelsInstancesGet = extensionprotocol.HostAPIMethodChannelsInstancesGet - HostAPIMethodChannelsInstancesReportState = extensionprotocol.HostAPIMethodChannelsInstancesReportState + HostAPIMethodSessionsList = extensionprotocol.HostAPIMethodSessionsList + HostAPIMethodSessionsCreate = extensionprotocol.HostAPIMethodSessionsCreate + HostAPIMethodSessionsPrompt = extensionprotocol.HostAPIMethodSessionsPrompt + HostAPIMethodSessionsStop = extensionprotocol.HostAPIMethodSessionsStop + HostAPIMethodSessionsStatus = extensionprotocol.HostAPIMethodSessionsStatus + HostAPIMethodSessionsEvents = extensionprotocol.HostAPIMethodSessionsEvents + HostAPIMethodMemoryRecall = extensionprotocol.HostAPIMethodMemoryRecall + HostAPIMethodMemoryStore = extensionprotocol.HostAPIMethodMemoryStore + HostAPIMethodMemoryForget = extensionprotocol.HostAPIMethodMemoryForget + HostAPIMethodObserveHealth = extensionprotocol.HostAPIMethodObserveHealth + HostAPIMethodObserveEvents = extensionprotocol.HostAPIMethodObserveEvents + HostAPIMethodSkillsList = extensionprotocol.HostAPIMethodSkillsList + HostAPIMethodAutomationJobs = extensionprotocol.HostAPIMethodAutomationJobs + HostAPIMethodAutomationJobsGet = extensionprotocol.HostAPIMethodAutomationJobsGet + HostAPIMethodAutomationJobsCreate = extensionprotocol.HostAPIMethodAutomationJobsCreate + HostAPIMethodAutomationJobsUpdate = extensionprotocol.HostAPIMethodAutomationJobsUpdate + HostAPIMethodAutomationJobsDelete = extensionprotocol.HostAPIMethodAutomationJobsDelete + HostAPIMethodAutomationJobsTrigger = extensionprotocol.HostAPIMethodAutomationJobsTrigger + HostAPIMethodAutomationJobsRuns = extensionprotocol.HostAPIMethodAutomationJobsRuns + HostAPIMethodAutomationTriggers = extensionprotocol.HostAPIMethodAutomationTriggers + HostAPIMethodAutomationTriggersGet = extensionprotocol.HostAPIMethodAutomationTriggersGet + HostAPIMethodAutomationTriggersCreate = extensionprotocol.HostAPIMethodAutomationTriggersCreate + HostAPIMethodAutomationTriggersUpdate = extensionprotocol.HostAPIMethodAutomationTriggersUpdate + HostAPIMethodAutomationTriggersDelete = extensionprotocol.HostAPIMethodAutomationTriggersDelete + HostAPIMethodAutomationTriggersRuns = extensionprotocol.HostAPIMethodAutomationTriggersRuns + HostAPIMethodAutomationTriggersFire = extensionprotocol.HostAPIMethodAutomationTriggersFire + HostAPIMethodAutomationRuns = extensionprotocol.HostAPIMethodAutomationRuns + HostAPIMethodBridgesMessagesIngest = extensionprotocol.HostAPIMethodBridgesMessagesIngest + HostAPIMethodBridgesInstancesGet = extensionprotocol.HostAPIMethodBridgesInstancesGet + HostAPIMethodBridgesInstancesReportState = extensionprotocol.HostAPIMethodBridgesInstancesReportState ) // NamedType links a generated TypeScript export name to a Go type. @@ -212,12 +212,12 @@ type AutomationTriggerFireParams struct { Payload map[string]any `json:"payload,omitempty"` } -// ChannelsMessagesIngestParams carries one normalized inbound channel message. -type ChannelsMessagesIngestParams = channelspkg.InboundMessageEnvelope +// BridgesMessagesIngestParams carries one normalized inbound bridge message. +type BridgesMessagesIngestParams = bridgepkg.InboundMessageEnvelope -// ChannelsInstancesReportStateParams reports one adapter-observed instance status update. -type ChannelsInstancesReportStateParams struct { - Status channelspkg.ChannelStatus `json:"status"` +// BridgesInstancesReportStateParams reports one adapter-observed instance status update. +type BridgesInstancesReportStateParams struct { + Status bridgepkg.BridgeStatus `json:"status"` } // SessionSummary is the lightweight host-visible session listing shape. @@ -279,11 +279,11 @@ type SkillSummary struct { // ObserveHealth is the host-visible daemon health payload. type ObserveHealth = observepkg.Health -// ChannelsMessagesIngestResult reports the resolved session association for one inbound message. -type ChannelsMessagesIngestResult struct { - SessionID string `json:"session_id"` - RouteCreated bool `json:"route_created"` - RoutingKey channelspkg.RoutingKey `json:"routing_key"` +// BridgesMessagesIngestResult reports the resolved session association for one inbound message. +type BridgesMessagesIngestResult struct { + SessionID string `json:"session_id"` + RouteCreated bool `json:"route_created"` + RoutingKey bridgepkg.RoutingKey `json:"routing_key"` } // HostAPIMethodSpecs returns the canonical Host API method registry in wire order. @@ -432,20 +432,20 @@ func HostAPIMethodSpecs() []HostAPIMethodSpec { OptionalParams: true, }, { - Method: HostAPIMethodChannelsMessagesIngest, - Params: NamedType{Name: "InboundMessageEnvelope", Value: channelspkg.InboundMessageEnvelope{}}, - Result: NamedType{Name: "ChannelsMessagesIngestResult", Value: ChannelsMessagesIngestResult{}}, + Method: HostAPIMethodBridgesMessagesIngest, + Params: NamedType{Name: "InboundMessageEnvelope", Value: bridgepkg.InboundMessageEnvelope{}}, + Result: NamedType{Name: "BridgesMessagesIngestResult", Value: BridgesMessagesIngestResult{}}, }, { - Method: HostAPIMethodChannelsInstancesGet, + Method: HostAPIMethodBridgesInstancesGet, Params: NamedType{Name: "EmptyResult", Value: EmptyResult{}}, - Result: NamedType{Name: "ChannelInstance", Value: channelspkg.ChannelInstance{}}, + Result: NamedType{Name: "BridgeInstance", Value: bridgepkg.BridgeInstance{}}, OptionalParams: true, }, { - Method: HostAPIMethodChannelsInstancesReportState, - Params: NamedType{Name: "ChannelsInstancesReportStateParams", Value: ChannelsInstancesReportStateParams{}}, - Result: NamedType{Name: "ChannelInstance", Value: channelspkg.ChannelInstance{}}, + Method: HostAPIMethodBridgesInstancesReportState, + Params: NamedType{Name: "BridgesInstancesReportStateParams", Value: BridgesInstancesReportStateParams{}}, + Result: NamedType{Name: "BridgeInstance", Value: bridgepkg.BridgeInstance{}}, }, } } diff --git a/internal/extension/contract/sdk.go b/internal/extension/contract/sdk.go index 7ef5e8d1f..6a02d4365 100644 --- a/internal/extension/contract/sdk.go +++ b/internal/extension/contract/sdk.go @@ -3,7 +3,7 @@ package contract import ( "fmt" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" "github.com/pedronauck/agh/internal/hooks" "github.com/pedronauck/agh/internal/memory" "github.com/pedronauck/agh/internal/subprocess" @@ -25,29 +25,29 @@ func SDKRootTypes() []NamedType { {Name: "InitializeCapabilities", Value: subprocess.InitializeCapabilities{}}, {Name: "InitializeMethods", Value: subprocess.InitializeMethods{}}, {Name: "InitializeRuntime", Value: subprocess.InitializeRuntime{}}, - {Name: "InitializeChannelRuntime", Value: subprocess.InitializeChannelRuntime{}}, - {Name: "InitializeChannelBoundSecret", Value: subprocess.InitializeChannelBoundSecret{}}, + {Name: "InitializeBridgeRuntime", Value: subprocess.InitializeBridgeRuntime{}}, + {Name: "InitializeBridgeBoundSecret", Value: subprocess.InitializeBridgeBoundSecret{}}, {Name: "InitializeResponse", Value: subprocess.InitializeResponse{}}, {Name: "InitializeExtensionInfo", Value: subprocess.InitializeExtensionInfo{}}, {Name: "AcceptedCapabilities", Value: subprocess.AcceptedCapabilities{}}, {Name: "InitializeSupports", Value: subprocess.InitializeSupports{}}, {Name: "ShutdownRequest", Value: subprocess.ShutdownRequest{}}, {Name: "ShutdownResponse", Value: subprocess.ShutdownResponse{}}, - {Name: "ChannelInstance", Value: channelspkg.ChannelInstance{}}, - {Name: "ChannelStatus", Value: channelspkg.ChannelStatus("")}, - {Name: "ChannelScope", Value: channelspkg.Scope("")}, - {Name: "RoutingPolicy", Value: channelspkg.RoutingPolicy{}}, - {Name: "RoutingKey", Value: channelspkg.RoutingKey{}}, - {Name: "InboundMessageEnvelope", Value: channelspkg.InboundMessageEnvelope{}}, - {Name: "DeliveryEvent", Value: channelspkg.DeliveryEvent{}}, - {Name: "DeliveryRequest", Value: channelspkg.DeliveryRequest{}}, - {Name: "DeliveryAck", Value: channelspkg.DeliveryAck{}}, - {Name: "DeliverySnapshot", Value: channelspkg.DeliverySnapshot{}}, - {Name: "DeliveryTarget", Value: channelspkg.DeliveryTarget{}}, - {Name: "DeliveryMode", Value: channelspkg.DeliveryMode("")}, - {Name: "MessageSender", Value: channelspkg.MessageSender{}}, - {Name: "MessageContent", Value: channelspkg.MessageContent{}}, - {Name: "MessageAttachment", Value: channelspkg.MessageAttachment{}}, + {Name: "BridgeInstance", Value: bridgepkg.BridgeInstance{}}, + {Name: "BridgeStatus", Value: bridgepkg.BridgeStatus("")}, + {Name: "BridgeScope", Value: bridgepkg.Scope("")}, + {Name: "RoutingPolicy", Value: bridgepkg.RoutingPolicy{}}, + {Name: "RoutingKey", Value: bridgepkg.RoutingKey{}}, + {Name: "InboundMessageEnvelope", Value: bridgepkg.InboundMessageEnvelope{}}, + {Name: "DeliveryEvent", Value: bridgepkg.DeliveryEvent{}}, + {Name: "DeliveryRequest", Value: bridgepkg.DeliveryRequest{}}, + {Name: "DeliveryAck", Value: bridgepkg.DeliveryAck{}}, + {Name: "DeliverySnapshot", Value: bridgepkg.DeliverySnapshot{}}, + {Name: "DeliveryTarget", Value: bridgepkg.DeliveryTarget{}}, + {Name: "DeliveryMode", Value: bridgepkg.DeliveryMode("")}, + {Name: "MessageSender", Value: bridgepkg.MessageSender{}}, + {Name: "MessageContent", Value: bridgepkg.MessageContent{}}, + {Name: "MessageAttachment", Value: bridgepkg.MessageAttachment{}}, {Name: "Tool", Value: tools.Tool{}}, {Name: "MemoryScope", Value: memory.Scope("")}, {Name: "HookEventFamily", Value: hooks.HookEventFamily("")}, diff --git a/internal/extension/describe_test.go b/internal/extension/describe_test.go index 18fc53996..dd50ed2e7 100644 --- a/internal/extension/describe_test.go +++ b/internal/extension/describe_test.go @@ -28,10 +28,10 @@ func TestDescribeExtension(t *testing.T) { Source: SourceUser, Enabled: true, Capabilities: CapabilitiesConfig{ - Provides: []string{"channel.adapter"}, + Provides: []string{"bridge.adapter"}, }, Actions: ActionsConfig{ - Requires: []string{"channels/messages/ingest"}, + Requires: []string{"bridges/messages/ingest"}, }, }, Status: ExtensionStatus{ diff --git a/internal/extension/host_api.go b/internal/extension/host_api.go index 0d30242ce..d84accc00 100644 --- a/internal/extension/host_api.go +++ b/internal/extension/host_api.go @@ -15,7 +15,7 @@ import ( "github.com/pedronauck/agh/internal/acp" apicontract "github.com/pedronauck/agh/internal/api/contract" automationpkg "github.com/pedronauck/agh/internal/automation" - channelspkg "github.com/pedronauck/agh/internal/channels" + bridgepkg "github.com/pedronauck/agh/internal/bridges" extensioncontract "github.com/pedronauck/agh/internal/extension/contract" "github.com/pedronauck/agh/internal/frontmatter" "github.com/pedronauck/agh/internal/memory" @@ -40,20 +40,20 @@ const ( // HostAPIMethodNotFoundCode is the JSON-RPC method-not-found code for unknown Host API methods. HostAPIMethodNotFoundCode = -32601 - defaultHostAPIRateLimit = 10 - defaultHostAPIBurst = 20 - defaultHostAPIDefaultLimit = 100 - defaultHostAPIRecallLimit = 10 - defaultHostAPIChannelIngestDedupTTL = 24 * time.Hour - defaultHostAPIChannelCleanupInterval = time.Hour - maxMemoryDescriptionLength = 160 - tagCommentPrefix = "