Compare commits

..
Author SHA1 Message Date
whyour 7603702eca fix: reduce task startup overhead and lazy-load notifications 2026-09-28 09:42:54 +08:00
whyour f168efcdae refactor: migrate cron scheduling to node-cron (#3079)
* refactor: migrate cron scheduling to node-cron

* feat: support annually midnight and minutely cron macros

* fix: harden cron recovery and system scheduler compatibility
2026-09-27 22:18:39 +08:00
46 changed files with 2300 additions and 2415 deletions
+4 -130
View File
@@ -9,14 +9,9 @@ import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate';
import path, { join, parse } from 'path';
import ScriptService from '../services/script';
import ScriptHistoryService from '../services/scriptHistory';
import { t } from '../shared/i18n';
import multer from 'multer';
import { writeFileWithLock } from '../shared/utils';
import {
ScriptHistoryError,
HistoryUnavailableError,
} from '../shared/scriptHistory';
const route = Router();
function isPathAllowed(targetPath: string): boolean {
@@ -120,82 +115,6 @@ export default (app: Router) => {
},
);
const historyError = (error: unknown, res: Response, next: NextFunction) => {
if (error instanceof ScriptHistoryError) {
return res.status(error.status).send({
code: error.status,
message: t(error.message),
...(error instanceof HistoryUnavailableError
? { historyUnavailable: true, currentHash: error.currentHash }
: {}),
});
}
return next(error);
};
const historyQuery = {
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
};
route.get(
'/history',
celebrate({ query: Joi.object(historyQuery).unknown(true) }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const service = Container.get(ScriptHistoryService);
const data = await service.list(
(req.query.path as string) || '',
req.query.filename as string,
);
res.send({ code: 200, data });
} catch (error) {
historyError(error, res, next);
}
},
);
route.get(
'/history/detail',
celebrate({
query: Joi.object({
...historyQuery,
id: Joi.string().uuid().required(),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const service = Container.get(ScriptHistoryService);
const data = await service.detail(
(req.query.path as string) || '',
req.query.filename as string,
req.query.id as string,
);
res.send({ code: 200, data });
} catch (error) {
historyError(error, res, next);
}
},
);
route.put(
'/history/restore',
celebrate({
body: Joi.object({
...historyQuery,
id: Joi.string().uuid().required(),
expectedHash: Joi.string().hex().length(64).required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { path = '', filename, id, expectedHash } = req.body;
const service = Container.get(ScriptHistoryService);
const data = await service.restore(path, filename, id, expectedHash);
res.send({ code: 200, data });
} catch (error) {
historyError(error, res, next);
}
},
);
route.get('/:file', (req: Request, res: Response) => {
return res.send({
code: 410,
@@ -217,11 +136,6 @@ export default (app: Router) => {
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().optional().allow(''),
skipHistory: Joi.boolean().optional(),
expectedHash: Joi.string()
.hex()
.length(64)
.when('skipHistory', { is: true, then: Joi.required() }),
originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''),
@@ -283,37 +197,6 @@ export default (app: Router) => {
}
await fs.mkdir(path, { recursive: true });
const fileExists = await fileExist(filePath);
if (
fileExists &&
resolveFileAccess(config.scriptPath, [filePath], config.blackFileList)
) {
let removeSource = filename !== originFilename;
if (removeSource) {
const [originRealPath, targetRealPath] = await Promise.all([
fs.realpath(originFilePath),
fs.realpath(filePath),
]);
// Aliases of one script are an in-place save. Removing the source
// would also remove the destination behind a target symlink.
removeSource = originRealPath !== targetRealPath;
}
// Save-as removes the source after committing the destination. Keep
// its original content too: destination history only protects the
// file being overwritten, not the source being deleted.
if (removeSource) {
await fs.copyFile(
originFilePath,
join(config.bakPath, originFilename.replace(/\//g, '')),
);
}
const service = Container.get(ScriptHistoryService);
const data = await service.save(path, filename, content, {
skipHistory: req.body.skipHistory,
expectedHash: req.body.expectedHash,
});
if (removeSource) await rmPath(originFilePath);
return res.send({ code: 200, data });
}
if (fileExists) {
await fs.copyFile(
originFilePath,
@@ -326,7 +209,7 @@ export default (app: Router) => {
await writeFileWithLock(filePath, content);
return res.send({ code: 200 });
} catch (e) {
return historyError(e, res, next);
return next(e);
}
},
);
@@ -338,11 +221,6 @@ export default (app: Router) => {
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().required().allow(''),
skipHistory: Joi.boolean().optional(),
expectedHash: Joi.string()
.hex()
.length(64)
.when('skipHistory', { is: true, then: Joi.required() }),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -360,14 +238,10 @@ export default (app: Router) => {
message: t('暂无权限'),
});
}
const service = Container.get(ScriptHistoryService);
const data = await service.save(path || '', filename, content, {
skipHistory: req.body.skipHistory,
expectedHash: req.body.expectedHash,
});
return res.send({ code: 200, data });
await writeFileWithLock(filePath, content);
return res.send({ code: 200 });
} catch (e) {
return historyError(e, res, next);
return next(e);
}
},
);
+3 -10
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston';
import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser';
import { isValidCronSchedule } from '../shared/cronSchedule';
const route = Router();
export default (app: Router) => {
@@ -58,10 +58,7 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (
!req.body.schedule ||
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body);
return res.send({ code: 200, data });
@@ -204,11 +201,7 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (
!req.body.schedule ||
typeof req.body.schedule === 'object' ||
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body);
return res.send({ code: 200, data });
+48 -73
View File
@@ -1,6 +1,6 @@
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
import { AddCronRequest, AddCronResponse } from '../protos/cron';
import nodeSchedule from 'node-schedule';
import { createCronJob, CronJob } from '../shared/cronScheduler';
import { isValidCronSchedule } from '../shared/cronSchedule';
import { scheduleStacks } from './data';
import { runCron } from '../shared/runCron';
@@ -24,11 +24,7 @@ const addCron = (
if (!isValidCronField(schedule)) {
validationErrors.push(
tf(
'任务ID %s: 无效的 cron 表达式 "%s"',
String(id),
schedule,
),
tf('任务ID %s: 无效的 cron 表达式 "%s"', String(id), schedule),
);
}
@@ -56,79 +52,58 @@ const addCron = (
return;
}
// Recovery replaces the whole snapshot, including deletions and disabled jobs.
// Validation above must finish before touching the previous schedule.
if (call.request.replace) {
for (const jobs of scheduleStacks.values()) {
for (const job of jobs) job?.cancel();
// Prepare stopped jobs first: construction errors must preserve the old snapshot.
const prepared = new Map<string, CronJob[]>();
try {
for (const item of call.request.crons) {
const jobs: CronJob[] = [];
prepared.get(item.id)?.forEach((job) => job.cancel());
prepared.set(item.id, jobs);
for (const schedule of [
item.schedule,
...(item.extra_schedules || []).map((x) => x.schedule),
]) {
jobs.push(
createCronJob(
schedule,
async () => {
Logger.info('[schedule][准备运行任务] 命令: %s', item.command);
await runCron(item.command, item);
},
{
name: `${item.id}: ${item.name || ''}`,
logger: Logger,
start: false,
},
),
);
}
}
scheduleStacks.clear();
} catch (error) {
for (const jobs of prepared.values()) jobs.forEach((job) => job.cancel());
const err: any = new Error(
error instanceof Error ? error.message : String(error),
);
err.code = status.INVALID_ARGUMENT;
err.details = err.message;
callback(err, null);
return;
}
// ===== 第二遍:注册所有任务 =====
for (const item of call.request.crons) {
const { id, schedule, command, extra_schedules, name } = item;
// 取消该 id 已有的旧任务
if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.forEach((x) => x.cancel());
}
if (call.request.replace) {
for (const jobs of scheduleStacks.values())
jobs.forEach((job) => job?.cancel());
scheduleStacks.clear();
}
for (const [id, jobs] of prepared) {
scheduleStacks.get(id)?.forEach((job) => job.cancel());
scheduleStacks.set(id, jobs);
jobs.forEach((job) => job.start());
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s',
id,
name,
schedule,
command,
jobs.length,
);
if (extra_schedules?.length) {
extra_schedules.forEach((x) => {
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
x.schedule,
command,
);
});
}
const mainJob = nodeSchedule.scheduleJob(id, schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
});
if (!mainJob) {
Logger.warn(
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
id,
schedule,
);
}
const extraJobs = extra_schedules?.length
? extra_schedules.map((x) => {
const job = nodeSchedule.scheduleJob(id, x.schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
});
if (!job) {
Logger.warn(
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
id,
x.schedule,
);
}
return job;
})
: [];
// 过滤 null(兜底保护,正常情况下预校验已拦截)
const jobs = [mainJob, ...extraJobs].filter((x) => x != null);
if (jobs.length > 0) {
scheduleStacks.set(id, jobs);
}
}
callback(null, null);
+7 -1
View File
@@ -67,11 +67,17 @@ class Client {
replace = false
): Promise<AddCronResponse> {
await this.waitForReady(2000);
// Include every rule in a recovery snapshot, but bound a stuck worker.
const ruleCount = request.reduce(
(count, item) => count + 1 + (item.extra_schedules?.length || 0),
0,
);
const registrationTimeoutMs = Math.min(120000, 5000 + ruleCount * 5);
return new Promise((resolve, reject) => {
this.client.addCron(
{ crons: request, replace },
new Metadata(),
{ deadline: Date.now() + 5000 },
{ deadline: Date.now() + registrationTimeoutMs },
(err, res) => {
if (err) {
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
+2 -2
View File
@@ -1,6 +1,6 @@
import nodeSchedule from 'node-schedule';
import { CronJob } from '../shared/cronScheduler';
import { ToadScheduler } from 'toad-scheduler';
export const scheduleStacks = new Map<string, nodeSchedule.Job[]>();
export const scheduleStacks = new Map<string, CronJob[]>();
export const intervalSchedule = new ToadScheduler();
+1 -1
View File
@@ -13,7 +13,7 @@ const delCron = (
'[schedule][取消定时任务] 任务ID: %s',
id,
);
// 过滤掉 nodeSchedule.scheduleJob() 对无效表达式返回的 null,
// 防御性过滤历史调度栈中的空任务,
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
+13 -6
View File
@@ -1,5 +1,5 @@
import { randomUUID } from 'crypto';
import { getInvalidCronSchedules } from '../shared/cronSchedule';
import { getInvalidCronSchedules, isValidCronSchedule } from '../shared/cronSchedule';
import {
withSchedulerMutation,
schedulerRegistrationError,
@@ -14,7 +14,6 @@ import {
} from '../data/runningInstance';
import { exec, execSync } from 'child_process';
import fs from 'fs/promises';
import CronExpressionParser from 'cron-parser';
import {
getFileContentByName,
fileExist,
@@ -47,12 +46,20 @@ export default class CronService {
private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron;
const fields = schedule?.trim().split(/\s+/) || [];
// System crontab only receives portable numeric five-field expressions.
// Extended syntax, macros and legacy shorthand belong to node-schedule.
// Extended syntax, macros and legacy shorthand use the Node scheduler.
return (
schedule?.trim().split(/\s+/).length !== 5 ||
fields.length !== 5 ||
/[^\d\s*,/\-]/.test(schedule || '') ||
Boolean(extra_schedules?.length)
Boolean(extra_schedules?.length) ||
// BusyBox treats N/step as a single value and doesn't support Sunday=7.
fields.some((field) => field.split(',').some((part) => /^\d+\//.test(part))) ||
fields[4].includes('7') ||
// BusyBox steps DOM from zero; its full-range day/week wildcard
// handling also differs from the legacy Node parser's OR semantics.
fields[2].includes('/') ||
(fields[2] !== '*' && fields[4] !== '*')
);
}
@@ -1057,7 +1064,7 @@ export default class CronService {
if (
command &&
schedule &&
CronExpressionParser.parse(schedule).hasNext()
isValidCronSchedule(schedule)
) {
const name = namePrefix + '_' + index;
+33 -14
View File
@@ -1,6 +1,6 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import nodeSchedule from 'node-schedule';
import { createCronJob, CronJob } from '../shared/cronScheduler';
import { ChildProcessWithoutNullStreams } from 'child_process';
import {
ToadScheduler,
@@ -11,7 +11,11 @@ import {
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
import { spawn } from 'cross-spawn';
import { observeChildProcess, asError, ProcessResult } from '../shared/childProcess';
import {
observeChildProcess,
asError,
ProcessResult,
} from '../shared/childProcess';
export interface ScheduleTaskType {
id?: number;
@@ -38,7 +42,7 @@ export interface TaskCallbacks {
@Service()
export default class ScheduleService {
private scheduleStacks = new Map<string, nodeSchedule.Job>();
private scheduleStacks = new Map<string, CronJob>();
private intervalSchedule = new ToadScheduler();
@@ -159,18 +163,33 @@ export default class ScheduleService {
command,
);
this.scheduleStacks.set(
_id,
nodeSchedule.scheduleJob(_id, schedule, async () => {
this.runTask(command, callbacks, {
name,
if (schedule) {
try {
const job = createCronJob(
schedule,
command,
id: _id,
runOrigin,
});
}),
);
() =>
this.runTask(command, callbacks, {
name,
schedule,
command,
id: _id,
runOrigin,
}),
{ name: `${_id}: ${name || ''}`, logger: this.logger, start: false },
);
this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.set(_id, job);
job.start();
} catch (error) {
// A persisted invalid subscription must not become an unhandled startup rejection.
this.logger.warn(
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s',
_id,
schedule,
asError(error).message,
);
}
}
if (runImmediately) {
this.runTask(command, callbacks, {
-16
View File
@@ -1,16 +0,0 @@
import path from 'path';
import { Service } from 'typedi';
import config from '../config';
import { ScriptHistory } from '../shared/scriptHistory';
/** Panel configuration and lifecycle; the storage core stays independently testable. */
@Service()
export default class ScriptHistoryService extends ScriptHistory {
constructor() {
super(
config.scriptPath,
path.join(config.dataPath, 'script-history'),
config.blackFileList,
);
}
}
+90 -7
View File
@@ -1,12 +1,93 @@
import cronParser from 'cron-parser-v4';
import CronExpressionParser from 'cron-parser';
import { validate } from 'node-cron';
import { ScheduleType } from '../interface/schedule';
// Keep validation aligned with node-schedule's locked cron-parser version.
// cron-parser 5 accepts syntax (e.g. H) that the scheduler cannot execute.
const aliases: Record<string, string> = {
'@yearly': '0 0 0 1 1 *',
'@annually': '0 0 0 1 1 *',
'@monthly': '0 0 0 1 * *',
'@weekly': '0 0 0 * * 0',
'@daily': '0 0 0 * * *',
'@midnight': '0 0 0 * * *',
'@hourly': '0 0 * * * *',
'@minutely': '0 * * * * *',
};
export interface CronSchedule {
patterns: string[];
dayCount: number;
nthDay: number;
}
// Validation and registration repeatedly see identical rules in large snapshots.
// Cache only immutable syntax, never dates or running tasks, and bound user input.
const scheduleCache = new Map<string, CronSchedule>();
// Canonicalize legacy shorthand, aliases, names and numeric-start steps before
// node-cron sees them. Extend legacy aliases explicitly; reject H and bare /N.
export function parseCronSchedule(schedule: unknown): CronSchedule {
if (typeof schedule !== 'string' || !schedule.trim()) {
throw new Error('Invalid cron schedule');
}
let source = schedule.trim();
const cacheKey = source;
const cached = scheduleCache.get(cacheKey);
if (cached) return cached;
if (source.startsWith('@')) {
if (!Object.hasOwn(aliases, source)) throw new Error('Invalid cron alias');
source = aliases[source];
}
let parts = source.split(/\s+/);
if (
parts.length > 6 ||
parts.some((part) => /(^|,)\//.test(part) || /H(?:\(|\/|$)/i.test(part))
) {
throw new Error('Unsupported cron syntax');
}
parts = [
...['0', '*', '*', '*', '*', '*'].slice(0, 6 - parts.length),
...parts,
];
if (
parts.some(
(part, index) => index !== 3 && index !== 5 && part.includes('?'),
)
) {
throw new Error('Question mark is only valid in day fields');
}
const expression = CronExpressionParser.parse(parts.join(' '));
if (!expression.hasNext())
throw new Error('Cron schedule has no next execution');
const normalized = expression.stringify(true).replace(/\?/g, '*').split(' ');
const dayCount = expression.fields.dayOfMonth.values.length;
const weekCount = expression.fields.dayOfWeek.values.length;
const patterns =
dayCount < 31 && weekCount < 8
? [
[...normalized.slice(0, 5), '*'].join(' '),
[...normalized.slice(0, 3), '*', ...normalized.slice(4)].join(' '),
]
: [normalized.join(' ')];
if (!patterns.every((pattern) => validate(pattern))) {
throw new Error('Unsupported cron schedule');
}
const parsed = {
patterns,
dayCount,
nthDay: expression.fields.dayOfWeek.nthDay,
};
Object.freeze(patterns);
Object.freeze(parsed);
if (scheduleCache.size >= 512)
scheduleCache.delete(scheduleCache.keys().next().value);
scheduleCache.set(cacheKey, parsed);
return parsed;
}
export function isValidCronSchedule(schedule: unknown): schedule is string {
if (typeof schedule !== 'string' || !schedule.trim()) return false;
try {
return cronParser.parseExpression(schedule).hasNext();
parseCronSchedule(schedule);
return true;
} catch {
return false;
}
@@ -22,6 +103,8 @@ export function getInvalidCronSchedules(cron: {
) {
return [];
}
return [cron.schedule, ...(cron.extra_schedules || []).map((x) => x.schedule)]
.filter((schedule) => !isValidCronSchedule(schedule));
return [
cron.schedule,
...(cron.extra_schedules || []).map((x) => x.schedule),
].filter((schedule) => !isValidCronSchedule(schedule));
}
+89
View File
@@ -0,0 +1,89 @@
import { createTask, ScheduledTask } from 'node-cron';
import { parseCronSchedule } from './cronSchedule';
interface SchedulerLogger {
warn(message: string, ...args: unknown[]): unknown;
error(message: string, ...args: unknown[]): unknown;
}
export interface CronJob {
start(): void;
cancel(): void;
}
// node-cron owns timers and calendar calculation; Qinglong owns execution
// concurrency and process lifetime. Missed in-process slots join that same queue.
export function createCronJob(
schedule: string,
callback: (date: Date) => unknown | Promise<unknown>,
options: { name: string; logger: SchedulerLogger; start?: boolean },
): CronJob {
const parsed = parseCronSchedule(schedule);
const tasks: ScheduledTask[] = [];
let cancelled = false;
let started = false;
const accepts = (index: number, date: Date) => {
if (tasks.length === 1) return true;
// Preserve cron-parser 4's day/weekday OR semantics, including its
// month-dependent full-day range and the global nth-week constraint.
if (parsed.nthDay && Math.ceil(date.getDate() / 7) !== parsed.nthDay)
return false;
if (index === 1) return !tasks[0].match(date);
const monthDays = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return parsed.dayCount < monthDays[date.getMonth()] || tasks[1].match(date);
};
const dispatch = async (index: number, date: Date, missed: boolean) => {
if (cancelled || !started || !accepts(index, date)) return;
if (missed) {
options.logger.warn(
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s',
options.name,
date.toISOString(),
);
}
try {
await callback(date);
} catch (error) {
options.logger.error(
'[schedule][定时回调失败] 任务: %s, 错误: %s',
options.name,
error instanceof Error ? error.message : String(error),
);
}
};
try {
for (const [index, pattern] of parsed.patterns.entries()) {
const task = createTask(
pattern,
(context) => dispatch(index, context.date, false),
{
noOverlap: false,
missedExecutionTolerance: 5000,
},
);
tasks.push(task);
task.on('execution:missed', (context) =>
dispatch(index, context.date, true),
);
// Fail before an existing schedule is cancelled, including impossible dates.
task.getNextRuns(1);
}
} catch (error) {
tasks.forEach((task) => task.destroy());
throw error;
}
const job: CronJob = {
start() {
if (started || cancelled) return;
started = true;
tasks.forEach((task) => task.start());
},
cancel() {
if (cancelled) return;
cancelled = true;
tasks.forEach((task) => task.destroy());
},
};
if (options.start !== false) job.start();
return job;
}
-467
View File
@@ -1,467 +0,0 @@
import * as fs from 'fs/promises';
import { createReadStream } from 'fs';
import path from 'path';
import { createHash, randomUUID } from 'crypto';
import { promisify } from 'util';
import { brotliCompress, brotliDecompress, constants } from 'zlib';
import { lock } from 'proper-lockfile';
import { resolveFileAccess } from './fileAccess';
import { getUniqueLockPath } from './utils';
const compress = promisify(brotliCompress);
const decompress = promisify(brotliDecompress);
const hash = (content: string) =>
createHash('sha256').update(content).digest('hex');
export const SCRIPT_HISTORY_LIMITS = {
versions: 20,
fileBytes: 1024 * 1024,
unpackedBytes: 8 * 1024 * 1024,
archiveBytes: 2 * 1024 * 1024,
totalBytes: 32 * 1024 * 1024,
files: 1000,
};
type Source = 'baseline' | 'save' | 'restore' | 'external';
interface Version {
id: string;
createdAt: string;
source: Source;
hash: string;
content: string;
}
interface Archive {
format: 1;
path: string;
versions: Version[];
head?: { hash: string; createdAt: string; source: Source };
}
export class ScriptHistoryError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
export class HistoryUnavailableError extends ScriptHistoryError {
constructor(public currentHash: string) {
super(413, '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本');
}
}
interface SaveOptions {
skipHistory?: boolean;
expectedHash?: string;
}
/** Lazy, bounded storage: no watcher, database, daemon, or in-memory cache.
* Full snapshots share a Brotli window, so similar revisions compress together
* without a patch chain. All mutations use the existing script lock and a
* cross-process archive lock. History is durable before overwriting the script. */
export class ScriptHistory {
constructor(
private scriptRoot: string,
private historyRoot: string,
private blacklist: string[] = [],
private limits = SCRIPT_HISTORY_LIMITS,
) {}
private async target(directory: string, filename: string) {
const candidate = resolveFileAccess(
this.scriptRoot,
[directory, filename],
this.blacklist,
);
if (!candidate) throw new ScriptHistoryError(403, '暂无权限');
try {
const file = await fs.realpath(candidate);
const root = await fs.realpath(this.scriptRoot);
const relative = path.relative(root, file);
const key = hash(relative);
return {
file,
relative,
archive: path.join(this.historyRoot, key + '.br'),
};
} catch (error: any) {
if (error.code === 'ENOENT')
throw new ScriptHistoryError(404, '脚本不存在');
throw error;
}
}
private async readText(file: string) {
const handle = await fs.open(file, 'r');
try {
const stat = await handle.stat();
if (!stat.isFile()) throw new ScriptHistoryError(400, '请选择脚本文件');
if (stat.size > this.limits.fileBytes)
throw new ScriptHistoryError(
413,
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
);
// Bounded read even if an external process grows the file after stat.
const bytes = Buffer.alloc(this.limits.fileBytes + 1);
let size = 0;
while (size < bytes.length) {
const { bytesRead } = await handle.read(
bytes,
size,
bytes.length - size,
null,
);
if (!bytesRead) break;
size += bytesRead;
}
const buffer = bytes.subarray(0, size);
const content = buffer.toString('utf8');
if (
size > this.limits.fileBytes ||
content.includes('\0') ||
!Buffer.from(content).equals(buffer)
) {
throw new ScriptHistoryError(
413,
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
);
}
return content;
} finally {
await handle.close();
}
}
private async readArchive(file: string, relative: string): Promise<Archive> {
try {
const stat = await fs.lstat(file);
if (!stat.isFile() || stat.size > this.limits.archiveBytes)
throw new Error('Invalid archive');
const bytes = await decompress(await fs.readFile(file), {
maxOutputLength: this.limits.unpackedBytes,
});
const archive: Archive = JSON.parse(bytes.toString('utf8'));
if (
archive.format !== 1 ||
archive.path !== relative ||
!Array.isArray(archive.versions) ||
archive.versions.length > this.limits.versions ||
archive.versions.some(
(v) =>
typeof v.content !== 'string' ||
typeof v.id !== 'string' ||
typeof v.createdAt !== 'string' ||
!['baseline', 'save', 'restore', 'external'].includes(v.source) ||
Buffer.byteLength(v.content) > this.limits.fileBytes ||
hash(v.content) !== v.hash,
)
) {
throw new Error('Invalid archive');
}
return archive;
} catch (error: any) {
if (error.code === 'ENOENT')
return { format: 1, path: relative, versions: [] };
throw new ScriptHistoryError(500, '历史版本读取失败,请检查历史文件');
}
}
async list(directory: string, filename: string) {
const target = await this.target(directory, filename);
const [current, archive] = await Promise.all([
this.readText(target.file),
this.readArchive(target.archive, target.relative),
]);
const currentHash = hash(current);
const versions = archive.versions
.slice()
.reverse()
.map(({ content, ...v }) => ({
...v,
size: Buffer.byteLength(content),
identical: v.hash === currentHash,
}));
return { versions, currentHash, limit: this.limits.versions };
}
async detail(directory: string, filename: string, id: string) {
const target = await this.target(directory, filename);
const [current, archive] = await Promise.all([
this.readText(target.file),
this.readArchive(target.archive, target.relative),
]);
const version = archive.versions.find((v) => v.id === id);
if (!version) throw new ScriptHistoryError(404, '历史版本不存在或已清理');
return { version, current, currentHash: hash(current) };
}
async save(
directory: string,
filename: string,
content: string,
options: SaveOptions = {},
) {
return this.mutate(directory, filename, { content, ...options });
}
async restore(
directory: string,
filename: string,
id: string,
expectedHash: string,
) {
return this.mutate(directory, filename, { id, expectedHash });
}
private async mutate(
directory: string,
filename: string,
change:
| ({ content: string } & SaveOptions)
| { id: string; expectedHash: string },
) {
const target = await this.target(directory, filename);
const releaseFile = await lock(target.file, {
lockfilePath: getUniqueLockPath(target.file),
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
});
try {
let current: string;
try {
current = await this.readText(target.file);
if (
'content' in change &&
(Buffer.byteLength(change.content) > this.limits.fileBytes ||
change.content.includes('\0'))
) {
throw new ScriptHistoryError(
413,
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
);
}
} catch (error) {
if (
!('content' in change) ||
!(error instanceof ScriptHistoryError) ||
error.status !== 413
)
throw error;
// Hash oversized or non-UTF-8 files without reading them all into memory.
const digest = createHash('sha256');
for await (const chunk of createReadStream(target.file))
digest.update(chunk);
const currentHash = digest.digest('hex');
if (!change.skipHistory) throw new HistoryUnavailableError(currentHash);
if (change.expectedHash !== currentHash)
throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试');
await this.preparedWrite(target.file, change.content, (commit) =>
commit(),
);
return {
content: change.content,
changed: true,
historyRecorded: false,
};
}
if (
('id' in change || change.skipHistory) &&
hash(current) !== change.expectedHash
) {
throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试');
}
if ('content' in change && change.content === current)
return { content: current, changed: false };
await fs.mkdir(this.historyRoot, { recursive: true, mode: 0o700 });
const releaseHistory = await lock(this.historyRoot, {
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
});
try {
const archive = await this.readArchive(target.archive, target.relative);
const restored =
'id' in change
? archive.versions.find((v) => v.id === change.id)
: undefined;
if ('id' in change && !restored)
throw new ScriptHistoryError(404, '历史版本不存在或已清理');
const content =
'content' in change ? change.content : restored!.content;
if (content === current) return { content, changed: false };
if (
Buffer.byteLength(content) > this.limits.fileBytes ||
content.includes('\0')
) {
throw new ScriptHistoryError(
413,
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
);
}
const currentHash = hash(current);
if (
archive.versions[archive.versions.length - 1]?.hash !== currentHash
) {
const head =
archive.head?.hash === currentHash ? archive.head : undefined;
archive.versions.push({
id: randomUUID(),
createdAt: head?.createdAt || new Date().toISOString(),
source:
head?.source ||
(archive.versions.length ? 'external' : 'baseline'),
hash: currentHash,
content: current,
});
}
// Only prior contents are stored. Head metadata labels the next snapshot,
// but is used only if its hash matches the file. A failed file write can
// never introduce a phantom content version or lose the old snapshot.
archive.head = {
hash: hash(content),
createdAt: new Date().toISOString(),
source: 'id' in change ? 'restore' : 'save',
};
const packed = await this.pack(archive);
const cleanupPending = await this.preparedWrite(
target.file,
content,
(commit) => this.store(target.archive, packed, commit),
);
return {
content,
changed: true,
historyRecorded: true,
cleanupPending,
};
} finally {
await releaseHistory();
}
} finally {
await releaseFile();
}
}
private async pack(archive: Archive) {
archive.versions = archive.versions.slice(-this.limits.versions);
while (true) {
const serialized = Buffer.from(JSON.stringify(archive));
if (serialized.length <= this.limits.unpackedBytes) {
const compressed = await compress(serialized, {
params: {
[constants.BROTLI_PARAM_QUALITY]: 4,
[constants.BROTLI_PARAM_LGWIN]: 22,
},
});
if (compressed.length <= this.limits.archiveBytes) return compressed;
}
if (archive.versions.length <= 1)
throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存');
archive.versions.shift();
}
}
/** Stage the script before publishing history. A failed write/fsync never
* truncates the original. Preserve the existing file's ownership and mode. */
private async preparedWrite<T>(
file: string,
content: string,
run: (commit: () => Promise<void>) => Promise<T>,
): Promise<T> {
const stat = await fs.stat(file);
const temporary = path.join(
path.dirname(file),
`.ql-save-${randomUUID()}.tmp`,
);
try {
await fs.writeFile(temporary, content, {
encoding: 'utf8',
mode: 0o600,
flag: 'wx',
});
if (
typeof process.getuid === 'function' &&
typeof process.getgid === 'function' &&
(stat.uid !== process.getuid() || stat.gid !== process.getgid())
) {
await fs.chown(temporary, stat.uid, stat.gid);
}
await fs.chmod(temporary, stat.mode & 0o7777);
const handle = await fs.open(temporary, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
return await run(() => fs.rename(temporary, file));
} finally {
await fs.unlink(temporary).catch(() => undefined);
}
}
private async store(
file: string,
content: Buffer,
commit: () => Promise<void>,
) {
const entries = [];
for (const name of await fs.readdir(this.historyRoot)) {
if (!/^[a-f0-9]{64}\.br$/.test(name)) continue;
const fullPath = path.join(this.historyRoot, name);
if (fullPath === file) continue;
const stat = await fs.lstat(fullPath);
entries.push({ file: fullPath, size: stat.size, time: stat.mtimeMs });
}
entries.sort((a, b) => a.time - b.time);
let total = entries.reduce((sum, v) => sum + v.size, content.length);
const victims: string[] = [];
while (
entries.length &&
(total > this.limits.totalBytes || entries.length >= this.limits.files)
) {
const oldest = entries.shift()!;
victims.push(oldest.file);
total -= oldest.size;
}
if (total > this.limits.totalBytes)
throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存');
const previous = await fs.readFile(file).catch((error) => {
if (error.code === 'ENOENT') return undefined;
throw error;
});
const temporary = path.join(this.historyRoot, '.pending.tmp');
const publish = async (bytes: Buffer) => {
await fs.unlink(temporary).catch((error) => {
if (error.code !== 'ENOENT') throw error;
});
await fs.writeFile(temporary, bytes, { mode: 0o600, flag: 'wx' });
const handle = await fs.open(temporary, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
await fs.rename(temporary, file);
};
try {
await publish(content);
try {
await commit();
} catch (error) {
// The original script is still intact. Restore its exact history before
// returning an error; no unrelated archive has been removed yet.
if (previous) await publish(previous);
else await fs.unlink(file);
throw error;
}
// Quota eviction is post-commit maintenance. Report cleanup failures as a
// warning, not a failed save (which would encourage a misleading retry).
let cleanupPending = false;
for (const victim of victims) {
try {
await fs.unlink(victim);
} catch (error: any) {
if (error.code !== 'ENOENT') cleanupPending = true;
}
}
return cleanupPending;
} finally {
await fs.unlink(temporary).catch(() => undefined);
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import path from 'path';
import { writeFile, open, chmod } from 'fs/promises';
import { fileExist } from '../config/util';
export function getUniqueLockPath(filePath: string) {
function getUniqueLockPath(filePath: string) {
const sanitizedPath = filePath
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/^_/, '');
@@ -1,8 +1,8 @@
# OpenAPI 全量参考 / Complete OpenAPI reference
CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
CLI 对应当前 develop 的 143 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
The catalogue covers 146 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
The catalogue covers 143 active routes in the current develop backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
## 认证与权限 / Authentication and permissions
@@ -46,9 +46,6 @@ ql env create --data @envs.json --json
ql env update 8 --data '{"name":"EXAMPLE","value":"value"}' --json
ql script create --file ./demo.js --data '{"filename":"demo.js","path":""}' --json
ql script get --query '{"file":"demo.js","path":""}' --json
ql script history-list --query '{"filename":"demo.js","path":""}' --json
ql script history-detail --query '{"filename":"demo.js","path":"","id":"VERSION_UUID"}' --json
ql script history-restore --data '{"filename":"demo.js","path":"","id":"VERSION_UUID","expectedHash":"CURRENT_SHA256"}' --json
ql config save --data '{"name":"example.sh","content":"# example"}' --json
ql config get --query '{"path":"example.sh"}' --json
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
@@ -136,9 +133,6 @@ System/user operations below execute remotely through the panel API. They do not
| `ql config save ` | POST | `configs/save` | --data, --query |
| `ql script list ` | GET | `scripts` | --query |
| `ql script get ` | GET | `scripts/detail` | --query |
| `ql script history-list ` | GET | `scripts/history` | --query |
| `ql script history-detail ` | GET | `scripts/history/detail` | --query |
| `ql script history-restore ` | PUT | `scripts/history/restore` | --data, --query |
| `ql script create ` | POST | `scripts` | --data, --file (file), --query |
| `ql script update ` | PUT | `scripts` | --data, --file (file), --query |
| `ql script delete ` | DELETE | `scripts` | --data, --query |
-12
View File
@@ -275,18 +275,6 @@ export const openOperations: readonly OpenOperation[] = [
{ name: 'config save', method: 'POST', path: 'configs/save', body: 'json' },
{ name: 'script list', method: 'GET', path: 'scripts' },
{ name: 'script get', method: 'GET', path: 'scripts/detail' },
{ name: 'script history-list', method: 'GET', path: 'scripts/history' },
{
name: 'script history-detail',
method: 'GET',
path: 'scripts/history/detail',
},
{
name: 'script history-restore',
method: 'PUT',
path: 'scripts/history/restore',
body: 'json',
},
{
name: 'script create',
method: 'POST',
-25
View File
@@ -58,31 +58,6 @@ test('dashboard record sends execution statistics and rejects a missing body bef
assert.equal(f.requests.length, 1);
});
test('script history commands send exact query parameters and restore payloads', async t => {
const f = await fixture(t);
const identity = { filename: '中文 script.js', path: 'nested folder' };
const version = { ...identity, id: '32ec8b22-2c48-4bcf-85d4-b394198417ba' };
for (const [command, endpoint, query] of [
['history-list', 'scripts/history', identity],
['history-detail', 'scripts/history/detail', version],
]) {
success(await f.run(['script', command, '--query', JSON.stringify(query)]));
const req = f.requests.at(-1), url = new URL(req.url, 'http://localhost');
assert.equal(req.method, 'GET');
assert.equal(url.pathname, '/panel/open/' + endpoint);
for (const [key, value] of Object.entries(query)) assert.equal(url.searchParams.get(key), value);
assert.equal(req.body, '');
}
const payload = { ...version, expectedHash: 'a'.repeat(64) };
success(await f.run(['script', 'history-restore', '--data', '-'], JSON.stringify(payload)));
assert.equal(f.requests.at(-1).method, 'PUT');
assert.equal(f.requests.at(-1).url, '/panel/open/scripts/history/restore');
assert.deepEqual(JSON.parse(f.requests.at(-1).body), payload);
const count = f.requests.length;
assert.equal((await f.run(['script', 'history-restore'])).code, 2);
assert.equal(f.requests.length, count, 'missing restore payload must not issue a request');
});
test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
const root = path.resolve(__dirname, '../../../back/api');
const expected = [];
+49
View File
@@ -0,0 +1,49 @@
# Node cron 调度器迁移
Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外规则)、订阅和系统 cron 共用 `back/shared/cronScheduler.ts`。启动/一次性任务、间隔任务、脚本并发限制和进程执行链路保持原有实现。系统模式只将语义一致的普通五字段规则交给 crond。
移除 `node-schedule`、其类型包以及兼容别名 `cron-parser-v4`。保留 `cron-parser@5` 做旧表达式的校验与标准化;计时器和日历推进由 node-cron 管理。
## 兼容行为
- 保留旧版 1–6 字段补全、五种宏、月份/星期名称、数字起点步长、L 和 #。另外明确新增 `@annually`(等同 `@yearly`)、`@midnight`(等同 `@daily`)、`@minutely`(每分钟第 0 秒)。宏按进程时区解释。`?` 仅允许日期和星期字段;裸 `/N`、H 和其他未列出的宏仍拒绝。
- 日期与星期同时指定时保留 OR 语义,不采用 node-cron 默认的 AND。两个原生任务分别处理日期和星期,并按计划时间过滤交集,避免重复执行;保留旧解析器的月份全日期范围及全局 nth-week 限制。
- 原生任务先暂停创建并验证,再替换旧任务。构造失败时销毁已准备的实例并保留旧快照。
- 进程内迟到的触发通过 `execution:missed` 加入既有执行队列,记录补执行日志。并发和单任务积压上限仍由青龙队列控制;这不提供进程退出期间的持久化补跑或分布式 exactly-once 保证。
- 标准化结果使用上限 512 条的只读缓存,仅缓存语法,不缓存运行实例或下次执行时间。避免大批相同表达式重复解析导致恢复 RPC 超时。
- 回调异常记录日志,后续调度继续;取消时调用原生 `destroy()` 释放计时器和注册表。
- 持久化无效任务在恢复时跳过并记录日志,无效订阅不会产生未处理的启动拒绝。API、导入和执行使用同一套表达式校验。
## 复审修复
- 移除没有生产调用的 `nextInvocation()` 接口,避免对 OR/# 等过滤规则报告不会实际执行的原生日期。回归测试直接观察实际执行和原生实例状态。
- 批量注册 RPC 时限为 5 秒加每条规则 5 毫秒,计入额外规则并封顶 120 秒;保留超时后的恢复机制,不立即重放结果不确定的写入。
- Alpine/系统模式下,数字起点步长(如 `0/5`)、星期包含 `7`、日期步长、日期和星期同时受限的表达式转入 Node 路径。BusyBox 的这些解析或通配语义与旧 Node 解析器不同,不能仅因是纯数字五字段就交给 crond。
- 这些规则在 `crontab.list` 中仍保留为注释,避免同时被系统和 Node 执行。其他可移植规则仍使用系统 crond。
## 夏令时差异
采用 node-cron 的当地时间语义:春季跳时不存在的时间跳过,秋季重复小时不再执行第二次。旧 cron-parser 对部分春季时间会顺延,并可能重复秋季高频任务。使用有夏令时的时区时需要考虑该行为变化;Asia/Shanghai 不受影响。回归测试固定 America/New_York 的 2026 年转换日期。
## 验证
`npm test` 和 `npm run build:back`。
- `test/fixtures/legacy-cron.json` 是迁移前使用 node-schedule 2.1.1 / cron-parser 4.9.0 保存的快照:269 条语法判定,18 组日历序列,每组 10 次执行。
- 真正的 node-cron 实例配合虚拟时钟验证日期/星期 OR、交集去重、闰年、L/#、8 秒迟到补执行、取消释放、回调拒绝和夏令时。
- 注册构造失败回滚、无效订阅隔离、空表达式立即运行,以及现有调度恢复/503/子进程测试覆盖接入流程。
- 隔离的正式 Debian 镜像使用实际编译产物验证 10,000 条有效任务和 2 条错误任务的恢复、300 个同时到期的 Python 脚本、并发上限 16 及 HTTP 健康检查。具体结果见本次测试报告;这是生产镜像隔离验证,不是线上长期运行证明。
### 本次验收结果(2026-09-27)
完整回归 260 通过、3 项原有跳过,后端编译成功。Node 20 生产运行时的 21 项兼容测试通过。隔离镜像中 300/300 脚本完成,无重复或遗漏,最大并发 16,270 个成功及 30 个预设失败均落库;完成耗时 52.36 秒。恢复后健康检查 1,870/1,870 成功,任务列表 38/38 成功。重启恢复阶段另有 50 次健康检查失败。
初版重复解析曾导致恢复超过 5 秒 RPC 时限,加入有上限的语法缓存后重跑通过。本次 10,000 个任务共享两个表达式,不能视为一万种复杂表达式的容量保证;实际脚本耗时也不代表纯调度性能。
新增宏在 UTC 和 Asia/Shanghai 下验证连续两次实际触发及不提前触发;原有语法快照仅对这三个新增宏允许预期变化。
### 复审后验收
完整回归 267 通过、3 项原有跳过;Node 20 的 10 项修复回归及 Alpine 镜像的 24 项兼容/宏测试通过。
额外使用 9,700 个不同表达式和 300 个同时到期任务验证恢复(另有 2 个无效任务),不再局限于共享两个表达式。300/300 脚本完成,无重复或遗漏,最大并发 16,270 个退出码 0、30 个预设退出码 3 均落库。恢复后健康检查 1,650/1,650 成功、任务列表 33/33 成功;启动恢复阶段另有 260 次健康检查失败,整批脚本耗时 60.27 秒。较大快照恢复仍需等待,不承诺启动阶段始终可用或任意规模都能在时限内恢复。
+54
View File
@@ -0,0 +1,54 @@
# Task startup and notification loading
`task` performs shell configuration, status reporting and dependency-path discovery
before invoking the selected runtime. These costs are separate from cron callback
latency and time spent waiting for an execution slot.
## Dependency-path cache
Node scheduling usually starts in `/ql`, while Alpine crond starts in `/root`.
The pnpm discovery key includes the working directory, npm/pnpm configuration,
relevant environment and executable metadata. Each key now has its own cached
record, so alternating scheduler environments do not evict each other.
Records expire after 60 seconds. Refreshes share a persistent per-user lock;
records older than an hour are removed on refresh. Warm lookups do not scan the
cache directory. The lock is never removed while another process might use it.
The opt-out `QL_NODE_PATH_CACHE=0`, failed-discovery fallback, and package resolution
order remain available. This caches the dependency directory, not its contents.
## Language startup paths
| Invocation | Environment and before hooks | Automatic notification loading |
| --- | --- | --- |
| `task script.py` / `.pyc` | Python preload imports generated environment, runs the shell/command before hooks, imports `task_before.py`, then applies account selection | `QLAPI.notify(...)` imports `__ql_notify__` on first call |
| `task script.js` / `.mjs` / `.ts` | Node preload imports generated environment, runs shell/command before hooks, requires `task_before.js`, then applies account selection | `QLAPI.notify(...)` requires `__ql_notify__.js` on first call |
| `task script.sh` | Shell sources the generated environment and runs shell before hooks | No automatic Python/Node notification module |
| Explicit interpreter or other command, e.g. `task python3 script.py` | Existing generic-command path uses shell environment/before hooks; it does not automatically install the Python/Node preload | No automatic notification module unless configured by the caller |
TypeScript uses `ts-node-transpile-only`; MJS uses the same Node preload plus the
ESM loader. There are no separate built-in Ruby, Go, Java or PHP preload modules.
All these task paths still pass through shared dependency-path setup: non-JS
scripts and their hooks may themselves call Node or use the QLAPI bridge.
The before hooks are deliberately still synchronous: they can export environment
variables or perform work required by the user's script. Python's preload still
starts a child shell/Python to capture that environment; Node's preload starts a
child shell/Node. Node also still loads its gRPC client eagerly. These are remaining
startup costs, not work performed by the cron library, and are not removed by this
change.
## Lazy notifications
Scripts keep calling `QLAPI.notify` with the same arguments. When no notification
is sent, the notification provider and its channel dependencies are never imported
by the preload. First use loads the provider; subsequent uses reuse the standard
Python/Node module cache. JS forwards the receiver and return value, including
promises, and Python forwards positional and keyword arguments. Import/send errors
surface at the notification call rather than preventing unrelated scripts from
obtaining QLAPI during startup. A failed import can be retried after its cause is
resolved.
Provider initialization, including provider configuration reads, now happens at
first use. A script's explicit `import __ql_notify__`, `require(...)`, or independent
notification helper still loads that module immediately.
-53
View File
@@ -1,53 +0,0 @@
# 脚本编辑历史
脚本管理工具栏的“历史版本”打开右侧抽屉。选择版本后,对比历史内容与服务器上当前已保存文件;桌面使用双栏对比,手机使用行内对比。恢复需要确认,有未保存修改时必须先保存或取消。恢复前保留当前版本,因此恢复操作也可以撤销。普通保存不再弹出不可恢复确认。
## 记录范围
- 编辑器 `PUT /api/scripts` 和调试窗口 `POST /api/scripts` 覆盖已有脚本时,在内容变化时留档,首次修改前的内容也保留;相同内容不写历史。
- 历史只包含保存前真实存在的内容;不把失败写入当成一个内容版本。
- 上传、新增、重命名、删除、订阅更新和外部编辑不主动留档。若下次面板保存时检测到外部变更,会保留那一刻的内容,标记为“面板外修改后”。这不代表外部修改之前的内容受到保护。
- 按脚本规范化后的相对路径隔离历史;不同目录同名文件不会混淆,指向同一脚本的安全符号链接共用历史。重命名后不会自动迁移历史。调试器另存为覆盖已有脚本时,目标旧内容进入历史,删除源文件前将其旧内容保留到现有 bak 目录;备份失败则不覆盖目标、不删除源文件。源和目标实际指向同一脚本时(如安全符号链接或不同路径写法),按原地保存处理,保留两个路径,不删除源文件。历史不能用于回收已删除的文件。
- 历史记录支持至多 1 MiB 的 UTF-8 文本文件(含空文件)。当前文件或新内容超出范围时,界面要求明确确认“不记录历史直接保存”;取消则保持文件和草稿不变。确认后以 `skipHistory: true` 和服务端返回的当前哈希重试,锁内检查哈希以防确认期间覆盖他人修改。成功后明确提示本次未留档。大文件可以通过此流程缩小,后续恢复普通留档。未确认的 API 请求返回 413,并包含 `historyUnavailable` 和 `currentHash`;历史读取损坏等其他错误不允许用此选项绕过。
## 存储与资源边界
位置:`<QL_DATA_DIR>/script-history/<SHA-256(规范相对路径)>.br`。目录位于脚本目录之外,避免进入脚本树、订阅和任务扫描。目录权限 `0700`,历史文件权限 `0600`。目录随整个数据目录备份。
每个脚本仅一个 Brotli 压缩 JSON 包,存储完整的旧内容快照以及时间、来源、内容哈希。新文件内容不再重复存储,只保存头版本元数据,供下一次留档标注来源。面板服务通过 TypeDI 注册为单例,路由使用 `Container.get(ScriptHistoryService)` 获取;构造实例只初始化配置,首次发生保存时才创建历史目录。包内重复文本共享压缩窗口,不依赖补丁链,也不需要额外数据库、文件监听、后台任务或常驻缓存。使用 Node 内置 zlib 异步压缩,Brotli quality=4、window=4 MiB,不新增依赖。
| 限制 | 默认值 |
| --- | --- |
| 每脚本历史版本 | 最多 20 个 |
| 单个脚本内容 | 1 MiB |
| 单包解压后的 JSON | 8 MiB |
| 单个压缩历史包 | 2 MiB |
| 所有压缩历史包合计 | 32 MiB |
| 有历史的脚本数 | 最多 1000 个 |
| 历史写入暂存文件 | 共享一个,最多 2 MiB |
| 脚本写入暂存文件 | 每次保存一个,与本次保存内容等大,结束后清理 |
达到单文件限制时删除最旧版本,至少保留本次覆盖前的内容;无法保留则拒绝覆盖。达到全局限制时,在脚本成功提交后优先清理最久未更新的脚本历史包(该文件的整份历史),不删除脚本本身。历史暂存、提交或脚本提交失败时不清理任何其他历史;脚本提交失败会回滚本文件历史。如果提交成功但清理失败,返回 `cleanupPending`,界面提示检查存储权限和空间,后续保存重新尝试清理。配额是正常清理成功后的目标,事务期间和清理失败时会暂时超出。32 MiB 是文件内容字节数,不包含文件系统块分配、目录和锁的开销。清理只在发生保存时执行,通常最多扫描 1000 个包的元数据(清理失败期间可能更多),不解压其他脚本的历史。读取仅解压当前脚本,限制解压输出长度。
文件保存复用现有锁路径,同时以独立跨进程目录锁串行化历史写入和配额清理;`finally` 释放锁。先准备并同步脚本暂存文件,保留文件权限和所有者;随后写并同步历史暂存文件、原子提交历史,再原子替换脚本。任一步准备失败不会截断原脚本;最后替换脚本失败时还原此前历史;成功后才清理配额。超限确认保存不创建新历史包,使用同样的脚本暂存和替换方式,并流式计算旧文件哈希。外部进程不遵守该锁时,不承诺与其并发写入的事务一致性。
恢复请求携带预览时的当前内容哈希,锁内检查后才写入;文件变化则返回 409。历史读写与脚本读取采用相同的路径、黑名单和符号链接边界检查。
## 压缩样本
运行 `node scripts/benchmark-script-history.cjs` 可复现比较。模拟同一脚本连续 20 次小修改,比较独立 gzip、合并 gzip 与合并 Brotli。首次本机测量:
| 样本 | 独立 gzip | 合并 gzip | 合并 Brotli(quality 4) |
| --- | ---: | ---: | ---: |
| sample/notify.js | 242,430 B | 221,832 B | 13,930 B |
| shell/task.sh | 30,159 B | 2,317 B | 1,737 B |
这是文本重复度较高的模拟样本,不是所有工作负载的压缩保证。Brotli 合包特别适合大小超过 gzip 32 KiB 窗口、但每次只改少量内容的脚本。增量补丁可能在部分样本更小,但增加补丁生成、链式恢复、裁剪和损坏恢复的复杂度;本实现选择压缩合包与硬上限,在磁盘、CPU 和可靠性之间取平衡。
## API
- `GET /api/scripts/history?filename=...&path=...`:版本元数据、当前哈希、保留数量。
- `GET /api/scripts/history/detail?filename=...&path=...&id=...`:历史内容、当前内容及当前哈希。
- `PUT /api/scripts/history/restore`:`{filename, path, id, expectedHash}`。
沿用现有 scripts 路由的认证与权限。历史 ID 使用 UUID;不存在或已清理返回 404,文件已变更返回 409,文本或容量限制返回 413。
+1 -3
View File
@@ -90,7 +90,6 @@
"compression": "^1.7.4",
"cors": "^2.8.5",
"cron-parser": "^5.4.0",
"cron-parser-v4": "npm:cron-parser@4.9.0",
"cross-spawn": "^7.0.6",
"dayjs": "^1.11.13",
"dotenv": "^16.4.6",
@@ -108,7 +107,7 @@
"keyv": "^5.2.3",
"lodash": "^4.17.21",
"multer": "2.1.1",
"node-schedule": "^2.1.0",
"node-cron": "4.6.0",
"nodemailer": "^8.0.1",
"p-queue-cjs": "7.3.4",
"proper-lockfile": "^4.1.2",
@@ -145,7 +144,6 @@
"@types/lodash": "^4.14.185",
"@types/multer": "^1.4.7",
"@types/node": "^17.0.21",
"@types/node-schedule": "^1.3.2",
"@types/nodemailer": "^6.4.4",
"@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
+8 -44
View File
@@ -42,9 +42,6 @@ dependencies:
cron-parser:
specifier: ^5.4.0
version: 5.4.0
cron-parser-v4:
specifier: npm:cron-parser@4.9.0
version: /cron-parser@4.9.0
cross-spawn:
specifier: ^7.0.6
version: 7.0.6
@@ -96,9 +93,9 @@ dependencies:
multer:
specifier: 2.1.1
version: 2.1.1
node-schedule:
specifier: ^2.1.0
version: 2.1.1
node-cron:
specifier: 4.6.0
version: 4.6.0
nodemailer:
specifier: ^8.0.1
version: 8.0.1
@@ -203,9 +200,6 @@ devDependencies:
'@types/node':
specifier: ^17.0.21
version: 17.0.45
'@types/node-schedule':
specifier: ^1.3.2
version: 1.3.2
'@types/nodemailer':
specifier: ^6.4.4
version: 6.4.17
@@ -4516,12 +4510,6 @@ packages:
'@types/express': 4.17.21
dev: true
/@types/node-schedule@1.3.2:
resolution: {integrity: sha512-Y0CqdAr+lCpArT8CJJjJq4U2v8Bb5e7ru2nV/NhDdaptCMCRdOL3Y7tAhen39HluQMaIKWvPbDuiFBUQpg7Srw==}
dependencies:
'@types/node': 17.0.45
dev: true
/@types/node@17.0.45:
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
@@ -7046,13 +7034,6 @@ packages:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
dev: true
/cron-parser@4.9.0:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'}
dependencies:
luxon: 3.5.0
dev: false
/cron-parser@5.4.0:
resolution: {integrity: sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA==}
engines: {node: '>=18'}
@@ -10445,10 +10426,6 @@ packages:
triple-beam: 1.4.1
dev: false
/long-timeout@0.1.1:
resolution: {integrity: sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==}
dev: false
/long@5.2.3:
resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==}
dev: false
@@ -10482,11 +10459,6 @@ packages:
yallist: 4.0.0
dev: true
/luxon@3.5.0:
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
engines: {node: '>=12'}
dev: false
/luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
@@ -10849,6 +10821,11 @@ packages:
resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==}
dev: false
/node-cron@4.6.0:
resolution: {integrity: sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==}
engines: {node: '>=20'}
dev: false
/node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -10963,15 +10940,6 @@ packages:
resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==}
dev: true
/node-schedule@2.1.1:
resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==}
engines: {node: '>=6'}
dependencies:
cron-parser: 4.9.0
long-timeout: 0.1.1
sorted-array-functions: 1.3.0
dev: false
/nodemailer@8.0.1:
resolution: {integrity: sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==}
engines: {node: '>=6.0.0'}
@@ -14180,10 +14148,6 @@ packages:
sort-object-keys: 1.1.3
dev: true
/sorted-array-functions@1.3.0:
resolution: {integrity: sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==}
dev: false
/source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
-39
View File
@@ -1,39 +0,0 @@
// Reproducible storage comparison, without touching actual script history.
const fs = require('node:fs');
const path = require('node:path');
const zlib = require('node:zlib');
for (const file of ['sample/notify.js', 'shell/task.sh']) {
const source = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
const versions = Array.from({ length: 20 }, (_, i) => ({
content: source + '\n// edit ' + i,
}));
const raw = Buffer.from(JSON.stringify(versions));
const strategies = {
gzipSeparate: () =>
versions.reduce(
(sum, version) => sum + zlib.gzipSync(version.content).length,
0,
),
gzipBundle: () => zlib.gzipSync(raw).length,
brotliBundle: () =>
zlib.brotliCompressSync(raw, {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 4,
[zlib.constants.BROTLI_PARAM_LGWIN]: 22,
},
}).length,
};
for (const [strategy, compress] of Object.entries(strategies)) {
const start = performance.now();
const bytes = compress();
console.log(
JSON.stringify({
file,
strategy,
rawBytes: raw.length,
bytes,
ms: +(performance.now() - start).toFixed(2),
}),
);
}
}
+10 -2
View File
@@ -65,7 +65,9 @@ ql_read_node_path_cache() {
# the caller's descriptors. A crashed refresher releases the kernel lock.
ql_refresh_node_global_path() (
local cache="$1" key="$2" now result previous_umask
local lock="${cache}.lock"
# Keep one persistent lock per owner. Removing per-key lock files could
# split concurrent waiters across different inodes during cache cleanup.
local lock="${dir_tmp}/pnpm-root-${EUID}.lock"
previous_umask=$(umask)
umask 077
if type -P flock &>/dev/null && mkdir -p -- "$dir_tmp" 2>/dev/null; then
@@ -93,6 +95,10 @@ ql_refresh_node_global_path() (
# Private lock creation must not change pnpm's inherited creation mask.
umask "$previous_umask"
# Warm lookups never scan the directory. Retire old environment records on
# refresh, while preserving the shared lock and unrelated cache files.
find "$dir_tmp" -maxdepth 1 -type f -user "$EUID" \
-name "pnpm-root-${EUID}-*.cache" -mmin +60 -delete 2>/dev/null || true
now=${EPOCHSECONDS:-$(date +%s)}
result=$(pnpm root -g 9>&- 2>/dev/null) || return $?
# Never cache failed, empty, multiline or non-absolute answers.
@@ -119,8 +125,10 @@ ql_get_node_global_path() {
fi
local key cache
cache="${dir_tmp}/pnpm-root-${EUID}.cache"
key=$(ql_node_path_cache_key) || { pnpm root -g 2>/dev/null; return $?; }
# Node and crond may have different working directories/configuration.
# Preserve both discoveries instead of evicting each other on every run.
cache="${dir_tmp}/pnpm-root-${EUID}-${key// /-}.cache"
if ql_read_node_path_cache "$cache" "$key"; then
return 0
fi
+6 -2
View File
@@ -159,9 +159,13 @@ try {
run();
const { sendNotify } = require('./__ql_notify__.js');
global.QLAPI = {
notify: sendNotify,
notify(...args) {
// Keep notification dependencies out of ordinary script startup.
// require caches the module after the first notification.
const { sendNotify } = require('./__ql_notify__.js');
return sendNotify.apply(this, args);
},
...client,
};
} catch (error) {
+4 -2
View File
@@ -129,10 +129,12 @@ try:
run()
from __ql_notify__ import send
class BaseApi(Client):
def notify(self, *args, **kwargs):
# Most scripts never send a notification. Python caches this
# module after the first call, including its channel dependencies.
from __ql_notify__ import send
return send(*args, **kwargs)
QLAPI = BaseApi()
+1 -29
View File
@@ -665,33 +665,5 @@
"重试": "Retry",
"WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs": "WPUSH API Key from https://wpush.cn/settings, see https://wpush.cn/docs",
"推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat": "Channel: wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot, default wechat",
"可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "Optional Topic code for broadcast; see https://wpush.cn/docs",
"历史版本": "Version history",
"首次修改前": "Before the first edit",
"手动保存": "Manual save",
"从历史版本恢复": "Restored version",
"面板外修改后": "After an external edit",
"恢复此版本": "Restore this version",
"当前已保存的内容会保留在历史版本中。": "The currently saved content will be kept in version history.",
"请先保存或取消当前修改,再恢复历史版本": "Save or discard your unsaved changes before restoring a version",
"已恢复,恢复前的内容已保留": "Restored. The previous content is kept in version history",
"最多保留 {count} 个历史版本,容量不足时自动清理旧历史。": "Keep up to {count} previous versions. Older history is removed when storage is full.",
"仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。": "Only panel saves are recorded. Subscription updates may still overwrite restored files.",
"暂无历史版本,下次保存修改时会自动保留当前内容": "No versions yet. Your current content will be kept when you next save changes.",
"选择历史版本": "Choose a version",
"历史版本加载失败,请刷新重试": "Unable to load this version. Refresh and try again.",
"当前已保存文件": "Currently saved file",
"与当前文件一致": "Identical to the current file",
"历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本": "Version history supports UTF-8 text scripts up to 1 MiB",
"历史版本读取失败,请检查历史文件": "Unable to read version history. Check the history file.",
"历史版本不存在或已清理": "This version does not exist or has been removed",
"文件已变化,请刷新历史版本后重试": "The file has changed. Refresh version history and try again.",
"历史版本容量不足,无法安全保存": "Not enough version history capacity to save safely",
"脚本不存在": "The script does not exist",
"不记录历史直接保存?": "Save without version history?",
"当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。": "The current file or new content is outside the history limits. The content being replaced will not be kept.",
"仍然保存": "Save anyway",
"已保存,本次未记录历史版本": "Saved without a history snapshot",
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
"保存失败": "Unable to save"
"可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "Optional Topic code for broadcast; see https://wpush.cn/docs"
}
+1 -29
View File
@@ -665,33 +665,5 @@
"重试": "重试",
"WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs": "WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs",
"推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat": "推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat",
"可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs",
"历史版本": "历史版本",
"首次修改前": "首次修改前",
"手动保存": "手动保存",
"从历史版本恢复": "从历史版本恢复",
"面板外修改后": "面板外修改后",
"恢复此版本": "恢复此版本",
"当前已保存的内容会保留在历史版本中。": "当前已保存的内容会保留在历史版本中。",
"请先保存或取消当前修改,再恢复历史版本": "请先保存或取消当前修改,再恢复历史版本",
"已恢复,恢复前的内容已保留": "已恢复,恢复前的内容已保留",
"最多保留 {count} 个历史版本,容量不足时自动清理旧历史。": "最多保留 {count} 个历史版本,容量不足时自动清理旧历史。",
"仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。": "仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。",
"暂无历史版本,下次保存修改时会自动保留当前内容": "暂无历史版本,下次保存修改时会自动保留当前内容",
"选择历史版本": "选择历史版本",
"历史版本加载失败,请刷新重试": "历史版本加载失败,请刷新重试",
"当前已保存文件": "当前已保存文件",
"与当前文件一致": "与当前文件一致",
"历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本": "历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本",
"历史版本读取失败,请检查历史文件": "历史版本读取失败,请检查历史文件",
"历史版本不存在或已清理": "历史版本不存在或已清理",
"文件已变化,请刷新历史版本后重试": "文件已变化,请刷新历史版本后重试",
"历史版本容量不足,无法安全保存": "历史版本容量不足,无法安全保存",
"脚本不存在": "脚本不存在",
"不记录历史直接保存?": "不记录历史直接保存?",
"当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。": "当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。",
"仍然保存": "仍然保存",
"已保存,本次未记录历史版本": "已保存,本次未记录历史版本",
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
"保存失败": "保存失败"
"可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs"
}
@@ -1,35 +0,0 @@
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
:global(.ant-btn) {
flex-shrink: 0;
}
}
.labels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
font-weight: 500;
}
.diff {
flex: 1;
min-height: 260px;
overflow: auto;
border: 1px solid var(--ant-border-color-split, #d9d9d9);
}
@media (max-width: 600px) {
.footer {
align-items: flex-end;
}
.labels {
gap: 8px;
font-size: 12px;
}
.diff {
flex: none;
min-height: 200px;
border: 0;
}
}
-333
View File
@@ -1,333 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import {
Alert,
Button,
Drawer,
Empty,
Modal,
Select,
Space,
Spin,
Typography,
message,
} from 'antd';
import { DiffEditor } from '@monaco-editor/react';
import ReactDiffViewer from 'react-diff-viewer';
import intl from 'react-intl-universal';
import config from '@/utils/config';
import { request } from '@/utils/http';
import styles from './historyDrawer.module.less';
interface Version {
id: string;
createdAt: string;
source: 'baseline' | 'save' | 'restore' | 'external';
identical: boolean;
}
interface Detail {
version: Version & { content: string };
current: string;
currentHash: string;
}
interface Props {
filename: string;
directory: string;
isPhone: boolean;
theme: string;
language: string;
hasUnsavedChanges: () => boolean;
onClose: () => void;
onRestored: (content: string) => void;
}
const sourceLabel = (source: Version['source']) =>
intl.get(
{
baseline: '首次修改前',
save: '手动保存',
restore: '从历史版本恢复',
external: '面板外修改后',
}[source],
);
const versionLabel = (v: Version) =>
`${new Date(v.createdAt).toLocaleString()} · ${sourceLabel(v.source)}`;
export default function HistoryDrawer(props: Props) {
const {
filename,
directory,
isPhone,
theme,
language,
onClose,
onRestored,
hasUnsavedChanges,
} = props;
const [versions, setVersions] = useState<Version[]>([]);
const [selected, setSelected] = useState<string>();
const [detail, setDetail] = useState<Detail>();
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [restoring, setRestoring] = useState(false);
const [error, setError] = useState('');
const [detailError, setDetailError] = useState(false);
const [limit, setLimit] = useState(20);
const active = useRef(true);
const detailRequest = useRef(0);
const params = { filename, path: directory };
const load = async () => {
setLoading(true);
setError('');
setDetail(undefined);
setSelected(undefined);
detailRequest.current++;
try {
const result = await request.get(`${config.apiPrefix}scripts/history`, {
params,
});
if (result.code !== 200) throw new Error(result.message);
if (!active.current) return;
setVersions(result.data.versions);
setLimit(result.data.limit);
setSelected(result.data.versions[0]?.id);
} catch (e: any) {
if (active.current)
setError(e?.response?.data?.message || intl.get('加载失败'));
} finally {
if (active.current) setLoading(false);
}
};
const loadDetail = async (id: string) => {
const sequence = ++detailRequest.current;
setDetail(undefined);
setDetailLoading(true);
setDetailError(false);
try {
const result = await request.get(
`${config.apiPrefix}scripts/history/detail`,
{ params: { ...params, id } },
);
if (result.code !== 200) throw new Error(result.message);
if (active.current && sequence === detailRequest.current)
setDetail(result.data);
} catch {
if (active.current && sequence === detailRequest.current)
setDetailError(true);
} finally {
if (active.current && sequence === detailRequest.current)
setDetailLoading(false);
}
};
useEffect(() => {
active.current = true;
load();
return () => {
active.current = false;
detailRequest.current++;
};
}, []);
useEffect(() => {
if (selected) loadDetail(selected);
}, [selected]);
const restore = () => {
if (!detail) return;
if (hasUnsavedChanges()) {
message.warning(intl.get('请先保存或取消当前修改,再恢复历史版本'));
return;
}
Modal.confirm({
title: intl.get('恢复此版本'),
content: (
<>
<p>{versionLabel(detail.version)}</p>
<p>{intl.get('当前已保存的内容会保留在历史版本中。')}</p>
</>
),
okText: intl.get('恢复此版本'),
onOk: async () => {
if (hasUnsavedChanges()) {
message.warning(intl.get('请先保存或取消当前修改,再恢复历史版本'));
throw new Error('Unsaved changes');
}
setRestoring(true);
try {
const result = await request.put(
`${config.apiPrefix}scripts/history/restore`,
{
...params,
id: detail.version.id,
expectedHash: detail.currentHash,
},
);
if (result.code !== 200) throw new Error(result.message);
if (!active.current) return;
onRestored(result.data.content);
if (result.data.cleanupPending) {
message.warning(
intl.get('已保存,但旧历史清理失败,请检查存储权限和空间'),
);
} else {
message.success(intl.get('已恢复,恢复前的内容已保留'));
}
await load();
} catch (e: any) {
if (e?.response?.status === 409) {
setDetail(undefined);
setDetailError(true);
}
throw e;
} finally {
if (active.current) setRestoring(false);
}
},
});
};
const identical = detail?.version.content === detail?.current;
return (
<Drawer
title={
<Space direction="vertical" size={0}>
<span>{intl.get('历史版本')}</span>
<Typography.Text
type="secondary"
style={{ fontSize: 12, wordBreak: 'break-all' }}
>
{directory ? `${directory}/` : ''}
{filename}
</Typography.Text>
</Space>
}
open
width={isPhone ? '100%' : 'min(1100px, 90vw)'}
onClose={restoring ? undefined : onClose}
closable={!restoring}
maskClosable={!restoring}
keyboard={!restoring}
bodyStyle={{
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: isPhone ? 16 : 24,
}}
footer={
<div className={styles.footer}>
<Typography.Text type="secondary">
{intl.get(
'最多保留 {count} 个历史版本,容量不足时自动清理旧历史。',
{ count: limit },
)}
</Typography.Text>
<Button
type="primary"
onClick={restore}
loading={restoring}
disabled={!detail || detailLoading || identical}
>
{intl.get('恢复此版本')}
</Button>
</div>
}
>
<Alert
type="info"
showIcon
message={intl.get(
'仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。',
)}
/>
{loading ? (
<Spin />
) : error ? (
<Alert
type="error"
message={error}
action={<Button onClick={load}>{intl.get('重试')}</Button>}
/>
) : versions.length === 0 ? (
<Empty
description={intl.get(
'暂无历史版本,下次保存修改时会自动保留当前内容',
)}
/>
) : (
<>
<Select
aria-label={intl.get('选择历史版本')}
value={selected}
onChange={setSelected}
disabled={restoring}
options={versions.map((v) => ({
value: v.id,
label: versionLabel(v),
}))}
/>
{detailLoading ? (
<Spin />
) : detailError ? (
<Alert
type="error"
message={intl.get('历史版本加载失败,请刷新重试')}
action={<Button onClick={load}>{intl.get('重试')}</Button>}
/>
) : (
detail && (
<>
<div className={styles.labels}>
<span>
{isPhone ? '− ' : ''}
{intl.get('历史版本')}
</span>
<span>
{isPhone ? '+ ' : ''}
{intl.get('当前已保存文件')}
</span>
</div>
{identical && (
<Alert type="success" message={intl.get('与当前文件一致')} />
)}
<div className={styles.diff}>
{isPhone ? (
<ReactDiffViewer
oldValue={detail.version.content}
newValue={detail.current}
splitView={false}
disableWordDiff
useDarkTheme={theme.includes('dark')}
styles={{
contentText: {
wordBreak: 'break-all',
whiteSpace: 'pre-wrap',
},
}}
/>
) : (
<DiffEditor
language={language}
original={detail.version.content}
modified={detail.current}
theme={theme}
options={{
readOnly: true,
originalEditable: false,
fontSize: 12,
minimap: { enabled: false },
scrollBeyondLastLine: false,
renderSideBySide: true,
automaticLayout: true,
}}
/>
)}
</div>
</>
)
)}
</>
)}
</Drawer>
);
}
+47 -94
View File
@@ -10,7 +10,6 @@ import {
DeleteOutlined,
EditOutlined,
EllipsisOutlined,
HistoryOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
@@ -41,8 +40,6 @@ import { useHotkeys } from 'react-hotkeys-hook';
import intl from 'react-intl-universal';
import SplitPane from 'react-split-pane';
import EditModal from './editModal';
import HistoryDrawer from './historyDrawer';
import { saveWithHistory } from './saveWithHistory';
import EditScriptNameModal from './editNameModal';
import styles from './index.module.less';
import RenameModal from './renameModal';
@@ -68,26 +65,6 @@ const Script = () => {
const [currentNode, setCurrentNode] = useState<any>();
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
const [showMonaco, setShowMonaco] = useState(true);
const [historyVisible, setHistoryVisible] = useState(false);
const [saving, setSaving] = useState(false);
const savingRef = useRef(false);
const savedContentRef = useRef('');
const currentKeyRef = useRef(currentNode?.key);
currentKeyRef.current = currentNode?.key;
useEffect(() => {
setHistoryVisible(false);
}, [currentNode?.key]);
const hasUnsavedChanges = () => {
const content =
!isPhone && editorRef.current ? editorRef.current.getValue() : value;
return (
isEditing &&
content.replace(/\r\n/g, '\n') !==
savedContentRef.current.replace(/\r\n/g, '\n')
);
};
const handleIsEditing = (filename: string, value: boolean) => {
setIsEditing(value && canPreviewInMonaco(filename));
@@ -112,11 +89,10 @@ const Script = () => {
.get(
`${config.apiPrefix}scripts/detail?file=${encodeURIComponent(
node.title,
)}&path=${encodeURIComponent(node.parent || '')}`,
)}&path=${node.parent || ''}`,
)
.then(({ code, data }) => {
if (code === 200 && currentKeyRef.current === node.key) {
savedContentRef.current = data;
if (code === 200) {
setValue(data);
if (options.callback) {
options.callback();
@@ -203,7 +179,7 @@ const Script = () => {
const currentContent = editorRef.current
? editorRef.current.getValue().replace(/\r\n/g, '\n')
: value;
const originalContent = savedContentRef.current.replace(/\r\n/g, '\n');
const originalContent = value.replace(/\r\n/g, '\n');
if (currentContent !== originalContent && isEditing) {
Modal.confirm({
@@ -271,36 +247,42 @@ const Script = () => {
getDetail(currentNode);
};
const saveFile = async () => {
if (savingRef.current || !currentNode) return;
const node = currentNode;
const content =
!isPhone && editorRef.current
? editorRef.current.getValue().replace(/\r\n/g, '\n')
: value;
savingRef.current = true;
setSaving(true);
try {
const result = await saveWithHistory(
'put',
`${config.apiPrefix}scripts`,
{
filename: node.title,
path: node.parent || '',
content,
},
);
if (result?.code === 200 && currentKeyRef.current === node.key) {
savedContentRef.current = content;
setValue(content);
handleIsEditing(node.title, false);
}
} catch {
// The shared HTTP handler presents the server's error; keep the draft.
} finally {
savingRef.current = false;
setSaving(false);
}
const saveFile = () => {
Modal.confirm({
title: intl.get('确认保存'),
content: (
<>
{intl.get('确认保存文件')}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{' '}
{currentNode.title}
</Text>
{intl.get(',保存后不可恢复')}
</>
),
onOk() {
const content = editorRef.current
? editorRef.current.getValue().replace(/\r\n/g, '\n')
: value;
return new Promise((resolve, reject) => {
request
.put(`${config.apiPrefix}scripts`, {
filename: currentNode.title,
path: currentNode.parent || '',
content,
})
.then(({ code, data }) => {
if (code === 200) {
message.success(intl.get('保存成功'));
setValue(content);
handleIsEditing(currentNode.title, false);
}
resolve(null);
})
.catch((e) => reject(e));
});
},
});
};
const deleteFile = () => {
@@ -566,20 +548,8 @@ const Script = () => {
</>
}
loading={loading}
extra={[
...(currentNode?.type === 'file' && showMonaco
? [
<Button
key="history"
icon={<HistoryOutlined />}
disabled={saving}
onClick={() => setHistoryVisible(true)}
>
{!isPhone && intl.get('历史版本')}
</Button>,
]
: []),
...(isPhone
extra={
isPhone
? [
<TreeSelect
treeExpandAction="click"
@@ -592,7 +562,7 @@ const Script = () => {
treeNodeFilterProp="title"
showSearch
allowClear
onSelect={(key, node) => onTreeSelect([key], { node })}
onSelect={onSelect}
/>,
<Dropdown menu={menu} trigger={['click']}>
<Button type="primary" icon={<EllipsisOutlined />} />
@@ -600,7 +570,7 @@ const Script = () => {
]
: isEditing
? [
<Button type="primary" onClick={saveFile} loading={saving}>
<Button type="primary" onClick={saveFile}>
{intl.get('保存')}
</Button>,
<Button type="primary" onClick={cancelEdit}>
@@ -655,29 +625,12 @@ const Script = () => {
>
{intl.get('调试')}
</Button>,
]),
]}
]
}
header={{
style: headerStyle,
}}
>
{historyVisible && currentNode && (
<HistoryDrawer
key={currentNode.key}
filename={currentNode.title}
directory={currentNode.parent || ''}
isPhone={isPhone}
theme={theme}
language={mode}
hasUnsavedChanges={hasUnsavedChanges}
onClose={() => setHistoryVisible(false)}
onRestored={(content) => {
savedContentRef.current = content;
setValue(content);
setIsEditing(false);
}}
/>
)}
<div className={`${styles['log-container']} log-container`}>
{!isPhone && (
/*// @ts-ignore*/
@@ -729,7 +682,7 @@ const Script = () => {
value={value}
theme={theme}
options={{
readOnly: !isEditing || saving,
readOnly: !isEditing,
fontSize: 12,
lineNumbersMinChars: 3,
glyphMargin: false,
@@ -751,7 +704,7 @@ const Script = () => {
mode ? [langs[mode as keyof typeof langs]()] : undefined
}
theme={theme.includes('dark') ? 'dark' : 'light'}
readOnly={!isEditing || saving}
readOnly={!isEditing}
onChange={(value) => {
setValue(value);
}}
+12 -16
View File
@@ -3,7 +3,6 @@ import React, { useEffect, useState } from 'react';
import { Modal, message, Input, Form } from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import { saveWithHistory } from './saveWithHistory';
const SaveModal = ({
file,
@@ -17,21 +16,18 @@ const SaveModal = ({
const handleOk = async (values: any) => {
setLoading(true);
const payload = {
...values,
originFilename: file.title,
content: file.content,
};
try {
const result = await saveWithHistory(
'post',
`${config.apiPrefix}scripts`,
payload,
);
if (result?.code === 200) handleCancel(result.data);
} finally {
setLoading(false);
}
const payload = { ...values, originFilename: file.title, content: file.content };
request
.post(`${config.apiPrefix}scripts`, payload)
.then(({ code, data }) => {
if (code === 200) {
message.success(intl.get('保存文件成功'));
handleCancel(data);
}
})
.finally(() => {
setLoading(false);
});
};
return (
-74
View File
@@ -1,74 +0,0 @@
import { Modal, message } from 'antd';
import intl from 'react-intl-universal';
import { IResponseData, request } from '@/utils/http';
/** Shared by the editor and debugger, so neither can silently skip history. */
export async function saveWithHistory(
method: 'put' | 'post',
url: string,
payload: Record<string, unknown>,
): Promise<IResponseData | undefined> {
const send = async (options: Record<string, unknown> = {}) => {
const result = await request[method](
url,
{ ...payload, ...options },
{
onError: (response) => Promise.reject({ response }),
},
);
if (result.code !== 200)
throw new Error(result.message || intl.get('保存失败'));
return result;
};
const report = (error: any) =>
message.error(
error?.response?.data?.message || error?.message || intl.get('保存失败'),
);
let result: IResponseData | undefined;
try {
result = await send();
} catch (error: any) {
const response = error?.response;
if (response?.status !== 413 || !response.data?.historyUnavailable) {
report(error);
return undefined;
}
result = await new Promise<IResponseData | undefined>((resolve) => {
const confirmation = Modal.confirm({
title: intl.get('不记录历史直接保存?'),
content: intl.get(
'当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。',
),
okText: intl.get('仍然保存'),
maskClosable: false,
onCancel: () => resolve(undefined),
onOk: async () => {
confirmation.update({
cancelButtonProps: { disabled: true },
keyboard: false,
});
try {
const saved = await send({
skipHistory: true,
expectedHash: response.data.currentHash,
});
resolve(saved);
} catch (failure) {
report(failure);
// Close the confirmation and release the editor. A conflict needs a
// fresh read, not another retry with the stale confirmation hash.
resolve(undefined);
}
},
});
});
}
if (result?.data?.historyRecorded === false) {
message.warning(intl.get('已保存,本次未记录历史版本'));
} else if (result?.data?.cleanupPending) {
message.warning(intl.get('已保存,但旧历史清理失败,请检查存储权限和空间'));
} else if (result) {
message.success(intl.get('保存成功'));
}
return result;
}
+51
View File
@@ -0,0 +1,51 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { createCronJob } = require('../../back/shared/cronScheduler');
const { isValidCronSchedule } = require('../../back/shared/cronSchedule');
// node-cron caches timezone formatters; model each deployment in a fresh process.
if (!process.env.QL_MACRO_TEST_TZ) {
for (const tz of ['UTC', 'Asia/Shanghai']) {
test(`macro execution in ${tz}`, () => {
const result = require('node:child_process').spawnSync(process.execPath,
['-r', 'ts-node/register/transpile-only', '--test', __filename],
{ env: { ...process.env, TZ: tz, QL_MACRO_TEST_TZ: tz }, encoding: 'utf8' });
assert.equal(result.status, 0, result.stdout + result.stderr);
});
}
}
for (const tz of process.env.QL_MACRO_TEST_TZ ? [process.env.QL_MACRO_TEST_TZ] : []) {
for (const [macro, start, next] of [
['@annually', [2026, 11, 31, 23, 59, 59], [[2027, 0, 1, 0, 0, 0], [2028, 0, 1, 0, 0, 0]]],
['@midnight', [2026, 8, 27, 23, 59, 59], [[2026, 8, 28, 0, 0, 0], [2026, 8, 29, 0, 0, 0]]],
['@minutely', [2026, 8, 27, 12, 34, 59], [[2026, 8, 27, 12, 35, 0], [2026, 8, 27, 12, 36, 0]]],
]) {
test(`${macro} fires at the expected local times in ${tz}`, async (t) => {
const previousTZ = process.env.TZ;
process.env.TZ = tz;
t.after(() => { if (previousTZ === undefined) delete process.env.TZ; else process.env.TZ = previousTZ; });
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date(...start) });
const expected = next.map((parts) => new Date(...parts).getTime());
const actual = [];
assert.equal(isValidCronSchedule(` ${macro} `), true);
const job = createCronJob(macro, (date) => actual.push(date.getTime()), {
name: macro, logger: { warn() {}, error() {} },
});
t.after(() => job.cancel());
for (const time of expected) {
t.mock.timers.tick(time - Date.now() - 1);
await new Promise(setImmediate);
assert.equal(actual.includes(time), false, 'must not fire early');
t.mock.timers.tick(1);
await new Promise(setImmediate);
}
assert.deepEqual(actual, expected);
});
}
}
test('new aliases do not enable unknown macros or raw parser extensions', () => {
for (const schedule of ['@annuallyx', '@midnightx', '@minutelyx', '@secondly', '@reboot', 'H * * * *', '0 /5 * * * *']) {
assert.equal(isValidCronSchedule(schedule), false, schedule);
}
});
+63 -35
View File
@@ -1,41 +1,69 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const nodeSchedule = require('node-schedule');
const { isValidCronSchedule } = require('../../back/shared/cronSchedule');
const { createCronJob } = require('../../back/shared/cronScheduler');
const legacy = require('../fixtures/legacy-cron.json');
const logger = { warn() {}, error() {} };
const flush = () => new Promise(setImmediate);
test('validation agrees with the actual scheduler across legacy cron syntax', () => {
const candidates = new Set([
'*', '0', '?', '0 0', '0 0 *', '0 0 * *',
'@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight',
'@hourly', '@secondly', '@minutely', '@weekdays', '@weekends', '@reboot',
'0 0 1 1 * 2027', '0 0 0 L * *', '0 0 0 * * 5L',
'0 0 0 * * MON#2', '0 0 12 LW * *', '0 0 12 15W * *',
'0 0 0 ? * MON', '0 0/30 * * * ?', '0 /5 * * * *',
]);
const fields = [
['*', '?', '/5', '*/5', '0/5', '0', '01', '1-5', '1,3', '5-1', 'H', 'H/5', 'H(0-10)', 'L'],
['*', '?', '/5', '*/5', '0/5', '0', '01', '1-5', '1,3', '5-1', 'H', 'H/5', 'H(0-10)', 'L'],
['*', '?', '/2', '*/2', '0/2', '0', '01', '1-5', '1,3', '23-2', 'H', 'L'],
['*', '?', '/2', '*/2', '1/2', '1', '01', '1-5', '1,3', 'L', 'L-1', 'LW', '15W', 'H'],
['*', '?', '/2', '*/2', '1/2', '1', '01', '1-5', '1,3', 'JAN', 'jan', 'JAN-MAR', 'DEC-FEB', 'H'],
['*', '?', '/2', '*/2', '0/2', '0', '7', '01', '1-5', '1,3', 'MON', 'mon', 'MON-FRI', 'FRI-MON', '5L', 'L', 'MON#2', '1#5', 'H'],
];
fields.forEach((options, index) => options.forEach((field) => {
const values = ['0', '0', '0', '*', '*', '*'];
values[index] = field;
candidates.add(values.join(' '));
}));
for (const value of [...candidates]) {
if (value.split(' ').length === 6) {
candidates.add(` ${value} `);
candidates.add(value.split(' ').slice(1).join(' '));
}
test('validation preserves legacy syntax except the explicitly added macros', () => {
const addedMacros = new Set(['@annually', '@midnight', '@minutely']);
assert.ok(legacy.validation.length > 250);
for (const { schedule, accepted } of legacy.validation) {
assert.equal(isValidCronSchedule(schedule), accepted || addedMacros.has(schedule), schedule);
}
for (const value of candidates) {
const job = nodeSchedule.scheduleJob(value, () => {});
const accepted = Boolean(job);
job?.cancel();
assert.equal(isValidCronSchedule(value), accepted, value);
}
assert.ok(candidates.size > 250);
});
for (const sample of legacy.times) {
test(`calendar compatibility: ${sample.schedule} from ${sample.currentDate}`, async (t) => {
const previousTZ = process.env.TZ;
process.env.TZ = 'UTC';
t.after(() => { if (previousTZ === undefined) delete process.env.TZ; else process.env.TZ = previousTZ; });
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date(sample.currentDate) });
const actual = [];
const job = createCronJob(sample.schedule, (date) => actual.push(date.toISOString()), { name: 'calendar', logger });
t.after(() => job.cancel());
for (const expected of sample.next) {
t.mock.timers.tick(new Date(expected).getTime() - Date.now());
await flush();
}
assert.deepEqual(actual, sample.next);
});
}
test('late callbacks catch up exactly once and cancellation destroys native tasks', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date('2026-09-27T00:00:00Z') });
const native = require('node-cron');
const before = native.getTasks().size;
const dates = [], warnings = [];
const job = createCronJob('* * * * * *', (date) => dates.push(date.getTime()), {
name: 'late', logger: { warn: (...args) => warnings.push(args), error() {} },
});
t.after(() => job.cancel());
t.mock.timers.tick(8000);
await flush();
assert.equal(dates.length, 8);
assert.equal(new Set(dates).size, 8);
assert.ok(warnings.length > 0);
job.cancel();
t.mock.timers.tick(5000);
await flush();
assert.equal(dates.length, 8);
assert.equal(native.getTasks().size, before);
});
test('callback rejection is logged and future executions continue', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date('2026-09-27T00:00:00Z') });
const errors = [];
let count = 0;
const job = createCronJob('* * * * * *', async () => { count++; throw Error('callback failed'); }, {
name: 'failure', logger: { warn() {}, error: (...args) => errors.push(args) },
});
t.after(() => job.cancel());
t.mock.timers.tick(1000);
await flush();
t.mock.timers.tick(1000);
await flush();
assert.equal(count, 2);
assert.equal(errors.length, 2);
});
+9 -1
View File
@@ -25,6 +25,14 @@ test('system and node modes route extended cron once and keep portable rules in
const rows = [
{ id: 'plain', schedule: '*/5 0-23 * * 1,3' },
{ id: 'spaces', schedule: ' 0\t0 * * * ' },
{ id: 'numeric-step', schedule: '0/5 * * * *' },
{ id: 'sunday-seven', schedule: '0 0 * * 7' },
{ id: 'day-step', schedule: '0 0 */2 * *' },
{ id: 'restricted-days', schedule: '0 0 1-31 * 1' },
{ id: 'restricted-week', schedule: '0 0 1 * 0-6' },
{ id: 'annually', schedule: '@annually' },
{ id: 'midnight', schedule: '@midnight' },
{ id: 'minutely', schedule: '@minutely' },
{ id: 'seconds', schedule: '0 0 * * * *' },
{ id: 'question', schedule: '0 0 * * ?' },
{ id: 'last', schedule: '0 0 L * *' },
@@ -43,7 +51,7 @@ test('system and node modes route extended cron once and keep portable rules in
}
await service.setCrontab({ data: rows, total: rows.length });
assert.equal(installs, mode === 'system' ? 1 : 0);
for (const id of ['seconds', 'question', 'last', 'nth', 'named', 'macro', 'short', 'extra', 'once', 'boot']) {
for (const id of ['numeric-step', 'sunday-seven', 'day-step', 'restricted-days', 'restricted-week', 'annually', 'midnight', 'minutely', 'seconds', 'question', 'last', 'nth', 'named', 'macro', 'short', 'extra', 'once', 'boot']) {
const line = file.split('\n').find((line) => line.endsWith(`task ${id}.js`));
assert.ok(line.startsWith('# '), `${mode}: ${id} cannot also run from system crontab`);
}
+8 -2
View File
@@ -4,6 +4,8 @@ const { format } = require('node:util');
const load = require('../helpers/load-security-module.cjs');
const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness');
const { getTasks } = require('node-cron');
const legacySchedules = [
'* * * * *',
'*/5 * * * *',
@@ -54,8 +56,11 @@ test('legacy main and extra schedules restore real jobs and healthy readiness',
assert.equal(stacks.size, crons.length);
for (const jobs of stacks.values()) {
assert.equal(jobs.length, 2);
assert.ok(jobs.every((job) => job.nextInvocation()));
assert.ok(jobs.every((job) => typeof job.cancel === 'function'));
}
const nativeJobs = [...getTasks().values()];
assert.equal(nativeJobs.length, crons.length * 2);
assert.ok(nativeJobs.every((job) => job.getStatus() !== 'stopped'));
const previousJobs = [...stacks.values()].flat();
for (const schedule of ['@bogus', '0 /5 * * * ?', '0 70 * * * ?']) {
for (const invalid of [
@@ -64,7 +69,8 @@ test('legacy main and extra schedules restore real jobs and healthy readiness',
]) {
await assert.rejects(register([invalid]), (error) => error.code === 3);
assert.deepEqual([...stacks.values()].flat(), previousJobs);
assert.ok(previousJobs.every((job) => job.nextInvocation()));
assert.deepEqual([...getTasks().values()], nativeJobs);
assert.ok(nativeJobs.every((job) => job.getStatus() !== 'stopped'));
}
}
});
+65
View File
@@ -0,0 +1,65 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const load = require('../helpers/load-security-module.cjs');
const { createCronJob } = require('../../back/shared/cronScheduler');
const logger = { info() {}, warn() {}, error() {} };
test('native calendar skips nonexistent spring time and duplicate fall hour', async (t) => {
const previous = process.env.TZ;
process.env.TZ = 'America/New_York';
t.after(() => { if (previous === undefined) delete process.env.TZ; else process.env.TZ = previous; });
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date('2026-03-08T05:00:00Z') });
const springRuns = [];
const spring = createCronJob('0 30 2 * * *', (date) => springRuns.push(date.toISOString()), { name: 'spring', logger });
t.after(() => spring.cancel());
t.mock.timers.tick(new Date('2026-03-09T06:30:00Z') - Date.now());
await new Promise(setImmediate);
assert.deepEqual(springRuns, ['2026-03-09T06:30:00.000Z']);
spring.cancel();
t.mock.timers.setTime(new Date('2026-11-01T05:30:00Z').getTime());
const fallRuns = [];
const fall = createCronJob('0 30 1 * * *', (date) => fallRuns.push(date.toISOString()), { name: 'fall', logger });
t.after(() => fall.cancel());
t.mock.timers.tick(new Date('2026-11-02T06:30:00Z') - Date.now());
await new Promise(setImmediate);
assert.deepEqual(fallRuns, ['2026-11-02T06:30:00.000Z']);
});
test('construction failure cleans staged jobs and preserves the running snapshot', async () => {
const cancelled = [];
const stacks = new Map([['old', [{ cancel: () => cancelled.push('old') }]]]);
let count = 0;
const { addCron } = load('back/schedule/addCron.ts', {
'./data': { scheduleStacks: stacks },
'../shared/cronScheduler': { createCronJob: () => {
if (++count === 2) throw Error('construction failed');
return { start() { throw Error('must not start'); }, cancel: () => cancelled.push('staged') };
} },
'../shared/runCron': {}, '../loaders/logger': logger, '../shared/i18n': { tf: require('node:util').format },
});
await assert.rejects(new Promise((resolve, reject) => addCron({ request: {
replace: true, crons: [{ id: 'new', schedule: '* * * * *', extra_schedules: [{ schedule: '0 * * * *' }] }],
} }, (error) => error ? reject(error) : resolve())), /construction failed/);
assert.deepEqual([...stacks.keys()], ['old']);
assert.deepEqual(cancelled, ['staged']);
});
test('invalid subscription schedules are isolated and immediate runs without a schedule survive', async () => {
const warnings = [], runs = [];
const Service = load('back/services/schedule.ts', {
typedi: { Service: () => (x) => x, Inject: () => () => {} },
'../shared/pLimit': {},
}).default;
const service = new Service({ ...logger, warn: (...args) => warnings.push(args) });
service.runTask = async (...args) => { runs.push(args); };
const item = { id: 123, name: 'subscription', command: 'true', runOrigin: 'subscription' };
await service.createCronTask({ ...item, schedule: 'not a cron' });
assert.equal(warnings.length, 1);
assert.equal(service.scheduleStacks.size, 0);
await service.createCronTask(item, {}, true);
assert.equal(runs.length, 1);
await service.createCronTask({ ...item, schedule: '* * * * *' });
assert.equal(service.scheduleStacks.size, 1);
await service.cancelCronTask(item);
assert.equal(service.scheduleStacks.size, 0);
});
+29
View File
@@ -65,6 +65,35 @@ test('config, environment, cwd, and executable changes invalidate discovery', (t
f.run();
assert.equal(f.count(), before + 1);
});
test('alternating Node/crond working directories retain independent cached roots', (t) => {
const f = fixture(t);
fs.mkdirSync(path.join(f.root, 'child'));
fs.writeFileSync(path.join(f.root, 'child', '.npmrc'), 'global-dir=/child\n');
for (let i = 0; i < 4; i++) {
assert.equal(f.run({ ANSWER: '/parent/modules' }).stdout.trim(), '/parent/modules');
assert.equal(
f.run({ ANSWER: '/child/modules' }, 'cd child; ' + f.command).stdout.trim(),
'/child/modules',
);
}
assert.equal(f.count(), 2, 'each working directory should discover only once');
assert.equal(fs.readdirSync(f.env.dir_tmp).filter((x) => x.endsWith('.cache')).length, 2);
});
test('refresh retires old per-environment records without removing locks or unrelated files', (t) => {
const f = fixture(t);
fs.mkdirSync(f.env.dir_tmp);
const prefix = `pnpm-root-${process.getuid()}`;
const names = [`${prefix}-1-2.cache`, `${prefix}.lock`, 'unrelated.cache'];
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
for (const name of names) {
const file = path.join(f.env.dir_tmp, name);
fs.writeFileSync(file, '');
fs.utimesSync(file, old, old);
}
assert.equal(f.run().status, 0);
assert.equal(fs.existsSync(path.join(f.env.dir_tmp, names[0])), false);
for (const name of names.slice(1)) assert.ok(fs.existsSync(path.join(f.env.dir_tmp, name)));
});
test('expired or malformed records refresh and failed lookups are not cached', (t) => {
const f = fixture(t);
f.run();
+6 -2
View File
@@ -51,7 +51,11 @@ function fixture(t) {
fs.existsSync(env.CALLS)
? fs.readFileSync(env.CALLS, 'utf8').trim().split('\n').length
: 0;
const cache = path.join(env.dir_tmp, `pnpm-root-${process.getuid()}.cache`);
const key = run({}, '. "$HELPER"; ql_node_path_cache_key').stdout.trim();
const cache = path.join(
env.dir_tmp,
`pnpm-root-${process.getuid()}-${key.replace(' ', '-')}.cache`,
);
return {
root,
bin,
@@ -61,7 +65,7 @@ function fixture(t) {
asyncRun,
count,
cache,
lock: cache + '.lock',
lock: path.join(env.dir_tmp, `pnpm-root-${process.getuid()}.lock`),
};
}
function check(r, output = '/test/global/node_modules') {
+168
View File
@@ -0,0 +1,168 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
function fixture(t, language) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-lazy-notify-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const extension = language === 'python' ? 'py' : 'js';
fs.copyFileSync(`shell/preload/sitecustomize.${extension}`, path.join(root, `sitecustomize.${extension}`));
fs.writeFileSync(path.join(root, `env.${extension}`), '');
fs.writeFileSync(path.join(root, 'before.sh'), 'export FROM_SHELL=before\n');
fs.writeFileSync(path.join(root, 'before.js'), 'process.env.FROM_LANGUAGE = "javascript";\n');
fs.writeFileSync(path.join(root, 'task_before.py'), 'import os\nos.environ["FROM_LANGUAGE"] = "python"\n');
fs.writeFileSync(path.join(root, 'client.js'), 'module.exports = { getEnvs: () => "client-ready" };\n');
fs.copyFileSync('shell/preload/client.py', path.join(root, 'client.py'));
fs.copyFileSync('shell/preload/esm-loader.mjs', path.join(root, 'esm-loader.mjs'));
const env = {
...process.env,
PYTHONPATH: root,
PREV_PYTHONPATH: '',
NODE_OPTIONS: '',
PREV_NODE_OPTIONS: '',
QL_NODE_GLOBAL_PATH: '',
file_task_before: path.join(root, 'before.sh'),
file_task_before_js: path.join(root, 'before.js'),
dir_scripts: root,
task_before: 'export FROM_COMMAND=command',
envParam: 'ACCOUNTS',
numParam: '2-3',
ACCOUNTS: 'first&second&third',
};
const write = (name, text) => fs.writeFileSync(path.join(root, name), text);
const run = (body, extensionOverride = extension) => {
const entry = path.join(root, `entry.${extensionOverride}`);
write(path.basename(entry), body);
const python = language === 'python';
const result = spawnSync(python ? 'python3' : process.execPath,
python ? [entry] : ['--require', path.join(root, 'sitecustomize.js'), entry],
{ cwd: root, env, encoding: 'utf8', timeout: 15000 });
assert.equal(result.status, 0, result.stderr + result.stdout);
return result.stdout;
};
return { root, env, write, run };
}
for (const extension of ['js', 'mjs']) {
test(`${extension} startup preserves hooks, environment selection and client without importing notifications`, (t) => {
const f = fixture(t, 'javascript');
f.write('__ql_notify__.js', 'throw Error("notification dependencies must not load at startup");');
f.run(`
const assert = ${extension === 'mjs' ? '(await import("node:assert/strict")).default' : 'require("node:assert/strict")'};
assert.equal(process.env.FROM_SHELL, 'before');
assert.equal(process.env.FROM_LANGUAGE, 'javascript');
assert.equal(process.env.FROM_COMMAND, 'command');
assert.equal(process.env.ACCOUNTS, 'second&third');
assert.equal(QLAPI.getEnvs(), 'client-ready');
assert.equal(typeof QLAPI.notify, 'function');
`, extension);
});
}
test('JavaScript first notification imports once and preserves arguments, this, promises and errors', (t) => {
const f = fixture(t, 'javascript');
f.write('__ql_notify__.js', `
global.notificationLoads = (global.notificationLoads || 0) + 1;
exports.sendNotify = function (...args) {
if (args[0] === 'fail') throw new Error('send failed');
global.notificationResult = Promise.resolve({ args, receiver: this });
return global.notificationResult;
};
`);
f.run(`
const assert = require('node:assert/strict');
assert.equal(global.notificationLoads, undefined);
(async () => {
const options = { channel: 'fixture' };
const promise = QLAPI.notify('title', 'content', options);
assert.equal(promise, global.notificationResult);
const result = await promise;
assert.deepEqual(result.args, ['title', 'content', options]);
assert.equal(result.receiver, QLAPI);
await QLAPI.notify('second');
assert.equal(global.notificationLoads, 1);
assert.throws(() => QLAPI.notify('fail'), /send failed/);
})().catch(error => { console.error(error); process.exitCode = 1; });
`);
});
test('JavaScript reports import failures on notify and permits a later retry', (t) => {
const f = fixture(t, 'javascript');
f.write('__ql_notify__.js', `
if (!process.env.NOTIFY_READY) throw Error('notification dependency unavailable');
exports.sendNotify = (...args) => args;
`);
f.run(`
const assert = require('node:assert/strict');
assert.equal(QLAPI.getEnvs(), 'client-ready');
assert.throws(() => QLAPI.notify('first'), /notification dependency unavailable/);
process.env.NOTIFY_READY = '1';
assert.deepEqual(QLAPI.notify('retry'), ['retry']);
`);
});
test('Python startup preserves hooks, environment selection and client without importing notifications', (t) => {
const f = fixture(t, 'python');
f.write('__ql_notify__.py', 'raise RuntimeError("notification dependencies must not load at startup")\n');
f.run(`
import os, sys
assert os.environ['FROM_SHELL'] == 'before'
assert os.environ['FROM_LANGUAGE'] == 'python'
assert os.environ['FROM_COMMAND'] == 'command'
assert os.environ['ACCOUNTS'] == 'second&third'
assert '__ql_notify__' not in sys.modules
assert callable(QLAPI.notify)
assert callable(QLAPI.getEnvs)
`);
});
test('Python first notification imports once and preserves args, kwargs, return values and errors', (t) => {
const f = fixture(t, 'python');
f.write('__ql_notify__.py', `
import builtins
builtins.notification_loads = getattr(builtins, 'notification_loads', 0) + 1
def send(*args, **kwargs):
if args[0] == 'fail':
raise ValueError('send failed')
return args, kwargs
`);
f.run(`
import builtins, sys
assert '__ql_notify__' not in sys.modules
assert QLAPI.notify('title', 'content', channel='fixture') == (('title', 'content'), {'channel': 'fixture'})
assert QLAPI.notify('second') == (('second',), {})
assert builtins.notification_loads == 1
try:
QLAPI.notify('fail')
except ValueError as error:
assert str(error) == 'send failed'
else:
raise AssertionError('notification exception was swallowed')
`);
});
test('Python reports import failures on notify and permits a later retry', (t) => {
const f = fixture(t, 'python');
f.write('__ql_notify__.py', `
import os
if not os.environ.get('NOTIFY_READY'):
raise RuntimeError('notification dependency unavailable')
def send(*args, **kwargs):
return args
`);
f.run(`
import os, sys
assert '__ql_notify__' not in sys.modules
try:
QLAPI.notify('first')
except RuntimeError as error:
assert str(error) == 'notification dependency unavailable'
else:
raise AssertionError('import exception was swallowed')
os.environ['NOTIFY_READY'] = '1'
assert QLAPI.notify('retry') == ('retry',)
`);
});
@@ -0,0 +1,40 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const grpc = require('@grpc/grpc-js');
const load = require('../helpers/load-security-module.cjs');
const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness');
for (const [name, rows, delay, succeeds] of [
['large distinct snapshot', Array.from({length:10000}, (_, i) => ({id:String(i), schedule:`${i%60} ${Math.floor(i/60)%60} ${Math.floor(i/3600)} * * *`})), 10000, true],
['extra schedules', [{id:'extra', schedule:'* * * * *', extra_schedules:Array.from({length:2000},()=>({schedule:'0 * * * *'}))}], 10000, true],
['unresponsive small write', [{id:'small', schedule:'* * * * *'}], 30000, false],
['unresponsive large write remains bounded', Array.from({length:30000},()=>({schedule:'* * * * *'})), 180000, false],
]) {
test(`recovery deadline: ${name}`, async (t) => {
t.mock.timers.enable({apis:['Date','setTimeout'], now:0});
let calls=0, deadline;
const fake={waitForReady:(_,cb)=>cb(), addCron:(_req,_meta,options,cb)=>{
calls++; deadline=options.deadline;
const budget=deadline-Date.now();
setTimeout(()=>cb(delay<=budget ? null : Object.assign(Error('deadline'),{code:grpc.status.DEADLINE_EXCEEDED}), {}), Math.min(delay,budget));
}};
const client=load('back/schedule/client.ts', {
'../protos/cron':{CronClient:class{constructor(){return fake}}},
'../config':{grpcPort:5500},
'../config/grpcCerts':{getGrpcCerts:()=>({caCert:'ca',clientKey:'key',clientCert:'cert'})},
'@grpc/grpc-js':{...grpc,credentials:{createSsl:()=>({})}},
}).default;
const readiness=new SchedulerReadiness(async()=>{}, 60000);
t.after(()=>clearTimeout(readiness.retry));
client.readiness=readiness;
readiness.configure(()=>client.addCron(rows,true));
const recovering=readiness.recover();
await new Promise(setImmediate);
assert.equal(calls,1);
assert.ok(deadline<=120000);
t.mock.timers.tick(Math.min(delay,deadline));
assert.equal(await recovering,succeeds);
assert.equal(calls,1,'uncertain writes are not replayed inline');
assert.equal(await readiness.check(),succeeds);
});
}
+4 -2
View File
@@ -41,8 +41,10 @@ test('recovery reconciles a surviving scheduler after missed deletes and disable
);
const { addCron } = load('back/schedule/addCron.ts', {
'./data': { scheduleStacks: stacks },
'node-schedule': {
scheduleJob: (id) => ({ cancel: () => cancelled.push(id) }),
'../shared/cronScheduler': {
createCronJob: (_schedule, _callback, options) => ({
start() {}, cancel: () => cancelled.push(options.name.split(':')[0]),
}),
},
'../shared/runCron': {},
'../loaders/logger': { info() {}, warn() {} },
-341
View File
@@ -1,341 +0,0 @@
require('reflect-metadata');
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const express = require('express');
const { errors } = require('celebrate');
const { Container } = require('typedi');
const load = require('../helpers/load-security-module.cjs');
const { resolveFileAccess } = load(
path.join(__dirname, '../../back/shared/fileAccess.ts'),
);
test('script routes save, list, preview and restore, with validation and conflict responses', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-history-api-'));
const scripts = path.join(root, 'scripts');
await fs.mkdir(scripts);
await fs.writeFile(path.join(scripts, 'test.js'), 'before');
const config = {
scriptPath: scripts,
dataPath: root,
bakPath: path.join(root, 'bak'),
blackFileList: ['auth.json'],
writePathList: [scripts],
};
await fs.mkdir(config.bakPath);
let failure = '';
class ScriptService {
checkFilePath(dir, name) {
return resolveFileAccess(scripts, [dir || '', name]);
}
}
const mocks = {
'../config': { default: config, __esModule: true },
'fs/promises': {
...fs,
copyFile: async (from, to) => {
if (failure === 'backup' && path.dirname(to) === config.bakPath)
throw Object.assign(new Error('backup denied'), { code: 'EACCES' });
return fs.copyFile(from, to);
},
writeFile: async (file, ...args) => {
if (failure === 'history' && String(file).endsWith('.pending.tmp'))
throw Object.assign(new Error('history full'), { code: 'ENOSPC' });
return fs.writeFile(file, ...args);
},
},
'../config/util': {
rmPath: (file) => fs.rm(file, { force: true, recursive: true }),
fileExist: async (file) =>
fs.access(file).then(
() => true,
() => false,
),
},
'../services/script': { default: ScriptService, __esModule: true },
'../shared/i18n': { t: (value) => value },
};
const cache = new Map();
const api = load(
path.join(__dirname, '../../back/api/script.ts'),
mocks,
cache,
);
const { default: ScriptHistoryService } = load(
path.join(__dirname, '../../back/services/scriptHistory.ts'),
mocks,
cache,
);
Container.set(ScriptService, new ScriptService());
t.after(() => {
Container.remove(ScriptHistoryService);
Container.remove(ScriptService);
});
assert.ok(Container.has(ScriptHistoryService));
const historyService = Container.get(ScriptHistoryService);
assert.strictEqual(Container.get(ScriptHistoryService), historyService);
// An empty history read must not create storage or other persistent resources.
assert.deepEqual((await historyService.list('', 'test.js')).versions, []);
await assert.rejects(fs.stat(path.join(root, 'script-history')), {
code: 'ENOENT',
});
const app = express();
app.use(express.json({ limit: '50mb' }));
api.default(app);
app.use(errors());
app.use((error, _req, res, _next) =>
res.status(500).json({ error: error.message }),
);
const server = app.listen(0, '127.0.0.1');
await new Promise((resolve) => server.once('listening', resolve));
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
await fs.rm(root, { recursive: true, force: true });
});
const base = `http://127.0.0.1:${server.address().port}`;
const call = async (url, body, method = 'PUT') => {
const res = await fetch(
base + url,
body && {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
},
);
return { status: res.status, body: await res.json() };
};
assert.equal(
(await call('/scripts', { filename: 'test.js', content: 'after' })).body
.code,
200,
);
const list = await call('/scripts/history?filename=test.js');
assert.equal(list.status, 200);
assert.equal(list.body.data.versions.length, 1);
const id = list.body.data.versions[0].id;
const preview = await call(
`/scripts/history/detail?filename=test.js&id=${id}`,
);
assert.equal(preview.body.data.version.content, 'before');
assert.equal(preview.body.data.current, 'after');
assert.equal(
(await call('/scripts/history/restore', { filename: 'test.js', id }))
.status,
400,
);
assert.equal(
(
await call('/scripts/history/restore', {
filename: 'test.js',
id,
expectedHash: '0'.repeat(64),
})
).status,
409,
);
assert.equal(
(
await call('/scripts/history/restore', {
filename: 'test.js',
id,
expectedHash: preview.body.data.currentHash,
})
).status,
200,
);
assert.equal(
await fs.readFile(path.join(scripts, 'test.js'), 'utf8'),
'before',
);
assert.equal(
(await call('/scripts/history?filename=..%2Foutside')).status,
403,
);
assert.equal((await call('/scripts/history?filename=missing')).status, 404);
for (const content of ['debug-C', 'debug-D']) {
const saved = await call(
'/scripts',
{ filename: 'test.js', originFilename: 'test.js', path: '', content },
'POST',
);
assert.equal(saved.status, 200);
assert.equal(saved.body.data.historyRecorded, true);
}
const debugList = await call('/scripts/history?filename=test.js');
const newest = await call(
`/scripts/history/detail?filename=test.js&id=${debugList.body.data.versions[0].id}`,
);
assert.equal(newest.body.data.version.content, 'debug-C');
assert.equal(newest.body.data.current, 'debug-D');
// Both editor PUT and debugger POST support the same explicit opt-out.
for (const method of ['PUT', 'POST']) {
await fs.writeFile(
path.join(scripts, 'large.js'),
'x'.repeat(1024 * 1024 + 1),
);
const payload = { filename: 'large.js', path: '', content: 'small' };
const rejected = await call('/scripts', payload, method);
assert.equal(rejected.status, 413);
assert.equal(rejected.body.historyUnavailable, true);
assert.equal(
(await call('/scripts', { ...payload, skipHistory: true }, method))
.status,
400,
);
const saved = await call(
'/scripts',
{
...payload,
skipHistory: true,
expectedHash: rejected.body.currentHash,
},
method,
);
assert.equal(saved.status, 200);
assert.equal(saved.body.data.historyRecorded, false);
assert.equal(
await fs.readFile(path.join(scripts, 'large.js'), 'utf8'),
'small',
);
}
for (const scenario of ['target-link', 'source-link', 'normalized-path']) {
await t.test(
`save-as preserves aliases of the same file: ${scenario}`,
async () => {
const sourceName = `${scenario}-source.js`;
const aliasName = `${scenario}-alias.js`;
const sourcePath = path.join(scripts, sourceName);
await fs.writeFile(sourcePath, 'source-original');
if (scenario !== 'normalized-path')
await fs.symlink(sourceName, path.join(scripts, aliasName));
const filename =
scenario === 'target-link'
? aliasName
: scenario === 'normalized-path'
? `./${sourceName}`
: sourceName;
const originFilename =
scenario === 'source-link' ? aliasName : sourceName;
const saved = await call(
'/scripts',
{ filename, originFilename, path: '', content: 'source-edited' },
'POST',
);
assert.equal(saved.status, 200);
assert.equal(saved.body.code, 200);
assert.equal(await fs.readFile(sourcePath, 'utf8'), 'source-edited');
assert.equal(
await fs.readFile(path.join(scripts, filename), 'utf8'),
'source-edited',
);
if (scenario !== 'normalized-path') {
assert.equal(
await fs.readlink(path.join(scripts, aliasName)),
sourceName,
);
const sourceHistory = await call(
`/scripts/history?filename=${sourceName}`,
);
const aliasHistory = await call(
`/scripts/history?filename=${aliasName}`,
);
assert.deepEqual(aliasHistory.body.data, sourceHistory.body.data);
}
const list = await call(
`/scripts/history?filename=${encodeURIComponent(filename)}`,
);
assert.equal(list.body.data.versions.length, 1);
const preview = await call(
`/scripts/history/detail?filename=${encodeURIComponent(
filename,
)}&id=${list.body.data.versions[0].id}`,
);
assert.equal(preview.body.data.version.content, 'source-original');
assert.equal(preview.body.data.current, 'source-edited');
},
);
}
await t.test(
'save-as backs up the source and retains destination history before removing the source',
async () => {
await fs.writeFile(path.join(scripts, 'source.js'), 'source-original');
await fs.writeFile(
path.join(scripts, 'destination.js'),
'destination-original',
);
const saved = await call(
'/scripts',
{
filename: 'destination.js',
originFilename: 'source.js',
path: '',
content: 'source-edited',
},
'POST',
);
assert.equal(saved.status, 200);
await assert.rejects(fs.access(path.join(scripts, 'source.js')), {
code: 'ENOENT',
});
assert.equal(
await fs.readFile(path.join(config.bakPath, 'source.js'), 'utf8'),
'source-original',
);
assert.equal(
await fs.readFile(path.join(scripts, 'destination.js'), 'utf8'),
'source-edited',
);
const list = await call('/scripts/history?filename=destination.js');
const preview = await call(
`/scripts/history/detail?filename=destination.js&id=${list.body.data.versions[0].id}`,
);
assert.equal(preview.body.data.version.content, 'destination-original');
},
);
for (const step of ['backup', 'history']) {
await t.test(
`save-as ${step} failure preserves both original scripts`,
async () => {
const sourceName = `${step}-source.js`,
destinationName = `${step}-destination.js`;
await fs.writeFile(path.join(scripts, sourceName), 'source-original');
await fs.writeFile(
path.join(scripts, destinationName),
'destination-original',
);
failure = step;
try {
const saved = await call(
'/scripts',
{
filename: destinationName,
originFilename: sourceName,
path: '',
content: 'source-edited',
},
'POST',
);
assert.equal(saved.status, 500);
assert.equal(
await fs.readFile(path.join(scripts, sourceName), 'utf8'),
'source-original',
);
assert.equal(
await fs.readFile(path.join(scripts, destinationName), 'utf8'),
'destination-original',
);
assert.equal(
(await call(`/scripts/history?filename=${destinationName}`)).body
.data.versions.length,
0,
);
} finally {
failure = '';
}
},
);
}
});
-383
View File
@@ -1,383 +0,0 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { randomBytes } = require('node:crypto');
const load = require('../helpers/load-security-module.cjs');
const source = path.join(__dirname, '../../back/shared/scriptHistory.ts');
const mocks = {
'../config/util': {
fileExist: async (file) =>
fs.access(file).then(
() => true,
() => false,
),
},
};
const { ScriptHistory, SCRIPT_HISTORY_LIMITS } = load(source, mocks);
async function fixture(t, limits = {}, History = ScriptHistory) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-history-'));
t.after(() => fs.rm(root, { force: true, recursive: true }));
const scripts = path.join(root, 'scripts');
const history = path.join(root, 'history');
await fs.mkdir(scripts);
const file = path.join(scripts, 'test.js');
await fs.writeFile(file, 'original\n');
const service = new History(scripts, history, ['auth.json'], {
...SCRIPT_HISTORY_LIMITS,
...limits,
});
return { root, scripts, history, file, service };
}
test('first save, unchanged save, restore and undo restore preserve exact content', async (t) => {
const { service, file, history } = await fixture(t);
assert.deepEqual((await service.list('', 'test.js')).versions, []);
await assert.rejects(fs.stat(history), { code: 'ENOENT' });
await service.save('', 'test.js', 'second\r\n中文');
let list = await service.list('', 'test.js');
assert.equal(list.versions.length, 1);
assert.equal(list.versions[0].source, 'baseline');
assert.equal(
(await service.detail('', 'test.js', list.versions[0].id)).version.content,
'original\n',
);
const archive = path.join(history, (await fs.readdir(history))[0]);
const before = await fs.readFile(archive);
await service.save('', 'test.js', 'second\r\n中文');
assert.deepEqual(
await fs.readFile(archive),
before,
'unchanged save performs no archive write',
);
await service.restore('', 'test.js', list.versions[0].id, list.currentHash);
assert.equal(await fs.readFile(file, 'utf8'), 'original\n');
list = await service.list('', 'test.js');
assert.equal(list.versions[0].source, 'save');
await service.restore('', 'test.js', list.versions[0].id, list.currentHash);
assert.equal(await fs.readFile(file, 'utf8'), 'second\r\n中文');
assert.equal(
(await service.list('', 'test.js')).versions[0].source,
'restore',
);
});
test('conflict check refuses to overwrite a file changed after preview, including external edits', async (t) => {
const { service, file } = await fixture(t);
await service.save('', 'test.js', 'saved');
const list = await service.list('', 'test.js');
await fs.writeFile(file, 'external');
await assert.rejects(
service.restore('', 'test.js', list.versions[0].id, list.currentHash),
{ status: 409 },
);
assert.equal(await fs.readFile(file, 'utf8'), 'external');
await service.save('', 'test.js', 'after external');
const current = await service.list('', 'test.js');
assert.equal(current.versions[0].source, 'external');
assert.equal(
(await service.detail('', 'test.js', current.versions[0].id)).version
.content,
'external',
);
});
test('same-name files are isolated, traversal, blacklist and escaping symlinks are forbidden', async (t) => {
const { service, scripts, root } = await fixture(t);
await fs.mkdir(path.join(scripts, 'nested'));
await fs.writeFile(path.join(scripts, 'nested', 'test.js'), 'nested');
await service.save('nested', 'test.js', 'changed');
assert.equal((await service.list('', 'test.js')).versions.length, 0);
await fs.writeFile(path.join(root, 'outside.js'), 'secret');
await fs.symlink(
path.join(root, 'outside.js'),
path.join(scripts, 'escape.js'),
);
for (const filename of ['../outside.js', 'auth.json', 'escape.js']) {
await assert.rejects(service.save('', filename, 'bad'), { status: 403 });
await assert.rejects(service.list('', filename), { status: 403 });
}
await fs.symlink(
path.join(scripts, 'test.js'),
path.join(scripts, 'alias.js'),
);
await service.save('', 'alias.js', 'via alias');
assert.equal(
(await service.list('', 'test.js')).versions.length,
1,
'safe aliases share one canonical history',
);
});
test('concurrent saves across service instances serialize snapshots without losing content', async (t) => {
const { service, scripts, history, file } = await fixture(t);
const other = new ScriptHistory(scripts, history);
await Promise.all([
service.save('', 'test.js', 'A'),
other.save('', 'test.js', 'B'),
]);
const list = await service.list('', 'test.js');
const contents = await Promise.all(
list.versions.map((v) =>
service.detail('', 'test.js', v.id).then((d) => d.version.content),
),
);
contents.push(await fs.readFile(file, 'utf8'));
assert.deepEqual(contents.sort(), ['A', 'B', 'original\n'].sort());
});
test('retention trims old revisions and bounds file count and global compressed storage', async (t) => {
const { service, scripts, history } = await fixture(t, {
versions: 3,
files: 2,
totalBytes: 1800,
});
for (let i = 0; i < 6; i++) await service.save('', 'test.js', `version ${i}`);
const list = await service.list('', 'test.js');
assert.equal(list.versions.length, 3);
assert.equal(
(await service.detail('', 'test.js', list.versions.at(-1).id)).version
.content,
'version 2',
);
for (let i = 0; i < 5; i++) {
const name = `${i}.js`;
await fs.writeFile(
path.join(scripts, name),
randomBytes(300).toString('hex'),
);
await service.save('', name, 'new');
}
const files = await fs.readdir(history);
assert.ok(files.length <= 2);
let total = 0;
for (const name of files)
total += (await fs.stat(path.join(history, name))).size;
assert.ok(total <= 1800);
assert.equal((await service.list('', '4.js')).versions.length, 1);
});
test('invalid text, oversized files and corrupted archives fail without overwriting scripts', async (t) => {
const { service, file, history } = await fixture(t, { fileBytes: 100 });
await assert.rejects(service.save('', 'test.js', 'a'.repeat(101)), {
status: 413,
});
await fs.writeFile(file, Buffer.from([0xff, 0xfe]));
await assert.rejects(service.save('', 'test.js', 'new'), { status: 413 });
await fs.writeFile(file, 'before');
await service.save('', 'test.js', 'after');
await fs.writeFile(
path.join(history, (await fs.readdir(history))[0]),
'corrupt',
);
await assert.rejects(service.save('', 'test.js', 'replacement'), {
status: 500,
});
assert.equal(await fs.readFile(file, 'utf8'), 'after');
});
test('archive failure prevents overwrite; file-write failure retains snapshot and releases locks', async (t) => {
let fail = 'archive';
const { ScriptHistory: FailingHistory } = load(source, {
...mocks,
'fs/promises': {
...fs,
writeFile: async (file, ...args) => {
if (fail === 'archive' && String(file).endsWith('.pending.tmp')) {
throw Object.assign(new Error('disk full'), { code: 'ENOSPC' });
}
return fs.writeFile(file, ...args);
},
rename: async (from, to) => {
if (fail === 'script' && String(to).endsWith('test.js'))
throw Object.assign(new Error('commit failed'), { code: 'ENOSPC' });
return fs.rename(from, to);
},
},
});
const { service, file } = await fixture(t, {}, FailingHistory);
await assert.rejects(service.save('', 'test.js', 'new'), { code: 'ENOSPC' });
assert.equal(await fs.readFile(file, 'utf8'), 'original\n');
fail = 'script';
await assert.rejects(service.save('', 'test.js', 'new'), { code: 'ENOSPC' });
assert.equal((await service.list('', 'test.js')).versions.length, 0);
assert.equal(await fs.readFile(file, 'utf8'), 'original\n');
fail = '';
await service.save('', 'test.js', 'success');
assert.equal(await fs.readFile(file, 'utf8'), 'success');
assert.equal((await service.list('', 'test.js')).versions.length, 1);
});
test('empty content is a valid recoverable version and missing versions return 404', async (t) => {
const { service, file } = await fixture(t);
await service.save('', 'test.js', '');
await service.save('', 'test.js', 'again');
const list = await service.list('', 'test.js');
const empty = list.versions[0];
assert.equal(empty.size, 0);
await service.restore('', 'test.js', empty.id, list.currentHash);
assert.equal(await fs.readFile(file, 'utf8'), '');
await assert.rejects(service.detail('', 'test.js', 'unknown'), {
status: 404,
});
});
test('quota eviction never happens on failed preparation or failed commit', async (t) => {
let failure = '';
const { ScriptHistory: Faulty } = load(source, {
...mocks,
'fs/promises': {
...fs,
writeFile: async (file, ...args) => {
if (
failure === 'archive-write' &&
String(file).endsWith('.pending.tmp')
)
throw Object.assign(new Error('full'), { code: 'ENOSPC' });
if (
failure === 'script-write' &&
path.basename(file).startsWith('.ql-save-')
)
throw Object.assign(new Error('full'), { code: 'ENOSPC' });
return fs.writeFile(file, ...args);
},
rename: async (from, to) => {
if (failure === 'archive-commit' && String(to).endsWith('.br'))
throw Object.assign(new Error('denied'), { code: 'EACCES' });
if (failure === 'script-commit' && String(to).endsWith('.js'))
throw Object.assign(new Error('denied'), { code: 'EACCES' });
return fs.rename(from, to);
},
},
});
const { service, scripts, history } = await fixture(t, { files: 1 }, Faulty);
await service.save('', 'test.js', 'saved');
const archive = path.join(history, (await fs.readdir(history))[0]);
const original = await fs.readFile(archive);
await fs.writeFile(path.join(scripts, 'other.js'), 'other-original');
for (const step of [
'archive-write',
'script-write',
'archive-commit',
'script-commit',
]) {
failure = step;
await assert.rejects(service.save('', 'other.js', 'other-changed'), {
code: step.endsWith('write') ? 'ENOSPC' : 'EACCES',
});
assert.deepEqual(await fs.readFile(archive), original, step);
assert.equal(
await fs.readFile(path.join(scripts, 'other.js'), 'utf8'),
'other-original',
);
assert.equal((await fs.readdir(history)).length, 1);
assert.equal(
(await fs.readdir(scripts)).length,
2,
'no script staging leftovers',
);
}
// Updating an existing history also restores its exact prior bytes if the
// script rename fails, including revisions that retention would have pruned.
failure = 'script-commit';
await assert.rejects(service.save('', 'test.js', 'changed'), {
code: 'EACCES',
});
assert.deepEqual(await fs.readFile(archive), original);
assert.equal(
await fs.readFile(path.join(scripts, 'test.js'), 'utf8'),
'saved',
);
failure = '';
await service.save('', 'other.js', 'other-changed');
assert.equal((await service.list('', 'test.js')).versions.length, 0);
});
test('cleanup errors report a successful save with a warning and are retried on the next save', async (t) => {
let failure = false;
const { ScriptHistory: Faulty } = load(source, {
...mocks,
'fs/promises': {
...fs,
unlink: async (file) => {
if (failure && String(file).endsWith('.br'))
throw Object.assign(new Error('denied'), { code: 'EACCES' });
return fs.unlink(file);
},
},
});
const { service, scripts, history } = await fixture(t, { files: 1 }, Faulty);
await service.save('', 'test.js', 'saved');
await fs.writeFile(path.join(scripts, 'other.js'), 'old');
failure = true;
const result = await service.save('', 'other.js', 'new');
assert.equal(result.cleanupPending, true);
assert.equal(
await fs.readFile(path.join(scripts, 'other.js'), 'utf8'),
'new',
);
failure = false;
await service.save('', 'other.js', 'next');
assert.equal((await fs.readdir(history)).length, 1);
});
test('large files can be reduced only after explicit historyless confirmation with a matching hash', async (t) => {
const { service, file } = await fixture(t, { fileBytes: 100 });
await fs.writeFile(file, 'x'.repeat(101));
let confirmation;
try {
await service.save('', 'test.js', 'small');
} catch (error) {
confirmation = error;
}
assert.equal(confirmation.status, 413);
assert.match(confirmation.currentHash, /^[a-f0-9]{64}$/);
assert.equal((await fs.stat(file)).size, 101);
await assert.rejects(
service.save('', 'test.js', 'small', {
skipHistory: true,
expectedHash: '0'.repeat(64),
}),
{ status: 409 },
);
const result = await service.save('', 'test.js', 'small', {
skipHistory: true,
expectedHash: confirmation.currentHash,
});
assert.equal(result.historyRecorded, false);
assert.equal(await fs.readFile(file, 'utf8'), 'small');
await service.save('', 'test.js', 'normal');
assert.equal(
(await service.list('', 'test.js')).versions.length,
1,
'normal history resumes',
);
try {
await service.save('', 'test.js', 'x'.repeat(101));
} catch (error) {
confirmation = error;
}
await fs.writeFile(file, 'someone else changed it');
await assert.rejects(
service.save('', 'test.js', 'x'.repeat(101), {
skipHistory: true,
expectedHash: confirmation.currentHash,
}),
{ status: 409 },
);
});
test('atomic saves preserve executable mode and canonical symlink targets', async (t) => {
const { service, scripts, file } = await fixture(t);
await fs.chmod(file, 0o755);
await fs.symlink(file, path.join(scripts, 'alias.js'));
await service.save('', 'alias.js', 'new');
assert.equal((await fs.stat(file)).mode & 0o777, 0o755);
assert.equal(
(await fs.lstat(path.join(scripts, 'alias.js'))).isSymbolicLink(),
true,
);
assert.equal(await fs.readFile(file, 'utf8'), 'new');
});
+1371
View File
File diff suppressed because it is too large Load Diff
-121
View File
@@ -1,121 +0,0 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const path = require('node:path');
const load = require('../helpers/load-security-module.cjs');
function fixture(send) {
const dialogs = [],
notices = [],
calls = [];
const request = {};
for (const method of ['put', 'post'])
request[method] = async (url, body, options) => {
calls.push({ method, body });
const result = await send(body);
if (result.status) return options.onError(result);
return result;
};
const { saveWithHistory } = load(
path.resolve(__dirname, '../../src/pages/script/saveWithHistory.ts'),
{
'@/utils/http': { request },
antd: {
Modal: {
confirm: (dialog) => {
dialogs.push(dialog);
return { update: (options) => Object.assign(dialog, options) };
},
},
message: Object.fromEntries(
['error', 'warning', 'success'].map((type) => [
type,
(text) => notices.push({ type, text }),
]),
),
},
'react-intl-universal': {
__esModule: true,
default: { get: (key) => key },
},
},
);
return { saveWithHistory, dialogs, notices, calls };
}
const unavailable = {
status: 413,
data: { historyUnavailable: true, currentHash: 'a'.repeat(64) },
};
const tick = () => new Promise((resolve) => setImmediate(resolve));
test('oversized editor save requires explicit confirmation and cancellation keeps the draft unsaved', async () => {
const f = fixture(() => unavailable);
const pending = f.saveWithHistory('put', '/api/scripts', {
filename: 'large.js',
content: 'small',
});
await tick();
assert.equal(f.calls.length, 1);
assert.equal(f.dialogs.length, 1);
assert.match(f.dialogs[0].content, /不会保留/);
f.dialogs[0].onCancel();
assert.equal(await pending, undefined);
assert.equal(f.calls.length, 1);
assert.equal(f.notices.length, 0);
});
test('debugger confirmation retries POST with the exact preview hash and displays a warning', async () => {
const f = fixture((body) =>
body.skipHistory
? { code: 200, data: { historyRecorded: false } }
: unavailable,
);
const pending = f.saveWithHistory('post', '/api/scripts', {
content: 'small',
});
await tick();
await f.dialogs[0].onOk();
assert.equal((await pending).code, 200);
assert.equal(f.dialogs[0].cancelButtonProps.disabled, true);
assert.equal(f.dialogs[0].keyboard, false);
assert.equal(f.calls[1].method, 'post');
assert.equal(f.calls[1].body.expectedHash, unavailable.data.currentHash);
assert.equal(f.calls[1].body.skipHistory, true);
assert.deepEqual(
f.notices.map((v) => v.type),
['warning'],
);
});
test('a stale confirmation reports the conflict without marking the editor saved', async () => {
const f = fixture((body) =>
body.skipHistory
? { status: 409, data: { message: 'changed' } }
: unavailable,
);
const pending = f.saveWithHistory('put', '/api/scripts', {});
await tick();
await f.dialogs[0].onOk();
assert.equal(await pending, undefined);
assert.deepEqual(f.notices, [{ type: 'error', text: 'changed' }]);
});
test('unrelated errors never offer history bypass, and post-commit cleanup failures remain successful saves', async () => {
const failed = fixture(() => ({
status: 500,
data: { message: 'storage failed' },
}));
assert.equal(
await failed.saveWithHistory('put', '/api/scripts', {}),
undefined,
);
assert.equal(failed.dialogs.length, 0);
const committed = fixture(() => ({
code: 200,
data: { cleanupPending: true },
}));
assert.equal(
(await committed.saveWithHistory('put', '/api/scripts', {})).code,
200,
);
assert.equal(committed.notices[0].type, 'warning');
});