Files
memind/schedule-time.mjs
T
john 81e63c16d3
Memind CI / Test, build, and release guards (push) Failing after 18s
Add scheduled task automation skill with worker execution pipeline.
Introduce scheduled-task-automation for H5 and WeChat, persist tasks in h5_scheduled_tasks, and run due jobs via a dedicated worker that executes agent tasks and delivers results.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 08:02:49 +08:00

264 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const DEFAULT_TIMEZONE = 'Asia/Shanghai';
function pad2(value) {
return String(value).padStart(2, '0');
}
export function normalizeTimezone(timezone) {
return String(timezone || process.env.H5_DEFAULT_TIMEZONE || DEFAULT_TIMEZONE).trim() || DEFAULT_TIMEZONE;
}
export function getLocalParts(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: normalizeTimezone(timezone),
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = Object.fromEntries(
formatter.formatToParts(new Date(epochMs)).map((part) => [part.type, part.value]),
);
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
hour: Number(parts.hour === '24' ? 0 : parts.hour),
minute: Number(parts.minute),
second: Number(parts.second),
};
}
export function localDateKey(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
const parts = getLocalParts(epochMs, timezone);
return `${parts.year}-${pad2(parts.month)}-${pad2(parts.day)}`;
}
export function localDateLabel(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
const parts = getLocalParts(epochMs, timezone);
return `${parts.month}${parts.day}`;
}
export function startOfLocalDay(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
const parts = getLocalParts(epochMs, timezone);
return zonedTimeToEpochMs(
{
year: parts.year,
month: parts.month,
day: parts.day,
hour: 0,
minute: 0,
second: 0,
},
timezone,
);
}
export function addLocalDays(epochMs, days, timezone = DEFAULT_TIMEZONE) {
const parts = getLocalParts(epochMs, timezone);
const utc = Date.UTC(parts.year, parts.month - 1, parts.day + Number(days || 0), parts.hour, parts.minute, parts.second);
const shifted = new Date(utc);
return zonedTimeToEpochMs(
{
year: shifted.getUTCFullYear(),
month: shifted.getUTCMonth() + 1,
day: shifted.getUTCDate(),
hour: shifted.getUTCHours(),
minute: shifted.getUTCMinutes(),
second: shifted.getUTCSeconds(),
},
timezone,
);
}
export function zonedTimeToEpochMs(parts, timezone = DEFAULT_TIMEZONE) {
const tz = normalizeTimezone(timezone);
let guess = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour ?? 0),
Number(parts.minute ?? 0),
Number(parts.second ?? 0),
0,
);
for (let i = 0; i < 3; i += 1) {
const actual = getLocalParts(guess, tz);
const actualAsUtc = Date.UTC(
actual.year,
actual.month - 1,
actual.day,
actual.hour,
actual.minute,
actual.second,
0,
);
const desiredAsUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour ?? 0),
Number(parts.minute ?? 0),
Number(parts.second ?? 0),
0,
);
const delta = actualAsUtc - desiredAsUtc;
if (delta === 0) break;
guess -= delta;
}
return guess;
}
export function nextWeeklyRunAt({
weekday,
hour,
minute = 0,
timezone = DEFAULT_TIMEZONE,
now = Date.now(),
} = {}) {
const tz = normalizeTimezone(timezone);
const safeWeekday = Number(weekday);
if (!Number.isInteger(safeWeekday) || safeWeekday < 0 || safeWeekday > 6) {
throw new Error('weekly 任务需要 weekday0=周日 … 6=周六)');
}
const todayStart = startOfLocalDay(now, tz);
const todayParts = getLocalParts(todayStart, tz);
const todayWeekday = new Date(
Date.UTC(todayParts.year, todayParts.month - 1, todayParts.day),
).getUTCDay();
const dayOffset = (safeWeekday - todayWeekday + 7) % 7;
let candidateDayStart = addLocalDays(todayStart, dayOffset, tz);
let parts = getLocalParts(candidateDayStart, tz);
let runAt = zonedTimeToEpochMs(
{
year: parts.year,
month: parts.month,
day: parts.day,
hour: Number(hour),
minute: Number(minute),
second: 0,
},
tz,
);
if (runAt <= now) {
candidateDayStart = addLocalDays(candidateDayStart, 7, tz);
parts = getLocalParts(candidateDayStart, tz);
runAt = zonedTimeToEpochMs(
{
year: parts.year,
month: parts.month,
day: parts.day,
hour: Number(hour),
minute: Number(minute),
second: 0,
},
tz,
);
}
return runAt;
}
export function nextDailyRunAt({ hour, minute = 0, timezone = DEFAULT_TIMEZONE, now = Date.now() }) {
const tz = normalizeTimezone(timezone);
const todayStart = startOfLocalDay(now, tz);
const todayParts = getLocalParts(todayStart, tz);
let runAt = zonedTimeToEpochMs(
{
year: todayParts.year,
month: todayParts.month,
day: todayParts.day,
hour: Number(hour),
minute: Number(minute),
second: 0,
},
tz,
);
if (runAt <= now) {
const tomorrowStart = addLocalDays(todayStart, 1, tz);
const tomorrowParts = getLocalParts(tomorrowStart, tz);
runAt = zonedTimeToEpochMs(
{
year: tomorrowParts.year,
month: tomorrowParts.month,
day: tomorrowParts.day,
hour: Number(hour),
minute: Number(minute),
second: 0,
},
tz,
);
}
return runAt;
}
export function formatLocalTime(epochMs, timezone = DEFAULT_TIMEZONE) {
const parts = getLocalParts(epochMs, timezone);
return `${pad2(parts.hour)}:${pad2(parts.minute)}`;
}
/**
* Parse a wall-clock datetime in the given timezone, e.g. "2026-07-01 06:00".
*/
export function parseLocalDateTimeString(value, timezone = DEFAULT_TIMEZONE) {
const raw = String(value ?? '').trim();
if (!raw) return null;
const match = raw.match(
/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/,
);
if (!match) {
throw new Error(`无法解析本地时间「${raw}」,请使用 YYYY-MM-DD HH:mm`);
}
return zonedTimeToEpochMs(
{
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4] ?? 0),
minute: Number(match[5] ?? 0),
second: Number(match[6] ?? 0),
},
timezone,
);
}
export function resolveScheduleTimestamp({
epochMs = null,
localString = null,
timezone = DEFAULT_TIMEZONE,
fieldName = '时间',
now = Date.now(),
} = {}) {
const local = String(localString ?? '').trim();
if (local) return parseLocalDateTimeString(local, timezone);
if (epochMs == null || epochMs === '') return null;
return assertReasonableScheduleEpoch(epochMs, { now, fieldName });
}
export function assertReasonableScheduleEpoch(
epochMs,
{ now = Date.now(), fieldName = '时间', maxPastDays = 2, maxFutureDays = 400 } = {},
) {
const safe = Number(epochMs);
if (!Number.isFinite(safe) || safe <= 0) {
throw new Error(`${fieldName}无效,请改用 YYYY-MM-DD HH:mm 的 local 字段`);
}
const min = now - maxPastDays * 86400000;
const max = now + maxFutureDays * 86400000;
if (safe < min || safe > max) {
const label = new Intl.DateTimeFormat('zh-CN', {
timeZone: normalizeTimezone(process.env.H5_DEFAULT_TIMEZONE),
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(safe));
throw new Error(
`${fieldName}${label})超出合理范围。请改用 startLocal/remindLocalYYYY-MM-DD HH:mm,用户时区),禁止自行估算 Unix 毫秒。`,
);
}
return safe;
}