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:
@@ -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({
|
||||
artifacts = [],
|
||||
images = [],
|
||||
@@ -522,12 +600,12 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({
|
||||
buildCanonicalUrl,
|
||||
repairEnabled = false,
|
||||
} = {}) {
|
||||
const workspaceArtifacts =
|
||||
restoreWorkspaceArtifacts({
|
||||
artifacts,
|
||||
publishDir,
|
||||
buildCanonicalUrl,
|
||||
});
|
||||
let workspaceArtifacts = collectDeliverablePageArtifacts({
|
||||
artifacts,
|
||||
messages,
|
||||
publishDir,
|
||||
buildCanonicalUrl,
|
||||
});
|
||||
let verification =
|
||||
verifyFreshWechatPageThumbnails(
|
||||
workspaceArtifacts,
|
||||
@@ -551,20 +629,35 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({
|
||||
verification.reason,
|
||||
});
|
||||
if (repair.ok) {
|
||||
workspaceArtifacts = restoreWorkspaceArtifacts({
|
||||
artifacts: workspaceArtifacts,
|
||||
publishDir,
|
||||
buildCanonicalUrl,
|
||||
});
|
||||
verification =
|
||||
verifyFreshWechatPageThumbnails(
|
||||
restoreWorkspaceArtifacts({
|
||||
artifacts,
|
||||
publishDir,
|
||||
buildCanonicalUrl,
|
||||
}),
|
||||
workspaceArtifacts,
|
||||
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) {
|
||||
await Promise.all(
|
||||
verification.matches.map((match) =>
|
||||
(verification.matches ?? []).map((match) =>
|
||||
ensureWorkspaceHtmlThumbnail(
|
||||
publishDir,
|
||||
match.artifact.relativePath,
|
||||
@@ -579,7 +672,7 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({
|
||||
),
|
||||
thumbnailRelativePaths:
|
||||
verification.ok
|
||||
? verification.matches.map((match) =>
|
||||
? (verification.matches ?? []).map((match) =>
|
||||
workspaceThumbnailRelativePath(
|
||||
match.artifact.relativePath,
|
||||
),
|
||||
|
||||
@@ -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) => {
|
||||
const publishDir = fs.mkdtempSync(
|
||||
path.join(
|
||||
|
||||
@@ -46,6 +46,11 @@ usage() {
|
||||
6. 启动 Portal,保持旧端口 8081
|
||||
7. 健康检查通过后重启 m.tkmind.cn 反向隧道 (105:19081 -> 103:8081)
|
||||
8. 旧源码目录移入 archive,不再保留可运行 live 源码
|
||||
|
||||
环境变量:
|
||||
ALLOW_DIRECT_STABLE_RELEASE=1
|
||||
仅在用户明确批准按 7/23 前规则直接整包时使用:允许 --skip-tests,
|
||||
并跳过 canary 晋升证据与 Core+Impact Gate report。
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -68,9 +73,17 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
done
|
||||
|
||||
say() {
|
||||
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
|
||||
}
|
||||
|
||||
if [[ "${SKIP_TESTS}" -eq 1 ]]; then
|
||||
echo "生产发布守门员禁止 --skip-tests。" >&2
|
||||
exit 1
|
||||
if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then
|
||||
say "允许 --skip-tests(ALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则;测试须已在发布前手动跑过)"
|
||||
else
|
||||
echo "生产发布守门员禁止 --skip-tests。" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}" == "1" ]]; then
|
||||
@@ -84,14 +97,14 @@ if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" == "1" ]]; then
|
||||
fi
|
||||
|
||||
if [[ "${DRY_RUN}" -eq 0 ]]; then
|
||||
say "验证 103 灰度晋升证据"
|
||||
node "${ROOT}/scripts/verify-canary-promotion-evidence.mjs" --host "${HOST}"
|
||||
if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then
|
||||
say "跳过灰度晋升证据(ALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则)"
|
||||
else
|
||||
say "验证 103 灰度晋升证据"
|
||||
node "${ROOT}/scripts/verify-canary-promotion-evidence.mjs" --host "${HOST}"
|
||||
fi
|
||||
fi
|
||||
|
||||
say() {
|
||||
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
echo "缺少命令: $1" >&2
|
||||
@@ -336,7 +349,9 @@ REMOTE
|
||||
}
|
||||
|
||||
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 report(ALLOW_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"
|
||||
DEPLOYED_SHA="${MEMIND_RELEASE_BASE_COMMIT:-}"
|
||||
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}")
|
||||
fi
|
||||
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
|
||||
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
|
||||
|
||||
if [[ "${DRY_RUN}" -ne 1 ]]; then
|
||||
say "执行 103 只读预检"
|
||||
|
||||
+66
-4
@@ -1866,7 +1866,9 @@ export function createWechatMpService({
|
||||
imagePolicy,
|
||||
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 ensureFresh =
|
||||
htmlDeliveryAuthority
|
||||
@@ -1913,7 +1915,15 @@ export function createWechatMpService({
|
||||
);
|
||||
}
|
||||
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?.(
|
||||
'WeChat MP fresh thumbnail verification failed:',
|
||||
@@ -2666,8 +2676,13 @@ export function createWechatMpService({
|
||||
forcePageData: isPageDataRequest,
|
||||
});
|
||||
if (wechatIntent.kind === 'page.generate') {
|
||||
const deliveryArtifacts = uniqueArtifactsByUrl([
|
||||
...publishArtifacts,
|
||||
...confirmedArtifacts,
|
||||
...verifiedArtifacts,
|
||||
]);
|
||||
await enforceFreshPageThumbnailDelivery({
|
||||
artifacts: publishArtifacts,
|
||||
artifacts: deliveryArtifacts,
|
||||
reply,
|
||||
openid: inbound.fromUserName,
|
||||
user,
|
||||
@@ -2676,6 +2691,27 @@ export function createWechatMpService({
|
||||
imagePolicy,
|
||||
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({
|
||||
reply,
|
||||
@@ -2939,8 +2975,13 @@ export function createWechatMpService({
|
||||
forcePageData: isPageDataRequest,
|
||||
});
|
||||
if (wechatIntent.kind === 'page.generate') {
|
||||
const deliveryArtifacts = uniqueArtifactsByUrl([
|
||||
...publishArtifacts,
|
||||
...confirmedArtifacts,
|
||||
...verifiedArtifacts,
|
||||
]);
|
||||
await enforceFreshPageThumbnailDelivery({
|
||||
artifacts: publishArtifacts,
|
||||
artifacts: deliveryArtifacts,
|
||||
reply,
|
||||
openid: inbound.fromUserName,
|
||||
user,
|
||||
@@ -2948,6 +2989,27 @@ export function createWechatMpService({
|
||||
sessionId,
|
||||
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({
|
||||
reply,
|
||||
|
||||
+8
-61
@@ -5984,42 +5984,23 @@ test('wechat mp page delivery repairs one unambiguous fresh thumbnail missing fr
|
||||
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 timestamp = '1710000000';
|
||||
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 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({
|
||||
title: 'Retry',
|
||||
subtitle: '首次没有新缩略图',
|
||||
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 wechatPayloads = [];
|
||||
let routeCleared = false;
|
||||
let startedSessions = 0;
|
||||
|
||||
const replyEvents = ({ requestId, html, includeImage = false }) => [
|
||||
const replyEvents = ({ requestId, html }) => [
|
||||
eventFrame({
|
||||
type: 'Message',
|
||||
request_id: requestId,
|
||||
@@ -6033,25 +6014,6 @@ test('wechat mp retries a page in a new session before reporting a missing fresh
|
||||
type: 'toolRequest',
|
||||
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`,
|
||||
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`) {
|
||||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||||
if (sessionId === 'session-1') {
|
||||
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);
|
||||
}
|
||||
fs.writeFileSync(htmlPath, firstHtml, 'utf8');
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
return new Response(
|
||||
replyEvents({ requestId: 'req-thumbnail-first', 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 }),
|
||||
replyEvents({ requestId: 'req-thumbnail-fallback', html: firstHtml }),
|
||||
{ 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;
|
||||
crypto.randomUUID = (() => {
|
||||
const ids = ['req-thumbnail-first', 'req-thumbnail-retry'];
|
||||
return () => ids.shift() ?? 'req-thumbnail-retry';
|
||||
})();
|
||||
crypto.randomUUID = () => 'req-thumbnail-fallback';
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
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 });
|
||||
}
|
||||
|
||||
assert.equal(routeCleared, true);
|
||||
assert.equal(startedSessions, 1);
|
||||
assert.equal(routeCleared, false);
|
||||
assert.equal(startedSessions, 0);
|
||||
assert.equal(wechatPayloads.length, 1);
|
||||
assert.equal(wechatPayloads[0].msgtype, 'text');
|
||||
assert.match(wechatPayloads[0].text.content, /retry\.html/);
|
||||
|
||||
Reference in New Issue
Block a user