From 08feae8bef808819cffb610a307269aa1e19b357 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 6 Jul 2026 14:19:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(h5-session):=20Session=20Broker=E3=80=81ru?= =?UTF-8?q?n=20SSE=20replay=20=E4=B8=8E=20Finish=20=E7=AB=9E=E6=80=81?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 落地 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 --- .env.example | 28 ++ agent-run-gateway.mjs | 85 +++++- agent-run-gateway.test.mjs | 165 +++++++++++ agent-run-routes.mjs | 190 +++++++++--- agent-run-routes.test.mjs | 122 ++++++++ agent-run-stream.mjs | 66 +++++ agent-run-stream.test.mjs | 48 +++ chat-agent-run-gate.mjs | 31 ++ chat-agent-run-gate.test.mjs | 31 ++ chat-intent-router.mjs | 167 +++++++++-- chat-intent-router.test.mjs | 107 +++++++ direct-chat-service.mjs | 5 +- direct-chat-service.test.mjs | 45 +++ docs/local-dev.md | 13 + docs/pending-fixes/README.md | 10 + .../h5-finish-before-run-gate-20260706.md | 35 +++ ...ublic-html-fake-delivery-guard-20260706.md | 81 ++++++ .../h5-run-stream-replay-4b-20260706.md | 61 ++++ goosed-proxy-boundary.mjs | 168 +++++++++++ goosed-proxy-boundary.test.mjs | 56 ++++ mindspace-agent-runner.mjs | 5 +- mindspace-conversation-package-routes.mjs | 73 +++-- ...space-conversation-package-routes.test.mjs | 24 ++ mindspace-h5-html-finish-guard.mjs | 275 ++++++++++++++++++ mindspace-h5-html-finish-guard.test.mjs | 151 ++++++++++ mindspace-page-edit-session.mjs | 11 +- package.json | 4 +- schema.sql | 2 + scripts/check-goosed-proxy-boundary.mjs | 26 ++ scripts/verify-h5-session-patches.mjs | 37 +++ scripts/verify-mindspace-publish-guards.mjs | 1 + server.mjs | 68 ++++- session-broker.mjs | 181 ++++++++++++ session-broker.test.mjs | 155 ++++++++++ src/api/client.ts | 13 +- src/hooks/useTKMindChat.ts | 14 +- sse-event-taxonomy.mjs | 86 ++++++ sse-event-taxonomy.test.mjs | 111 +++++++ tkmind-proxy.mjs | 31 +- tkmind-proxy.test.mjs | 64 ++++ wechat-mp.mjs | 12 +- 41 files changed, 2728 insertions(+), 130 deletions(-) create mode 100644 agent-run-stream.mjs create mode 100644 agent-run-stream.test.mjs create mode 100644 chat-agent-run-gate.mjs create mode 100644 chat-agent-run-gate.test.mjs create mode 100644 docs/pending-fixes/README.md create mode 100644 docs/pending-fixes/h5-finish-before-run-gate-20260706.md create mode 100644 docs/pending-fixes/h5-public-html-fake-delivery-guard-20260706.md create mode 100644 docs/pending-fixes/h5-run-stream-replay-4b-20260706.md create mode 100644 goosed-proxy-boundary.mjs create mode 100644 goosed-proxy-boundary.test.mjs create mode 100644 mindspace-h5-html-finish-guard.mjs create mode 100644 mindspace-h5-html-finish-guard.test.mjs create mode 100644 scripts/check-goosed-proxy-boundary.mjs create mode 100644 scripts/verify-h5-session-patches.mjs create mode 100644 session-broker.mjs create mode 100644 session-broker.test.mjs create mode 100644 sse-event-taxonomy.mjs create mode 100644 sse-event-taxonomy.test.mjs diff --git a/.env.example b/.env.example index 435f18d..6434de7 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,34 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # MEMIND_RUNTIME_SLO_HOUR=23 # MEMIND_RUNTIME_SLO_MINUTE=55 +# H5 Session Broker(docs/h5-session-architecture-20260706.md Patch 1+) +# 本地开发:Patch 2 接入后可设 1 验证 broker 路径;默认 0 保持 userAuth 直调。 +# 103 生产:仅在灰度窗口于服务器 /Users/john/Project/Memind/.env 设置,勿把生产值写入 Git。 +# MEMIND_SESSION_BROKER_ENABLED=0 +# 103 生产 goosed 多实例示例(仅服务器 .env,见 docs/103-runtime-topology.md): +# TKMIND_API_TARGET=https://127.0.0.1:18006 +# TKMIND_API_TARGET_1=https://127.0.0.1:18007 + +# H5 Router normalized decision(docs/h5-session-architecture-20260706.md Patch 3) +# 默认 0:gateway 仍读 legacy route;设 1 时 gateway 优先读 classification.decision.route。 +# MEMIND_ROUTER_NORMALIZED_DECISION=0 + +# H5 SSE event taxonomy(docs/h5-session-architecture-20260706.md Patch 4) +# 默认 0:不改变 SSE wire format;设 1 时在 session/run SSE payload 附加 taxonomy 字段(shadow)。 +# MEMIND_SSE_EVENT_TAXONOMY=0 + +# H5 goosed proxy boundary(docs/h5-session-architecture-20260706.md Patch 5) +# CI/发版前跑:npm run verify:goosed-proxy-boundary +# 新增 goosed 调用须走 tkmind-proxy;MindSpace 例外见 goosed-proxy-boundary.mjs allowlist + +# H5 public HTML 假交付 guard(docs/pending-fixes/h5-public-html-fake-delivery-guard-20260706.md) +# 默认 0:仅 warn;设 1 时 Finish 后检测缺失 public/*.html / assets/* 并自动 session repair(每 session 1 次) +# MEMIND_H5_HTML_FINISH_GUARD=0 + +# H5 run SSE replay(Patch 4b,仅本地/测试分支;默认关,未合并 main / 未上 103) +# 设 1 时 GET /agent/runs/:id/events 使用 h5_agent_run_events + Last-Event-ID 重连回放 +# MEMIND_RUN_STREAM_REPLAY=0 + # ===== memind_adm(独立后台服务)===== # 平台超管 /admin-api/* 与广场运营 /api/ops/v1/* 已从公网服务拆出,独立进程运行。 # 登录仍在主域完成;后台凭 H5_COOKIE_DOMAIN=.tkmind.cn 共享的会话 Cookie 鉴权(生产务必设置)。 diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index 7866e5c..903aaec 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -1,7 +1,10 @@ import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { isRunStreamReplayEnabled } from './agent-run-stream.mjs'; import { isDirectChatSessionId } from './direct-chat-service.mjs'; +import { CHAT_INTENT_ROUTE, resolveLegacyRouteFromClassification } from './chat-intent-router.mjs'; +import { resolveSessionAccess } from './session-broker.mjs'; import { loadSnapshotMessages, persistSessionTranscriptFromSnapshot, @@ -226,6 +229,7 @@ function projectRun(row) { export function createAgentRunGateway({ pool, userAuth, + sessionAccess = null, tkmindProxy, toolGateway = null, directChatService = null, @@ -247,6 +251,7 @@ export function createAgentRunGateway({ DEFAULT_RUN_HEARTBEAT_MS, ), }) { + const sessionStore = resolveSessionAccess({ userAuth, sessionAccess }); const inFlight = new Set(); const queuedDispatches = []; const queuedDispatchSet = new Set(); @@ -278,6 +283,13 @@ export function createAgentRunGateway({ ); } + async function appendRunSnapshot(runId) { + if (!isRunStreamReplayEnabled()) return; + const row = await getRunById(runId); + if (!row) return; + await appendEvent(runId, 'run_snapshot', { run: projectRun(row) }); + } + async function getRunById(runId) { const [rows] = await pool.query( `SELECT * FROM h5_agent_runs WHERE id = ? LIMIT 1`, @@ -367,6 +379,7 @@ export function createAgentRunGateway({ values, ); await appendEvent(runId, status, fields); + await appendRunSnapshot(runId); } function startRunHeartbeat(runId, { attempt }) { @@ -422,6 +435,17 @@ export function createAgentRunGateway({ await sessionSnapshotService.remove(sessionId).catch(() => {}); } + async function assertOwnedAgentSession(userId, sessionId) { + if (!sessionStore.enabled || !sessionId || isDirectChatSessionId(sessionId)) return; + const owns = await sessionStore.validateOwnership(userId, sessionId); + if (!owns) { + const err = new Error('无权访问该会话'); + err.code = 'SESSION_FORBIDDEN'; + err.status = 403; + throw err; + } + } + async function resolveRunRouting(row, userMessage, runOptions) { if (!chatIntentRouter?.classify) return null; const enabled = chatIntentRouter.isEnabled @@ -445,16 +469,16 @@ export function createAgentRunGateway({ const runOptions = getRunOptionsFromMessage(userMessage); const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null; const routing = await resolveRunRouting(row, userMessage, runOptions); + const routingDecision = resolveLegacyRouteFromClassification(routing) ?? routing?.route ?? null; if (routing) { await appendEvent(runId, 'intent_routed', routing); - if (routing.route === 'agent_orchestration' && chatIntentRouter?.applyAgentOrchestration) { + if (routingDecision === CHAT_INTENT_ROUTE.AGENT && chatIntentRouter?.applyAgentOrchestration) { const grantedSkills = await resolveGrantedSkills(row.user_id); userMessage = chatIntentRouter.applyAgentOrchestration(userMessage, routing, { grantedSkills }); } } - const routingDecision = routing?.route ?? null; const preferDirectChat = - routingDecision === 'direct_chat' || + routingDecision === CHAT_INTENT_ROUTE.DIRECT_CHAT || (isDirectChatSessionId(row.agent_session_id ?? null) && !runOptions.forceDeepReasoning); const directChatInput = { sessionId: row.agent_session_id ?? null, @@ -487,6 +511,7 @@ export function createAgentRunGateway({ `UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`, [activeSessionId, nowMs(), runId], ); + await appendRunSnapshot(runId); }, }); await appendEvent(runId, 'direct_chat_completed', { @@ -495,6 +520,7 @@ export function createAgentRunGateway({ model: result.model ?? null, billed: Boolean(result.billing?.ok), }); + await appendRunSnapshot(runId); return { sessionId: result.sessionId }; } catch (err) { await appendEvent(runId, 'direct_chat_failed', { @@ -577,6 +603,7 @@ export function createAgentRunGateway({ toolMode: runOptions.toolMode, taskType: runOptions.taskType, }); + await appendRunSnapshot(runId); if (escalatedDirectSessionId) { const priorMessages = await loadSnapshotMessages( sessionSnapshotService, @@ -596,6 +623,8 @@ export function createAgentRunGateway({ }); } } + } else { + await assertOwnedAgentSession(row.user_id, sessionId); } const transcriptPersisted = await persistSessionTranscriptFromSnapshot({ @@ -895,9 +924,59 @@ export function createAgentRunGateway({ }; } + async function listRunEventsForUser(userId, runId, { afterEventId = null, limit = 500 } = {}) { + const run = await getRunForUser(userId, runId); + if (!run) return null; + + let afterCreatedAt = null; + let cursorMiss = false; + if (afterEventId) { + const [cursorRows] = await pool.query( + `SELECT e.created_at + FROM h5_agent_run_events e + INNER JOIN h5_agent_runs r ON r.id = e.run_id + WHERE e.id = ? AND e.run_id = ? AND r.user_id = ? + LIMIT 1`, + [afterEventId, runId, userId], + ); + if (!cursorRows[0]) { + cursorMiss = true; + } else { + afterCreatedAt = Number(cursorRows[0].created_at); + } + } + + const [rows] = await pool.query( + afterCreatedAt == null + ? `SELECT id, event_type, data_json, created_at + FROM h5_agent_run_events + WHERE run_id = ? + ORDER BY created_at ASC, id ASC + LIMIT ?` + : `SELECT id, event_type, data_json, created_at + FROM h5_agent_run_events + WHERE run_id = ? AND created_at > ? + ORDER BY created_at ASC, id ASC + LIMIT ?`, + afterCreatedAt == null ? [runId, limit] : [runId, afterCreatedAt, limit], + ); + + return { + run, + events: rows.map((row) => ({ + id: row.id, + eventType: row.event_type, + data: row.data_json ? JSON.parse(row.data_json) : null, + createdAt: Number(row.created_at), + })), + cursorMiss, + }; + } + return { createRun, getRunForUser, + listRunEventsForUser, dispatchRun, getQueueStatus, dispatchQueuedRuns, diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index fd3c2dc..75aaf6d 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -120,6 +120,35 @@ function createFakePool() { events.push({ id, runId, eventType, dataJson, createdAt }); return [{ affectedRows: 1 }]; } + if (sql.includes('FROM h5_agent_run_events e') && sql.includes('INNER JOIN h5_agent_runs r')) { + const [eventId, runId, userId] = params; + const row = runs.get(runId); + const event = events.find((item) => item.id === eventId && item.runId === runId); + if (!row || row.user_id !== userId || !event) { + return [[]]; + } + return [[{ created_at: event.createdAt }]]; + } + if (sql.includes('FROM h5_agent_run_events') && sql.includes('WHERE run_id = ?')) { + const runId = params[0]; + let filtered = events + .filter((event) => event.runId === runId) + .map((event) => ({ + id: event.id, + event_type: event.eventType, + data_json: event.dataJson, + created_at: event.createdAt, + })) + .sort((a, b) => Number(a.created_at) - Number(b.created_at) || String(a.id).localeCompare(String(b.id))); + if (sql.includes('created_at > ?')) { + const afterCreatedAt = Number(params[1]); + filtered = filtered.filter((event) => Number(event.created_at) > afterCreatedAt); + const limit = Number(params[2]); + return [filtered.slice(0, limit)]; + } + const limit = Number(params[1]); + return [filtered.slice(0, limit)]; + } if (sql.includes("SET status = 'running'")) { const [attempts, startedAt, updatedAt, id] = params; const row = runs.get(id); @@ -696,6 +725,44 @@ test('agent run persists direct session transcript before escalating to goosed', assert.equal(removed[0], 'deep-session-1'); }); +test('agent run rejects reused goosed session when broker ownership check fails', async () => { + const pool = createFakePool(); + const gateway = createAgentRunGateway({ + pool, + sessionAccess: { + enabled: true, + async validateOwnership(userId, sessionId) { + assert.equal(userId, 'user-1'); + assert.equal(sessionId, '20260705_2'); + return false; + }, + }, + tkmindProxy: { + async submitSessionReplyForUser() { + throw new Error('should not submit'); + }, + }, + chatIntentRouter: { + isEnabled() { + return true; + }, + async classify() { + return { route: 'agent_orchestration', confidence: 0.9, reason: 'test', source: 'rule' }; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + sessionId: '20260705_2', + requestId: 'req-forbidden-session', + userMessage: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'failed'); + assert.match(pool.runs.get(run.id).error_message ?? '', /无权访问该会话/); +}); + test('agent run persists portal direct snapshot before goosed submit on same session', async () => { const pool = createFakePool(); const submitted = []; @@ -1347,3 +1414,101 @@ test('queue status reports running heartbeat age and missing heartbeat count', a assert.equal(status.oldestRunningHeartbeatAt, null); assert.ok(status.oldestRunningHeartbeatAgeMs >= 1900); }); + +test('listRunEventsForUser replays events after Last-Event-ID cursor', async () => { + const pool = createFakePool(); + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: {}, + autoDispatch: false, + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-replay-1', + userMessage: { role: 'user', content: [] }, + }); + + const queuedAt = Number(pool.events.find((event) => event.runId === run.id)?.createdAt ?? 1000); + pool.events.push({ + id: 'evt-running', + runId: run.id, + eventType: 'running', + dataJson: null, + createdAt: queuedAt + 1000, + }); + pool.events.push({ + id: 'evt-snapshot', + runId: run.id, + eventType: 'run_snapshot', + dataJson: JSON.stringify({ run: { ...run, status: 'running', agentSessionId: 'sess-1' } }), + createdAt: queuedAt + 2000, + }); + + const full = await gateway.listRunEventsForUser('user-1', run.id); + assert.equal(full.events.length, 3); + const snapshotEvent = full.events.find((event) => event.eventType === 'run_snapshot'); + assert.ok(snapshotEvent); + assert.equal(snapshotEvent.data.run.agentSessionId, 'sess-1'); + assert.equal(full.cursorMiss, false); + + const replay = await gateway.listRunEventsForUser('user-1', run.id, { afterEventId: 'evt-running' }); + assert.equal(replay.events.length, 1); + assert.equal(replay.events[0].id, 'evt-snapshot'); + assert.equal(replay.events[0].data.run.agentSessionId, 'sess-1'); +}); + +test('listRunEventsForUser reports cursorMiss for unknown Last-Event-ID', async () => { + const pool = createFakePool(); + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: {}, + autoDispatch: false, + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-replay-miss', + userMessage: { role: 'user', content: [] }, + }); + + const batch = await gateway.listRunEventsForUser('user-1', run.id, { afterEventId: 'missing-cursor' }); + assert.equal(batch.cursorMiss, true); + assert.ok(batch.events.length >= 1); +}); + +test('markRun appends run_snapshot when MEMIND_RUN_STREAM_REPLAY=1', async () => { + const previous = process.env.MEMIND_RUN_STREAM_REPLAY; + process.env.MEMIND_RUN_STREAM_REPLAY = '1'; + try { + const pool = createFakePool(); + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + return { id: 'sess-replay-1' }; + }, + async submitSessionReplyForUser() {}, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-replay-snapshot', + userMessage: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + + const snapshots = pool.events.filter((event) => ( + event.runId === run.id && event.eventType === 'run_snapshot' + )); + assert.ok(snapshots.length >= 1); + const latestSnapshot = JSON.parse(snapshots.at(-1).dataJson); + assert.equal(latestSnapshot.run.status, 'succeeded'); + } finally { + if (previous === undefined) delete process.env.MEMIND_RUN_STREAM_REPLAY; + else process.env.MEMIND_RUN_STREAM_REPLAY = previous; + } +}); diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index ab5ee5a..e59f60a 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -1,4 +1,14 @@ import { normalizeAgentRunToolMode } from './agent-run-gateway.mjs'; +import { createSessionAccess } from './session-broker.mjs'; +import { + extractRunFromStreamEvent, + formatRunStreamSseChunk, + isRunStreamReplayEnabled, + isTerminalRunStatus, + parseRunStreamLastEventId, + shouldEmitRunUpdateForStreamEvent, +} from './agent-run-stream.mjs'; +import { wrapRunStreamPayload, writeSseErrorAndEnd } from './sse-event-taxonomy.mjs'; function envFlag(value) { return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase()); @@ -43,12 +53,14 @@ function hasExpectedFileValidation(userMessage) { export function createPostAgentRunsHandler({ userAuth, + sessionAccess = null, agentRunGateway, codeRunsEnabled = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED), codeRunUserIds = parseUserIdSet(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS), codeRunTaskTypes = parseTaskTypeSet(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES), requireCodeRunValidation = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION), }) { + const sessionStore = sessionAccess ?? createSessionAccess({ userAuth, enabled: false }); return async function postAgentRuns(request, response) { try { const sessionId = String(request.body?.session_id ?? '').trim() || null; @@ -99,7 +111,7 @@ export function createPostAgentRunsHandler({ return; } if (sessionId) { - const owns = await userAuth.ownsSession(request.currentUser.id, sessionId); + const owns = await sessionStore.validateOwnership(request.currentUser.id, sessionId); if (!owns) { response.status(403).json({ message: '无权访问该会话' }); return; @@ -145,18 +157,49 @@ export function createGetAgentRunHandler({ agentRunGateway }) { }; } +function createRunStreamWriter(response, { wrapPayload = true } = {}) { + let lastSentEventId = null; + let lastRunPayload = null; + + const writeChunk = (eventId, eventName, data) => { + const payload = wrapPayload ? wrapRunStreamPayload(eventName, data) : data; + response.write(formatRunStreamSseChunk({ id: eventId, event: eventName, data: payload })); + if (eventId) lastSentEventId = eventId; + }; + + const emitRunIfChanged = (run, eventId = null) => { + const nextPayload = JSON.stringify(run); + if (nextPayload === lastRunPayload) return false; + lastRunPayload = nextPayload; + writeChunk(eventId, 'run', { run }); + return true; + }; + + return { + getLastSentEventId: () => lastSentEventId, + setLastSentEventId: (value) => { + lastSentEventId = value; + }, + emitRunIfChanged, + writeError: (message) => { + writeChunk(null, 'error', { message }); + }, + }; +} + export function createAgentRunEventsHandler({ agentRunGateway, pollIntervalMs = 1000, keepaliveIntervalMs = 20000, + replayEnabled = isRunStreamReplayEnabled(), }) { return async function getAgentRunEvents(request, response) { const userId = request.currentUser.id; const runId = request.params.runId; let closed = false; - let lastPayload = null; let pollTimer = null; let keepaliveTimer = null; + const initialLastEventId = parseRunStreamLastEventId(request.get?.('last-event-id')); const cleanup = () => { closed = true; @@ -166,41 +209,6 @@ export function createAgentRunEventsHandler({ keepaliveTimer = null; }; - const sendEvent = (event, data) => { - response.write(`event: ${event}\n`); - response.write(`data: ${JSON.stringify(data)}\n\n`); - }; - - const publish = async (prefetchedRun = null) => { - if (closed) return; - try { - const run = prefetchedRun ?? await agentRunGateway.getRunForUser(userId, runId); - if (!run) { - sendEvent('error', { message: '任务不存在' }); - cleanup(); - response.end(); - return; - } - const nextPayload = JSON.stringify(run); - if (nextPayload !== lastPayload) { - lastPayload = nextPayload; - sendEvent('run', { run }); - } - if (run.status !== 'succeeded' && run.status !== 'failed') { - agentRunGateway.dispatchRun(run.id); - return; - } - cleanup(); - response.end(); - } catch (err) { - sendEvent('error', { - message: err instanceof Error ? err.message : '读取任务失败', - }); - cleanup(); - response.end(); - } - }; - const firstRun = await agentRunGateway.getRunForUser(userId, runId); if (!firstRun) { response.status(404).json({ message: '任务不存在' }); @@ -216,9 +224,115 @@ export function createAgentRunEventsHandler({ keepaliveTimer = setInterval(() => { if (!closed) response.write(': keepalive\n\n'); }, keepaliveIntervalMs); + + const stream = createRunStreamWriter(response); + + const publishLegacy = async (prefetchedRun = null) => { + if (closed) return; + try { + const run = prefetchedRun ?? await agentRunGateway.getRunForUser(userId, runId); + if (!run) { + stream.writeError('任务不存在'); + cleanup(); + response.end(); + return; + } + stream.emitRunIfChanged(run); + if (!isTerminalRunStatus(run.status)) { + agentRunGateway.dispatchRun(run.id); + return; + } + cleanup(); + response.end(); + } catch (err) { + if (response.headersSent) { + writeSseErrorAndEnd(response, err instanceof Error ? err.message : '读取任务失败'); + } else { + stream.writeError(err instanceof Error ? err.message : '读取任务失败'); + response.end(); + } + cleanup(); + } + }; + + const publishReplay = async () => { + if (closed) return; + try { + const cursor = stream.getLastSentEventId() ?? initialLastEventId; + const batch = await agentRunGateway.listRunEventsForUser(userId, runId, { + afterEventId: cursor, + }); + if (!batch?.run) { + stream.writeError('任务不存在'); + cleanup(); + response.end(); + return; + } + + let latestRun = batch.run; + for (const event of batch.events) { + if (closed) return; + const snapshotRun = extractRunFromStreamEvent(event); + if (snapshotRun) { + latestRun = snapshotRun; + stream.emitRunIfChanged(snapshotRun, event.id); + continue; + } + if (shouldEmitRunUpdateForStreamEvent(event.eventType)) { + latestRun = await agentRunGateway.getRunForUser(userId, runId) ?? latestRun; + stream.emitRunIfChanged(latestRun, event.id); + continue; + } + response.write(formatRunStreamSseChunk({ + id: event.id, + event: 'run_event', + data: wrapRunStreamPayload('run_event', { + eventId: event.id, + eventType: event.eventType, + data: event.data, + createdAt: event.createdAt, + }), + })); + stream.setLastSentEventId(event.id); + } + + if (batch.cursorMiss && !cursor) { + stream.emitRunIfChanged(batch.run); + } else if (batch.cursorMiss && cursor) { + latestRun = await agentRunGateway.getRunForUser(userId, runId) ?? latestRun; + stream.emitRunIfChanged(latestRun); + } else { + stream.emitRunIfChanged(latestRun); + } + + if (!isTerminalRunStatus(latestRun.status)) { + agentRunGateway.dispatchRun(runId); + return; + } + cleanup(); + response.end(); + } catch (err) { + if (response.headersSent) { + writeSseErrorAndEnd(response, err instanceof Error ? err.message : '读取任务失败'); + } else { + stream.writeError(err instanceof Error ? err.message : '读取任务失败'); + response.end(); + } + cleanup(); + } + }; + + const publish = replayEnabled && agentRunGateway.listRunEventsForUser + ? publishReplay + : publishLegacy; + pollTimer = setInterval(() => { void publish(); }, pollIntervalMs); - await publish(firstRun); + if (replayEnabled && agentRunGateway.listRunEventsForUser) { + await publishReplay(); + } else { + await publishLegacy(firstRun); + } }; } diff --git a/agent-run-routes.test.mjs b/agent-run-routes.test.mjs index bc5c74d..0ddefee 100644 --- a/agent-run-routes.test.mjs +++ b/agent-run-routes.test.mjs @@ -464,6 +464,40 @@ test('POST /agent/runs rejects a session the user does not own', async () => { assert.deepEqual(res.body, { message: '无权访问该会话' }); }); +test('POST /agent/runs validates ownership through sessionAccess when provided', async () => { + const validated = []; + const handler = createPostAgentRunsHandler({ + userAuth: {}, + sessionAccess: { + async validateOwnership(userId, sessionId) { + validated.push({ userId, sessionId }); + return false; + }, + }, + agentRunGateway: { + async createRun() { + throw new Error('should not be called'); + }, + }, + }); + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1' }, + body: { + session_id: 'session-broker', + request_id: 'req-broker', + user_message: { role: 'user', content: [] }, + }, + }, + res, + ); + + assert.deepEqual(validated, [{ userId: 'user-1', sessionId: 'session-broker' }]); + assert.equal(res.statusCode, 403); +}); + test('GET /agent/runs/:runId dispatches unfinished runs', async () => { const dispatched = []; const handler = createGetAgentRunHandler({ @@ -550,6 +584,94 @@ test('GET /agent/runs/:runId/events streams the current run and closes on termin assert.equal(res.ended, true); }); +test('GET /agent/runs/:runId/events attaches run taxonomy when flag enabled', async () => { + const previous = process.env.MEMIND_SSE_EVENT_TAXONOMY; + process.env.MEMIND_SSE_EVENT_TAXONOMY = '1'; + try { + const handler = createAgentRunEventsHandler({ + agentRunGateway: { + async getRunForUser() { + return { id: 'run-tax', status: 'succeeded', sessionId: 'session-1' }; + }, + dispatchRun() {}, + }, + pollIntervalMs: 5, + keepaliveIntervalMs: 50, + }); + const res = createSseResponseRecorder(); + await handler( + { + currentUser: { id: 'user-1' }, + params: { runId: 'run-tax' }, + on() {}, + }, + res, + ); + assert.match(res.chunks.join(''), /"taxonomy":"terminal"/); + } finally { + if (previous == null) delete process.env.MEMIND_SSE_EVENT_TAXONOMY; + else process.env.MEMIND_SSE_EVENT_TAXONOMY = previous; + } +}); + +test('GET /agent/runs/:runId/events replay mode emits SSE ids and replays after Last-Event-ID', async () => { + const events = [ + { + id: 'evt-1', + eventType: 'run_snapshot', + data: { run: { id: 'run-replay', status: 'running', sessionId: null } }, + createdAt: 1, + }, + { + id: 'evt-2', + eventType: 'run_snapshot', + data: { run: { id: 'run-replay', status: 'succeeded', sessionId: 'session-9' } }, + createdAt: 2, + }, + ]; + const handler = createAgentRunEventsHandler({ + replayEnabled: true, + agentRunGateway: { + async getRunForUser() { + return { id: 'run-replay', status: 'succeeded', sessionId: 'session-9' }; + }, + async listRunEventsForUser(_userId, _runId, { afterEventId } = {}) { + if (afterEventId === 'evt-1') { + return { + run: { id: 'run-replay', status: 'succeeded', sessionId: 'session-9' }, + events: [events[1]], + cursorMiss: false, + }; + } + return { + run: { id: 'run-replay', status: 'running', sessionId: null }, + events, + cursorMiss: false, + }; + }, + dispatchRun() {}, + }, + pollIntervalMs: 1000, + keepaliveIntervalMs: 5000, + }); + const res = createSseResponseRecorder(); + await handler( + { + currentUser: { id: 'user-1' }, + params: { runId: 'run-replay' }, + get(name) { + return name.toLowerCase() === 'last-event-id' ? 'evt-1' : undefined; + }, + on() {}, + }, + res, + ); + assert.equal(res.ended, true); + const output = res.chunks.join(''); + assert.match(output, /id: evt-2/); + assert.match(output, /"sessionId":"session-9"/); +}); + test('GET /agent/runs/:runId/events republishes changed run state before terminal close', async () => { let readCount = 0; const dispatched = []; diff --git a/agent-run-stream.mjs b/agent-run-stream.mjs new file mode 100644 index 0000000..d0dc2d1 --- /dev/null +++ b/agent-run-stream.mjs @@ -0,0 +1,66 @@ +export const RUN_STREAM_STATUS_EVENTS = new Set([ + 'queued', + 'running', + 'retryable', + 'succeeded', + 'failed', +]); + +export const RUN_STREAM_TERMINAL_STATUSES = new Set(['succeeded', 'failed']); + +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +export function isRunStreamReplayEnabled(env = process.env) { + return envFlag(env.MEMIND_RUN_STREAM_REPLAY, false); +} + +export function parseRunStreamLastEventId(value) { + const id = String(value ?? '').trim(); + return id || null; +} + +export function extractRunFromStreamEvent(event) { + if (!event) return null; + if (event.eventType === 'run_snapshot' && event.data?.run) { + return event.data.run; + } + return null; +} + +export function shouldEmitRunUpdateForStreamEvent(eventType) { + return RUN_STREAM_STATUS_EVENTS.has(String(eventType ?? '').trim()) + || String(eventType ?? '').trim() === 'run_snapshot'; +} + +export function formatRunStreamSseChunk({ id = null, event, data }) { + const lines = []; + if (id) lines.push(`id: ${id}`); + lines.push(`event: ${event}`); + lines.push(`data: ${JSON.stringify(data)}`); + return `${lines.join('\n')}\n\n`; +} + +export function projectRunStreamEvents(events, { fallbackRun = null } = {}) { + let latestRun = fallbackRun; + const emittedRuns = []; + for (const event of Array.isArray(events) ? events : []) { + const snapshotRun = extractRunFromStreamEvent(event); + if (snapshotRun) { + latestRun = snapshotRun; + emittedRuns.push({ eventId: event.id, run: snapshotRun }); + continue; + } + if (shouldEmitRunUpdateForStreamEvent(event.eventType) && latestRun) { + emittedRuns.push({ eventId: event.id, run: latestRun }); + } + } + return { latestRun, emittedRuns }; +} + +export function isTerminalRunStatus(status) { + return RUN_STREAM_TERMINAL_STATUSES.has(String(status ?? '').trim()); +} diff --git a/agent-run-stream.test.mjs b/agent-run-stream.test.mjs new file mode 100644 index 0000000..8b4867c --- /dev/null +++ b/agent-run-stream.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + extractRunFromStreamEvent, + formatRunStreamSseChunk, + isRunStreamReplayEnabled, + isTerminalRunStatus, + projectRunStreamEvents, + shouldEmitRunUpdateForStreamEvent, +} from './agent-run-stream.mjs'; + +test('formatRunStreamSseChunk includes SSE id for replay cursor', () => { + const chunk = formatRunStreamSseChunk({ + id: 'evt-1', + event: 'run', + data: { run: { id: 'run-1', status: 'running' } }, + }); + assert.match(chunk, /^id: evt-1\n/); + assert.match(chunk, /event: run\n/); + assert.match(chunk, /"status":"running"/); +}); + +test('projectRunStreamEvents prefers run_snapshot payloads', () => { + const { emittedRuns, latestRun } = projectRunStreamEvents([ + { id: 'e1', eventType: 'queued', data: null, createdAt: 1 }, + { + id: 'e2', + eventType: 'run_snapshot', + data: { run: { id: 'run-1', status: 'running', sessionId: 's1' } }, + createdAt: 2, + }, + { id: 'e3', eventType: 'succeeded', data: null, createdAt: 3 }, + ]); + assert.equal(emittedRuns.length, 2); + assert.equal(latestRun?.sessionId, 's1'); + assert.equal(extractRunFromStreamEvent(emittedRuns[0] && { + eventType: 'run_snapshot', + data: { run: emittedRuns[0].run }, + })?.sessionId, 's1'); +}); + +test('shouldEmitRunUpdateForStreamEvent covers status and snapshot events', () => { + assert.equal(shouldEmitRunUpdateForStreamEvent('run_snapshot'), true); + assert.equal(shouldEmitRunUpdateForStreamEvent('worker_heartbeat'), false); + assert.equal(isTerminalRunStatus('succeeded'), true); + assert.equal(isRunStreamReplayEnabled({ MEMIND_RUN_STREAM_REPLAY: '0' }), false); + assert.equal(isRunStreamReplayEnabled({ MEMIND_RUN_STREAM_REPLAY: '1' }), true); +}); diff --git a/chat-agent-run-gate.mjs b/chat-agent-run-gate.mjs new file mode 100644 index 0000000..e942d82 --- /dev/null +++ b/chat-agent-run-gate.mjs @@ -0,0 +1,31 @@ +/** + * Agent-run gate vs session Finish ordering (regression guard). + * + * Context (2026-07): session SSE Finish can arrive before agent-run SSE reports + * succeeded. Re-entering streaming after the run gate completes leaves the UI stuck + * on typing dots even though the assistant reply is already visible. + * + * Tests: chat-agent-run-gate.test.mjs + * Consumer: src/hooks/useTKMindChat.ts + */ + +/** + * @param {{ chatState?: string; finishedViaPortalDirectChat?: boolean }} input + * @returns {'idle' | 'streaming'} + */ +export function resolvePostAgentRunChatState({ + chatState = 'waiting', + finishedViaPortalDirectChat = false, +} = {}) { + if (finishedViaPortalDirectChat) return 'idle'; + if (chatState === 'idle') return 'idle'; + return 'streaming'; +} + +/** + * @param {string | undefined | null} chatState + * @returns {boolean} + */ +export function shouldPromoteSessionIdToStreaming(chatState) { + return chatState !== 'idle'; +} diff --git a/chat-agent-run-gate.test.mjs b/chat-agent-run-gate.test.mjs new file mode 100644 index 0000000..270550b --- /dev/null +++ b/chat-agent-run-gate.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + resolvePostAgentRunChatState, + shouldPromoteSessionIdToStreaming, +} from './chat-agent-run-gate.mjs'; + +test('resolvePostAgentRunChatState keeps idle when Finish beat agent-run gate', () => { + assert.equal( + resolvePostAgentRunChatState({ chatState: 'idle', finishedViaPortalDirectChat: false }), + 'idle', + ); +}); + +test('resolvePostAgentRunChatState returns streaming while still waiting for session Finish', () => { + assert.equal(resolvePostAgentRunChatState({ chatState: 'waiting' }), 'streaming'); + assert.equal(resolvePostAgentRunChatState({ chatState: 'streaming' }), 'streaming'); +}); + +test('resolvePostAgentRunChatState prefers portal direct chat completion', () => { + assert.equal( + resolvePostAgentRunChatState({ chatState: 'streaming', finishedViaPortalDirectChat: true }), + 'idle', + ); +}); + +test('shouldPromoteSessionIdToStreaming skips re-streaming after Finish', () => { + assert.equal(shouldPromoteSessionIdToStreaming('idle'), false); + assert.equal(shouldPromoteSessionIdToStreaming('waiting'), true); + assert.equal(shouldPromoteSessionIdToStreaming('streaming'), true); +}); diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index 9e5dcc9..eca2a83 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -14,6 +14,29 @@ export const CHAT_INTENT_ROUTE = { AGENT: 'agent_orchestration', }; +export const ROUTER_DECISION_ROUTE = { + CHAT: 'chat', + AGENT: 'agent', +}; + +export const ROUTER_DECISION_MODE = { + SSE: 'sse', + WS: 'ws', +}; + +export const ROUTER_DECISION_SESSION_HINT = { + REUSE: 'reuse', + NEW: 'new', +}; + +const ALLOWED_ROUTER_DECISION_FLAGS = new Set([ + 'memory_recall', + 'selected_skill', + 'force_deep_reasoning', + 'code_task', + 'long_running', +]); + const AGENT_ORCHESTRATION_HEADER = '【Memind 任务编排】'; const SKILL_PROMPT_KEYS = { @@ -301,11 +324,86 @@ function parseRouterJson(reply) { function normalizeRoute(value) { const normalized = String(value ?? '').trim().toLowerCase(); - if (normalized === CHAT_INTENT_ROUTE.DIRECT_CHAT) return CHAT_INTENT_ROUTE.DIRECT_CHAT; - if (normalized === CHAT_INTENT_ROUTE.AGENT || normalized === 'agent') return CHAT_INTENT_ROUTE.AGENT; + if (normalized === CHAT_INTENT_ROUTE.DIRECT_CHAT || normalized === ROUTER_DECISION_ROUTE.CHAT) { + return CHAT_INTENT_ROUTE.DIRECT_CHAT; + } + if ( + normalized === CHAT_INTENT_ROUTE.AGENT || + normalized === 'agent' || + normalized === 'agent_orchestration' || + normalized === ROUTER_DECISION_ROUTE.AGENT + ) { + return CHAT_INTENT_ROUTE.AGENT; + } return null; } +export function isNormalizedRouterDecisionEnabled(env = process.env) { + return envFlag(env.MEMIND_ROUTER_NORMALIZED_DECISION, false); +} + +export function buildRouterNormalizedDecision( + classification, + { + sessionId = null, + sessionMessageCount = null, + forceDeepReasoning = false, + toolMode = 'chat', + text = '', + } = {}, +) { + const legacyRoute = classification?.route ?? CHAT_INTENT_ROUTE.AGENT; + const route = + legacyRoute === CHAT_INTENT_ROUTE.DIRECT_CHAT + ? ROUTER_DECISION_ROUTE.CHAT + : ROUTER_DECISION_ROUTE.AGENT; + const mode = ROUTER_DECISION_MODE.SSE; + let sessionHint = ROUTER_DECISION_SESSION_HINT.NEW; + if (sessionId) { + sessionHint = ROUTER_DECISION_SESSION_HINT.REUSE; + } + if ( + sessionId && + !isDirectChatSessionId(sessionId) && + sessionMessageCount != null && + Number(sessionMessageCount) === 0 + ) { + sessionHint = ROUTER_DECISION_SESSION_HINT.NEW; + } + + const flags = []; + if (isMemoryRecallQuestion(text)) flags.push('memory_recall'); + if (forceDeepReasoning) flags.push('force_deep_reasoning'); + if (toolMode === 'code') flags.push('code_task'); + if (classification?.suggestedSkill) flags.push('selected_skill'); + const normalizedFlags = [...new Set(flags)].filter((flag) => ALLOWED_ROUTER_DECISION_FLAGS.has(flag)); + + return { + route, + mode, + session_hint: sessionHint, + flags: normalizedFlags, + }; +} + +export function finalizeRouterClassification(classification, context = {}) { + if (!classification) return classification; + return { + ...classification, + decision: buildRouterNormalizedDecision(classification, context), + }; +} + +export function resolveLegacyRouteFromClassification(classification) { + if (!classification) return null; + if (isNormalizedRouterDecisionEnabled() && classification.decision?.route) { + return classification.decision.route === ROUTER_DECISION_ROUTE.CHAT + ? CHAT_INTENT_ROUTE.DIRECT_CHAT + : CHAT_INTENT_ROUTE.AGENT; + } + return classification.route ?? null; +} + export function resolveChatIntentRouterPolicy({ env = process.env, overrides = {} } = {}) { const fallbackRoute = normalizeRoute(env?.MEMIND_CHAT_ROUTER_FALLBACK_ROUTE) ?? CHAT_INTENT_ROUTE.AGENT; return { @@ -432,23 +530,30 @@ export function classifyWithRules({ includeIntentPatterns = true, llmRouterEnabled = false, } = {}) { + const decisionContext = { + text, + forceDeepReasoning, + toolMode, + sessionId, + sessionMessageCount, + }; const normalized = String(text ?? '').trim(); if (forceDeepReasoning || toolMode !== 'chat') { - return normalizeClassification({ + return finalizeRouterClassification(normalizeClassification({ route: CHAT_INTENT_ROUTE.AGENT, confidence: 1, reason: forceDeepReasoning ? '用户开启深度推理' : '代码任务模式', - }, { source: 'rule' }); + }, { source: 'rule' }), decisionContext); } if (userMessage && messageHasExplicitChatSkillSelection(userMessage)) { - return buildSkillSelectionClassification(userMessage); + return finalizeRouterClassification(buildSkillSelectionClassification(userMessage), decisionContext); } if (userMessage && !isTextOnlyUserMessage(userMessage)) { - return normalizeClassification({ + return finalizeRouterClassification(normalizeClassification({ route: CHAT_INTENT_ROUTE.AGENT, confidence: 1, reason: '消息包含非文本内容', - }, { source: 'rule' }); + }, { source: 'rule' }), decisionContext); } if ( !llmRouterEnabled && @@ -456,42 +561,42 @@ export function classifyWithRules({ normalized && isMemoryRecallQuestion(normalized) ) { - return normalizeClassification({ + return finalizeRouterClassification(normalizeClassification({ route: CHAT_INTENT_ROUTE.DIRECT_CHAT, confidence: 0.98, reason: '用户在询问个人记忆或历史对话', - }, { source: 'rule' }); + }, { source: 'rule' }), decisionContext); } if (!llmRouterEnabled && hasPriorAgentConversation(sessionId, sessionMessageCount)) { - return normalizeClassification({ + return finalizeRouterClassification(normalizeClassification({ route: CHAT_INTENT_ROUTE.AGENT, confidence: 1, reason: '延续已有 Agent 会话', - }, { source: 'rule' }); + }, { source: 'rule' }), decisionContext); } if ( includeIntentPatterns && normalized && OBVIOUS_AGENT_PATTERNS.some((pattern) => pattern.test(normalized)) ) { - return normalizeClassification({ + return finalizeRouterClassification(normalizeClassification({ route: CHAT_INTENT_ROUTE.AGENT, confidence: 0.95, reason: '明确需要生成或发布页面/文件', suggested_skill: 'static-page-publish', agent_brief: '生成或更新 MindSpace 公开页面,并返回可访问链接。', - }, { source: 'rule' }); + }, { source: 'rule' }), decisionContext); } if ( includeIntentPatterns && normalized && OBVIOUS_DIRECT_PATTERNS.some((pattern) => pattern.test(normalized)) ) { - return normalizeClassification({ + return finalizeRouterClassification(normalizeClassification({ route: CHAT_INTENT_ROUTE.DIRECT_CHAT, confidence: 0.95, reason: '简单寒暄或连通性测试', - }, { source: 'rule' }); + }, { source: 'rule' }), decisionContext); } return null; } @@ -530,6 +635,7 @@ export function createChatIntentRouter(options = {}) { memoryResolveLimit: policy.memoryResolveLimit, timeoutMs: policy.timeoutMs, fallbackRoute: policy.fallbackRoute, + normalizedDecisionEnabled: isNormalizedRouterDecisionEnabled(env), }; } @@ -584,6 +690,13 @@ export function createChatIntentRouter(options = {}) { grantedSkills = [], } = {}) { const text = messageDisplayText(userMessage); + const decisionContext = { + text, + forceDeepReasoning, + toolMode, + sessionId, + sessionMessageCount, + }; const ruleResult = classifyWithRules({ text, forceDeepReasoning, @@ -596,11 +709,11 @@ export function createChatIntentRouter(options = {}) { }); if (ruleResult) return ruleResult; if (!isEnabled()) { - return normalizeClassification({ + return finalizeRouterClassification(normalizeClassification({ route: policy.fallbackRoute, confidence: 0.5, reason: '意图路由未启用,走默认通道', - }, { source: 'fallback' }); + }, { source: 'fallback' }), decisionContext); } const routerContext = await resolveRouterContext({ @@ -632,47 +745,47 @@ export function createChatIntentRouter(options = {}) { 'Chat intent router', ); } catch (err) { - return normalizeClassification({ + return finalizeRouterClassification(normalizeClassification({ route: policy.fallbackRoute, confidence: 0, reason: err instanceof Error ? err.message : '意图路由失败,走默认通道', memory: routerContext, - }, { source: 'fallback', fallbackRoute: policy.fallbackRoute }); + }, { source: 'fallback', fallbackRoute: policy.fallbackRoute }), decisionContext); } if (!completion?.ok) { - return normalizeClassification({ + return finalizeRouterClassification(normalizeClassification({ route: policy.fallbackRoute, confidence: 0, reason: completion?.message ?? '意图路由失败,走默认通道', memory: routerContext, - }, { source: 'fallback', fallbackRoute: policy.fallbackRoute }); + }, { source: 'fallback', fallbackRoute: policy.fallbackRoute }), decisionContext); } const parsed = parseRouterJson(completion.reply); if (!parsed) { - return normalizeClassification({ + return finalizeRouterClassification(normalizeClassification({ route: policy.fallbackRoute, confidence: 0, reason: '意图路由响应无法解析,走默认通道', memory: routerContext, - }, { source: 'fallback', fallbackRoute: policy.fallbackRoute }); + }, { source: 'fallback', fallbackRoute: policy.fallbackRoute }), decisionContext); } - const classification = { + const classification = finalizeRouterClassification({ ...normalizeClassification(parsed, { source: 'llm', fallbackRoute: policy.fallbackRoute }), providerKeyId: completion.providerKeyId ?? policy.modelProviderKeyId ?? null, model: completion.model ?? policy.model ?? null, memory: routerContext, - }; + }, decisionContext); if ( classification.route === CHAT_INTENT_ROUTE.DIRECT_CHAT && classification.confidence < policy.minConfidence ) { - return normalizeClassification({ + return finalizeRouterClassification(normalizeClassification({ ...classification, route: policy.fallbackRoute, reason: `${classification.reason}(置信度 ${classification.confidence} 低于阈值,走默认通道)`, - }, { source: 'threshold', fallbackRoute: policy.fallbackRoute }); + }, { source: 'threshold', fallbackRoute: policy.fallbackRoute }), decisionContext); } return classification; } diff --git a/chat-intent-router.test.mjs b/chat-intent-router.test.mjs index 922de1d..f497b39 100644 --- a/chat-intent-router.test.mjs +++ b/chat-intent-router.test.mjs @@ -4,10 +4,16 @@ import { applyAgentOrchestrationToUserMessage, buildRouterContext, buildAgentOrchestrationAgentText, + buildRouterNormalizedDecision, CHAT_INTENT_ROUTE, classifyWithRules, createChatIntentRouter, createManagedChatIntentRouter, + isNormalizedRouterDecisionEnabled, + resolveLegacyRouteFromClassification, + ROUTER_DECISION_ROUTE, + ROUTER_DECISION_MODE, + ROUTER_DECISION_SESSION_HINT, } from './chat-intent-router.mjs'; test('classifyWithRules routes greetings to direct chat', () => { @@ -527,3 +533,104 @@ test('buildAgentOrchestrationAgentText includes execution brief', () => { assert.match(text, /帮我搜索今天的新闻/); assert.match(text, /请使用 web 技能/); }); + +test('classifyWithRules attaches normalized decision on every rule result', () => { + const result = classifyWithRules({ + text: '你好', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '你好' }], + metadata: { displayText: '你好' }, + }, + }); + assert.ok(result.decision); + assert.equal(result.decision.route, ROUTER_DECISION_ROUTE.CHAT); + assert.equal(result.decision.mode, ROUTER_DECISION_MODE.SSE); + assert.equal(result.decision.session_hint, ROUTER_DECISION_SESSION_HINT.NEW); +}); + +test('buildRouterNormalizedDecision maps legacy routes and session hints', () => { + const continued = buildRouterNormalizedDecision( + { route: CHAT_INTENT_ROUTE.AGENT, suggestedSkill: 'web' }, + { + text: '继续', + sessionId: '20260704_10', + sessionMessageCount: 2, + toolMode: 'chat', + }, + ); + assert.equal(continued.route, ROUTER_DECISION_ROUTE.AGENT); + assert.equal(continued.session_hint, ROUTER_DECISION_SESSION_HINT.REUSE); + assert.ok(continued.flags.includes('selected_skill')); + + const freshAgentSession = buildRouterNormalizedDecision( + { route: CHAT_INTENT_ROUTE.AGENT }, + { + text: '你好', + sessionId: '20260704_11', + sessionMessageCount: 0, + }, + ); + assert.equal(freshAgentSession.session_hint, ROUTER_DECISION_SESSION_HINT.NEW); + + const memoryRecall = buildRouterNormalizedDecision( + { route: CHAT_INTENT_ROUTE.DIRECT_CHAT }, + { text: '你记得我说过什么吗' }, + ); + assert.equal(memoryRecall.route, ROUTER_DECISION_ROUTE.CHAT); + assert.ok(memoryRecall.flags.includes('memory_recall')); +}); + +test('resolveLegacyRouteFromClassification prefers decision when flag enabled', () => { + const classification = { + route: CHAT_INTENT_ROUTE.DIRECT_CHAT, + decision: { route: ROUTER_DECISION_ROUTE.AGENT }, + }; + assert.equal( + resolveLegacyRouteFromClassification(classification), + CHAT_INTENT_ROUTE.DIRECT_CHAT, + ); + const previous = process.env.MEMIND_ROUTER_NORMALIZED_DECISION; + process.env.MEMIND_ROUTER_NORMALIZED_DECISION = '1'; + try { + assert.equal( + resolveLegacyRouteFromClassification(classification), + CHAT_INTENT_ROUTE.AGENT, + ); + assert.equal(isNormalizedRouterDecisionEnabled(), true); + } finally { + if (previous == null) delete process.env.MEMIND_ROUTER_NORMALIZED_DECISION; + else process.env.MEMIND_ROUTER_NORMALIZED_DECISION = previous; + } +}); + +test('createChatIntentRouter accepts chat and agent route synonyms from llm', async () => { + const router = createChatIntentRouter({ + enabled: true, + llmProviderService: { + async createChatCompletion() { + return { + ok: true, + reply: JSON.stringify({ + route: 'chat', + confidence: 0.92, + reason: '纯问答', + suggested_skill: null, + agent_brief: '', + }), + }; + }, + }, + }); + + const result = await router.classify({ + userMessage: { + role: 'user', + content: [{ type: 'text', text: '什么是 SSE?' }], + metadata: { displayText: '什么是 SSE?' }, + }, + }); + + assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); + assert.equal(result.decision.route, ROUTER_DECISION_ROUTE.CHAT); +}); diff --git a/direct-chat-service.mjs b/direct-chat-service.mjs index e46bcd3..48fb251 100644 --- a/direct-chat-service.mjs +++ b/direct-chat-service.mjs @@ -1,5 +1,6 @@ import crypto from 'node:crypto'; import { MEMORY_INTERVENTION_LIMIT } from './memory-intervention.mjs'; +import { resolveSessionAccess } from './session-broker.mjs'; export const DIRECT_CHAT_SESSION_PREFIX = 'h5direct_'; @@ -257,12 +258,14 @@ function normalizeUsageForBilling(usage, previousState) { export function createDirectChatService({ userAuth, + sessionAccess = null, llmProviderService, sessionSnapshotService, memoryV2 = null, conversationMemoryService = null, enabled = envFlag(process.env.MEMIND_DIRECT_CHAT_ENABLED, true), } = {}) { + const sessionStore = resolveSessionAccess({ userAuth, sessionAccess }); function getStatus() { return { enabled, @@ -323,7 +326,7 @@ export function createDirectChatService({ const previousMessages = Array.isArray(snapshot?.messages) ? snapshot.messages : []; const now = new Date().toISOString(); if (!sessionId) { - await userAuth.registerAgentSession(userId, activeSessionId, 'h5-direct'); + await sessionStore.registerAgentSession(userId, activeSessionId, 'h5-direct'); } if (typeof onSessionReady === 'function') { await onSessionReady(activeSessionId); diff --git a/direct-chat-service.test.mjs b/direct-chat-service.test.mjs index b507519..c907fad 100644 --- a/direct-chat-service.test.mjs +++ b/direct-chat-service.test.mjs @@ -21,6 +21,51 @@ test('direct chat is enabled by default', () => { }), true); }); +test('direct chat registers session through sessionAccess when provided', async () => { + const registered = []; + const service = createDirectChatService({ + enabled: true, + userAuth: {}, + sessionAccess: { + enabled: true, + async registerAgentSession(userId, sessionId, target) { + registered.push({ userId, sessionId, target }); + }, + }, + llmProviderService: { + async createChatCompletion() { + return { + ok: true, + providerId: 'custom_deepseek', + model: 'deepseek-chat', + reply: 'ok', + usage: { inputTokens: 1, outputTokens: 1 }, + }; + }, + }, + sessionSnapshotService: { + async get() { + return null; + }, + async save() {}, + }, + }); + + const result = await service.run({ + userId: 'user-1', + requestId: 'req-access', + userMessage: { + role: 'user', + content: [{ type: 'text', text: 'hi' }], + metadata: { userVisible: true }, + }, + }); + + assert.equal(registered.length, 1); + assert.equal(registered[0].target, 'h5-direct'); + assert.equal(isDirectChatSessionId(result.sessionId), true); +}); + test('direct chat creates a direct session, saves snapshot, and bills returned usage', async () => { const registered = []; const saved = []; diff --git a/docs/local-dev.md b/docs/local-dev.md index 55115fc..a30122a 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -61,9 +61,22 @@ pnpm dev:all | `ADMIN_PORT` | 8082 | memind_adm | | `H5_PUBLIC_BASE_URL` | http://127.0.0.1:5173 | 公开链接基址 | | `PLAZA_APP_DIR` | ../memind_plaza | Plaza 专用脚本的源码路径(`pnpm dev:plaza` / `pnpm start:plaza` / `pnpm dev:all`) | +| `MEMIND_SESSION_BROKER_ENABLED` | `0`(未设置) | Session Broker 灰度开关;Patch 2+ 本地验证时可设 `1`,见 [H5 Session 架构](./h5-session-architecture-20260706.md) | Plaza 本地开发说明见 [plaza-local.md](./plaza-local.md)。生产发布、同步与回滚不要在这里处理,统一看 [生产更新发布指南](./release-deploy.md)。 +### 本地 `.env` 与 103 生产 `.env` 分离 + +| 项 | 本地(本机 `.env` / `../../.env.local`) | 103 生产(服务器 `/Users/john/Project/Memind/.env`) | +|---|---|---| +| 配置真相 | 复制 `.env.example`,只填本地联调值 | 仅在 103 维护,**不提交 Git** | +| `TKMIND_API_TARGET` | 通常单实例 `https://127.0.0.1:18006` | 多 goosed 实例,见 [103 runtime topology](./103-runtime-topology.md) | +| `H5_PUBLIC_BASE_URL` | `http://127.0.0.1:5173` | `https://mm.tkmind.cn` | +| `MEMIND_SESSION_BROKER_ENABLED` | Patch 2 起可本地设 `1` 验证 | 灰度窗口由运维在 103 `.env` 单独开启 | +| RDS / Redis | 可连本地 MySQL 或留空 | 生产 RDS + Redis,见 103 `.env` | + +硬规则:不要把 103 的 `.env`、密钥、RDS 连接串复制进仓库;`.env.example` 只记录变量名与本地示例值。 + ## 公众号 Agent 调试 服务号消息转发到专属 Agent 这条链路默认关闭,只有显式设置下面这些变量后才会启用: diff --git a/docs/pending-fixes/README.md b/docs/pending-fixes/README.md new file mode 100644 index 0000000..214a13e --- /dev/null +++ b/docs/pending-fixes/README.md @@ -0,0 +1,10 @@ +# 待修复项索引 + +记录**已确认、暂缓实现**的问题,避免与进行中的大分支并行改同一批文件导致分叉。 + +| 文档 | 问题 | 状态 | 阻塞原因 | +|------|------|------|----------| +| [h5-finish-before-run-gate-20260706.md](./h5-finish-before-run-gate-20260706.md) | Finish 先于 agent-run gate 导致 UI 永久 loading | 已修复(分支内) | 随 H5 session 分支合并 | +| [h5-public-html-fake-delivery-guard-20260706.md](./h5-public-html-fake-delivery-guard-20260706.md) | H5 Agent 宣称「页面已生成」但 `public/*.html` 未落盘(无 write_file) | 待修复 | 等 H5 session / broker / SSE 收口分支合并后,与 `server.mjs`、`tkmind-proxy.mjs` 统一改 | + +开工前:读对应 md 全文 + [h5-session-architecture-20260706.md](../h5-session-architecture-20260706.md),确认主线已合并再动 Finish 路径。 diff --git a/docs/pending-fixes/h5-finish-before-run-gate-20260706.md b/docs/pending-fixes/h5-finish-before-run-gate-20260706.md new file mode 100644 index 0000000..dec3d89 --- /dev/null +++ b/docs/pending-fixes/h5-finish-before-run-gate-20260706.md @@ -0,0 +1,35 @@ +# Finish 先于 agent-run gate 的 UI 竞态(已修复,禁止 merge main / 上 103) + +> **状态:已修复 + 单测(2026-07-06)** + +## 现象 + +用户发消息后: + +- assistant 回复已显示 +- 底部三个点(`streaming`)一直转,无法输入下一条 + +## 根因 + +H5 双 SSE 通道时序竞态: + +1. `subscribeSessionEvents` 收到 goosed `Finish` → `chatState = idle` +2. `waitForAgentRun` 稍后才 `succeeded` 返回 +3. 旧逻辑 unconditionally `setChatState('streaming')` → `Finish` 已错过,永久卡住 + +## 修复 + +| 文件 | 说明 | +|------|------| +| `chat-agent-run-gate.mjs` | 纯函数 `resolvePostAgentRunChatState` / `shouldPromoteSessionIdToStreaming` | +| `chat-agent-run-gate.test.mjs` | 回归单测 | +| `src/hooks/useTKMindChat.ts` | run gate 完成后若已是 `idle` 则保持 idle | + +## 验证 + +```bash +node --test chat-agent-run-gate.test.mjs +npm run verify:h5-session-patches +``` + +手工:本地 `pnpm dev` 发 `hi`,确认回复完成后 loading 点消失。 diff --git a/docs/pending-fixes/h5-public-html-fake-delivery-guard-20260706.md b/docs/pending-fixes/h5-public-html-fake-delivery-guard-20260706.md new file mode 100644 index 0000000..1c21789 --- /dev/null +++ b/docs/pending-fixes/h5-public-html-fake-delivery-guard-20260706.md @@ -0,0 +1,81 @@ +# 待修复:H5 页面「假交付」检测与 write_file 自动重试 + +> **状态:已实现 guard 模块(2026-07-06),默认关闭 `MEMIND_H5_HTML_FINISH_GUARD=0`** +> **原暂缓原因**:H5 session / broker 分支已在本分支落地,Finish guard 已接入 `server.mjs` onAfterFinish。 + +## 症状 + +- H5 Agent 回复含「页面已生成 / 生成完成 / 已保存至 public/…」及公网 Markdown 链接 +- 用户点击链接 **404** +- 磁盘 `MindSpace//public/*.html` **不存在** +- Finish 后 `syncPublicHtmlAfterFinish` 无 tool call 可 materialize,文件不会凭空出现 + +**典型案例(103 生产,2026-07-06):** + +- 目标:`public/kids-posture-business.html` +- 第一次:Agent 在聊天里输出完整商业包内容 + 链接,但未调用 `write_file` → 404 +- 用户追问后第二次同会话重试:真正 `write_file` 落盘 → 200 +- **后续(同页 10:54)**:HTML 用 `edit_file` 加了 5 张 ``,Agent 宣称「SVG 已 write_file 落盘」,但 `public/assets/kids-posture-hero.svg` 等 **5 个文件均不存在** → 页面 200、插图全部 404(与 HTML 假交付同类问题,范围扩展到非 `.html` 资源) + +## 根因(非意图识别) + +| 层级 | 行为 | 第一次失败时 | +|------|------|--------------| +| 意图路由 | `chat-intent-router` / `buildAutoChatSkillPrefix` 命中「生成页面」 | ✅ | +| 技能提示 | `static-page-publish` 要求「必须先 write_file」 | ✅ 已注入(软约束) | +| 模型执行 | 应调用 `write_file` | ❌ 未调用 | +| Finish materialize | 仅从 messages 中 tool call 写盘 | ❌ 无 tool call | +| 回复校验 | 拦截「已生成」但磁盘无文件 | ❌ **H5 无此闸门** | + +微信通道已有 `shouldRetryHtmlGenerationReply`(`wechat-mp.mjs`),检测到链接/宣称与磁盘不一致时会 **session 重试**。H5 SSE 路径(`server.mjs` → `tkmindProxy.proxySessionEvents` → `onAfterFinish`)**没有等价逻辑**。 + +## 拟议修复(合并其它分支后统一做) + +### 目标行为 + +当 Assistant 回复同时满足: + +1. 含「页面已生成 / 生成完成 / 已发布 / …」类成功宣称,**或**含 `MindSpace/.../public/*.html` 公网链接 +2. 磁盘上对应 `public/*.html` **不存在**(或仅为 stub) + +则: + +1. **自动重试**:向同 session 发送 follow-up(参考 `wechat-mp.mjs` 的 `executeSessionReply`),明确要求 `load_skill` → `static-page-publish` → `write_file` 写入缺失路径 +2. **继续检验**:重试后再次 `materializeMissingPublicHtmlWrites` + 磁盘校验;仍失败则打 `[MindSpace]` warn 日志(可选:前端提示「页面落盘失败,请重试」) +3. **资源完整性**:解析已落盘 HTML 中的 `src`/`href`(含 `assets/*.svg|png|jpg|webp`),缺失则一并列入 retry 清单(`materializePublicHtmlWritesFromSessionEvent` 目前只处理 `.html` tool call,不能假设插图已落盘) +3. **上限**:建议最多 1~2 次重试,避免无限 loop + +### 建议落点(实现时再对齐当时主线) + +| 模块 | 改动 | +|------|------| +| 新建 `mindspace-h5-html-finish-guard.mjs`(或并入 `mindspace-public-finish-sync.mjs`) | 检测假交付、提取缺失 `public/*.html`、构建 retry prompt | +| `server.mjs` `onAfterFinish` | Finish 后 sync 仍失败 → 调用 guard 重试 → 再 sync + register artifacts | +| 复用 | `wechat-mp.mjs` 中 `shouldRetryHtmlGenerationReply`、`createPublicHtmlLinkExists`、`resolveHtmlPublishArtifacts` 等(考虑抽到共享模块,避免 H5 直接 import 整个 wechat-mp) | +| 单测 | `mindspace-h5-html-finish-guard.test.mjs`;接入 `npm run verify:mindspace-publish-guards` | +| 文档 | 合并后更新 [mindspace-publish-and-chat-finish.md](../regression-guards/mindspace-publish-and-chat-finish.md) | + +### 与进行中分支的协调点 + +合并前必须先读并对齐: + +- [docs/h5-session-architecture-20260706.md](../h5-session-architecture-20260706.md) +- `session-broker.mjs`、`agent-run-gateway.mjs`、`tkmind-proxy.mjs` 的 Finish / SSE 收口 + +**不要在 broker 分支未合并前单独改上述文件的 Finish 路径**,否则合并冲突与行为分叉。 + +## 实现 checklist(待开工时) + +- [ ] 其它分支(H5 session / broker)已合并 `main` 且 CI 绿 +- [ ] 从 `wechat-mp.mjs` 抽取或复用 HTML 发布校验纯函数 +- [ ] H5 `onAfterFinish` 接入 guard + 有限次 retry +- [ ] 单测覆盖:假链接 404、retry 后落盘、正常 Finish 不误触发 +- [ ] `npm run verify:mindspace-publish-guards` 全绿 +- [ ] 103 手工:生成页 → 故意 mock 无 write_file → 确认自动 repair 或明确失败提示 + +## 参考 + +- 回归守卫:[mindspace-publish-and-chat-finish.md](../regression-guards/mindspace-publish-and-chat-finish.md) +- 微信 retry:`wechat-mp.mjs` — `shouldRetryHtmlGenerationReply`、`executeSessionReply` +- 落盘:`mindspace-public-finish-sync.mjs` — `materializeMissingPublicHtmlWrites`、`syncPublicHtmlAfterFinish` +- 技能前缀:`chat-skills.mjs` — `generate-page` prompt diff --git a/docs/pending-fixes/h5-run-stream-replay-4b-20260706.md b/docs/pending-fixes/h5-run-stream-replay-4b-20260706.md new file mode 100644 index 0000000..477b625 --- /dev/null +++ b/docs/pending-fixes/h5-run-stream-replay-4b-20260706.md @@ -0,0 +1,61 @@ +# Patch 4b:Run SSE Replay(进行中,禁止合并 main / 上 103) + +> **状态:代码 + 单测已完成(2026-07-06);Finish 竞态见 [h5-finish-before-run-gate-20260706.md](./h5-finish-before-run-gate-20260706.md)。禁止 merge main / 上 103。** + +## 目标 + +解决 H5 run SSE 重连丢状态问题: + +- 使用已有 `h5_agent_run_events.id` 作为 SSE `id:` cursor +- 客户端 `Last-Event-ID` 重连后从 DB 补发 missed events +- 状态变更时写入 `run_snapshot` 事件,保证 sessionId 回填可回放 + +## 开关 + +```bash +# 服务端(默认 0 = 旧 poll-only 行为) +MEMIND_RUN_STREAM_REPLAY=1 + +# 本地验证 +MEMIND_RUN_STREAM_REPLAY=1 pnpm dev +``` + +客户端 `subscribeAgentRunEvents` 已支持 `Last-Event-ID` + 断线重连(与 session stream 一致)。 + +## 改动文件 + +| 文件 | 说明 | +|------|------| +| `agent-run-stream.mjs` | replay 纯函数、flag | +| `agent-run-gateway.mjs` | `listRunEventsForUser`、`appendRunSnapshot` | +| `agent-run-routes.mjs` | replay 模式 SSE handler | +| `src/api/client.ts` | run SSE Last-Event-ID + 重连 | +| `agent-run-stream.test.mjs` | 单测 | +| `agent-run-routes.test.mjs` | replay SSE handler 用例 | +| `agent-run-gateway.test.mjs` | replay cursor / cursorMiss / run_snapshot 用例 | + +## 2026-07-06 补充 + +- 修复 `listRunEventsForUser` 误返回 `cursorMiss: false`( stale `Last-Event-ID` 无法触发全量补发) +- gateway 新增 3 项 replay 单测(51 pass 合计) + +## 本地测试清单(合并/发布前必跑) + +```bash +npm run verify:h5-session-patches +# 或分项: +node --test agent-run-stream.test.mjs agent-run-routes.test.mjs agent-run-gateway.test.mjs +npm run verify:goosed-proxy-boundary +``` + +手工: + +1. `MEMIND_RUN_STREAM_REPLAY=1 pnpm dev` +2. 发起 agent run,DevTools 观察 `/agent/runs/:id/events` 含 `id:` 行 +3. 模拟断网/刷新,确认重连后仍能收到 `sessionId` 与 terminal `succeeded` + +## 与 Patch 4a 关系 + +- Patch 4a:taxonomy shadow(已完成) +- Patch 4b:run replay(本文件) +- Session stream replay 仍为后续项,不在 4b 范围 diff --git a/goosed-proxy-boundary.mjs b/goosed-proxy-boundary.mjs new file mode 100644 index 0000000..99215b6 --- /dev/null +++ b/goosed-proxy-boundary.mjs @@ -0,0 +1,168 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * GOOSED PROXY BOUNDARY (H5 Session architecture Patch 5) + * + * H5 chat 主链路 Portal → goosed 的唯一 adapter 是 tkmind-proxy.mjs。 + * 其它模块须通过 tkmindProxy.apiFetch / apiFetchTo 间接访问,不得自建 createApiFetch。 + * + * 见 docs/h5-session-architecture-20260706.md §7.2 + */ + +/** @typedef {{ file: string, line: number, rule: string, text: string }} GoosedBoundaryViolation */ + +export const GOOSED_PROXY_ADAPTER_FILE = 'tkmind-proxy.mjs'; + +export const GOOSED_DIRECT_ALLOWLIST = [ + GOOSED_PROXY_ADAPTER_FILE, + 'mindspace-agent-runner.mjs', + 'mindspace-page-edit-session.mjs', + 'wechat-mp.mjs', + 'session-reconcile.mjs', +]; + +export const GOOSED_PROXY_GUARDED_FILES = [ + 'server.mjs', + 'agent-run-gateway.mjs', + 'agent-run-routes.mjs', + 'direct-chat-service.mjs', + 'chat-intent-router.mjs', +]; + +export const GOOSED_BOUNDARY_LINE_ALLOW = [ + /tkmindProxy\./, + /GOOSED PROXY BOUNDARY/, + /AGENT_RUNS_REQUIRED/, + /runHandlerChain\(tkmindProxy/, + /handlers\['POST \/agent/, + /api\.(post|get|put|delete|patch)\(['"]\/agent/, + /api\.(post|get|put|delete|patch)\(['"]\/sessions/, + /req\.path\.endsWith\(['"]\/reply['"]\)/, + /req\.path\.endsWith\(['"]\/events['"]\)/, + /parseApiTargets/, + /TKMIND_API_TARGET/, + /createTkmindProxy/, + /API_TARGETS/, + /proxyFallback/, + /proxySessionEvents/, +]; + +export const GOOSED_BOUNDARY_FORBIDDEN = [ + { id: 'create-api-fetch', pattern: /\bcreateApiFetch\s*\(/ }, + { + id: 'direct-sessions-reply', + pattern: /\bapiFetch\s*\(\s*[`'"]\/sessions\/[^`'"]+\/reply/, + }, + { + id: 'direct-sessions-events', + pattern: /\bapiFetch\s*\(\s*[`'"]\/sessions\/[^`'"]+\/events/, + }, + { id: 'direct-agent-start', pattern: /\bapiFetch\s*\(\s*[`'"]\/agent\/start/ }, + { id: 'direct-agent-resume', pattern: /\bapiFetch\s*\(\s*[`'"]\/agent\/resume/ }, + { + id: 'raw-goosed-target-fetch', + pattern: /fetch\s*\(\s*[`'"]https?:\/\/127\.0\.0\.1:1800[67]/, + }, +]; + +export const GOOSED_BOUNDARY_SCAN_IGNORE = [ + /^scripts\//, + /^mindspace-service\//, + /^\.runtime\//, + /^node_modules\//, + /\.test\.mjs$/, + /\.test\.ts$/, +]; + +function relativePosix(rootDir, absolutePath) { + return path.relative(rootDir, absolutePath).split(path.sep).join('/'); +} + +function isIgnoredScanPath(relativePath) { + return GOOSED_BOUNDARY_SCAN_IGNORE.some((pattern) => pattern.test(relativePath)); +} + +function isLineAllowed(line) { + return GOOSED_BOUNDARY_LINE_ALLOW.some((pattern) => pattern.test(line)); +} + +function scanFileContent(relativePath, content, { guardedOnly = false } = {}) { + /** @type {GoosedBoundaryViolation[]} */ + const violations = []; + const lines = content.split('\n'); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (!line.trim() || line.trim().startsWith('//') || line.trim().startsWith('*')) continue; + if (isLineAllowed(line)) continue; + for (const rule of GOOSED_BOUNDARY_FORBIDDEN) { + if (!rule.pattern.test(line)) continue; + violations.push({ + file: relativePath, + line: index + 1, + rule: rule.id, + text: line.trim(), + }); + } + } + if (guardedOnly && violations.length === 0) return violations; + return violations; +} + +export function checkGoosedProxyBoundary(rootDir, { + extraAllowlist = [], + extraGuarded = [], + scanRepo = true, +} = {}) { + /** @type {GoosedBoundaryViolation[]} */ + const violations = []; + const allowlist = new Set([...GOOSED_DIRECT_ALLOWLIST, ...extraAllowlist]); + const guarded = [...GOOSED_PROXY_GUARDED_FILES, ...extraGuarded]; + + for (const relativePath of guarded) { + const absolutePath = path.join(rootDir, relativePath); + if (!fs.existsSync(absolutePath)) { + violations.push({ + file: relativePath, + line: 0, + rule: 'missing-guarded-file', + text: 'Guarded file missing from repository', + }); + continue; + } + const content = fs.readFileSync(absolutePath, 'utf8'); + violations.push(...scanFileContent(relativePath, content, { guardedOnly: true })); + } + + if (!scanRepo) return violations; + + const queue = [rootDir]; + while (queue.length > 0) { + const current = queue.pop(); + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === '.git') continue; + const absolutePath = path.join(current, entry.name); + const relativePath = relativePosix(rootDir, absolutePath); + if (entry.isDirectory()) { + queue.push(absolutePath); + continue; + } + if (!entry.name.endsWith('.mjs')) continue; + if (isIgnoredScanPath(relativePath)) continue; + if (allowlist.has(relativePath)) continue; + if (guarded.includes(relativePath)) continue; + const content = fs.readFileSync(absolutePath, 'utf8'); + violations.push(...scanFileContent(relativePath, content)); + } + } + + return violations; +} + +export function formatGoosedBoundaryViolations(violations) { + if (!violations.length) return ''; + return violations + .map((item) => `${item.file}:${item.line} [${item.rule}] ${item.text}`) + .join('\n'); +} diff --git a/goosed-proxy-boundary.test.mjs b/goosed-proxy-boundary.test.mjs new file mode 100644 index 0000000..45c96f9 --- /dev/null +++ b/goosed-proxy-boundary.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + checkGoosedProxyBoundary, + GOOSED_DIRECT_ALLOWLIST, + GOOSED_PROXY_ADAPTER_FILE, + GOOSED_PROXY_GUARDED_FILES, +} from './goosed-proxy-boundary.mjs'; + +const repoRoot = path.resolve(new URL('.', import.meta.url).pathname, '.'); + +test('current repository passes goosed proxy boundary check', () => { + const violations = checkGoosedProxyBoundary(repoRoot); + assert.equal( + violations.length, + 0, + violations.map((item) => `${item.file}:${item.line} ${item.rule}`).join('\n'), + ); +}); + +test('guard detects createApiFetch outside allowlist', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'goosed-boundary-')); + try { + fs.writeFileSync( + path.join(tempRoot, 'agent-run-gateway.mjs'), + 'export async function run() {}\n', + ); + fs.writeFileSync( + path.join(tempRoot, 'rogue-module.mjs'), + 'function createApiFetch() {}\nexport const apiFetch = createApiFetch("https://127.0.0.1:18006");\n', + ); + const violations = checkGoosedProxyBoundary(tempRoot, { + extraGuarded: [], + scanRepo: true, + }); + assert.ok(violations.some((item) => item.file === 'rogue-module.mjs' && item.rule === 'create-api-fetch')); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('allowlist permits MindSpace direct adapters', () => { + assert.ok(GOOSED_DIRECT_ALLOWLIST.includes('mindspace-agent-runner.mjs')); + assert.ok(GOOSED_DIRECT_ALLOWLIST.includes('mindspace-page-edit-session.mjs')); + assert.equal(GOOSED_PROXY_ADAPTER_FILE, 'tkmind-proxy.mjs'); + assert.ok(GOOSED_PROXY_GUARDED_FILES.includes('server.mjs')); +}); + +test('tkmind proxy keeps legacy reply path at 410 AGENT_RUNS_REQUIRED', () => { + const content = fs.readFileSync(path.join(repoRoot, 'tkmind-proxy.mjs'), 'utf8'); + assert.match(content, /AGENT_RUNS_REQUIRED/); + assert.match(content, /isReplyPath/); +}); diff --git a/mindspace-agent-runner.mjs b/mindspace-agent-runner.mjs index bba3a43..06fa444 100644 --- a/mindspace-agent-runner.mjs +++ b/mindspace-agent-runner.mjs @@ -4,6 +4,7 @@ import { Readable } from 'node:stream'; import { Agent, fetch as undiciFetch } from 'undici'; import { jsonrepair } from 'jsonrepair'; import { reconcileAgentSession } from './session-reconcile.mjs'; +import { resolveSessionAccess } from './session-broker.mjs'; const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false }, @@ -304,11 +305,13 @@ export function createMindSpaceAgentRunner({ apiTarget, apiSecret, userAuth, + sessionAccess = null, agentJobService, experienceService = null, executeSessionReply = defaultExecuteSessionReply, apiFetchImpl = null, }) { + const sessionStore = resolveSessionAccess({ userAuth, sessionAccess }); const apiFetch = apiFetchImpl ?? (async (pathname, init = {}) => { const url = new URL(pathname, apiTarget); const headers = { @@ -358,7 +361,7 @@ export function createMindSpaceAgentRunner({ if (!sessionId) { throw runnerError('Agent 会话启动失败', 'worker_unavailable'); } - await userAuth.registerAgentSession(claim.userId, sessionId); + await sessionStore.registerAgentSession(claim.userId, sessionId); await reconcileAgentSession( (pathname, init) => apiFetch(pathname, init), sessionId, diff --git a/mindspace-conversation-package-routes.mjs b/mindspace-conversation-package-routes.mjs index 3f9af44..6d32e2a 100644 --- a/mindspace-conversation-package-routes.mjs +++ b/mindspace-conversation-package-routes.mjs @@ -1,3 +1,5 @@ +import { resolveSessionAccess } from './session-broker.mjs'; + function defaultSendData(res, _req, data) { return res.json({ data }); } @@ -10,6 +12,32 @@ function resolveRegistry(input) { return typeof input === 'function' ? input() : input; } +function resolveActiveSessionAccess({ sessionAccess, getSessionAccess, userAuth, getUserAuth }) { + const explicit = resolveRegistry(getSessionAccess ?? sessionAccess); + if (explicit) return explicit; + const activeUserAuth = resolveRegistry(getUserAuth ?? userAuth); + if (!activeUserAuth) return null; + return resolveSessionAccess({ userAuth: activeUserAuth, enabled: false }); +} + +async function assertConversationPackageSessionOwned(req, res, options) { + const sessionId = String(req.params?.sessionId ?? '').trim(); + if (!sessionId) { + res.status(400).json({ message: '缺少 sessionId' }); + return null; + } + const access = resolveActiveSessionAccess(options); + if (!access) { + res.status(503).json({ message: 'MindSpace 对话包未启用' }); + return null; + } + if (!(await access.validateOwnership(req.currentUser.id, sessionId))) { + res.status(403).json({ message: '无权访问该会话' }); + return null; + } + return sessionId; +} + function encodeDownloadFilename(filename) { const safe = String(filename ?? '').trim() || 'mindspace-artifact'; return `attachment; filename="${encodeURIComponent(safe)}"; filename*=UTF-8''${encodeURIComponent(safe)}`; @@ -28,6 +56,8 @@ export function createGetConversationPackageHandler({ getRegistry, userAuth, getUserAuth, + sessionAccess, + getSessionAccess, ensureMindSpaceEnabled = () => true, sendData = defaultSendData, mindSpaceError = defaultRouteError, @@ -39,14 +69,15 @@ export function createGetConversationPackageHandler({ if (res.headersSent) return undefined; return res.status(503).json({ message: 'MindSpace 对话包未启用' }); } - const sessionId = String(req.params?.sessionId ?? '').trim(); - if (!sessionId) return res.status(400).json({ message: '缺少 sessionId' }); + const sessionId = await assertConversationPackageSessionOwned(req, res, { + sessionAccess, + getSessionAccess, + userAuth, + getUserAuth, + }); + if (!sessionId) return undefined; try { - const activeUserAuth = resolveRegistry(getUserAuth ?? userAuth); - if (!(await activeUserAuth.ownsSession(req.currentUser.id, sessionId))) { - return res.status(403).json({ message: '无权访问该会话' }); - } if (typeof beforeReadManifest === 'function') { await beforeReadManifest({ req, userId: req.currentUser.id, sessionId }); } @@ -67,6 +98,8 @@ export function createDownloadConversationPackageManifestHandler({ getRegistry, userAuth, getUserAuth, + sessionAccess, + getSessionAccess, ensureMindSpaceEnabled = () => true, mindSpaceError = defaultRouteError, beforeReadManifest, @@ -77,14 +110,15 @@ export function createDownloadConversationPackageManifestHandler({ if (res.headersSent) return undefined; return res.status(503).json({ message: 'MindSpace 对话包未启用' }); } - const sessionId = String(req.params?.sessionId ?? '').trim(); - if (!sessionId) return res.status(400).json({ message: '缺少 sessionId' }); + const sessionId = await assertConversationPackageSessionOwned(req, res, { + sessionAccess, + getSessionAccess, + userAuth, + getUserAuth, + }); + if (!sessionId) return undefined; try { - const activeUserAuth = resolveRegistry(getUserAuth ?? userAuth); - if (!(await activeUserAuth.ownsSession(req.currentUser.id, sessionId))) { - return res.status(403).json({ message: '无权访问该会话' }); - } if (typeof beforeReadManifest === 'function') { await beforeReadManifest({ req, userId: req.currentUser.id, sessionId }); } @@ -114,6 +148,8 @@ export function createDownloadConversationPackageArtifactHandler({ getRegistry, userAuth, getUserAuth, + sessionAccess, + getSessionAccess, ensureMindSpaceEnabled = () => true, mindSpaceError = defaultRouteError, } = {}) { @@ -123,16 +159,17 @@ export function createDownloadConversationPackageArtifactHandler({ if (res.headersSent) return undefined; return res.status(503).json({ message: 'MindSpace 对话包未启用' }); } - const sessionId = String(req.params?.sessionId ?? '').trim(); + const sessionId = await assertConversationPackageSessionOwned(req, res, { + sessionAccess, + getSessionAccess, + userAuth, + getUserAuth, + }); + if (!sessionId) return undefined; const artifactId = String(req.params?.artifactId ?? '').trim(); - if (!sessionId) return res.status(400).json({ message: '缺少 sessionId' }); if (!artifactId) return res.status(400).json({ message: '缺少 artifactId' }); try { - const activeUserAuth = resolveRegistry(getUserAuth ?? userAuth); - if (!(await activeUserAuth.ownsSession(req.currentUser.id, sessionId))) { - return res.status(403).json({ message: '无权访问该会话' }); - } const result = await activeRegistry.readArtifactObject({ userId: req.currentUser.id, sessionId, diff --git a/mindspace-conversation-package-routes.test.mjs b/mindspace-conversation-package-routes.test.mjs index 10b7d91..08d0ff3 100644 --- a/mindspace-conversation-package-routes.test.mjs +++ b/mindspace-conversation-package-routes.test.mjs @@ -266,3 +266,27 @@ test('GET conversation package artifact blocks unauthorized sessions before stor assert.equal(res.statusCode, 403); assert.deepEqual(res.body, { message: '无权访问该会话' }); }); + +test('GET conversation package validates ownership through sessionAccess when provided', async () => { + const validated = []; + const handler = createGetConversationPackageHandler({ + registry: { + async readManifestForSession() { + return { packageId: 'cp_session-broker', sessionId: 'session-broker', artifacts: [] }; + }, + }, + userAuth: {}, + sessionAccess: { + async validateOwnership(userId, sessionId) { + validated.push({ userId, sessionId }); + return false; + }, + }, + }); + const res = createResponseRecorder(); + + await handler({ currentUser: { id: 'user-1' }, params: { sessionId: 'session-broker' } }, res); + + assert.deepEqual(validated, [{ userId: 'user-1', sessionId: 'session-broker' }]); + assert.equal(res.statusCode, 403); +}); diff --git a/mindspace-h5-html-finish-guard.mjs b/mindspace-h5-html-finish-guard.mjs new file mode 100644 index 0000000..5667dbc --- /dev/null +++ b/mindspace-h5-html-finish-guard.mjs @@ -0,0 +1,275 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { extractStaticPageLinks } from './mindspace-chat-save.mjs'; +import { + createPublicHtmlLinkExists, + shouldRetryHtmlGenerationReply, +} from './wechat-mp.mjs'; +import { + isStubPublicHtmlContent, + materializeMissingPublicHtmlWrites, +} from './mindspace-public-finish-sync.mjs'; + +const PUBLIC_HTML_PATH_PATTERN_GLOBAL = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/gi; +const PUBLIC_ASSET_ATTR_PATTERN = /(?:href|src)=["']([^"'#?\s]+)["']/gi; +const PUBLIC_ASSET_EXT_PATTERN = /\.(svg|png|jpe?g|webp|gif)$/i; + +const repairAttemptsBySession = new Map(); + +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +function messageText(message) { + const content = message?.content; + if (typeof content === 'string') return content.trim(); + if (!Array.isArray(content)) return ''; + return content + .filter((item) => item?.type === 'text') + .map((item) => String(item.text ?? '').trim()) + .filter(Boolean) + .join('\n') + .trim(); +} + +function normalizePublicRelativePath(value, { publishDir } = {}) { + const clean = String(value ?? '') + .split('?')[0] + .split('#')[0] + .replace(/^\.\//, '') + .trim() + .replace(/\\/g, '/'); + if (!clean || clean.includes('://') || clean.startsWith('data:')) return null; + if (!clean.startsWith('public/')) { + if (clean.startsWith('assets/')) return `public/${clean}`; + return null; + } + const root = path.resolve(String(publishDir ?? '')); + const target = path.resolve(root, clean); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) return null; + return clean; +} + +function publicFileExists(publishDir, relativePath) { + const normalized = normalizePublicRelativePath(relativePath, { publishDir }); + if (!normalized) return false; + const target = path.resolve(String(publishDir ?? ''), normalized); + return fs.existsSync(target) && fs.statSync(target).isFile(); +} + +function publicHtmlIsMissingOrStub(publishDir, relativePath) { + const normalized = normalizePublicRelativePath(relativePath, { publishDir }); + if (!normalized || !normalized.toLowerCase().endsWith('.html')) return false; + const target = path.resolve(String(publishDir ?? ''), normalized); + if (!fs.existsSync(target) || !fs.statSync(target).isFile()) return true; + try { + return isStubPublicHtmlContent(fs.readFileSync(target, 'utf8')); + } catch { + return true; + } +} + +export function isH5HtmlFinishGuardEnabled(env = process.env) { + return envFlag(env.MEMIND_H5_HTML_FINISH_GUARD, false); +} + +export function resetH5HtmlFinishGuardAttempts(sessionId = null) { + if (sessionId) repairAttemptsBySession.delete(String(sessionId)); + else repairAttemptsBySession.clear(); +} + +export function collectReferencedPublicHtmlPaths(messages, currentUser) { + const paths = new Set(); + for (const message of Array.isArray(messages) ? messages : []) { + if (message?.role !== 'assistant') continue; + const text = messageText(message); + if (!text) continue; + for (const link of extractStaticPageLinks(text, { + userId: currentUser?.id, + username: currentUser?.username ?? null, + })) { + if (link?.relativePath) paths.add(link.relativePath); + } + for (const match of text.matchAll(PUBLIC_HTML_PATH_PATTERN_GLOBAL)) { + if (match[1]) paths.add(match[1]); + } + } + return [...paths].sort(); +} + +export function collectMissingPublicHtmlPaths(messages, currentUser, publishDir) { + return collectReferencedPublicHtmlPaths(messages, currentUser).filter((relativePath) => + publicHtmlIsMissingOrStub(publishDir, relativePath), + ); +} + +export function collectMissingPublicAssetReferences(publishDir) { + const root = path.resolve(String(publishDir ?? '')); + const publicDir = path.join(root, 'public'); + if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) return []; + + const missing = new Map(); + for (const file of fs.readdirSync(publicDir)) { + if (!file.toLowerCase().endsWith('.html')) continue; + const htmlRelativePath = `public/${file}`; + const content = fs.readFileSync(path.join(publicDir, file), 'utf8'); + for (const match of content.matchAll(PUBLIC_ASSET_ATTR_PATTERN)) { + const relativePath = normalizePublicRelativePath(match[1], { publishDir: root }); + if (!relativePath || !PUBLIC_ASSET_EXT_PATTERN.test(relativePath)) continue; + if (publicFileExists(root, relativePath)) continue; + missing.set(relativePath, { relativePath, htmlRelativePath }); + } + } + return [...missing.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath)); +} + +export function evaluateH5HtmlFinishGuard({ + messages, + currentUser, + publishDir, + syncResult = null, +} = {}) { + materializeMissingPublicHtmlWrites({ messages, publishDir }); + const missingHtml = collectMissingPublicHtmlPaths(messages, currentUser, publishDir); + const missingAssets = collectMissingPublicAssetReferences(publishDir); + + const lastAssistant = [...(Array.isArray(messages) ? messages : [])] + .reverse() + .find((message) => message?.role === 'assistant'); + const reply = { + text: messageText(lastAssistant), + messages: Array.isArray(messages) ? messages : [], + }; + const intent = { agentText: reply.text }; + const linkExists = createPublicHtmlLinkExists(publishDir); + const confirmedArtifacts = (syncResult?.publicHtmlRelativePaths ?? []) + .filter((relativePath) => publicFileExists(publishDir, relativePath)) + .map((relativePath) => ({ relativePath, localPath: path.join(publishDir, relativePath) })); + + const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({ + reply, + intent, + confirmedArtifacts, + hasValidLinkInReply: false, + }); + + const needsRepair = + missingHtml.length > 0 || + missingAssets.length > 0 || + htmlGenerationNeedsRetry; + + return { + needsRepair, + missingHtml, + missingAssets, + htmlGenerationNeedsRetry, + linkExists, + }; +} + +export function buildH5HtmlRepairPrompt({ missingHtml = [], missingAssets = [] } = {}) { + const lines = [ + '【系统补盘请求】检测到 public 页面交付不完整。请立即使用 write_file 写入缺失文件(完整内容),并在完成后确认磁盘存在。', + '要求:先 load_skill static-page-publish,再逐个 write_file;不要用空回复或仅 edit_file 改 HTML 代替资源落盘。', + ]; + if (missingHtml.length) { + lines.push('', '缺失或仍为 stub 的 HTML:'); + for (const relativePath of missingHtml) lines.push(`- ${relativePath}`); + } + if (missingAssets.length) { + lines.push('', '页面已引用但磁盘缺失的资源:'); + for (const item of missingAssets) { + lines.push(`- ${item.relativePath}(来自 ${item.htmlRelativePath})`); + } + } + lines.push('', '写完后请 list_dir public/assets 或对应目录,确认文件真实存在。'); + return lines.join('\n'); +} + +export async function maybeRepairH5HtmlAfterFinish({ + sessionId, + userId, + currentUser, + messages, + publishDir, + syncResult = null, + tkmindProxy = null, + maxAttempts = 1, + env = process.env, + logger = console, +} = {}) { + const evaluation = evaluateH5HtmlFinishGuard({ + messages, + currentUser, + publishDir, + syncResult, + }); + if (!evaluation.needsRepair) { + resetH5HtmlFinishGuardAttempts(sessionId); + return { repaired: false, skipped: 'ok', ...evaluation }; + } + if (!isH5HtmlFinishGuardEnabled(env)) { + logger.warn?.( + `[MindSpace][h5-html-guard] incomplete public delivery for session ${sessionId}: ` + + `html=${evaluation.missingHtml.join(',') || 'none'} ` + + `assets=${evaluation.missingAssets.map((item) => item.relativePath).join(',') || 'none'}`, + ); + return { repaired: false, skipped: 'disabled', ...evaluation }; + } + if (!tkmindProxy?.submitSessionReplyForUser) { + return { repaired: false, skipped: 'no_proxy', ...evaluation }; + } + + const key = String(sessionId ?? ''); + const attempts = repairAttemptsBySession.get(key) ?? 0; + if (!key || attempts >= maxAttempts) { + logger.warn?.( + `[MindSpace][h5-html-guard] repair limit reached for session ${sessionId}`, + evaluation, + ); + return { repaired: false, skipped: 'limit', attempts, ...evaluation }; + } + repairAttemptsBySession.set(key, attempts + 1); + + const prompt = buildH5HtmlRepairPrompt(evaluation); + const requestId = `h5-html-repair-${crypto.randomUUID()}`; + const userMessage = { + role: 'user', + content: [{ type: 'text', text: prompt }], + metadata: { + displayText: '请补全缺失的 public 页面与资源文件', + userVisible: false, + agentVisible: true, + memindRun: { htmlFinishRepair: true }, + }, + }; + + try { + await tkmindProxy.submitSessionReplyForUser( + userId, + sessionId, + requestId, + userMessage, + ); + logger.info?.( + `[MindSpace][h5-html-guard] triggered repair for session ${sessionId} ` + + `(attempt ${attempts + 1}/${maxAttempts})`, + ); + return { repaired: true, triggered: true, attempts: attempts + 1, ...evaluation }; + } catch (err) { + logger.warn?.( + `[MindSpace][h5-html-guard] repair failed for session ${sessionId}: ` + + `${err instanceof Error ? err.message : err}`, + ); + return { + repaired: false, + skipped: 'error', + error: err instanceof Error ? err.message : String(err), + attempts: attempts + 1, + ...evaluation, + }; + } +} diff --git a/mindspace-h5-html-finish-guard.test.mjs b/mindspace-h5-html-finish-guard.test.mjs new file mode 100644 index 0000000..1ab021a --- /dev/null +++ b/mindspace-h5-html-finish-guard.test.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + buildH5HtmlRepairPrompt, + collectMissingPublicAssetReferences, + collectMissingPublicHtmlPaths, + evaluateH5HtmlFinishGuard, + isH5HtmlFinishGuardEnabled, + maybeRepairH5HtmlAfterFinish, + resetH5HtmlFinishGuardAttempts, +} from './mindspace-h5-html-finish-guard.mjs'; + +const USER = { id: 'user-1', username: 'john' }; + +test('collectMissingPublicAssetReferences finds missing svg referenced by html', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-')); + try { + fs.mkdirSync(path.join(publishDir, 'public', 'assets'), { recursive: true }); + fs.writeFileSync( + path.join(publishDir, 'public', 'kids-posture-business.html'), + 'hero', + 'utf8', + ); + const missing = collectMissingPublicAssetReferences(publishDir); + assert.deepEqual(missing, [{ + relativePath: 'public/assets/kids-posture-hero.svg', + htmlRelativePath: 'public/kids-posture-business.html', + }]); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('collectMissingPublicHtmlPaths detects assistant link without disk file', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-')); + try { + const messages = [{ + role: 'assistant', + content: [{ + type: 'text', + text: '[页面](https://m.tkmind.cn/MindSpace/user-1/public/missing-page.html) 已生成', + }], + }]; + const missing = collectMissingPublicHtmlPaths(messages, USER, publishDir); + assert.deepEqual(missing, ['public/missing-page.html']); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('evaluateH5HtmlFinishGuard flags missing assets after html exists', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-')); + try { + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + fs.writeFileSync( + path.join(publishDir, 'public', 'page.html'), + '', + 'utf8', + ); + const evaluation = evaluateH5HtmlFinishGuard({ + messages: [], + currentUser: USER, + publishDir, + }); + assert.equal(evaluation.needsRepair, true); + assert.equal(evaluation.missingAssets.length, 1); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('buildH5HtmlRepairPrompt lists html and asset targets', () => { + const prompt = buildH5HtmlRepairPrompt({ + missingHtml: ['public/page.html'], + missingAssets: [{ relativePath: 'public/assets/icon.svg', htmlRelativePath: 'public/page.html' }], + }); + assert.match(prompt, /public\/page\.html/); + assert.match(prompt, /public\/assets\/icon\.svg/); + assert.match(prompt, /write_file/); +}); + +test('maybeRepairH5HtmlAfterFinish triggers proxy reply when enabled', async () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-')); + const previous = process.env.MEMIND_H5_HTML_FINISH_GUARD; + process.env.MEMIND_H5_HTML_FINISH_GUARD = '1'; + resetH5HtmlFinishGuardAttempts('session-1'); + const calls = []; + try { + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + fs.writeFileSync( + path.join(publishDir, 'public', 'page.html'), + '', + 'utf8', + ); + const result = await maybeRepairH5HtmlAfterFinish({ + sessionId: 'session-1', + userId: 'user-1', + currentUser: USER, + messages: [], + publishDir, + tkmindProxy: { + async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) { + calls.push({ userId, sessionId, requestId, userMessage }); + }, + }, + }); + assert.equal(result.repaired, true); + assert.equal(calls.length, 1); + assert.match(calls[0].userMessage.content[0].text, /icon\.svg/); + } finally { + if (previous == null) delete process.env.MEMIND_H5_HTML_FINISH_GUARD; + else process.env.MEMIND_H5_HTML_FINISH_GUARD = previous; + resetH5HtmlFinishGuardAttempts('session-1'); + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('maybeRepairH5HtmlAfterFinish stays off when flag disabled', async () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-')); + const previous = process.env.MEMIND_H5_HTML_FINISH_GUARD; + delete process.env.MEMIND_H5_HTML_FINISH_GUARD; + try { + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + fs.writeFileSync( + path.join(publishDir, 'public', 'page.html'), + '', + 'utf8', + ); + const result = await maybeRepairH5HtmlAfterFinish({ + sessionId: 'session-2', + userId: 'user-1', + currentUser: USER, + messages: [], + publishDir, + tkmindProxy: { + async submitSessionReplyForUser() { + throw new Error('should not call proxy when disabled'); + }, + }, + }); + assert.equal(result.skipped, 'disabled'); + assert.equal(isH5HtmlFinishGuardEnabled(), false); + } finally { + if (previous == null) delete process.env.MEMIND_H5_HTML_FINISH_GUARD; + else process.env.MEMIND_H5_HTML_FINISH_GUARD = previous; + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); diff --git a/mindspace-page-edit-session.mjs b/mindspace-page-edit-session.mjs index 73adbbe..79469f2 100644 --- a/mindspace-page-edit-session.mjs +++ b/mindspace-page-edit-session.mjs @@ -3,6 +3,7 @@ import { buildPageEditAgentPolicy } from './capabilities.mjs'; import { buildPageEditSubAgentInstructions } from './mindspace-chat-context.mjs'; import { buildMindSpacePageSaveInstructions } from './mindspace-page-patch.mjs'; import { reconcileAgentSession } from './session-reconcile.mjs'; +import { resolveSessionAccess } from './session-broker.mjs'; const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false }, @@ -93,10 +94,12 @@ export function createPageEditSessionService({ apiTarget, apiSecret, userAuth, + sessionAccess = null, pageService, pageLiveEdit, llmProviderService, }) { + const sessionStore = resolveSessionAccess({ userAuth, sessionAccess }); const apiFetch = createApiFetch(apiTarget, apiSecret); const applySessionLlmProvider = async (sessionId) => { @@ -114,7 +117,7 @@ export function createPageEditSessionService({ throw Object.assign(new Error('缺少 fork 参数'), { code: 'invalid_request' }); } - const ownsParent = await userAuth.ownsSession(userId, parentSessionId); + const ownsParent = await sessionStore.validateOwnership(userId, parentSessionId); if (!ownsParent) { throw Object.assign(new Error('无权使用父会话'), { code: 'forbidden' }); } @@ -152,7 +155,7 @@ export function createPageEditSessionService({ throw Object.assign(new Error('Agent 会话启动失败'), { code: 'worker_unavailable' }); } - await userAuth.registerAgentSession(userId, sessionId); + await sessionStore.registerAgentSession(userId, sessionId); if (sessionPolicy.gooseMode) { await readJson( @@ -229,7 +232,7 @@ export function createPageEditSessionService({ throw Object.assign(new Error('缺少 close 参数'), { code: 'invalid_request' }); } - const owns = await userAuth.ownsSession(userId, sessionId); + const owns = await sessionStore.validateOwnership(userId, sessionId); if (!owns) { throw Object.assign(new Error('无权关闭该子会话'), { code: 'forbidden' }); } @@ -244,7 +247,7 @@ export function createPageEditSessionService({ const trimmedSummary = String(summary ?? '').trim(); const parentId = String(parentSessionId ?? binding?.parentSessionId ?? '').trim(); if (trimmedSummary && parentId) { - const ownsParent = await userAuth.ownsSession(userId, parentId); + const ownsParent = await sessionStore.validateOwnership(userId, parentId); if (ownsParent) { const page = await pageService.getPage(userId, pageId).catch(() => null); await apiFetch('/agent/harness_remember', { diff --git a/package.json b/package.json index 05825d9..a292812 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:memory-v2-qdrant": "node scripts/smoke-memory-v2-qdrant.mjs", "smoke:memory-v2-external": "node scripts/smoke-memory-v2-external.mjs", "mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs", "verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs", "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", @@ -61,6 +61,8 @@ "verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime", "verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs", "verify:wechat-channel-isolation": "node scripts/check-wechat-channel-isolation.mjs", + "verify:goosed-proxy-boundary": "node scripts/check-goosed-proxy-boundary.mjs", + "verify:h5-session-patches": "node scripts/verify-h5-session-patches.mjs", "test:mindspace-e2e": "node scripts/mindspace-e2e.mjs", "test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs", "test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs", diff --git a/schema.sql b/schema.sql index efc9dbd..3ba9ae7 100644 --- a/schema.sql +++ b/schema.sql @@ -357,6 +357,8 @@ CREATE TABLE IF NOT EXISTS h5_user_path_grants ( CREATE TABLE IF NOT EXISTS h5_user_sessions ( agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY, user_id CHAR(36) NOT NULL, + goosed_node TINYINT UNSIGNED NOT NULL DEFAULT 0, + goosed_target VARCHAR(255) NULL, origin ENUM('h5', 'wechat') NOT NULL DEFAULT 'h5', created_at BIGINT NOT NULL, KEY idx_h5_user_sessions_user (user_id), diff --git a/scripts/check-goosed-proxy-boundary.mjs b/scripts/check-goosed-proxy-boundary.mjs new file mode 100644 index 0000000..94c24de --- /dev/null +++ b/scripts/check-goosed-proxy-boundary.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + checkGoosedProxyBoundary, + formatGoosedBoundaryViolations, + GOOSED_DIRECT_ALLOWLIST, + GOOSED_PROXY_ADAPTER_FILE, + GOOSED_PROXY_GUARDED_FILES, +} from '../goosed-proxy-boundary.mjs'; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const violations = checkGoosedProxyBoundary(rootDir); + +if (violations.length > 0) { + console.error('goosed proxy boundary check failed:\n'); + console.error(formatGoosedBoundaryViolations(violations)); + console.error('\nH5 chat must reach goosed only via tkmind-proxy.mjs.'); + console.error(`Allowlist: ${GOOSED_DIRECT_ALLOWLIST.join(', ')}`); + console.error(`Guarded: ${GOOSED_PROXY_GUARDED_FILES.join(', ')}`); + process.exit(1); +} + +console.log( + `goosed proxy boundary ok (adapter=${GOOSED_PROXY_ADAPTER_FILE}, allowlist=${GOOSED_DIRECT_ALLOWLIST.length}, guarded=${GOOSED_PROXY_GUARDED_FILES.length})`, +); diff --git a/scripts/verify-h5-session-patches.mjs b/scripts/verify-h5-session-patches.mjs new file mode 100644 index 0000000..c70a29b --- /dev/null +++ b/scripts/verify-h5-session-patches.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const testFiles = [ + 'session-broker.test.mjs', + 'agent-run-stream.test.mjs', + 'agent-run-routes.test.mjs', + 'agent-run-gateway.test.mjs', + 'sse-event-taxonomy.test.mjs', + 'goosed-proxy-boundary.test.mjs', + 'chat-agent-run-gate.test.mjs', + 'chat-finish-sync.test.mjs', + 'conversation-display.test.mjs', + 'mindspace-h5-html-finish-guard.test.mjs', +]; + +function run(label, command, args) { + const result = spawnSync(command, args, { + cwd: root, + stdio: 'inherit', + env: process.env, + }); + if (result.status !== 0) { + console.error(`[verify:h5-session-patches] failed at ${label}`); + process.exit(result.status ?? 1); + } +} + +run('unit tests', process.execPath, ['--test', ...testFiles]); +run('goosed proxy boundary', process.execPath, ['scripts/check-goosed-proxy-boundary.mjs']); +run('mindspace publish guards', process.execPath, ['scripts/verify-mindspace-publish-guards.mjs']); + +console.log('h5 session patch verification ok'); diff --git a/scripts/verify-mindspace-publish-guards.mjs b/scripts/verify-mindspace-publish-guards.mjs index 525887b..db0b02f 100644 --- a/scripts/verify-mindspace-publish-guards.mjs +++ b/scripts/verify-mindspace-publish-guards.mjs @@ -18,6 +18,7 @@ function run(label, args) { run('MindSpace publish + chat finish unit tests', [ '--test', 'mindspace-public-finish-sync.test.mjs', + 'mindspace-h5-html-finish-guard.test.mjs', 'conversation-display.test.mjs', 'chat-finish-sync.test.mjs', ]); diff --git a/server.mjs b/server.mjs index fc6e8d7..b76d195 100644 --- a/server.mjs +++ b/server.mjs @@ -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: '无权访问该会话' }); } diff --git a/session-broker.mjs b/session-broker.mjs new file mode 100644 index 0000000..516db99 --- /dev/null +++ b/session-broker.mjs @@ -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; +} diff --git a/session-broker.test.mjs b/session-broker.test.mjs new file mode 100644 index 0000000..384e5d2 --- /dev/null +++ b/session-broker.test.mjs @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createSessionBroker, createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs'; + +function createMockUserAuth(overrides = {}) { + const store = new Map(); + return { + async registerAgentSession(userId, sessionId, target = 0) { + const isLegacyIndex = + typeof target === 'number' || /^\d+$/.test(String(target)); + const node = isLegacyIndex ? Number(target) : 0; + const targetUrl = isLegacyIndex ? null : String(target); + store.set(sessionId, { + userId, + node, + target: targetUrl, + origin: 'h5', + }); + }, + async getSessionTarget(sessionId) { + const row = store.get(sessionId); + return { target: row?.target ?? null, node: row?.node ?? 0 }; + }, + async ownsSession(userId, sessionId) { + return store.get(sessionId)?.userId === userId; + }, + async unregisterAgentSession(userId, sessionId) { + if (store.get(sessionId)?.userId === userId) store.delete(sessionId); + }, + async setSessionOrigin(sessionId, origin) { + const row = store.get(sessionId); + if (row) row.origin = origin; + }, + async getSessionOrigins(sessionIds = []) { + return new Map( + sessionIds + .map((sessionId) => { + const row = store.get(sessionId); + return row ? [sessionId, row.origin] : null; + }) + .filter(Boolean), + ); + }, + ...overrides, + _store: store, + }; +} + +test('isSessionBrokerEnabled defaults to false', () => { + assert.equal(isSessionBrokerEnabled({}), false); + assert.equal(isSessionBrokerEnabled({ MEMIND_SESSION_BROKER_ENABLED: '0' }), false); + assert.equal(isSessionBrokerEnabled({ MEMIND_SESSION_BROKER_ENABLED: '1' }), true); +}); + +test('validateOwnership delegates to userAuth', async () => { + const userAuth = createMockUserAuth(); + const broker = createSessionBroker({ userAuth }); + await userAuth.registerAgentSession('user-1', 'sess-1', 'https://127.0.0.1:18006'); + assert.equal(await broker.validateOwnership('user-1', 'sess-1'), true); + assert.equal(await broker.validateOwnership('user-2', 'sess-1'), false); + assert.equal(await broker.validateOwnership('user-1', ''), false); +}); + +test('registerSession records direct chat h5-direct target', async () => { + const userAuth = createMockUserAuth(); + const broker = createSessionBroker({ userAuth }); + await broker.registerSession({ + userId: 'user-1', + sessionId: 'h5direct_abc', + target: 'h5-direct', + origin: 'h5', + }); + assert.equal(await broker.validateOwnership('user-1', 'h5direct_abc'), true); + const resolved = await broker.resolveSessionTarget('h5direct_abc'); + assert.equal(resolved.target, 'h5-direct'); + assert.equal(resolved.node, 0); +}); + +test('resolveSessionTarget prefers goosed_target over legacy node', async () => { + const userAuth = createMockUserAuth(); + const broker = createSessionBroker({ userAuth }); + await userAuth.registerAgentSession('user-1', 'sess-url', 'https://127.0.0.1:18007'); + assert.deepEqual(await broker.resolveSessionTarget('sess-url'), { + target: 'https://127.0.0.1:18007', + node: 0, + }); + + await userAuth.registerAgentSession('user-1', 'sess-node', 2); + assert.deepEqual(await broker.resolveSessionTarget('sess-node'), { + target: null, + node: 2, + }); +}); + +test('resolveSession includes origin for wechat sessions', async () => { + const userAuth = createMockUserAuth(); + const broker = createSessionBroker({ userAuth }); + await broker.registerSession({ + userId: 'user-wx', + sessionId: 'wx-sess-1', + target: 'https://127.0.0.1:18006', + origin: 'wechat', + }); + assert.deepEqual(await broker.resolveSession('wx-sess-1'), { + sessionId: 'wx-sess-1', + target: 'https://127.0.0.1:18006', + node: 0, + origin: 'wechat', + }); +}); + +test('unregisterSession removes ownership', async () => { + const userAuth = createMockUserAuth(); + const broker = createSessionBroker({ userAuth }); + await broker.registerSession({ + userId: 'user-1', + sessionId: 'sess-del', + target: 1, + }); + assert.equal(await broker.validateOwnership('user-1', 'sess-del'), true); + await broker.unregisterSession({ userId: 'user-1', sessionId: 'sess-del' }); + assert.equal(await broker.validateOwnership('user-1', 'sess-del'), false); +}); + +test('createSessionBroker requires userAuth session helpers', () => { + assert.throws(() => createSessionBroker({ userAuth: {} }), /registerAgentSession/); +}); + +test('createSessionAccess with enabled=false uses userAuth directly', async () => { + const userAuth = createMockUserAuth(); + const access = createSessionAccess({ userAuth, enabled: false }); + assert.equal(access.enabled, false); + await access.registerAgentSession('user-1', 'sess-fallback', 'https://127.0.0.1:18006'); + assert.equal(await access.validateOwnership('user-1', 'sess-fallback'), true); + assert.deepEqual(await access.getSessionTarget('sess-fallback'), { + target: 'https://127.0.0.1:18006', + node: 0, + }); +}); + +test('createSessionAccess with enabled=true routes through broker', async () => { + const userAuth = createMockUserAuth(); + const access = createSessionAccess({ userAuth, enabled: true }); + assert.equal(access.enabled, true); + await access.registerSession({ + userId: 'user-1', + sessionId: 'sess-broker', + target: 'h5-direct', + origin: 'wechat', + }); + assert.equal(await access.validateOwnership('user-1', 'sess-broker'), true); + const resolved = await access.resolveSession('sess-broker'); + assert.equal(resolved.origin, 'wechat'); + assert.equal(resolved.target, 'h5-direct'); +}); diff --git a/src/api/client.ts b/src/api/client.ts index 6bb1cb2..ed943ac 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2542,12 +2542,16 @@ export function subscribeAgentRunEvents( ): () => void { let closed = false; let activeController: AbortController | null = null; + let lastEventId: string | undefined; const run = async (controller: AbortController) => { while (!closed && !controller.signal.aborted) { try { + const headers: Record = { Accept: 'text/event-stream' }; + if (lastEventId) headers['Last-Event-ID'] = lastEventId; + const res = await fetch(`${API}${AGENT_RUNS_PATH}/${encodeURIComponent(runId)}/events`, { - headers: { Accept: 'text/event-stream' }, + headers, credentials: 'same-origin', signal: controller.signal, }); @@ -2567,18 +2571,21 @@ export function subscribeAgentRunEvents( while (!closed && !controller.signal.aborted) { const { done, value } = await reader.read(); - if (done) return; + if (done) break; buffer += decoder.decode(value, { stream: true }); const chunks = buffer.split('\n\n'); buffer = chunks.pop() ?? ''; for (const chunk of chunks) { + let eventId: string | undefined; let eventName = 'message'; let data = ''; for (const line of chunk.split('\n')) { + if (line.startsWith('id:')) eventId = line.slice(3).trim(); if (line.startsWith('event:')) eventName = line.slice(6).trim(); if (line.startsWith('data:')) data = line.slice(5).trim(); } + if (eventId) lastEventId = eventId; if (!data) continue; if (eventName === 'run') { const payload = JSON.parse(data) as { run: AgentRun }; @@ -2594,7 +2601,7 @@ export function subscribeAgentRunEvents( } catch (err) { if (controller.signal.aborted || closed) return; onError(err instanceof Error ? err : new Error(String(err))); - return; + await new Promise((resolve) => setTimeout(resolve, 1500)); } } }; diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index b9331ab..a1a0dbc 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -48,6 +48,10 @@ import type { import { buildContextPrefix } from '../utils/mindspaceChatContext'; import { buildUserAddressPrefix } from '../utils/userAddress'; import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs'; +import { + resolvePostAgentRunChatState, + shouldPromoteSessionIdToStreaming, +} from '../../chat-agent-run-gate.mjs'; import { mergeConversationSnapshot } from '../../chat-finish-sync.mjs'; import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, @@ -1366,7 +1370,9 @@ export function useTKMindChat( } if (!isDirectChatSessionId(sessionId)) { subscribeToSession(sessionId); - setChatState('streaming'); + if (shouldPromoteSessionIdToStreaming(chatStateRef.current)) { + setChatState('streaming'); + } } }, onMessages: (snapshotMessages) => { @@ -1443,7 +1449,11 @@ export function useTKMindChat( // Fall through to agent streaming when the snapshot is not ready yet. } subscribeToSession(activeSessionId); - if (finishedViaPortalDirectChat) { + const nextChatState = resolvePostAgentRunChatState({ + chatState: chatStateRef.current, + finishedViaPortalDirectChat, + }); + if (nextChatState === 'idle') { clearActiveRequestMissingTimer(); activeRequestId.current = null; setChatState('idle'); diff --git a/sse-event-taxonomy.mjs b/sse-event-taxonomy.mjs new file mode 100644 index 0000000..a659960 --- /dev/null +++ b/sse-event-taxonomy.mjs @@ -0,0 +1,86 @@ +export const SSE_EVENT_TAXONOMY = { + DELTA: 'delta', + CONTROL: 'control', + TERMINAL: 'terminal', +}; + +const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed']); + +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +export function isSseEventTaxonomyEnabled(env = process.env) { + return envFlag(env.MEMIND_SSE_EVENT_TAXONOMY, false); +} + +export function classifySessionEventTaxonomy(event) { + const type = String(event?.type ?? '').trim(); + if (!type) return null; + switch (type) { + case 'Message': + return SSE_EVENT_TAXONOMY.DELTA; + case 'Finish': + case 'Error': + return SSE_EVENT_TAXONOMY.TERMINAL; + case 'UpdateConversation': + case 'Ping': + case 'ActiveRequests': + return SSE_EVENT_TAXONOMY.CONTROL; + default: + break; + } + const normalized = type.toLowerCase(); + if (normalized === 'message') return SSE_EVENT_TAXONOMY.DELTA; + if (normalized === 'finish' || normalized === 'error') return SSE_EVENT_TAXONOMY.TERMINAL; + if ( + normalized === 'updateconversation' || + normalized === 'ping' || + normalized === 'activerequests' + ) { + return SSE_EVENT_TAXONOMY.CONTROL; + } + return null; +} + +export function classifyRunStreamTaxonomy(eventName, payload = {}) { + const name = String(eventName ?? '').trim().toLowerCase(); + if (name === 'error') return SSE_EVENT_TAXONOMY.TERMINAL; + if (name !== 'run') return null; + const status = String(payload?.run?.status ?? payload?.status ?? '').trim().toLowerCase(); + if (TERMINAL_RUN_STATUSES.has(status)) return SSE_EVENT_TAXONOMY.TERMINAL; + if (status) return SSE_EVENT_TAXONOMY.CONTROL; + return null; +} + +export function finalizeSessionStreamEvent(event, env = process.env) { + if (!event || typeof event !== 'object' || Array.isArray(event)) return event; + if (!isSseEventTaxonomyEnabled(env)) return event; + const taxonomy = classifySessionEventTaxonomy(event); + if (!taxonomy || event.taxonomy === taxonomy) return event; + return { ...event, taxonomy }; +} + +export function wrapRunStreamPayload(eventName, data, env = process.env) { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data; + if (!isSseEventTaxonomyEnabled(env)) return data; + const taxonomy = classifyRunStreamTaxonomy(eventName, data); + if (!taxonomy || data.taxonomy === taxonomy) return data; + return { ...data, taxonomy }; +} + +export function writeSseErrorAndEnd(res, message, { status = 502 } = {}) { + if (res.writableEnded) return; + if (res.headersSent) { + try { + res.write(`event: error\ndata: ${JSON.stringify({ message })}\n\n`); + } catch { + // Client may already be gone. + } + res.end(); + return; + } + res.status(status).json({ message }); +} diff --git a/sse-event-taxonomy.test.mjs b/sse-event-taxonomy.test.mjs new file mode 100644 index 0000000..eeae16c --- /dev/null +++ b/sse-event-taxonomy.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; +import { + classifyRunStreamTaxonomy, + classifySessionEventTaxonomy, + finalizeSessionStreamEvent, + isSseEventTaxonomyEnabled, + SSE_EVENT_TAXONOMY, + wrapRunStreamPayload, + writeSseErrorAndEnd, +} from './sse-event-taxonomy.mjs'; + +test('classifySessionEventTaxonomy maps goosed session events', () => { + assert.equal( + classifySessionEventTaxonomy({ type: 'Message', message: { role: 'assistant' } }), + SSE_EVENT_TAXONOMY.DELTA, + ); + assert.equal( + classifySessionEventTaxonomy({ type: 'UpdateConversation', conversation: [] }), + SSE_EVENT_TAXONOMY.CONTROL, + ); + assert.equal( + classifySessionEventTaxonomy({ type: 'Finish', reason: 'stop' }), + SSE_EVENT_TAXONOMY.TERMINAL, + ); + assert.equal( + classifySessionEventTaxonomy({ type: 'Error', error: 'boom' }), + SSE_EVENT_TAXONOMY.TERMINAL, + ); + assert.equal( + classifySessionEventTaxonomy({ type: 'Ping' }), + SSE_EVENT_TAXONOMY.CONTROL, + ); +}); + +test('classifyRunStreamTaxonomy separates control and terminal run states', () => { + assert.equal( + classifyRunStreamTaxonomy('run', { run: { status: 'running' } }), + SSE_EVENT_TAXONOMY.CONTROL, + ); + assert.equal( + classifyRunStreamTaxonomy('run', { run: { status: 'succeeded' } }), + SSE_EVENT_TAXONOMY.TERMINAL, + ); + assert.equal( + classifyRunStreamTaxonomy('error', { message: 'failed' }), + SSE_EVENT_TAXONOMY.TERMINAL, + ); +}); + +test('finalizeSessionStreamEvent attaches taxonomy only when flag enabled', () => { + const event = { type: 'Finish', reason: 'stop' }; + const previous = process.env.MEMIND_SSE_EVENT_TAXONOMY; + process.env.MEMIND_SSE_EVENT_TAXONOMY = '0'; + try { + assert.deepEqual(finalizeSessionStreamEvent(event), event); + } finally { + if (previous == null) delete process.env.MEMIND_SSE_EVENT_TAXONOMY; + else process.env.MEMIND_SSE_EVENT_TAXONOMY = previous; + } + + process.env.MEMIND_SSE_EVENT_TAXONOMY = '1'; + try { + assert.equal(isSseEventTaxonomyEnabled(), true); + assert.deepEqual(finalizeSessionStreamEvent(event), { + ...event, + taxonomy: SSE_EVENT_TAXONOMY.TERMINAL, + }); + } finally { + if (previous == null) delete process.env.MEMIND_SSE_EVENT_TAXONOMY; + else process.env.MEMIND_SSE_EVENT_TAXONOMY = previous; + } +}); + +test('wrapRunStreamPayload keeps legacy payload when flag disabled', () => { + const payload = { run: { id: 'run-1', status: 'running' } }; + const previous = process.env.MEMIND_SSE_EVENT_TAXONOMY; + delete process.env.MEMIND_SSE_EVENT_TAXONOMY; + try { + assert.deepEqual(wrapRunStreamPayload('run', payload), payload); + } finally { + if (previous == null) delete process.env.MEMIND_SSE_EVENT_TAXONOMY; + else process.env.MEMIND_SSE_EVENT_TAXONOMY = previous; + } +}); + +test('writeSseErrorAndEnd uses SSE error frame after headers sent', () => { + const res = new EventEmitter(); + res.headersSent = true; + res.writableEnded = false; + const chunks = []; + res.write = (chunk) => { + chunks.push(String(chunk)); + return true; + }; + res.end = () => { + res.writableEnded = true; + }; + res.status = () => { + throw new Error('status must not be called after SSE started'); + }; + res.json = () => { + throw new Error('json must not be called after SSE started'); + }; + + writeSseErrorAndEnd(res, 'stream failed'); + assert.equal(res.writableEnded, true); + assert.match(chunks.join(''), /event: error/); + assert.match(chunks.join(''), /stream failed/); +}); diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index b23619a..09bd169 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -4,6 +4,7 @@ import { Readable, Transform, Writable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { Agent, fetch as undiciFetch } from 'undici'; import { appendBalanceEvent, createSseBillingTransform } from './sse-billing.mjs'; +import { finalizeSessionStreamEvent, writeSseErrorAndEnd } from './sse-event-taxonomy.mjs'; import { developerToolsFromPolicy } from './capabilities.mjs'; import { evaluateProxyRequest, isNativeH5ApiPath } from './policies.mjs'; import { @@ -14,6 +15,7 @@ import { } from './user-publish.mjs'; import { buildCurrentTimeAgentPrefix, buildTaskRoutingAgentText } from './user-memory-profile.mjs'; import { reconcileAgentSession } from './session-reconcile.mjs'; +import { createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs'; import { createImgproxySigner } from './imgproxy-signer.mjs'; import { isDirectChatSessionId } from './direct-chat-service.mjs'; import { ensureGooseUserMessageMetadata } from './goose-message.mjs'; @@ -407,17 +409,7 @@ function sendProxyResponse(res, upstream) { } function writeSseProxyErrorAndEnd(res, message) { - if (res.writableEnded) return; - if (res.headersSent) { - try { - res.write(`event: error\ndata: ${JSON.stringify({ message })}\n\n`); - } catch { - // The client may already be gone. Ending below is still harmless. - } - res.end(); - return; - } - res.status(502).json({ message }); + writeSseErrorAndEnd(res, message, { status: 502 }); } function extractSessionId(req, body) { @@ -593,6 +585,7 @@ function createSessionEventSanitizer(currentUser, { onEvent } = {}) { } catch { return line; } + event = finalizeSessionStreamEvent(event); if (typeof onEvent === 'function') { try { onEvent(event); @@ -845,10 +838,12 @@ export async function buildVisionPayload({ } export function createTkmindProxy({ + // GOOSED PROXY BOUNDARY: canonical H5 chat → goosed adapter (Patch 5, goosed-proxy-boundary.mjs) apiTarget, apiTargets, apiSecret, userAuth, + sessionAccess = null, llmProviderService, localFetchAsset, subscriptionService, @@ -856,6 +851,8 @@ export function createTkmindProxy({ conversationMemoryService, memoryV2, }) { + const sessionStore = + sessionAccess ?? createSessionAccess({ userAuth, enabled: isSessionBrokerEnabled() }); const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : []; const primaryTarget = targets[0] ?? apiTarget ?? ''; let rrIdx = 0; @@ -1170,7 +1167,7 @@ export function createTkmindProxy({ throw new Error('创建会话失败:缺少 session id'); } await rememberSessionTarget(session.id, startTarget); - await userAuth.registerAgentSession(userId, session.id, startTarget); + await sessionStore.registerAgentSession(userId, session.id, startTarget); if (resolvedSessionPolicy?.gooseMode) { const modeRes = await apiFetch(startTarget, apiSecret, '/agent/update_session', { method: 'POST', @@ -1213,7 +1210,7 @@ export function createTkmindProxy({ try { const routedTarget = await runtimeRouter?.resolveSessionTarget(sessionId); if (routedTarget) return routedTarget; - const { target, node } = await userAuth.getSessionTarget(sessionId); + const { target, node } = await sessionStore.getSessionTarget(sessionId); // Prefer the pinned URL: stable across reordering/resizing the target list. // Only honor it if that upstream is still configured; otherwise fall back to // the legacy integer index, then to primary. @@ -1330,7 +1327,7 @@ export function createTkmindProxy({ { toolMode = 'chat', forceDeepReasoning = false } = {}, ) { if (!userId || !sessionId) throw new Error('缺少会话信息'); - const owns = await userAuth.ownsSession(userId, sessionId); + const owns = await sessionStore.validateOwnership(userId, sessionId); if (!owns) throw new Error('无权访问该会话'); const gate = await userAuth.canUseChat(userId); if (!gate.ok) { @@ -1460,7 +1457,7 @@ export function createTkmindProxy({ const session = JSON.parse(text); if (session?.id) { await rememberSessionTarget(session.id, startTarget); - await userAuth.registerAgentSession( + await sessionStore.registerAgentSession( req.currentUser.id, session.id, startTarget, @@ -1527,7 +1524,7 @@ export function createTkmindProxy({ res.status(400).json({ message: '缺少 session_id' }); return; } - const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); + const owns = await sessionStore.validateOwnership(req.currentUser.id, sessionId); if (!owns) { res.status(403).json({ message: '无权访问该会话' }); return; @@ -1726,7 +1723,7 @@ export function createTkmindProxy({ res.status(400).json({ message: '缺少 session_id' }); return; } - const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); + const owns = await sessionStore.validateOwnership(req.currentUser.id, sessionId); if (!owns) { res.status(403).json({ message: '无权访问该会话' }); return; diff --git a/tkmind-proxy.test.mjs b/tkmind-proxy.test.mjs index 2cba4e1..48b2c82 100644 --- a/tkmind-proxy.test.mjs +++ b/tkmind-proxy.test.mjs @@ -386,6 +386,70 @@ test('proxySessionEvents does not send JSON after SSE headers were sent', async } }); +test('proxySessionEvents attaches session taxonomy when flag enabled', async () => { + const previous = process.env.MEMIND_SSE_EVENT_TAXONOMY; + process.env.MEMIND_SSE_EVENT_TAXONOMY = '1'; + let upstream; + + try { + upstream = createServer((req, res) => { + if (req.method === 'GET' && req.url === '/sessions/session-1/events') { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write('data: {"type":"Finish","reason":"stop"}\n\n'); + res.end(); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: `unexpected ${req.method} ${req.url}` })); + }); + const upstreamPort = await listen(upstream); + const proxy = createTkmindProxy({ + apiTarget: `http://127.0.0.1:${upstreamPort}`, + apiSecret: 'test-secret', + userAuth: createMemoryTestUserAuth(process.cwd()), + }); + + const req = new EventEmitter(); + req.currentUser = { id: 'user-1', username: 'john' }; + req.get = () => ''; + req.once = req.once.bind(req); + req.off = req.off.bind(req); + + const chunks = []; + const res = new EventEmitter(); + res.headersSent = false; + res.writableEnded = false; + res.statusCode = 200; + res.status = (code) => { + res.statusCode = code; + return res; + }; + res.setHeader = () => {}; + res.flushHeaders = () => { + res.headersSent = true; + }; + res.write = (chunk) => { + res.headersSent = true; + chunks.push(String(chunk)); + return true; + }; + res.end = () => { + res.writableEnded = true; + }; + res.json = () => { + throw new Error('json must not be called after SSE started'); + }; + + await proxy.proxySessionEvents(req, res, 'session-1'); + + assert.match(chunks.join(''), /"taxonomy":"terminal"/); + } finally { + if (previous == null) delete process.env.MEMIND_SSE_EVENT_TAXONOMY; + else process.env.MEMIND_SSE_EVENT_TAXONOMY = previous; + await closeServer(upstream); + } +}); + test('startSessionForUser resolves memories through Memory V2 facade', async () => { let resolveInput = null; await withFakeGoosedSession(async ({ apiTarget, workingDir, harnessEntries }) => { diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 2eb853f..d63f021 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -5,6 +5,7 @@ import { fetch as undiciFetch } from 'undici'; import { developerToolsFromPolicy } from './capabilities.mjs'; import { mergeMessageContent } from './message-stream.mjs'; import { reconcileAgentSession } from './session-reconcile.mjs'; +import { resolveSessionAccess } from './session-broker.mjs'; import { isStubPublicHtmlContent, materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs'; import { loadWechatMpConfig } from './wechat-mp-config.mjs'; import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs'; @@ -1288,6 +1289,7 @@ export function buildWechatTextReply({ export function createWechatMpService({ config, userAuth, + sessionAccess = null, apiFetch, startAgentSession = null, sessionApiFetch = null, @@ -1298,6 +1300,7 @@ export function createWechatMpService({ linkExists = defaultPublicHtmlLinkExists, logger = console, }) { + const sessionStore = resolveSessionAccess({ userAuth, sessionAccess }); if (!config?.enabled) { return { enabled: false, @@ -1658,8 +1661,13 @@ export function createWechatMpService({ // `/agent/start` already persists the owning user and goosed node via the // portal proxy. Re-registering here without the node can overwrite the // correct mapping back to node 0 in multi-goosed production. - if (!startAgentSession && typeof userAuth.registerAgentSession === 'function') { - await userAuth.registerAgentSession(userId, sessionId); + if (!startAgentSession) { + await sessionStore.registerSession({ + userId, + sessionId, + target: 0, + origin: 'wechat', + }); } await reconcileAgentSession( (pathname, init) => fetchForSession(sessionId, pathname, init),