- chat_sessions 增 read_at(毫秒,与 chat_messages.created_at 同单位);迁移 0010 并把既有会话回填为各自最新消息时间(升级当天不误报)
- ListSessions 单查询派生 unreadCount(无 N+1,复用 idx_chat_msgs_session_created 覆盖索引)
- MarkSessionRead 只前进 MAX(read_at, ?);未知会话 ErrSessionNotFound;游标方法挂在网关消费侧窄接口(沿用 GetMessage/messageLookup 先例,避免污染 runtime 接口与其 fake)
- POST /api/v1/sessions/{id}/read:writeAuth + 400/404/501,成功广播 session_read、失败路径不广播
- Docs/api-client v0.37(§1 unreadCount / 新行 8a / §3.9 session_read / §4.2 用法)
- 含迁移回填测试、store/handler 测试与变异验证;顺带把 migrate_pinned_test 的「回退一步=0009」硬编码改为对迁移数量免疫
237 lines
12 KiB
Go
237 lines
12 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
|
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
|
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
|
)
|
|
|
|
// This file declares the dependency contracts the gateway consumes. Every one is
|
|
// a narrow interface satisfied by a concrete backend the composition root injects
|
|
// (see Deps in deps.go), so the gateway never reaches for a concrete
|
|
// implementation and tests can substitute a stub.
|
|
|
|
// Authenticator abstracts the credential + RBAC service. *auth.JWTService is the
|
|
// production implementation; it is an interface so tests substitute a mock.
|
|
type Authenticator interface {
|
|
// Sign issues a token for sub with the given role and lifetime.
|
|
Sign(sub string, role auth.Role, ttl time.Duration) (string, error)
|
|
// Verify validates a token and returns its claims.
|
|
Verify(token string) (*auth.Claims, error)
|
|
// Authorize returns an HTTP middleware enforcing at least one required role.
|
|
Authorize(required ...auth.Role) func(http.Handler) http.Handler
|
|
}
|
|
|
|
// CapabilityStore abstracts the global capability registry used to enumerate
|
|
// plugins and channels in the admin API. *capability.Registry is the production
|
|
// implementation.
|
|
type CapabilityStore interface {
|
|
// List returns every capability whose Kind matches, or all when kind is
|
|
// empty.
|
|
List(kind capreg.Kind) []capreg.Capability
|
|
// SetPolicy replaces the reachability markers for the named capability.
|
|
SetPolicy(name string, p capreg.ToolPolicy) error
|
|
}
|
|
|
|
// Chatter runs a single agent turn. *loop.Loop is the production implementation.
|
|
type Chatter interface {
|
|
// Run drives one turn for sessionID and returns the final answer.
|
|
Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error)
|
|
}
|
|
|
|
// SessionStore lists known channel sessions. *session.SessionManager is the
|
|
// production implementation.
|
|
type SessionStore interface {
|
|
List(ctx context.Context) ([]session.Session, error)
|
|
}
|
|
|
|
// PersonaStore lists registered characters (each bound one-to-one to a persona).
|
|
// *persona/registry.Registry is the production implementation. The gateway maps
|
|
// each Character to its wire summary; the interface deliberately returns the
|
|
// domain type so the wire shape stays out of the store contract.
|
|
type PersonaStore interface {
|
|
List() []personareg.Character
|
|
// Persona returns the persona with the given id (to read its visual assets).
|
|
// The production registry implements it; the wire summary reads Avatar /
|
|
// ChatBackground from it so each role's visuals stay role-scoped.
|
|
Persona(id string) (*loader.Persona, bool)
|
|
}
|
|
|
|
// MemoryStore lists memory entries. *runtime/memory.Store is the production
|
|
// implementation. The gateway maps each MemoryItem to its wire summary.
|
|
type MemoryStore interface {
|
|
List(ctx context.Context) ([]memory.MemoryItem, error)
|
|
}
|
|
|
|
// HistoryStore is the persistent chat-history store (design/16). *runtime/history.Store
|
|
// is the production implementation. When nil the session/message endpoints degrade: list
|
|
// sessions falls back to the channel-session view, and the create/messages/delete
|
|
// endpoints answer with a clear 501.
|
|
type HistoryStore interface {
|
|
Append(ctx context.Context, msg history.Message) error
|
|
// AppendFull persists msg like Append but returns the complete stored message
|
|
// (with the assigned id/timestamp) so the orchestrator can broadcast it for
|
|
// cross-end realtime sync (design/16 §4.2).
|
|
AppendFull(ctx context.Context, msg history.Message) (history.Message, error)
|
|
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]history.Message, error)
|
|
// ListAfter returns up to limit messages strictly newer than after in
|
|
// oldest-first order, for forward incremental catch-up after a reconnect
|
|
// (the "before" cursor can only walk backwards). platform filters as in List.
|
|
ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]history.Message, error)
|
|
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
|
|
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]history.Message, error)
|
|
ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]history.Message, error)
|
|
ListAllLog(ctx context.Context, before int64, limit int) ([]history.Message, error)
|
|
DeleteSession(ctx context.Context, sessionID string) error
|
|
// Search returns session-scoped content matches (newest first) for the
|
|
// ?q= message-search path. An empty query yields an empty slice.
|
|
Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]history.Message, error)
|
|
// UpdateMessage edits a single message's content and returns the updated
|
|
// message. An unknown id returns history.ErrMessageNotFound.
|
|
UpdateMessage(ctx context.Context, id, content string) (history.Message, error)
|
|
// DeleteMessage removes a single message. An unknown id returns
|
|
// history.ErrMessageNotFound.
|
|
DeleteMessage(ctx context.Context, id string) error
|
|
// RenameSession sets a session's display title (main or sub). An unknown id
|
|
// returns history.ErrSessionNotFound.
|
|
RenameSession(ctx context.Context, sessionID, title string) error
|
|
// SetPinned pins/unpins a session (main or sub). An unknown id returns
|
|
// history.ErrSessionNotFound.
|
|
SetPinned(ctx context.Context, sessionID string, pinned bool) error
|
|
NewSubSession(ctx context.Context, ownerID string) (string, error)
|
|
// MarkSessionRead advances a session's read cursor (D4) to before and returns
|
|
// the resulting cursor as a Unix millisecond timestamp; a zero before means
|
|
// "now". The cursor only moves forward. An unknown id returns
|
|
// history.ErrSessionNotFound.
|
|
MarkSessionRead(ctx context.Context, sessionID string, before time.Time) (int64, error)
|
|
ListSessions(ctx context.Context) ([]history.SessionMeta, error)
|
|
}
|
|
|
|
// IoTStore lists IoT devices. *iot.Manager is the production implementation.
|
|
type IoTStore interface {
|
|
List(ctx context.Context) ([]iot.Device, error)
|
|
}
|
|
|
|
// SubagentAuditor audits a sub-agent's conversation context for the subagent_ask
|
|
// WS route. *orchestration.Scheduler is the production implementation.
|
|
type SubagentAuditor interface {
|
|
AuditContext(ctx context.Context, id string, mode orchestration.AuditMode, n int, query string) (*orchestration.AuditResult, error)
|
|
}
|
|
|
|
// TaskOutputStore queries a task's output for the task_output_query WS route.
|
|
// *task.Manager is the production implementation.
|
|
type TaskOutputStore interface {
|
|
TaskOutput(ctx context.Context, id string) (task.OutputEvent, error)
|
|
}
|
|
|
|
// AutomationStore enumerates the automations and reminders for the admin list.
|
|
// *automations.Service is the production implementation. The gateway maps each
|
|
// record to its wire shape.
|
|
type AutomationStore interface {
|
|
List() []automations.AutomationRecord
|
|
}
|
|
|
|
// UserStore enumerates the accounts and API keys visible to the admin panel.
|
|
// The production implementation reads the configured admin account plus the
|
|
// auth.APIKey table. The gateway maps each summary to its wire shape.
|
|
type UserStore interface {
|
|
List() []auth.UserSummary
|
|
}
|
|
|
|
// RefreshGrantStore manages opaque refresh tokens (auth.RefreshStore is the
|
|
// production implementation). It is the credential behind the refreshToken
|
|
// returned by login and the POST /auth/refresh, /auth/logout endpoints. Only
|
|
// hashes are persisted; the plaintext token crosses this interface exactly once
|
|
// per issue/rotate. Optional: when nil login omits refreshToken and the refresh
|
|
// and logout endpoints report a clear not-configured error.
|
|
type RefreshGrantStore interface {
|
|
// Issue mints a refresh token for userID/role and returns its plaintext
|
|
// (visible once) plus its expiry.
|
|
Issue(ctx context.Context, userID string, role auth.Role, ttl time.Duration) (string, time.Time, error)
|
|
// Rotate validates plaintext and, when opts.Rotate is set, revokes it and
|
|
// returns a replacement. It returns auth.ErrInvalidGrant for an unknown,
|
|
// revoked, or expired token.
|
|
Rotate(ctx context.Context, plaintext string, opts auth.RotateOptions) (auth.RefreshToken, string, error)
|
|
// Revoke marks the token matching plaintext revoked, returning whether a
|
|
// live token was found. It is idempotent.
|
|
Revoke(ctx context.Context, plaintext string) (bool, error)
|
|
// RevokeAll deletes every refresh token (global invalidation on secret
|
|
// rotation) and returns how many were removed.
|
|
RevokeAll(ctx context.Context) (int64, error)
|
|
// RevokeByUser deletes every refresh token for userID (logout by access
|
|
// token, where the individual grant is unknown) and returns how many were
|
|
// removed.
|
|
RevokeByUser(ctx context.Context, userID string) (int64, error)
|
|
}
|
|
|
|
// BackthinkStore enumerates the persisted background-think records for the
|
|
// M9/M10 monitor panel. *backthink.Backthink is the production implementation.
|
|
type BackthinkStore interface {
|
|
List(ctx context.Context, limit int) ([]backthink.Record, error)
|
|
}
|
|
|
|
// ChannelConfigStore is the runtime-editable channel-adapter configuration
|
|
// (channels.json, design/04 §3.1). It is implemented by the composition root's
|
|
// channel store: Load returns the effective per-platform accounts (file → env
|
|
// fallback), Path reports the channels.json path, and Reload hot-replaces a
|
|
// platform's running adapters after a panel save (Bridge.Reload). Load returns
|
|
// account configs with real tokens so the owner write path can persist them; the
|
|
// admin read path masks them before returning to the client.
|
|
type ChannelConfigStore interface {
|
|
Path() string
|
|
// Load returns the effective per-platform accounts (file → env fallback),
|
|
// including the secret tokens needed to persist them.
|
|
Load() map[string][]config.ChannelAccount
|
|
// Reload rebuilds and hot-replaces the running adapters for platform.
|
|
Reload(platform string) error
|
|
}
|
|
|
|
// OwnerRegistrar registers a channel account id into the owner whitelist
|
|
// (configs/users/owner.yaml). It is the structural identity red line: a scan-login
|
|
// confirmation stamps the scanner's bound ilink_user_id into the whitelist so the
|
|
// channel bridge recognizes it as the owner via actor metadata, never a prompt.
|
|
// The composition root implements it (cmd/cyrene's ownerRegistrar) to persist the
|
|
// whitelist and hot-update the live owner profile. Registration is best-effort:
|
|
// a failure is logged by the caller but does not fail the already-applied
|
|
// channels.json persist.
|
|
type OwnerRegistrar interface {
|
|
RegisterChannelOwner(ctx context.Context, platform, accountID string) error
|
|
}
|
|
|
|
// RagStore backs the admin knowledge-base RAG module (design/10 §3.12 module 8).
|
|
// *runtime/knowledge.Store is the production implementation: it lists/searches
|
|
// the project index, imports a document (chunk + index) and deletes one
|
|
// (project entry + cascaded chapters). An implementation that cannot write
|
|
// reports a clear error (knowledge.ErrWriteUnsupported) rather than a fabricated
|
|
// success; the handlers map a missing document to 404.
|
|
type RagStore interface {
|
|
// ListDocuments enumerates the knowledge-base documents (project-index
|
|
// entries).
|
|
ListDocuments(ctx context.Context) ([]knowledge.Doc, error)
|
|
// Search answers a knowledge-base query (line-level hierarchy + optional
|
|
// semantic/vector ranking).
|
|
Search(ctx context.Context, query string) ([]knowledge.Hit, error)
|
|
// IndexDocument uploads/chunks/embeds a document into the project index.
|
|
IndexDocument(ctx context.Context, doc knowledge.Doc) error
|
|
// DeleteDocument removes a document (and its chapters) by id, returning
|
|
// knowledge.ErrDocumentNotFound when no such document exists.
|
|
DeleteDocument(ctx context.Context, id string) error
|
|
}
|