Add scheduled task automation skill with worker execution pipeline.
Memind CI / Test, build, and release guards (push) Failing after 18s
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:
@@ -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', `任务 completed,updated_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);
|
||||
});
|
||||
Reference in New Issue
Block a user