fix(mindspace): edit_file 落盘、Finish 聊天 merge 与回归守卫
- finish-sync 支持 edit_file 覆盖 public HTML - Finish 同步 merge 本地流式消息,剥离 agent 内部前缀 - 新增 verify:mindspace-publish-guards 与 AGENTS.md 跨工具说明 - 发版脚本接入回归门禁;103 runtime 发布含备份回退 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,12 @@ import path from 'node:path';
|
||||
|
||||
import { extractStaticPageLinks } from './mindspace-chat-save.mjs';
|
||||
|
||||
/**
|
||||
* Public HTML finish-sync invariants (regression guard — do not simplify away).
|
||||
* edit_file patches must materialize onto MindSpace/<userId>/public/*.html.
|
||||
* See docs/regression-guards/mindspace-publish-and-chat-finish.md
|
||||
*/
|
||||
|
||||
const PUBLIC_HTML_PATH_PATTERN = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/i;
|
||||
const PUBLIC_DOCX_HREF_PATTERN = /href=["']([^"'#?\s]+\.docx)["']/gi;
|
||||
|
||||
@@ -119,7 +125,7 @@ function isWriteLikeHtmlTool(name, args) {
|
||||
const action = String(args?.action ?? '').toLowerCase();
|
||||
const normalizedName = String(name ?? '').trim();
|
||||
const writeLikeDeveloper =
|
||||
(normalizedName === 'developer' && action === 'write') ||
|
||||
(normalizedName === 'developer' && (action === 'write' || action === 'edit')) ||
|
||||
normalizedName === 'write' ||
|
||||
normalizedName.endsWith('__write');
|
||||
const writeLikeSandbox =
|
||||
@@ -131,7 +137,37 @@ function isWriteLikeHtmlTool(name, args) {
|
||||
return (writeLikeDeveloper && typeof args?.path === 'string') || writeLikeSandbox;
|
||||
}
|
||||
|
||||
export function extractPublicHtmlWriteArtifacts(messages = []) {
|
||||
function isEditLikeHtmlTool(name, args) {
|
||||
const normalizedName = String(name ?? '').trim();
|
||||
return (
|
||||
(normalizedName === 'edit_file' ||
|
||||
normalizedName.endsWith('__edit_file') ||
|
||||
(normalizedName === 'developer' && String(args?.action ?? '').toLowerCase() === 'edit')) &&
|
||||
typeof args?.path === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function readPublicHtmlBaseline(publishDir, relativePath) {
|
||||
const root = path.resolve(String(publishDir ?? ''));
|
||||
if (!root) return '';
|
||||
const destination = path.resolve(root, relativePath);
|
||||
if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) return '';
|
||||
if (!fs.existsSync(destination) || !fs.statSync(destination).isFile()) return '';
|
||||
try {
|
||||
return fs.readFileSync(destination, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function applyPublicHtmlEdit(existing, args) {
|
||||
if (typeof args?.new_str !== 'string') return null;
|
||||
const oldStr = typeof args.old_str === 'string' ? args.old_str : '';
|
||||
if (oldStr && !existing.includes(oldStr)) return null;
|
||||
return oldStr ? existing.replace(oldStr, args.new_str) : args.new_str;
|
||||
}
|
||||
|
||||
export function extractPublicHtmlWriteArtifacts(messages = [], { publishDir = null } = {}) {
|
||||
const artifacts = new Map();
|
||||
for (const message of messages) {
|
||||
for (const item of message?.content ?? []) {
|
||||
@@ -143,12 +179,17 @@ export function extractPublicHtmlWriteArtifacts(messages = []) {
|
||||
if (!candidate.toLowerCase().endsWith('.html')) continue;
|
||||
const relativePath = normalizePublicHtmlRelativePath(candidate);
|
||||
if (!relativePath) continue;
|
||||
const content =
|
||||
typeof args.content === 'string'
|
||||
? args.content
|
||||
: typeof args.new_str === 'string' && !args.old_str
|
||||
? args.new_str
|
||||
: null;
|
||||
let content = null;
|
||||
if (typeof args.content === 'string') {
|
||||
content = args.content;
|
||||
} else if (isEditLikeHtmlTool(toolCall?.name, args)) {
|
||||
const baseline =
|
||||
artifacts.get(relativePath)?.content ??
|
||||
readPublicHtmlBaseline(publishDir, relativePath);
|
||||
content = applyPublicHtmlEdit(baseline, args);
|
||||
} else if (typeof args.new_str === 'string' && !args.old_str) {
|
||||
content = args.new_str;
|
||||
}
|
||||
if (content == null) continue;
|
||||
artifacts.set(relativePath, { relativePath, content });
|
||||
}
|
||||
@@ -180,7 +221,7 @@ export function materializeMissingPublicHtmlWrites({ messages, publishDir }) {
|
||||
|
||||
const materialized = [];
|
||||
const skipped = [];
|
||||
for (const artifact of extractPublicHtmlWriteArtifacts(messages)) {
|
||||
for (const artifact of extractPublicHtmlWriteArtifacts(messages, { publishDir: root })) {
|
||||
const destination = path.resolve(root, artifact.relativePath);
|
||||
if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) {
|
||||
skipped.push(artifact.relativePath);
|
||||
@@ -203,11 +244,53 @@ export function materializeMissingPublicHtmlWrites({ messages, publishDir }) {
|
||||
return { materialized, skipped };
|
||||
}
|
||||
|
||||
export function hasRecentOwnPublicHtmlReference(messages, currentUser, { recentCount = 80 } = {}) {
|
||||
export function materializePublicHtmlWritesFromSessionEvent(
|
||||
event,
|
||||
{ publishDir, recentCount = 20 } = {},
|
||||
) {
|
||||
if (!event || !publishDir) {
|
||||
return { materialized: [], skipped: [] };
|
||||
}
|
||||
if (event.type === 'Message' && event.message) {
|
||||
return materializeMissingPublicHtmlWrites({
|
||||
messages: [event.message],
|
||||
publishDir,
|
||||
});
|
||||
}
|
||||
if (event.type === 'UpdateConversation' && Array.isArray(event.conversation)) {
|
||||
return materializeMissingPublicHtmlWrites({
|
||||
messages: event.conversation.slice(-Math.max(1, recentCount)),
|
||||
publishDir,
|
||||
});
|
||||
}
|
||||
return { materialized: [], skipped: [] };
|
||||
}
|
||||
|
||||
function messageHasPublicHtmlToolRequest(message, { publishDir = null } = {}) {
|
||||
if (extractPublicHtmlWriteArtifacts([message], { publishDir }).length > 0) {
|
||||
return true;
|
||||
}
|
||||
for (const item of message?.content ?? []) {
|
||||
if (item?.type !== 'toolRequest') continue;
|
||||
const toolCall = item.toolCall?.value;
|
||||
const args = toolCall?.arguments ?? {};
|
||||
if (!isWriteLikeHtmlTool(toolCall?.name, args)) continue;
|
||||
const candidate = String(args.path ?? '').trim();
|
||||
if (!candidate.toLowerCase().endsWith('.html')) continue;
|
||||
if (normalizePublicHtmlRelativePath(candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasRecentOwnPublicHtmlReference(
|
||||
messages,
|
||||
currentUser,
|
||||
{ recentCount = 80, publishDir = null } = {},
|
||||
) {
|
||||
if (!Array.isArray(messages) || messages.length === 0 || !currentUser?.id) return false;
|
||||
const recentMessages = messages.slice(-Math.max(1, recentCount));
|
||||
for (const message of recentMessages) {
|
||||
if (extractPublicHtmlWriteArtifacts([message]).length > 0) {
|
||||
if (messageHasPublicHtmlToolRequest(message, { publishDir })) {
|
||||
return true;
|
||||
}
|
||||
const text = messageText(message);
|
||||
@@ -233,7 +316,7 @@ export async function syncPublicHtmlAfterFinish({
|
||||
publishDir,
|
||||
syncWorkspaceAssets,
|
||||
} = {}) {
|
||||
if (!hasRecentOwnPublicHtmlReference(messages, currentUser)) {
|
||||
if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) {
|
||||
return { materialized: [], skipped: [], synced: false };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user