Files
memind/scheduled-task-service.mjs
T
john a1a921eba6 fix(wechat): route scheduled task cancel and spec updates through Goose MCP
Pass cancel requests to Goose like create instead of inbound title matching,
and add scheduled_task_update_spec so format or execution constraints update
taskSpec without triggering page delivery guards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 09:47:39 +08:00

443 lines
13 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.
import crypto from 'node:crypto';
import {
nextDailyRunAt,
nextWeeklyRunAt,
normalizeTimezone,
parseLocalDateTimeString,
} from './schedule-time.mjs';
const DEFAULT_TIMEZONE = 'Asia/Shanghai';
const VALID_RECURRENCES = new Set(['once', 'daily', 'weekly']);
const VALID_NOTIFY_CHANNELS = new Set(['wechat', 'web', 'both']);
function nowMs() {
return Date.now();
}
function rowToTask(row) {
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
title: row.title,
taskSpec: row.task_spec,
recurrence: row.recurrence,
hour: row.hour == null ? null : Number(row.hour),
minute: Number(row.minute ?? 0),
weekday: row.weekday == null ? null : Number(row.weekday),
timezone: row.timezone || DEFAULT_TIMEZONE,
nextRunAt: Number(row.next_run_at),
lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at),
notifyChannel: row.notify_channel || 'both',
status: row.status,
attempts: Number(row.attempts ?? 0),
lastError: row.last_error ?? null,
lastResult: parseJsonColumn(row.last_result_json),
sourceChannel: row.source_channel ?? 'agent',
sourceSessionId: row.source_session_id ?? null,
sourceMessageId: row.source_message_id ?? null,
sourceText: row.source_text ?? null,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
function parseJsonColumn(value) {
if (value == null || value === '') return null;
if (typeof value === 'string') {
try { return JSON.parse(value); } catch { return null; }
}
if (typeof value === 'object') return value;
return null;
}
export function computeScheduledTaskNextRunAt({
recurrence,
runAtLocal = null,
hour = null,
minute = 0,
weekday = null,
timezone = DEFAULT_TIMEZONE,
now = Date.now(),
} = {}) {
const safeRecurrence = String(recurrence ?? '').trim();
if (!VALID_RECURRENCES.has(safeRecurrence)) {
throw new Error('recurrence 无效,仅支持 once / daily / weekly');
}
const tz = normalizeTimezone(timezone);
if (safeRecurrence === 'once') {
const runAt = parseLocalDateTimeString(runAtLocal, tz);
if (runAt == null) throw new Error('一次性任务需要 runAtLocalYYYY-MM-DD HH:mm');
if (runAt <= now) throw new Error('一次性任务的执行时间必须在未来');
return runAt;
}
const safeHour = Number(hour);
const safeMinute = Number(minute ?? 0);
if (!Number.isInteger(safeHour) || safeHour < 0 || safeHour > 23) {
throw new Error('daily/weekly 任务需要有效 hour0-23');
}
if (!Number.isInteger(safeMinute) || safeMinute < 0 || safeMinute > 59) {
throw new Error('minute 无效(0-59');
}
if (safeRecurrence === 'weekly') {
return nextWeeklyRunAt({
weekday,
hour: safeHour,
minute: safeMinute,
timezone: tz,
now,
});
}
return nextDailyRunAt({
hour: safeHour,
minute: safeMinute,
timezone: tz,
now,
});
}
export function createScheduledTaskService(pool, { defaultTimezone = DEFAULT_TIMEZONE, clock = { now: nowMs } } = {}) {
if (!pool) throw new Error('缺少数据库连接');
const createTask = async ({
userId,
title,
taskSpec,
recurrence = 'daily',
runAtLocal = null,
hour = null,
minute = 0,
weekday = null,
timezone = defaultTimezone,
notifyChannel = 'both',
sourceChannel = 'agent',
sourceSessionId = null,
sourceMessageId = null,
sourceText = null,
}) => {
if (!userId) throw new Error('缺少用户');
const safeTitle = String(title ?? '').trim();
const safeTaskSpec = String(taskSpec ?? '').trim();
if (!safeTaskSpec) throw new Error('缺少 taskSpec(执行内容)');
const safeRecurrence = String(recurrence ?? 'daily').trim();
if (!VALID_RECURRENCES.has(safeRecurrence)) {
throw new Error('recurrence 无效,仅支持 once / daily / weekly');
}
const safeNotifyChannel = String(notifyChannel ?? 'both').trim();
if (!VALID_NOTIFY_CHANNELS.has(safeNotifyChannel)) {
throw new Error('notifyChannel 无效,仅支持 wechat / web / both');
}
const tz = normalizeTimezone(timezone);
const nextRunAt = computeScheduledTaskNextRunAt({
recurrence: safeRecurrence,
runAtLocal,
hour,
minute,
weekday,
timezone: tz,
now: clock.now(),
});
const normalizedTitle = safeTitle || safeTaskSpec.slice(0, 80);
const normalizedHour = hour == null ? null : Number(hour);
const normalizedMinute = Number(minute ?? 0);
if (safeRecurrence !== 'once') {
const [duplicateRows] = await pool.query(
`SELECT *
FROM h5_scheduled_tasks
WHERE user_id = ?
AND status IN ('active', 'locked')
AND recurrence = ?
AND hour <=> ?
AND minute = ?
AND title = ?
ORDER BY created_at ASC
LIMIT 1`,
[userId, safeRecurrence, normalizedHour, normalizedMinute, normalizedTitle],
);
if (duplicateRows?.[0]) {
return rowToTask(duplicateRows[0]);
}
}
const id = crypto.randomUUID();
const ts = clock.now();
await pool.query(
`INSERT INTO h5_scheduled_tasks
(id, user_id, title, task_spec, recurrence, hour, minute, weekday, timezone,
next_run_at, notify_channel, status, source_channel, source_session_id,
source_message_id, source_text, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)`,
[
id,
userId,
normalizedTitle,
safeTaskSpec,
safeRecurrence,
normalizedHour,
normalizedMinute,
weekday == null ? null : Number(weekday),
tz,
nextRunAt,
safeNotifyChannel,
sourceChannel,
sourceSessionId,
sourceMessageId,
sourceText,
ts,
ts,
],
);
const [rows] = await pool.query(
`SELECT * FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[id],
);
return rowToTask(rows[0]);
};
const listTasks = async ({
userId,
status = 'active',
limit = 20,
} = {}) => {
if (!userId) throw new Error('缺少用户');
const clauses = ['user_id = ?'];
const params = [userId];
if (status && status !== 'all') {
const statuses = Array.isArray(status) ? status : [status];
clauses.push(`status IN (${statuses.map(() => '?').join(', ')})`);
params.push(...statuses);
}
params.push(Math.max(1, Math.min(200, Number(limit) || 20)));
const [rows] = await pool.query(
`SELECT *
FROM h5_scheduled_tasks
WHERE ${clauses.join(' AND ')}
ORDER BY next_run_at ASC, created_at DESC
LIMIT ?`,
params,
);
return rows.map(rowToTask);
};
const updateTaskSpec = async ({
userId,
taskId,
taskSpec = null,
title = null,
mergeTaskSpec = false,
} = {}) => {
if (!userId) throw new Error('缺少用户');
const safeTaskId = String(taskId ?? '').trim();
if (!safeTaskId) throw new Error('缺少 taskId');
const safeTaskSpec = taskSpec == null ? null : String(taskSpec).trim();
const safeTitle = title == null ? null : String(title).trim();
if (!safeTaskSpec && !safeTitle) throw new Error('缺少 taskSpec 或 title');
const [rows] = await pool.query(
`SELECT *
FROM h5_scheduled_tasks
WHERE id = ? AND user_id = ? AND status IN ('active', 'locked', 'failed')
LIMIT 1`,
[safeTaskId, userId],
);
const existing = rows[0];
if (!existing) throw new Error('未找到可更新的定时任务');
let nextSpec = existing.task_spec;
if (safeTaskSpec) {
nextSpec = mergeTaskSpec
? [String(existing.task_spec ?? '').trim(), safeTaskSpec].filter(Boolean).join('\n\n')
: safeTaskSpec;
}
const nextTitle = safeTitle || existing.title;
const ts = clock.now();
await pool.query(
`UPDATE h5_scheduled_tasks
SET task_spec = ?, title = ?, updated_at = ?
WHERE id = ?`,
[nextSpec, nextTitle, ts, safeTaskId],
);
const [updated] = await pool.query(
`SELECT * FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[safeTaskId],
);
return rowToTask(updated[0]);
};
const cancelTask = async ({
userId,
taskId = null,
titleMatch = null,
} = {}) => {
if (!userId) throw new Error('缺少用户');
const ts = clock.now();
if (taskId) {
const [result] = await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'cancelled', updated_at = ?
WHERE id = ? AND user_id = ? AND status IN ('active', 'locked', 'failed')`,
[ts, taskId, userId],
);
if (Number(result?.affectedRows ?? 0) !== 1) {
throw new Error('未找到可取消的定时任务');
}
const [rows] = await pool.query(
`SELECT * FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[taskId],
);
return rowToTask(rows[0]);
}
const safeTitleMatch = String(titleMatch ?? '').trim();
if (!safeTitleMatch) throw new Error('取消任务需要 taskId 或 titleMatch');
const [rows] = await pool.query(
`SELECT *
FROM h5_scheduled_tasks
WHERE user_id = ?
AND status IN ('active', 'locked', 'failed')
AND title LIKE ?
ORDER BY created_at DESC
LIMIT 1`,
[userId, `%${safeTitleMatch}%`],
);
const target = rows[0];
if (!target) throw new Error('未找到可取消的定时任务');
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'cancelled', updated_at = ?
WHERE id = ?`,
[ts, target.id],
);
return rowToTask({ ...target, status: 'cancelled', updated_at: ts });
};
const listDueTasks = async ({ now = clock.now(), limit = 50 } = {}) => {
const [rows] = await pool.query(
`SELECT *
FROM h5_scheduled_tasks
WHERE next_run_at <= ?
AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))
ORDER BY next_run_at ASC
LIMIT ?`,
[now, now, Math.max(1, Math.min(200, Number(limit) || 50))],
);
return rows.map(rowToTask);
};
const lockTask = async (id, { now = clock.now(), lockMs = 300_000 } = {}) => {
const lockedUntil = now + lockMs;
const [result] = await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ?
WHERE id = ? AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))`,
[lockedUntil, now, id, now],
);
if (Number(result?.affectedRows ?? 0) !== 1) return null;
const [rows] = await pool.query(
`SELECT * FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[id],
);
return rowToTask(rows[0]);
};
const markTaskRunning = async (task, { now = clock.now() } = {}) => {
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'running', updated_at = ?
WHERE id = ?`,
[now, task.id],
);
return { ...task, status: 'running' };
};
const markTaskSucceeded = async (task, {
now = clock.now(),
result = null,
deliveryText = null,
sessionId = null,
requestId = null,
} = {}) => {
const lastResult = {
...(result && typeof result === 'object' ? result : {}),
deliveryText: deliveryText ?? null,
sessionId: sessionId ?? null,
requestId: requestId ?? null,
finishedAt: now,
};
if (task.recurrence === 'once') {
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'completed', last_run_at = ?, locked_until = NULL,
last_error = NULL, last_result_json = ?, updated_at = ?
WHERE id = ?`,
[now, JSON.stringify(lastResult), now, task.id],
);
return {
...task,
status: 'completed',
lastRunAt: now,
lastResult,
};
}
const nextRunAt = computeScheduledTaskNextRunAt({
recurrence: task.recurrence,
hour: task.hour,
minute: task.minute,
weekday: task.weekday,
timezone: task.timezone,
now: now + 1000,
});
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'active', next_run_at = ?, last_run_at = ?, locked_until = NULL,
last_error = NULL, last_result_json = ?, updated_at = ?
WHERE id = ?`,
[nextRunAt, now, JSON.stringify(lastResult), now, task.id],
);
return {
...task,
status: 'active',
nextRunAt,
lastRunAt: now,
lastResult,
};
};
const markTaskFailed = async (task, error, {
now = clock.now(),
retryMs = 10 * 60 * 1000,
maxAttempts = 5,
} = {}) => {
const attempts = Number(task.attempts ?? 0);
const message = String(error?.message ?? error ?? '执行失败').slice(0, 500);
const terminal = attempts >= maxAttempts;
const nextStatus = terminal ? 'failed' : 'active';
const nextRunAt = terminal
? task.nextRunAt
: now + retryMs;
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = ?, next_run_at = ?, locked_until = NULL, last_error = ?, updated_at = ?
WHERE id = ?`,
[nextStatus, nextRunAt, message, now, task.id],
);
return {
...task,
status: nextStatus,
nextRunAt,
lastError: message,
};
};
return {
createTask,
listTasks,
updateTaskSpec,
cancelTask,
listDueTasks,
lockTask,
markTaskRunning,
markTaskSucceeded,
markTaskFailed,
computeScheduledTaskNextRunAt,
};
}