@dcrays/scheduled-task 0.1.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/README.md +5 -0
- package/cordis.patch.yml +12 -0
- package/package.json +49 -0
- package/plugin/index.d.ts +65 -0
- package/plugin/index.js +1799 -0
- package/src/cron.d.ts +24 -0
- package/src/cron.js +211 -0
- package/src/manager.d.ts +51 -0
- package/src/manager.js +452 -0
- package/src/openclaw-sqlite.d.ts +40 -0
- package/src/openclaw-sqlite.js +136 -0
- package/src/protocol.d.ts +24 -0
- package/src/protocol.js +94 -0
- package/src/repository.d.ts +85 -0
- package/src/repository.js +393 -0
package/src/manager.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { assertMinimumCronInterval, CronExpressionError, MIN_CRON_INTERVAL_SECONDS, nextCronOccurrence, validTimeZone } from './cron.js';
|
|
3
|
+
import { CronAutomationInputError } from './protocol.js';
|
|
4
|
+
import { CronAutomationRepository } from './repository.js';
|
|
5
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
6
|
+
function automationId(agentId, requestId) {
|
|
7
|
+
return `cron-${createHash('sha256').update(`${agentId}\0${requestId}`).digest('hex').slice(0, 24)}`;
|
|
8
|
+
}
|
|
9
|
+
function cloneJob(job) {
|
|
10
|
+
return structuredClone(job);
|
|
11
|
+
}
|
|
12
|
+
function dshAgentIdFor(job) {
|
|
13
|
+
return job.dshSessionId ?? job.agentId;
|
|
14
|
+
}
|
|
15
|
+
function timezoneFor(schedule) {
|
|
16
|
+
return schedule.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
|
|
17
|
+
}
|
|
18
|
+
function atTimestamp(schedule) {
|
|
19
|
+
if (typeof schedule.atMs === 'number')
|
|
20
|
+
return schedule.atMs;
|
|
21
|
+
return typeof schedule.at === 'string' ? Date.parse(schedule.at) : Number.NaN;
|
|
22
|
+
}
|
|
23
|
+
function assertMinimumScheduleInterval(schedule) {
|
|
24
|
+
if (schedule.kind === 'at')
|
|
25
|
+
return;
|
|
26
|
+
if (schedule.kind === 'every') {
|
|
27
|
+
if (schedule.everyMs < MIN_CRON_INTERVAL_SECONDS * 1_000) {
|
|
28
|
+
throw new CronAutomationInputError('invalid_cron', `schedule interval must be at least ${MIN_CRON_INTERVAL_SECONDS} seconds (${MIN_CRON_INTERVAL_SECONDS / 60} minutes)`);
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
assertMinimumCronInterval(schedule.expr);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
throw new CronAutomationInputError('invalid_cron', error instanceof Error ? error.message : 'invalid cron expression');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function disableIfTooFrequent(job) {
|
|
40
|
+
if (!job.enabled)
|
|
41
|
+
return false;
|
|
42
|
+
try {
|
|
43
|
+
assertMinimumScheduleInterval(job.schedule);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (!(error instanceof CronAutomationInputError))
|
|
48
|
+
throw error;
|
|
49
|
+
job.enabled = false;
|
|
50
|
+
delete job.state.runningAtMs;
|
|
51
|
+
delete job.state.nextRunAtMs;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function nextOccurrence(afterEpochMs, schedule) {
|
|
56
|
+
if (schedule.kind === 'at') {
|
|
57
|
+
const at = atTimestamp(schedule);
|
|
58
|
+
return Number.isFinite(at) && at > afterEpochMs ? at : undefined;
|
|
59
|
+
}
|
|
60
|
+
if (schedule.kind === 'every') {
|
|
61
|
+
const anchor = schedule.anchorMs ?? afterEpochMs;
|
|
62
|
+
if (anchor > afterEpochMs)
|
|
63
|
+
return anchor;
|
|
64
|
+
return anchor + (Math.floor((afterEpochMs - anchor) / schedule.everyMs) + 1) * schedule.everyMs;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return nextCronOccurrence(afterEpochMs, schedule.expr, timezoneFor(schedule));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof CronExpressionError)
|
|
71
|
+
throw new CronAutomationInputError('invalid_cron', error.message);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function initialOccurrence(job, now) {
|
|
76
|
+
return job.schedule.kind === 'at' ? atTimestamp(job.schedule) : nextOccurrence(now, job.schedule);
|
|
77
|
+
}
|
|
78
|
+
function setNextOccurrence(job, value) {
|
|
79
|
+
if (value === undefined)
|
|
80
|
+
delete job.state.nextRunAtMs;
|
|
81
|
+
else
|
|
82
|
+
job.state.nextRunAtMs = value;
|
|
83
|
+
}
|
|
84
|
+
function sameRequest(job, envelope) {
|
|
85
|
+
return (job.name === envelope.name &&
|
|
86
|
+
job.payload.message === envelope.prompt &&
|
|
87
|
+
job.schedule.kind === 'cron' &&
|
|
88
|
+
job.schedule.expr === envelope.cron &&
|
|
89
|
+
timezoneFor(job.schedule) === envelope.timezone);
|
|
90
|
+
}
|
|
91
|
+
export class CronAutomationManager {
|
|
92
|
+
repository;
|
|
93
|
+
options;
|
|
94
|
+
jobs = new Map();
|
|
95
|
+
blockedAgents = new Set();
|
|
96
|
+
now;
|
|
97
|
+
started;
|
|
98
|
+
tail = Promise.resolve();
|
|
99
|
+
timer;
|
|
100
|
+
stopping = false;
|
|
101
|
+
constructor(repository, options) {
|
|
102
|
+
this.repository = repository;
|
|
103
|
+
this.options = options;
|
|
104
|
+
this.now = options.now ?? Date.now;
|
|
105
|
+
}
|
|
106
|
+
start() {
|
|
107
|
+
return (this.started ??= this.initialize());
|
|
108
|
+
}
|
|
109
|
+
async initialize() {
|
|
110
|
+
const loaded = await this.repository.load();
|
|
111
|
+
const changed = [];
|
|
112
|
+
const now = this.now();
|
|
113
|
+
for (const job of loaded) {
|
|
114
|
+
let jobChanged = disableIfTooFrequent(job);
|
|
115
|
+
if (job.state.runningAtMs !== undefined) {
|
|
116
|
+
delete job.state.runningAtMs;
|
|
117
|
+
jobChanged = true;
|
|
118
|
+
}
|
|
119
|
+
if (job.enabled && job.state.nextRunAtMs === undefined) {
|
|
120
|
+
setNextOccurrence(job, initialOccurrence(job, now));
|
|
121
|
+
jobChanged = true;
|
|
122
|
+
}
|
|
123
|
+
if (jobChanged)
|
|
124
|
+
changed.push(job);
|
|
125
|
+
this.jobs.set(job.id, job);
|
|
126
|
+
}
|
|
127
|
+
await this.repository.upsert(changed);
|
|
128
|
+
this.arm();
|
|
129
|
+
}
|
|
130
|
+
async stop() {
|
|
131
|
+
this.stopping = true;
|
|
132
|
+
if (this.timer)
|
|
133
|
+
clearTimeout(this.timer);
|
|
134
|
+
this.timer = undefined;
|
|
135
|
+
try {
|
|
136
|
+
await this.tail;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
this.repository.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async create(agentId, envelope) {
|
|
143
|
+
await this.start();
|
|
144
|
+
return this.enqueue(async () => {
|
|
145
|
+
if (this.stopping)
|
|
146
|
+
throw new Error('cron automation manager is stopping');
|
|
147
|
+
const id = automationId(agentId, envelope.requestId);
|
|
148
|
+
const existing = this.jobs.get(id);
|
|
149
|
+
if (existing) {
|
|
150
|
+
if (!sameRequest(existing, envelope)) {
|
|
151
|
+
throw new CronAutomationInputError('request_id_conflict', 'requestId was already used for a different cron automation');
|
|
152
|
+
}
|
|
153
|
+
return cloneJob(existing);
|
|
154
|
+
}
|
|
155
|
+
if (envelope.prompt.length > this.options.maxPromptChars) {
|
|
156
|
+
throw new CronAutomationInputError('invalid_prompt', `prompt must not exceed ${this.options.maxPromptChars} characters`);
|
|
157
|
+
}
|
|
158
|
+
const now = this.now();
|
|
159
|
+
const schedule = { kind: 'cron', expr: envelope.cron, tz: envelope.timezone };
|
|
160
|
+
assertMinimumScheduleInterval(schedule);
|
|
161
|
+
const nextRunAtMs = nextOccurrence(now, schedule);
|
|
162
|
+
const job = {
|
|
163
|
+
id,
|
|
164
|
+
name: envelope.name,
|
|
165
|
+
description: envelope.prompt,
|
|
166
|
+
enabled: true,
|
|
167
|
+
createdAtMs: now,
|
|
168
|
+
updatedAtMs: now,
|
|
169
|
+
schedule,
|
|
170
|
+
payload: { kind: 'agentTurn', message: envelope.prompt, timeoutSeconds: 600 },
|
|
171
|
+
agentId,
|
|
172
|
+
dshSessionId: agentId,
|
|
173
|
+
sessionTarget: 'isolated',
|
|
174
|
+
wakeMode: 'now',
|
|
175
|
+
delivery: { mode: 'announce', bestEffort: true },
|
|
176
|
+
state: { ...(nextRunAtMs === undefined ? {} : { nextRunAtMs }) }
|
|
177
|
+
};
|
|
178
|
+
this.jobs.set(job.id, job);
|
|
179
|
+
try {
|
|
180
|
+
await this.repository.upsert([job]);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
this.jobs.delete(job.id);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
this.options.onCreated?.(cloneJob(job));
|
|
187
|
+
this.arm();
|
|
188
|
+
return cloneJob(job);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async update(jobId, patch) {
|
|
192
|
+
await this.start();
|
|
193
|
+
return this.enqueue(async () => {
|
|
194
|
+
const current = this.jobs.get(jobId);
|
|
195
|
+
if (!current)
|
|
196
|
+
return undefined;
|
|
197
|
+
const patchKeys = Object.keys(patch);
|
|
198
|
+
if (patchKeys.length === 0)
|
|
199
|
+
throw new CronAutomationInputError('invalid_envelope', 'at least one editable field is required');
|
|
200
|
+
if (patchKeys.length === 1 && patch.enabled === current.enabled)
|
|
201
|
+
return cloneJob(current);
|
|
202
|
+
const next = cloneJob(current);
|
|
203
|
+
if (patch.name !== undefined) {
|
|
204
|
+
if (patch.name.trim() !== patch.name || patch.name.length < 1 || patch.name.length > 120) {
|
|
205
|
+
throw new CronAutomationInputError('invalid_name', 'name must contain 1-120 characters without surrounding whitespace');
|
|
206
|
+
}
|
|
207
|
+
next.name = patch.name;
|
|
208
|
+
}
|
|
209
|
+
if (patch.prompt !== undefined) {
|
|
210
|
+
if (patch.prompt.trim() !== patch.prompt ||
|
|
211
|
+
patch.prompt.length < 1 ||
|
|
212
|
+
patch.prompt.length > this.options.maxPromptChars) {
|
|
213
|
+
throw new CronAutomationInputError('invalid_prompt', `prompt must contain 1-${this.options.maxPromptChars} characters without surrounding whitespace`);
|
|
214
|
+
}
|
|
215
|
+
next.payload.message = patch.prompt;
|
|
216
|
+
next.description = patch.prompt;
|
|
217
|
+
}
|
|
218
|
+
if (patch.cron !== undefined) {
|
|
219
|
+
try {
|
|
220
|
+
assertMinimumCronInterval(patch.cron);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
throw new CronAutomationInputError('invalid_cron', error instanceof Error ? error.message : 'invalid cron expression');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (patch.timezone !== undefined && !validTimeZone(patch.timezone)) {
|
|
227
|
+
throw new CronAutomationInputError('invalid_timezone', 'timezone must be a valid IANA time zone');
|
|
228
|
+
}
|
|
229
|
+
if (patch.sessionKey !== undefined) {
|
|
230
|
+
if (!patch.sessionKey.trim() || patch.sessionKey.trim() !== patch.sessionKey) {
|
|
231
|
+
throw new CronAutomationInputError('invalid_envelope', 'sessionKey must be non-empty without surrounding whitespace');
|
|
232
|
+
}
|
|
233
|
+
next.sessionKey = patch.sessionKey;
|
|
234
|
+
next.delivery = {
|
|
235
|
+
...next.delivery,
|
|
236
|
+
mode: 'announce',
|
|
237
|
+
to: `dcg-cron:${patch.sessionKey}`,
|
|
238
|
+
channel: 'dcgchat',
|
|
239
|
+
bestEffort: true
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
for (const key of ['agentId', 'dshSessionId']) {
|
|
243
|
+
if (patch[key] !== undefined) {
|
|
244
|
+
if (!patch[key]?.trim() || patch[key]?.trim() !== patch[key]) {
|
|
245
|
+
throw new CronAutomationInputError('invalid_envelope', `${key} must be non-empty without surrounding whitespace`);
|
|
246
|
+
}
|
|
247
|
+
next[key] = patch[key];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (patch.cron !== undefined || patch.timezone !== undefined) {
|
|
251
|
+
const currentCron = next.schedule.kind === 'cron' ? next.schedule.expr : undefined;
|
|
252
|
+
const currentTimezone = next.schedule.kind === 'cron' ? timezoneFor(next.schedule) : undefined;
|
|
253
|
+
if (!patch.cron && !currentCron)
|
|
254
|
+
throw new CronAutomationInputError('invalid_cron', 'changing a non-cron schedule requires a cron expression');
|
|
255
|
+
next.schedule = {
|
|
256
|
+
kind: 'cron',
|
|
257
|
+
expr: patch.cron ?? currentCron ?? '',
|
|
258
|
+
tz: patch.timezone ?? currentTimezone ?? 'UTC'
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const now = this.now();
|
|
262
|
+
if (patch.enabled !== undefined) {
|
|
263
|
+
next.enabled = patch.enabled;
|
|
264
|
+
delete next.state.runningAtMs;
|
|
265
|
+
}
|
|
266
|
+
if (next.enabled)
|
|
267
|
+
assertMinimumScheduleInterval(next.schedule);
|
|
268
|
+
if (patch.enabled === true) {
|
|
269
|
+
setNextOccurrence(next, initialOccurrence(next, now));
|
|
270
|
+
}
|
|
271
|
+
else if (patch.cron !== undefined || patch.timezone !== undefined) {
|
|
272
|
+
setNextOccurrence(next, nextOccurrence(now, next.schedule));
|
|
273
|
+
}
|
|
274
|
+
next.updatedAtMs = now;
|
|
275
|
+
this.jobs.set(jobId, next);
|
|
276
|
+
try {
|
|
277
|
+
await this.repository.upsert([next]);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
this.jobs.set(jobId, current);
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
if (patch.enabled || dshAgentIdFor(current) !== dshAgentIdFor(next)) {
|
|
284
|
+
this.blockedAgents.delete(dshAgentIdFor(current));
|
|
285
|
+
this.blockedAgents.delete(dshAgentIdFor(next));
|
|
286
|
+
}
|
|
287
|
+
this.options.onChanged?.(cloneJob(next));
|
|
288
|
+
this.arm();
|
|
289
|
+
return cloneJob(next);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
async delete(jobId) {
|
|
293
|
+
await this.start();
|
|
294
|
+
return this.enqueue(async () => {
|
|
295
|
+
const job = this.jobs.get(jobId);
|
|
296
|
+
if (!job)
|
|
297
|
+
return false;
|
|
298
|
+
this.jobs.delete(jobId);
|
|
299
|
+
this.blockedAgents.delete(dshAgentIdFor(job));
|
|
300
|
+
try {
|
|
301
|
+
await this.repository.delete(jobId);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
this.jobs.set(jobId, job);
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
this.options.onChanged?.(cloneJob(job));
|
|
308
|
+
this.arm();
|
|
309
|
+
return true;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
async runOnceStatus(jobId) {
|
|
313
|
+
await this.start();
|
|
314
|
+
return this.enqueue(async () => {
|
|
315
|
+
const job = this.jobs.get(jobId);
|
|
316
|
+
if (!job)
|
|
317
|
+
return 'not_found';
|
|
318
|
+
if (job.state.runningAtMs !== undefined)
|
|
319
|
+
return 'already_running';
|
|
320
|
+
const occurrenceAtMs = this.now();
|
|
321
|
+
job.state.runningAtMs = occurrenceAtMs;
|
|
322
|
+
job.updatedAtMs = occurrenceAtMs;
|
|
323
|
+
await this.repository.upsert([job]);
|
|
324
|
+
const delivered = await this.deliver(job, occurrenceAtMs);
|
|
325
|
+
delete job.state.runningAtMs;
|
|
326
|
+
job.state.lastRunAtMs = occurrenceAtMs;
|
|
327
|
+
job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
|
|
328
|
+
job.updatedAtMs = this.now();
|
|
329
|
+
await this.repository.upsert([job]);
|
|
330
|
+
if (delivered) {
|
|
331
|
+
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
332
|
+
return 'dispatched';
|
|
333
|
+
}
|
|
334
|
+
this.options.onChanged?.(cloneJob(job));
|
|
335
|
+
return 'delivery_unavailable';
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
async runOnce(jobId) {
|
|
339
|
+
return (await this.runOnceStatus(jobId)) === 'dispatched';
|
|
340
|
+
}
|
|
341
|
+
async list(agentId) {
|
|
342
|
+
await this.start();
|
|
343
|
+
await this.tail;
|
|
344
|
+
return [...this.jobs.values()]
|
|
345
|
+
.filter((job) => agentId === undefined || dshAgentIdFor(job) === agentId)
|
|
346
|
+
.sort((left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id))
|
|
347
|
+
.map(cloneJob);
|
|
348
|
+
}
|
|
349
|
+
notifyAgentAvailable(agentId) {
|
|
350
|
+
if (!this.blockedAgents.delete(agentId))
|
|
351
|
+
return;
|
|
352
|
+
this.requestDispatch();
|
|
353
|
+
}
|
|
354
|
+
/** Import each source job once. Existing ids win so DSH bindings stay intact. */
|
|
355
|
+
async importFrom(source, incoming) {
|
|
356
|
+
await this.start();
|
|
357
|
+
return this.enqueue(async () => {
|
|
358
|
+
const prepared = [];
|
|
359
|
+
for (const job of incoming) {
|
|
360
|
+
const next = cloneJob(job);
|
|
361
|
+
delete next.state.runningAtMs;
|
|
362
|
+
disableIfTooFrequent(next);
|
|
363
|
+
if (next.enabled && next.state.nextRunAtMs === undefined) {
|
|
364
|
+
setNextOccurrence(next, initialOccurrence(next, this.now()));
|
|
365
|
+
}
|
|
366
|
+
prepared.push(next);
|
|
367
|
+
}
|
|
368
|
+
const { added } = await this.repository.import(source, prepared);
|
|
369
|
+
if (added.length === 0)
|
|
370
|
+
return added;
|
|
371
|
+
for (const job of added) {
|
|
372
|
+
this.jobs.set(job.id, cloneJob(job));
|
|
373
|
+
this.options.onCreated?.(cloneJob(job));
|
|
374
|
+
}
|
|
375
|
+
this.arm();
|
|
376
|
+
return added.map(cloneJob);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
async deliver(job, occurrenceAtMs) {
|
|
380
|
+
try {
|
|
381
|
+
return await this.options.deliver(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
this.options.onError?.(error);
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
enqueue(operation) {
|
|
389
|
+
const run = this.tail.then(operation);
|
|
390
|
+
this.tail = run.then(() => undefined, () => undefined);
|
|
391
|
+
return run;
|
|
392
|
+
}
|
|
393
|
+
requestDispatch() {
|
|
394
|
+
void this.enqueue(() => this.dispatchDue()).catch((error) => this.options.onError?.(error));
|
|
395
|
+
}
|
|
396
|
+
arm() {
|
|
397
|
+
if (this.stopping)
|
|
398
|
+
return;
|
|
399
|
+
if (this.timer)
|
|
400
|
+
clearTimeout(this.timer);
|
|
401
|
+
this.timer = undefined;
|
|
402
|
+
const now = this.now();
|
|
403
|
+
const target = [...this.jobs.values()]
|
|
404
|
+
.filter((job) => job.enabled && job.state.runningAtMs === undefined && !this.blockedAgents.has(dshAgentIdFor(job)))
|
|
405
|
+
.map((job) => job.state.nextRunAtMs)
|
|
406
|
+
.filter((value) => value !== undefined)
|
|
407
|
+
.reduce((earliest, candidate) => (earliest === undefined || candidate < earliest ? candidate : earliest), undefined);
|
|
408
|
+
if (target === undefined)
|
|
409
|
+
return;
|
|
410
|
+
const delay = Math.max(0, Math.min(target - now, MAX_TIMER_DELAY_MS));
|
|
411
|
+
this.timer = setTimeout(() => {
|
|
412
|
+
this.timer = undefined;
|
|
413
|
+
this.requestDispatch();
|
|
414
|
+
}, delay);
|
|
415
|
+
this.timer.unref();
|
|
416
|
+
}
|
|
417
|
+
async dispatchDue() {
|
|
418
|
+
if (this.stopping)
|
|
419
|
+
return;
|
|
420
|
+
const now = this.now();
|
|
421
|
+
const due = [...this.jobs.values()]
|
|
422
|
+
.filter((job) => job.enabled && job.state.nextRunAtMs !== undefined && job.state.nextRunAtMs <= now)
|
|
423
|
+
.sort((left, right) => (left.state.nextRunAtMs ?? 0) - (right.state.nextRunAtMs ?? 0) || left.createdAtMs - right.createdAtMs);
|
|
424
|
+
for (const job of due) {
|
|
425
|
+
const occurrenceAtMs = job.state.nextRunAtMs ?? now;
|
|
426
|
+
job.state.runningAtMs = now;
|
|
427
|
+
job.updatedAtMs = now;
|
|
428
|
+
await this.repository.upsert([job]);
|
|
429
|
+
const delivered = await this.deliver(job, occurrenceAtMs);
|
|
430
|
+
delete job.state.runningAtMs;
|
|
431
|
+
job.state.lastRunAtMs = now;
|
|
432
|
+
job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
|
|
433
|
+
job.updatedAtMs = now;
|
|
434
|
+
if (!delivered) {
|
|
435
|
+
this.blockedAgents.add(dshAgentIdFor(job));
|
|
436
|
+
await this.repository.upsert([job]);
|
|
437
|
+
this.options.onChanged?.(cloneJob(job));
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (job.deleteAfterRun === true || job.schedule.kind === 'at') {
|
|
441
|
+
this.jobs.delete(job.id);
|
|
442
|
+
await this.repository.delete(job.id);
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
setNextOccurrence(job, nextOccurrence(now, job.schedule));
|
|
446
|
+
await this.repository.upsert([job]);
|
|
447
|
+
}
|
|
448
|
+
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
449
|
+
}
|
|
450
|
+
this.arm();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type StoredCronAutomation } from "./repository.js";
|
|
2
|
+
export declare const DEFAULT_OPENCLAW_SQLITE: string;
|
|
3
|
+
interface CronJobRow {
|
|
4
|
+
job_id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
description: string | null;
|
|
7
|
+
enabled: number;
|
|
8
|
+
delete_after_run: number | null;
|
|
9
|
+
created_at_ms: number;
|
|
10
|
+
updated_at: number;
|
|
11
|
+
agent_id: string | null;
|
|
12
|
+
session_key: string | null;
|
|
13
|
+
schedule_kind: string;
|
|
14
|
+
schedule_expr: string | null;
|
|
15
|
+
schedule_tz: string | null;
|
|
16
|
+
every_ms: number | null;
|
|
17
|
+
anchor_ms: number | null;
|
|
18
|
+
at: string | null;
|
|
19
|
+
session_target: string;
|
|
20
|
+
wake_mode: string;
|
|
21
|
+
payload_kind: string;
|
|
22
|
+
payload_message: string | null;
|
|
23
|
+
payload_timeout_seconds: number | null;
|
|
24
|
+
delivery_mode: string | null;
|
|
25
|
+
delivery_channel: string | null;
|
|
26
|
+
delivery_to: string | null;
|
|
27
|
+
delivery_account_id: string | null;
|
|
28
|
+
delivery_best_effort: number | null;
|
|
29
|
+
next_run_at_ms: number | null;
|
|
30
|
+
running_at_ms: number | null;
|
|
31
|
+
last_run_at_ms: number | null;
|
|
32
|
+
last_run_status: string | null;
|
|
33
|
+
job_json: string;
|
|
34
|
+
state_json: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function jobFromOpenClawRow(row: CronJobRow): StoredCronAutomation;
|
|
37
|
+
export declare function loadOpenClawSqliteJobs(sqlitePath: string): StoredCronAutomation[];
|
|
38
|
+
export declare function resolveOpenClawSqlitePath(configured?: string): string;
|
|
39
|
+
export declare function openClawImportSource(sqlitePath: string): string;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { decodeJob } from "./repository.js";
|
|
6
|
+
export const DEFAULT_OPENCLAW_SQLITE = path.join(os.homedir(), ".mobook", "state", "openclaw.sqlite");
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function parseJson(raw, label) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new Error(`OpenClaw ${label} is not valid JSON`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function scheduleFromRow(row) {
|
|
19
|
+
if (row.schedule_kind === "every") {
|
|
20
|
+
return {
|
|
21
|
+
kind: "every",
|
|
22
|
+
everyMs: row.every_ms,
|
|
23
|
+
...(row.anchor_ms === null ? {} : { anchorMs: row.anchor_ms })
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (row.schedule_kind === "at") {
|
|
27
|
+
return { kind: "at", ...(row.at === null ? {} : { at: row.at }) };
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
kind: "cron",
|
|
31
|
+
expr: row.schedule_expr,
|
|
32
|
+
...(row.schedule_tz === null ? {} : { tz: row.schedule_tz })
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function jobFromColumns(row, state) {
|
|
36
|
+
return {
|
|
37
|
+
id: row.job_id,
|
|
38
|
+
name: row.name,
|
|
39
|
+
...(row.description ? { description: row.description } : {}),
|
|
40
|
+
enabled: row.enabled === 1,
|
|
41
|
+
createdAtMs: row.created_at_ms,
|
|
42
|
+
updatedAtMs: row.updated_at,
|
|
43
|
+
schedule: scheduleFromRow(row),
|
|
44
|
+
payload: {
|
|
45
|
+
kind: row.payload_kind || "agentTurn",
|
|
46
|
+
message: row.payload_message ?? "",
|
|
47
|
+
...(row.payload_timeout_seconds === null ? {} : { timeoutSeconds: row.payload_timeout_seconds })
|
|
48
|
+
},
|
|
49
|
+
agentId: row.agent_id ?? "main",
|
|
50
|
+
...(row.session_key ? { sessionKey: row.session_key } : {}),
|
|
51
|
+
sessionTarget: row.session_target || "isolated",
|
|
52
|
+
wakeMode: row.wake_mode || "now",
|
|
53
|
+
...(row.delete_after_run === 1 ? { deleteAfterRun: true } : {}),
|
|
54
|
+
delivery: {
|
|
55
|
+
mode: row.delivery_mode ?? "announce",
|
|
56
|
+
...(row.delivery_channel ? { channel: row.delivery_channel } : {}),
|
|
57
|
+
...(row.delivery_to ? { to: row.delivery_to } : {}),
|
|
58
|
+
...(row.delivery_account_id ? { accountId: row.delivery_account_id } : {}),
|
|
59
|
+
bestEffort: row.delivery_best_effort !== 0
|
|
60
|
+
},
|
|
61
|
+
state
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function overlayState(row, jobState, tableState) {
|
|
65
|
+
const state = {
|
|
66
|
+
...(isRecord(jobState) ? jobState : {}),
|
|
67
|
+
...(isRecord(tableState) ? tableState : {})
|
|
68
|
+
};
|
|
69
|
+
if (row.next_run_at_ms !== null)
|
|
70
|
+
state.nextRunAtMs = row.next_run_at_ms;
|
|
71
|
+
if (row.running_at_ms !== null)
|
|
72
|
+
state.runningAtMs = row.running_at_ms;
|
|
73
|
+
if (row.last_run_at_ms !== null)
|
|
74
|
+
state.lastRunAtMs = row.last_run_at_ms;
|
|
75
|
+
if (row.last_run_status)
|
|
76
|
+
state.lastRunStatus = row.last_run_status;
|
|
77
|
+
return state;
|
|
78
|
+
}
|
|
79
|
+
export function jobFromOpenClawRow(row) {
|
|
80
|
+
const tableState = parseJson(row.state_json || "{}", "state_json");
|
|
81
|
+
try {
|
|
82
|
+
const parsed = parseJson(row.job_json, "job_json");
|
|
83
|
+
if (!isRecord(parsed))
|
|
84
|
+
throw new Error("job_json must be an object");
|
|
85
|
+
return decodeJob({
|
|
86
|
+
...parsed,
|
|
87
|
+
id: typeof parsed.id === "string" && parsed.id ? parsed.id : row.job_id,
|
|
88
|
+
updatedAtMs: typeof parsed.updatedAtMs === "number" ? parsed.updatedAtMs : row.updated_at,
|
|
89
|
+
createdAtMs: typeof parsed.createdAtMs === "number" ? parsed.createdAtMs : row.created_at_ms,
|
|
90
|
+
state: overlayState(row, parsed.state, tableState)
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return decodeJob(jobFromColumns(row, overlayState(row, {}, tableState)));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function loadOpenClawSqliteJobs(sqlitePath) {
|
|
98
|
+
if (!existsSync(sqlitePath))
|
|
99
|
+
return [];
|
|
100
|
+
const database = new DatabaseSync(sqlitePath, { readOnly: true, timeout: 5_000 });
|
|
101
|
+
try {
|
|
102
|
+
const table = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_jobs'").get();
|
|
103
|
+
if (!table)
|
|
104
|
+
return [];
|
|
105
|
+
// SELECT * deliberately tolerates OpenClaw schema additions and older schemas
|
|
106
|
+
// that omit newer nullable projection columns. job_json remains authoritative.
|
|
107
|
+
const rows = database.prepare("SELECT * FROM cron_jobs ORDER BY updated_at ASC, job_id ASC").all();
|
|
108
|
+
const jobs = new Map();
|
|
109
|
+
for (const row of rows) {
|
|
110
|
+
try {
|
|
111
|
+
const job = jobFromOpenClawRow(row);
|
|
112
|
+
const previous = jobs.get(job.id);
|
|
113
|
+
if (!previous || job.updatedAtMs >= previous.updatedAtMs)
|
|
114
|
+
jobs.set(job.id, job);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Skip a corrupt row so one bad job cannot block the rest of the import.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return [...jobs.values()];
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
database.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export function resolveOpenClawSqlitePath(configured) {
|
|
127
|
+
const explicit = configured?.trim() || process.env.OPENCLAW_SQLITE?.trim();
|
|
128
|
+
if (explicit)
|
|
129
|
+
return path.resolve(explicit);
|
|
130
|
+
return DEFAULT_OPENCLAW_SQLITE;
|
|
131
|
+
}
|
|
132
|
+
export function openClawImportSource(sqlitePath) {
|
|
133
|
+
const resolved = path.resolve(sqlitePath);
|
|
134
|
+
const canonical = existsSync(resolved) ? realpathSync.native(resolved) : resolved;
|
|
135
|
+
return `openclaw-sqlite:${canonical}`;
|
|
136
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { UserMessage } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
export declare const CRON_CREATE_TYPE: "dsh/cron.create";
|
|
3
|
+
export declare const CRON_LIST_TYPE: "dsh/cron.list";
|
|
4
|
+
export interface CronCreateEnvelope {
|
|
5
|
+
type: typeof CRON_CREATE_TYPE;
|
|
6
|
+
requestId: string;
|
|
7
|
+
name: string;
|
|
8
|
+
prompt: string;
|
|
9
|
+
cron: string;
|
|
10
|
+
timezone: string;
|
|
11
|
+
}
|
|
12
|
+
export interface CronEditorRequest {
|
|
13
|
+
id: string;
|
|
14
|
+
text: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class CronAutomationInputError extends Error {
|
|
17
|
+
readonly code: "invalid_envelope" | "invalid_request_id" | "invalid_name" | "invalid_prompt" | "invalid_cron" | "invalid_timezone" | "request_id_conflict";
|
|
18
|
+
constructor(code: "invalid_envelope" | "invalid_request_id" | "invalid_name" | "invalid_prompt" | "invalid_cron" | "invalid_timezone" | "request_id_conflict", message: string);
|
|
19
|
+
}
|
|
20
|
+
export declare function decodeCronCreateEnvelope(value: unknown): CronCreateEnvelope;
|
|
21
|
+
/** Parse a cron control envelope carried as the sole text block of a direct user message. */
|
|
22
|
+
export declare function parseCronAutomationMessage(message: UserMessage): CronCreateEnvelope | undefined;
|
|
23
|
+
/** Recognize the stable prompt prefix emitted by the Mobook scheduled-task editor. */
|
|
24
|
+
export declare function parseCronEditorRequest(message: UserMessage): CronEditorRequest | undefined;
|