mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
* feat(slack): single app-level Socket Mode connection routed by team_id (MUL-3666) Reshape the Slack adapter from the stage-3 per-installation Socket Mode model into the multi-tenant B2 connection model: ONE deployment-level Socket Mode connection (app-level xapp- token, env MULTICA_SLACK_APP_TOKEN) receives the Events API stream for every installed workspace and routes each inbound event to its channel_installation by team_id — the existing GetChannelInstallationByAppID routing, unchanged. - AppConnector: the single shared connection (slack/app_connector.go). No leader election — per the design "one (or a few)" connections are fine: each replica opens one, Slack delivers each event to one of them, and the existing (installation, message_id) two-phase dedup guarantees exactly-once processing. Resolves the per-team bot user id (via the same app_id query) to detect/strip @-mentions, since one connection serves many workspaces. - Inbound translation (Events API -> channel.InboundMessage) extracted to slack/inbound.go as free functions parameterized by the per-team bot identity. - channel.go trimmed to the outbound Send-only sender; per-installation config (config.go) no longer carries an app-level token — installs hold only the per-workspace bot token (xoxb-) for outbound, since xapp- can't be OAuth'd. - engine.Supervisor now skips channel types with no registered Factory, so Slack installs (driven by the app-level connector, not per-installation channels) no longer churn the lease/Build loop. - Wiring: router.go builds the connector when MULTICA_SLACK_APP_TOKEN is set; main.go runs it alongside the Supervisor. Feishu untouched; channel_* schema unchanged. Verified: go build ./..., go vet ./..., gofmt, and go test ./internal/integrations/... all pass. Co-authored-by: multica-agent <github@multica.ai> * feat(slack): OAuth self-serve install backend (MUL-3666) Add the in-product OAuth install flow that creates Slack installations, the keystone the B2 connector consumes. - slack.InstallService: Begin (build authorize URL, seal workspace/agent/ initiator into the OAuth state), Complete (verify state, exchange code via oauth.v2.access, upsert channel_type='slack' install with the bot token encrypted at rest, auto-bind the installer's Slack id so their first message is not dropped), plus List/Get/Revoke. State is stateless: sealed with the deployment secretbox + an embedded expiry, no session store. - HTTP handlers (handler/slack.go): member-visible list, admin-only begin + revoke, and the public OAuth callback (recovers context from the sealed state, redirects the browser back to Settings → Integrations with a result flag). - Routes + wiring: workspace-scoped list/begin/revoke mirror the Lark admin/member split; the callback is a public route like GitHub's. Built from MULTICA_SLACK_CLIENT_ID/SECRET (+ redirect derived from MULTICA_PUBLIC_URL, override MULTICA_SLACK_REDIRECT_URL; scopes via MULTICA_SLACK_SCOPES). - Realtime: slack_installation:created / :revoked events. Verified: go build ./..., go vet, gofmt, and go test ./internal/integrations/slack/... all pass (new install_test.go covers state sign/verify/expiry/tamper, authorize URL, code exchange + encrypted upsert + installer bind, and oauth error paths). Co-authored-by: multica-agent <github@multica.ai> * feat(slack): in-product OAuth install UI for web + desktop (MUL-3666) Add the "Connect Slack" self-serve install UI mirroring the Feishu/Lark integration, completing the in-product install half of B2. Slack's OAuth flow is a redirect (not a device-code QR poll), so the UI is simpler than Lark's. - core: SlackInstallation / List / Begin types; api.listSlackInstallations / beginSlackInstall / deleteSlackInstallation; slackKeys + slackInstallationsOptions query; realtime invalidation on slack_installation:* events. - views: slack-tab.tsx (SlackTab settings panel + per-agent SlackAgentBindButton + connected badge + disconnect confirm). Connect calls beginSlackInstall and hands the authorize URL to openExternal (system browser on desktop, new tab on web); Slack bounces to the backend callback which lands the install, and the realtime event refreshes the list. Wired into the Settings → Integrations tab and the agent-detail Integrations tab alongside Lark. - i18n: en + zh-Hans settings.slack.* strings. Verified: pnpm typecheck (full monorepo, 6/6) and pnpm lint (@multica/core, @multica/views — 0 errors) pass. Co-authored-by: multica-agent <github@multica.ai> * feat(slack): outbound Replier + user-binding redeem flow (MUL-3666) Fill the stage-3 Replier=nil tail so non-installer Slack users can onboard and get status feedback — completing B2 end to end. - slack.OutboundReplier (engine.OutboundReplier): on NeedsBinding it mints a single-use binding token and DMs/replies a "link your account" prompt with the redeem URL (wrapped as <url|label> so formatMrkdwn doesn't mangle the base64url token); on AgentOffline/AgentArchived it posts a status notice; on an /issue-created Ingest it confirms the new issue. Plain chat stays silent (the agent's own reply lands via EventChatDone). Reuses the bot-token Send path and reads the installation row from ResolvedInstallation.Platform — no new transport. - slack.BindingTokenService: Mint + transactional RedeemAndBind over the generic channel_binding_token / channel_user_binding queries (channel_type='slack'), mirroring lark.BindingTokenService. 15-min TTL, SHA256-hashed tokens, the three typed failure modes (invalid/expired, already-assigned, not-member). - HTTP: POST /api/slack/binding/redeem (public, session-authed) maps the failures to 410/409/403. NewSlackResolverSet now takes the replier (nil disables it). - Frontend: /slack/bind redeem page (packages/views/slack + apps/web route) + api.redeemSlackBindingToken + en/zh slack_bind copy. Verified: go build ./..., go vet, gofmt, go test ./internal/integrations/... (new replier_test.go covers all outcome branches + the prompt URL), plus full pnpm typecheck (6/6) and pnpm lint (0 errors). Co-authored-by: multica-agent <github@multica.ai> * fix(slack): address review must-fixes — connector leak, team-keyed install, /issue copy (MUL-3666) Three fixes from Niko's review: 1. AppConnector.connectOnce leaked the Socket Mode goroutine/connection on a handler error: it ran sm.RunContext on the long-lived ctx and returned the error without cancelling it, so a transient DB/router error left the old connection alive (consuming events into an unread channel) while Run opened a second one. Each connection now runs under its own cancellable context and a deferred cancel + join tears it down on every exit path before reconnect. 2. Slack re-install collided with the (channel_type, app_id) unique index: connecting the same Slack team to a different agent failed because the upsert conflict key was (workspace_id, agent_id, channel_type). Add a team-keyed UpsertChannelInstallationByAppID (ON CONFLICT on the (channel_type, app_id) index, updating agent_id) and use it for the Slack OAuth install, so re-connecting a workspace moves the bot to the chosen agent instead of erroring. Feishu's per-agent upsert is unchanged. 3. /issue clarified: it is not a registered Slack slash command (no `commands` scope), so Slack never routes one to us. Issue creation runs through the message path — `@bot /issue <title>` in a channel or `/issue <title>` in a DM — which the engine parser handles. Documented in the connector and the user-facing copy (en + zh). Verified: go build ./..., go vet, gofmt, go test ./internal/integrations/..., make sqlc, plus pnpm typecheck (6/6) and pnpm lint (0 errors). Co-authored-by: multica-agent <github@multica.ai> * fix(slack): make OAuth install transactional — agent-move binding consistency + cross-workspace guard (MUL-3666) Address Elon's review: the team-keyed upsert kept the same installation row and only flipped agent_id, but engine session reuse matches purely on (installation_id, channel_chat_id) and each chat_session is permanently tied to the agent it was created under — so after moving a Slack team from Agent A to Agent B, existing DMs/threads kept routing to Agent A; only brand-new channels/threads reached B. Cross-workspace re-install was worse: the SQL also moved workspace_id while the application-layer user/chat-session bindings stayed behind, inheriting the previous workspace's relations. InstallService.Complete now runs one transaction (lookup → upsert → retire → installer-bind), all application-layer per the no-FK rule: - Look up the existing installation by team_id (config->>'app_id'). - Reject a silent cross-workspace ownership change (ErrTeamOwnedByAnotherWorkspace → callback redirects with slack_error=team_in_other_workspace). The owning workspace must disconnect first. - On an agent change within the same workspace, retire the installation's chat-session bindings (new DeleteChannelChatSessionBindingsByInstallation) so the next message creates a fresh session under the new agent. The chat_session rows are preserved for history; user bindings stay valid (same users/workspace). - Installer auto-bind moves into the tx; an already-bound-elsewhere id is a benign skip, a real DB error aborts the whole install. InstallService now takes a TxStarter; the queries seam gains WithTx (dbInstallQueries adapter) so Complete stays unit-testable with a fake tx. Verified: make sqlc, go build ./..., go vet, gofmt, go test ./internal/integrations/... (new tests: agent-move retire, same-agent no-retire, cross-workspace reject, fresh-install no-retire). Co-authored-by: multica-agent <github@multica.ai> * fix(slack): atomic cross-workspace install guard + green up frontend CI (MUL-3666) Two things: address Elon's review and fix the failing frontend CI job. Review (atomic cross-workspace guard): the previous guard was a SELECT before the upsert, which loses the concurrent-OAuth race — two workspaces can both read no rows, one inserts, the other's ON CONFLICT update then silently re-points the team. Move the guard into the upsert itself: ON CONFLICT ... DO UPDATE ... WHERE channel_installation.workspace_id = EXCLUDED.workspace_id, and map the empty RETURNING (pgx.ErrNoRows) to ErrTeamOwnedByAnotherWorkspace. The pre-SELECT now only feeds the agent-change cleanup. Also corrected the error copy: a team stays bound to its first Multica workspace (revoke is soft, keeping the row + unique index), so migration is an operator action, not "disconnect first". CI (frontend vitest, @multica/views#test): - The agent IntegrationsTab now renders the real SlackAgentBindButton, whose connected badge calls useQueryClient — absent from integrations-tab.test.tsx's react-query mock. Hoisted the owner/admin gate above the per-platform sections (one role notice instead of one per platform), made the agents members_note generic (en/zh/ja/ko), and updated the test (mock @multica/core/slack, stub SlackAgentBindButton, assert both platforms). - Added slack-tab.test.tsx covering the real SlackAgentBindButton / SlackTab. - locale parity: added the slack (settings) + slack_bind (common) blocks to ja and ko so every EN key has a translated counterpart. Verified: make sqlc, go build ./..., go vet, gofmt, go test ./internal/integrations/...; pnpm --filter @multica/views test (1478 pass), pnpm typecheck (6/6), pnpm lint (0 errors). Co-authored-by: multica-agent <github@multica.ai> * fix(slack): surface agent-page Slack entry points when Lark is off (MUL-3666) The agent-detail Integrations tab and the inspector's Integrations section only considered Lark, so a Slack-only deployment (Lark disabled) showed neither the Integrations tab nor a Connect-Slack button — the per-agent entry points were unreachable. - agent-overview-pane: gate the Integrations tab on Lark OR Slack configured (new slackInstallationsOptions query), not Lark alone. - agent-detail-inspector: render SlackAgentBindButton alongside LarkAgentBindButton in the Integrations section. - regression test: the Integrations tab appears when only Slack is configured. Verified: pnpm typecheck (6/6), pnpm --filter @multica/views test (1478+ pass), pnpm lint (0 errors). Co-authored-by: multica-agent <github@multica.ai> * feat(slack): BYO-app install backend — paste xoxb+xapp, per-app install keyed by real app id (MUL-3666) Adds the bring-your-own-app install path so multiple agents can each have their own bot identity in the SAME Slack workspace (hosted B2 caps at one agent/workspace). User pastes their app's bot token (xoxb-) + app-level token (xapp-); we validate the bot token via auth.test, parse the real Slack app id from the xapp- token, encrypt both tokens, and persist a per-app installation keyed by that app id (real 'A…' ids never collide with hosted 'T…' team ids in the existing unique index — no schema change). - config.go: add app_token_encrypted (BYO discriminator + per-app socket token) - install.go: extract shared persistInstall (atomic cross-ws guard + agent-move retire) - byo_install.go: RegisterBYO + auth.test + app-id parse - handler + route: POST /api/workspaces/{id}/slack/install/byo (admin-only) - tests: keying, encryption, invalid tokens, auth.test failure, cross-ws, agent move Follow-ups (separate commits): per-app Socket Mode connector that consumes the stored app token; in-product BYO install dialog (video + paste form). Co-authored-by: multica-agent <github@multica.ai> * refactor(slack): drop OAuth, unify on BYO per-installation model (MUL-3666) Per product decision, Slack drops the hosted-app OAuth path entirely and unifies on bring-your-own-app (BYO): every installation carries its OWN app-level token and gets its OWN Socket Mode connection, so multiple agents can each have a distinct bot identity in one Slack workspace. - Remove OAuth install (Begin/Complete/code-exchange/sealed state/OAuthConfig/ default scopes), the OAuth callback + begin handlers + routes, and the MULTICA_SLACK_CLIENT_ID/SECRET/REDIRECT/APP_TOKEN env wiring. - Replace the single deployment-level AppConnector with a per-installation slackChannel (authenticated with its own xapp- token) registered as a channel Factory, so the engine Supervisor drives one Socket Mode connection per installation (exactly like Feishu). inbound/outbound/resolvers reused as-is. - Route inbound by the event's api_app_id (== the installation's real app id), not team_id. - InstallService slims to at-rest encryption + the shared persistInstall + list/get/revoke; install is the BYO paste path only (byo_install.go). - Tests: drop the OAuth tests; slack + handler + engine all green. Follow-up (frontend): replace the OAuth "Connect Slack" button with the BYO paste dialog (the begin endpoint it calls is now gone). Co-authored-by: multica-agent <github@multica.ai> * fix(slack): verify BYO bot + app tokens are from the same app, and the app token is live (MUL-3666) Niko review: RegisterBYO only parsed the app id from the xapp string and auth.test'd the bot token, so pasting app A's bot token with app B's app token would 'connect' but be broken (inbound on B's socket, outbound with A's identity). Now: resolve the bot's owning app id via bots.info (on the bot_id from auth.test) and require it to equal the xapp's app id; and live- validate the app token via apps.connections.open. Reject (no persist) on mismatch or a dead app token. Co-authored-by: multica-agent <github@multica.ai> * feat(slack): in-product BYO install dialog (paste bot + app tokens) (MUL-3666) The OAuth begin endpoint was removed server-side, so the "Connect Slack" button now opens a dialog where the admin pastes the bot token (xoxb-) and app-level token (xapp-) of the Slack app they created, and submits to the BYO install endpoint. Includes an optional setup-video link (URL constant, left empty until the walkthrough is recorded). - core: drop beginSlackInstall / BeginSlackInstallResponse; add registerSlackBYO + RegisterSlackBYORequest. - views: SlackAgentBindButton opens the BYO dialog; refreshed comments and install_supported docs (now means "configured", no OAuth). - i18n: new slack.byo_* keys + refreshed page_description in en/zh-Hans/ja/ko. - tests: dialog submit path; views vitest (1479), typecheck, lint, locale parity all green. Co-authored-by: multica-agent <github@multica.ai> * fix(slack): Elon review — team_id routing guard, per-agent reconnect, users:read hint (MUL-3666) 1. Inbound routing keys on api_app_id (the APP, not the Slack workspace), so additionally require the event's team_id to match the installation's stored team. A distributed BYO app installed into another Slack workspace emits the same app id and would otherwise mis-route to this Multica installation. Extracted installationServesTeam() + unit test. 2. BYO install is now agent-keyed (UpsertChannelInstallation, conflict on workspace_id+agent_id+channel_type): one bot per agent. Disconnect → reconnect a NEW app for the SAME agent now UPDATES that agent's row in place instead of violating the (workspace, agent, channel) unique. A unique violation on the (channel_type, app_id) routing index → ErrTeamOwnedByAnother- Workspace (the app is already connected to another agent/workspace). No chat-session retire is needed: a row's agent_id never changes. 3. UX: bots.info (the same-app check) needs the users:read scope — the connect dialog now lists the required bot scopes including it, and the error text says so. Backend build/vet/gofmt/test + views vitest + typecheck + locale parity green. Co-authored-by: multica-agent <github@multica.ai> * fix(slack): publish slack_installation:created on BYO connect; refresh stale comments (MUL-3666) Niko final review: RegisterSlackBYO wrote the response but never published EventSlackInstallationCreated, so only the installer's own tab refreshed — other open clients (Settings, Agent Integrations, other tabs) did not see the new bot in realtime, inconsistent with the revoke event and Lark. Now publishes it on success via a small publishSlackInstallationCreated helper, with a unit test (Bus.Publish is synchronous). Also refreshed comments that still described the removed hosted-OAuth / single deployment-level AppConnector model (handler SlackInstall field, channel.go / inbound.go / outbound.go / byo_install.go). PR title updated separately to the BYO per-installation Socket Mode model. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
683 lines
23 KiB
Go
683 lines
23 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
cryptorand "crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
mathrand "math/rand/v2"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
)
|
|
|
|
// Installation is the channel-agnostic view of one channel_installation
|
|
// row the Supervisor needs to drive a connection. It is intentionally
|
|
// minimal: the engine never reads platform credentials directly — it
|
|
// hands Config to the registry factory, which decodes what it needs — and
|
|
// it never branches on ChannelType beyond using it to pick the factory.
|
|
type Installation struct {
|
|
// ID is the channel_installation primary key. It is the lease key and
|
|
// the supervisors-map key (one supervisor goroutine per ID).
|
|
ID pgtype.UUID
|
|
|
|
// ChannelType selects the registry Factory that builds this row's
|
|
// Channel ("feishu", "slack", …).
|
|
ChannelType channel.Type
|
|
|
|
// Fingerprint condenses the credential-bearing config into an opaque
|
|
// string. Two rows with equal fingerprints are interchangeable to the
|
|
// supervisor; any change between sweeps tears the running connection
|
|
// down and rebuilds it so a re-installed channel (fresh app_id /
|
|
// secret / region) is picked up instead of running indefinitely
|
|
// against stale credentials. The store computes it; the engine treats
|
|
// it as opaque.
|
|
Fingerprint string
|
|
|
|
// Config is the platform credential/config blob, passed verbatim to
|
|
// the registry Factory as channel.Config.Raw. The engine never reads
|
|
// inside it.
|
|
Config json.RawMessage
|
|
}
|
|
|
|
// AcquireLeaseParams fences the WS supervisor lease for an installation.
|
|
// The store performs the CAS: grant when the row is unleased, expired, or
|
|
// already held by Token (renewal); otherwise report it held elsewhere via
|
|
// ErrLeaseNotAcquired.
|
|
type AcquireLeaseParams struct {
|
|
ID pgtype.UUID
|
|
Token string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// ReleaseLeaseParams releases a WS supervisor lease the caller still
|
|
// holds. The store must fence on Token so a stale release from a rotation
|
|
// predecessor cannot clobber a successor's freshly acquired lease.
|
|
type ReleaseLeaseParams struct {
|
|
ID pgtype.UUID
|
|
Token string
|
|
}
|
|
|
|
// ErrLeaseNotAcquired is the sentinel a store returns from AcquireWSLease
|
|
// when the CAS predicate did not match — i.e. another replica (or an
|
|
// in-process predecessor mid-rotation) holds a live lease. The Supervisor
|
|
// treats it as "not ours yet, retry later", distinct from a transport
|
|
// error. Stores wrap their backend's no-rows signal into this.
|
|
var ErrLeaseNotAcquired = errors.New("engine: ws lease held elsewhere")
|
|
|
|
// InstallationStore is the narrow data seam the Supervisor needs:
|
|
// enumerate active installations across every channel type and manage the
|
|
// per-installation WS lease. The application backs it with the generalized
|
|
// channel_* tables; tests substitute a fake.
|
|
type InstallationStore interface {
|
|
// ListActiveInstallations returns every active installation across ALL
|
|
// channel types. There is no per-platform filter here — that hard-coded
|
|
// "feishu" was the whole limitation MUL-3620 removes.
|
|
ListActiveInstallations(ctx context.Context) ([]Installation, error)
|
|
|
|
// AcquireWSLease grants or renews the lease, or returns
|
|
// ErrLeaseNotAcquired when it is held elsewhere.
|
|
AcquireWSLease(ctx context.Context, arg AcquireLeaseParams) error
|
|
|
|
// ReleaseWSLease releases a lease the caller holds (token-fenced).
|
|
ReleaseWSLease(ctx context.Context, arg ReleaseLeaseParams) error
|
|
}
|
|
|
|
// Config tunes the Supervisor's lifecycle loops. All fields have sensible
|
|
// production defaults via withDefaults; tests typically set Now and Logger
|
|
// to inject determinism.
|
|
type Config struct {
|
|
// LeaseTTL is how long a successful AcquireWSLease grant is valid
|
|
// before another replica may steal it. Renewals happen on the tighter
|
|
// LeaseRenewInterval; the gap absorbs transient DB blips.
|
|
LeaseTTL time.Duration
|
|
|
|
// LeaseRenewInterval is the cadence at which the Supervisor re-acquires
|
|
// leases it already owns. MUST be substantially less than LeaseTTL so a
|
|
// single missed renewal does not yield the lease.
|
|
LeaseRenewInterval time.Duration
|
|
|
|
// PollInterval is how often the Supervisor scans for installations to
|
|
// take over (new ones, or ones whose lease expired on another replica).
|
|
PollInterval time.Duration
|
|
|
|
// MinBackoff / MaxBackoff bound the per-installation reconnect
|
|
// schedule: start at MinBackoff, double after each consecutive failure
|
|
// (capped at MaxBackoff), reset on any connection that lived at least
|
|
// ResetBackoffAfter.
|
|
MinBackoff time.Duration
|
|
MaxBackoff time.Duration
|
|
ResetBackoffAfter time.Duration
|
|
|
|
// LeaseReleaseTimeout caps a single ReleaseWSLease call. The release
|
|
// runs on a fresh context (the parent ctx is already cancelled by the
|
|
// time we release on shutdown), so without a deadline a frozen pool
|
|
// could hang shutdown indefinitely. On timeout the lease falls back to
|
|
// natural TTL expiry on the next replica.
|
|
LeaseReleaseTimeout time.Duration
|
|
|
|
// DisconnectTimeout caps a single Channel.Disconnect call made after a
|
|
// connection ends, for the same reason as LeaseReleaseTimeout.
|
|
DisconnectTimeout time.Duration
|
|
|
|
// ShutdownTimeout bounds how long Wait blocks for supervisor goroutines
|
|
// after their parent ctx is cancelled. Wait itself does not enforce it;
|
|
// callers pass it to WaitWithTimeout. Exposed so boot and tests share a
|
|
// default.
|
|
ShutdownTimeout time.Duration
|
|
|
|
// Now returns the current time. Injected for tests; production uses
|
|
// time.Now.
|
|
Now func() time.Time
|
|
|
|
// Logger optional; defaults to slog.Default.
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
func (c Config) withDefaults() Config {
|
|
if c.LeaseTTL == 0 {
|
|
c.LeaseTTL = 90 * time.Second
|
|
}
|
|
if c.LeaseRenewInterval == 0 {
|
|
c.LeaseRenewInterval = 30 * time.Second
|
|
}
|
|
if c.PollInterval == 0 {
|
|
c.PollInterval = 30 * time.Second
|
|
}
|
|
if c.MinBackoff == 0 {
|
|
c.MinBackoff = 2 * time.Second
|
|
}
|
|
if c.MaxBackoff == 0 {
|
|
c.MaxBackoff = 60 * time.Second
|
|
}
|
|
if c.ResetBackoffAfter == 0 {
|
|
c.ResetBackoffAfter = 60 * time.Second
|
|
}
|
|
if c.LeaseReleaseTimeout == 0 {
|
|
c.LeaseReleaseTimeout = 5 * time.Second
|
|
}
|
|
if c.DisconnectTimeout == 0 {
|
|
c.DisconnectTimeout = 5 * time.Second
|
|
}
|
|
if c.ShutdownTimeout == 0 {
|
|
c.ShutdownTimeout = 15 * time.Second
|
|
}
|
|
if c.Now == nil {
|
|
c.Now = time.Now
|
|
}
|
|
if c.Logger == nil {
|
|
c.Logger = slog.Default()
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Supervisor owns the per-installation supervisor goroutines that keep a
|
|
// long-running connection per active installation, across every channel
|
|
// type. It enforces the multi-replica safety rule via the WS lease CAS —
|
|
// at most one Supervisor globally holds the lease for any installation, so
|
|
// duplicate event consumption across replicas is impossible.
|
|
//
|
|
// It is the channel-agnostic generalization of lark.Hub: where the Hub
|
|
// drove a Feishu-only EventConnector built by a ConnectorFactory, the
|
|
// Supervisor drives any channel.Channel built by the channel.Registry, and
|
|
// enumerates installations of every channel type rather than just feishu.
|
|
//
|
|
// Lifecycle:
|
|
//
|
|
// sup := NewSupervisor(store, registry, handler, engine.Config{})
|
|
// go sup.Run(ctx) // returns when ctx is cancelled
|
|
// ... ctx cancellation triggers ...
|
|
// sup.Wait() // joins on every per-installation goroutine
|
|
type Supervisor struct {
|
|
store InstallationStore
|
|
registry *channel.Registry
|
|
handler channel.InboundHandler
|
|
cfg Config
|
|
|
|
// nodeID is the per-process lease ownership token. AcquireWSLease
|
|
// treats matching tokens as "this is us, renew", so a stable nodeID
|
|
// keeps renewals from ping-ponging between replicas.
|
|
nodeID string
|
|
|
|
mu sync.Mutex
|
|
// supervisors keys each in-flight supervisor goroutine by
|
|
// installation_id, alongside the credentials fingerprint the channel
|
|
// was built with. When the row's fingerprint drifts (re-install), the
|
|
// next sweep tears the connection down and rebuilds it with fresh
|
|
// credentials.
|
|
supervisors map[string]supervisorEntry
|
|
// supervisorGen is the source of the monotonic gen counter stored on
|
|
// each entry. Bumped under mu when a new entry is minted (initial start
|
|
// or rotation restart).
|
|
supervisorGen uint64
|
|
wg sync.WaitGroup
|
|
stopped bool
|
|
stopChan chan struct{}
|
|
}
|
|
|
|
// supervisorEntry is the per-installation state the Supervisor holds on
|
|
// each running goroutine. cancel terminates the goroutine (cascading into
|
|
// channel teardown + lease release); fingerprint is the credentials
|
|
// snapshot the channel was built with, used by sweep to detect mid-life
|
|
// rotation; gen is a monotonic counter so the goroutine's deferred cleanup
|
|
// can tell its own entry apart from a successor entry that the rotation
|
|
// path already swapped in.
|
|
type supervisorEntry struct {
|
|
cancel context.CancelFunc
|
|
fingerprint string
|
|
gen uint64
|
|
}
|
|
|
|
// NewSupervisor constructs a Supervisor bound to the supplied store,
|
|
// channel registry, and shared inbound handler. The handler is injected
|
|
// into every Channel the Supervisor builds (via channel.Config.Handler) so
|
|
// the inbound pipeline is written once and shared across platforms. The
|
|
// Supervisor starts no goroutines until Run is called. A nil registry or
|
|
// store is a programming error and will panic at Run.
|
|
func NewSupervisor(store InstallationStore, registry *channel.Registry, handler channel.InboundHandler, cfg Config) *Supervisor {
|
|
cfg = cfg.withDefaults()
|
|
return &Supervisor{
|
|
store: store,
|
|
registry: registry,
|
|
handler: handler,
|
|
cfg: cfg,
|
|
nodeID: newNodeID(),
|
|
supervisors: make(map[string]supervisorEntry),
|
|
stopChan: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// NodeID exposes the per-process lease token, for tests and observability
|
|
// (so operators can correlate DB lease rows to a running replica).
|
|
func (s *Supervisor) NodeID() string { return s.nodeID }
|
|
|
|
// Run is the Supervisor's main loop. It scans installations every
|
|
// PollInterval, attempts to lease any not currently supervised by this
|
|
// process, and reaps supervisors for installations that were revoked or
|
|
// whose lease was lost. Returns when ctx is cancelled; the caller MUST then
|
|
// call Wait to join all supervisor goroutines before exiting.
|
|
func (s *Supervisor) Run(ctx context.Context) {
|
|
defer close(s.stopChan)
|
|
|
|
// First sweep immediately so a freshly-restarted server doesn't wait a
|
|
// full PollInterval before picking up its installations.
|
|
s.sweep(ctx)
|
|
|
|
t := time.NewTicker(s.cfg.PollInterval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
s.cancelAll()
|
|
return
|
|
case <-t.C:
|
|
s.sweep(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wait blocks until every supervisor goroutine the Supervisor started has
|
|
// exited. Call this AFTER cancelling Run's context.
|
|
//
|
|
// It first waits for Run to return (stopChan closed) and only then joins the
|
|
// supervisor WaitGroup. This ordering is load-bearing: Run is the sole caller
|
|
// of startSupervisor, which does s.wg.Add(1), and calling WaitGroup.Add
|
|
// concurrently with WaitGroup.Wait is a data race (and undefined per the
|
|
// WaitGroup contract). Once Run has returned no further Add can happen, so the
|
|
// wg.Wait below is race-free. (Run always closes stopChan via defer, even on
|
|
// panic; callers always pair Wait with a started Run + cancelled ctx.)
|
|
//
|
|
// Prefer WaitWithTimeout in shutdown paths so a stuck supervisor (typically
|
|
// a hung lease release on a frozen DB pool) cannot block process exit
|
|
// indefinitely.
|
|
func (s *Supervisor) Wait() {
|
|
<-s.stopChan
|
|
s.wg.Wait()
|
|
}
|
|
|
|
// WaitWithTimeout is the bounded variant of Wait. Returns true if all
|
|
// supervisor goroutines exited within the deadline, false on timeout. On
|
|
// timeout the orphaned goroutines are reclaimed by the OS and any
|
|
// unreleased leases expire naturally after LeaseTTL on the next replica. A
|
|
// timeout <= 0 falls back to unbounded Wait.
|
|
func (s *Supervisor) WaitWithTimeout(timeout time.Duration) bool {
|
|
if timeout <= 0 {
|
|
s.Wait()
|
|
return true
|
|
}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
s.Wait()
|
|
close(done)
|
|
}()
|
|
t := time.NewTimer(timeout)
|
|
defer t.Stop()
|
|
select {
|
|
case <-done:
|
|
return true
|
|
case <-t.C:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ShutdownTimeout exposes the configured graceful-shutdown deadline so boot
|
|
// can pass the same value to WaitWithTimeout without re-deriving it.
|
|
func (s *Supervisor) ShutdownTimeout() time.Duration { return s.cfg.ShutdownTimeout }
|
|
|
|
// sweep enumerates currently-active installations and starts a supervisor
|
|
// for any this process does not yet supervise. Supervisors for revoked
|
|
// installations are cancelled. Supervisors whose installation row rotated
|
|
// credentials are cancelled and replaced inline so the new channel picks up
|
|
// the fresh row.
|
|
func (s *Supervisor) sweep(ctx context.Context) {
|
|
rows, err := s.store.ListActiveInstallations(ctx)
|
|
if err != nil {
|
|
s.cfg.Logger.Warn("channel engine: list active installations failed", "error", err)
|
|
return
|
|
}
|
|
active := make(map[string]struct{}, len(rows))
|
|
for _, row := range rows {
|
|
// Skip channel types with no registered per-installation Factory. Such
|
|
// rows are driven outside the Supervisor (e.g. Slack's app-level Socket
|
|
// Mode connector owns ONE deployment connection for all its
|
|
// installations, so each Slack row carries only outbound creds + routing,
|
|
// not its own connection). Without this guard the supervise loop would
|
|
// acquire the lease, hit ErrUnknownType from Registry.Build, release, and
|
|
// back off forever — churning the lease and the log on every such row.
|
|
if _, ok := s.registry.Lookup(row.ChannelType); !ok {
|
|
continue
|
|
}
|
|
id := uuidString(row.ID)
|
|
active[id] = struct{}{}
|
|
s.maybeRestartOnRotation(id, row)
|
|
s.startSupervisor(ctx, row)
|
|
}
|
|
// Reap supervisors whose installation is no longer active (revoked
|
|
// since the last sweep). The supervisor exits on the next boundary,
|
|
// releases its lease, and the goroutine returns.
|
|
s.mu.Lock()
|
|
for id, entry := range s.supervisors {
|
|
if _, stillActive := active[id]; !stillActive {
|
|
entry.cancel()
|
|
delete(s.supervisors, id)
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// maybeRestartOnRotation cancels an existing supervisor when its
|
|
// fingerprint differs from the current row's. The replacement is started by
|
|
// the subsequent startSupervisor call in the same sweep iteration. The map
|
|
// entry is dropped inline so the startSupervisor "skip if already
|
|
// supervised" guard does not race the cancel.
|
|
func (s *Supervisor) maybeRestartOnRotation(id string, row Installation) {
|
|
want := row.Fingerprint
|
|
s.mu.Lock()
|
|
entry, ok := s.supervisors[id]
|
|
if !ok || entry.fingerprint == want {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.cfg.Logger.Info("channel engine: credentials rotated, restarting supervisor",
|
|
"installation_id", id,
|
|
"channel_type", string(row.ChannelType),
|
|
)
|
|
entry.cancel()
|
|
delete(s.supervisors, id)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *Supervisor) startSupervisor(parent context.Context, inst Installation) {
|
|
id := uuidString(inst.ID)
|
|
s.mu.Lock()
|
|
if s.stopped {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
if _, exists := s.supervisors[id]; exists {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
ctx, cancel := context.WithCancel(parent)
|
|
s.supervisorGen++
|
|
gen := s.supervisorGen
|
|
s.supervisors[id] = supervisorEntry{
|
|
cancel: cancel,
|
|
fingerprint: inst.Fingerprint,
|
|
gen: gen,
|
|
}
|
|
s.wg.Add(1)
|
|
s.mu.Unlock()
|
|
go s.supervise(ctx, inst, id, gen)
|
|
}
|
|
|
|
// leaseToken composes the per-supervisor lease token: the process-wide
|
|
// nodeID (for cross-replica observability) paired with the supervisor's gen
|
|
// so two supervisors inside the SAME process running back-to-back for the
|
|
// same installation (the rotation path) carry different tokens. That
|
|
// distinction stops an old supervisor's post-cancel release from
|
|
// CAS-matching and deleting the successor's just-acquired lease.
|
|
func leaseToken(nodeID string, gen uint64) string {
|
|
return nodeID + "-g" + strconv.FormatUint(gen, 10)
|
|
}
|
|
|
|
// supervise owns one installation's connection lifecycle. It loops:
|
|
// acquire lease → build channel → run it (Connect blocks) → renew lease
|
|
// while it runs → on exit, release + back off → repeat. Returns when ctx is
|
|
// cancelled.
|
|
func (s *Supervisor) supervise(ctx context.Context, inst Installation, id string, gen uint64) {
|
|
defer s.wg.Done()
|
|
defer func() {
|
|
// Only clear the map entry if it still belongs to us — gen
|
|
// disambiguates "this entry is mine" from "the rotation path already
|
|
// replaced me with a fresh supervisor."
|
|
s.mu.Lock()
|
|
if entry, ok := s.supervisors[id]; ok && entry.gen == gen {
|
|
delete(s.supervisors, id)
|
|
}
|
|
s.mu.Unlock()
|
|
}()
|
|
|
|
leaseTok := leaseToken(s.nodeID, gen)
|
|
log := s.cfg.Logger.With(
|
|
"installation_id", id,
|
|
"channel_type", string(inst.ChannelType),
|
|
"node_id", s.nodeID,
|
|
"lease_token", leaseTok,
|
|
)
|
|
backoff := s.cfg.MinBackoff
|
|
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
// Claim the WS lease. If another replica owns a live lease, sleep
|
|
// until it expires or our context is cancelled.
|
|
leased, err := s.acquireLease(ctx, inst.ID, leaseTok)
|
|
if err != nil {
|
|
log.Warn("channel engine: acquire lease error", "error", err)
|
|
if sleep(ctx, s.cfg.LeaseRenewInterval) {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
if !leased {
|
|
if sleep(ctx, s.cfg.LeaseRenewInterval) {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Lease acquired. Build the platform channel via the registry,
|
|
// run it under a child context, and renew the lease in parallel.
|
|
ch, err := s.registry.Build(channel.Config{
|
|
Type: inst.ChannelType,
|
|
Raw: inst.Config,
|
|
Handler: s.handler,
|
|
})
|
|
if err != nil {
|
|
log.Error("channel engine: build channel failed", "error", err)
|
|
s.releaseLease(inst.ID, leaseTok)
|
|
if sleep(ctx, backoff) {
|
|
return
|
|
}
|
|
backoff = nextBackoff(backoff, s.cfg.MaxBackoff)
|
|
continue
|
|
}
|
|
|
|
runCtx, runCancel := context.WithCancel(ctx)
|
|
renewDone := make(chan struct{})
|
|
go func() {
|
|
defer close(renewDone)
|
|
// renewLeaseUntil cancels runCtx itself on lease loss so the
|
|
// channel exits even if its wire I/O is blocked. This is what
|
|
// makes "at most one active connection per installation across
|
|
// replicas" hold under lease theft.
|
|
s.renewLeaseUntil(runCtx, runCancel, inst.ID, leaseTok)
|
|
}()
|
|
|
|
startedAt := s.cfg.Now()
|
|
runErr := ch.Connect(runCtx)
|
|
runCancel()
|
|
<-renewDone
|
|
s.disconnect(ch, id, log)
|
|
s.releaseLease(inst.ID, leaseTok)
|
|
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
// If the connection lived long enough to be "stable", reset the
|
|
// backoff so a single late failure does not start us at the cap.
|
|
uptime := s.cfg.Now().Sub(startedAt)
|
|
if uptime >= s.cfg.ResetBackoffAfter {
|
|
backoff = s.cfg.MinBackoff
|
|
}
|
|
if runErr != nil {
|
|
log.Warn("channel engine: connection exited with error", "error", runErr, "uptime", uptime.String())
|
|
} else {
|
|
log.Info("channel engine: connection exited cleanly", "uptime", uptime.String())
|
|
}
|
|
if sleep(ctx, jitter(backoff)) {
|
|
return
|
|
}
|
|
backoff = nextBackoff(backoff, s.cfg.MaxBackoff)
|
|
}
|
|
}
|
|
|
|
// acquireLease tries to claim or renew the WS lease. Returns (true, nil)
|
|
// when owned after the call; (false, nil) when held elsewhere; (false, err)
|
|
// for transport / DB failures. token is the per-supervisor token (see
|
|
// leaseToken), NOT the process-wide nodeID.
|
|
func (s *Supervisor) acquireLease(ctx context.Context, instID pgtype.UUID, token string) (bool, error) {
|
|
expires := s.cfg.Now().Add(s.cfg.LeaseTTL)
|
|
err := s.store.AcquireWSLease(ctx, AcquireLeaseParams{
|
|
ID: instID,
|
|
Token: token,
|
|
ExpiresAt: expires,
|
|
})
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
if errors.Is(err, ErrLeaseNotAcquired) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
// renewLeaseUntil re-acquires the lease on a tight cadence so a single
|
|
// missed renewal does not yield it. Exits when ctx is cancelled. Lease loss
|
|
// MUST cancel the channel's run context — otherwise the supervise loop would
|
|
// release the lease while the channel's receive loop kept consuming events
|
|
// until its wire I/O finally errored, exactly the "two replicas processing
|
|
// the same installation" failure mode. cancelRun forces the channel's ctx
|
|
// done immediately, so Connect returns in bounded time even on a silent
|
|
// socket. token MUST be the same per-supervisor token used to acquire.
|
|
func (s *Supervisor) renewLeaseUntil(ctx context.Context, cancelRun context.CancelFunc, instID pgtype.UUID, token string) {
|
|
t := time.NewTicker(s.cfg.LeaseRenewInterval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
leased, err := s.acquireLease(ctx, instID, token)
|
|
if err != nil {
|
|
s.cfg.Logger.Warn("channel engine: lease renewal error",
|
|
"installation_id", uuidString(instID),
|
|
"error", err,
|
|
)
|
|
continue
|
|
}
|
|
if !leased {
|
|
s.cfg.Logger.Warn("channel engine: lease lost; tearing down connection",
|
|
"installation_id", uuidString(instID),
|
|
)
|
|
cancelRun()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// releaseLease writes a token-fenced release so the next supervisor (this
|
|
// process or another replica) can pick up the installation without waiting
|
|
// for LeaseTTL. It runs on a fresh, bounded context (the parent ctx is
|
|
// already cancelled by shutdown time). token MUST be the same per-supervisor
|
|
// token used to acquire — a rotation successor's lease carries a different
|
|
// token, so a stale release no-ops instead of clobbering it.
|
|
func (s *Supervisor) releaseLease(instID pgtype.UUID, token string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), s.cfg.LeaseReleaseTimeout)
|
|
defer cancel()
|
|
if err := s.store.ReleaseWSLease(ctx, ReleaseLeaseParams{
|
|
ID: instID,
|
|
Token: token,
|
|
}); err != nil {
|
|
s.cfg.Logger.Warn("channel engine: release lease failed",
|
|
"installation_id", uuidString(instID),
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// disconnect tears down a channel after its Connect returned, on a fresh
|
|
// bounded context so a wedged Disconnect cannot hang the supervise loop. By
|
|
// the time we get here the link is already down (Connect returned), so this
|
|
// is best-effort resource cleanup.
|
|
func (s *Supervisor) disconnect(ch channel.Channel, id string, log *slog.Logger) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), s.cfg.DisconnectTimeout)
|
|
defer cancel()
|
|
if err := ch.Disconnect(ctx); err != nil {
|
|
log.Warn("channel engine: disconnect failed", "installation_id", id, "error", err)
|
|
}
|
|
}
|
|
|
|
func (s *Supervisor) cancelAll() {
|
|
s.mu.Lock()
|
|
s.stopped = true
|
|
for id, entry := range s.supervisors {
|
|
entry.cancel()
|
|
delete(s.supervisors, id)
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// newNodeID returns a 16-byte hex random string unique to this process.
|
|
// Stored in channel_installation.ws_lease_token; matching tokens on a
|
|
// subsequent acquire are treated as renewals (same owner).
|
|
func newNodeID() string {
|
|
buf := make([]byte, 16)
|
|
if _, err := cryptorand.Read(buf); err != nil {
|
|
// crypto/rand failure is catastrophic and rare; fall back to a
|
|
// timestamp-derived token rather than panicking on boot.
|
|
return fmt.Sprintf("nodeid-fallback-%d", time.Now().UnixNano())
|
|
}
|
|
return hex.EncodeToString(buf)
|
|
}
|
|
|
|
// nextBackoff doubles the current backoff up to max.
|
|
func nextBackoff(cur, max time.Duration) time.Duration {
|
|
next := cur * 2
|
|
if next > max {
|
|
return max
|
|
}
|
|
return next
|
|
}
|
|
|
|
// jitter spreads reconnect storms across the [0.5d, 1.5d) window so many
|
|
// installations do not all retry on the same timer edge.
|
|
func jitter(d time.Duration) time.Duration {
|
|
if d <= 0 {
|
|
return d
|
|
}
|
|
delta := d / 2
|
|
return d - delta + time.Duration(mathrand.Int64N(int64(2*delta)+1))
|
|
}
|
|
|
|
// sleep is a ctx-aware time.Sleep. Returns true iff ctx was cancelled before
|
|
// the sleep completed, so callers can short-circuit shutdown.
|
|
func sleep(ctx context.Context, d time.Duration) bool {
|
|
if d <= 0 {
|
|
return ctx.Err() != nil
|
|
}
|
|
t := time.NewTimer(d)
|
|
defer t.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return true
|
|
case <-t.C:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func uuidString(u pgtype.UUID) string { return util.UUIDToString(u) }
|