Compare commits

..

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 0bbff927b1 Address code review feedback
- Wrapped status updates in try-catch blocks to handle database errors
- Moved CrontabModel import to top of pLimit.ts to avoid repeated dynamic imports

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-30 16:43:16 +00:00
copilot-swe-agent[bot] 58eb9feec0 Fix scheduled task instance mode issues
- Fixed runCron to properly handle allow_multiple_instances setting
- Updated default behavior to single-instance mode for backward compatibility
- Added status updates (running/idle) to track task execution
- Fixed queue limit logic to respect instance mode settings
- For single-instance mode: allow up to 2 queued tasks to handle task killing
- For multi-instance mode: increased queue limit to 10 tasks

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-30 16:41:28 +00:00
copilot-swe-agent[bot] 802ca93a3d Initial plan 2025-12-30 16:33:54 +00:00
3 changed files with 60 additions and 39 deletions
+5 -34
View File
@@ -13,29 +13,9 @@ import { isValidToken } from '../shared/auth';
import path from 'path';
export default ({ app }: { app: Application }) => {
// Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true);
app.set('strict routing', true);
app.set('trust proxy', 'loopback');
app.use(cors());
// Security: Path normalization middleware to prevent case variation attacks
app.use((req, res, next) => {
const originalPath = req.path;
const normalizedPath = originalPath.toLowerCase();
// Block requests with case variations on protected paths
if (originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
return res.status(400).json({
code: 400,
message: 'Invalid path format'
});
}
next();
});
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) {
@@ -56,7 +36,7 @@ export default ({ app }: { app: Application }) => {
secret: config.jwt.secret,
algorithms: ['HS384'],
}).unless({
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
path: [...config.apiWhiteList, /^\/(?!api\/).*/],
}),
);
@@ -71,20 +51,19 @@ export default ({ app }: { app: Application }) => {
});
app.use(async (req: Request, res, next) => {
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (pathLower.startsWith('/open/')) {
if (req.path.startsWith('/open/')) {
const apps = await shareStore.getApps();
const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken),
)?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (
doc.scopes.includes(key as any) &&
@@ -119,15 +98,7 @@ export default ({ app }: { app: Application }) => {
});
app.use(async (req, res, next) => {
const pathLower = req.path.toLowerCase();
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(req.path)
) {
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {
return next();
}
const authInfo =
+28 -2
View File
@@ -14,6 +14,7 @@ import {
import config from '../config';
import { credentials } from '@grpc/grpc-js';
import { ApiClient } from '../protos/api';
import { CrontabModel } from '../data/cron';
class TaskLimit {
private dependenyLimit = new PQueue({ concurrency: 1 });
@@ -131,13 +132,38 @@ class TaskLimit {
let runs = this.queuedCrons.get(cron.id);
const result = runs?.length ? [...runs, fn] : [fn];
const repeatTimes = this.repeatCronNotifyMap.get(cron.id) || 0;
if (result?.length > 5) {
// Check instance mode from database to determine queue limit
let maxQueueSize = 10; // Default for multi-instance mode (increased from 5)
let isSingleInstanceMode = false;
try {
const cronRecord = await CrontabModel.findOne({
where: { id: Number(cron.id) },
});
// Default to single instance mode (0) for backward compatibility
// allow_multiple_instances is 1 for multi-instance, 0 or null/undefined for single instance
isSingleInstanceMode = cronRecord?.allow_multiple_instances !== 1;
if (isSingleInstanceMode) {
// For single instance mode, allow up to 2 queued tasks
// This allows the new task to be queued while the old one is being killed
maxQueueSize = 2;
}
} catch (error) {
Logger.error(
`[schedule][检查实例模式失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
}
if (result?.length > maxQueueSize) {
if (repeatTimes < 3) {
this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1);
const modeStr = isSingleInstanceMode ? '单实例' : '多实例';
this.client.systemNotify(
{
title: '任务重复运行',
content: `任务:${cron.name},命令:${cron.command},定时:${cron.schedule},处于运行中的超过 5 个,请检查定时设置`,
content: `任务:${cron.name}${modeStr}模式),命令:${cron.command},定时:${cron.schedule},处于运行中的超过 ${maxQueueSize} 个,请检查定时设置`,
},
(err, res) => {
if (err) {
+27 -3
View File
@@ -15,11 +15,12 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
});
// Default to single instance mode (0) for backward compatibility
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
// allow_multiple_instances is 1 for multi-instance, 0 or null/undefined for single instance
const isSingleInstanceMode =
existingCron?.allow_multiple_instances !== 1;
if (
allowSingleInstances &&
isSingleInstanceMode &&
existingCron &&
existingCron.pid &&
(existingCron.status === CrontabStatus.running ||
@@ -49,6 +50,18 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
);
const cp = spawn(cmd, { shell: '/bin/bash' });
// Update status to running after spawning the process
try {
await CrontabModel.update(
{ status: CrontabStatus.running, pid: cp.pid },
{ where: { id: Number(cron.id) } },
);
} catch (error) {
Logger.error(
`[schedule][更新任务状态失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
}
cp.stderr.on('data', (data) => {
Logger.info(
'[schedule][执行任务失败] 命令: %s, 错误信息: %j',
@@ -66,6 +79,17 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
cp.on('exit', async (code) => {
taskLimit.removeQueuedCron(cron.id);
// Update status to idle after task completes
try {
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
} catch (error) {
Logger.error(
`[schedule][更新任务状态失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
}
Logger.info(
'[schedule][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify({