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:
+55
-13
@@ -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: '无权访问该会话' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user