feat(h5-session): Session Broker、run SSE replay 与 Finish 竞态修复

落地 H5 Session 架构 Patch 1–5(Broker 收口、Router decision、SSE taxonomy、goosed 边界检查),
并新增可选 MEMIND_RUN_STREAM_REPLAY run 事件回放与 H5 假交付 guard;修复 Finish 先于 agent-run
gate 导致 UI 永久 loading 的竞态,接入 verify:h5-session-patches 回归脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-06 14:19:48 +08:00
parent e2ad3bf62b
commit 08feae8bef
41 changed files with 2728 additions and 130 deletions
+181
View File
@@ -0,0 +1,181 @@
/**
* Session Broker v0 — thin facade over userAuth session ownership + goosed target mapping.
*
* Does NOT own message history, tool state, memory, or SSE replay.
* See docs/h5-session-architecture-20260706.md Patch 1.
*/
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
export function isSessionBrokerEnabled(env = process.env) {
return envFlag(env.MEMIND_SESSION_BROKER_ENABLED, false);
}
function requireUserAuthFn(userAuth, name) {
if (typeof userAuth?.[name] !== 'function') {
throw new Error(`userAuth.${name} is required for Session Broker`);
}
}
export function createSessionBroker({ userAuth } = {}) {
requireUserAuthFn(userAuth, 'registerAgentSession');
requireUserAuthFn(userAuth, 'getSessionTarget');
requireUserAuthFn(userAuth, 'ownsSession');
requireUserAuthFn(userAuth, 'unregisterAgentSession');
async function validateOwnership(userId, sessionId) {
if (!userId || !sessionId) return false;
return userAuth.ownsSession(userId, sessionId);
}
async function registerSession({ userId, sessionId, target = 0, origin = 'h5' } = {}) {
if (!userId || !sessionId) {
throw new Error('registerSession requires userId and sessionId');
}
await userAuth.registerAgentSession(userId, sessionId, target);
if (typeof userAuth.setSessionOrigin === 'function' && (origin === 'h5' || origin === 'wechat')) {
await userAuth.setSessionOrigin(sessionId, origin);
}
}
async function unregisterSession({ userId, sessionId } = {}) {
if (!userId || !sessionId) return;
await userAuth.unregisterAgentSession(userId, sessionId);
}
async function resolveSessionTarget(sessionId) {
if (!sessionId) return { target: null, node: 0 };
return userAuth.getSessionTarget(sessionId);
}
async function resolveSession(sessionId) {
if (!sessionId) return null;
const { target, node } = await userAuth.getSessionTarget(sessionId);
let origin = 'h5';
if (typeof userAuth.getSessionOrigins === 'function') {
const origins = await userAuth.getSessionOrigins([sessionId]);
origin = origins.get(sessionId) ?? 'h5';
}
return { sessionId, target, node, origin };
}
return {
validateOwnership,
registerSession,
unregisterSession,
resolveSession,
resolveSessionTarget,
};
}
/**
* Unified session ownership/target access with optional broker routing.
* When disabled, delegates to userAuth with identical behavior (Patch 2 fallback).
*/
export function createSessionAccess({ userAuth, enabled = isSessionBrokerEnabled(), broker = null } = {}) {
if (!userAuth) {
throw new Error('createSessionAccess requires userAuth');
}
const useBroker = Boolean(enabled);
const sessionBroker = useBroker ? (broker ?? createSessionBroker({ userAuth })) : null;
async function validateOwnership(userId, sessionId) {
if (useBroker) return sessionBroker.validateOwnership(userId, sessionId);
if (!userId || !sessionId) return false;
return userAuth.ownsSession(userId, sessionId);
}
async function ownsSession(userId, sessionId) {
return validateOwnership(userId, sessionId);
}
async function registerSession({ userId, sessionId, target = 0, origin = 'h5' } = {}) {
if (useBroker) return sessionBroker.registerSession({ userId, sessionId, target, origin });
await userAuth.registerAgentSession(userId, sessionId, target);
if (
typeof userAuth.setSessionOrigin === 'function' &&
(origin === 'h5' || origin === 'wechat')
) {
await userAuth.setSessionOrigin(sessionId, origin);
}
}
async function registerAgentSession(userId, sessionId, target = 0) {
return registerSession({ userId, sessionId, target, origin: 'h5' });
}
async function unregisterSession({ userId, sessionId } = {}) {
if (useBroker) return sessionBroker.unregisterSession({ userId, sessionId });
if (!userId || !sessionId) return;
await userAuth.unregisterAgentSession(userId, sessionId);
}
async function resolveSessionTarget(sessionId) {
if (useBroker) return sessionBroker.resolveSessionTarget(sessionId);
if (!sessionId) return { target: null, node: 0 };
return userAuth.getSessionTarget(sessionId);
}
async function getSessionTarget(sessionId) {
return resolveSessionTarget(sessionId);
}
async function resolveSession(sessionId) {
if (useBroker) return sessionBroker.resolveSession(sessionId);
if (!sessionId) return null;
const { target, node } = await userAuth.getSessionTarget(sessionId);
let origin = 'h5';
if (typeof userAuth.getSessionOrigins === 'function') {
const origins = await userAuth.getSessionOrigins([sessionId]);
origin = origins.get(sessionId) ?? 'h5';
}
return { sessionId, target, node, origin };
}
return {
enabled: useBroker,
broker: sessionBroker,
validateOwnership,
ownsSession,
registerSession,
registerAgentSession,
unregisterSession,
resolveSession,
resolveSessionTarget,
getSessionTarget,
};
}
const noopSessionAccess = {
enabled: false,
broker: null,
async validateOwnership() {
return true;
},
async ownsSession() {
return true;
},
async registerSession() {},
async registerAgentSession() {},
async unregisterSession() {},
async resolveSession() {
return null;
},
async resolveSessionTarget() {
return { target: null, node: 0 };
},
async getSessionTarget() {
return { target: null, node: 0 };
},
};
/** Resolve shared session access for server wiring and tests. */
export function resolveSessionAccess({ userAuth, sessionAccess = null, enabled = isSessionBrokerEnabled() } = {}) {
if (sessionAccess) return sessionAccess;
if (userAuth) return createSessionAccess({ userAuth, enabled });
return noopSessionAccess;
}