@aalis/plugin-scheduler 0.15.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 +37 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +230 -159
- 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,15 +1,27 @@
|
|
|
1
1
|
import { cronEngine } from '@aalis/api-cron-engine';
|
|
2
|
+
import { rootSessionOf, sessionManager } from '@aalis/api-session-manager';
|
|
2
3
|
import { isStorageNotFound, isStorageUri, storage } from '@aalis/api-storage';
|
|
3
|
-
import { tools } from '@aalis/api-tools';
|
|
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
7
|
import { configError, defineConfig, parseConfig } from '@aalis/schema-config';
|
|
7
|
-
import {
|
|
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;
|
|
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
|
+
}
|
|
13
25
|
// ----- 服务描述符(调用型:业务层就是提供者的形状)-----
|
|
14
26
|
export const scheduler = defineService('scheduler');
|
|
15
27
|
// ──────────── 插件元数据 ────────────
|
|
@@ -17,61 +29,61 @@ 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,9 +91,9 @@ 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 页面 ────────────
|
|
@@ -89,29 +101,33 @@ export const configSchema = defineConfig({
|
|
|
89
101
|
const JOB_FIELDS = defineConfig({
|
|
90
102
|
cron: {
|
|
91
103
|
type: 'string',
|
|
92
|
-
label: '
|
|
93
|
-
description: '
|
|
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 }),
|
|
94
111
|
},
|
|
95
|
-
interval: { type: 'number', label: '固定间隔(秒)', description: '不小于 5;与其余三种调度方式四选一' },
|
|
96
112
|
runAt: {
|
|
97
113
|
type: 'string',
|
|
98
|
-
label: '
|
|
99
|
-
description: '
|
|
114
|
+
label: t('runAtLabel'),
|
|
115
|
+
description: t('fieldRunAtDescription'),
|
|
100
116
|
},
|
|
101
117
|
delaySeconds: {
|
|
102
118
|
type: 'number',
|
|
103
|
-
label: '
|
|
104
|
-
description: '
|
|
119
|
+
label: t('delaySecondsLabel'),
|
|
120
|
+
description: t('delaySecondsDescription'),
|
|
105
121
|
},
|
|
106
|
-
sessionId: { type: 'string', label: '
|
|
107
|
-
platform: { type: 'string', label: '
|
|
108
|
-
content: { type: 'textarea', label: '
|
|
109
|
-
enabled: { type: 'boolean', label: '
|
|
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 },
|
|
110
126
|
paused: {
|
|
111
127
|
type: 'boolean',
|
|
112
|
-
label: '
|
|
128
|
+
label: t('paused'),
|
|
113
129
|
default: false,
|
|
114
|
-
description: '
|
|
130
|
+
description: t('pausedDescription'),
|
|
115
131
|
},
|
|
116
132
|
});
|
|
117
133
|
/** 单个会话最多几个计划任务(防资源耗尽);编辑已有任务不计入 */
|
|
@@ -126,6 +142,8 @@ const uses = {
|
|
|
126
142
|
storage: optional(storage),
|
|
127
143
|
/** 计划任务页的登记口;无 WebUI 时调度照常运行 */
|
|
128
144
|
webui: optional(webuiServer),
|
|
145
|
+
/** 认调用所在的会话是不是子会话(在子会话里建任务要写明目标);没有会话管理就没有子会话 */
|
|
146
|
+
sessionManager: optional(sessionManager),
|
|
129
147
|
events,
|
|
130
148
|
lifecycle,
|
|
131
149
|
logger,
|
|
@@ -134,7 +152,7 @@ const uses = {
|
|
|
134
152
|
};
|
|
135
153
|
export default definePlugin({
|
|
136
154
|
name: '@aalis/plugin-scheduler',
|
|
137
|
-
displayName: '
|
|
155
|
+
displayName: t('displayName'),
|
|
138
156
|
subsystem: 'scheduler',
|
|
139
157
|
extends: {
|
|
140
158
|
events: ['scheduler:job:start', 'scheduler:job:done', 'scheduler:job:error'],
|
|
@@ -146,7 +164,7 @@ export default definePlugin({
|
|
|
146
164
|
});
|
|
147
165
|
async function run(caps) {
|
|
148
166
|
const cfg = parseConfig(configSchema, caps.config.use, caps.logger.use);
|
|
149
|
-
const { cronEngine, storage, webui, events, lifecycle, logger, provide, tools } = caps;
|
|
167
|
+
const { cronEngine, storage, webui, sessionManager, events, lifecycle, logger, provide, tools } = caps;
|
|
150
168
|
const config = resolveConfig(cfg);
|
|
151
169
|
const runtimes = new Map();
|
|
152
170
|
// 配置中定义的任务名称集合(不需要持久化)
|
|
@@ -160,8 +178,11 @@ async function run(caps) {
|
|
|
160
178
|
* 此后一律拒写,否则下一次增删改就把原有动态任务冲掉。只在激活时读一次,storage 迟到也不重读。
|
|
161
179
|
*/
|
|
162
180
|
let loadFailed = false;
|
|
163
|
-
/**
|
|
164
|
-
|
|
181
|
+
/**
|
|
182
|
+
* 拒写期间新建或改动的任务只在内存、重启即失:回执末尾如实注明(AI 工具与计划任务页同一附注,工具取原文),
|
|
183
|
+
* 文件正常时没有
|
|
184
|
+
*/
|
|
185
|
+
const persistNote = () => (loadFailed ? t('notPersisted', { uri: persistUri }) : undefined);
|
|
165
186
|
// 先读完持久化任务再登记页面:登记之后到服务就绪之间不再有等待,页面一出现取数与动作就可用
|
|
166
187
|
const persisted = await loadDynamicJobs();
|
|
167
188
|
// ── 计划任务页:动作是本次激活的闭包,直接操作本插件自己的调度表;登记随激活撤回 ──
|
|
@@ -176,28 +197,25 @@ async function run(caps) {
|
|
|
176
197
|
const rawRunAt = input.runAt?.trim() || undefined;
|
|
177
198
|
const provided = [cron, interval, delaySeconds, rawRunAt].filter(v => v !== undefined).length;
|
|
178
199
|
if (provided === 0)
|
|
179
|
-
throw new
|
|
200
|
+
throw new TextError(t('scheduleMissing'));
|
|
180
201
|
if (provided > 1)
|
|
181
|
-
throw new
|
|
202
|
+
throw new TextError(t('scheduleConflict'));
|
|
182
203
|
const runAt = delaySeconds !== undefined ? new Date(Date.now() + delaySeconds * 1000).toISOString() : rawRunAt;
|
|
183
204
|
// 一次性任务被暂停时到点的定时器直接跳过,之后既不重排也不自删,任务就永久卡死在那里
|
|
184
205
|
if (input.paused && runAt)
|
|
185
|
-
throw new
|
|
186
|
-
// 防误填高频任务:挡住 interval:0.5 这类会占满调度的间隔
|
|
187
|
-
if (interval !== undefined && interval < 5)
|
|
188
|
-
throw new Error('固定间隔不能小于 5 秒(防高频任务耗尽资源)');
|
|
206
|
+
throw new TextError(t('pauseOneShot'));
|
|
189
207
|
const sessionId = input.sessionId.trim();
|
|
190
208
|
if (!sessionId)
|
|
191
|
-
throw new
|
|
209
|
+
throw new TextError(t('sessionIdEmpty'));
|
|
192
210
|
const content = input.content.trim();
|
|
193
211
|
if (!content)
|
|
194
|
-
throw new
|
|
212
|
+
throw new TextError(t('contentEmpty'));
|
|
195
213
|
if (creating) {
|
|
196
214
|
const all = service.getJobs();
|
|
197
215
|
if (all.some(j => j.name === name))
|
|
198
|
-
throw new
|
|
216
|
+
throw new TextError(t('duplicateName', { name }));
|
|
199
217
|
if (all.filter(j => j.sessionId === sessionId).length >= MAX_JOBS_PER_SESSION) {
|
|
200
|
-
throw new
|
|
218
|
+
throw new TextError(t('tooManyJobs', { max: MAX_JOBS_PER_SESSION }));
|
|
201
219
|
}
|
|
202
220
|
}
|
|
203
221
|
service.setJob({
|
|
@@ -213,81 +231,82 @@ async function run(caps) {
|
|
|
213
231
|
enabled: input.enabled,
|
|
214
232
|
paused: input.paused,
|
|
215
233
|
});
|
|
216
|
-
|
|
234
|
+
const note = persistNote();
|
|
235
|
+
return note ? textClauses([t('jobSaved', { name }), note]) : t('jobSaved', { name });
|
|
217
236
|
}
|
|
218
237
|
const jobsPage = webui.use.definePage({
|
|
219
238
|
key: 'scheduler',
|
|
220
|
-
title: '
|
|
239
|
+
title: t('pageTitle'),
|
|
221
240
|
icon: 'clock',
|
|
222
241
|
actions: {
|
|
223
242
|
createJob: {
|
|
224
|
-
label: '
|
|
243
|
+
label: t('create'),
|
|
225
244
|
effect: 'write',
|
|
226
245
|
input: {
|
|
227
|
-
name: { type: 'string', label: '
|
|
246
|
+
name: { type: 'string', label: t('name'), required: true, description: t('nameDescription') },
|
|
228
247
|
...JOB_FIELDS,
|
|
229
248
|
},
|
|
230
249
|
handler({ input, ctx }) {
|
|
231
250
|
const name = input.name.trim();
|
|
232
251
|
if (!name)
|
|
233
|
-
throw new
|
|
252
|
+
throw new TextError(t('nameEmpty'));
|
|
234
253
|
return saveJob(name, input, ctx.caller, true);
|
|
235
254
|
},
|
|
236
255
|
},
|
|
237
256
|
editJob: {
|
|
238
|
-
label: '
|
|
257
|
+
label: t('edit'),
|
|
239
258
|
effect: 'write',
|
|
240
259
|
row: true,
|
|
241
260
|
input: JOB_FIELDS,
|
|
242
261
|
handler({ rowKey, input, ctx }) {
|
|
243
262
|
if (!service.getJobs().some(j => j.name === rowKey))
|
|
244
|
-
throw new
|
|
263
|
+
throw new TextError(t('jobMissing', { name: rowKey }));
|
|
245
264
|
return saveJob(rowKey, input, ctx.caller, false);
|
|
246
265
|
},
|
|
247
266
|
},
|
|
248
267
|
triggerJob: {
|
|
249
|
-
label: '
|
|
268
|
+
label: t('triggerNow'),
|
|
250
269
|
effect: 'write',
|
|
251
270
|
row: true,
|
|
252
271
|
async handler({ rowKey }) {
|
|
253
272
|
// 这一轮还没执行完时 triggerJob 直接跳过;与它置 running 在同一个同步段里判,没有竞态
|
|
254
273
|
if (service.getJobs().find(j => j.name === rowKey)?.running) {
|
|
255
|
-
throw new
|
|
274
|
+
throw new TextError(t('jobRunning', { name: rowKey }));
|
|
256
275
|
}
|
|
257
276
|
if (!(await service.triggerJob(rowKey)))
|
|
258
|
-
throw new
|
|
259
|
-
return
|
|
277
|
+
throw new TextError(t('jobMissing', { name: rowKey }));
|
|
278
|
+
return t('triggered', { name: rowKey });
|
|
260
279
|
},
|
|
261
280
|
},
|
|
262
281
|
pauseJob: {
|
|
263
|
-
label: '
|
|
282
|
+
label: t('pause'),
|
|
264
283
|
effect: 'write',
|
|
265
284
|
row: true,
|
|
266
285
|
handler({ rowKey }) {
|
|
267
286
|
if (!service.pauseJob(rowKey))
|
|
268
|
-
throw new
|
|
269
|
-
return
|
|
287
|
+
throw new TextError(t('cannotPause'));
|
|
288
|
+
return t('pausedReceipt', { name: rowKey });
|
|
270
289
|
},
|
|
271
290
|
},
|
|
272
291
|
resumeJob: {
|
|
273
|
-
label: '
|
|
292
|
+
label: t('resume'),
|
|
274
293
|
effect: 'write',
|
|
275
294
|
row: true,
|
|
276
295
|
handler({ rowKey }) {
|
|
277
296
|
if (!service.resumeJob(rowKey))
|
|
278
|
-
throw new
|
|
279
|
-
return
|
|
297
|
+
throw new TextError(t('jobMissing', { name: rowKey }));
|
|
298
|
+
return t('resumed', { name: rowKey });
|
|
280
299
|
},
|
|
281
300
|
},
|
|
282
301
|
removeJob: {
|
|
283
|
-
label: '
|
|
302
|
+
label: t('delete'),
|
|
284
303
|
effect: 'destructive',
|
|
285
|
-
confirm: '
|
|
304
|
+
confirm: t('deleteConfirm'),
|
|
286
305
|
row: true,
|
|
287
306
|
handler({ rowKey }) {
|
|
288
307
|
if (!service.removeJob(rowKey))
|
|
289
|
-
throw new
|
|
290
|
-
return
|
|
308
|
+
throw new TextError(t('jobMissing', { name: rowKey }));
|
|
309
|
+
return t('deleted', { name: rowKey });
|
|
291
310
|
},
|
|
292
311
|
},
|
|
293
312
|
},
|
|
@@ -296,16 +315,16 @@ async function run(caps) {
|
|
|
296
315
|
type: 'table',
|
|
297
316
|
id: 'jobs',
|
|
298
317
|
rowKey: 'name',
|
|
299
|
-
empty: '
|
|
318
|
+
empty: t('noJobs'),
|
|
300
319
|
columns: [
|
|
301
|
-
{ key: 'name', label: '
|
|
302
|
-
{ key: 'schedule', label: '
|
|
303
|
-
{ key: 'sessionId', label: '
|
|
304
|
-
{ key: 'status', label: '
|
|
305
|
-
{ key: 'nextRun', label: '
|
|
306
|
-
{ key: 'lastRun', label: '
|
|
307
|
-
{ key: 'runCount', label: '
|
|
308
|
-
{ key: 'lastResult', label: '
|
|
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' },
|
|
309
328
|
],
|
|
310
329
|
rowActions: [
|
|
311
330
|
{ action: 'triggerJob' },
|
|
@@ -326,14 +345,19 @@ async function run(caps) {
|
|
|
326
345
|
content: j.content,
|
|
327
346
|
enabled: j.enabled,
|
|
328
347
|
paused: j.paused,
|
|
329
|
-
schedule: j.cron ??
|
|
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')),
|
|
330
354
|
status: !j.enabled
|
|
331
|
-
? { text: '
|
|
355
|
+
? { text: t('statusDisabled'), state: 'inactive' }
|
|
332
356
|
: j.paused
|
|
333
|
-
? { text: '
|
|
357
|
+
? { text: t('paused'), state: 'inactive' }
|
|
334
358
|
: j.running
|
|
335
|
-
? { text: '
|
|
336
|
-
: { text: '
|
|
359
|
+
? { text: t('statusRunning'), state: 'running' }
|
|
360
|
+
: { text: t('statusReady') },
|
|
337
361
|
nextRun: ready && j.nextRun > 0 ? j.nextRun : undefined,
|
|
338
362
|
lastRun: j.lastRun || undefined,
|
|
339
363
|
runCount: j.runCount,
|
|
@@ -363,7 +387,7 @@ async function run(caps) {
|
|
|
363
387
|
reading = false;
|
|
364
388
|
const data = JSON.parse(raw);
|
|
365
389
|
if (!Array.isArray(data)) {
|
|
366
|
-
throw new
|
|
390
|
+
throw new TextError(t('notArray', { got: data === null ? 'null' : typeof data }));
|
|
367
391
|
}
|
|
368
392
|
// biome-ignore lint/suspicious/noExplicitAny: 从 JSON 文件反序列化的原始字段,手动校验转型
|
|
369
393
|
return data.map((j) => {
|
|
@@ -400,11 +424,8 @@ async function run(caps) {
|
|
|
400
424
|
webui.use.notify({
|
|
401
425
|
id: 'jobs-load',
|
|
402
426
|
level: 'error',
|
|
403
|
-
title:
|
|
404
|
-
detail: (reading
|
|
405
|
-
? '确认本地存储插件在运行、配置项 persistPath 的根名写对、这个文件能读取,然后重载本插件。'
|
|
406
|
-
: '修好这个文件后重载本插件:内容要是任务数组;不要里面的任务就把它移走再重载。') +
|
|
407
|
-
`修好之前新建与改动的任务只在本次运行生效。原因:${err instanceof Error ? err.message : String(err)}`,
|
|
427
|
+
title: t('loadFailedTitle', { uri: persistUri }),
|
|
428
|
+
detail: t(reading ? 'loadFailedReading' : 'loadFailedContent', { error: textOf(err) }),
|
|
408
429
|
});
|
|
409
430
|
return [];
|
|
410
431
|
}
|
|
@@ -455,11 +476,13 @@ async function run(caps) {
|
|
|
455
476
|
content: rt.config.content,
|
|
456
477
|
},
|
|
457
478
|
});
|
|
479
|
+
// 内容由 owner 写下或审批(见 SchedulerService;owner 免了 scheduler_create_job 的确认时例外,见 security-model),声明 ownerApproved
|
|
458
480
|
const message = {
|
|
459
481
|
content: rt.config.content,
|
|
460
482
|
sessionId: rt.config.sessionId,
|
|
461
483
|
platform: rt.config.platform,
|
|
462
484
|
source: 'scheduler',
|
|
485
|
+
ownerApproved: true,
|
|
463
486
|
};
|
|
464
487
|
// 注入代理身份:authority 守卫优先读 actor 而非 platform/userId,
|
|
465
488
|
// 从而让 scheduler 触发的 AI 走**创建者**的权限等级。
|
|
@@ -475,12 +498,12 @@ async function run(caps) {
|
|
|
475
498
|
await events.use.emit('inbound:message', message);
|
|
476
499
|
rt.lastRun = Date.now();
|
|
477
500
|
rt.runCount++;
|
|
478
|
-
rt.lastResult = '
|
|
501
|
+
rt.lastResult = t('jobSucceeded');
|
|
479
502
|
await events.use.emit('scheduler:job:done', jobName);
|
|
480
503
|
}
|
|
481
504
|
catch (err) {
|
|
482
505
|
const msg = err instanceof Error ? err.message : String(err);
|
|
483
|
-
rt.lastResult =
|
|
506
|
+
rt.lastResult = t('jobFailed', { error: textOf(err) });
|
|
484
507
|
logger.use.error(`任务 "${jobName}" 执行失败: ${msg}`);
|
|
485
508
|
await events.use.emit('scheduler:job:error', jobName, msg);
|
|
486
509
|
}
|
|
@@ -496,7 +519,8 @@ async function run(caps) {
|
|
|
496
519
|
return Number.isFinite(ms) ? ms : 0;
|
|
497
520
|
}
|
|
498
521
|
if (job.interval) {
|
|
499
|
-
|
|
522
|
+
// 间隔无效的任务没有排上(见 initJob),不知道下次
|
|
523
|
+
return intervalProblem(job.interval) ? 0 : (lastRun || Date.now()) + job.interval * 1000;
|
|
500
524
|
}
|
|
501
525
|
if (job.cron) {
|
|
502
526
|
const tz = job.timeZone?.trim() || undefined;
|
|
@@ -512,12 +536,23 @@ async function run(caps) {
|
|
|
512
536
|
return 0;
|
|
513
537
|
}
|
|
514
538
|
// ── 初始化所有任务 ──
|
|
515
|
-
/** cron
|
|
539
|
+
/** cron 订阅不上、固定间隔无效,永远不会运行的任务各一条提示:任务重排或删掉时撤回 */
|
|
516
540
|
const unscheduled = new Map();
|
|
517
541
|
function clearUnscheduled(name) {
|
|
518
542
|
unscheduled.get(name)?.clear();
|
|
519
543
|
unscheduled.delete(name);
|
|
520
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
|
+
}
|
|
521
556
|
function initJob(jobCfg) {
|
|
522
557
|
if (runtimes.has(jobCfg.name))
|
|
523
558
|
return;
|
|
@@ -550,14 +585,21 @@ async function run(caps) {
|
|
|
550
585
|
// 优先采用持久化的 paused 字段;若未设置则回落到 enabled 状态(禁用即视作暂停)
|
|
551
586
|
paused: jobCfg.paused === true || !jobCfg.enabled,
|
|
552
587
|
};
|
|
553
|
-
//
|
|
554
|
-
if (jobCfg.interval
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
+
}
|
|
561
603
|
}
|
|
562
604
|
// cron 任务:订阅到共享 cron-engine
|
|
563
605
|
if (jobCfg.cron && jobCfg.enabled) {
|
|
@@ -573,16 +615,7 @@ async function run(caps) {
|
|
|
573
615
|
catch (err) {
|
|
574
616
|
const reason = err instanceof Error ? err.message : String(err);
|
|
575
617
|
logger.use.warn(`任务 ${jobCfg.name} cron 订阅失败: ${reason}`);
|
|
576
|
-
|
|
577
|
-
unscheduled.set(jobCfg.name, webui.use.notify({
|
|
578
|
-
id: `unscheduled:${jobCfg.name}`,
|
|
579
|
-
level: 'error',
|
|
580
|
-
title: `计划任务「${jobCfg.name}」没有排上,不会运行`,
|
|
581
|
-
detail: fromConfig
|
|
582
|
-
? `在配置文件里改正这个任务的 cron 或 timeZone,保存后生效。原因:${reason}`
|
|
583
|
-
: `在计划任务页编辑这个任务,改正它的 cron 后保存。原因:${reason}`,
|
|
584
|
-
open: fromConfig ? 'config' : { page: 'scheduler' },
|
|
585
|
-
}));
|
|
618
|
+
markUnscheduled(jobCfg.name, textOf(err));
|
|
586
619
|
}
|
|
587
620
|
}
|
|
588
621
|
// 一次性任务:到点用 setTimeout 触发一次,执行完 disable 并持久化
|
|
@@ -652,23 +685,31 @@ async function run(caps) {
|
|
|
652
685
|
jobsPage.invalidate();
|
|
653
686
|
logger.use.info(`一次性任务已执行完毕并停用(静态): ${jobName}`);
|
|
654
687
|
}
|
|
655
|
-
// 静态 YAML 任务的「创建者」是编辑配置文件的人——那本身就是 owner
|
|
688
|
+
// 静态 YAML 任务的「创建者」是编辑配置文件的人——那本身就是 owner 行为,故两项都不写时补 owner。
|
|
656
689
|
// **这是全插件唯一允许推断 owner 的地方**:来源是配置文件(只有能写盘的人才改得动),
|
|
657
690
|
// 不是运行时输入。补在此处而非触发时,是为了让「谁是 actor」在任务入表那一刻就定死,
|
|
658
691
|
// 触发路径只透传、不再有第二次机会发明身份。YAML 显式写 actor 则用其值(可降权)。
|
|
659
692
|
for (const job of config.jobs) {
|
|
660
693
|
const p = job.actorPlatform?.trim();
|
|
661
694
|
const u = job.actorUserId?.trim();
|
|
662
|
-
if (
|
|
663
|
-
//
|
|
664
|
-
|
|
665
|
-
|
|
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;
|
|
666
706
|
}
|
|
667
707
|
initJob(p && u ? job : { ...job, actorPlatform: 'webui', actorUserId: 'console' });
|
|
668
708
|
}
|
|
669
|
-
//
|
|
709
|
+
// 持久化的动态任务:配置文件里写了的名字归配置文件,那一条没载入也不让同名的动态任务顶上来;
|
|
710
|
+
// 文件里重名的只认第一条
|
|
670
711
|
for (const job of persisted) {
|
|
671
|
-
if (!runtimes.has(job.name)) {
|
|
712
|
+
if (!configJobNames.has(job.name) && !runtimes.has(job.name)) {
|
|
672
713
|
dynamicJobs.set(job.name, job);
|
|
673
714
|
initJob(job);
|
|
674
715
|
}
|
|
@@ -677,13 +718,31 @@ async function run(caps) {
|
|
|
677
718
|
logger.use.info(`已加载 ${persisted.length} 个持久化任务`);
|
|
678
719
|
}
|
|
679
720
|
// ── 注册 scheduler 服务 ──
|
|
680
|
-
/**
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
if (
|
|
686
|
-
throw new
|
|
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
|
+
}
|
|
687
746
|
}
|
|
688
747
|
const service = {
|
|
689
748
|
getJobs() {
|
|
@@ -746,20 +805,16 @@ async function run(caps) {
|
|
|
746
805
|
return true;
|
|
747
806
|
},
|
|
748
807
|
addJob(job) {
|
|
749
|
-
|
|
808
|
+
rejectConfigName(job.name);
|
|
809
|
+
rejectInvalidSchedule(job);
|
|
750
810
|
initJob(job);
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
dynamicJobs.set(job.name, job);
|
|
754
|
-
saveDynamicJobs();
|
|
755
|
-
}
|
|
811
|
+
dynamicJobs.set(job.name, job);
|
|
812
|
+
saveDynamicJobs();
|
|
756
813
|
},
|
|
757
814
|
setJob(job) {
|
|
758
|
-
// upsert:先 remove 再 add
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
}
|
|
762
|
-
rejectInvalidCron(job);
|
|
815
|
+
// upsert:先 remove 再 add
|
|
816
|
+
rejectConfigName(job.name);
|
|
817
|
+
rejectInvalidSchedule(job);
|
|
763
818
|
const existed = runtimes.get(job.name);
|
|
764
819
|
if (existed) {
|
|
765
820
|
if (existed.timer)
|
|
@@ -795,7 +850,8 @@ async function run(caps) {
|
|
|
795
850
|
// ── 注册 AI 工具:让 Agent 可以自主创建/管理任务 ──
|
|
796
851
|
tools.use.registerGroup({
|
|
797
852
|
name: 'scheduler',
|
|
798
|
-
label: '
|
|
853
|
+
label: t('displayName'),
|
|
854
|
+
summary: t('groupSummary'),
|
|
799
855
|
description: '创建、查看和取消定时/周期性自主行动计划',
|
|
800
856
|
});
|
|
801
857
|
tools.use.register({
|
|
@@ -830,7 +886,10 @@ async function run(caps) {
|
|
|
830
886
|
type: 'string',
|
|
831
887
|
description: 'ISO 8601 绝对时间,到点执行一次后自动停止。例:"2026-05-19T15:46:44+08:00"。',
|
|
832
888
|
},
|
|
833
|
-
sessionId: {
|
|
889
|
+
sessionId: {
|
|
890
|
+
type: 'string',
|
|
891
|
+
description: '任务消息发往的会话 ID。不传则默认使用当前会话(子任务里必须传)。',
|
|
892
|
+
},
|
|
834
893
|
platform: { type: 'string', description: '目标平台标识。不传则默认使用当前平台。' },
|
|
835
894
|
content: { type: 'string', description: '届时发送给自己的指令/提示内容' },
|
|
836
895
|
},
|
|
@@ -839,9 +898,9 @@ async function run(caps) {
|
|
|
839
898
|
},
|
|
840
899
|
},
|
|
841
900
|
// 建任务 = 让 AI 在未来自主行动,且 actor 身份在此固化(见 resolveActor)。
|
|
842
|
-
// 写类:restricted +
|
|
901
|
+
// 写类:restricted + 确认,与别的要确认的操作同一规则(参与者都是 owner 且在免确认白名单里的回合免)。
|
|
843
902
|
risk: 'dangerous',
|
|
844
|
-
confirm: '
|
|
903
|
+
confirm: 'session',
|
|
845
904
|
handler: async (args, callCtx) => {
|
|
846
905
|
// 互斥校验:cron / interval / delaySeconds / runAt 必须恰好提供一个
|
|
847
906
|
const hasCron = typeof args.cron === 'string' && args.cron.trim().length > 0;
|
|
@@ -860,19 +919,30 @@ async function run(caps) {
|
|
|
860
919
|
if (hasDelay) {
|
|
861
920
|
runAt = new Date(Date.now() + Math.floor(args.delaySeconds) * 1000).toISOString();
|
|
862
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);
|
|
863
937
|
const job = {
|
|
864
938
|
name: args.name,
|
|
865
939
|
cron: hasCron ? args.cron.trim() : undefined,
|
|
866
940
|
interval: hasInterval ? args.interval : undefined,
|
|
867
941
|
runAt,
|
|
868
|
-
sessionId:
|
|
942
|
+
sessionId: target || callCtx.sessionId,
|
|
869
943
|
platform: args.platform || callCtx.platform || 'internal',
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
// 优先 callCtx.actor(本回合本就在代人执行,如子任务链、workflow 或定时任务驱动的回合),否则用物理身份;
|
|
873
|
-
// 两者皆空则子任务匿名(defaultAuthority),实现权限的自然传递与不可提升。
|
|
874
|
-
actorPlatform: callCtx.actor?.platform ?? callCtx.platform,
|
|
875
|
-
actorUserId: callCtx.actor?.userId ?? callCtx.userId,
|
|
944
|
+
actorPlatform: creator.platform,
|
|
945
|
+
actorUserId: creator.userId || undefined,
|
|
876
946
|
content: args.content,
|
|
877
947
|
enabled: true,
|
|
878
948
|
};
|
|
@@ -891,9 +961,10 @@ async function run(caps) {
|
|
|
891
961
|
: job.runAt
|
|
892
962
|
? `一次性 @ ${job.runAt}`
|
|
893
963
|
: '?';
|
|
964
|
+
const note = persistNote();
|
|
894
965
|
return JSON.stringify({
|
|
895
966
|
ok: true,
|
|
896
|
-
message: `任务 "${job.name}" 已创建 (${scheduleDesc}),目标会话: ${job.sessionId} (${job.platform})${
|
|
967
|
+
message: `任务 "${job.name}" 已创建 (${scheduleDesc}),目标会话: ${job.sessionId} (${job.platform})${note ? `;${toSource(note)}` : ''}`,
|
|
897
968
|
});
|
|
898
969
|
},
|
|
899
970
|
});
|
|
@@ -919,8 +990,8 @@ async function run(caps) {
|
|
|
919
990
|
},
|
|
920
991
|
},
|
|
921
992
|
},
|
|
922
|
-
//
|
|
923
|
-
|
|
993
|
+
// 只看不改:任务清单含 sessionId / prompt,2 级、不确认,低于 2 级的看不到
|
|
994
|
+
visibility: 'restricted',
|
|
924
995
|
handler: async (args) => {
|
|
925
996
|
const all = service.getJobs().map(j => ({
|
|
926
997
|
name: j.name,
|
|
@@ -982,7 +1053,7 @@ async function run(caps) {
|
|
|
982
1053
|
},
|
|
983
1054
|
// 删类:删掉定时任务不可逆(无回收站)。
|
|
984
1055
|
risk: 'dangerous',
|
|
985
|
-
confirm: '
|
|
1056
|
+
confirm: 'session',
|
|
986
1057
|
handler: async (args) => {
|
|
987
1058
|
const ok = service.removeJob(args.name);
|
|
988
1059
|
return JSON.stringify({ ok, message: ok ? '已删除' : '任务不存在' });
|
|
@@ -1006,7 +1077,7 @@ async function run(caps) {
|
|
|
1006
1077
|
},
|
|
1007
1078
|
// 改状态类:暂停等于静默停掉一条自主行动,用户不会收到任何提示。
|
|
1008
1079
|
risk: 'dangerous',
|
|
1009
|
-
confirm: '
|
|
1080
|
+
confirm: 'session',
|
|
1010
1081
|
handler: async (args) => {
|
|
1011
1082
|
const ok = service.pauseJob(args.name);
|
|
1012
1083
|
return JSON.stringify({ ok, message: ok ? '已暂停' : '任务不存在,或是一次性任务(不能暂停,只能删除重建)' });
|
|
@@ -1030,7 +1101,7 @@ async function run(caps) {
|
|
|
1030
1101
|
},
|
|
1031
1102
|
// 改状态类:恢复会让一条被停掉的自主行动重新开始跑。
|
|
1032
1103
|
risk: 'dangerous',
|
|
1033
|
-
confirm: '
|
|
1104
|
+
confirm: 'session',
|
|
1034
1105
|
handler: async (args) => {
|
|
1035
1106
|
const ok = service.resumeJob(args.name);
|
|
1036
1107
|
return JSON.stringify({ ok, message: ok ? '已恢复' : '任务不存在' });
|
|
@@ -1065,7 +1136,7 @@ function resolvePersistPath(input) {
|
|
|
1065
1136
|
if (!s)
|
|
1066
1137
|
return configSchema.persistPath.default;
|
|
1067
1138
|
if (!isStorageUri(s)) {
|
|
1068
|
-
throw configError(
|
|
1139
|
+
throw configError(t('badPersistPath', { value: s }));
|
|
1069
1140
|
}
|
|
1070
1141
|
return s;
|
|
1071
1142
|
}
|