@aalis/plugin-scheduler 0.15.0 → 0.17.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 +43 -31
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +321 -205
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +95 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +95 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/texts.d.ts +189 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +100 -0
- package/dist/texts.js.map +1 -0
- package/package.json +14 -11
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
|
/** 单个会话最多几个计划任务(防资源耗尽);编辑已有任务不计入 */
|
|
@@ -122,10 +138,15 @@ const uses = {
|
|
|
122
138
|
tools,
|
|
123
139
|
// 共享 cron tick 与表达式校验
|
|
124
140
|
cronEngine,
|
|
125
|
-
/**
|
|
126
|
-
|
|
141
|
+
/**
|
|
142
|
+
* 动态任务的持久化出口:激活时读回整表、增删改整表写回。required:读回要在它上线之后(optional 不参与激活先后),
|
|
143
|
+
* 它下线时随之停下、重新上线后重新读回
|
|
144
|
+
*/
|
|
145
|
+
storage,
|
|
127
146
|
/** 计划任务页的登记口;无 WebUI 时调度照常运行 */
|
|
128
147
|
webui: optional(webuiServer),
|
|
148
|
+
/** 认调用所在的会话是不是子会话(在子会话里建任务要写明目标);没有会话管理就没有子会话 */
|
|
149
|
+
sessionManager: optional(sessionManager),
|
|
129
150
|
events,
|
|
130
151
|
lifecycle,
|
|
131
152
|
logger,
|
|
@@ -134,7 +155,7 @@ const uses = {
|
|
|
134
155
|
};
|
|
135
156
|
export default definePlugin({
|
|
136
157
|
name: '@aalis/plugin-scheduler',
|
|
137
|
-
displayName: '
|
|
158
|
+
displayName: t('displayName'),
|
|
138
159
|
subsystem: 'scheduler',
|
|
139
160
|
extends: {
|
|
140
161
|
events: ['scheduler:job:start', 'scheduler:job:done', 'scheduler:job:error'],
|
|
@@ -146,7 +167,7 @@ export default definePlugin({
|
|
|
146
167
|
});
|
|
147
168
|
async function run(caps) {
|
|
148
169
|
const cfg = parseConfig(configSchema, caps.config.use, caps.logger.use);
|
|
149
|
-
const { cronEngine, storage, webui, events, lifecycle, logger, provide, tools } = caps;
|
|
170
|
+
const { cronEngine, storage, webui, sessionManager, events, lifecycle, logger, provide, tools } = caps;
|
|
150
171
|
const config = resolveConfig(cfg);
|
|
151
172
|
const runtimes = new Map();
|
|
152
173
|
// 配置中定义的任务名称集合(不需要持久化)
|
|
@@ -154,16 +175,26 @@ async function run(caps) {
|
|
|
154
175
|
// 动态任务集合(需要持久化)
|
|
155
176
|
const dynamicJobs = new Map();
|
|
156
177
|
// ── 持久化读写 (经 storage 抽象,默认 data:/scheduler-jobs.json) ──
|
|
178
|
+
// 文件形状:{ jobs: [动态任务], onceFired: { <配置里一次性任务的名字>: <执行过的那次 runAt 原文> } }
|
|
157
179
|
const persistUri = config.persistPath;
|
|
158
180
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
181
|
+
* 激活时那次读失败了、又不是「文件不存在」(读错误、解析失败、结构不对):写的是整份,
|
|
182
|
+
* 此后一律拒写,否则下一次增删改就把原有动态任务与执行记录冲掉;配置里的一次性任务执行过没有也无从判断,
|
|
183
|
+
* 本次运行不安排。只在激活时读一次。
|
|
161
184
|
*/
|
|
162
185
|
let loadFailed = false;
|
|
163
|
-
/**
|
|
164
|
-
|
|
186
|
+
/**
|
|
187
|
+
* 拒写期间新建或改动的任务只在内存、重启即失:回执末尾如实注明(AI 工具与计划任务页同一附注,工具取原文),
|
|
188
|
+
* 文件正常时没有
|
|
189
|
+
*/
|
|
190
|
+
const persistNote = () => (loadFailed ? t('notPersisted', { uri: persistUri }) : undefined);
|
|
165
191
|
// 先读完持久化任务再登记页面:登记之后到服务就绪之间不再有等待,页面一出现取数与动作就可用
|
|
166
192
|
const persisted = await loadDynamicJobs();
|
|
193
|
+
/**
|
|
194
|
+
* 配置里一次性任务的执行记录:任务名 → 执行过的那次 runAt 原文。到点先记下再执行,记过的之后激活不再执行;
|
|
195
|
+
* 配置里改了 runAt 与记录对不上,算新的一次
|
|
196
|
+
*/
|
|
197
|
+
const onceFired = persisted.onceFired;
|
|
167
198
|
// ── 计划任务页:动作是本次激活的闭包,直接操作本插件自己的调度表;登记随激活撤回 ──
|
|
168
199
|
/**
|
|
169
200
|
* 新建与编辑共用:调度方式恰好一个(四者是互不相容的定时语义,多填时取哪个都是猜),与 scheduler_create_job
|
|
@@ -176,28 +207,25 @@ async function run(caps) {
|
|
|
176
207
|
const rawRunAt = input.runAt?.trim() || undefined;
|
|
177
208
|
const provided = [cron, interval, delaySeconds, rawRunAt].filter(v => v !== undefined).length;
|
|
178
209
|
if (provided === 0)
|
|
179
|
-
throw new
|
|
210
|
+
throw new TextError(t('scheduleMissing'));
|
|
180
211
|
if (provided > 1)
|
|
181
|
-
throw new
|
|
212
|
+
throw new TextError(t('scheduleConflict'));
|
|
182
213
|
const runAt = delaySeconds !== undefined ? new Date(Date.now() + delaySeconds * 1000).toISOString() : rawRunAt;
|
|
183
214
|
// 一次性任务被暂停时到点的定时器直接跳过,之后既不重排也不自删,任务就永久卡死在那里
|
|
184
215
|
if (input.paused && runAt)
|
|
185
|
-
throw new
|
|
186
|
-
// 防误填高频任务:挡住 interval:0.5 这类会占满调度的间隔
|
|
187
|
-
if (interval !== undefined && interval < 5)
|
|
188
|
-
throw new Error('固定间隔不能小于 5 秒(防高频任务耗尽资源)');
|
|
216
|
+
throw new TextError(t('pauseOneShot'));
|
|
189
217
|
const sessionId = input.sessionId.trim();
|
|
190
218
|
if (!sessionId)
|
|
191
|
-
throw new
|
|
219
|
+
throw new TextError(t('sessionIdEmpty'));
|
|
192
220
|
const content = input.content.trim();
|
|
193
221
|
if (!content)
|
|
194
|
-
throw new
|
|
222
|
+
throw new TextError(t('contentEmpty'));
|
|
195
223
|
if (creating) {
|
|
196
224
|
const all = service.getJobs();
|
|
197
225
|
if (all.some(j => j.name === name))
|
|
198
|
-
throw new
|
|
226
|
+
throw new TextError(t('duplicateName', { name }));
|
|
199
227
|
if (all.filter(j => j.sessionId === sessionId).length >= MAX_JOBS_PER_SESSION) {
|
|
200
|
-
throw new
|
|
228
|
+
throw new TextError(t('tooManyJobs', { max: MAX_JOBS_PER_SESSION }));
|
|
201
229
|
}
|
|
202
230
|
}
|
|
203
231
|
service.setJob({
|
|
@@ -213,81 +241,82 @@ async function run(caps) {
|
|
|
213
241
|
enabled: input.enabled,
|
|
214
242
|
paused: input.paused,
|
|
215
243
|
});
|
|
216
|
-
|
|
244
|
+
const note = persistNote();
|
|
245
|
+
return note ? textClauses([t('jobSaved', { name }), note]) : t('jobSaved', { name });
|
|
217
246
|
}
|
|
218
247
|
const jobsPage = webui.use.definePage({
|
|
219
248
|
key: 'scheduler',
|
|
220
|
-
title: '
|
|
249
|
+
title: t('pageTitle'),
|
|
221
250
|
icon: 'clock',
|
|
222
251
|
actions: {
|
|
223
252
|
createJob: {
|
|
224
|
-
label: '
|
|
253
|
+
label: t('create'),
|
|
225
254
|
effect: 'write',
|
|
226
255
|
input: {
|
|
227
|
-
name: { type: 'string', label: '
|
|
256
|
+
name: { type: 'string', label: t('name'), required: true, description: t('nameDescription') },
|
|
228
257
|
...JOB_FIELDS,
|
|
229
258
|
},
|
|
230
259
|
handler({ input, ctx }) {
|
|
231
260
|
const name = input.name.trim();
|
|
232
261
|
if (!name)
|
|
233
|
-
throw new
|
|
262
|
+
throw new TextError(t('nameEmpty'));
|
|
234
263
|
return saveJob(name, input, ctx.caller, true);
|
|
235
264
|
},
|
|
236
265
|
},
|
|
237
266
|
editJob: {
|
|
238
|
-
label: '
|
|
267
|
+
label: t('edit'),
|
|
239
268
|
effect: 'write',
|
|
240
269
|
row: true,
|
|
241
270
|
input: JOB_FIELDS,
|
|
242
271
|
handler({ rowKey, input, ctx }) {
|
|
243
272
|
if (!service.getJobs().some(j => j.name === rowKey))
|
|
244
|
-
throw new
|
|
273
|
+
throw new TextError(t('jobMissing', { name: rowKey }));
|
|
245
274
|
return saveJob(rowKey, input, ctx.caller, false);
|
|
246
275
|
},
|
|
247
276
|
},
|
|
248
277
|
triggerJob: {
|
|
249
|
-
label: '
|
|
278
|
+
label: t('triggerNow'),
|
|
250
279
|
effect: 'write',
|
|
251
280
|
row: true,
|
|
252
281
|
async handler({ rowKey }) {
|
|
253
282
|
// 这一轮还没执行完时 triggerJob 直接跳过;与它置 running 在同一个同步段里判,没有竞态
|
|
254
283
|
if (service.getJobs().find(j => j.name === rowKey)?.running) {
|
|
255
|
-
throw new
|
|
284
|
+
throw new TextError(t('jobRunning', { name: rowKey }));
|
|
256
285
|
}
|
|
257
286
|
if (!(await service.triggerJob(rowKey)))
|
|
258
|
-
throw new
|
|
259
|
-
return
|
|
287
|
+
throw new TextError(t('jobMissing', { name: rowKey }));
|
|
288
|
+
return t('triggered', { name: rowKey });
|
|
260
289
|
},
|
|
261
290
|
},
|
|
262
291
|
pauseJob: {
|
|
263
|
-
label: '
|
|
292
|
+
label: t('pause'),
|
|
264
293
|
effect: 'write',
|
|
265
294
|
row: true,
|
|
266
295
|
handler({ rowKey }) {
|
|
267
296
|
if (!service.pauseJob(rowKey))
|
|
268
|
-
throw new
|
|
269
|
-
return
|
|
297
|
+
throw new TextError(t('cannotPause'));
|
|
298
|
+
return t('pausedReceipt', { name: rowKey });
|
|
270
299
|
},
|
|
271
300
|
},
|
|
272
301
|
resumeJob: {
|
|
273
|
-
label: '
|
|
302
|
+
label: t('resume'),
|
|
274
303
|
effect: 'write',
|
|
275
304
|
row: true,
|
|
276
305
|
handler({ rowKey }) {
|
|
277
306
|
if (!service.resumeJob(rowKey))
|
|
278
|
-
throw new
|
|
279
|
-
return
|
|
307
|
+
throw new TextError(t('jobMissing', { name: rowKey }));
|
|
308
|
+
return t('resumed', { name: rowKey });
|
|
280
309
|
},
|
|
281
310
|
},
|
|
282
311
|
removeJob: {
|
|
283
|
-
label: '
|
|
312
|
+
label: t('delete'),
|
|
284
313
|
effect: 'destructive',
|
|
285
|
-
confirm: '
|
|
314
|
+
confirm: t('deleteConfirm'),
|
|
286
315
|
row: true,
|
|
287
316
|
handler({ rowKey }) {
|
|
288
317
|
if (!service.removeJob(rowKey))
|
|
289
|
-
throw new
|
|
290
|
-
return
|
|
318
|
+
throw new TextError(t('jobMissing', { name: rowKey }));
|
|
319
|
+
return t('deleted', { name: rowKey });
|
|
291
320
|
},
|
|
292
321
|
},
|
|
293
322
|
},
|
|
@@ -296,16 +325,16 @@ async function run(caps) {
|
|
|
296
325
|
type: 'table',
|
|
297
326
|
id: 'jobs',
|
|
298
327
|
rowKey: 'name',
|
|
299
|
-
empty: '
|
|
328
|
+
empty: t('noJobs'),
|
|
300
329
|
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: '
|
|
330
|
+
{ key: 'name', label: t('name'), primary: true },
|
|
331
|
+
{ key: 'schedule', label: t('schedule') },
|
|
332
|
+
{ key: 'sessionId', label: t('target') },
|
|
333
|
+
{ key: 'status', label: t('status'), kind: 'status', primary: true },
|
|
334
|
+
{ key: 'nextRun', label: t('nextRun'), kind: 'countdown' },
|
|
335
|
+
{ key: 'lastRun', label: t('lastRun'), kind: 'time' },
|
|
336
|
+
{ key: 'runCount', label: t('runCount') },
|
|
337
|
+
{ key: 'lastResult', label: t('lastResult'), kind: 'long-text' },
|
|
309
338
|
],
|
|
310
339
|
rowActions: [
|
|
311
340
|
{ action: 'triggerJob' },
|
|
@@ -326,14 +355,19 @@ async function run(caps) {
|
|
|
326
355
|
content: j.content,
|
|
327
356
|
enabled: j.enabled,
|
|
328
357
|
paused: j.paused,
|
|
329
|
-
schedule: j.cron ??
|
|
358
|
+
schedule: j.cron ??
|
|
359
|
+
(j.interval
|
|
360
|
+
? t('everySeconds', { seconds: j.interval })
|
|
361
|
+
: j.runAt
|
|
362
|
+
? t('oneShot', { at: j.runAt })
|
|
363
|
+
: t('unset')),
|
|
330
364
|
status: !j.enabled
|
|
331
|
-
? { text: '
|
|
365
|
+
? { text: t('statusDisabled'), state: 'inactive' }
|
|
332
366
|
: j.paused
|
|
333
|
-
? { text: '
|
|
367
|
+
? { text: t('paused'), state: 'inactive' }
|
|
334
368
|
: j.running
|
|
335
|
-
? { text: '
|
|
336
|
-
: { text: '
|
|
369
|
+
? { text: t('statusRunning'), state: 'running' }
|
|
370
|
+
: { text: t('statusReady') },
|
|
337
371
|
nextRun: ready && j.nextRun > 0 ? j.nextRun : undefined,
|
|
338
372
|
lastRun: j.lastRun || undefined,
|
|
339
373
|
runCount: j.runCount,
|
|
@@ -347,7 +381,7 @@ async function run(caps) {
|
|
|
347
381
|
],
|
|
348
382
|
});
|
|
349
383
|
async function loadDynamicJobs() {
|
|
350
|
-
/**
|
|
384
|
+
/** 失败出在读文件这一步(persistPath 的根名写错、文件读不了),文件内容未必有错 */
|
|
351
385
|
let reading = true;
|
|
352
386
|
try {
|
|
353
387
|
let raw;
|
|
@@ -357,16 +391,21 @@ async function run(caps) {
|
|
|
357
391
|
catch (err) {
|
|
358
392
|
// 只有「文件不存在」算全新;「未知存储根」等其它失败不能当成空表
|
|
359
393
|
if (isStorageNotFound(err))
|
|
360
|
-
return [];
|
|
394
|
+
return { jobs: [], onceFired: new Map() };
|
|
361
395
|
throw err;
|
|
362
396
|
}
|
|
363
397
|
reading = false;
|
|
364
398
|
const data = JSON.parse(raw);
|
|
365
|
-
|
|
366
|
-
|
|
399
|
+
// 执行记录有一条不是运行时间也算读不懂:当成没有记录就会把执行过的一次性任务再执行一次
|
|
400
|
+
if (!isRecord(data) ||
|
|
401
|
+
!Array.isArray(data.jobs) ||
|
|
402
|
+
!isRecord(data.onceFired) ||
|
|
403
|
+
!Object.values(data.onceFired).every(at => typeof at === 'string')) {
|
|
404
|
+
throw new TextError(t('badShape'));
|
|
367
405
|
}
|
|
406
|
+
const onceFired = new Map(Object.entries(data.onceFired));
|
|
368
407
|
// biome-ignore lint/suspicious/noExplicitAny: 从 JSON 文件反序列化的原始字段,手动校验转型
|
|
369
|
-
|
|
408
|
+
const jobs = data.jobs.map((j) => {
|
|
370
409
|
const hasActor = typeof j.actorPlatform === 'string' &&
|
|
371
410
|
j.actorPlatform.trim().length > 0 &&
|
|
372
411
|
typeof j.actorUserId === 'string' &&
|
|
@@ -392,6 +431,7 @@ async function run(caps) {
|
|
|
392
431
|
paused: j.paused === true,
|
|
393
432
|
};
|
|
394
433
|
});
|
|
434
|
+
return { jobs, onceFired };
|
|
395
435
|
}
|
|
396
436
|
catch (err) {
|
|
397
437
|
loadFailed = true;
|
|
@@ -400,13 +440,10 @@ async function run(caps) {
|
|
|
400
440
|
webui.use.notify({
|
|
401
441
|
id: 'jobs-load',
|
|
402
442
|
level: 'error',
|
|
403
|
-
title:
|
|
404
|
-
detail: (reading
|
|
405
|
-
? '确认本地存储插件在运行、配置项 persistPath 的根名写对、这个文件能读取,然后重载本插件。'
|
|
406
|
-
: '修好这个文件后重载本插件:内容要是任务数组;不要里面的任务就把它移走再重载。') +
|
|
407
|
-
`修好之前新建与改动的任务只在本次运行生效。原因:${err instanceof Error ? err.message : String(err)}`,
|
|
443
|
+
title: t('loadFailedTitle', { uri: persistUri }),
|
|
444
|
+
detail: t(reading ? 'loadFailedReading' : 'loadFailedContent', { error: textOf(err) }),
|
|
408
445
|
});
|
|
409
|
-
return [];
|
|
446
|
+
return { jobs: [], onceFired: new Map() };
|
|
410
447
|
}
|
|
411
448
|
}
|
|
412
449
|
/**
|
|
@@ -422,7 +459,7 @@ async function run(caps) {
|
|
|
422
459
|
return;
|
|
423
460
|
}
|
|
424
461
|
const jobs = [...dynamicJobs.values()];
|
|
425
|
-
const payload = JSON.stringify(jobs, null, 2);
|
|
462
|
+
const payload = JSON.stringify({ jobs, onceFired: Object.fromEntries(onceFired) }, null, 2);
|
|
426
463
|
saveChain = saveChain
|
|
427
464
|
.then(() => storage.use.writeFile(persistUri, payload))
|
|
428
465
|
.then(() => {
|
|
@@ -455,11 +492,13 @@ async function run(caps) {
|
|
|
455
492
|
content: rt.config.content,
|
|
456
493
|
},
|
|
457
494
|
});
|
|
495
|
+
// 内容由 owner 写下或审批(见 SchedulerService;owner 免了 scheduler_create_job 的确认时例外,见 security-model),声明 ownerApproved
|
|
458
496
|
const message = {
|
|
459
497
|
content: rt.config.content,
|
|
460
498
|
sessionId: rt.config.sessionId,
|
|
461
499
|
platform: rt.config.platform,
|
|
462
500
|
source: 'scheduler',
|
|
501
|
+
ownerApproved: true,
|
|
463
502
|
};
|
|
464
503
|
// 注入代理身份:authority 守卫优先读 actor 而非 platform/userId,
|
|
465
504
|
// 从而让 scheduler 触发的 AI 走**创建者**的权限等级。
|
|
@@ -475,12 +514,12 @@ async function run(caps) {
|
|
|
475
514
|
await events.use.emit('inbound:message', message);
|
|
476
515
|
rt.lastRun = Date.now();
|
|
477
516
|
rt.runCount++;
|
|
478
|
-
rt.lastResult = '
|
|
517
|
+
rt.lastResult = t('jobSucceeded');
|
|
479
518
|
await events.use.emit('scheduler:job:done', jobName);
|
|
480
519
|
}
|
|
481
520
|
catch (err) {
|
|
482
521
|
const msg = err instanceof Error ? err.message : String(err);
|
|
483
|
-
rt.lastResult =
|
|
522
|
+
rt.lastResult = t('jobFailed', { error: textOf(err) });
|
|
484
523
|
logger.use.error(`任务 "${jobName}" 执行失败: ${msg}`);
|
|
485
524
|
await events.use.emit('scheduler:job:error', jobName, msg);
|
|
486
525
|
}
|
|
@@ -496,7 +535,8 @@ async function run(caps) {
|
|
|
496
535
|
return Number.isFinite(ms) ? ms : 0;
|
|
497
536
|
}
|
|
498
537
|
if (job.interval) {
|
|
499
|
-
|
|
538
|
+
// 间隔无效的任务没有排上(见 initJob),不知道下次
|
|
539
|
+
return intervalProblem(job.interval) ? 0 : (lastRun || Date.now()) + job.interval * 1000;
|
|
500
540
|
}
|
|
501
541
|
if (job.cron) {
|
|
502
542
|
const tz = job.timeZone?.trim() || undefined;
|
|
@@ -512,12 +552,23 @@ async function run(caps) {
|
|
|
512
552
|
return 0;
|
|
513
553
|
}
|
|
514
554
|
// ── 初始化所有任务 ──
|
|
515
|
-
/** cron
|
|
555
|
+
/** cron 订阅不上、固定间隔无效,永远不会运行的任务各一条提示:任务重排或删掉时撤回 */
|
|
516
556
|
const unscheduled = new Map();
|
|
517
557
|
function clearUnscheduled(name) {
|
|
518
558
|
unscheduled.get(name)?.clear();
|
|
519
559
|
unscheduled.delete(name);
|
|
520
560
|
}
|
|
561
|
+
/** 发「没有排上」的提示:配置文件里的任务写去配置里改,别的写去计划任务页编辑,带出原因 */
|
|
562
|
+
function markUnscheduled(name, reason) {
|
|
563
|
+
const fromConfig = configJobNames.has(name);
|
|
564
|
+
unscheduled.set(name, webui.use.notify({
|
|
565
|
+
id: `unscheduled:${name}`,
|
|
566
|
+
level: 'error',
|
|
567
|
+
title: t('unscheduledTitle', { name }),
|
|
568
|
+
detail: t(fromConfig ? 'unscheduledInConfig' : 'unscheduledOnPage', { error: reason }),
|
|
569
|
+
open: fromConfig ? 'config' : { page: 'scheduler' },
|
|
570
|
+
}));
|
|
571
|
+
}
|
|
521
572
|
function initJob(jobCfg) {
|
|
522
573
|
if (runtimes.has(jobCfg.name))
|
|
523
574
|
return;
|
|
@@ -550,14 +601,21 @@ async function run(caps) {
|
|
|
550
601
|
// 优先采用持久化的 paused 字段;若未设置则回落到 enabled 状态(禁用即视作暂停)
|
|
551
602
|
paused: jobCfg.paused === true || !jobCfg.enabled,
|
|
552
603
|
};
|
|
553
|
-
//
|
|
554
|
-
if (jobCfg.interval
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
604
|
+
// 固定间隔调度。间隔无效(见 intervalProblem)的不排、发提示:服务入口已当场拒收,这里只剩配置文件与存量文件里的任务
|
|
605
|
+
if (jobCfg.interval !== undefined && jobCfg.enabled) {
|
|
606
|
+
const problem = intervalProblem(jobCfg.interval);
|
|
607
|
+
if (problem) {
|
|
608
|
+
logger.use.warn(`任务 ${jobCfg.name} 的固定间隔无效: ${toSource(problem)}`);
|
|
609
|
+
markUnscheduled(jobCfg.name, problem);
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
rt.timer = setInterval(() => {
|
|
613
|
+
if (!rt.paused) {
|
|
614
|
+
executeJob(rt);
|
|
615
|
+
rt.nextRun = estimateNextRun(rt.config, Date.now());
|
|
616
|
+
}
|
|
617
|
+
}, jobCfg.interval * 1000);
|
|
618
|
+
}
|
|
561
619
|
}
|
|
562
620
|
// cron 任务:订阅到共享 cron-engine
|
|
563
621
|
if (jobCfg.cron && jobCfg.enabled) {
|
|
@@ -573,48 +631,27 @@ async function run(caps) {
|
|
|
573
631
|
catch (err) {
|
|
574
632
|
const reason = err instanceof Error ? err.message : String(err);
|
|
575
633
|
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
|
-
}));
|
|
634
|
+
markUnscheduled(jobCfg.name, textOf(err));
|
|
586
635
|
}
|
|
587
636
|
}
|
|
588
|
-
// 一次性任务:到点用 setTimeout
|
|
637
|
+
// 一次性任务:到点用 setTimeout 执行一次(见 fireOneShot);配置里执行过的停用后载入,不到这里
|
|
589
638
|
if (jobCfg.runAt && jobCfg.enabled) {
|
|
590
639
|
const targetMs = Date.parse(jobCfg.runAt);
|
|
591
640
|
if (Number.isFinite(targetMs)) {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
641
|
+
// 远期 runAt 分段重排:delay 超过 2^31-1ms 会溢出成立即触发。每段最多排到上限,段末未到点就续排下一段,到点才执行。
|
|
642
|
+
// 已过期的(含停机时错过的)排 0ms,下一轮定时器就执行:与未到点的同记在 rt.timer,清理段一并撤掉,激活后紧接着
|
|
643
|
+
// 重载时不会在拆卸之后的旧实例上再执行一次
|
|
644
|
+
const arm = (wait) => {
|
|
645
|
+
rt.timer = setTimeout(() => {
|
|
646
|
+
const remain = targetMs - Date.now();
|
|
647
|
+
if (remain > 0) {
|
|
648
|
+
arm(Math.min(remain, MAX_TIMEOUT_MS));
|
|
649
|
+
return;
|
|
598
650
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
// 段末未到点就续排下一段,到点才执行
|
|
604
|
-
const arm = (wait) => {
|
|
605
|
-
rt.timer = setTimeout(() => {
|
|
606
|
-
const remain = targetMs - Date.now();
|
|
607
|
-
if (remain > 0) {
|
|
608
|
-
arm(Math.min(remain, MAX_TIMEOUT_MS));
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
if (!rt.paused) {
|
|
612
|
-
executeJob(rt).finally(() => disableOneShot(rt));
|
|
613
|
-
}
|
|
614
|
-
}, wait);
|
|
615
|
-
};
|
|
616
|
-
arm(Math.min(delay, MAX_TIMEOUT_MS));
|
|
617
|
-
}
|
|
651
|
+
fireOneShot(rt);
|
|
652
|
+
}, wait);
|
|
653
|
+
};
|
|
654
|
+
arm(Math.max(0, Math.min(targetMs - Date.now(), MAX_TIMEOUT_MS)));
|
|
618
655
|
}
|
|
619
656
|
else {
|
|
620
657
|
logger.use.warn(`任务 ${jobCfg.name} 的 runAt 无法解析: ${jobCfg.runAt}`);
|
|
@@ -631,17 +668,34 @@ async function run(caps) {
|
|
|
631
668
|
: '(no schedule)';
|
|
632
669
|
logger.use.info(`任务已注册: ${jobCfg.name} ${scheduleHint}`);
|
|
633
670
|
}
|
|
634
|
-
/**
|
|
671
|
+
/**
|
|
672
|
+
* 一次性任务到点(过期分支与未到点分支同一判据,暂停态不执行):先记下、排进写盘,再执行——配置里的记下「任务名 + runAt」,
|
|
673
|
+
* 动态的从持久化任务里删掉。执行要等投递、可能很久,途中重载本插件或停机,拆卸等这次写盘落定,之后激活不再执行
|
|
674
|
+
*/
|
|
675
|
+
function fireOneShot(rt) {
|
|
676
|
+
if (rt.paused)
|
|
677
|
+
return;
|
|
678
|
+
const { name, runAt } = rt.config;
|
|
679
|
+
if (!configJobNames.has(name))
|
|
680
|
+
dynamicJobs.delete(name);
|
|
681
|
+
else if (runAt)
|
|
682
|
+
onceFired.set(name, runAt);
|
|
683
|
+
saveDynamicJobs();
|
|
684
|
+
executeJob(rt).finally(() => disableOneShot(rt));
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* 一次性任务执行完:动态任务执行前已删掉并落盘,这里只撤调度表里的这一条(执行途中同名重建的那条不动),不再写盘;
|
|
688
|
+
* 静态 yaml 任务仅标记 enabled=false
|
|
689
|
+
*/
|
|
635
690
|
function disableOneShot(rt) {
|
|
636
691
|
const jobName = rt.config.name;
|
|
637
|
-
if (
|
|
692
|
+
if (!configJobNames.has(jobName)) {
|
|
638
693
|
if (rt.timer)
|
|
639
694
|
clearTimeout(rt.timer);
|
|
640
695
|
if (rt.cronDispose)
|
|
641
696
|
rt.cronDispose();
|
|
642
|
-
runtimes.
|
|
643
|
-
|
|
644
|
-
saveDynamicJobs();
|
|
697
|
+
if (runtimes.get(jobName) === rt)
|
|
698
|
+
runtimes.delete(jobName);
|
|
645
699
|
jobsPage.invalidate();
|
|
646
700
|
logger.use.info(`一次性任务已执行完毕并删除: ${jobName}`);
|
|
647
701
|
return;
|
|
@@ -652,39 +706,81 @@ async function run(caps) {
|
|
|
652
706
|
jobsPage.invalidate();
|
|
653
707
|
logger.use.info(`一次性任务已执行完毕并停用(静态): ${jobName}`);
|
|
654
708
|
}
|
|
655
|
-
// 静态 YAML 任务的「创建者」是编辑配置文件的人——那本身就是 owner
|
|
709
|
+
// 静态 YAML 任务的「创建者」是编辑配置文件的人——那本身就是 owner 行为,故两项都不写时补 owner。
|
|
656
710
|
// **这是全插件唯一允许推断 owner 的地方**:来源是配置文件(只有能写盘的人才改得动),
|
|
657
711
|
// 不是运行时输入。补在此处而非触发时,是为了让「谁是 actor」在任务入表那一刻就定死,
|
|
658
712
|
// 触发路径只透传、不再有第二次机会发明身份。YAML 显式写 actor 则用其值(可降权)。
|
|
659
713
|
for (const job of config.jobs) {
|
|
660
714
|
const p = job.actorPlatform?.trim();
|
|
661
715
|
const u = job.actorUserId?.trim();
|
|
662
|
-
if (
|
|
663
|
-
//
|
|
664
|
-
|
|
665
|
-
|
|
716
|
+
if (!p !== !u) {
|
|
717
|
+
// 只写了一半:写执行身份通常是为了降权,补成 owner 与作者意图相反,另一半也无从知道,所以这条不载入
|
|
718
|
+
logger.use.error(`任务 "${job.name}" 只写了 actorPlatform、actorUserId 其中一个,两者须成对填写;这条任务没有载入`);
|
|
719
|
+
unscheduled.set(job.name, webui.use.notify({
|
|
720
|
+
id: `unscheduled:${job.name}`,
|
|
721
|
+
level: 'error',
|
|
722
|
+
title: t('halfActorTitle', { name: job.name }),
|
|
723
|
+
detail: t('halfActorDetail'),
|
|
724
|
+
open: 'config',
|
|
725
|
+
}));
|
|
726
|
+
continue;
|
|
666
727
|
}
|
|
667
|
-
|
|
728
|
+
const resolved = p && u ? job : { ...job, actorPlatform: 'webui', actorUserId: 'console' };
|
|
729
|
+
// 一次性任务按「任务名 + runAt」认:记录对得上的执行过了,与本次运行里执行完的一样停用;记录读不出时无从判断执行过没有,
|
|
730
|
+
// 本次运行不安排。改了 runAt 的对不上记录,算新的一次
|
|
731
|
+
const settled = !!job.runAt && job.enabled && (loadFailed || onceFired.get(job.name) === job.runAt);
|
|
732
|
+
if (settled && loadFailed) {
|
|
733
|
+
logger.use.warn(`一次性任务 "${job.name}" 执行过没有无从判断(持久化文件读不出),本次运行不安排`);
|
|
734
|
+
}
|
|
735
|
+
else if (settled) {
|
|
736
|
+
logger.use.info(`一次性任务 "${job.name}"(runAt: ${job.runAt})已执行过,不再执行`);
|
|
737
|
+
}
|
|
738
|
+
initJob(settled ? { ...resolved, enabled: false } : resolved);
|
|
668
739
|
}
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
740
|
+
// 持久化的动态任务:配置文件里写了的名字归配置文件,那一条没载入也不让同名的动态任务顶上来;
|
|
741
|
+
// 文件里重名的只认第一条
|
|
742
|
+
for (const job of persisted.jobs) {
|
|
743
|
+
if (!configJobNames.has(job.name) && !runtimes.has(job.name)) {
|
|
672
744
|
dynamicJobs.set(job.name, job);
|
|
673
745
|
initJob(job);
|
|
674
746
|
}
|
|
675
747
|
}
|
|
676
|
-
if (persisted.length > 0) {
|
|
677
|
-
logger.use.info(`已加载 ${persisted.length} 个持久化任务`);
|
|
748
|
+
if (persisted.jobs.length > 0) {
|
|
749
|
+
logger.use.info(`已加载 ${persisted.jobs.length} 个持久化任务`);
|
|
678
750
|
}
|
|
679
751
|
// ── 注册 scheduler 服务 ──
|
|
680
|
-
/**
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
if (
|
|
686
|
-
throw new
|
|
752
|
+
/**
|
|
753
|
+
* 配置文件里写了的名字归配置文件(那一条没载入也一样):页面、工具与别的插件经服务新建与修改时都不能用它,免得顶替它、
|
|
754
|
+
* 撤掉它的错误提示,又因名字在配置里不落盘、重启就没了
|
|
755
|
+
*/
|
|
756
|
+
function rejectConfigName(name) {
|
|
757
|
+
if (configJobNames.has(name))
|
|
758
|
+
throw new TextError(t('fromConfig', { name }));
|
|
687
759
|
}
|
|
760
|
+
/**
|
|
761
|
+
* 无效的 cron 与固定间隔(含 cron 的 `@every`,见 intervalProblem)当场拒收:收下了也订阅不上、永远不会运行,或者间隔太短、
|
|
762
|
+
* 超过定时器能排的上限溢出成每毫秒触发,还会落进持久化文件。页面、工具与别的插件都经服务新建与修改
|
|
763
|
+
*/
|
|
764
|
+
function rejectInvalidSchedule(job) {
|
|
765
|
+
if (job.cron) {
|
|
766
|
+
const checked = cronEngine.use.validate(job.cron);
|
|
767
|
+
if (!checked.ok)
|
|
768
|
+
throw typeof checked.text === 'string' ? new Error(checked.text) : new TextError(checked.text);
|
|
769
|
+
}
|
|
770
|
+
const every = job.cron ? parseEverySeconds(job.cron) : 0;
|
|
771
|
+
const interval = every > 0 ? every : job.interval;
|
|
772
|
+
if (interval !== undefined) {
|
|
773
|
+
const problem = intervalProblem(interval);
|
|
774
|
+
if (problem)
|
|
775
|
+
throw new TextError(problem);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* 清理段开始之后置位,新建与修改一律拒收:拿着旧服务对象的调用方此时再交来任务,会在本实例上挂清理不再撤的定时器与
|
|
780
|
+
* cron 订阅、照常触发,还按本实例的内存整表写盘,盖掉新实例的文件。删除、暂停与恢复不用拦:清理段清空了调度表,
|
|
781
|
+
* 它们找不到任务,不写盘
|
|
782
|
+
*/
|
|
783
|
+
let disposed = false;
|
|
688
784
|
const service = {
|
|
689
785
|
getJobs() {
|
|
690
786
|
return [...runtimes.values()].map(rt => ({
|
|
@@ -746,20 +842,20 @@ async function run(caps) {
|
|
|
746
842
|
return true;
|
|
747
843
|
},
|
|
748
844
|
addJob(job) {
|
|
749
|
-
|
|
845
|
+
if (disposed)
|
|
846
|
+
throw new TextError(t('disposed', { name: job.name }));
|
|
847
|
+
rejectConfigName(job.name);
|
|
848
|
+
rejectInvalidSchedule(job);
|
|
750
849
|
initJob(job);
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
dynamicJobs.set(job.name, job);
|
|
754
|
-
saveDynamicJobs();
|
|
755
|
-
}
|
|
850
|
+
dynamicJobs.set(job.name, job);
|
|
851
|
+
saveDynamicJobs();
|
|
756
852
|
},
|
|
757
853
|
setJob(job) {
|
|
758
|
-
// upsert:先 remove 再 add
|
|
759
|
-
if (
|
|
760
|
-
throw new
|
|
761
|
-
|
|
762
|
-
|
|
854
|
+
// upsert:先 remove 再 add
|
|
855
|
+
if (disposed)
|
|
856
|
+
throw new TextError(t('disposed', { name: job.name }));
|
|
857
|
+
rejectConfigName(job.name);
|
|
858
|
+
rejectInvalidSchedule(job);
|
|
763
859
|
const existed = runtimes.get(job.name);
|
|
764
860
|
if (existed) {
|
|
765
861
|
if (existed.timer)
|
|
@@ -795,7 +891,8 @@ async function run(caps) {
|
|
|
795
891
|
// ── 注册 AI 工具:让 Agent 可以自主创建/管理任务 ──
|
|
796
892
|
tools.use.registerGroup({
|
|
797
893
|
name: 'scheduler',
|
|
798
|
-
label: '
|
|
894
|
+
label: t('displayName'),
|
|
895
|
+
summary: t('groupSummary'),
|
|
799
896
|
description: '创建、查看和取消定时/周期性自主行动计划',
|
|
800
897
|
});
|
|
801
898
|
tools.use.register({
|
|
@@ -830,7 +927,10 @@ async function run(caps) {
|
|
|
830
927
|
type: 'string',
|
|
831
928
|
description: 'ISO 8601 绝对时间,到点执行一次后自动停止。例:"2026-05-19T15:46:44+08:00"。',
|
|
832
929
|
},
|
|
833
|
-
sessionId: {
|
|
930
|
+
sessionId: {
|
|
931
|
+
type: 'string',
|
|
932
|
+
description: '任务消息发往的会话 ID。不传则默认使用当前会话(子任务里必须传)。',
|
|
933
|
+
},
|
|
834
934
|
platform: { type: 'string', description: '目标平台标识。不传则默认使用当前平台。' },
|
|
835
935
|
content: { type: 'string', description: '届时发送给自己的指令/提示内容' },
|
|
836
936
|
},
|
|
@@ -839,9 +939,9 @@ async function run(caps) {
|
|
|
839
939
|
},
|
|
840
940
|
},
|
|
841
941
|
// 建任务 = 让 AI 在未来自主行动,且 actor 身份在此固化(见 resolveActor)。
|
|
842
|
-
// 写类:restricted +
|
|
942
|
+
// 写类:restricted + 确认,与别的要确认的操作同一规则(参与者都是 owner 且在免确认白名单里的回合免)。
|
|
843
943
|
risk: 'dangerous',
|
|
844
|
-
confirm: '
|
|
944
|
+
confirm: 'session',
|
|
845
945
|
handler: async (args, callCtx) => {
|
|
846
946
|
// 互斥校验:cron / interval / delaySeconds / runAt 必须恰好提供一个
|
|
847
947
|
const hasCron = typeof args.cron === 'string' && args.cron.trim().length > 0;
|
|
@@ -860,19 +960,30 @@ async function run(caps) {
|
|
|
860
960
|
if (hasDelay) {
|
|
861
961
|
runAt = new Date(Date.now() + Math.floor(args.delaySeconds) * 1000).toISOString();
|
|
862
962
|
}
|
|
963
|
+
const target = typeof args.sessionId === 'string' ? args.sessionId.trim() : '';
|
|
964
|
+
const here = sessionManager.present ? sessionManager.use.getSession(callCtx.sessionId) : undefined;
|
|
965
|
+
// 子会话没有窗口,删掉后发往它的任务就静默丢了:在子会话里不按默认发回当前会话,要写明目标;根会话的 id 作候选
|
|
966
|
+
if (!target && here?.parentId) {
|
|
967
|
+
const root = rootSessionOf(here, id => sessionManager.use.getSession(id));
|
|
968
|
+
const candidate = root ? `;要发回根会话,sessionId 写 "${root.id}"` : '';
|
|
969
|
+
return JSON.stringify({
|
|
970
|
+
error: `在子任务里建定时任务必须写明目标会话 sessionId:子任务的会话删掉后,发往它的任务会静默丢失${candidate}`,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
// 安全关键:创建者强制取这次调用的授权身份(api-tools 的 actingIdentity),不从 args 读取:即使 LLM 在 prompt 中尝试
|
|
974
|
+
// 伪造身份也会被忽略。只有一位参与者时是他(代人执行的回合,如子任务链、workflow 或定时任务驱动的,就是被代的人);
|
|
975
|
+
// 这一项声明了确认,多人的一轮里经 owner 批过,参与者已换成她;没有单一身份的是无主体,任务按匿名(defaultAuthority)
|
|
976
|
+
// 运行,实现权限的自然传递与不可提升
|
|
977
|
+
const creator = actingIdentity(callCtx);
|
|
863
978
|
const job = {
|
|
864
979
|
name: args.name,
|
|
865
980
|
cron: hasCron ? args.cron.trim() : undefined,
|
|
866
981
|
interval: hasInterval ? args.interval : undefined,
|
|
867
982
|
runAt,
|
|
868
|
-
sessionId:
|
|
983
|
+
sessionId: target || callCtx.sessionId,
|
|
869
984
|
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,
|
|
985
|
+
actorPlatform: creator.platform,
|
|
986
|
+
actorUserId: creator.userId || undefined,
|
|
876
987
|
content: args.content,
|
|
877
988
|
enabled: true,
|
|
878
989
|
};
|
|
@@ -891,9 +1002,10 @@ async function run(caps) {
|
|
|
891
1002
|
: job.runAt
|
|
892
1003
|
? `一次性 @ ${job.runAt}`
|
|
893
1004
|
: '?';
|
|
1005
|
+
const note = persistNote();
|
|
894
1006
|
return JSON.stringify({
|
|
895
1007
|
ok: true,
|
|
896
|
-
message: `任务 "${job.name}" 已创建 (${scheduleDesc}),目标会话: ${job.sessionId} (${job.platform})${
|
|
1008
|
+
message: `任务 "${job.name}" 已创建 (${scheduleDesc}),目标会话: ${job.sessionId} (${job.platform})${note ? `;${toSource(note)}` : ''}`,
|
|
897
1009
|
});
|
|
898
1010
|
},
|
|
899
1011
|
});
|
|
@@ -919,8 +1031,8 @@ async function run(caps) {
|
|
|
919
1031
|
},
|
|
920
1032
|
},
|
|
921
1033
|
},
|
|
922
|
-
//
|
|
923
|
-
|
|
1034
|
+
// 只看不改:任务清单含 sessionId / prompt,2 级、不确认,低于 2 级的看不到
|
|
1035
|
+
visibility: 'restricted',
|
|
924
1036
|
handler: async (args) => {
|
|
925
1037
|
const all = service.getJobs().map(j => ({
|
|
926
1038
|
name: j.name,
|
|
@@ -982,7 +1094,7 @@ async function run(caps) {
|
|
|
982
1094
|
},
|
|
983
1095
|
// 删类:删掉定时任务不可逆(无回收站)。
|
|
984
1096
|
risk: 'dangerous',
|
|
985
|
-
confirm: '
|
|
1097
|
+
confirm: 'session',
|
|
986
1098
|
handler: async (args) => {
|
|
987
1099
|
const ok = service.removeJob(args.name);
|
|
988
1100
|
return JSON.stringify({ ok, message: ok ? '已删除' : '任务不存在' });
|
|
@@ -1006,7 +1118,7 @@ async function run(caps) {
|
|
|
1006
1118
|
},
|
|
1007
1119
|
// 改状态类:暂停等于静默停掉一条自主行动,用户不会收到任何提示。
|
|
1008
1120
|
risk: 'dangerous',
|
|
1009
|
-
confirm: '
|
|
1121
|
+
confirm: 'session',
|
|
1010
1122
|
handler: async (args) => {
|
|
1011
1123
|
const ok = service.pauseJob(args.name);
|
|
1012
1124
|
return JSON.stringify({ ok, message: ok ? '已暂停' : '任务不存在,或是一次性任务(不能暂停,只能删除重建)' });
|
|
@@ -1030,14 +1142,15 @@ async function run(caps) {
|
|
|
1030
1142
|
},
|
|
1031
1143
|
// 改状态类:恢复会让一条被停掉的自主行动重新开始跑。
|
|
1032
1144
|
risk: 'dangerous',
|
|
1033
|
-
confirm: '
|
|
1145
|
+
confirm: 'session',
|
|
1034
1146
|
handler: async (args) => {
|
|
1035
1147
|
const ok = service.resumeJob(args.name);
|
|
1036
1148
|
return JSON.stringify({ ok, message: ok ? '已恢复' : '任务不存在' });
|
|
1037
1149
|
},
|
|
1038
1150
|
});
|
|
1039
1151
|
// ── 清理 ──
|
|
1040
|
-
lifecycle.use.onDispose(() => {
|
|
1152
|
+
lifecycle.use.onDispose(async () => {
|
|
1153
|
+
disposed = true;
|
|
1041
1154
|
for (const rt of runtimes.values()) {
|
|
1042
1155
|
if (rt.timer)
|
|
1043
1156
|
clearInterval(rt.timer);
|
|
@@ -1045,10 +1158,13 @@ async function run(caps) {
|
|
|
1045
1158
|
rt.cronDispose();
|
|
1046
1159
|
}
|
|
1047
1160
|
runtimes.clear();
|
|
1161
|
+
// 等排队中的写盘落定再结束拆卸:否则重启时新实例读到的文件还没有这次改动,app.stop() 返回后也仍有写在飞
|
|
1162
|
+
await saveChain;
|
|
1048
1163
|
});
|
|
1049
1164
|
logger.use.info(`计划任务调度器已启动 (${config.jobs.length} 个任务)`);
|
|
1050
1165
|
}
|
|
1051
1166
|
// ──────────── 辅助函数 ────────────
|
|
1167
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1052
1168
|
export function resolveConfig(cfg) {
|
|
1053
1169
|
return {
|
|
1054
1170
|
jobs: cfg.jobs.map(j => ({
|
|
@@ -1065,7 +1181,7 @@ function resolvePersistPath(input) {
|
|
|
1065
1181
|
if (!s)
|
|
1066
1182
|
return configSchema.persistPath.default;
|
|
1067
1183
|
if (!isStorageUri(s)) {
|
|
1068
|
-
throw configError(
|
|
1184
|
+
throw configError(t('badPersistPath', { value: s }));
|
|
1069
1185
|
}
|
|
1070
1186
|
return s;
|
|
1071
1187
|
}
|