mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 13:06:20 +02:00
* feat(channel): add channel-agnostic engine Supervisor (MUL-3620) Stage-1 (MUL-3515) shipped the channel abstraction but nothing drove it. Add the generic engine that does: - channel.InboundHandler + Config.Handler: the single shared inbound entry the engine injects into every adapter (Hermes set_message_handler model). - channel.Channel.Connect now blocks for the connection lifetime (doc), so the supervisor can tie lease renewal to connection liveness. - new package channel/engine: Supervisor, generalized out of lark.Hub. It enumerates active installations across ALL channel types (no hard-coded feishu), fences each behind the WS lease CAS, builds the platform Channel via channel.Registry, drives Connect/Disconnect with backoff+jitter, and restarts on credential rotation. Knows nothing about any platform. channel.Channel is now driven by an engine; integrations/channel has an external consumer. Feishu adapter + boot cutover follow next. Tests: supervisor_test.go covers lease CAS, reclaim, reap-on-revoke, rotation restart + token fencing, backoff on build error, lease-loss teardown, bounded release, shutdown timeout. Race-clean. Co-authored-by: multica-agent <github@multica.ai> * feat(lark): drive Feishu through the channel engine; remove lark.Hub (MUL-3620) Refactor Feishu into the first channel.Channel and cut boot over to the channel-agnostic engine.Supervisor, removing the Feishu-only Hub. - feishuChannel implements channel.Channel: Connect runs the existing WS long-conn connector for one installation; Send posts a text reply via the Lark IM API; Capabilities declares Feishu's feature set. RegisterFeishu wires it to channel.TypeFeishu — adding a platform is now 'register a Factory', no engine edit. - FeishuRuntime extracts the former Hub.handleEvent / scheduleReply: runs the Dispatcher and drives the detached typing indicator + OutcomeReplier off the connector ACK path. main.go drains it on shutdown after the supervisor stops delivering events. - channelInstallationStore (engine.InstallationStore) enumerates active installations across ALL channel types via the new de-hardcoded query ListAllActiveChannelInstallations; the Supervisor routes each row to its registered Factory by channel_type. Generic per-row fingerprint replaces the feishu-specific one. - boot: engine.Supervisor replaces lark.Hub.Run; MULTICA_LARK_HUB_DISABLED keeps its name for runbook compatibility. - delete hub.go / hub_pgx.go / hub_test.go; relocate the connector contract (EventConnector/EventEmitter), uuidString, and the reply-path tests (-> feishu_runtime_test.go) so coverage is preserved. No channel_* schema change. Feishu behaviour unchanged; lark + channel + engine tests green under -race; go build/vet ./... clean. Remaining (follow-up): lift the Dispatcher pipeline into a channel- agnostic engine.Router over channel.InboundMessage + resolver interfaces, so the inbound core stops being Lark-shaped and adding a channel needs zero core edits (validated by Slack, MUL-3516). Co-authored-by: multica-agent <github@multica.ai> * feat(channel): add channel-agnostic engine.Router (inbound pipeline) (MUL-3620) Generalize lark.Dispatcher's inbound pipeline into engine.Router: the single shared channel.InboundHandler the Supervisor injects into every Channel. It routes by ChannelType to a registered ResolverSet and runs the same ordered pipeline for every platform (install route -> two-phase dedup -> group @bot filter -> identity+membership -> ensure session -> append+mark -> /issue -> debounced run), then drives the detached OutboundReplier + typing indicator. Platform specifics live behind resolver interfaces (InstallationResolver, IdentityResolver, Deduper, SessionBinder, Auditor, OutboundReplier, TypingNotifier) + shared services (IssueCreator/TaskEnqueuer/SessionReader). Adding a platform is 'register a ResolverSet', not 'edit the Router'. Outcome / DropReason values match the legacy lark ones 1:1. Additive: lark.Dispatcher untouched and still wired; the feishu ResolverSet, the cutover, and the old-path removal land next. channel.InboundMessage gains ForceFresh (the normalized /fresh affordance). Batcher moved into engine. router_test.go covers the pipeline invariants (routing, dedup finalize states, group filter, identity, membership, ensure/append, /issue, debounce, flush offline, force-fresh, drain) with generic fakes; race-clean. Co-authored-by: multica-agent <github@multica.ai> * feat(lark): cut Feishu over to engine.Router; remove lark.Dispatcher; core no longer Lark-shaped (MUL-3620) Wire the channel-agnostic engine.Router (added in the prior commit) as the shared inbound handler and refactor Feishu into a ResolverSet, completing the generic-engine cutover. The inbound core (engine.Router) now contains zero platform specifics. - Feishu ResolverSet (feishu_resolvers.go): InstallationResolver, IdentityResolver, Deduper, SessionBinder, Auditor, OutboundReplier, TypingNotifier — each backed by the existing ChannelStore / ChatSessionService / OutcomeReplier / typing indicator, translating at the channel.InboundMessage boundary (platform fields read from Raw). origin_type stays 'lark_chat'. - feishuChannel now produces a normalized channel.InboundMessage and hands it to the engine handler via channel.Config.Handler; the old Raw round-trip through lark.Dispatcher is gone. - Remove lark.Dispatcher, FeishuRuntime, and lark's pending_batcher (the engine owns the pipeline + batcher now); their behavioural coverage moved to engine.Router tests. Surviving native types (InboundMessage / Outcome / DispatchResult) relocated to feishu_types.go. elon review nits addressed: - The channel engine (Registry + Router + Supervisor) is now built UNCONDITIONALLY, outside the MULTICA_LARK_SECRET_KEY gate, so a non-Lark deployment runs it; Feishu registers its Factory + ResolverSet only when its key is present. - channel.Config.Raw is now genuinely the platform config JSONB (channel_installation.config): the feishu factory builds a credentials-only Installation from it, and the workspace/agent identity is resolved per message by the Router — no full-db-row marshaling. - feishuChannel gains direct unit tests: factory config decode, Send text + reply-target mapping, Capabilities, inbound normalization + Raw round-trip, msg-type + result mapping. No channel_* schema change. go build/vet ./... clean; channel + engine + lark green under -race. Feishu behaviour preserved (pipeline logic lifted verbatim, only generalized). Co-authored-by: multica-agent <github@multica.ai> * docs(channel): fix stale comments on the channel engine boot (MUL-3620) Address Elon's review nit: three comments still described the pre-cutover behavior. - handler.go: ChannelSupervisor is built UNCONDITIONALLY now, not nil when MULTICA_LARK_SECRET_KEY is unset. - main.go: same — the supervisor always exists; only MULTICA_LARK_HUB_DISABLED parks it. - router.go: with no platform registered the store still lists active rows; Registry.Build returns ErrUnknownType and the supervisor backs off (it does not 'find no installations'). Comment-only; no behavior change. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
218 lines
8.7 KiB
Go
218 lines
8.7 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// This file defines the pluggable seams the Router runs the inbound pipeline
|
|
// through. Everything platform-specific lives behind these interfaces; a
|
|
// platform registers a ResolverSet and the channel-agnostic Router stays
|
|
// unchanged. The Feishu implementation is the first ResolverSet.
|
|
|
|
// Outcome categorizes what the Router decided to do with an inbound message.
|
|
// Values match the legacy lark outcomes 1:1 so behavior and dashboards carry
|
|
// over unchanged.
|
|
type Outcome string
|
|
|
|
const (
|
|
OutcomeDropped Outcome = "dropped"
|
|
OutcomeNeedsBinding Outcome = "needs_binding"
|
|
OutcomeIngested Outcome = "ingested"
|
|
OutcomeAgentOffline Outcome = "agent_offline"
|
|
OutcomeAgentArchived Outcome = "agent_archived"
|
|
)
|
|
|
|
// DropReason enumerates the drop-audit categories. Values match the legacy
|
|
// lark drop reasons 1:1.
|
|
type DropReason string
|
|
|
|
const (
|
|
DropReasonUnboundUser DropReason = "unbound_user"
|
|
DropReasonNonWorkspaceMember DropReason = "non_workspace_member"
|
|
DropReasonNotAddressedInGroup DropReason = "not_addressed_in_group"
|
|
DropReasonDuplicate DropReason = "duplicate"
|
|
DropReasonRevokedInstallation DropReason = "revoked_installation"
|
|
DropReasonInvalidEvent DropReason = "invalid_event"
|
|
)
|
|
|
|
// Result is the typed verdict the Router produces for one inbound message,
|
|
// consumed by the outbound side (OutboundReplier / typing). It mirrors the
|
|
// legacy lark.DispatchResult.
|
|
type Result struct {
|
|
Outcome Outcome
|
|
DropReason DropReason
|
|
InstallationID pgtype.UUID
|
|
ChatSessionID pgtype.UUID
|
|
// Sender is the platform-native sender id (e.g. Lark open_id), so the
|
|
// replier can target a binding prompt back to the sender.
|
|
Sender string
|
|
IssueID pgtype.UUID
|
|
IssueNumber int32
|
|
IssueIdentifier string
|
|
IssueTitle string
|
|
}
|
|
|
|
// ResolvedInstallation is the channel-agnostic installation context the Router
|
|
// needs after routing. Platform carries the adapter's own installation value
|
|
// opaquely so the set's other ports (binder, replier, typing) reuse it without
|
|
// a re-fetch; the Router never reads Platform.
|
|
type ResolvedInstallation struct {
|
|
ID pgtype.UUID
|
|
WorkspaceID pgtype.UUID
|
|
AgentID pgtype.UUID
|
|
InstallerUserID pgtype.UUID
|
|
Active bool
|
|
Platform any
|
|
}
|
|
|
|
// ResolvedIdentity is the sender mapped to a Multica user.
|
|
type ResolvedIdentity struct {
|
|
UserID pgtype.UUID
|
|
}
|
|
|
|
// EnsureSessionParams carries the inputs for SessionBinder.EnsureSession.
|
|
// Sender is the resolved session creator (the sole human for p2p, the
|
|
// installer for group chats — the Router decides which and passes it here).
|
|
type EnsureSessionParams struct {
|
|
Installation ResolvedInstallation
|
|
Sender pgtype.UUID
|
|
Message channel.InboundMessage
|
|
}
|
|
|
|
// AppendParams carries the inputs for SessionBinder.AppendMessage. ClaimToken
|
|
// is the dedup owner-fence token; the binder runs the dedup Mark INSIDE its
|
|
// chat_message+session tx so the durable write and the Mark commit atomically.
|
|
type AppendParams struct {
|
|
SessionID pgtype.UUID
|
|
Sender pgtype.UUID
|
|
InstallationID pgtype.UUID
|
|
Message channel.InboundMessage
|
|
ClaimToken pgtype.UUID
|
|
}
|
|
|
|
// AppendResult reports what AppendMessage decided.
|
|
type AppendResult struct {
|
|
// IssueCommand is non-nil when the message was an /issue command.
|
|
IssueCommand *IssueCommand
|
|
// DedupMarked is true when AppendMessage finalized the dedup claim in its
|
|
// own tx; the Router then skips the post-pipeline finalize.
|
|
DedupMarked bool
|
|
}
|
|
|
|
// IssueCommand is the parsed /issue command.
|
|
type IssueCommand struct {
|
|
Title string
|
|
Description string
|
|
}
|
|
|
|
// Sentinel errors the resolvers return so the Router can map them to the right
|
|
// product outcome instead of an infrastructure failure.
|
|
var (
|
|
// ErrInstallationNotFound: no installation matches the message's routing
|
|
// key → invalid_event drop.
|
|
ErrInstallationNotFound = errors.New("engine: installation not found")
|
|
// ErrSenderUnbound: the sender has no identity binding → needs_binding.
|
|
ErrSenderUnbound = errors.New("engine: sender unbound")
|
|
// ErrSenderNotMember: the sender is bound but not a workspace member →
|
|
// non_workspace_member drop.
|
|
ErrSenderNotMember = errors.New("engine: sender not a workspace member")
|
|
// ErrDuplicate: Claim found the message already processed / in flight →
|
|
// duplicate drop.
|
|
ErrDuplicate = errors.New("engine: duplicate message")
|
|
// ErrClaimLost: a concurrent reclaim rotated the dedup token mid-flight →
|
|
// treated as a duplicate.
|
|
ErrClaimLost = errors.New("engine: dedup claim lost")
|
|
)
|
|
|
|
// InstallationResolver routes an inbound message to its installation. The
|
|
// adapter reads whatever platform routing key it needs from the message
|
|
// (Source or Raw). Return ErrInstallationNotFound when none matches; return a
|
|
// ResolvedInstallation with Active=false when it exists but is revoked.
|
|
type InstallationResolver interface {
|
|
ResolveInstallation(ctx context.Context, msg channel.InboundMessage) (ResolvedInstallation, error)
|
|
}
|
|
|
|
// IdentityResolver maps the message sender to a Multica user within the
|
|
// installation, re-checking workspace membership. Return ErrSenderUnbound or
|
|
// ErrSenderNotMember for the product cases.
|
|
type IdentityResolver interface {
|
|
ResolveSender(ctx context.Context, inst ResolvedInstallation, msg channel.InboundMessage) (ResolvedIdentity, error)
|
|
}
|
|
|
|
// Deduper is the two-phase idempotency seam. Claim mints an owner-fence token
|
|
// (ErrDuplicate when already processed / in flight); Mark/Release are fenced on
|
|
// the token (a no-op on token mismatch is not an error).
|
|
type Deduper interface {
|
|
Claim(ctx context.Context, installationID pgtype.UUID, messageID string) (claimToken pgtype.UUID, err error)
|
|
Mark(ctx context.Context, installationID pgtype.UUID, messageID string, claimToken pgtype.UUID) error
|
|
Release(ctx context.Context, installationID pgtype.UUID, messageID string, claimToken pgtype.UUID) error
|
|
}
|
|
|
|
// SessionBinder ensures the chat_session and appends the message (with the
|
|
// in-tx dedup Mark). AppendMessage returns ErrClaimLost when the token was
|
|
// rotated mid-flight.
|
|
type SessionBinder interface {
|
|
EnsureSession(ctx context.Context, p EnsureSessionParams) (pgtype.UUID, error)
|
|
AppendMessage(ctx context.Context, p AppendParams) (AppendResult, error)
|
|
}
|
|
|
|
// Auditor records a dropped inbound event (no message body — drop-audit
|
|
// policy). instID may be the zero UUID for installation-less events.
|
|
type Auditor interface {
|
|
RecordDrop(ctx context.Context, instID pgtype.UUID, msg channel.InboundMessage, reason DropReason) error
|
|
}
|
|
|
|
// OutboundReplier delivers the verdict-driven reply (binding prompt, offline /
|
|
// archived notice, /issue confirmation). Optional; nil disables outbound
|
|
// replies. Driven off the ACK critical path by the Router.
|
|
type OutboundReplier interface {
|
|
Reply(ctx context.Context, inst ResolvedInstallation, msg channel.InboundMessage, res Result)
|
|
}
|
|
|
|
// TypingNotifier shows a "processing" indicator when a message is ingested.
|
|
// Optional; nil disables it.
|
|
type TypingNotifier interface {
|
|
OnIngested(ctx context.Context, inst ResolvedInstallation, msg channel.InboundMessage, sessionID pgtype.UUID)
|
|
}
|
|
|
|
// ResolverSet is the per-platform bundle the Router runs the pipeline through.
|
|
// Installation/Identity/Dedup/Session/Audit are required; Replier/Typing are
|
|
// optional. OriginType is the issue.origin_type label written for /issue
|
|
// commands from this channel (Feishu: "lark_chat").
|
|
type ResolverSet struct {
|
|
Installation InstallationResolver
|
|
Identity IdentityResolver
|
|
Dedup Deduper
|
|
Session SessionBinder
|
|
Audit Auditor
|
|
Replier OutboundReplier
|
|
Typing TypingNotifier
|
|
OriginType string
|
|
}
|
|
|
|
// IssueCreator is the narrow subset of service.IssueService the Router needs
|
|
// for the /issue command. Shared across platforms.
|
|
type IssueCreator interface {
|
|
Create(ctx context.Context, p service.IssueCreateParams, opts service.IssueCreateOpts) (service.IssueCreateResult, error)
|
|
}
|
|
|
|
// TaskEnqueuer is the narrow subset of service.TaskService the Router needs to
|
|
// trigger a chat run. Shared across platforms.
|
|
type TaskEnqueuer interface {
|
|
EnqueueChatTask(ctx context.Context, session db.ChatSession, initiatorUserID pgtype.UUID, forceFreshSession bool) (db.AgentTaskQueue, error)
|
|
}
|
|
|
|
// SessionReader reads the rows the debounced flush + /issue identifier need.
|
|
// Shared across platforms; backed by *db.Queries (the channel-backed store).
|
|
type SessionReader interface {
|
|
GetChatSession(ctx context.Context, id pgtype.UUID) (db.ChatSession, error)
|
|
GetWorkspace(ctx context.Context, id pgtype.UUID) (db.Workspace, error)
|
|
}
|