2026-07-31 08:02:49 +08:00
|
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 定时自动任务(Scheduled Task Automation)本地验证
|
|
|
|
|
|
*
|
|
|
|
|
|
* 默认:DB + mock worker 全链路(无需 Portal 进程)
|
|
|
|
|
|
* 可选:
|
2026-08-10 19:32:09 +08:00
|
|
|
|
* VERIFY_SCHEDULED_TASK_LIVE=1 等待已开启 worker 的 Portal 到点执行(需 H5_SCHEDULED_TASK_WORKER_ENABLED=1 或未显式关闭且 H5_REMINDER_WORKER_ENABLED=1)
|
2026-07-31 08:02:49 +08:00
|
|
|
|
* --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);
|
2026-08-10 21:42:31 +08:00
|
|
|
|
let minute = parts.minute;
|
|
|
|
|
|
let hour = parts.hour;
|
|
|
|
|
|
if (parts.second > 0) {
|
|
|
|
|
|
minute += 1;
|
|
|
|
|
|
if (minute >= 60) {
|
|
|
|
|
|
minute = 0;
|
|
|
|
|
|
hour += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
2026-07-31 08:02:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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`,
|
2026-08-10 22:41:29 +08:00
|
|
|
|
taskSpec: '【验证】直接回复一行文字:验证成功。禁止追问、禁止生成页面、禁止调用任何工具。',
|
2026-07-31 08:02:49 +08:00
|
|
|
|
recurrence: 'once',
|
|
|
|
|
|
runAtLocal: formatRunAtLocal(runAtMs, tz),
|
|
|
|
|
|
notifyChannel: 'web',
|
|
|
|
|
|
sourceChannel: 'api',
|
|
|
|
|
|
});
|
|
|
|
|
|
pass('Live 任务已创建', `${waitSeconds}s 后执行,id=${created.id}`);
|
|
|
|
|
|
|
2026-08-10 22:41:29 +08:00
|
|
|
|
const executionTimeoutMs = Number(
|
|
|
|
|
|
process.env.H5_SCHEDULED_TASK_EXECUTION_TIMEOUT_MS
|
|
|
|
|
|
?? process.env.VERIFY_SCHEDULED_TASK_EXECUTION_TIMEOUT_MS
|
|
|
|
|
|
?? 180_000,
|
|
|
|
|
|
);
|
|
|
|
|
|
const waitBudgetMs = waitSeconds * 1000 + executionTimeoutMs + 60_000;
|
|
|
|
|
|
console.log(`\n等待 Portal worker 执行(最多 ${Math.ceil(waitBudgetMs / 1000)}s,含 Agent 超时 ${Math.ceil(executionTimeoutMs / 1000)}s)…`);
|
2026-08-10 19:32:09 +08:00
|
|
|
|
console.log('请确认 Portal 进程已开启 scheduled task worker(H5_SCHEDULED_TASK_WORKER_ENABLED=1,或未设置时 H5_REMINDER_WORKER_ENABLED=1)\n');
|
2026-07-31 08:02:49 +08:00
|
|
|
|
|
2026-08-10 22:41:29 +08:00
|
|
|
|
const deadline = Date.now() + waitBudgetMs;
|
2026-07-31 08:02:49 +08:00
|
|
|
|
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];
|
2026-08-10 22:41:29 +08:00
|
|
|
|
if (!terminal) {
|
|
|
|
|
|
fail('Live Portal worker', `任务 ${created.id} 在数据库中消失`);
|
|
|
|
|
|
await cleanupTestRows(pool, userId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-31 08:02:49 +08:00
|
|
|
|
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');
|
2026-08-10 22:41:29 +08:00
|
|
|
|
} else if (terminal?.status === 'running') {
|
|
|
|
|
|
fail('Live Portal worker', `Agent 仍在执行中(status=running);可提高 H5_SCHEDULED_TASK_EXECUTION_TIMEOUT_MS 或检查 Goose 连通性`);
|
|
|
|
|
|
} else if (terminal?.status === 'active' && terminal?.last_error) {
|
|
|
|
|
|
fail('Live Portal worker', `已失败并重试调度:${terminal.last_error}`);
|
2026-07-31 08:02:49 +08:00
|
|
|
|
} 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);
|
|
|
|
|
|
});
|