Files
memind/scripts/verify-scheduled-task-automation.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

278 lines
9.9 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.
#!/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);
});