design/11 §7.1(主人 2026-10-03 拍板)。此前 hub.broadcast 是全连接广播,而任意角色
(含 viewer)都能连 /ws/chat ⇒ viewer 能收到角色回复正文(message.*)、推理内容
(thinking.*)、消息增量、graph_update、mood 等。这与 design/19 §1/§3「flow 可视化仅
owner/admin 可见」冲突,也与 GET /sessions/{id}/messages 的 owner/admin 档位不一致。
收口:唯一分类点 internal/services/gateway/ws_frame_scope.go 的 frameReachesAll(typ)
(fail-closed switch,默认 false);hub.broadcast 投递前查表,白名单外对非 owner/admin
连接直接跳过。**不给 broadcast 加 bool 参数、不让调用点自己判断** —— 那正是"漏一处就是
泄漏"的形状。全包只有一个 fan-out 循环,14 个 .broadcast( 调用点全部落进该入口。
- 白名单只有 7 个状态机信号帧(不含任何内容):session_busy / pending_ack / pending_drain /
session_idle / session_interrupted / pending_full / session_read
- 其余全部(流式 message/thinking/tool_call、message_persisted/edited/deleted、graph_update、
subagent_event/result、task_output/task_done、automation_result、tool_used、capacity_awaken、
mode_switch、mood、device_update、reminder)仅 owner+admin
- 语义澄清:本门管「谁被主动推送」,数据可见性仍由 REST 决定(viewer 的 12 条只读监控端点
不变,含 GET /personas 的 mood);/ws/chat 连接鉴权与 voice_output 定向帧不动
契约:Docs/api-client.md → v0.48(对 viewer 是收紧;owner/admin 零变化;无端点/字段形状变更)
验证:go build/vet 绿;go test ./... = 58 包 ok / 0 FAIL;-race ./internal/services/gateway/... ok;
linux/amd64 + darwin/arm64 交叉编译绿。真 httptest WS + 真 JWT 按角色逐帧实测 37 帧:
viewer 只收到 7 个状态信号,owner/admin 37/37。
变异验证:① graph_update 加回白名单 → 真 WS 抓到 viewer 收到 graph_update,3 个测试 FAIL;
② session_idle 移出白名单 → 3 个测试 FAIL(viewer 等不到哨兵帧而超时)。均已还原。
遗留(已登记,待主人裁决):① 握手 history 回放对任意角色照发(viewer 连上即得主会话最近
50 条正文);② /ws/chat 上行未按角色授权(viewer 可发 chat = 真驱动模型并落库;
subagent_ask / task_output_query 的定向应答绕过本门)。
182 lines
8.1 KiB
Go
182 lines
8.1 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
)
|
|
|
|
// relay is the bus handler for the ws.> pattern and the producer bridge
|
|
// patterns. The ws.chat subject is a streaming trigger (run the loop and emit
|
|
// chunks); a ws.* subject becomes an outbound event of its design type; a
|
|
// bridged producer subject (device_update, task output/done, automation result,
|
|
// reminder, subagent progress) is normalized into the matching ws.* event type.
|
|
// Events with no WS-visible mapping are dropped so internal agent-to-agent
|
|
// traffic never leaks to clients.
|
|
func (h *hub) relay(ctx context.Context, e bus.Event) error {
|
|
if e.Subject == wsChatSubject {
|
|
go h.streamChat(ctx, e.Payload)
|
|
return nil
|
|
}
|
|
if e.Subject == wsStreamSubject {
|
|
// OnStream→bus→relay path: the loop already published a structured
|
|
// model.StreamEvent; map it to the typed downstream event.
|
|
h.broadcastStream(e.Payload)
|
|
return nil
|
|
}
|
|
ev, ok := normalizeEvent(e)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
h.broadcast(ev)
|
|
return nil
|
|
}
|
|
|
|
// streamChat is the ws.chat trigger entry point. It decodes the request, and
|
|
// routes the turn through the session orchestrator when one is wired (the
|
|
// non-blocking busy/pending/resume path of design/15); otherwise it falls back to
|
|
// the legacy direct run so a hub built without an orchestrator keeps the exact
|
|
// stream_chunk/stream_end contract. It runs on its own goroutine so a slow model
|
|
// call does not stall the bus subscription worker.
|
|
func (h *hub) streamChat(ctx context.Context, payload any) {
|
|
var req chatStreamRequest
|
|
if b, err := json.Marshal(payload); err == nil {
|
|
_ = json.Unmarshal(b, &req)
|
|
}
|
|
if h.runner == nil || (strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "") {
|
|
h.broadcast(wsEvent{Type: "stream_end", TurnID: newTurnID(), Seq: int(h.streamSeq.Add(1)), SessionID: req.SessionID, Data: map[string]any{"stopReason": "idle"}})
|
|
return
|
|
}
|
|
if req.SessionID == "" {
|
|
req.SessionID = "web"
|
|
}
|
|
// The Web main session is the owner's private conversation (JWT = owner), so
|
|
// it binds the full-access env: memory read+write, unchanged behavior. The role
|
|
// comes from the session's binding (design/22 §4.5): a session switched to role Y
|
|
// answers as Y on the next turn, while an unbound/existing session keeps
|
|
// resolving to the global default.
|
|
msgs := userChatMessages(req.Text, req.Image)
|
|
// Thread the client's deviceID and behavior mode into the orchestrator via ctx
|
|
// so both the persisted user turn and the assistant reply are tagged
|
|
// (design/16 §4.5, §5.1).
|
|
ctx = withMode(withDeviceID(ctx, req.DeviceID), req.Mode)
|
|
env := h.envResolver.owner(ctx, req.SessionID)
|
|
if h.orchestrator != nil {
|
|
_ = h.orchestrator.Submit(ctx, req.SessionID, msgs, env)
|
|
return
|
|
}
|
|
_, _ = h.runTurn(ctx, req.SessionID, msgs, env)
|
|
}
|
|
|
|
// runTurn executes one agent turn and broadcasts its output stream. It is the
|
|
// unit both the orchestrator (via its injected TurnRunner) and the legacy
|
|
// fallback path share, so a turn always emits the same stream_chunk/stream_end
|
|
// events regardless of who drives it. When the loop is streaming (OnStream wired)
|
|
// the structured deltas already fan out as message.*/thinking.*/tool_call.*
|
|
// events through the OnStream→bus→relay path, so only the non-streaming path
|
|
// emits a single whole-text chunk to hold the stream contract.
|
|
func (h *hub) runTurn(ctx context.Context, sessionID string, msgs []model.Message, env loop.SessionEnv) (*loop.Result, error) {
|
|
turnID := newTurnID()
|
|
// Thread the turn identity into the run context so the loop stamps it on every
|
|
// streamed event it emits (message.*/thinking.*/tool_call.*); the hub then puts
|
|
// it on the outbound frame. Every turn takes a fresh id — a session has at most
|
|
// one in-flight turn — so a client can correlate one burst of deltas to one
|
|
// assistant turn (design/16 §4.2).
|
|
ctx = loop.WithTurnID(ctx, turnID)
|
|
if h.runner == nil || len(msgs) == 0 {
|
|
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": "idle"}})
|
|
return &loop.Result{StopReason: "idle"}, nil
|
|
}
|
|
res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), sessionID, msgs)
|
|
if err != nil {
|
|
h.log.Error("chat run", "err", err, "session", sessionID)
|
|
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": "error", "error": err.Error()}})
|
|
return nil, err
|
|
}
|
|
if !h.streaming {
|
|
h.broadcast(wsEvent{Type: "stream_chunk", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"delta": res.Text}})
|
|
}
|
|
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": res.StopReason, "steps": res.Steps}})
|
|
return res, nil
|
|
}
|
|
|
|
// handleInterrupt routes a client "interrupt" frame (design/15 §3) to the
|
|
// orchestrator, canceling the in-flight turn and clearing the pending queue for
|
|
// the requested session. It is a no-op when no orchestrator is wired.
|
|
func (h *hub) handleInterrupt(payload json.RawMessage) {
|
|
if h.orchestrator == nil {
|
|
return
|
|
}
|
|
var req struct {
|
|
SessionID string `json:"sessionId"`
|
|
}
|
|
_ = json.Unmarshal(payload, &req)
|
|
if req.SessionID == "" {
|
|
return
|
|
}
|
|
h.orchestrator.Interrupt(req.SessionID)
|
|
}
|
|
|
|
// broadcastStream maps a streamed model.StreamEvent to its downstream WS event
|
|
// type and fans it out through the hub's frame gate (design/11 §7.1): every
|
|
// structured stream frame carries model output, so it reaches owner + admin only.
|
|
// It runs on the bus subscription worker;
|
|
// seq is assigned atomically so a burst stays ordered even if the loop emits
|
|
// events from more than one goroutine. SessionID/TurnID ride on the event itself
|
|
// (the loop stamps them at the turn boundary from the run context), so the frame
|
|
// carries the scope a client needs to route it to the active session and to one
|
|
// assistant turn.
|
|
func (h *hub) broadcastStream(payload any) {
|
|
ev, ok := payload.(model.StreamEvent)
|
|
if !ok {
|
|
return
|
|
}
|
|
typ, data := streamEventData(ev)
|
|
h.broadcast(wsEvent{Type: typ, SessionID: ev.SessionID, TurnID: ev.TurnID, Seq: int(h.streamSeq.Add(1)), Data: data})
|
|
}
|
|
|
|
// chatStreamRequest is the decoded ws.chat payload.
|
|
type chatStreamRequest struct {
|
|
SessionID string `json:"sessionId"`
|
|
Text string `json:"text"`
|
|
Image string `json:"image,omitempty"`
|
|
// DeviceID is the client-produced identifier of the sending browser/device
|
|
// (design/16 §4.5), e.g. "web:chrome:a1b2c3"; it is optional and persisted
|
|
// alongside the message so the agent knows which end the owner is chatting from.
|
|
DeviceID string `json:"deviceId,omitempty"`
|
|
// Mode is the behavior mode the client sent the turn in (design/01 §3,
|
|
// design/16 §5.1); it is optional and persisted per message, falling back to
|
|
// "chat" when omitted.
|
|
Mode string `json:"mode,omitempty"`
|
|
}
|
|
|
|
// newTurnID returns a fresh random hex identifier used to correlate a burst of
|
|
// streamed WS events to one assistant turn. It burns the crypto/rand source and
|
|
// falls back to a nanosecond timestamp if the source is unavailable, so a id is
|
|
// always produced (a blank turnId would break client correlation silently).
|
|
func newTurnID() string {
|
|
var b [8]byte
|
|
if _, err := rand.Read(b[:]); err == nil {
|
|
return "turn-" + hex.EncodeToString(b[:])
|
|
}
|
|
return "turn-" + time.Now().UTC().Format("20060102150405.000000000")
|
|
}
|
|
|
|
// publishChatStream publishes a ws.chat streaming trigger (REST stream mode).
|
|
// deviceID is the sending browser/device id and mode the behavior mode the client
|
|
// sent the turn in, both carried on the payload so the ws.chat consumer can tag
|
|
// the persisted turn (design/16 §4.5, §5.1).
|
|
func (s *Server) publishChatStream(ctx context.Context, sessionID, text, image, deviceID, mode string) {
|
|
if s.bus == nil {
|
|
return
|
|
}
|
|
_ = s.bus.Publish(ctx, wsChatSubject, map[string]any{"sessionId": sessionID, "text": text, "image": image, "deviceId": deviceID, "mode": mode})
|
|
}
|