@aalis/plugin-scheduler 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +41 -31
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +466 -293
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +94 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +94 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/texts.d.ts +187 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +99 -0
- package/dist/texts.js.map +1 -0
- package/package.json +13 -10
package/dist/index.js
CHANGED
|
@@ -1,77 +1,89 @@
|
|
|
1
1
|
import { cronEngine } from '@aalis/api-cron-engine';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { rootSessionOf, sessionManager } from '@aalis/api-session-manager';
|
|
3
|
+
import { isStorageNotFound, isStorageUri, storage } from '@aalis/api-storage';
|
|
4
|
+
import { actingIdentity, tools } from '@aalis/api-tools';
|
|
4
5
|
import { webuiServer } from '@aalis/api-webui';
|
|
5
6
|
import { config, definePlugin, defineService, events, lifecycle, logger, optional, provide, } from '@aalis/core';
|
|
6
|
-
import { defineConfig, parseConfig } from '@aalis/schema-config';
|
|
7
|
-
import {
|
|
7
|
+
import { configError, defineConfig, parseConfig } from '@aalis/schema-config';
|
|
8
|
+
import { TextError, textClauses, textOf, toSource } from '@aalis/schema-i18n';
|
|
9
|
+
import { MAX_INTERVAL_SECONDS, parseEverySeconds, validateIntervalSeconds } from '@aalis/util-cron';
|
|
10
|
+
import { t } from './texts.js';
|
|
8
11
|
// ──────────── Cron 解析 ────────────
|
|
9
12
|
// 解析在 @aalis/util-cron(parseEverySeconds 等);
|
|
10
13
|
// scheduler 声明 cronEngine 后调用 subscribe()/nextFireTime()。
|
|
11
14
|
/** setTimeout 的 delay 上限(32 位有符号毫秒,约 24.8 天);超过即溢出成立即触发 */
|
|
12
15
|
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
|
13
|
-
|
|
16
|
+
/** 固定间隔的下限(秒):挡住 interval:0.5 这类会占满调度、耗尽资源的高频任务 */
|
|
17
|
+
const MIN_INTERVAL_SECONDS = 5;
|
|
18
|
+
/**
|
|
19
|
+
* 固定间隔(含 cron 的 `@every`)无效的原因,所有入口与配置、存量文件同一条:要是大于 0、不超过定时器能排的上限(超过的溢出成
|
|
20
|
+
* 每毫秒触发)的数,且不小于 MIN_INTERVAL_SECONDS
|
|
21
|
+
*/
|
|
22
|
+
function intervalProblem(seconds) {
|
|
23
|
+
return validateIntervalSeconds(seconds) ?? (seconds < MIN_INTERVAL_SECONDS ? t('intervalTooShort') : null);
|
|
24
|
+
}
|
|
25
|
+
// ----- 服务描述符(调用型:业务层就是提供者的形状)-----
|
|
14
26
|
export const scheduler = defineService('scheduler');
|
|
15
27
|
// ──────────── 插件元数据 ────────────
|
|
16
28
|
export const configSchema = defineConfig({
|
|
17
29
|
jobs: {
|
|
18
30
|
type: 'array',
|
|
19
31
|
onInvalid: 'error',
|
|
20
|
-
label: '
|
|
21
|
-
description: '
|
|
32
|
+
label: t('jobsLabel'),
|
|
33
|
+
description: t('jobsDescription'),
|
|
22
34
|
items: {
|
|
23
|
-
name: { type: 'string', label: '
|
|
35
|
+
name: { type: 'string', label: t('jobNameLabel') },
|
|
24
36
|
cron: {
|
|
25
37
|
type: 'string',
|
|
26
|
-
label: '
|
|
27
|
-
description: '
|
|
38
|
+
label: t('cronLabel'),
|
|
39
|
+
description: t('cronDescription'),
|
|
28
40
|
},
|
|
29
41
|
interval: {
|
|
30
42
|
type: 'number',
|
|
31
|
-
label: '
|
|
32
|
-
description: '
|
|
43
|
+
label: t('intervalLabel'),
|
|
44
|
+
description: t('intervalDescription'),
|
|
33
45
|
},
|
|
34
46
|
runAt: {
|
|
35
47
|
type: 'string',
|
|
36
|
-
label: '
|
|
37
|
-
description: '
|
|
48
|
+
label: t('runAtLabel'),
|
|
49
|
+
description: t('runAtDescription'),
|
|
38
50
|
},
|
|
39
51
|
sessionId: {
|
|
40
52
|
type: 'string',
|
|
41
|
-
label: '
|
|
42
|
-
description: '
|
|
53
|
+
label: t('sessionIdLabel'),
|
|
54
|
+
description: t('sessionIdDescription'),
|
|
43
55
|
},
|
|
44
56
|
platform: {
|
|
45
57
|
type: 'string',
|
|
46
58
|
onInvalid: 'error',
|
|
47
|
-
label: '
|
|
59
|
+
label: t('platformLabel'),
|
|
48
60
|
required: true,
|
|
49
|
-
description: '
|
|
61
|
+
description: t('platformDescription'),
|
|
50
62
|
default: 'internal',
|
|
51
63
|
},
|
|
52
64
|
actorPlatform: {
|
|
53
65
|
type: 'string',
|
|
54
66
|
onInvalid: 'error',
|
|
55
|
-
label: '
|
|
56
|
-
description: '
|
|
67
|
+
label: t('actorPlatformLabel'),
|
|
68
|
+
description: t('actorPlatformDescription'),
|
|
57
69
|
},
|
|
58
70
|
actorUserId: {
|
|
59
71
|
type: 'string',
|
|
60
72
|
onInvalid: 'error',
|
|
61
|
-
label: '
|
|
62
|
-
description: '
|
|
73
|
+
label: t('actorUserIdLabel'),
|
|
74
|
+
description: t('actorUserIdDescription'),
|
|
63
75
|
},
|
|
64
76
|
content: {
|
|
65
77
|
type: 'string',
|
|
66
|
-
label: '
|
|
78
|
+
label: t('contentLabel'),
|
|
67
79
|
default: '',
|
|
68
|
-
description: '
|
|
80
|
+
description: t('contentDescription'),
|
|
69
81
|
},
|
|
70
|
-
enabled: { type: 'boolean', label: '
|
|
82
|
+
enabled: { type: 'boolean', label: t('enabledLabel'), default: true, onInvalid: 'error' },
|
|
71
83
|
timeZone: {
|
|
72
84
|
type: 'string',
|
|
73
|
-
label: '
|
|
74
|
-
description: '
|
|
85
|
+
label: t('timeZoneLabel'),
|
|
86
|
+
description: t('timeZoneDescription'),
|
|
75
87
|
},
|
|
76
88
|
},
|
|
77
89
|
default: [],
|
|
@@ -79,84 +91,47 @@ export const configSchema = defineConfig({
|
|
|
79
91
|
persistPath: {
|
|
80
92
|
type: 'string',
|
|
81
93
|
onInvalid: 'error',
|
|
82
|
-
label: '
|
|
94
|
+
label: t('persistPathLabel'),
|
|
83
95
|
default: 'data:/scheduler-jobs.json',
|
|
84
|
-
description: '
|
|
96
|
+
description: t('persistPathDescription'),
|
|
85
97
|
},
|
|
86
98
|
});
|
|
87
99
|
// ──────────── WebUI 页面 ────────────
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
actions: [
|
|
110
|
-
{ label: '立即执行', method: 'triggerJob' },
|
|
111
|
-
{ label: '暂停', method: 'pauseJob' },
|
|
112
|
-
{ label: '恢复', method: 'resumeJob' },
|
|
113
|
-
{ label: '删除', method: 'removeJob', confirm: '确定删除该任务?', danger: true },
|
|
114
|
-
],
|
|
115
|
-
refresh: 30,
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
type: 'form',
|
|
119
|
-
label: '新建 / 编辑任务',
|
|
120
|
-
source: 'newJobDraft',
|
|
121
|
-
save: 'upsertJob',
|
|
122
|
-
schema: {
|
|
123
|
-
name: {
|
|
124
|
-
type: 'string',
|
|
125
|
-
label: '名称',
|
|
126
|
-
required: true,
|
|
127
|
-
description: '任务唯一标识;填已有动态任务名会覆盖(配置文件中的静态任务无法覆盖)',
|
|
128
|
-
},
|
|
129
|
-
cron: {
|
|
130
|
-
type: 'string',
|
|
131
|
-
label: 'Cron 表达式',
|
|
132
|
-
description: '5 字段或别名:@hourly / @daily / @weekly / @every 30s;与 interval / runAt / delaySeconds 四选一',
|
|
133
|
-
},
|
|
134
|
-
interval: { type: 'number', label: '固定间隔(秒)', description: '与 cron / runAt / delaySeconds 四选一' },
|
|
135
|
-
runAt: {
|
|
136
|
-
type: 'string',
|
|
137
|
-
label: '一次性运行时间',
|
|
138
|
-
description: 'ISO 字符串,如 2026-05-19T10:00:00;执行一次后自动删除',
|
|
139
|
-
},
|
|
140
|
-
delaySeconds: {
|
|
141
|
-
type: 'number',
|
|
142
|
-
label: 'X 秒后执行一次',
|
|
143
|
-
description: '与 cron / interval / runAt 四选一;填写后会转换为 runAt = now + N 秒,执行一次后自动删除',
|
|
144
|
-
},
|
|
145
|
-
sessionId: { type: 'string', label: '目标会话 ID', required: true },
|
|
146
|
-
platform: { type: 'string', label: '目标平台', default: 'internal' },
|
|
147
|
-
content: { type: 'textarea', label: '消息内容', required: true, description: '届时发送给自己的指令/提示' },
|
|
148
|
-
enabled: { type: 'boolean', label: '启用', default: true },
|
|
149
|
-
paused: {
|
|
150
|
-
type: 'boolean',
|
|
151
|
-
label: '创建后立即暂停',
|
|
152
|
-
default: false,
|
|
153
|
-
description: '勾选后任务创建但不会自动运行,需手动「恢复」;仅周期任务(cron / interval)可用',
|
|
154
|
-
},
|
|
155
|
-
},
|
|
156
|
-
},
|
|
157
|
-
],
|
|
100
|
+
/** 新建与编辑共用的输入:调度方式(四选一)、目标会话与内容。新建另收任务名,编辑的任务名取这一行 */
|
|
101
|
+
const JOB_FIELDS = defineConfig({
|
|
102
|
+
cron: {
|
|
103
|
+
type: 'string',
|
|
104
|
+
label: t('cronLabel'),
|
|
105
|
+
description: t('fieldCronDescription'),
|
|
106
|
+
},
|
|
107
|
+
interval: {
|
|
108
|
+
type: 'number',
|
|
109
|
+
label: t('fieldIntervalLabel'),
|
|
110
|
+
description: t('fieldIntervalDescription', { max: MAX_INTERVAL_SECONDS }),
|
|
111
|
+
},
|
|
112
|
+
runAt: {
|
|
113
|
+
type: 'string',
|
|
114
|
+
label: t('runAtLabel'),
|
|
115
|
+
description: t('fieldRunAtDescription'),
|
|
116
|
+
},
|
|
117
|
+
delaySeconds: {
|
|
118
|
+
type: 'number',
|
|
119
|
+
label: t('delaySecondsLabel'),
|
|
120
|
+
description: t('delaySecondsDescription'),
|
|
158
121
|
},
|
|
159
|
-
|
|
122
|
+
sessionId: { type: 'string', label: t('sessionIdLabel'), required: true },
|
|
123
|
+
platform: { type: 'string', label: t('platformLabel'), default: 'internal' },
|
|
124
|
+
content: { type: 'textarea', label: t('contentLabel'), required: true, description: t('fieldContentDescription') },
|
|
125
|
+
enabled: { type: 'boolean', label: t('enabledLabel'), default: true },
|
|
126
|
+
paused: {
|
|
127
|
+
type: 'boolean',
|
|
128
|
+
label: t('paused'),
|
|
129
|
+
default: false,
|
|
130
|
+
description: t('pausedDescription'),
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
/** 单个会话最多几个计划任务(防资源耗尽);编辑已有任务不计入 */
|
|
134
|
+
const MAX_JOBS_PER_SESSION = 20;
|
|
160
135
|
// ──────────── 插件入口 ────────────
|
|
161
136
|
const uses = {
|
|
162
137
|
// tools 必须先就绪,否则下方 register tool 全部静默丢失(optional 不参与激活闸)
|
|
@@ -165,8 +140,10 @@ const uses = {
|
|
|
165
140
|
cronEngine,
|
|
166
141
|
/** 动态任务的持久化出口;没有 storage 时任务只活在本次激活里(读写各自降级) */
|
|
167
142
|
storage: optional(storage),
|
|
168
|
-
/**
|
|
143
|
+
/** 计划任务页的登记口;无 WebUI 时调度照常运行 */
|
|
169
144
|
webui: optional(webuiServer),
|
|
145
|
+
/** 认调用所在的会话是不是子会话(在子会话里建任务要写明目标);没有会话管理就没有子会话 */
|
|
146
|
+
sessionManager: optional(sessionManager),
|
|
170
147
|
events,
|
|
171
148
|
lifecycle,
|
|
172
149
|
logger,
|
|
@@ -175,7 +152,7 @@ const uses = {
|
|
|
175
152
|
};
|
|
176
153
|
export default definePlugin({
|
|
177
154
|
name: '@aalis/plugin-scheduler',
|
|
178
|
-
displayName: '
|
|
155
|
+
displayName: t('displayName'),
|
|
179
156
|
subsystem: 'scheduler',
|
|
180
157
|
extends: {
|
|
181
158
|
events: ['scheduler:job:start', 'scheduler:job:done', 'scheduler:job:error'],
|
|
@@ -186,41 +163,231 @@ export default definePlugin({
|
|
|
186
163
|
apply: run,
|
|
187
164
|
});
|
|
188
165
|
async function run(caps) {
|
|
189
|
-
const cfg = parseConfig(configSchema, caps.config, caps.logger);
|
|
190
|
-
const { cronEngine, storage, webui, events, lifecycle, logger, provide, tools } = caps;
|
|
166
|
+
const cfg = parseConfig(configSchema, caps.config.use, caps.logger.use);
|
|
167
|
+
const { cronEngine, storage, webui, sessionManager, events, lifecycle, logger, provide, tools } = caps;
|
|
191
168
|
const config = resolveConfig(cfg);
|
|
192
|
-
for (const page of webuiPages)
|
|
193
|
-
webui.registerPage(page);
|
|
194
169
|
const runtimes = new Map();
|
|
195
170
|
// 配置中定义的任务名称集合(不需要持久化)
|
|
196
171
|
const configJobNames = new Set(config.jobs.map(j => j.name));
|
|
197
172
|
// 动态任务集合(需要持久化)
|
|
198
173
|
const dynamicJobs = new Map();
|
|
199
174
|
// ── 持久化读写 (经 storage 抽象,默认 data:/scheduler-jobs.json) ──
|
|
200
|
-
const storageGateway = createStorageGateway(storage);
|
|
201
175
|
const persistUri = config.persistPath;
|
|
202
176
|
/**
|
|
203
177
|
* 激活时那次读失败了、又不是「文件不存在」(storage 不在场、读错误、解析失败、结构不对):写的是整表,
|
|
204
178
|
* 此后一律拒写,否则下一次增删改就把原有动态任务冲掉。只在激活时读一次,storage 迟到也不重读。
|
|
205
179
|
*/
|
|
206
180
|
let loadFailed = false;
|
|
207
|
-
/**
|
|
208
|
-
|
|
181
|
+
/**
|
|
182
|
+
* 拒写期间新建或改动的任务只在内存、重启即失:回执末尾如实注明(AI 工具与计划任务页同一附注,工具取原文),
|
|
183
|
+
* 文件正常时没有
|
|
184
|
+
*/
|
|
185
|
+
const persistNote = () => (loadFailed ? t('notPersisted', { uri: persistUri }) : undefined);
|
|
186
|
+
// 先读完持久化任务再登记页面:登记之后到服务就绪之间不再有等待,页面一出现取数与动作就可用
|
|
187
|
+
const persisted = await loadDynamicJobs();
|
|
188
|
+
// ── 计划任务页:动作是本次激活的闭包,直接操作本插件自己的调度表;登记随激活撤回 ──
|
|
189
|
+
/**
|
|
190
|
+
* 新建与编辑共用:调度方式恰好一个(四者是互不相容的定时语义,多填时取哪个都是猜),与 scheduler_create_job
|
|
191
|
+
* 工具路径同一条判据与同一份 delaySeconds 换算。actor 取闸门放行后的调用者,不从输入读,免得伪造他人身份
|
|
192
|
+
*/
|
|
193
|
+
function saveJob(name, input, caller, creating) {
|
|
194
|
+
const cron = input.cron?.trim() || undefined;
|
|
195
|
+
const interval = input.interval !== undefined && input.interval > 0 ? input.interval : undefined;
|
|
196
|
+
const delaySeconds = input.delaySeconds !== undefined && input.delaySeconds > 0 ? Math.floor(input.delaySeconds) : undefined;
|
|
197
|
+
const rawRunAt = input.runAt?.trim() || undefined;
|
|
198
|
+
const provided = [cron, interval, delaySeconds, rawRunAt].filter(v => v !== undefined).length;
|
|
199
|
+
if (provided === 0)
|
|
200
|
+
throw new TextError(t('scheduleMissing'));
|
|
201
|
+
if (provided > 1)
|
|
202
|
+
throw new TextError(t('scheduleConflict'));
|
|
203
|
+
const runAt = delaySeconds !== undefined ? new Date(Date.now() + delaySeconds * 1000).toISOString() : rawRunAt;
|
|
204
|
+
// 一次性任务被暂停时到点的定时器直接跳过,之后既不重排也不自删,任务就永久卡死在那里
|
|
205
|
+
if (input.paused && runAt)
|
|
206
|
+
throw new TextError(t('pauseOneShot'));
|
|
207
|
+
const sessionId = input.sessionId.trim();
|
|
208
|
+
if (!sessionId)
|
|
209
|
+
throw new TextError(t('sessionIdEmpty'));
|
|
210
|
+
const content = input.content.trim();
|
|
211
|
+
if (!content)
|
|
212
|
+
throw new TextError(t('contentEmpty'));
|
|
213
|
+
if (creating) {
|
|
214
|
+
const all = service.getJobs();
|
|
215
|
+
if (all.some(j => j.name === name))
|
|
216
|
+
throw new TextError(t('duplicateName', { name }));
|
|
217
|
+
if (all.filter(j => j.sessionId === sessionId).length >= MAX_JOBS_PER_SESSION) {
|
|
218
|
+
throw new TextError(t('tooManyJobs', { max: MAX_JOBS_PER_SESSION }));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
service.setJob({
|
|
222
|
+
name,
|
|
223
|
+
cron,
|
|
224
|
+
interval,
|
|
225
|
+
runAt,
|
|
226
|
+
sessionId,
|
|
227
|
+
platform: input.platform,
|
|
228
|
+
actorPlatform: caller.platform,
|
|
229
|
+
actorUserId: caller.userId,
|
|
230
|
+
content,
|
|
231
|
+
enabled: input.enabled,
|
|
232
|
+
paused: input.paused,
|
|
233
|
+
});
|
|
234
|
+
const note = persistNote();
|
|
235
|
+
return note ? textClauses([t('jobSaved', { name }), note]) : t('jobSaved', { name });
|
|
236
|
+
}
|
|
237
|
+
const jobsPage = webui.use.definePage({
|
|
238
|
+
key: 'scheduler',
|
|
239
|
+
title: t('pageTitle'),
|
|
240
|
+
icon: 'clock',
|
|
241
|
+
actions: {
|
|
242
|
+
createJob: {
|
|
243
|
+
label: t('create'),
|
|
244
|
+
effect: 'write',
|
|
245
|
+
input: {
|
|
246
|
+
name: { type: 'string', label: t('name'), required: true, description: t('nameDescription') },
|
|
247
|
+
...JOB_FIELDS,
|
|
248
|
+
},
|
|
249
|
+
handler({ input, ctx }) {
|
|
250
|
+
const name = input.name.trim();
|
|
251
|
+
if (!name)
|
|
252
|
+
throw new TextError(t('nameEmpty'));
|
|
253
|
+
return saveJob(name, input, ctx.caller, true);
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
editJob: {
|
|
257
|
+
label: t('edit'),
|
|
258
|
+
effect: 'write',
|
|
259
|
+
row: true,
|
|
260
|
+
input: JOB_FIELDS,
|
|
261
|
+
handler({ rowKey, input, ctx }) {
|
|
262
|
+
if (!service.getJobs().some(j => j.name === rowKey))
|
|
263
|
+
throw new TextError(t('jobMissing', { name: rowKey }));
|
|
264
|
+
return saveJob(rowKey, input, ctx.caller, false);
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
triggerJob: {
|
|
268
|
+
label: t('triggerNow'),
|
|
269
|
+
effect: 'write',
|
|
270
|
+
row: true,
|
|
271
|
+
async handler({ rowKey }) {
|
|
272
|
+
// 这一轮还没执行完时 triggerJob 直接跳过;与它置 running 在同一个同步段里判,没有竞态
|
|
273
|
+
if (service.getJobs().find(j => j.name === rowKey)?.running) {
|
|
274
|
+
throw new TextError(t('jobRunning', { name: rowKey }));
|
|
275
|
+
}
|
|
276
|
+
if (!(await service.triggerJob(rowKey)))
|
|
277
|
+
throw new TextError(t('jobMissing', { name: rowKey }));
|
|
278
|
+
return t('triggered', { name: rowKey });
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
pauseJob: {
|
|
282
|
+
label: t('pause'),
|
|
283
|
+
effect: 'write',
|
|
284
|
+
row: true,
|
|
285
|
+
handler({ rowKey }) {
|
|
286
|
+
if (!service.pauseJob(rowKey))
|
|
287
|
+
throw new TextError(t('cannotPause'));
|
|
288
|
+
return t('pausedReceipt', { name: rowKey });
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
resumeJob: {
|
|
292
|
+
label: t('resume'),
|
|
293
|
+
effect: 'write',
|
|
294
|
+
row: true,
|
|
295
|
+
handler({ rowKey }) {
|
|
296
|
+
if (!service.resumeJob(rowKey))
|
|
297
|
+
throw new TextError(t('jobMissing', { name: rowKey }));
|
|
298
|
+
return t('resumed', { name: rowKey });
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
removeJob: {
|
|
302
|
+
label: t('delete'),
|
|
303
|
+
effect: 'destructive',
|
|
304
|
+
confirm: t('deleteConfirm'),
|
|
305
|
+
row: true,
|
|
306
|
+
handler({ rowKey }) {
|
|
307
|
+
if (!service.removeJob(rowKey))
|
|
308
|
+
throw new TextError(t('jobMissing', { name: rowKey }));
|
|
309
|
+
return t('deleted', { name: rowKey });
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
content: [
|
|
314
|
+
{
|
|
315
|
+
type: 'table',
|
|
316
|
+
id: 'jobs',
|
|
317
|
+
rowKey: 'name',
|
|
318
|
+
empty: t('noJobs'),
|
|
319
|
+
columns: [
|
|
320
|
+
{ key: 'name', label: t('name'), primary: true },
|
|
321
|
+
{ key: 'schedule', label: t('schedule') },
|
|
322
|
+
{ key: 'sessionId', label: t('target') },
|
|
323
|
+
{ key: 'status', label: t('status'), kind: 'status', primary: true },
|
|
324
|
+
{ key: 'nextRun', label: t('nextRun'), kind: 'countdown' },
|
|
325
|
+
{ key: 'lastRun', label: t('lastRun'), kind: 'time' },
|
|
326
|
+
{ key: 'runCount', label: t('runCount') },
|
|
327
|
+
{ key: 'lastResult', label: t('lastResult'), kind: 'long-text' },
|
|
328
|
+
],
|
|
329
|
+
rowActions: [
|
|
330
|
+
{ action: 'triggerJob' },
|
|
331
|
+
{ action: 'pauseJob', visibleWhen: 'pausable' },
|
|
332
|
+
{ action: 'resumeJob', visibleWhen: 'paused' },
|
|
333
|
+
{ action: 'editJob', visibleWhen: 'editable' },
|
|
334
|
+
{ action: 'removeJob' },
|
|
335
|
+
],
|
|
336
|
+
data: () => service.getJobs().map(j => {
|
|
337
|
+
const ready = j.enabled && !j.paused && !j.running;
|
|
338
|
+
return {
|
|
339
|
+
name: j.name,
|
|
340
|
+
cron: j.cron,
|
|
341
|
+
interval: j.interval,
|
|
342
|
+
runAt: j.runAt,
|
|
343
|
+
sessionId: j.sessionId,
|
|
344
|
+
platform: j.platform,
|
|
345
|
+
content: j.content,
|
|
346
|
+
enabled: j.enabled,
|
|
347
|
+
paused: j.paused,
|
|
348
|
+
schedule: j.cron ??
|
|
349
|
+
(j.interval
|
|
350
|
+
? t('everySeconds', { seconds: j.interval })
|
|
351
|
+
: j.runAt
|
|
352
|
+
? t('oneShot', { at: j.runAt })
|
|
353
|
+
: t('unset')),
|
|
354
|
+
status: !j.enabled
|
|
355
|
+
? { text: t('statusDisabled'), state: 'inactive' }
|
|
356
|
+
: j.paused
|
|
357
|
+
? { text: t('paused'), state: 'inactive' }
|
|
358
|
+
: j.running
|
|
359
|
+
? { text: t('statusRunning'), state: 'running' }
|
|
360
|
+
: { text: t('statusReady') },
|
|
361
|
+
nextRun: ready && j.nextRun > 0 ? j.nextRun : undefined,
|
|
362
|
+
lastRun: j.lastRun || undefined,
|
|
363
|
+
runCount: j.runCount,
|
|
364
|
+
lastResult: j.lastResult,
|
|
365
|
+
pausable: ready && !!(j.cron || j.interval),
|
|
366
|
+
editable: !configJobNames.has(j.name),
|
|
367
|
+
};
|
|
368
|
+
}),
|
|
369
|
+
},
|
|
370
|
+
{ type: 'actions', actions: ['createJob'] },
|
|
371
|
+
],
|
|
372
|
+
});
|
|
209
373
|
async function loadDynamicJobs() {
|
|
374
|
+
/** 失败出在读文件这一步(存储插件不在、persistPath 的根名写错、文件读不了),文件内容未必有错 */
|
|
375
|
+
let reading = true;
|
|
210
376
|
try {
|
|
211
377
|
let raw;
|
|
212
378
|
try {
|
|
213
|
-
raw = (await
|
|
379
|
+
raw = (await storage.use.readFile(persistUri, 'utf-8'));
|
|
214
380
|
}
|
|
215
381
|
catch (err) {
|
|
216
|
-
//
|
|
382
|
+
// 只有「文件不存在」算全新;「未知存储根」等其它失败不能当成空表
|
|
217
383
|
if (isStorageNotFound(err))
|
|
218
384
|
return [];
|
|
219
385
|
throw err;
|
|
220
386
|
}
|
|
387
|
+
reading = false;
|
|
221
388
|
const data = JSON.parse(raw);
|
|
222
389
|
if (!Array.isArray(data)) {
|
|
223
|
-
throw new
|
|
390
|
+
throw new TextError(t('notArray', { got: data === null ? 'null' : typeof data }));
|
|
224
391
|
}
|
|
225
392
|
// biome-ignore lint/suspicious/noExplicitAny: 从 JSON 文件反序列化的原始字段,手动校验转型
|
|
226
393
|
return data.map((j) => {
|
|
@@ -233,7 +400,7 @@ async function run(caps) {
|
|
|
233
400
|
// `saveDynamicJobs` 的 JSON.stringify 会丢掉 undefined 键,匿名创建的任务落盘后
|
|
234
401
|
// 与无 actor 字段的老任务同形;此处补 owner 即等于给它开一条提权通道。
|
|
235
402
|
// 「创建者未知」不等于「创建者是 owner」。
|
|
236
|
-
logger.warn(`持久化任务 "${j.name}" 缺少 actor 身份,已按匿名(最低权限)运行;如需更高权限请在 WebUI 中显式设置创建者身份`);
|
|
403
|
+
logger.use.warn(`持久化任务 "${j.name}" 缺少 actor 身份,已按匿名(最低权限)运行;如需更高权限请在 WebUI 中显式设置创建者身份`);
|
|
237
404
|
}
|
|
238
405
|
return {
|
|
239
406
|
name: String(j.name ?? 'unnamed'),
|
|
@@ -252,7 +419,14 @@ async function run(caps) {
|
|
|
252
419
|
}
|
|
253
420
|
catch (err) {
|
|
254
421
|
loadFailed = true;
|
|
255
|
-
logger.warn(`加载持久化任务失败,本次运行不再写入该文件: ${err}`);
|
|
422
|
+
logger.use.warn(`加载持久化任务失败,本次运行不再写入该文件: ${err}`);
|
|
423
|
+
// 里面的任务全没加载、新改动也不落盘:只记日志用户看不到
|
|
424
|
+
webui.use.notify({
|
|
425
|
+
id: 'jobs-load',
|
|
426
|
+
level: 'error',
|
|
427
|
+
title: t('loadFailedTitle', { uri: persistUri }),
|
|
428
|
+
detail: t(reading ? 'loadFailedReading' : 'loadFailedContent', { error: textOf(err) }),
|
|
429
|
+
});
|
|
256
430
|
return [];
|
|
257
431
|
}
|
|
258
432
|
}
|
|
@@ -265,17 +439,17 @@ async function run(caps) {
|
|
|
265
439
|
let saveChain = Promise.resolve();
|
|
266
440
|
function saveDynamicJobs() {
|
|
267
441
|
if (loadFailed) {
|
|
268
|
-
logger.warn(`持久化任务上次加载失败,跳过写入以免覆盖原有任务(本次改动仅在内存生效): ${persistUri}`);
|
|
442
|
+
logger.use.warn(`持久化任务上次加载失败,跳过写入以免覆盖原有任务(本次改动仅在内存生效): ${persistUri}`);
|
|
269
443
|
return;
|
|
270
444
|
}
|
|
271
445
|
const jobs = [...dynamicJobs.values()];
|
|
272
446
|
const payload = JSON.stringify(jobs, null, 2);
|
|
273
447
|
saveChain = saveChain
|
|
274
|
-
.then(() =>
|
|
448
|
+
.then(() => storage.use.writeFile(persistUri, payload))
|
|
275
449
|
.then(() => {
|
|
276
|
-
logger.debug(`已持久化 ${jobs.length} 个动态任务`);
|
|
450
|
+
logger.use.debug(`已持久化 ${jobs.length} 个动态任务`);
|
|
277
451
|
}, err => {
|
|
278
|
-
logger.warn(`持久化任务失败: ${err}`);
|
|
452
|
+
logger.use.warn(`持久化任务失败: ${err}`);
|
|
279
453
|
});
|
|
280
454
|
}
|
|
281
455
|
// ── 发送调度消息 ──
|
|
@@ -283,15 +457,16 @@ async function run(caps) {
|
|
|
283
457
|
if (rt.running)
|
|
284
458
|
return;
|
|
285
459
|
rt.running = true;
|
|
460
|
+
jobsPage.invalidate();
|
|
286
461
|
const jobName = rt.config.name;
|
|
287
462
|
try {
|
|
288
|
-
logger.info(`执行任务: ${jobName}`);
|
|
289
|
-
await events.emit('scheduler:job:start', jobName);
|
|
463
|
+
logger.use.info(`执行任务: ${jobName}`);
|
|
464
|
+
await events.use.emit('scheduler:job:start', jobName);
|
|
290
465
|
// 通用触发事件:供 workflow 等订阅者使用
|
|
291
466
|
// (任务本身仍走 inbound:message 投递,这里同时广播 trigger:fired,
|
|
292
467
|
// 便于平滑迁移到 plugin-workflow)
|
|
293
468
|
// biome-ignore lint/suspicious/noExplicitAny: 事件类型由 api-workflow 增广,scheduler 不直接依赖
|
|
294
|
-
await events.emit('trigger:fired', {
|
|
469
|
+
await events.use.emit('trigger:fired', {
|
|
295
470
|
source: `scheduler:${jobName}`,
|
|
296
471
|
type: rt.config.cron ? 'cron' : 'interval',
|
|
297
472
|
payload: {
|
|
@@ -301,11 +476,13 @@ async function run(caps) {
|
|
|
301
476
|
content: rt.config.content,
|
|
302
477
|
},
|
|
303
478
|
});
|
|
479
|
+
// 内容由 owner 写下或审批(见 SchedulerService;owner 免了 scheduler_create_job 的确认时例外,见 security-model),声明 ownerApproved
|
|
304
480
|
const message = {
|
|
305
481
|
content: rt.config.content,
|
|
306
482
|
sessionId: rt.config.sessionId,
|
|
307
483
|
platform: rt.config.platform,
|
|
308
484
|
source: 'scheduler',
|
|
485
|
+
ownerApproved: true,
|
|
309
486
|
};
|
|
310
487
|
// 注入代理身份:authority 守卫优先读 actor 而非 platform/userId,
|
|
311
488
|
// 从而让 scheduler 触发的 AI 走**创建者**的权限等级。
|
|
@@ -318,20 +495,21 @@ async function run(caps) {
|
|
|
318
495
|
if (actorPlatform && actorUserId) {
|
|
319
496
|
message.actor = { platform: actorPlatform, userId: actorUserId };
|
|
320
497
|
}
|
|
321
|
-
await events.emit('inbound:message', message);
|
|
498
|
+
await events.use.emit('inbound:message', message);
|
|
322
499
|
rt.lastRun = Date.now();
|
|
323
500
|
rt.runCount++;
|
|
324
|
-
rt.lastResult = '
|
|
325
|
-
await events.emit('scheduler:job:done', jobName);
|
|
501
|
+
rt.lastResult = t('jobSucceeded');
|
|
502
|
+
await events.use.emit('scheduler:job:done', jobName);
|
|
326
503
|
}
|
|
327
504
|
catch (err) {
|
|
328
505
|
const msg = err instanceof Error ? err.message : String(err);
|
|
329
|
-
rt.lastResult =
|
|
330
|
-
logger.error(`任务 "${jobName}" 执行失败: ${msg}`);
|
|
331
|
-
await events.emit('scheduler:job:error', jobName, msg);
|
|
506
|
+
rt.lastResult = t('jobFailed', { error: textOf(err) });
|
|
507
|
+
logger.use.error(`任务 "${jobName}" 执行失败: ${msg}`);
|
|
508
|
+
await events.use.emit('scheduler:job:error', jobName, msg);
|
|
332
509
|
}
|
|
333
510
|
finally {
|
|
334
511
|
rt.running = false;
|
|
512
|
+
jobsPage.invalidate();
|
|
335
513
|
}
|
|
336
514
|
}
|
|
337
515
|
// ── 估算下次执行时间 ──
|
|
@@ -341,19 +519,44 @@ async function run(caps) {
|
|
|
341
519
|
return Number.isFinite(ms) ? ms : 0;
|
|
342
520
|
}
|
|
343
521
|
if (job.interval) {
|
|
344
|
-
|
|
522
|
+
// 间隔无效的任务没有排上(见 initJob),不知道下次
|
|
523
|
+
return intervalProblem(job.interval) ? 0 : (lastRun || Date.now()) + job.interval * 1000;
|
|
345
524
|
}
|
|
346
525
|
if (job.cron) {
|
|
347
526
|
const tz = job.timeZone?.trim() || undefined;
|
|
348
|
-
// 每次求值取当前提供者:cron-engine
|
|
349
|
-
|
|
527
|
+
// 每次求值取当前提供者:cron-engine 换人后不会算在旧实例上。时区无效时它同步抛错:这只是展示用的估计,
|
|
528
|
+
// 按「不知道」处理,任务排不上的原因由订阅失败的提示报出
|
|
529
|
+
try {
|
|
530
|
+
return cronEngine.use.nextFireTime(job.cron, new Date(), undefined, tz ? { timeZone: tz } : undefined) ?? 0;
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return 0;
|
|
534
|
+
}
|
|
350
535
|
}
|
|
351
536
|
return 0;
|
|
352
537
|
}
|
|
353
538
|
// ── 初始化所有任务 ──
|
|
539
|
+
/** cron 订阅不上、固定间隔无效,永远不会运行的任务各一条提示:任务重排或删掉时撤回 */
|
|
540
|
+
const unscheduled = new Map();
|
|
541
|
+
function clearUnscheduled(name) {
|
|
542
|
+
unscheduled.get(name)?.clear();
|
|
543
|
+
unscheduled.delete(name);
|
|
544
|
+
}
|
|
545
|
+
/** 发「没有排上」的提示:配置文件里的任务写去配置里改,别的写去计划任务页编辑,带出原因 */
|
|
546
|
+
function markUnscheduled(name, reason) {
|
|
547
|
+
const fromConfig = configJobNames.has(name);
|
|
548
|
+
unscheduled.set(name, webui.use.notify({
|
|
549
|
+
id: `unscheduled:${name}`,
|
|
550
|
+
level: 'error',
|
|
551
|
+
title: t('unscheduledTitle', { name }),
|
|
552
|
+
detail: t(fromConfig ? 'unscheduledInConfig' : 'unscheduledOnPage', { error: reason }),
|
|
553
|
+
open: fromConfig ? 'config' : { page: 'scheduler' },
|
|
554
|
+
}));
|
|
555
|
+
}
|
|
354
556
|
function initJob(jobCfg) {
|
|
355
557
|
if (runtimes.has(jobCfg.name))
|
|
356
558
|
return;
|
|
559
|
+
clearUnscheduled(jobCfg.name);
|
|
357
560
|
// 归一化 cron 快捷符;"@every Ns" 转成 interval 通道
|
|
358
561
|
if (jobCfg.cron) {
|
|
359
562
|
const everySec = parseEverySeconds(jobCfg.cron);
|
|
@@ -367,7 +570,7 @@ async function run(caps) {
|
|
|
367
570
|
// 创建口与 pauseJob 都已拒绝这种组合,但存量持久化文件里可能还留着;启动时告警并按未暂停处理
|
|
368
571
|
// (要停掉它请改 enabled 或直接删除)。
|
|
369
572
|
if (jobCfg.runAt && !jobCfg.cron && !jobCfg.interval && jobCfg.paused) {
|
|
370
|
-
logger.warn(`任务 "${jobCfg.name}" 是一次性任务却带 paused,暂停对其无可用语义;已按未暂停处理`);
|
|
573
|
+
logger.use.warn(`任务 "${jobCfg.name}" 是一次性任务却带 paused,暂停对其无可用语义;已按未暂停处理`);
|
|
371
574
|
jobCfg.paused = false;
|
|
372
575
|
}
|
|
373
576
|
const rt = {
|
|
@@ -382,20 +585,27 @@ async function run(caps) {
|
|
|
382
585
|
// 优先采用持久化的 paused 字段;若未设置则回落到 enabled 状态(禁用即视作暂停)
|
|
383
586
|
paused: jobCfg.paused === true || !jobCfg.enabled,
|
|
384
587
|
};
|
|
385
|
-
//
|
|
386
|
-
if (jobCfg.interval
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
588
|
+
// 固定间隔调度。间隔无效(见 intervalProblem)的不排、发提示:服务入口已当场拒收,这里只剩配置文件与存量文件里的任务
|
|
589
|
+
if (jobCfg.interval !== undefined && jobCfg.enabled) {
|
|
590
|
+
const problem = intervalProblem(jobCfg.interval);
|
|
591
|
+
if (problem) {
|
|
592
|
+
logger.use.warn(`任务 ${jobCfg.name} 的固定间隔无效: ${toSource(problem)}`);
|
|
593
|
+
markUnscheduled(jobCfg.name, problem);
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
rt.timer = setInterval(() => {
|
|
597
|
+
if (!rt.paused) {
|
|
598
|
+
executeJob(rt);
|
|
599
|
+
rt.nextRun = estimateNextRun(rt.config, Date.now());
|
|
600
|
+
}
|
|
601
|
+
}, jobCfg.interval * 1000);
|
|
602
|
+
}
|
|
393
603
|
}
|
|
394
604
|
// cron 任务:订阅到共享 cron-engine
|
|
395
605
|
if (jobCfg.cron && jobCfg.enabled) {
|
|
396
606
|
try {
|
|
397
607
|
const tz = jobCfg.timeZone?.trim() || undefined;
|
|
398
|
-
rt.cronDispose = cronEngine.
|
|
608
|
+
rt.cronDispose = cronEngine.use.subscribe(jobCfg.cron, () => {
|
|
399
609
|
if (rt.paused || !rt.config.enabled)
|
|
400
610
|
return;
|
|
401
611
|
executeJob(rt);
|
|
@@ -403,7 +613,9 @@ async function run(caps) {
|
|
|
403
613
|
}, tz ? { timeZone: tz } : undefined);
|
|
404
614
|
}
|
|
405
615
|
catch (err) {
|
|
406
|
-
|
|
616
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
617
|
+
logger.use.warn(`任务 ${jobCfg.name} cron 订阅失败: ${reason}`);
|
|
618
|
+
markUnscheduled(jobCfg.name, textOf(err));
|
|
407
619
|
}
|
|
408
620
|
}
|
|
409
621
|
// 一次性任务:到点用 setTimeout 触发一次,执行完 disable 并持久化
|
|
@@ -438,10 +650,11 @@ async function run(caps) {
|
|
|
438
650
|
}
|
|
439
651
|
}
|
|
440
652
|
else {
|
|
441
|
-
logger.warn(`任务 ${jobCfg.name} 的 runAt 无法解析: ${jobCfg.runAt}`);
|
|
653
|
+
logger.use.warn(`任务 ${jobCfg.name} 的 runAt 无法解析: ${jobCfg.runAt}`);
|
|
442
654
|
}
|
|
443
655
|
}
|
|
444
656
|
runtimes.set(jobCfg.name, rt);
|
|
657
|
+
jobsPage.invalidate();
|
|
445
658
|
const scheduleHint = jobCfg.cron
|
|
446
659
|
? `(cron: ${jobCfg.cron})`
|
|
447
660
|
: jobCfg.interval
|
|
@@ -449,7 +662,7 @@ async function run(caps) {
|
|
|
449
662
|
: jobCfg.runAt
|
|
450
663
|
? `(runAt: ${jobCfg.runAt})`
|
|
451
664
|
: '(no schedule)';
|
|
452
|
-
logger.info(`任务已注册: ${jobCfg.name} ${scheduleHint}`);
|
|
665
|
+
logger.use.info(`任务已注册: ${jobCfg.name} ${scheduleHint}`);
|
|
453
666
|
}
|
|
454
667
|
/** 一次性任务执行完后的清理:动态任务直接删除;静态 yaml 任务仅标记 enabled=false */
|
|
455
668
|
function disableOneShot(rt) {
|
|
@@ -462,40 +675,75 @@ async function run(caps) {
|
|
|
462
675
|
runtimes.delete(jobName);
|
|
463
676
|
dynamicJobs.delete(jobName);
|
|
464
677
|
saveDynamicJobs();
|
|
465
|
-
|
|
678
|
+
jobsPage.invalidate();
|
|
679
|
+
logger.use.info(`一次性任务已执行完毕并删除: ${jobName}`);
|
|
466
680
|
return;
|
|
467
681
|
}
|
|
468
682
|
// 静态配置任务:不删,仅标记 enabled=false(yaml 才是权威源)
|
|
469
683
|
rt.config.enabled = false;
|
|
470
684
|
rt.paused = true;
|
|
471
|
-
|
|
685
|
+
jobsPage.invalidate();
|
|
686
|
+
logger.use.info(`一次性任务已执行完毕并停用(静态): ${jobName}`);
|
|
472
687
|
}
|
|
473
|
-
// 静态 YAML 任务的「创建者」是编辑配置文件的人——那本身就是 owner
|
|
688
|
+
// 静态 YAML 任务的「创建者」是编辑配置文件的人——那本身就是 owner 行为,故两项都不写时补 owner。
|
|
474
689
|
// **这是全插件唯一允许推断 owner 的地方**:来源是配置文件(只有能写盘的人才改得动),
|
|
475
690
|
// 不是运行时输入。补在此处而非触发时,是为了让「谁是 actor」在任务入表那一刻就定死,
|
|
476
691
|
// 触发路径只透传、不再有第二次机会发明身份。YAML 显式写 actor 则用其值(可降权)。
|
|
477
692
|
for (const job of config.jobs) {
|
|
478
693
|
const p = job.actorPlatform?.trim();
|
|
479
694
|
const u = job.actorUserId?.trim();
|
|
480
|
-
if (
|
|
481
|
-
//
|
|
482
|
-
|
|
483
|
-
|
|
695
|
+
if (!p !== !u) {
|
|
696
|
+
// 只写了一半:写执行身份通常是为了降权,补成 owner 与作者意图相反,另一半也无从知道,所以这条不载入
|
|
697
|
+
logger.use.error(`任务 "${job.name}" 只写了 actorPlatform、actorUserId 其中一个,两者须成对填写;这条任务没有载入`);
|
|
698
|
+
unscheduled.set(job.name, webui.use.notify({
|
|
699
|
+
id: `unscheduled:${job.name}`,
|
|
700
|
+
level: 'error',
|
|
701
|
+
title: t('halfActorTitle', { name: job.name }),
|
|
702
|
+
detail: t('halfActorDetail'),
|
|
703
|
+
open: 'config',
|
|
704
|
+
}));
|
|
705
|
+
continue;
|
|
484
706
|
}
|
|
485
707
|
initJob(p && u ? job : { ...job, actorPlatform: 'webui', actorUserId: 'console' });
|
|
486
708
|
}
|
|
487
|
-
//
|
|
488
|
-
|
|
709
|
+
// 持久化的动态任务:配置文件里写了的名字归配置文件,那一条没载入也不让同名的动态任务顶上来;
|
|
710
|
+
// 文件里重名的只认第一条
|
|
489
711
|
for (const job of persisted) {
|
|
490
|
-
if (!runtimes.has(job.name)) {
|
|
712
|
+
if (!configJobNames.has(job.name) && !runtimes.has(job.name)) {
|
|
491
713
|
dynamicJobs.set(job.name, job);
|
|
492
714
|
initJob(job);
|
|
493
715
|
}
|
|
494
716
|
}
|
|
495
717
|
if (persisted.length > 0) {
|
|
496
|
-
logger.info(`已加载 ${persisted.length} 个持久化任务`);
|
|
718
|
+
logger.use.info(`已加载 ${persisted.length} 个持久化任务`);
|
|
497
719
|
}
|
|
498
720
|
// ── 注册 scheduler 服务 ──
|
|
721
|
+
/**
|
|
722
|
+
* 配置文件里写了的名字归配置文件(那一条没载入也一样):页面、工具与别的插件经服务新建与修改时都不能用它,免得顶替它、
|
|
723
|
+
* 撤掉它的错误提示,又因名字在配置里不落盘、重启就没了
|
|
724
|
+
*/
|
|
725
|
+
function rejectConfigName(name) {
|
|
726
|
+
if (configJobNames.has(name))
|
|
727
|
+
throw new TextError(t('fromConfig', { name }));
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* 无效的 cron 与固定间隔(含 cron 的 `@every`,见 intervalProblem)当场拒收:收下了也订阅不上、永远不会运行,或者间隔太短、
|
|
731
|
+
* 超过定时器能排的上限溢出成每毫秒触发,还会落进持久化文件。页面、工具与别的插件都经服务新建与修改
|
|
732
|
+
*/
|
|
733
|
+
function rejectInvalidSchedule(job) {
|
|
734
|
+
if (job.cron) {
|
|
735
|
+
const checked = cronEngine.use.validate(job.cron);
|
|
736
|
+
if (!checked.ok)
|
|
737
|
+
throw typeof checked.text === 'string' ? new Error(checked.text) : new TextError(checked.text);
|
|
738
|
+
}
|
|
739
|
+
const every = job.cron ? parseEverySeconds(job.cron) : 0;
|
|
740
|
+
const interval = every > 0 ? every : job.interval;
|
|
741
|
+
if (interval !== undefined) {
|
|
742
|
+
const problem = intervalProblem(interval);
|
|
743
|
+
if (problem)
|
|
744
|
+
throw new TextError(problem);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
499
747
|
const service = {
|
|
500
748
|
getJobs() {
|
|
501
749
|
return [...runtimes.values()].map(rt => ({
|
|
@@ -521,7 +769,7 @@ async function run(caps) {
|
|
|
521
769
|
return false;
|
|
522
770
|
// 一次性任务被暂停会永久卡住(到点的定时器直接跳过且不重排),与创建口同一判据:直接拒绝
|
|
523
771
|
if (rt.config.runAt && !rt.config.cron && !rt.config.interval) {
|
|
524
|
-
logger.warn(`一次性任务不能暂停: ${jobName}(只能删除重建)`);
|
|
772
|
+
logger.use.warn(`一次性任务不能暂停: ${jobName}(只能删除重建)`);
|
|
525
773
|
return false;
|
|
526
774
|
}
|
|
527
775
|
rt.paused = true;
|
|
@@ -530,7 +778,8 @@ async function run(caps) {
|
|
|
530
778
|
dynamicJobs.set(jobName, { ...rt.config });
|
|
531
779
|
saveDynamicJobs();
|
|
532
780
|
}
|
|
533
|
-
|
|
781
|
+
jobsPage.invalidate();
|
|
782
|
+
logger.use.info(`任务已暂停: ${jobName}`);
|
|
534
783
|
return true;
|
|
535
784
|
},
|
|
536
785
|
resumeJob(jobName) {
|
|
@@ -544,7 +793,8 @@ async function run(caps) {
|
|
|
544
793
|
dynamicJobs.set(jobName, { ...rt.config });
|
|
545
794
|
saveDynamicJobs();
|
|
546
795
|
}
|
|
547
|
-
|
|
796
|
+
jobsPage.invalidate();
|
|
797
|
+
logger.use.info(`任务已恢复: ${jobName}`);
|
|
548
798
|
return true;
|
|
549
799
|
},
|
|
550
800
|
async triggerJob(jobName) {
|
|
@@ -555,18 +805,16 @@ async function run(caps) {
|
|
|
555
805
|
return true;
|
|
556
806
|
},
|
|
557
807
|
addJob(job) {
|
|
808
|
+
rejectConfigName(job.name);
|
|
809
|
+
rejectInvalidSchedule(job);
|
|
558
810
|
initJob(job);
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
dynamicJobs.set(job.name, job);
|
|
562
|
-
saveDynamicJobs();
|
|
563
|
-
}
|
|
811
|
+
dynamicJobs.set(job.name, job);
|
|
812
|
+
saveDynamicJobs();
|
|
564
813
|
},
|
|
565
814
|
setJob(job) {
|
|
566
|
-
// upsert:先 remove 再 add
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
}
|
|
815
|
+
// upsert:先 remove 再 add
|
|
816
|
+
rejectConfigName(job.name);
|
|
817
|
+
rejectInvalidSchedule(job);
|
|
570
818
|
const existed = runtimes.get(job.name);
|
|
571
819
|
if (existed) {
|
|
572
820
|
if (existed.timer)
|
|
@@ -592,116 +840,21 @@ async function run(caps) {
|
|
|
592
840
|
dynamicJobs.delete(jobName);
|
|
593
841
|
saveDynamicJobs();
|
|
594
842
|
}
|
|
595
|
-
|
|
843
|
+
clearUnscheduled(jobName);
|
|
844
|
+
jobsPage.invalidate();
|
|
845
|
+
logger.use.info(`任务已删除: ${jobName}`);
|
|
596
846
|
return true;
|
|
597
847
|
},
|
|
598
848
|
};
|
|
599
|
-
provide(scheduler, service);
|
|
600
|
-
// ── WebUI 页面动作 ──
|
|
601
|
-
// 动作是本次激活的闭包,直接操作本插件自己的调度表;登记随激活撤回。
|
|
602
|
-
webui.registerAction('listJobs', async () => {
|
|
603
|
-
return service.getJobs().map(j => {
|
|
604
|
-
const ready = j.enabled && !j.paused && !j.running;
|
|
605
|
-
return {
|
|
606
|
-
...j,
|
|
607
|
-
nextRun: ready ? j.nextRun : 0,
|
|
608
|
-
schedule: j.cron ?? (j.interval ? `每 ${j.interval}s` : j.runAt ? `一次性@${j.runAt}` : '未设置'),
|
|
609
|
-
status: !j.enabled ? '❌ 禁用' : j.paused ? '⏸ 暂停' : j.running ? '⏳ 执行中' : '✅ 就绪',
|
|
610
|
-
lastRunText: j.lastRun ? new Date(j.lastRun).toLocaleString('zh-CN') : '从未',
|
|
611
|
-
};
|
|
612
|
-
});
|
|
613
|
-
});
|
|
614
|
-
webui.registerAction('triggerJob', async (args) => service.triggerJob(args.name));
|
|
615
|
-
// 三个开关类 action 返回 { ok, error }:裸 boolean 会被前端按成功处理(一次性任务不能暂停就是这样被静默掉的)
|
|
616
|
-
webui.registerAction('pauseJob', async (args) => service.pauseJob(args.name)
|
|
617
|
-
? { ok: true }
|
|
618
|
-
: { ok: false, error: '任务不存在,或是一次性任务(不能暂停,只能删除重建)' });
|
|
619
|
-
webui.registerAction('resumeJob', async (args) => service.resumeJob(args.name) ? { ok: true } : { ok: false, error: '任务不存在' });
|
|
620
|
-
webui.registerAction('removeJob', async (args) => service.removeJob(args.name) ? { ok: true } : { ok: false, error: '任务不存在' });
|
|
621
|
-
webui.registerAction('newJobDraft', async () => ({
|
|
622
|
-
name: '',
|
|
623
|
-
cron: '',
|
|
624
|
-
interval: 0,
|
|
625
|
-
runAt: '',
|
|
626
|
-
delaySeconds: 0,
|
|
627
|
-
sessionId: '',
|
|
628
|
-
platform: 'internal',
|
|
629
|
-
content: '',
|
|
630
|
-
enabled: true,
|
|
631
|
-
paused: false,
|
|
632
|
-
}));
|
|
633
|
-
webui.registerAction('upsertJob', async (args, caller) => {
|
|
634
|
-
const jobName = String(args.name ?? '').trim();
|
|
635
|
-
if (!jobName)
|
|
636
|
-
return { ok: false, error: '任务名称不能为空' };
|
|
637
|
-
const cron = String(args.cron ?? '').trim() || undefined;
|
|
638
|
-
const interval = Number(args.interval) > 0 ? Number(args.interval) : undefined;
|
|
639
|
-
const delaySeconds = Number(args.delaySeconds) > 0 ? Math.floor(Number(args.delaySeconds)) : undefined;
|
|
640
|
-
const rawRunAt = String(args.runAt ?? '').trim() || undefined;
|
|
641
|
-
// 调度方式恰好一个:四者同时也是四种互不相容的定时语义(周期 cron / 周期间隔 / 一次性),
|
|
642
|
-
// 多填时取哪个都是猜,一律报错要求填一个——与 scheduler_create_job 工具路径同一条判据。
|
|
643
|
-
const provided = [cron, interval, delaySeconds, rawRunAt].filter(v => v !== undefined).length;
|
|
644
|
-
if (provided === 0) {
|
|
645
|
-
return { ok: false, error: 'cron / interval / runAt / delaySeconds 必须填写其中之一' };
|
|
646
|
-
}
|
|
647
|
-
if (provided > 1) {
|
|
648
|
-
return { ok: false, error: 'cron / interval / runAt / delaySeconds 互斥,只能填一个' };
|
|
649
|
-
}
|
|
650
|
-
// delaySeconds → runAt:与 scheduler_create_job 工具路径同一份转换(到点执行一次后自动停止)
|
|
651
|
-
const runAt = delaySeconds !== undefined ? new Date(Date.now() + delaySeconds * 1000).toISOString() : rawRunAt;
|
|
652
|
-
// 「创建后立即暂停」只对周期任务有意义:一次性任务被暂停时到点的 setTimeout 直接跳过,
|
|
653
|
-
// 之后既不会重排也不会自删(disableOneShot 只在真执行后走),任务就永久卡死在那里。
|
|
654
|
-
if (args.paused === true && runAt) {
|
|
655
|
-
return { ok: false, error: '创建后立即暂停对一次性任务(runAt / delaySeconds)没有可用语义' };
|
|
656
|
-
}
|
|
657
|
-
// 防资源耗尽(scheduler 工具对聊天访客公开):interval 下限,挡住 interval:0.5 这类高频任务。
|
|
658
|
-
if (interval !== undefined && interval < 5) {
|
|
659
|
-
return { ok: false, error: 'interval 不能小于 5 秒(防高频任务耗尽资源)' };
|
|
660
|
-
}
|
|
661
|
-
const sessionId = String(args.sessionId ?? '').trim();
|
|
662
|
-
if (!sessionId)
|
|
663
|
-
return { ok: false, error: 'sessionId 不能为空' };
|
|
664
|
-
const content = String(args.content ?? '').trim();
|
|
665
|
-
if (!content)
|
|
666
|
-
return { ok: false, error: 'content 不能为空' };
|
|
667
|
-
// 防资源耗尽:单会话任务数上限;更新已有任务(同名)不计入,仅拦新建。
|
|
668
|
-
const MAX_JOBS_PER_SESSION = 20;
|
|
669
|
-
const allJobs = service.getJobs();
|
|
670
|
-
if (!allJobs.some(j => j.name === jobName) &&
|
|
671
|
-
allJobs.filter(j => j.sessionId === sessionId).length >= MAX_JOBS_PER_SESSION) {
|
|
672
|
-
return { ok: false, error: `本会话定时任务数已达上限 ${MAX_JOBS_PER_SESSION},请先删除部分任务再新建` };
|
|
673
|
-
}
|
|
674
|
-
try {
|
|
675
|
-
// actor 从权限闸放行后的 caller 快照(登录账户或单 token 模式的 webui:console),
|
|
676
|
-
// 不从 args 读取,避免 WebUI 调用方伪造他人身份。
|
|
677
|
-
const actor = caller ?? { platform: 'webui', userId: 'console' };
|
|
678
|
-
service.setJob({
|
|
679
|
-
name: jobName,
|
|
680
|
-
cron,
|
|
681
|
-
interval,
|
|
682
|
-
runAt,
|
|
683
|
-
sessionId,
|
|
684
|
-
platform: String(args.platform ?? 'internal'),
|
|
685
|
-
actorPlatform: actor.platform,
|
|
686
|
-
actorUserId: actor.userId,
|
|
687
|
-
content,
|
|
688
|
-
enabled: args.enabled !== false,
|
|
689
|
-
paused: args.paused === true,
|
|
690
|
-
});
|
|
691
|
-
const note = persistNote();
|
|
692
|
-
return note ? { ok: true, message: `已保存${note}` } : { ok: true };
|
|
693
|
-
}
|
|
694
|
-
catch (err) {
|
|
695
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
696
|
-
}
|
|
697
|
-
});
|
|
849
|
+
provide.use(scheduler, service);
|
|
698
850
|
// ── 注册 AI 工具:让 Agent 可以自主创建/管理任务 ──
|
|
699
|
-
tools.registerGroup({
|
|
851
|
+
tools.use.registerGroup({
|
|
700
852
|
name: 'scheduler',
|
|
701
|
-
label: '
|
|
853
|
+
label: t('displayName'),
|
|
854
|
+
summary: t('groupSummary'),
|
|
702
855
|
description: '创建、查看和取消定时/周期性自主行动计划',
|
|
703
856
|
});
|
|
704
|
-
tools.register({
|
|
857
|
+
tools.use.register({
|
|
705
858
|
groups: ['scheduler'],
|
|
706
859
|
definition: {
|
|
707
860
|
type: 'function',
|
|
@@ -733,7 +886,10 @@ async function run(caps) {
|
|
|
733
886
|
type: 'string',
|
|
734
887
|
description: 'ISO 8601 绝对时间,到点执行一次后自动停止。例:"2026-05-19T15:46:44+08:00"。',
|
|
735
888
|
},
|
|
736
|
-
sessionId: {
|
|
889
|
+
sessionId: {
|
|
890
|
+
type: 'string',
|
|
891
|
+
description: '任务消息发往的会话 ID。不传则默认使用当前会话(子任务里必须传)。',
|
|
892
|
+
},
|
|
737
893
|
platform: { type: 'string', description: '目标平台标识。不传则默认使用当前平台。' },
|
|
738
894
|
content: { type: 'string', description: '届时发送给自己的指令/提示内容' },
|
|
739
895
|
},
|
|
@@ -742,9 +898,9 @@ async function run(caps) {
|
|
|
742
898
|
},
|
|
743
899
|
},
|
|
744
900
|
// 建任务 = 让 AI 在未来自主行动,且 actor 身份在此固化(见 resolveActor)。
|
|
745
|
-
// 写类:restricted +
|
|
901
|
+
// 写类:restricted + 确认,与别的要确认的操作同一规则(参与者都是 owner 且在免确认白名单里的回合免)。
|
|
746
902
|
risk: 'dangerous',
|
|
747
|
-
confirm: '
|
|
903
|
+
confirm: 'session',
|
|
748
904
|
handler: async (args, callCtx) => {
|
|
749
905
|
// 互斥校验:cron / interval / delaySeconds / runAt 必须恰好提供一个
|
|
750
906
|
const hasCron = typeof args.cron === 'string' && args.cron.trim().length > 0;
|
|
@@ -763,25 +919,41 @@ async function run(caps) {
|
|
|
763
919
|
if (hasDelay) {
|
|
764
920
|
runAt = new Date(Date.now() + Math.floor(args.delaySeconds) * 1000).toISOString();
|
|
765
921
|
}
|
|
922
|
+
const target = typeof args.sessionId === 'string' ? args.sessionId.trim() : '';
|
|
923
|
+
const here = sessionManager.present ? sessionManager.use.getSession(callCtx.sessionId) : undefined;
|
|
924
|
+
// 子会话没有窗口,删掉后发往它的任务就静默丢了:在子会话里不按默认发回当前会话,要写明目标;根会话的 id 作候选
|
|
925
|
+
if (!target && here?.parentId) {
|
|
926
|
+
const root = rootSessionOf(here, id => sessionManager.use.getSession(id));
|
|
927
|
+
const candidate = root ? `;要发回根会话,sessionId 写 "${root.id}"` : '';
|
|
928
|
+
return JSON.stringify({
|
|
929
|
+
error: `在子任务里建定时任务必须写明目标会话 sessionId:子任务的会话删掉后,发往它的任务会静默丢失${candidate}`,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
// 安全关键:创建者强制取这次调用的授权身份(api-tools 的 actingIdentity),不从 args 读取:即使 LLM 在 prompt 中尝试
|
|
933
|
+
// 伪造身份也会被忽略。只有一位参与者时是他(代人执行的回合,如子任务链、workflow 或定时任务驱动的,就是被代的人);
|
|
934
|
+
// 这一项声明了确认,多人的一轮里经 owner 批过,参与者已换成她;没有单一身份的是无主体,任务按匿名(defaultAuthority)
|
|
935
|
+
// 运行,实现权限的自然传递与不可提升
|
|
936
|
+
const creator = actingIdentity(callCtx);
|
|
766
937
|
const job = {
|
|
767
938
|
name: args.name,
|
|
768
939
|
cron: hasCron ? args.cron.trim() : undefined,
|
|
769
940
|
interval: hasInterval ? args.interval : undefined,
|
|
770
941
|
runAt,
|
|
771
|
-
sessionId:
|
|
942
|
+
sessionId: target || callCtx.sessionId,
|
|
772
943
|
platform: args.platform || callCtx.platform || 'internal',
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
// 优先 callCtx.actor(本回合本就在代人执行,如委派/子任务链),否则用物理身份;
|
|
776
|
-
// 两者皆空则子任务匿名(defaultAuthority),实现权限的自然传递与不可提升。
|
|
777
|
-
actorPlatform: callCtx.actor?.platform ?? callCtx.platform,
|
|
778
|
-
actorUserId: callCtx.actor?.userId ?? callCtx.userId,
|
|
944
|
+
actorPlatform: creator.platform,
|
|
945
|
+
actorUserId: creator.userId || undefined,
|
|
779
946
|
content: args.content,
|
|
780
947
|
enabled: true,
|
|
781
948
|
};
|
|
782
949
|
if (runtimes.has(job.name))
|
|
783
950
|
return JSON.stringify({ error: `任务 "${job.name}" 已存在` });
|
|
784
|
-
|
|
951
|
+
try {
|
|
952
|
+
service.addJob(job);
|
|
953
|
+
}
|
|
954
|
+
catch (err) {
|
|
955
|
+
return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });
|
|
956
|
+
}
|
|
785
957
|
const scheduleDesc = job.cron
|
|
786
958
|
? `cron="${job.cron}"`
|
|
787
959
|
: job.interval
|
|
@@ -789,13 +961,14 @@ async function run(caps) {
|
|
|
789
961
|
: job.runAt
|
|
790
962
|
? `一次性 @ ${job.runAt}`
|
|
791
963
|
: '?';
|
|
964
|
+
const note = persistNote();
|
|
792
965
|
return JSON.stringify({
|
|
793
966
|
ok: true,
|
|
794
|
-
message: `任务 "${job.name}" 已创建 (${scheduleDesc}),目标会话: ${job.sessionId} (${job.platform})${
|
|
967
|
+
message: `任务 "${job.name}" 已创建 (${scheduleDesc}),目标会话: ${job.sessionId} (${job.platform})${note ? `;${toSource(note)}` : ''}`,
|
|
795
968
|
});
|
|
796
969
|
},
|
|
797
970
|
});
|
|
798
|
-
tools.register({
|
|
971
|
+
tools.use.register({
|
|
799
972
|
groups: ['scheduler'],
|
|
800
973
|
definition: {
|
|
801
974
|
type: 'function',
|
|
@@ -817,8 +990,8 @@ async function run(caps) {
|
|
|
817
990
|
},
|
|
818
991
|
},
|
|
819
992
|
},
|
|
820
|
-
//
|
|
821
|
-
|
|
993
|
+
// 只看不改:任务清单含 sessionId / prompt,2 级、不确认,低于 2 级的看不到
|
|
994
|
+
visibility: 'restricted',
|
|
822
995
|
handler: async (args) => {
|
|
823
996
|
const all = service.getJobs().map(j => ({
|
|
824
997
|
name: j.name,
|
|
@@ -862,7 +1035,7 @@ async function run(caps) {
|
|
|
862
1035
|
});
|
|
863
1036
|
},
|
|
864
1037
|
});
|
|
865
|
-
tools.register({
|
|
1038
|
+
tools.use.register({
|
|
866
1039
|
groups: ['scheduler'],
|
|
867
1040
|
definition: {
|
|
868
1041
|
type: 'function',
|
|
@@ -880,13 +1053,13 @@ async function run(caps) {
|
|
|
880
1053
|
},
|
|
881
1054
|
// 删类:删掉定时任务不可逆(无回收站)。
|
|
882
1055
|
risk: 'dangerous',
|
|
883
|
-
confirm: '
|
|
1056
|
+
confirm: 'session',
|
|
884
1057
|
handler: async (args) => {
|
|
885
1058
|
const ok = service.removeJob(args.name);
|
|
886
1059
|
return JSON.stringify({ ok, message: ok ? '已删除' : '任务不存在' });
|
|
887
1060
|
},
|
|
888
1061
|
});
|
|
889
|
-
tools.register({
|
|
1062
|
+
tools.use.register({
|
|
890
1063
|
groups: ['scheduler'],
|
|
891
1064
|
definition: {
|
|
892
1065
|
type: 'function',
|
|
@@ -904,13 +1077,13 @@ async function run(caps) {
|
|
|
904
1077
|
},
|
|
905
1078
|
// 改状态类:暂停等于静默停掉一条自主行动,用户不会收到任何提示。
|
|
906
1079
|
risk: 'dangerous',
|
|
907
|
-
confirm: '
|
|
1080
|
+
confirm: 'session',
|
|
908
1081
|
handler: async (args) => {
|
|
909
1082
|
const ok = service.pauseJob(args.name);
|
|
910
1083
|
return JSON.stringify({ ok, message: ok ? '已暂停' : '任务不存在,或是一次性任务(不能暂停,只能删除重建)' });
|
|
911
1084
|
},
|
|
912
1085
|
});
|
|
913
|
-
tools.register({
|
|
1086
|
+
tools.use.register({
|
|
914
1087
|
groups: ['scheduler'],
|
|
915
1088
|
definition: {
|
|
916
1089
|
type: 'function',
|
|
@@ -928,14 +1101,14 @@ async function run(caps) {
|
|
|
928
1101
|
},
|
|
929
1102
|
// 改状态类:恢复会让一条被停掉的自主行动重新开始跑。
|
|
930
1103
|
risk: 'dangerous',
|
|
931
|
-
confirm: '
|
|
1104
|
+
confirm: 'session',
|
|
932
1105
|
handler: async (args) => {
|
|
933
1106
|
const ok = service.resumeJob(args.name);
|
|
934
1107
|
return JSON.stringify({ ok, message: ok ? '已恢复' : '任务不存在' });
|
|
935
1108
|
},
|
|
936
1109
|
});
|
|
937
1110
|
// ── 清理 ──
|
|
938
|
-
lifecycle.onDispose(() => {
|
|
1111
|
+
lifecycle.use.onDispose(() => {
|
|
939
1112
|
for (const rt of runtimes.values()) {
|
|
940
1113
|
if (rt.timer)
|
|
941
1114
|
clearInterval(rt.timer);
|
|
@@ -944,7 +1117,7 @@ async function run(caps) {
|
|
|
944
1117
|
}
|
|
945
1118
|
runtimes.clear();
|
|
946
1119
|
});
|
|
947
|
-
logger.info(`计划任务调度器已启动 (${config.jobs.length} 个任务)`);
|
|
1120
|
+
logger.use.info(`计划任务调度器已启动 (${config.jobs.length} 个任务)`);
|
|
948
1121
|
}
|
|
949
1122
|
// ──────────── 辅助函数 ────────────
|
|
950
1123
|
export function resolveConfig(cfg) {
|
|
@@ -963,7 +1136,7 @@ function resolvePersistPath(input) {
|
|
|
963
1136
|
if (!s)
|
|
964
1137
|
return configSchema.persistPath.default;
|
|
965
1138
|
if (!isStorageUri(s)) {
|
|
966
|
-
throw
|
|
1139
|
+
throw configError(t('badPersistPath', { value: s }));
|
|
967
1140
|
}
|
|
968
1141
|
return s;
|
|
969
1142
|
}
|