@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.
@@ -0,0 +1,1799 @@
1
+ import { createRequire as __mobookCreateRequire } from 'node:module'; const require = __mobookCreateRequire(import.meta.url);
2
+
3
+ // plugins/scheduled-task/dist-npm/plugin/index.js
4
+ import path3 from "node:path";
5
+ import { Service } from "@deepseek-ai/cordis";
6
+ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
7
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
8
+ import { defineTool } from "@deepseek-ai/dsh-tools";
9
+ import z from "@deepseek-ai/schemastery";
10
+
11
+ // plugins/scheduled-task/dist-npm/src/cron.js
12
+ var FIELD_COUNT = 5;
13
+ var MAX_SEARCH_DAYS = 366 * 8;
14
+ var GREGORIAN_CYCLE_DAYS = 146097;
15
+ var MIN_CRON_INTERVAL_SECONDS = 600;
16
+ var CronExpressionError = class extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "CronExpressionError";
20
+ }
21
+ };
22
+ function validTimeZone(timeZone) {
23
+ try {
24
+ new Intl.DateTimeFormat("en-US", { timeZone });
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+ function parseInteger(value, label) {
31
+ if (!/^\d+$/.test(value))
32
+ throw new CronExpressionError(`${label} contains a non-integer value`);
33
+ return Number(value);
34
+ }
35
+ function parseField(raw, min, max, label, normalize) {
36
+ const values = /* @__PURE__ */ new Set();
37
+ const wildcard = raw === "*" || raw.startsWith("*/");
38
+ if (raw.length === 0)
39
+ throw new CronExpressionError(`${label} is empty`);
40
+ for (const segment of raw.split(",")) {
41
+ if (segment.length === 0)
42
+ throw new CronExpressionError(`${label} contains an empty list item`);
43
+ const slash = segment.split("/");
44
+ if (slash.length > 2)
45
+ throw new CronExpressionError(`${label} contains an invalid step`);
46
+ const base = slash[0] ?? "";
47
+ const step = slash[1] === void 0 ? 1 : parseInteger(slash[1], label);
48
+ if (step < 1)
49
+ throw new CronExpressionError(`${label} step must be positive`);
50
+ let start;
51
+ let end;
52
+ if (base === "*") {
53
+ start = min;
54
+ end = max;
55
+ } else if (base.includes("-")) {
56
+ const range = base.split("-");
57
+ if (range.length !== 2)
58
+ throw new CronExpressionError(`${label} contains an invalid range`);
59
+ start = parseInteger(range[0] ?? "", label);
60
+ end = parseInteger(range[1] ?? "", label);
61
+ } else {
62
+ start = parseInteger(base, label);
63
+ end = slash[1] === void 0 ? start : max;
64
+ }
65
+ if (start < min || start > max || end < min || end > max || start > end) {
66
+ throw new CronExpressionError(`${label} must stay within ${min}-${max}`);
67
+ }
68
+ for (let value = start; value <= end; value += step)
69
+ values.add(normalize?.(value) ?? value);
70
+ }
71
+ if (values.size === 0)
72
+ throw new CronExpressionError(`${label} selects no values`);
73
+ return { values, wildcard };
74
+ }
75
+ function parseCronExpression(value) {
76
+ if (value.trim() !== value || value.length === 0)
77
+ throw new CronExpressionError("cron expression must not have surrounding whitespace");
78
+ const fields = value.split(/\s+/);
79
+ if (fields.length !== FIELD_COUNT)
80
+ throw new CronExpressionError("cron expression must contain exactly five fields");
81
+ return {
82
+ expression: fields.join(" "),
83
+ minutes: parseField(fields[0] ?? "", 0, 59, "minute"),
84
+ hours: parseField(fields[1] ?? "", 0, 23, "hour"),
85
+ daysOfMonth: parseField(fields[2] ?? "", 1, 31, "day-of-month"),
86
+ months: parseField(fields[3] ?? "", 1, 12, "month"),
87
+ daysOfWeek: parseField(fields[4] ?? "", 0, 7, "day-of-week", (day) => day === 7 ? 0 : day)
88
+ };
89
+ }
90
+ function formatter(timeZone) {
91
+ return new Intl.DateTimeFormat("en-CA-u-ca-iso8601-nu-latn", {
92
+ timeZone,
93
+ year: "numeric",
94
+ month: "2-digit",
95
+ day: "2-digit",
96
+ hour: "2-digit",
97
+ minute: "2-digit",
98
+ second: "2-digit",
99
+ hourCycle: "h23",
100
+ timeZoneName: "longOffset"
101
+ });
102
+ }
103
+ function projectedParts(format, epochMs) {
104
+ const values = Object.fromEntries(format.formatToParts(epochMs).map((part) => [part.type, part.value]));
105
+ const match = /^GMT(?:(?<sign>[+-])(?<hour>\d{2}):(?<minute>\d{2}))?$/.exec(values.timeZoneName ?? "");
106
+ if (!match?.groups)
107
+ throw new CronExpressionError("time zone did not expose a usable UTC offset");
108
+ const direction = match.groups.sign === "-" ? -1 : 1;
109
+ const offset = match.groups.sign === void 0 ? 0 : direction * (Number(match.groups.hour) * 60 + Number(match.groups.minute)) * 6e4;
110
+ return {
111
+ year: Number(values.year),
112
+ month: Number(values.month),
113
+ day: Number(values.day),
114
+ hour: Number(values.hour),
115
+ minute: Number(values.minute),
116
+ second: Number(values.second),
117
+ offset
118
+ };
119
+ }
120
+ function localToEpoch(parts, format) {
121
+ const localEpoch = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, 0);
122
+ const offsets = /* @__PURE__ */ new Set();
123
+ for (const delta of [-864e5, 0, 864e5])
124
+ offsets.add(projectedParts(format, localEpoch + delta).offset);
125
+ const candidates = [];
126
+ for (const offset of offsets) {
127
+ const candidate = localEpoch - offset;
128
+ const projected = projectedParts(format, candidate);
129
+ if (projected.year === parts.year && projected.month === parts.month && projected.day === parts.day && projected.hour === parts.hour && projected.minute === parts.minute) {
130
+ candidates.push(candidate);
131
+ }
132
+ }
133
+ return [...new Set(candidates)].sort((left, right) => left - right);
134
+ }
135
+ function dateMatches(parsed, year, month, day) {
136
+ if (!parsed.months.values.has(month))
137
+ return false;
138
+ const dayOfWeek = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
139
+ const dom = parsed.daysOfMonth.values.has(day);
140
+ const dow = parsed.daysOfWeek.values.has(dayOfWeek);
141
+ if (parsed.daysOfMonth.wildcard && parsed.daysOfWeek.wildcard)
142
+ return true;
143
+ if (parsed.daysOfMonth.wildcard)
144
+ return dow;
145
+ if (parsed.daysOfWeek.wildcard)
146
+ return dom;
147
+ return dom || dow;
148
+ }
149
+ function canMatchConsecutiveDays(parsed) {
150
+ let date = Date.UTC(2e3, 0, 1);
151
+ let previousMatched = false;
152
+ for (let offset = 0; offset < GREGORIAN_CYCLE_DAYS; offset += 1) {
153
+ const calendar = new Date(date);
154
+ const matched = dateMatches(parsed, calendar.getUTCFullYear(), calendar.getUTCMonth() + 1, calendar.getUTCDate());
155
+ if (matched && previousMatched)
156
+ return true;
157
+ previousMatched = matched;
158
+ date += 864e5;
159
+ }
160
+ return false;
161
+ }
162
+ function assertMinimumCronInterval(cron, minimumSeconds = MIN_CRON_INTERVAL_SECONDS) {
163
+ const parsed = typeof cron === "string" ? parseCronExpression(cron) : cron;
164
+ const slots = [...parsed.hours.values].flatMap((hour) => [...parsed.minutes.values].map((minute) => hour * 60 + minute)).sort((left, right) => left - right);
165
+ const minimumMinutes = Math.ceil(minimumSeconds / 60);
166
+ for (let index = 1; index < slots.length; index += 1) {
167
+ if ((slots[index] ?? 0) - (slots[index - 1] ?? 0) < minimumMinutes) {
168
+ throw new CronExpressionError(`cron interval must be at least ${minimumSeconds} seconds (${minimumMinutes} minutes)`);
169
+ }
170
+ }
171
+ const first = slots[0];
172
+ const last = slots.at(-1);
173
+ if (first !== void 0 && last !== void 0 && first + 1440 - last < minimumMinutes && canMatchConsecutiveDays(parsed)) {
174
+ throw new CronExpressionError(`cron interval must be at least ${minimumSeconds} seconds (${minimumMinutes} minutes)`);
175
+ }
176
+ return parsed;
177
+ }
178
+ function nextCronOccurrence(afterEpochMs, cron, timeZone) {
179
+ if (!validTimeZone(timeZone))
180
+ throw new CronExpressionError("timezone must be a valid IANA time zone");
181
+ const parsed = typeof cron === "string" ? parseCronExpression(cron) : cron;
182
+ const format = formatter(timeZone);
183
+ const start = projectedParts(format, afterEpochMs);
184
+ const hours = [...parsed.hours.values].sort((left, right) => left - right);
185
+ const minutes = [...parsed.minutes.values].sort((left, right) => left - right);
186
+ let date = Date.UTC(start.year, start.month - 1, start.day);
187
+ for (let offset = 0; offset < MAX_SEARCH_DAYS; offset += 1) {
188
+ const calendar = new Date(date);
189
+ const year = calendar.getUTCFullYear();
190
+ const month = calendar.getUTCMonth() + 1;
191
+ const day = calendar.getUTCDate();
192
+ if (dateMatches(parsed, year, month, day)) {
193
+ let earliest;
194
+ for (const hour of hours) {
195
+ for (const minute of minutes) {
196
+ const candidates = localToEpoch({ year, month, day, hour, minute }, format);
197
+ for (const candidate of candidates) {
198
+ if (candidate > afterEpochMs && (earliest === void 0 || candidate < earliest))
199
+ earliest = candidate;
200
+ }
201
+ }
202
+ }
203
+ if (earliest !== void 0)
204
+ return earliest;
205
+ }
206
+ date = Date.UTC(year, month - 1, day + 1);
207
+ }
208
+ throw new CronExpressionError("cron expression has no occurrence within the next eight years");
209
+ }
210
+
211
+ // plugins/scheduled-task/dist-npm/src/manager.js
212
+ import { createHash } from "node:crypto";
213
+
214
+ // plugins/scheduled-task/dist-npm/src/protocol.js
215
+ var CRON_CREATE_TYPE = "dsh/cron.create";
216
+ var CRON_LIST_TYPE = "dsh/cron.list";
217
+ var CronAutomationInputError = class extends Error {
218
+ code;
219
+ constructor(code, message) {
220
+ super(message);
221
+ this.code = code;
222
+ this.name = "CronAutomationInputError";
223
+ }
224
+ };
225
+ function isRecord(value) {
226
+ return typeof value === "object" && value !== null && !Array.isArray(value);
227
+ }
228
+ function exactKeys(value, keys) {
229
+ const actual = Object.keys(value).sort();
230
+ const expected = [...keys].sort();
231
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
232
+ }
233
+ function decodeCronCreateEnvelope(value) {
234
+ if (!isRecord(value) || value.type !== CRON_CREATE_TYPE) {
235
+ throw new CronAutomationInputError("invalid_envelope", `type must be ${CRON_CREATE_TYPE}`);
236
+ }
237
+ if (!exactKeys(value, ["type", "requestId", "name", "prompt", "cron", "timezone"])) {
238
+ throw new CronAutomationInputError("invalid_envelope", "cron envelope contains unknown or missing fields");
239
+ }
240
+ if (typeof value.requestId !== "string" || value.requestId.trim() !== value.requestId || value.requestId.length < 1 || value.requestId.length > 128) {
241
+ throw new CronAutomationInputError("invalid_request_id", "requestId must contain 1-128 characters without surrounding whitespace");
242
+ }
243
+ if (typeof value.name !== "string" || value.name.trim() !== value.name || value.name.length < 1 || value.name.length > 120) {
244
+ throw new CronAutomationInputError("invalid_name", "name must contain 1-120 characters without surrounding whitespace");
245
+ }
246
+ if (typeof value.prompt !== "string" || value.prompt.trim() !== value.prompt || value.prompt.length === 0) {
247
+ throw new CronAutomationInputError("invalid_prompt", "prompt must be a non-empty string without surrounding whitespace");
248
+ }
249
+ if (typeof value.cron !== "string") {
250
+ throw new CronAutomationInputError("invalid_cron", "cron must be a standard five-field expression");
251
+ }
252
+ try {
253
+ assertMinimumCronInterval(value.cron);
254
+ } catch (error) {
255
+ if (error instanceof CronExpressionError) {
256
+ throw new CronAutomationInputError("invalid_cron", error.message);
257
+ }
258
+ throw error;
259
+ }
260
+ if (typeof value.timezone !== "string" || !validTimeZone(value.timezone)) {
261
+ throw new CronAutomationInputError("invalid_timezone", "timezone must be a valid IANA time zone");
262
+ }
263
+ return {
264
+ type: CRON_CREATE_TYPE,
265
+ requestId: value.requestId,
266
+ name: value.name,
267
+ prompt: value.prompt,
268
+ cron: value.cron,
269
+ timezone: value.timezone
270
+ };
271
+ }
272
+ function parseCronAutomationMessage(message) {
273
+ if (message.source.kind !== "user" || message.content.length !== 1)
274
+ return void 0;
275
+ const block = message.content[0];
276
+ if (block?.type !== "text")
277
+ return void 0;
278
+ const text = block.text.trim();
279
+ if (!text.startsWith("{"))
280
+ return void 0;
281
+ let value;
282
+ try {
283
+ value = JSON.parse(text);
284
+ } catch {
285
+ return void 0;
286
+ }
287
+ if (!isRecord(value) || value.type !== CRON_CREATE_TYPE)
288
+ return void 0;
289
+ return decodeCronCreateEnvelope(value);
290
+ }
291
+ function parseCronEditorRequest(message) {
292
+ if (message.source.kind !== "user" || message.content.length !== 1)
293
+ return void 0;
294
+ const block = message.content[0];
295
+ if (block?.type !== "text")
296
+ return void 0;
297
+ const text = block.text.trim();
298
+ const match = /^编辑定时任务[((]\s*任务\s*ID\s+([^))]+?)\s*[))][::]/u.exec(text);
299
+ const id = match?.[1]?.trim();
300
+ if (!id || id.length > 128 || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(id))
301
+ return void 0;
302
+ return { id, text };
303
+ }
304
+
305
+ // plugins/scheduled-task/dist-npm/src/repository.js
306
+ import fs from "node:fs";
307
+ import path from "node:path";
308
+ import { DatabaseSync } from "node:sqlite";
309
+ var STORE_KEY = "dsh-cron-automation";
310
+ function isRecord2(value) {
311
+ return typeof value === "object" && value !== null && !Array.isArray(value);
312
+ }
313
+ function finiteTimestamp(value) {
314
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
315
+ }
316
+ function decodeSchedule(value) {
317
+ if (!isRecord2(value) || typeof value.kind !== "string")
318
+ throw new Error("cron job has an invalid schedule");
319
+ if (value.kind === "cron") {
320
+ const expr = typeof value.expr === "string" ? value.expr : value.cron;
321
+ if (typeof expr !== "string")
322
+ throw new Error("cron job has an invalid cron expression");
323
+ parseCronExpression(expr);
324
+ if (value.tz !== void 0 && (typeof value.tz !== "string" || !validTimeZone(value.tz))) {
325
+ throw new Error("cron job has an invalid time zone");
326
+ }
327
+ const { cron: _legacyCron, ...rest } = value;
328
+ return { ...rest, kind: "cron", expr };
329
+ }
330
+ if (value.kind === "every") {
331
+ if (!finiteTimestamp(value.everyMs) || value.everyMs < 6e4) {
332
+ throw new Error("cron job has an invalid interval");
333
+ }
334
+ if (value.anchorMs !== void 0 && !finiteTimestamp(value.anchorMs)) {
335
+ throw new Error("cron job has an invalid interval anchor");
336
+ }
337
+ return value;
338
+ }
339
+ if (value.kind === "at") {
340
+ const atMs = finiteTimestamp(value.atMs) ? value.atMs : typeof value.at === "string" ? Date.parse(value.at) : Number.NaN;
341
+ if (!Number.isFinite(atMs))
342
+ throw new Error("cron job has an invalid one-shot time");
343
+ return value;
344
+ }
345
+ throw new Error("cron job uses an unsupported schedule kind");
346
+ }
347
+ function decodeJob(value) {
348
+ if (!isRecord2(value))
349
+ throw new Error("cron store contains a non-object job");
350
+ if (typeof value.id !== "string" || !value.id || typeof value.name !== "string" || !value.name) {
351
+ throw new Error("cron job has an invalid id or name");
352
+ }
353
+ if (!isRecord2(value.payload) || value.payload.kind !== "agentTurn" || typeof value.payload.message !== "string" || !value.payload.message) {
354
+ throw new Error("cron job has an invalid agentTurn payload");
355
+ }
356
+ const agentId = typeof value.agentId === "string" && value.agentId ? value.agentId : typeof value.sessionKey === "string" ? value.sessionKey.split(":").filter(Boolean).at(-2) ?? "main" : "main";
357
+ if (value.dshSessionId !== void 0 && (typeof value.dshSessionId !== "string" || !value.dshSessionId)) {
358
+ throw new Error("cron job has an invalid DSH session binding");
359
+ }
360
+ if (!finiteTimestamp(value.createdAtMs) || !finiteTimestamp(value.updatedAtMs)) {
361
+ throw new Error("cron job has invalid timestamps");
362
+ }
363
+ if (typeof value.enabled !== "boolean")
364
+ throw new Error("cron job has an invalid enabled flag");
365
+ if (value.sessionTarget !== void 0 && value.sessionTarget !== "isolated") {
366
+ throw new Error("agentTurn cron jobs must use an isolated session");
367
+ }
368
+ if (value.delivery !== void 0 && (!isRecord2(value.delivery) || !["announce", "none", "webhook"].includes(String(value.delivery.mode)))) {
369
+ throw new Error("cron job has invalid delivery settings");
370
+ }
371
+ const state = isRecord2(value.state) ? value.state : {};
372
+ for (const key of ["nextRunAtMs", "runningAtMs", "lastRunAtMs"]) {
373
+ if (state[key] !== void 0 && !finiteTimestamp(state[key]))
374
+ throw new Error(`cron job has invalid ${key}`);
375
+ }
376
+ return {
377
+ ...value,
378
+ id: value.id,
379
+ name: value.name,
380
+ enabled: value.enabled,
381
+ createdAtMs: value.createdAtMs,
382
+ updatedAtMs: value.updatedAtMs,
383
+ schedule: decodeSchedule(value.schedule),
384
+ payload: value.payload,
385
+ agentId,
386
+ sessionTarget: "isolated",
387
+ wakeMode: "now",
388
+ delivery: isRecord2(value.delivery) ? value.delivery : { mode: "none" },
389
+ state
390
+ };
391
+ }
392
+ function booleanInteger(value) {
393
+ return value === void 0 ? null : value ? 1 : 0;
394
+ }
395
+ function atValue(schedule) {
396
+ if (schedule.kind !== "at")
397
+ return null;
398
+ if (schedule.at)
399
+ return schedule.at;
400
+ return schedule.atMs === void 0 ? null : new Date(schedule.atMs).toISOString();
401
+ }
402
+ function uniqueJobs(jobs) {
403
+ const unique = /* @__PURE__ */ new Map();
404
+ for (const job of jobs) {
405
+ const decoded = decodeJob(job);
406
+ if (unique.has(decoded.id))
407
+ throw new Error(`cron store contains duplicate job id: ${decoded.id}`);
408
+ unique.set(decoded.id, decoded);
409
+ }
410
+ return [...unique.values()];
411
+ }
412
+ var CronAutomationRepository = class {
413
+ file;
414
+ database;
415
+ insertJobStatement;
416
+ readOnly;
417
+ closed = false;
418
+ constructor(file, options = {}) {
419
+ this.file = file;
420
+ this.readOnly = options.readOnly === true;
421
+ if (!this.readOnly)
422
+ fs.mkdirSync(path.dirname(file), { recursive: true });
423
+ this.database = new DatabaseSync(file, { readOnly: this.readOnly, timeout: 5e3 });
424
+ if (this.readOnly)
425
+ return;
426
+ this.database.exec("PRAGMA journal_mode = WAL");
427
+ this.database.exec("PRAGMA synchronous = FULL");
428
+ this.database.exec("PRAGMA busy_timeout = 5000");
429
+ this.database.exec(`
430
+ CREATE TABLE IF NOT EXISTS cron_jobs (
431
+ store_key TEXT NOT NULL,
432
+ job_id TEXT NOT NULL,
433
+ name TEXT NOT NULL,
434
+ description TEXT,
435
+ enabled INTEGER NOT NULL,
436
+ delete_after_run INTEGER,
437
+ created_at_ms INTEGER NOT NULL,
438
+ agent_id TEXT,
439
+ session_key TEXT,
440
+ schedule_kind TEXT NOT NULL,
441
+ schedule_expr TEXT,
442
+ schedule_tz TEXT,
443
+ every_ms INTEGER,
444
+ anchor_ms INTEGER,
445
+ at TEXT,
446
+ stagger_ms INTEGER,
447
+ session_target TEXT NOT NULL,
448
+ wake_mode TEXT NOT NULL,
449
+ payload_kind TEXT NOT NULL,
450
+ payload_message TEXT,
451
+ payload_model TEXT,
452
+ payload_fallbacks_json TEXT,
453
+ payload_thinking TEXT,
454
+ payload_timeout_seconds INTEGER,
455
+ payload_allow_unsafe_external_content INTEGER,
456
+ payload_external_content_source_json TEXT,
457
+ payload_light_context INTEGER,
458
+ payload_tools_allow_json TEXT,
459
+ delivery_mode TEXT,
460
+ delivery_channel TEXT,
461
+ delivery_to TEXT,
462
+ delivery_thread_id TEXT,
463
+ delivery_account_id TEXT,
464
+ delivery_best_effort INTEGER,
465
+ delivery_completion_mode TEXT,
466
+ delivery_completion_to TEXT,
467
+ failure_delivery_mode TEXT,
468
+ failure_delivery_channel TEXT,
469
+ failure_delivery_to TEXT,
470
+ failure_delivery_account_id TEXT,
471
+ failure_alert_disabled INTEGER,
472
+ failure_alert_after INTEGER,
473
+ failure_alert_channel TEXT,
474
+ failure_alert_to TEXT,
475
+ failure_alert_cooldown_ms INTEGER,
476
+ failure_alert_include_skipped INTEGER,
477
+ failure_alert_mode TEXT,
478
+ failure_alert_account_id TEXT,
479
+ next_run_at_ms INTEGER,
480
+ running_at_ms INTEGER,
481
+ last_run_at_ms INTEGER,
482
+ last_run_status TEXT,
483
+ last_error TEXT,
484
+ last_duration_ms INTEGER,
485
+ consecutive_errors INTEGER,
486
+ consecutive_skipped INTEGER,
487
+ schedule_error_count INTEGER,
488
+ last_delivery_status TEXT,
489
+ last_delivery_error TEXT,
490
+ last_delivered INTEGER,
491
+ last_failure_alert_at_ms INTEGER,
492
+ job_json TEXT NOT NULL,
493
+ state_json TEXT NOT NULL DEFAULT '{}',
494
+ runtime_updated_at_ms INTEGER,
495
+ schedule_identity TEXT,
496
+ sort_order INTEGER NOT NULL DEFAULT 0,
497
+ updated_at INTEGER NOT NULL,
498
+ PRIMARY KEY (store_key, job_id)
499
+ );
500
+
501
+ CREATE INDEX IF NOT EXISTS idx_cron_jobs_store_updated
502
+ ON cron_jobs(store_key, sort_order ASC, updated_at DESC, job_id);
503
+ CREATE INDEX IF NOT EXISTS idx_cron_jobs_store_order
504
+ ON cron_jobs(store_key, sort_order ASC, updated_at ASC, job_id);
505
+ CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled_next_run
506
+ ON cron_jobs(store_key, enabled, next_run_at_ms, job_id)
507
+ WHERE next_run_at_ms IS NOT NULL;
508
+ CREATE INDEX IF NOT EXISTS idx_cron_jobs_agent_session
509
+ ON cron_jobs(agent_id, session_key, updated_at DESC, job_id)
510
+ WHERE agent_id IS NOT NULL OR session_key IS NOT NULL;
511
+
512
+ CREATE TABLE IF NOT EXISTS cron_imported_jobs (
513
+ source TEXT NOT NULL,
514
+ job_id TEXT NOT NULL,
515
+ imported_at INTEGER NOT NULL,
516
+ PRIMARY KEY (source, job_id)
517
+ );
518
+ `);
519
+ this.insertJobStatement = this.database.prepare(`
520
+ INSERT INTO cron_jobs (
521
+ store_key, job_id, name, description, enabled, delete_after_run,
522
+ created_at_ms, agent_id, session_key,
523
+ schedule_kind, schedule_expr, schedule_tz, every_ms, anchor_ms, at,
524
+ session_target, wake_mode,
525
+ payload_kind, payload_message, payload_timeout_seconds,
526
+ delivery_mode, delivery_channel, delivery_to, delivery_account_id, delivery_best_effort,
527
+ next_run_at_ms, running_at_ms, last_run_at_ms, last_run_status,
528
+ job_json, state_json, runtime_updated_at_ms, sort_order, updated_at
529
+ ) VALUES (
530
+ ?, ?, ?, ?, ?, ?,
531
+ ?, ?, ?,
532
+ ?, ?, ?, ?, ?, ?,
533
+ ?, ?,
534
+ ?, ?, ?,
535
+ ?, ?, ?, ?, ?,
536
+ ?, ?, ?, ?,
537
+ ?, ?, ?, ?, ?
538
+ )
539
+ ON CONFLICT(store_key, job_id) DO UPDATE SET
540
+ name = excluded.name,
541
+ description = excluded.description,
542
+ enabled = excluded.enabled,
543
+ delete_after_run = excluded.delete_after_run,
544
+ created_at_ms = excluded.created_at_ms,
545
+ agent_id = excluded.agent_id,
546
+ session_key = excluded.session_key,
547
+ schedule_kind = excluded.schedule_kind,
548
+ schedule_expr = excluded.schedule_expr,
549
+ schedule_tz = excluded.schedule_tz,
550
+ every_ms = excluded.every_ms,
551
+ anchor_ms = excluded.anchor_ms,
552
+ at = excluded.at,
553
+ session_target = excluded.session_target,
554
+ wake_mode = excluded.wake_mode,
555
+ payload_kind = excluded.payload_kind,
556
+ payload_message = excluded.payload_message,
557
+ payload_timeout_seconds = excluded.payload_timeout_seconds,
558
+ delivery_mode = excluded.delivery_mode,
559
+ delivery_channel = excluded.delivery_channel,
560
+ delivery_to = excluded.delivery_to,
561
+ delivery_account_id = excluded.delivery_account_id,
562
+ delivery_best_effort = excluded.delivery_best_effort,
563
+ next_run_at_ms = excluded.next_run_at_ms,
564
+ running_at_ms = excluded.running_at_ms,
565
+ last_run_at_ms = excluded.last_run_at_ms,
566
+ last_run_status = excluded.last_run_status,
567
+ job_json = excluded.job_json,
568
+ state_json = excluded.state_json,
569
+ runtime_updated_at_ms = excluded.runtime_updated_at_ms,
570
+ updated_at = excluded.updated_at
571
+ `);
572
+ try {
573
+ fs.chmodSync(file, 384);
574
+ } catch {
575
+ }
576
+ }
577
+ async load() {
578
+ this.assertOpen();
579
+ const rows = this.database.prepare(`
580
+ SELECT job_json
581
+ FROM cron_jobs
582
+ WHERE store_key = ?
583
+ ORDER BY sort_order ASC, updated_at ASC, job_id ASC
584
+ `).all(STORE_KEY);
585
+ return rows.map((row) => decodeJob(JSON.parse(row.job_json)));
586
+ }
587
+ async upsert(jobs) {
588
+ this.assertWritable();
589
+ const decoded = uniqueJobs(jobs);
590
+ if (decoded.length === 0)
591
+ return;
592
+ this.writeTransaction(() => {
593
+ const nextOrder = this.nextSortOrder();
594
+ decoded.forEach((job, index) => this.insertJob(job, nextOrder + index));
595
+ });
596
+ }
597
+ async delete(jobId) {
598
+ this.assertWritable();
599
+ const result = this.database.prepare("DELETE FROM cron_jobs WHERE store_key = ? AND job_id = ?").run(STORE_KEY, jobId);
600
+ return Number(result.changes) > 0;
601
+ }
602
+ async previewImport(source, incoming) {
603
+ this.assertOpen();
604
+ return this.planImport(source, uniqueJobs(incoming));
605
+ }
606
+ async import(source, incoming) {
607
+ this.assertWritable();
608
+ if (!source.trim())
609
+ throw new Error("cron import source must be non-empty");
610
+ const decoded = uniqueJobs(incoming);
611
+ return this.writeTransaction(() => {
612
+ const result = this.planImport(source, decoded);
613
+ const nextOrder = this.nextSortOrder();
614
+ result.added.forEach((job, index) => this.insertJob(job, nextOrder + index));
615
+ const mark = this.database.prepare(`
616
+ INSERT OR IGNORE INTO cron_imported_jobs (source, job_id, imported_at)
617
+ VALUES (?, ?, ?)
618
+ `);
619
+ const importedAt = Date.now();
620
+ for (const job of decoded)
621
+ mark.run(source, job.id, importedAt);
622
+ return result;
623
+ });
624
+ }
625
+ close() {
626
+ if (this.closed)
627
+ return;
628
+ this.closed = true;
629
+ this.database.close();
630
+ }
631
+ planImport(source, incoming) {
632
+ const existing = new Set(this.database.prepare("SELECT job_id FROM cron_jobs WHERE store_key = ?").all(STORE_KEY).map((row) => row.job_id));
633
+ const imported = new Set(this.database.prepare("SELECT job_id FROM cron_imported_jobs WHERE source = ?").all(source).map((row) => row.job_id));
634
+ const added = [];
635
+ const skippedIds = [];
636
+ for (const job of incoming) {
637
+ if (existing.has(job.id) || imported.has(job.id)) {
638
+ skippedIds.push(job.id);
639
+ continue;
640
+ }
641
+ existing.add(job.id);
642
+ added.push(job);
643
+ }
644
+ return { added, skippedIds };
645
+ }
646
+ nextSortOrder() {
647
+ const row = this.database.prepare(`
648
+ SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order
649
+ FROM cron_jobs
650
+ WHERE store_key = ?
651
+ `).get(STORE_KEY);
652
+ return row.next_order;
653
+ }
654
+ insertJob(job, sortOrder) {
655
+ const statement = this.insertJobStatement;
656
+ if (!statement)
657
+ throw new Error("cron sqlite repository is read-only");
658
+ const schedule = job.schedule;
659
+ const state = job.state;
660
+ statement.run(STORE_KEY, job.id, job.name, job.description ?? null, booleanInteger(job.enabled), booleanInteger(job.deleteAfterRun), job.createdAtMs, job.agentId, job.sessionKey ?? null, schedule.kind, schedule.kind === "cron" ? schedule.expr : null, schedule.kind === "cron" ? schedule.tz ?? null : null, schedule.kind === "every" ? schedule.everyMs : null, schedule.kind === "every" ? schedule.anchorMs ?? null : null, atValue(schedule), job.sessionTarget, job.wakeMode, job.payload.kind, job.payload.message, typeof job.payload.timeoutSeconds === "number" ? job.payload.timeoutSeconds : null, job.delivery.mode, job.delivery.channel ?? null, job.delivery.to ?? null, job.delivery.accountId ?? null, booleanInteger(job.delivery.bestEffort), state.nextRunAtMs ?? null, state.runningAtMs ?? null, state.lastRunAtMs ?? null, state.lastRunStatus ?? null, JSON.stringify(job), JSON.stringify(state), job.updatedAtMs, sortOrder, job.updatedAtMs);
661
+ }
662
+ assertOpen() {
663
+ if (this.closed)
664
+ throw new Error("cron sqlite repository is closed");
665
+ }
666
+ assertWritable() {
667
+ this.assertOpen();
668
+ if (this.readOnly)
669
+ throw new Error("cron sqlite repository is read-only");
670
+ }
671
+ writeTransaction(operation) {
672
+ this.database.exec("BEGIN IMMEDIATE");
673
+ try {
674
+ const result = operation();
675
+ this.database.exec("COMMIT");
676
+ return result;
677
+ } catch (error) {
678
+ this.database.exec("ROLLBACK");
679
+ throw error;
680
+ }
681
+ }
682
+ };
683
+
684
+ // plugins/scheduled-task/dist-npm/src/manager.js
685
+ var MAX_TIMER_DELAY_MS = 2147483647;
686
+ function automationId(agentId, requestId) {
687
+ return `cron-${createHash("sha256").update(`${agentId}\0${requestId}`).digest("hex").slice(0, 24)}`;
688
+ }
689
+ function cloneJob(job) {
690
+ return structuredClone(job);
691
+ }
692
+ function dshAgentIdFor(job) {
693
+ return job.dshSessionId ?? job.agentId;
694
+ }
695
+ function timezoneFor(schedule) {
696
+ return schedule.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
697
+ }
698
+ function atTimestamp(schedule) {
699
+ if (typeof schedule.atMs === "number")
700
+ return schedule.atMs;
701
+ return typeof schedule.at === "string" ? Date.parse(schedule.at) : Number.NaN;
702
+ }
703
+ function assertMinimumScheduleInterval(schedule) {
704
+ if (schedule.kind === "at")
705
+ return;
706
+ if (schedule.kind === "every") {
707
+ if (schedule.everyMs < MIN_CRON_INTERVAL_SECONDS * 1e3) {
708
+ throw new CronAutomationInputError("invalid_cron", `schedule interval must be at least ${MIN_CRON_INTERVAL_SECONDS} seconds (${MIN_CRON_INTERVAL_SECONDS / 60} minutes)`);
709
+ }
710
+ return;
711
+ }
712
+ try {
713
+ assertMinimumCronInterval(schedule.expr);
714
+ } catch (error) {
715
+ throw new CronAutomationInputError("invalid_cron", error instanceof Error ? error.message : "invalid cron expression");
716
+ }
717
+ }
718
+ function disableIfTooFrequent(job) {
719
+ if (!job.enabled)
720
+ return false;
721
+ try {
722
+ assertMinimumScheduleInterval(job.schedule);
723
+ return false;
724
+ } catch (error) {
725
+ if (!(error instanceof CronAutomationInputError))
726
+ throw error;
727
+ job.enabled = false;
728
+ delete job.state.runningAtMs;
729
+ delete job.state.nextRunAtMs;
730
+ return true;
731
+ }
732
+ }
733
+ function nextOccurrence(afterEpochMs, schedule) {
734
+ if (schedule.kind === "at") {
735
+ const at = atTimestamp(schedule);
736
+ return Number.isFinite(at) && at > afterEpochMs ? at : void 0;
737
+ }
738
+ if (schedule.kind === "every") {
739
+ const anchor = schedule.anchorMs ?? afterEpochMs;
740
+ if (anchor > afterEpochMs)
741
+ return anchor;
742
+ return anchor + (Math.floor((afterEpochMs - anchor) / schedule.everyMs) + 1) * schedule.everyMs;
743
+ }
744
+ try {
745
+ return nextCronOccurrence(afterEpochMs, schedule.expr, timezoneFor(schedule));
746
+ } catch (error) {
747
+ if (error instanceof CronExpressionError)
748
+ throw new CronAutomationInputError("invalid_cron", error.message);
749
+ throw error;
750
+ }
751
+ }
752
+ function initialOccurrence(job, now) {
753
+ return job.schedule.kind === "at" ? atTimestamp(job.schedule) : nextOccurrence(now, job.schedule);
754
+ }
755
+ function setNextOccurrence(job, value) {
756
+ if (value === void 0)
757
+ delete job.state.nextRunAtMs;
758
+ else
759
+ job.state.nextRunAtMs = value;
760
+ }
761
+ function sameRequest(job, envelope) {
762
+ return job.name === envelope.name && job.payload.message === envelope.prompt && job.schedule.kind === "cron" && job.schedule.expr === envelope.cron && timezoneFor(job.schedule) === envelope.timezone;
763
+ }
764
+ var CronAutomationManager = class {
765
+ repository;
766
+ options;
767
+ jobs = /* @__PURE__ */ new Map();
768
+ blockedAgents = /* @__PURE__ */ new Set();
769
+ now;
770
+ started;
771
+ tail = Promise.resolve();
772
+ timer;
773
+ stopping = false;
774
+ constructor(repository, options) {
775
+ this.repository = repository;
776
+ this.options = options;
777
+ this.now = options.now ?? Date.now;
778
+ }
779
+ start() {
780
+ return this.started ??= this.initialize();
781
+ }
782
+ async initialize() {
783
+ const loaded = await this.repository.load();
784
+ const changed = [];
785
+ const now = this.now();
786
+ for (const job of loaded) {
787
+ let jobChanged = disableIfTooFrequent(job);
788
+ if (job.state.runningAtMs !== void 0) {
789
+ delete job.state.runningAtMs;
790
+ jobChanged = true;
791
+ }
792
+ if (job.enabled && job.state.nextRunAtMs === void 0) {
793
+ setNextOccurrence(job, initialOccurrence(job, now));
794
+ jobChanged = true;
795
+ }
796
+ if (jobChanged)
797
+ changed.push(job);
798
+ this.jobs.set(job.id, job);
799
+ }
800
+ await this.repository.upsert(changed);
801
+ this.arm();
802
+ }
803
+ async stop() {
804
+ this.stopping = true;
805
+ if (this.timer)
806
+ clearTimeout(this.timer);
807
+ this.timer = void 0;
808
+ try {
809
+ await this.tail;
810
+ } finally {
811
+ this.repository.close();
812
+ }
813
+ }
814
+ async create(agentId, envelope) {
815
+ await this.start();
816
+ return this.enqueue(async () => {
817
+ if (this.stopping)
818
+ throw new Error("cron automation manager is stopping");
819
+ const id = automationId(agentId, envelope.requestId);
820
+ const existing = this.jobs.get(id);
821
+ if (existing) {
822
+ if (!sameRequest(existing, envelope)) {
823
+ throw new CronAutomationInputError("request_id_conflict", "requestId was already used for a different cron automation");
824
+ }
825
+ return cloneJob(existing);
826
+ }
827
+ if (envelope.prompt.length > this.options.maxPromptChars) {
828
+ throw new CronAutomationInputError("invalid_prompt", `prompt must not exceed ${this.options.maxPromptChars} characters`);
829
+ }
830
+ const now = this.now();
831
+ const schedule = { kind: "cron", expr: envelope.cron, tz: envelope.timezone };
832
+ assertMinimumScheduleInterval(schedule);
833
+ const nextRunAtMs = nextOccurrence(now, schedule);
834
+ const job = {
835
+ id,
836
+ name: envelope.name,
837
+ description: envelope.prompt,
838
+ enabled: true,
839
+ createdAtMs: now,
840
+ updatedAtMs: now,
841
+ schedule,
842
+ payload: { kind: "agentTurn", message: envelope.prompt, timeoutSeconds: 600 },
843
+ agentId,
844
+ dshSessionId: agentId,
845
+ sessionTarget: "isolated",
846
+ wakeMode: "now",
847
+ delivery: { mode: "announce", bestEffort: true },
848
+ state: { ...nextRunAtMs === void 0 ? {} : { nextRunAtMs } }
849
+ };
850
+ this.jobs.set(job.id, job);
851
+ try {
852
+ await this.repository.upsert([job]);
853
+ } catch (error) {
854
+ this.jobs.delete(job.id);
855
+ throw error;
856
+ }
857
+ this.options.onCreated?.(cloneJob(job));
858
+ this.arm();
859
+ return cloneJob(job);
860
+ });
861
+ }
862
+ async update(jobId, patch) {
863
+ await this.start();
864
+ return this.enqueue(async () => {
865
+ const current = this.jobs.get(jobId);
866
+ if (!current)
867
+ return void 0;
868
+ const patchKeys = Object.keys(patch);
869
+ if (patchKeys.length === 0)
870
+ throw new CronAutomationInputError("invalid_envelope", "at least one editable field is required");
871
+ if (patchKeys.length === 1 && patch.enabled === current.enabled)
872
+ return cloneJob(current);
873
+ const next = cloneJob(current);
874
+ if (patch.name !== void 0) {
875
+ if (patch.name.trim() !== patch.name || patch.name.length < 1 || patch.name.length > 120) {
876
+ throw new CronAutomationInputError("invalid_name", "name must contain 1-120 characters without surrounding whitespace");
877
+ }
878
+ next.name = patch.name;
879
+ }
880
+ if (patch.prompt !== void 0) {
881
+ if (patch.prompt.trim() !== patch.prompt || patch.prompt.length < 1 || patch.prompt.length > this.options.maxPromptChars) {
882
+ throw new CronAutomationInputError("invalid_prompt", `prompt must contain 1-${this.options.maxPromptChars} characters without surrounding whitespace`);
883
+ }
884
+ next.payload.message = patch.prompt;
885
+ next.description = patch.prompt;
886
+ }
887
+ if (patch.cron !== void 0) {
888
+ try {
889
+ assertMinimumCronInterval(patch.cron);
890
+ } catch (error) {
891
+ throw new CronAutomationInputError("invalid_cron", error instanceof Error ? error.message : "invalid cron expression");
892
+ }
893
+ }
894
+ if (patch.timezone !== void 0 && !validTimeZone(patch.timezone)) {
895
+ throw new CronAutomationInputError("invalid_timezone", "timezone must be a valid IANA time zone");
896
+ }
897
+ if (patch.sessionKey !== void 0) {
898
+ if (!patch.sessionKey.trim() || patch.sessionKey.trim() !== patch.sessionKey) {
899
+ throw new CronAutomationInputError("invalid_envelope", "sessionKey must be non-empty without surrounding whitespace");
900
+ }
901
+ next.sessionKey = patch.sessionKey;
902
+ next.delivery = {
903
+ ...next.delivery,
904
+ mode: "announce",
905
+ to: `dcg-cron:${patch.sessionKey}`,
906
+ channel: "dcgchat",
907
+ bestEffort: true
908
+ };
909
+ }
910
+ for (const key of ["agentId", "dshSessionId"]) {
911
+ if (patch[key] !== void 0) {
912
+ if (!patch[key]?.trim() || patch[key]?.trim() !== patch[key]) {
913
+ throw new CronAutomationInputError("invalid_envelope", `${key} must be non-empty without surrounding whitespace`);
914
+ }
915
+ next[key] = patch[key];
916
+ }
917
+ }
918
+ if (patch.cron !== void 0 || patch.timezone !== void 0) {
919
+ const currentCron = next.schedule.kind === "cron" ? next.schedule.expr : void 0;
920
+ const currentTimezone = next.schedule.kind === "cron" ? timezoneFor(next.schedule) : void 0;
921
+ if (!patch.cron && !currentCron)
922
+ throw new CronAutomationInputError("invalid_cron", "changing a non-cron schedule requires a cron expression");
923
+ next.schedule = {
924
+ kind: "cron",
925
+ expr: patch.cron ?? currentCron ?? "",
926
+ tz: patch.timezone ?? currentTimezone ?? "UTC"
927
+ };
928
+ }
929
+ const now = this.now();
930
+ if (patch.enabled !== void 0) {
931
+ next.enabled = patch.enabled;
932
+ delete next.state.runningAtMs;
933
+ }
934
+ if (next.enabled)
935
+ assertMinimumScheduleInterval(next.schedule);
936
+ if (patch.enabled === true) {
937
+ setNextOccurrence(next, initialOccurrence(next, now));
938
+ } else if (patch.cron !== void 0 || patch.timezone !== void 0) {
939
+ setNextOccurrence(next, nextOccurrence(now, next.schedule));
940
+ }
941
+ next.updatedAtMs = now;
942
+ this.jobs.set(jobId, next);
943
+ try {
944
+ await this.repository.upsert([next]);
945
+ } catch (error) {
946
+ this.jobs.set(jobId, current);
947
+ throw error;
948
+ }
949
+ if (patch.enabled || dshAgentIdFor(current) !== dshAgentIdFor(next)) {
950
+ this.blockedAgents.delete(dshAgentIdFor(current));
951
+ this.blockedAgents.delete(dshAgentIdFor(next));
952
+ }
953
+ this.options.onChanged?.(cloneJob(next));
954
+ this.arm();
955
+ return cloneJob(next);
956
+ });
957
+ }
958
+ async delete(jobId) {
959
+ await this.start();
960
+ return this.enqueue(async () => {
961
+ const job = this.jobs.get(jobId);
962
+ if (!job)
963
+ return false;
964
+ this.jobs.delete(jobId);
965
+ this.blockedAgents.delete(dshAgentIdFor(job));
966
+ try {
967
+ await this.repository.delete(jobId);
968
+ } catch (error) {
969
+ this.jobs.set(jobId, job);
970
+ throw error;
971
+ }
972
+ this.options.onChanged?.(cloneJob(job));
973
+ this.arm();
974
+ return true;
975
+ });
976
+ }
977
+ async runOnceStatus(jobId) {
978
+ await this.start();
979
+ return this.enqueue(async () => {
980
+ const job = this.jobs.get(jobId);
981
+ if (!job)
982
+ return "not_found";
983
+ if (job.state.runningAtMs !== void 0)
984
+ return "already_running";
985
+ const occurrenceAtMs = this.now();
986
+ job.state.runningAtMs = occurrenceAtMs;
987
+ job.updatedAtMs = occurrenceAtMs;
988
+ await this.repository.upsert([job]);
989
+ const delivered = await this.deliver(job, occurrenceAtMs);
990
+ delete job.state.runningAtMs;
991
+ job.state.lastRunAtMs = occurrenceAtMs;
992
+ job.state.lastRunStatus = delivered ? "ok" : "skipped";
993
+ job.updatedAtMs = this.now();
994
+ await this.repository.upsert([job]);
995
+ if (delivered) {
996
+ this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
997
+ return "dispatched";
998
+ }
999
+ this.options.onChanged?.(cloneJob(job));
1000
+ return "delivery_unavailable";
1001
+ });
1002
+ }
1003
+ async runOnce(jobId) {
1004
+ return await this.runOnceStatus(jobId) === "dispatched";
1005
+ }
1006
+ async list(agentId) {
1007
+ await this.start();
1008
+ await this.tail;
1009
+ return [...this.jobs.values()].filter((job) => agentId === void 0 || dshAgentIdFor(job) === agentId).sort((left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id)).map(cloneJob);
1010
+ }
1011
+ notifyAgentAvailable(agentId) {
1012
+ if (!this.blockedAgents.delete(agentId))
1013
+ return;
1014
+ this.requestDispatch();
1015
+ }
1016
+ /** Import each source job once. Existing ids win so DSH bindings stay intact. */
1017
+ async importFrom(source, incoming) {
1018
+ await this.start();
1019
+ return this.enqueue(async () => {
1020
+ const prepared = [];
1021
+ for (const job of incoming) {
1022
+ const next = cloneJob(job);
1023
+ delete next.state.runningAtMs;
1024
+ disableIfTooFrequent(next);
1025
+ if (next.enabled && next.state.nextRunAtMs === void 0) {
1026
+ setNextOccurrence(next, initialOccurrence(next, this.now()));
1027
+ }
1028
+ prepared.push(next);
1029
+ }
1030
+ const { added } = await this.repository.import(source, prepared);
1031
+ if (added.length === 0)
1032
+ return added;
1033
+ for (const job of added) {
1034
+ this.jobs.set(job.id, cloneJob(job));
1035
+ this.options.onCreated?.(cloneJob(job));
1036
+ }
1037
+ this.arm();
1038
+ return added.map(cloneJob);
1039
+ });
1040
+ }
1041
+ async deliver(job, occurrenceAtMs) {
1042
+ try {
1043
+ return await this.options.deliver(cloneJob(job), new Date(occurrenceAtMs).toISOString());
1044
+ } catch (error) {
1045
+ this.options.onError?.(error);
1046
+ return false;
1047
+ }
1048
+ }
1049
+ enqueue(operation) {
1050
+ const run = this.tail.then(operation);
1051
+ this.tail = run.then(() => void 0, () => void 0);
1052
+ return run;
1053
+ }
1054
+ requestDispatch() {
1055
+ void this.enqueue(() => this.dispatchDue()).catch((error) => this.options.onError?.(error));
1056
+ }
1057
+ arm() {
1058
+ if (this.stopping)
1059
+ return;
1060
+ if (this.timer)
1061
+ clearTimeout(this.timer);
1062
+ this.timer = void 0;
1063
+ const now = this.now();
1064
+ const target = [...this.jobs.values()].filter((job) => job.enabled && job.state.runningAtMs === void 0 && !this.blockedAgents.has(dshAgentIdFor(job))).map((job) => job.state.nextRunAtMs).filter((value) => value !== void 0).reduce((earliest, candidate) => earliest === void 0 || candidate < earliest ? candidate : earliest, void 0);
1065
+ if (target === void 0)
1066
+ return;
1067
+ const delay = Math.max(0, Math.min(target - now, MAX_TIMER_DELAY_MS));
1068
+ this.timer = setTimeout(() => {
1069
+ this.timer = void 0;
1070
+ this.requestDispatch();
1071
+ }, delay);
1072
+ this.timer.unref();
1073
+ }
1074
+ async dispatchDue() {
1075
+ if (this.stopping)
1076
+ return;
1077
+ const now = this.now();
1078
+ const due = [...this.jobs.values()].filter((job) => job.enabled && job.state.nextRunAtMs !== void 0 && job.state.nextRunAtMs <= now).sort((left, right) => (left.state.nextRunAtMs ?? 0) - (right.state.nextRunAtMs ?? 0) || left.createdAtMs - right.createdAtMs);
1079
+ for (const job of due) {
1080
+ const occurrenceAtMs = job.state.nextRunAtMs ?? now;
1081
+ job.state.runningAtMs = now;
1082
+ job.updatedAtMs = now;
1083
+ await this.repository.upsert([job]);
1084
+ const delivered = await this.deliver(job, occurrenceAtMs);
1085
+ delete job.state.runningAtMs;
1086
+ job.state.lastRunAtMs = now;
1087
+ job.state.lastRunStatus = delivered ? "ok" : "skipped";
1088
+ job.updatedAtMs = now;
1089
+ if (!delivered) {
1090
+ this.blockedAgents.add(dshAgentIdFor(job));
1091
+ await this.repository.upsert([job]);
1092
+ this.options.onChanged?.(cloneJob(job));
1093
+ continue;
1094
+ }
1095
+ if (job.deleteAfterRun === true || job.schedule.kind === "at") {
1096
+ this.jobs.delete(job.id);
1097
+ await this.repository.delete(job.id);
1098
+ } else {
1099
+ setNextOccurrence(job, nextOccurrence(now, job.schedule));
1100
+ await this.repository.upsert([job]);
1101
+ }
1102
+ this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
1103
+ }
1104
+ this.arm();
1105
+ }
1106
+ };
1107
+
1108
+ // plugins/scheduled-task/dist-npm/src/openclaw-sqlite.js
1109
+ import { existsSync, realpathSync } from "node:fs";
1110
+ import os from "node:os";
1111
+ import path2 from "node:path";
1112
+ import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
1113
+ var DEFAULT_OPENCLAW_SQLITE = path2.join(os.homedir(), ".mobook", "state", "openclaw.sqlite");
1114
+ function isRecord3(value) {
1115
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1116
+ }
1117
+ function parseJson(raw, label) {
1118
+ try {
1119
+ return JSON.parse(raw);
1120
+ } catch {
1121
+ throw new Error(`OpenClaw ${label} is not valid JSON`);
1122
+ }
1123
+ }
1124
+ function scheduleFromRow(row) {
1125
+ if (row.schedule_kind === "every") {
1126
+ return {
1127
+ kind: "every",
1128
+ everyMs: row.every_ms,
1129
+ ...row.anchor_ms === null ? {} : { anchorMs: row.anchor_ms }
1130
+ };
1131
+ }
1132
+ if (row.schedule_kind === "at") {
1133
+ return { kind: "at", ...row.at === null ? {} : { at: row.at } };
1134
+ }
1135
+ return {
1136
+ kind: "cron",
1137
+ expr: row.schedule_expr,
1138
+ ...row.schedule_tz === null ? {} : { tz: row.schedule_tz }
1139
+ };
1140
+ }
1141
+ function jobFromColumns(row, state) {
1142
+ return {
1143
+ id: row.job_id,
1144
+ name: row.name,
1145
+ ...row.description ? { description: row.description } : {},
1146
+ enabled: row.enabled === 1,
1147
+ createdAtMs: row.created_at_ms,
1148
+ updatedAtMs: row.updated_at,
1149
+ schedule: scheduleFromRow(row),
1150
+ payload: {
1151
+ kind: row.payload_kind || "agentTurn",
1152
+ message: row.payload_message ?? "",
1153
+ ...row.payload_timeout_seconds === null ? {} : { timeoutSeconds: row.payload_timeout_seconds }
1154
+ },
1155
+ agentId: row.agent_id ?? "main",
1156
+ ...row.session_key ? { sessionKey: row.session_key } : {},
1157
+ sessionTarget: row.session_target || "isolated",
1158
+ wakeMode: row.wake_mode || "now",
1159
+ ...row.delete_after_run === 1 ? { deleteAfterRun: true } : {},
1160
+ delivery: {
1161
+ mode: row.delivery_mode ?? "announce",
1162
+ ...row.delivery_channel ? { channel: row.delivery_channel } : {},
1163
+ ...row.delivery_to ? { to: row.delivery_to } : {},
1164
+ ...row.delivery_account_id ? { accountId: row.delivery_account_id } : {},
1165
+ bestEffort: row.delivery_best_effort !== 0
1166
+ },
1167
+ state
1168
+ };
1169
+ }
1170
+ function overlayState(row, jobState, tableState) {
1171
+ const state = {
1172
+ ...isRecord3(jobState) ? jobState : {},
1173
+ ...isRecord3(tableState) ? tableState : {}
1174
+ };
1175
+ if (row.next_run_at_ms !== null)
1176
+ state.nextRunAtMs = row.next_run_at_ms;
1177
+ if (row.running_at_ms !== null)
1178
+ state.runningAtMs = row.running_at_ms;
1179
+ if (row.last_run_at_ms !== null)
1180
+ state.lastRunAtMs = row.last_run_at_ms;
1181
+ if (row.last_run_status)
1182
+ state.lastRunStatus = row.last_run_status;
1183
+ return state;
1184
+ }
1185
+ function jobFromOpenClawRow(row) {
1186
+ const tableState = parseJson(row.state_json || "{}", "state_json");
1187
+ try {
1188
+ const parsed = parseJson(row.job_json, "job_json");
1189
+ if (!isRecord3(parsed))
1190
+ throw new Error("job_json must be an object");
1191
+ return decodeJob({
1192
+ ...parsed,
1193
+ id: typeof parsed.id === "string" && parsed.id ? parsed.id : row.job_id,
1194
+ updatedAtMs: typeof parsed.updatedAtMs === "number" ? parsed.updatedAtMs : row.updated_at,
1195
+ createdAtMs: typeof parsed.createdAtMs === "number" ? parsed.createdAtMs : row.created_at_ms,
1196
+ state: overlayState(row, parsed.state, tableState)
1197
+ });
1198
+ } catch {
1199
+ return decodeJob(jobFromColumns(row, overlayState(row, {}, tableState)));
1200
+ }
1201
+ }
1202
+ function loadOpenClawSqliteJobs(sqlitePath) {
1203
+ if (!existsSync(sqlitePath))
1204
+ return [];
1205
+ const database = new DatabaseSync2(sqlitePath, { readOnly: true, timeout: 5e3 });
1206
+ try {
1207
+ const table = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_jobs'").get();
1208
+ if (!table)
1209
+ return [];
1210
+ const rows = database.prepare("SELECT * FROM cron_jobs ORDER BY updated_at ASC, job_id ASC").all();
1211
+ const jobs = /* @__PURE__ */ new Map();
1212
+ for (const row of rows) {
1213
+ try {
1214
+ const job = jobFromOpenClawRow(row);
1215
+ const previous = jobs.get(job.id);
1216
+ if (!previous || job.updatedAtMs >= previous.updatedAtMs)
1217
+ jobs.set(job.id, job);
1218
+ } catch {
1219
+ }
1220
+ }
1221
+ return [...jobs.values()];
1222
+ } finally {
1223
+ database.close();
1224
+ }
1225
+ }
1226
+ function resolveOpenClawSqlitePath(configured) {
1227
+ const explicit = configured?.trim() || process.env.OPENCLAW_SQLITE?.trim();
1228
+ if (explicit)
1229
+ return path2.resolve(explicit);
1230
+ return DEFAULT_OPENCLAW_SQLITE;
1231
+ }
1232
+ function openClawImportSource(sqlitePath) {
1233
+ const resolved = path2.resolve(sqlitePath);
1234
+ const canonical = existsSync(resolved) ? realpathSync.native(resolved) : resolved;
1235
+ return `openclaw-sqlite:${canonical}`;
1236
+ }
1237
+
1238
+ // plugins/scheduled-task/dist-npm/plugin/index.js
1239
+ var name = "@dcrays/scheduled-task";
1240
+ var inject = ["agentDefaultModel", "agents", "sessionPersistence", "webServer", "tools"];
1241
+ var API_PREFIX = "/api/cron-automations";
1242
+ var Schema = z;
1243
+ var Config = Schema.object({
1244
+ databaseFile: Schema.string().required(false),
1245
+ openclawSqlite: Schema.string().required(false),
1246
+ maxPromptChars: Schema.natural().min(1).default(16384),
1247
+ listPushIntervalSeconds: Schema.natural().min(1).default(30)
1248
+ });
1249
+ function acknowledgement(cron) {
1250
+ const schedule = cron.schedule.kind === "cron" ? `${cron.schedule.expr} (${cron.schedule.tz ?? "local"})` : JSON.stringify(cron.schedule);
1251
+ return createUserMessage({
1252
+ content: [
1253
+ {
1254
+ type: "text",
1255
+ text: [
1256
+ "[CRON AUTOMATION REGISTERED]",
1257
+ "A persistent cron automation was registered successfully. Do not execute task_prompt_json now. Do not create files, todos, subagents, or any other work from that content. Tell the user only the task name and next run time. Do not mention registration, skipping execution, or that this is a scheduled-task workflow.",
1258
+ `cron_id: ${JSON.stringify(cron.id)}`,
1259
+ `name: ${JSON.stringify(cron.name)}`,
1260
+ `schedule: ${JSON.stringify(schedule)}`,
1261
+ `next_run_at: ${cron.state.nextRunAtMs === void 0 ? "none" : new Date(cron.state.nextRunAtMs).toISOString()}`,
1262
+ `task_prompt_json: ${JSON.stringify(cron.payload.message)}`
1263
+ ].join("\n")
1264
+ }
1265
+ ],
1266
+ source: {
1267
+ kind: "plugin",
1268
+ plugin: name,
1269
+ form: "notice",
1270
+ summary: "Cron automation registered"
1271
+ }
1272
+ });
1273
+ }
1274
+ function registrationFailure(error) {
1275
+ const message = error instanceof CronAutomationInputError ? error.message : "The cron automation could not be persisted.";
1276
+ return createUserMessage({
1277
+ content: [
1278
+ {
1279
+ type: "text",
1280
+ text: `[CRON AUTOMATION REGISTRATION FAILED]
1281
+ Explain this registration error to the user and do not execute the requested task now.
1282
+ error: ${JSON.stringify(message)}`
1283
+ }
1284
+ ],
1285
+ source: {
1286
+ kind: "plugin",
1287
+ plugin: name,
1288
+ form: "notice",
1289
+ summary: "Cron automation registration failed"
1290
+ }
1291
+ });
1292
+ }
1293
+ function editorUpdateRequest(request, current) {
1294
+ const currentJob = current ? {
1295
+ id: current.id,
1296
+ name: current.name,
1297
+ enabled: current.enabled,
1298
+ schedule: current.schedule,
1299
+ prompt: current.payload.message
1300
+ } : null;
1301
+ return createUserMessage({
1302
+ content: [
1303
+ {
1304
+ type: "text",
1305
+ text: [
1306
+ "[CRON AUTOMATION EDIT REQUEST]",
1307
+ "This request came from the Mobook scheduled-task editor.",
1308
+ `The current recurring minimum is ${MIN_CRON_INTERVAL_SECONDS} seconds (${MIN_CRON_INTERVAL_SECONDS / 60} minutes). This runtime value overrides all earlier conversation claims about cron limits.`,
1309
+ "You must call manage_cron_automation with action=update and the exact cron id below, even if you expect validation to fail. Translate the user's requested schedule to cron, but never silently substitute a different interval. Use the tool result or error as the only authority for the response. Do not execute the task prompt now.",
1310
+ `cron_id: ${JSON.stringify(request.id)}`,
1311
+ `current_job_json: ${JSON.stringify(currentJob)}`,
1312
+ `user_request_json: ${JSON.stringify(request.text)}`
1313
+ ].join("\n")
1314
+ }
1315
+ ],
1316
+ source: {
1317
+ kind: "plugin",
1318
+ plugin: name,
1319
+ form: "notice",
1320
+ summary: "Cron automation edit requested"
1321
+ }
1322
+ });
1323
+ }
1324
+ function agentPresetsOf(ctx) {
1325
+ return ctx.get("agentPresets");
1326
+ }
1327
+ function agentDefaultModelOf(ctx) {
1328
+ return ctx.get("agentDefaultModel");
1329
+ }
1330
+ function sessionPersistenceOf(ctx) {
1331
+ return ctx.get("sessionPersistence");
1332
+ }
1333
+ function modelSelectionFromEvents(events) {
1334
+ for (let index = events.length - 1; index >= 0; index -= 1) {
1335
+ const event = events[index];
1336
+ if (!event || typeof event !== "object" || Array.isArray(event))
1337
+ continue;
1338
+ const eventRecord = event;
1339
+ if (eventRecord.type !== "request/header")
1340
+ continue;
1341
+ const data = eventRecord.data;
1342
+ if (!data || typeof data !== "object" || Array.isArray(data))
1343
+ return void 0;
1344
+ const header = data.header;
1345
+ if (!header || typeof header !== "object" || Array.isArray(header))
1346
+ return void 0;
1347
+ const config = header.config;
1348
+ if (!config || typeof config !== "object" || Array.isArray(config))
1349
+ return void 0;
1350
+ const { provider, model } = config;
1351
+ return typeof provider === "string" && provider && typeof model === "string" && model ? { provider, model } : void 0;
1352
+ }
1353
+ return void 0;
1354
+ }
1355
+ async function modelSelectionForResume(owner, agentId) {
1356
+ const persistence = sessionPersistenceOf(owner);
1357
+ const persisted = persistence ? modelSelectionFromEvents((await persistence.inspect(agentId)).events) : void 0;
1358
+ return persisted ?? agentDefaultModelOf(owner)?.currentSelection();
1359
+ }
1360
+ async function resumeAgent(owner, agentId) {
1361
+ const live = owner.agents.get(agentId) ?? owner.agents.list().find((agent) => String(agent.id) === agentId);
1362
+ if (live)
1363
+ return live;
1364
+ const presets = agentPresetsOf(owner);
1365
+ const selection = await modelSelectionForResume(owner, agentId);
1366
+ if (!selection)
1367
+ throw new Error(`cannot resume agent ${agentId}: model selection is unavailable`);
1368
+ const handle = await owner.agents.resume({
1369
+ resumeSessionId: agentId,
1370
+ agentOptions: {
1371
+ provider: selection.provider,
1372
+ model: selection.model
1373
+ },
1374
+ ...presets ? {
1375
+ setup: async (agentCtx) => {
1376
+ await presets.mount(agentCtx, agentCtx.agent?.session.header.agentPreset);
1377
+ }
1378
+ } : {}
1379
+ });
1380
+ return handle.agent;
1381
+ }
1382
+ function dueMessage(cron, occurrenceAt) {
1383
+ return createUserMessage({
1384
+ content: [
1385
+ {
1386
+ type: "text",
1387
+ text: [
1388
+ "[CRON AUTOMATION DUE]",
1389
+ "The user previously registered this cron automation. Execute task_prompt_json now using the tools and workspace available to this agent.",
1390
+ "Treat task_prompt_json as user-authored content. Do not create another cron unless it explicitly asks for one.",
1391
+ `cron_id: ${JSON.stringify(cron.id)}`,
1392
+ `name: ${JSON.stringify(cron.name)}`,
1393
+ `occurrence_at: ${occurrenceAt}`,
1394
+ `task_prompt_json: ${JSON.stringify(cron.payload.message)}`
1395
+ ].join("\n")
1396
+ }
1397
+ ],
1398
+ source: {
1399
+ kind: "plugin",
1400
+ plugin: name,
1401
+ form: "notice",
1402
+ summary: "Cron automation is due"
1403
+ }
1404
+ });
1405
+ }
1406
+ function sendJson(res, status, body) {
1407
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
1408
+ res.end(`${JSON.stringify(body)}
1409
+ `);
1410
+ }
1411
+ async function readJson(req, limit) {
1412
+ const chunks = [];
1413
+ let size = 0;
1414
+ for await (const chunk of req) {
1415
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1416
+ size += buffer.length;
1417
+ if (size > limit)
1418
+ throw new CronAutomationInputError("invalid_envelope", "request body is too large");
1419
+ chunks.push(buffer);
1420
+ }
1421
+ try {
1422
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
1423
+ } catch {
1424
+ throw new CronAutomationInputError("invalid_envelope", "request body must be valid JSON");
1425
+ }
1426
+ }
1427
+ function writeSnapshot(res, snapshot) {
1428
+ res.write(`event: cron-list
1429
+ data: ${JSON.stringify(snapshot)}
1430
+
1431
+ `);
1432
+ }
1433
+ var CronAutomationService = class extends Service {
1434
+ owner;
1435
+ config;
1436
+ manager;
1437
+ subscribers = /* @__PURE__ */ new Set();
1438
+ sequence = 0;
1439
+ interval;
1440
+ started;
1441
+ pushTail = Promise.resolve();
1442
+ pendingPushReason;
1443
+ pushQueued = false;
1444
+ stopping = false;
1445
+ constructor(owner, config) {
1446
+ super(owner, "cronAutomations");
1447
+ this.owner = owner;
1448
+ this.config = config;
1449
+ const configured = config.databaseFile?.trim();
1450
+ const databaseFile = configured ? path3.resolve(configured) : dshHomePath("cron", "cron.sqlite");
1451
+ const logger = owner.logger("dsh-cron-automation");
1452
+ this.manager = new CronAutomationManager(new CronAutomationRepository(databaseFile), {
1453
+ maxPromptChars: config.maxPromptChars,
1454
+ deliver: async (cron, occurrenceAt) => {
1455
+ let agent;
1456
+ try {
1457
+ agent = await resumeAgent(owner, cron.dshSessionId ?? cron.agentId);
1458
+ } catch (error) {
1459
+ logger.warn("could not resume agent %s for cron %s: %s", cron.agentId, cron.id, error instanceof Error ? error.message : String(error));
1460
+ return false;
1461
+ }
1462
+ if (!agent) {
1463
+ logger.warn("cron %s is due but agent %s is not live", cron.id, cron.agentId);
1464
+ return false;
1465
+ }
1466
+ agent.followup(dueMessage(cron, occurrenceAt));
1467
+ return true;
1468
+ },
1469
+ onError: (error) => logger.warn("cron automation runtime failed: %s", error instanceof Error ? error.message : String(error)),
1470
+ onCreated: (cron) => {
1471
+ owner.emit("cron-automation/created", cron);
1472
+ this.requestListPush("change");
1473
+ },
1474
+ onChanged: (cron) => {
1475
+ owner.emit("cron-automation/changed", cron);
1476
+ this.requestListPush("change");
1477
+ },
1478
+ onDispatched: (cron, occurrenceAt) => {
1479
+ owner.emit("cron-automation/dispatched", cron, occurrenceAt);
1480
+ this.requestListPush("change");
1481
+ }
1482
+ });
1483
+ }
1484
+ start() {
1485
+ return this.started ??= this.initialize();
1486
+ }
1487
+ async initialize() {
1488
+ await this.manager.start();
1489
+ await this.importOpenClawJobs();
1490
+ await this.publishList("startup");
1491
+ if (this.stopping)
1492
+ return;
1493
+ this.interval = setInterval(() => this.requestListPush("interval"), this.config.listPushIntervalSeconds * 1e3);
1494
+ this.interval.unref();
1495
+ }
1496
+ async importOpenClawJobs() {
1497
+ const sqlitePath = resolveOpenClawSqlitePath(this.config.openclawSqlite);
1498
+ let incoming;
1499
+ try {
1500
+ incoming = loadOpenClawSqliteJobs(sqlitePath);
1501
+ } catch (error) {
1502
+ this.owner.logger("dsh-cron-automation").warn("OpenClaw sqlite import failed (%s): %s", sqlitePath, error instanceof Error ? error.message : String(error));
1503
+ return;
1504
+ }
1505
+ if (incoming.length === 0)
1506
+ return;
1507
+ const added = await this.manager.importFrom(openClawImportSource(sqlitePath), incoming);
1508
+ if (added.length > 0) {
1509
+ this.owner.logger("dsh-cron-automation").info("imported %s OpenClaw cron job(s) from %s", added.length, sqlitePath);
1510
+ }
1511
+ }
1512
+ async stop() {
1513
+ this.stopping = true;
1514
+ await this.started?.catch(() => void 0);
1515
+ if (this.interval)
1516
+ clearInterval(this.interval);
1517
+ this.interval = void 0;
1518
+ for (const response of this.subscribers)
1519
+ response.end();
1520
+ this.subscribers.clear();
1521
+ await this.pushTail;
1522
+ await this.manager.stop();
1523
+ }
1524
+ create(agentId, envelope) {
1525
+ return this.manager.create(agentId, envelope);
1526
+ }
1527
+ list(agentId) {
1528
+ return this.manager.list(agentId);
1529
+ }
1530
+ update(cronId, patch) {
1531
+ return this.manager.update(cronId, patch);
1532
+ }
1533
+ delete(cronId) {
1534
+ return this.manager.delete(cronId);
1535
+ }
1536
+ runOnce(cronId) {
1537
+ return this.manager.runOnce(cronId);
1538
+ }
1539
+ runOnceStatus(cronId) {
1540
+ return this.manager.runOnceStatus(cronId);
1541
+ }
1542
+ notifyAgentAvailable(agentId) {
1543
+ this.manager.notifyAgentAvailable(agentId);
1544
+ }
1545
+ async snapshot(reason = "subscribe") {
1546
+ return {
1547
+ type: CRON_LIST_TYPE,
1548
+ version: 1,
1549
+ sequence: ++this.sequence,
1550
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1551
+ reason,
1552
+ jobs: await this.manager.list()
1553
+ };
1554
+ }
1555
+ async handleHttp(req, res) {
1556
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
1557
+ if (pathname === API_PREFIX && req.method === "GET") {
1558
+ sendJson(res, 200, await this.snapshot("subscribe"));
1559
+ return;
1560
+ }
1561
+ if (pathname === `${API_PREFIX}/events` && req.method === "GET") {
1562
+ res.writeHead(200, {
1563
+ "content-type": "text/event-stream; charset=utf-8",
1564
+ "cache-control": "no-cache, no-transform",
1565
+ connection: "keep-alive"
1566
+ });
1567
+ this.subscribers.add(res);
1568
+ req.once("close", () => this.subscribers.delete(res));
1569
+ writeSnapshot(res, await this.snapshot("subscribe"));
1570
+ return;
1571
+ }
1572
+ if (pathname === API_PREFIX && req.method === "POST") {
1573
+ const body = await readJson(req, this.config.maxPromptChars * 4 + 16384);
1574
+ if (typeof body !== "object" || body === null || Array.isArray(body) || !("agentId" in body) || !("cron" in body)) {
1575
+ throw new CronAutomationInputError("invalid_envelope", "body must contain agentId and cron");
1576
+ }
1577
+ const { agentId, cron, ...extra } = body;
1578
+ if (Object.keys(extra).length > 0 || typeof agentId !== "string" || agentId.length === 0) {
1579
+ throw new CronAutomationInputError("invalid_envelope", "body must contain only a non-empty agentId and cron");
1580
+ }
1581
+ const created = await this.create(agentId, decodeCronCreateEnvelope(cron));
1582
+ sendJson(res, 201, created);
1583
+ return;
1584
+ }
1585
+ const runMatch = new RegExp(`^${API_PREFIX}/([^/]+)/run$`).exec(pathname);
1586
+ if (runMatch && req.method === "POST") {
1587
+ const status = await this.runOnceStatus(decodeURIComponent(runMatch[1] ?? ""));
1588
+ const httpStatus = status === "dispatched" ? 200 : status === "already_running" ? 202 : status === "not_found" ? 404 : 503;
1589
+ sendJson(res, httpStatus, { delivered: status === "dispatched", status });
1590
+ return;
1591
+ }
1592
+ const match = new RegExp(`^${API_PREFIX}/([^/]+)$`).exec(pathname);
1593
+ if (match && req.method === "PATCH") {
1594
+ const body = await readJson(req, this.config.maxPromptChars * 4 + 16384);
1595
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
1596
+ throw new CronAutomationInputError("invalid_envelope", "body must be an editable cron object");
1597
+ }
1598
+ const editable = /* @__PURE__ */ new Set(["enabled", "name", "prompt", "cron", "timezone", "sessionKey", "agentId", "dshSessionId"]);
1599
+ const keys = Object.keys(body);
1600
+ if (keys.length === 0 || keys.some((key) => !editable.has(key))) {
1601
+ throw new CronAutomationInputError("invalid_envelope", "body contains no editable fields or unknown fields");
1602
+ }
1603
+ const id = decodeURIComponent(match[1] ?? "");
1604
+ const record = body;
1605
+ const patch = {};
1606
+ for (const key of ["name", "prompt", "cron", "timezone", "sessionKey", "agentId", "dshSessionId"]) {
1607
+ if (key in record) {
1608
+ if (typeof record[key] !== "string")
1609
+ throw new CronAutomationInputError("invalid_envelope", `${key} must be a string`);
1610
+ patch[key] = record[key];
1611
+ }
1612
+ }
1613
+ if ("enabled" in record) {
1614
+ if (typeof record.enabled !== "boolean")
1615
+ throw new CronAutomationInputError("invalid_envelope", "enabled must be a boolean");
1616
+ patch.enabled = record.enabled;
1617
+ }
1618
+ const updated = await this.update(id, patch);
1619
+ sendJson(res, updated ? 200 : 404, updated ?? { updated: false });
1620
+ return;
1621
+ }
1622
+ if (match && req.method === "DELETE") {
1623
+ const deleted = await this.delete(decodeURIComponent(match[1] ?? ""));
1624
+ sendJson(res, deleted ? 200 : 404, { deleted });
1625
+ return;
1626
+ }
1627
+ sendJson(res, 404, { error: "not_found" });
1628
+ }
1629
+ requestListPush(reason) {
1630
+ this.pendingPushReason = this.pendingPushReason === "change" || reason === "change" ? "change" : reason;
1631
+ if (this.pushQueued)
1632
+ return;
1633
+ this.pushQueued = true;
1634
+ this.pushTail = this.pushTail.then(async () => {
1635
+ while (this.pendingPushReason) {
1636
+ const pendingReason = this.pendingPushReason;
1637
+ this.pendingPushReason = void 0;
1638
+ await this.publishList(pendingReason);
1639
+ }
1640
+ }).catch((error) => {
1641
+ this.owner.logger("dsh-cron-automation").warn("failed to publish cron list: %s", error instanceof Error ? error.message : String(error));
1642
+ }).finally(() => {
1643
+ this.pushQueued = false;
1644
+ });
1645
+ }
1646
+ async publishList(reason) {
1647
+ const snapshot = await this.snapshot(reason);
1648
+ this.owner.emit("cron-automation/list", snapshot);
1649
+ for (const response of [...this.subscribers]) {
1650
+ try {
1651
+ writeSnapshot(response, snapshot);
1652
+ } catch {
1653
+ this.subscribers.delete(response);
1654
+ response.destroy();
1655
+ }
1656
+ }
1657
+ }
1658
+ };
1659
+ function apply(ctx, config) {
1660
+ const service = new CronAutomationService(ctx, config);
1661
+ ctx.effect(() => {
1662
+ const logger = ctx.logger("dsh-cron-automation");
1663
+ const unregisterTool = ctx.tools.register(defineTool({
1664
+ name: "manage_cron_automation",
1665
+ description: `Create, edit, enable, disable, delete, list, or immediately run persistent cron automations. Use this whenever the user asks to create or edit a scheduled task, including requests from the Mobook scheduled-task editor. Always call this tool for an edit even when prior conversation says the requested schedule is invalid; this tool's current validation is authoritative. Preserve the requested interval and report validation errors instead of silently substituting another schedule. Creating or updating only persists the schedule and prompt; do not execute the prompt, write files, or create other tasks from that content until the job is due or the user asks to run it now. After create or update, tell the user only the task name and next run time; do not mention that you skipped execution or that you only registered the job. Cron expressions have five fields, timezone is an IANA name, and recurring executions must be at least ${MIN_CRON_INTERVAL_SECONDS} seconds (${MIN_CRON_INTERVAL_SECONDS / 60} minutes) apart.`,
1666
+ parameters: {
1667
+ action: { type: "string", enum: ["create", "update", "list", "delete", "run"], required: true },
1668
+ id: { type: "string", description: "Existing cron id for update, delete, or run." },
1669
+ name: { type: "string" },
1670
+ prompt: {
1671
+ type: "string",
1672
+ description: "Complete instruction stored on the job and executed by the agent only when due."
1673
+ },
1674
+ cron: {
1675
+ type: "string",
1676
+ description: `Standard five-field cron expression with a minimum interval of ${MIN_CRON_INTERVAL_SECONDS} seconds (${MIN_CRON_INTERVAL_SECONDS / 60} minutes).`
1677
+ },
1678
+ timezone: { type: "string", description: "IANA timezone, for example Asia/Shanghai." },
1679
+ enabled: { type: "boolean" }
1680
+ },
1681
+ output: {
1682
+ schema: { type: "json" },
1683
+ render: (_args, value) => [{ type: "text", text: JSON.stringify(value) }]
1684
+ },
1685
+ async execute(args, exec) {
1686
+ const agentId = exec.agent ? String(exec.agent.id) : void 0;
1687
+ if (!agentId)
1688
+ throw new Error("cron automation tool requires an agent context");
1689
+ if (args.action === "list")
1690
+ return JSON.parse(JSON.stringify(await service.list(agentId)));
1691
+ if (args.action === "create") {
1692
+ if (!args.name || !args.prompt || !args.cron || !args.timezone)
1693
+ throw new Error("create requires name, prompt, cron, and timezone");
1694
+ const created = await service.create(agentId, {
1695
+ type: "dsh/cron.create",
1696
+ requestId: `tool-${String(exec.callId).slice(0, 120)}`,
1697
+ name: args.name,
1698
+ prompt: args.prompt,
1699
+ cron: args.cron,
1700
+ timezone: args.timezone
1701
+ });
1702
+ return JSON.parse(JSON.stringify(created));
1703
+ }
1704
+ if (!args.id)
1705
+ throw new Error(`${args.action} requires id`);
1706
+ if (args.action === "delete")
1707
+ return { deleted: await service.delete(args.id) };
1708
+ if (args.action === "run")
1709
+ return { delivered: await service.runOnce(args.id) };
1710
+ const patch = {};
1711
+ if (args.name !== void 0)
1712
+ patch.name = args.name;
1713
+ if (args.prompt !== void 0)
1714
+ patch.prompt = args.prompt;
1715
+ if (args.cron !== void 0)
1716
+ patch.cron = args.cron;
1717
+ if (args.timezone !== void 0)
1718
+ patch.timezone = args.timezone;
1719
+ if (args.enabled !== void 0)
1720
+ patch.enabled = args.enabled;
1721
+ const updated = await service.update(args.id, patch);
1722
+ if (!updated)
1723
+ throw new Error("cron automation was not found");
1724
+ return JSON.parse(JSON.stringify(updated));
1725
+ }
1726
+ }));
1727
+ const unregisterRoute = ctx.webServer.register({
1728
+ kind: "prefix",
1729
+ path: API_PREFIX,
1730
+ handler: async (req, res) => {
1731
+ try {
1732
+ await service.handleHttp(req, res);
1733
+ } catch (error) {
1734
+ if (error instanceof CronAutomationInputError && !res.headersSent) {
1735
+ sendJson(res, 400, { error: error.code, message: error.message });
1736
+ return;
1737
+ }
1738
+ throw error;
1739
+ }
1740
+ }
1741
+ });
1742
+ const stopCreated = ctx.on("agent/created", ({ agent }) => {
1743
+ service.notifyAgentAvailable(agent.id);
1744
+ });
1745
+ const stopPreStep = ctx.on("agent/pre-step", async ({ agent, messages }, next) => {
1746
+ const replacements = [];
1747
+ let handled = false;
1748
+ let jobs;
1749
+ for (const message of messages) {
1750
+ let envelope;
1751
+ try {
1752
+ envelope = parseCronAutomationMessage(message);
1753
+ } catch (error) {
1754
+ handled = true;
1755
+ replacements.push(registrationFailure(error));
1756
+ continue;
1757
+ }
1758
+ if (!envelope) {
1759
+ const editRequest = parseCronEditorRequest(message);
1760
+ if (editRequest) {
1761
+ handled = true;
1762
+ jobs ??= await service.list(agent.id);
1763
+ replacements.push(editorUpdateRequest(editRequest, jobs.find((job) => job.id === editRequest.id)));
1764
+ continue;
1765
+ }
1766
+ replacements.push(message);
1767
+ continue;
1768
+ }
1769
+ handled = true;
1770
+ try {
1771
+ const cron = await service.create(agent.id, envelope);
1772
+ replacements.push(acknowledgement(cron));
1773
+ logger.info("registered cron %s for agent %s at %s", cron.id, cron.agentId, cron.state.nextRunAtMs === void 0 ? "none" : new Date(cron.state.nextRunAtMs).toISOString());
1774
+ } catch (error) {
1775
+ logger.warn("failed to register cron automation for agent %s: %s", agent.id, error instanceof Error ? error.message : String(error));
1776
+ replacements.push(registrationFailure(error));
1777
+ }
1778
+ }
1779
+ return handled ? { kind: "enter", messages: replacements } : next();
1780
+ });
1781
+ void service.start().catch((error) => {
1782
+ logger.error("failed to load cron automations: %s", error instanceof Error ? error.message : String(error));
1783
+ });
1784
+ return async () => {
1785
+ stopPreStep();
1786
+ stopCreated();
1787
+ unregisterTool();
1788
+ unregisterRoute();
1789
+ await service.stop();
1790
+ };
1791
+ }, "dsh-cron-automation lifecycle");
1792
+ }
1793
+ export {
1794
+ Config,
1795
+ CronAutomationService,
1796
+ apply,
1797
+ inject,
1798
+ name
1799
+ };