Files
memind/mindspace-page-edit-session.mjs
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

273 lines
8.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Agent, fetch as undiciFetch } from 'undici';
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 },
});
function isHttpsTarget(target) {
return target.startsWith('https://');
}
function createApiFetch(apiTarget, apiSecret) {
return async (pathname, init = {}) => {
const url = new URL(pathname, apiTarget);
const headers = {
...(init.headers ?? {}),
'X-Secret-Key': apiSecret,
};
if (init.body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
return undiciFetch(url, {
...init,
headers,
dispatcher: isHttpsTarget(apiTarget) ? insecureDispatcher : undefined,
});
};
}
async function readJson(upstream) {
const text = await upstream.text();
if (!upstream.ok) {
throw Object.assign(new Error(text || `upstream ${upstream.status}`), {
code: 'worker_unavailable',
});
}
return text ? JSON.parse(text) : null;
}
function buildPageEditSeedContent({ page, parentSessionId, h5ApiBase, sessionId }) {
const context = {
spaceId: 'mindspace',
spaceName: 'MindSpace',
view: 'page',
route: `/mindspace/page/${page.id}/preview`,
pageEditMode: true,
parentAgentSessionId: parentSessionId,
agentSessionId: sessionId,
h5ApiBase,
page: {
id: page.id,
title: page.title,
summary: page.summary ?? '',
status: page.status,
versionNo: page.versionNo,
templateId: page.templateId,
contentFormat: page.contentFormat ?? 'html',
},
};
const lines = [
...buildPageEditSubAgentInstructions(context).split('\n'),
'[MindSpace 页面编辑子会话]',
`- 页面:${page.title}id: ${page.id}`,
`- 内容格式:${page.contentFormat ?? 'html'}`,
`- 当前版本:v${page.versionNo ?? 1}`,
...buildMindSpacePageSaveInstructions(
{
id: page.id,
contentFormat: page.contentFormat ?? 'html',
},
{ sessionId, h5ApiBase },
),
'请用一句话确认你已准备好修改此页面,并等待用户指令。',
];
return lines.join('\n');
}
export function buildPageEditSessionConstraints(pageTitle) {
return [
'【页面编辑子 Agent 沙箱】',
`- 当前任务:仅修改 MindSpace 页面「${pageTitle}`,
'- 禁止读写工作区其它文件;禁止 delegate / load_skill / 发布新页面',
'- 改页面优先 curl 调用 mindspace_page_patch;若无 shell 权限则在回复末尾输出 ```mindspace-page-update``` 块',
'- 每次改动后立即触发预览更新,不要只口头说「已修改」',
].join('\n');
}
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) => {
if (!llmProviderService || !sessionId) return null;
try {
return await llmProviderService.applyBestProviderForSession(sessionId);
} catch {
return null;
}
};
return {
async forkSession({ userId, pageId, parentSessionId, h5ApiBase = null }) {
if (!userId || !pageId || !parentSessionId) {
throw Object.assign(new Error('缺少 fork 参数'), { code: 'invalid_request' });
}
const ownsParent = await sessionStore.validateOwnership(userId, parentSessionId);
if (!ownsParent) {
throw Object.assign(new Error('无权使用父会话'), { code: 'forbidden' });
}
const page = await pageService.getPage(userId, pageId);
if (page.contentFormat !== 'html') {
throw Object.assign(new Error('仅 HTML 页面支持编辑子会话'), { code: 'invalid_request' });
}
const gate = await userAuth.canUseChat(userId);
if (!gate.ok) {
throw Object.assign(new Error(gate.message || '当前用户无法使用 Agent'), { code: 'forbidden' });
}
const workingDir = await userAuth.resolveWorkingDir(userId);
const basePolicy = await userAuth.getAgentSessionPolicy(userId);
const sessionPolicy = buildPageEditAgentPolicy(basePolicy);
const publishLayout = await userAuth.getUserPublishLayout(userId);
const startSession = await readJson(
await apiFetch('/agent/start', {
method: 'POST',
body: JSON.stringify({
working_dir: workingDir,
enable_context_memory: sessionPolicy.enableContextMemory,
...(sessionPolicy.extensionOverrides
? { extension_overrides: sessionPolicy.extensionOverrides }
: {}),
}),
}),
);
const sessionId = startSession?.id;
if (!sessionId) {
throw Object.assign(new Error('Agent 会话启动失败'), { code: 'worker_unavailable' });
}
await sessionStore.registerAgentSession(userId, sessionId);
if (sessionPolicy.gooseMode) {
await readJson(
await apiFetch('/agent/update_session', {
method: 'POST',
body: JSON.stringify({
session_id: sessionId,
goose_mode: sessionPolicy.gooseMode,
}),
}),
);
}
const sandboxConstraints = [
publishLayout?.constraints ?? '',
buildPageEditSessionConstraints(page.title),
]
.filter(Boolean)
.join('\n\n');
await reconcileAgentSession(apiFetch, sessionId, {
workingDir,
sessionPolicy,
sandboxConstraints,
userContext: publishLayout
? {
userId,
displayName: publishLayout.displayName,
username: publishLayout.username,
slug: publishLayout.slug,
}
: null,
});
await applySessionLlmProvider(sessionId);
await pageLiveEdit.bindSession({
userId,
sessionId,
pageId,
parentSessionId,
});
const seed = buildPageEditSeedContent({
page,
parentSessionId,
h5ApiBase,
sessionId,
});
await apiFetch('/agent/harness_remember', {
method: 'POST',
body: JSON.stringify({
sessionId,
title: `页面编辑 · ${page.title}`,
content: seed,
}),
});
await apiFetch('/agent/harness_bootstrap', {
method: 'POST',
body: JSON.stringify({ sessionId, force: true }),
});
return {
sessionId,
pageId,
parentSessionId,
};
},
async closeSession({ userId, sessionId, pageId, parentSessionId, summary = '' }) {
if (!userId || !sessionId || !pageId) {
throw Object.assign(new Error('缺少 close 参数'), { code: 'invalid_request' });
}
const owns = await sessionStore.validateOwnership(userId, sessionId);
if (!owns) {
throw Object.assign(new Error('无权关闭该子会话'), { code: 'forbidden' });
}
const binding = pageLiveEdit.getBinding(sessionId);
if (binding && binding.pageId !== String(pageId)) {
throw Object.assign(new Error('子会话未绑定该页面'), { code: 'page_binding_mismatch' });
}
pageLiveEdit.unbindSession(sessionId);
const trimmedSummary = String(summary ?? '').trim();
const parentId = String(parentSessionId ?? binding?.parentSessionId ?? '').trim();
if (trimmedSummary && parentId) {
const ownsParent = await sessionStore.validateOwnership(userId, parentId);
if (ownsParent) {
const page = await pageService.getPage(userId, pageId).catch(() => null);
await apiFetch('/agent/harness_remember', {
method: 'POST',
body: JSON.stringify({
sessionId: parentId,
title: `页面编辑摘要 · ${page?.title ?? pageId}`,
content: trimmedSummary,
}),
});
}
}
return { sessionId, pageId, merged: Boolean(trimmedSummary && parentId) };
},
};
}
export const pageEditSessionInternals = {
buildPageEditSeedContent,
buildPageEditSessionConstraints,
};