fix(mindspace): release edited public pages and fix achievements dates
Memind CI / Test, build, and release guards (push) Successful in 3m37s
Memind CI / Test, build, and release guards (push) Successful in 3m37s
Finish now releases static HTML delivery contracts in a finally block so re-edited pages are not stuck at HTTP 409, and M成果 groups pages by Asia/Shanghai calendar dates to avoid duplicate day headings. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,3 +46,34 @@ export async function markPageDeliveryContractReady({ pool, userId, relativePath
|
|||||||
);
|
);
|
||||||
return Number(result?.affectedRows ?? 0) > 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
markPageDeliveryContractReady,
|
markPageDeliveryContractReady,
|
||||||
normalizeDeliveryRelativePath,
|
normalizeDeliveryRelativePath,
|
||||||
preparePageDeliveryContract,
|
preparePageDeliveryContract,
|
||||||
|
releaseMaterializedPageDeliveryContracts,
|
||||||
} from './mindspace-delivery-contract.mjs';
|
} from './mindspace-delivery-contract.mjs';
|
||||||
|
|
||||||
test('normalizes only safe public HTML delivery paths', () => {
|
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.equal(await markPageDeliveryContractReady({ pool, userId: 'user-1', relativePath: 'public/form.html' }), true);
|
||||||
assert.ok(calls.some((call) => call.sql.includes("status = 'ready'")));
|
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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
+16
-16
@@ -1,7 +1,7 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { buildChatSkillPrompt, SCHEDULED_TASK_AUTOMATION_SKILL_NAME } from './chat-skills.mjs';
|
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 {
|
import {
|
||||||
collectOwnPublicHtmlRelativePaths,
|
collectOwnPublicHtmlRelativePaths,
|
||||||
materializeMissingPublicHtmlWrites,
|
materializeMissingPublicHtmlWrites,
|
||||||
@@ -126,29 +126,29 @@ export async function finalizeScheduledTaskPageDelivery({
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT workspace_relative_path
|
`SELECT workspace_relative_path
|
||||||
FROM h5_page_delivery_contracts
|
FROM h5_page_delivery_contracts
|
||||||
WHERE user_id = ? AND request_id = ? AND status = 'preparing'`,
|
WHERE user_id = ? AND status = 'preparing'`,
|
||||||
[userId, sessionId],
|
[userId],
|
||||||
);
|
);
|
||||||
for (const row of rows ?? []) {
|
for (const row of rows ?? []) {
|
||||||
if (row?.workspace_relative_path) relativePaths.add(row.workspace_relative_path);
|
if (row?.workspace_relative_path) relativePaths.add(row.workspace_relative_path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const readyPaths = [];
|
const readyPaths = await releaseMaterializedPageDeliveryContracts({
|
||||||
for (const relativePath of relativePaths) {
|
pool,
|
||||||
const ready = await markPageDeliveryContractReady({
|
userId,
|
||||||
pool,
|
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,
|
userId,
|
||||||
|
sessionId,
|
||||||
relativePath,
|
relativePath,
|
||||||
}).catch(() => false);
|
});
|
||||||
if (ready) readyPaths.push(relativePath);
|
|
||||||
else {
|
|
||||||
logger.warn?.('[ScheduledTask] delivery contract not ready', {
|
|
||||||
userId,
|
|
||||||
sessionId,
|
|
||||||
relativePath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return readyPaths;
|
return readyPaths;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
markPageDeliveryContractReady,
|
markPageDeliveryContractReady,
|
||||||
preparePageDeliveryContract,
|
preparePageDeliveryContract,
|
||||||
|
releaseMaterializedPageDeliveryContracts,
|
||||||
} from '../mindspace-delivery-contract.mjs';
|
} from '../mindspace-delivery-contract.mjs';
|
||||||
import { maybeRepairH5HtmlAfterFinish } from '../mindspace-h5-html-finish-guard.mjs';
|
import { maybeRepairH5HtmlAfterFinish } from '../mindspace-h5-html-finish-guard.mjs';
|
||||||
import { maybeRepairPageDataAfterFinish } from '../mindspace-page-data-finish-guard.mjs';
|
import { maybeRepairPageDataAfterFinish } from '../mindspace-page-data-finish-guard.mjs';
|
||||||
@@ -79,6 +80,8 @@ export function attachPortalSessionRoutes(
|
|||||||
maybeRepairPageDataAfterFinish,
|
maybeRepairPageDataAfterFinish,
|
||||||
markPageDeliveryContractReadyFn =
|
markPageDeliveryContractReadyFn =
|
||||||
markPageDeliveryContractReady,
|
markPageDeliveryContractReady,
|
||||||
|
releaseMaterializedPageDeliveryContractsFn =
|
||||||
|
releaseMaterializedPageDeliveryContracts,
|
||||||
finishDeliveryRetryDelaysMs = [250, 1_000],
|
finishDeliveryRetryDelaysMs = [250, 1_000],
|
||||||
finishDeliveryRetryWaitFn = (delayMs) =>
|
finishDeliveryRetryWaitFn = (delayMs) =>
|
||||||
new Promise((resolve) =>
|
new Promise((resolve) =>
|
||||||
@@ -477,6 +480,8 @@ export function attachPortalSessionRoutes(
|
|||||||
// workspace from DB-backed assets only.
|
// workspace from DB-backed assets only.
|
||||||
const finalizeAfterFinishOnce = async (sid, uid) => {
|
const finalizeAfterFinishOnce = async (sid, uid) => {
|
||||||
beginSessionPageDelivery(sid);
|
beginSessionPageDelivery(sid);
|
||||||
|
let releaseCandidatePaths = [];
|
||||||
|
let allowPgRequiredRelease = false;
|
||||||
try {
|
try {
|
||||||
const apiFetchFn = async (pathname, init) => {
|
const apiFetchFn = async (pathname, init) => {
|
||||||
const target = await tkmindProxy.resolveTarget(sid);
|
const target = await tkmindProxy.resolveTarget(sid);
|
||||||
@@ -616,6 +621,8 @@ export function attachPortalSessionRoutes(
|
|||||||
...deliveryContractWrites.keys(),
|
...deliveryContractWrites.keys(),
|
||||||
]),
|
]),
|
||||||
].sort();
|
].sort();
|
||||||
|
releaseCandidatePaths = publicHtmlRelativePaths;
|
||||||
|
allowPgRequiredRelease = htmlReady && pageDataReady;
|
||||||
const pgRequired = [
|
const pgRequired = [
|
||||||
...(Array.isArray(messages) ? messages : []),
|
...(Array.isArray(messages) ? messages : []),
|
||||||
].some(
|
].some(
|
||||||
@@ -660,11 +667,6 @@ export function attachPortalSessionRoutes(
|
|||||||
pgRequired,
|
pgRequired,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await markPageDeliveryContractReadyFn({
|
|
||||||
pool: authPool,
|
|
||||||
userId: uid,
|
|
||||||
relativePath,
|
|
||||||
}).catch(() => false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const memoryV2 = getMemoryV2();
|
const memoryV2 = getMemoryV2();
|
||||||
@@ -685,6 +687,20 @@ export function attachPortalSessionRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} 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);
|
endSessionPageDelivery(sid);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ function createDependencies(overrides = {}) {
|
|||||||
return { sessionId, hooks };
|
return { sessionId, hooks };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return {
|
const setup = {
|
||||||
calls,
|
calls,
|
||||||
proxy,
|
proxy,
|
||||||
dependencies: {
|
dependencies: {
|
||||||
@@ -203,6 +203,30 @@ function createDependencies(overrides = {}) {
|
|||||||
...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', () => {
|
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',
|
'prepare-page-data',
|
||||||
'repair-page-data',
|
'repair-page-data',
|
||||||
'prepare-contract',
|
'prepare-contract',
|
||||||
'ready',
|
|
||||||
'memory',
|
'memory',
|
||||||
|
'ready',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -957,5 +981,73 @@ test('Finish retries when a delivery guard is initially not ready', async () =>
|
|||||||
assert.equal(pageDataChecks, 2);
|
assert.equal(pageDataChecks, 2);
|
||||||
assert.deepEqual(setup.calls.begin, ['session-1', 'session-1']);
|
assert.deepEqual(setup.calls.begin, ['session-1', 'session-1']);
|
||||||
assert.deepEqual(setup.calls.end, ['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']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
function formatDateHeading(timestamp: number) {
|
||||||
return new Date(timestamp).toLocaleDateString('zh-CN', {
|
return new Date(timestamp).toLocaleDateString('zh-CN', {
|
||||||
|
timeZone: ACHIEVEMENTS_DATE_TIMEZONE,
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -45,7 +61,7 @@ function formatClickMetric(page: MindSpacePage) {
|
|||||||
function groupPagesByCreatedDate(pages: MindSpacePage[]) {
|
function groupPagesByCreatedDate(pages: MindSpacePage[]) {
|
||||||
const groups = new Map<string, MindSpacePage[]>();
|
const groups = new Map<string, MindSpacePage[]>();
|
||||||
for (const page of pages) {
|
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);
|
const bucket = groups.get(key);
|
||||||
if (bucket) bucket.push(page);
|
if (bucket) bucket.push(page);
|
||||||
else groups.set(key, [page]);
|
else groups.set(key, [page]);
|
||||||
@@ -54,7 +70,7 @@ function groupPagesByCreatedDate(pages: MindSpacePage[]) {
|
|||||||
.sort(([left], [right]) => right.localeCompare(left))
|
.sort(([left], [right]) => right.localeCompare(left))
|
||||||
.map(([dateKey, items]) => ({
|
.map(([dateKey, items]) => ({
|
||||||
dateKey,
|
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,
|
items,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user