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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 14:19:48 +08:00

87 lines
2.8 KiB
JavaScript

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 });
}