Files
Cyrene_v2/frontend/web/src/lib/ws.ts
T
AskaEth 63236c9b00
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(capability,gateway): 工具策略落盘 + 主代理代审真接线(裁决 1.1 核对/1.3/10.4.2/13.4.3)
- 1.1(核对):registry 默认两布尔 false 已一致,零代码改动(设计文档已按裁决改写)
- 1.3 + 10.4.2:新增迁移 0012_tool_policy(tool_policies 表,表名复数 + idx_tool_policies_name);
  新增 registry.PolicyStore/SQLitePolicyStore,启动 Load 覆盖注册初值、SetPolicy 先落库后改内存
  (落盘失败保持旧值并报错);面板保存路径不变但从此重启仍在
- 13.4.3:代审真接线 —— restrict.SensitiveCall 结构化判定(terminal / file write / http POST)、
  restrict.Gate/Reviewer/Resolver(无代审者或代审报错一律 fail-closed 拦截),
  组合根装进 loop.Options.Hooks 的 CallAdmit;gateway.RecordApproval 落 audit_logs
  (who=角色名 / what=approval.<工具> / result=granted|denied)并广播 approval_notice 帧
- 前端:代审小字「xxx 想要干什么,我审查后批准了」进聊天流顶部(帧→store→组件,文案单一来源
  复用 audit.Approval.Explain()),附纯 node 逻辑测试
- 复核:迁移在 data/cyrene.db **副本**上 up/down/up 各一次通过;TestRegistry_PolicyStoreSurvivesRestart
  与 TestAdminSetPolicySurvivesRestart 证明「重启后策略仍在」;terminal 触发代审 + 审计有行有端到端断言
2026-10-04 12:30:16 +08:00

206 lines
7.1 KiB
TypeScript

