From 0ab9ff432486bf2b1e4f8fc2023ef1b271cf7eb3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 18:15:55 +0000 Subject: [PATCH 1/4] docs: add EGM README contracts for every code directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a README.md contract to each code directory: adapters, state, internal, examples, and scripts. Each one has Overview, Interface, Invariants, Side Effects, and Verification sections that cite the source as [file:symbol]. Directories without Go files get a short index of their children. Add a repository blueprint to the root README, before "Status": the dispatch steps, the root package's exported symbols and rules, links to each child contract, cross-package rules and side effects, and the test commands. Existing README text is kept. The one change to it fixes a broken tutorial anchor in the Linear example. _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- README.md | 229 ++++++++++++++++++ adapters/README.md | 18 ++ adapters/linear/README.md | 137 +++++++++++ adapters/slack/README.md | 110 +++++++++ adapters/slack/slackapi/README.md | 179 ++++++++++++++ .../slack/slackapi/slackapitest/README.md | 92 +++++++ examples/README.md | 25 ++ examples/linear-agent-hello-world/README.md | 104 +++++++- examples/slack-hello-world/README.md | 74 ++++++ examples/slack-nats-state/README.md | 66 +++++ examples/slack-postgres-state/README.md | 66 +++++ examples/slack-redis-state/README.md | 65 +++++ internal/README.md | 24 ++ internal/ratelimit/README.md | 49 ++++ internal/statetest/README.md | 46 ++++ internal/tokens/README.md | 31 +++ scripts/README.md | 147 +++++++++++ state/README.md | 54 +++++ state/memory/README.md | 104 ++++++++ state/nats/README.md | 129 ++++++++++ state/postgres/README.md | 141 +++++++++++ state/redis/README.md | 112 +++++++++ 22 files changed, 2001 insertions(+), 1 deletion(-) create mode 100644 adapters/README.md create mode 100644 adapters/linear/README.md create mode 100644 adapters/slack/README.md create mode 100644 adapters/slack/slackapi/README.md create mode 100644 adapters/slack/slackapi/slackapitest/README.md create mode 100644 examples/README.md create mode 100644 internal/README.md create mode 100644 internal/ratelimit/README.md create mode 100644 internal/statetest/README.md create mode 100644 internal/tokens/README.md create mode 100644 scripts/README.md create mode 100644 state/README.md create mode 100644 state/memory/README.md create mode 100644 state/nats/README.md create mode 100644 state/postgres/README.md create mode 100644 state/redis/README.md diff --git a/README.md b/README.md index 2ebaaa1..3af280c 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,235 @@ These are left out on purpose, each by a recorded decision subscriptions, dedupe marks, and locks. - **App-user auth and OAuth web flows.** Your application owns these. +## System Overview + +This section and the five after it form the repository blueprint: a map of +the code for contributors and coding agents. Each code directory has its own +`README.md` contract with Interface, Invariants, Side Effects, and +Verification sections. Citations use the form `[file:symbol]`. + +The root package `chat` is the runtime. It imports no other package from +this repository. A platform adapter in `adapters/` receives a delivery over +`Chat.Webhook` or `Chat.Listen`, normalizes it to a `chat.Event`, and calls +the runtime's `DispatchFunc` [types.go:DispatchFunc]. The runtime then +works through these steps [runtime.go:Chat.dispatch, runtime.go:Chat.prelude]: + +1. **Check.** The event must carry an `ID`, an `Adapter`, and a `ThreadID` + [runtime.go:validateEvent]. +2. **Admit.** The admission gate rejects the delivery once `Shutdown` has + started. Under `DispatchDeferred` it also rejects the delivery when + `MaxDetached` deliveries, or `MaxDetachedPerTenant` for its tenant, are + already admitted [admission.go:admissionGate.admit]. +3. **Join duplicates.** A concurrent delivery of an event ID that is already + in the prelude waits for that delivery's outcome + [runtime.go:Chat.beginEventAcceptance]. +4. **Validate.** The adapter validates the Thread ID before any lock is + taken [runtime.go:Chat.prelude]. +5. **Lock.** Under `ConcurrencyDrop` and `ConcurrencyQueue`, + `State.AcquireLock` takes the Thread Lock here. `ConcurrencyDebounce` and + `ConcurrencyBurst` take it later, and `ConcurrencyConcurrent` takes none + [runtime.go:Chat.prelude]. +6. **Route.** Commands go to `OnCommand` and interactions to `OnInteraction`. + A message goes to `OnSubscribedMessage` when its thread is subscribed, + else to `OnNewMention` when it is a mention or a direct message + [runtime.go:Chat.route]. +7. **Mark.** When the prelude knows the outcome (dropped on a lock conflict, + ignored, or routed), `State.MarkEvent` records the event ID for + `DedupeTTL`. A duplicate stops here [runtime.go:Chat.prelude, + runtime.go:Chat.routedWork, runtime.go:Chat.markAcceptedEvent]. +8. **Run.** `DispatchSync` runs the handler before the adapter acknowledges. + `DispatchDeferred` acknowledges first and runs the handler on a detached + context bounded by `DetachTimeout` [runtime.go:Chat.startDetachedTail]. + +Handlers reply through `Thread.Post`, which calls `Adapter.PostMessage` +[thread.go:Thread.Post]. The runtime keeps its coordination data in a +`chat.State` backend from `state/` [state.go:State]. + +## Local Interface & Invariants + +The root directory holds package `chat`. Exported symbols by file: + +| File | Symbols | +| --- | --- | +| `runtime.go` | `New`, `Option`, `WithState`, `WithAdapter`, `WithLogger`, `WithRuntimeOptions`, `RuntimeOptions`, `DefaultRuntimeOptions`, `ConcurrencyStrategy` (`ConcurrencyDrop`, `ConcurrencyQueue`, `ConcurrencyDebounce`, `ConcurrencyConcurrent`, `ConcurrencyBurst`), `LockScope` (`LockScopeThread`, `LockScopeChannel`), `DispatchMode` (`DispatchSync`, `DispatchDeferred`), `Chat` (`OnNewMention`, `OnSubscribedMessage`, `OnCommand`, `OnInteraction`, `Webhook`, `Listen`, `Thread`, `Shutdown`), `AdapterAs` | +| `types.go` | `Adapter`, `DispatchFunc`, `Event`, `Message`, `MessageEvent`, `MessageHandler`, `Command`, `CommandEvent`, `CommandHandler`, `Interaction`, `InteractionKind` (`InteractionUnknown`, `InteractionBlockAction`), `InteractionEvent`, `InteractionHandler`, `Actor`, `BotKind` (`BotUnknown`, `BotHuman`, `BotBot`), `ThreadID`, `ThreadRef`, `RetryMetadata`, `PostableMessage`, `MessageFormat` (`MessageFormatText`, `MessageFormatMarkdown`), `Text`, `Markdown`, `SentMessage`, and the Optional Capabilities `Listener`, `HistoryReader` (`HistoryQuery`), `EphemeralPoster` (`EphemeralOptions`), `NativeContentPoster` (`NativeContent`) | +| `state.go` | `State` (`IsThreadSubscribed`, `SubscribeThread`, `UnsubscribeThread`, `MarkEvent`, `AcquireLock`, `ExtendLock`, `ReleaseLock`, `Shutdown`), `LockLease` | +| `thread.go` | `Thread` (`ID`, `Subscribe`, `Unsubscribe`, `Post`, `PostEphemeral`) | +| `install.go` | `InstallStore` (`Lookup`), `Install`, `ErrInstallNotFound` | +| `errors.go` | `ErrUnsupportedCapability`, `ErrPreempted`, `ErrAdmissionRejected` | +| `observer.go` | `Observer`, `DispatchSpan`, `WithObserver`, `ObservationName` and the `Obs*` constants, `DispatchOutcome` and the `Outcome*` constants, `Attr` with the `Attr*` keys and `*Attr` builders | +| `admission.go`, `burst.go` | No exported symbols. They hold the admission gate and burst batching. | +| `doc.go` | Package documentation (`go doc github.com/coder/chat`) | + +Invariants: + +- `New` fails without a `State`, without an adapter, without a logger, on a + nil option or adapter, or on an empty or duplicate adapter name. It calls + `Adapter.Init` for each adapter in registration order. An `Init` error + fails `New`, which then shuts down every adapter registered so far, + including the one that failed [runtime.go:New]. +- `validateRuntimeOptions` rejects these options + [runtime.go:validateRuntimeOptions]: + - a non-positive `DedupeTTL` or `ThreadLockTTL`; + - under `ConcurrencyDebounce`: a non-positive `DebounceInterval`, + `DispatchSync`, or a `DetachTimeout` that does not exceed + `DebounceInterval`; + - under `ConcurrencyConcurrent`: a non-positive `MaxConcurrent`; + - under `ConcurrencyBurst`: a non-positive `BurstWindow`, a negative + `MaxBurstBatch`, or `DispatchSync`; + - under `DispatchDeferred`: a non-positive `DetachTimeout` or + `MaxDetached`, or a negative `MaxDetachedPerTenant`; + - an unknown `ConcurrencyStrategy`, `LockScope`, or `DispatchMode` value. +- The defaults are `DedupeTTL` 24h, `ThreadLockTTL` 2m, `ConcurrencyDrop`, + `DispatchSync`, `LockScopeThread`, and `MaxDetached` 1024 + [runtime.go:DefaultRuntimeOptions]. +- Each hook holds one handler. A later `On*` call replaces it atomically + under `handlersMu` [runtime.go:Chat.OnNewMention]. +- An `Event` must carry an `ID`, an `Adapter`, and a `ThreadID` + [runtime.go:validateEvent], and it sets at most one of `Message`, + `Command`, or `Interaction` [types.go:Event]. +- A rejected delivery returns `ErrAdmissionRejected` before acknowledgement + and is never marked, so the platform's retry is not deduped away + [runtime.go:Chat.dispatch, errors.go:ErrAdmissionRejected]. +- A failed prelude also leaves the event unmarked [runtime.go:Chat.prelude]. +- An accepted event is acknowledged even when its handler returns an error. + The runtime logs and observes the error and does not retry it + [runtime.go:Chat.dispatchSync, doc.go]. +- The runtime ignores messages, commands, and interactions from the + adapter's own bot actor [runtime.go:isSelfActor]. +- While a locked handler runs, the runtime renews the lease every + `ThreadLockTTL/2`. A lost lease cancels the handler with `ErrPreempted` as + the cause [runtime.go:Chat.startLockRefresh, errors.go:ErrPreempted]. +- Lock waiters poll `State.AcquireLock` every `ThreadLockTTL/20`, clamped to + 1ms–1s, because `State` does not signal a release + [runtime.go:Chat.pollForLock]. +- `Shutdown` closes admission, cancels deferred work, drains it, stops + listeners, shuts down the adapters, and then shuts down `State`. A second + call waits for the first [runtime.go:Chat.Shutdown]. +- `Thread.Post` rejects an empty `Text` and does not subscribe the thread + [thread.go:Thread.Post, doc.go]. +- `Thread.PostEphemeral` rejects an empty actor ID or `Text`. It returns + `ErrUnsupportedCapability` when the adapter does not implement + `EphemeralPoster` [thread.go:Thread.PostEphemeral]. +- `Chat.Webhook` and `Chat.Listen` return an error for an adapter name that + is not registered. `Chat.Listen` returns `ErrUnsupportedCapability` for an + adapter that does not implement `Listener`. After `Shutdown` has started, + `Chat.Listen` returns nil at once and does not call the adapter + [runtime.go:Chat.Webhook, runtime.go:Chat.Listen]. +- `Observer` calls are best-effort: the runtime recovers a panic and logs it + at Warn [observer.go:safeEvent, observer.go:recoverObserver]. Default + attributes never include Thread IDs, message text, or raw actor IDs + [observer.go:AttrAdapter]. +- Under `ConcurrencyBurst`, one runner serves each scope. The window starts + at the first member's join and seals at `BurstWindow` or `MaxBurstBatch` + [burst.go:burstScope, burst.go:joinBurstBatch]. + +Side effects: + +- Starts goroutines for each detached tail [runtime.go:Chat.startDetachedTail], + each lock refresh ticker [runtime.go:Chat.startLockRefresh], each burst + scope runner [burst.go:joinBurstBatch], and each bounded wait + [runtime.go:waitBounded]. +- Calls `State.MarkEvent`, `AcquireLock`, `ExtendLock`, `ReleaseLock`, and + `IsThreadSubscribed` [runtime.go]. Lock release uses + `context.WithoutCancel`, so it still runs after the caller's context ends + [runtime.go:Chat.releaseLock]. `Thread.Subscribe` and `Thread.Unsubscribe` + write subscriptions [thread.go:Thread.Subscribe]. +- Calls the adapter methods `Init`, `Webhook`, `ValidateThreadID`, + `PostMessage`, and `Shutdown`, plus `Listener.Listen` for adapters that + implement it [runtime.go:New, runtime.go:Chat.Listen, thread.go:Thread.Post]. +- Logs through `slog` and calls the `Observer` synchronously on the dispatch + path [observer.go:safeEvent, observer.go:safeDispatch]. + +## Subdirectories & System Boundaries + +- **adapters**: [adapters/README.md](./adapters/README.md) - The Linear and + Slack platform adapters. Both verify webhook signatures, support + single-install and multi-tenant (`InstallStore`) modes, implement + `chat.HistoryReader`, and retry rate-limited calls only within the + caller's deadline. Slack's low-level Web API and Socket Mode client is + `adapters/slack/slackapi`, with test fakes in `slackapitest`. +- **state**: [state/README.md](./state/README.md) - The four `chat.State` + backends: `memory` (root module, in-process) and `nats`, `postgres`, and + `redis` (each a separate Go module). All four run the + `internal/statetest` conformance suite. +- **internal**: [internal/README.md](./internal/README.md) - Private helpers: + `ratelimit` (retry mechanics for the adapters), `statetest` (the `State` + conformance suite), and `tokens` (random lock lease tokens). +- **examples**: [examples/README.md](./examples/README.md) - Five runnable + bots: `slack-hello-world` and `linear-agent-hello-world` with in-memory + state, and three Slack bots on NATS, Postgres, and Redis state, each a + separate module with `compose.yaml` and `pitchfork.toml`. +- **scripts**: [scripts/README.md](./scripts/README.md) - + `clawpatch-autofix.sh`, the developer tool behind + `mise run clawpatch:autofix`. +- **docs**: Prose documentation, not a code contract. See + [Documentation](#documentation). + +## Global Invariants + +- Dependencies point inward. `adapters/*` and `state/*` import the root + `chat` package, and the root package imports neither + [runtime.go, types.go, state.go]. +- `State` holds coordination data only: thread subscriptions, dedupe marks, + and lock leases. The runtime never creates one silently [doc.go, + runtime.go:New]. +- Every backend must pass `RunStateConformance`. The first `MarkEvent` for + an ID within its TTL wins. Only the lease token's owner can extend or + release a lock, and a stale token gets `false` with no error. A cancelled + context changes no state [internal/statetest/state.go:RunStateConformance]. +- A Thread ID starts with the adapter name and a colon + [runtime.go:adapterNameFromThreadID]. The Slack adapter uses `slack:v1:` + [adapters/slack/slack.go:decodeThreadID], and the Linear adapter uses + `linear:v1:` [adapters/linear/linear.go:decodeThreadID]. +- An adapter turns `ErrAdmissionRejected` into a retryable response: HTTP + 503 from Linear [adapters/linear/linear.go:Webhook] and `ErrBusy` from + Slack, which `slackapi.Webhook` serves as 503 + [adapters/slack/slack.go:deliveryHandler, + adapters/slack/slackapi/webhook.go:Webhook.ServeHTTP]. The exception is a + Slack slash command: Slack does not redeliver it, so the adapter acks it + with a busy reply instead [adapters/slack/slack.go:deliveryHandler]. +- `InstallStore.Lookup` runs on every inbound webhook, with no cache in the + adapter. `ErrInstallNotFound` makes the delivery an Ignored Event + [install.go:InstallStore, install.go:ErrInstallNotFound]. +- Adapter rate-limit retry never sleeps past the caller's deadline and ends + in a typed `RateLimited` error. Both adapters share the mechanics in + `internal/ratelimit` [internal/ratelimit/ratelimit.go:SleepCtx]. +- `go.work` adds six separate modules to the root module: `state/nats`, + `state/postgres`, `state/redis`, and the three `examples/slack-*-state` + bots. Its `replace` directives point the root and the three state module + paths at their local directories [go.work]. + +## Global Side Effects + +- **Outbound network:** the Slack Web API and Socket Mode websockets + (`adapters/slack/slackapi`), and Linear GraphQL plus its OAuth token + endpoint (`adapters/linear`). +- **Inbound HTTP:** the application serves the `http.Handler` that + `Chat.Webhook` returns. Adapters verify each request's signature before + they dispatch it [adapters/slack/slackapi/webhook.go:Webhook.ServeHTTP, + adapters/linear/linear.go:Webhook]. +- **Persistent state:** NATS JetStream KV buckets `_sub`, + `_event`, and `_lock`. Postgres tables + `chat_runtime_subscriptions`, `chat_runtime_events`, and + `chat_runtime_locks`, which `postgres.New` creates. Redis keys + `:sub:`, `:event:`, and `:lock:`. +- **Caller-owned clients:** `Chat.Shutdown` calls `State.Shutdown`, which + closes the NATS connection, pgx pool, or Redis client that the caller + passed in. +- **Observability:** `slog` logs and the optional `Observer`. Adapters also + emit `ObsAdapterCall` and `ObsRateLimit`. +- **Developer tooling:** `scripts/clawpatch-autofix.sh` runs Codex with the + sandbox disabled and creates git commits. + +## System Verification + +- Run: `mise run test`. It tests the root module, the three backend + modules, and the three state example modules. The Postgres and Redis + backend tests use Testcontainers and need Docker. +- Run: `go test ./...` for the root module only. +- Run: `mise run vet` for `go vet` across every module. + ## Status Chat SDK Go is pre-1.0, and the public API may change before 1.0. See the diff --git a/adapters/README.md b/adapters/README.md new file mode 100644 index 0000000..850efc7 --- /dev/null +++ b/adapters/README.md @@ -0,0 +1,18 @@ +# adapters + +## Overview + +The `adapters` directory groups the platform adapters of Chat SDK Go (`github.com/coder/chat`). It has no Go source files of its own. Each child package connects the portable `chat` runtime (root package `github.com/coder/chat`) to one chat platform: it receives platform webhooks or connections, normalizes them into `chat.Event` values, and posts replies through the platform API. + +Both adapters share these traits, from their own contracts: + +1. Each adapter runs in single-install mode (static credentials) or multi-tenant mode (`chat.InstallStore`, looked up per request with no caching). +2. Each adapter verifies the signature of inbound webhooks before dispatch. +3. Each adapter retries platform rate limits within a `RetryPolicy`, never sleeps past the caller's context deadline, and returns a typed `*RateLimited` error when retry stops. +4. Each adapter emits `chat.ObsAdapterCall` and `chat.ObsRateLimit` events to the configured observer. +5. Each adapter implements `chat.HistoryReader` as a live read with no storage or caching. + +## Subdirectories & Boundaries + +- linear: [./linear/README.md](./linear/README.md) - Linear app-actor adapter. It handles agent-session and issue-comment webhooks, posts agent activities and comments through the Linear GraphQL API, and owns its own rate-limit retry. +- slack: [./slack/README.md](./slack/README.md) - Slack adapter. It receives deliveries over HTTP webhooks and Socket Mode, and posts messages, ephemeral messages, Block Kit content, and modals. It contains the low-level `slackapi` package ([./slack/slackapi/README.md](./slack/slackapi/README.md)), which holds the Slack Web API client, transports, and rate-limit retry, and its `slackapitest` test fakes ([./slack/slackapi/slackapitest/README.md](./slack/slackapi/slackapitest/README.md)). diff --git a/adapters/linear/README.md b/adapters/linear/README.md new file mode 100644 index 0000000..d748dad --- /dev/null +++ b/adapters/linear/README.md @@ -0,0 +1,137 @@ +# linear + +## Overview + +Package `linear` (`github.com/coder/chat/adapters/linear`) is the Linear app-actor adapter for the `chat` runtime (root package `github.com/coder/chat`). The bot acts as a Linear app-owned actor on App-Actor Client Credentials. [doc.go:linear] + +It supports two Linear interaction models, both reached through `chat.Adapter` and an opaque, versioned Thread ID: [doc.go:linear] + +1. Agent sessions. The bot receives `AgentSessionEvent` `created` and `prompted` webhooks. It posts the five Linear agent activity types: thought, response, action, elicitation, and error. +2. Issue comments. The bot receives Linear `Comment` webhooks and posts ordinary issue comments. + +The adapter runs in one of two modes. Single-install mode uses a static `Options.WebhookSecret` and `Options.ClientCredentials` for one organization. Multi-tenant mode uses `Options.InstallStore` (`chat.InstallStore`) and reads the webhook secret and credentials from the install record of each organization. [doc.go:linear], [linear.go:New] + +The adapter owns its own rate-limit retry (`RetryPolicy`) and Linear-specific extras (agent activities, session updates, proactive sessions, repository suggestions, and a raw GraphQL escape hatch). Callers reach the extras through `chat.AdapterAs`. [doc.go:linear] + +## Interface + +### Construction and types + +- `New` validates `Options`, sets defaults, and returns an `Adapter`. [linear.go:New] +- `Options` holds `WebhookSecret`, `ClientCredentials`, `InstallStore`, `APIBaseURL`, `Client`, `Now`, `SignatureTolerance`, `Logger`, `RetryPolicy`, and `Observer`. [linear.go:Options] +- `ClientCredentials` holds `ClientID`, `ClientSecret`, and `Scopes`. [linear.go:ClientCredentials] +- `LinearInstall` is the install credential (`WebhookSecret`, `ClientCredentials`, `AccessToken`, `BotUserID`) carried on `chat.Install.Credential`. [linear.go:LinearInstall] +- `Adapter` is the Linear adapter. It implements `chat.Adapter` and `chat.HistoryReader`. [linear.go:Adapter], [history.go:HistoryReader assertion] +- `RetryPolicy` sets `MaxAttempts`, `MaxElapsed`, `BaseDelay`, and `MaxDelay`. [retry.go:RetryPolicy] +- `RateLimited` is the error returned when throttling retry stops (`Adapter`, `RetryAfter`, `Attempts`, `Raw`, `Err`). [retry.go:RateLimited] + +### chat.Adapter methods + +- `Adapter.Name` returns `"linear"`. [linear.go:Name] +- `Adapter.Init` exchanges a token and fetches the viewer identity in single-install mode. It does nothing in multi-tenant mode. [linear.go:Init] +- `Adapter.Shutdown` returns nil. [linear.go:Shutdown] +- `Adapter.Webhook` returns the `http.Handler` for inbound Linear webhooks, for a `chat.DispatchFunc`. [linear.go:Webhook] +- `Adapter.ValidateThreadID` decodes a Thread ID into a `chat.ThreadRef`. [linear.go:ValidateThreadID] +- `Adapter.PostMessage` posts an issue comment on a comment thread, or a response activity on an agent-session thread. [linear.go:PostMessage] +- `Adapter.BotActor` returns the bot as a `chat.Actor`. [linear.go:BotActor] +- `Adapter.ReadHistory` reads agent activities or comment replies, newest first. [history.go:ReadHistory] + +### Agent activities + +- `Adapter.PostThought` posts an ephemeral thought activity. [linear.go:PostThought] +- `Adapter.CreateAgentActivity` posts any agent activity from an `AgentActivityInput`. [activity.go:CreateAgentActivity,AgentActivityInput] +- `Adapter.PostAction` posts an action activity from an `ActionInput`. [activity.go:PostAction,ActionInput] +- `Adapter.PostElicitation` posts an elicitation activity from an `ElicitationInput`. [activity.go:PostElicitation,ElicitationInput] +- `Adapter.PostError` posts an error activity from an `ErrorInput`. [activity.go:PostError,ErrorInput] +- `AuthSignalMetadata`, `SelectSignalMetadata`, and `SelectOption` are elicitation signal metadata types. [activity.go:AuthSignalMetadata,SelectSignalMetadata,SelectOption] + +### Sessions, suggestions, and GraphQL + +- `Adapter.UpdateSession` sets external URLs and the plan from an `AgentSessionUpdateInput`. [session.go:UpdateSession,AgentSessionUpdateInput] +- `ExternalURL` and `PlanStep` are session update types. [session.go:ExternalURL,PlanStep] +- `Adapter.CreateSessionOnIssue` and `Adapter.CreateSessionOnIssueForTenant` create a proactive session on an issue. [session.go:CreateSessionOnIssue,CreateSessionOnIssueForTenant] +- `Adapter.CreateSessionOnComment` and `Adapter.CreateSessionOnCommentForTenant` create a proactive session on a comment. [session.go:CreateSessionOnComment,CreateSessionOnCommentForTenant] +- `CreateSessionOnIssueInput`, `CreateSessionOnCommentInput`, and `CreatedAgentSession` are the proactive session types. [session.go:CreateSessionOnIssueInput,CreateSessionOnCommentInput,CreatedAgentSession] +- `Adapter.SuggestRepositories` ranks `CandidateRepository` values into `RepositorySuggestion` values for a thread's issue. [suggestions.go:SuggestRepositories,CandidateRepository,RepositorySuggestion] +- `Adapter.GraphQL` and `Adapter.GraphQLForTenant` send a raw GraphQL request. [session.go:GraphQL,GraphQLForTenant] + +### Platform escape hatch + +- `RawMessage` is the Linear payload on `chat.Message.Raw`. [raw.go:RawMessage] +- `RawMessageFrom` extracts a `*RawMessage` from a `chat.Message`. [raw.go:RawMessageFrom] +- `RawMessage.StopRequested` reports the human-to-agent `stop` signal. It is nil-safe. [raw.go:RawMessage.StopRequested] +- `RawAgentSession` holds session context. `FirstThoughtDeadline` holds the first-thought timing. [raw.go:RawAgentSession,FirstThoughtDeadline] + +## Invariants + +### Construction + +- Multi-tenant mode (`InstallStore` set) rejects a static `WebhookSecret` or `ClientCredentials`. [linear.go:New] +- Single-install mode requires `WebhookSecret`, `ClientCredentials.ClientID`, and `ClientCredentials.ClientSecret`. [linear.go:New] +- `New` rejects a negative `SignatureTolerance`. Zero uses `defaultSignatureTolerance` (1 minute). [linear.go:New,defaultSignatureTolerance] +- Empty scopes default to `read`, `write`, `app:mentionable`, and `app:assignable`. [linear.go:New,defaultClientCredentialScopes] +- `RetryPolicy` defaults for zero fields: `MaxAttempts` 3, `MaxElapsed` 3s, `BaseDelay` 200ms, `MaxDelay` 1s. `MaxAttempts` 1 turns retry off. [retry.go:RetryPolicy.withDefaults], [doc.go:linear] +- Single-install `Init` requires a non-empty organization ID and app user ID from the viewer query. [linear.go:Init] +- `Init`, `Webhook`, `PostThought`, `BotActor`, `ReadHistory`, and the activity, session, suggestion, and GraphQL methods panic on a nil `*Adapter` through `assertAdapter`. [linear.go:assertAdapter,Init,Webhook,PostThought,BotActor], [history.go:ReadHistory], [activity.go:CreateAgentActivity], [session.go:GraphQL], [suggestions.go:SuggestRepositories] + +### Webhooks + +- `Webhook` accepts only POST (else 405) and bodies up to 1 MiB (else 413). [linear.go:Webhook,maxWebhookBodyBytes] +- An unreadable body returns 400 `read body`. After the signature check, a body that does not decode into the webhook envelope returns 400 `invalid linear payload`. [linear.go:Webhook] +- The `Linear-Signature` header must be the hex HMAC-SHA256 of the raw body. The adapter checks it before it decodes the event envelope or dispatches (else 401). In multi-tenant mode, the unverified routing read in the next rule comes first. [linear.go:verifySignatureWithSecret,Webhook,resolveWebhookInstall] +- `webhookTimestamp` must be non-zero and within the signature tolerance (else 401). [linear.go:verifyTimestamp] +- Multi-tenant mode reads `organizationId` from the unverified body for routing only. It then verifies the signature with the install record's webhook secret. [doc.go:linear], [linear.go:resolveWebhookInstall] +- In multi-tenant mode, a body with no organization or a `chat.ErrInstallNotFound` lookup is an Ignored Event (200, not dispatched). Other lookup errors return 500. [linear.go:resolveWebhookInstall] +- `chat.ErrAdmissionRejected` from dispatch returns 503, so Linear redelivers. Other dispatch errors return 500. [linear.go:Webhook] +- An agent-session event needs an organization, a session ID, and an issue ID. A `prompted` event needs `agentActivity`. [linear.go:normalizeAgentSessionEvent] +- The adapter drops agent-session and comment events whose organization or OAuth client ID conflicts with the resolved install. [linear.go:normalizeAgentSessionEvent], [comments.go:normalizeCommentEvent] +- A comment event needs a comment ID, an organization, an issue ID, and an author. [comments.go:normalizeCommentEvent] +- Multi-tenant mode drops events that the bot authored. [linear.go:normalizeAgentSessionEvent], [comments.go:normalizeCommentEvent] +- Event IDs have the form `linear::message:`. [linear.go:normalizeAgentSessionEvent], [comments.go:normalizeCommentEvent] + +### Tokens and tenants + +- The adapter refreshes a token when it is empty or within 1 hour of expiry. [linear.go:ensureToken,tokenRefreshBuffer] +- A token response without a positive `expires_in` gets no expiry, so the adapter never refreshes that token. This holds for the single-install token and for cached per-tenant tokens. [linear.go:exchangeClientCredentials,ensureToken,tenantToken] +- A client-credentials response must contain `access_token` and grant every requested scope. [linear.go:exchangeClientCredentials,verifyGrantedScopes] +- An install credential must be a `LinearInstall` or a non-nil `*LinearInstall`. A multi-tenant install needs a webhook secret. A pre-supplied `AccessToken` is used as is. [linear.go:linearCredential,resolveWebhookInstall,tenantToken] +- `GraphQL`, `CreateSessionOnIssue`, and `CreateSessionOnComment` fail in multi-tenant mode. Use the `ForTenant` forms. [session.go:GraphQL,CreateSessionOnIssue,CreateSessionOnComment] +- `GraphQLForTenant` requires a non-empty query and never returns the access token. [session.go:GraphQLForTenant] + +### Thread IDs and posting + +- A Thread ID must start with `linear:v1:`. Its payload needs `org` and `issue`, plus `session` for agent-session threads or `comment` for comment threads. An empty kind means agent session. [linear.go:decodeThreadID,threadPayload.validate,threadPayload.kind] +- Single-install mode rejects a Thread ID from another organization. [linear.go:validateThreadPayload] +- Agent activity operations fail on a non-agent-session thread. [linear.go:agentSessionPayload] +- `PostMessage` requires non-empty text and `chat.MessageFormatText` or `chat.MessageFormatMarkdown`. `PostThought` requires non-blank text. [linear.go:validatePostableMessage,PostThought] +- `Ephemeral` is valid only for thought and action activities. [linear.go:createAgentActivity] +- An activity or comment mutation must report `success` and a non-empty ID, else the call fails. [linear.go:createAgentActivity], [comments.go:createIssueComment] +- `CreateAgentActivity` rejects empty `Content`. `PostAction` rejects a blank `Action`. `PostElicitation` and `PostError` reject a blank `Body`. [activity.go:CreateAgentActivity,PostAction,PostElicitation,PostError] +- `UpdateSession` rejects an empty update. [session.go:UpdateSession] +- `SuggestRepositories` requires at least one candidate, and each candidate needs `Hostname` and `RepositoryFullName`. [suggestions.go:SuggestRepositories] + +### History and retry + +- `ReadHistory` uses 50 when `Limit <= 0` and caps `Limit` at 250. [history.go:ReadHistory,linearDefaultHistoryLimit,linearMaxHistoryLimit] +- `ReadHistory` does no storage, dedupe, or caching. [history.go:ReadHistory], [doc.go:linear] +- A `Before` cursor resolves to a `createdAt` filter. An unresolved cursor is an error. [history.go:historyCursorFilter] +- Retry runs on HTTP 429 or a throttling GraphQL error. It never sleeps past the request context deadline, and returns `*RateLimited` instead. [retry.go:doJSONWithRetry,rateLimitedResponse] +- Retry also returns `*RateLimited` after `MaxAttempts`, or when the next delay would push the total past `MaxElapsed`. Each delay is the exponential backoff, raised to the platform's Retry-After, then capped at `MaxDelay`. [retry.go:doJSONWithRetry], [internal/ratelimit/ratelimit.go:BackoffDelay] +- The first-thought budget is 10 seconds from session creation. The adapter reports the deadline and runs no watchdog. [raw.go:firstThoughtBudget], [doc.go:linear] +- When the session timestamp does not parse, `newFirstThoughtDeadline` uses `now()` as the creation time. Its doc comment says it returns nil in that case, so the doc comment and the code disagree. [raw.go:newFirstThoughtDeadline] + +## Side Effects + +- The adapter POSTs to `{APIBaseURL}/oauth/token` (default `https://api.linear.app`) for the client-credentials grant. [linear.go:exchangeClientCredentials,defaultAPIBaseURL] +- All GraphQL calls POST to `{APIBaseURL}/graphql` with a bearer token through `Options.Client` (default `http.DefaultClient`). [linear.go:callGraphQLWithToken,New] +- GraphQL operations: `ViewerIdentity` at `Init`, `AgentActivityCreate` for activities, `CommentCreate` for comments, `AgentSessionUpdate`, `AgentSessionCreateOnIssue`, `AgentSessionCreateOnComment`, `IssueRepositorySuggestions`, and history queries. [linear.go:fetchIdentity,createAgentActivity], [comments.go:createIssueComment], [session.go:UpdateSession,createSessionOnIssue,createSessionOnComment], [suggestions.go:SuggestRepositories], [history.go:readAgentSessionHistory,readCommentThreadHistory] +- Multi-tenant mode calls `InstallStore.Lookup` on every webhook and every outbound token resolution. It does not cache install records. [linear.go:resolveWebhookInstall,resolveToken] +- A mutex (`a.mu`) guards the access token, expiry, organization, and bot identity. A second mutex guards the per-tenant token cache. [linear.go:Adapter,tenantToken] +- `Webhook` calls the runtime `chat.DispatchFunc` for each normalized event. [linear.go:Webhook] +- Retry sleeps with `ratelimit.SleepCtx` from `internal/ratelimit`, and resends a buffered copy of the request body. [retry.go:doJSONWithRetry] +- The adapter emits `chat.ObsAdapterCall` per attempt and `chat.ObsRateLimit` per throttle to `Options.Observer`. [retry.go:doJSONWithRetry] +- The adapter logs a `linear rate limited` warning on throttles, warnings for dropped events, and debug records for ignored webhooks. [retry.go:logRetry], [linear.go:Webhook,normalizeAgentSessionEvent], [comments.go:normalizeCommentEvent] + +## Verification + +- Run: `go test ./adapters/linear/...` diff --git a/adapters/slack/README.md b/adapters/slack/README.md new file mode 100644 index 0000000..1f53b1b --- /dev/null +++ b/adapters/slack/README.md @@ -0,0 +1,110 @@ +# slack + +## Overview + +Package `slack` (`github.com/coder/chat/adapters/slack`) is the Slack adapter for the `chat` runtime (root package `github.com/coder/chat`). [doc.go:slack] + +1. It receives Slack deliveries over HTTP (`Adapter.Webhook`), over Socket Mode (`Adapter.Listen`), or over both at the same time. One delivery handler serves both transports, so a payload becomes the same `chat.Event` on either transport. [doc.go:slack], [slack.go:deliveryHandler] +2. It posts messages, ephemeral messages, Block Kit content, modals, and `response_url` replies. [slack.go:PostMessage,PostEphemeralMessage], [interactive.go:PostNative,OpenModal,RespondURL] +3. It reads live history through `Adapter.ReadHistory`. [history.go:ReadHistory] + +The adapter runs in one of two modes. Single-install mode uses a static `Options.BotToken` for one workspace. Multi-tenant mode uses `Options.InstallStore` (`chat.InstallStore`) and looks up a per-workspace bot token by `team_id`. [doc.go:slack] + +The adapter sends every Slack Web API call and every `response_url` post through the child package `slackapi`, which owns rate-limit retry. [doc.go:slack] + +## Interface + +### Construction and types + +- `New` builds an `Adapter` from `Options` after validation. [slack.go:New] +- `Options` holds tokens (`SigningSecret`, `AppToken`, `BotToken`), team and bot IDs, `InstallStore`, `APIBaseURL`, `Client`, `Now`, `SignatureTolerance`, `DisableNativeEphemeral`, `Logger`, `Observer`, and `RetryPolicy`. [slack.go:Options] +- `SlackInstall` is the per-workspace credential (`BotToken`, `BotUserID`) carried on `chat.Install.Credential`. [slack.go:SlackInstall] +- `Adapter` is the Slack adapter. [slack.go:Adapter] +- `RetryPolicy` is a type alias of `slackapi.RetryPolicy`. [retry.go:RetryPolicy] +- `RateLimited` is a type alias of `slackapi.RateLimited`. [retry.go:RateLimited] + +### Adapter methods + +- `Adapter.Name` returns `"slack"`. [slack.go:Name] +- `Adapter.Init` discovers the bot identity with `auth.test` in single-install mode, unless `TeamID`, `BotUserID`, and `BotID` are all configured. It does nothing in multi-tenant mode. [slack.go:Init] +- `Adapter.Shutdown` returns nil. [slack.go:Shutdown] +- `Adapter.Webhook` returns the HTTP transport handler for a `chat.DispatchFunc`. [slack.go:Webhook] +- `Adapter.Listen` runs the Socket Mode transport. It implements `chat.Listener`. [slack.go:Listen] +- `Adapter.ValidateThreadID` decodes a Thread ID into a `chat.ThreadRef`. [slack.go:ValidateThreadID] +- `Adapter.PostMessage` posts a message to a thread. [slack.go:PostMessage] +- `Adapter.PostEphemeralMessage` posts an ephemeral message, with an optional DM fallback. It implements `chat.EphemeralPoster`. [slack.go:PostEphemeralMessage], [interactive.go:EphemeralPoster assertion] +- `Adapter.BotActor` returns the bot as a `chat.Actor`. [slack.go:BotActor] +- `Adapter.ReadHistory` reads thread replies or DM history, newest first. It implements `chat.HistoryReader`. [history.go:ReadHistory] +- `Adapter.PostNative` posts Block Kit blocks to a thread. It implements `chat.NativeContentPoster`. [interactive.go:PostNative] +- `Adapter.OpenModal` and `Adapter.OpenModalForTenant` open a modal with a `trigger_id`. [interactive.go:OpenModal,OpenModalForTenant] +- `Adapter.OpenModalFromRaw` and `Adapter.OpenModalForTenantFromRaw` open a modal with the `trigger_id` from a raw inbound payload. [interactive.go:OpenModalFromRaw,OpenModalForTenantFromRaw] +- `Adapter.RespondURL` posts an ephemeral reply to the `response_url` from a raw inbound payload. [interactive.go:RespondURL] + +The code has compile-time assertions for `chat.Listener`, `chat.HistoryReader`, `chat.NativeContentPoster`, and `chat.EphemeralPoster`. [slack.go:Listener assertion], [history.go:HistoryReader assertion], [interactive.go:NativeContentPoster assertion] + +### Subdirectories + +- [slackapi](./slackapi/README.md): low-level Slack Web API client, inbound transports (`Webhook`, Socket Mode `Listen`), payload decoding, and formats. + +## Invariants + +### Construction + +- `New` requires `SigningSecret` or `AppToken`. [slack.go:New] +- `New` requires exactly one of `BotToken` or `InstallStore`. Both, or neither, is an error. [slack.go:New], [doc.go:slack] +- `New` rejects a negative `SignatureTolerance` and uses `slackapi.DefaultSignatureTolerance` when it is zero. [slack.go:New] +- `Init` requires non-empty `team_id`, `user_id`, and `bot_id` from `auth.test`. Each must match any configured value. [slack.go:Init,requireMatchingAuthField] +- Without a `SigningSecret`, `Webhook` answers 404 to each request. Without an `AppToken`, `Listen` returns an error. [slack.go:Webhook,Listen] + +### Inbound deliveries + +- When the runtime rejects a delivery with `chat.ErrAdmissionRejected`, the adapter acks a slash command with a busy reply. It returns a wrapped `slackapi.ErrBusy` for other deliveries, so Slack does not get an ack. [slack.go:deliveryHandler] +- An unknown delivery type, a non-`event_callback` envelope, and an interaction type other than `block_actions` are Ignored Events. [slack.go:normalizeDelivery,normalizeEvent], [interactive.go:normalizeInteraction] +- A malformed payload returns an error that unwraps to `slackapi.ErrInvalidPayload`. [slack.go:invalidPayload] +- The adapter accepts `app_mention` events, and `message` events with an empty or `bot_message` subtype. [slack.go:supportedMessageEvent] +- A message event needs envelope `team_id` and `event_id`, and event channel, `ts`, and an author ID. [slack.go:messageEvent] +- A slash command needs `command`, `team_id`, `channel_id`, `user_id`, and `trigger_id`. Its event ID is deterministic. [interactive.go:commandEvent,commandEventID] +- An interaction's event ID joins team, channel, user, action ID, and the first non-empty value of `action_ts`, `trigger_id`, the message ts, and the thread ts. A redelivered activation repeats its ID, and each new click gets a new one. [interactive.go:interactionEventID] +- Single-install mode rejects events, commands, and interactions from another team as invalid payloads (`slackapi.ErrInvalidPayload`). Over HTTP, `slackapi.Webhook` answers them with 400. [slack.go:messageEvent,invalidPayload], [interactive.go:commandEvent,interactionEvent] +- Multi-tenant mode drops the bot's own messages when the bot user ID matches. [slack.go:messageEvent] +- In multi-tenant mode, the bot user ID comes from `chat.Install.BotActorID` when it is set, else from `SlackInstall.BotUserID`. [slack.go:resolveInstall,SlackInstall] + +### Multi-tenant installs + +- An empty tenant or `chat.ErrInstallNotFound` from `InstallStore.Lookup` is an Ignored Event. Any other lookup error is a transport failure. [slack.go:resolveInstall], [doc.go:slack] +- An install credential must be a `SlackInstall` or a non-nil `*SlackInstall` with a `BotToken`. [slack.go:slackCredential,resolveInstall,postToken] +- An outbound post in multi-tenant mode requires a non-empty tenant. [slack.go:postToken] +- `OpenModal` fails in multi-tenant mode. Use `OpenModalForTenant`. [interactive.go:OpenModal] + +### Thread IDs and posting + +- A Thread ID starts with `slack:v1:`. It needs a team and a channel, and a root unless the thread is a direct message. [slack.go:encodeThreadID,decodeThreadID] +- A channel message thread is rooted at `thread_ts`, else `ts`. A direct message thread has no root. [slack.go:threadIDForEvent] +- Posts accept only `chat.MessageFormatText` and `chat.MessageFormatMarkdown`. [slack.go:slackMessageFields] +- `PostEphemeralMessage` falls back to a DM only on an `ok:false` rejection, and only when `FallbackToDM` is set. With `DisableNativeEphemeral` set and no fallback, it returns `(nil, nil)`. [slack.go:PostEphemeralMessage] +- `PostNative` requires `content.Adapter == "slack"` and a non-nil payload. It does not downgrade the content. [interactive.go:PostNative] +- `OpenModal` requires a `trigger_id` and a non-nil view. `RespondURL` requires a `response_url`. [interactive.go:OpenModal,RespondURL] + +### History + +- `ReadHistory` uses 100 when `Limit <= 0` and caps `Limit` at 1000. [history.go:ReadHistory,slackDefaultHistoryLimit,slackMaxHistoryLimit] +- `ReadHistory` does no storage, dedupe, or caching. [doc.go:slack] +- `ReadHistory` returns an empty page with no Slack call when the `Before` cursor equals the thread root. [history.go:ReadHistory] +- Retry never sleeps past the caller's context deadline, so synchronous retry stays inside Slack's 3-second ack window. [doc.go:slack] + +## Side Effects + +- In single-install mode, `Init` calls `auth.test` unless all three IDs are configured, and writes the team, bot user, and bot IDs on the `Adapter`. [slack.go:Init] +- Posts call `chat.postMessage`, `chat.postEphemeral`, `conversations.open` (DM fallback), and `views.open` through `slackapi.Client.Call`. [slack.go:PostMessage,PostEphemeralMessage,postEphemeralFallback], [interactive.go:PostNative,openModalWithToken] +- `ReadHistory` calls `conversations.replies` for threads and `conversations.history` for direct messages. [history.go:ReadHistory] +- `RespondURL` POSTs to the `response_url` through `slackapi.Client.PostResponseURL`, with no bot token. [interactive.go:RespondURL] +- `Listen` opens a Socket Mode websocket through `slackapi.Client.Listen` with the app token. [slack.go:Listen] +- `Webhook` serves inbound HTTP through a `slackapi.Webhook` that verifies signatures. [slack.go:Webhook] +- The delivery handler calls the runtime `chat.DispatchFunc` for each normalized event. [slack.go:deliveryHandler] +- Multi-tenant mode calls `InstallStore.Lookup` on every delivery and every outbound post. The adapter does not cache install records. [slack.go:resolveInstall,postToken], [doc.go:slack] +- Retry sleeps, observation events (`chat.ObsAdapterCall`, `chat.ObsRateLimit`), and throttle logs come from `slackapi`. `New` passes `Client`, `RetryPolicy`, `Observer`, and `Logger` to `slackapi.New`. [slack.go:New], [doc.go:slack] +- The package starts no goroutines and holds no mutable package-level state. [slack.go:New] + +## Verification + +- Run: `go test ./adapters/slack/...` diff --git a/adapters/slack/slackapi/README.md b/adapters/slack/slackapi/README.md new file mode 100644 index 0000000..d1080c0 --- /dev/null +++ b/adapters/slack/slackapi/README.md @@ -0,0 +1,179 @@ +# slackapi + +## Overview + +Package `slackapi` (`github.com/coder/chat/adapters/slack/slackapi`) is a low-level Slack package for applications that own their runtime: their own state, dispatch, and routing. It works on raw Slack IDs (team, channel, and user IDs and message timestamps). It does not use the portable `chat.Adapter` surface. It holds Slack protocol and data formats only, with no runtime, state, dispatch, or policy. [doc.go:slackapi] + +The package has four parts: + +1. A Slack Web API client (`Client`) with typed methods, a generic `Call` escape hatch, and bounded rate-limit retry. +2. Two inbound transports that share one `Handler`: `Webhook` (signed HTTP requests) and `Client.Listen` (Socket Mode websocket). +3. Payload decoding for the Events API (`ParseEnvelope` and event types). +4. Formats: Block Kit blocks, a Markdown splitter, mention helpers, and app manifest types. + +The Slack adapter (package `slack`) uses this package for its Web API calls, rate-limit retry, history reads, and both inbound transports. The package doc marks the API as experimental. `RetryPolicy` and `RateLimited` keep the supported tier, because the Slack adapter exports them as type aliases. [doc.go:slackapi] + +## Interface + +### Client + +- `New` returns a `Client` for `Options`, with defaults for every empty field. [client.go:New] +- `Options` holds `Token`, `BaseURL`, `HTTPClient`, `RetryPolicy`, `FileOrigins`, `Observer`, and `Logger`. [client.go:Options] +- `DefaultBaseURL` is `https://slack.com/api`. [client.go:DefaultBaseURL] +- `Client` is the Web API client. [client.go:Client] +- `Client.WithToken` returns a copy of the client that sends another token. [client.go:Client.WithToken] +- `Client.Call` is the generic Web API escape hatch. [client.go:Client.Call] +- `Client.PostResponseURL` POSTs JSON to a `response_url` with no token. [client.go:Client.PostResponseURL] +- `APIError` is a Slack rejection (`Method`, `StatusCode`, `Code`, `Detail`, `Raw`). [errors.go:APIError] +- `RetryPolicy` sets `MaxAttempts`, `MaxElapsed`, `BaseDelay`, and `MaxDelay`. [retry.go:RetryPolicy] +- `RateLimited` is the error returned when throttling retry stops. [retry.go:RateLimited] + +### Typed Web API methods + +| Method | Slack API method | Source | +| --- | --- | --- | +| `Client.AuthTest` | `auth.test` | [auth.go:Client.AuthTest] | +| `Client.CreateApp`, `Client.DeleteApp` | `apps.manifest.create`, `apps.manifest.delete` | [apps.go:Client.CreateApp,Client.DeleteApp] | +| `Client.PostMessage` | `chat.postMessage` | [chat.go:Client.PostMessage] | +| `Client.PostEphemeral` | `chat.postEphemeral` | [chat.go:Client.PostEphemeral] | +| `Client.UpdateMessage` | `chat.update` | [chat.go:Client.UpdateMessage] | +| `Client.DeleteMessage` | `chat.delete` | [chat.go:Client.DeleteMessage] | +| `Client.ConversationInfo` | `conversations.info` | [conversations.go:Client.ConversationInfo] | +| `Client.ConversationHistory` | `conversations.history` | [conversations.go:Client.ConversationHistory] | +| `Client.ConversationReplies` | `conversations.replies` | [conversations.go:Client.ConversationReplies] | +| `Client.FileInfo` | `files.info` | [files.go:Client.FileInfo] | +| `Client.GetUploadURLExternal` | `files.getUploadURLExternal` | [files.go:Client.GetUploadURLExternal] | +| `Client.CompleteUploadExternal` | `files.completeUploadExternal` | [files.go:Client.CompleteUploadExternal] | +| `Client.OAuthV2Access` | `oauth.v2.access` | [oauth.go:Client.OAuthV2Access] | +| `Client.AddReaction`, `Client.RemoveReaction` | `reactions.add`, `reactions.remove` | [reactions.go:Client.AddReaction,Client.RemoveReaction] | +| `Client.SetAssistantThreadStatus` | `assistant.threads.setStatus` | [status.go:Client.SetAssistantThreadStatus] | +| `Client.SetAgentSessionStatus` | `agents.sessions.setStatus` | [status.go:Client.SetAgentSessionStatus] | +| `Client.UserInfo` | `users.info` | [users.go:Client.UserInfo] | +| `Client.OpenConnection` | `apps.connections.open` | [socketmode.go:Client.OpenConnection] | + +Most methods have request or response types in the same file, for example `PostMessageRequest` and `PostMessageResponse` [chat.go:PostMessageRequest,PostMessageResponse]. + +Some of those types hold nested exported types: + +- `AppCredentials`: the app credentials in `CreateAppResponse`. [apps.go:AppCredentials] +- `FileSummary`: one uploaded file in `CompleteUploadExternalRequest`. [files.go:FileSummary] +- `FileShares` and `FileShare`: the shares of a file by channel ID, in `FileInfoResponse`. [files.go:FileShares,FileShare] +- `OAuthTeam`: the workspace or Enterprise Grid organization in `OAuthV2AccessResponse`. [oauth.go:OAuthTeam] + +### Files + +- `Client.UploadFile` runs the three-step external upload and returns the completed `File`. [files.go:Client.UploadFile] +- `Client.UploadToURL` POSTs raw bytes to a pre-authorized upload URL. [files.go:Client.UploadToURL] +- `Client.DownloadFile` GETs a file URL with the bearer token into a writer, with a size limit. [files.go:Client.DownloadFile] +- `ErrFileTooLarge` is the sentinel for a file over the size limit. [files.go:ErrFileTooLarge] + +### Inbound payloads and transports + +- `Delivery` is one inbound payload (`Type`, `Payload`, `RetryAttempt`, `RetryReason`). [delivery.go:Delivery] +- `DeliveryType` has the values `DeliveryEventsAPI`, `DeliverySlashCommands`, and `DeliveryInteractive`. [delivery.go:DeliveryType] +- `Handler` handles a `Delivery` and returns the ack response. [delivery.go:Handler] +- `ErrBusy` and `ErrInvalidPayload` are sentinel handler errors. [delivery.go:ErrBusy,ErrInvalidPayload] +- `Webhook` is the HTTP transport (`SigningSecret`, `Handler`, `Now`, `SignatureTolerance`). `Webhook.ServeHTTP` handles one request. [webhook.go:Webhook,Webhook.ServeHTTP] +- `VerifyRequest` checks the Slack v0 HMAC-SHA256 request signature. `DefaultSignatureTolerance` is 5 minutes. [signature.go:VerifyRequest,DefaultSignatureTolerance] +- `Client.Listen` is the Socket Mode transport. [socketmode.go:Client.Listen] +- `ListenOptions` holds `OnConnect`, `OnError`, `PingTimeout`, `MinBackoff`, and `MaxBackoff`. `OnConnect` and `OnError` run on the goroutine that called `Listen`, so they must return quickly. [socketmode.go:ListenOptions] +- `Hello` is the Socket Mode hello message. `DisconnectError` is a Slack disconnect message as an error. [socketmode.go:Hello,DisconnectError] +- `ErrPingTimeout` and `ErrConnectTimeout` are Socket Mode sentinel errors. [socketmode.go:ErrPingTimeout,ErrConnectTimeout] + +### Events API decoding + +- `ParseEnvelope` decodes an Events API body. It does not check the signature. [envelope.go:ParseEnvelope] +- `Envelope` is the outer Events API body. [envelope.go:Envelope] +- `Envelope.MessageEvent` decodes `app_mention` and `message` events. `Envelope.AgentSessionStoppedEvent` and `Envelope.ChannelSharedEvent` decode one event type each. [envelope.go:Envelope.MessageEvent,Envelope.AgentSessionStoppedEvent,Envelope.ChannelSharedEvent] +- `EnvelopeURLVerification`, `EnvelopeEventCallback`, `EventType*`, and `Subtype*` are envelope, event, and message subtype constants. [envelope.go:EnvelopeURLVerification,EventTypeAppMention,SubtypeMessageChanged] + +### Data types and formats + +- `Message`, `BotProfile`, `Edited`, and `File` are Slack message types. [message.go:Message,BotProfile,Edited,File] +- `User` and `UserProfile` are users.info types. `User.DisplayName` returns the first non-empty name. [users.go:User,UserProfile,User.DisplayName] +- `Conversation` and `MessagePage` are conversations types. [conversations.go:Conversation,MessagePage] +- `AgentSessionActive` and `AgentSessionProcessing` are agent session status values. [status.go:AgentSessionActive,AgentSessionProcessing] +- `Block` is the Block Kit block interface. `MarkdownBlock`, `ActionsBlock`, `ButtonElement`, and `ImageBlock` implement Block Kit objects. [blocks.go:Block,MarkdownBlock,ActionsBlock,ButtonElement,ImageBlock] +- `SplitMarkdown` splits text into fence-aware chunks. `MarkdownBlockLimit` is 12000. [markdown.go:SplitMarkdown,MarkdownBlockLimit] +- `UserMentions` and `ReplaceUserMentions` read and replace `<@U...>` mentions. [mentions.go:UserMentions,ReplaceUserMentions] +- `Manifest` and its `Manifest*` section types encode a Slack app manifest. [manifest.go:Manifest] + +### Subdirectories + +- [slackapitest](./slackapitest/README.md): fake Slack Web API, file server, and Socket Mode, plus a request signer, for tests. + +## Invariants + +### Client and retry + +- `Client` is safe for concurrent use. [client.go:Client] +- `New` trims trailing slashes from `BaseURL`. It drops each `FileOrigins` entry that is not an absolute URL with a host, and logs a warning for it. [client.go:New] +- The client sends the `Authorization` header only when the token is not empty. `UploadToURL`, `PostResponseURL`, and `OAuthV2Access` never send the client token. [client.go:send,Options], [oauth.go:Client.OAuthV2Access] +- `Call` encodes a nil payload as `{}`, a `url.Values` payload as a form body, and any other payload as JSON. [client.go:Client.Call,encodeRequest] +- An `ok:false` response or a non-2xx status returns `*APIError`. [client.go:Client.Call,send] +- `WithToken` shares the base URL, file origins, HTTP client, retry policy, observer, and logger with the original client. [client.go:Client.WithToken] +- `RetryPolicy` defaults for zero fields: `MaxAttempts` 3, `MaxElapsed` 2s, `BaseDelay` 200ms, `MaxDelay` 1s. `MaxAttempts` 1 turns retry off. [retry.go:RetryPolicy.withDefaults] +- Retry runs only on HTTP 429 or an `ok:false` envelope with error `ratelimited` or `rate_limited`. Transport errors return directly and are never retried. [retry.go:slackThrottle,Client.doWithRetry] +- Retry returns `*RateLimited` when `MaxAttempts` is reached, when total backoff would pass `MaxElapsed`, or when the next sleep would pass the context deadline. [retry.go:Client.doWithRetry] +- Each retry attempt sends a fresh copy of the buffered request body. [retry.go:Client.doWithRetry] +- `DownloadFile` does not retry. [doc.go:slackapi] + +### Messages + +- `PostMessageRequest.MarkdownText` cannot combine with `Text` or `Blocks`, and holds at most 12,000 characters. [chat.go:PostMessageRequest] +- `PostMessageRequest.ThreadTS` must be the parent message ts, not a reply ts. [chat.go:PostMessageRequest] +- `PostEphemeralResponse.MessageTS` cannot update or delete the ephemeral message. [chat.go:PostEphemeralResponse] +- `UpdateMessage` with `Text` and no `Blocks` removes the existing blocks. A bot token can update and delete only its own messages. [chat.go:Client.UpdateMessage,Client.DeleteMessage] +- `ConversationHistory` returns messages newest first. `ConversationReplies` returns them oldest first. [conversations.go:Client.ConversationHistory,Client.ConversationReplies] +- `Message.Raw` holds the raw JSON of each message from history reads and is never JSON-encoded. [conversations.go:messagePage], [message.go:Message] +- `SetAssistantThreadStatusRequest.LoadingMessages` holds at most 10 entries. An empty `Status` clears the status. [status.go:SetAssistantThreadStatusRequest] + +### Files + +- `UploadToURL` and `DownloadFile` accept only URLs whose origin is in `FileOrigins`. They check every redirect against `FileOrigins` and reject a redirect that changes the HTTP method. [files.go:Client.checkFileURL,Client.fileRedirectPolicy] +- Origin matching lowercases scheme and host and strips default ports. [client.go:originOf] +- `DownloadFile` requires a positive `maxBytes`. It rejects a `text/html` response. A larger body returns an error that wraps `ErrFileTooLarge`. [files.go:Client.DownloadFile] +- `UploadFile` wraps each step error with the step name, so `errors.As` still finds `*APIError` and `*RateLimited`. [files.go:Client.UploadFile] + +### Inbound transports + +- A `Handler` must not do slow work, because Slack expects an ack within 3 seconds. [delivery.go:Handler] +- Inbound payloads are capped at `maxPayloadBytes` (1 MiB) in `Webhook` and `Listen`. [delivery.go:maxPayloadBytes], [webhook.go:Webhook.ServeHTTP], [socketmode.go:socketConn.dial] +- `Webhook.ServeHTTP` accepts only POST (else 405). It verifies the signature before it parses the body (else 401). [webhook.go:Webhook.ServeHTTP] +- A body over 1 MiB returns 413, and an unreadable body returns 400. [webhook.go:Webhook.ServeHTTP] +- After the signature check, a form body with `payload` becomes a `DeliveryInteractive`, and a form body with `command` becomes a `DeliverySlashCommands` whose payload is the form fields as a JSON object. Any other body becomes a `DeliveryEventsAPI`. Another form body, a `payload` that is not valid JSON, or a JSON body that does not parse returns 400. `X-Slack-Retry-Num` and `X-Slack-Retry-Reason` fill `RetryAttempt` and `RetryReason`. [webhook.go:parseRequest] +- `Webhook.ServeHTTP` answers `url_verification` with the challenge and does not call the `Handler`. [webhook.go:parseRequest,Webhook.ServeHTTP] +- `Webhook` maps handler errors: `ErrBusy` to 503, `ErrInvalidPayload` to 400, and any other error to 500. [webhook.go:Webhook.ServeHTTP] +- `Webhook` requires `SigningSecret` and `Handler`. `Webhook` is safe for concurrent use. [webhook.go:Webhook] +- `VerifyRequest` rejects an empty secret, missing headers, a bad timestamp, and clock skew beyond the tolerance in either direction. It compares signatures with `hmac.Equal`. [signature.go:VerifyRequest] +- `Listen` acks an envelope only when the handler returns no error. It acks on the connection the envelope came on. [socketmode.go:socketConn.handle] +- `Listen` returns at once on fatal auth and config codes such as `invalid_auth`, `token_revoked`, `missing_scope`, and `link_disabled`. It retries other errors with backoff. [socketmode.go:fatal,Client.Listen] +- `Listen` returns nil when `ctx` is done, after it closes all connections and waits for handlers. Handler contexts are not cancelled when `ctx` is done. [socketmode.go:Client.Listen,listener.run] +- `Listen` promotes a replacement connection only after its hello arrives. The old connection stays up until then. [socketmode.go:listener.promote,listener.replace] +- A closing connection waits at most 3 seconds for running handlers, then sends a close frame. [socketmode.go:socketConn.drain,socketConn.close] +- `Listen` removes the query (the connection ticket) from websocket dial errors. [socketmode.go:withoutTicket] +- `ListenOptions` defaults: `PingTimeout` 30s, `MinBackoff` 1s, `MaxBackoff` 1m. `MinBackoff` is capped at `MaxBackoff`. [socketmode.go:ListenOptions.withDefaults] + +### Formats + +- `SplitMarkdown` uses `MarkdownBlockLimit` for a limit of zero or less, returns nil for empty text, and does not end a chunk inside or right after a code-fence opening line. [markdown.go:SplitMarkdown,splitFenced] +- `UserMentions` returns IDs in first-appearance order with no duplicates. [mentions.go:UserMentions] +- `ParseEnvelope` returns an error for invalid JSON or an empty envelope type. [envelope.go:ParseEnvelope] + +## Side Effects + +- Every typed method and `Call` sends an HTTP POST to `BaseURL` + `/` through the configured `http.Client`. [client.go:Client.Call,send] +- `PostResponseURL` sends an HTTP POST to a caller-supplied `response_url`. [client.go:Client.PostResponseURL] +- `UploadToURL` POSTs file content to the upload URL. `DownloadFile` GETs a file and writes it to the caller's writer. Both use a copied `http.Client` with a custom redirect policy. [files.go:Client.UploadToURL,Client.DownloadFile,Client.fileHTTPClient] +- Retry sleeps between attempts with `ratelimit.SleepCtx` from `internal/ratelimit`. [retry.go:Client.doWithRetry] +- The client emits `chat.ObsAdapterCall` per attempt and `chat.ObsRateLimit` per throttle to `Options.Observer`. `DownloadFile` also emits `chat.ObsAdapterCall`. [retry.go:Client.doWithRetry], [files.go:Client.DownloadFile] +- The client logs a `slack rate limited` warning per throttle. `New` logs a warning per dropped file origin. [retry.go:Client.logRetry], [client.go:New] +- `Listen` dials a websocket with the client's HTTP client, under a 30s connect deadline. [socketmode.go:socketConn.dial,newSocketConn] +- `Listen` starts a read goroutine per connection and a goroutine per envelope handler. It uses timers for pings and retry backoff, and `math/rand/v2` for jitter. [socketmode.go:socketConn.read,socketConn.handle,socketConn.onPing,listener.failed,ListenOptions.backoff] +- `Listen` logs connects, refreshes, reconnects, ping timeouts, and retries through the client logger. [socketmode.go:listener.promote,listener.disconnected,listener.failed] +- `Webhook.ServeHTTP` reads the request body into memory, calls the `Handler` with the request context, and writes the HTTP response. [webhook.go:Webhook.ServeHTTP,writeResponse] +- `SplitMarkdown`, `UserMentions`, `ReplaceUserMentions`, and `VerifyRequest` are pure functions with no I/O. [markdown.go:SplitMarkdown], [mentions.go:UserMentions,ReplaceUserMentions], [signature.go:VerifyRequest] + +## Verification + +- Run: `go test ./adapters/slack/slackapi/...` diff --git a/adapters/slack/slackapi/slackapitest/README.md b/adapters/slack/slackapi/slackapitest/README.md new file mode 100644 index 0000000..840acf7 --- /dev/null +++ b/adapters/slack/slackapi/slackapitest/README.md @@ -0,0 +1,92 @@ +# slackapitest + +## Overview + +Package `slackapitest` (`github.com/coder/chat/adapters/slack/slackapi/slackapitest`) holds test fakes for code that uses the parent `slackapi` package. It has three parts: + +1. A fake Slack Web API server and a fake Slack file server (`Server`). +2. A fake Socket Mode service on that server (`SocketMode`). It sends hello, pings, and envelopes over websockets, and it resends envelopes until a client acks them. +3. A request signer (`SignRequest`) that makes `slackapi.VerifyRequest` accept a request. + +A test points `slackapi.Options.BaseURL` at `Server.URL` and `slackapi.Options.HTTPClient` at `Server.Client`. The package doc marks the API as experimental. Its exported API can change before promotion. [doc.go:slackapitest] + +## Interface + +### Web API fake + +- `NewServer` starts a `Server` and closes it at test cleanup. [server.go:NewServer] +- `Server` is the fake Web API and file server. [server.go:Server] +- `Call` is one recorded Web API request (`Method`, `Header`, `Body`, `Form`). [server.go:Call] +- `Response` is a full HTTP response (`StatusCode`, `Header`, `Body`), for example a 429 with `Retry-After`. [server.go:Response] +- `Server.URL` returns the base URL for `slackapi.Options.BaseURL`. [server.go:Server.URL] +- `Server.Client` returns the HTTP client for `slackapi.Options.HTTPClient`. [server.go:Server.Client] +- `Server.Respond` sets a fixed response for a Web API method. [server.go:Server.Respond] +- `Server.Handle` sets a function that computes the response for each call to a method. [server.go:Server.Handle] +- `Server.Calls` returns the recorded calls to a method, in arrival order. [server.go:Server.Calls] +- `Server.Wait` waits until a condition is true or the context ends. [server.go:Server.Wait] + +### File server fake + +- `FileRequest` is one recorded file-server request (`Method`, `Path`, `Header`, `Body`). [files.go:FileRequest] +- `Server.FileOrigin` returns the origin to add to `slackapi.Options.FileOrigins`. [files.go:Server.FileOrigin] +- `Server.UploadURL` returns an upload URL that answers `OK - `. [files.go:Server.UploadURL] +- `Server.ServeFile` serves a fixed body at a path and returns its URL. [files.go:Server.ServeFile] +- `Server.HandleFile` serves a computed response at a path and returns its URL. [files.go:Server.HandleFile] +- `Server.FileRequests` returns the recorded requests to a file target, in order. [files.go:Server.FileRequests] + +### Socket Mode fake + +- `Server.SocketMode` installs the fake Socket Mode on the server. [socketmode.go:Server.SocketMode] +- `SocketModeOptions` configures `AppToken`, `AppID`, `PingInterval`, `ResendAfter`, and `MaxConnections`. [socketmode.go:SocketModeOptions] +- `SocketMode` is the fake Socket Mode service. [socketmode.go:SocketMode] +- `SocketMode.Open` answers `apps.connections.open`. [socketmode.go:SocketMode.Open] +- `SocketMode.SetAppToken` changes the accepted app-level token. [socketmode.go:SocketMode.SetAppToken] +- `SocketMode.Send` sends an envelope to a random live connection and returns its ID. [socketmode.go:SocketMode.Send] +- `SocketMode.Disconnect` and `SocketMode.DisconnectConn` send a disconnect message to all live connections or to one connection. [socketmode.go:SocketMode.Disconnect,SocketMode.DisconnectConn] +- `SocketMode.StopPings` stops pings on one connection. [socketmode.go:SocketMode.StopPings] +- `SocketMode.Drop` closes one connection without a close frame. [socketmode.go:SocketMode.Drop] +- `SocketMode.Live`, `SocketMode.Conns`, `SocketMode.Closes`, `SocketMode.Envelope`, and `SocketMode.Envelopes` return counts and snapshots. [socketmode.go:SocketMode.Live,SocketMode.Conns,SocketMode.Closes,SocketMode.Envelope,SocketMode.Envelopes] +- `SocketMode.WaitLive`, `SocketMode.WaitClosed`, and `SocketMode.WaitAcked` wait for a condition and fail the test on timeout. [socketmode.go:SocketMode.WaitLive,SocketMode.WaitClosed,SocketMode.WaitAcked] +- `SocketConn` is a snapshot of one connection. `SocketConn.Live` reports hello received, not closed, and not dropped. [socketmode.go:SocketConn,SocketConn.Live] +- `SocketEnvelope`, `SocketDelivery`, and `SocketAck` are snapshots of one envelope, one send of it, and one ack. [socketmode.go:SocketEnvelope,SocketDelivery,SocketAck] + +### Signing + +- `SignRequest` sets the Slack v0 signature headers on a request. [sign.go:SignRequest] + +## Invariants + +- A `Server` is safe for concurrent use. [doc.go:slackapitest] +- `Handle` and `HandleFile` functions run on the server goroutine. They must not call `t.Fatal`. [server.go:Server.Handle], [files.go:Server.HandleFile] +- The `Wait` condition must not block. `Wait` does not see a change that a `Handle` or `HandleFile` function makes until that function returns. [server.go:Server.Wait] +- A request to a method with no handler fails the test (`t.Errorf`) and gets `{"ok":false,"error":"unknown_method"}`. [server.go:Server.serveHTTP] +- A request to a file path with no handler fails the test and gets status 404. [files.go:Server.serveFile] +- A Web API request whose path does not start with `/api/` followed by a method name fails the test (`t.Errorf`) and gets status 404. [server.go:Server.serveHTTP,apiPrefix] +- `SocketMode.Send` with a payload that is not valid JSON fails the test (`t.Errorf`), sends nothing, and returns an empty ID. [socketmode.go:SocketMode.Send,encodeJSON] +- A websocket upgrade with an unknown ticket, or after the fake has stopped, gets status 404. [socketmode.go:SocketMode.serve] +- The fake reads at most `socketReadLimit` (1 MiB) per websocket message from a client. [socketmode.go:socketReadLimit,SocketMode.serve] +- The server parses `Call.Form` only when the `Content-Type` is `application/x-www-form-urlencoded`. [server.go:isForm] +- A zero `StatusCode` sends 200. A `json.RawMessage` or `[]byte` body is sent as is. Other bodies are sent as JSON with a default `application/json` content type. [server.go:statusOrOK,encodeBody,Server.write] +- `Server.SocketMode` can run once per `Server`. A second call calls `t.Fatalf`. [socketmode.go:Server.SocketMode] +- `SocketModeOptions` defaults: `AppID` `A0123456789`, `PingInterval` 10s, `ResendAfter` 3s, `MaxConnections` 10. A negative `PingInterval` sends no pings. A negative `ResendAfter` turns resends off. [socketmode.go:Server.SocketMode,SocketMode.ping,SocketMode.deliverLocked] +- `SocketMode.Open` returns `not_authed` with no bearer token, and `invalid_auth` when the token differs from a non-empty `AppToken`. [socketmode.go:SocketMode.Open] +- `SetAppToken` does not change connections that are already open. [socketmode.go:SocketMode.SetAppToken] +- An unacked envelope is resent at most `maxResends` (3) times. An ack stops its resend timer. [socketmode.go:SocketMode.deliverLocked,SocketMode.ack] +- A connection at or over `MaxConnections` gets disconnect reason `too_many_websockets` and no hello. [socketmode.go:SocketMode.serve] +- The fake sets `accepts_response_payload` to true only for `slash_commands` envelopes. [socketmode.go:SocketMode.Send] +- An ack without `envelope_id`, an ack for an unknown envelope, and a binary websocket message fail the test (`t.Errorf`). [socketmode.go:SocketMode.ack,SocketMode.read] +- Hellos, deliveries, acks, and closes take sequence numbers from one counter, so a test can compare their order. [socketmode.go:SocketMode.nextSeqLocked] + +## Side Effects + +- `NewServer` starts two `httptest` servers (Web API and files) and registers `t.Cleanup` to close them. [server.go:NewServer] +- The server records calls and file requests in maps under `Server.mu`, and wakes `Wait` callers on each change. [server.go:Server.serveHTTP,notifier.notify], [files.go:Server.serveFile] +- `Server.SocketMode` registers an `apps.connections.open` handler and serves websocket connections at `/link/`. [socketmode.go:Server.SocketMode,SocketMode.Open] +- Each websocket connection starts write and ping goroutines. Pings use `time.NewTicker`. Resends use `time.AfterFunc`. [socketmode.go:SocketMode.serve,SocketMode.ping,SocketMode.deliverLocked] +- `SocketMode.Send` picks a live connection with `math/rand/v2`. [socketmode.go:SocketMode.deliverLocked] +- At test cleanup, the fake stops resend timers, closes open connections without a close frame, and waits for its goroutines. [socketmode.go:SocketMode.stop] +- The package does no disk access, logging, or metrics. [socketmode.go:SocketMode] + +## Verification + +- Run: `go test ./adapters/slack/slackapi/slackapitest/...` diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..891c4ee --- /dev/null +++ b/examples/README.md @@ -0,0 +1,25 @@ +# examples + +## Overview + +Runnable example bots for the `github.com/coder/chat` SDK. Each subdirectory +is one `package main` program. Two examples build in the root module and use +in-memory state (`github.com/coder/chat/state/memory`). Three examples are +separate Go modules that pair the Slack adapter with a durable state backend +and ship a `compose.yaml` and a `pitchfork.toml` for the local service. Each +of those `go.mod` files replaces `github.com/coder/chat` with the local tree +(`../..`). + +## Subdirectories & Boundaries + +- linear-agent-hello-world: [./linear-agent-hello-world/README.md](./linear-agent-hello-world/README.md) - Linear app-actor agent on `github.com/coder/chat/adapters/linear` with in-memory state and `chat.DispatchDeferred`. Serves `/webhooks/linear` and needs `LINEAR_WEBHOOK_SECRET`, `LINEAR_CLIENT_CREDENTIALS_CLIENT_ID`, and `LINEAR_CLIENT_CREDENTIALS_CLIENT_SECRET`. `capabilities.go` holds the agent-session helper loops. Root module. +- slack-hello-world: [./slack-hello-world/README.md](./slack-hello-world/README.md) - Slack bot with in-memory state that replies `**hello** _world_` to mentions. Runs Socket Mode (`SLACK_APP_TOKEN`), HTTP on `/webhooks/slack` (`SLACK_SIGNING_SECRET`), or both. Requires `CHAT_DEMO_IN_MEMORY_STATE=1`. Root module. +- slack-nats-state: [./slack-nats-state/README.md](./slack-nats-state/README.md) - Slack webhook bot with NATS JetStream state (`github.com/coder/chat/state/nats`). Needs `NATS_URL`. Compose service `nats` on `127.0.0.1:42220`. Separate module. +- slack-postgres-state: [./slack-postgres-state/README.md](./slack-postgres-state/README.md) - Slack webhook bot with Postgres state (`github.com/coder/chat/state/postgres`). Needs `DATABASE_URL`. Compose service `postgres` on `127.0.0.1:54330`. Separate module. +- slack-redis-state: [./slack-redis-state/README.md](./slack-redis-state/README.md) - Slack webhook bot with Redis state (`github.com/coder/chat/state/redis`). Needs `REDIS_URL`. Compose service `redis` on `127.0.0.1:63800`. Separate module. + +Every HTTP listener in these examples uses `:$PORT` (default `:8080`). +`slack-hello-world` opens one only when `SLACK_SIGNING_SECRET` is set. With +Socket Mode alone it listens on no port. Root-module examples +run with `go test ./examples//...` from the repository root. The +separate modules run with `cd examples/ && go test ./...`. diff --git a/examples/linear-agent-hello-world/README.md b/examples/linear-agent-hello-world/README.md index 6a93165..e24a65f 100644 --- a/examples/linear-agent-hello-world/README.md +++ b/examples/linear-agent-hello-world/README.md @@ -1,5 +1,7 @@ # Linear Agent Hello World Example +## Overview + A small Linear agent with in-memory state. Mention the installed Linear app in an issue, or delegate an issue to it, and Linear opens an agent session. The example then: @@ -76,7 +78,7 @@ assignment-created sessions started arriving. Linear requires a public HTTPS endpoint. For local development, expose port `8080` with a tunnel such as Tailscale Funnel, `ngrok`, or `cloudflared` (see -[Step 5 of the Slack tutorial](../../docs/tutorials/slack-bot.md#step-5-expose-the-bot-to-slack) +[Use HTTP Instead Of Socket Mode in the Slack tutorial](../../docs/tutorials/slack-bot.md#use-http-instead-of-socket-mode) for Funnel commands), then use that public HTTPS URL as `YOUR_PUBLIC_HOST`. ## Run @@ -146,3 +148,103 @@ Expected behavior: - This example runs under `chat.DispatchDeferred`, so the webhook acknowledgement does not wait for follow-up work, and the runtime holds and renews the thread lock while that work runs. + +## Interface + +`package main` in the root module, built from [`main.go`](main.go) and +[`capabilities.go`](capabilities.go). It takes no CLI flags. It imports +`github.com/coder/chat`, `github.com/coder/chat/adapters/linear`, and +`github.com/coder/chat/state/memory` [main.go:main]. + +| Symbol | Role | Source | +| --- | --- | --- | +| `main()` | Builds the Linear adapter and the `chat.Chat` runtime, registers both handlers, and serves the webhook. | [main.go:main] | +| `/webhooks/linear` | The only HTTP route. It serves `bot.Webhook("linear")`, which answers 405 to any method but POST. | [main.go:newWebhookServer] | +| `linearAgentAccess` | Interface over `PostThought`, `PostAction`, `PostElicitation`, `PostError`, `UpdateSession`, `CreateSessionOnIssue`, and `SuggestRepositories`. `*linear.Adapter` and a test fake satisfy it. | [main.go:linearAgentAccess] | +| `newMentionHandler` | `OnNewMention` handler: comment reply, or subscribe, thought, action, external URL, then a final response or a `select` elicitation. | [main.go:newMentionHandler] | +| `newFollowUpHandler` | `OnSubscribedMessage` handler: stop confirmation, then a pending select answer, then a normal follow-up reply. | [main.go:newFollowUpHandler] | +| `shouldAsk` | Returns true when the prompt text is `deploy`. | [main.go:shouldAsk] | +| `newWebhookServer` | Builds the `*http.Server` with a `ServeMux` and fixed timeouts. | [main.go:newWebhookServer] | +| `mustEnv` | Reads a required environment variable. | [main.go:mustEnv] | +| `selectionKindDeploy`, `selectionKindRepository` | Kinds of pending select elicitation (`"deploy"`, `"repository"`). | [capabilities.go:selectionKindDeploy], [capabilities.go:selectionKindRepository] | +| `pendingSelection`, `pendingSelections`, `newPendingSelections` | Per-thread record of the offered select values, with `set` and `take`. | [capabilities.go:pendingSelections] | +| `startProactiveSession` | Creates a session on an issue with `CreateSessionOnIssue`, seeds a `Run dashboard` URL, and posts a first thought. | [capabilities.go:startProactiveSession] | +| `chooseRepository`, `bestSuggestion`, `offerRepositoryChoice`, `repositoryOptionValue` | Repository suggestion loop with `SuggestRepositories` and a `select` fallback. | [capabilities.go:chooseRepository] | +| `handleSelection` | Matches a follow-up to an offered value and posts an acknowledgement for the selection kind. | [capabilities.go:handleSelection] | +| `requireAccountLink`, `resumeAfterAccountLink` | `auth` elicitation, and the resume that the application's own auth callback calls. | [capabilities.go:requireAccountLink], [capabilities.go:resumeAfterAccountLink] | +| `publishPullRequest` | Adds a `Pull request` external URL with `AddExternalURLs`. | [capabilities.go:publishPullRequest] | +| `confirmStop` | Detects `linear.RawMessageFrom(...).StopRequested()` and posts a stop confirmation. | [capabilities.go:confirmStop] | + +The running bot calls `confirmStop` and `handleSelection` from +`newFollowUpHandler` [main.go:newFollowUpHandler]. The other +`capabilities.go` helpers are not called from `main.go`. + +## Invariants + +- `LINEAR_WEBHOOK_SECRET`, `LINEAR_CLIENT_CREDENTIALS_CLIENT_ID`, and + `LINEAR_CLIENT_CREDENTIALS_CLIENT_SECRET` must be non-empty. Otherwise + `mustEnv` panics with ` is required` [main.go:mustEnv]. +- An error from `linear.New`, `chat.New`, `bot.Webhook("linear")`, or + `ListenAndServe` panics. A missing adapter panics with + `linear adapter is not registered` [main.go:main]. +- The runtime uses `chat.DispatchDeferred` with `DedupeTTL` 24h, + `ThreadLockTTL` 2m, `DetachTimeout` 5m, and `MaxDetached` 1024. The code + comment says deferred dispatch requires this admission bound [main.go:main]. +- A mention on a `comment`-kind thread gets one plain reply and returns before + `Thread.Subscribe` [main.go:newMentionHandler]. +- On an agent session, the handler subscribes the thread before it posts any + activity [main.go:newMentionHandler]. +- If the final response fails to post, the handler posts an error activity and + unsubscribes with `context.WithoutCancel(ctx)` [main.go:newMentionHandler]. +- A stop request is handled first and drops the pending selection for the + thread [main.go:newFollowUpHandler]. +- Each follow-up consumes the pending selection. The handler registers it again + only when a matched choice fails to post, because deferred dispatch does not + redeliver a handler error [main.go:newFollowUpHandler]. +- `handleSelection` matches only `strings.TrimSpace(text)` equal to an offered + value. Other text returns `handled=false` [capabilities.go:handleSelection]. +- `chooseRepository` skips the question only when the best suggestion has + `Confidence >= 0.8` [capabilities.go:chooseRepository]. +- `offerRepositoryChoice` records a pending selection only for a non-empty + shortlist whose elicitation posted without error + [capabilities.go:offerRepositoryChoice]. +- With an empty shortlist, `offerRepositoryChoice` posts a free-text + elicitation with no `select` signal and records no pending selection + [capabilities.go:offerRepositoryChoice]. +- `startProactiveSession` returns the created thread ID with the error when the + first thought fails, so a caller does not create a duplicate session + [capabilities.go:startProactiveSession]. +- `pendingSelections` guards its map with a `sync.Mutex` + [capabilities.go:pendingSelections]. + +## Side Effects + +| Environment variable | Default | Use | Source | +| --- | --- | --- | --- | +| `LINEAR_WEBHOOK_SECRET` | none (required) | `linear.Options.WebhookSecret` | [main.go:main] | +| `LINEAR_CLIENT_CREDENTIALS_CLIENT_ID` | none (required) | `linear.ClientCredentials.ClientID` | [main.go:main] | +| `LINEAR_CLIENT_CREDENTIALS_CLIENT_SECRET` | none (required) | `linear.ClientCredentials.ClientSecret` | [main.go:main] | +| `PORT` | `8080` | Listen address `":" + PORT` | [main.go:main] | + +- Network: `http.Server.ListenAndServe` on `:$PORT`, with + `ReadHeaderTimeout` 5s, `ReadTimeout` 10s, `WriteTimeout` 10s, and + `IdleTimeout` 60s [main.go:newWebhookServer]. +- Network: Linear API calls through `linearAgentAccess` (thoughts, actions, + elicitations, errors, session updates) and `Thread.Post` + [main.go:newMentionHandler], [main.go:newFollowUpHandler]. +- External URL: each agent-session mention sets the `Draft PR` link + `https://example.com/pr/1` on the session [main.go:newMentionHandler]. +- State: runtime state lives in `memory.New()`, and pending selections live in + a process map. Both are lost on exit [main.go:main], + [capabilities.go:pendingSelections]. +- Logging: `slog.Default()` goes to the adapter and the runtime. `main` logs + `listening` and `chat shutdown failed` [main.go:main]. +- Lifecycle: a deferred `bot.Shutdown(context.Background())` runs when `main` + returns or panics. `main` installs no signal handler [main.go:main]. + +## Verification + +From the repository root: + +- Run: `go test ./examples/linear-agent-hello-world/...` +- Run: `go vet ./examples/linear-agent-hello-world/...` diff --git a/examples/slack-hello-world/README.md b/examples/slack-hello-world/README.md index 0e5b468..ae1573b 100644 --- a/examples/slack-hello-world/README.md +++ b/examples/slack-hello-world/README.md @@ -1,5 +1,7 @@ # Slack Hello World Example +## Overview + This example runs a tiny Slack bot with in-memory runtime state. When the bot is mentioned in a channel, or messaged directly if you enable the DM event, it replies with portable Markdown: `**hello** _world_`. @@ -94,3 +96,75 @@ closes the Socket Mode connection. `SLACK_SIGNING_SECRET`. - The bot token is used for `auth.test` during adapter startup and `chat.postMessage` when replying. + +## Interface + +`package main` in the root module, built from [`main.go`](main.go). It takes no +CLI flags. It imports `github.com/coder/chat`, +`github.com/coder/chat/adapters/slack`, and +`github.com/coder/chat/state/memory` [main.go:main]. + +| Symbol | Role | Source | +| --- | --- | --- | +| `main()` | Reads the environment, builds the bot, and calls `serve`, which returns on `SIGINT`, `SIGTERM`, or the first transport error. `main` panics on a returned error. | [main.go:main] | +| `newBot` | Builds the Slack adapter and a `chat.Chat` with `memory.New()`. Registers an `OnNewMention` handler that posts `chat.Markdown("**hello** _world_")`. | [main.go:newBot] | +| `serve` | Starts Socket Mode (`bot.Listen`) when the app token is set, and the webhook server when the signing secret is set. Then it shuts down both. | [main.go:serve] | +| `shutdown` | Calls a stop function with a 10-second timeout context. | [main.go:shutdown] | +| `serveWebhooks` | Runs `ListenAndServe` and treats `http.ErrServerClosed` as a clean exit. | [main.go:serveWebhooks] | +| `/webhooks/slack` | The only HTTP route. It serves `bot.Webhook("slack")`, which answers 405 to any method but POST. | [main.go:newWebhookServer] | +| `newWebhookServer` | Builds the `*http.Server` with a `ServeMux` and fixed timeouts. | [main.go:newWebhookServer] | +| `mustEnv` | Reads a required environment variable. | [main.go:mustEnv] | +| `mustAllowDemoMemoryState` | Requires `CHAT_DEMO_IN_MEMORY_STATE=1`. | [main.go:mustAllowDemoMemoryState] | + +## Invariants + +- `CHAT_DEMO_IN_MEMORY_STATE` must equal `1`. Otherwise `main` panics with + `CHAT_DEMO_IN_MEMORY_STATE=1 is required` [main.go:mustAllowDemoMemoryState]. +- `SLACK_BOT_TOKEN` must be non-empty. Otherwise `mustEnv` panics with + `SLACK_BOT_TOKEN is required` [main.go:mustEnv]. +- An error from `newBot` or `serve` panics [main.go:main]. `slack.New` returns + an error when both `SLACK_APP_TOKEN` and `SLACK_SIGNING_SECRET` are empty + (check in `github.com/coder/chat/adapters/slack`) [main.go:newBot]. +- Socket Mode runs only when `opts.AppToken != ""`. The webhook server runs + only when `opts.SigningSecret != ""` [main.go:serve]. +- `serve` stops on the first transport error or on context cancellation. It + shuts down the webhook server before the runtime, because the runtime does + not wait for webhook requests in sync dispatch mode [main.go:serve]. +- `bot.Listen` gets `context.Background()`, so only `bot.Shutdown` stops it + [main.go:serve]. +- Each shutdown step gets its own 10-second deadline [main.go:shutdown]. +- `serve` joins the first error, both shutdown errors, and the errors of the + other running transports [main.go:serve]. + +## Side Effects + +| Environment variable | Default | Use | Source | +| --- | --- | --- | --- | +| `CHAT_DEMO_IN_MEMORY_STATE` | none (must be `1`) | Opt-in to in-memory state | [main.go:mustAllowDemoMemoryState] | +| `SLACK_BOT_TOKEN` | none (required) | `slack.Options.BotToken` | [main.go:main] | +| `SLACK_APP_TOKEN` | empty (Socket Mode off) | `slack.Options.AppToken` | [main.go:main] | +| `SLACK_SIGNING_SECRET` | empty (HTTP off) | `slack.Options.SigningSecret` | [main.go:main] | +| `PORT` | `8080` | Listen address `":" + PORT` | [main.go:main] | + +- Signals: `signal.NotifyContext` cancels the context on `os.Interrupt` and + `syscall.SIGTERM` [main.go:main]. +- Network: an outbound Socket Mode connection through + `bot.Listen(context.Background(), "slack")` [main.go:serve]. +- Network: `ListenAndServe` on `:$PORT` with `ReadHeaderTimeout` 5s, + `ReadTimeout` 10s, `WriteTimeout` 10s, and `IdleTimeout` 60s + [main.go:serveWebhooks], [main.go:newWebhookServer]. +- Network: `slack.New` builds the adapter, and each mention posts a reply with + `Thread.Post` [main.go:newBot]. +- Goroutines: one for `bot.Listen` and one for `serveWebhooks`, each only when + its transport is set [main.go:serve]. +- State: `memory.New()`. State is lost on exit [main.go:newBot]. +- Logging: `slog.Default()` goes to the adapter and the runtime. + `serveWebhooks` logs `serving Slack webhooks` [main.go:main], + [main.go:serveWebhooks]. + +## Verification + +From the repository root: + +- Run: `go test ./examples/slack-hello-world/...` +- Run: `go vet ./examples/slack-hello-world/...` diff --git a/examples/slack-nats-state/README.md b/examples/slack-nats-state/README.md index 24b7ec9..d896a64 100644 --- a/examples/slack-nats-state/README.md +++ b/examples/slack-nats-state/README.md @@ -1,5 +1,7 @@ # Slack NATS State Example +## Overview + This example runs a tiny Slack bot with NATS JetStream-backed runtime state. When the bot is mentioned in a channel, or messaged directly if you enable the DM event, it subscribes the thread and replies. Later messages in that subscribed @@ -108,3 +110,67 @@ NATS remembered this subscribed thread. Relevant docs: - NATS JetStream Key-Value store: https://docs.nats.io/nats-concepts/jetstream/key-value-store + +## Interface + +`package main` in its own module, `github.com/coder/chat/examples/slack-nats-state` +[go.mod:module]. It is built from [`main.go`](main.go) and takes no CLI flags. +It imports `github.com/coder/chat`, `github.com/coder/chat/adapters/slack`, +`github.com/coder/chat/state/nats` (as `chatnats`), and `github.com/nats-io/nats.go` [main.go:main]. + +| Symbol | Role | Source | +| --- | --- | --- | +| `main()` | Connects to NATS, builds the state, the Slack adapter, and the `chat.Chat` runtime, registers both handlers, and serves the webhook. | [main.go:main] | +| `OnNewMention` handler | Subscribes the thread, then posts `chat.Markdown("**hello** _world_ from NATS state. This thread is now subscribed.")`. | [main.go:main] | +| `OnSubscribedMessage` handler | Posts `chat.Markdown("NATS remembered this subscribed thread.")`. | [main.go:main] | +| `/webhooks/slack` | The only HTTP route, on `http.DefaultServeMux`. It serves `bot.Webhook("slack")`, which answers 405 to any method but POST. | [main.go:main] | +| `mustEnv` | Reads a required environment variable. | [main.go:mustEnv] | +| [`compose.yaml`](compose.yaml) | Local `nats` service and volume. | [compose.yaml:services.nats] | +| [`pitchfork.toml`](pitchfork.toml) | Pitchfork daemon `nats` that wraps the Compose service. | [pitchfork.toml:daemons.nats] | + +## Invariants + +- `NATS_URL`, `SLACK_SIGNING_SECRET`, and `SLACK_BOT_TOKEN` must be + non-empty. Otherwise `mustEnv` panics with ` is required` + [main.go:mustEnv]. +- An error from `natsgo.Connect`, `chatnats.New`, `slack.New`, `chat.New`, + `bot.Webhook("slack")`, or `http.ListenAndServe` panics [main.go:main]. +- The state keys use the `slack-example` namespace: `chatnats.New` with `Conn` and `Prefix: "slack-example"` + [main.go:main]. +- The mention handler subscribes the thread before it posts the reply + [main.go:main]. +- `go.mod` replaces `github.com/coder/chat` with `../..` and + `github.com/coder/chat/state/nats` with `../../state/nats`, so the example builds against + the local tree [go.mod:replace]. + +## Side Effects + +| Environment variable | Default | Use | Source | +| --- | --- | --- | --- | +| `NATS_URL` | none (required) | NATS connection | [main.go:main] | +| `SLACK_SIGNING_SECRET` | none (required) | `slack.Options.SigningSecret` | [main.go:main] | +| `SLACK_BOT_TOKEN` | none (required) | `slack.Options.BotToken` | [main.go:main] | +| `PORT` | `8080` | Listen address `":" + PORT` | [main.go:main] | + +- Network: `natsgo.Connect(NATS_URL)` opens a NATS connection [main.go:main]. `main` does not close it. +- Network: `http.ListenAndServe` on `:$PORT` with the default server, so no + server timeouts are set [main.go:main]. +- Network: `slack.New` builds the Slack adapter, and each handler posts a reply + with `Thread.Post` [main.go:main]. +- State: runtime state goes to NATS through `chatnats.New` [main.go:main]. +- Logging: `slog.Default()` goes to the adapter and the runtime. `main` logs + `listening` and `chat shutdown failed` [main.go:main]. +- Lifecycle: a deferred `bot.Shutdown(context.Background())` runs when `main` + returns or panics. `main` installs no signal handler [main.go:main]. +- Compose service `nats`: image `nats:2.11-alpine`, port `127.0.0.1:42220:4222`, volume `nats-data` at `/data` [compose.yaml:services.nats]. +- Command `-js -sd /data -m 8222` enables JetStream, stores data in `/data`, and serves monitoring on port 8222 inside the container. Port 8222 is not published [compose.yaml:services.nats]. +- Compose healthcheck: `wget -q -O- http://localhost:8222/healthz` every 2s, timeout 5s, 30 retries [compose.yaml:services.nats.healthcheck]. +- Pitchfork daemon `nats` in namespace `chat-sdk-go-slack-nats-state`: runs `docker compose up nats`, waits on `docker compose exec -T nats wget -q -O- http://localhost:8222/healthz`, and has `auto = ["start", "stop"]` [pitchfork.toml:daemons.nats]. + +## Verification + +This example is a separate Go module with no `_test.go` files, so `go test` +only builds it. From the repository root: + +- Run: `cd examples/slack-nats-state && go test ./...` +- Run: `cd examples/slack-nats-state && go vet ./...` diff --git a/examples/slack-postgres-state/README.md b/examples/slack-postgres-state/README.md index 2c0e46c..e31b0ef 100644 --- a/examples/slack-postgres-state/README.md +++ b/examples/slack-postgres-state/README.md @@ -1,5 +1,7 @@ # Slack Postgres State Example +## Overview + This example runs a tiny Slack bot with Postgres-backed runtime state. When the bot is mentioned in a channel, or messaged directly if you enable the DM event, it subscribes the thread and replies. Later messages in that subscribed thread @@ -99,3 +101,67 @@ Postgres remembered this subscribed thread. - Slack request signatures are verified with `SLACK_SIGNING_SECRET`. - The bot token is used for `auth.test` during adapter startup and `chat.postMessage` when replying. + +## Interface + +`package main` in its own module, `github.com/coder/chat/examples/slack-postgres-state` +[go.mod:module]. It is built from [`main.go`](main.go) and takes no CLI flags. +It imports `github.com/coder/chat`, `github.com/coder/chat/adapters/slack`, +`github.com/coder/chat/state/postgres` (as `chatpostgres`), and `github.com/jackc/pgx/v5/pgxpool` [main.go:main]. + +| Symbol | Role | Source | +| --- | --- | --- | +| `main()` | Connects to Postgres, builds the state, the Slack adapter, and the `chat.Chat` runtime, registers both handlers, and serves the webhook. | [main.go:main] | +| `OnNewMention` handler | Subscribes the thread, then posts `chat.Markdown("**hello** _world_ from Postgres state. This thread is now subscribed.")`. | [main.go:main] | +| `OnSubscribedMessage` handler | Posts `chat.Markdown("Postgres remembered this subscribed thread.")`. | [main.go:main] | +| `/webhooks/slack` | The only HTTP route, on `http.DefaultServeMux`. It serves `bot.Webhook("slack")`, which answers 405 to any method but POST. | [main.go:main] | +| `mustEnv` | Reads a required environment variable. | [main.go:mustEnv] | +| [`compose.yaml`](compose.yaml) | Local `postgres` service and volume. | [compose.yaml:services.postgres] | +| [`pitchfork.toml`](pitchfork.toml) | Pitchfork daemon `postgres` that wraps the Compose service. | [pitchfork.toml:daemons.postgres] | + +## Invariants + +- `DATABASE_URL`, `SLACK_SIGNING_SECRET`, and `SLACK_BOT_TOKEN` must be + non-empty. Otherwise `mustEnv` panics with ` is required` + [main.go:mustEnv]. +- An error from `pgxpool.New`, `chatpostgres.New`, `slack.New`, `chat.New`, + `bot.Webhook("slack")`, or `http.ListenAndServe` panics [main.go:main]. +- The state keys use the `slack-example` namespace: `chatpostgres.New` with `Pool` and `Namespace: "slack-example"` + [main.go:main]. +- The mention handler subscribes the thread before it posts the reply + [main.go:main]. +- `go.mod` replaces `github.com/coder/chat` with `../..` and + `github.com/coder/chat/state/postgres` with `../../state/postgres`, so the example builds against + the local tree [go.mod:replace]. + +## Side Effects + +| Environment variable | Default | Use | Source | +| --- | --- | --- | --- | +| `DATABASE_URL` | none (required) | Postgres connection | [main.go:main] | +| `SLACK_SIGNING_SECRET` | none (required) | `slack.Options.SigningSecret` | [main.go:main] | +| `SLACK_BOT_TOKEN` | none (required) | `slack.Options.BotToken` | [main.go:main] | +| `PORT` | `8080` | Listen address `":" + PORT` | [main.go:main] | + +- Network: `pgxpool.New(ctx, DATABASE_URL)` opens a connection pool [main.go:main]. `main` does not close it. +- Network: `http.ListenAndServe` on `:$PORT` with the default server, so no + server timeouts are set [main.go:main]. +- Network: `slack.New` builds the Slack adapter, and each handler posts a reply + with `Thread.Post` [main.go:main]. +- State: runtime state goes to Postgres through `chatpostgres.New` [main.go:main]. +- Logging: `slog.Default()` goes to the adapter and the runtime. `main` logs + `listening` and `chat shutdown failed` [main.go:main]. +- Lifecycle: a deferred `bot.Shutdown(context.Background())` runs when `main` + returns or panics. `main` installs no signal handler [main.go:main]. +- Compose service `postgres`: image `postgres:17-alpine`, port `127.0.0.1:54330:5432`, volume `postgres-data` at `/var/lib/postgresql/data` [compose.yaml:services.postgres]. +- The Compose environment sets `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` to `chat_sdk_go` [compose.yaml:services.postgres]. +- Compose healthcheck: `pg_isready -U chat_sdk_go -d chat_sdk_go` every 2s, timeout 5s, 30 retries [compose.yaml:services.postgres.healthcheck]. +- Pitchfork daemon `postgres` in namespace `chat-sdk-go-slack-postgres-state`: runs `docker compose up postgres`, waits on `docker compose exec -T postgres pg_isready -U chat_sdk_go -d chat_sdk_go`, and has `auto = ["start", "stop"]` [pitchfork.toml:daemons.postgres]. + +## Verification + +This example is a separate Go module with no `_test.go` files, so `go test` +only builds it. From the repository root: + +- Run: `cd examples/slack-postgres-state && go test ./...` +- Run: `cd examples/slack-postgres-state && go vet ./...` diff --git a/examples/slack-redis-state/README.md b/examples/slack-redis-state/README.md index bb7d5e0..309abe0 100644 --- a/examples/slack-redis-state/README.md +++ b/examples/slack-redis-state/README.md @@ -1,5 +1,7 @@ # Slack Redis State Example +## Overview + This example runs a tiny Slack bot with Redis-backed runtime state. When the bot is mentioned in a channel, or messaged directly if you enable the DM event, it subscribes the thread and replies. Later messages in that subscribed thread are @@ -98,3 +100,66 @@ Redis remembered this subscribed thread. - Slack request signatures are verified with `SLACK_SIGNING_SECRET`. - The bot token is used for `auth.test` during adapter startup and `chat.postMessage` when replying. + +## Interface + +`package main` in its own module, `github.com/coder/chat/examples/slack-redis-state` +[go.mod:module]. It is built from [`main.go`](main.go) and takes no CLI flags. +It imports `github.com/coder/chat`, `github.com/coder/chat/adapters/slack`, +`github.com/coder/chat/state/redis` (as `chatredis`), and `github.com/redis/go-redis/v9` [main.go:main]. + +| Symbol | Role | Source | +| --- | --- | --- | +| `main()` | Connects to Redis, builds the state, the Slack adapter, and the `chat.Chat` runtime, registers both handlers, and serves the webhook. | [main.go:main] | +| `OnNewMention` handler | Subscribes the thread, then posts `chat.Markdown("**hello** _world_ from Redis state. This thread is now subscribed.")`. | [main.go:main] | +| `OnSubscribedMessage` handler | Posts `chat.Markdown("Redis remembered this subscribed thread.")`. | [main.go:main] | +| `/webhooks/slack` | The only HTTP route, on `http.DefaultServeMux`. It serves `bot.Webhook("slack")`, which answers 405 to any method but POST. | [main.go:main] | +| `mustEnv` | Reads a required environment variable. | [main.go:mustEnv] | +| [`compose.yaml`](compose.yaml) | Local `redis` service and volume. | [compose.yaml:services.redis] | +| [`pitchfork.toml`](pitchfork.toml) | Pitchfork daemon `redis` that wraps the Compose service. | [pitchfork.toml:daemons.redis] | + +## Invariants + +- `REDIS_URL`, `SLACK_SIGNING_SECRET`, and `SLACK_BOT_TOKEN` must be + non-empty. Otherwise `mustEnv` panics with ` is required` + [main.go:mustEnv]. +- An error from `goredis.ParseURL`, `chatredis.New`, `slack.New`, `chat.New`, + `bot.Webhook("slack")`, or `http.ListenAndServe` panics [main.go:main]. +- The state keys use the `slack-example` namespace: `chatredis.New` with `Client` and `Prefix: "slack-example"` + [main.go:main]. +- The mention handler subscribes the thread before it posts the reply + [main.go:main]. +- `go.mod` replaces `github.com/coder/chat` with `../..` and + `github.com/coder/chat/state/redis` with `../../state/redis`, so the example builds against + the local tree [go.mod:replace]. + +## Side Effects + +| Environment variable | Default | Use | Source | +| --- | --- | --- | --- | +| `REDIS_URL` | none (required) | Redis connection | [main.go:main] | +| `SLACK_SIGNING_SECRET` | none (required) | `slack.Options.SigningSecret` | [main.go:main] | +| `SLACK_BOT_TOKEN` | none (required) | `slack.Options.BotToken` | [main.go:main] | +| `PORT` | `8080` | Listen address `":" + PORT` | [main.go:main] | + +- Network: `goredis.ParseURL(REDIS_URL)` and `goredis.NewClient` build a Redis client [main.go:main]. `main` does not close it. +- Network: `http.ListenAndServe` on `:$PORT` with the default server, so no + server timeouts are set [main.go:main]. +- Network: `slack.New` builds the Slack adapter, and each handler posts a reply + with `Thread.Post` [main.go:main]. +- State: runtime state goes to Redis through `chatredis.New` [main.go:main]. +- Logging: `slog.Default()` goes to the adapter and the runtime. `main` logs + `listening` and `chat shutdown failed` [main.go:main]. +- Lifecycle: a deferred `bot.Shutdown(context.Background())` runs when `main` + returns or panics. `main` installs no signal handler [main.go:main]. +- Compose service `redis`: image `redis:7.2-alpine`, port `127.0.0.1:63800:6379`, volume `redis-data` at `/data` [compose.yaml:services.redis]. +- Compose healthcheck: `redis-cli ping` every 2s, timeout 5s, 30 retries [compose.yaml:services.redis.healthcheck]. +- Pitchfork daemon `redis` in namespace `chat-sdk-go-slack-redis-state`: runs `docker compose up redis`, waits on `docker compose exec -T redis redis-cli ping`, and has `auto = ["start", "stop"]` [pitchfork.toml:daemons.redis]. + +## Verification + +This example is a separate Go module with no `_test.go` files, so `go test` +only builds it. From the repository root: + +- Run: `cd examples/slack-redis-state && go test ./...` +- Run: `cd examples/slack-redis-state && go vet ./...` diff --git a/internal/README.md b/internal/README.md new file mode 100644 index 0000000..29b2494 --- /dev/null +++ b/internal/README.md @@ -0,0 +1,24 @@ +# internal + +## Overview + +`internal` groups private helper packages of the `github.com/coder/chat` module. +It has no Go source files of its own. Go allows only code in the tree rooted at +`github.com/coder/chat` to import these packages. The importers are the +adapters (`github.com/coder/chat/adapters/linear`, +`github.com/coder/chat/adapters/slack/slackapi`) and the state backends +(`github.com/coder/chat/state/memory`, `github.com/coder/chat/state/nats`, +`github.com/coder/chat/state/postgres`, `github.com/coder/chat/state/redis`). +The `state/nats`, `state/postgres`, and `state/redis` backends are separate +modules in `go.work`. Their import paths are inside that tree, so the rule +allows them. + +Run all three packages with `go test ./internal/...`. None of them has its own +tests, so the command reports `[no test files]`. Each child README names the +importer tests that exercise it. + +## Subdirectories & Boundaries + +- ratelimit: [./ratelimit/README.md](./ratelimit/README.md) - Shared retry mechanics for the adapter rate-limit loops: `BufferRequestBody`, `CloneRequest`, `BackoffDelay` (1-based attempt, clamped to `maxDelay`), and `SleepCtx` (never outlasts the caller's context). Throttling detection stays in each adapter. +- statetest: [./statetest/README.md](./statetest/README.md) - Test-only `chat.State` conformance suite: `Harness` and `RunStateConformance`, which check subscriptions, event dedupe, lock ownership and expiry, and that a cancelled context changes no state. Every state backend's tests call it. +- tokens: [./tokens/README.md](./tokens/README.md) - `New` returns a 32-character hex token from 16 `crypto/rand` bytes. The memory, nats, and postgres backends use it for lock lease tokens. diff --git a/internal/ratelimit/README.md b/internal/ratelimit/README.md new file mode 100644 index 0000000..e559ddc --- /dev/null +++ b/internal/ratelimit/README.md @@ -0,0 +1,49 @@ +# ratelimit + +## Overview + +Package `ratelimit` holds the transport-level retry mechanics that the per-adapter +rate-limit retry loops share: request body buffering, backoff math, and a +context-bounded sleep. The package doc says the `RetryPolicy` and `RateLimited` +types stay per-adapter and public, because throttling detection is +platform-specific. Only the identical mechanics live here +(`[ratelimit.go:package ratelimit]`, see `docs/adr/0005-rate-limit-handling.md`). + +These packages import it: + +| Importer | Symbols used | +| --- | --- | +| `github.com/coder/chat/adapters/linear` (`retry.go`) | `BufferRequestBody`, `CloneRequest`, `BackoffDelay`, `SleepCtx` | +| `github.com/coder/chat/adapters/slack/slackapi` (`retry.go`) | `BufferRequestBody`, `CloneRequest`, `BackoffDelay`, `SleepCtx` | +| `github.com/coder/chat/adapters/slack/slackapi` (`socketmode.go`) | `BackoffDelay` | + +## Interface + +- `func BufferRequestBody(req *http.Request) ([]byte, error)`: reads the request body so each retry can replay it, and rewinds `req` to the buffered bytes. `[ratelimit.go:BufferRequestBody]` +- `func CloneRequest(req *http.Request, body []byte) *http.Request`: returns a copy of `req` whose body is rewound to `body`, for retry attempts after the first. `[ratelimit.go:CloneRequest]` +- `func BackoffDelay(base, maxDelay time.Duration, attempt int, retryAfter time.Duration) time.Duration`: computes the next backoff delay. `[ratelimit.go:BackoffDelay]` +- `func SleepCtx(ctx context.Context, d time.Duration) error`: sleeps for `d`, or returns the context error if `ctx` ends first. `[ratelimit.go:SleepCtx]` + +## Invariants + +- Keep platform-specific throttling detection (`RetryPolicy`, `RateLimited`) in the adapters. Put only mechanics that are identical across adapters in this package. `[ratelimit.go:package ratelimit]` +- A nil `req.Body` returns `(nil, nil)` and leaves `req` unchanged. `[ratelimit.go:BufferRequestBody]` +- After a successful read, `req.Body` is a fresh reader over the buffered bytes and `req.ContentLength` equals `len(body)`. `[ratelimit.go:BufferRequestBody]` +- A nil `body` leaves the clone's body as `req.Clone` produced it. A non-nil `body` replaces the clone's body and sets `ContentLength` to `len(body)`. `[ratelimit.go:CloneRequest]` +- `attempt` is 1-based: the delay is `base << (attempt - 1)`, so attempt 1 starts from `base`. An `attempt` below 1 gives a negative shift count, and Go panics at run time. The code does not guard this. `[ratelimit.go:BackoffDelay]` +- A shifted delay that is non-positive (shift overflow) or above `maxDelay` becomes `maxDelay`. `[ratelimit.go:BackoffDelay]` +- The result is `min(max(delay, retryAfter), maxDelay)`. A platform `retryAfter` can raise the delay but never above `maxDelay`. `[ratelimit.go:BackoffDelay]` +- `d <= 0` returns `ctx.Err()` at once without waiting. `[ratelimit.go:SleepCtx]` +- The sleep never outlasts the caller's context, so a retry never exceeds the platform ack window. `[ratelimit.go:SleepCtx]` + +## Side Effects + +- Reads the whole request body into memory with `io.ReadAll`, closes the original body, and mutates `req.Body` and `req.ContentLength` in place. `[ratelimit.go:BufferRequestBody]` +- Allocates a new request with `req.Clone(req.Context())`. The original request is not mutated. `[ratelimit.go:CloneRequest]` +- Blocks the calling goroutine on a `time.NewTimer` until the timer fires or `ctx` is done. A deferred `timer.Stop` releases the timer. `[ratelimit.go:SleepCtx]` +- No package-level state, goroutines, locks, network, or disk access. `[ratelimit.go]` + +## Verification + +- Run: `go test ./internal/ratelimit/...` (reports `[no test files]`; this package has no tests of its own). +- The importers' retry tests exercise it: `go test ./adapters/linear/... ./adapters/slack/slackapi/...` (for example `TestCallRetriesThrottling` and `TestCallRetryStopsBeforeDeadline` in `adapters/slack/slackapi/retry_test.go`). diff --git a/internal/statetest/README.md b/internal/statetest/README.md new file mode 100644 index 0000000..15be1dd --- /dev/null +++ b/internal/statetest/README.md @@ -0,0 +1,46 @@ +# statetest + +## Overview + +Package `statetest` is the shared conformance suite for `chat.State` backends. +It lives in a non-test file (`state.go`) and imports `testing`, so only test code +uses it. Each backend's `TestStateConformance` calls `RunStateConformance` with a +factory that builds a fresh backend. These test files import it: + +- `state/memory/memory_test.go` (`github.com/coder/chat/state/memory`) +- `state/nats/nats_test.go` (`github.com/coder/chat/state/nats`) +- `state/postgres/postgres_test.go` (`github.com/coder/chat/state/postgres`) +- `state/redis/redis_test.go` (`github.com/coder/chat/state/redis`, twice: `TestStateConformance` with miniredis and `TestStateConformanceWithRedisContainer`) + +## Interface + +- `type Harness struct`: the backend under test and its timing controls. `[state.go:Harness]` + - `State chat.State`: the backend under test. + - `AdvanceTime func(time.Duration)`: optional fake clock. When nil, the suite sleeps in real time. + - `ShortTTL time.Duration`: optional TTL for entries that the suite lets expire. Zero means 25ms. + - `ExpiryWait time.Duration`: optional time the suite advances to pass `ShortTTL`. Zero means 60ms. +- `func RunStateConformance(t *testing.T, newState func(*testing.T) Harness)`: runs the conformance subtests against harnesses from `newState`. `[state.go:RunStateConformance]` + +## Invariants + +The suite asserts these contracts on every `chat.State` backend: + +- `subscriptions`: a new thread is not subscribed. After `SubscribeThread` it is subscribed. After `UnsubscribeThread` it is not. `[state.go:RunStateConformance]` +- `dedupe`: the first `MarkEvent` for an ID returns `true`. A duplicate within the TTL returns `false`. After the TTL expires, `MarkEvent` returns `true` again. `[state.go:RunStateConformance]` +- `locks`: a second `AcquireLock` on a held key fails. `ReleaseLock` with a stale token returns `false` and keeps the lock. The current owner can `ExtendLock` and `ReleaseLock`. After the TTL expires, a new owner can acquire the key, and the old lease's `ReleaseLock` returns `false` and keeps the newer lock. `[state.go:RunStateConformance]` +- `context cancellation`: with a cancelled context, `SubscribeThread`, `MarkEvent`, and `AcquireLock` return an error and do not change state. A later call with a live context succeeds as if the cancelled call never happened. `[state.go:RunStateConformance]` +- `newState` must return an independent backend on each call. Each of the four subtests calls it once and runs with `t.Parallel()`. `[state.go:RunStateConformance]` +- `ExpiryWait` must be longer than `ShortTTL`, because the suite expects entries created with `ShortTTL` to expire after one `ExpiryWait`. `[state.go:Harness.shortTTL]`, `[state.go:Harness.expiryWait]` +- `AdvanceTime`, when set, replaces the real sleep. `[state.go:advanceTime]` + +## Side Effects + +- Fails the calling test with `t.Fatal` or `t.Fatalf` on any contract violation. `[state.go:RunStateConformance]` +- Starts four parallel subtests with `t.Run` and `t.Parallel`. `[state.go:RunStateConformance]` +- Without `AdvanceTime`, blocks on `time.Sleep(ExpiryWait)` (60ms by default) in the `dedupe` and `locks` subtests. `[state.go:advanceTime]` +- Mutates the backend in `Harness.State`. Any I/O comes from that backend, not from this package. `[state.go:RunStateConformance]` + +## Verification + +- Run: `go test ./internal/statetest/...` (reports `[no test files]`; the suite has no tests of its own). +- The backends run the suite: `go test -run '^TestStateConformance$' ./state/memory/... ./state/nats/... ./state/redis/... ./state/postgres/...`. The memory, nats (embedded server), and redis (miniredis) runs need no Docker. The postgres run needs a healthy testcontainers (Docker) provider and skips without one. `TestStateConformanceWithRedisContainer` also needs Docker, and this `-run` filter excludes it. diff --git a/internal/tokens/README.md b/internal/tokens/README.md new file mode 100644 index 0000000..5271a2f --- /dev/null +++ b/internal/tokens/README.md @@ -0,0 +1,31 @@ +# tokens + +## Overview + +Package `tokens` creates random opaque token strings. The state backends use it to +create the owner token of a lock lease in `AcquireLock`. These packages import it: + +- `github.com/coder/chat/state/memory` (`memory.go`) +- `github.com/coder/chat/state/nats` (`nats.go`) +- `github.com/coder/chat/state/postgres` (`postgres.go`) + +`github.com/coder/chat/state/redis` does not import it. + +## Interface + +- `func New() (string, error)`: returns a new random token. `[tokens.go:New]` + +## Invariants + +- On success, the token is the lowercase hex encoding of 16 random bytes, so it is 32 characters long. `[tokens.go:New]` +- If `crypto/rand.Read` fails, `New` returns `""` and the error unchanged. `[tokens.go:New]` + +## Side Effects + +- Reads 16 bytes from `crypto/rand` (operating-system entropy). `[tokens.go:New]` +- No package-level state, goroutines, locks, network, or disk access. `[tokens.go]` + +## Verification + +- Run: `go test ./internal/tokens/...` (reports `[no test files]`; this package has no tests of its own). +- The lock tests of the state backends exercise it: `go test ./state/memory/... ./state/nats/... ./state/postgres/...`. The `state/postgres` conformance test needs a healthy testcontainers (Docker) provider and skips without one. diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..e7216b2 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,147 @@ +# scripts + +## Overview + +This directory holds `clawpatch-autofix.sh`, a Bash driver behind the +`mise run clawpatch:autofix` task. The script asks Clawpatch for the next open +finding, runs `clawpatch fix` until Clawpatch revalidates the finding as +`fixed`, runs two Codex cleanup passes (`$simplify` and `$deslop`), checks the +finding again after each pass, and then asks Codex to commit the result. + +CAUTION: The script runs Codex with `--dangerously-bypass-approvals-and-sandbox` +and tells Codex to "commit everything". It does not check for a clean working +tree first, so uncommitted changes that exist before the run go into the commit. + +## Interface + +### Entry points + +- `mise run clawpatch:autofix` runs `scripts/clawpatch-autofix.sh` with no + arguments. Its description is "Fix Clawpatch findings, run Codex cleanup + passes, and commit". [mise.toml:tasks."clawpatch:autofix"] +- `scripts/clawpatch-autofix.sh` takes no positional arguments and no flags. + The top level never reads `$@`. [clawpatch-autofix.sh:190-197] + +### Environment variables + +| Variable | Default | Effect | Source | +| --- | --- | --- | --- | +| `ROOT` | `$(git rev-parse --show-toplevel)` | Repository root. Passed to `clawpatch --root`, `codex exec -C`, and `git -C`. | [clawpatch-autofix.sh:ROOT] | +| `MAX_FINDINGS` | `1` | Maximum number of findings processed in one run. | [clawpatch-autofix.sh:MAX_FINDINGS] | +| `MAX_FIX_ATTEMPTS` | `3` | Maximum `clawpatch fix` attempts per call to `fix_until_fixed`. | [clawpatch-autofix.sh:MAX_FIX_ATTEMPTS] | +| `MAX_HYGIENE_ROUNDS` | `2` | Maximum fix, simplify, and deslop rounds per finding. | [clawpatch-autofix.sh:MAX_HYGIENE_ROUNDS] | +| `CODEX_MODEL` | unset | When set, the script adds `--model "$CODEX_MODEL"` to every Codex call. | [clawpatch-autofix.sh:codex_one_shot] | + +### Exit codes + +- `0`: `next_finding` returned no ID. The script prints `no open findings`. + [clawpatch-autofix.sh:192-195] +- `0`: the main loop finished `MAX_FINDINGS` findings. + [clawpatch-autofix.sh:190-197] +- Non-zero: `fix_until_fixed`, `process_finding`, or `commit_current_changes` + returned `1`, or another unguarded command failed under `set -Eeuo pipefail`. + [clawpatch-autofix.sh:2] [clawpatch-autofix.sh:34] + [clawpatch-autofix.sh:154] [clawpatch-autofix.sh:187] + +### Functions + +- `claw`: runs `clawpatch --root "$ROOT" "$@"`. [clawpatch-autofix.sh:claw] +- `codex_one_shot `: runs `codex exec -C "$ROOT"` with + `--dangerously-bypass-approvals-and-sandbox`, an optional + `--model "$CODEX_MODEL"`, and the prompt. Stdin comes from `/dev/null`. + [clawpatch-autofix.sh:codex_one_shot] +- `commit_current_changes`: sends Codex the prompt "commit everything to git. + Do not push or open a pull request." Returns `1` if `git status --porcelain` + still shows changes. [clawpatch-autofix.sh:commit_current_changes] +- `finding_id`: reads JSON on stdin and prints `.finding.id // .id`. + [clawpatch-autofix.sh:finding_id] +- `finding_status`: reads JSON on stdin and prints + `.outcome // .status // .finding.status`. [clawpatch-autofix.sh:finding_status] +- `finding_details_file `: builds or fetches the finding details JSON once + per run and prints its path. [clawpatch-autofix.sh:finding_details_file] +- `finding_title `: prints the finding title, or `untitled finding`. + [clawpatch-autofix.sh:finding_title] +- `print_finding_summary `: prints title, severity, feature, description, + recommendation, and evidence lines. [clawpatch-autofix.sh:print_finding_summary] +- `next_finding`: runs `clawpatch next --json` and prints the finding ID. + [clawpatch-autofix.sh:next_finding] +- `revalidate `: runs `clawpatch revalidate --finding --json` and + prints the status. [clawpatch-autofix.sh:revalidate] +- `fix_until_fixed `: runs `clawpatch fix` and `revalidate` up to + `MAX_FIX_ATTEMPTS` times. [clawpatch-autofix.sh:fix_until_fixed] +- `process_finding `: runs fix, `$simplify`, revalidate, `$deslop`, + revalidate, then commit, up to `MAX_HYGIENE_ROUNDS` times. + [clawpatch-autofix.sh:process_finding] + +### Subdirectories + +- None. + +## Invariants + +- `set -Eeuo pipefail` is on. Unset variables and unguarded failing commands + stop the script. [clawpatch-autofix.sh:2] +- A fix attempt succeeds only when `revalidate` prints exactly `fixed`. + [clawpatch-autofix.sh:fix_until_fixed] +- `process_finding` calls `fix_until_fixed` without a guard. Under `set -e`, + a finding that is still not `fixed` after `MAX_FIX_ATTEMPTS` ends the whole + run with a non-zero exit, so no later hygiene round or finding runs. + [clawpatch-autofix.sh:166] [clawpatch-autofix.sh:2] +- The script commits a finding only after `revalidate` prints `fixed` after the + `$simplify` pass and again after the `$deslop` pass. Any other status starts + the next hygiene round. [clawpatch-autofix.sh:process_finding] +- A commit counts as done only when `git status --porcelain` is empty after the + Codex commit call. [clawpatch-autofix.sh:commit_current_changes] +- Codex always runs with stdin from `/dev/null`. + [clawpatch-autofix.sh:codex_one_shot] +- The commit prompt tells Codex not to push and not to open a pull request. + [clawpatch-autofix.sh:commit_current_changes] +- The temporary directory is removed on exit by `trap 'rm -rf "$tmpdir"' EXIT`. + [clawpatch-autofix.sh:10] +- The script does not check for a clean working tree at start, and does not + check that `clawpatch`, `codex`, `jq`, or `git` exist. A missing tool fails at + its first use. [clawpatch-autofix.sh:1-10] + +## Side Effects + +- Creates a temporary directory with `mktemp -d` and writes `next.json`, + `fix-.json`, `revalidate-.json`, and `finding-details-.json` into + it. [clawpatch-autofix.sh:9] [clawpatch-autofix.sh:next_finding] + [clawpatch-autofix.sh:fix_until_fixed] [clawpatch-autofix.sh:revalidate] + [clawpatch-autofix.sh:finding_details_file] +- Reads `$ROOT/.clawpatch/findings/.json` and + `$ROOT/.clawpatch/features/.json` with `jq`. If the finding file + is missing, runs `clawpatch show --finding --json` instead. + [clawpatch-autofix.sh:finding_details_file] +- Runs these Clawpatch commands against `$ROOT`: + `clawpatch next --json`, + `clawpatch fix --finding --skip-git-repo-check --json`, and + `clawpatch revalidate --finding --json`. + [clawpatch-autofix.sh:next_finding] + [clawpatch-autofix.sh:fix_until_fixed] [clawpatch-autofix.sh:revalidate] +- Runs Codex in `$ROOT` with approvals and the sandbox disabled, using the + literal prompts `$simplify` and `$deslop`. The single quotes stop shell + expansion. [clawpatch-autofix.sh:168] [clawpatch-autofix.sh:175] + [clawpatch-autofix.sh:codex_one_shot] +- Creates git commits through Codex. The script itself runs only + `git rev-parse`, `git status --porcelain`, and `git status --short`. + [clawpatch-autofix.sh:ROOT] [clawpatch-autofix.sh:commit_current_changes] +- Leaves edits in the working tree when a finding fails. The script contains no + `git reset`, `git checkout`, or `git stash`. [clawpatch-autofix.sh:1-197] +- Prints to stdout: progress lines that start with `==>`, the finding summary + from `print_finding_summary` in the first hygiene round, and + `no open findings`. Prints failure messages plus `git status --short` output + to stderr. + [clawpatch-autofix.sh:fix_until_fixed] [clawpatch-autofix.sh:process_finding] + [clawpatch-autofix.sh:print_finding_summary] [clawpatch-autofix.sh:193] + [clawpatch-autofix.sh:commit_current_changes] +- Makes no direct network calls. Network use by `clawpatch` and `codex` is not + visible in this script. [clawpatch-autofix.sh:1-197] + +## Verification + +- Run: `bash -n scripts/clawpatch-autofix.sh` (syntax check only). The + repository has no tests and no shell linter task for this script. +- Runner: `mise run clawpatch:autofix`. CAUTION: This runs Clawpatch and Codex + with the sandbox disabled, edits files, and creates git commits. Run it only + on a branch where those commits are wanted. diff --git a/state/README.md b/state/README.md new file mode 100644 index 0000000..d9d293f --- /dev/null +++ b/state/README.md @@ -0,0 +1,54 @@ +# state + +## Overview + +`state` groups the implementations of the `chat.State` interface from +`github.com/coder/chat`. The runtime uses that interface for thread +subscriptions, event dedupe (`MarkEvent`), and token-checked thread locks +(`AcquireLock`, `ExtendLock`, `ReleaseLock`). This directory has no Go files +of its own. `memory` is part of the root Go module. `nats`, `postgres`, and +`redis` are separate Go modules, each with its own `go.mod`, so their driver +dependencies stay out of the root module. Each package declares +`var _ chat.State = (*State)(nil)` and runs the shared conformance suite from +`github.com/coder/chat/internal/statetest` in its tests. + +Shared behavior across all four backends: + +- Empty event IDs, lock keys, and lease fields fail with a + `" state: ... is required"` error. Non-positive TTLs fail with + `"... must be positive"`. Empty thread IDs fail the same way, except in + `memory`'s `IsThreadSubscribed`, which returns `false`. +- `MarkEvent` and `AcquireLock` are first-writer-wins. A duplicate event or a + held lock returns `false` with a `nil` error. +- `ExtendLock` and `ReleaseLock` act only when the stored token equals + `lease.Token`. A mismatch or a missing lock returns `false` with a `nil` + error. +- Subscriptions never expire. +- `Shutdown` on the three networked backends closes the caller-supplied + connection, pool, or client. + +## Subdirectories & Boundaries + +- memory: [./memory/README.md](./memory/README.md) - In-process maps under one + mutex, for tests and local development. Expired records are pruned lazily. + `Shutdown` makes later calls fail with `"memory state: closed"`. +- nats: [./nats/README.md](./nats/README.md) - NATS JetStream KV buckets + `_sub`, `_event`, and `_lock` (default prefix + `chat`). Expiry uses bucket TTLs from `Options`, not per-call `ttl` + arguments. Locks use revision-gated update and delete. +- postgres: [./postgres/README.md](./postgres/README.md) - Tables + `chat_runtime_subscriptions`, `chat_runtime_events`, and + `chat_runtime_locks`, created on `New` under an advisory lock. Rows are + scoped by `Namespace` (default `chat`). Expiry uses the database `now()`. +- redis: [./redis/README.md](./redis/README.md) - Keys + `:sub:`, `:event:`, and `:lock:` + (default prefix `chat`). Expiry uses Redis key TTLs. Lua scripts check the + token before `PEXPIRE` or `DEL`. + +## Verification + +- `go test ./state/memory/...` (from the repository root) +- `cd state/nats && go test ./...` (embedded NATS server, no Docker) +- `cd state/postgres && go test ./...` (Testcontainers, needs Docker) +- `cd state/redis && go test ./...` (Testcontainers test needs Docker, a + miniredis test does not) diff --git a/state/memory/README.md b/state/memory/README.md new file mode 100644 index 0000000..2c3be66 --- /dev/null +++ b/state/memory/README.md @@ -0,0 +1,104 @@ +# memory + +## Overview + +Package `memory` (import path `github.com/coder/chat/state/memory`) is an +in-process implementation of the `chat.State` interface from +`github.com/coder/chat`. The package comment describes it as state "for tests +and local development" [doc.go:memory]. All data lives in Go maps guarded by +one mutex. Nothing survives a process restart, and nothing is shared between +processes. It is part of the root Go module, not a separate module. + +## Interface + +- `State` [memory.go:State]: in-process state. Unexported fields: `mu` + (`sync.Mutex`), `subscribed`, `events`, `locks` maps, `closed` + (`atomic.Bool`), and `now` (clock func). +- `var _ chat.State = (*State)(nil)` [memory.go:State]: compile-time check + that `*State` implements `chat.State`. +- `func New() *State` [memory.go:New]: returns a `State` with empty maps and + `now` set to `time.Now`. There are no options. +- `(*State).IsThreadSubscribed(ctx, id chat.ThreadID) (bool, error)` + [memory.go:State.IsThreadSubscribed] +- `(*State).SubscribeThread(ctx, id chat.ThreadID) error` + [memory.go:State.SubscribeThread] +- `(*State).UnsubscribeThread(ctx, id chat.ThreadID) error` + [memory.go:State.UnsubscribeThread] +- `(*State).MarkEvent(ctx, id string, ttl time.Duration) (bool, error)` + [memory.go:State.MarkEvent]: returns `true` the first time an ID is seen + within its TTL. +- `(*State).AcquireLock(ctx, key string, ttl time.Duration) (chat.LockLease, bool, error)` + [memory.go:State.AcquireLock] +- `(*State).ExtendLock(ctx, lease chat.LockLease, ttl time.Duration) (bool, error)` + [memory.go:State.ExtendLock] +- `(*State).ReleaseLock(ctx, lease chat.LockLease) (bool, error)` + [memory.go:State.ReleaseLock] +- `(*State).Shutdown(context.Context) error` [memory.go:State.Shutdown] + +## Invariants + +- Every method except `Shutdown` calls `checkOperation`. It returns + `"memory state: nil state"` for a nil receiver, `ctx.Err()` for a done + context, and `"memory state: closed"` after `Shutdown` + [memory.go:State.checkOperation]. +- `lockOperationAfterCheck` runs `checkOperation` again after it takes `mu`. + An operation that waits on the mutex while `Shutdown` runs fails with + `"memory state: closed"` and does not mutate state + [memory.go:State.lockOperationAfterCheck]. +- `SubscribeThread` and `UnsubscribeThread` reject an empty ID with + `"memory state: thread id is required"` + [memory.go:State.SubscribeThread] [memory.go:State.UnsubscribeThread]. + `IsThreadSubscribed` does not check for an empty ID + [memory.go:State.IsThreadSubscribed]. +- Subscriptions have no expiry [memory.go:State.SubscribeThread]. +- `MarkEvent` rejects an empty ID with `"memory state: event id is required"` + and `ttl <= 0` with `"memory state: dedupe ttl must be positive"` + [memory.go:State.MarkEvent]. +- `MarkEvent` on an unexpired ID returns `(false, nil)` and does not refresh + its expiry [memory.go:State.MarkEvent]. +- `AcquireLock` rejects an empty key with `"memory state: lock key is required"` + and `ttl <= 0` with `"memory state: lock ttl must be positive"`. A token + error is wrapped as `"memory state: create lock token: %w"` + [memory.go:State.AcquireLock]. +- `AcquireLock` on a key with an unexpired lock returns + `(chat.LockLease{}, false, nil)`. At most one unexpired holder exists per key + [memory.go:State.AcquireLock]. +- `ExtendLock` and `ReleaseLock` reject a lease with an empty `Key` or `Token` + with `"memory state: lock lease is required"`. `ExtendLock` also rejects + `ttl <= 0` with `"memory state: lock ttl must be positive"` + [memory.go:State.ExtendLock] [memory.go:State.ReleaseLock]. +- `ExtendLock` and `ReleaseLock` act only when the stored token equals + `lease.Token`. A missing, expired, or mismatched lock returns `(false, nil)`, + not an error [memory.go:State.ExtendLock] [memory.go:State.ReleaseLock]. +- An event or lock is expired when `!expiry.After(now)`, that is + `expiry <= now` [memory.go:State.pruneExpiredEvents] + [memory.go:State.pruneExpiredLocks]. +- All map reads and writes happen while `mu` is held. The `closed` flag is an + `atomic.Bool`, so `checkOperation` reads it without the mutex + [memory.go:State]. + +## Side Effects + +- `SubscribeThread` sets `subscribed[id] = true`. `UnsubscribeThread` deletes + `subscribed[id]` [memory.go:State.SubscribeThread] + [memory.go:State.UnsubscribeThread]. +- `MarkEvent` scans all events, deletes expired ones, then stores + `events[id] = now + ttl` for a new ID [memory.go:State.MarkEvent] + [memory.go:State.pruneExpiredEvents]. +- `AcquireLock`, `ExtendLock`, and `ReleaseLock` scan all locks and delete + expired ones before they act [memory.go:State.pruneExpiredLocks]. +- `AcquireLock` creates a token with `tokens.New()` from + `github.com/coder/chat/internal/tokens`, then stores + `locks[key] = lockRecord{token, now + ttl}` [memory.go:State.AcquireLock]. +- `ExtendLock` overwrites the lock expiry with `now + ttl` + [memory.go:State.ExtendLock]. `ReleaseLock` deletes `locks[key]` + [memory.go:State.ReleaseLock]. +- `Shutdown` stores `closed = true`, then takes and releases `mu`. It does not + clear subscriptions, events, or locks, and always returns `nil` + [memory.go:State.Shutdown]. +- No goroutines, timers, or background cleanup exist. Expired records are + removed only during `MarkEvent` and the lock methods [memory.go:State]. + +## Verification + +- Run from the repository root: `go test ./state/memory/...` diff --git a/state/nats/README.md b/state/nats/README.md new file mode 100644 index 0000000..5c1dcdd --- /dev/null +++ b/state/nats/README.md @@ -0,0 +1,129 @@ +# nats + +## Overview + +Package `nats` (module and import path `github.com/coder/chat/state/nats`) is a +NATS JetStream-backed implementation of the `chat.State` interface from +`github.com/coder/chat` [doc.go:nats]. It stores subscriptions, dedupe events, +and thread locks in three JetStream key-value (KV) buckets. Expiry comes from +per-bucket TTLs set at construction, not from the per-call `ttl` arguments +[nats.go:Options]. It is a separate Go module with its own `go.mod` +[go.mod:module]. `nats.go` imports `github.com/coder/chat`, +`github.com/coder/chat/internal/tokens`, `github.com/nats-io/nats.go`, and +`github.com/nats-io/nats.go/jetstream`. + +## Interface + +- `Options` [nats.go:Options]: `Conn *natsgo.Conn`, `Prefix string`, + `DedupeTTL time.Duration`, `ThreadLockTTL time.Duration`. +- `State` [nats.go:State]: unexported fields `nc`, `sub`, `event`, `lock` + (`jetstream.KeyValue`), and `once` (`sync.Once`). +- `var _ chat.State = (*State)(nil)` [nats.go:State]: compile-time check that + `*State` implements `chat.State`. +- `func New(ctx context.Context, opts Options) (*State, error)` [nats.go:New] +- `(*State).IsThreadSubscribed(ctx, id chat.ThreadID) (bool, error)` + [nats.go:State.IsThreadSubscribed] +- `(*State).SubscribeThread(ctx, id chat.ThreadID) error` + [nats.go:State.SubscribeThread] +- `(*State).UnsubscribeThread(ctx, id chat.ThreadID) error` + [nats.go:State.UnsubscribeThread] +- `(*State).MarkEvent(ctx, id string, ttl time.Duration) (bool, error)` + [nats.go:State.MarkEvent] +- `(*State).AcquireLock(ctx, key string, ttl time.Duration) (chat.LockLease, bool, error)` + [nats.go:State.AcquireLock] +- `(*State).ExtendLock(ctx, lease chat.LockLease, ttl time.Duration) (bool, error)` + [nats.go:State.ExtendLock] +- `(*State).ReleaseLock(ctx, lease chat.LockLease) (bool, error)` + [nats.go:State.ReleaseLock] +- `(*State).Shutdown(context.Context) error` [nats.go:State.Shutdown] + +## Invariants + +- `New` requires `opts.Conn`. A nil connection fails with + `"nats state: conn is required"` [nats.go:New]. +- `New` computes the prefix as + `strings.TrimRight(strings.TrimSpace(opts.Prefix), "_")`. An empty result + becomes `"chat"` [nats.go:New]. +- The prefix must contain only ASCII letters, digits, `-`, and `_`. Otherwise + `New` fails with `"nats state: prefix %q contains invalid characters"` + [nats.go:validBucketToken] [nats.go:New]. +- `DedupeTTL <= 0` defaults to `24 * time.Hour`. `ThreadLockTTL <= 0` + defaults to `2 * time.Minute` [nats.go:New]. +- Bucket names are `prefix + "_" + name` for `sub`, `event`, and `lock`. With + the default prefix they are `chat_sub`, `chat_event`, and `chat_lock` + [nats.go:New]. +- Bucket config: `sub` has TTL `0` (no expiry), `event` has the dedupe TTL, + `lock` has the lock TTL. Every bucket has `History: 1`, so TTL expiry clears + the subject [nats.go:New]. +- `ensureBucket` never mutates an existing bucket. If the bucket exists with a + different TTL or history, `New` fails with an error that ends in + `"delete the bucket to change its TTL"` or + `"delete the bucket to change its history"` [nats.go:ensureBucket]. +- The per-call `ttl` arguments to `MarkEvent`, `AcquireLock`, and `ExtendLock` + must be positive, but the code does not use them for expiry + [nats.go:Options] [nats.go:State.MarkEvent] [nats.go:State.AcquireLock] + [nats.go:State.ExtendLock]. +- Every KV key is `base64.RawURLEncoding` of the raw ID or lock key. The NATS + KV key charset forbids `:` and treats `.` as a subject separator + [nats.go:encodeKey]. +- Validation errors: `"nats state: thread id is required"` (empty thread ID in + the three subscription methods), `"nats state: event id is required"`, + `"nats state: dedupe ttl must be positive"`, `"nats state: lock key is required"`, + `"nats state: lock ttl must be positive"`, and + `"nats state: lock lease is required"` (empty `Key` or `Token`) + [nats.go:State.IsThreadSubscribed] [nats.go:State.MarkEvent] + [nats.go:State.AcquireLock] [nats.go:State.ExtendLock] + [nats.go:State.ReleaseLock]. +- `IsThreadSubscribed` maps `jetstream.ErrKeyNotFound` to `(false, nil)`. + `UnsubscribeThread` maps it to `nil` [nats.go:State.IsThreadSubscribed] + [nats.go:State.UnsubscribeThread]. +- `MarkEvent` and `AcquireLock` use KV `Create`, so the first writer wins. + `jetstream.ErrKeyExists` returns `(false, nil)` or + `(chat.LockLease{}, false, nil)` [nats.go:State.MarkEvent] + [nats.go:State.AcquireLock]. +- The lock value is the lease token from `tokens.New()` + [nats.go:State.AcquireLock]. +- `ExtendLock` and `ReleaseLock` read the entry first. A missing key or a + value that is not `lease.Token` returns `(false, nil)` + [nats.go:State.ExtendLock] [nats.go:State.ReleaseLock]. +- `ExtendLock` writes with `Update(..., entry.Revision())`. `ReleaseLock` + deletes with `jetstream.LastRevision(entry.Revision())`. A + `JSErrCodeStreamWrongLastSequence` error returns `(false, nil)`, so a lock + that changed between the read and the write is not touched + [nats.go:State.ExtendLock] [nats.go:State.ReleaseLock] + [nats.go:isWrongLastSequence]. +- `Shutdown` closes the connection at most once and always returns `nil` + [nats.go:State.Shutdown]. + +## Side Effects + +- `New` creates a JetStream context with `jetstream.New(opts.Conn)` and calls + `js.AccountInfo(ctx)`. Failure returns `"nats state: jetstream account: %w"` + [nats.go:New]. +- `New` creates or opens the three KV buckets with `js.CreateKeyValue`, then + `js.KeyValue` and `kv.Status` on `jetstream.ErrBucketExists`. Failure + returns `"nats state: setup %s bucket: %w"` [nats.go:New] + [nats.go:ensureBucket]. +- `IsThreadSubscribed` runs KV `Get` on the `sub` bucket + [nats.go:State.IsThreadSubscribed]. +- `SubscribeThread` runs KV `Put` of value `1` on the `sub` bucket + [nats.go:State.SubscribeThread]. +- `UnsubscribeThread` runs KV `Delete` on the `sub` bucket + [nats.go:State.UnsubscribeThread]. +- `MarkEvent` runs KV `Create` of value `1` on the `event` bucket. The entry + expires after the bucket TTL [nats.go:State.MarkEvent]. +- `AcquireLock` runs KV `Create` of the token on the `lock` bucket. The entry + expires after the bucket TTL [nats.go:State.AcquireLock]. +- `ExtendLock` runs KV `Get`, then KV `Update` with the same token. The new + write resets the entry age, which renews the lease under the bucket TTL + [nats.go:State.ExtendLock]. +- `ReleaseLock` runs KV `Get`, then a revision-gated KV `Delete` + [nats.go:State.ReleaseLock]. +- `Shutdown` calls `Close` on the caller-supplied `*natsgo.Conn` + [nats.go:State.Shutdown]. + +## Verification + +- Run: `cd state/nats && go test ./...` +- The tests start an embedded NATS server, so they need no Docker or external + server (`test:nats` task in the root `mise.toml`). diff --git a/state/postgres/README.md b/state/postgres/README.md new file mode 100644 index 0000000..ef219a2 --- /dev/null +++ b/state/postgres/README.md @@ -0,0 +1,141 @@ +# postgres + +## Overview + +Package `postgres` (module and import path +`github.com/coder/chat/state/postgres`) is a Postgres-backed implementation of +the `chat.State` interface from `github.com/coder/chat` [doc.go:postgres]. It +stores subscriptions, dedupe events, and thread locks in three tables that it +creates on startup. All expiry checks use the database clock `now()`. Every +row is scoped by a namespace string. It is a separate Go module with its own +`go.mod` [go.mod:module]. `postgres.go` imports `github.com/coder/chat`, +`github.com/coder/chat/internal/tokens`, and +`github.com/jackc/pgx/v5/pgxpool`. + +## Interface + +- `Options` [postgres.go:Options]: `Pool *pgxpool.Pool`, `Namespace string`. +- `State` [postgres.go:State]: unexported fields `pool`, `namespace`, and + `once` (`sync.Once`). +- `var _ chat.State = (*State)(nil)` [postgres.go:State]: compile-time check + that `*State` implements `chat.State`. +- `func New(ctx context.Context, opts Options) (*State, error)` + [postgres.go:New]: pings the pool and creates the schema. +- `(*State).IsThreadSubscribed(ctx, id chat.ThreadID) (bool, error)` + [postgres.go:State.IsThreadSubscribed] +- `(*State).SubscribeThread(ctx, id chat.ThreadID) error` + [postgres.go:State.SubscribeThread] +- `(*State).UnsubscribeThread(ctx, id chat.ThreadID) error` + [postgres.go:State.UnsubscribeThread] +- `(*State).MarkEvent(ctx, id string, ttl time.Duration) (bool, error)` + [postgres.go:State.MarkEvent] +- `(*State).AcquireLock(ctx, key string, ttl time.Duration) (chat.LockLease, bool, error)` + [postgres.go:State.AcquireLock] +- `(*State).ExtendLock(ctx, lease chat.LockLease, ttl time.Duration) (bool, error)` + [postgres.go:State.ExtendLock] +- `(*State).ReleaseLock(ctx, lease chat.LockLease) (bool, error)` + [postgres.go:State.ReleaseLock] +- `(*State).Shutdown(context.Context) error` [postgres.go:State.Shutdown] + +## Invariants + +- `New` requires `opts.Pool`. A nil pool fails with + `"postgres state: pool is required"` before any database call + [postgres.go:New]. +- `New` trims whitespace from `Namespace`. An empty result becomes `"chat"` + [postgres.go:New]. +- Every table's primary key starts with `namespace` + [postgres.go:schemaStatements], and every query in the `State` methods + filters on it [postgres.go:State.MarkEvent] [postgres.go:State.AcquireLock]. +- Validation errors: `"postgres state: thread id is required"` (empty thread + ID in the three subscription methods), `"postgres state: event id is required"`, + `"postgres state: lock key is required"`, and + `"postgres state: lock lease is required"` (empty `Key` or `Token`) + [postgres.go:State.IsThreadSubscribed] [postgres.go:State.MarkEvent] + [postgres.go:State.AcquireLock] [postgres.go:State.ExtendLock] + [postgres.go:State.ReleaseLock]. +- `positiveMicros` rejects `ttl <= 0` with + `"postgres state: dedupe ttl must be positive"` or + `"postgres state: lock ttl must be positive"`. It converts the TTL with + `Duration.Microseconds()` and rounds a positive sub-microsecond TTL up to + `1` [postgres.go:positiveMicros]. +- `SubscribeThread` is idempotent through `ON CONFLICT (namespace, thread_id) DO NOTHING` + [postgres.go:State.SubscribeThread]. `UnsubscribeThread` returns `nil` when + no row exists [postgres.go:State.UnsubscribeThread]. +- `MarkEvent` returns `true` only when it inserts a row or replaces a row whose + `expires_at <= now()`. An unexpired row returns `(false, nil)` + [postgres.go:State.MarkEvent]. +- `AcquireLock` takes over an existing row only when its `expires_at <= now()`. + It then writes a new token. When the lock is held it returns + `(chat.LockLease{}, false, nil)` [postgres.go:State.AcquireLock]. +- `ExtendLock` and `ReleaseLock` match on `namespace`, `lock_key`, `token`, + and `expires_at > now()`. They return `true` only when `RowsAffected() == 1`. + An expired lease cannot be extended or released + [postgres.go:State.ExtendLock] [postgres.go:State.ReleaseLock]. +- Schema setup runs in one transaction that first runs + `SELECT pg_advisory_xact_lock(hashtext($1)::bigint)` with + `$1 = "chat_runtime_state_schema"`. This serializes concurrent setup calls + against one database [postgres.go:State.setup]. +- Query errors from the state methods are returned unwrapped. `New` wraps its + errors as `"postgres state: ping: %w"` and `"postgres state: setup: %w"` + [postgres.go:New]. `AcquireLock` wraps a token error as + `"postgres state: create lock token: %w"` [postgres.go:State.AcquireLock]. +- `Shutdown` closes the pool at most once and always returns `nil` + [postgres.go:State.Shutdown]. + +## Side Effects + +- `New` calls `pool.Ping(ctx)`. If ping or setup fails, `New` does not close + the pool [postgres.go:New]. +- `setup` runs this DDL from `schemaStatements` inside the advisory-locked + transaction. No `CREATE INDEX` exists, so the only indexes are the primary + keys. Each statement runs as its own `tx.Exec`. This README adds the + semicolons [postgres.go:State.setup] [postgres.go:schemaStatements]. + + ```sql + CREATE TABLE IF NOT EXISTS chat_runtime_subscriptions ( + namespace text NOT NULL, + thread_id text NOT NULL, + PRIMARY KEY (namespace, thread_id) + ); + CREATE TABLE IF NOT EXISTS chat_runtime_events ( + namespace text NOT NULL, + event_id text NOT NULL, + expires_at timestamptz NOT NULL, + PRIMARY KEY (namespace, event_id) + ); + CREATE TABLE IF NOT EXISTS chat_runtime_locks ( + namespace text NOT NULL, + lock_key text NOT NULL, + token text NOT NULL, + expires_at timestamptz NOT NULL, + PRIMARY KEY (namespace, lock_key) + ); + ``` + +- `IsThreadSubscribed` reads `chat_runtime_subscriptions` with + `SELECT EXISTS (...)` [postgres.go:State.IsThreadSubscribed]. +- `SubscribeThread` inserts into `chat_runtime_subscriptions` + [postgres.go:State.SubscribeThread]. `UnsubscribeThread` deletes from it + [postgres.go:State.UnsubscribeThread]. +- `MarkEvent` upserts into `chat_runtime_events` with + `expires_at = now() + ($3::bigint * interval '1 microsecond')` + [postgres.go:State.MarkEvent]. +- `AcquireLock` upserts `token` and `expires_at` into `chat_runtime_locks` + [postgres.go:State.AcquireLock]. The token comes from `tokens.New()` in + `github.com/coder/chat/internal/tokens`. +- `ExtendLock` updates `expires_at` in `chat_runtime_locks` + [postgres.go:State.ExtendLock]. `ReleaseLock` deletes the row + [postgres.go:State.ReleaseLock]. +- Expired rows are never deleted. They stay in the tables until a later + `MarkEvent` or `AcquireLock` overwrites them [postgres.go:State.MarkEvent] + [postgres.go:State.AcquireLock]. +- `Shutdown` calls `Close` on the caller-supplied `*pgxpool.Pool` + [postgres.go:State.Shutdown]. + +## Verification + +- Run: `cd state/postgres && go test ./...` +- The tests start Postgres with Testcontainers, so they need a running Docker + daemon (`test:postgres` task in the root `mise.toml`). The test skips when + no healthy container provider exists. diff --git a/state/redis/README.md b/state/redis/README.md new file mode 100644 index 0000000..6729c2b --- /dev/null +++ b/state/redis/README.md @@ -0,0 +1,112 @@ +# redis + +## Overview + +Package `redis` (module and import path `github.com/coder/chat/state/redis`) is +a Redis-backed implementation of the `chat.State` interface from +`github.com/coder/chat` [doc.go:redis]. It stores subscriptions, dedupe events, +and thread locks as plain string keys under one prefix. Expiry uses native +Redis key TTLs from the per-call `ttl` arguments. Lock extend and release use +Lua scripts that compare the lease token first. It is a separate Go module +with its own `go.mod` [go.mod:module]. `redis.go` imports +`github.com/coder/chat` and `github.com/redis/go-redis/v9`. + +## Interface + +- `Options` [redis.go:Options]: `Client redis.UniversalClient`, + `Prefix string`. +- `State` [redis.go:State]: unexported fields `client`, `prefix`, and `once` + (`sync.Once`). +- `var _ chat.State = (*State)(nil)` [redis.go:State]: compile-time check that + `*State` implements `chat.State`. +- `func New(ctx context.Context, opts Options) (*State, error)` [redis.go:New]: + pings Redis. +- `(*State).IsThreadSubscribed(ctx, id chat.ThreadID) (bool, error)` + [redis.go:State.IsThreadSubscribed] +- `(*State).SubscribeThread(ctx, id chat.ThreadID) error` + [redis.go:State.SubscribeThread] +- `(*State).UnsubscribeThread(ctx, id chat.ThreadID) error` + [redis.go:State.UnsubscribeThread] +- `(*State).MarkEvent(ctx, id string, ttl time.Duration) (bool, error)` + [redis.go:State.MarkEvent] +- `(*State).AcquireLock(ctx, key string, ttl time.Duration) (chat.LockLease, bool, error)` + [redis.go:State.AcquireLock] +- `(*State).ExtendLock(ctx, lease chat.LockLease, ttl time.Duration) (bool, error)` + [redis.go:State.ExtendLock] +- `(*State).ReleaseLock(ctx, lease chat.LockLease) (bool, error)` + [redis.go:State.ReleaseLock] +- `(*State).Shutdown(context.Context) error` [redis.go:State.Shutdown] + +## Invariants + +- `New` requires `opts.Client`. A nil client fails with + `"redis state: client is required"` [redis.go:New]. +- An empty `Prefix` becomes `"chat"`. `New` then trims trailing `:` characters. + It does not trim whitespace [redis.go:New]. +- Every key is `prefix + ":" + kind + ":" + value`, with kind `sub`, `event`, + or `lock`. With the default prefix the keys are `chat:sub:`, + `chat:event:`, and `chat:lock:` [redis.go:State.key]. + IDs are not encoded or escaped. +- Validation errors: `"redis state: thread id is required"` (empty thread ID in + the three subscription methods), `"redis state: event id is required"`, + `"redis state: dedupe ttl must be positive"`, `"redis state: lock key is required"`, + `"redis state: lock ttl must be positive"`, and + `"redis state: lock lease is required"` (empty `Key` or `Token`) + [redis.go:State.IsThreadSubscribed] [redis.go:State.MarkEvent] + [redis.go:State.AcquireLock] [redis.go:State.ExtendLock] + [redis.go:State.ReleaseLock]. +- `IsThreadSubscribed` returns `true` only when `EXISTS` returns `1` + [redis.go:State.IsThreadSubscribed]. +- Subscription keys have no expiry [redis.go:State.SubscribeThread]. +- `MarkEvent` and `AcquireLock` use `SetNX`, so the first writer wins. A held + lock returns `(chat.LockLease{}, false, nil)` [redis.go:State.MarkEvent] + [redis.go:State.AcquireLock]. +- Lock tokens are 16 bytes from `crypto/rand`, hex-encoded to 32 characters. + A token error is wrapped as `"redis state: create lock token: %w"` + [redis.go:newToken] [redis.go:State.AcquireLock]. +- `extendScript` runs `PEXPIRE` and `releaseScript` runs `DEL` only when + `GET KEYS[1] == ARGV[1]` (the lease token). Otherwise they return `0`, and + the Go methods return `false` [redis.go:extendScript] + [redis.go:releaseScript] [redis.go:State.ExtendLock] + [redis.go:State.ReleaseLock]. +- `ExtendLock` passes `ttl.Milliseconds()` to the script, so it truncates + sub-millisecond precision [redis.go:State.ExtendLock]. +- A positive `ttl` under 1 ms therefore reaches the script as `0`. Redis + deletes a key given a non-positive `PEXPIRE` and returns `1`, so + `ExtendLock` deletes the lock and still returns `true` + [redis.go:State.ExtendLock] [redis.go:extendScript]. The runtime renews + with `ThreadLockTTL` (default 2 minutes), so only a sub-millisecond + configuration reaches this path. +- Redis errors from the state methods are returned unwrapped. `New` wraps a + ping error as `"redis state: ping: %w"` [redis.go:New]. +- `Shutdown` closes the client at most once. It returns the `Close` error from + the first call and `nil` from later calls [redis.go:State.Shutdown]. + +## Side Effects + +- `New` sends `PING` [redis.go:New]. +- `IsThreadSubscribed` sends `EXISTS :sub:` + [redis.go:State.IsThreadSubscribed]. +- `SubscribeThread` sets `:sub:` to `"1"` with expiration `0` + [redis.go:State.SubscribeThread]. +- `UnsubscribeThread` sends `DEL :sub:` + [redis.go:State.UnsubscribeThread]. +- `MarkEvent` runs `SetNX` on `:event:` with value `"1"` and the + caller's `ttl` [redis.go:State.MarkEvent]. +- `AcquireLock` reads `crypto/rand`, then runs `SetNX` on `:lock:` + with the token and the caller's `ttl` [redis.go:State.AcquireLock] + [redis.go:newToken]. +- `ExtendLock` sends `EVAL extendScript` on `:lock:` with + `ARGV = [token, ttl in ms]` [redis.go:State.ExtendLock]. +- `ReleaseLock` sends `EVAL releaseScript` on `:lock:` with + `ARGV = [token]` [redis.go:State.ReleaseLock]. +- `Shutdown` calls `Close` on the caller-supplied `redis.UniversalClient` + [redis.go:State.Shutdown]. + +## Verification + +- Run: `cd state/redis && go test ./...` +- `TestStateConformanceWithRedisContainer` uses Testcontainers and needs a + running Docker daemon (`test:redis` task in the root `mise.toml`). It skips + when no healthy container provider exists. The other conformance run uses an + in-process miniredis server. From 16dcdfcd699c53c1aa3c25fdf016f66204d2b241 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 19:19:24 +0000 Subject: [PATCH 2/4] docs: correct six README claims from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Root README: deferred dispatch starts the handler before the adapter acknowledges, so the two run at the same time; lease renewal happens only under deferred dispatch and burst batches. - adapters/slack and internal/ratelimit: the deadline-bounded retry keeps the backoff sleep inside the ack window, but not the HTTP attempts. - slackapi: limit the POST-to-BaseURL side effect to Call and the typed Web API methods, and document the small-limit SplitMarkdown exception. - State examples: the deferred bot.Shutdown closes the state client through State.Shutdown. _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- README.md | 17 ++++++++++++----- adapters/slack/README.md | 2 +- adapters/slack/slackapi/README.md | 4 ++-- examples/slack-nats-state/README.md | 2 +- examples/slack-postgres-state/README.md | 2 +- examples/slack-redis-state/README.md | 2 +- internal/ratelimit/README.md | 2 +- 7 files changed, 19 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3af280c..953d5ab 100644 --- a/README.md +++ b/README.md @@ -230,8 +230,11 @@ works through these steps [runtime.go:Chat.dispatch, runtime.go:Chat.prelude]: `DedupeTTL`. A duplicate stops here [runtime.go:Chat.prelude, runtime.go:Chat.routedWork, runtime.go:Chat.markAcceptedEvent]. 8. **Run.** `DispatchSync` runs the handler before the adapter acknowledges. - `DispatchDeferred` acknowledges first and runs the handler on a detached - context bounded by `DetachTimeout` [runtime.go:Chat.startDetachedTail]. + `DispatchDeferred` starts the handler in a goroutine on a detached context + bounded by `DetachTimeout`, then returns to the adapter without waiting + for it. The adapter's acknowledgement and the handler run at the same + time, so a fast handler can act before the acknowledgement is written + [runtime.go:Chat.dispatchDeferred, runtime.go:Chat.startDetachedTail]. Handlers reply through `Thread.Post`, which calls `Adapter.PostMessage` [thread.go:Thread.Post]. The runtime keeps its coordination data in a @@ -289,9 +292,13 @@ Invariants: [runtime.go:Chat.dispatchSync, doc.go]. - The runtime ignores messages, commands, and interactions from the adapter's own bot actor [runtime.go:isSelfActor]. -- While a locked handler runs, the runtime renews the lease every - `ThreadLockTTL/2`. A lost lease cancels the handler with `ErrPreempted` as - the cause [runtime.go:Chat.startLockRefresh, errors.go:ErrPreempted]. +- Under `DispatchDeferred`, while a locked handler or burst batch runs, the + runtime renews the lease every `ThreadLockTTL/2`. A lost lease cancels the + handler with `ErrPreempted` as the cause [runtime.go:Chat.runLockedTail, + burst.go:dispatchBurstBatch, runtime.go:Chat.startLockRefresh, + errors.go:ErrPreempted]. `DispatchSync` does not renew: a synchronous + handler that runs longer than `ThreadLockTTL` loses mutual exclusion and is + not cancelled [runtime.go:Chat.dispatchSync]. - Lock waiters poll `State.AcquireLock` every `ThreadLockTTL/20`, clamped to 1ms–1s, because `State` does not signal a release [runtime.go:Chat.pollForLock]. diff --git a/adapters/slack/README.md b/adapters/slack/README.md index 1f53b1b..5e72788 100644 --- a/adapters/slack/README.md +++ b/adapters/slack/README.md @@ -90,7 +90,7 @@ The code has compile-time assertions for `chat.Listener`, `chat.HistoryReader`, - `ReadHistory` uses 100 when `Limit <= 0` and caps `Limit` at 1000. [history.go:ReadHistory,slackDefaultHistoryLimit,slackMaxHistoryLimit] - `ReadHistory` does no storage, dedupe, or caching. [doc.go:slack] - `ReadHistory` returns an empty page with no Slack call when the `Before` cursor equals the thread root. [history.go:ReadHistory] -- Retry never sleeps past the caller's context deadline, so synchronous retry stays inside Slack's 3-second ack window. [doc.go:slack] +- Retry never sleeps past the caller's context deadline. The default `MaxElapsed` (2 s) also keeps the total backoff sleep under Slack's 3-second ack window. Neither bound covers the time of each HTTP attempt, so under `DispatchSync` a delivery stays inside the window only when the request context or the HTTP client (`Options.Client`) bounds those attempts. [doc.go:slack], [slackapi/retry.go:RetryPolicy.withDefaults,Client.doWithRetry] ## Side Effects diff --git a/adapters/slack/slackapi/README.md b/adapters/slack/slackapi/README.md index d1080c0..8cc7b46 100644 --- a/adapters/slack/slackapi/README.md +++ b/adapters/slack/slackapi/README.md @@ -156,13 +156,13 @@ Some of those types hold nested exported types: ### Formats -- `SplitMarkdown` uses `MarkdownBlockLimit` for a limit of zero or less, returns nil for empty text, and does not end a chunk inside or right after a code-fence opening line. [markdown.go:SplitMarkdown,splitFenced] +- `SplitMarkdown` uses `MarkdownBlockLimit` for a limit of zero or less, and returns nil for empty text. When the limit is at least twice the length of the longest fence line plus six runes, it closes and reopens a fence that a chunk ends inside, and does not end a chunk inside or right after a code-fence opening line. Below that limit it splits without this fence repair. [markdown.go:SplitMarkdown,splitFenced,longestFenceLine] - `UserMentions` returns IDs in first-appearance order with no duplicates. [mentions.go:UserMentions] - `ParseEnvelope` returns an error for invalid JSON or an empty envelope type. [envelope.go:ParseEnvelope] ## Side Effects -- Every typed method and `Call` sends an HTTP POST to `BaseURL` + `/` through the configured `http.Client`. [client.go:Client.Call,send] +- `Call` and each typed Web API method in the table above send an HTTP POST to `BaseURL` + `/` through the configured `http.Client`. [client.go:Client.Call,send] The file methods below use other URLs or verbs: `UploadFile` calls two Web API methods and also POSTs to the upload URL. - `PostResponseURL` sends an HTTP POST to a caller-supplied `response_url`. [client.go:Client.PostResponseURL] - `UploadToURL` POSTs file content to the upload URL. `DownloadFile` GETs a file and writes it to the caller's writer. Both use a copied `http.Client` with a custom redirect policy. [files.go:Client.UploadToURL,Client.DownloadFile,Client.fileHTTPClient] - Retry sleeps between attempts with `ratelimit.SleepCtx` from `internal/ratelimit`. [retry.go:Client.doWithRetry] diff --git a/examples/slack-nats-state/README.md b/examples/slack-nats-state/README.md index d896a64..7919d00 100644 --- a/examples/slack-nats-state/README.md +++ b/examples/slack-nats-state/README.md @@ -152,7 +152,7 @@ It imports `github.com/coder/chat`, `github.com/coder/chat/adapters/slack`, | `SLACK_BOT_TOKEN` | none (required) | `slack.Options.BotToken` | [main.go:main] | | `PORT` | `8080` | Listen address `":" + PORT` | [main.go:main] | -- Network: `natsgo.Connect(NATS_URL)` opens a NATS connection [main.go:main]. `main` does not close it. +- Network: `natsgo.Connect(NATS_URL)` opens a NATS connection [main.go:main]. `main` does not close it directly: the deferred `bot.Shutdown` calls `State.Shutdown`, which closes it [main.go:main], [`state/nats` `State.Shutdown`]. - Network: `http.ListenAndServe` on `:$PORT` with the default server, so no server timeouts are set [main.go:main]. - Network: `slack.New` builds the Slack adapter, and each handler posts a reply diff --git a/examples/slack-postgres-state/README.md b/examples/slack-postgres-state/README.md index e31b0ef..9f0f654 100644 --- a/examples/slack-postgres-state/README.md +++ b/examples/slack-postgres-state/README.md @@ -143,7 +143,7 @@ It imports `github.com/coder/chat`, `github.com/coder/chat/adapters/slack`, | `SLACK_BOT_TOKEN` | none (required) | `slack.Options.BotToken` | [main.go:main] | | `PORT` | `8080` | Listen address `":" + PORT` | [main.go:main] | -- Network: `pgxpool.New(ctx, DATABASE_URL)` opens a connection pool [main.go:main]. `main` does not close it. +- Network: `pgxpool.New(ctx, DATABASE_URL)` opens a connection pool [main.go:main]. `main` does not close it directly: the deferred `bot.Shutdown` calls `State.Shutdown`, which closes it [main.go:main], [`state/postgres` `State.Shutdown`]. - Network: `http.ListenAndServe` on `:$PORT` with the default server, so no server timeouts are set [main.go:main]. - Network: `slack.New` builds the Slack adapter, and each handler posts a reply diff --git a/examples/slack-redis-state/README.md b/examples/slack-redis-state/README.md index 309abe0..77943ec 100644 --- a/examples/slack-redis-state/README.md +++ b/examples/slack-redis-state/README.md @@ -142,7 +142,7 @@ It imports `github.com/coder/chat`, `github.com/coder/chat/adapters/slack`, | `SLACK_BOT_TOKEN` | none (required) | `slack.Options.BotToken` | [main.go:main] | | `PORT` | `8080` | Listen address `":" + PORT` | [main.go:main] | -- Network: `goredis.ParseURL(REDIS_URL)` and `goredis.NewClient` build a Redis client [main.go:main]. `main` does not close it. +- Network: `goredis.ParseURL(REDIS_URL)` and `goredis.NewClient` build a Redis client [main.go:main]. `main` does not close it directly: the deferred `bot.Shutdown` calls `State.Shutdown`, which closes it [main.go:main], [`state/redis` `State.Shutdown`]. - Network: `http.ListenAndServe` on `:$PORT` with the default server, so no server timeouts are set [main.go:main]. - Network: `slack.New` builds the Slack adapter, and each handler posts a reply diff --git a/internal/ratelimit/README.md b/internal/ratelimit/README.md index e559ddc..bbc4547 100644 --- a/internal/ratelimit/README.md +++ b/internal/ratelimit/README.md @@ -34,7 +34,7 @@ These packages import it: - A shifted delay that is non-positive (shift overflow) or above `maxDelay` becomes `maxDelay`. `[ratelimit.go:BackoffDelay]` - The result is `min(max(delay, retryAfter), maxDelay)`. A platform `retryAfter` can raise the delay but never above `maxDelay`. `[ratelimit.go:BackoffDelay]` - `d <= 0` returns `ctx.Err()` at once without waiting. `[ratelimit.go:SleepCtx]` -- The sleep never outlasts the caller's context, so a retry never exceeds the platform ack window. `[ratelimit.go:SleepCtx]` +- The sleep never outlasts the caller's context. The doc comment says this keeps retry inside the platform ack window. That holds only when the caller's context deadline fits that window. `[ratelimit.go:SleepCtx]` ## Side Effects From cb84b5953292a4bce8e35dec0af0493d2798f9fa Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 19:28:24 +0000 Subject: [PATCH 3/4] docs: correct three more README claims from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - slackapi: a throttling response ends in *RateLimited, not *APIError. - Root README: ctx bounds the Shutdown waits, so adapters and State can shut down while deferred work or listeners still run. - Adapters and root README: InstallStore.Lookup runs only for deliveries routed to an install and for calls that need a token, not for every webhook. _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- README.md | 21 +++++++++++++++------ adapters/README.md | 2 +- adapters/linear/README.md | 2 +- adapters/slack/README.md | 2 +- adapters/slack/slackapi/README.md | 2 +- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 953d5ab..9075cda 100644 --- a/README.md +++ b/README.md @@ -302,9 +302,13 @@ Invariants: - Lock waiters poll `State.AcquireLock` every `ThreadLockTTL/20`, clamped to 1ms–1s, because `State` does not signal a release [runtime.go:Chat.pollForLock]. -- `Shutdown` closes admission, cancels deferred work, drains it, stops - listeners, shuts down the adapters, and then shuts down `State`. A second - call waits for the first [runtime.go:Chat.Shutdown]. +- `Shutdown` closes admission, cancels deferred work, waits for it to drain, + stops listeners and waits for them, shuts down the adapters, and then + shuts down `State`. `ctx` bounds the two waits. If `ctx` ends first, + `Shutdown` stops waiting and shuts down the adapters and `State` while + deferred work or listeners can still run. A second call waits for the + first, bounded by its own `ctx` [runtime.go:Chat.Shutdown, + runtime.go:waitBounded]. - `Thread.Post` rejects an empty `Text` and does not subscribe the thread [thread.go:Thread.Post, doc.go]. - `Thread.PostEphemeral` rejects an empty actor ID or `Text`. It returns @@ -388,9 +392,14 @@ Side effects: adapters/slack/slackapi/webhook.go:Webhook.ServeHTTP]. The exception is a Slack slash command: Slack does not redeliver it, so the adapter acks it with a busy reply instead [adapters/slack/slack.go:deliveryHandler]. -- `InstallStore.Lookup` runs on every inbound webhook, with no cache in the - adapter. `ErrInstallNotFound` makes the delivery an Ignored Event - [install.go:InstallStore, install.go:ErrInstallNotFound]. +- In multi-tenant mode, an adapter calls `InstallStore.Lookup` for each + inbound delivery that it routes to an install, with no cache in the + adapter. Deliveries that it ignores before that point, such as an + unsupported Slack event type or a Linear body with no organization, get + no lookup. `ErrInstallNotFound` makes the delivery an Ignored Event + [install.go:InstallStore, install.go:ErrInstallNotFound, + adapters/slack/slack.go:resolveInstall, + adapters/linear/linear.go:resolveWebhookInstall]. - Adapter rate-limit retry never sleeps past the caller's deadline and ends in a typed `RateLimited` error. Both adapters share the mechanics in `internal/ratelimit` [internal/ratelimit/ratelimit.go:SleepCtx]. diff --git a/adapters/README.md b/adapters/README.md index 850efc7..49af9fc 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -6,7 +6,7 @@ The `adapters` directory groups the platform adapters of Chat SDK Go (`github.co Both adapters share these traits, from their own contracts: -1. Each adapter runs in single-install mode (static credentials) or multi-tenant mode (`chat.InstallStore`, looked up per request with no caching). +1. Each adapter runs in single-install mode (static credentials) or multi-tenant mode (`chat.InstallStore`, looked up for each request routed to an install, with no caching). 2. Each adapter verifies the signature of inbound webhooks before dispatch. 3. Each adapter retries platform rate limits within a `RetryPolicy`, never sleeps past the caller's context deadline, and returns a typed `*RateLimited` error when retry stops. 4. Each adapter emits `chat.ObsAdapterCall` and `chat.ObsRateLimit` events to the configured observer. diff --git a/adapters/linear/README.md b/adapters/linear/README.md index d748dad..6ea80ed 100644 --- a/adapters/linear/README.md +++ b/adapters/linear/README.md @@ -125,7 +125,7 @@ The adapter owns its own rate-limit retry (`RetryPolicy`) and Linear-specific ex - The adapter POSTs to `{APIBaseURL}/oauth/token` (default `https://api.linear.app`) for the client-credentials grant. [linear.go:exchangeClientCredentials,defaultAPIBaseURL] - All GraphQL calls POST to `{APIBaseURL}/graphql` with a bearer token through `Options.Client` (default `http.DefaultClient`). [linear.go:callGraphQLWithToken,New] - GraphQL operations: `ViewerIdentity` at `Init`, `AgentActivityCreate` for activities, `CommentCreate` for comments, `AgentSessionUpdate`, `AgentSessionCreateOnIssue`, `AgentSessionCreateOnComment`, `IssueRepositorySuggestions`, and history queries. [linear.go:fetchIdentity,createAgentActivity], [comments.go:createIssueComment], [session.go:UpdateSession,createSessionOnIssue,createSessionOnComment], [suggestions.go:SuggestRepositories], [history.go:readAgentSessionHistory,readCommentThreadHistory] -- Multi-tenant mode calls `InstallStore.Lookup` on every webhook and every outbound token resolution. It does not cache install records. [linear.go:resolveWebhookInstall,resolveToken] +- Multi-tenant mode calls `InstallStore.Lookup` on every webhook whose body names an organization, and on every outbound token resolution. A body with no organization is ignored without a lookup. It does not cache install records. [linear.go:resolveWebhookInstall,resolveToken] - A mutex (`a.mu`) guards the access token, expiry, organization, and bot identity. A second mutex guards the per-tenant token cache. [linear.go:Adapter,tenantToken] - `Webhook` calls the runtime `chat.DispatchFunc` for each normalized event. [linear.go:Webhook] - Retry sleeps with `ratelimit.SleepCtx` from `internal/ratelimit`, and resends a buffered copy of the request body. [retry.go:doJSONWithRetry] diff --git a/adapters/slack/README.md b/adapters/slack/README.md index 5e72788..7300c6a 100644 --- a/adapters/slack/README.md +++ b/adapters/slack/README.md @@ -101,7 +101,7 @@ The code has compile-time assertions for `chat.Listener`, `chat.HistoryReader`, - `Listen` opens a Socket Mode websocket through `slackapi.Client.Listen` with the app token. [slack.go:Listen] - `Webhook` serves inbound HTTP through a `slackapi.Webhook` that verifies signatures. [slack.go:Webhook] - The delivery handler calls the runtime `chat.DispatchFunc` for each normalized event. [slack.go:deliveryHandler] -- Multi-tenant mode calls `InstallStore.Lookup` on every delivery and every outbound post. The adapter does not cache install records. [slack.go:resolveInstall,postToken], [doc.go:slack] +- Multi-tenant mode calls `InstallStore.Lookup` for each supported delivery with a team ID (an `event_callback` envelope, a slash command, or a `block_actions` interaction), and for each outbound call that needs a bot token. Ignored delivery types and `RespondURL`, which sends no token, do no lookup. The adapter does not cache install records. [slack.go:normalizeDelivery,normalizeEvent,resolveInstall,postToken], [interactive.go:normalizeCommand,normalizeInteraction,RespondURL], [doc.go:slack] - Retry sleeps, observation events (`chat.ObsAdapterCall`, `chat.ObsRateLimit`), and throttle logs come from `slackapi`. `New` passes `Client`, `RetryPolicy`, `Observer`, and `Logger` to `slackapi.New`. [slack.go:New], [doc.go:slack] - The package starts no goroutines and holds no mutable package-level state. [slack.go:New] diff --git a/adapters/slack/slackapi/README.md b/adapters/slack/slackapi/README.md index 8cc7b46..68f9e5b 100644 --- a/adapters/slack/slackapi/README.md +++ b/adapters/slack/slackapi/README.md @@ -110,7 +110,7 @@ Some of those types hold nested exported types: - `New` trims trailing slashes from `BaseURL`. It drops each `FileOrigins` entry that is not an absolute URL with a host, and logs a warning for it. [client.go:New] - The client sends the `Authorization` header only when the token is not empty. `UploadToURL`, `PostResponseURL`, and `OAuthV2Access` never send the client token. [client.go:send,Options], [oauth.go:Client.OAuthV2Access] - `Call` encodes a nil payload as `{}`, a `url.Values` payload as a form body, and any other payload as JSON. [client.go:Client.Call,encodeRequest] -- An `ok:false` response or a non-2xx status returns `*APIError`. [client.go:Client.Call,send] +- An `ok:false` response or a non-2xx status returns `*APIError`, except a throttling response: HTTP 429, or `ok:false` with `ratelimited` or `rate_limited`. Retry handles that response and returns `*RateLimited` when it stops. [client.go:Client.Call,send], [retry.go:slackThrottle,Client.doWithRetry] - `WithToken` shares the base URL, file origins, HTTP client, retry policy, observer, and logger with the original client. [client.go:Client.WithToken] - `RetryPolicy` defaults for zero fields: `MaxAttempts` 3, `MaxElapsed` 2s, `BaseDelay` 200ms, `MaxDelay` 1s. `MaxAttempts` 1 turns retry off. [retry.go:RetryPolicy.withDefaults] - Retry runs only on HTTP 429 or an `ok:false` envelope with error `ratelimited` or `rate_limited`. Transport errors return directly and are never retried. [retry.go:slackThrottle,Client.doWithRetry] From 9fe04e10435e83ed2f2cebd9cdf9d8e6b2f01077 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 19:34:30 +0000 Subject: [PATCH 4/4] docs: qualify the typed RateLimited guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retry returns *RateLimited only when the retry policy or the deadline check stops it. A context cancelled during a backoff sleep returns the context error, and a failed HTTP attempt returns its transport error. Say so in the adapters index, the root README, and the slackapi and Linear retry rules. _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- README.md | 10 +++++++--- adapters/README.md | 2 +- adapters/linear/README.md | 2 +- adapters/slack/slackapi/README.md | 4 ++-- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9075cda..536c772 100644 --- a/README.md +++ b/README.md @@ -400,9 +400,13 @@ Side effects: [install.go:InstallStore, install.go:ErrInstallNotFound, adapters/slack/slack.go:resolveInstall, adapters/linear/linear.go:resolveWebhookInstall]. -- Adapter rate-limit retry never sleeps past the caller's deadline and ends - in a typed `RateLimited` error. Both adapters share the mechanics in - `internal/ratelimit` [internal/ratelimit/ratelimit.go:SleepCtx]. +- Adapter rate-limit retry never sleeps past the caller's deadline. When the + retry policy or the deadline check stops it, it returns a typed + `RateLimited` error. A context cancelled during a backoff sleep returns the + context error instead. Both adapters share the mechanics in + `internal/ratelimit` [internal/ratelimit/ratelimit.go:SleepCtx, + adapters/slack/slackapi/retry.go:Client.doWithRetry, + adapters/linear/retry.go:doJSONWithRetry]. - `go.work` adds six separate modules to the root module: `state/nats`, `state/postgres`, `state/redis`, and the three `examples/slack-*-state` bots. Its `replace` directives point the root and the three state module diff --git a/adapters/README.md b/adapters/README.md index 49af9fc..24205d4 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -8,7 +8,7 @@ Both adapters share these traits, from their own contracts: 1. Each adapter runs in single-install mode (static credentials) or multi-tenant mode (`chat.InstallStore`, looked up for each request routed to an install, with no caching). 2. Each adapter verifies the signature of inbound webhooks before dispatch. -3. Each adapter retries platform rate limits within a `RetryPolicy`, never sleeps past the caller's context deadline, and returns a typed `*RateLimited` error when retry stops. +3. Each adapter retries platform rate limits within a `RetryPolicy` and never sleeps past the caller's context deadline. When the policy or the deadline check stops retry, it returns a typed `*RateLimited` error. A context cancelled during a backoff sleep returns the context error, and a failed HTTP attempt returns its transport error. 4. Each adapter emits `chat.ObsAdapterCall` and `chat.ObsRateLimit` events to the configured observer. 5. Each adapter implements `chat.HistoryReader` as a live read with no storage or caching. diff --git a/adapters/linear/README.md b/adapters/linear/README.md index 6ea80ed..58d6588 100644 --- a/adapters/linear/README.md +++ b/adapters/linear/README.md @@ -116,7 +116,7 @@ The adapter owns its own rate-limit retry (`RetryPolicy`) and Linear-specific ex - `ReadHistory` does no storage, dedupe, or caching. [history.go:ReadHistory], [doc.go:linear] - A `Before` cursor resolves to a `createdAt` filter. An unresolved cursor is an error. [history.go:historyCursorFilter] - Retry runs on HTTP 429 or a throttling GraphQL error. It never sleeps past the request context deadline, and returns `*RateLimited` instead. [retry.go:doJSONWithRetry,rateLimitedResponse] -- Retry also returns `*RateLimited` after `MaxAttempts`, or when the next delay would push the total past `MaxElapsed`. Each delay is the exponential backoff, raised to the platform's Retry-After, then capped at `MaxDelay`. [retry.go:doJSONWithRetry], [internal/ratelimit/ratelimit.go:BackoffDelay] +- Retry also returns `*RateLimited` after `MaxAttempts`, or when the next delay would push the total past `MaxElapsed`. A context cancelled during a backoff sleep returns the context error instead. Each delay is the exponential backoff, raised to the platform's Retry-After, then capped at `MaxDelay`. [retry.go:doJSONWithRetry], [internal/ratelimit/ratelimit.go:BackoffDelay] - The first-thought budget is 10 seconds from session creation. The adapter reports the deadline and runs no watchdog. [raw.go:firstThoughtBudget], [doc.go:linear] - When the session timestamp does not parse, `newFirstThoughtDeadline` uses `now()` as the creation time. Its doc comment says it returns nil in that case, so the doc comment and the code disagree. [raw.go:newFirstThoughtDeadline] diff --git a/adapters/slack/slackapi/README.md b/adapters/slack/slackapi/README.md index 68f9e5b..33d7a9b 100644 --- a/adapters/slack/slackapi/README.md +++ b/adapters/slack/slackapi/README.md @@ -110,11 +110,11 @@ Some of those types hold nested exported types: - `New` trims trailing slashes from `BaseURL`. It drops each `FileOrigins` entry that is not an absolute URL with a host, and logs a warning for it. [client.go:New] - The client sends the `Authorization` header only when the token is not empty. `UploadToURL`, `PostResponseURL`, and `OAuthV2Access` never send the client token. [client.go:send,Options], [oauth.go:Client.OAuthV2Access] - `Call` encodes a nil payload as `{}`, a `url.Values` payload as a form body, and any other payload as JSON. [client.go:Client.Call,encodeRequest] -- An `ok:false` response or a non-2xx status returns `*APIError`, except a throttling response: HTTP 429, or `ok:false` with `ratelimited` or `rate_limited`. Retry handles that response and returns `*RateLimited` when it stops. [client.go:Client.Call,send], [retry.go:slackThrottle,Client.doWithRetry] +- An `ok:false` response or a non-2xx status returns `*APIError`, except a throttling response: HTTP 429, or `ok:false` with `ratelimited` or `rate_limited`. Retry handles that response, and the retry rules below say when it returns `*RateLimited`. [client.go:Client.Call,send], [retry.go:slackThrottle,Client.doWithRetry] - `WithToken` shares the base URL, file origins, HTTP client, retry policy, observer, and logger with the original client. [client.go:Client.WithToken] - `RetryPolicy` defaults for zero fields: `MaxAttempts` 3, `MaxElapsed` 2s, `BaseDelay` 200ms, `MaxDelay` 1s. `MaxAttempts` 1 turns retry off. [retry.go:RetryPolicy.withDefaults] - Retry runs only on HTTP 429 or an `ok:false` envelope with error `ratelimited` or `rate_limited`. Transport errors return directly and are never retried. [retry.go:slackThrottle,Client.doWithRetry] -- Retry returns `*RateLimited` when `MaxAttempts` is reached, when total backoff would pass `MaxElapsed`, or when the next sleep would pass the context deadline. [retry.go:Client.doWithRetry] +- Retry returns `*RateLimited` when `MaxAttempts` is reached, when total backoff would pass `MaxElapsed`, or when the next sleep would pass the context deadline. A context cancelled during a backoff sleep returns the context error instead. [retry.go:Client.doWithRetry] - Each retry attempt sends a fresh copy of the buffered request body. [retry.go:Client.doWithRetry] - `DownloadFile` does not retry. [doc.go:slackapi]