diff --git a/mindspace-delivery-contract.mjs b/mindspace-delivery-contract.mjs index 2917b3b..3c95698 100644 --- a/mindspace-delivery-contract.mjs +++ b/mindspace-delivery-contract.mjs @@ -46,3 +46,34 @@ export async function markPageDeliveryContractReady({ pool, userId, relativePath ); return Number(result?.affectedRows ?? 0) > 0; } + +export async function releaseMaterializedPageDeliveryContracts({ + pool, + userId, + relativePaths = [], + allowPgRequired = false, +} = {}) { + if (!pool || !userId) return []; + const released = []; + for (const rawPath of relativePaths) { + const workspaceRelativePath = normalizeDeliveryRelativePath(rawPath); + if (!workspaceRelativePath) continue; + const contract = await getPageDeliveryContract({ + pool, + userId, + relativePath: workspaceRelativePath, + }); + if (!contract || contract.status === 'ready') continue; + if (contract.data_mode === 'pg_required' && !allowPgRequired) continue; + if ( + await markPageDeliveryContractReady({ + pool, + userId, + relativePath: workspaceRelativePath, + }) + ) { + released.push(workspaceRelativePath); + } + } + return released; +} diff --git a/mindspace-delivery-contract.test.mjs b/mindspace-delivery-contract.test.mjs index 6c8bf39..aa6da9f 100644 --- a/mindspace-delivery-contract.test.mjs +++ b/mindspace-delivery-contract.test.mjs @@ -5,6 +5,7 @@ import { markPageDeliveryContractReady, normalizeDeliveryRelativePath, preparePageDeliveryContract, + releaseMaterializedPageDeliveryContracts, } from './mindspace-delivery-contract.mjs'; test('normalizes only safe public HTML delivery paths', () => { @@ -40,3 +41,43 @@ test('contract lifecycle writes preparing then ready against the same route key' assert.equal(await markPageDeliveryContractReady({ pool, userId: 'user-1', relativePath: 'public/form.html' }), true); assert.ok(calls.some((call) => call.sql.includes("status = 'ready'"))); }); + +test('releaseMaterializedPageDeliveryContracts skips pg_required until allowed', async () => { + const calls = []; + const pool = { + async query(sql, params) { + calls.push({ sql, params }); + if (sql.includes('SELECT id, data_mode')) { + const path = params?.[1]; + if (path === 'public/form.html') { + return [[{ id: 'c1', data_mode: 'pg_required', status: 'preparing' }]]; + } + if (path === 'public/report.html') { + return [[{ id: 'c2', data_mode: 'static', status: 'preparing' }]]; + } + return [[]]; + } + if (sql.includes("status = 'ready'")) return [{ affectedRows: 1 }]; + return [{ affectedRows: 0 }]; + }, + }; + + assert.deepEqual( + await releaseMaterializedPageDeliveryContracts({ + pool, + userId: 'user-1', + relativePaths: ['public/form.html', 'public/report.html'], + allowPgRequired: false, + }), + ['public/report.html'], + ); + assert.deepEqual( + await releaseMaterializedPageDeliveryContracts({ + pool, + userId: 'user-1', + relativePaths: ['public/form.html'], + allowPgRequired: true, + }), + ['public/form.html'], + ); +}); diff --git a/scheduled-task-executor.mjs b/scheduled-task-executor.mjs index b35b932..c860142 100644 --- a/scheduled-task-executor.mjs +++ b/scheduled-task-executor.mjs @@ -1,7 +1,7 @@ import crypto from 'node:crypto'; import path from 'node:path'; import { buildChatSkillPrompt, SCHEDULED_TASK_AUTOMATION_SKILL_NAME } from './chat-skills.mjs'; -import { markPageDeliveryContractReady } from './mindspace-delivery-contract.mjs'; +import { releaseMaterializedPageDeliveryContracts } from './mindspace-delivery-contract.mjs'; import { collectOwnPublicHtmlRelativePaths, materializeMissingPublicHtmlWrites, @@ -126,29 +126,29 @@ export async function finalizeScheduledTaskPageDelivery({ const [rows] = await pool.query( `SELECT workspace_relative_path FROM h5_page_delivery_contracts - WHERE user_id = ? AND request_id = ? AND status = 'preparing'`, - [userId, sessionId], + WHERE user_id = ? AND status = 'preparing'`, + [userId], ); for (const row of rows ?? []) { if (row?.workspace_relative_path) relativePaths.add(row.workspace_relative_path); } } - const readyPaths = []; - for (const relativePath of relativePaths) { - const ready = await markPageDeliveryContractReady({ - pool, + const readyPaths = await releaseMaterializedPageDeliveryContracts({ + pool, + userId, + relativePaths: [...relativePaths], + allowPgRequired: true, + }).catch((error) => { + logger.warn?.('[ScheduledTask] release delivery contracts failed:', error); + return []; + }); + for (const relativePath of readyPaths) { + logger.info?.('[ScheduledTask] delivery contract ready', { userId, + sessionId, relativePath, - }).catch(() => false); - if (ready) readyPaths.push(relativePath); - else { - logger.warn?.('[ScheduledTask] delivery contract not ready', { - userId, - sessionId, - relativePath, - }); - } + }); } return readyPaths; } diff --git a/server/portal-session-routes.mjs b/server/portal-session-routes.mjs index bb7f09a..83d6c2e 100644 --- a/server/portal-session-routes.mjs +++ b/server/portal-session-routes.mjs @@ -13,6 +13,7 @@ import { import { markPageDeliveryContractReady, preparePageDeliveryContract, + releaseMaterializedPageDeliveryContracts, } from '../mindspace-delivery-contract.mjs'; import { maybeRepairH5HtmlAfterFinish } from '../mindspace-h5-html-finish-guard.mjs'; import { maybeRepairPageDataAfterFinish } from '../mindspace-page-data-finish-guard.mjs'; @@ -79,6 +80,8 @@ export function attachPortalSessionRoutes( maybeRepairPageDataAfterFinish, markPageDeliveryContractReadyFn = markPageDeliveryContractReady, + releaseMaterializedPageDeliveryContractsFn = + releaseMaterializedPageDeliveryContracts, finishDeliveryRetryDelaysMs = [250, 1_000], finishDeliveryRetryWaitFn = (delayMs) => new Promise((resolve) => @@ -477,6 +480,8 @@ export function attachPortalSessionRoutes( // workspace from DB-backed assets only. const finalizeAfterFinishOnce = async (sid, uid) => { beginSessionPageDelivery(sid); + let releaseCandidatePaths = []; + let allowPgRequiredRelease = false; try { const apiFetchFn = async (pathname, init) => { const target = await tkmindProxy.resolveTarget(sid); @@ -616,6 +621,8 @@ export function attachPortalSessionRoutes( ...deliveryContractWrites.keys(), ]), ].sort(); + releaseCandidatePaths = publicHtmlRelativePaths; + allowPgRequiredRelease = htmlReady && pageDataReady; const pgRequired = [ ...(Array.isArray(messages) ? messages : []), ].some( @@ -660,11 +667,6 @@ export function attachPortalSessionRoutes( pgRequired, }); } - await markPageDeliveryContractReadyFn({ - pool: authPool, - userId: uid, - relativePath, - }).catch(() => false); } } const memoryV2 = getMemoryV2(); @@ -685,6 +687,20 @@ export function attachPortalSessionRoutes( }); } } finally { + if (releaseCandidatePaths.length > 0) { + await releaseMaterializedPageDeliveryContractsFn({ + pool: authPool, + userId: uid, + relativePaths: releaseCandidatePaths, + allowPgRequired: allowPgRequiredRelease, + }).catch((error) => { + logger.warn( + `[MindSpace] delivery contract release failed for session ${sid}: ${ + error instanceof Error ? error.message : error + }`, + ); + }); + } endSessionPageDelivery(sid); } }; diff --git a/server/portal-session-routes.test.mjs b/server/portal-session-routes.test.mjs index a88fa5d..5745623 100644 --- a/server/portal-session-routes.test.mjs +++ b/server/portal-session-routes.test.mjs @@ -112,7 +112,7 @@ function createDependencies(overrides = {}) { return { sessionId, hooks }; }, }; - return { + const setup = { calls, proxy, dependencies: { @@ -203,6 +203,30 @@ function createDependencies(overrides = {}) { ...overrides, }, }; + if ( + !Object.prototype.hasOwnProperty.call( + overrides, + 'releaseMaterializedPageDeliveryContractsFn', + ) + ) { + setup.dependencies.releaseMaterializedPageDeliveryContractsFn = + async (input) => { + const released = []; + for (const relativePath of input.relativePaths ?? []) { + if ( + await setup.dependencies.markPageDeliveryContractReadyFn({ + pool: input.pool, + userId: input.userId, + relativePath, + }) + ) { + released.push(relativePath); + } + } + return released; + }; + } + return setup; } test('session module preserves route inventory and order', () => { @@ -709,8 +733,8 @@ test('Finish hook preserves refresh, sync, delivery readiness, memory, and lock 'prepare-page-data', 'repair-page-data', 'prepare-contract', - 'ready', 'memory', + 'ready', ], ); assert.equal( @@ -957,5 +981,73 @@ test('Finish retries when a delivery guard is initially not ready', async () => assert.equal(pageDataChecks, 2); assert.deepEqual(setup.calls.begin, ['session-1', 'session-1']); assert.deepEqual(setup.calls.end, ['session-1', 'session-1']); - assert.deepEqual(readyPaths, ['public/survey.html']); + assert.deepEqual(readyPaths, ['public/survey.html', 'public/survey.html']); +}); + +test('Finish finally releases static HTML contracts when delivery guards fail', async () => { + let hooks = null; + const releasedPaths = []; + const setup = createDependencies({ + finishDeliveryRetryDelaysMs: [], + getAuthPool: () => ({ id: 'pool' }), + getTkmindProxy: () => ({ + async resolveTarget(sessionId) { + return `target:${sessionId}`; + }, + async apiFetchTo() { + return createUpstream({ + body: { + id: 'session-1', + conversation: [ + { + id: 'user-1', + role: 'user', + content: 'update page', + metadata: { userVisible: true }, + }, + ], + }, + }); + }, + proxySessionEvents(_req, _res, _sessionId, receivedHooks) { + hooks = receivedHooks; + }, + }), + getMindSpacePublicFinish: () => ({ + async syncAfterFinish() { + return { + publicHtmlRelativePaths: ['public/daily-news-0813.html'], + docxSync: { missing: [] }, + }; + }, + async preparePageDataAfterFinish() { + return { + autoBind: { bound: [], skipped: [], errors: [] }, + evaluation: { structuralPageData: false, relevantFiles: [] }, + }; + }, + }), + async maybeRepairH5HtmlAfterFinishFn() { + return { skipped: 'limit' }; + }, + async releaseMaterializedPageDeliveryContractsFn(input) { + releasedPaths.push(...(input.relativePaths ?? [])); + return input.relativePaths ?? []; + }, + }); + const api = createRouterRecorder(); + attachPortalSessionRoutes(api, setup.dependencies); + await api.routes.get('GET /sessions/:sessionId/events')( + createRequest(), + createResponseRecorder(), + () => {}, + ); + + await assert.rejects( + () => hooks.onAfterFinish('session-1', 'user-1'), + /page delivery guards are not ready/, + ); + + assert.deepEqual(releasedPaths, ['public/daily-news-0813.html']); + assert.deepEqual(setup.calls.end, ['session-1']); }); diff --git a/src/components/MindSpaceAchievementsPanel.tsx b/src/components/MindSpaceAchievementsPanel.tsx index 4885ee5..a5bbf73 100644 --- a/src/components/MindSpaceAchievementsPanel.tsx +++ b/src/components/MindSpaceAchievementsPanel.tsx @@ -23,8 +23,24 @@ function formatDateTime(timestamp: number) { }); } +const ACHIEVEMENTS_DATE_TIMEZONE = 'Asia/Shanghai'; + +function localCreatedDateKey(timestamp: number) { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: ACHIEVEMENTS_DATE_TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date(timestamp)); + const year = parts.find((part) => part.type === 'year')?.value ?? '0000'; + const month = parts.find((part) => part.type === 'month')?.value ?? '01'; + const day = parts.find((part) => part.type === 'day')?.value ?? '01'; + return `${year}-${month}-${day}`; +} + function formatDateHeading(timestamp: number) { return new Date(timestamp).toLocaleDateString('zh-CN', { + timeZone: ACHIEVEMENTS_DATE_TIMEZONE, year: 'numeric', month: 'long', day: 'numeric', @@ -45,7 +61,7 @@ function formatClickMetric(page: MindSpacePage) { function groupPagesByCreatedDate(pages: MindSpacePage[]) { const groups = new Map(); for (const page of pages) { - const key = new Date(page.createdAt).toISOString().slice(0, 10); + const key = localCreatedDateKey(page.createdAt); const bucket = groups.get(key); if (bucket) bucket.push(page); else groups.set(key, [page]); @@ -54,7 +70,7 @@ function groupPagesByCreatedDate(pages: MindSpacePage[]) { .sort(([left], [right]) => right.localeCompare(left)) .map(([dateKey, items]) => ({ dateKey, - heading: formatDateHeading(items[0]?.createdAt ?? Date.parse(`${dateKey}T00:00:00`)), + heading: formatDateHeading(items[0]?.createdAt ?? Date.parse(`${dateKey}T12:00:00+08:00`)), items, })); }