import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { getAuthToken, isTokenExpired, onAuthExpired, setAuthToken } from "./api";
import { toRecord } from "./wsParse";
import type { WsUpEvent } from "./types";
import { handleStreamEvent } from "./ws/handlers/stream";
import { handleThinkingEvent } from "./ws/handlers/thinking";
import { handleToolEvent } from "./ws/handlers/tool";
import { handleMessageEvent } from "./ws/handlers/message";
import { handleAgentEvent } from "./ws/handlers/agent";
import { handleSessionEvent } from "./ws/handlers/session";
import { handleDeviceEvent } from "./ws/handlers/device";
import { handleApprovalEvent } from "./ws/handlers/approval";
/**
* Realtime client for `/ws/chat`. Reconnects with exponential backoff and
* decodes the downstream event stream into the app store. All `data` payloads
* are `unknown` and narrowed here; nothing leaks `any` into components.
*/
let ws: WebSocket | null = null;
let shouldRun = false;
let reconnectTimer: number | null = null;
let attempt = 0;
const MAX_BACKOFF = 30_000;
const BASE_BACKOFF = 1_000;
function wsUrl(): string {
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const token = getAuthToken() ?? "";
const qs = token ? `?token=${encodeURIComponent(token)}` : "";
return `${proto}://${window.location.host}/ws/chat${qs}`;
}
/** Route one decoded downstream frame into the store. Exported (with a `frame`
* shaped first argument) so the frame→store mapping can be exercised as a pure
* logic test without a browser: this project has no jsdom/test runner, so the
* socket lifecycle around it stays untested while the mapping does not have to.
* 帧按类型分组在 lib/ws/handlers/* 中处理:任一组命中(含组内主动丢弃)即结束,
* 全部未命中走下方未知帧兜底 —— 与拆分前的单个 switch 等价。 */
export function dispatchFrame(ev: { type: string; sessionId?: string; data: unknown }): void {
const s = useAppStore.getState();
const r = toRecord(ev.data);
if (handleStreamEvent(ev, r, s)) return;
if (handleThinkingEvent(ev, r, s)) return;
if (handleToolEvent(ev, r, s)) return;
if (handleMessageEvent(ev, r, s)) return;
if (handleAgentEvent(ev, r, s)) return;
if (handleSessionEvent(ev, r, s)) return;
if (handleDeviceEvent(ev, r, s)) return;
if (handleApprovalEvent(ev, r, s)) return;
// Unknown future event: keep it in the context log so no frame is lost.
const text = typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data);
s.addDeviceUpdate(`[${ev.type}] ${text}`);
}
function scheduleReconnect(): void {
if (!shouldRun) return;
if (reconnectTimer !== null) return;
// Only a live, non-expired credential may retry; otherwise this isn't a network
// blip but a dead session we should surface, not loop on.
const token = getAuthToken();
if (!token || isTokenExpired(token)) {
markAuthExpired();
return;
}
const delay = Math.min(BASE_BACKOFF * 2 ** attempt, MAX_BACKOFF);
attempt += 1;
useAppStore.getState().setWsStatus("reconnecting");
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
openSocket();
}, delay);
}
function openSocket(): void {
const s = useAppStore.getState();
// A locally-expired token means the handshake will be rejected with 401 before
// it ever upgrades; don't even attempt it — flag the expiry instead.
const token = getAuthToken();
if (token && isTokenExpired(token)) {
markAuthExpired();
return;
}
try {
ws = new WebSocket(wsUrl());
} catch {
s.setWsStatus("disconnected");
scheduleReconnect();
return;
}
// Tracks whether this socket ever completed the handshake (reached OPEN). A
// browser surfaces a handshake 401 only as onerror+onclose (code 1006, no HTTP
// status), so the only way to tell "auth rejected" from "network blip" is to
// compare the staged close against the token's own expiry.
let opened = false;
s.setWsStatus(attempt === 0 ? "connecting" : "reconnecting");
ws.onopen = () => {
opened = true;
attempt = 0;
useAppStore.getState().setWsStatus("connected");
// Sync the current mode on every (re)connect so the agent's shell matches.
const mode = useModeStore.getState().mode;
sendUp({ type: "mode_switch", data: { mode } });
};
ws.onmessage = (msg) => {
try {
const parsed = JSON.parse(String(msg.data)) as unknown;
const r = toRecord(parsed);
if (!r) return;
dispatchFrame({
type: typeof r.type === "string" ? r.type : "",
// The frame envelope's sessionId is what scopes a payload that does not
// carry its own (see the `message_deleted` case in dispatch).
sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined,
data: r.data,
});
} catch {
// ignore malformed frames
}
};
ws.onerror = () => {
// Handshake/auth rejects land here first. An expired credential is not a
// transient outage, so stop the loop and prompt re-login rather than looping.
const cur = getAuthToken();
if (cur && isTokenExpired(cur)) {
markAuthExpired();
return;
}
useAppStore.getState().setWsStatus("disconnected");
};
ws.onclose = () => {
ws = null;
const cur = getAuthToken();
if (!opened && cur && isTokenExpired(cur)) {
markAuthExpired();
return;
}
useAppStore.getState().setWsStatus("disconnected");
scheduleReconnect();
};
}
/** Open (or reopen) the realtime channel. Safe to call repeatedly. */
export function connectWs(): void {
if (shouldRun && (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING)) {
return;
}
shouldRun = true;
openSocket();
}
/** Close the channel and stop reconnecting. */
export function disconnectWs(): void {
shouldRun = false;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
const cur = ws;
ws = null;
if (cur) {
cur.onclose = null;
cur.close();
}
useAppStore.getState().setWsStatus("idle");
}
/** Terminal "auth expired" path: stop reconnecting, drop the stale token, and
* flag the UI so it prompts re-login instead of burning backoff on a dead
* credential. Fired from (a) a handshake reject on an expired token and (b) the
* REST 401 handler (via onAuthExpired). */
function markAuthExpired(): void {
setAuthToken(null);
disconnectWs();
useAppStore.getState().setWsStatus("expired");
}
/** Send an upstream frame; returns false when the socket is not open. */
export function sendUp(ev: WsUpEvent): boolean {
const cur = ws;
if (!cur || cur.readyState !== WebSocket.OPEN) return false;
cur.send(JSON.stringify(ev));
return true;
}
/** Is the realtime channel open? */
export function isWsOpen(): boolean {
return ws?.readyState === WebSocket.OPEN;
}
// When the REST layer hits a 401/unauthorized on a logged-in call it clears the
// token and fires this handler; mirror it into the same terminal expiry path so
// the reconnect loop stops and the UI shows "登录过期" for both WS and REST 401s.
onAuthExpired(() => {
markAuthExpired();
});