feat(h5-session): Session Broker、run SSE replay 与 Finish 竞态修复
落地 H5 Session 架构 Patch 1–5(Broker 收口、Router decision、SSE taxonomy、goosed 边界检查), 并新增可选 MEMIND_RUN_STREAM_REPLAY run 事件回放与 H5 假交付 guard;修复 Finish 先于 agent-run gate 导致 UI 永久 loading 的竞态,接入 verify:h5-session-patches 回归脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user