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
+55 -13
View File
@@ -21,6 +21,7 @@ import {
createPostAgentRunsHandler,
} from './agent-run-routes.mjs';
import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs';
import { createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs';
import {
clearUserSessionCookie,
createUserAuth,
@@ -124,6 +125,7 @@ import {
normalizePublicHtmlRelativePath,
syncPublicHtmlAfterFinish,
} from './mindspace-public-finish-sync.mjs';
import { maybeRepairH5HtmlAfterFinish } from './mindspace-h5-html-finish-guard.mjs';
import { quickPlazaFromChat, quickPlazaFromPublicHtml, getQuickPlazaFromPublicHtmlStatus } from './mindspace-chat-plaza.mjs';
import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs';
import { resolvePlazaPostPath, resolvePlazaPublicBase } from './src/utils/public-site-bases.mjs';
@@ -290,7 +292,24 @@ if (ACCESS_PASSWORD && !isDatabaseConfigured()) {
}
let userAuth = null;
let sessionAccess = null;
let tkmindProxy = null;
async function ownsAgentSession(userId, sessionId) {
if (!userId || !sessionId) return false;
if (sessionAccess) return sessionAccess.validateOwnership(userId, sessionId);
if (userAuth) return userAuth.ownsSession(userId, sessionId);
return false;
}
async function unregisterAgentSessionForUser(userId, sessionId) {
if (!userId || !sessionId) return;
if (sessionAccess) {
await sessionAccess.unregisterSession({ userId, sessionId });
return;
}
if (userAuth) await unregisterAgentSessionForUser(userId, sessionId);
}
let agentRunGateway = null;
let chatIntentRouter = null;
let toolGateway = null;
@@ -447,6 +466,10 @@ async function bootstrapUserAuth() {
defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500),
subscriptionService,
});
sessionAccess = createSessionAccess({ userAuth, enabled: isSessionBrokerEnabled() });
if (sessionAccess.enabled) {
console.log('[Portal] Session Broker enabled (MEMIND_SESSION_BROKER_ENABLED=1)');
}
wechatPayClient = createWechatPayClient(loadWechatPayConfig());
wechatOAuthService = createWechatOAuthService(pool, loadWechatOAuthConfig(), { userAuth });
rechargeService = createRechargeService(pool, {
@@ -490,6 +513,7 @@ async function bootstrapUserAuth() {
apiTarget: API_TARGET,
apiSecret: API_SECRET,
userAuth,
sessionAccess,
agentJobService: mindSpaceAgentJobs,
experienceService,
});
@@ -553,6 +577,7 @@ async function bootstrapUserAuth() {
});
directChatService = createDirectChatService({
userAuth,
sessionAccess,
llmProviderService,
sessionSnapshotService,
memoryV2,
@@ -563,11 +588,13 @@ async function bootstrapUserAuth() {
memoryV2,
configService: memoryV2ConfigService,
});
// GOOSED PROXY BOUNDARY: H5 chat → goosed 唯一入口(Patch 5, goosed-proxy-boundary.mjs
tkmindProxy = createTkmindProxy({
apiTarget: API_TARGET,
apiTargets: API_TARGETS,
apiSecret: API_SECRET,
userAuth,
sessionAccess,
llmProviderService,
subscriptionService,
sessionSnapshotService,
@@ -585,6 +612,7 @@ async function bootstrapUserAuth() {
agentRunGateway = createAgentRunGateway({
pool,
userAuth,
sessionAccess,
tkmindProxy,
toolGateway,
directChatService,
@@ -601,6 +629,7 @@ async function bootstrapUserAuth() {
wechatMpService = wechatMp.createWechatMpService({
config: WECHAT_MP_CONFIG,
userAuth,
sessionAccess,
apiFetch: tkmindProxy.apiFetch,
startAgentSession: ({ userId, workingDir, sessionPolicy }) =>
tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy }),
@@ -656,6 +685,7 @@ async function bootstrapUserAuth() {
apiTarget: API_TARGET,
apiSecret: API_SECRET,
userAuth,
sessionAccess,
pageService: mindSpacePages,
pageLiveEdit: mindSpacePageLiveEdit,
llmProviderService,
@@ -1933,7 +1963,7 @@ api.post('/user-memory/v1/remember-recent', async (req, res) => {
if (!sessionId) {
return res.status(400).json({ message: '缺少 sessionId' });
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
const owns = await ownsAgentSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
@@ -1976,7 +2006,7 @@ api.post('/user-memory/v1/sync', async (req, res) => {
if (!sessionId) {
return res.status(400).json({ message: '缺少 sessionId' });
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
const owns = await ownsAgentSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
@@ -2535,6 +2565,7 @@ api.post('/internal/agent/jobs/:jobId/complete', async (req, res) => {
api.get('/mindspace/v1/conversation-packages/:sessionId', createGetConversationPackageHandler({
getRegistry: () => mindSpaceConversationPackageRegistry,
getUserAuth: () => userAuth,
getSessionAccess: () => sessionAccess,
ensureMindSpaceEnabled,
sendData,
mindSpaceError,
@@ -2550,6 +2581,7 @@ api.get(
createDownloadConversationPackageManifestHandler({
getRegistry: () => mindSpaceConversationPackageRegistry,
getUserAuth: () => userAuth,
getSessionAccess: () => sessionAccess,
ensureMindSpaceEnabled,
mindSpaceError,
beforeReadManifest: ({ req, sessionId }) =>
@@ -2565,6 +2597,7 @@ api.get(
createDownloadConversationPackageArtifactHandler({
getRegistry: () => mindSpaceConversationPackageRegistry,
getUserAuth: () => userAuth,
getSessionAccess: () => sessionAccess,
ensureMindSpaceEnabled,
mindSpaceError,
}),
@@ -2931,7 +2964,7 @@ async function resolveOwnedAssistantMessage(user, sessionId, messageId) {
code: 'invalid_page_input',
});
}
if (!(await userAuth.ownsSession(userId, sessionId))) {
if (!(await ownsAgentSession(userId, sessionId))) {
throw Object.assign(new Error('来源会话不存在'), { code: 'source_message_not_found' });
}
@@ -3760,7 +3793,7 @@ api.post('/mindspace/v1/pages/:pageId/live-edit/bind', async (req, res) => {
if (!sessionId) {
return sendError(res, req, 400, 'invalid_request', '缺少 session_id');
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
const owns = await ownsAgentSession(req.currentUser.id, sessionId);
if (!owns) {
return sendError(res, req, 403, 'forbidden', '无权绑定该 Agent 会话');
}
@@ -4524,7 +4557,7 @@ api.post('/llm/apply-local-fallback', async (req, res) => {
if (!me) return res.status(401).json({ message: '未登录' });
const sessionId = String(req.body?.session_id ?? '').trim();
if (!sessionId) return res.status(400).json({ message: '缺少 session_id' });
const owns = await userAuth.ownsSession(me.id, sessionId);
const owns = await ownsAgentSession(me.id, sessionId);
if (!owns) return res.status(403).json({ message: '无权访问该会话' });
try {
const result = await tkmindProxy.applyLocalFallbackForSession(sessionId);
@@ -4550,7 +4583,7 @@ api.post('/agent/runs', async (req, res, next) => {
[
tkmindProxy.requireUser,
tkmindProxy.ensureChatAllowed,
createPostAgentRunsHandler({ userAuth, agentRunGateway }),
createPostAgentRunsHandler({ userAuth, sessionAccess, agentRunGateway }),
],
req,
res,
@@ -4603,7 +4636,7 @@ api.get('/sessions/:sessionId', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
const sessionId = req.params.sessionId;
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
const owns = await ownsAgentSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
@@ -4702,13 +4735,13 @@ api.delete('/sessions/:sessionId', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
const sessionId = req.params.sessionId;
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
const owns = await ownsAgentSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
if (isDirectChatSessionId(sessionId)) {
await userAuth.unregisterAgentSession(req.currentUser.id, sessionId);
await unregisterAgentSessionForUser(req.currentUser.id, sessionId);
void sessionSnapshotService?.remove(sessionId).catch(() => {});
return res.status(204).end();
}
@@ -4720,7 +4753,7 @@ api.delete('/sessions/:sessionId', async (req, res, next) => {
const text = await upstream.text().catch(() => '');
return res.status(upstream.status).send(text || '删除会话失败');
}
await userAuth.unregisterAgentSession(req.currentUser.id, sessionId);
await unregisterAgentSessionForUser(req.currentUser.id, sessionId);
// Remove snapshot so it doesn't linger after deletion.
void sessionSnapshotService?.remove(sessionId).catch(() => {});
return res.status(204).end();
@@ -4733,7 +4766,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
const sessionId = req.params.sessionId;
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
const owns = await ownsAgentSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
@@ -4806,6 +4839,15 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
`[MindSpace] missing public download files after finish for user ${uid}: ${syncResult.docxSync.missing.join(', ')}`,
);
}
await maybeRepairH5HtmlAfterFinish({
sessionId: sid,
userId: uid,
currentUser: req.currentUser,
messages,
publishDir,
syncResult,
tkmindProxy,
});
await syncUserGeneratedPages(uid);
};
return tkmindProxy.proxySessionEvents(req, res, sessionId, {
@@ -4843,14 +4885,14 @@ api.use(async (req, res, next) => {
code: 'AGENT_RUNS_REQUIRED',
});
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
const owns = await ownsAgentSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
}
if (req.method === 'POST' && req.path === '/agent/resume' && req.body?.session_id) {
const owns = await userAuth.ownsSession(req.currentUser.id, req.body.session_id);
const owns = await ownsAgentSession(req.currentUser.id, req.body.session_id);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}