diff --git a/mindspace-wechat-html-delivery.mjs b/mindspace-wechat-html-delivery.mjs
index 4b856c8..87984a4 100644
--- a/mindspace-wechat-html-delivery.mjs
+++ b/mindspace-wechat-html-delivery.mjs
@@ -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,
),
diff --git a/mindspace-wechat-html-delivery.test.mjs b/mindspace-wechat-html-delivery.test.mjs
index 890b16d..37b1d00 100644
--- a/mindspace-wechat-html-delivery.test.mjs
+++ b/mindspace-wechat-html-delivery.test.mjs
@@ -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(
diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh
index cc7f46e..00df9ac 100755
--- a/scripts/release-portal-runtime-prod.sh
+++ b/scripts/release-portal-runtime-prod.sh
@@ -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 只读预检"
diff --git a/wechat-mp.mjs b/wechat-mp.mjs
index 4edbb04..948955c 100644
--- a/wechat-mp.mjs
+++ b/wechat-mp.mjs
@@ -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,
diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs
index af15d85..640aa91 100644
--- a/wechat-mp.test.mjs
+++ b/wechat-mp.test.mjs
@@ -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/);