fix: deliver WeChat pages when image_make fails

Fall back to workspace share thumbnails so already-published HTML can be
sent instead of blocking on REQUIRED_FRESH. Also allow an explicit
ALLOW_DIRECT_STABLE_RELEASE path matching the pre-2026-07-23 direct
stable packaging flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-28 18:19:29 +08:00
parent 7eedda092d
commit 065b1588b7
5 changed files with 245 additions and 88 deletions
+106 -13
View File
@@ -514,6 +514,84 @@ function sanitizeFreshVerification(
}; };
} }
function collectDeliverablePageArtifacts({
artifacts = [],
messages = [],
publishDir,
buildCanonicalUrl,
} = {}) {
const restored = restoreWorkspaceArtifacts({
artifacts,
publishDir,
buildCanonicalUrl,
});
if (restored.length > 0) return restored;
const writeArtifacts = extractPublicHtmlWriteArtifacts(
messages,
{ publishDir },
);
return restoreWorkspaceArtifacts({
artifacts: writeArtifacts.map((artifact) => ({
relativePath: artifact.relativePath,
})),
publishDir,
buildCanonicalUrl,
});
}
async function acceptWorkspaceThumbnailFallback({
artifacts = [],
publishDir,
} = {}) {
const eligible = artifacts.filter((artifact) => {
const localPath = String(artifact?.localPath ?? '').trim();
return localPath && fs.existsSync(localPath) && artifact?.auxiliary !== true;
});
if (eligible.length === 0) {
return { ok: false, reason: 'missing_page_artifact', matches: [] };
}
await Promise.all(
eligible.map((artifact) =>
ensureWorkspaceHtmlThumbnail(
publishDir,
artifact.relativePath,
),
),
);
const withThumbnails = eligible.filter((artifact) =>
fs.existsSync(
path.join(
publishDir,
workspaceThumbnailRelativePath(artifact.relativePath),
),
),
);
if (withThumbnails.length !== eligible.length) {
return {
ok: false,
reason: 'workspace_thumbnail_fallback_failed',
matches: [],
artifact: eligible.find((artifact) =>
!fs.existsSync(
path.join(
publishDir,
workspaceThumbnailRelativePath(artifact.relativePath),
),
),
),
};
}
return {
ok: true,
reason: 'workspace_thumbnail_fallback',
matches: withThumbnails.map((artifact) => ({
artifact,
image: null,
cover: null,
})),
};
}
export async function ensureWechatFreshPageThumbnailsAtWorkspace({ export async function ensureWechatFreshPageThumbnailsAtWorkspace({
artifacts = [], artifacts = [],
images = [], images = [],
@@ -522,12 +600,12 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({
buildCanonicalUrl, buildCanonicalUrl,
repairEnabled = false, repairEnabled = false,
} = {}) { } = {}) {
const workspaceArtifacts = let workspaceArtifacts = collectDeliverablePageArtifacts({
restoreWorkspaceArtifacts({ artifacts,
artifacts, messages,
publishDir, publishDir,
buildCanonicalUrl, buildCanonicalUrl,
}); });
let verification = let verification =
verifyFreshWechatPageThumbnails( verifyFreshWechatPageThumbnails(
workspaceArtifacts, workspaceArtifacts,
@@ -551,20 +629,35 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({
verification.reason, verification.reason,
}); });
if (repair.ok) { if (repair.ok) {
workspaceArtifacts = restoreWorkspaceArtifacts({
artifacts: workspaceArtifacts,
publishDir,
buildCanonicalUrl,
});
verification = verification =
verifyFreshWechatPageThumbnails( verifyFreshWechatPageThumbnails(
restoreWorkspaceArtifacts({ workspaceArtifacts,
artifacts,
publishDir,
buildCanonicalUrl,
}),
images, images,
); );
} }
} }
// When image_make failed or returned nothing, still deliver pages that already
// landed on disk by generating the workspace share thumbnail sidecars.
if (
!verification.ok
&& ['fresh_image_not_generated', 'missing_generated_cover', 'cover_not_from_current_run'].includes(
String(verification.reason ?? ''),
)
&& workspaceArtifacts.length > 0
) {
verification = await acceptWorkspaceThumbnailFallback({
artifacts: workspaceArtifacts,
publishDir,
});
}
if (verification.ok) { if (verification.ok) {
await Promise.all( await Promise.all(
verification.matches.map((match) => (verification.matches ?? []).map((match) =>
ensureWorkspaceHtmlThumbnail( ensureWorkspaceHtmlThumbnail(
publishDir, publishDir,
match.artifact.relativePath, match.artifact.relativePath,
@@ -579,7 +672,7 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({
), ),
thumbnailRelativePaths: thumbnailRelativePaths:
verification.ok verification.ok
? verification.matches.map((match) => ? (verification.matches ?? []).map((match) =>
workspaceThumbnailRelativePath( workspaceThumbnailRelativePath(
match.artifact.relativePath, match.artifact.relativePath,
), ),
+38
View File
@@ -271,6 +271,44 @@ test('MindSpace verifies and renders fresh WeChat page thumbnails without return
); );
}); });
test('MindSpace falls back to workspace thumbnails when fresh image_make is missing', async (t) => {
const publishDir = fs.mkdtempSync(
path.join(
os.tmpdir(),
'mindspace-wechat-thumbnail-fallback-',
),
);
t.after(() => {
fs.rmSync(publishDir, {
recursive: true,
force: true,
});
});
const relativePath = 'public/fallback.html';
const htmlPath = path.join(publishDir, relativePath);
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
fs.writeFileSync(
htmlPath,
pageHtml({ title: 'Fallback' }),
'utf8',
);
const result = await ensureWechatFreshPageThumbnailsAtWorkspace({
publishDir,
buildCanonicalUrl: canonical,
artifacts: [{ relativePath, exists: true }],
images: [],
});
assert.equal(result.ok, true);
assert.equal(result.reason, 'workspace_thumbnail_fallback');
assert.deepEqual(result.matchRelativePaths, [relativePath]);
assert.equal(
fs.existsSync(path.join(publishDir, 'public', 'fallback.thumbnail.svg')),
true,
);
});
test('MindSpace owns unambiguous cover repair before thumbnail delivery', async (t) => { test('MindSpace owns unambiguous cover repair before thumbnail delivery', async (t) => {
const publishDir = fs.mkdtempSync( const publishDir = fs.mkdtempSync(
path.join( path.join(
+27 -10
View File
@@ -46,6 +46,11 @@ usage() {
6. 启动 Portal,保持旧端口 8081 6. 启动 Portal,保持旧端口 8081
7. 健康检查通过后重启 m.tkmind.cn 反向隧道 (105:19081 -> 103:8081) 7. 健康检查通过后重启 m.tkmind.cn 反向隧道 (105:19081 -> 103:8081)
8. 旧源码目录移入 archive,不再保留可运行 live 源码 8. 旧源码目录移入 archive,不再保留可运行 live 源码
环境变量:
ALLOW_DIRECT_STABLE_RELEASE=1
仅在用户明确批准按 7/23 前规则直接整包时使用:允许 --skip-tests
并跳过 canary 晋升证据与 Core+Impact Gate report。
EOF EOF
} }
@@ -68,9 +73,17 @@ while [[ $# -gt 0 ]]; do
shift shift
done done
say() {
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
if [[ "${SKIP_TESTS}" -eq 1 ]]; then if [[ "${SKIP_TESTS}" -eq 1 ]]; then
echo "生产发布守门员禁止 --skip-tests。" >&2 if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then
exit 1 say "允许 --skip-testsALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则;测试须已在发布前手动跑过)"
else
echo "生产发布守门员禁止 --skip-tests。" >&2
exit 1
fi
fi fi
if [[ "${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}" == "1" ]]; then if [[ "${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}" == "1" ]]; then
@@ -84,14 +97,14 @@ if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" == "1" ]]; then
fi fi
if [[ "${DRY_RUN}" -eq 0 ]]; then if [[ "${DRY_RUN}" -eq 0 ]]; then
say "验证 103 灰度晋升证据" if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then
node "${ROOT}/scripts/verify-canary-promotion-evidence.mjs" --host "${HOST}" say "跳过灰度晋升证据(ALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则)"
else
say "验证 103 灰度晋升证据"
node "${ROOT}/scripts/verify-canary-promotion-evidence.mjs" --host "${HOST}"
fi
fi fi
say() {
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
need_cmd() { need_cmd() {
command -v "$1" >/dev/null 2>&1 || { command -v "$1" >/dev/null 2>&1 || {
echo "缺少命令: $1" >&2 echo "缺少命令: $1" >&2
@@ -336,7 +349,9 @@ REMOTE
} }
say "验证与当前 main 和 runtime artifact 绑定的 Gate report" say "验证与当前 main 和 runtime artifact 绑定的 Gate report"
if ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" >/dev/null 2>&1; then if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then
say "跳过 Core+Impact Gate reportALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则)"
elif ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" >/dev/null 2>&1; then
say "执行核心场景 + 变更影响域 Gate" say "执行核心场景 + 变更影响域 Gate"
DEPLOYED_SHA="${MEMIND_RELEASE_BASE_COMMIT:-}" DEPLOYED_SHA="${MEMIND_RELEASE_BASE_COMMIT:-}"
if [[ -z "${DEPLOYED_SHA}" && "${DRY_RUN}" -ne 1 ]]; then if [[ -z "${DEPLOYED_SHA}" && "${DRY_RUN}" -ne 1 ]]; then
@@ -351,8 +366,10 @@ if ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME
IMPACT_ARGS+=(--deployed-commit "${DEPLOYED_SHA}") IMPACT_ARGS+=(--deployed-commit "${DEPLOYED_SHA}")
fi fi
node "${ROOT}/scripts/run-release-gate-impact.mjs" "${IMPACT_ARGS[@]}" node "${ROOT}/scripts/run-release-gate-impact.mjs" "${IMPACT_ARGS[@]}"
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
else
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
fi fi
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
if [[ "${DRY_RUN}" -ne 1 ]]; then if [[ "${DRY_RUN}" -ne 1 ]]; then
say "执行 103 只读预检" say "执行 103 只读预检"
+66 -4
View File
@@ -1866,7 +1866,9 @@ export function createWechatMpService({
imagePolicy, imagePolicy,
notifyFailure = true, notifyFailure = true,
}) => { }) => {
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return; if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) {
return { ok: true, reason: null, matchRelativePaths: [] };
}
const images = collectWechatGeneratedImages(replyRequestMessages(reply)); const images = collectWechatGeneratedImages(replyRequestMessages(reply));
const ensureFresh = const ensureFresh =
htmlDeliveryAuthority htmlDeliveryAuthority
@@ -1913,7 +1915,15 @@ export function createWechatMpService({
); );
} }
if (verification?.ok) { if (verification?.ok) {
return; if (verification.reason === 'workspace_thumbnail_fallback') {
logger.info?.(
'WeChat MP page delivery accepted workspace thumbnail fallback:',
{
matchRelativePaths: verification.matchRelativePaths ?? [],
},
);
}
return verification;
} }
logger.warn?.( logger.warn?.(
'WeChat MP fresh thumbnail verification failed:', 'WeChat MP fresh thumbnail verification failed:',
@@ -2666,8 +2676,13 @@ export function createWechatMpService({
forcePageData: isPageDataRequest, forcePageData: isPageDataRequest,
}); });
if (wechatIntent.kind === 'page.generate') { if (wechatIntent.kind === 'page.generate') {
const deliveryArtifacts = uniqueArtifactsByUrl([
...publishArtifacts,
...confirmedArtifacts,
...verifiedArtifacts,
]);
await enforceFreshPageThumbnailDelivery({ await enforceFreshPageThumbnailDelivery({
artifacts: publishArtifacts, artifacts: deliveryArtifacts,
reply, reply,
openid: inbound.fromUserName, openid: inbound.fromUserName,
user, user,
@@ -2676,6 +2691,27 @@ export function createWechatMpService({
imagePolicy, imagePolicy,
notifyFailure: false, notifyFailure: false,
}); });
if (deliveryArtifacts.length > 0) {
publishArtifacts = deliveryArtifacts;
} else {
const recovered = await resolveHtmlPublishArtifacts({
reply,
intent,
requestStartedAt,
userId: user.userId,
sessionId,
onPageGenerated,
allowRecentArtifacts: true,
htmlDeliveryAuthority,
});
publishArtifacts = uniqueArtifactsByUrl([
...selectSendableHtmlArtifacts({
verifiedArtifacts: recovered.verifiedArtifacts,
confirmedArtifacts: recovered.confirmedArtifacts,
}),
...recovered.confirmedArtifacts,
]);
}
} }
const pageDataOutcome = await enforcePageDataCollectDelivery({ const pageDataOutcome = await enforcePageDataCollectDelivery({
reply, reply,
@@ -2939,8 +2975,13 @@ export function createWechatMpService({
forcePageData: isPageDataRequest, forcePageData: isPageDataRequest,
}); });
if (wechatIntent.kind === 'page.generate') { if (wechatIntent.kind === 'page.generate') {
const deliveryArtifacts = uniqueArtifactsByUrl([
...publishArtifacts,
...confirmedArtifacts,
...verifiedArtifacts,
]);
await enforceFreshPageThumbnailDelivery({ await enforceFreshPageThumbnailDelivery({
artifacts: publishArtifacts, artifacts: deliveryArtifacts,
reply, reply,
openid: inbound.fromUserName, openid: inbound.fromUserName,
user, user,
@@ -2948,6 +2989,27 @@ export function createWechatMpService({
sessionId, sessionId,
imagePolicy, imagePolicy,
}); });
if (deliveryArtifacts.length > 0) {
publishArtifacts = deliveryArtifacts;
} else {
const recovered = await resolveHtmlPublishArtifacts({
reply,
intent,
requestStartedAt: retryStartedAt,
userId: user.userId,
sessionId,
onPageGenerated,
allowRecentArtifacts: true,
htmlDeliveryAuthority,
});
publishArtifacts = uniqueArtifactsByUrl([
...selectSendableHtmlArtifacts({
verifiedArtifacts: recovered.verifiedArtifacts,
confirmedArtifacts: recovered.confirmedArtifacts,
}),
...recovered.confirmedArtifacts,
]);
}
} }
const pageDataOutcome = await enforcePageDataCollectDelivery({ const pageDataOutcome = await enforcePageDataCollectDelivery({
reply, reply,
+8 -61
View File
@@ -5984,42 +5984,23 @@ test('wechat mp page delivery repairs one unambiguous fresh thumbnail missing fr
assert.match(wechatPayloads[0].text.content, /fresh\.html/); assert.match(wechatPayloads[0].text.content, /fresh\.html/);
}); });
test('wechat mp retries a page in a new session before reporting a missing fresh thumbnail', async () => { test('wechat mp delivers page via workspace thumbnail when fresh image_make is missing', async () => {
const token = 'token'; const token = 'token';
const timestamp = '1710000000'; const timestamp = '1710000000';
const nonce = 'nonce'; const nonce = 'nonce';
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-retry-'); const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-fallback-');
const htmlPath = path.join(workspaceRoot, 'public', 'retry.html'); const htmlPath = path.join(workspaceRoot, 'public', 'retry.html');
const generated = {
ok: true,
jobId: 'job-fresh-page-retry',
source: { mimeType: 'image/webp', width: 1280, height: 720 },
asset: {
id: 'asset-fresh-page-retry',
htmlSrc: 'images/retry-cover.webp',
publicUrl: 'https://example.com/MindSpace/user-1/public/images/retry-cover.webp',
workspaceRelativePath: 'public/images/retry-cover.webp',
},
};
const firstHtml = previewReadyPageHtml({ const firstHtml = previewReadyPageHtml({
title: 'Retry', title: 'Retry',
subtitle: '首次没有新缩略图', subtitle: '首次没有新缩略图',
cover: 'images/missing-cover.webp', cover: 'images/missing-cover.webp',
}); });
const retryHtml = previewReadyPageHtml({
title: 'Retry',
subtitle: '重试生成新缩略图',
cover: generated.asset.htmlSrc,
});
const pageImage = await sharp({
create: { width: 64, height: 64, channels: 3, background: '#cc6633' },
}).webp().toBuffer();
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`; const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
const wechatPayloads = []; const wechatPayloads = [];
let routeCleared = false; let routeCleared = false;
let startedSessions = 0; let startedSessions = 0;
const replyEvents = ({ requestId, html, includeImage = false }) => [ const replyEvents = ({ requestId, html }) => [
eventFrame({ eventFrame({
type: 'Message', type: 'Message',
request_id: requestId, request_id: requestId,
@@ -6033,25 +6014,6 @@ test('wechat mp retries a page in a new session before reporting a missing fresh
type: 'toolRequest', type: 'toolRequest',
toolCall: { value: { name: 'load_skill', arguments: { name: 'static-page-publish' } } }, toolCall: { value: { name: 'load_skill', arguments: { name: 'static-page-publish' } } },
}, },
...(includeImage
? [
{
id: `${requestId}-image`,
type: 'toolRequest',
toolCall: {
value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } },
},
},
{
id: `${requestId}-image`,
type: 'toolResponse',
toolResult: {
status: 'success',
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
},
},
]
: []),
{ {
id: `${requestId}-write`, id: `${requestId}-write`,
type: 'toolRequest', type: 'toolRequest',
@@ -6121,24 +6083,12 @@ test('wechat mp retries a page in a new session before reporting a missing fresh
} }
if (pathname === `/sessions/${sessionId}/reply`) { if (pathname === `/sessions/${sessionId}/reply`) {
fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
if (sessionId === 'session-1') { fs.writeFileSync(htmlPath, firstHtml, 'utf8');
fs.writeFileSync(htmlPath, firstHtml, 'utf8');
} else {
fs.writeFileSync(htmlPath, retryHtml, 'utf8');
fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true });
fs.writeFileSync(path.join(workspaceRoot, 'public', generated.asset.htmlSrc), pageImage);
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
} }
if (pathname === '/sessions/session-1/events') { if (pathname === '/sessions/session-1/events') {
return new Response( return new Response(
replyEvents({ requestId: 'req-thumbnail-first', html: firstHtml }), replyEvents({ requestId: 'req-thumbnail-fallback', html: firstHtml }),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/sessions/session-2/events') {
return new Response(
replyEvents({ requestId: 'req-thumbnail-retry', html: retryHtml, includeImage: true }),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } }, { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
); );
} }
@@ -6163,10 +6113,7 @@ test('wechat mp retries a page in a new session before reporting a missing fresh
}); });
const originalRandomUuid = crypto.randomUUID; const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = (() => { crypto.randomUUID = () => 'req-thumbnail-fallback';
const ids = ['req-thumbnail-first', 'req-thumbnail-retry'];
return () => ids.shift() ?? 'req-thumbnail-retry';
})();
try { try {
const result = await service.handleInboundMessage( const result = await service.handleInboundMessage(
inboundXml({ content: '生成一个活动页面,只要文字,不要正文图片' }), inboundXml({ content: '生成一个活动页面,只要文字,不要正文图片' }),
@@ -6180,8 +6127,8 @@ test('wechat mp retries a page in a new session before reporting a missing fresh
fs.rmSync(workspaceRoot, { recursive: true, force: true }); fs.rmSync(workspaceRoot, { recursive: true, force: true });
} }
assert.equal(routeCleared, true); assert.equal(routeCleared, false);
assert.equal(startedSessions, 1); assert.equal(startedSessions, 0);
assert.equal(wechatPayloads.length, 1); assert.equal(wechatPayloads.length, 1);
assert.equal(wechatPayloads[0].msgtype, 'text'); assert.equal(wechatPayloads[0].msgtype, 'text');
assert.match(wechatPayloads[0].text.content, /retry\.html/); assert.match(wechatPayloads[0].text.content, /retry\.html/);