Add scheduled task automation skill with worker execution pipeline.
Memind CI / Test, build, and release guards (push) Failing after 18s

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>
This commit is contained in:
john
2026-07-31 08:02:49 +08:00
parent 4186522c72
commit 81e63c16d3
27 changed files with 1736 additions and 2 deletions
@@ -0,0 +1,277 @@
#!/usr/bin/env node
/**
* 定时自动任务(Scheduled Task Automation)本地验证
*
* 默认:DB + mock worker 全链路(无需 Portal 进程)
* 可选:
* VERIFY_SCHEDULED_TASK_LIVE=1 等待已开启 worker 的 Portal 到点执行(需 H5_SCHEDULED_TASK_WORKER_ENABLED=1
* --due-seconds=90 创建 N 秒后执行的一次性任务(live 模式用)
*/
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import fs from 'node:fs';
import mysql from 'mysql2/promise';
import { migrateSchema } from '../db.mjs';
import { parseScheduledTaskIntent, shouldUseScheduledTaskAutomation } from '../scheduled-task-intent.mjs';
import { createScheduledTaskService } from '../scheduled-task-service.mjs';
import { startScheduledTaskWorker } from '../scheduled-task-worker.mjs';
import { getLocalParts, normalizeTimezone } from '../schedule-time.mjs';
const tz = 'Asia/Shanghai';
const TEST_TITLE_PREFIX = 'VERIFY_SCHEDULED_TASK_';
let passed = 0;
let failed = 0;
function pass(label, detail = '') {
passed += 1;
console.log(`${label}${detail ? `: ${detail}` : ''}`);
}
function fail(label, detail = '') {
failed += 1;
console.error(`${label}${detail ? `: ${detail}` : ''}`);
}
function loadDatabaseUrl() {
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
const envPath = new URL('../.env', import.meta.url);
if (!fs.existsSync(envPath)) return null;
const envText = fs.readFileSync(envPath, 'utf8');
const dbLine = envText.match(/^DATABASE_URL=(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '');
return dbLine || null;
}
function parseArgs(argv) {
const dueSecondsArg = argv.find((arg) => arg.startsWith('--due-seconds='));
return {
dueSeconds: dueSecondsArg ? Number(dueSecondsArg.split('=')[1]) : 120,
};
}
function formatRunAtLocal(epochMs, timezone = tz) {
const parts = getLocalParts(epochMs, timezone);
return `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')} ${String(parts.hour).padStart(2, '0')}:${String(parts.minute).padStart(2, '0')}`;
}
function testIntentLayer() {
assert.equal(shouldUseScheduledTaskAutomation('每天6点帮我做今日新闻页面'), true);
assert.equal(parseScheduledTaskIntent('每天6点帮我做今日新闻页面').action, 'create_scheduled_task');
assert.equal(shouldUseScheduledTaskAutomation('每天早上7点把当天待办发给我'), false);
pass('意图识别', '自动化 vs 待办 digest 边界正确');
}
async function cleanupTestRows(pool, userId) {
await pool.query(
`DELETE FROM h5_scheduled_tasks
WHERE user_id = ? AND title LIKE ?`,
[userId, `${TEST_TITLE_PREFIX}%`],
);
}
async function testDatabaseAndMockWorker(pool, userId, { dueSeconds }) {
await migrateSchema(pool);
pass('数据库 schema', 'h5_scheduled_tasks 已就绪');
const scheduledTaskService = createScheduledTaskService(pool, {
defaultTimezone: normalizeTimezone(tz),
});
await cleanupTestRows(pool, userId);
const runAtMs = Date.now() + Math.max(30, dueSeconds) * 1000;
const runAtLocal = formatRunAtLocal(runAtMs, tz);
const created = await scheduledTaskService.createTask({
userId,
title: `${TEST_TITLE_PREFIX}mock_pipeline`,
taskSpec: '生成一段今日新闻摘要(验证脚本,无需写页面)',
recurrence: 'once',
runAtLocal,
notifyChannel: 'web',
sourceChannel: 'api',
sourceText: 'verify-scheduled-task-automation',
});
if (created.recurrence === 'once' && created.nextRunAt > Date.now()) {
pass('创建一次性任务', `next_run_at=${new Date(created.nextRunAt).toLocaleString('zh-CN', { timeZone: tz })}`);
} else {
fail('创建一次性任务', `status=${created.status}, nextRunAt=${created.nextRunAt}`);
return;
}
const notDue = await scheduledTaskService.listDueTasks({ now: Date.now() - 1000 });
if (!notDue.some((row) => row.id === created.id)) {
pass('到期扫描(未到点)', '未来任务不会进入 due 列表');
} else {
fail('到期扫描(未到点)', '未来任务不应被扫描为 due');
}
await pool.query(
`UPDATE h5_scheduled_tasks SET next_run_at = ?, status = 'active', locked_until = NULL WHERE id = ?`,
[Date.now() - 1000, created.id],
);
const notifications = [];
const worker = startScheduledTaskWorker({
scheduledTaskService,
scheduleService: {
async createUserNotification(input) {
notifications.push(input);
},
},
userAuth: { id: 'mock-user-auth' },
tkmindProxy: { id: 'mock-proxy' },
executeTask: async (task) => ({
sessionId: 'verify-session',
requestId: crypto.randomUUID(),
deliveryText: `验证交付:${task.title} 已在 ${new Date().toLocaleString('zh-CN', { timeZone: tz })} 完成。`,
}),
logger: { warn() {}, info() {} },
runOnStart: false,
});
await worker.runOnce();
worker.stop();
const [rows] = await pool.query(
`SELECT status, last_result_json, last_error FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[created.id],
);
const row = rows[0];
if (row?.status === 'completed') {
pass('mock worker 执行', '一次性任务 status=completed');
} else {
fail('mock worker 执行', `status=${row?.status ?? 'missing'}, error=${row?.last_error ?? ''}`);
}
if (notifications.some((item) => item.notificationType === 'scheduled_task_result')) {
pass('结果推送(mock', 'scheduled_task_result 通知已写入');
} else {
fail('结果推送(mock', `notifications=${notifications.length}`);
}
let lastResult = row?.last_result_json;
if (typeof lastResult === 'string') {
try { lastResult = JSON.parse(lastResult); } catch { lastResult = null; }
}
if (lastResult?.deliveryText) {
pass('执行结果落盘', String(lastResult.deliveryText).slice(0, 80));
} else {
fail('执行结果落盘', 'last_result_json 缺少 deliveryText');
}
await cleanupTestRows(pool, userId);
}
async function testLivePortalWorker(pool, userId, { dueSeconds }) {
const enabled = ['1', 'true', 'yes', 'on'].includes(
String(process.env.VERIFY_SCHEDULED_TASK_LIVE ?? '').toLowerCase(),
);
if (!enabled) {
pass('Live Portal worker(跳过)', '设置 VERIFY_SCHEDULED_TASK_LIVE=1 可启用');
return;
}
const baseUrl = process.env.H5_PORT
? `http://127.0.0.1:${process.env.H5_PORT}`
: 'http://127.0.0.1:8081';
let portalOk = false;
try {
const res = await fetch(`${baseUrl}/api/status`, { signal: AbortSignal.timeout(3000) });
portalOk = res.ok;
} catch {
portalOk = false;
}
if (!portalOk) {
pass('Live Portal worker(跳过)', `Portal 未运行:${baseUrl}/api/status 不可达`);
return;
}
pass('Live Portal 探活', baseUrl);
const scheduledTaskService = createScheduledTaskService(pool, {
defaultTimezone: normalizeTimezone(tz),
});
await cleanupTestRows(pool, userId);
const waitSeconds = Math.max(30, Math.min(180, dueSeconds));
const runAtMs = Date.now() + waitSeconds * 1000;
const created = await scheduledTaskService.createTask({
userId,
title: `${TEST_TITLE_PREFIX}live_portal`,
taskSpec: '用三句话总结今天的热点新闻(验证脚本,回复文本即可,不要生成页面)',
recurrence: 'once',
runAtLocal: formatRunAtLocal(runAtMs, tz),
notifyChannel: 'web',
sourceChannel: 'api',
});
pass('Live 任务已创建', `${waitSeconds}s 后执行,id=${created.id}`);
console.log(`\n等待 Portal worker 执行(最多 ${waitSeconds + 120}s)…`);
console.log('请确认 Portal 进程已设置 H5_SCHEDULED_TASK_WORKER_ENABLED=1\n');
const deadline = Date.now() + (waitSeconds + 120) * 1000;
let terminal = null;
while (Date.now() < deadline) {
const [rows] = await pool.query(
`SELECT status, last_error, last_result_json, updated_at FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[created.id],
);
terminal = rows[0];
if (terminal?.status === 'completed' || terminal?.status === 'failed') break;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
if (terminal?.status === 'completed') {
pass('Live Portal worker', `任务 completedupdated_at=${terminal.updated_at}`);
} else if (terminal?.status === 'failed') {
fail('Live Portal worker', terminal.last_error ?? 'failed');
} else {
fail('Live Portal worker', `超时,最后 status=${terminal?.status ?? 'unknown'}`);
}
await cleanupTestRows(pool, userId);
}
async function main() {
const { dueSeconds } = parseArgs(process.argv.slice(2));
console.log('=== 定时自动任务本地验证 ===\n');
testIntentLayer();
const dbUrl = loadDatabaseUrl();
if (!dbUrl) {
fail('数据库场景', '未配置 DATABASE_URL');
} else {
const pool = mysql.createPool(dbUrl);
try {
const username = process.env.VERIFY_SCHEDULED_TASK_USER ?? 'john8';
const [users] = await pool.query(
'SELECT id, username FROM h5_users WHERE username = ? LIMIT 1',
[username],
);
if (!users[0]) {
const [fallback] = await pool.query('SELECT id, username FROM h5_users ORDER BY created_at ASC LIMIT 1');
if (!fallback[0]) {
fail('数据库场景', '无可用测试用户');
} else {
pass('测试用户', `${fallback[0].username} (${fallback[0].id})`);
await testDatabaseAndMockWorker(pool, fallback[0].id, { dueSeconds });
await testLivePortalWorker(pool, fallback[0].id, { dueSeconds });
}
} else {
pass('测试用户', `${users[0].username} (${users[0].id})`);
await testDatabaseAndMockWorker(pool, users[0].id, { dueSeconds });
await testLivePortalWorker(pool, users[0].id, { dueSeconds });
}
} finally {
await pool.end();
}
}
console.log('\n=== 汇总 ===');
console.log(`通过: ${passed}`);
console.log(`失败: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});